From 092c896a9fbdafa25fe384368646a4b657300501 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 14 Jul 2026 09:59:54 +0800 Subject: [PATCH 01/87] Persist dashboard run history in SQLite --- Directory.Packages.props | 1 + .../Api/TelemetryApiService.cs | 2 +- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 5 + .../Controls/AspireMenuButton.razor.cs | 8 +- .../Components/Controls/Chart/ChartBase.cs | 38 +- .../Controls/Chart/ChartContainer.razor | 4 +- .../Controls/Chart/ChartContainer.razor.cs | 55 +- .../Controls/ClearSignalsButton.razor | 2 + .../Controls/ClearSignalsButton.razor.cs | 3 +- .../Controls/ParameterValueDisplay.razor | 6 +- .../Controls/PauseIncomingDataSwitch.razor | 1 + .../Controls/PauseIncomingDataSwitch.razor.cs | 8 + .../PropertyValues/SpanIdButtonValue.razor.cs | 2 +- .../Components/Controls/ResourceActions.razor | 2 +- .../Controls/ResourceActions.razor.cs | 3 + .../Controls/SignalsActionsDisplay.razor | 4 +- .../Controls/SignalsActionsDisplay.razor.cs | 3 + .../Components/Controls/SpanDetails.razor.cs | 2 +- .../Controls/TreeMetricSelector.razor.cs | 2 +- .../Dialogs/DashboardRunsDialog.razor | 17 + .../Dialogs/DashboardRunsDialog.razor.cs | 62 + .../Dialogs/ExemplarsDialog.razor.cs | 2 +- .../Components/Dialogs/FilterDialog.razor.cs | 2 +- .../Dialogs/GenAIVisualizerDialog.razor.cs | 4 +- .../Components/Dialogs/ManageDataDialog.razor | 4 +- .../Dialogs/ManageDataDialog.razor.cs | 2 +- .../Components/Layout/MainLayout.razor | 41 +- .../Components/Layout/MainLayout.razor.cs | 74 ++ .../Components/Layout/MainLayout.razor.css | 23 +- .../Components/Pages/ConsoleLogs.razor | 2 +- .../Components/Pages/ConsoleLogs.razor.cs | 2 +- .../Components/Pages/Metrics.razor.cs | 2 +- .../Components/Pages/Resources.razor.cs | 2 +- .../Components/Pages/StructuredLogs.razor.cs | 2 +- .../Components/Pages/TraceDetail.razor.cs | 2 +- .../Components/Pages/Traces.razor.cs | 2 +- .../UnreadLogErrorsBadge.razor.cs | 2 +- .../Configuration/DashboardOptions.cs | 6 + .../PostConfigureDashboardOptions.cs | 10 + .../DashboardWebApplication.cs | 58 +- src/Aspire.Dashboard/Model/ExportHelpers.cs | 4 +- .../GenAI/GenAIVisualizerDialogViewModel.cs | 8 +- .../Model/ResourceMenuBuilder.cs | 11 +- .../Model/SpanDetailsViewModel.cs | 4 +- src/Aspire.Dashboard/Model/SpanMenuBuilder.cs | 4 +- .../Model/StructuredLogsViewModel.cs | 4 +- .../Model/TelemetryExportService.cs | 4 +- .../Model/TelemetryImportService.cs | 4 +- src/Aspire.Dashboard/Model/TraceHelpers.cs | 2 +- .../Model/TraceMenuBuilder.cs | 4 +- src/Aspire.Dashboard/Model/TracesViewModel.cs | 4 +- .../Otlp/Model/OtlpHelpers.cs | 21 +- .../Otlp/Model/OtlpInstrument.cs | 10 +- .../Otlp/Model/OtlpLogEntry.cs | 32 + .../Otlp/Model/OtlpResource.cs | 50 +- .../Otlp/Model/OtlpResourceView.cs | 6 + src/Aspire.Dashboard/Otlp/OtlpLogsService.cs | 4 +- .../Otlp/OtlpMetricsService.cs | 4 +- src/Aspire.Dashboard/Otlp/OtlpTraceService.cs | 4 +- .../Otlp/Storage/ITelemetryRepository.cs | 65 + .../Storage/SqliteTelemetryRepository.Logs.cs | 940 ++++++++++++++ .../SqliteTelemetryRepository.Metrics.cs | 841 +++++++++++++ .../SqliteTelemetryRepository.Runtime.cs | 458 +++++++ .../SqliteTelemetryRepository.Traces.cs | 1090 +++++++++++++++++ .../Otlp/Storage/SqliteTelemetryRepository.cs | 203 +++ .../Otlp/Storage/Subscription.cs | 4 +- .../Otlp/Storage/TelemetryRepositoryLimits.cs | 14 + .../Resources/Dialogs.Designer.cs | 63 + src/Aspire.Dashboard/Resources/Dialogs.resx | 23 + .../Resources/xlf/Dialogs.cs.xlf | 35 + .../Resources/xlf/Dialogs.de.xlf | 35 + .../Resources/xlf/Dialogs.es.xlf | 35 + .../Resources/xlf/Dialogs.fr.xlf | 35 + .../Resources/xlf/Dialogs.it.xlf | 35 + .../Resources/xlf/Dialogs.ja.xlf | 35 + .../Resources/xlf/Dialogs.ko.xlf | 35 + .../Resources/xlf/Dialogs.pl.xlf | 35 + .../Resources/xlf/Dialogs.pt-BR.xlf | 35 + .../Resources/xlf/Dialogs.ru.xlf | 35 + .../Resources/xlf/Dialogs.tr.xlf | 35 + .../Resources/xlf/Dialogs.zh-Hans.xlf | 35 + .../Resources/xlf/Dialogs.zh-Hant.xlf | 35 + .../ServiceClient/DashboardClient.cs | 18 +- .../ServiceClient/DashboardDataSource.cs | 104 ++ .../ServiceClient/DashboardRunStore.cs | 173 +++ .../ServiceClient/DashboardSqliteDatabase.cs | 170 +++ .../ServiceClient/DatabaseSchema/001.Core.sql | 10 + .../DatabaseSchema/002.Resources.sql | 177 +++ .../ServiceClient/DatabaseSchema/003.Logs.sql | 80 ++ .../DatabaseSchema/004.Traces.sql | 89 ++ .../DatabaseSchema/005.Metrics.sql | 83 ++ .../ServiceClient/IDashboardClient.cs | 42 +- .../ServiceClient/IResourceRepository.cs | 37 + .../IResourceRepositoryWriter.cs | 13 + .../ServiceClient/SelectedDashboardClient.cs | 72 ++ .../SqliteResourceRepository.Storage.cs | 818 +++++++++++++ .../ServiceClient/SqliteResourceRepository.cs | 370 ++++++ .../Utils/BrowserStorageKeys.cs | 1 + .../Dashboard/DashboardEventHandlers.cs | 8 + .../Dashboard/DashboardService.cs | 16 +- src/Shared/DashboardConfigNames.cs | 2 + .../Controls/AspireMenuButtonTests.cs | 16 + .../Controls/GenAIVisualizerDialogTests.cs | 10 +- .../Dialogs/DashboardRunsDialogTests.cs | 75 ++ .../Layout/MainLayoutTests.cs | 132 +- .../Pages/ConsoleLogsTests.cs | 47 + .../Pages/MetricsTests.cs | 56 +- .../Pages/ResourcesTests.cs | 46 +- .../Pages/StructuredLogsTests.cs | 2 +- .../Pages/TraceDetailsTests.cs | 24 +- .../Shared/FluentUISetupHelpers.cs | 37 +- .../Shared/MetricsSetupHelpers.cs | 3 +- .../DashboardOptionsTests.cs | 22 + .../Integration/OtlpHttpJsonTests.cs | 12 +- .../Integration/StartupTests.cs | 12 + .../Model/DashboardClientTests.cs | 80 +- .../Model/DashboardDataSourceTests.cs | 253 ++++ .../GenAIVisualizerDialogViewModelTests.cs | 2 +- .../Model/ResourceMenuBuilderTests.cs | 50 +- .../Model/SqliteResourceRepositoryTests.cs | 351 ++++++ .../Model/TelemetryExportServiceTests.cs | 6 +- .../Model/TelemetryImportServiceTests.cs | 2 +- .../Model/TraceHelpersTests.cs | 17 + .../TelemetryApiServiceTests.cs | 12 +- .../TelemetryRepositoryTests/LogTests.cs | 12 +- .../TelemetryRepositoryTests/MetricsTests.cs | 12 +- .../TelemetryRepositoryTests/ResourceTests.cs | 14 +- .../SqliteTelemetryPersistenceTests.cs | 369 ++++++ .../TelemetryLimitTests.cs | 24 +- .../TelemetryRepositoryTestBase.cs | 88 ++ .../TelemetryRepositoryTests.cs | 24 +- .../TelemetryRepositoryTests/TraceTests.cs | 41 +- .../Dashboard/DashboardEventHandlersTests.cs | 43 + .../InMemoryTelemetryRepository.Watchers.cs | 3 +- .../Telemetry/InMemoryTelemetryRepository.cs | 61 +- .../Shared/Telemetry/TelemetryTestHelpers.cs | 4 +- tests/Shared/TestDashboardClient.cs | 5 +- 137 files changed, 8784 insertions(+), 278 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor create mode 100644 src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/TelemetryRepositoryLimits.cs create mode 100644 src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs create mode 100644 src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs create mode 100644 src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs create mode 100644 src/Aspire.Dashboard/ServiceClient/DatabaseSchema/001.Core.sql create mode 100644 src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql create mode 100644 src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql create mode 100644 src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql create mode 100644 src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql create mode 100644 src/Aspire.Dashboard/ServiceClient/IResourceRepository.cs create mode 100644 src/Aspire.Dashboard/ServiceClient/IResourceRepositoryWriter.cs create mode 100644 src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs create mode 100644 src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs create mode 100644 src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Dialogs/DashboardRunsDialogTests.cs create mode 100644 tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs create mode 100644 tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs create mode 100644 tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs create mode 100644 tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTestBase.cs rename src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs => tests/Shared/Telemetry/InMemoryTelemetryRepository.Watchers.cs (99%) rename src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs => tests/Shared/Telemetry/InMemoryTelemetryRepository.cs (97%) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6bcf1c8a819..405e8c5ad01 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -119,6 +119,7 @@ + diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index d365c608d38..a83cb19b960 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -16,7 +16,7 @@ namespace Aspire.Dashboard.Api; /// Handles telemetry API requests, returning data in OTLP JSON format. /// internal sealed class TelemetryApiService( - TelemetryRepository telemetryRepository, + ITelemetryRepository telemetryRepository, IEnumerable outgoingPeerResolvers) { private const int DefaultLimit = 200; diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 2897628bb1e..7477f0dc22c 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -44,12 +44,15 @@ ServiceClient\dashboard_service.proto + + + @@ -58,9 +61,11 @@ + + diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs index 511444e755a..e92ced9e774 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs @@ -51,6 +51,9 @@ public partial class AspireMenuButton : FluentComponentBase [Parameter] public bool HideIcon { get; set; } + [Parameter] + public bool Disabled { get; set; } + /// /// Gets or sets a value indicating whether focus should return to this menu button after a menu item is clicked. /// @@ -68,10 +71,9 @@ protected override void OnParametersSet() if (Items != null && !_items.SequenceEqual(Items)) { _items = Items.ToArray(); - - // Disabled if there are no actionable items - _disabled = !_items.Any(i => !i.IsDivider); } + + _disabled = Disabled || !_items.Any(i => !i.IsDivider); } private void ToggleMenu() diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs index 95dceb762d4..38140efe02d 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs @@ -29,6 +29,7 @@ public abstract class ChartBase : ComponentBase, IAsyncDisposable private OtlpInstrumentKey? _renderedInstrument; private string? _renderedTheme; private bool _renderedShowCount; + private DateTimeOffset? _previousDataEndTime; [Inject] public required IStringLocalizer Loc { get; init; } @@ -40,7 +41,7 @@ public abstract class ChartBase : ComponentBase, IAsyncDisposable public required BrowserTimeProvider TimeProvider { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } [Inject] public required PauseManager PauseManager { get; init; } @@ -54,6 +55,9 @@ public abstract class ChartBase : ComponentBase, IAsyncDisposable [Parameter] public required List Resources { get; set; } + [Parameter] + public DateTimeOffset? DataEndTime { get; set; } + // Stores a cache of the last set of spans returned as exemplars. // This dictionary is replaced each time the chart is updated. private Dictionary _currentCache = new Dictionary(); @@ -63,7 +67,7 @@ protected override void OnInitialized() { // Copy the token so there is no chance it is accessed on CTS after it is disposed. CancellationToken = _cts.Token; - _currentDataStartTime = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : GetCurrentDataTime(); + _currentDataStartTime = GetCurrentDataTime(out _); InstrumentViewModel.DataUpdateSubscriptions.Add(OnInstrumentDataUpdate); } @@ -77,11 +81,10 @@ InstrumentViewModel.MatchedDimensions is null || return; } - var inProgressDataTime = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : GetCurrentDataTime(); + var inProgressDataTime = GetCurrentDataTime(out var isTimeFrozen); - // Only advance the time window when not paused. When paused, keep the chart's - // time axis stable so filter changes don't cause the x-axis to jump. - if (pausedAt is null) + // Keep the time axis stable for historical data and while live data is paused. + if (!isTimeFrozen) { while (_currentDataStartTime.Add(_tickDuration) < inProgressDataTime) { @@ -113,6 +116,11 @@ InstrumentViewModel.MatchedDimensions is null || protected override void OnParametersSet() { _tickDuration = Duration / GraphPointCount; + if (_previousDataEndTime != DataEndTime) + { + _currentDataStartTime = GetCurrentDataTime(out _); + _previousDataEndTime = DataEndTime; + } } private Task OnInstrumentDataUpdate() @@ -230,9 +238,23 @@ private void ResolveExemplarSpans(List exemplars) } } - private DateTimeOffset GetCurrentDataTime() + private DateTimeOffset GetCurrentDataTime(out bool isTimeFrozen) { - return TimeProvider.GetUtcNow().Subtract(TimeSpan.FromSeconds(1)); // Compensate for delay in receiving metrics from services. + if (DataEndTime is { } dataEndTime) + { + isTimeFrozen = true; + return dataEndTime; + } + + if (PauseManager.AreMetricsPaused(out var pausedAt)) + { + isTimeFrozen = true; + return pausedAt.Value; + } + + isTimeFrozen = false; + // Compensate for delay in receiving metrics from services. + return TimeProvider.GetUtcNow().Subtract(TimeSpan.FromSeconds(1)); } private string GetDisplayedUnit(OtlpInstrumentSummary instrument) diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor index a5590f8ce68..b3cd920b922 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor @@ -37,7 +37,7 @@ else Label="@Loc[nameof(ControlsStrings.ChartContainerGraphTab)]" Icon="@(new Icons.Regular.Size24.DataArea())">
- +
@@ -46,7 +46,7 @@ else Label="@Loc[nameof(ControlsStrings.ChartContainerTableTab)]" Icon="@(new Icons.Regular.Size24.Table())">
- +
diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs index db941dbfa17..fe8f0187424 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs @@ -21,6 +21,8 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable private IDisposable? _themeChangedSubscription; private int _renderedDimensionsCount; private readonly InstrumentViewModel _instrumentViewModel = new InstrumentViewModel(); + private (ResourceKey ResourceKey, string MeterName, string InstrumentName)? _dataEndTimeKey; + private DateTimeOffset? _dataEndTime; [Parameter, EditorRequired] public required ResourceKey ResourceKey { get; set; } @@ -47,7 +49,7 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable public required string? PauseText { get; set; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } [Inject] public required ILogger Logger { get; init; } @@ -58,6 +60,9 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable [Inject] public required PauseManager PauseManager { get; init; } + [Inject] + public required IDashboardClient DashboardClient { get; init; } + public ImmutableList DimensionFilters { get; set; } = []; public string? PreviousMeterName { get; set; } public string? PreviousInstrumentName { get; set; } @@ -66,9 +71,12 @@ protected override async Task OnInitializedAsync() { await ThemeManager.EnsureInitializedAsync(); - // Update the graph every 200ms. This displays the latest data and moves time forward. - _tickTimer = new PeriodicTimer(TimeSpan.FromSeconds(0.2)); - _tickTask = Task.Run(UpdateDataAsync); + if (!DashboardClient.IsReadOnly) + { + // Update the graph every 200ms. This displays the latest data and moves time forward. + _tickTimer = new PeriodicTimer(TimeSpan.FromSeconds(0.2)); + _tickTask = Task.Run(UpdateDataAsync); + } _themeChangedSubscription = ThemeManager.OnThemeChanged(async () => { _instrumentViewModel.Theme = ThemeManager.EffectiveTheme; @@ -183,9 +191,19 @@ protected override async Task OnParametersSetAsync() private OtlpInstrumentData? GetInstrument() { - // When paused, use the paused time to keep the data window stable. - // This ensures filter changes while paused still show the same data. - var endDate = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : DateTime.UtcNow; + DateTime endDate; + if (DashboardClient.IsReadOnly) + { + EnsureDataEndTime(); + endDate = _dataEndTime?.UtcDateTime ?? DateTime.UtcNow; + } + else + { + // When paused, use the paused time to keep the data window stable. + // This ensures filter changes while paused still show the same data. + endDate = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : DateTime.UtcNow; + } + // Get more data than is being displayed. Histogram graph uses some historical data to calculate bucket counts. // It's ok to get more data than is needed here. An additional date filter is applied when building chart values. var startDate = endDate.Subtract(Duration + TimeSpan.FromSeconds(30)); @@ -211,6 +229,29 @@ protected override async Task OnParametersSetAsync() return instrument; } + private void EnsureDataEndTime() + { + var key = (ResourceKey, MeterName, InstrumentName); + if (_dataEndTimeKey == key) + { + return; + } + + var instrument = TelemetryRepository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = ResourceKey, + MeterName = MeterName, + InstrumentName = InstrumentName, + StartTime = DateTime.MinValue, + EndTime = DateTime.MaxValue, + }); + var values = instrument?.Dimensions.SelectMany(dimension => dimension.Values).ToList(); + _dataEndTime = values is { Count: > 0 } + ? new DateTimeOffset(values.Max(value => value.End)) + : null; + _dataEndTimeKey = key; + } + private List CreateUpdatedFilters(bool hasInstrumentChanged) { var filters = new List(); diff --git a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor index 47843d590af..35f6b6434f5 100644 --- a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor @@ -2,6 +2,7 @@ @namespace Aspire.Dashboard.Components.Controls @inject IStringLocalizer Loc +@inject IDashboardClient DashboardClient diff --git a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs index b9c10c7530a..747720d5694 100644 --- a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs @@ -37,6 +37,7 @@ protected override void OnParametersSet() Id = "clear-menu-all", Icon = s_clearAllResourcesIcon, OnClick = () => HandleClearSignal(null), + IsDisabled = DashboardClient.IsReadOnly, Text = ControlsStringsLoc[name: nameof(ControlsStrings.ClearAllResources)], }); @@ -45,7 +46,7 @@ protected override void OnParametersSet() Id = "clear-menu-resource", Icon = s_clearSelectedResourceIcon, OnClick = () => HandleClearSignal(SelectedResource.Id?.GetResourceKey()), - IsDisabled = SelectedResource.Id == null, + IsDisabled = DashboardClient.IsReadOnly || SelectedResource.Id == null, Text = SelectedResource.Id == null ? ControlsStringsLoc[nameof(ControlsStrings.ClearPendingSelectedResource)] : string.Format(CultureInfo.InvariantCulture, ControlsStringsLoc[name: nameof(ControlsStrings.ClearSelectedResource)], SelectedResource.Name), diff --git a/src/Aspire.Dashboard/Components/Controls/ParameterValueDisplay.razor b/src/Aspire.Dashboard/Components/Controls/ParameterValueDisplay.razor index f584645d426..19c67f05ad8 100644 --- a/src/Aspire.Dashboard/Components/Controls/ParameterValueDisplay.razor +++ b/src/Aspire.Dashboard/Components/Controls/ParameterValueDisplay.razor @@ -1,6 +1,7 @@ @using Aspire.Dashboard.Model @using Aspire.Dashboard.Resources @inject IStringLocalizer ControlsStringsLoc +@inject IDashboardClient DashboardClient @if (SetCommand is not null) { @@ -29,7 +30,7 @@ else public required Func IsCommandExecuting { get; set; } private CommandViewModel? SetCommand { get; set; } - private bool IsSetCommandDisabled => SetCommand is null || SetCommand.State == CommandViewModelState.Disabled || IsSetCommandExecuting; + private bool IsSetCommandDisabled => DashboardClient.IsReadOnly || SetCommand is null || SetCommand.State == CommandViewModelState.Disabled || IsSetCommandExecuting; private bool IsSetCommandExecuting => SetCommand is not null && IsCommandExecuting(Resource, SetCommand); protected override void OnParametersSet() @@ -48,7 +49,8 @@ else private Task OnClickAsync() { - if (SetCommand is not { } setCommand || + if (DashboardClient.IsReadOnly || + SetCommand is not { } setCommand || setCommand.State == CommandViewModelState.Disabled || IsCommandExecuting(Resource, setCommand)) { diff --git a/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor b/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor index de03ac0948d..5f9b61ce64b 100644 --- a/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor +++ b/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor @@ -2,6 +2,7 @@ @inject IStringLocalizer Loc @if (IsPaused) diff --git a/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor.cs b/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor.cs index 3d04ab4ff97..b0fa5df2a48 100644 --- a/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/PauseIncomingDataSwitch.razor.cs @@ -12,8 +12,16 @@ public partial class PauseIncomingDataSwitch : ComponentBase [Parameter] public EventCallback IsPausedChanged { get; set; } + [Parameter] + public bool Disabled { get; set; } + private async Task OnTogglePauseCoreAsync() { + if (Disabled) + { + return; + } + IsPaused = !IsPaused; await IsPausedChanged.InvokeAsync(IsPaused); } diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs b/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs index ae638f67e10..f7c7b482f3f 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs @@ -24,7 +24,7 @@ public partial class SpanIdButtonValue public required DashboardDialogService DialogService { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } [Inject] public required IStringLocalizer Loc { get; init; } diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor index f11f265ea49..b1851ed6d1f 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor +++ b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor @@ -11,7 +11,7 @@ { var highlightedCommand = _highlightedCommands[i]; - + } diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs index 606f25ef39a..c409003589b 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor.cs @@ -32,6 +32,9 @@ public partial class ResourceActions : ComponentBase [Inject] public required IconResolver IconResolver { get; init; } + [Inject] + public required IDashboardClient DashboardClient { get; init; } + [Parameter] public required EventCallback CommandSelected { get; set; } diff --git a/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor b/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor index f73652631ce..fc66215078f 100644 --- a/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor +++ b/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor @@ -3,7 +3,7 @@ @if (ViewportInformation.IsDesktop) { - + } @@ -12,7 +12,7 @@ else @ControlsStringsLoc[nameof(ControlsStrings.ResourceActions)] - + diff --git a/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor.cs b/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor.cs index 3311c7bc1b2..a21961eddc0 100644 --- a/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor.cs @@ -9,6 +9,9 @@ namespace Aspire.Dashboard.Components.Controls; public partial class SignalsActionsDisplay { + [Inject] + public required IDashboardClient DashboardClient { get; init; } + [CascadingParameter] public required ViewportInformation ViewportInformation { get; set; } diff --git a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs index 4bfdbf5b917..df50f70656d 100644 --- a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs @@ -34,7 +34,7 @@ public partial class SpanDetails : IDisposable public required NavigationManager NavigationManager { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } [Inject] public required IJSRuntime JS { get; init; } diff --git a/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs b/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs index 44b2947c7ca..2a19127794c 100644 --- a/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs @@ -19,7 +19,7 @@ public partial class TreeMetricSelector public bool IncludeLabel { get; set; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } public void OnResourceChanged() { diff --git a/src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor new file mode 100644 index 00000000000..86363a69940 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor @@ -0,0 +1,17 @@ +@using Aspire.Dashboard.Resources +@implements IDialogContentComponent +@inject IStringLocalizer Loc + + + + +

@Loc[nameof(Dialogs.SettingsDialogDashboardRunPageReloads)]

+
+
\ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor.cs new file mode 100644 index 00000000000..de133da86ef --- /dev/null +++ b/src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Aspire.Dashboard.Model; +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Aspire.Dashboard.Components.Dialogs; + +public partial class DashboardRunsDialog : IDialogContentComponent +{ + private IReadOnlyList _runOptions = []; + + [Parameter] + public required DashboardRunsDialogViewModel Content { get; set; } + + [Inject] + public required BrowserTimeProvider TimeProvider { get; init; } + + [Inject] + internal IDashboardRunStore RunStore { get; init; } = null!; + + protected override void OnInitialized() + { + _runOptions = RunStore.GetRuns(); + if (!_runOptions.Any(run => string.Equals(run.RunId, Content.SelectedRun.RunId, StringComparison.Ordinal))) + { + Content.SelectedRun = _runOptions.Single(run => run.IsCurrent); + } + } + + private string FormatRunOption(DashboardRunDescriptor run) + { + if (run.IsCurrent) + { + return Loc[nameof(Dashboard.Resources.Dialogs.SettingsDialogDashboardRunCurrent)]; + } + + var localStartedAt = TimeZoneInfo.ConvertTime(run.StartedAtUtc, TimeProvider.LocalTimeZone); + var startedAtText = localStartedAt.ToString("g", CultureInfo.CurrentCulture); + return string.Format( + CultureInfo.CurrentCulture, + Loc[nameof(Dashboard.Resources.Dialogs.DashboardRunsDialogStartedAt)], + startedAtText); + } + + private string FormatRunsLabel() + { + var applicationName = _runOptions.Single(run => run.IsCurrent).ApplicationName + ?? Loc[nameof(Dashboard.Resources.Dialogs.SettingsDialogDashboardRunUnknownApplication)]; + return string.Format( + CultureInfo.CurrentCulture, + Loc[nameof(Dashboard.Resources.Dialogs.SettingsDialogDashboardRun)], + applicationName); + } +} + +public sealed class DashboardRunsDialogViewModel +{ + internal DashboardRunDescriptor SelectedRun { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs index f318a57284e..e2a896813c1 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs @@ -27,7 +27,7 @@ public partial class ExemplarsDialog : IDisposable public required NavigationManager NavigationManager { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } public IQueryable MetricView => Content.Exemplars.AsQueryable(); diff --git a/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs index ebbcca61d47..59681dd0a74 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs @@ -30,7 +30,7 @@ private SelectViewModel CreateFilterSelectViewModel(FilterCondi public FilterDialogViewModel Content { get; set; } = default!; [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } [Inject] public required IJSRuntime JS { get; init; } diff --git a/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs index 5d515933230..91d24c179e8 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs @@ -47,7 +47,7 @@ public partial class GenAIVisualizerDialog : ComponentBase, IComponentWithTeleme public required BrowserTimeProvider TimeProvider { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } [Inject] public required IStringLocalizer Loc { get; init; } @@ -371,7 +371,7 @@ public void Dispose() public static async Task OpenDialogAsync(DashboardDialogService dialogService, OtlpSpan span, long? selectedLogEntryId, - TelemetryRepository telemetryRepository, ITelemetryErrorRecorder errorRecorder, List resources, Func> getContextGenAISpans) + ITelemetryRepository telemetryRepository, ITelemetryErrorRecorder errorRecorder, List resources, Func> getContextGenAISpans) { var title = span.Name; var width = dialogService.IsDesktop ? "75vw" : "100vw"; diff --git a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor index 7ef0ece5f9d..1e2db7d1712 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor @@ -140,13 +140,13 @@ @Loc[nameof(Dialogs.ManageDataRemoveButtonText)] - @if (TelemetryImportService.IsImportEnabled) + @if (TelemetryImportService.IsImportEnabled && !DashboardClient.IsReadOnly) {
- +
+ + + + +
- +
+ + + + +
@@ -87,7 +111,10 @@
- @Body + @if (_runSelectionLoaded) + { + @Body + }
diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 50fc3cd78ad..f30e15bc015 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Globalization; using System.Text; using Aspire.Dashboard.Components.Dialogs; using Aspire.Dashboard.Components.Pages; @@ -32,9 +33,13 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable private const string HelpDialogId = "HelpDialog"; private const string NotificationsDialogId = "NotificationsDialog"; private const string AIAgentsDialogId = "AIAgentsDialog"; + private const string DashboardRunsDialogId = "DashboardRunsDialog"; internal const string HelpButtonId = "dashboard-help-button"; internal const string SettingsButtonId = "dashboard-settings-button"; + internal const string DashboardRunsButtonId = "dashboard-runs-button"; internal const string NavigationButtonId = "dashboard-navigation-button"; + private DashboardRunDescriptor? _selectedRun; + private bool _runSelectionLoaded; [Inject] public required ThemeManager ThemeManager { get; init; } @@ -72,11 +77,28 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable [Inject] public required ILocalStorage LocalStorage { get; init; } + [Inject] + public required ISessionStorage SessionStorage { get; init; } + + [Inject] + internal IDashboardRunStore RunStore { get; init; } = null!; + + [Inject] + internal IDashboardRunSelection RunSelection { get; init; } = null!; + [CascadingParameter] public required ViewportInformation ViewportInformation { get; set; } protected override async Task OnInitializedAsync() { + var runs = RunStore.GetRuns(); + var selectedRunResult = await SessionStorage.GetAsync(BrowserStorageKeys.SelectedDashboardRunId); + var selectedRunId = selectedRunResult is { Success: true } ? selectedRunResult.Value : null; + _selectedRun = runs.FirstOrDefault(run => string.Equals(run.RunId, selectedRunId, StringComparison.Ordinal)) + ?? runs.Single(run => run.IsCurrent); + RunSelection.SelectRun(_selectedRun.IsCurrent ? null : _selectedRun.RunId); + _runSelectionLoaded = true; + // Theme change can be triggered from the settings dialog. This logic applies the new theme to the browser window. // Note that this event could be raised from a settings dialog opened in a different browser window. _themeChangedSubscription = ThemeManager.OnThemeChanged(async () => @@ -213,6 +235,20 @@ protected override void OnParametersSet() private string GetDefaultReturnFocusElementId(string desktopButtonId) => ViewportInformation.IsDesktop ? desktopButtonId : NavigationButtonId; + private string? GetHistoricalRunStartText() + { + if (_selectedRun is not { IsCurrent: false } selectedRun) + { + return null; + } + + var localStartedAt = TimeZoneInfo.ConvertTime(selectedRun.StartedAtUtc, TimeProvider.LocalTimeZone); + return string.Format( + CultureInfo.CurrentCulture, + DialogsLoc[nameof(Resources.Dialogs.DashboardRunsDialogStartedAt)], + localStartedAt.ToString("g", CultureInfo.CurrentCulture)); + } + private string? GetVisibleReturnFocusElementId(string? returnFocusElementId, string desktopButtonId) { // Dialog launchers move between the desktop header and the mobile navigation menu. @@ -309,6 +345,44 @@ public async Task LaunchAIAgentsAsync() public Task LaunchSettingsAsync() => LaunchSettingsAsync(GetDefaultReturnFocusElementId(SettingsButtonId)); + private async Task LaunchDashboardRunsAsync() + { + var content = new DashboardRunsDialogViewModel + { + SelectedRun = _selectedRun ?? RunStore.GetRuns().Single(run => run.IsCurrent) + }; + var parameters = new DialogParameters + { + Title = DialogsLoc[nameof(Resources.Dialogs.DashboardRunsDialogTitle)], + PrimaryAction = DialogsLoc[nameof(Resources.Dialogs.InteractionButtonOk)], + SecondaryAction = DialogsLoc[nameof(Resources.Dialogs.InteractionButtonCancel)], + TrapFocus = true, + Modal = true, + Alignment = HorizontalAlignment.Center, + Width = "500px", + Height = "auto", + Id = DashboardRunsDialogId, + OnDialogClosing = EventCallback.Factory.Create(this, _ => HandleDialogClose(DashboardRunsButtonId)), + OnDialogResult = EventCallback.Factory.Create(this, async result => + { + if (result.Cancelled || string.Equals(content.SelectedRun.RunId, _selectedRun?.RunId, StringComparison.Ordinal)) + { + return; + } + + await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, content.SelectedRun.IsCurrent ? string.Empty : content.SelectedRun.RunId); + NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true); + }) + }; + + if (!await CloseOpenPageDialogForReplacementAsync(DashboardRunsDialogId).ConfigureAwait(true)) + { + return; + } + + _openPageDialog = await DialogService.ShowDialogAsync(content, parameters).ConfigureAwait(true); + } + private async Task LaunchSettingsAsync(string? returnFocusElementId) { var parameters = new DialogParameters diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css index 92a56860df0..726ab2748c2 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css @@ -56,7 +56,7 @@ margin-bottom: 0; } - ::deep.layout > header > .header-gutters > fluent-anchor { + ::deep.layout > header > .header-gutters > .application-header > fluent-anchor { font-size: var(--type-ramp-plus-2-font-size); } @@ -152,7 +152,7 @@ margin-bottom: 0; } - ::deep.layout > header > .header-gutters > fluent-anchor { + ::deep.layout > header > .header-gutters > .application-header > fluent-anchor { font-size: var(--type-ramp-plus-2-font-size); } @@ -183,11 +183,30 @@ ::deep.layout > header > .header-gutters > fluent-button[appearance=stealth]:not(:hover)::part(control), ::deep.layout > header > .header-gutters > fluent-anchor[appearance=stealth]:not(:hover)::part(control), +::deep.layout > header > .header-gutters > .application-header > fluent-anchor[appearance=stealth]:not(:hover)::part(control), ::deep.layout > header > .header-gutters > fluent-anchor[appearance=stealth].logo::part(control), ::deep.layout > .aspire-icon fluent-anchor[appearance=stealth].logo::part(control) { background-color: var(--neutral-layer-4); } +::deep .header-gutters .application-header { + display: flex; + align-items: center; + min-width: 0; +} + +::deep .header-gutters .application-run-start { + margin-left: 8px; + font-size: var(--type-ramp-base-font-size); + white-space: nowrap; +} + +::deep .header-gutters .application-run-button::part(control) { + width: 32px; + height: 32px; + padding: 0; +} + ::deep .header-gutters .header-button { height: 100%; display: block; diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor index 2da6176b1fa..31282c95add 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor @@ -46,7 +46,7 @@ { diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index c5a53d55f36..a4da52dd92f 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -81,7 +81,7 @@ public void Cancel() public required ISessionStorage SessionStorage { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } [Inject] public required ILogger Logger { get; init; } diff --git a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs index 1cf0e7542c5..bd42b257a45 100644 --- a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs @@ -60,7 +60,7 @@ public partial class Metrics : IDisposable, IComponentWithTelemetry, IPageWithSe public required ISessionStorage SessionStorage { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } [Inject] public required ILogger Logger { get; init; } diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs index 53a02f30961..1e5884649dd 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs @@ -45,7 +45,7 @@ public partial class Resources : ComponentBase, IComponentWithTelemetry, IAsyncD [Inject] public required IDashboardClient DashboardClient { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } [Inject] public required NavigationManager NavigationManager { get; init; } [Inject] diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index c385a5a8ba2..11fe289504d 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -58,7 +58,7 @@ public partial class StructuredLogs : IComponentWithTelemetry, IPageWithSessionA public StructuredLogsPageViewModel PageViewModel { get; set; } = null!; [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } [Inject] public required StructuredLogsViewModel ViewModel { get; init; } diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs index 0f8ccaab682..f98fc985cb2 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs @@ -64,7 +64,7 @@ public partial class TraceDetail : ComponentBase, IComponentWithTelemetry, IDisp public required ITelemetryErrorRecorder ErrorRecorder { get; init; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } [Inject] public required IEnumerable OutgoingPeerResolvers { get; init; } diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs index 23fbbbe915a..37c8a744075 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs @@ -56,7 +56,7 @@ public partial class Traces : IComponentWithTelemetry, IPageWithSessionAndUrlSta public string? ResourceName { get; set; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } [Inject] public required TracesViewModel TracesViewModel { get; init; } diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs index 9db58df002f..161cc221c8a 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs @@ -20,7 +20,7 @@ public partial class UnreadLogErrorsBadge public required Dictionary? UnviewedErrorCounts { get; set; } [Inject] - public required TelemetryRepository TelemetryRepository { get; init; } + public required ITelemetryRepository TelemetryRepository { get; init; } protected override void OnParametersSet() { diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index 5be0cac8966..104f2ae4e8a 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -19,6 +19,12 @@ public sealed class DashboardOptions public TelemetryLimitOptions TelemetryLimits { get; set; } = new(); public DebugSessionOptions DebugSession { get; set; } = new(); public UIOptions UI { get; set; } = new(); + public DashboardDataOptions Data { get; set; } = new(); +} + +public sealed class DashboardDataOptions +{ + public string? Directory { get; set; } } // Don't set values after validating/parsing options. diff --git a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs index 114d0901c2e..052ac565656 100644 --- a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs @@ -26,6 +26,16 @@ public void PostConfigure(string? name, DashboardOptions options) { _logger.LogDebug($"PostConfigure {nameof(DashboardOptions)} with name '{name}'."); + if (_configuration[DashboardConfigNames.DashboardApplicationName.EnvVarName] is { Length: > 0 } applicationName) + { + options.ApplicationName = applicationName; + } + + if (_configuration[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] is { Length: > 0 } dataDirectory) + { + options.Data.Directory = dataDirectory; + } + // Copy aliased config values to the strongly typed options. if (_configuration.GetString(DashboardConfigNames.DashboardOtlpGrpcUrlName.ConfigKey, DashboardConfigNames.Legacy.DashboardOtlpGrpcUrlName.ConfigKey, fallbackOnEmpty: true) is { } otlpGrpcUrl) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 421fa1b1a8b..a889da1ddf7 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -277,7 +277,10 @@ public DashboardWebApplication( } // Data from the server. - builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(services => services.GetRequiredService()); + builder.Services.AddScoped(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(TimeProvider.System); @@ -294,18 +297,49 @@ public DashboardWebApplication( // OTLP services. builder.Services.AddGrpc(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(services => services.GetRequiredService()); + builder.Services.AddSingleton(services => new DashboardSqliteDatabase( + services.GetRequiredService().DatabasePath)); + builder.Services.AddSingleton(services => + { + return new SqliteTelemetryRepository( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService>(), + services.GetRequiredService(), + services.GetServices()); + }); + builder.Services.AddScoped(services => services.GetRequiredService().TelemetryRepository); + builder.Services.AddSingleton(services => + { + return new SqliteResourceRepository( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + }); + builder.Services.AddSingleton(services => services.GetRequiredService()); + builder.Services.AddScoped(services => services.GetRequiredService().ResourceRepository); builder.Services.AddTransient(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddTransient(services => new OtlpLogsService( + services.GetRequiredService>(), + services.GetRequiredService())); + builder.Services.AddTransient(services => new OtlpTraceService( + services.GetRequiredService>(), + services.GetRequiredService())); + builder.Services.AddTransient(services => new OtlpMetricsService( + services.GetRequiredService>(), + services.GetRequiredService())); // Telemetry API. - builder.Services.AddSingleton(); + builder.Services.AddSingleton(services => new TelemetryApiService( + services.GetRequiredService(), + services.GetServices())); builder.Services.AddTransient(); - builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.AddSingleton(services => + new ResourceOutgoingPeerResolver(services.GetRequiredService())); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.AddFluentUIComponents(); @@ -319,7 +353,10 @@ public DashboardWebApplication( builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(services => new TelemetryImportService( + services.GetRequiredService(), + services.GetRequiredService>(), + services.GetRequiredService>())); builder.Services.AddSingleton(); // Time zone is set by the browser. @@ -333,7 +370,8 @@ public DashboardWebApplication( // resource snapshot stream. Default impl looks up by display name and // replica index in IDashboardClient and connects to the consumer UDS // path the AppHost stamped onto the snapshot. - builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(services => + new Aspire.Dashboard.Terminal.DefaultTerminalConnectionResolver(services.GetRequiredService())); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -441,7 +479,7 @@ public DashboardWebApplication( { if (context.Request.Path.Equals(TargetLocationInterceptor.ResourcesPath, StringComparisons.UrlPath)) { - var client = context.RequestServices.GetRequiredService(); + var client = context.RequestServices.GetRequiredService(); if (!client.IsEnabled) { context.Response.Redirect(TargetLocationInterceptor.StructuredLogsPath); diff --git a/src/Aspire.Dashboard/Model/ExportHelpers.cs b/src/Aspire.Dashboard/Model/ExportHelpers.cs index 0e2d2302cf9..82c72697cb8 100644 --- a/src/Aspire.Dashboard/Model/ExportHelpers.cs +++ b/src/Aspire.Dashboard/Model/ExportHelpers.cs @@ -21,7 +21,7 @@ internal static class ExportHelpers /// /// Gets a span as a JSON export result, including associated log entries. /// - public static ExportResult GetSpanAsJson(OtlpSpan span, TelemetryRepository telemetryRepository, IOutgoingPeerResolver[] outgoingPeerResolvers) + public static ExportResult GetSpanAsJson(OtlpSpan span, ITelemetryRepository telemetryRepository, IOutgoingPeerResolver[] outgoingPeerResolvers) { var logs = telemetryRepository.GetLogsForSpan(span.TraceId, span.SpanId); var json = TelemetryExportService.ConvertSpanToJson(span, outgoingPeerResolvers, logs); @@ -44,7 +44,7 @@ public static ExportResult GetLogEntryAsJson(OtlpLogEntry logEntry) /// /// Gets all spans in a trace as a JSON export result, including associated log entries. /// - public static ExportResult GetTraceAsJson(OtlpTrace trace, TelemetryRepository telemetryRepository, IOutgoingPeerResolver[] outgoingPeerResolvers) + public static ExportResult GetTraceAsJson(OtlpTrace trace, ITelemetryRepository telemetryRepository, IOutgoingPeerResolver[] outgoingPeerResolvers) { var logs = telemetryRepository.GetLogsForTrace(trace.TraceId); var json = TelemetryExportService.ConvertTraceToJson(trace, outgoingPeerResolvers, logs); diff --git a/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs b/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs index 256878c5a7b..6b5bc8dca51 100644 --- a/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs +++ b/src/Aspire.Dashboard/Model/GenAI/GenAIVisualizerDialogViewModel.cs @@ -51,7 +51,7 @@ public static GenAIVisualizerDialogViewModel Create( SpanDetailsViewModel spanDetailsViewModel, long? selectedLogEntryId, ITelemetryErrorRecorder errorRecorder, - TelemetryRepository telemetryRepository, + ITelemetryRepository telemetryRepository, Func> getContextGenAISpans) { var resources = telemetryRepository.GetResources(); @@ -276,7 +276,7 @@ private static bool AllMessagesHaveNoContent(List messageVie // - Span attributes. // - Log entry bodies. // - Span event attributes. - private static void CreateMessages(GenAIVisualizerDialogViewModel viewModel, TelemetryRepository telemetryRepository) + private static void CreateMessages(GenAIVisualizerDialogViewModel viewModel, ITelemetryRepository telemetryRepository) { var currentIndex = 0; @@ -592,7 +592,7 @@ private static bool TryMapEventName(string name, [NotNullWhen(true)] out GenAIIt return type != null; } - private static List GetSpanLogEntries(TelemetryRepository telemetryRepository, OtlpSpan span) + private static List GetSpanLogEntries(ITelemetryRepository telemetryRepository, OtlpSpan span) { var logsContext = new GetLogsContext { @@ -612,7 +612,7 @@ private static List GetSpanLogEntries(TelemetryRepository telemetr return logsResult.Items; } - private static void ParseEvaluations(GenAIVisualizerDialogViewModel viewModel, TelemetryRepository telemetryRepository) + private static void ParseEvaluations(GenAIVisualizerDialogViewModel viewModel, ITelemetryRepository telemetryRepository) { var evaluations = new List(); diff --git a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs index f879be3ecb7..57b562c8fa3 100644 --- a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs @@ -29,22 +29,24 @@ public sealed class ResourceMenuBuilder private static readonly Icon s_exportEnvIcon = new Icons.Regular.Size16.DocumentText(); private readonly NavigationManager _navigationManager; - private readonly TelemetryRepository _telemetryRepository; + private readonly ITelemetryRepository _telemetryRepository; private readonly IStringLocalizer _controlLoc; private readonly IStringLocalizer _loc; private readonly IconResolver _iconResolver; private readonly DashboardDialogService _dialogService; + private readonly IDashboardClient _dashboardClient; /// /// Initializes a new instance of the class. /// public ResourceMenuBuilder( NavigationManager navigationManager, - TelemetryRepository telemetryRepository, + ITelemetryRepository telemetryRepository, IStringLocalizer controlLoc, IStringLocalizer loc, IconResolver iconResolver, - DashboardDialogService dialogService) + DashboardDialogService dialogService, + IDashboardClient dashboardClient) { _navigationManager = navigationManager; _telemetryRepository = telemetryRepository; @@ -52,6 +54,7 @@ public ResourceMenuBuilder( _loc = loc; _iconResolver = iconResolver; _dialogService = dialogService; + _dashboardClient = dashboardClient; } /// @@ -309,7 +312,7 @@ MenuButtonItem CreateMenuItem(CommandViewModel command) Tooltip = command.GetDisplayDescription(), Icon = _iconResolver.ResolveCommandIcon(command.IconName, command.IconVariant), OnClick = () => commandSelected.InvokeAsync(command), - IsDisabled = command.State == CommandViewModelState.Disabled || isCommandExecuting(resource, command) + IsDisabled = _dashboardClient.IsReadOnly || command.State == CommandViewModelState.Disabled || isCommandExecuting(resource, command) }; } } diff --git a/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs b/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs index faeac64969f..97db8f5d57a 100644 --- a/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs +++ b/src/Aspire.Dashboard/Model/SpanDetailsViewModel.cs @@ -19,7 +19,7 @@ public sealed class SpanDetailsViewModel public required string Title { get; init; } public required List Resources { get; init; } - public static SpanDetailsViewModel Create(OtlpSpan span, TelemetryRepository telemetryRepository, List resources) + public static SpanDetailsViewModel Create(OtlpSpan span, ITelemetryRepository telemetryRepository, List resources) { ArgumentNullException.ThrowIfNull(span); ArgumentNullException.ThrowIfNull(telemetryRepository); @@ -59,7 +59,7 @@ static TelemetryPropertyViewModel CreateTelemetryProperty(OtlpDisplayField f) } } - private static SpanLinkViewModel CreateLinkViewModel(string traceId, string spanId, KeyValuePair[] attributes, TelemetryRepository telemetryRepository, Dictionary traceCache) + private static SpanLinkViewModel CreateLinkViewModel(string traceId, string spanId, KeyValuePair[] attributes, ITelemetryRepository telemetryRepository, Dictionary traceCache) { ref var trace = ref CollectionsMarshal.GetValueRefOrAddDefault(traceCache, traceId, out _); // Adds to dictionary if not present. diff --git a/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs b/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs index 4fc9e65e2f3..edda12fe402 100644 --- a/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs @@ -27,7 +27,7 @@ public sealed class SpanMenuBuilder private readonly IStringLocalizer _controlsLoc; private readonly NavigationManager _navigationManager; private readonly DashboardDialogService _dialogService; - private readonly TelemetryRepository _telemetryRepository; + private readonly ITelemetryRepository _telemetryRepository; private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; /// @@ -37,7 +37,7 @@ public SpanMenuBuilder( IStringLocalizer controlsLoc, NavigationManager navigationManager, DashboardDialogService dialogService, - TelemetryRepository telemetryRepository, + ITelemetryRepository telemetryRepository, IEnumerable outgoingPeerResolvers) { _controlsLoc = controlsLoc; diff --git a/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs b/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs index 0ef4fe98147..e734c253010 100644 --- a/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs +++ b/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs @@ -11,7 +11,7 @@ namespace Aspire.Dashboard.Model; public class StructuredLogsViewModel { - private readonly TelemetryRepository _telemetryRepository; + private readonly ITelemetryRepository _telemetryRepository; private readonly List _filters = new(); // Cache span lookups for GenAI attributes to avoid repeated lookups. private readonly ConcurrentDictionary _spanGenAICache = new(); @@ -24,7 +24,7 @@ public class StructuredLogsViewModel private LogLevel? _logLevel; private bool _currentDataHasErrors; - public StructuredLogsViewModel(TelemetryRepository telemetryRepository) + public StructuredLogsViewModel(ITelemetryRepository telemetryRepository) { _telemetryRepository = telemetryRepository; } diff --git a/src/Aspire.Dashboard/Model/TelemetryExportService.cs b/src/Aspire.Dashboard/Model/TelemetryExportService.cs index 9458faf0138..91f13ea53fd 100644 --- a/src/Aspire.Dashboard/Model/TelemetryExportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryExportService.cs @@ -22,7 +22,7 @@ namespace Aspire.Dashboard.Model; /// public sealed class TelemetryExportService { - private readonly TelemetryRepository _telemetryRepository; + private readonly ITelemetryRepository _telemetryRepository; private readonly ConsoleLogsFetcher _consoleLogsFetcher; private readonly IDashboardClient _dashboardClient; private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; @@ -34,7 +34,7 @@ public sealed class TelemetryExportService /// The console log fetcher. /// The dashboard client for fetching resources. /// The outgoing peer resolvers for destination name resolution. - public TelemetryExportService(TelemetryRepository telemetryRepository, ConsoleLogsFetcher consoleLogsFetcher, IDashboardClient dashboardClient, IEnumerable outgoingPeerResolvers) + public TelemetryExportService(ITelemetryRepository telemetryRepository, ConsoleLogsFetcher consoleLogsFetcher, IDashboardClient dashboardClient, IEnumerable outgoingPeerResolvers) { _telemetryRepository = telemetryRepository; _consoleLogsFetcher = consoleLogsFetcher; diff --git a/src/Aspire.Dashboard/Model/TelemetryImportService.cs b/src/Aspire.Dashboard/Model/TelemetryImportService.cs index 8d3bea3600f..ecde29bd8fb 100644 --- a/src/Aspire.Dashboard/Model/TelemetryImportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryImportService.cs @@ -17,7 +17,7 @@ namespace Aspire.Dashboard.Model; /// public sealed class TelemetryImportService { - private readonly TelemetryRepository _telemetryRepository; + private readonly ITelemetryRepository _telemetryRepository; private readonly IOptionsMonitor _options; private readonly ILogger _logger; @@ -32,7 +32,7 @@ public sealed class TelemetryImportService /// The telemetry repository. /// The dashboard options. /// The logger. - public TelemetryImportService(TelemetryRepository telemetryRepository, IOptionsMonitor options, ILogger logger) + public TelemetryImportService(ITelemetryRepository telemetryRepository, IOptionsMonitor options, ILogger logger) { _telemetryRepository = telemetryRepository; _options = options; diff --git a/src/Aspire.Dashboard/Model/TraceHelpers.cs b/src/Aspire.Dashboard/Model/TraceHelpers.cs index 2b9ce0deb5f..7d874b11441 100644 --- a/src/Aspire.Dashboard/Model/TraceHelpers.cs +++ b/src/Aspire.Dashboard/Model/TraceHelpers.cs @@ -64,7 +64,7 @@ static void Visit(Dictionary> spanLookup, OtlpSpan span /// public static IEnumerable GetOrderedResources(OtlpTrace trace) { - var resourceFirstTimes = new Dictionary(); + var resourceFirstTimes = new Dictionary(OtlpResourceEqualityComparer.Instance); VisitSpans(trace, (OtlpSpan span, OrderedResourcesState state) => { diff --git a/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs b/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs index 6178f4778cc..fbfa0347d48 100644 --- a/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs @@ -25,7 +25,7 @@ public sealed class TraceMenuBuilder private readonly IStringLocalizer _controlsLoc; private readonly NavigationManager _navigationManager; private readonly DashboardDialogService _dialogService; - private readonly TelemetryRepository _telemetryRepository; + private readonly ITelemetryRepository _telemetryRepository; private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; /// @@ -35,7 +35,7 @@ public TraceMenuBuilder( IStringLocalizer controlsLoc, NavigationManager navigationManager, DashboardDialogService dialogService, - TelemetryRepository telemetryRepository, + ITelemetryRepository telemetryRepository, IEnumerable outgoingPeerResolvers) { _controlsLoc = controlsLoc; diff --git a/src/Aspire.Dashboard/Model/TracesViewModel.cs b/src/Aspire.Dashboard/Model/TracesViewModel.cs index 53ef52fd1c1..94823076441 100644 --- a/src/Aspire.Dashboard/Model/TracesViewModel.cs +++ b/src/Aspire.Dashboard/Model/TracesViewModel.cs @@ -9,7 +9,7 @@ namespace Aspire.Dashboard.Model; public class TracesViewModel { - private readonly TelemetryRepository _telemetryRepository; + private readonly ITelemetryRepository _telemetryRepository; private readonly List _filters = new(); private PagedResult? _traces; @@ -20,7 +20,7 @@ public class TracesViewModel private bool _currentDataHasErrors; private SpanType? _spanType; - public TracesViewModel(TelemetryRepository telemetryRepository) + public TracesViewModel(ITelemetryRepository telemetryRepository) { _telemetryRepository = telemetryRepository; } diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs index 07da1018db5..38691a3a19e 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs @@ -12,6 +12,7 @@ using Google.Protobuf.Collections; using OpenTelemetry.Proto.Common.V1; using OpenTelemetry.Proto.Resource.V1; +using static OpenTelemetry.Proto.Trace.V1.Span.Types; namespace Aspire.Dashboard.Otlp.Model; @@ -25,6 +26,22 @@ public static partial class OtlpHelpers // Note: ShortenedIdLength is defined in the shared OtlpHelpers.cs + internal static OtlpSpanKind ConvertSpanKind(SpanKind? kind) + { + return kind switch + { + // Unspecified to Internal is intentional. + // "Implementations MAY assume SpanKind to be INTERNAL when receiving UNSPECIFIED." + SpanKind.Unspecified => OtlpSpanKind.Internal, + SpanKind.Internal => OtlpSpanKind.Internal, + SpanKind.Client => OtlpSpanKind.Client, + SpanKind.Server => OtlpSpanKind.Server, + SpanKind.Producer => OtlpSpanKind.Producer, + SpanKind.Consumer => OtlpSpanKind.Consumer, + _ => OtlpSpanKind.Unspecified + }; + } + public static ResourceKey GetResourceKey(this Resource resource) { string? serviceName = null; @@ -471,9 +488,9 @@ public static bool TryGetOrAddScope(Dictionary scopes, Instru return true; } - if (scopes.Count >= TelemetryRepository.MaxScopeCount) + if (scopes.Count >= TelemetryRepositoryLimits.MaxScopeCount) { - throw new InvalidOperationException($"Scope limit of {TelemetryRepository.MaxScopeCount} reached for {telemetryType}. Scope '{name}' will not be added."); + throw new InvalidOperationException($"Scope limit of {TelemetryRepositoryLimits.MaxScopeCount} reached for {telemetryType}. Scope '{name}' will not be added."); } s = (scope != null) diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs index 5a97d1dce0f..b7978ca3c1f 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs @@ -64,9 +64,9 @@ public DimensionScope FindScope(RepeatedField attributes, ref KeyValue // Need to add dimensions using durable attributes instance after scope is created. if (!Dimensions.TryGetValue(comparableAttributes, out var dimension)) { - if (Dimensions.Count >= TelemetryRepository.MaxDimensionCount) + if (Dimensions.Count >= TelemetryRepositoryLimits.MaxDimensionCount) { - throw new InvalidOperationException($"Dimension limit of {TelemetryRepository.MaxDimensionCount} reached for instrument '{Summary.Name}'."); + throw new InvalidOperationException($"Dimension limit of {TelemetryRepositoryLimits.MaxDimensionCount} reached for instrument '{Summary.Name}'."); } dimension = CreateDimensionScope(comparableAttributes); @@ -88,7 +88,7 @@ private DimensionScope CreateDimensionScope(Memory> // Adds to dictionary if not present. if (values == null) { - if (!existed && KnownAttributeValues.Count > TelemetryRepository.MaxKnownAttributeValueCount) + if (!existed && KnownAttributeValues.Count > TelemetryRepositoryLimits.MaxKnownAttributeValueCount) { // Over limit. Remove the default entry that GetValueRefOrAddDefault added. KnownAttributeValues.Remove(key); @@ -100,12 +100,12 @@ private DimensionScope CreateDimensionScope(Memory> // If the key is new and there are already dimensions, add an empty value because there are dimensions without this key. if (!isFirst) { - TryAddValue(values, null, TelemetryRepository.MaxKnownAttributeValuesPerKey); + TryAddValue(values, null, TelemetryRepositoryLimits.MaxKnownAttributeValuesPerKey); } } var currentDimensionValue = OtlpHelpers.GetValue(durableAttributes, key); - TryAddValue(values, currentDimensionValue, TelemetryRepository.MaxKnownAttributeValuesPerKey); + TryAddValue(values, currentDimensionValue, TelemetryRepositoryLimits.MaxKnownAttributeValuesPerKey); } return dimension; diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs index 4f6f322ca7e..56c1d4f207d 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpLogEntry.cs @@ -82,6 +82,38 @@ public OtlpLogEntry(LogRecord record, OtlpResourceView resourceView, OtlpScope s EventName = !string.IsNullOrEmpty(record.EventName) ? record.EventName : eventNameFromAttribute; } + internal OtlpLogEntry( + long internalId, + DateTime timeStamp, + uint flags, + LogLevel severity, + int severityNumber, + string message, + string spanId, + string traceId, + string parentId, + string? originalFormat, + OtlpResourceView resourceView, + OtlpScope scope, + KeyValuePair[] attributes, + string? eventName) + { + InternalId = internalId; + TimeStamp = timeStamp; + Flags = flags; + Severity = severity; + SeverityNumber = severityNumber; + Message = message; + SpanId = spanId; + TraceId = traceId; + ParentId = parentId; + OriginalFormat = originalFormat; + ResourceView = resourceView; + Scope = scope; + Attributes = attributes; + EventName = eventName; + } + private static DateTime ResolveTimeStamp(LogRecord record) { // From proto docs: diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs index 7d873db37eb..920aa6248ad 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs @@ -48,6 +48,9 @@ public class OtlpResource : IOtlpResource private readonly Dictionary _meters = new(); private readonly Dictionary _instruments = new(); private readonly ConcurrentDictionary[], OtlpResourceView> _resourceViews = new(ResourceViewKeyComparer.Instance); + private Func? _instrumentProvider; + private Func>? _instrumentSummariesProvider; + private Func>? _resourceViewsProvider; public OtlpResource(string name, string? instanceId, bool uninstrumentedPeer, OtlpContext context) { @@ -90,7 +93,7 @@ public void AddMetrics(AddContext context, RepeatedField scopeMetr { instrument = existingInstrument; } - else if (_instruments.Count < TelemetryRepository.MaxInstrumentCount) + else if (_instruments.Count < TelemetryRepositoryLimits.MaxInstrumentCount) { var newInstrument = new OtlpInstrument { @@ -113,7 +116,7 @@ public void AddMetrics(AddContext context, RepeatedField scopeMetr } else { - throw new InvalidOperationException($"Instrument limit of {TelemetryRepository.MaxInstrumentCount} reached. Instrument '{metric.Name}' will not be added."); + throw new InvalidOperationException($"Instrument limit of {TelemetryRepositoryLimits.MaxInstrumentCount} reached. Instrument '{metric.Name}' will not be added."); } } catch (Exception ex) @@ -246,6 +249,11 @@ private static OtlpAggregationTemporality MapAggregationTemporality(Metric metri public OtlpInstrument? GetInstrument(string meterName, string instrumentName, DateTime? valuesStart, DateTime? valuesEnd) { + if (_instrumentProvider is not null) + { + return _instrumentProvider(meterName, instrumentName, valuesStart, valuesEnd); + } + _metricsLock.EnterReadLock(); try @@ -265,6 +273,11 @@ private static OtlpAggregationTemporality MapAggregationTemporality(Metric metri public List GetInstrumentsSummary() { + if (_instrumentSummariesProvider is not null) + { + return _instrumentSummariesProvider(); + } + _metricsLock.EnterReadLock(); try @@ -292,7 +305,17 @@ public static Dictionary> GetReplicasByResourceName(I public static string GetResourceName(OtlpResourceView resource, IReadOnlyList allResources) => OtlpHelpers.GetResourceName(resource.Resource, allResources); - internal List GetViews() => _resourceViews.Values.ToList(); + internal List GetViews() => _resourceViewsProvider?.Invoke() ?? _resourceViews.Values.ToList(); + + internal void ConfigureDataProviders( + Func instrumentProvider, + Func> instrumentSummariesProvider, + Func> resourceViewsProvider) + { + _instrumentProvider = instrumentProvider; + _instrumentSummariesProvider = instrumentSummariesProvider; + _resourceViewsProvider = resourceViewsProvider; + } internal OtlpResourceView GetView(RepeatedField attributes) { @@ -304,9 +327,9 @@ internal OtlpResourceView GetView(RepeatedField attributes) return resourceView; } - if (_resourceViews.Count >= TelemetryRepository.MaxResourceViewCount) + if (_resourceViews.Count >= TelemetryRepositoryLimits.MaxResourceViewCount) { - throw new InvalidOperationException($"Resource view limit of {TelemetryRepository.MaxResourceViewCount} reached."); + throw new InvalidOperationException($"Resource view limit of {TelemetryRepositoryLimits.MaxResourceViewCount} reached."); } return _resourceViews.GetOrAdd(view.Properties, view); @@ -372,3 +395,20 @@ public int GetHashCode([DisallowNull] KeyValuePair[] obj) } } } + +/// +/// Compares resources by their resource key. +/// +internal sealed class OtlpResourceEqualityComparer : IEqualityComparer +{ + public static readonly OtlpResourceEqualityComparer Instance = new(); + + private OtlpResourceEqualityComparer() + { + } + + public bool Equals(OtlpResource? x, OtlpResource? y) => + ReferenceEquals(x, y) || x is not null && y is not null && x.ResourceKey == y.ResourceKey; + + public int GetHashCode(OtlpResource obj) => obj.ResourceKey.GetHashCode(); +} diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpResourceView.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpResourceView.cs index 13d53df648b..5dfa48d4efe 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpResourceView.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpResourceView.cs @@ -39,6 +39,12 @@ public OtlpResourceView(OtlpResource resource, RepeatedField attribute Properties = properties; } + internal OtlpResourceView(OtlpResource resource, KeyValuePair[] properties) + { + Resource = resource; + Properties = properties; + } + public List AllProperties() { var props = new List diff --git a/src/Aspire.Dashboard/Otlp/OtlpLogsService.cs b/src/Aspire.Dashboard/Otlp/OtlpLogsService.cs index d04cc5cde19..80416549dcc 100644 --- a/src/Aspire.Dashboard/Otlp/OtlpLogsService.cs +++ b/src/Aspire.Dashboard/Otlp/OtlpLogsService.cs @@ -12,9 +12,9 @@ namespace Aspire.Dashboard.Otlp; public sealed class OtlpLogsService { private readonly ILogger _logger; - private readonly TelemetryRepository _telemetryRepository; + private readonly ITelemetryRepository _telemetryRepository; - public OtlpLogsService(ILogger logger, TelemetryRepository telemetryRepository) + public OtlpLogsService(ILogger logger, ITelemetryRepository telemetryRepository) { _logger = logger; _telemetryRepository = telemetryRepository; diff --git a/src/Aspire.Dashboard/Otlp/OtlpMetricsService.cs b/src/Aspire.Dashboard/Otlp/OtlpMetricsService.cs index 624c13221de..1247305eeaa 100644 --- a/src/Aspire.Dashboard/Otlp/OtlpMetricsService.cs +++ b/src/Aspire.Dashboard/Otlp/OtlpMetricsService.cs @@ -12,9 +12,9 @@ namespace Aspire.Dashboard.Otlp; public sealed class OtlpMetricsService { private readonly ILogger _logger; - private readonly TelemetryRepository _telemetryRepository; + private readonly ITelemetryRepository _telemetryRepository; - public OtlpMetricsService(ILogger logger, TelemetryRepository telemetryRepository) + public OtlpMetricsService(ILogger logger, ITelemetryRepository telemetryRepository) { _logger = logger; _telemetryRepository = telemetryRepository; diff --git a/src/Aspire.Dashboard/Otlp/OtlpTraceService.cs b/src/Aspire.Dashboard/Otlp/OtlpTraceService.cs index 2ef19b9da44..1f2675860b9 100644 --- a/src/Aspire.Dashboard/Otlp/OtlpTraceService.cs +++ b/src/Aspire.Dashboard/Otlp/OtlpTraceService.cs @@ -12,9 +12,9 @@ namespace Aspire.Dashboard.Otlp; public sealed class OtlpTraceService { private readonly ILogger _logger; - private readonly TelemetryRepository _telemetryRepository; + private readonly ITelemetryRepository _telemetryRepository; - public OtlpTraceService(ILogger logger, TelemetryRepository telemetryRepository) + public OtlpTraceService(ILogger logger, ITelemetryRepository telemetryRepository) { _logger = logger; _telemetryRepository = telemetryRepository; diff --git a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs new file mode 100644 index 00000000000..1c03135b621 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs @@ -0,0 +1,65 @@ +// 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 Google.Protobuf.Collections; +using Microsoft.FluentUI.AspNetCore.Components; +using OpenTelemetry.Proto.Logs.V1; +using OpenTelemetry.Proto.Metrics.V1; +using OpenTelemetry.Proto.Trace.V1; + +namespace Aspire.Dashboard.Otlp.Storage; + +/// +/// Provides storage and queries for dashboard telemetry. +/// +public interface ITelemetryRepository : IDisposable +{ + bool HasDisplayedMaxLogLimitMessage { get; set; } + Message? MaxLogLimitMessage { get; set; } + bool HasDisplayedMaxTraceLimitMessage { get; set; } + Message? MaxTraceLimitMessage { get; set; } + + List GetResources(bool includeUninstrumentedPeers = false); + List GetResourcesByName(string name, bool includeUninstrumentedPeers = false); + OtlpResource? GetResourceByCompositeName(string compositeName); + OtlpResource? GetResource(ResourceKey key); + List GetResources(ResourceKey key, bool includeUninstrumentedPeers = false); + Dictionary GetResourceUnviewedErrorLogsCount(); + void MarkViewedErrorLogs(ResourceKey? key); + + Subscription OnNewResources(Func callback); + Subscription OnNewLogs(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback); + Subscription OnNewMetrics(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback); + Subscription OnNewTraces(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback); + + void AddLogs(AddContext context, RepeatedField resourceLogs); + void AddMetrics(AddContext context, RepeatedField resourceMetrics); + void AddTraces(AddContext context, RepeatedField resourceSpans); + + PagedResult GetLogs(GetLogsContext context); + OtlpLogEntry? GetLog(long logId); + List GetLogsForSpan(string traceId, string spanId); + List GetLogsForTrace(string traceId); + List GetLogPropertyKeys(ResourceKey? resourceKey); + List GetTracePropertyKeys(ResourceKey? resourceKey); + GetTracesResponse GetTraces(GetTracesRequest context); + GetSpansResponse GetSpans(GetSpansRequest context); + Dictionary GetTraceFieldValues(string attributeName); + Dictionary GetLogsFieldValues(string attributeName); + bool HasUpdatedTrace(OtlpTrace trace); + OtlpTrace? GetTrace(string traceId); + OtlpSpan? GetSpan(string traceId, string spanId); + OtlpResource? GetPeerResource(OtlpSpan span); + List GetInstrumentsSummaries(ResourceKey key); + OtlpInstrumentData? GetInstrument(GetInstrumentRequest request); + + IAsyncEnumerable WatchSpansAsync(WatchSpansRequest request, CancellationToken cancellationToken); + IAsyncEnumerable WatchLogsAsync(WatchLogsRequest request, CancellationToken cancellationToken); + + void ClearSelectedSignals(Dictionary> selectedResources); + void ClearTraces(ResourceKey? resourceKey = null); + void ClearStructuredLogs(ResourceKey? resourceKey = null); + void ClearMetrics(ResourceKey? resourceKey = null); +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs new file mode 100644 index 00000000000..79edf05afd1 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs @@ -0,0 +1,940 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data; +using System.Globalization; +using System.Text; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.Otlp; +using Aspire.Dashboard.Otlp.Model; +using Dapper; +using Google.Protobuf.Collections; +using Microsoft.Data.Sqlite; +using OpenTelemetry.Proto.Common.V1; +using OpenTelemetry.Proto.Logs.V1; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed partial class SqliteTelemetryRepository +{ + private List AddLogsToDatabase(AddContext context, RepeatedField resourceLogs) + { + var addedLogs = new List(); + lock (_writeLock) + { + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + + foreach (var resourceLogsItem in resourceLogs) + { + OtlpResourceView resourceView; + long resourceId; + long resourceViewId; + try + { + var resourceKey = resourceLogsItem.Resource.GetResourceKey(); + resourceId = GetOrAddTelemetryResource(connection, transaction, resourceKey); + var resource = new OtlpResource(resourceKey.Name, resourceKey.InstanceId, uninstrumentedPeer: false, _otlpContext) + { + HasLogs = true + }; + resourceView = new OtlpResourceView(resource, resourceLogsItem.Resource.Attributes); + resourceViewId = InsertResourceView(connection, transaction, resourceId, resourceView.Properties); + } + catch (Exception exception) + { + context.FailureCount += resourceLogsItem.ScopeLogs.Sum(scope => scope.LogRecords.Count); + _otlpContext.Logger.LogInformation(exception, "Error adding resource."); + continue; + } + + foreach (var scopeLogs in resourceLogsItem.ScopeLogs) + { + OtlpScope scope; + long scopeId; + try + { + (scopeId, scope) = GetOrAddScope(connection, transaction, scopeLogs.Scope); + } + catch (Exception exception) + { + context.FailureCount += scopeLogs.LogRecords.Count; + _otlpContext.Logger.LogInformation(exception, "Error adding log scope."); + continue; + } + + foreach (var record in scopeLogs.LogRecords) + { + try + { + var log = new OtlpLogEntry(record, resourceView, scope, _otlpContext); + var logId = connection.QuerySingle(""" + INSERT INTO telemetry_logs ( + resource_id, resource_view_id, scope_id, timestamp_ticks, flags, severity, + severity_name, severity_number, message, span_id, trace_id, parent_id, + original_format, event_name) + VALUES ( + @ResourceId, @ResourceViewId, @ScopeId, @TimestampTicks, @Flags, @Severity, + @SeverityName, @SeverityNumber, @Message, @SpanId, @TraceId, @ParentId, + @OriginalFormat, @EventName) + RETURNING log_id; + """, new + { + ResourceId = resourceId, + ResourceViewId = resourceViewId, + ScopeId = scopeId, + TimestampTicks = log.TimeStamp.Ticks, + Flags = (long)log.Flags, + Severity = (int)log.Severity, + SeverityName = log.Severity.ToString(), + log.SeverityNumber, + log.Message, + log.SpanId, + log.TraceId, + log.ParentId, + log.OriginalFormat, + log.EventName + }, transaction); + + connection.Execute(""" + INSERT INTO telemetry_log_attributes (log_id, ordinal, attribute_key, attribute_value) + VALUES (@LogId, @Ordinal, @Key, @Value); + """, log.Attributes.Select((attribute, ordinal) => new + { + LogId = logId, + Ordinal = ordinal, + attribute.Key, + attribute.Value + }), transaction); + addedLogs.Add(new OtlpLogEntry( + logId, + log.TimeStamp, + log.Flags, + log.Severity, + log.SeverityNumber, + log.Message, + log.SpanId, + log.TraceId, + log.ParentId, + log.OriginalFormat, + resourceView, + scope, + log.Attributes, + log.EventName)); + context.SuccessCount++; + } + catch (Exception exception) + { + context.FailureCount++; + _otlpContext.Logger.LogInformation(exception, "Error adding log entry."); + } + } + } + + connection.Execute( + "UPDATE telemetry_resources SET has_logs = 1 WHERE resource_id = @ResourceId;", + new { ResourceId = resourceId }, + transaction); + } + + TrimLogsToCapacity(connection, transaction); + transaction.Commit(); + } + + return addedLogs; + } + + private long GetOrAddTelemetryResource(SqliteConnection connection, IDbTransaction transaction, ResourceKey resourceKey) + { + var instanceIdIsNull = resourceKey.InstanceId is null; + var instanceId = resourceKey.InstanceId ?? string.Empty; + var resourceId = connection.QuerySingleOrDefault(""" + SELECT resource_id + FROM telemetry_resources + WHERE resource_name = @ResourceName + AND instance_id_is_null = @InstanceIdIsNull + AND instance_id = @InstanceId; + """, new { ResourceName = resourceKey.Name, InstanceIdIsNull = instanceIdIsNull, InstanceId = instanceId }, transaction); + if (resourceId is not null) + { + return resourceId.Value; + } + + var resourceCount = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_resources;", transaction: transaction); + if (resourceCount >= _otlpContext.Options.MaxResourceCount) + { + throw new InvalidOperationException($"Resource limit of {_otlpContext.Options.MaxResourceCount} reached. Resource '{resourceKey}' will not be added."); + } + + return connection.QuerySingle(""" + INSERT INTO telemetry_resources (resource_name, instance_id, instance_id_is_null) + VALUES (@ResourceName, @InstanceId, @InstanceIdIsNull) + RETURNING resource_id; + """, new { ResourceName = resourceKey.Name, InstanceId = instanceId, InstanceIdIsNull = instanceIdIsNull }, transaction); + } + + private static long InsertResourceView( + SqliteConnection connection, + IDbTransaction transaction, + long resourceId, + KeyValuePair[] properties) + { + var resourceViewId = connection.QuerySingle(""" + INSERT INTO telemetry_resource_views (resource_id) + VALUES (@ResourceId) + RETURNING resource_view_id; + """, new { ResourceId = resourceId }, transaction); + connection.Execute(""" + INSERT INTO telemetry_resource_view_attributes (resource_view_id, ordinal, attribute_key, attribute_value) + VALUES (@ResourceViewId, @Ordinal, @Key, @Value); + """, properties.Select((property, ordinal) => new + { + ResourceViewId = resourceViewId, + Ordinal = ordinal, + property.Key, + property.Value + }), transaction); + return resourceViewId; + } + + private (long ScopeId, OtlpScope Scope) GetOrAddScope( + SqliteConnection connection, + IDbTransaction transaction, + InstrumentationScope? instrumentationScope) + { + var incomingScope = instrumentationScope is null + ? OtlpScope.Empty + : new OtlpScope( + instrumentationScope.Name, + instrumentationScope.Version, + instrumentationScope.Attributes.ToKeyValuePairs(_otlpContext)); + var existing = connection.QuerySingleOrDefault(""" + SELECT scope_id AS ScopeId, scope_name AS ScopeName, scope_version AS ScopeVersion + FROM telemetry_scopes + WHERE scope_name = @ScopeName; + """, new { ScopeName = incomingScope.Name }, transaction); + if (existing is not null) + { + var attributes = connection.Query(""" + SELECT attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_scope_attributes + WHERE scope_id = @ScopeId + ORDER BY ordinal; + """, new { existing.ScopeId }, transaction) + .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)) + .ToArray(); + return (existing.ScopeId, new OtlpScope(existing.ScopeName, existing.ScopeVersion, attributes)); + } + + var scopeCount = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_scopes;", transaction: transaction); + if (scopeCount >= TelemetryRepositoryLimits.MaxScopeCount) + { + throw new InvalidOperationException($"Scope limit of {TelemetryRepositoryLimits.MaxScopeCount} reached. Scope '{incomingScope.Name}' will not be added."); + } + + var scopeId = connection.QuerySingle(""" + INSERT INTO telemetry_scopes (scope_name, scope_version) + VALUES (@ScopeName, @ScopeVersion) + RETURNING scope_id; + """, new { ScopeName = incomingScope.Name, ScopeVersion = incomingScope.Version }, transaction); + connection.Execute(""" + INSERT INTO telemetry_scope_attributes (scope_id, ordinal, attribute_key, attribute_value) + VALUES (@ScopeId, @Ordinal, @Key, @Value); + """, incomingScope.Attributes.Select((attribute, ordinal) => new + { + ScopeId = scopeId, + Ordinal = ordinal, + attribute.Key, + attribute.Value + }), transaction); + return (scopeId, incomingScope); + } + + private void TrimLogsToCapacity(SqliteConnection connection, IDbTransaction transaction) + { + connection.Execute(""" + DELETE FROM telemetry_logs + WHERE log_id IN ( + SELECT log_id + FROM telemetry_logs + ORDER BY timestamp_ticks, log_id + LIMIT MAX((SELECT COUNT(*) FROM telemetry_logs) - @MaxLogCount, 0) + ); + + DELETE FROM telemetry_resource_views + WHERE NOT EXISTS ( + SELECT 1 FROM telemetry_logs WHERE telemetry_logs.resource_view_id = telemetry_resource_views.resource_view_id + ) + AND NOT EXISTS ( + SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_view_id = telemetry_resource_views.resource_view_id + ); + """, new { _otlpContext.Options.MaxLogCount }, transaction); + } + + private PagedResult GetLogsFromDatabase(GetLogsContext context) + { + using var connection = _database.OpenConnection(); + var query = BuildLogQuery(context); + var totalCount = connection.QuerySingle($"SELECT COUNT(*) {query.FromAndWhere}", query.Parameters); + if (totalCount == 0) + { + return PagedResult.Empty; + } + + query.Parameters.Add("StartIndex", context.StartIndex); + query.Parameters.Add("Count", context.Count); + var records = connection.Query( + $""" + SELECT + l.log_id AS LogId, + l.resource_id AS ResourceId, + l.resource_view_id AS ResourceViewId, + l.scope_id AS ScopeId, + l.timestamp_ticks AS TimestampTicks, + l.flags AS Flags, + l.severity AS Severity, + l.severity_number AS SeverityNumber, + l.message AS Message, + l.span_id AS SpanId, + l.trace_id AS TraceId, + l.parent_id AS ParentId, + l.original_format AS OriginalFormat, + l.event_name AS EventName, + r.resource_name AS ResourceName, + r.instance_id AS InstanceId, + r.instance_id_is_null AS InstanceIdIsNull, + r.uninstrumented_peer AS UninstrumentedPeer, + r.has_logs AS HasLogs, + r.has_traces AS HasTraces, + r.has_metrics AS HasMetrics, + s.scope_name AS ScopeName, + s.scope_version AS ScopeVersion + {query.FromAndWhere} + ORDER BY l.timestamp_ticks, l.log_id DESC + LIMIT @Count OFFSET @StartIndex; + """, + query.Parameters).AsList(); + + return new PagedResult + { + TotalItemCount = totalCount, + Items = MaterializeLogs(connection, records), + IsFull = totalCount >= _otlpContext.Options.MaxLogCount + }; + } + + private OtlpLogEntry? GetLogFromDatabase(long logId) + { + using var connection = _database.OpenConnection(); + var records = connection.Query(""" + SELECT + l.log_id AS LogId, + l.resource_id AS ResourceId, + l.resource_view_id AS ResourceViewId, + l.scope_id AS ScopeId, + l.timestamp_ticks AS TimestampTicks, + l.flags AS Flags, + l.severity AS Severity, + l.severity_number AS SeverityNumber, + l.message AS Message, + l.span_id AS SpanId, + l.trace_id AS TraceId, + l.parent_id AS ParentId, + l.original_format AS OriginalFormat, + l.event_name AS EventName, + r.resource_name AS ResourceName, + r.instance_id AS InstanceId, + r.instance_id_is_null AS InstanceIdIsNull, + r.uninstrumented_peer AS UninstrumentedPeer, + r.has_logs AS HasLogs, + r.has_traces AS HasTraces, + r.has_metrics AS HasMetrics, + s.scope_name AS ScopeName, + s.scope_version AS ScopeVersion + FROM telemetry_logs l + JOIN telemetry_resources r ON r.resource_id = l.resource_id + JOIN telemetry_scopes s ON s.scope_id = l.scope_id + WHERE l.log_id = @LogId; + """, new { LogId = logId }).AsList(); + return records.Count == 0 ? null : MaterializeLogs(connection, records)[0]; + } + + private List GetLogsForSpanFromDatabase(string traceId, string spanId) + { + return GetLogsFromDatabase(new GetLogsContext + { + ResourceKeys = [], + StartIndex = 0, + Count = int.MaxValue, + Filters = + [ + new FieldTelemetryFilter { Field = KnownStructuredLogFields.TraceIdField, Condition = FilterCondition.Equals, Value = traceId }, + new FieldTelemetryFilter { Field = KnownStructuredLogFields.SpanIdField, Condition = FilterCondition.Equals, Value = spanId } + ] + }).Items; + } + + private List GetLogsForTraceFromDatabase(string traceId) + { + return GetLogsFromDatabase(new GetLogsContext + { + ResourceKeys = [], + StartIndex = 0, + Count = int.MaxValue, + Filters = + [ + new FieldTelemetryFilter { Field = KnownStructuredLogFields.TraceIdField, Condition = FilterCondition.Equals, Value = traceId } + ] + }).Items; + } + + private List GetLogPropertyKeysFromDatabase(ResourceKey? resourceKey) + { + using var connection = _database.OpenConnection(); + var sql = new StringBuilder(""" + SELECT DISTINCT a.attribute_key + FROM telemetry_log_attributes a + JOIN telemetry_logs l ON l.log_id = a.log_id + JOIN telemetry_resources r ON r.resource_id = l.resource_id + """); + var parameters = new DynamicParameters(); + if (resourceKey is not null) + { + sql.Append(" WHERE r.resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE"); + parameters.Add("ResourceName", resourceKey.Value.Name); + if (resourceKey.Value.InstanceId is not null) + { + sql.Append(" AND r.instance_id_is_null = 0 AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"); + parameters.Add("InstanceId", resourceKey.Value.InstanceId); + } + } + sql.Append(" ORDER BY a.attribute_key;"); + return connection.Query(sql.ToString(), parameters).AsList(); + } + + private Dictionary GetLogsFieldValuesFromDatabase(string attributeName) + { + using var connection = _database.OpenConnection(); + var parameters = new DynamicParameters(); + var expression = GetLogFieldExpression(attributeName, parameters, "FieldValue"); + return connection.Query($""" + SELECT {expression} AS FieldValue, COUNT(*) AS ValueCount + FROM telemetry_logs l + JOIN telemetry_resources r ON r.resource_id = l.resource_id + JOIN telemetry_scopes s ON s.scope_id = l.scope_id + GROUP BY {expression}; + """, parameters) + .Where(record => record.FieldValue is not null) + .ToDictionary(record => record.FieldValue!, record => record.ValueCount, StringComparers.OtlpAttribute); + } + + private static LogQuery BuildLogQuery(GetLogsContext context) + { + var parameters = new DynamicParameters(); + var predicates = new List(); + if (context.ResourceKeys.Count > 0) + { + var resourcePredicates = new List(); + for (var i = 0; i < context.ResourceKeys.Count; i++) + { + var key = context.ResourceKeys[i]; + var predicate = $"r.resource_name = @ResourceName{i} COLLATE ORDINAL_IGNORE_CASE"; + parameters.Add($"ResourceName{i}", key.Name); + if (key.InstanceId is not null) + { + predicate += $" AND r.instance_id_is_null = 0 AND r.instance_id = @InstanceId{i} COLLATE ORDINAL_IGNORE_CASE"; + parameters.Add($"InstanceId{i}", key.InstanceId); + } + resourcePredicates.Add($"({predicate})"); + } + predicates.Add($"({string.Join(" OR ", resourcePredicates)})"); + } + + var filterIndex = 0; + foreach (var filter in context.Filters.Where(filter => filter.Enabled)) + { + if (filter is not FieldTelemetryFilter fieldFilter) + { + throw new NotSupportedException($"Unsupported log filter type '{filter.GetType().Name}'."); + } + predicates.Add(BuildLogFilterPredicate(fieldFilter, parameters, filterIndex++)); + } + + if (context.TextFragments is { Length: > 0 }) + { + for (var i = 0; i < context.TextFragments.Length; i++) + { + var parameterName = $"TextFragment{i}"; + parameters.Add(parameterName, context.TextFragments[i]); + predicates.Add($$""" + ( + ordinal_contains(l.message, @{{parameterName}}) + OR ordinal_contains(s.scope_name, @{{parameterName}}) + OR ordinal_contains(l.trace_id, @{{parameterName}}) + OR ordinal_contains(l.span_id, @{{parameterName}}) + OR ordinal_contains(l.severity_name, @{{parameterName}}) + OR ordinal_contains(r.resource_name, @{{parameterName}}) + OR ordinal_contains(COALESCE(l.event_name, ''), @{{parameterName}}) + OR EXISTS ( + SELECT 1 + FROM telemetry_log_attributes text_attribute + WHERE text_attribute.log_id = l.log_id + AND ( + ordinal_contains(text_attribute.attribute_key, @{{parameterName}}) + OR ordinal_contains(text_attribute.attribute_value, @{{parameterName}}) + ) + ) + ) + """); + } + } + + var where = predicates.Count == 0 ? string.Empty : $" WHERE {string.Join(" AND ", predicates)}"; + return new LogQuery($""" + FROM telemetry_logs l + JOIN telemetry_resources r ON r.resource_id = l.resource_id + JOIN telemetry_scopes s ON s.scope_id = l.scope_id + {where} + """, parameters); + } + + private static string BuildLogFilterPredicate(FieldTelemetryFilter filter, DynamicParameters parameters, int index) + { + var parameterName = $"FilterValue{index}"; + if (filter.Field == nameof(OtlpLogEntry.Severity)) + { + if (!Enum.TryParse(filter.Value, ignoreCase: true, out var severity)) + { + return "1 = 1"; + } + parameters.Add(parameterName, (int)severity); + return BuildNumericPredicate("l.severity", filter.Condition, parameterName); + } + + if (filter.Field == nameof(OtlpLogEntry.TimeStamp)) + { + var timestamp = DateTime.Parse(filter.Value, CultureInfo.InvariantCulture); + parameters.Add(parameterName, timestamp.Ticks); + return BuildNumericPredicate("l.timestamp_ticks", filter.Condition, parameterName); + } + + if (filter.Field == KnownStructuredLogFields.TimestampField) + { + if (!DateTime.TryParse(filter.Value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeLocal, out var timestamp)) + { + return "0 = 1"; + } + parameters.Add(parameterName, timestamp.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond); + return BuildNumericPredicate($"l.timestamp_ticks / {TimeSpan.TicksPerMillisecond}", filter.Condition, parameterName); + } + + var expression = GetLogFieldExpression(filter.Field, parameters, $"AttributeName{index}"); + parameters.Add(parameterName, filter.Value); + return BuildStringPredicate(expression, filter.Condition, parameterName); + } + + private static string GetLogFieldExpression(string field, DynamicParameters parameters, string attributeParameterName) + { + return field switch + { + nameof(OtlpLogEntry.Message) or KnownStructuredLogFields.MessageField => "l.message", + KnownStructuredLogFields.TraceIdField => "l.trace_id", + KnownStructuredLogFields.SpanIdField => "l.span_id", + KnownStructuredLogFields.OriginalFormatField => "COALESCE(l.original_format, '')", + KnownStructuredLogFields.CategoryField => "s.scope_name", + KnownStructuredLogFields.EventNameField => "COALESCE(l.event_name, '')", + KnownStructuredLogFields.LevelField => "l.severity_name", + KnownStructuredLogFields.TimestampField => $"CAST(l.timestamp_ticks / {TimeSpan.TicksPerMillisecond} AS TEXT)", + KnownResourceFields.ServiceNameField => "r.resource_name", + _ => GetAttributeExpression(field, parameters, attributeParameterName) + }; + + static string GetAttributeExpression(string field, DynamicParameters parameters, string parameterName) + { + parameters.Add(parameterName, field); + return $""" + COALESCE(( + SELECT attribute.attribute_value + FROM telemetry_log_attributes attribute + WHERE attribute.log_id = l.log_id + AND attribute.attribute_key = @{parameterName} + LIMIT 1 + ), '') + """; + } + } + + private static string BuildStringPredicate(string expression, FilterCondition condition, string parameterName) + { + return condition switch + { + FilterCondition.Equals => $"{expression} = @{parameterName} COLLATE ORDINAL_IGNORE_CASE", + FilterCondition.Contains => $"ordinal_contains({expression}, @{parameterName})", + FilterCondition.GreaterThan or FilterCondition.LessThan or FilterCondition.GreaterThanOrEqual or FilterCondition.LessThanOrEqual => "0 = 1", + FilterCondition.NotEqual => $"{expression} <> @{parameterName} COLLATE ORDINAL_IGNORE_CASE", + FilterCondition.NotContains => $"NOT ordinal_contains({expression}, @{parameterName})", + _ => throw new ArgumentOutOfRangeException(nameof(condition), condition, null) + }; + } + + private static string BuildNumericPredicate(string expression, FilterCondition condition, string parameterName) + { + var operation = condition switch + { + FilterCondition.Equals => "=", + FilterCondition.GreaterThan => ">", + FilterCondition.LessThan => "<", + FilterCondition.GreaterThanOrEqual => ">=", + FilterCondition.LessThanOrEqual => "<=", + FilterCondition.NotEqual => "<>", + FilterCondition.Contains or FilterCondition.NotContains => null, + _ => throw new ArgumentOutOfRangeException(nameof(condition), condition, null) + }; + return operation is null ? "0 = 1" : $"{expression} {operation} @{parameterName}"; + } + + private List MaterializeLogs(SqliteConnection connection, List records) + { + if (records.Count == 0) + { + return []; + } + + var logIds = records.Select(record => record.LogId).Distinct().ToArray(); + var viewIds = records.Select(record => record.ResourceViewId).Distinct().ToArray(); + var scopeIds = records.Select(record => record.ScopeId).Distinct().ToArray(); + var logAttributes = connection.Query(""" + SELECT log_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_log_attributes + WHERE log_id IN @Ids + ORDER BY log_id, ordinal; + """, new { Ids = logIds }).ToLookup(record => record.OwnerId); + var viewAttributes = connection.Query(""" + SELECT resource_view_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_resource_view_attributes + WHERE resource_view_id IN @Ids + ORDER BY resource_view_id, ordinal; + """, new { Ids = viewIds }).ToLookup(record => record.OwnerId); + var scopeAttributes = connection.Query(""" + SELECT scope_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_scope_attributes + WHERE scope_id IN @Ids + ORDER BY scope_id, ordinal; + """, new { Ids = scopeIds }).ToLookup(record => record.OwnerId); + + var resources = new Dictionary(); + var views = new Dictionary(); + var scopes = new Dictionary(); + var results = new List(records.Count); + foreach (var record in records) + { + if (!resources.TryGetValue(record.ResourceId, out var resource)) + { + resource = new OtlpResource( + record.ResourceName, + record.InstanceIdIsNull ? null : record.InstanceId, + record.UninstrumentedPeer, + _otlpContext) + { + HasLogs = record.HasLogs, + HasTraces = record.HasTraces, + HasMetrics = record.HasMetrics + }; + resources.Add(record.ResourceId, resource); + } + if (!views.TryGetValue(record.ResourceViewId, out var view)) + { + view = new OtlpResourceView(resource, ToPairs(viewAttributes[record.ResourceViewId])); + views.Add(record.ResourceViewId, view); + } + if (!scopes.TryGetValue(record.ScopeId, out var scope)) + { + var attributes = ToPairs(scopeAttributes[record.ScopeId]); + scope = record.ScopeName == OtlpScope.Empty.Name && record.ScopeVersion.Length == 0 && attributes.Length == 0 + ? OtlpScope.Empty + : new OtlpScope(record.ScopeName, record.ScopeVersion, attributes); + scopes.Add(record.ScopeId, scope); + } + + results.Add(new OtlpLogEntry( + record.LogId, + new DateTime(record.TimestampTicks, DateTimeKind.Utc), + checked((uint)record.Flags), + (LogLevel)record.Severity, + record.SeverityNumber, + record.Message, + record.SpanId, + record.TraceId, + record.ParentId, + record.OriginalFormat, + view, + scope, + ToPairs(logAttributes[record.LogId]), + record.EventName)); + } + + return results; + + static KeyValuePair[] ToPairs(IEnumerable attributes) + { + return attributes.Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray(); + } + } + + private void ClearSelectedLogsFromDatabase(Dictionary> selectedResources) + { + EnsureWritable(); + using var connection = _database.OpenConnection(); + var resources = connection.Query(""" + SELECT resource_name AS ResourceName, instance_id AS InstanceId, instance_id_is_null AS InstanceIdIsNull + FROM telemetry_resources; + """); + foreach (var resource in resources) + { + var key = new ResourceKey(resource.ResourceName, resource.InstanceIdIsNull ? null : resource.InstanceId); + if (!selectedResources.TryGetValue(key.GetCompositeName(), out var dataTypes)) + { + continue; + } + + if (dataTypes.Contains(AspireDataType.Resource)) + { + DeleteTelemetryResourceFromDatabase(key); + } + else if (dataTypes.Contains(AspireDataType.StructuredLogs)) + { + ClearStructuredLogsFromDatabase(key); + } + } + } + + private void DeleteTelemetryResourceFromDatabase(ResourceKey resourceKey) + { + lock (_writeLock) + { + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + var sql = new StringBuilder(""" + DELETE FROM telemetry_resources + WHERE resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE + """); + var parameters = new DynamicParameters(); + parameters.Add("ResourceName", resourceKey.Name); + if (resourceKey.InstanceId is not null) + { + sql.Append(" AND instance_id_is_null = 0 AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"); + parameters.Add("InstanceId", resourceKey.InstanceId); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + connection.Execute(""" + DELETE FROM telemetry_traces + WHERE NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.trace_id = telemetry_traces.trace_id); + """, transaction: transaction); + DeleteOrphanedScopes(connection, transaction); + transaction.Commit(); + } + + _resourceCache.TryRemove(resourceKey, out _); + } + + private void ClearStructuredLogsFromDatabase(ResourceKey? resourceKey) + { + lock (_writeLock) + { + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + var parameters = new DynamicParameters(); + var where = string.Empty; + if (resourceKey is not null) + { + where = " WHERE resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE"; + parameters.Add("ResourceName", resourceKey.Value.Name); + if (resourceKey.Value.InstanceId is not null) + { + where += " AND instance_id_is_null = 0 AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; + parameters.Add("InstanceId", resourceKey.Value.InstanceId); + } + } + + connection.Execute($""" + DELETE FROM telemetry_logs + WHERE resource_id IN (SELECT resource_id FROM telemetry_resources{where}); + + DELETE FROM telemetry_resource_views + WHERE NOT EXISTS ( + SELECT 1 FROM telemetry_logs WHERE telemetry_logs.resource_view_id = telemetry_resource_views.resource_view_id + ) + AND NOT EXISTS ( + SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_view_id = telemetry_resource_views.resource_view_id + ); + + UPDATE telemetry_resources + SET has_logs = EXISTS (SELECT 1 FROM telemetry_logs WHERE telemetry_logs.resource_id = telemetry_resources.resource_id); + """, parameters, transaction); + DeleteOrphanedScopes(connection, transaction); + transaction.Commit(); + } + } + + private static void DeleteOrphanedScopes(SqliteConnection connection, IDbTransaction transaction) + { + connection.Execute(""" + DELETE FROM telemetry_scopes + WHERE NOT EXISTS (SELECT 1 FROM telemetry_logs WHERE telemetry_logs.scope_id = telemetry_scopes.scope_id) + AND NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.scope_id = telemetry_scopes.scope_id) + AND NOT EXISTS (SELECT 1 FROM telemetry_metric_instruments WHERE telemetry_metric_instruments.scope_id = telemetry_scopes.scope_id); + """, transaction: transaction); + } + + private List GetTelemetryResources(bool includeUninstrumentedPeers, string? name) + { + var resources = new Dictionary(); + using var connection = _database.OpenConnection(); + foreach (var record in connection.Query(""" + SELECT + resource_name AS ResourceName, + instance_id AS InstanceId, + instance_id_is_null AS InstanceIdIsNull, + uninstrumented_peer AS UninstrumentedPeer, + has_logs AS HasLogs, + has_traces AS HasTraces, + has_metrics AS HasMetrics + FROM telemetry_resources; + """)) + { + var key = new ResourceKey(record.ResourceName, record.InstanceIdIsNull ? null : record.InstanceId); + var resource = _resourceCache.GetOrAdd(key, resourceKey => + { + var newResource = new OtlpResource(resourceKey.Name, resourceKey.InstanceId, record.UninstrumentedPeer, _otlpContext); + newResource.ConfigureDataProviders( + (meterName, instrumentName, startTime, endTime) => GetResourceInstrumentFromDatabase(resourceKey, meterName, instrumentName, startTime, endTime), + () => GetInstrumentsSummariesFromDatabase(resourceKey), + () => GetResourceViewsFromDatabase(resourceKey, newResource)); + return newResource; + }); + resource.SetUninstrumentedPeer(record.UninstrumentedPeer); + resource.HasLogs = record.HasLogs; + resource.HasTraces = record.HasTraces; + resource.HasMetrics = record.HasMetrics; + resources.Add(key, resource); + } + + return resources.Values + .Where(resource => includeUninstrumentedPeers || !resource.UninstrumentedPeer) + .Where(resource => name is null || string.Equals(resource.ResourceName, name, StringComparisons.ResourceName)) + .OrderBy(resource => resource.ResourceKey) + .ToList(); + } + + private List GetResourceViewsFromDatabase(ResourceKey resourceKey, OtlpResource resource) + { + using var connection = _database.OpenConnection(); + var viewIds = connection.Query(""" + SELECT v.resource_view_id + FROM telemetry_resource_views v + JOIN telemetry_resources r ON r.resource_id = v.resource_id + WHERE r.resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE + AND (@InstanceId IS NULL OR (r.instance_id_is_null = 0 AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE)) + ORDER BY v.resource_view_id; + """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId }).AsList(); + var attributes = connection.Query(""" + SELECT resource_view_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_resource_view_attributes + WHERE resource_view_id IN @Ids + ORDER BY resource_view_id, ordinal; + """, new { Ids = viewIds }).ToLookup(attribute => attribute.OwnerId); + return viewIds + .Select(viewId => new OtlpResourceView(resource, attributes[viewId] + .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)) + .ToArray())) + .DistinctBy(view => view.Properties, ResourceViewPropertiesComparer.Instance) + .ToList(); + } + + private sealed class ResourceViewPropertiesComparer : IEqualityComparer[]> + { + public static readonly ResourceViewPropertiesComparer Instance = new(); + + public bool Equals(KeyValuePair[]? left, KeyValuePair[]? right) => + ReferenceEquals(left, right) || left is not null && right is not null && left.SequenceEqual(right); + + public int GetHashCode(KeyValuePair[] properties) + { + var hash = new HashCode(); + foreach (var property in properties) + { + hash.Add(property); + } + return hash.ToHashCode(); + } + } + + private sealed record LogQuery(string FromAndWhere, DynamicParameters Parameters); + + private sealed class ScopeRecord + { + public required long ScopeId { get; init; } + public required string ScopeName { get; init; } + public required string ScopeVersion { get; init; } + } + + private class AttributeRecord + { + public required string AttributeKey { get; init; } + public required string AttributeValue { get; init; } + } + + private sealed class OwnedAttributeRecord : AttributeRecord + { + public required long OwnerId { get; init; } + } + + private sealed class LogRecord + { + public required long LogId { get; init; } + public required long ResourceId { get; init; } + public required long ResourceViewId { get; init; } + public required long ScopeId { get; init; } + public required long TimestampTicks { get; init; } + public required long Flags { get; init; } + public required int Severity { get; init; } + public required int SeverityNumber { get; init; } + public required string Message { get; init; } + public required string SpanId { get; init; } + public required string TraceId { get; init; } + public required string ParentId { get; init; } + public string? OriginalFormat { get; init; } + public string? EventName { get; init; } + public required string ResourceName { get; init; } + public required string InstanceId { get; init; } + public required bool InstanceIdIsNull { get; init; } + public required bool UninstrumentedPeer { get; init; } + public required bool HasLogs { get; init; } + public required bool HasTraces { get; init; } + public required bool HasMetrics { get; init; } + public required string ScopeName { get; init; } + public required string ScopeVersion { get; init; } + } + + private sealed class FieldValueRecord + { + public string? FieldValue { get; init; } + public required int ValueCount { get; init; } + } + + private class TelemetryResourceRecord + { + public required string ResourceName { get; init; } + public required string InstanceId { get; init; } + public required bool InstanceIdIsNull { get; init; } + } + + private sealed class TelemetryResourceStateRecord : TelemetryResourceRecord + { + public required bool UninstrumentedPeer { get; init; } + public required bool HasLogs { get; init; } + public required bool HasTraces { get; init; } + public required bool HasMetrics { get; init; } + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs new file mode 100644 index 00000000000..eb56def7734 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs @@ -0,0 +1,841 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data; +using System.Globalization; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Model.MetricValues; +using Dapper; +using Google.Protobuf.Collections; +using Microsoft.Data.Sqlite; +using OpenTelemetry.Proto.Common.V1; +using OpenTelemetry.Proto.Metrics.V1; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed partial class SqliteTelemetryRepository +{ + private const int LongPointType = 1; + private const int DoublePointType = 2; + private const int HistogramPointType = 3; + + private void AddMetricsToDatabase(AddContext context, RepeatedField resourceMetrics) + { + lock (_writeLock) + { + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + foreach (var resourceMetricsItem in resourceMetrics) + { + long resourceId; + try + { + resourceId = GetOrAddTelemetryResource(connection, transaction, resourceMetricsItem.Resource.GetResourceKey()); + } + catch (Exception exception) + { + context.FailureCount += resourceMetricsItem.ScopeMetrics.Sum(scope => scope.Metrics.Sum(OtlpResource.GetMetricDataPointCount)); + _otlpContext.Logger.LogInformation(exception, "Error adding resource."); + continue; + } + + foreach (var scopeMetrics in resourceMetricsItem.ScopeMetrics) + { + OtlpScope scope; + long scopeId; + try + { + (scopeId, scope) = GetOrAddScope(connection, transaction, scopeMetrics.Scope); + } + catch (Exception exception) + { + context.FailureCount += scopeMetrics.Metrics.Sum(OtlpResource.GetMetricDataPointCount); + _otlpContext.Logger.LogInformation(exception, "Error adding metric scope."); + continue; + } + + foreach (var metric in scopeMetrics.Metrics) + { + AddMetricToDatabase(connection, transaction, context, resourceId, scopeId, scope, metric); + } + } + + connection.Execute( + "UPDATE telemetry_resources SET has_metrics = 1 WHERE resource_id = @ResourceId;", + new { ResourceId = resourceId }, + transaction); + } + transaction.Commit(); + } + } + + private void AddMetricToDatabase( + SqliteConnection connection, + IDbTransaction transaction, + AddContext context, + long resourceId, + long scopeId, + OtlpScope scope, + Metric metric) + { + var pointCount = OtlpResource.GetMetricDataPointCount(metric); + if (metric.DataCase is Metric.DataOneofCase.Summary or Metric.DataOneofCase.ExponentialHistogram) + { + context.FailureCount += pointCount; + _otlpContext.Logger.LogInformation("Error adding {MetricType} metrics. {MetricType} is not supported.", metric.DataCase, metric.DataCase); + return; + } + if (metric.DataCase is Metric.DataOneofCase.None) + { + return; + } + + long instrumentId; + try + { + instrumentId = GetOrAddMetricInstrument(connection, transaction, resourceId, scopeId, metric); + } + catch (Exception exception) + { + context.FailureCount += pointCount; + _otlpContext.Logger.LogInformation(exception, "Error adding metric instrument {MetricName}.", metric.Name); + return; + } + + switch (metric.DataCase) + { + case Metric.DataOneofCase.Gauge: + foreach (var point in metric.Gauge.DataPoints) + { + AddNumberMetricPoint(connection, transaction, context, instrumentId, scope, point); + } + break; + case Metric.DataOneofCase.Sum: + foreach (var point in metric.Sum.DataPoints) + { + AddNumberMetricPoint(connection, transaction, context, instrumentId, scope, point); + } + break; + case Metric.DataOneofCase.Histogram: + foreach (var point in metric.Histogram.DataPoints) + { + AddHistogramMetricPoint(connection, transaction, context, instrumentId, scope, point); + } + break; + } + } + + private static long GetOrAddMetricInstrument( + SqliteConnection connection, + IDbTransaction transaction, + long resourceId, + long scopeId, + Metric metric) + { + if (string.IsNullOrEmpty(metric.Name)) + { + throw new InvalidOperationException("Instrument name is required."); + } + var existingId = connection.QuerySingleOrDefault(""" + SELECT instrument_id + FROM telemetry_metric_instruments + WHERE resource_id = @ResourceId AND scope_id = @ScopeId AND instrument_name = @InstrumentName; + """, new { ResourceId = resourceId, ScopeId = scopeId, InstrumentName = metric.Name }, transaction); + if (existingId is not null) + { + return existingId.Value; + } + + var instrumentCount = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_metric_instruments WHERE resource_id = @ResourceId;", new { ResourceId = resourceId }, transaction); + if (instrumentCount >= TelemetryRepositoryLimits.MaxInstrumentCount) + { + throw new InvalidOperationException($"Instrument limit of {TelemetryRepositoryLimits.MaxInstrumentCount} reached. Instrument '{metric.Name}' will not be added."); + } + + return connection.QuerySingle(""" + INSERT INTO telemetry_metric_instruments ( + resource_id, scope_id, instrument_name, description, unit, instrument_type, + aggregation_temporality, is_monotonic) + VALUES ( + @ResourceId, @ScopeId, @InstrumentName, @Description, @Unit, @InstrumentType, + @AggregationTemporality, @IsMonotonic) + RETURNING instrument_id; + """, new + { + ResourceId = resourceId, + ScopeId = scopeId, + InstrumentName = metric.Name, + metric.Description, + metric.Unit, + InstrumentType = (int)MapMetricType(metric.DataCase), + AggregationTemporality = (int)MapAggregationTemporality(metric), + IsMonotonic = metric.DataCase == Metric.DataOneofCase.Sum && metric.Sum.IsMonotonic + }, transaction); + } + + private void AddNumberMetricPoint( + SqliteConnection connection, + IDbTransaction transaction, + AddContext context, + long instrumentId, + OtlpScope scope, + NumberDataPoint point) + { + try + { + var dimensionId = GetOrAddMetricDimension(connection, transaction, instrumentId, scope, point.Attributes); + var pointType = point.ValueCase switch + { + NumberDataPoint.ValueOneofCase.AsInt => LongPointType, + NumberDataPoint.ValueOneofCase.AsDouble => DoublePointType, + _ => throw new InvalidOperationException("Metric data point has no value.") + }; + var latest = GetLatestMetricPoint(connection, transaction, dimensionId); + var sameValue = latest is not null && latest.PointType == pointType && + (pointType == LongPointType ? latest.IntegerValue == point.AsInt : latest.DoubleValue == point.AsDouble); + long pointId; + if (sameValue) + { + pointId = latest!.PointId; + connection.Execute(""" + UPDATE telemetry_metric_points + SET end_time_ticks = @EndTimeTicks, repeat_count = repeat_count + 1 + WHERE point_id = @PointId; + """, new { PointId = pointId, EndTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks }, transaction); + } + else + { + var start = OtlpHelpers.UnixNanoSecondsToDateTime(point.StartTimeUnixNano); + if (latest?.PointType == pointType) + { + start = new DateTime(latest.EndTimeTicks, DateTimeKind.Utc); + } + pointId = connection.QuerySingle(""" + INSERT INTO telemetry_metric_points ( + dimension_id, point_type, start_time_ticks, end_time_ticks, repeat_count, + integer_value, double_value, flags) + VALUES ( + @DimensionId, @PointType, @StartTimeTicks, @EndTimeTicks, 1, + @IntegerValue, @DoubleValue, @Flags) + RETURNING point_id; + """, new + { + DimensionId = dimensionId, + PointType = pointType, + StartTimeTicks = start.Ticks, + EndTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks, + IntegerValue = pointType == LongPointType ? point.AsInt : (long?)null, + DoubleValue = pointType == DoublePointType ? point.AsDouble : (double?)null, + Flags = (long)point.Flags + }, transaction); + } + AddMetricExemplars(connection, transaction, pointId, point.Exemplars); + TrimMetricDimension(connection, transaction, dimensionId); + context.SuccessCount++; + } + catch (Exception exception) + { + context.FailureCount++; + _otlpContext.Logger.LogInformation(exception, "Error adding metric."); + } + } + + private void AddHistogramMetricPoint( + SqliteConnection connection, + IDbTransaction transaction, + AddContext context, + long instrumentId, + OtlpScope scope, + HistogramDataPoint point) + { + try + { + if (point.BucketCounts.Count > 0 && point.ExplicitBounds.Count == 0) + { + throw new InvalidOperationException("Histogram data point has bucket counts without any explicit bounds."); + } + var dimensionId = GetOrAddMetricDimension(connection, transaction, instrumentId, scope, point.Attributes); + var latest = GetLatestMetricPoint(connection, transaction, dimensionId); + var sameCount = latest is not null && latest.PointType == HistogramPointType && latest.HistogramCount == point.Count.ToString(CultureInfo.InvariantCulture); + long pointId; + if (sameCount) + { + pointId = latest!.PointId; + connection.Execute( + "UPDATE telemetry_metric_points SET end_time_ticks = @EndTimeTicks WHERE point_id = @PointId;", + new { PointId = pointId, EndTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks }, + transaction); + } + else + { + var start = OtlpHelpers.UnixNanoSecondsToDateTime(point.StartTimeUnixNano); + if (latest?.PointType == HistogramPointType) + { + start = new DateTime(latest.EndTimeTicks, DateTimeKind.Utc); + } + pointId = connection.QuerySingle(""" + INSERT INTO telemetry_metric_points ( + dimension_id, point_type, start_time_ticks, end_time_ticks, repeat_count, + histogram_sum, histogram_count, flags) + VALUES ( + @DimensionId, @PointType, @StartTimeTicks, @EndTimeTicks, 1, + @HistogramSum, @HistogramCount, @Flags) + RETURNING point_id; + """, new + { + DimensionId = dimensionId, + PointType = HistogramPointType, + StartTimeTicks = start.Ticks, + EndTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks, + HistogramSum = point.Sum, + HistogramCount = point.Count.ToString(CultureInfo.InvariantCulture), + Flags = (long)point.Flags + }, transaction); + connection.Execute(""" + INSERT INTO telemetry_metric_histogram_bucket_counts (point_id, ordinal, bucket_count) + VALUES (@PointId, @Ordinal, @BucketCount); + """, point.BucketCounts.Select((count, ordinal) => new { PointId = pointId, Ordinal = ordinal, BucketCount = count.ToString(CultureInfo.InvariantCulture) }), transaction); + connection.Execute(""" + INSERT INTO telemetry_metric_histogram_explicit_bounds (point_id, ordinal, explicit_bound) + VALUES (@PointId, @Ordinal, @ExplicitBound); + """, point.ExplicitBounds.Select((bound, ordinal) => new { PointId = pointId, Ordinal = ordinal, ExplicitBound = bound }), transaction); + } + AddMetricExemplars(connection, transaction, pointId, point.Exemplars); + TrimMetricDimension(connection, transaction, dimensionId); + context.SuccessCount++; + } + catch (Exception exception) + { + context.FailureCount++; + _otlpContext.Logger.LogInformation(exception, "Error adding metric."); + } + } + + private long GetOrAddMetricDimension( + SqliteConnection connection, + IDbTransaction transaction, + long instrumentId, + OtlpScope scope, + RepeatedField pointAttributes) + { + KeyValuePair[]? temporaryAttributes = null; + OtlpHelpers.CopyKeyValuePairs(pointAttributes, scope.Attributes, _otlpContext, out var copyCount, ref temporaryAttributes); + Array.Sort(temporaryAttributes, 0, copyCount, MetricAttributeComparer.Instance); + var attributes = temporaryAttributes.AsSpan(0, copyCount).ToArray(); + foreach (var existingDimensionId in connection.Query("SELECT dimension_id FROM telemetry_metric_dimensions WHERE instrument_id = @InstrumentId ORDER BY dimension_id;", new { InstrumentId = instrumentId }, transaction)) + { + var existingAttributes = connection.Query(""" + SELECT attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_metric_dimension_attributes + WHERE dimension_id = @DimensionId + ORDER BY ordinal; + """, new { DimensionId = existingDimensionId }, transaction) + .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)); + if (existingAttributes.SequenceEqual(attributes)) + { + return existingDimensionId; + } + } + + var dimensionCount = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_metric_dimensions WHERE instrument_id = @InstrumentId;", new { InstrumentId = instrumentId }, transaction); + if (dimensionCount >= TelemetryRepositoryLimits.MaxDimensionCount) + { + throw new InvalidOperationException($"Dimension limit of {TelemetryRepositoryLimits.MaxDimensionCount} reached."); + } + var dimensionId = connection.QuerySingle(""" + INSERT INTO telemetry_metric_dimensions (instrument_id) + VALUES (@InstrumentId) + RETURNING dimension_id; + """, new { InstrumentId = instrumentId }, transaction); + connection.Execute(""" + INSERT INTO telemetry_metric_dimension_attributes (dimension_id, ordinal, attribute_key, attribute_value) + VALUES (@DimensionId, @Ordinal, @Key, @Value); + """, attributes.Select((attribute, ordinal) => new { DimensionId = dimensionId, Ordinal = ordinal, attribute.Key, attribute.Value }), transaction); + + if (pointAttributes.Count == 1 && pointAttributes[0].Key == "otel.metric.overflow" && pointAttributes[0].Value.GetString() == "true") + { + connection.Execute("UPDATE telemetry_metric_instruments SET has_overflow = 1 WHERE instrument_id = @InstrumentId;", new { InstrumentId = instrumentId }, transaction); + } + return dimensionId; + } + + private static MetricPointRecord? GetLatestMetricPoint(SqliteConnection connection, IDbTransaction transaction, long dimensionId) + { + return connection.QuerySingleOrDefault(""" + SELECT + point_id AS PointId, + point_type AS PointType, + end_time_ticks AS EndTimeTicks, + integer_value AS IntegerValue, + double_value AS DoubleValue, + histogram_count AS HistogramCount + FROM telemetry_metric_points + WHERE dimension_id = @DimensionId + ORDER BY point_id DESC + LIMIT 1; + """, new { DimensionId = dimensionId }, transaction); + } + + private void AddMetricExemplars(SqliteConnection connection, IDbTransaction transaction, long pointId, RepeatedField exemplars) + { + foreach (var exemplar in exemplars) + { + if (exemplar.TraceId is null || exemplar.SpanId is null) + { + continue; + } + var startTicks = OtlpHelpers.UnixNanoSecondsToDateTime(exemplar.TimeUnixNano).Ticks; + var value = exemplar.HasAsDouble ? exemplar.AsDouble : exemplar.AsInt; + var exists = connection.QuerySingle(""" + SELECT EXISTS ( + SELECT 1 FROM telemetry_metric_exemplars + WHERE point_id = @PointId AND start_time_ticks = @StartTimeTicks AND exemplar_value = @Value + ); + """, new { PointId = pointId, StartTimeTicks = startTicks, Value = value }, transaction); + if (exists) + { + continue; + } + var exemplarId = connection.QuerySingle(""" + INSERT INTO telemetry_metric_exemplars ( + point_id, start_time_ticks, exemplar_value, span_id, trace_id) + VALUES (@PointId, @StartTimeTicks, @Value, @SpanId, @TraceId) + RETURNING exemplar_id; + """, new + { + PointId = pointId, + StartTimeTicks = startTicks, + Value = value, + SpanId = exemplar.SpanId.ToHexString(), + TraceId = exemplar.TraceId.ToHexString() + }, transaction); + var attributes = exemplar.FilteredAttributes.ToKeyValuePairs(_otlpContext); + connection.Execute(""" + INSERT INTO telemetry_metric_exemplar_attributes (exemplar_id, ordinal, attribute_key, attribute_value) + VALUES (@ExemplarId, @Ordinal, @Key, @Value); + """, attributes.Select((attribute, ordinal) => new { ExemplarId = exemplarId, Ordinal = ordinal, attribute.Key, attribute.Value }), transaction); + } + } + + private void TrimMetricDimension(SqliteConnection connection, IDbTransaction transaction, long dimensionId) + { + connection.Execute(""" + DELETE FROM telemetry_metric_points + WHERE point_id IN ( + SELECT point_id + FROM telemetry_metric_points + WHERE dimension_id = @DimensionId + ORDER BY point_id + LIMIT MAX((SELECT COUNT(*) FROM telemetry_metric_points WHERE dimension_id = @DimensionId) - @MaxMetricsCount, 0) + ); + """, new { DimensionId = dimensionId, _otlpContext.Options.MaxMetricsCount }, transaction); + } + + private List GetInstrumentsSummariesFromDatabase(ResourceKey key) + { + using var connection = _database.OpenConnection(); + var records = QueryMetricInstruments(connection, key, meterName: null, instrumentName: null); + var scopes = MaterializeMetricScopes(connection, records); + return records + .Select(record => CreateMetricSummary(record, scopes[record.ScopeId])) + .DistinctBy(summary => summary.GetKey()) + .ToList(); + } + + private OtlpInstrumentData? GetInstrumentFromDatabase(GetInstrumentRequest request) + { + using var connection = _database.OpenConnection(); + var records = QueryMetricInstruments(connection, request.ResourceKey, request.MeterName, request.InstrumentName); + if (records.Count == 0) + { + return null; + } + var scopes = MaterializeMetricScopes(connection, records); + var dimensions = new List(); + var knownAttributeValues = new Dictionary>(); + foreach (var record in records) + { + foreach (var dimension in MaterializeMetricDimensions(connection, record.InstrumentId, request.StartTime, request.EndTime)) + { + var isFirst = dimensions.Count == 0; + foreach (var key in knownAttributeValues.Keys.Union(dimension.Attributes.Select(attribute => attribute.Key)).Distinct().ToList()) + { + if (!knownAttributeValues.TryGetValue(key, out var values)) + { + values = []; + knownAttributeValues.Add(key, values); + if (!isFirst) + { + values.Add(null); + } + } + var value = OtlpHelpers.GetValue(dimension.Attributes, key); + if (!values.Contains(value)) + { + values.Add(value); + } + } + dimensions.Add(dimension); + } + } + return new OtlpInstrumentData + { + Summary = CreateMetricSummary(records[0], scopes[records[0].ScopeId]), + Dimensions = dimensions, + KnownAttributeValues = knownAttributeValues, + HasOverflow = records.Any(record => record.HasOverflow) + }; + } + + private OtlpInstrument? GetResourceInstrumentFromDatabase( + ResourceKey resourceKey, + string meterName, + string instrumentName, + DateTime? startTime, + DateTime? endTime) + { + var data = GetInstrumentFromDatabase(new GetInstrumentRequest + { + ResourceKey = resourceKey, + MeterName = meterName, + InstrumentName = instrumentName, + StartTime = startTime, + EndTime = endTime + }); + if (data is null) + { + return null; + } + + var instrument = new OtlpInstrument + { + Summary = data.Summary, + Context = _otlpContext, + HasOverflow = data.HasOverflow + }; + foreach (var (key, values) in data.KnownAttributeValues) + { + instrument.KnownAttributeValues.Add(key, values); + } + foreach (var dimension in data.Dimensions) + { + instrument.Dimensions.Add(dimension.Attributes, dimension); + } + return instrument; + } + + private static List QueryMetricInstruments( + SqliteConnection connection, + ResourceKey key, + string? meterName, + string? instrumentName) + { + return connection.Query(""" + SELECT + i.instrument_id AS InstrumentId, + i.scope_id AS ScopeId, + i.instrument_name AS InstrumentName, + i.description AS Description, + i.unit AS Unit, + i.instrument_type AS InstrumentType, + i.aggregation_temporality AS AggregationTemporality, + i.has_overflow AS HasOverflow + FROM telemetry_metric_instruments i + JOIN telemetry_resources r ON r.resource_id = i.resource_id + JOIN telemetry_scopes s ON s.scope_id = i.scope_id + WHERE r.resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE + AND (@InstanceId IS NULL OR (r.instance_id_is_null = 0 AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE)) + AND (@MeterName IS NULL OR s.scope_name = @MeterName) + AND (@InstrumentName IS NULL OR i.instrument_name = @InstrumentName) + ORDER BY i.instrument_id; + """, new { ResourceName = key.Name, key.InstanceId, MeterName = meterName, InstrumentName = instrumentName }).AsList(); + } + + private static Dictionary MaterializeMetricScopes(SqliteConnection connection, List records) + { + var scopeIds = records.Select(record => record.ScopeId).Distinct().ToArray(); + var scopeRecords = connection.Query(""" + SELECT scope_id AS ScopeId, scope_name AS ScopeName, scope_version AS ScopeVersion + FROM telemetry_scopes + WHERE scope_id IN @Ids; + """, new { Ids = scopeIds }).ToDictionary(record => record.ScopeId); + var attributes = connection.Query(""" + SELECT scope_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_scope_attributes + WHERE scope_id IN @Ids + ORDER BY scope_id, ordinal; + """, new { Ids = scopeIds }).ToLookup(record => record.OwnerId); + return scopeRecords.ToDictionary( + pair => pair.Key, + pair => CreateScope(pair.Value.ScopeName, pair.Value.ScopeVersion, attributes[pair.Key].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray())); + } + + private List MaterializeMetricDimensions( + SqliteConnection connection, + long instrumentId, + DateTime? startTime, + DateTime? endTime) + { + var dimensionIds = connection.Query("SELECT dimension_id FROM telemetry_metric_dimensions WHERE instrument_id = @InstrumentId ORDER BY dimension_id;", new { InstrumentId = instrumentId }).AsList(); + var attributes = connection.Query(""" + SELECT dimension_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_metric_dimension_attributes + WHERE dimension_id IN @Ids + ORDER BY dimension_id, ordinal; + """, new { Ids = dimensionIds }).ToLookup(record => record.OwnerId); + var points = connection.Query(""" + SELECT + point_id AS PointId, + dimension_id AS DimensionId, + point_type AS PointType, + start_time_ticks AS StartTimeTicks, + end_time_ticks AS EndTimeTicks, + repeat_count AS RepeatCount, + integer_value AS IntegerValue, + double_value AS DoubleValue, + histogram_sum AS HistogramSum, + histogram_count AS HistogramCount + FROM telemetry_metric_points + WHERE dimension_id IN @Ids + AND (@ApplyRange = 0 OR (start_time_ticks <= @EndTicks AND end_time_ticks >= @StartTicks) OR (start_time_ticks >= @StartTicks AND end_time_ticks <= @EndTicks)) + ORDER BY dimension_id, point_id; + """, new + { + Ids = dimensionIds, + ApplyRange = startTime is not null && endTime is not null, + StartTicks = startTime?.Ticks ?? 0, + EndTicks = endTime?.Ticks ?? 0 + }).ToLookup(record => record.DimensionId); + var allPointIds = points.SelectMany(group => group).Select(point => point.PointId).ToArray(); + var bucketCounts = connection.Query(""" + SELECT point_id AS PointId, bucket_count AS BucketCount + FROM telemetry_metric_histogram_bucket_counts + WHERE point_id IN @Ids + ORDER BY point_id, ordinal; + """, new { Ids = allPointIds }).ToLookup(record => record.PointId); + var explicitBounds = connection.Query(""" + SELECT point_id AS PointId, explicit_bound AS ExplicitBound + FROM telemetry_metric_histogram_explicit_bounds + WHERE point_id IN @Ids + ORDER BY point_id, ordinal; + """, new { Ids = allPointIds }).ToLookup(record => record.PointId); + var exemplars = MaterializeMetricExemplars(connection, allPointIds); + + var results = new List(dimensionIds.Count); + foreach (var dimensionId in dimensionIds) + { + var dimension = new DimensionScope( + _otlpContext.Options.MaxMetricsCount, + attributes[dimensionId].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray()); + if (startTime is not null && endTime is not null) + { + foreach (var point in points[dimensionId]) + { + MetricValueBase value = point.PointType switch + { + LongPointType => new MetricValue(point.IntegerValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), + DoublePointType => new MetricValue(point.DoubleValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), + HistogramPointType => new HistogramValue( + bucketCounts[point.PointId].Select(bucket => ulong.Parse(bucket.BucketCount, CultureInfo.InvariantCulture)).ToArray(), + point.HistogramSum!.Value, + ulong.Parse(point.HistogramCount!, CultureInfo.InvariantCulture), + new DateTime(point.StartTimeTicks, DateTimeKind.Utc), + new DateTime(point.EndTimeTicks, DateTimeKind.Utc), + explicitBounds[point.PointId].Select(bound => bound.ExplicitBound).ToArray()), + _ => throw new InvalidOperationException($"Unknown metric point type '{point.PointType}'.") + }; + if (point.PointType != HistogramPointType) + { + value.Count = checked((ulong)point.RepeatCount); + } + value.Exemplars.AddRange(exemplars[point.PointId]); + dimension.Values.Add(value); + } + } + results.Add(dimension); + } + return results; + } + + private static ILookup MaterializeMetricExemplars(SqliteConnection connection, long[] pointIds) + { + var records = connection.Query(""" + SELECT + exemplar_id AS ExemplarId, + point_id AS PointId, + start_time_ticks AS StartTimeTicks, + exemplar_value AS ExemplarValue, + span_id AS SpanId, + trace_id AS TraceId + FROM telemetry_metric_exemplars + WHERE point_id IN @Ids + ORDER BY point_id, exemplar_id; + """, new { Ids = pointIds }).AsList(); + var attributes = connection.Query(""" + SELECT exemplar_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_metric_exemplar_attributes + WHERE exemplar_id IN @Ids + ORDER BY exemplar_id, ordinal; + """, new { Ids = records.Select(record => record.ExemplarId).ToArray() }).ToLookup(record => record.OwnerId); + return records.Select(record => new KeyValuePair(record.PointId, new MetricsExemplar + { + Start = new DateTime(record.StartTimeTicks, DateTimeKind.Utc), + Value = record.ExemplarValue, + SpanId = record.SpanId, + TraceId = record.TraceId, + Attributes = attributes[record.ExemplarId].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray() + })).ToLookup(pair => pair.Key, pair => pair.Value); + } + + private void ClearSelectedMetricsFromDatabase(Dictionary> selectedResources) + { + using var connection = _database.OpenConnection(); + foreach (var resource in connection.Query("SELECT resource_name AS ResourceName, instance_id AS InstanceId, instance_id_is_null AS InstanceIdIsNull FROM telemetry_resources;")) + { + var key = new ResourceKey(resource.ResourceName, resource.InstanceIdIsNull ? null : resource.InstanceId); + if (selectedResources.TryGetValue(key.GetCompositeName(), out var dataTypes) && dataTypes.Contains(AspireDataType.Metrics) && !dataTypes.Contains(AspireDataType.Resource)) + { + ClearMetricsFromDatabase(key); + } + } + } + + private void ClearMetricsFromDatabase(ResourceKey? resourceKey) + { + lock (_writeLock) + { + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + var parameters = new DynamicParameters(); + var where = string.Empty; + if (resourceKey is not null) + { + where = " WHERE resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE"; + parameters.Add("ResourceName", resourceKey.Value.Name); + if (resourceKey.Value.InstanceId is not null) + { + where += " AND instance_id_is_null = 0 AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; + parameters.Add("InstanceId", resourceKey.Value.InstanceId); + } + } + connection.Execute($""" + DELETE FROM telemetry_metric_instruments + WHERE resource_id IN (SELECT resource_id FROM telemetry_resources{where}); + + UPDATE telemetry_resources + SET has_metrics = EXISTS (SELECT 1 FROM telemetry_metric_instruments WHERE telemetry_metric_instruments.resource_id = telemetry_resources.resource_id); + """, parameters, transaction); + DeleteOrphanedScopes(connection, transaction); + transaction.Commit(); + } + } + + private static OtlpInstrumentSummary CreateMetricSummary(MetricInstrumentRecord record, OtlpScope scope) + { + return new OtlpInstrumentSummary + { + Name = record.InstrumentName, + Description = record.Description, + Unit = record.Unit, + Type = (OtlpInstrumentType)record.InstrumentType, + AggregationTemporality = (OtlpAggregationTemporality)record.AggregationTemporality, + Parent = scope + }; + } + + private static OtlpScope CreateScope(string name, string version, KeyValuePair[] attributes) + { + return name == OtlpScope.Empty.Name && version.Length == 0 && attributes.Length == 0 + ? OtlpScope.Empty + : new OtlpScope(name, version, attributes); + } + + private static OtlpInstrumentType MapMetricType(Metric.DataOneofCase dataCase) + { + return dataCase switch + { + Metric.DataOneofCase.Gauge => OtlpInstrumentType.Gauge, + Metric.DataOneofCase.Sum => OtlpInstrumentType.Sum, + Metric.DataOneofCase.Histogram => OtlpInstrumentType.Histogram, + _ => OtlpInstrumentType.Unsupported + }; + } + + private static OtlpAggregationTemporality MapAggregationTemporality(Metric metric) + { + return metric.DataCase switch + { + Metric.DataOneofCase.Sum => (OtlpAggregationTemporality)metric.Sum.AggregationTemporality, + Metric.DataOneofCase.Histogram => (OtlpAggregationTemporality)metric.Histogram.AggregationTemporality, + Metric.DataOneofCase.ExponentialHistogram => (OtlpAggregationTemporality)metric.ExponentialHistogram.AggregationTemporality, + _ => OtlpAggregationTemporality.Unspecified + }; + } + + private sealed class MetricAttributeComparer : IComparer> + { + public static readonly MetricAttributeComparer Instance = new(); + + public int Compare(KeyValuePair x, KeyValuePair y) => string.Compare(x.Key, y.Key, StringComparison.Ordinal); + } + + private class MetricPointRecord + { + public required long PointId { get; init; } + public required int PointType { get; init; } + public required long EndTimeTicks { get; init; } + public long? IntegerValue { get; init; } + public double? DoubleValue { get; init; } + public string? HistogramCount { get; init; } + } + + private sealed class MetricInstrumentRecord + { + public required long InstrumentId { get; init; } + public required long ScopeId { get; init; } + public required string InstrumentName { get; init; } + public required string Description { get; init; } + public required string Unit { get; init; } + public required int InstrumentType { get; init; } + public required int AggregationTemporality { get; init; } + public required bool HasOverflow { get; init; } + } + + private sealed class MetricScopeRecord + { + public required long ScopeId { get; init; } + public required string ScopeName { get; init; } + public required string ScopeVersion { get; init; } + } + + private sealed class MetricPointDataRecord : MetricPointRecord + { + public required long DimensionId { get; init; } + public required long StartTimeTicks { get; init; } + public required long RepeatCount { get; init; } + public double? HistogramSum { get; init; } + } + + private sealed class MetricHistogramBucketRecord + { + public required long PointId { get; init; } + public required string BucketCount { get; init; } + } + + private sealed class MetricHistogramBoundRecord + { + public required long PointId { get; init; } + public required double ExplicitBound { get; init; } + } + + private sealed class MetricExemplarRecord + { + public required long ExemplarId { get; init; } + public required long PointId { get; init; } + public required long StartTimeTicks { get; init; } + public required double ExemplarValue { get; init; } + public required string SpanId { get; init; } + public required string TraceId { get; init; } + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs new file mode 100644 index 00000000000..86f4ffef770 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs @@ -0,0 +1,458 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using System.Collections.Concurrent; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Microsoft.FluentUI.AspNetCore.Components; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed partial class SqliteTelemetryRepository +{ + private const int MaxWatcherSnapshotCount = 10_000; + + private readonly object _subscriptionLock = new(); + private readonly List _resourceSubscriptions = []; + private readonly List _logSubscriptions = []; + private readonly List _metricsSubscriptions = []; + private readonly List _tracesSubscriptions = []; + private readonly Dictionary _resourceUnviewedErrorLogs = []; + private readonly ConcurrentDictionary _resourceCache = []; + private TimeSpan _subscriptionMinExecuteInterval = TimeSpan.FromMilliseconds(100); + + private readonly object _watchersLock = new(); + private List? _spanWatchers; + private List? _logWatchers; + + internal TimeSpan SubscriptionMinExecuteInterval + { + set => _subscriptionMinExecuteInterval = value; + } + + public bool HasDisplayedMaxLogLimitMessage { get; set; } + public Message? MaxLogLimitMessage { get; set; } + public bool HasDisplayedMaxTraceLimitMessage { get; set; } + public Message? MaxTraceLimitMessage { get; set; } + + public Dictionary GetResourceUnviewedErrorLogsCount() + { + lock (_subscriptionLock) + { + return _resourceUnviewedErrorLogs.ToDictionary(); + } + } + + public void MarkViewedErrorLogs(ResourceKey? key) + { + var changed = false; + lock (_subscriptionLock) + { + if (key is null) + { + changed = _resourceUnviewedErrorLogs.Count > 0; + _resourceUnviewedErrorLogs.Clear(); + } + else if (key.Value.InstanceId is null) + { + changed = _resourceUnviewedErrorLogs.Keys + .Where(resourceKey => string.Equals(resourceKey.Name, key.Value.Name, StringComparisons.ResourceName)) + .ToList() + .Aggregate(false, (removed, resourceKey) => _resourceUnviewedErrorLogs.Remove(resourceKey) || removed); + } + else + { + changed = _resourceUnviewedErrorLogs.Remove(key.Value); + } + } + + if (changed) + { + RaiseSubscriptionChanged(_logSubscriptions); + } + } + + private void ClearUnviewedErrorCounts(ResourceKey? key) + { + lock (_subscriptionLock) + { + if (key is null) + { + _resourceUnviewedErrorLogs.Clear(); + return; + } + + foreach (var resourceKey in _resourceUnviewedErrorLogs.Keys + .Where(resourceKey => key.Value.InstanceId is null + ? string.Equals(resourceKey.Name, key.Value.Name, StringComparisons.ResourceName) + : resourceKey == key.Value) + .ToList()) + { + _resourceUnviewedErrorLogs.Remove(resourceKey); + } + } + } + + private void ClearUnviewedErrorCounts(Dictionary> selectedResources) + { + lock (_subscriptionLock) + { + foreach (var resourceKey in _resourceUnviewedErrorLogs.Keys.ToList()) + { + if (selectedResources.TryGetValue(resourceKey.GetCompositeName(), out var dataTypes) && + (dataTypes.Contains(AspireDataType.StructuredLogs) || dataTypes.Contains(AspireDataType.Resource))) + { + _resourceUnviewedErrorLogs.Remove(resourceKey); + } + } + } + } + + public Subscription OnNewResources(Func callback) => + AddSubscription(nameof(OnNewResources), null, SubscriptionType.Read, callback, _resourceSubscriptions); + + public Subscription OnNewLogs(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback) => + AddSubscription(nameof(OnNewLogs), resourceKey, subscriptionType, callback, _logSubscriptions); + + public Subscription OnNewMetrics(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback) => + AddSubscription(nameof(OnNewMetrics), resourceKey, subscriptionType, callback, _metricsSubscriptions); + + public Subscription OnNewTraces(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback) => + AddSubscription(nameof(OnNewTraces), resourceKey, subscriptionType, callback, _tracesSubscriptions); + + private Subscription AddSubscription( + string name, + ResourceKey? resourceKey, + SubscriptionType subscriptionType, + Func callback, + List subscriptions) + { + Subscription? subscription = null; + subscription = new Subscription(name, resourceKey, subscriptionType, callback, () => + { + lock (_subscriptionLock) + { + subscriptions.Remove(subscription!); + } + }, ExecutionContext.Capture(), _otlpContext.Logger, _subscriptionMinExecuteInterval); + + lock (_subscriptionLock) + { + subscriptions.Add(subscription); + } + + return subscription; + } + + private void RaiseSubscriptionChanged(List subscriptions) + { + Subscription[] snapshot; + lock (_subscriptionLock) + { + snapshot = subscriptions.ToArray(); + } + + foreach (var subscription in snapshot) + { + subscription.Execute(); + } + } + + private void NotifyLogsAdded(List logs) + { + lock (_subscriptionLock) + { + foreach (var log in logs) + { + if (!log.IsError || _logSubscriptions.Any(subscription => + subscription.SubscriptionType == SubscriptionType.Read && + (subscription.ResourceKey is null || subscription.ResourceKey == log.ResourceView.ResourceKey))) + { + continue; + } + + _resourceUnviewedErrorLogs.TryGetValue(log.ResourceView.ResourceKey, out var count); + _resourceUnviewedErrorLogs[log.ResourceView.ResourceKey] = count + 1; + } + } + + PushLogsToWatchers(logs); + RaiseSubscriptionChanged(_logSubscriptions); + RaiseSubscriptionChanged(_resourceSubscriptions); + } + + private void NotifySpansAdded(List spans) + { + PushSpansToWatchers(spans); + RaiseSubscriptionChanged(_tracesSubscriptions); + RaiseSubscriptionChanged(_resourceSubscriptions); + } + + private void NotifyMetricsAdded() + { + RaiseSubscriptionChanged(_metricsSubscriptions); + RaiseSubscriptionChanged(_resourceSubscriptions); + } + + public async IAsyncEnumerable WatchSpansAsync( + WatchSpansRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var channel = Channel.CreateBounded(new BoundedChannelOptions(1000) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true + }); + var watcher = new SpanWatcher(request, channel); + lock (_watchersLock) + { + _spanWatchers ??= []; + _spanWatchers.Add(watcher); + } + + try + { + var existingSpans = GetSpans(new GetSpansRequest + { + ResourceKeys = request.ResourceKeys, + StartIndex = 0, + Count = MaxWatcherSnapshotCount, + Filters = request.Filters, + TraceId = request.TraceId, + HasError = request.HasError, + TextFragments = request.TextFragments + }); + var seenSpans = new HashSet<(string TraceId, string SpanId)>(); + foreach (var span in existingSpans.PagedResult.Items.OrderBy(span => span.StartTime)) + { + seenSpans.Add((span.TraceId, span.SpanId)); + yield return span; + } + while (channel.Reader.TryRead(out var pendingSpan)) + { + if (seenSpans.Add((pendingSpan.TraceId, pendingSpan.SpanId))) + { + yield return pendingSpan; + } + } + + await foreach (var span in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + yield return span; + } + } + finally + { + lock (_watchersLock) + { + _spanWatchers?.Remove(watcher); + } + channel.Writer.TryComplete(); + } + } + + public async IAsyncEnumerable WatchLogsAsync( + WatchLogsRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var channel = Channel.CreateBounded(new BoundedChannelOptions(1000) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true + }); + var watcher = new LogWatcher(request, channel); + lock (_watchersLock) + { + _logWatchers ??= []; + _logWatchers.Add(watcher); + } + + try + { + var existingLogs = GetLogs(new GetLogsContext + { + ResourceKeys = request.ResourceKeys, + StartIndex = 0, + Count = MaxWatcherSnapshotCount, + Filters = request.Filters, + TextFragments = request.TextFragments + }); + long maxYieldedLogId = 0; + foreach (var log in existingLogs.Items) + { + maxYieldedLogId = Math.Max(maxYieldedLogId, log.InternalId); + yield return log; + } + while (channel.Reader.TryRead(out var pendingLog)) + { + if (pendingLog.InternalId > maxYieldedLogId) + { + maxYieldedLogId = pendingLog.InternalId; + yield return pendingLog; + } + } + + await foreach (var log in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + if (log.InternalId > maxYieldedLogId) + { + maxYieldedLogId = log.InternalId; + yield return log; + } + } + } + finally + { + lock (_watchersLock) + { + _logWatchers?.Remove(watcher); + } + channel.Writer.TryComplete(); + } + } + + private void PushSpansToWatchers(List spans) + { + SpanWatcher[] watchers; + lock (_watchersLock) + { + watchers = _spanWatchers?.ToArray() ?? []; + } + + foreach (var span in spans) + { + foreach (var watcher in watchers) + { + var request = watcher.Request; + if (request.ResourceKeys is { Count: > 0 } keys && + !keys.Contains(span.Source.ResourceKey) && + (span.UninstrumentedPeer is null || !keys.Contains(span.UninstrumentedPeer.ResourceKey))) + { + continue; + } + if (!MatchesSpanWatcherRequest(span, request)) + { + continue; + } + watcher.Channel.Writer.TryWrite(span); + } + } + } + + private void PushLogsToWatchers(List logs) + { + LogWatcher[] watchers; + lock (_watchersLock) + { + watchers = _logWatchers?.ToArray() ?? []; + } + + foreach (var log in logs) + { + foreach (var watcher in watchers) + { + var request = watcher.Request; + if (request.ResourceKeys is { Count: > 0 } keys && !keys.Contains(log.ResourceView.ResourceKey)) + { + continue; + } + if (!MatchesLogWatcherRequest(log, request)) + { + continue; + } + watcher.Channel.Writer.TryWrite(log); + } + } + } + + private static bool MatchesSpanWatcherRequest(OtlpSpan span, WatchSpansRequest request) + { + if (!string.IsNullOrEmpty(request.TraceId) && !OtlpHelpers.MatchTelemetryId(request.TraceId, span.TraceId)) + { + return false; + } + if (request.HasError.HasValue && (span.Status == OtlpSpanStatusCode.Error) != request.HasError.Value) + { + return false; + } + if (request.Filters.Any(filter => filter.Enabled && !filter.Apply(span))) + { + return false; + } + return request.TextFragments is not { Length: > 0 } fragments || MatchesSpanTextFragments(span, fragments); + } + + private static bool MatchesLogWatcherRequest(OtlpLogEntry log, WatchLogsRequest request) + { + IEnumerable matches = [log]; + foreach (var filter in request.Filters.Where(filter => filter.Enabled)) + { + matches = filter.Apply(matches); + } + return matches.Any() && + (request.TextFragments is not { Length: > 0 } fragments || MatchesLogTextFragments(log, fragments)); + } + + private static bool MatchesSpanTextFragments(OtlpSpan span, string[] fragments) + { + return SearchTextParser.MatchesAllFragments(fragments, span, static (candidate, fragment) => + candidate.Name.Contains(fragment, StringComparisons.FullTextSearch) || + candidate.SpanId.Contains(fragment, StringComparisons.FullTextSearch) || + candidate.TraceId.Contains(fragment, StringComparisons.FullTextSearch) || + candidate.Scope.Name.Contains(fragment, StringComparisons.FullTextSearch) || + candidate.Source.Resource.ResourceName.Contains(fragment, StringComparisons.FullTextSearch) || + candidate.Status.ToString().Contains(fragment, StringComparisons.FullTextSearch) || + candidate.Kind.ToString().Contains(fragment, StringComparisons.FullTextSearch) || + candidate.StatusMessage?.Contains(fragment, StringComparisons.FullTextSearch) == true || + candidate.Attributes.Any(attribute => + attribute.Key.Contains(fragment, StringComparisons.FullTextSearch) || + attribute.Value.Contains(fragment, StringComparisons.FullTextSearch)) || + candidate.Events.Any(spanEvent => spanEvent.Name.Contains(fragment, StringComparisons.FullTextSearch))); + } + + private static bool MatchesLogTextFragments(OtlpLogEntry log, string[] fragments) + { + return SearchTextParser.MatchesAllFragments(fragments, log, static (candidate, fragment) => + candidate.Message.Contains(fragment, StringComparisons.FullTextSearch) || + candidate.Scope.Name.Contains(fragment, StringComparisons.FullTextSearch) || + candidate.TraceId.Contains(fragment, StringComparisons.FullTextSearch) || + candidate.SpanId.Contains(fragment, StringComparisons.FullTextSearch) || + candidate.Severity.ToString().Contains(fragment, StringComparisons.FullTextSearch) || + candidate.ResourceView.Resource.ResourceName.Contains(fragment, StringComparisons.FullTextSearch) || + candidate.EventName?.Contains(fragment, StringComparisons.FullTextSearch) == true || + candidate.Attributes.Any(attribute => + attribute.Key.Contains(fragment, StringComparisons.FullTextSearch) || + attribute.Value.Contains(fragment, StringComparisons.FullTextSearch))); + } + + private void DisposeWatchers() + { + lock (_watchersLock) + { + foreach (var watcher in _spanWatchers ?? []) + { + watcher.Channel.Writer.TryComplete(); + } + foreach (var watcher in _logWatchers ?? []) + { + watcher.Channel.Writer.TryComplete(); + } + _spanWatchers?.Clear(); + _logWatchers?.Clear(); + } + } + + private sealed class SpanWatcher(WatchSpansRequest request, Channel channel) + { + public WatchSpansRequest Request => request; + public Channel Channel => channel; + } + + private sealed class LogWatcher(WatchLogsRequest request, Channel channel) + { + public WatchLogsRequest Request => request; + public Channel Channel => channel; + } +} diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs new file mode 100644 index 00000000000..a564b3761a5 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs @@ -0,0 +1,1090 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data; +using System.Globalization; +using System.Text; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.Otlp; +using Aspire.Dashboard.Otlp.Model; +using Dapper; +using Google.Protobuf.Collections; +using Microsoft.Data.Sqlite; +using OpenTelemetry.Proto.Trace.V1; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed partial class SqliteTelemetryRepository +{ + private List AddTracesToDatabase(AddContext context, RepeatedField resourceSpans) + { + var addedSpans = new List(); + lock (_writeLock) + { + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + var traces = new Dictionary(StringComparer.Ordinal); + + foreach (var resourceSpansItem in resourceSpans) + { + OtlpResourceView resourceView; + long resourceId; + long resourceViewId; + try + { + var resourceKey = resourceSpansItem.Resource.GetResourceKey(); + resourceId = GetOrAddTelemetryResource(connection, transaction, resourceKey); + var resource = new OtlpResource(resourceKey.Name, resourceKey.InstanceId, uninstrumentedPeer: false, _otlpContext) + { + HasTraces = true + }; + resourceView = new OtlpResourceView(resource, resourceSpansItem.Resource.Attributes); + resourceViewId = InsertResourceView(connection, transaction, resourceId, resourceView.Properties); + } + catch (Exception exception) + { + context.FailureCount += resourceSpansItem.ScopeSpans.Sum(scope => scope.Spans.Count); + _otlpContext.Logger.LogInformation(exception, "Error adding resource."); + continue; + } + + foreach (var scopeSpans in resourceSpansItem.ScopeSpans) + { + OtlpScope scope; + long scopeId; + try + { + (scopeId, scope) = GetOrAddScope(connection, transaction, scopeSpans.Scope); + } + catch (Exception exception) + { + context.FailureCount += scopeSpans.Spans.Count; + _otlpContext.Logger.LogInformation(exception, "Error adding trace scope."); + continue; + } + + foreach (var span in scopeSpans.Spans) + { + try + { + addedSpans.Add(AddSpanToDatabase(connection, transaction, traces, resourceId, resourceViewId, resourceView, scopeId, scope, span)); + context.SuccessCount++; + } + catch (Exception exception) + { + context.FailureCount++; + _otlpContext.Logger.LogInformation(exception, "Error adding span."); + } + } + } + + connection.Execute( + "UPDATE telemetry_resources SET has_traces = 1 WHERE resource_id = @ResourceId;", + new { ResourceId = resourceId }, + transaction); + } + + TrimTracesToCapacity(connection, transaction); + transaction.Commit(); + } + + return addedSpans; + } + + private OtlpSpan AddSpanToDatabase( + SqliteConnection connection, + IDbTransaction transaction, + Dictionary traces, + long resourceId, + long resourceViewId, + OtlpResourceView resourceView, + long scopeId, + OtlpScope scope, + Span span) + { + var traceId = span.TraceId.ToHexString(); + if (!traces.TryGetValue(traceId, out var trace)) + { + trace = MaterializeTrace(connection, traceId, transaction) ?? new OtlpTrace(span.TraceId.Memory, DateTime.UtcNow); + traces.Add(traceId, trace); + } + var newTrace = trace.Spans.Count == 0; + var modelSpan = CreateSqliteSpan(resourceView, trace, scope, span); + trace.AddSpan(modelSpan); + + if (newTrace) + { + connection.Execute(""" + INSERT INTO telemetry_traces ( + trace_id, insertion_sequence, first_span_timestamp_ticks, duration_ticks, last_updated_timestamp_ticks, full_name) + VALUES ( + @TraceId, + COALESCE((SELECT MAX(insertion_sequence) + 1 FROM telemetry_traces), 1), + @FirstSpanTimestampTicks, + @DurationTicks, + @LastUpdatedTimestampTicks, + @FullName); + """, new + { + trace.TraceId, + FirstSpanTimestampTicks = trace.TimeStamp.Ticks, + DurationTicks = trace.Duration.Ticks, + LastUpdatedTimestampTicks = trace.LastUpdatedDate.Ticks, + trace.FullName + }, transaction); + } + + connection.Execute(""" + INSERT INTO telemetry_spans ( + trace_id, span_id, parent_span_id, resource_id, resource_view_id, scope_id, name, kind, + start_time_ticks, end_time_ticks, status, status_message, trace_state) + VALUES ( + @TraceId, @SpanId, @ParentSpanId, @ResourceId, @ResourceViewId, @ScopeId, @Name, @Kind, + @StartTimeTicks, @EndTimeTicks, @Status, @StatusMessage, @State); + """, new + { + trace.TraceId, + modelSpan.SpanId, + modelSpan.ParentSpanId, + ResourceId = resourceId, + ResourceViewId = resourceViewId, + ScopeId = scopeId, + modelSpan.Name, + Kind = (int)modelSpan.Kind, + StartTimeTicks = modelSpan.StartTime.Ticks, + EndTimeTicks = modelSpan.EndTime.Ticks, + Status = (int)modelSpan.Status, + modelSpan.StatusMessage, + modelSpan.State + }, transaction); + + InsertSpanDetails(connection, transaction, modelSpan); + UpdateUninstrumentedPeers(connection, transaction, trace); + connection.Execute(""" + UPDATE telemetry_traces + SET first_span_timestamp_ticks = @FirstSpanTimestampTicks, + duration_ticks = @DurationTicks, + last_updated_timestamp_ticks = @LastUpdatedTimestampTicks, + full_name = @FullName + WHERE trace_id = @TraceId; + """, new + { + trace.TraceId, + FirstSpanTimestampTicks = trace.TimeStamp.Ticks, + DurationTicks = trace.Duration.Ticks, + LastUpdatedTimestampTicks = trace.LastUpdatedDate.Ticks, + trace.FullName + }, transaction); + + return modelSpan; + } + + private void UpdateUninstrumentedPeers(SqliteConnection connection, IDbTransaction transaction, OtlpTrace trace) + { + foreach (var span in trace.Spans) + { + OtlpResource? peer = null; + long? peerResourceId = null; + var hasPeerAddress = OtlpHelpers.GetPeerAddress(span.Attributes) is not null; + if (hasPeerAddress && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !span.GetChildSpans().Any()) + { + foreach (var resolver in _outgoingPeerResolvers) + { + if (!resolver.TryResolvePeer(span.Attributes, out _, out var matchedResource) || matchedResource is null) + { + continue; + } + + var peerKey = ResourceKey.Create(matchedResource.DisplayName, matchedResource.Name); + peerResourceId = GetOrAddTelemetryResource(connection, transaction, peerKey); + connection.Execute( + "UPDATE telemetry_resources SET uninstrumented_peer = 1 WHERE resource_id = @ResourceId;", + new { ResourceId = peerResourceId }, + transaction); + peer = new OtlpResource(peerKey.Name, peerKey.InstanceId, uninstrumentedPeer: true, _otlpContext); + break; + } + } + + trace.SetSpanUninstrumentedPeer(span, peer); + connection.Execute(""" + UPDATE telemetry_spans + SET uninstrumented_peer_resource_id = @PeerResourceId + WHERE trace_id = @TraceId AND span_id = @SpanId; + """, new { PeerResourceId = peerResourceId, span.TraceId, span.SpanId }, transaction); + } + } + + private static void InsertSpanDetails(SqliteConnection connection, IDbTransaction transaction, OtlpSpan span) + { + connection.Execute(""" + INSERT INTO telemetry_span_attributes (trace_id, span_id, ordinal, attribute_key, attribute_value) + VALUES (@TraceId, @SpanId, @Ordinal, @Key, @Value); + """, span.Attributes.Select((attribute, ordinal) => new + { + span.TraceId, + span.SpanId, + Ordinal = ordinal, + attribute.Key, + attribute.Value + }), transaction); + + foreach (var (spanEvent, ordinal) in span.Events.Select((spanEvent, ordinal) => (spanEvent, ordinal))) + { + var eventId = spanEvent.InternalId.ToString("D"); + connection.Execute(""" + INSERT INTO telemetry_span_events (event_id, trace_id, span_id, ordinal, event_name, event_time_ticks) + VALUES (@EventId, @TraceId, @SpanId, @Ordinal, @Name, @TimeTicks); + """, new { EventId = eventId, span.TraceId, span.SpanId, Ordinal = ordinal, spanEvent.Name, TimeTicks = spanEvent.Time.Ticks }, transaction); + connection.Execute(""" + INSERT INTO telemetry_span_event_attributes (event_id, ordinal, attribute_key, attribute_value) + VALUES (@EventId, @Ordinal, @Key, @Value); + """, spanEvent.Attributes.Select((attribute, attributeOrdinal) => new + { + EventId = eventId, + Ordinal = attributeOrdinal, + attribute.Key, + attribute.Value + }), transaction); + } + + foreach (var link in span.Links) + { + var linkId = connection.QuerySingle(""" + INSERT INTO telemetry_span_links ( + source_trace_id, source_span_id, target_trace_id, target_span_id, trace_state) + VALUES (@SourceTraceId, @SourceSpanId, @TraceId, @SpanId, @TraceState) + RETURNING link_id; + """, link, transaction); + connection.Execute(""" + INSERT INTO telemetry_span_link_attributes (link_id, ordinal, attribute_key, attribute_value) + VALUES (@LinkId, @Ordinal, @Key, @Value); + """, link.Attributes.Select((attribute, ordinal) => new + { + LinkId = linkId, + Ordinal = ordinal, + attribute.Key, + attribute.Value + }), transaction); + } + } + + private OtlpSpan CreateSqliteSpan(OtlpResourceView resourceView, OtlpTrace trace, OtlpScope scope, Span span) + { + var spanId = span.SpanId?.ToHexString(); + if (spanId is null) + { + throw new ArgumentException("Span has no SpanId"); + } + + var modelSpan = new OtlpSpan(resourceView, trace, scope) + { + SpanId = spanId, + ParentSpanId = span.ParentSpanId?.ToHexString(), + Name = span.Name, + Kind = OtlpHelpers.ConvertSpanKind(span.Kind), + StartTime = OtlpHelpers.UnixNanoSecondsToDateTime(span.StartTimeUnixNano), + EndTime = OtlpHelpers.UnixNanoSecondsToDateTime(span.EndTimeUnixNano), + Status = ConvertSqliteStatus(span.Status), + StatusMessage = span.Status?.Message, + Attributes = span.Attributes.ToKeyValuePairs(_otlpContext, filter: attribute => attribute.Key != OtlpHelpers.AspireDestinationNameAttribute), + State = !string.IsNullOrEmpty(span.TraceState) ? span.TraceState : null, + Events = [], + Links = [], + BackLinks = [] + }; + + foreach (var spanEvent in span.Events.OrderBy(spanEvent => spanEvent.TimeUnixNano).Take(_otlpContext.Options.MaxSpanEventCount)) + { + modelSpan.Events.Add(new OtlpSpanEvent(modelSpan) + { + InternalId = Guid.NewGuid(), + Name = spanEvent.Name, + Time = OtlpHelpers.UnixNanoSecondsToDateTime(spanEvent.TimeUnixNano), + Attributes = spanEvent.Attributes.ToKeyValuePairs(_otlpContext) + }); + } + + foreach (var link in span.Links) + { + modelSpan.Links.Add(new OtlpSpanLink + { + SourceSpanId = spanId, + SourceTraceId = trace.TraceId, + TraceState = link.TraceState, + SpanId = link.SpanId.ToHexString(), + TraceId = link.TraceId.ToHexString(), + Attributes = link.Attributes.ToKeyValuePairs(_otlpContext) + }); + } + + return modelSpan; + } + + private static OtlpSpanStatusCode ConvertSqliteStatus(Status? status) + { + return status?.Code switch + { + Status.Types.StatusCode.Ok => OtlpSpanStatusCode.Ok, + Status.Types.StatusCode.Error => OtlpSpanStatusCode.Error, + _ => OtlpSpanStatusCode.Unset + }; + } + + private void TrimTracesToCapacity(SqliteConnection connection, IDbTransaction transaction) + { + connection.Execute(""" + DELETE FROM telemetry_traces + WHERE trace_id IN ( + SELECT trace_id + FROM telemetry_traces + ORDER BY first_span_timestamp_ticks, insertion_sequence DESC + LIMIT MAX((SELECT COUNT(*) FROM telemetry_traces) - @MaxTraceCount, 0) + ); + + DELETE FROM telemetry_resource_views + WHERE NOT EXISTS (SELECT 1 FROM telemetry_logs WHERE telemetry_logs.resource_view_id = telemetry_resource_views.resource_view_id) + AND NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_view_id = telemetry_resource_views.resource_view_id); + + UPDATE telemetry_resources + SET has_traces = EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_id = telemetry_resources.resource_id); + """, new { _otlpContext.Options.MaxTraceCount }, transaction); + } + + private GetTracesResponse GetTracesFromDatabase(GetTracesRequest context) + { + using var connection = _database.OpenConnection(); + var query = BuildTraceQuery(context); + var aggregate = connection.QuerySingle($""" + SELECT COUNT(*) AS TotalItemCount, COALESCE(MAX(t.duration_ticks), 0) AS MaxDurationTicks + {query.FromAndWhere}; + """, query.Parameters); + query.Parameters.Add("StartIndex", Math.Max(context.StartIndex, 0)); + query.Parameters.Add("Count", Math.Max(context.Count, 0)); + var records = connection.Query($""" + SELECT + t.trace_id AS TraceId, + t.last_updated_timestamp_ticks AS LastUpdatedTimestampTicks + {query.FromAndWhere} + ORDER BY t.first_span_timestamp_ticks, t.insertion_sequence DESC + LIMIT @Count OFFSET @StartIndex; + """, query.Parameters).AsList(); + var traces = records.Select(record => MaterializeTrace(connection, record.TraceId)!).ToList(); + return new GetTracesResponse + { + PagedResult = new PagedResult + { + Items = traces, + TotalItemCount = aggregate.TotalItemCount, + IsFull = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_traces;") >= _otlpContext.Options.MaxTraceCount + }, + MaxDuration = TimeSpan.FromTicks(aggregate.MaxDurationTicks) + }; + } + + private static TraceQuery BuildTraceQuery(GetTracesRequest context) + { + var sql = new StringBuilder("FROM telemetry_traces t WHERE EXISTS (SELECT 1 FROM telemetry_spans existing_span WHERE existing_span.trace_id = t.trace_id)"); + var parameters = new DynamicParameters(); + if (context.ResourceKeys.Count > 0) + { + var resourcePredicates = new List(context.ResourceKeys.Count); + for (var index = 0; index < context.ResourceKeys.Count; index++) + { + var key = context.ResourceKeys[index]; + parameters.Add($"ResourceName{index}", key.Name); + var sourcePredicate = $"r.resource_name = @ResourceName{index} COLLATE ORDINAL_IGNORE_CASE"; + var peerPredicate = $"pr.resource_name = @ResourceName{index} COLLATE ORDINAL_IGNORE_CASE"; + if (key.InstanceId is not null) + { + parameters.Add($"InstanceId{index}", key.InstanceId); + sourcePredicate += $" AND r.instance_id_is_null = 0 AND r.instance_id = @InstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; + peerPredicate += $" AND pr.instance_id_is_null = 0 AND pr.instance_id = @InstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; + } + resourcePredicates.Add($"(({sourcePredicate}) OR ({peerPredicate}))"); + } + sql.Append(" AND EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id WHERE s.trace_id = t.trace_id AND ("); + sql.AppendJoin(" OR ", resourcePredicates); + sql.Append("))"); + } + if (!string.IsNullOrWhiteSpace(context.TraceNameFilterText)) + { + sql.Append(" AND ordinal_contains(t.full_name, @TraceNameFilterText)"); + parameters.Add("TraceNameFilterText", context.TraceNameFilterText); + } + + var positivePredicates = new List(); + var filterIndex = 0; + foreach (var filter in context.Filters.Where(filter => filter.Enabled)) + { + if (filter is not FieldTelemetryFilter fieldFilter) + { + continue; + } + + if (fieldFilter.Field == KnownTraceFields.DurationField) + { + sql.Append(" AND "); + sql.Append(BuildTraceDurationPredicate(fieldFilter, parameters, filterIndex++)); + continue; + } + + if (fieldFilter.Condition is FilterCondition.NotEqual or FilterCondition.NotContains) + { + sql.Append(" AND NOT EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id WHERE s.trace_id = t.trace_id AND "); + sql.Append(BuildSpanFieldPredicate(fieldFilter, parameters, filterIndex++, invertNegative: true)); + sql.Append(')'); + } + else + { + positivePredicates.Add(BuildSpanFieldPredicate(fieldFilter, parameters, filterIndex++, invertNegative: false)); + } + } + if (positivePredicates.Count > 0) + { + sql.Append(" AND EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id WHERE s.trace_id = t.trace_id AND "); + sql.AppendJoin(" AND ", positivePredicates); + sql.Append(')'); + } + + if (context.TextFragments is { Length: > 0 }) + { + var fullNamePredicates = new List(context.TextFragments.Length); + var spanPredicates = new List(context.TextFragments.Length); + for (var index = 0; index < context.TextFragments.Length; index++) + { + var parameterName = $"TextFragment{index}"; + parameters.Add(parameterName, context.TextFragments[index]); + fullNamePredicates.Add($"ordinal_contains(t.full_name, @{parameterName})"); + spanPredicates.Add($""" + ( + ordinal_contains(s.name, @{parameterName}) OR + ordinal_contains(s.span_id, @{parameterName}) OR + ordinal_contains(s.trace_id, @{parameterName}) OR + ordinal_contains(sc.scope_name, @{parameterName}) OR + ordinal_contains(r.resource_name, @{parameterName}) OR + ordinal_contains(CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END, @{parameterName}) OR + ordinal_contains(CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END, @{parameterName}) OR + ordinal_contains(COALESCE(s.status_message, ''), @{parameterName}) OR + EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND (ordinal_contains(a.attribute_key, @{parameterName}) OR ordinal_contains(a.attribute_value, @{parameterName}))) OR + EXISTS (SELECT 1 FROM telemetry_span_events e WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id AND ordinal_contains(e.event_name, @{parameterName})) + ) + """); + } + sql.Append(" AND (("); + sql.AppendJoin(" AND ", fullNamePredicates); + sql.Append(") OR EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id WHERE s.trace_id = t.trace_id AND "); + sql.AppendJoin(" AND ", spanPredicates); + sql.Append("))"); + } + return new TraceQuery(sql.ToString(), parameters); + } + + private static string BuildTraceDurationPredicate(FieldTelemetryFilter filter, DynamicParameters parameters, int filterIndex) + { + var parameterName = $"TraceDuration{filterIndex}"; + if (!double.TryParse(filter.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var milliseconds) || !double.IsFinite(milliseconds)) + { + return "0 = 1"; + } + + parameters.Add(parameterName, milliseconds); + return BuildNumericPredicate($"(CAST(t.duration_ticks AS REAL) / {TimeSpan.TicksPerMillisecond})", filter.Condition, parameterName); + } + + private static string BuildSpanFieldPredicate( + FieldTelemetryFilter filter, + DynamicParameters parameters, + int filterIndex, + bool invertNegative) + { + var parameterName = $"TraceFilter{filterIndex}"; + var condition = invertNegative + ? filter.Condition switch + { + FilterCondition.NotEqual => FilterCondition.Equals, + FilterCondition.NotContains => FilterCondition.Contains, + _ => filter.Condition + } + : filter.Condition; + parameters.Add(parameterName, filter.Value); + + var expression = filter.Field switch + { + KnownResourceFields.ServiceNameField => null, + KnownTraceFields.TraceIdField => "s.trace_id", + KnownTraceFields.SpanIdField => "s.span_id", + KnownTraceFields.NameField => "s.name", + KnownTraceFields.KindField => "CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END", + KnownTraceFields.StatusField => "CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END", + KnownSourceFields.NameField => "sc.scope_name", + KnownTraceFields.TimestampField => "s.start_time_ticks / 10000", + _ => null + }; + if (filter.Field == KnownTraceFields.TimestampField) + { + if (!DateTime.TryParse(filter.Value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeLocal, out var date)) + { + return "0 = 1"; + } + parameters.Add(parameterName, date.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond); + return BuildNumericPredicate(expression!, condition, parameterName); + } + if (expression is not null) + { + return BuildStringPredicate(expression, condition, parameterName); + } + if (filter.Field == KnownResourceFields.ServiceNameField) + { + var sourcePredicate = BuildStringPredicate("r.resource_name", condition, parameterName); + var peerPredicate = BuildStringPredicate("pr.resource_name", condition, parameterName); + return $"({sourcePredicate} OR (pr.resource_id IS NOT NULL AND {peerPredicate}))"; + } + + var attributePredicate = BuildStringPredicate("a.attribute_value", condition, parameterName); + parameters.Add($"TraceField{filterIndex}", filter.Field); + return $"EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND a.attribute_key = @TraceField{filterIndex} COLLATE ORDINAL_IGNORE_CASE AND {attributePredicate})"; + } + + private GetSpansResponse GetSpansFromDatabase(GetSpansRequest context) + { + using var connection = _database.OpenConnection(); + var query = BuildSpanQuery(context); + var totalCount = connection.QuerySingle($"SELECT COUNT(*) {query.FromAndWhere};", query.Parameters); + query.Parameters.Add("StartIndex", Math.Max(context.StartIndex, 0)); + query.Parameters.Add("Count", Math.Max(context.Count, 0)); + var identities = connection.Query($""" + SELECT s.trace_id AS TraceId, s.span_id AS SpanId + {query.FromAndWhere} + ORDER BY t.first_span_timestamp_ticks, t.insertion_sequence DESC, s.start_time_ticks, s.span_id + LIMIT @Count OFFSET @StartIndex; + """, query.Parameters).AsList(); + var traces = identities.Select(identity => identity.TraceId).Distinct(StringComparer.Ordinal) + .ToDictionary(traceId => traceId, traceId => MaterializeTrace(connection, traceId)!, StringComparer.Ordinal); + return new GetSpansResponse + { + PagedResult = new PagedResult + { + Items = identities.Select(identity => traces[identity.TraceId].Spans.Single(span => span.SpanId == identity.SpanId)).ToList(), + TotalItemCount = totalCount, + IsFull = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_traces;") >= _otlpContext.Options.MaxTraceCount + } + }; + } + + private static TraceQuery BuildSpanQuery(GetSpansRequest context) + { + var sql = new StringBuilder(""" + FROM telemetry_spans s + JOIN telemetry_traces t ON t.trace_id = s.trace_id + JOIN telemetry_resources r ON r.resource_id = s.resource_id + JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id + LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id + WHERE 1 = 1 + """); + var parameters = new DynamicParameters(); + if (context.ResourceKeys.Count > 0) + { + var predicates = new List(context.ResourceKeys.Count); + for (var index = 0; index < context.ResourceKeys.Count; index++) + { + var key = context.ResourceKeys[index]; + parameters.Add($"SpanResourceName{index}", key.Name); + var source = $"r.resource_name = @SpanResourceName{index} COLLATE ORDINAL_IGNORE_CASE"; + var peer = $"pr.resource_name = @SpanResourceName{index} COLLATE ORDINAL_IGNORE_CASE"; + if (key.InstanceId is not null) + { + parameters.Add($"SpanInstanceId{index}", key.InstanceId); + source += $" AND r.instance_id_is_null = 0 AND r.instance_id = @SpanInstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; + peer += $" AND pr.instance_id_is_null = 0 AND pr.instance_id = @SpanInstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; + } + predicates.Add($"(({source}) OR ({peer}))"); + } + sql.Append(" AND ("); + sql.AppendJoin(" OR ", predicates); + sql.Append(')'); + } + if (!string.IsNullOrEmpty(context.TraceId)) + { + parameters.Add("SpanTraceId", context.TraceId); + sql.Append(context.TraceId.Length >= OtlpHelpers.ShortenedIdLength + ? " AND ordinal_starts_with(s.trace_id, @SpanTraceId)" + : " AND s.trace_id = @SpanTraceId COLLATE ORDINAL_IGNORE_CASE"); + } + if (context.HasError is not null) + { + parameters.Add("SpanErrorStatus", (int)OtlpSpanStatusCode.Error); + sql.Append(context.HasError.Value ? " AND s.status = @SpanErrorStatus" : " AND s.status <> @SpanErrorStatus"); + } + + var filterIndex = 0; + foreach (var filter in context.Filters.Where(filter => filter.Enabled)) + { + if (filter is not FieldTelemetryFilter fieldFilter) + { + continue; + } + if (fieldFilter.Field == KnownTraceFields.DurationField) + { + var parameterName = $"SpanDuration{filterIndex++}"; + if (!double.TryParse(fieldFilter.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var milliseconds) || !double.IsFinite(milliseconds)) + { + sql.Append(" AND 0 = 1"); + continue; + } + parameters.Add(parameterName, milliseconds); + sql.Append(" AND "); + sql.Append(BuildNumericPredicate($"(CAST(s.end_time_ticks - s.start_time_ticks AS REAL) / {TimeSpan.TicksPerMillisecond})", fieldFilter.Condition, parameterName)); + continue; + } + + if (fieldFilter.Condition is FilterCondition.NotEqual or FilterCondition.NotContains && + fieldFilter.Field is not (KnownResourceFields.ServiceNameField or KnownTraceFields.TraceIdField or KnownTraceFields.SpanIdField or KnownTraceFields.NameField or KnownTraceFields.KindField or KnownTraceFields.StatusField or KnownSourceFields.NameField or KnownTraceFields.TimestampField)) + { + var violationFilter = new FieldTelemetryFilter + { + Field = fieldFilter.Field, + Condition = fieldFilter.Condition == FilterCondition.NotEqual ? FilterCondition.Equals : FilterCondition.Contains, + Value = fieldFilter.Value + }; + sql.Append(" AND NOT "); + sql.Append(BuildSpanFieldPredicate(violationFilter, parameters, filterIndex++, invertNegative: false)); + } + else + { + sql.Append(" AND "); + sql.Append(BuildSpanFieldPredicate(fieldFilter, parameters, filterIndex++, invertNegative: false)); + } + } + + if (context.TextFragments is { Length: > 0 }) + { + for (var index = 0; index < context.TextFragments.Length; index++) + { + var parameterName = $"SpanTextFragment{index}"; + parameters.Add(parameterName, context.TextFragments[index]); + sql.Append(CultureInfo.InvariantCulture, $""" + AND ( + ordinal_contains(s.name, @{parameterName}) OR + ordinal_contains(s.span_id, @{parameterName}) OR + ordinal_contains(s.trace_id, @{parameterName}) OR + ordinal_contains(sc.scope_name, @{parameterName}) OR + ordinal_contains(r.resource_name, @{parameterName}) OR + ordinal_contains(CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END, @{parameterName}) OR + ordinal_contains(CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END, @{parameterName}) OR + ordinal_contains(COALESCE(s.status_message, ''), @{parameterName}) OR + EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND (ordinal_contains(a.attribute_key, @{parameterName}) OR ordinal_contains(a.attribute_value, @{parameterName}))) OR + EXISTS (SELECT 1 FROM telemetry_span_events e WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id AND ordinal_contains(e.event_name, @{parameterName})) + ) + """); + } + } + return new TraceQuery(sql.ToString(), parameters); + } + + private List GetTracePropertyKeysFromDatabase(ResourceKey? resourceKey) + { + using var connection = _database.OpenConnection(); + var parameters = new DynamicParameters(); + var resourceWhere = string.Empty; + if (resourceKey is not null) + { + resourceWhere = " AND r.resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE"; + parameters.Add("ResourceName", resourceKey.Value.Name); + if (resourceKey.Value.InstanceId is not null) + { + resourceWhere += " AND r.instance_id_is_null = 0 AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; + parameters.Add("InstanceId", resourceKey.Value.InstanceId); + } + } + return connection.Query($""" + SELECT DISTINCT a.attribute_key + FROM telemetry_span_attributes a + JOIN telemetry_spans s ON s.trace_id = a.trace_id AND s.span_id = a.span_id + JOIN telemetry_resources r ON r.resource_id = s.resource_id + WHERE 1 = 1{resourceWhere} + ORDER BY a.attribute_key; + """, parameters).AsList(); + } + + private Dictionary GetTraceFieldValuesFromDatabase(string attributeName) + { + using var connection = _database.OpenConnection(); + var traces = connection.Query("SELECT trace_id FROM telemetry_traces ORDER BY first_span_timestamp_ticks, insertion_sequence DESC;") + .Select(traceId => MaterializeTrace(connection, traceId)!) + .ToList(); + return OtlpSpan.GetFieldValuesFromTraces(traces, attributeName); + } + + private bool HasUpdatedTraceInDatabase(OtlpTrace trace) + { + using var connection = _database.OpenConnection(); + var lastUpdatedTicks = connection.QuerySingleOrDefault(""" + SELECT last_updated_timestamp_ticks + FROM telemetry_traces + WHERE trace_id = @TraceId; + """, new { trace.TraceId }); + return lastUpdatedTicks is null || lastUpdatedTicks.Value > trace.LastUpdatedDate.Ticks; + } + + private OtlpTrace? GetTraceFromDatabase(string traceId) + { + using var connection = _database.OpenConnection(); + var storedTraceId = connection.QueryFirstOrDefault(traceId.Length >= OtlpHelpers.ShortenedIdLength + ? "SELECT trace_id FROM telemetry_traces WHERE ordinal_starts_with(trace_id, @TraceId) ORDER BY first_span_timestamp_ticks, insertion_sequence DESC LIMIT 1;" + : "SELECT trace_id FROM telemetry_traces WHERE trace_id = @TraceId COLLATE ORDINAL_IGNORE_CASE ORDER BY first_span_timestamp_ticks, insertion_sequence DESC LIMIT 1;", new { TraceId = traceId }); + return storedTraceId is null ? null : MaterializeTrace(connection, storedTraceId); + } + + private OtlpSpan? GetSpanFromDatabase(string traceId, string spanId) + { + var trace = GetTraceFromDatabase(traceId); + return trace?.Spans.FirstOrDefault(span => span.SpanId == spanId); + } + + private OtlpTrace? MaterializeTrace(SqliteConnection connection, string traceId, IDbTransaction? transaction = null) + { + var records = connection.Query(""" + SELECT + s.trace_id AS TraceId, + s.span_id AS SpanId, + s.parent_span_id AS ParentSpanId, + s.resource_id AS ResourceId, + s.resource_view_id AS ResourceViewId, + s.scope_id AS ScopeId, + s.name AS Name, + s.kind AS Kind, + s.start_time_ticks AS StartTimeTicks, + s.end_time_ticks AS EndTimeTicks, + s.status AS Status, + s.status_message AS StatusMessage, + s.trace_state AS State, + s.uninstrumented_peer_resource_id AS PeerResourceId, + t.last_updated_timestamp_ticks AS LastUpdatedTimestampTicks, + r.resource_name AS ResourceName, + r.instance_id AS InstanceId, + r.instance_id_is_null AS InstanceIdIsNull, + r.uninstrumented_peer AS UninstrumentedPeer, + r.has_logs AS HasLogs, + r.has_traces AS HasTraces, + r.has_metrics AS HasMetrics, + sc.scope_name AS ScopeName, + sc.scope_version AS ScopeVersion, + pr.resource_name AS PeerResourceName, + pr.instance_id AS PeerInstanceId, + pr.instance_id_is_null AS PeerInstanceIdIsNull + FROM telemetry_spans s + JOIN telemetry_traces t ON t.trace_id = s.trace_id + JOIN telemetry_resources r ON r.resource_id = s.resource_id + JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id + LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id + WHERE s.trace_id = @TraceId + ORDER BY s.start_time_ticks, s.span_id; + """, new { TraceId = traceId }, transaction).AsList(); + if (records.Count == 0) + { + return null; + } + + var spanIds = records.Select(record => record.SpanId).ToArray(); + var viewIds = records.Select(record => record.ResourceViewId).Distinct().ToArray(); + var scopeIds = records.Select(record => record.ScopeId).Distinct().ToArray(); + var spanAttributes = connection.Query(""" + SELECT span_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_span_attributes + WHERE trace_id = @TraceId AND span_id IN @SpanIds + ORDER BY span_id, ordinal; + """, new { TraceId = traceId, SpanIds = spanIds }, transaction).ToLookup(record => record.OwnerId); + var viewAttributes = connection.Query(""" + SELECT resource_view_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_resource_view_attributes + WHERE resource_view_id IN @Ids + ORDER BY resource_view_id, ordinal; + """, new { Ids = viewIds }, transaction).ToLookup(record => record.OwnerId); + var scopeAttributes = connection.Query(""" + SELECT scope_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_scope_attributes + WHERE scope_id IN @Ids + ORDER BY scope_id, ordinal; + """, new { Ids = scopeIds }, transaction).ToLookup(record => record.OwnerId); + var eventRecords = connection.Query(""" + SELECT event_id AS EventId, span_id AS SpanId, event_name AS EventName, event_time_ticks AS EventTimeTicks + FROM telemetry_span_events + WHERE trace_id = @TraceId + ORDER BY span_id, ordinal; + """, new { TraceId = traceId }, transaction).AsList(); + var eventAttributes = connection.Query(""" + SELECT event_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_span_event_attributes + WHERE event_id IN @Ids + ORDER BY event_id, ordinal; + """, new { Ids = eventRecords.Select(record => record.EventId).ToArray() }, transaction).ToLookup(record => record.OwnerId); + var events = eventRecords.ToLookup(record => record.SpanId); + var linkRecords = connection.Query(""" + SELECT + link_id AS LinkId, + source_trace_id AS SourceTraceId, + source_span_id AS SourceSpanId, + target_trace_id AS TraceId, + target_span_id AS SpanId, + trace_state AS TraceState + FROM telemetry_span_links + WHERE source_trace_id = @TraceId OR target_trace_id = @TraceId + ORDER BY link_id; + """, new { TraceId = traceId }, transaction).AsList(); + var linkAttributes = connection.Query(""" + SELECT link_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_span_link_attributes + WHERE link_id IN @Ids + ORDER BY link_id, ordinal; + """, new { Ids = linkRecords.Select(record => record.LinkId).ToArray() }, transaction).ToLookup(record => record.OwnerId); + var outgoingLinks = linkRecords.Where(record => record.SourceTraceId == traceId).ToLookup(record => record.SourceSpanId); + var incomingLinks = linkRecords.Where(record => record.TraceId == traceId).ToLookup(record => record.SpanId); + + var trace = new OtlpTrace(Convert.FromHexString(traceId), new DateTime(records[0].LastUpdatedTimestampTicks, DateTimeKind.Utc)); + var resources = new Dictionary(); + var views = new Dictionary(); + var scopes = new Dictionary(); + foreach (var record in records) + { + if (!resources.TryGetValue(record.ResourceId, out var resource)) + { + resource = new OtlpResource(record.ResourceName, record.InstanceIdIsNull ? null : record.InstanceId, record.UninstrumentedPeer, _otlpContext) + { + HasLogs = record.HasLogs, + HasTraces = record.HasTraces, + HasMetrics = record.HasMetrics + }; + resources.Add(record.ResourceId, resource); + } + if (!views.TryGetValue(record.ResourceViewId, out var view)) + { + view = new OtlpResourceView(resource, ToPairs(viewAttributes[record.ResourceViewId])); + views.Add(record.ResourceViewId, view); + } + if (!scopes.TryGetValue(record.ScopeId, out var scope)) + { + var attributes = ToPairs(scopeAttributes[record.ScopeId]); + scope = record.ScopeName == OtlpScope.Empty.Name && record.ScopeVersion.Length == 0 && attributes.Length == 0 + ? OtlpScope.Empty + : new OtlpScope(record.ScopeName, record.ScopeVersion, attributes); + scopes.Add(record.ScopeId, scope); + } + + var modelSpan = new OtlpSpan(view, trace, scope) + { + SpanId = record.SpanId, + ParentSpanId = record.ParentSpanId, + Name = record.Name, + Kind = (OtlpSpanKind)record.Kind, + StartTime = new DateTime(record.StartTimeTicks, DateTimeKind.Utc), + EndTime = new DateTime(record.EndTimeTicks, DateTimeKind.Utc), + Status = (OtlpSpanStatusCode)record.Status, + StatusMessage = record.StatusMessage, + State = record.State, + Attributes = ToPairs(spanAttributes[record.SpanId]), + Events = [], + Links = outgoingLinks[record.SpanId].Select(CreateLink).ToList(), + BackLinks = incomingLinks[record.SpanId].Select(CreateLink).ToList() + }; + if (record.PeerResourceId is not null) + { + modelSpan.SetUninstrumentedPeer(new OtlpResource( + record.PeerResourceName!, + record.PeerInstanceIdIsNull ? null : record.PeerInstanceId, + uninstrumentedPeer: true, + _otlpContext)); + } + modelSpan.Events.AddRange(events[record.SpanId].Select(spanEvent => new OtlpSpanEvent(modelSpan) + { + InternalId = Guid.Parse(spanEvent.EventId), + Name = spanEvent.EventName, + Time = new DateTime(spanEvent.EventTimeTicks, DateTimeKind.Utc), + Attributes = ToPairs(eventAttributes[spanEvent.EventId]) + })); + trace.AddSpan(modelSpan, skipLastUpdatedDate: true); + } + return trace; + + OtlpSpanLink CreateLink(SpanLinkRecord link) + { + return new OtlpSpanLink + { + SourceTraceId = link.SourceTraceId, + SourceSpanId = link.SourceSpanId, + TraceId = link.TraceId, + SpanId = link.SpanId, + TraceState = link.TraceState, + Attributes = ToPairs(linkAttributes[link.LinkId]) + }; + } + + static KeyValuePair[] ToPairs(IEnumerable attributes) + { + return attributes.Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray(); + } + } + + private void ClearSelectedTracesFromDatabase(Dictionary> selectedResources) + { + using var connection = _database.OpenConnection(); + var resources = connection.Query(""" + SELECT resource_name AS ResourceName, instance_id AS InstanceId, instance_id_is_null AS InstanceIdIsNull + FROM telemetry_resources; + """); + foreach (var resource in resources) + { + var key = new ResourceKey(resource.ResourceName, resource.InstanceIdIsNull ? null : resource.InstanceId); + if (selectedResources.TryGetValue(key.GetCompositeName(), out var dataTypes) && + dataTypes.Contains(AspireDataType.Traces) && + !dataTypes.Contains(AspireDataType.Resource)) + { + ClearTracesFromDatabase(key); + } + } + } + + private void RecalculateUninstrumentedPeers() + { + lock (_writeLock) + { + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + foreach (var traceId in connection.Query("SELECT trace_id FROM telemetry_traces;", transaction: transaction)) + { + var trace = MaterializeTrace(connection, traceId, transaction)!; + UpdateUninstrumentedPeers(connection, transaction, trace); + connection.Execute(""" + UPDATE telemetry_traces + SET last_updated_timestamp_ticks = @LastUpdatedTimestampTicks + WHERE trace_id = @TraceId; + """, new { TraceId = traceId, LastUpdatedTimestampTicks = trace.LastUpdatedDate.Ticks }, transaction); + } + transaction.Commit(); + } + } + + private void ClearTracesFromDatabase(ResourceKey? resourceKey) + { + lock (_writeLock) + { + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + var parameters = new DynamicParameters(); + var where = string.Empty; + if (resourceKey is not null) + { + where = " WHERE resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE"; + parameters.Add("ResourceName", resourceKey.Value.Name); + if (resourceKey.Value.InstanceId is not null) + { + where += " AND instance_id_is_null = 0 AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; + parameters.Add("InstanceId", resourceKey.Value.InstanceId); + } + } + parameters.Add("ClearAll", resourceKey is null); + + connection.Execute($""" + DELETE FROM telemetry_traces + WHERE @ClearAll OR trace_id IN ( + SELECT DISTINCT trace_id + FROM telemetry_spans + WHERE resource_id IN (SELECT resource_id FROM telemetry_resources{where}) + ); + + DELETE FROM telemetry_resource_views + WHERE NOT EXISTS (SELECT 1 FROM telemetry_logs WHERE telemetry_logs.resource_view_id = telemetry_resource_views.resource_view_id) + AND NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_view_id = telemetry_resource_views.resource_view_id); + + UPDATE telemetry_resources + SET has_traces = EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_id = telemetry_resources.resource_id); + """, parameters, transaction); + DeleteOrphanedScopes(connection, transaction); + transaction.Commit(); + } + } + + private sealed record TraceQuery(string FromAndWhere, DynamicParameters Parameters); + + private sealed class TraceAggregateRecord + { + public required int TotalItemCount { get; init; } + public required long MaxDurationTicks { get; init; } + } + + private sealed class TraceSummaryRecord + { + public required string TraceId { get; init; } + public required long LastUpdatedTimestampTicks { get; init; } + } + + private sealed class SpanIdentityRecord + { + public required string TraceId { get; init; } + public required string SpanId { get; init; } + } + + private sealed class TraceOwnedAttributeRecord : AttributeRecord + { + public required string OwnerId { get; init; } + } + + private sealed class TextOwnedAttributeRecord : AttributeRecord + { + public required string OwnerId { get; init; } + } + + private sealed class LongOwnedAttributeRecord : AttributeRecord + { + public required long OwnerId { get; init; } + } + + private sealed class SpanEventRecord + { + public required string EventId { get; init; } + public required string SpanId { get; init; } + public required string EventName { get; init; } + public required long EventTimeTicks { get; init; } + } + + private sealed class SpanLinkRecord + { + public required long LinkId { get; init; } + public required string SourceTraceId { get; init; } + public required string SourceSpanId { get; init; } + public required string TraceId { get; init; } + public required string SpanId { get; init; } + public required string TraceState { get; init; } + } + + private sealed class SpanRecord + { + public required string TraceId { get; init; } + public required string SpanId { get; init; } + public string? ParentSpanId { get; init; } + public required long ResourceId { get; init; } + public required long ResourceViewId { get; init; } + public required long ScopeId { get; init; } + public required string Name { get; init; } + public required int Kind { get; init; } + public required long StartTimeTicks { get; init; } + public required long EndTimeTicks { get; init; } + public required int Status { get; init; } + public string? StatusMessage { get; init; } + public string? State { get; init; } + public long? PeerResourceId { get; init; } + public required long LastUpdatedTimestampTicks { get; init; } + public required string ResourceName { get; init; } + public required string InstanceId { get; init; } + public required bool InstanceIdIsNull { get; init; } + public required bool UninstrumentedPeer { get; init; } + public required bool HasLogs { get; init; } + public required bool HasTraces { get; init; } + public required bool HasMetrics { get; init; } + public required string ScopeName { get; init; } + public required string ScopeVersion { get; init; } + public string? PeerResourceName { get; init; } + public string? PeerInstanceId { get; init; } + public bool PeerInstanceIdIsNull { get; init; } + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs new file mode 100644 index 00000000000..d482e24e66a --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -0,0 +1,203 @@ +// 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.Configuration; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Google.Protobuf.Collections; +using Microsoft.Extensions.Options; +using OpenTelemetry.Proto.Logs.V1; +using OpenTelemetry.Proto.Metrics.V1; +using OpenTelemetry.Proto.Trace.V1; + +namespace Aspire.Dashboard.Otlp.Storage; + +/// +/// Persists telemetry to SQLite and exposes it through the dashboard telemetry model. +/// +public sealed partial class SqliteTelemetryRepository : ITelemetryRepository +{ + private readonly DashboardSqliteDatabase _database; + private readonly OtlpContext _otlpContext; + private readonly PauseManager _pauseManager; + private readonly IReadOnlyList _outgoingPeerResolvers; + private readonly List _outgoingPeerSubscriptions = []; + private readonly object _writeLock = new(); + + public SqliteTelemetryRepository( + string databasePath, + ILoggerFactory loggerFactory, + IOptions dashboardOptions, + PauseManager pauseManager, + IEnumerable outgoingPeerResolvers, + bool readOnly = false) + : this(new DashboardSqliteDatabase(databasePath, readOnly), loggerFactory, dashboardOptions, pauseManager, outgoingPeerResolvers) + { + } + + internal SqliteTelemetryRepository( + DashboardSqliteDatabase database, + ILoggerFactory loggerFactory, + IOptions dashboardOptions, + PauseManager pauseManager, + IEnumerable outgoingPeerResolvers) + { + _database = database; + _pauseManager = pauseManager; + _outgoingPeerResolvers = outgoingPeerResolvers.ToList(); + _otlpContext = new OtlpContext + { + Logger = loggerFactory.CreateLogger(), + Options = dashboardOptions.Value.TelemetryLimits + }; + + if (!database.IsReadOnly) + { + database.InitializeSchema(); + foreach (var resolver in _outgoingPeerResolvers) + { + _outgoingPeerSubscriptions.Add(resolver.OnPeerChanges(() => + { + RecalculateUninstrumentedPeers(); + return Task.CompletedTask; + })); + } + } + + } + + public List GetResources(bool includeUninstrumentedPeers = false) => GetTelemetryResources(includeUninstrumentedPeers, name: null); + public List GetResourcesByName(string name, bool includeUninstrumentedPeers = false) => GetTelemetryResources(includeUninstrumentedPeers, name); + public OtlpResource? GetResourceByCompositeName(string compositeName) => GetResources(includeUninstrumentedPeers: true).SingleOrDefault(resource => resource.ResourceKey.EqualsCompositeName(compositeName)); + public OtlpResource? GetResource(ResourceKey key) => GetResources(includeUninstrumentedPeers: true).SingleOrDefault(resource => resource.ResourceKey == key); + public List GetResources(ResourceKey key, bool includeUninstrumentedPeers = false) + { + return key.InstanceId is null + ? GetResourcesByName(key.Name, includeUninstrumentedPeers) + : GetResources(includeUninstrumentedPeers).Where(resource => resource.ResourceKey == key).ToList(); + } + public void AddLogs(AddContext context, RepeatedField resourceLogs) + { + if (_pauseManager.AreStructuredLogsPaused(out _)) + { + _otlpContext.Logger.LogTrace("{Count} incoming structured log resource(s) ignored because of an active pause.", resourceLogs.Count); + return; + } + + EnsureWritable(); + NotifyLogsAdded(AddLogsToDatabase(context, resourceLogs)); + } + + public void AddMetrics(AddContext context, RepeatedField resourceMetrics) + { + if (_pauseManager.AreMetricsPaused(out _)) + { + _otlpContext.Logger.LogTrace("{Count} incoming metric resource(s) ignored because of an active pause.", resourceMetrics.Count); + return; + } + + EnsureWritable(); + var successCount = context.SuccessCount; + AddMetricsToDatabase(context, resourceMetrics); + if (context.SuccessCount > successCount) + { + NotifyMetricsAdded(); + } + } + + public void AddTraces(AddContext context, RepeatedField resourceSpans) + { + if (_pauseManager.AreTracesPaused(out _)) + { + _otlpContext.Logger.LogTrace("{Count} incoming trace resource(s) ignored because of an active pause.", resourceSpans.Count); + return; + } + + EnsureWritable(); + NotifySpansAdded(AddTracesToDatabase(context, resourceSpans)); + } + + public PagedResult GetLogs(GetLogsContext context) => GetLogsFromDatabase(context); + public OtlpLogEntry? GetLog(long logId) => GetLogFromDatabase(logId); + public List GetLogsForSpan(string traceId, string spanId) => GetLogsForSpanFromDatabase(traceId, spanId); + public List GetLogsForTrace(string traceId) => GetLogsForTraceFromDatabase(traceId); + public List GetLogPropertyKeys(ResourceKey? resourceKey) => GetLogPropertyKeysFromDatabase(resourceKey); + public List GetTracePropertyKeys(ResourceKey? resourceKey) => GetTracePropertyKeysFromDatabase(resourceKey); + public GetTracesResponse GetTraces(GetTracesRequest context) => GetTracesFromDatabase(context); + public GetSpansResponse GetSpans(GetSpansRequest context) => GetSpansFromDatabase(context); + public Dictionary GetTraceFieldValues(string attributeName) => GetTraceFieldValuesFromDatabase(attributeName); + public Dictionary GetLogsFieldValues(string attributeName) => GetLogsFieldValuesFromDatabase(attributeName); + public bool HasUpdatedTrace(OtlpTrace trace) => HasUpdatedTraceInDatabase(trace); + public OtlpTrace? GetTrace(string traceId) => GetTraceFromDatabase(traceId); + public OtlpSpan? GetSpan(string traceId, string spanId) => GetSpanFromDatabase(traceId, spanId); + public OtlpResource? GetPeerResource(OtlpSpan span) + { + if (span.UninstrumentedPeer is not null) + { + return span.UninstrumentedPeer; + } + + foreach (var resolver in _outgoingPeerResolvers) + { + if (resolver.TryResolvePeer(span.Attributes, out _, out var matchedResource) && matchedResource is not null) + { + return GetResource(ResourceKey.Create(matchedResource.DisplayName, matchedResource.Name)); + } + } + + return null; + } + public List GetInstrumentsSummaries(ResourceKey key) => GetInstrumentsSummariesFromDatabase(key); + public OtlpInstrumentData? GetInstrument(GetInstrumentRequest request) => GetInstrumentFromDatabase(request); + public void ClearSelectedSignals(Dictionary> selectedResources) + { + ClearSelectedLogsFromDatabase(selectedResources); + ClearSelectedTracesFromDatabase(selectedResources); + ClearSelectedMetricsFromDatabase(selectedResources); + ClearUnviewedErrorCounts(selectedResources); + RaiseSubscriptionChanged(_logSubscriptions); + RaiseSubscriptionChanged(_tracesSubscriptions); + RaiseSubscriptionChanged(_metricsSubscriptions); + RaiseSubscriptionChanged(_resourceSubscriptions); + } + + public void ClearTraces(ResourceKey? resourceKey = null) + { + EnsureWritable(); + ClearTracesFromDatabase(resourceKey); + RaiseSubscriptionChanged(_tracesSubscriptions); + RaiseSubscriptionChanged(_resourceSubscriptions); + } + + public void ClearStructuredLogs(ResourceKey? resourceKey = null) + { + EnsureWritable(); + ClearStructuredLogsFromDatabase(resourceKey); + ClearUnviewedErrorCounts(resourceKey); + RaiseSubscriptionChanged(_logSubscriptions); + RaiseSubscriptionChanged(_resourceSubscriptions); + } + + public void ClearMetrics(ResourceKey? resourceKey = null) + { + EnsureWritable(); + ClearMetricsFromDatabase(resourceKey); + RaiseSubscriptionChanged(_metricsSubscriptions); + RaiseSubscriptionChanged(_resourceSubscriptions); + } + + private void EnsureWritable() + { + _database.EnsureWritable("Historical dashboard telemetry is read-only."); + } + + public void Dispose() + { + foreach (var subscription in _outgoingPeerSubscriptions) + { + subscription.Dispose(); + } + DisposeWatchers(); + } + +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/Subscription.cs b/src/Aspire.Dashboard/Otlp/Storage/Subscription.cs index 3d0ebdcaa7d..ca7dd7c072f 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/Subscription.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/Subscription.cs @@ -24,12 +24,12 @@ public sealed class Subscription : IDisposable public SubscriptionType SubscriptionType { get; } public string Name { get; } - public Subscription(string name, ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback, Action unsubscribe, ExecutionContext? executionContext, TelemetryRepository telemetryRepository) + public Subscription(string name, ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback, Action unsubscribe, ExecutionContext? executionContext, ILogger logger, TimeSpan minExecuteInterval) { Name = name; ResourceKey = resourceKey; SubscriptionType = subscriptionType; - _callbackThrottler = new CallbackThrottler(name, telemetryRepository._otlpContext.Logger, telemetryRepository._subscriptionMinExecuteInterval, callback, executionContext); + _callbackThrottler = new CallbackThrottler(name, logger, minExecuteInterval, callback, executionContext); _unsubscribe = unsubscribe; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepositoryLimits.cs b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepositoryLimits.cs new file mode 100644 index 00000000000..f1db1369f20 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepositoryLimits.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Otlp.Storage; + +internal static class TelemetryRepositoryLimits +{ + public const int MaxResourceViewCount = 10_000; + public const int MaxInstrumentCount = 10_000; + public const int MaxScopeCount = 10_000; + public const int MaxDimensionCount = 10_000; + public const int MaxKnownAttributeValueCount = 10_000; + public const int MaxKnownAttributeValuesPerKey = 10_000; +} diff --git a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs index 6cae3205282..ffca273d72c 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs @@ -1115,6 +1115,69 @@ public static string SettingsDialogDashboardLogsAndTelemetry { return ResourceManager.GetString("SettingsDialogDashboardLogsAndTelemetry", resourceCulture); } } + + /// + /// Looks up a localized string similar to Select dashboard run. + /// + public static string DashboardRunsDialogLaunchButton { + get { + return ResourceManager.GetString("DashboardRunsDialogLaunchButton", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Started {0}. + /// + public static string DashboardRunsDialogStartedAt { + get { + return ResourceManager.GetString("DashboardRunsDialogStartedAt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Dashboard runs. + /// + public static string DashboardRunsDialogTitle { + get { + return ResourceManager.GetString("DashboardRunsDialogTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} runs:. + /// + public static string SettingsDialogDashboardRun { + get { + return ResourceManager.GetString("SettingsDialogDashboardRun", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Current. + /// + public static string SettingsDialogDashboardRunCurrent { + get { + return ResourceManager.GetString("SettingsDialogDashboardRunCurrent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Historical runs are read-only. The page will reload when the selected run changes.. + /// + public static string SettingsDialogDashboardRunPageReloads { + get { + return ResourceManager.GetString("SettingsDialogDashboardRunPageReloads", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Aspire. + /// + public static string SettingsDialogDashboardRunUnknownApplication { + get { + return ResourceManager.GetString("SettingsDialogDashboardRunUnknownApplication", resourceCulture); + } + } /// /// Looks up a localized string similar to Runtime: {0}. diff --git a/src/Aspire.Dashboard/Resources/Dialogs.resx b/src/Aspire.Dashboard/Resources/Dialogs.resx index 93a016c52f7..df4d8c806c1 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.resx +++ b/src/Aspire.Dashboard/Resources/Dialogs.resx @@ -159,6 +159,29 @@ Theme + + Select dashboard run + + + Started {0} + {0} is the run start time. + + + Dashboard runs + + + {0} runs: + {0} is the application name. + + + Current + + + Historical runs are read-only. The page will reload when the selected run changes. + + + Aspire + Version: {0} {0} is the dashboard version string diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf index b2e6c8455e5..70f3be57115 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf @@ -89,6 +89,21 @@ For more information about using AI agents with the dashboard, including setting AI agents + + Select dashboard run + Select dashboard run + + + + Started {0} + Started {0} + {0} is the run start time. + + + Dashboard runs + Dashboard runs + + Close Zavřít @@ -634,6 +649,26 @@ For more information about using AI agents with the dashboard, including setting Protokoly prostředků a telemetrie + + {0} runs: + {0} runs: + {0} is the application name. + + + Current + Current + + + + Historical runs are read-only. The page will reload when the selected run changes. + Historical runs are read-only. The page will reload when the selected run changes. + + + + Aspire + Aspire + + Runtime: {0} Modul runtime: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf index c9e3664a8b4..0cf7ee39da5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf @@ -89,6 +89,21 @@ For more information about using AI agents with the dashboard, including setting AI agents + + Select dashboard run + Select dashboard run + + + + Started {0} + Started {0} + {0} is the run start time. + + + Dashboard runs + Dashboard runs + + Close Schließen @@ -634,6 +649,26 @@ For more information about using AI agents with the dashboard, including setting Ressourcenprotokolle und Telemetrie + + {0} runs: + {0} runs: + {0} is the application name. + + + Current + Current + + + + Historical runs are read-only. The page will reload when the selected run changes. + Historical runs are read-only. The page will reload when the selected run changes. + + + + Aspire + Aspire + + Runtime: {0} Laufzeit: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf index f5e3f5388df..1f53182c55f 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf @@ -89,6 +89,21 @@ For more information about using AI agents with the dashboard, including setting AI agents + + Select dashboard run + Select dashboard run + + + + Started {0} + Started {0} + {0} is the run start time. + + + Dashboard runs + Dashboard runs + + Close Cerrar @@ -634,6 +649,26 @@ For more information about using AI agents with the dashboard, including setting Registros de recursos y telemetría + + {0} runs: + {0} runs: + {0} is the application name. + + + Current + Current + + + + Historical runs are read-only. The page will reload when the selected run changes. + Historical runs are read-only. The page will reload when the selected run changes. + + + + Aspire + Aspire + + Runtime: {0} Runtime: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf index b0f9201311a..15dfe235aa1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf @@ -89,6 +89,21 @@ For more information about using AI agents with the dashboard, including setting AI agents + + Select dashboard run + Select dashboard run + + + + Started {0} + Started {0} + {0} is the run start time. + + + Dashboard runs + Dashboard runs + + Close Fermer @@ -634,6 +649,26 @@ For more information about using AI agents with the dashboard, including setting Journaux d’activité de ressources et télémétrie + + {0} runs: + {0} runs: + {0} is the application name. + + + Current + Current + + + + Historical runs are read-only. The page will reload when the selected run changes. + Historical runs are read-only. The page will reload when the selected run changes. + + + + Aspire + Aspire + + Runtime: {0} Runtime : {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf index bbe37fdcd76..e90bcd9b3e7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf @@ -89,6 +89,21 @@ For more information about using AI agents with the dashboard, including setting AI agents + + Select dashboard run + Select dashboard run + + + + Started {0} + Started {0} + {0} is the run start time. + + + Dashboard runs + Dashboard runs + + Close Chiudi @@ -634,6 +649,26 @@ For more information about using AI agents with the dashboard, including setting Log delle risorse e telemetria + + {0} runs: + {0} runs: + {0} is the application name. + + + Current + Current + + + + Historical runs are read-only. The page will reload when the selected run changes. + Historical runs are read-only. The page will reload when the selected run changes. + + + + Aspire + Aspire + + Runtime: {0} Runtime: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf index b013d625481..05d5e624083 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf @@ -89,6 +89,21 @@ For more information about using AI agents with the dashboard, including setting AI agents + + Select dashboard run + Select dashboard run + + + + Started {0} + Started {0} + {0} is the run start time. + + + Dashboard runs + Dashboard runs + + Close 閉じる @@ -634,6 +649,26 @@ For more information about using AI agents with the dashboard, including setting リソース ログとテレメトリ + + {0} runs: + {0} runs: + {0} is the application name. + + + Current + Current + + + + Historical runs are read-only. The page will reload when the selected run changes. + Historical runs are read-only. The page will reload when the selected run changes. + + + + Aspire + Aspire + + Runtime: {0} ランタイム: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf index d4194f4f585..db01d0def43 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf @@ -89,6 +89,21 @@ For more information about using AI agents with the dashboard, including setting AI agents + + Select dashboard run + Select dashboard run + + + + Started {0} + Started {0} + {0} is the run start time. + + + Dashboard runs + Dashboard runs + + Close 닫기 @@ -634,6 +649,26 @@ For more information about using AI agents with the dashboard, including setting 리소스 로그 및 원격 분석 + + {0} runs: + {0} runs: + {0} is the application name. + + + Current + Current + + + + Historical runs are read-only. The page will reload when the selected run changes. + Historical runs are read-only. The page will reload when the selected run changes. + + + + Aspire + Aspire + + Runtime: {0} 런타임: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf index 83b3a751b7c..e979d07602e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf @@ -89,6 +89,21 @@ For more information about using AI agents with the dashboard, including setting AI agents + + Select dashboard run + Select dashboard run + + + + Started {0} + Started {0} + {0} is the run start time. + + + Dashboard runs + Dashboard runs + + Close Zamknij @@ -634,6 +649,26 @@ For more information about using AI agents with the dashboard, including setting Dzienniki zasobów i dane telemetryczne + + {0} runs: + {0} runs: + {0} is the application name. + + + Current + Current + + + + Historical runs are read-only. The page will reload when the selected run changes. + Historical runs are read-only. The page will reload when the selected run changes. + + + + Aspire + Aspire + + Runtime: {0} Środowisko uruchomieniowe: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf index c63e3536bbd..f5ef4cb7302 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf @@ -89,6 +89,21 @@ For more information about using AI agents with the dashboard, including setting AI agents + + Select dashboard run + Select dashboard run + + + + Started {0} + Started {0} + {0} is the run start time. + + + Dashboard runs + Dashboard runs + + Close Fechar @@ -634,6 +649,26 @@ For more information about using AI agents with the dashboard, including setting Logs e telemetria do recurso + + {0} runs: + {0} runs: + {0} is the application name. + + + Current + Current + + + + Historical runs are read-only. The page will reload when the selected run changes. + Historical runs are read-only. The page will reload when the selected run changes. + + + + Aspire + Aspire + + Runtime: {0} Tempo de execução: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf index 4b47e0562b6..46329ceb3fe 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf @@ -89,6 +89,21 @@ For more information about using AI agents with the dashboard, including setting AI agents + + Select dashboard run + Select dashboard run + + + + Started {0} + Started {0} + {0} is the run start time. + + + Dashboard runs + Dashboard runs + + Close Закрыть @@ -634,6 +649,26 @@ For more information about using AI agents with the dashboard, including setting Журналы ресурсов и телеметрия + + {0} runs: + {0} runs: + {0} is the application name. + + + Current + Current + + + + Historical runs are read-only. The page will reload when the selected run changes. + Historical runs are read-only. The page will reload when the selected run changes. + + + + Aspire + Aspire + + Runtime: {0} Среда выполнения: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf index 108cfc53ec4..0cdd6722177 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf @@ -89,6 +89,21 @@ For more information about using AI agents with the dashboard, including setting AI agents + + Select dashboard run + Select dashboard run + + + + Started {0} + Started {0} + {0} is the run start time. + + + Dashboard runs + Dashboard runs + + Close Kapat @@ -634,6 +649,26 @@ For more information about using AI agents with the dashboard, including setting Kaynak günlükleri ve telemetri + + {0} runs: + {0} runs: + {0} is the application name. + + + Current + Current + + + + Historical runs are read-only. The page will reload when the selected run changes. + Historical runs are read-only. The page will reload when the selected run changes. + + + + Aspire + Aspire + + Runtime: {0} Çalışma Zamanı: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf index a07b6d553ae..f36ccb1a428 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf @@ -89,6 +89,21 @@ For more information about using AI agents with the dashboard, including setting AI agents + + Select dashboard run + Select dashboard run + + + + Started {0} + Started {0} + {0} is the run start time. + + + Dashboard runs + Dashboard runs + + Close 关闭 @@ -634,6 +649,26 @@ For more information about using AI agents with the dashboard, including setting 资源日志和遥测 + + {0} runs: + {0} runs: + {0} is the application name. + + + Current + Current + + + + Historical runs are read-only. The page will reload when the selected run changes. + Historical runs are read-only. The page will reload when the selected run changes. + + + + Aspire + Aspire + + Runtime: {0} 运行时: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf index 4d7d545e332..6ef54a17ea3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf @@ -89,6 +89,21 @@ For more information about using AI agents with the dashboard, including setting AI agents + + Select dashboard run + Select dashboard run + + + + Started {0} + Started {0} + {0} is the run start time. + + + Dashboard runs + Dashboard runs + + Close 關閉 @@ -634,6 +649,26 @@ For more information about using AI agents with the dashboard, including setting 資源記錄與遙測 + + {0} runs: + {0} runs: + {0} is the application name. + + + Current + Current + + + + Historical runs are read-only. The page will reload when the selected run changes. + Historical runs are read-only. The page will reload when the selected run changes. + + + + Aspire + Aspire + + Runtime: {0} 執行階段: {0} diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index c92afe65e11..aebeb734a15 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -36,7 +36,7 @@ namespace Aspire.Dashboard.ServiceClient; /// /// If the ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL environment variable is not specified, then there's /// no known endpoint to connect to, and this dashboard client will be disabled. Calls to -/// and +/// and /// will throw if is . Callers should /// check this property first, before calling these methods. /// @@ -66,6 +66,7 @@ internal sealed class DashboardClient : IDashboardClient private readonly DashboardOptions _dashboardOptions; private readonly IStringLocalizer _loc; private readonly ILogger _logger; + private readonly IResourceRepositoryWriter? _resourceRepositoryWriter; private ImmutableHashSet>> _outgoingResourceChannels = []; private ImmutableHashSet> _outgoingInteractionChannels = []; @@ -95,12 +96,14 @@ public DashboardClient( IOptions dashboardOptions, IKnownPropertyLookup knownPropertyLookup, IStringLocalizer loc, - Action? configureHttpHandler = null) + Action? configureHttpHandler = null, + IResourceRepositoryWriter? resourceRepositoryWriter = null) { _loggerFactory = loggerFactory; _knownPropertyLookup = knownPropertyLookup; _dashboardOptions = dashboardOptions.Value; _loc = loc; + _resourceRepositoryWriter = resourceRepositoryWriter; // Take a copy of the token and always use it to avoid race between disposal of CTS and usage of token. _clientCancellationToken = _cts.Token; @@ -630,6 +633,15 @@ private async Task WatchResourcesAsync(RetryContext retryContext, C } } + if (response.KindCase == WatchResourcesUpdate.KindOneofCase.InitialData) + { + _resourceRepositoryWriter?.ReplaceResources(response.InitialData.Resources); + } + else if (response.KindCase == WatchResourcesUpdate.KindOneofCase.Changes) + { + _resourceRepositoryWriter?.ApplyChanges(response.Changes.Value); + } + // Update connection state outside the lock to avoid potential deadlocks // if a subscriber tries to access DashboardClient state. if (shouldUpdateConnectionState) @@ -913,6 +925,7 @@ public async IAsyncEnumerable> SubscribeConsoleLo { await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken: combinedTokens.Token).ConfigureAwait(false)) { + _resourceRepositoryWriter?.AddConsoleLogs(resourceName, response.LogLines); // Channel is unbound so TryWrite always succeeds. channel.Writer.TryWrite(CreateLogLines(response.LogLines)); } @@ -944,6 +957,7 @@ public async IAsyncEnumerable> GetConsoleLogs(str await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken: combinedTokens.Token).ConfigureAwait(false)) { + _resourceRepositoryWriter?.AddConsoleLogs(resourceName, response.LogLines); yield return CreateLogLines(response.LogLines); } } diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs new file mode 100644 index 00000000000..26ceba3acc8 --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs @@ -0,0 +1,104 @@ +// 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.Configuration; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Storage; +using Microsoft.Extensions.Options; + +namespace Aspire.Dashboard.ServiceClient; + +internal interface IDashboardRunSelection +{ + void SelectRun(string? runId); +} + +internal sealed class DashboardDataSource : IDashboardRunSelection, IDisposable +{ + private readonly DashboardRunStore _runStore; + private readonly SqliteTelemetryRepository _currentTelemetryRepository; + private readonly SqliteResourceRepository _currentResourceRepository; + private readonly ILoggerFactory _loggerFactory; + private readonly IOptions _dashboardOptions; + private readonly PauseManager _pauseManager; + private readonly IEnumerable _outgoingPeerResolvers; + private readonly IKnownPropertyLookup _knownPropertyLookup; + + private SqliteTelemetryRepository? _historicalTelemetryRepository; + private SqliteResourceRepository? _historicalResourceRepository; + + public DashboardDataSource( + DashboardRunStore runStore, + SqliteTelemetryRepository currentTelemetryRepository, + SqliteResourceRepository currentResourceRepository, + ILoggerFactory loggerFactory, + IOptions dashboardOptions, + PauseManager pauseManager, + IEnumerable outgoingPeerResolvers, + IKnownPropertyLookup knownPropertyLookup) + { + _runStore = runStore; + _currentTelemetryRepository = currentTelemetryRepository; + _currentResourceRepository = currentResourceRepository; + _loggerFactory = loggerFactory; + _dashboardOptions = dashboardOptions; + _pauseManager = pauseManager; + _outgoingPeerResolvers = outgoingPeerResolvers; + _knownPropertyLookup = knownPropertyLookup; + + SelectRun(runId: null); + } + + public DashboardRunDescriptor SelectedRun { get; private set; } = null!; + public ITelemetryRepository TelemetryRepository { get; private set; } = null!; + public IResourceRepository ResourceRepository { get; private set; } = null!; + public bool IsReadOnly { get; private set; } + + public void SelectRun(string? runId) + { + var runs = _runStore.GetRuns(); + var selectedRun = runs.FirstOrDefault(run => string.Equals(run.RunId, runId, StringComparison.Ordinal)) + ?? runs.Single(run => run.IsCurrent); + if (SelectedRun?.RunId == selectedRun.RunId) + { + return; + } + + _historicalTelemetryRepository?.Dispose(); + _historicalResourceRepository?.Dispose(); + _historicalTelemetryRepository = null; + _historicalResourceRepository = null; + + if (!selectedRun.IsCurrent) + { + var historicalDatabase = new DashboardSqliteDatabase(selectedRun.DatabasePath, readOnly: true); + _historicalTelemetryRepository = new SqliteTelemetryRepository( + historicalDatabase, + _loggerFactory, + _dashboardOptions, + _pauseManager, + _outgoingPeerResolvers); + _historicalResourceRepository = new SqliteResourceRepository( + historicalDatabase, + _knownPropertyLookup, + _loggerFactory); + TelemetryRepository = _historicalTelemetryRepository; + ResourceRepository = _historicalResourceRepository; + IsReadOnly = true; + } + else + { + TelemetryRepository = _currentTelemetryRepository; + ResourceRepository = _currentResourceRepository; + IsReadOnly = false; + } + + SelectedRun = selectedRun; + } + + public void Dispose() + { + _historicalTelemetryRepository?.Dispose(); + _historicalResourceRepository?.Dispose(); + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs new file mode 100644 index 00000000000..6fcec10de1e --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -0,0 +1,173 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Hashing; +using System.Text; +using System.Text.Json; +using Aspire.Dashboard.Configuration; +using Microsoft.Extensions.Options; + +namespace Aspire.Dashboard.ServiceClient; + +internal interface IDashboardRunStore +{ + IReadOnlyList GetRuns(); +} + +internal sealed class DashboardRunStore : IDashboardRunStore, IDisposable +{ + internal const int MaxApplicationDirectoryNameLength = 80; + internal const int SchemaVersion = DashboardSqliteDatabase.SchemaVersion; + + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + + private readonly string _runsDirectory; + private readonly string _metadataPath; + private readonly DashboardRunMetadata _metadata; + + public DashboardRunStore(IOptions options) + { + var dataRoot = options.Value.Data.Directory; + if (string.IsNullOrWhiteSpace(dataRoot)) + { + dataRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Aspire", "Dashboard"); + } + + var applicationName = string.IsNullOrWhiteSpace(options.Value.ApplicationName) ? "Aspire" : options.Value.ApplicationName; + var applicationDirectoryName = GetApplicationDirectoryName(applicationName); + var startedAt = DateTimeOffset.UtcNow; + var runId = $"{startedAt:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}"; + _runsDirectory = Path.Combine(Path.GetFullPath(dataRoot), applicationDirectoryName, "runs"); + RunDirectory = Path.Combine(_runsDirectory, runId); + Directory.CreateDirectory(RunDirectory); + DatabasePath = Path.Combine(RunDirectory, "dashboard.db"); + _metadataPath = Path.Combine(RunDirectory, "run.json"); + _metadata = new DashboardRunMetadata + { + SchemaVersion = SchemaVersion, + RunId = runId, + StartedAtUtc = startedAt, + ApplicationName = options.Value.ApplicationName, + DatabaseFileName = Path.GetFileName(DatabasePath) + }; + WriteMetadata(_metadata); + } + + public string RunDirectory { get; } + public string DatabasePath { get; } + public string RunId => _metadata.RunId; + + public IReadOnlyList GetRuns() + { + var runs = new List + { + CreateDescriptor(_metadata, RunDirectory, isCurrent: true) + }; + + if (!Directory.Exists(_runsDirectory)) + { + return runs; + } + + foreach (var directory in Directory.EnumerateDirectories(_runsDirectory)) + { + if (string.Equals(directory, RunDirectory, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var metadataPath = Path.Combine(directory, "run.json"); + try + { + var metadata = JsonSerializer.Deserialize(File.ReadAllText(metadataPath)); + if (metadata is { SchemaVersion: SchemaVersion }) + { + var descriptor = CreateDescriptor(metadata, directory, isCurrent: false); + if (DashboardSqliteDatabase.IsCompatible(descriptor.DatabasePath)) + { + runs.Add(descriptor); + } + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + // Ignore incomplete or unreadable run metadata. A later dashboard process may still be writing it. + } + } + + return runs.OrderByDescending(run => run.IsCurrent).ThenByDescending(run => run.StartedAtUtc).ToList(); + } + + public void Dispose() + { + WriteMetadata(_metadata with { EndedAtUtc = DateTimeOffset.UtcNow, CleanShutdown = true }); + } + + private void WriteMetadata(DashboardRunMetadata metadata) + { + File.WriteAllText(_metadataPath, JsonSerializer.Serialize(metadata, s_jsonOptions)); + } + + private static DashboardRunDescriptor CreateDescriptor(DashboardRunMetadata metadata, string runDirectory, bool isCurrent) + { + return new DashboardRunDescriptor( + metadata.RunId, + metadata.StartedAtUtc, + metadata.EndedAtUtc, + metadata.CleanShutdown, + metadata.ApplicationName, + Path.Combine(runDirectory, metadata.DatabaseFileName), + isCurrent); + } + + internal static string GetApplicationDirectoryName(string applicationName) + { + ArgumentException.ThrowIfNullOrEmpty(applicationName); + + const int hashLength = 16; + const int separatorLength = 1; + var maxPrefixLength = MaxApplicationDirectoryNameLength - separatorLength - hashLength; + var prefixBuilder = new StringBuilder(Math.Min(applicationName.Length, maxPrefixLength)); + + foreach (var character in applicationName) + { + if (prefixBuilder.Length == maxPrefixLength) + { + break; + } + + prefixBuilder.Append(character is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or (>= '0' and <= '9') or '-' or '_' + ? character + : '-'); + } + + var prefix = prefixBuilder.ToString().Trim('-', '_'); + if (prefix.Length == 0) + { + prefix = "dashboard"; + } + + var hash = Convert.ToHexString(XxHash3.Hash(Encoding.UTF8.GetBytes(applicationName))).ToLowerInvariant(); + return $"{prefix}-{hash}"; + } + + private sealed record DashboardRunMetadata + { + public required int SchemaVersion { get; init; } + public required string RunId { get; init; } + public required DateTimeOffset StartedAtUtc { get; init; } + public DateTimeOffset? EndedAtUtc { get; init; } + public bool CleanShutdown { get; init; } + public string? ApplicationName { get; init; } + public required string DatabaseFileName { get; init; } + } +} + +internal sealed record DashboardRunDescriptor( + string RunId, + DateTimeOffset StartedAtUtc, + DateTimeOffset? EndedAtUtc, + bool CleanShutdown, + string? ApplicationName, + string DatabasePath, + bool IsCurrent); \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs new file mode 100644 index 00000000000..e8876020cc7 --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -0,0 +1,170 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Dapper; +using Microsoft.Data.Sqlite; + +namespace Aspire.Dashboard.ServiceClient; + +/// +/// Creates consistently configured connections to a dashboard run database. +/// +internal sealed class DashboardSqliteDatabase +{ + private const string SchemaResourcePrefix = "Aspire.Dashboard.ServiceClient.DatabaseSchema."; + + internal const int SchemaVersion = 5; + internal const string OrdinalIgnoreCaseCollation = "ORDINAL_IGNORE_CASE"; + internal const string OrdinalContainsFunction = "ordinal_contains"; + internal const string OrdinalStartsWithFunction = "ordinal_starts_with"; + + private static readonly Lazy> s_schemaScripts = new(LoadSchemaScripts); + + private readonly string _connectionString; + private readonly object _schemaLock = new(); + private bool _schemaInitialized; + + public DashboardSqliteDatabase(string databasePath, bool readOnly = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(databasePath); + + DatabasePath = Path.GetFullPath(databasePath); + IsReadOnly = readOnly; + + if (!readOnly) + { + Directory.CreateDirectory(Path.GetDirectoryName(DatabasePath)!); + } + + _connectionString = new SqliteConnectionStringBuilder + { + DataSource = DatabasePath, + Mode = readOnly ? SqliteOpenMode.ReadOnly : SqliteOpenMode.ReadWriteCreate, + Pooling = false, + DefaultTimeout = 5 + }.ToString(); + } + + public string DatabasePath { get; } + + public bool IsReadOnly { get; } + + public static bool IsCompatible(string databasePath) + { + if (!File.Exists(databasePath)) + { + return false; + } + + try + { + var database = new DashboardSqliteDatabase(databasePath, readOnly: true); + using var connection = database.OpenConnection(); + var version = connection.QuerySingleOrDefault("SELECT version FROM dashboard_schema;"); + return version == SchemaVersion; + } + catch (SqliteException) + { + return false; + } + } + + public SqliteConnection OpenConnection() + { + var connection = new SqliteConnection(_connectionString); + connection.CreateCollation( + OrdinalIgnoreCaseCollation, + (left, right) => string.Compare(left, right, StringComparison.OrdinalIgnoreCase)); + connection.CreateFunction( + OrdinalContainsFunction, + (value, fragment) => value?.Contains(fragment ?? string.Empty, StringComparison.OrdinalIgnoreCase) ?? false, + isDeterministic: true); + connection.CreateFunction( + OrdinalStartsWithFunction, + (value, prefix) => value?.StartsWith(prefix ?? string.Empty, StringComparison.OrdinalIgnoreCase) ?? false, + isDeterministic: true); + connection.Open(); + connection.Execute("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;"); + + return connection; + } + + public void InitializeSchema() + { + EnsureWritable("Historical dashboard data is read-only."); + + lock (_schemaLock) + { + if (_schemaInitialized) + { + return; + } + + using var connection = OpenConnection(); + connection.Execute("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;"); + + var schemaTableExists = connection.QuerySingle(""" + SELECT COUNT(*) + FROM sqlite_schema + WHERE type = 'table' AND name = 'dashboard_schema'; + """) != 0; + if (schemaTableExists) + { + ValidateSchemaVersion(connection, transaction: null); + } + + using var transaction = connection.BeginTransaction(); + foreach (var script in s_schemaScripts.Value) + { + connection.Execute(script, new { SchemaVersion }, transaction); + } + + ValidateSchemaVersion(connection, transaction); + transaction.Commit(); + _schemaInitialized = true; + } + } + + public void EnsureWritable(string message) + { + if (IsReadOnly) + { + throw new InvalidOperationException(message); + } + } + + private static IReadOnlyList LoadSchemaScripts() + { + var assembly = typeof(DashboardSqliteDatabase).Assembly; + // Numeric filename prefixes define execution order because later schema domains reference tables created by earlier scripts. + var resourceNames = assembly.GetManifestResourceNames() + .Where(name => name.StartsWith(SchemaResourcePrefix, StringComparison.Ordinal) && name.EndsWith(".sql", StringComparison.Ordinal)) + .Order(StringComparer.Ordinal) + .ToArray(); + + if (resourceNames.Length == 0) + { + throw new InvalidOperationException("No embedded dashboard database schema scripts were found."); + } + + var scripts = new List(resourceNames.Length); + foreach (var resourceName in resourceNames) + { + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Embedded dashboard database schema script '{resourceName}' was not found."); + using var reader = new StreamReader(stream); + scripts.Add(reader.ReadToEnd()); + } + + return scripts; + } + + private static void ValidateSchemaVersion(SqliteConnection connection, SqliteTransaction? transaction) + { + var version = connection.QuerySingle("SELECT version FROM dashboard_schema;", transaction: transaction); + if (version != SchemaVersion) + { + throw new InvalidOperationException($"Unsupported dashboard database schema version '{version}'."); + } + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/001.Core.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/001.Core.sql new file mode 100644 index 00000000000..8ea2c4b0577 --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/001.Core.sql @@ -0,0 +1,10 @@ +-- Licensed to the .NET Foundation under one or more agreements. +-- The .NET Foundation licenses this file to you under the MIT license. + +CREATE TABLE IF NOT EXISTS dashboard_schema ( + version INTEGER NOT NULL +) STRICT; + +INSERT INTO dashboard_schema (version) +SELECT @SchemaVersion +WHERE NOT EXISTS (SELECT 1 FROM dashboard_schema); \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql new file mode 100644 index 00000000000..672d9ed4b11 --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql @@ -0,0 +1,177 @@ +-- Licensed to the .NET Foundation under one or more agreements. +-- The .NET Foundation licenses this file to you under the MIT license. + +CREATE TABLE IF NOT EXISTS dashboard_resources ( + resource_name TEXT PRIMARY KEY, + replica_index INTEGER NOT NULL, + resource_type TEXT NOT NULL, + display_name TEXT NOT NULL, + uid TEXT NOT NULL, + state TEXT NULL, + created_at_seconds INTEGER NULL, + created_at_nanos INTEGER NULL, + state_style TEXT NULL, + started_at_seconds INTEGER NULL, + started_at_nanos INTEGER NULL, + stopped_at_seconds INTEGER NULL, + stopped_at_nanos INTEGER NULL, + is_hidden INTEGER NOT NULL, + supports_detailed_telemetry INTEGER NOT NULL, + icon_name TEXT NULL, + icon_variant INTEGER NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS dashboard_resource_environment ( + resource_name TEXT NOT NULL REFERENCES dashboard_resources(resource_name) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + name TEXT NOT NULL, + value TEXT NULL, + is_from_spec INTEGER NOT NULL, + PRIMARY KEY (resource_name, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS dashboard_resource_urls ( + resource_name TEXT NOT NULL REFERENCES dashboard_resources(resource_name) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + endpoint_name TEXT NULL, + full_url TEXT NOT NULL, + is_internal INTEGER NOT NULL, + is_inactive INTEGER NOT NULL, + display_sort_order INTEGER NOT NULL, + display_name TEXT NOT NULL, + PRIMARY KEY (resource_name, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS dashboard_resource_volumes ( + resource_name TEXT NOT NULL REFERENCES dashboard_resources(resource_name) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + source TEXT NOT NULL, + target TEXT NOT NULL, + mount_type TEXT NOT NULL, + is_read_only INTEGER NOT NULL, + PRIMARY KEY (resource_name, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS dashboard_resource_health_reports ( + resource_name TEXT NOT NULL REFERENCES dashboard_resources(resource_name) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + status INTEGER NULL, + key TEXT NOT NULL, + description TEXT NOT NULL, + exception TEXT NOT NULL, + last_run_at_seconds INTEGER NULL, + last_run_at_nanos INTEGER NULL, + PRIMARY KEY (resource_name, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS dashboard_resource_relationships ( + resource_name TEXT NOT NULL REFERENCES dashboard_resources(resource_name) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + related_resource_name TEXT NOT NULL, + relationship_type TEXT NOT NULL, + PRIMARY KEY (resource_name, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS dashboard_values ( + value_id INTEGER PRIMARY KEY AUTOINCREMENT, + resource_name TEXT NOT NULL REFERENCES dashboard_resources(resource_name) ON DELETE CASCADE, + value_kind INTEGER NOT NULL, + string_value TEXT NULL, + number_value REAL NULL, + bool_value INTEGER NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS dashboard_value_map_entries ( + parent_value_id INTEGER NOT NULL REFERENCES dashboard_values(value_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + map_key TEXT NOT NULL, + child_value_id INTEGER NOT NULL REFERENCES dashboard_values(value_id) ON DELETE CASCADE, + PRIMARY KEY (parent_value_id, ordinal), + UNIQUE (parent_value_id, map_key) +) STRICT; + +CREATE TABLE IF NOT EXISTS dashboard_value_list_items ( + parent_value_id INTEGER NOT NULL REFERENCES dashboard_values(value_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + child_value_id INTEGER NOT NULL REFERENCES dashboard_values(value_id) ON DELETE CASCADE, + PRIMARY KEY (parent_value_id, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS dashboard_resource_properties ( + resource_name TEXT NOT NULL REFERENCES dashboard_resources(resource_name) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + name TEXT NOT NULL, + display_name TEXT NULL, + value_id INTEGER NOT NULL REFERENCES dashboard_values(value_id), + is_sensitive INTEGER NULL, + is_highlighted INTEGER NOT NULL, + sort_order INTEGER NULL, + PRIMARY KEY (resource_name, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS dashboard_resource_commands ( + resource_name TEXT NOT NULL REFERENCES dashboard_resources(resource_name) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + name TEXT NOT NULL, + display_name TEXT NOT NULL, + confirmation_message TEXT NULL, + parameter_value_id INTEGER NULL REFERENCES dashboard_values(value_id), + is_highlighted INTEGER NOT NULL, + icon_name TEXT NULL, + icon_variant INTEGER NULL, + display_description TEXT NULL, + state INTEGER NOT NULL, + PRIMARY KEY (resource_name, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS dashboard_resource_command_inputs ( + resource_name TEXT NOT NULL, + command_ordinal INTEGER NOT NULL, + ordinal INTEGER NOT NULL, + label TEXT NOT NULL, + placeholder TEXT NOT NULL, + input_type INTEGER NOT NULL, + required INTEGER NOT NULL, + value TEXT NOT NULL, + description TEXT NOT NULL, + enable_description_markdown INTEGER NOT NULL, + max_length INTEGER NOT NULL, + allow_custom_choice INTEGER NOT NULL, + loading INTEGER NOT NULL, + update_state_on_change INTEGER NOT NULL, + name TEXT NOT NULL, + disabled INTEGER NOT NULL, + max_file_size INTEGER NOT NULL, + allow_multiple_files INTEGER NOT NULL, + file_filter TEXT NOT NULL, + PRIMARY KEY (resource_name, command_ordinal, ordinal), + FOREIGN KEY (resource_name, command_ordinal) REFERENCES dashboard_resource_commands(resource_name, ordinal) ON DELETE CASCADE +) STRICT; + +CREATE TABLE IF NOT EXISTS dashboard_resource_command_input_options ( + resource_name TEXT NOT NULL, + command_ordinal INTEGER NOT NULL, + input_ordinal INTEGER NOT NULL, + option_key TEXT NOT NULL, + option_value TEXT NOT NULL, + PRIMARY KEY (resource_name, command_ordinal, input_ordinal, option_key), + FOREIGN KEY (resource_name, command_ordinal, input_ordinal) REFERENCES dashboard_resource_command_inputs(resource_name, command_ordinal, ordinal) ON DELETE CASCADE +) STRICT; + +CREATE TABLE IF NOT EXISTS dashboard_resource_command_input_validation_errors ( + resource_name TEXT NOT NULL, + command_ordinal INTEGER NOT NULL, + input_ordinal INTEGER NOT NULL, + ordinal INTEGER NOT NULL, + validation_error TEXT NOT NULL, + PRIMARY KEY (resource_name, command_ordinal, input_ordinal, ordinal), + FOREIGN KEY (resource_name, command_ordinal, input_ordinal) REFERENCES dashboard_resource_command_inputs(resource_name, command_ordinal, ordinal) ON DELETE CASCADE +) STRICT; + +CREATE TABLE IF NOT EXISTS console_logs ( + resource_name TEXT NOT NULL, + line_number INTEGER NOT NULL, + content TEXT NOT NULL, + is_stderr INTEGER NOT NULL, + PRIMARY KEY (resource_name, line_number) +) STRICT; \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql new file mode 100644 index 00000000000..080fa50d2cf --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql @@ -0,0 +1,80 @@ +-- Licensed to the .NET Foundation under one or more agreements. +-- The .NET Foundation licenses this file to you under the MIT license. + +CREATE TABLE IF NOT EXISTS telemetry_resources ( + resource_id INTEGER PRIMARY KEY AUTOINCREMENT, + resource_name TEXT NOT NULL, + instance_id TEXT NOT NULL, + instance_id_is_null INTEGER NOT NULL, + uninstrumented_peer INTEGER NOT NULL DEFAULT 0, + has_logs INTEGER NOT NULL DEFAULT 0, + has_traces INTEGER NOT NULL DEFAULT 0, + has_metrics INTEGER NOT NULL DEFAULT 0, + UNIQUE (resource_name, instance_id_is_null, instance_id) +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_resource_views ( + resource_view_id INTEGER PRIMARY KEY AUTOINCREMENT, + resource_id INTEGER NOT NULL REFERENCES telemetry_resources(resource_id) ON DELETE CASCADE +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_resource_view_attributes ( + resource_view_id INTEGER NOT NULL REFERENCES telemetry_resource_views(resource_view_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + attribute_key TEXT NOT NULL, + attribute_value TEXT NOT NULL, + PRIMARY KEY (resource_view_id, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_scopes ( + scope_id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_name TEXT NOT NULL UNIQUE, + scope_version TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_scope_attributes ( + scope_id INTEGER NOT NULL REFERENCES telemetry_scopes(scope_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + attribute_key TEXT NOT NULL, + attribute_value TEXT NOT NULL, + PRIMARY KEY (scope_id, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_logs ( + log_id INTEGER PRIMARY KEY AUTOINCREMENT, + resource_id INTEGER NOT NULL REFERENCES telemetry_resources(resource_id) ON DELETE CASCADE, + resource_view_id INTEGER NOT NULL REFERENCES telemetry_resource_views(resource_view_id) ON DELETE CASCADE, + scope_id INTEGER NOT NULL REFERENCES telemetry_scopes(scope_id), + timestamp_ticks INTEGER NOT NULL, + flags INTEGER NOT NULL, + severity INTEGER NOT NULL, + severity_name TEXT NOT NULL, + severity_number INTEGER NOT NULL, + message TEXT NOT NULL, + span_id TEXT NOT NULL, + trace_id TEXT NOT NULL, + parent_id TEXT NOT NULL, + original_format TEXT NULL, + event_name TEXT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_log_attributes ( + log_id INTEGER NOT NULL REFERENCES telemetry_logs(log_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + attribute_key TEXT NOT NULL, + attribute_value TEXT NOT NULL, + PRIMARY KEY (log_id, ordinal) +) STRICT; + +CREATE INDEX IF NOT EXISTS ix_telemetry_logs_resource_order + ON telemetry_logs(resource_id, timestamp_ticks, log_id); +CREATE INDEX IF NOT EXISTS ix_telemetry_logs_order + ON telemetry_logs(timestamp_ticks, log_id); +CREATE INDEX IF NOT EXISTS ix_telemetry_logs_trace_span_order + ON telemetry_logs(trace_id, span_id, timestamp_ticks, log_id); +CREATE INDEX IF NOT EXISTS ix_telemetry_logs_trace_order + ON telemetry_logs(trace_id, timestamp_ticks, log_id); +CREATE INDEX IF NOT EXISTS ix_telemetry_log_attributes_owner_key + ON telemetry_log_attributes(log_id, attribute_key); +CREATE INDEX IF NOT EXISTS ix_telemetry_log_attributes_key_value_owner + ON telemetry_log_attributes(attribute_key COLLATE ORDINAL_IGNORE_CASE, attribute_value COLLATE ORDINAL_IGNORE_CASE, log_id); \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql new file mode 100644 index 00000000000..fb47a9e6612 --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql @@ -0,0 +1,89 @@ +-- Licensed to the .NET Foundation under one or more agreements. +-- The .NET Foundation licenses this file to you under the MIT license. + +CREATE TABLE IF NOT EXISTS telemetry_traces ( + trace_id TEXT PRIMARY KEY, + insertion_sequence INTEGER NOT NULL UNIQUE, + first_span_timestamp_ticks INTEGER NOT NULL, + duration_ticks INTEGER NOT NULL, + last_updated_timestamp_ticks INTEGER NOT NULL, + full_name TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_spans ( + trace_id TEXT NOT NULL REFERENCES telemetry_traces(trace_id) ON DELETE CASCADE, + span_id TEXT NOT NULL, + parent_span_id TEXT NULL, + resource_id INTEGER NOT NULL REFERENCES telemetry_resources(resource_id) ON DELETE CASCADE, + resource_view_id INTEGER NOT NULL REFERENCES telemetry_resource_views(resource_view_id) ON DELETE CASCADE, + scope_id INTEGER NOT NULL REFERENCES telemetry_scopes(scope_id), + name TEXT NOT NULL, + kind INTEGER NOT NULL, + start_time_ticks INTEGER NOT NULL, + end_time_ticks INTEGER NOT NULL, + status INTEGER NOT NULL, + status_message TEXT NULL, + trace_state TEXT NULL, + uninstrumented_peer_resource_id INTEGER NULL REFERENCES telemetry_resources(resource_id) ON DELETE SET NULL, + PRIMARY KEY (trace_id, span_id) +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_span_attributes ( + trace_id TEXT NOT NULL, + span_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + attribute_key TEXT NOT NULL, + attribute_value TEXT NOT NULL, + PRIMARY KEY (trace_id, span_id, ordinal), + FOREIGN KEY (trace_id, span_id) REFERENCES telemetry_spans(trace_id, span_id) ON DELETE CASCADE +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_span_events ( + event_id TEXT PRIMARY KEY, + trace_id TEXT NOT NULL, + span_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + event_name TEXT NOT NULL, + event_time_ticks INTEGER NOT NULL, + UNIQUE (trace_id, span_id, ordinal), + FOREIGN KEY (trace_id, span_id) REFERENCES telemetry_spans(trace_id, span_id) ON DELETE CASCADE +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_span_event_attributes ( + event_id TEXT NOT NULL REFERENCES telemetry_span_events(event_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + attribute_key TEXT NOT NULL, + attribute_value TEXT NOT NULL, + PRIMARY KEY (event_id, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_span_links ( + link_id INTEGER PRIMARY KEY AUTOINCREMENT, + source_trace_id TEXT NOT NULL, + source_span_id TEXT NOT NULL, + target_trace_id TEXT NOT NULL, + target_span_id TEXT NOT NULL, + trace_state TEXT NOT NULL, + FOREIGN KEY (source_trace_id, source_span_id) REFERENCES telemetry_spans(trace_id, span_id) ON DELETE CASCADE +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_span_link_attributes ( + link_id INTEGER NOT NULL REFERENCES telemetry_span_links(link_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + attribute_key TEXT NOT NULL, + attribute_value TEXT NOT NULL, + PRIMARY KEY (link_id, ordinal) +) STRICT; + +CREATE INDEX IF NOT EXISTS ix_telemetry_traces_order + ON telemetry_traces(first_span_timestamp_ticks, insertion_sequence DESC); +CREATE INDEX IF NOT EXISTS ix_telemetry_spans_resource_order + ON telemetry_spans(resource_id, start_time_ticks, trace_id, span_id); +CREATE INDEX IF NOT EXISTS ix_telemetry_spans_trace_order + ON telemetry_spans(trace_id, start_time_ticks, span_id); +CREATE INDEX IF NOT EXISTS ix_telemetry_span_attributes_owner_key + ON telemetry_span_attributes(trace_id, span_id, attribute_key); +CREATE INDEX IF NOT EXISTS ix_telemetry_span_attributes_key_value_owner + ON telemetry_span_attributes(attribute_key COLLATE ORDINAL_IGNORE_CASE, attribute_value COLLATE ORDINAL_IGNORE_CASE, trace_id, span_id); +CREATE INDEX IF NOT EXISTS ix_telemetry_span_links_target + ON telemetry_span_links(target_trace_id, target_span_id); \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql new file mode 100644 index 00000000000..91ba2f3080c --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql @@ -0,0 +1,83 @@ +-- Licensed to the .NET Foundation under one or more agreements. +-- The .NET Foundation licenses this file to you under the MIT license. + +CREATE TABLE IF NOT EXISTS telemetry_metric_instruments ( + instrument_id INTEGER PRIMARY KEY AUTOINCREMENT, + resource_id INTEGER NOT NULL REFERENCES telemetry_resources(resource_id) ON DELETE CASCADE, + scope_id INTEGER NOT NULL REFERENCES telemetry_scopes(scope_id), + instrument_name TEXT NOT NULL, + description TEXT NOT NULL, + unit TEXT NOT NULL, + instrument_type INTEGER NOT NULL, + aggregation_temporality INTEGER NOT NULL, + is_monotonic INTEGER NOT NULL, + has_overflow INTEGER NOT NULL DEFAULT 0, + UNIQUE (resource_id, scope_id, instrument_name) +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_metric_dimensions ( + dimension_id INTEGER PRIMARY KEY AUTOINCREMENT, + instrument_id INTEGER NOT NULL REFERENCES telemetry_metric_instruments(instrument_id) ON DELETE CASCADE +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_metric_dimension_attributes ( + dimension_id INTEGER NOT NULL REFERENCES telemetry_metric_dimensions(dimension_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + attribute_key TEXT NOT NULL, + attribute_value TEXT NOT NULL, + PRIMARY KEY (dimension_id, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_metric_points ( + point_id INTEGER PRIMARY KEY AUTOINCREMENT, + dimension_id INTEGER NOT NULL REFERENCES telemetry_metric_dimensions(dimension_id) ON DELETE CASCADE, + point_type INTEGER NOT NULL, + start_time_ticks INTEGER NOT NULL, + end_time_ticks INTEGER NOT NULL, + repeat_count INTEGER NOT NULL, + integer_value INTEGER NULL, + double_value REAL NULL, + histogram_sum REAL NULL, + histogram_count TEXT NULL, + flags INTEGER NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_metric_histogram_bucket_counts ( + point_id INTEGER NOT NULL REFERENCES telemetry_metric_points(point_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + bucket_count TEXT NOT NULL, + PRIMARY KEY (point_id, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_metric_histogram_explicit_bounds ( + point_id INTEGER NOT NULL REFERENCES telemetry_metric_points(point_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + explicit_bound REAL NOT NULL, + PRIMARY KEY (point_id, ordinal) +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_metric_exemplars ( + exemplar_id INTEGER PRIMARY KEY AUTOINCREMENT, + point_id INTEGER NOT NULL REFERENCES telemetry_metric_points(point_id) ON DELETE CASCADE, + start_time_ticks INTEGER NOT NULL, + exemplar_value REAL NOT NULL, + span_id TEXT NOT NULL, + trace_id TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS telemetry_metric_exemplar_attributes ( + exemplar_id INTEGER NOT NULL REFERENCES telemetry_metric_exemplars(exemplar_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + attribute_key TEXT NOT NULL, + attribute_value TEXT NOT NULL, + PRIMARY KEY (exemplar_id, ordinal) +) STRICT; + +CREATE INDEX IF NOT EXISTS ix_telemetry_metric_instruments_lookup + ON telemetry_metric_instruments(resource_id, scope_id, instrument_name); +CREATE INDEX IF NOT EXISTS ix_telemetry_metric_dimensions_instrument + ON telemetry_metric_dimensions(instrument_id, dimension_id); +CREATE INDEX IF NOT EXISTS ix_telemetry_metric_points_dimension_order + ON telemetry_metric_points(dimension_id, point_id); +CREATE INDEX IF NOT EXISTS ix_telemetry_metric_points_time + ON telemetry_metric_points(dimension_id, start_time_ticks, end_time_ticks); \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs index 3b39fbe8ab6..2f39379d6aa 100644 --- a/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/IDashboardClient.cs @@ -11,7 +11,7 @@ namespace Aspire.Dashboard.ServiceClient; /// /// Provides data about active resources to external components, such as the dashboard. /// -public interface IDashboardClient : IAsyncDisposable +public interface IDashboardClient : IResourceRepository, IAsyncDisposable { Task WhenConnected { get; } @@ -24,6 +24,11 @@ public interface IDashboardClient : IAsyncDisposable /// bool IsEnabled { get; } + /// + /// Gets whether the selected dashboard data source is read-only. + /// + bool IsReadOnly => false; + /// /// Gets the current connection state of the client to the resource service. /// @@ -53,45 +58,10 @@ public interface IDashboardClient : IAsyncDisposable /// string? MinRequiredVersion { get; } - /// - /// Gets the current set of resources and a stream of updates. - /// - /// - /// The returned subscription will not complete on its own. - /// Callers are required to manage the lifetime of the subscription, - /// using cancellation during enumeration. - /// - Task SubscribeResourcesAsync(CancellationToken cancellationToken); - - /// - /// Gets a resource matching the specified name. This resource won't be updated with changes after it is fetched. - /// - ResourceViewModel? GetResource(string resourceName); - - /// - /// Get the current resources. - /// - /// - IReadOnlyList GetResources(); - IAsyncEnumerable SubscribeInteractionsAsync(CancellationToken cancellationToken); Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken); - /// - /// Gets a stream of console log messages for the specified resource. - /// Includes messages logged both before and after this method call. - /// - /// - /// The returned sequence may end when the resource terminates. - /// It is up to the implementation. - /// - /// It is important that callers trigger - /// so that resources owned by the sequence and its consumers can be freed. - IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken); - - IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken); - Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, ExecuteResourceCommandOptions options, CancellationToken cancellationToken); Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, CancellationToken cancellationToken); diff --git a/src/Aspire.Dashboard/ServiceClient/IResourceRepository.cs b/src/Aspire.Dashboard/ServiceClient/IResourceRepository.cs new file mode 100644 index 00000000000..c75c9693606 --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/IResourceRepository.cs @@ -0,0 +1,37 @@ +// 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; + +namespace Aspire.Dashboard.ServiceClient; + +/// +/// Provides dashboard resource snapshots, updates, and console logs. +/// +public interface IResourceRepository +{ + /// + /// Gets the current set of resources and a stream of updates. + /// + Task SubscribeResourcesAsync(CancellationToken cancellationToken); + + /// + /// Gets a resource matching the specified name. + /// + ResourceViewModel? GetResource(string resourceName); + + /// + /// Gets the current resources. + /// + IReadOnlyList GetResources(); + + /// + /// Gets existing and incoming console log messages for a resource. + /// + IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken); + + /// + /// Gets existing console log messages for a resource. + /// + IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/IResourceRepositoryWriter.cs b/src/Aspire.Dashboard/ServiceClient/IResourceRepositoryWriter.cs new file mode 100644 index 00000000000..62c542ba552 --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/IResourceRepositoryWriter.cs @@ -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.DashboardService.Proto.V1; + +namespace Aspire.Dashboard.ServiceClient; + +internal interface IResourceRepositoryWriter +{ + void ReplaceResources(IReadOnlyList resources); + void ApplyChanges(IReadOnlyList changes); + void AddConsoleLogs(string resourceName, IReadOnlyList logLines); +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs new file mode 100644 index 00000000000..aa2fbf0443d --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs @@ -0,0 +1,72 @@ +// 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.DashboardService.Proto.V1; + +namespace Aspire.Dashboard.ServiceClient; + +internal sealed class SelectedDashboardClient(DashboardClient currentClient, DashboardDataSource dataSource) : IDashboardClient +{ + public Task WhenConnected => currentClient.WhenConnected; + public bool IsEnabled => currentClient.IsEnabled; + public bool IsReadOnly => dataSource.IsReadOnly; + public DashboardConnectionState ConnectionState => currentClient.ConnectionState; + public string ApplicationName => dataSource.SelectedRun.ApplicationName ?? currentClient.ApplicationName; + public string? MinRequiredVersion => currentClient.MinRequiredVersion; + + public event Action? ConnectionStateChanged + { + add => currentClient.ConnectionStateChanged += value; + remove => currentClient.ConnectionStateChanged -= value; + } + + public Task ReconnectAsync() => currentClient.ReconnectAsync(); + public Task SubscribeResourcesAsync(CancellationToken cancellationToken) => dataSource.ResourceRepository.SubscribeResourcesAsync(cancellationToken); + public ResourceViewModel? GetResource(string resourceName) => dataSource.ResourceRepository.GetResource(resourceName); + public IReadOnlyList GetResources() => dataSource.ResourceRepository.GetResources(); + public IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => + IsReadOnly + ? dataSource.ResourceRepository.SubscribeConsoleLogs(resourceName, cancellationToken) + : currentClient.SubscribeConsoleLogs(resourceName, cancellationToken); + public IAsyncEnumerable> GetConsoleLogs(string resourceName, CancellationToken cancellationToken) => + IsReadOnly + ? dataSource.ResourceRepository.GetConsoleLogs(resourceName, cancellationToken) + : currentClient.GetConsoleLogs(resourceName, cancellationToken); + public IAsyncEnumerable SubscribeInteractionsAsync(CancellationToken cancellationToken) => + IsReadOnly ? EmptyInteractionsAsync() : currentClient.SubscribeInteractionsAsync(cancellationToken); + + public Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken) + { + EnsureWritable(); + return currentClient.SendInteractionRequestAsync(request, cancellationToken); + } + + public Task ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, ExecuteResourceCommandOptions options, CancellationToken cancellationToken) + { + EnsureWritable(); + return currentClient.ExecuteResourceCommandAsync(resourceName, resourceType, command, options, cancellationToken); + } + + public Task UploadFileAsync(Stream fileStream, string fileName, long expectedSize, CancellationToken cancellationToken) + { + EnsureWritable(); + return currentClient.UploadFileAsync(fileStream, fileName, expectedSize, cancellationToken); + } + + private void EnsureWritable() + { + if (IsReadOnly) + { + throw new InvalidOperationException("Historical dashboard data is read-only."); + } + } + + private static async IAsyncEnumerable EmptyInteractionsAsync() + { + await Task.CompletedTask.ConfigureAwait(false); + yield break; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs new file mode 100644 index 00000000000..6f096cf7c22 --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs @@ -0,0 +1,818 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data; +using Aspire.DashboardService.Proto.V1; +using Dapper; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Data.Sqlite; + +namespace Aspire.Dashboard.ServiceClient; + +public sealed partial class SqliteResourceRepository +{ + private static void SaveResource(SqliteConnection connection, IDbTransaction transaction, Resource resource, int replicaIndex) + { + connection.Execute("DELETE FROM dashboard_resources WHERE resource_name = @ResourceName;", new { ResourceName = resource.Name }, transaction); + connection.Execute(""" + INSERT INTO dashboard_resources ( + resource_name, replica_index, resource_type, display_name, uid, state, + created_at_seconds, created_at_nanos, state_style, + started_at_seconds, started_at_nanos, stopped_at_seconds, stopped_at_nanos, + is_hidden, supports_detailed_telemetry, icon_name, icon_variant) + VALUES ( + @Name, @ReplicaIndex, @ResourceType, @DisplayName, @Uid, @State, + @CreatedAtSeconds, @CreatedAtNanos, @StateStyle, + @StartedAtSeconds, @StartedAtNanos, @StoppedAtSeconds, @StoppedAtNanos, + @IsHidden, @SupportsDetailedTelemetry, @IconName, @IconVariant); + """, new + { + resource.Name, + ReplicaIndex = replicaIndex, + resource.ResourceType, + resource.DisplayName, + resource.Uid, + State = resource.HasState ? resource.State : null, + CreatedAtSeconds = resource.CreatedAt?.Seconds, + CreatedAtNanos = resource.CreatedAt?.Nanos, + StateStyle = resource.HasStateStyle ? resource.StateStyle : null, + StartedAtSeconds = resource.StartedAt?.Seconds, + StartedAtNanos = resource.StartedAt?.Nanos, + StoppedAtSeconds = resource.StoppedAt?.Seconds, + StoppedAtNanos = resource.StoppedAt?.Nanos, + resource.IsHidden, + resource.SupportsDetailedTelemetry, + IconName = resource.HasIconName ? resource.IconName : null, + IconVariant = resource.HasIconVariant ? (int?)resource.IconVariant : null + }, transaction); + + InsertEnvironment(connection, transaction, resource); + InsertUrls(connection, transaction, resource); + InsertVolumes(connection, transaction, resource); + InsertHealthReports(connection, transaction, resource); + InsertRelationships(connection, transaction, resource); + InsertProperties(connection, transaction, resource); + InsertCommands(connection, transaction, resource); + } + + private static void InsertEnvironment(SqliteConnection connection, IDbTransaction transaction, Resource resource) + { + connection.Execute(""" + INSERT INTO dashboard_resource_environment (resource_name, ordinal, name, value, is_from_spec) + VALUES (@ResourceName, @Ordinal, @Name, @Value, @IsFromSpec); + """, resource.Environment.Select((item, ordinal) => new + { + ResourceName = resource.Name, + Ordinal = ordinal, + item.Name, + Value = item.HasValue ? item.Value : null, + item.IsFromSpec + }), transaction); + } + + private static void InsertUrls(SqliteConnection connection, IDbTransaction transaction, Resource resource) + { + connection.Execute(""" + INSERT INTO dashboard_resource_urls ( + resource_name, ordinal, endpoint_name, full_url, is_internal, is_inactive, display_sort_order, display_name) + VALUES ( + @ResourceName, @Ordinal, @EndpointName, @FullUrl, @IsInternal, @IsInactive, @DisplaySortOrder, @DisplayName); + """, resource.Urls.Select((item, ordinal) => new + { + ResourceName = resource.Name, + Ordinal = ordinal, + EndpointName = item.HasEndpointName ? item.EndpointName : null, + item.FullUrl, + item.IsInternal, + item.IsInactive, + DisplaySortOrder = item.DisplayProperties.SortOrder, + DisplayName = item.DisplayProperties.DisplayName + }), transaction); + } + + private static void InsertVolumes(SqliteConnection connection, IDbTransaction transaction, Resource resource) + { + connection.Execute(""" + INSERT INTO dashboard_resource_volumes (resource_name, ordinal, source, target, mount_type, is_read_only) + VALUES (@ResourceName, @Ordinal, @Source, @Target, @MountType, @IsReadOnly); + """, resource.Volumes.Select((item, ordinal) => new + { + ResourceName = resource.Name, + Ordinal = ordinal, + item.Source, + item.Target, + item.MountType, + item.IsReadOnly + }), transaction); + } + + private static void InsertHealthReports(SqliteConnection connection, IDbTransaction transaction, Resource resource) + { + connection.Execute(""" + INSERT INTO dashboard_resource_health_reports ( + resource_name, ordinal, status, key, description, exception, last_run_at_seconds, last_run_at_nanos) + VALUES ( + @ResourceName, @Ordinal, @Status, @Key, @Description, @Exception, @LastRunAtSeconds, @LastRunAtNanos); + """, resource.HealthReports.Select((item, ordinal) => new + { + ResourceName = resource.Name, + Ordinal = ordinal, + Status = item.HasStatus ? (int?)item.Status : null, + item.Key, + item.Description, + item.Exception, + LastRunAtSeconds = item.LastRunAt?.Seconds, + LastRunAtNanos = item.LastRunAt?.Nanos + }), transaction); + } + + private static void InsertRelationships(SqliteConnection connection, IDbTransaction transaction, Resource resource) + { + connection.Execute(""" + INSERT INTO dashboard_resource_relationships ( + resource_name, ordinal, related_resource_name, relationship_type) + VALUES (@ResourceName, @Ordinal, @RelatedResourceName, @RelationshipType); + """, resource.Relationships.Select((item, ordinal) => new + { + ResourceName = resource.Name, + Ordinal = ordinal, + RelatedResourceName = item.ResourceName, + RelationshipType = item.Type + }), transaction); + } + + private static void InsertProperties(SqliteConnection connection, IDbTransaction transaction, Resource resource) + { + foreach (var (property, ordinal) in resource.Properties.Select((item, ordinal) => (item, ordinal))) + { + var valueId = InsertValue(connection, transaction, resource.Name, property.Value); + connection.Execute(""" + INSERT INTO dashboard_resource_properties ( + resource_name, ordinal, name, display_name, value_id, is_sensitive, is_highlighted, sort_order) + VALUES ( + @ResourceName, @Ordinal, @Name, @DisplayName, @ValueId, @IsSensitive, @IsHighlighted, @SortOrder); + """, new + { + ResourceName = resource.Name, + Ordinal = ordinal, + property.Name, + DisplayName = property.HasDisplayName ? property.DisplayName : null, + ValueId = valueId, + IsSensitive = property.HasIsSensitive ? (bool?)property.IsSensitive : null, + property.IsHighlighted, + SortOrder = property.HasSortOrder ? (int?)property.SortOrder : null + }, transaction); + } + } + + private static void InsertCommands(SqliteConnection connection, IDbTransaction transaction, Resource resource) + { +#pragma warning disable CS0612 // ResourceCommand.Parameter must be persisted for compatibility with older AppHosts. + foreach (var (command, commandOrdinal) in resource.Commands.Select((item, ordinal) => (item, ordinal))) + { + long? parameterValueId = command.Parameter is not null + ? InsertValue(connection, transaction, resource.Name, command.Parameter) + : null; + connection.Execute(""" + INSERT INTO dashboard_resource_commands ( + resource_name, ordinal, name, display_name, confirmation_message, parameter_value_id, + is_highlighted, icon_name, icon_variant, display_description, state) + VALUES ( + @ResourceName, @Ordinal, @Name, @DisplayName, @ConfirmationMessage, @ParameterValueId, + @IsHighlighted, @IconName, @IconVariant, @DisplayDescription, @State); + """, new + { + ResourceName = resource.Name, + Ordinal = commandOrdinal, + command.Name, + command.DisplayName, + ConfirmationMessage = command.HasConfirmationMessage ? command.ConfirmationMessage : null, + ParameterValueId = parameterValueId, + command.IsHighlighted, + IconName = command.HasIconName ? command.IconName : null, + IconVariant = command.HasIconVariant ? (int?)command.IconVariant : null, + DisplayDescription = command.HasDisplayDescription ? command.DisplayDescription : null, + State = (int)command.State + }, transaction); + + foreach (var (input, inputOrdinal) in command.ArgumentInputs.Select((item, ordinal) => (item, ordinal))) + { + connection.Execute(""" + INSERT INTO dashboard_resource_command_inputs ( + resource_name, command_ordinal, ordinal, label, placeholder, input_type, required, value, + description, enable_description_markdown, max_length, allow_custom_choice, loading, + update_state_on_change, name, disabled, max_file_size, allow_multiple_files, file_filter) + VALUES ( + @ResourceName, @CommandOrdinal, @Ordinal, @Label, @Placeholder, @InputType, @Required, @Value, + @Description, @EnableDescriptionMarkdown, @MaxLength, @AllowCustomChoice, @Loading, + @UpdateStateOnChange, @Name, @Disabled, @MaxFileSize, @AllowMultipleFiles, @FileFilter); + """, new + { + ResourceName = resource.Name, + CommandOrdinal = commandOrdinal, + Ordinal = inputOrdinal, + input.Label, + input.Placeholder, + InputType = (int)input.InputType, + input.Required, + input.Value, + input.Description, + input.EnableDescriptionMarkdown, + input.MaxLength, + input.AllowCustomChoice, + input.Loading, + input.UpdateStateOnChange, + input.Name, + input.Disabled, + input.MaxFileSize, + input.AllowMultipleFiles, + input.FileFilter + }, transaction); + + connection.Execute(""" + INSERT INTO dashboard_resource_command_input_options ( + resource_name, command_ordinal, input_ordinal, option_key, option_value) + VALUES (@ResourceName, @CommandOrdinal, @InputOrdinal, @OptionKey, @OptionValue); + """, input.Options.Select(option => new + { + ResourceName = resource.Name, + CommandOrdinal = commandOrdinal, + InputOrdinal = inputOrdinal, + OptionKey = option.Key, + OptionValue = option.Value + }), transaction); + + connection.Execute(""" + INSERT INTO dashboard_resource_command_input_validation_errors ( + resource_name, command_ordinal, input_ordinal, ordinal, validation_error) + VALUES (@ResourceName, @CommandOrdinal, @InputOrdinal, @Ordinal, @ValidationError); + """, input.ValidationErrors.Select((validationError, ordinal) => new + { + ResourceName = resource.Name, + CommandOrdinal = commandOrdinal, + InputOrdinal = inputOrdinal, + Ordinal = ordinal, + ValidationError = validationError + }), transaction); + } + } +#pragma warning restore CS0612 + } + + private static long InsertValue(SqliteConnection connection, IDbTransaction transaction, string resourceName, Value value) + { + var valueId = connection.QuerySingle(""" + INSERT INTO dashboard_values (resource_name, value_kind, string_value, number_value, bool_value) + VALUES (@ResourceName, @ValueKind, @StringValue, @NumberValue, @BoolValue) + RETURNING value_id; + """, new + { + ResourceName = resourceName, + ValueKind = (int)value.KindCase, + StringValue = value.KindCase == Value.KindOneofCase.StringValue ? value.StringValue : null, + NumberValue = value.KindCase == Value.KindOneofCase.NumberValue ? (double?)value.NumberValue : null, + BoolValue = value.KindCase == Value.KindOneofCase.BoolValue ? (bool?)value.BoolValue : null + }, transaction); + + if (value.KindCase == Value.KindOneofCase.StructValue) + { + var ordinal = 0; + foreach (var field in value.StructValue.Fields) + { + var childValueId = InsertValue(connection, transaction, resourceName, field.Value); + connection.Execute(""" + INSERT INTO dashboard_value_map_entries (parent_value_id, ordinal, map_key, child_value_id) + VALUES (@ParentValueId, @Ordinal, @MapKey, @ChildValueId); + """, new { ParentValueId = valueId, Ordinal = ordinal++, MapKey = field.Key, ChildValueId = childValueId }, transaction); + } + } + else if (value.KindCase == Value.KindOneofCase.ListValue) + { + foreach (var (item, ordinal) in value.ListValue.Values.Select((item, ordinal) => (item, ordinal))) + { + var childValueId = InsertValue(connection, transaction, resourceName, item); + connection.Execute(""" + INSERT INTO dashboard_value_list_items (parent_value_id, ordinal, child_value_id) + VALUES (@ParentValueId, @Ordinal, @ChildValueId); + """, new { ParentValueId = valueId, Ordinal = ordinal, ChildValueId = childValueId }, transaction); + } + } + + return valueId; + } + + private static IEnumerable LoadResourceRecords(SqliteConnection connection) + { + var resourceRecords = connection.Query(""" + SELECT + resource_name AS ResourceName, + replica_index AS ReplicaIndex, + resource_type AS ResourceType, + display_name AS DisplayName, + uid AS Uid, + state AS State, + created_at_seconds AS CreatedAtSeconds, + created_at_nanos AS CreatedAtNanos, + state_style AS StateStyle, + started_at_seconds AS StartedAtSeconds, + started_at_nanos AS StartedAtNanos, + stopped_at_seconds AS StoppedAtSeconds, + stopped_at_nanos AS StoppedAtNanos, + is_hidden AS IsHidden, + supports_detailed_telemetry AS SupportsDetailedTelemetry, + icon_name AS IconName, + icon_variant AS IconVariant + FROM dashboard_resources + ORDER BY rowid; + """); + + foreach (var record in resourceRecords) + { + var resource = new Resource + { + Name = record.ResourceName, + ResourceType = record.ResourceType, + DisplayName = record.DisplayName, + Uid = record.Uid, + IsHidden = record.IsHidden, + SupportsDetailedTelemetry = record.SupportsDetailedTelemetry + }; + + SetOptionalResourceFields(resource, record); + LoadEnvironment(connection, resource); + LoadUrls(connection, resource); + LoadVolumes(connection, resource); + LoadHealthReports(connection, resource); + LoadRelationships(connection, resource); + LoadProperties(connection, resource); + LoadCommands(connection, resource); + + yield return new StoredResource(resource, record.ReplicaIndex); + } + } + + private static void SetOptionalResourceFields(Resource resource, ResourceRecord record) + { + if (record.State is not null) + { + resource.State = record.State; + } + if (record.CreatedAtSeconds is not null) + { + resource.CreatedAt = CreateTimestamp(record.CreatedAtSeconds.Value, record.CreatedAtNanos); + } + if (record.StateStyle is not null) + { + resource.StateStyle = record.StateStyle; + } + if (record.StartedAtSeconds is not null) + { + resource.StartedAt = CreateTimestamp(record.StartedAtSeconds.Value, record.StartedAtNanos); + } + if (record.StoppedAtSeconds is not null) + { + resource.StoppedAt = CreateTimestamp(record.StoppedAtSeconds.Value, record.StoppedAtNanos); + } + if (record.IconName is not null) + { + resource.IconName = record.IconName; + } + if (record.IconVariant is not null) + { + resource.IconVariant = (IconVariant)record.IconVariant.Value; + } + } + + private static void LoadEnvironment(SqliteConnection connection, Resource resource) + { + foreach (var record in connection.Query(""" + SELECT name AS Name, value AS Value, is_from_spec AS IsFromSpec + FROM dashboard_resource_environment + WHERE resource_name = @ResourceName + ORDER BY ordinal; + """, new { ResourceName = resource.Name })) + { + var item = new EnvironmentVariable { Name = record.Name, IsFromSpec = record.IsFromSpec }; + if (record.Value is not null) + { + item.Value = record.Value; + } + resource.Environment.Add(item); + } + } + + private static void LoadUrls(SqliteConnection connection, Resource resource) + { + foreach (var record in connection.Query(""" + SELECT + endpoint_name AS EndpointName, + full_url AS FullUrl, + is_internal AS IsInternal, + is_inactive AS IsInactive, + display_sort_order AS DisplaySortOrder, + display_name AS DisplayName + FROM dashboard_resource_urls + WHERE resource_name = @ResourceName + ORDER BY ordinal; + """, new { ResourceName = resource.Name })) + { + var item = new Url + { + FullUrl = record.FullUrl, + IsInternal = record.IsInternal, + IsInactive = record.IsInactive, + DisplayProperties = new UrlDisplayProperties { SortOrder = record.DisplaySortOrder, DisplayName = record.DisplayName } + }; + if (record.EndpointName is not null) + { + item.EndpointName = record.EndpointName; + } + resource.Urls.Add(item); + } + } + + private static void LoadVolumes(SqliteConnection connection, Resource resource) + { + resource.Volumes.Add(connection.Query(""" + SELECT source AS Source, target AS Target, mount_type AS MountType, is_read_only AS IsReadOnly + FROM dashboard_resource_volumes + WHERE resource_name = @ResourceName + ORDER BY ordinal; + """, new { ResourceName = resource.Name })); + } + + private static void LoadHealthReports(SqliteConnection connection, Resource resource) + { + foreach (var record in connection.Query(""" + SELECT + status AS Status, + key AS Key, + description AS Description, + exception AS Exception, + last_run_at_seconds AS LastRunAtSeconds, + last_run_at_nanos AS LastRunAtNanos + FROM dashboard_resource_health_reports + WHERE resource_name = @ResourceName + ORDER BY ordinal; + """, new { ResourceName = resource.Name })) + { + var item = new HealthReport { Key = record.Key, Description = record.Description, Exception = record.Exception }; + if (record.Status is not null) + { + item.Status = (HealthStatus)record.Status.Value; + } + if (record.LastRunAtSeconds is not null) + { + item.LastRunAt = CreateTimestamp(record.LastRunAtSeconds.Value, record.LastRunAtNanos); + } + resource.HealthReports.Add(item); + } + } + + private static void LoadRelationships(SqliteConnection connection, Resource resource) + { + resource.Relationships.Add(connection.Query(""" + SELECT related_resource_name AS ResourceName, relationship_type AS Type + FROM dashboard_resource_relationships + WHERE resource_name = @ResourceName + ORDER BY ordinal; + """, new { ResourceName = resource.Name })); + } + + private static void LoadProperties(SqliteConnection connection, Resource resource) + { + foreach (var record in connection.Query(""" + SELECT + name AS Name, + display_name AS DisplayName, + value_id AS ValueId, + is_sensitive AS IsSensitive, + is_highlighted AS IsHighlighted, + sort_order AS SortOrder + FROM dashboard_resource_properties + WHERE resource_name = @ResourceName + ORDER BY ordinal; + """, new { ResourceName = resource.Name })) + { + var item = new ResourceProperty + { + Name = record.Name, + Value = LoadValue(connection, record.ValueId), + IsHighlighted = record.IsHighlighted + }; + if (record.DisplayName is not null) + { + item.DisplayName = record.DisplayName; + } + if (record.IsSensitive is not null) + { + item.IsSensitive = record.IsSensitive.Value; + } + if (record.SortOrder is not null) + { + item.SortOrder = record.SortOrder.Value; + } + resource.Properties.Add(item); + } + } + + private static void LoadCommands(SqliteConnection connection, Resource resource) + { +#pragma warning disable CS0612 // ResourceCommand.Parameter must be restored for compatibility with older AppHosts. + foreach (var record in connection.Query(""" + SELECT + ordinal AS Ordinal, + name AS Name, + display_name AS DisplayName, + confirmation_message AS ConfirmationMessage, + parameter_value_id AS ParameterValueId, + is_highlighted AS IsHighlighted, + icon_name AS IconName, + icon_variant AS IconVariant, + display_description AS DisplayDescription, + state AS State + FROM dashboard_resource_commands + WHERE resource_name = @ResourceName + ORDER BY ordinal; + """, new { ResourceName = resource.Name })) + { + var command = new ResourceCommand + { + Name = record.Name, + DisplayName = record.DisplayName, + IsHighlighted = record.IsHighlighted, + State = (ResourceCommandState)record.State + }; + if (record.ConfirmationMessage is not null) + { + command.ConfirmationMessage = record.ConfirmationMessage; + } + if (record.ParameterValueId is not null) + { + command.Parameter = LoadValue(connection, record.ParameterValueId.Value); + } + if (record.IconName is not null) + { + command.IconName = record.IconName; + } + if (record.IconVariant is not null) + { + command.IconVariant = (IconVariant)record.IconVariant.Value; + } + if (record.DisplayDescription is not null) + { + command.DisplayDescription = record.DisplayDescription; + } + + LoadCommandInputs(connection, resource.Name, record.Ordinal, command); + resource.Commands.Add(command); + } +#pragma warning restore CS0612 + } + + private static void LoadCommandInputs(SqliteConnection connection, string resourceName, int commandOrdinal, ResourceCommand command) + { + foreach (var record in connection.Query(""" + SELECT + ordinal AS Ordinal, + label AS Label, + placeholder AS Placeholder, + input_type AS InputType, + required AS Required, + value AS Value, + description AS Description, + enable_description_markdown AS EnableDescriptionMarkdown, + max_length AS MaxLength, + allow_custom_choice AS AllowCustomChoice, + loading AS Loading, + update_state_on_change AS UpdateStateOnChange, + name AS Name, + disabled AS Disabled, + max_file_size AS MaxFileSize, + allow_multiple_files AS AllowMultipleFiles, + file_filter AS FileFilter + FROM dashboard_resource_command_inputs + WHERE resource_name = @ResourceName AND command_ordinal = @CommandOrdinal + ORDER BY ordinal; + """, new { ResourceName = resourceName, CommandOrdinal = commandOrdinal })) + { + var input = new InteractionInput + { + Label = record.Label, + Placeholder = record.Placeholder, + InputType = (InputType)record.InputType, + Required = record.Required, + Value = record.Value, + Description = record.Description, + EnableDescriptionMarkdown = record.EnableDescriptionMarkdown, + MaxLength = record.MaxLength, + AllowCustomChoice = record.AllowCustomChoice, + Loading = record.Loading, + UpdateStateOnChange = record.UpdateStateOnChange, + Name = record.Name, + Disabled = record.Disabled, + MaxFileSize = record.MaxFileSize, + AllowMultipleFiles = record.AllowMultipleFiles, + FileFilter = record.FileFilter + }; + + foreach (var option in connection.Query(""" + SELECT option_key AS OptionKey, option_value AS OptionValue + FROM dashboard_resource_command_input_options + WHERE resource_name = @ResourceName AND command_ordinal = @CommandOrdinal AND input_ordinal = @InputOrdinal; + """, new { ResourceName = resourceName, CommandOrdinal = commandOrdinal, InputOrdinal = record.Ordinal })) + { + input.Options.Add(option.OptionKey, option.OptionValue); + } + input.ValidationErrors.Add(connection.Query(""" + SELECT validation_error + FROM dashboard_resource_command_input_validation_errors + WHERE resource_name = @ResourceName AND command_ordinal = @CommandOrdinal AND input_ordinal = @InputOrdinal + ORDER BY ordinal; + """, new { ResourceName = resourceName, CommandOrdinal = commandOrdinal, InputOrdinal = record.Ordinal })); + command.ArgumentInputs.Add(input); + } + } + + private static Value LoadValue(SqliteConnection connection, long valueId) + { + var record = connection.QuerySingle(""" + SELECT + value_id AS ValueId, + value_kind AS ValueKind, + string_value AS StringValue, + number_value AS NumberValue, + bool_value AS BoolValue + FROM dashboard_values + WHERE value_id = @ValueId; + """, new { ValueId = valueId }); + + var value = new Value(); + switch ((Value.KindOneofCase)record.ValueKind) + { + case Value.KindOneofCase.NullValue: + value.NullValue = NullValue.NullValue; + break; + case Value.KindOneofCase.NumberValue: + value.NumberValue = record.NumberValue!.Value; + break; + case Value.KindOneofCase.StringValue: + value.StringValue = record.StringValue!; + break; + case Value.KindOneofCase.BoolValue: + value.BoolValue = record.BoolValue!.Value; + break; + case Value.KindOneofCase.StructValue: + value.StructValue = new Struct(); + foreach (var child in connection.Query(""" + SELECT map_key AS MapKey, child_value_id AS ChildValueId + FROM dashboard_value_map_entries + WHERE parent_value_id = @ValueId + ORDER BY ordinal; + """, new { ValueId = valueId })) + { + value.StructValue.Fields.Add(child.MapKey, LoadValue(connection, child.ChildValueId)); + } + break; + case Value.KindOneofCase.ListValue: + value.ListValue = new ListValue(); + foreach (var childValueId in connection.Query(""" + SELECT child_value_id + FROM dashboard_value_list_items + WHERE parent_value_id = @ValueId + ORDER BY ordinal; + """, new { ValueId = valueId })) + { + value.ListValue.Values.Add(LoadValue(connection, childValueId)); + } + break; + case Value.KindOneofCase.None: + break; + default: + throw new InvalidOperationException($"Unknown dashboard value kind '{record.ValueKind}'."); + } + + return value; + } + + private static Timestamp CreateTimestamp(long seconds, int? nanos) + { + return new Timestamp { Seconds = seconds, Nanos = nanos ?? 0 }; + } + + private sealed record StoredResource(Resource Resource, int ReplicaIndex); + + private sealed class ResourceRecord + { + public required string ResourceName { get; init; } + public required int ReplicaIndex { get; init; } + public required string ResourceType { get; init; } + public required string DisplayName { get; init; } + public required string Uid { get; init; } + public string? State { get; init; } + public long? CreatedAtSeconds { get; init; } + public int? CreatedAtNanos { get; init; } + public string? StateStyle { get; init; } + public long? StartedAtSeconds { get; init; } + public int? StartedAtNanos { get; init; } + public long? StoppedAtSeconds { get; init; } + public int? StoppedAtNanos { get; init; } + public required bool IsHidden { get; init; } + public required bool SupportsDetailedTelemetry { get; init; } + public string? IconName { get; init; } + public int? IconVariant { get; init; } + } + + private sealed class EnvironmentRecord + { + public required string Name { get; init; } + public string? Value { get; init; } + public required bool IsFromSpec { get; init; } + } + + private sealed class UrlRecord + { + public string? EndpointName { get; init; } + public required string FullUrl { get; init; } + public required bool IsInternal { get; init; } + public required bool IsInactive { get; init; } + public required int DisplaySortOrder { get; init; } + public required string DisplayName { get; init; } + } + + private sealed class HealthReportRecord + { + public int? Status { get; init; } + public required string Key { get; init; } + public required string Description { get; init; } + public required string Exception { get; init; } + public long? LastRunAtSeconds { get; init; } + public int? LastRunAtNanos { get; init; } + } + + private sealed class PropertyRecord + { + public required string Name { get; init; } + public string? DisplayName { get; init; } + public required long ValueId { get; init; } + public bool? IsSensitive { get; init; } + public required bool IsHighlighted { get; init; } + public int? SortOrder { get; init; } + } + + private sealed class CommandRecord + { + public required int Ordinal { get; init; } + public required string Name { get; init; } + public required string DisplayName { get; init; } + public string? ConfirmationMessage { get; init; } + public long? ParameterValueId { get; init; } + public required bool IsHighlighted { get; init; } + public string? IconName { get; init; } + public int? IconVariant { get; init; } + public string? DisplayDescription { get; init; } + public required int State { get; init; } + } + + private sealed class InputRecord + { + public required int Ordinal { get; init; } + public required string Label { get; init; } + public required string Placeholder { get; init; } + public required int InputType { get; init; } + public required bool Required { get; init; } + public required string Value { get; init; } + public required string Description { get; init; } + public required bool EnableDescriptionMarkdown { get; init; } + public required int MaxLength { get; init; } + public required bool AllowCustomChoice { get; init; } + public required bool Loading { get; init; } + public required bool UpdateStateOnChange { get; init; } + public required string Name { get; init; } + public required bool Disabled { get; init; } + public required long MaxFileSize { get; init; } + public required bool AllowMultipleFiles { get; init; } + public required string FileFilter { get; init; } + } + + private sealed class OptionRecord + { + public required string OptionKey { get; init; } + public required string OptionValue { get; init; } + } + + private sealed class ValueRecord + { + public required long ValueId { get; init; } + public required int ValueKind { get; init; } + public string? StringValue { get; init; } + public double? NumberValue { get; init; } + public bool? BoolValue { get; init; } + } + + private sealed class MapValueRecord + { + public required string MapKey { get; init; } + public required long ChildValueId { get; init; } + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs new file mode 100644 index 00000000000..2c7e399e5e6 --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs @@ -0,0 +1,370 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using System.Threading.Channels; +using Aspire.Dashboard.Model; +using Aspire.DashboardService.Proto.V1; +using Dapper; + +namespace Aspire.Dashboard.ServiceClient; + +/// +/// Stores dashboard resources and console logs in SQLite. +/// +public sealed partial class SqliteResourceRepository : IResourceRepository, IResourceRepositoryWriter, IDisposable +{ + private readonly DashboardSqliteDatabase _database; + private readonly IKnownPropertyLookup _knownPropertyLookup; + private readonly ILogger _logger; + private readonly object _lock = new(); + private readonly Dictionary _resources = new(StringComparers.ResourceName); + private ImmutableHashSet>> _resourceChannels = []; + private readonly Dictionary>>> _consoleChannels = new(StringComparers.ResourceName); + private bool _disposed; + + public SqliteResourceRepository( + string databasePath, + IKnownPropertyLookup knownPropertyLookup, + ILoggerFactory loggerFactory, + bool readOnly = false) + : this(new DashboardSqliteDatabase(databasePath, readOnly), knownPropertyLookup, loggerFactory) + { + } + + internal SqliteResourceRepository( + DashboardSqliteDatabase database, + IKnownPropertyLookup knownPropertyLookup, + ILoggerFactory loggerFactory) + { + _database = database; + _knownPropertyLookup = knownPropertyLookup; + _logger = loggerFactory.CreateLogger(); + + if (!database.IsReadOnly) + { + database.InitializeSchema(); + } + + LoadResources(); + } + + public ResourceViewModel? GetResource(string resourceName) + { + lock (_lock) + { + ThrowIfDisposed(); + return _resources.GetValueOrDefault(resourceName); + } + } + + public IReadOnlyList GetResources() + { + lock (_lock) + { + ThrowIfDisposed(); + return _resources.Values.ToList(); + } + } + + public Task SubscribeResourcesAsync(CancellationToken cancellationToken) + { + lock (_lock) + { + ThrowIfDisposed(); + var channel = Channel.CreateUnbounded>(new UnboundedChannelOptions + { + AllowSynchronousContinuations = false, + SingleReader = true, + SingleWriter = false + }); + _resourceChannels = _resourceChannels.Add(channel); + + return Task.FromResult(new ResourceViewModelSubscription( + _resources.Values.ToImmutableArray(), + ReadResourceUpdatesAsync(channel, cancellationToken))); + } + } + + public async IAsyncEnumerable> GetConsoleLogs( + string resourceName, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + ResourceLogLine[] lines; + lock (_lock) + { + ThrowIfDisposed(); + using var connection = _database.OpenConnection(); + lines = connection.Query(""" + SELECT line_number AS LineNumber, content AS Content, is_stderr AS IsStdErr + FROM console_logs + WHERE resource_name = @ResourceName + ORDER BY line_number; + """, new { ResourceName = resourceName }) + .Select(line => new ResourceLogLine(line.LineNumber, line.Content, line.IsStdErr)) + .ToArray(); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (lines.Length > 0) + { + yield return lines; + } + } + + public async IAsyncEnumerable> SubscribeConsoleLogs( + string resourceName, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + ResourceLogLine[] initialLines; + Channel> channel; + lock (_lock) + { + ThrowIfDisposed(); + using var connection = _database.OpenConnection(); + initialLines = connection.Query(""" + SELECT line_number AS LineNumber, content AS Content, is_stderr AS IsStdErr + FROM console_logs + WHERE resource_name = @ResourceName + ORDER BY line_number; + """, new { ResourceName = resourceName }) + .Select(line => new ResourceLogLine(line.LineNumber, line.Content, line.IsStdErr)) + .ToArray(); + + channel = Channel.CreateUnbounded>(new UnboundedChannelOptions + { + AllowSynchronousContinuations = false, + SingleReader = true, + SingleWriter = false + }); + _consoleChannels[resourceName] = (_consoleChannels.GetValueOrDefault(resourceName) ?? []).Add(channel); + } + + try + { + if (initialLines.Length > 0) + { + yield return initialLines; + } + + await foreach (var lines in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + yield return lines; + } + } + finally + { + lock (_lock) + { + if (_consoleChannels.TryGetValue(resourceName, out var channels)) + { + channels = channels.Remove(channel); + if (channels.Count == 0) + { + _consoleChannels.Remove(resourceName); + } + else + { + _consoleChannels[resourceName] = channels; + } + } + } + } + } + + void IResourceRepositoryWriter.ReplaceResources(IReadOnlyList resources) + { + EnsureWritable(); + List changes = []; + + lock (_lock) + { + ThrowIfDisposed(); + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + connection.Execute("DELETE FROM dashboard_resources;", transaction: transaction); + _resources.Clear(); + + foreach (var resource in resources) + { + var viewModel = CreateViewModel(resource); + SaveResource(connection, transaction, resource, viewModel.ReplicaIndex); + _resources[resource.Name] = viewModel; + changes.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, viewModel)); + } + + transaction.Commit(); + } + + PublishResourceChanges(changes); + } + + void IResourceRepositoryWriter.ApplyChanges(IReadOnlyList changes) + { + EnsureWritable(); + List viewModelChanges = []; + + lock (_lock) + { + ThrowIfDisposed(); + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + + foreach (var change in changes) + { + if (change.KindCase == WatchResourcesChange.KindOneofCase.Upsert) + { + var resource = change.Upsert; + var viewModel = CreateViewModel(resource); + SaveResource(connection, transaction, resource, viewModel.ReplicaIndex); + _resources[resource.Name] = viewModel; + viewModelChanges.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, viewModel)); + } + else if (change.KindCase == WatchResourcesChange.KindOneofCase.Delete && _resources.Remove(change.Delete.ResourceName, out var removed)) + { + connection.Execute("DELETE FROM dashboard_resources WHERE resource_name = @ResourceName;", new { ResourceName = change.Delete.ResourceName }, transaction); + viewModelChanges.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Delete, removed)); + } + } + + transaction.Commit(); + } + + PublishResourceChanges(viewModelChanges); + } + + void IResourceRepositoryWriter.AddConsoleLogs(string resourceName, IReadOnlyList logLines) + { + EnsureWritable(); + if (logLines.Count == 0) + { + return; + } + + var viewModelLines = logLines.Select(line => new ResourceLogLine(line.LineNumber, line.Text, line.IsStdErr)).ToArray(); + Channel>[] channels; + lock (_lock) + { + ThrowIfDisposed(); + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + connection.Execute(""" + INSERT INTO console_logs (resource_name, line_number, content, is_stderr) + VALUES (@ResourceName, @LineNumber, @Content, @IsStdErr) + ON CONFLICT(resource_name, line_number) DO UPDATE SET + content = excluded.content, + is_stderr = excluded.is_stderr; + """, logLines.Select(line => new + { + ResourceName = resourceName, + line.LineNumber, + Content = line.Text, + line.IsStdErr + }), transaction); + transaction.Commit(); + channels = (_consoleChannels.GetValueOrDefault(resourceName) ?? []).ToArray(); + } + + foreach (var channel in channels) + { + channel.Writer.TryWrite(viewModelLines); + } + } + + private ResourceViewModel CreateViewModel(Resource resource) + { + if (_resources.TryGetValue(resource.Name, out var existingResource)) + { + return resource.ToViewModel(existingResource.ReplicaIndex, _knownPropertyLookup, _logger); + } + + var replicaIndex = _resources.Values.Count(r => string.Equals(r.DisplayName, resource.DisplayName, StringComparisons.ResourceName)) + 1; + return resource.ToViewModel(replicaIndex, _knownPropertyLookup, _logger); + } + + private void LoadResources() + { + using var connection = _database.OpenConnection(); + foreach (var storedResource in LoadResourceRecords(connection)) + { + _resources[storedResource.Resource.Name] = storedResource.Resource.ToViewModel(storedResource.ReplicaIndex, _knownPropertyLookup, _logger); + } + } + + private async IAsyncEnumerable> ReadResourceUpdatesAsync( + Channel> channel, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + try + { + await foreach (var changes in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + yield return changes; + } + } + finally + { + lock (_lock) + { + _resourceChannels = _resourceChannels.Remove(channel); + } + } + } + + private void PublishResourceChanges(IReadOnlyList changes) + { + if (changes.Count == 0) + { + return; + } + + Channel>[] channels; + lock (_lock) + { + channels = _resourceChannels.ToArray(); + } + foreach (var channel in channels) + { + channel.Writer.TryWrite(changes); + } + } + + private void EnsureWritable() + { + _database.EnsureWritable("Historical dashboard resources are read-only."); + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + + public void Dispose() + { + lock (_lock) + { + if (_disposed) + { + return; + } + _disposed = true; + + foreach (var channel in _resourceChannels) + { + channel.Writer.TryComplete(); + } + foreach (var channels in _consoleChannels.Values) + { + foreach (var channel in channels) + { + channel.Writer.TryComplete(); + } + } + } + } + + private sealed class ConsoleLogRecord + { + public required int LineNumber { get; init; } + public required string Content { get; init; } + public required bool IsStdErr { get; init; } + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs b/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs index 4ed6b3025d4..b70a67dcc37 100644 --- a/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs +++ b/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs @@ -23,6 +23,7 @@ internal static class BrowserStorageKeys public const string ResourcesShowResourceTypes = "Aspire_Resources_ShowResourceTypes"; public const string DashboardTelemetrySettings = "Aspire_Settings_DashboardTelemetry"; + public const string SelectedDashboardRunId = "Aspire_Settings_SelectedDashboardRunId"; public const string ResourcesShowHiddenResources = "Aspire_Resources_ShowHiddenResources"; public const string CollapsedResourceNamesKeyPrefix = "Aspire_Resources_CollapsedResourceNames_"; diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 2aadecad5b4..afb1a324425 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -599,6 +599,14 @@ internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext con context.EnvironmentVariables[KnownAspNetCoreConfigNames.Environment] = environment; context.EnvironmentVariables[DashboardConfigNames.ResourceServiceUrlName.EnvVarName] = resourceServiceUrl; + if (configuration["AppHost:DashboardApplicationName"] is { Length: > 0 } applicationName) + { + context.EnvironmentVariables[DashboardConfigNames.DashboardApplicationName.EnvVarName] = DashboardService.GetDashboardApplicationName(applicationName); + } + if (configuration["Aspire:Store:Path"] is { Length: > 0 } aspireStorePath) + { + context.EnvironmentVariables[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] = Path.Combine(aspireStorePath, ".aspire", "dashboard"); + } PopulateDashboardUrls(context); diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index ddb8cdec545..840cb39a71e 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -52,18 +52,18 @@ public override Task GetApplicationInformation( return Task.FromResult(new ApplicationInformationResponse { - ApplicationName = ComputeApplicationName(applicationName), + ApplicationName = GetDashboardApplicationName(applicationName), MinDashboardVersion = MinRequiredDashboardVersion }); + } - static string ComputeApplicationName(string applicationName) + internal static string GetDashboardApplicationName(string applicationName) + { + return ApplicationNameRegex().Match(applicationName) switch { - return ApplicationNameRegex().Match(applicationName) switch - { - Match { Success: true } match => match.Groups["name"].Value, - _ => applicationName - }; - } + Match { Success: true } match => match.Groups["name"].Value, + _ => applicationName + }; } public override async Task WatchInteractions(IAsyncStreamReader requestStream, IServerStreamWriter responseStream, ServerCallContext context) diff --git a/src/Shared/DashboardConfigNames.cs b/src/Shared/DashboardConfigNames.cs index 3f109ff9b35..21afe27c25a 100644 --- a/src/Shared/DashboardConfigNames.cs +++ b/src/Shared/DashboardConfigNames.cs @@ -16,6 +16,8 @@ internal static class DashboardConfigNames public static readonly ConfigName DashboardAspireApiDisabledName = new(KnownConfigNames.DashboardApiDisabled); public static readonly ConfigName ResourceServiceUrlName = new(KnownConfigNames.ResourceServiceEndpointUrl); public static readonly ConfigName ForwardedHeaders = new(KnownConfigNames.DashboardForwardedHeadersEnabled); + public static readonly ConfigName DashboardApplicationName = new("Dashboard:ApplicationName", "ASPIRE_DASHBOARD_APPLICATION_NAME"); + public static readonly ConfigName DashboardDataDirectoryName = new("Dashboard:Data:Directory", "ASPIRE_DASHBOARD_DATA_DIRECTORY"); public static readonly ConfigName DashboardOtlpAuthModeName = new("Dashboard:Otlp:AuthMode", "DASHBOARD__OTLP__AUTHMODE"); public static readonly ConfigName DashboardOtlpPrimaryApiKeyName = new("Dashboard:Otlp:PrimaryApiKey", "DASHBOARD__OTLP__PRIMARYAPIKEY"); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuButtonTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuButtonTests.cs index 169aecebcae..d7ef0cf23fa 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuButtonTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuButtonTests.cs @@ -10,6 +10,22 @@ namespace Aspire.Dashboard.Components.Tests.Controls; public class AspireMenuButtonTests : DashboardTestContext { + [Fact] + public void Disabled_DisablesButton() + { + FluentUISetupHelpers.SetupFluentUIComponents(this); + FluentUISetupHelpers.SetupFluentAnchoredRegion(this); + FluentUISetupHelpers.SetupFluentButton(this); + FluentUISetupHelpers.SetupFluentMenu(this); + + var cut = RenderComponent(builder => builder + .Add(component => component.MenuButtonId, "disabled-menu-button") + .Add(component => component.Items, [new MenuButtonItem { Text = "Item" }]) + .Add(component => component.Disabled, true)); + + Assert.True(cut.FindComponent().Instance.Disabled); + } + [Fact] public void ToggleMenu_UpdatesAriaExpandedState() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs index c6b6db75c73..36c01344ca6 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs @@ -41,7 +41,7 @@ await GenAIVisualizerDialog.OpenDialogAsync( dialogService: dialogService, span: CreateOtlpSpan(resource, trace, scope, spanId: "abc", parentSpanId: null, startDate: s_testTime), selectedLogEntryId: null, - telemetryRepository: Services.GetRequiredService(), + telemetryRepository: Services.GetRequiredService(), errorRecorder: new TestTelemetryErrorRecorder(), resources: [], getContextGenAISpans: () => [] @@ -107,7 +107,7 @@ await GenAIVisualizerDialog.OpenDialogAsync( dialogService: dialogService, span: span, selectedLogEntryId: null, - telemetryRepository: Services.GetRequiredService(), + telemetryRepository: Services.GetRequiredService(), errorRecorder: new TestTelemetryErrorRecorder(), resources: [], getContextGenAISpans: () => [] @@ -129,7 +129,7 @@ public async Task Render_HasGenAIMessages_CopyButtonHasAccessibleName() var span = CreateOtlpSpan(resource, trace, scope, spanId: GetHexId("abc"), parentSpanId: null, startDate: s_testTime); var cut = SetUpDialog(out var dialogService); - var repository = Services.GetRequiredService(); + var repository = Services.GetRequiredService(); repository.AddLogs(new AddContext(), new RepeatedField { new ResourceLogs @@ -177,7 +177,7 @@ public async Task UpdateTelemetry_DifferentTrace_ContentInstanceUnchanged() { // Arrange - Setup dialog infrastructure and repository var cut = SetUpDialog(out var dialogService); - var repository = Services.GetRequiredService(); + var repository = Services.GetRequiredService(); // Add initial trace to repository for the dialog to display var addContext = new AddContext(); @@ -260,7 +260,7 @@ public async Task UpdateTelemetry_SameTrace_ContentInstanceChanged() { // Arrange - Setup dialog infrastructure and repository var cut = SetUpDialog(out var dialogService); - var repository = Services.GetRequiredService(); + var repository = Services.GetRequiredService(); // Add initial trace to repository for the dialog to display var addContext = new AddContext(); diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/DashboardRunsDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/DashboardRunsDialogTests.cs new file mode 100644 index 00000000000..e693f54578c --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/DashboardRunsDialogTests.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Aspire.Dashboard.Components.Dialogs; +using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Tests.Shared; +using Aspire.Dashboard.Utils; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FluentUI.AspNetCore.Components; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Dialogs; + +public class DashboardRunsDialogTests : DashboardTestContext +{ + [Fact] + public async Task SelectHistoricalRun_OnlyUpdatesPendingSelection() + { + var historicalRun = new DashboardRunDescriptor( + RunId: "historical", + StartedAtUtc: new DateTimeOffset(2025, 1, 2, 12, 30, 0, TimeSpan.Zero), + EndedAtUtc: new DateTimeOffset(2025, 1, 2, 13, 30, 0, TimeSpan.Zero), + CleanShutdown: true, + ApplicationName: "TestApp", + DatabasePath: string.Empty, + IsCurrent: false); + var runStore = new FluentUISetupHelpers.TestDashboardRunStore( + [ + new( + RunId: "current", + StartedAtUtc: DateTimeOffset.UnixEpoch, + EndedAtUtc: null, + CleanShutdown: false, + ApplicationName: "TestApp", + DatabasePath: string.Empty, + IsCurrent: true), + historicalRun + ]); + string? storedRunId = null; + var sessionStorage = new TestSessionStorage + { + OnSetAsync = (key, value) => + { + Assert.Equal(BrowserStorageKeys.SelectedDashboardRunId, key); + storedRunId = Assert.IsType(value); + } + }; + + FluentUISetupHelpers.AddCommonDashboardServices(this, sessionStorage: sessionStorage, dashboardRunStore: runStore); + FluentUISetupHelpers.SetupFluentUIComponents(this); + FluentUISetupHelpers.SetupFluentInputLabel(this); + FluentUISetupHelpers.SetupFluentList(this); + FluentUISetupHelpers.SetupFluentCombobox(this); + + var content = new DashboardRunsDialogViewModel { SelectedRun = runStore.GetRuns().Single(run => run.IsCurrent) }; + var cut = RenderComponent(builder => builder.Add(component => component.Content, content)); + var select = Assert.Single(cut.FindComponents>()); + + Assert.Equal("TestApp runs:", select.Instance.Label); + Assert.Equal("Current", select.Instance.OptionText(runStore.GetRuns().Single(run => run.IsCurrent))); + var localStartedAt = TimeZoneInfo.ConvertTime(historicalRun.StartedAtUtc, Services.GetRequiredService().LocalTimeZone); + Assert.Equal($"Started {localStartedAt.ToString("g", CultureInfo.CurrentCulture)}", select.Instance.OptionText(historicalRun)); + + await cut.InvokeAsync(() => select.Instance.SelectedOptionChanged.InvokeAsync(historicalRun)); + + Assert.Same(historicalRun, content.SelectedRun); + Assert.Null(storedRunId); + var navigationManager = Services.GetRequiredService(); + Assert.Empty(new Uri(navigationManager.Uri).Query); + } +} \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index 5b96b2d13d6..43d1bf4534c 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -6,6 +6,7 @@ using Aspire.Dashboard.Components.Tests.Shared; using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; +using Aspire.Dashboard.Model.BrowserStorage; using Aspire.Dashboard.Tests.Shared; using Aspire.Dashboard.Utils; using Aspire.Tests.Shared; @@ -17,6 +18,8 @@ using Microsoft.FluentUI.AspNetCore.Components.Components.Tooltip; using Microsoft.JSInterop; using Xunit; +using DashboardRunsDialog = Aspire.Dashboard.Components.Dialogs.DashboardRunsDialog; +using DashboardRunsDialogViewModel = Aspire.Dashboard.Components.Dialogs.DashboardRunsDialogViewModel; namespace Aspire.Dashboard.Components.Tests.Layout; @@ -238,6 +241,122 @@ public async Task HeaderDialogClose_RestoresFocusToLaunchButton(bool isDesktop, }); } + [Fact] + public void DashboardRunsButton_Click_OpensDashboardRunsDialog() + { + DialogParameters? capturedParameters = null; + TestDialogService? dialogService = null; + dialogService = new TestDialogService(onShowDialog: (_, parameters) => + { + capturedParameters = parameters; + return Task.FromResult(new DialogReference(parameters.Id, dialogService!)); + }); + + SetupMainLayoutServices(dialogService: dialogService); + + var cut = RenderComponent(builder => + { + builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); + }); + + cut.Find("#dashboard-runs-button").Click(); + + Assert.NotNull(capturedParameters); + Assert.Equal(nameof(DashboardRunsDialog), capturedParameters.Id); + } + + [Fact] + public async Task DashboardRunsDialog_OkStoresSelectionAndReloadsWithoutQueryString() + { + var historicalRun = new DashboardRunDescriptor( + RunId: "historical", + StartedAtUtc: new DateTimeOffset(2025, 1, 2, 12, 30, 0, TimeSpan.Zero), + EndedAtUtc: new DateTimeOffset(2025, 1, 2, 13, 30, 0, TimeSpan.Zero), + CleanShutdown: true, + ApplicationName: "TestApp", + DatabasePath: string.Empty, + IsCurrent: false); + var runStore = new FluentUISetupHelpers.TestDashboardRunStore( + [ + new( + RunId: "current", + StartedAtUtc: DateTimeOffset.UnixEpoch, + EndedAtUtc: null, + CleanShutdown: false, + ApplicationName: "TestApp", + DatabasePath: string.Empty, + IsCurrent: true), + historicalRun + ]); + string? storedRunId = null; + var sessionStorage = new TestSessionStorage + { + OnSetAsync = (_, value) => storedRunId = Assert.IsType(value) + }; + DashboardRunsDialogViewModel? content = null; + DialogParameters? capturedParameters = null; + TestDialogService? dialogService = null; + dialogService = new TestDialogService(onShowDialog: (dialogContent, parameters) => + { + content = Assert.IsType(dialogContent); + capturedParameters = parameters; + return Task.FromResult(new DialogReference(parameters.Id, dialogService!)); + }); + + SetupMainLayoutServices(dialogService: dialogService, dashboardRunStore: runStore, sessionStorage: sessionStorage); + var cut = RenderComponent(builder => + { + builder.Add(component => component.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); + }); + + cut.Find("#dashboard-runs-button").Click(); + content!.SelectedRun = historicalRun; + await cut.InvokeAsync(() => capturedParameters!.OnDialogResult.InvokeAsync(DialogResult.Ok(true))); + + Assert.Equal("historical", storedRunId); + Assert.Empty(new Uri(Services.GetRequiredService().Uri).Query); + } + + [Fact] + public void HistoricalRun_DisplaysLocalStartTimeAfterApplicationName() + { + var timeProvider = new TestTimeProvider(); + var runStore = new FluentUISetupHelpers.TestDashboardRunStore( + [ + new( + RunId: "current", + StartedAtUtc: DateTimeOffset.UnixEpoch, + EndedAtUtc: null, + CleanShutdown: false, + ApplicationName: "TestApp", + DatabasePath: string.Empty, + IsCurrent: true), + new( + RunId: "historical", + StartedAtUtc: new DateTimeOffset(2025, 1, 2, 12, 30, 0, TimeSpan.Zero), + EndedAtUtc: new DateTimeOffset(2025, 1, 2, 13, 30, 0, TimeSpan.Zero), + CleanShutdown: true, + ApplicationName: "TestApp", + DatabasePath: string.Empty, + IsCurrent: false) + ]); + + var sessionStorage = new TestSessionStorage + { + OnGetAsync = key => key == BrowserStorageKeys.SelectedDashboardRunId + ? (true, "historical") + : (false, null) + }; + SetupMainLayoutServices(browserTimeProvider: timeProvider, dashboardRunStore: runStore, sessionStorage: sessionStorage); + + var cut = RenderComponent(builder => + { + builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); + }); + + Assert.Equal("Started 1/2/2025 1:30 PM", cut.Find(".application-run-start").TextContent); + } + [Theory] [InlineData(true, false, "dashboard-help-button", "HelpDialog", "dashboard-navigation-button")] [InlineData(true, false, "dashboard-settings-button", "SettingsDialog", "dashboard-navigation-button")] @@ -342,9 +461,18 @@ private void SetupMainLayoutServices( TestLocalStorage? localStorage = null, MessageService? messageService = null, Action? configureOptions = null, - IDialogService? dialogService = null) + IDialogService? dialogService = null, + BrowserTimeProvider? browserTimeProvider = null, + IDashboardRunStore? dashboardRunStore = null, + ISessionStorage? sessionStorage = null) { - FluentUISetupHelpers.AddCommonDashboardServices(this, localStorage: localStorage, messageService: messageService); + FluentUISetupHelpers.AddCommonDashboardServices( + this, + localStorage: localStorage, + messageService: messageService, + browserTimeProvider: browserTimeProvider, + dashboardRunStore: dashboardRunStore, + sessionStorage: sessionStorage); if (dialogService is not null) { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs index 818a6583228..358612982eb 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs @@ -716,6 +716,53 @@ public void MenuButtons_SelectedResourceChanged_ButtonsUpdated() }); } + [Fact] + public void ReadOnly_HighlightedCommandIsVisibleAndDisabled() + { + var resource = ModelTestHelpers.CreateResource( + resourceName: "test-resource", + state: KnownResourceState.Running, + commands: + [ + new CommandViewModel( + "test-command", + CommandViewModelState.Enabled, + "Test command", + "Test command description", + confirmationMessage: "", + argumentInputs: [], + isHighlighted: true, + iconName: string.Empty, + iconVariant: IconVariant.Regular) + ]); + var dashboardClient = new TestDashboardClient( + isEnabled: true, + consoleLogsChannelProvider: _ => Channel.CreateUnbounded>(), + resourceChannelProvider: Channel.CreateUnbounded>, + initialResources: [resource], + isReadOnly: true); + SetupConsoleLogsServices(dashboardClient); + var viewport = CreateViewport(isDesktop: true); + + var cut = RenderConsoleLogsPage(viewport, resource.Name); + + cut.WaitForAssertion(() => + { + var commandButton = Assert.Single( + cut.FindComponents(), + button => string.Equals(button.Instance.Class, "highlighted-command", StringComparison.Ordinal)); + Assert.True(commandButton.Instance.Disabled); + + var clearButton = Assert.Single( + cut.FindComponents(), + button => string.Equals(button.Instance.Class, "clear-button", StringComparison.Ordinal)); + Assert.True(clearButton.Instance.Disabled); + + var pauseButton = Assert.Single(cut.FindComponents()); + Assert.True(pauseButton.Instance.Disabled); + }); + } + [Fact] public async Task ExecuteCommand_DelayExecuting_IsExecutingReturnsTrueWhileRunning() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs index b0c581e510c..3481ff2045c 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs @@ -6,6 +6,8 @@ using Aspire.Dashboard.Components.Pages; using Aspire.Dashboard.Components.Resize; using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Extensions; +using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Tests.Shared; @@ -63,7 +65,7 @@ public async Task InitialLoad_SingleResource_RedirectToResource() return ValueTask.CompletedTask; }); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddMetrics(new AddContext(), new RepeatedField { new ResourceMetrics @@ -132,7 +134,7 @@ public void InitialLoad_HasSessionState_RedirectUsingState() loadRedirect = new Uri(a.Location); }; - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddMetrics(new AddContext(), new RepeatedField { new ResourceMetrics @@ -190,7 +192,7 @@ public void MetricsTree_MetricsAdded_TreeUpdated() // Arrange MetricsSetupHelpers.SetupMetricsPage(this); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddMetrics(new AddContext(), new RepeatedField { new ResourceMetrics @@ -280,6 +282,52 @@ public void MetricsTree_MetricsAdded_TreeUpdated() }); } + [Fact] + public void ReadOnly_ChartEndsAtLatestMetricTime() + { + MetricsSetupHelpers.SetupMetricsPage(this); + Services.AddSingleton(new TestDashboardClient(isReadOnly: true)); + + var telemetryRepository = Services.GetRequiredService(); + telemetryRepository.AddMetrics(new AddContext(), new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(name: "TestApp"), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateSumMetric(metricName: "test-instrument", startTime: s_testTime.AddMinutes(1)), + CreateSumMetric(metricName: "test-instrument", startTime: s_testTime.AddMinutes(2)) + } + } + } + } + }); + Services.GetRequiredService().SetMetricsPaused(true); + Services.GetRequiredService().NavigateTo( + DashboardUrls.MetricsUrl(resource: "TestApp", meter: "test-meter", instrument: "test-instrument", duration: 5, view: MetricViewKind.Graph.ToString())); + + var cut = RenderComponent(builder => + { + builder.Add(m => m.ResourceName, "TestApp"); + builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); + }); + + var chart = cut.FindComponent(); + var expectedEndTime = new DateTimeOffset(s_testTime.AddMinutes(2)); + Assert.Equal(expectedEndTime, chart.Instance.DataEndTime); + cut.WaitForAssertion(() => + { + var initializeChart = Assert.Single(JSInterop.Invocations, invocation => invocation.Identifier == "initializeChart"); + Assert.Equal(chart.Instance.TimeProvider.ToLocal(expectedEndTime), Assert.IsType(initializeChart.Arguments[3])); + }); + } + private void ChangeResourceAndAssertInstrument(string app1InstrumentName, string app2InstrumentName, string? expectedMeterNameAfterChange, string? expectedInstrumentNameAfterChange) { // Arrange @@ -288,7 +336,7 @@ private void ChangeResourceAndAssertInstrument(string app1InstrumentName, string var navigationManager = Services.GetRequiredService(); navigationManager.NavigateTo(DashboardUrls.MetricsUrl(resource: "TestApp", meter: "test-meter", instrument: app1InstrumentName, duration: 720, view: MetricViewKind.Table.ToString())); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddMetrics(new AddContext(), new RepeatedField { new ResourceMetrics diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs index 0d50aaa3a2e..45496f38f04 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs @@ -12,6 +12,7 @@ using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Tests.Shared; using Aspire.Dashboard.Utils; +using Aspire.Tests.Shared.DashboardModel; using Bunit; using ProtobufValue = Google.Protobuf.WellKnownTypes.Value; using Google.Protobuf.Collections; @@ -29,6 +30,47 @@ namespace Aspire.Dashboard.Components.Tests.Pages; [UseCulture("en-US")] public partial class ResourcesTests : DashboardTestContext { + [Fact] + public void ReadOnly_HighlightedCommandIsVisibleAndDisabled() + { + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + var resource = ModelTestHelpers.CreateResource( + resourceName: "test-resource", + state: KnownResourceState.Running, + commands: + [ + new CommandViewModel( + "test-command", + CommandViewModelState.Enabled, + "Test command", + "Test command description", + confirmationMessage: "", + argumentInputs: [], + isHighlighted: true, + iconName: string.Empty, + iconVariant: IconVariant.Regular) + ]); + var dashboardClient = new TestDashboardClient( + isEnabled: true, + initialResources: [resource], + resourceChannelProvider: Channel.CreateUnbounded>, + isReadOnly: true); + ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); + + var cut = RenderComponent(builder => + { + builder.AddCascadingValue(viewport); + }); + + cut.WaitForAssertion(() => + { + var commandButton = Assert.Single( + cut.FindComponents(), + button => string.Equals(button.Instance.Title, "Test command description", StringComparison.Ordinal)); + Assert.True(commandButton.Instance.Disabled); + }); + } + [Fact] public void UpdateResources_FiltersUpdated() { @@ -477,7 +519,7 @@ public void UnreadLogErrorsBadge_StopsKeyboardPropagation() FluentUISetupHelpers.SetupFluentUIComponents(this); FluentUISetupHelpers.SetupFluentAnchor(this); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); AddErrorLog(telemetryRepository, resourceName: "Resource1"); var unviewedErrorCounts = telemetryRepository.GetResourceUnviewedErrorLogsCount(); var resourceKey = Assert.Single(unviewedErrorCounts.Keys); @@ -529,7 +571,7 @@ private static ResourceViewModel CreateResource( }; } - private static void AddErrorLog(TelemetryRepository repository, string resourceName) + private static void AddErrorLog(InMemoryTelemetryRepository repository, string resourceName) { var addContext = new AddContext(); var logs = new RepeatedField(); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs index bbb28f57445..9f4b07e5fea 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs @@ -33,7 +33,7 @@ public void Render_ResourceInstanceHasDashes_AppKeyResolvedCorrectly() // Arrange SetupStructureLogsServices(); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddLogs(new AddContext(), new RepeatedField { new ResourceLogs diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs index db450e2d6d9..63f8f0ef7a0 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs @@ -46,7 +46,7 @@ public void Render_HasTrace_SubscriptionRemovedOnDispose() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { new ResourceSpans @@ -78,7 +78,7 @@ public void Render_HasTrace_SubscriptionRemovedOnDispose() // Assert Assert.Collection(telemetryRepository.TracesSubscriptions, t => { - Assert.Equal(nameof(TelemetryRepository.OnNewTraces), t.Name); + Assert.Equal(nameof(InMemoryTelemetryRepository.OnNewTraces), t.Name); }); DisposeComponents(); @@ -96,7 +96,7 @@ public void Render_FocusesAccessibleScrollContainerOnInitialRender() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { new ResourceSpans @@ -154,7 +154,7 @@ public async Task Render_ChangeTrace_RowsRendered() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { new ResourceSpans @@ -222,7 +222,7 @@ public async Task Render_TraceUpdateWithNewSpans_RowsRendered() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { new ResourceSpans @@ -304,7 +304,7 @@ public async Task Render_UpdateDifferentTrace_TraceNotUpdated() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { new ResourceSpans @@ -381,7 +381,7 @@ public async Task Render_SpansOrderedByStartTime_RowsRenderedInCorrectOrder() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { @@ -449,7 +449,7 @@ public async Task Render_DurationFilter_FiltersShortSpans() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { @@ -574,7 +574,7 @@ public async Task Render_DurationFilter_LongRoot_DoesNotExposeShortChildren() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { @@ -657,7 +657,7 @@ public void ToggleCollapse_SpanStateChanges() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { @@ -740,7 +740,7 @@ public void CollapseAllSpans_CollapsesAllSpans() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { @@ -815,7 +815,7 @@ public void ExpandAllSpans_ExpandsAllSpans() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index 6640764a84d..bb3c71ce819 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -7,6 +7,7 @@ using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.BrowserStorage; using Aspire.Dashboard.Otlp.Storage; +using Aspire.Dashboard.ServiceClient; using Aspire.Dashboard.Tests.Shared; using Aspire.Dashboard.Telemetry; using Aspire.Dashboard.Tests; @@ -150,15 +151,20 @@ public static void AddCommonDashboardServices( ISessionStorage? sessionStorage = null, ThemeManager? themeManager = null, IMessageService? messageService = null, - BrowserTimeProvider? browserTimeProvider = null) + BrowserTimeProvider? browserTimeProvider = null, + IDashboardRunStore? dashboardRunStore = null) { context.Services.AddLocalization(); context.Services.AddSingleton(browserTimeProvider ?? new TestTimeProvider()); - context.Services.AddSingleton(); + context.Services.AddSingleton(); + context.Services.AddSingleton(services => services.GetRequiredService()); context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(localStorage ?? new TestLocalStorage()); context.Services.AddSingleton(sessionStorage ?? new TestSessionStorage()); + context.Services.AddSingleton(dashboardRunStore ?? new TestDashboardRunStore()); + context.Services.AddSingleton(); + context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(); @@ -180,6 +186,33 @@ public static void AddCommonDashboardServices( context.Services.AddSingleton>(Options.Create(new DashboardOptions())); } + internal sealed class TestDashboardRunStore(IReadOnlyList? runs = null) : IDashboardRunStore + { + private readonly IReadOnlyList _runs = runs ?? + [ + new( + RunId: "current", + StartedAtUtc: DateTimeOffset.UnixEpoch, + EndedAtUtc: null, + CleanShutdown: false, + ApplicationName: "TestApp", + DatabasePath: string.Empty, + IsCurrent: true) + ]; + + public IReadOnlyList GetRuns() => _runs; + } + + internal sealed class TestDashboardRunSelection : IDashboardRunSelection + { + public string? SelectedRunId { get; private set; } + + public void SelectRun(string? runId) + { + SelectedRunId = runId; + } + } + public static void SetupFluentUIComponents(TestContext context) { context.Services.AddFluentUIComponents(); diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/MetricsSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/MetricsSetupHelpers.cs index 24cb036704f..ad93071204d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/MetricsSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/MetricsSetupHelpers.cs @@ -20,7 +20,8 @@ internal static class MetricsSetupHelpers { public static void SetupChartContainer(TestContext context) { - _ = context.JSInterop.SetupModule("/Components/Controls/Chart/MetricTable.razor.js"); + var metricTableModule = context.JSInterop.SetupModule("/Components/Controls/Chart/MetricTable.razor.js"); + metricTableModule.SetupVoid("announceDataGridRows", _ => true); FluentUISetupHelpers.SetupFluentTab(context); FluentUISetupHelpers.SetupFluentOverflow(context); diff --git a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs index 790e5ac0496..9a73581c478 100644 --- a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs +++ b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs @@ -45,6 +45,28 @@ public void ValidOptions_AreValid() Assert.True(result.Succeeded); } + [Fact] + public void PostConfigure_MapsDashboardRunStorageEnvironmentAliases() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DashboardConfigNames.DashboardApplicationName.EnvVarName] = "My Dashboard", + [DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] = "/data/.aspire/dashboard" + }) + .Build(); + var options = new DashboardOptions + { + ApplicationName = "Section Name", + Data = new DashboardDataOptions { Directory = "/section/path" } + }; + + new PostConfigureDashboardOptions(configuration).PostConfigure(null, options); + + Assert.Equal("My Dashboard", options.ApplicationName); + Assert.Equal("/data/.aspire/dashboard", options.Data.Directory); + } + #region Frontend options [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs b/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs index 582a575bb68..f34ff02be34 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs @@ -485,7 +485,7 @@ public OtlpHttpJsonTests(ITestOutputHelper testOutputHelper) _testOutputHelper = testOutputHelper; } - private static OtlpResource AssertMyServiceResource(TelemetryRepository telemetryRepository) + private static OtlpResource AssertMyServiceResource(ITelemetryRepository telemetryRepository) { var resources = telemetryRepository.GetResourcesByName("my.service"); var resource = Assert.Single(resources); @@ -521,7 +521,7 @@ public async Task CallService_Traces_JsonContentType_Success() Assert.NotNull(response); // Verify data was stored in the repository - var telemetryRepository = app.Services.GetRequiredService(); + var telemetryRepository = app.Services.GetRequiredService(); var resource = AssertMyServiceResource(telemetryRepository); var traces = telemetryRepository.GetTraces(new GetTracesRequest @@ -581,7 +581,7 @@ public async Task CallService_Logs_JsonContentType_Success() Assert.NotNull(response); // Verify data was stored in the repository - var telemetryRepository = app.Services.GetRequiredService(); + var telemetryRepository = app.Services.GetRequiredService(); var resource = AssertMyServiceResource(telemetryRepository); var logs = telemetryRepository.GetLogs(new GetLogsContext @@ -658,7 +658,7 @@ public async Task CallService_Events_JsonContentType_Success() Assert.NotNull(response); // Verify data was stored in the repository (events are stored as logs) - var telemetryRepository = app.Services.GetRequiredService(); + var telemetryRepository = app.Services.GetRequiredService(); var resource = AssertMyServiceResource(telemetryRepository); var logs = telemetryRepository.GetLogs(new GetLogsContext @@ -708,7 +708,7 @@ public async Task CallService_Metrics_JsonContentType_Success() Assert.NotNull(response); // Verify data was stored in the repository - var telemetryRepository = app.Services.GetRequiredService(); + var telemetryRepository = app.Services.GetRequiredService(); var resource = AssertMyServiceResource(telemetryRepository); var instruments = resource.GetInstrumentsSummary(); @@ -758,7 +758,7 @@ public async Task CallService_Metrics_NumericBucketCounts_Success() var response = System.Text.Json.JsonSerializer.Deserialize(responseBody, OtlpJsonSerializerContext.Default.OtlpExportMetricsServiceResponseJson); Assert.NotNull(response); - var telemetryRepository = app.Services.GetRequiredService(); + var telemetryRepository = app.Services.GetRequiredService(); var resources = telemetryRepository.GetResourcesByName("copilot-chat"); var resource = Assert.Single(resources); diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index 987e350a11c..2cfce9ef0fb 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -32,6 +32,18 @@ namespace Aspire.Dashboard.Tests.Integration; public class StartupTests(ITestOutputHelper testOutputHelper) { + [Fact] + public async Task Construction_ValidatesServiceDescriptorsAndScopes() + { + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication( + testOutputHelper, + preConfigureBuilder: builder => builder.WebHost.UseDefaultServiceProvider(options => + { + options.ValidateOnBuild = true; + options.ValidateScopes = true; + })); + } + [Fact] public async Task EndPointAccessors_AppStarted_EndPointPortsAssigned() { diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index 3bd41abf4a8..62944acc504 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -153,6 +153,38 @@ public async Task SubscribeResources_HasInitialData_InitialDataReturned() Assert.Single(initialData); } + [Fact] + public async Task SubscribeConsoleLogs_ReceivesAndPersistsLogs() + { + var repositoryWriter = new RecordingResourceRepositoryWriter(); + await using var instance = CreateResourceServiceClient(repositoryWriter); + instance.SetDashboardServiceClient(new MockDashboardServiceClient + { + ConsoleLogUpdates = + [ + new WatchResourceConsoleLogsUpdate + { + LogLines = + { + new ConsoleLogLine { LineNumber = 1, Text = "Hello", IsStdErr = false } + } + } + ] + }); + + var batches = new List>(); + await foreach (var batch in instance.SubscribeConsoleLogs("api", CancellationToken.None)) + { + batches.Add(batch); + } + + var line = Assert.Single(Assert.Single(batches)); + Assert.Equal(new ResourceLogLine(1, "Hello", false), line); + var persistedLogs = Assert.Single(repositoryWriter.ConsoleLogs); + Assert.Equal("api", persistedLogs.ResourceName); + Assert.Equal("Hello", Assert.Single(persistedLogs.LogLines).Text); + } + [Fact] public async Task SubscribeInteractions_OnCancel_ChannelRemoved() { @@ -522,6 +554,17 @@ private sealed class MockDashboardServiceClient : Aspire.DashboardService.Proto. public bool FailOnExecuteResourceCommand { get; init; } public bool CancelExecuteResourceCommandOnCallCancellation { get; init; } public string MinDashboardVersion { get; init; } = ""; + public IReadOnlyList ConsoleLogUpdates { get; init; } = []; + + public override AsyncServerStreamingCall WatchResourceConsoleLogs(WatchResourceConsoleLogsRequest request, CallOptions options) + { + return new AsyncServerStreamingCall( + new AsyncStreamReader(ConsoleLogUpdates), + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => new Metadata(), + () => { }); + } public override AsyncDuplexStreamingCall WatchInteractions(CallOptions options) { @@ -632,14 +675,45 @@ public Task MoveNext(CancellationToken cancellationToken) private sealed class AsyncStreamReader : IAsyncStreamReader { - public T Current { get; } = default!; + private readonly Queue _items; + + public AsyncStreamReader(IEnumerable? items = null) + { + _items = new Queue(items ?? []); + } + + public T Current { get; private set; } = default!; public Task MoveNext(CancellationToken cancellationToken) { + if (_items.TryDequeue(out var item)) + { + Current = item; + return Task.FromResult(true); + } + return Task.FromResult(false); } } + private sealed class RecordingResourceRepositoryWriter : IResourceRepositoryWriter + { + public List<(string ResourceName, IReadOnlyList LogLines)> ConsoleLogs { get; } = []; + + public void ReplaceResources(IReadOnlyList resources) + { + } + + public void ApplyChanges(IReadOnlyList changes) + { + } + + public void AddConsoleLogs(string resourceName, IReadOnlyList logLines) + { + ConsoleLogs.Add((resourceName, logLines)); + } + } + private sealed class ClientStreamWriter : IClientStreamWriter { public WriteOptions? WriteOptions { get; set; } @@ -655,9 +729,9 @@ public Task WriteAsync(T message) } } - private DashboardClient CreateResourceServiceClient() + private DashboardClient CreateResourceServiceClient(IResourceRepositoryWriter? resourceRepositoryWriter = null) { - return new DashboardClient(NullLoggerFactory.Instance, _configuration, _dashboardOptions, new MockKnownPropertyLookup(), new TestStringLocalizer()); + return new DashboardClient(NullLoggerFactory.Instance, _configuration, _dashboardOptions, new MockKnownPropertyLookup(), new TestStringLocalizer(), resourceRepositoryWriter: resourceRepositoryWriter); } private static CommandViewModel CreateCommand() diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs new file mode 100644 index 00000000000..4722016dc43 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -0,0 +1,253 @@ +// 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.Configuration; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; +using Aspire.DashboardService.Proto.V1; +using Google.Protobuf.Collections; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using OpenTelemetry.Proto.Logs.V1; +using Xunit; +using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers; + +namespace Aspire.Dashboard.Tests.Model; + +public sealed class DashboardDataSourceTests : IDisposable +{ + private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-runs-tests-").FullName; + + [Fact] + public void RunDirectory_IsNestedUnderApplicationDirectoryAndRuns() + { + var options = CreateOptions("My Dashboard"); + + using var runStore = new DashboardRunStore(options); + + var applicationDirectoryName = DashboardRunStore.GetApplicationDirectoryName("My Dashboard"); + var expectedRunsDirectory = Path.Combine(_temporaryDirectory, applicationDirectoryName, "runs"); + Assert.Equal(expectedRunsDirectory, Directory.GetParent(runStore.RunDirectory)!.FullName); + } + + [Fact] + public void ApplicationDirectoryName_IsSafeBoundedAndUnique() + { + var firstName = new string('a', 300) + "/dashboard"; + var secondName = new string('a', 300) + ":dashboard"; + + var firstDirectoryName = DashboardRunStore.GetApplicationDirectoryName(firstName); + var secondDirectoryName = DashboardRunStore.GetApplicationDirectoryName(secondName); + + Assert.Equal(DashboardRunStore.MaxApplicationDirectoryNameLength, firstDirectoryName.Length); + Assert.Equal(DashboardRunStore.MaxApplicationDirectoryNameLength, secondDirectoryName.Length); + Assert.Matches("^[A-Za-z0-9_-]+-[0-9a-f]{16}$", firstDirectoryName); + Assert.Matches("^[A-Za-z0-9_-]+-[0-9a-f]{16}$", secondDirectoryName); + Assert.NotEqual(firstDirectoryName, secondDirectoryName); + Assert.Equal(firstDirectoryName, DashboardRunStore.GetApplicationDirectoryName(firstName)); + } + + [Fact] + public void GetRuns_ReturnsCurrentThenCompletedHistoricalRun() + { + var options = CreateOptions(); + string historicalRunId; + + using (var historicalRunStore = new DashboardRunStore(options)) + { + historicalRunId = historicalRunStore.RunId; + using var telemetryRepository = CreateTelemetryRepository(historicalRunStore.DatabasePath, options); + } + + using var currentRunStore = new DashboardRunStore(options); + using var currentTelemetryRepository = CreateTelemetryRepository(currentRunStore.DatabasePath, options); + + Assert.Collection( + currentRunStore.GetRuns(), + currentRun => + { + Assert.True(currentRun.IsCurrent); + Assert.Equal(currentRunStore.RunId, currentRun.RunId); + }, + historicalRun => + { + Assert.False(historicalRun.IsCurrent); + Assert.True(historicalRun.CleanShutdown); + Assert.NotNull(historicalRun.EndedAtUtc); + Assert.Equal(historicalRunId, historicalRun.RunId); + }); + } + + [Fact] + public void GetRuns_ExcludesIncompatibleDatabaseWithoutDeletingIt() + { + var options = CreateOptions(); + string incompatibleDatabasePath; + + using (var incompatibleRunStore = new DashboardRunStore(options)) + { + incompatibleDatabasePath = incompatibleRunStore.DatabasePath; + using var connection = new SqliteConnection($"Data Source={incompatibleDatabasePath};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TABLE dashboard_schema (version INTEGER NOT NULL); INSERT INTO dashboard_schema VALUES (1);"; + command.ExecuteNonQuery(); + } + + using var currentRunStore = new DashboardRunStore(options); + + Assert.Collection(currentRunStore.GetRuns(), run => Assert.True(run.IsCurrent)); + Assert.True(File.Exists(incompatibleDatabasePath)); + } + + [Fact] + public void SqliteDatabase_ConfiguresExactStringFunctionsAndForeignKeys() + { + var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "connection.db")); + using var connection = database.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT + 'Ångström' = 'ångström' COLLATE ORDINAL_IGNORE_CASE, + ordinal_contains('CAFÉ au lait', 'fé AU'), + ordinal_starts_with('Δelta', 'δE'), + (SELECT foreign_keys FROM pragma_foreign_keys()); + """; + using var reader = command.ExecuteReader(); + + Assert.True(reader.Read()); + Assert.Equal(1, reader.GetInt64(0)); + Assert.Equal(1, reader.GetInt64(1)); + Assert.Equal(1, reader.GetInt64(2)); + Assert.Equal(1, reader.GetInt64(3)); + } + + [Fact] + public void SelectedHistoricalRun_ReplaysDataAndRejectsMutation() + { + var options = CreateOptions(); + string historicalRunId; + + using (var historicalRunStore = new DashboardRunStore(options)) + { + historicalRunId = historicalRunStore.RunId; + using var telemetryRepository = CreateTelemetryRepository(historicalRunStore.DatabasePath, options); + telemetryRepository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("HistoricalLogger"), + LogRecords = { CreateLogRecord() } + } + } + } + }); + using var resourceRepository = CreateResourceRepository(historicalRunStore.DatabasePath); + ((IResourceRepositoryWriter)resourceRepository).ReplaceResources([new Resource + { + Name = "api", + DisplayName = "API", + ResourceType = "Project", + CreatedAt = Timestamp.FromDateTime(DateTime.UnixEpoch) + }]); + } + + using var currentRunStore = new DashboardRunStore(options); + using var currentTelemetryRepository = CreateTelemetryRepository(currentRunStore.DatabasePath, options); + using var currentResourceRepository = CreateResourceRepository(currentRunStore.DatabasePath); + using var dataSource = CreateDataSource( + currentRunStore, + currentTelemetryRepository, + currentResourceRepository, + options); + dataSource.SelectRun(historicalRunId); + + Assert.True(dataSource.IsReadOnly); + Assert.Equal(historicalRunId, dataSource.SelectedRun.RunId); + Assert.Equal("api", Assert.Single(dataSource.ResourceRepository.GetResources()).Name); + Assert.Equal("TestService", Assert.Single(dataSource.TelemetryRepository.GetResources()).ResourceName); + Assert.Equal("Test Value!", Assert.Single(dataSource.TelemetryRepository.GetLogs(new GetLogsContext + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + Filters = [] + }).Items).Message); + Assert.Throws(() => dataSource.TelemetryRepository.ClearMetrics()); + } + + [Fact] + public void UnknownRunId_SelectsCurrentRun() + { + var options = CreateOptions(); + using var currentRunStore = new DashboardRunStore(options); + using var currentTelemetryRepository = CreateTelemetryRepository(currentRunStore.DatabasePath, options); + using var currentResourceRepository = CreateResourceRepository(currentRunStore.DatabasePath); + using var dataSource = CreateDataSource( + currentRunStore, + currentTelemetryRepository, + currentResourceRepository, + options); + dataSource.SelectRun("missing"); + + Assert.False(dataSource.IsReadOnly); + Assert.True(dataSource.SelectedRun.IsCurrent); + Assert.Equal(currentRunStore.RunId, dataSource.SelectedRun.RunId); + Assert.Same(currentResourceRepository, dataSource.ResourceRepository); + Assert.Same(currentTelemetryRepository, dataSource.TelemetryRepository); + } + + private IOptions CreateOptions(string applicationName = "TestApp") + { + return Options.Create(new DashboardOptions + { + ApplicationName = applicationName, + Data = new DashboardDataOptions { Directory = _temporaryDirectory } + }); + } + + private static SqliteTelemetryRepository CreateTelemetryRepository(string databasePath, IOptions options) + { + return new SqliteTelemetryRepository( + databasePath, + NullLoggerFactory.Instance, + options, + new PauseManager(), + []); + } + + private static SqliteResourceRepository CreateResourceRepository(string databasePath) + { + return new SqliteResourceRepository(databasePath, new MockKnownPropertyLookup(), NullLoggerFactory.Instance); + } + + private static DashboardDataSource CreateDataSource( + DashboardRunStore runStore, + SqliteTelemetryRepository telemetryRepository, + SqliteResourceRepository resourceRepository, + IOptions options) + { + return new DashboardDataSource( + runStore, + telemetryRepository, + resourceRepository, + NullLoggerFactory.Instance, + options, + new PauseManager(), + [], + new MockKnownPropertyLookup()); + } + + public void Dispose() + { + Directory.Delete(_temporaryDirectory, recursive: true); + } +} \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/Model/GenAIVisualizerDialogViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/GenAIVisualizerDialogViewModelTests.cs index abd28024efb..0305f7f65e0 100644 --- a/tests/Aspire.Dashboard.Tests/Model/GenAIVisualizerDialogViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/GenAIVisualizerDialogViewModelTests.cs @@ -1657,7 +1657,7 @@ public void Create_GenAIToolDefinitions_WithArrayItems_ParsesAndFormatsCorrectly } private static GenAIVisualizerDialogViewModel Create( - TelemetryRepository repository, + InMemoryTelemetryRepository repository, SpanDetailsViewModel spanDetailsViewModel) { return GenAIVisualizerDialogViewModel.Create( diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs index 49326a27f46..b0e15bfa7dc 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs @@ -6,6 +6,7 @@ using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Resources; +using Aspire.Dashboard.Tests.Shared; using Aspire.Dashboard.Tests.TelemetryRepositoryTests; using Aspire.Tests.Shared; using Aspire.Tests.Shared.DashboardModel; @@ -35,7 +36,9 @@ public ResourceMenuBuilderTests() dimensionManager); } - private ResourceMenuBuilder CreateResourceMenuBuilder(TelemetryRepository repository) + private ResourceMenuBuilder CreateResourceMenuBuilder(TelemetryRepository repository, TestAIContextProvider aiContextProvider) + InMemoryTelemetryRepository repository, + IDashboardClient? dashboardClient = null) { return new ResourceMenuBuilder( new TestNavigationManager(), @@ -43,7 +46,8 @@ private ResourceMenuBuilder CreateResourceMenuBuilder(TelemetryRepository reposi new TestStringLocalizer(), new TestStringLocalizer(), _iconResolver, - _dialogService); + _dialogService, + dashboardClient ?? new TestDashboardClient()); } [Fact] @@ -289,6 +293,48 @@ public void AddMenuItems_IncludesStartCommandLikeOtherVisibleCommands() e => Assert.Equal("Stop", e.Text)); } + [Fact] + public void AddMenuItems_ReadOnly_DisablesResourceCommands() + { + var command = new CommandViewModel( + CommandViewModel.StartCommand, + CommandViewModelState.Enabled, + "Start", + "Start the resource.", + confirmationMessage: "", + argumentInputs: [], + isHighlighted: true, + iconName: string.Empty, + iconVariant: IconVariant.Regular); + var resource = ModelTestHelpers.CreateResource(commands: [command]); + var repository = TelemetryTestHelpers.CreateRepository(); + var resourceMenuBuilder = CreateResourceMenuBuilder( + repository, + new TestAIContextProvider(), + new TestDashboardClient(isReadOnly: true)); + + var menuItems = new List(); + resourceMenuBuilder.AddMenuItems( + menuItems, + resource, + new Dictionary(StringComparer.OrdinalIgnoreCase) { [resource.Name] = resource }, + EventCallback.Empty, + EventCallback.Empty, + (_, _) => false, + showViewDetails: false, + showConsoleLogsItem: false, + showUrls: false); + + Assert.Collection(menuItems, + e => Assert.Equal("Localized:ViewJson", e.Text), + e => Assert.True(e.IsDivider), + e => + { + Assert.Equal("Start", e.Text); + Assert.True(e.IsDisabled); + }); + } + [Fact] public void AddMenuItems_UnresolvableIconName_UsesFallbackIcon() { diff --git a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs new file mode 100644 index 00000000000..bcf23d140b0 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs @@ -0,0 +1,351 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.DashboardService.Proto.V1; +using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Aspire.Dashboard.Tests.Model; + +public sealed class SqliteResourceRepositoryTests : IDisposable +{ + private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-resource-tests-").FullName; + + [Fact] + public void Resources_PersistAndReplayWithEquivalentValues() + { + var databasePath = Path.Combine(_temporaryDirectory, "dashboard.db"); + var resource = CreateResource("api-123", "api"); + + using (var repository = CreateRepository(databasePath)) + { + var writer = (IResourceRepositoryWriter)repository; + writer.ReplaceResources([resource]); + + AssertResource(Assert.Single(repository.GetResources()), resource, replicaIndex: 1); + + var updated = resource.Clone(); + updated.State = "Running"; + writer.ApplyChanges([new WatchResourcesChange { Upsert = updated }]); + Assert.Equal("Running", repository.GetResource(resource.Name)!.State); + } + + using var historicalRepository = CreateRepository(databasePath, readOnly: true); + AssertResource(Assert.Single(historicalRepository.GetResources()), resource, replicaIndex: 1, state: "Running"); + } + + [Fact] + public async Task ResourceSubscription_ReceivesUpsertAndDelete() + { + using var repository = CreateRepository(Path.Combine(_temporaryDirectory, "subscription.db")); + var writer = (IResourceRepositoryWriter)repository; + var subscription = await repository.SubscribeResourcesAsync(CancellationToken.None); + Assert.Empty(subscription.InitialState); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.Subscription.GetAsyncEnumerator(cts.Token); + + var resource = CreateResource("worker", "worker"); + writer.ApplyChanges([new WatchResourcesChange { Upsert = resource }]); + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Equal(ResourceViewModelChangeType.Upsert, Assert.Single(enumerator.Current).ChangeType); + + writer.ApplyChanges([new WatchResourcesChange { Delete = new ResourceDeletion { ResourceName = resource.Name } }]); + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Equal(ResourceViewModelChangeType.Delete, Assert.Single(enumerator.Current).ChangeType); + Assert.Empty(repository.GetResources()); + } + + [Fact] + public async Task ConsoleLogs_AreOrderedIdempotentAndReplayable() + { + var databasePath = Path.Combine(_temporaryDirectory, "console.db"); + using (var repository = CreateRepository(databasePath)) + { + var writer = (IResourceRepositoryWriter)repository; + writer.AddConsoleLogs("api", [ + new ConsoleLogLine { LineNumber = 2, Text = "second", IsStdErr = true }, + new ConsoleLogLine { LineNumber = 1, Text = "first" } + ]); + writer.AddConsoleLogs("api", [new ConsoleLogLine { LineNumber = 2, Text = "second-updated", IsStdErr = true }]); + } + + using var historicalRepository = CreateRepository(databasePath, readOnly: true); + var batches = new List>(); + await foreach (var batch in historicalRepository.GetConsoleLogs("api", CancellationToken.None)) + { + batches.Add(batch); + } + var lines = Assert.Single(batches); + Assert.Collection(lines, + line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(1, "first", false), line), + line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(2, "second-updated", true), line)); + } + + [Fact] + public void Resources_AllFieldsAndRecursiveValuesRoundTrip() + { + var databasePath = Path.Combine(_temporaryDirectory, "all-fields.db"); + var nestedValue = new Value + { + StructValue = new Struct + { + Fields = + { + ["name"] = Value.ForString("database"), + ["values"] = new Value + { + ListValue = new ListValue + { + Values = + { + Value.ForNumber(42.5), + Value.ForBool(true), + new Value { NullValue = NullValue.NullValue } + } + } + } + } + } + }; + var resource = CreateResource("api-complete", "api"); + resource.State = "Running"; + resource.StateStyle = "success"; + resource.StartedAt = Timestamp.FromDateTime(DateTime.UnixEpoch.AddMinutes(1)); + resource.StoppedAt = Timestamp.FromDateTime(DateTime.UnixEpoch.AddMinutes(2)); + resource.IsHidden = true; + resource.SupportsDetailedTelemetry = true; + resource.IconName = "Box"; + resource.IconVariant = Aspire.DashboardService.Proto.V1.IconVariant.Filled; + resource.Environment.Add(new EnvironmentVariable { Name = "OPTIONAL", IsFromSpec = true }); + resource.Environment.Add(new EnvironmentVariable { Name = "VALUE", Value = "set" }); + resource.Urls.Add(new Url + { + EndpointName = "https", + FullUrl = "https://localhost:5001/path", + IsInternal = true, + DisplayProperties = new UrlDisplayProperties { SortOrder = 3, DisplayName = "Secure endpoint" } + }); + resource.Volumes.Add(new Volume { Source = "data", Target = "/data", MountType = "volume", IsReadOnly = true }); + resource.Relationships.Add(new ResourceRelationship { ResourceName = "database", Type = "Reference" }); + resource.HealthReports.Add(new HealthReport + { + Status = HealthStatus.Healthy, + Key = "ready", + Description = "Ready", + Exception = string.Empty, + LastRunAt = Timestamp.FromDateTime(DateTime.UnixEpoch.AddSeconds(30)) + }); + resource.Properties.Add(new ResourceProperty + { + Name = "nested", + DisplayName = "Nested value", + Value = nestedValue, + IsSensitive = true, + IsHighlighted = true, + SortOrder = 7 + }); + resource.Commands.Add(new ResourceCommand + { + Name = "configure", + DisplayName = "Configure", + DisplayDescription = "Configure the resource", + ConfirmationMessage = "Continue?", + IsHighlighted = true, + IconName = "Settings", + IconVariant = Aspire.DashboardService.Proto.V1.IconVariant.Filled, + State = ResourceCommandState.Enabled, + ArgumentInputs = + { + new InteractionInput + { + Name = "mode", + Label = "Mode", + Placeholder = "Select a mode", + InputType = InputType.Choice, + Required = true, + Value = "safe", + Description = "Execution mode", + EnableDescriptionMarkdown = true, + MaxLength = 20, + AllowCustomChoice = true, + Loading = true, + UpdateStateOnChange = true, + Disabled = true, + MaxFileSize = 1024, + AllowMultipleFiles = true, + FileFilter = ".json", + Options = { ["safe"] = "Safe", ["fast"] = "Fast" }, + ValidationErrors = { "Choose a mode" } + } + } + }); + + using (var repository = CreateRepository(databasePath)) + { + ((IResourceRepositoryWriter)repository).ReplaceResources([resource]); + } + + using var historicalRepository = CreateRepository(databasePath, readOnly: true); + var actual = Assert.Single(historicalRepository.GetResources()); + Assert.Equal("Running", actual.State); + Assert.Equal("success", actual.StateStyle); + Assert.Equal(DateTime.UnixEpoch.AddMinutes(1), actual.StartTimeStamp); + Assert.Equal(DateTime.UnixEpoch.AddMinutes(2), actual.StopTimeStamp); + Assert.True(actual.SupportsDetailedTelemetry); + Assert.Equal("Box", actual.IconName); + Assert.Collection(actual.Environment, + item => + { + Assert.Equal("OPTIONAL", item.Name); + Assert.Equal(string.Empty, item.Value); + Assert.True(item.FromSpec); + }, + item => Assert.Equal("set", item.Value)); + Assert.Equal(nestedValue, actual.Properties["nested"].Value); + Assert.True(actual.Properties["nested"].IsValueSensitive); + Assert.Equal(14, actual.Properties["nested"].SortOrder); + var command = Assert.Single(actual.Commands); + Assert.Equal("configure", command.Name); + var input = Assert.Single(command.ArgumentInputs); + Assert.Equal("Safe", input.Options["safe"]); + Assert.Equal("Fast", input.Options["fast"]); + Assert.Equal("Choose a mode", Assert.Single(input.ValidationErrors)); + Assert.Equal("https", Assert.Single(actual.Urls).EndpointName); + Assert.Equal("/data", Assert.Single(actual.Volumes).Target); + Assert.Equal("database", Assert.Single(actual.Relationships).ResourceName); + Assert.Equal("ready", Assert.Single(actual.HealthReports).Name); + } + + [Fact] + public void Schema_HasNoSerializedResourceColumns() + { + var databasePath = Path.Combine(_temporaryDirectory, "schema.db"); + using (CreateRepository(databasePath)) + { + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT COUNT(*) + FROM sqlite_schema + WHERE type = 'table' AND name = 'resources'; + """; + Assert.Equal(0L, command.ExecuteScalar()); + + command.CommandText = """ + SELECT COUNT(*) + FROM pragma_table_info('dashboard_resources') + WHERE name = 'payload' OR upper(type) = 'BLOB'; + """; + Assert.Equal(0L, command.ExecuteScalar()); + } + + [Fact] + public void Schema_ResourceRepositoryInitializesAllEmbeddedScripts() + { + var databasePath = Path.Combine(_temporaryDirectory, "complete-schema.db"); + using (CreateRepository(databasePath)) + { + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT name + FROM sqlite_schema + WHERE type = 'table' AND name IN ( + 'dashboard_schema', + 'dashboard_resources', + 'telemetry_logs', + 'telemetry_traces', + 'telemetry_metric_instruments') + ORDER BY name; + """; + + using var reader = command.ExecuteReader(); + var tableNames = new List(); + while (reader.Read()) + { + tableNames.Add(reader.GetString(0)); + } + + Assert.Equal( + [ + "dashboard_resources", + "dashboard_schema", + "telemetry_logs", + "telemetry_metric_instruments", + "telemetry_traces" + ], tableNames); + } + + [Fact] + public void Schema_AllDashboardTablesAreStrict() + { + var databasePath = Path.Combine(_temporaryDirectory, "strict-schema.db"); + using (CreateRepository(databasePath)) + { + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT name + FROM pragma_table_list + WHERE schema = 'main' + AND type = 'table' + AND name NOT LIKE 'sqlite_%' + AND strict = 0 + ORDER BY name; + """; + + using var reader = command.ExecuteReader(); + var nonStrictTableNames = new List(); + while (reader.Read()) + { + nonStrictTableNames.Add(reader.GetString(0)); + } + + Assert.Empty(nonStrictTableNames); + } + + private static SqliteResourceRepository CreateRepository(string databasePath, bool readOnly = false) + { + return new SqliteResourceRepository(databasePath, new MockKnownPropertyLookup(), NullLoggerFactory.Instance, readOnly); + } + + private static Resource CreateResource(string name, string displayName) + { + return new Resource + { + Name = name, + DisplayName = displayName, + ResourceType = "Project", + Uid = $"uid-{name}", + CreatedAt = Timestamp.FromDateTime(DateTime.UnixEpoch) + }; + } + + private static void AssertResource(global::Aspire.Dashboard.Model.ResourceViewModel actual, Resource expected, int replicaIndex, string? state = null) + { + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.DisplayName, actual.DisplayName); + Assert.Equal(expected.ResourceType, actual.ResourceType); + Assert.Equal(expected.Uid, actual.Uid); + Assert.Equal(replicaIndex, actual.ReplicaIndex); + Assert.Equal(state, actual.State); + } + + public void Dispose() + { + Directory.Delete(_temporaryDirectory, recursive: true); + } +} \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index 898c8d2e2a7..157b18d8dbc 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs @@ -1091,7 +1091,7 @@ public void ConvertLogEntryToJson_ReturnsValidOtlpTelemetryDataJson() Assert.Single(data.ResourceLogs[0].ScopeLogs![0].LogRecords!); } - private static async Task CreateExportServiceAsync(TelemetryRepository repository, bool isDashboardClientEnabled = true) + private static async Task CreateExportServiceAsync(InMemoryTelemetryRepository repository, bool isDashboardClientEnabled = true) { var dashboardClient = new TestDashboardClient(isEnabled: isDashboardClientEnabled); var sessionStorage = new TestSessionStorage(); @@ -1101,7 +1101,7 @@ private static async Task CreateExportServiceAsync(Telem return new TelemetryExportService(repository, consoleLogsFetcher, dashboardClient, Array.Empty()); } - private static Dictionary> BuildAllResourcesSelection(TelemetryRepository repository) + private static Dictionary> BuildAllResourcesSelection(InMemoryTelemetryRepository repository) { var allResources = repository.GetResources(); return allResources.ToDictionary( @@ -1109,7 +1109,7 @@ private static Dictionary> BuildAllResourcesSele _ => new HashSet([AspireDataType.ConsoleLogs, AspireDataType.StructuredLogs, AspireDataType.Traces, AspireDataType.Metrics])); } - private static void AddTestData(TelemetryRepository repository, string resourceName, string instanceId) + private static void AddTestData(InMemoryTelemetryRepository repository, string resourceName, string instanceId) { var compositeName = $"{resourceName}-{instanceId}"; diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs index db2be4cb474..73864373f15 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs @@ -23,7 +23,7 @@ public sealed class TelemetryImportServiceTests { private static readonly DateTime s_testTime = new(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc); - private static TelemetryImportService CreateImportService(TelemetryRepository repository, bool disableImport = false) + private static TelemetryImportService CreateImportService(InMemoryTelemetryRepository repository, bool disableImport = false) { var options = new DashboardOptions { UI = new UIOptions { DisableImport = disableImport } }; var optionsMonitor = new TestOptionsMonitor(options); diff --git a/tests/Aspire.Dashboard.Tests/Model/TraceHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/TraceHelpersTests.cs index de569f905a9..08f3d51a371 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TraceHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TraceHelpersTests.cs @@ -151,4 +151,21 @@ public void GetOrderedResources_HasUninstrumentedPeer_AddedToResults() Assert.Equal(app3, g.Resource); }); } + + [Fact] + public void GetOrderedResources_DifferentResourceInstancesWithSameKey_GroupedResult() + { + var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; + var app1 = new OtlpResource("app1", "instance", uninstrumentedPeer: false, context); + var equivalentApp1 = new OtlpResource("app1", "instance", uninstrumentedPeer: true, context); + var trace = new OtlpTrace(new byte[] { 1, 2, 3 }, DateTime.MinValue); + var scope = TelemetryTestHelpers.CreateOtlpScope(context); + trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(app1, trace, scope, spanId: "1", parentSpanId: null, startDate: new DateTime(2001, 1, 1, 1, 1, 1, DateTimeKind.Utc), uninstrumentedPeer: equivalentApp1)); + + var results = TraceHelpers.GetOrderedResources(trace); + + var result = Assert.Single(results); + Assert.Same(app1, result.Resource); + Assert.Equal(2, result.TotalSpans); + } } diff --git a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs index 64ab8f1db21..c1efa5a3bc7 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs @@ -700,7 +700,7 @@ public void GetSpans_WithTimestampDateOnly_FiltersCorrectly() /// Adds spans with sequential trace/span IDs to the repository. Each span is added in a separate /// AddTraces call so that it gets its own trace entry. /// - private static void AddSpans(TelemetryRepository repository, int count, int startMinuteSpacing = 1) + private static void AddSpans(InMemoryTelemetryRepository repository, int count, int startMinuteSpacing = 1) { for (var i = 1; i <= count; i++) { @@ -713,7 +713,7 @@ private static void AddSpans(TelemetryRepository repository, int count, int star /// /// Adds a batch of spans (as raw Span objects) to the repository under a single resource. /// - private static void AddSpansToRepository(TelemetryRepository repository, IEnumerable spans) + private static void AddSpansToRepository(InMemoryTelemetryRepository repository, IEnumerable spans) { repository.AddTraces(new AddContext(), new RepeatedField { @@ -735,7 +735,7 @@ private static void AddSpansToRepository(TelemetryRepository repository, IEnumer /// /// Adds two traces (separate trace IDs) with OK and Error status for hasError filter tests. /// - private static void AddTracesWithStatus(TelemetryRepository repository) + private static void AddTracesWithStatus(InMemoryTelemetryRepository repository) { AddSpansToRepository(repository, [ CreateSpan(traceId: "ok-trace", spanId: "span1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1), status: new Status { Code = Status.Types.StatusCode.Ok }) @@ -748,7 +748,7 @@ private static void AddTracesWithStatus(TelemetryRepository repository) /// /// Adds log entries with the specified messages to the repository. /// - private static void AddLogs(TelemetryRepository repository, string[] messages, SeverityNumber severity = SeverityNumber.Info) + private static void AddLogs(InMemoryTelemetryRepository repository, string[] messages, SeverityNumber severity = SeverityNumber.Info) { var logRecords = new RepeatedField(); for (var i = 0; i < messages.Length; i++) @@ -762,7 +762,7 @@ private static void AddLogs(TelemetryRepository repository, string[] messages, S /// /// Adds a batch of raw LogRecord objects to the repository under a single resource. /// - private static void AddLogsToRepository(TelemetryRepository repository, RepeatedField logRecords) + private static void AddLogsToRepository(InMemoryTelemetryRepository repository, RepeatedField logRecords) { repository.AddLogs(new AddContext(), new RepeatedField { @@ -782,7 +782,7 @@ private static void AddLogsToRepository(TelemetryRepository repository, Repeated } private static TelemetryApiService CreateService( - TelemetryRepository? repository = null, + InMemoryTelemetryRepository? repository = null, IOutgoingPeerResolver[]? peerResolvers = null) { return new TelemetryApiService( diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs index 389d3609bb2..464873508b0 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs @@ -16,7 +16,7 @@ namespace Aspire.Dashboard.Tests.TelemetryRepositoryTests; -public class LogTests +public abstract class LogTests : TelemetryRepositoryTestBase { private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -1527,3 +1527,13 @@ public void GetLogs_DisabledFiltersAreIgnored() Assert.Equal("matching log", logs.Items[0].Message); } } + +public sealed class InMemoryLogTests(ITestOutputHelper testOutputHelper) : LogTests(testOutputHelper) +{ + protected override bool UseSqlite => false; +} + +public sealed class SqliteLogTests(ITestOutputHelper testOutputHelper) : LogTests(testOutputHelper) +{ + protected override bool UseSqlite => true; +} diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index 49a2697b8d4..f4afeefa2c8 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -15,7 +15,7 @@ namespace Aspire.Dashboard.Tests.TelemetryRepositoryTests; -public class MetricsTests +public abstract class MetricsTests : TelemetryRepositoryTestBase { private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -1249,3 +1249,13 @@ private static void AssertDimensionValues(Dictionary false; +} + +public sealed class SqliteMetricsTests : MetricsTests +{ + protected override bool UseSqlite => true; +} diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs index 24006e4a25d..cf69d81c9b8 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs @@ -10,7 +10,7 @@ namespace Aspire.Dashboard.Tests.TelemetryRepositoryTests; -public class ResourceTests +public abstract class ResourceTests : TelemetryRepositoryTestBase { [Fact] public void GetResourceByCompositeName() @@ -173,7 +173,7 @@ public void GetResourceName_Version7GuidInstanceId_ShortenedNamesDiffer() Assert.Equal("app1-a0e9f6ad", instance2Name); } - private static void AddResource(TelemetryRepository repository, string name, string? instanceId = null) + private static void AddResource(ITelemetryRepository repository, string name, string? instanceId = null) { var addContext = new AddContext(); repository.AddTraces(addContext, new RepeatedField() @@ -187,3 +187,13 @@ private static void AddResource(TelemetryRepository repository, string name, str Assert.Equal(0, addContext.FailureCount); } } + +public sealed class InMemoryResourceTests : ResourceTests +{ + protected override bool UseSqlite => false; +} + +public sealed class SqliteResourceTests : ResourceTests +{ + protected override bool UseSqlite => true; +} diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs new file mode 100644 index 00000000000..5247bbfaba7 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs @@ -0,0 +1,369 @@ +// 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.Configuration; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Model.MetricValues; +using Aspire.Dashboard.Otlp.Storage; +using Google.Protobuf; +using Google.Protobuf.Collections; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using OpenTelemetry.Proto.Logs.V1; +using OpenTelemetry.Proto.Metrics.V1; +using OpenTelemetry.Proto.Trace.V1; +using Xunit; +using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers; + +namespace Aspire.Dashboard.Tests.TelemetryRepositoryTests; + +public sealed class SqliteTelemetryPersistenceTests : IDisposable +{ + private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-telemetry-persistence-tests-").FullName; + + [Fact] + public void Logs_ReopenFromNormalizedRowsWithStableIds() + { + var databasePath = Path.Combine(_temporaryDirectory, "dashboard.db"); + long logId; + using (var repository = CreateRepository(databasePath)) + { + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = { CreateLogRecord() } + } + } + } + }); + + var log = Assert.Single(repository.GetLogs(CreateLogsContext()).Items); + logId = log.InternalId; + } + + using (var historicalRepository = CreateRepository(databasePath, readOnly: true)) + { + var log = Assert.Single(historicalRepository.GetLogs(CreateLogsContext()).Items); + Assert.Equal(logId, log.InternalId); + Assert.Equal("Test Value!", log.Message); + Assert.Equal("TestLogger", log.Scope.Name); + Assert.Equal(logId, historicalRepository.GetLog(logId)!.InternalId); + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'telemetry_records';"; + Assert.Equal(0L, command.ExecuteScalar()); + command.CommandText = "SELECT COUNT(*) FROM telemetry_logs;"; + Assert.Equal(1L, command.ExecuteScalar()); + } + + [Fact] + public void Traces_ReopenFromNormalizedRowsWithStableEventIds() + { + var databasePath = Path.Combine(_temporaryDirectory, "dashboard.db"); + var startTime = new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc); + var link = new Span.Types.Link + { + TraceId = ByteString.CopyFromUtf8("2"), + SpanId = ByteString.CopyFromUtf8("2-1"), + TraceState = "state" + }; + Guid eventId; + using (var repository = CreateRepository(databasePath)) + { + repository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope("TestSource"), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-1", + startTime, + startTime.AddSeconds(2), + events: [CreateSpanEvent("event", 1, [KeyValuePair.Create("event-key", "event-value")])], + links: [link], + attributes: [KeyValuePair.Create("span-key", "span-value")]), + CreateSpan("1", "1-2", startTime.AddSeconds(1), startTime.AddSeconds(2), parentSpanId: "1-1") + } + } + } + } + }); + + var trace = Assert.Single(repository.GetTraces(GetTracesRequest.ForResourceKey(new ResourceKey("TestService", "TestId"))).PagedResult.Items); + eventId = Assert.Single(trace.FirstSpan.Events).InternalId; + } + + using (var historicalRepository = CreateRepository(databasePath, readOnly: true)) + { + var trace = Assert.Single(historicalRepository.GetTraces(GetTracesRequest.ForResourceKey(new ResourceKey("TestService", "TestId"))).PagedResult.Items); + Assert.Equal("TestSource", trace.FirstSpan.Scope.Name); + Assert.Equal(KeyValuePair.Create("span-key", "span-value"), Assert.Single(trace.FirstSpan.Attributes)); + var spanEvent = Assert.Single(trace.FirstSpan.Events); + Assert.Equal(eventId, spanEvent.InternalId); + Assert.Equal(KeyValuePair.Create("event-key", "event-value"), Assert.Single(spanEvent.Attributes)); + var persistedLink = Assert.Single(trace.FirstSpan.Links); + Assert.Equal(link.TraceId.ToHexString(), persistedLink.TraceId); + Assert.Equal(link.SpanId.ToHexString(), persistedLink.SpanId); + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'telemetry_records';"; + Assert.Equal(0L, command.ExecuteScalar()); + command.CommandText = "SELECT COUNT(*) FROM telemetry_spans;"; + Assert.Equal(2L, command.ExecuteScalar()); + } + + [Fact] + public void Metrics_ReopenFromNormalizedRows() + { + var databasePath = Path.Combine(_temporaryDirectory, "dashboard.db"); + var startTime = new DateTime(2025, 2, 3, 4, 5, 6, DateTimeKind.Utc); + using (var repository = CreateRepository(databasePath)) + { + repository.AddMetrics(new AddContext(), new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope("TestMeter"), + Metrics = { CreateSumMetric("requests", startTime, attributes: [KeyValuePair.Create("route", "/api")], value: 42) } + } + } + } + }); + } + + using (var historicalRepository = CreateRepository(databasePath, readOnly: true)) + { + var resourceKey = new ResourceKey("TestService", "TestId"); + var summary = Assert.Single(historicalRepository.GetInstrumentsSummaries(resourceKey)); + Assert.Equal("requests", summary.Name); + Assert.Equal("TestMeter", summary.Parent.Name); + + var instrument = historicalRepository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = resourceKey, + MeterName = "TestMeter", + InstrumentName = "requests", + StartTime = startTime.AddMinutes(-1), + EndTime = startTime.AddMinutes(1) + }); + var dimension = Assert.Single(instrument!.Dimensions); + Assert.Equal(KeyValuePair.Create("route", "/api"), Assert.Single(dimension.Attributes)); + Assert.Equal(42, Assert.IsType>(Assert.Single(dimension.Values)).Value); + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM telemetry_metric_points;"; + Assert.Equal(1L, command.ExecuteScalar()); + } + + [Fact] + public void Scopes_AreSharedAcrossLogsTracesAndMetrics() + { + var databasePath = Path.Combine(_temporaryDirectory, "shared-scopes.db"); + var startTime = new DateTime(2025, 3, 4, 5, 6, 7, DateTimeKind.Utc); + var scope = CreateScope(name: "SharedScope", attributes: [KeyValuePair.Create("scope-key", "scope-value")]); + using (var repository = CreateRepository(databasePath)) + { + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(), + ScopeLogs = + { + new ScopeLogs + { + Scope = scope, + LogRecords = { CreateLogRecord() } + } + } + } + }); + repository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = scope, + Spans = { CreateSpan("shared-trace", "shared-span", startTime, startTime.AddSeconds(1)) } + } + } + } + }); + repository.AddMetrics(new AddContext(), new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = scope, + Metrics = { CreateSumMetric("requests", startTime) } + } + } + } + }); + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM telemetry_scopes WHERE scope_name = 'SharedScope';"; + Assert.Equal(1L, command.ExecuteScalar()); + command.CommandText = """ + SELECT COUNT(DISTINCT scope_id) + FROM ( + SELECT scope_id FROM telemetry_logs + UNION ALL + SELECT scope_id FROM telemetry_spans + UNION ALL + SELECT scope_id FROM telemetry_metric_instruments + ); + """; + Assert.Equal(1L, command.ExecuteScalar()); + command.CommandText = """ + SELECT COUNT(*) + FROM telemetry_scope_attributes + WHERE attribute_key = 'scope-key' AND attribute_value = 'scope-value'; + """; + Assert.Equal(1L, command.ExecuteScalar()); + command.CommandText = """ + SELECT COUNT(*) + FROM sqlite_schema + WHERE type = 'table' AND name IN ( + 'telemetry_log_scopes', + 'telemetry_log_scope_attributes', + 'telemetry_trace_scopes', + 'telemetry_trace_scope_attributes', + 'telemetry_metric_scopes', + 'telemetry_metric_scope_attributes'); + """; + Assert.Equal(0L, command.ExecuteScalar()); + } + + [Fact] + public void Scopes_AreDeletedAfterTheirFinalOwnerIsCleared() + { + var databasePath = Path.Combine(_temporaryDirectory, "scope-cleanup.db"); + var startTime = new DateTime(2025, 3, 4, 5, 6, 7, DateTimeKind.Utc); + var scope = CreateScope("SharedScope"); + using var repository = CreateRepository(databasePath); + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(), + ScopeLogs = { new ScopeLogs { Scope = scope, LogRecords = { CreateLogRecord() } } } + } + }); + repository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = scope, + Spans = { CreateSpan("shared-trace", "shared-span", startTime, startTime.AddSeconds(1)) } + } + } + } + }); + repository.AddMetrics(new AddContext(), new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = scope, + Metrics = { CreateSumMetric("requests", startTime) } + } + } + } + }); + + repository.ClearStructuredLogs(); + Assert.Equal(1L, GetScopeCount(databasePath)); + repository.ClearTraces(); + Assert.Equal(1L, GetScopeCount(databasePath)); + repository.ClearMetrics(); + Assert.Equal(0L, GetScopeCount(databasePath)); + } + + private static long GetScopeCount(string databasePath) + { + using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM telemetry_scopes;"; + return (long)command.ExecuteScalar()!; + } + + private static SqliteTelemetryRepository CreateRepository(string databasePath, bool readOnly = false) + { + return new SqliteTelemetryRepository( + databasePath, + NullLoggerFactory.Instance, + Options.Create(new DashboardOptions()), + new PauseManager(), + [], + readOnly); + } + + private static GetLogsContext CreateLogsContext() + { + return new GetLogsContext + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + Filters = [] + }; + } + + public void Dispose() + { + Directory.Delete(_temporaryDirectory, recursive: true); + } +} \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs index 9f77e947968..9da4c3cd3d6 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs @@ -12,7 +12,7 @@ namespace Aspire.Dashboard.Tests.TelemetryRepositoryTests; -public class TelemetryLimitTests +public abstract class TelemetryLimitTests : TelemetryRepositoryTestBase { private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -123,7 +123,7 @@ public void AddMetrics_ExceedsInstrumentLimit_ReportsFailure() // Fill instruments up to the limit. var metrics = new RepeatedField(); - for (var i = 0; i < TelemetryRepository.MaxInstrumentCount; i++) + for (var i = 0; i < InMemoryTelemetryRepository.MaxInstrumentCount; i++) { metrics.Add(CreateSumMetric(metricName: $"metric{i}", startTime: s_testTime.AddMinutes(1))); } @@ -149,7 +149,7 @@ public void AddMetrics_ExceedsInstrumentLimit_ReportsFailure() var resources = repository.GetResources(); var instruments = repository.GetInstrumentsSummaries(resources[0].ResourceKey); - Assert.Equal(TelemetryRepository.MaxInstrumentCount, instruments.Count); + Assert.Equal(InMemoryTelemetryRepository.MaxInstrumentCount, instruments.Count); // Adding one more instrument should fail. var failContext = new AddContext(); @@ -173,7 +173,7 @@ public void AddMetrics_ExceedsInstrumentLimit_ReportsFailure() Assert.Equal(0, failContext.SuccessCount); instruments = repository.GetInstrumentsSummaries(resources[0].ResourceKey); - Assert.Equal(TelemetryRepository.MaxInstrumentCount, instruments.Count); + Assert.Equal(InMemoryTelemetryRepository.MaxInstrumentCount, instruments.Count); } [Fact] @@ -366,7 +366,7 @@ public void AddLogs_ExceedsScopeLimit_ReportsFailure() // Fill scopes up to the limit. var scopeLogs = new RepeatedField(); var rl = new ResourceLogs { Resource = CreateResource() }; - for (var i = 0; i < TelemetryRepository.MaxScopeCount; i++) + for (var i = 0; i < InMemoryTelemetryRepository.MaxScopeCount; i++) { rl.ScopeLogs.Add(new ScopeLogs { @@ -414,7 +414,7 @@ public void AddTraces_ExceedsScopeLimit_ReportsFailure() // Fill scopes up to the limit. var rs = new ResourceSpans { Resource = CreateResource() }; - for (var i = 0; i < TelemetryRepository.MaxScopeCount; i++) + for (var i = 0; i < InMemoryTelemetryRepository.MaxScopeCount; i++) { rs.ScopeSpans.Add(new ScopeSpans { @@ -460,7 +460,7 @@ public void AddMetrics_ExceedsScopeLimit_ReportsFailure() // Fill scopes up to the limit. var rm = new ResourceMetrics { Resource = CreateResource() }; - for (var i = 0; i < TelemetryRepository.MaxScopeCount; i++) + for (var i = 0; i < InMemoryTelemetryRepository.MaxScopeCount; i++) { rm.ScopeMetrics.Add(new ScopeMetrics { @@ -500,3 +500,13 @@ public void AddMetrics_ExceedsScopeLimit_ReportsFailure() Assert.Equal(0, failContext.SuccessCount); } } + +public sealed class InMemoryTelemetryLimitTests : TelemetryLimitTests +{ + protected override bool UseSqlite => false; +} + +public sealed class SqliteTelemetryLimitTests : TelemetryLimitTests +{ + protected override bool UseSqlite => true; +} diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTestBase.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTestBase.cs new file mode 100644 index 00000000000..312b2a6f484 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTestBase.cs @@ -0,0 +1,88 @@ +// 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.Storage; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Aspire.Dashboard.Tests.TelemetryRepositoryTests; + +public abstract class TelemetryRepositoryTestBase : IDisposable +{ + private readonly List _repositories = []; + private readonly List _temporaryDirectories = []; + + protected abstract bool UseSqlite { get; } + + protected ITelemetryRepository CreateRepository( + int? maxMetricsCount = null, + int? maxAttributeCount = null, + int? maxAttributeLength = null, + int? maxSpanEventCount = null, + int? maxTraceCount = null, + int? maxLogCount = null, + int? maxResourceCount = null, + TimeSpan? subscriptionMinExecuteInterval = null, + ILoggerFactory? loggerFactory = null, + global::Aspire.Dashboard.Model.PauseManager? pauseManager = null, + global::Aspire.Dashboard.Model.IOutgoingPeerResolver[]? outgoingPeerResolvers = null) + { + var telemetryLimits = new global::Aspire.Dashboard.Configuration.TelemetryLimitOptions(); + telemetryLimits.MaxMetricsCount = maxMetricsCount ?? telemetryLimits.MaxMetricsCount; + telemetryLimits.MaxAttributeCount = maxAttributeCount ?? telemetryLimits.MaxAttributeCount; + telemetryLimits.MaxAttributeLength = maxAttributeLength ?? telemetryLimits.MaxAttributeLength; + telemetryLimits.MaxSpanEventCount = maxSpanEventCount ?? telemetryLimits.MaxSpanEventCount; + telemetryLimits.MaxTraceCount = maxTraceCount ?? telemetryLimits.MaxTraceCount; + telemetryLimits.MaxLogCount = maxLogCount ?? telemetryLimits.MaxLogCount; + telemetryLimits.MaxResourceCount = maxResourceCount ?? telemetryLimits.MaxResourceCount; + + loggerFactory ??= NullLoggerFactory.Instance; + pauseManager ??= new global::Aspire.Dashboard.Model.PauseManager(); + outgoingPeerResolvers ??= []; + var options = Options.Create(new global::Aspire.Dashboard.Configuration.DashboardOptions { TelemetryLimits = telemetryLimits }); + + ITelemetryRepository repository; + if (UseSqlite) + { + var temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-telemetry-tests-").FullName; + _temporaryDirectories.Add(temporaryDirectory); + var sqliteRepository = new SqliteTelemetryRepository( + Path.Combine(temporaryDirectory, "dashboard.db"), + loggerFactory, + options, + pauseManager, + outgoingPeerResolvers); + if (subscriptionMinExecuteInterval is not null) + { + sqliteRepository.SubscriptionMinExecuteInterval = subscriptionMinExecuteInterval.Value; + } + repository = sqliteRepository; + } + else + { + var inMemoryRepository = new InMemoryTelemetryRepository(loggerFactory, options, pauseManager, outgoingPeerResolvers); + if (subscriptionMinExecuteInterval is not null) + { + inMemoryRepository._subscriptionMinExecuteInterval = subscriptionMinExecuteInterval.Value; + } + repository = inMemoryRepository; + } + + _repositories.Add(repository); + return repository; + } + + public void Dispose() + { + foreach (var repository in _repositories) + { + repository.Dispose(); + } + + foreach (var temporaryDirectory in _temporaryDirectories) + { + Directory.Delete(temporaryDirectory, recursive: true); + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs index fc0be1546c6..72b81971f32 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs @@ -8,6 +8,7 @@ using Google.Protobuf.Collections; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; using OpenTelemetry.Proto.Logs.V1; using OpenTelemetry.Proto.Metrics.V1; @@ -17,7 +18,7 @@ namespace Aspire.Dashboard.Tests.TelemetryRepositoryTests; -public class TelemetryRepositoryTests +public abstract class TelemetryRepositoryTests : TelemetryRepositoryTestBase { private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -141,7 +142,6 @@ void AddTrace() public void Subscription_MultipleDisposes_UnsubscribeOnce() { // Arrange - var telemetryRepository = CreateRepository(); var unsubscribeCallCount = 0; var subscription = new Subscription( @@ -151,7 +151,8 @@ public void Subscription_MultipleDisposes_UnsubscribeOnce() callback: () => Task.CompletedTask, unsubscribe: () => unsubscribeCallCount++, executionContext: null, - telemetryRepository: telemetryRepository); + logger: NullLogger.Instance, + minExecuteInterval: TimeSpan.FromMilliseconds(100)); // Act subscription.Dispose(); @@ -180,8 +181,6 @@ public async Task Subscription_ExecuteAfterDispose_LogWithNoExecute() b.SetMinimumLevel(LogLevel.Trace); }); - var telemetryRepository = CreateRepository(loggerFactory: factory); - var subscription = new Subscription( name: "Test", resourceKey: null, @@ -189,7 +188,8 @@ public async Task Subscription_ExecuteAfterDispose_LogWithNoExecute() callback: () => Task.CompletedTask, unsubscribe: () => { }, executionContext: null, - telemetryRepository: telemetryRepository); + logger: factory.CreateLogger("Test"), + minExecuteInterval: TimeSpan.FromMilliseconds(100)); subscription.Dispose(); @@ -1395,7 +1395,7 @@ public async Task WatchSpansAsync_DisabledFiltersAreIgnored() #endregion - private static void AddTestData(TelemetryRepository repository, string resourceName, string instanceId) + private static void AddTestData(ITelemetryRepository repository, string resourceName, string instanceId) { var compositeName = $"{resourceName}-{instanceId}"; @@ -1454,3 +1454,13 @@ private static void AddTestData(TelemetryRepository repository, string resourceN }); } } + +public sealed class InMemoryTelemetryRepositoryTests : TelemetryRepositoryTests +{ + protected override bool UseSqlite => false; +} + +public sealed class SqliteTelemetryRepositoryTests : TelemetryRepositoryTests +{ + protected override bool UseSqlite => true; +} diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index 892ee6c33be..195e6f5f08e 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -18,7 +18,7 @@ namespace Aspire.Dashboard.Tests.TelemetryRepositoryTests; -public class TraceTests +public abstract class TraceTests : TelemetryRepositoryTestBase { private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -32,7 +32,7 @@ public class TraceTests [InlineData(OtlpSpanKind.Unspecified, (Span.Types.SpanKind)1000)] public void ConvertSpanKind(OtlpSpanKind expected, Span.Types.SpanKind value) { - var result = TelemetryRepository.ConvertSpanKind(value); + var result = InMemoryTelemetryRepository.ConvertSpanKind(value); Assert.Equal(expected, result); } @@ -664,29 +664,6 @@ public void AddTraces_SpanLinks_ReturnData() }); }); - Assert.Collection(repository.SpanLinks, - l => - { - AssertId("1", l.TraceId); - AssertId("1-1", l.SpanId); - Assert.Collection(l.Attributes, - a => - { - Assert.Equal("key2", a.Key); - Assert.Equal("Value!", a.Value); - }); - }, - l => - { - AssertId("2", l.TraceId); - AssertId("2-1", l.SpanId); - Assert.Collection(l.Attributes, - a => - { - Assert.Equal("key1", a.Key); - Assert.Equal("Value!", a.Value); - }); - }); } [Fact] @@ -939,10 +916,10 @@ public void AddTraces_ExceedLimit_FirstInFirstOut() var expectedOrder = traces.PagedResult.Items.OrderBy(t => t.FirstSpan.StartTime).Select(t => t.TraceId).ToList(); Assert.Equal(expectedOrder, actualOrder); - Assert.Equal(MaxTraceCount * 2, repository.SpanLinks.Count); + Assert.Equal(MaxTraceCount * 2, traces.PagedResult.Items.SelectMany(t => t.Spans).SelectMany(s => s.Links).Count()); } - private static void AddTrace(TelemetryRepository repository, string traceId, DateTime startTime) + private static void AddTrace(ITelemetryRepository repository, string traceId, DateTime startTime) { var addContext = new AddContext(); @@ -3333,3 +3310,13 @@ public void GetTraces_NotEqualTimestampFilter_ExcludesTraceViaUnoptimizedPath() trace => AssertId("2", trace.TraceId)); } } + +public sealed class InMemoryTraceTests : TraceTests +{ + protected override bool UseSqlite => false; +} + +public sealed class SqliteTraceTests : TraceTests +{ + protected override bool UseSqlite => true; +} diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs index 0901bfed7d7..c56ffdf3929 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs @@ -308,6 +308,49 @@ public async Task ConfigureEnvironmentVariables_HasAspireDashboardEnvVars_Copied Assert.Equal("true", envVars.Single(e => e.Key == "ASPIRE_DASHBOARD_PURPLE_MONKEY_DISHWASHER").Value); } + [Fact] + public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRootAndApplicationName() + { + var storeDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-store-tests-"); + try + { + var resourceLoggerService = new ResourceLoggerService(); + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Aspire:Store:Path"] = storeDirectory.FullName, + ["AppHost:DashboardApplicationName"] = "My App.AppHost" + }) + .Build(); + var dashboardOptions = Options.Create(new DashboardOptions + { + DashboardPath = "test.dll", + DashboardUrl = "http://localhost:8080", + OtlpGrpcEndpointUrl = "http://localhost:4317", + }); + var hook = CreateHook(resourceLoggerService, resourceNotificationService, configuration, dashboardOptions: dashboardOptions); + var environmentVariables = new Dictionary(); + var dashboardResource = new ExecutableResource("aspire-dashboard", "dashboard.exe", "."); + var model = new DistributedApplicationModel([dashboardResource]); + var context = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) + { + Services = new TestServiceProvider().AddService(model) + }); + + await hook.ConfigureEnvironmentVariables(new EnvironmentCallbackContext(context, environmentVariables: environmentVariables, resource: dashboardResource)); + + Assert.Equal( + Path.Combine(storeDirectory.FullName, ".aspire", "dashboard"), + environmentVariables[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName]); + Assert.Equal("My App", environmentVariables[DashboardConfigNames.DashboardApplicationName.EnvVarName]); + } + finally + { + storeDirectory.Delete(recursive: true); + } + } + [Theory] [InlineData("https://localhost:17131", "localhost", 9999, "https", "localhost")] [InlineData("https://aspire-dashboard.dev.localhost:17131", "aspire-dashboard.dev.localhost", 9999, "https", "aspire-dashboard.dev.localhost")] diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.Watchers.cs similarity index 99% rename from src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs rename to tests/Shared/Telemetry/InMemoryTelemetryRepository.Watchers.cs index 77c41216dde..7e7b2c32fe8 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.Watchers.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.Watchers.cs @@ -5,13 +5,14 @@ using System.Threading.Channels; using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; +using Microsoft.Extensions.Logging; namespace Aspire.Dashboard.Otlp.Storage; /// /// Partial class containing push-based streaming (watcher) functionality. /// -public sealed partial class TelemetryRepository +public sealed partial class InMemoryTelemetryRepository { // Watcher fields are defined in the main file: // private readonly object _watchersLock; diff --git a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs similarity index 97% rename from src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs rename to tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index 2b5af9a7fff..7c18d807753 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/TelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -14,6 +14,7 @@ using Aspire.Dashboard.Otlp.Model.MetricValues; using Aspire.Dashboard.Utils; using Google.Protobuf.Collections; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.FluentUI.AspNetCore.Components; using OpenTelemetry.Proto.Logs.V1; @@ -24,18 +25,19 @@ namespace Aspire.Dashboard.Otlp.Storage; -public sealed partial class TelemetryRepository : IDisposable +public sealed partial class InMemoryTelemetryRepository : ITelemetryRepository { - internal const int MaxResourceViewCount = 10_000; - internal const int MaxInstrumentCount = 10_000; - internal const int MaxScopeCount = 10_000; - internal const int MaxDimensionCount = 10_000; - internal const int MaxKnownAttributeValueCount = 10_000; - internal const int MaxKnownAttributeValuesPerKey = 10_000; + internal const int MaxResourceViewCount = TelemetryRepositoryLimits.MaxResourceViewCount; + internal const int MaxInstrumentCount = TelemetryRepositoryLimits.MaxInstrumentCount; + internal const int MaxScopeCount = TelemetryRepositoryLimits.MaxScopeCount; + internal const int MaxDimensionCount = TelemetryRepositoryLimits.MaxDimensionCount; + internal const int MaxKnownAttributeValueCount = TelemetryRepositoryLimits.MaxKnownAttributeValueCount; + internal const int MaxKnownAttributeValuesPerKey = TelemetryRepositoryLimits.MaxKnownAttributeValuesPerKey; private readonly PauseManager _pauseManager; private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; private readonly ILogger _logger; + private bool _isReadOnly; private readonly object _lock = new(); internal TimeSpan _subscriptionMinExecuteInterval = TimeSpan.FromMilliseconds(100); @@ -82,9 +84,19 @@ public sealed partial class TelemetryRepository : IDisposable internal List SpanLinks => _spanLinks; internal List TracesSubscriptions => _tracesSubscriptions; - public TelemetryRepository(ILoggerFactory loggerFactory, IOptions dashboardOptions, PauseManager pauseManager, IEnumerable outgoingPeerResolvers) + internal void MakeReadOnly() => _isReadOnly = true; + + private void ThrowIfReadOnly() { - _logger = loggerFactory.CreateLogger(typeof(TelemetryRepository)); + if (_isReadOnly) + { + throw new InvalidOperationException("Historical telemetry is read-only."); + } + } + + public InMemoryTelemetryRepository(ILoggerFactory loggerFactory, IOptions dashboardOptions, PauseManager pauseManager, IEnumerable outgoingPeerResolvers) + { + _logger = loggerFactory.CreateLogger(typeof(InMemoryTelemetryRepository)); _otlpContext = new OtlpContext { Logger = _logger, @@ -194,7 +206,7 @@ public Dictionary GetResourceUnviewedErrorLogsCount() } } - internal void MarkViewedErrorLogs(ResourceKey? key) + public void MarkViewedErrorLogs(ResourceKey? key) { _logsLock.EnterWriteLock(); @@ -306,7 +318,7 @@ private Subscription AddSubscription(string name, ResourceKey? resourceKey, Subs { subscriptions.Remove(subscription!); } - }, ExecutionContext.Capture(), this); + }, ExecutionContext.Capture(), _logger, _subscriptionMinExecuteInterval); lock (_lock) { @@ -329,6 +341,8 @@ private void RaiseSubscriptionChanged(List subscriptions) public void AddLogs(AddContext context, RepeatedField resourceLogs) { + ThrowIfReadOnly(); + if (_pauseManager.AreStructuredLogsPaused(out _)) { _logger.LogTrace("{Count} incoming structured log(s) ignored because of an active pause.", resourceLogs.Count); @@ -1311,6 +1325,8 @@ private void SetResourceHasMetrics(OtlpResource resource, bool value) /// Dictionary mapping resource names to the data types to clear. public void ClearSelectedSignals(Dictionary> selectedResources) { + ThrowIfReadOnly(); + var allOtlpResources = GetResources(); foreach (var otlpResource in allOtlpResources) @@ -1357,6 +1373,8 @@ static bool IsDataTypeSelected(HashSet dataTypes, AspireDataType public void ClearTraces(ResourceKey? resourceKey = null) { + ThrowIfReadOnly(); + List? resources = null; if (resourceKey.HasValue) { @@ -1419,6 +1437,8 @@ public void ClearTraces(ResourceKey? resourceKey = null) public void ClearStructuredLogs(ResourceKey? resourceKey = null) { + ThrowIfReadOnly(); + List? resources = null; if (resourceKey.HasValue) { @@ -1481,6 +1501,8 @@ private void ClearResource(ResourceKey resourceKey) public void ClearMetrics(ResourceKey? resourceKey = null) { + ThrowIfReadOnly(); + List resources; if (resourceKey.HasValue) { @@ -1636,6 +1658,8 @@ public bool HasUpdatedTrace(OtlpTrace trace) public void AddMetrics(AddContext context, RepeatedField resourceMetrics) { + ThrowIfReadOnly(); + if (_pauseManager.AreMetricsPaused(out _)) { _logger.LogTrace("{Count} incoming metric(s) ignored because of an active pause.", resourceMetrics.Count); @@ -1665,6 +1689,8 @@ public void AddMetrics(AddContext context, RepeatedField resour public void AddTraces(AddContext context, RepeatedField resourceSpans) { + ThrowIfReadOnly(); + if (_pauseManager.AreTracesPaused(out _)) { _logger.LogTrace("{Count} incoming trace(s) ignored because of an active pause.", resourceSpans.Count); @@ -1705,18 +1731,7 @@ private static OtlpSpanStatusCode ConvertStatus(Status? status) internal static OtlpSpanKind ConvertSpanKind(SpanKind? kind) { - return kind switch - { - // Unspecified to Internal is intentional. - // "Implementations MAY assume SpanKind to be INTERNAL when receiving UNSPECIFIED." - SpanKind.Unspecified => OtlpSpanKind.Internal, - SpanKind.Internal => OtlpSpanKind.Internal, - SpanKind.Client => OtlpSpanKind.Client, - SpanKind.Server => OtlpSpanKind.Server, - SpanKind.Producer => OtlpSpanKind.Producer, - SpanKind.Consumer => OtlpSpanKind.Consumer, - _ => OtlpSpanKind.Unspecified - }; + return OtlpHelpers.ConvertSpanKind(kind); } internal void AddTracesCore(AddContext context, OtlpResourceView resourceView, RepeatedField scopeSpans) diff --git a/tests/Shared/Telemetry/TelemetryTestHelpers.cs b/tests/Shared/Telemetry/TelemetryTestHelpers.cs index 0b559ccb215..f81827b4762 100644 --- a/tests/Shared/Telemetry/TelemetryTestHelpers.cs +++ b/tests/Shared/Telemetry/TelemetryTestHelpers.cs @@ -231,7 +231,7 @@ public static Resource CreateResource(string? name = null, string? instanceId = return resource; } - public static TelemetryRepository CreateRepository( + public static InMemoryTelemetryRepository CreateRepository( int? maxMetricsCount = null, int? maxAttributeCount = null, int? maxAttributeLength = null, @@ -274,7 +274,7 @@ public static TelemetryRepository CreateRepository( options.MaxResourceCount = maxResourceCount.Value; } - var repository = new TelemetryRepository( + var repository = new InMemoryTelemetryRepository( loggerFactory ?? NullLoggerFactory.Instance, Options.Create(new DashboardOptions { TelemetryLimits = options }), pauseManager ?? new PauseManager(), diff --git a/tests/Shared/TestDashboardClient.cs b/tests/Shared/TestDashboardClient.cs index 524c7c0bd49..c1383744525 100644 --- a/tests/Shared/TestDashboardClient.cs +++ b/tests/Shared/TestDashboardClient.cs @@ -22,6 +22,7 @@ public class TestDashboardClient : IDashboardClient private readonly IList? _initialResources; public bool IsEnabled { get; } + public bool IsReadOnly { get; } public Task WhenConnected { get; } public string ApplicationName { get; } = "TestApp"; public string? MinRequiredVersion => null; @@ -41,9 +42,11 @@ public TestDashboardClient( Func>? executeResourceCommand = null, Channel? sendInteractionUpdateChannel = null, IList? initialResources = null, - Task? whenConnected = null) + Task? whenConnected = null, + bool isReadOnly = false) { IsEnabled = isEnabled ?? false; + IsReadOnly = isReadOnly; ApplicationName = applicationName ?? "TestApp"; WhenConnected = whenConnected ?? Task.CompletedTask; _consoleLogsChannelProvider = consoleLogsChannelProvider; From d8aeaaf6d848978f539ebe6496712fdadc921b9f Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 14 Jul 2026 10:42:03 +0800 Subject: [PATCH 02/87] Fix --- tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs index b0e15bfa7dc..3be7be48df9 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs @@ -36,7 +36,7 @@ public ResourceMenuBuilderTests() dimensionManager); } - private ResourceMenuBuilder CreateResourceMenuBuilder(TelemetryRepository repository, TestAIContextProvider aiContextProvider) + private ResourceMenuBuilder CreateResourceMenuBuilder( InMemoryTelemetryRepository repository, IDashboardClient? dashboardClient = null) { @@ -310,7 +310,6 @@ public void AddMenuItems_ReadOnly_DisablesResourceCommands() var repository = TelemetryTestHelpers.CreateRepository(); var resourceMenuBuilder = CreateResourceMenuBuilder( repository, - new TestAIContextProvider(), new TestDashboardClient(isReadOnly: true)); var menuItems = new List(); From de9c22975cf1210f31f6b465fffc380ef5b44634 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 14 Jul 2026 14:01:55 +0800 Subject: [PATCH 03/87] Add dashboard persistence modes --- .../Components/Layout/MainLayout.razor | 35 ++++---- .../Components/Layout/MainLayout.razor.cs | 15 +++- .../Configuration/DashboardOptions.cs | 10 +++ .../PostConfigureDashboardOptions.cs | 14 +++ .../Configuration/ValidateDashboardOptions.cs | 9 ++ .../ServiceClient/DashboardRunStore.cs | 86 +++++++++++++++---- .../ServiceClient/DashboardSqliteDatabase.cs | 2 +- .../DatabaseSchema/002.Resources.sql | 9 +- .../ServiceClient/SqliteResourceRepository.cs | 41 ++++++--- .../Dashboard/DashboardEventHandlers.cs | 2 + src/Shared/DashboardConfigNames.cs | 1 + .../Layout/MainLayoutTests.cs | 35 ++++++++ .../Shared/FluentUISetupHelpers.cs | 6 +- .../DashboardOptionsTests.cs | 25 +++++- .../Model/DashboardDataSourceTests.cs | 77 ++++++++++++++++- .../Model/SqliteResourceRepositoryTests.cs | 12 ++- .../Dashboard/DashboardEventHandlersTests.cs | 12 ++- 17 files changed, 330 insertions(+), 61 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index fbb497d917e..c8c0acbdb01 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -24,12 +24,15 @@ @startText }
- - - + @if (RunStore.SupportsRunSelection) + { + + + + }
@startText } - - - + @if (RunStore.SupportsRunSelection) + { + + + + }
@@ -111,10 +117,7 @@
- @if (_runSelectionLoaded) - { - @Body - } + @Body
diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index f30e15bc015..9bc0efc1846 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -39,7 +39,6 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable internal const string DashboardRunsButtonId = "dashboard-runs-button"; internal const string NavigationButtonId = "dashboard-navigation-button"; private DashboardRunDescriptor? _selectedRun; - private bool _runSelectionLoaded; [Inject] public required ThemeManager ThemeManager { get; init; } @@ -92,12 +91,15 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable protected override async Task OnInitializedAsync() { var runs = RunStore.GetRuns(); - var selectedRunResult = await SessionStorage.GetAsync(BrowserStorageKeys.SelectedDashboardRunId); - var selectedRunId = selectedRunResult is { Success: true } ? selectedRunResult.Value : null; + string? selectedRunId = null; + if (RunStore.SupportsRunSelection) + { + var selectedRunResult = await SessionStorage.GetAsync(BrowserStorageKeys.SelectedDashboardRunId); + selectedRunId = selectedRunResult is { Success: true } ? selectedRunResult.Value : null; + } _selectedRun = runs.FirstOrDefault(run => string.Equals(run.RunId, selectedRunId, StringComparison.Ordinal)) ?? runs.Single(run => run.IsCurrent); RunSelection.SelectRun(_selectedRun.IsCurrent ? null : _selectedRun.RunId); - _runSelectionLoaded = true; // Theme change can be triggered from the settings dialog. This logic applies the new theme to the browser window. // Note that this event could be raised from a settings dialog opened in a different browser window. @@ -347,6 +349,11 @@ public async Task LaunchAIAgentsAsync() private async Task LaunchDashboardRunsAsync() { + if (!RunStore.SupportsRunSelection) + { + return; + } + var content = new DashboardRunsDialogViewModel { SelectedRun = _selectedRun ?? RunStore.GetRuns().Single(run => run.IsCurrent) diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index 104f2ae4e8a..aa3528fc185 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -25,6 +25,16 @@ public sealed class DashboardOptions public sealed class DashboardDataOptions { public string? Directory { get; set; } + public DashboardPersistenceMode PersistenceMode { get; set; } + + internal string? PersistenceModeParseError { get; set; } +} + +public enum DashboardPersistenceMode +{ + None, + Runs, + Append } // Don't set values after validating/parsing options. diff --git a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs index 052ac565656..23413c450df 100644 --- a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs @@ -36,6 +36,20 @@ public void PostConfigure(string? name, DashboardOptions options) options.Data.Directory = dataDirectory; } + if (_configuration[DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] is { Length: > 0 } persistenceMode) + { + if (Enum.TryParse(persistenceMode, ignoreCase: true, out var parsedPersistenceMode) && + Enum.IsDefined(parsedPersistenceMode)) + { + options.Data.PersistenceMode = parsedPersistenceMode; + options.Data.PersistenceModeParseError = null; + } + else + { + options.Data.PersistenceModeParseError = $"Failed to parse dashboard persistence mode '{persistenceMode}'. Possible values: {string.Join(", ", typeof(DashboardPersistenceMode).GetEnumNames())}."; + } + } + // Copy aliased config values to the strongly typed options. if (_configuration.GetString(DashboardConfigNames.DashboardOtlpGrpcUrlName.ConfigKey, DashboardConfigNames.Legacy.DashboardOtlpGrpcUrlName.ConfigKey, fallbackOnEmpty: true) is { } otlpGrpcUrl) diff --git a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs index c88febd47e4..bfdc4cdbef1 100644 --- a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs @@ -26,6 +26,15 @@ public ValidateOptionsResult Validate(string? name, DashboardOptions options) var errorMessages = new List(); + if (options.Data.PersistenceModeParseError is { } persistenceModeParseError) + { + errorMessages.Add(persistenceModeParseError); + } + else if (!Enum.IsDefined(options.Data.PersistenceMode)) + { + errorMessages.Add($"Unexpected dashboard persistence mode: {options.Data.PersistenceMode}"); + } + if (!options.Frontend.TryParseOptions(out var frontendParseErrorMessage)) { errorMessages.Add(frontendParseErrorMessage); diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index 6fcec10de1e..4363c78ace7 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -11,6 +11,7 @@ namespace Aspire.Dashboard.ServiceClient; internal interface IDashboardRunStore { + bool SupportsRunSelection { get; } IReadOnlyList GetRuns(); } @@ -21,27 +22,46 @@ internal sealed class DashboardRunStore : IDashboardRunStore, IDisposable private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; - private readonly string _runsDirectory; - private readonly string _metadataPath; + private readonly string? _runsDirectory; + private readonly string? _metadataPath; + private readonly string? _temporaryDirectory; private readonly DashboardRunMetadata _metadata; public DashboardRunStore(IOptions options) { - var dataRoot = options.Value.Data.Directory; - if (string.IsNullOrWhiteSpace(dataRoot)) - { - dataRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Aspire", "Dashboard"); - } - var applicationName = string.IsNullOrWhiteSpace(options.Value.ApplicationName) ? "Aspire" : options.Value.ApplicationName; - var applicationDirectoryName = GetApplicationDirectoryName(applicationName); var startedAt = DateTimeOffset.UtcNow; var runId = $"{startedAt:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}"; - _runsDirectory = Path.Combine(Path.GetFullPath(dataRoot), applicationDirectoryName, "runs"); - RunDirectory = Path.Combine(_runsDirectory, runId); - Directory.CreateDirectory(RunDirectory); - DatabasePath = Path.Combine(RunDirectory, "dashboard.db"); - _metadataPath = Path.Combine(RunDirectory, "run.json"); + PersistenceMode = options.Value.Data.PersistenceMode; + + switch (PersistenceMode) + { + case DashboardPersistenceMode.None: + _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-").FullName; + RunDirectory = _temporaryDirectory; + DatabasePath = Path.Combine(RunDirectory, "dashboard.db"); + break; + case DashboardPersistenceMode.Runs: + var applicationDirectory = GetApplicationDirectory(options.Value.Data.Directory, applicationName); + _runsDirectory = Path.Combine(applicationDirectory, "runs"); + RunDirectory = Path.Combine(_runsDirectory, runId); + Directory.CreateDirectory(RunDirectory); + DatabasePath = Path.Combine(RunDirectory, "dashboard.db"); + _metadataPath = Path.Combine(RunDirectory, "run.json"); + break; + case DashboardPersistenceMode.Append: + RunDirectory = GetApplicationDirectory(options.Value.Data.Directory, applicationName); + Directory.CreateDirectory(RunDirectory); + DatabasePath = Path.Combine(RunDirectory, "dashboard.db"); + if (File.Exists(DatabasePath) && !DashboardSqliteDatabase.IsCompatible(DatabasePath)) + { + DeleteDatabaseFiles(DatabasePath); + } + break; + default: + throw new InvalidOperationException($"Unexpected dashboard persistence mode: {PersistenceMode}"); + } + _metadata = new DashboardRunMetadata { SchemaVersion = SchemaVersion, @@ -50,12 +70,17 @@ public DashboardRunStore(IOptions options) ApplicationName = options.Value.ApplicationName, DatabaseFileName = Path.GetFileName(DatabasePath) }; - WriteMetadata(_metadata); + if (_metadataPath is not null) + { + WriteMetadata(_metadata); + } } public string RunDirectory { get; } public string DatabasePath { get; } public string RunId => _metadata.RunId; + public DashboardPersistenceMode PersistenceMode { get; } + public bool SupportsRunSelection => PersistenceMode == DashboardPersistenceMode.Runs; public IReadOnlyList GetRuns() { @@ -64,7 +89,7 @@ public IReadOnlyList GetRuns() CreateDescriptor(_metadata, RunDirectory, isCurrent: true) }; - if (!Directory.Exists(_runsDirectory)) + if (!SupportsRunSelection || !Directory.Exists(_runsDirectory)) { return runs; } @@ -100,12 +125,19 @@ public IReadOnlyList GetRuns() public void Dispose() { - WriteMetadata(_metadata with { EndedAtUtc = DateTimeOffset.UtcNow, CleanShutdown = true }); + if (_metadataPath is not null) + { + WriteMetadata(_metadata with { EndedAtUtc = DateTimeOffset.UtcNow, CleanShutdown = true }); + } + else if (_temporaryDirectory is not null && Directory.Exists(_temporaryDirectory)) + { + Directory.Delete(_temporaryDirectory, recursive: true); + } } private void WriteMetadata(DashboardRunMetadata metadata) { - File.WriteAllText(_metadataPath, JsonSerializer.Serialize(metadata, s_jsonOptions)); + File.WriteAllText(_metadataPath!, JsonSerializer.Serialize(metadata, s_jsonOptions)); } private static DashboardRunDescriptor CreateDescriptor(DashboardRunMetadata metadata, string runDirectory, bool isCurrent) @@ -120,6 +152,24 @@ private static DashboardRunDescriptor CreateDescriptor(DashboardRunMetadata meta isCurrent); } + private static string GetApplicationDirectory(string? dataRoot, string applicationName) + { + if (string.IsNullOrWhiteSpace(dataRoot)) + { + dataRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Aspire", "Dashboard"); + } + + return Path.Combine(Path.GetFullPath(dataRoot), GetApplicationDirectoryName(applicationName)); + } + + private static void DeleteDatabaseFiles(string databasePath) + { + foreach (var path in new[] { databasePath, $"{databasePath}-wal", $"{databasePath}-shm" }) + { + File.Delete(path); + } + } + internal static string GetApplicationDirectoryName(string applicationName) { ArgumentException.ThrowIfNullOrEmpty(applicationName); diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index e8876020cc7..27844eabd2e 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -13,7 +13,7 @@ internal sealed class DashboardSqliteDatabase { private const string SchemaResourcePrefix = "Aspire.Dashboard.ServiceClient.DatabaseSchema."; - internal const int SchemaVersion = 5; + internal const int SchemaVersion = 6; internal const string OrdinalIgnoreCaseCollation = "ORDINAL_IGNORE_CASE"; internal const string OrdinalContainsFunction = "ordinal_contains"; internal const string OrdinalStartsWithFunction = "ordinal_starts_with"; diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql index 672d9ed4b11..91145b959a1 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql @@ -169,9 +169,12 @@ CREATE TABLE IF NOT EXISTS dashboard_resource_command_input_validation_errors ( ) STRICT; CREATE TABLE IF NOT EXISTS console_logs ( + console_log_id INTEGER PRIMARY KEY AUTOINCREMENT, resource_name TEXT NOT NULL, line_number INTEGER NOT NULL, content TEXT NOT NULL, - is_stderr INTEGER NOT NULL, - PRIMARY KEY (resource_name, line_number) -) STRICT; \ No newline at end of file + is_stderr INTEGER NOT NULL +) STRICT; + +CREATE INDEX IF NOT EXISTS ix_console_logs_resource_id +ON console_logs(resource_name, console_log_id); \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs index 2c7e399e5e6..c7c65c188e1 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs @@ -22,6 +22,7 @@ public sealed partial class SqliteResourceRepository : IResourceRepository, IRes private readonly Dictionary _resources = new(StringComparers.ResourceName); private ImmutableHashSet>> _resourceChannels = []; private readonly Dictionary>>> _consoleChannels = new(StringComparers.ResourceName); + private readonly Dictionary> _consoleLogIds = new(StringComparers.ResourceName); private bool _disposed; public SqliteResourceRepository( @@ -100,7 +101,7 @@ public async IAsyncEnumerable> GetConsoleLogs( SELECT line_number AS LineNumber, content AS Content, is_stderr AS IsStdErr FROM console_logs WHERE resource_name = @ResourceName - ORDER BY line_number; + ORDER BY console_log_id; """, new { ResourceName = resourceName }) .Select(line => new ResourceLogLine(line.LineNumber, line.Content, line.IsStdErr)) .ToArray(); @@ -127,7 +128,7 @@ public async IAsyncEnumerable> SubscribeConsoleLo SELECT line_number AS LineNumber, content AS Content, is_stderr AS IsStdErr FROM console_logs WHERE resource_name = @ResourceName - ORDER BY line_number; + ORDER BY console_log_id; """, new { ResourceName = resourceName }) .Select(line => new ResourceLogLine(line.LineNumber, line.Content, line.IsStdErr)) .ToArray(); @@ -249,19 +250,39 @@ void IResourceRepositoryWriter.AddConsoleLogs(string resourceName, IReadOnlyList ThrowIfDisposed(); using var connection = _database.OpenConnection(); using var transaction = connection.BeginTransaction(); - connection.Execute(""" - INSERT INTO console_logs (resource_name, line_number, content, is_stderr) - VALUES (@ResourceName, @LineNumber, @Content, @IsStdErr) - ON CONFLICT(resource_name, line_number) DO UPDATE SET - content = excluded.content, - is_stderr = excluded.is_stderr; - """, logLines.Select(line => new + if (!_consoleLogIds.TryGetValue(resourceName, out var resourceLogIds)) + { + resourceLogIds = []; + _consoleLogIds.Add(resourceName, resourceLogIds); + } + + foreach (var line in logLines) + { + var parameters = new { ResourceName = resourceName, line.LineNumber, Content = line.Text, line.IsStdErr - }), transaction); + }; + if (resourceLogIds.TryGetValue(line.LineNumber, out var consoleLogId)) + { + connection.Execute(""" + UPDATE console_logs + SET content = @Content, is_stderr = @IsStdErr + WHERE console_log_id = @ConsoleLogId; + """, new { parameters.Content, parameters.IsStdErr, ConsoleLogId = consoleLogId }, transaction); + } + else + { + consoleLogId = connection.QuerySingle(""" + INSERT INTO console_logs (resource_name, line_number, content, is_stderr) + VALUES (@ResourceName, @LineNumber, @Content, @IsStdErr) + RETURNING console_log_id; + """, parameters, transaction); + resourceLogIds.Add(line.LineNumber, consoleLogId); + } + } transaction.Commit(); channels = (_consoleChannels.GetValueOrDefault(resourceName) ?? []).ToArray(); } diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index afb1a324425..d44c71a142c 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -607,6 +607,8 @@ internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext con { context.EnvironmentVariables[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] = Path.Combine(aspireStorePath, ".aspire", "dashboard"); } + context.EnvironmentVariables[DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = + configuration["Aspire:Dashboard:PersistenceMode"] ?? "None"; PopulateDashboardUrls(context); diff --git a/src/Shared/DashboardConfigNames.cs b/src/Shared/DashboardConfigNames.cs index 21afe27c25a..403b31b4d5e 100644 --- a/src/Shared/DashboardConfigNames.cs +++ b/src/Shared/DashboardConfigNames.cs @@ -18,6 +18,7 @@ internal static class DashboardConfigNames public static readonly ConfigName ForwardedHeaders = new(KnownConfigNames.DashboardForwardedHeadersEnabled); public static readonly ConfigName DashboardApplicationName = new("Dashboard:ApplicationName", "ASPIRE_DASHBOARD_APPLICATION_NAME"); public static readonly ConfigName DashboardDataDirectoryName = new("Dashboard:Data:Directory", "ASPIRE_DASHBOARD_DATA_DIRECTORY"); + public static readonly ConfigName DashboardPersistenceModeName = new("Dashboard:Data:PersistenceMode", "ASPIRE_DASHBOARD_PERSISTENCE_MODE"); public static readonly ConfigName DashboardOtlpAuthModeName = new("Dashboard:Otlp:AuthMode", "DASHBOARD__OTLP__AUTHMODE"); public static readonly ConfigName DashboardOtlpPrimaryApiKeyName = new("Dashboard:Otlp:PrimaryApiKey", "DASHBOARD__OTLP__PRIMARYAPIKEY"); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index 43d1bf4534c..732b38c0b77 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -265,6 +265,41 @@ public void DashboardRunsButton_Click_OpensDashboardRunsDialog() Assert.Equal(nameof(DashboardRunsDialog), capturedParameters.Id); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DashboardRunsButton_Unsupported_IsHiddenAndStaleSelectionIsIgnored(bool isDesktop) + { + var runStore = new FluentUISetupHelpers.TestDashboardRunStore( + [ + new( + RunId: "current", + StartedAtUtc: DateTimeOffset.UnixEpoch, + EndedAtUtc: null, + CleanShutdown: false, + ApplicationName: "TestApp", + DatabasePath: string.Empty, + IsCurrent: true) + ], + supportsRunSelection: false); + var sessionStorage = new TestSessionStorage + { + OnGetAsync = _ => throw new InvalidOperationException("Run selection should not be read.") + }; + SetupMainLayoutServices(dashboardRunStore: runStore, sessionStorage: sessionStorage); + + var cut = RenderComponent(builder => + { + builder.Add( + component => component.ViewportInformation, + new ViewportInformation(IsDesktop: isDesktop, IsUltraLowHeight: false, IsUltraLowWidth: false)); + }); + + Assert.Empty(cut.FindAll("#dashboard-runs-button")); + var runSelection = Assert.IsType(Services.GetRequiredService()); + Assert.Null(runSelection.SelectedRunId); + } + [Fact] public async Task DashboardRunsDialog_OkStoresSelectionAndReloadsWithoutQueryString() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index bb3c71ce819..801e41bb938 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -186,7 +186,9 @@ public static void AddCommonDashboardServices( context.Services.AddSingleton>(Options.Create(new DashboardOptions())); } - internal sealed class TestDashboardRunStore(IReadOnlyList? runs = null) : IDashboardRunStore + internal sealed class TestDashboardRunStore( + IReadOnlyList? runs = null, + bool supportsRunSelection = true) : IDashboardRunStore { private readonly IReadOnlyList _runs = runs ?? [ @@ -201,6 +203,8 @@ internal sealed class TestDashboardRunStore(IReadOnlyList GetRuns() => _runs; + + public bool SupportsRunSelection => supportsRunSelection; } internal sealed class TestDashboardRunSelection : IDashboardRunSelection diff --git a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs index 9a73581c478..9e39ade54d0 100644 --- a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs +++ b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs @@ -43,6 +43,7 @@ public void ValidOptions_AreValid() Assert.Null(result.FailureMessage); Assert.True(result.Succeeded); + Assert.Equal(DashboardPersistenceMode.None, GetValidOptions().Data.PersistenceMode); } [Fact] @@ -52,7 +53,8 @@ public void PostConfigure_MapsDashboardRunStorageEnvironmentAliases() .AddInMemoryCollection(new Dictionary { [DashboardConfigNames.DashboardApplicationName.EnvVarName] = "My Dashboard", - [DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] = "/data/.aspire/dashboard" + [DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] = "/data/.aspire/dashboard", + [DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = "append" }) .Build(); var options = new DashboardOptions @@ -65,6 +67,27 @@ public void PostConfigure_MapsDashboardRunStorageEnvironmentAliases() Assert.Equal("My Dashboard", options.ApplicationName); Assert.Equal("/data/.aspire/dashboard", options.Data.Directory); + Assert.Equal(DashboardPersistenceMode.Append, options.Data.PersistenceMode); + } + + [Fact] + public void PostConfigure_InvalidDashboardPersistenceMode_IsInvalid() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = "invalid" + }) + .Build(); + var options = GetValidOptions(); + + new PostConfigureDashboardOptions(configuration).PostConfigure(null, options); + var result = new ValidateDashboardOptions().Validate(null, options); + + Assert.False(result.Succeeded); + Assert.Equal( + "Failed to parse dashboard persistence mode 'invalid'. Possible values: None, Runs, Append.", + result.FailureMessage); } #region Frontend options diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index 4722016dc43..fe80cd78714 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -33,6 +33,73 @@ public void RunDirectory_IsNestedUnderApplicationDirectoryAndRuns() Assert.Equal(expectedRunsDirectory, Directory.GetParent(runStore.RunDirectory)!.FullName); } + [Fact] + public void NoneMode_UsesTemporaryDatabaseAndDeletesItOnDispose() + { + var options = CreateOptions(persistenceMode: DashboardPersistenceMode.None); + string runDirectory; + string databasePath; + + using (var runStore = new DashboardRunStore(options)) + { + runDirectory = runStore.RunDirectory; + databasePath = runStore.DatabasePath; + new DashboardSqliteDatabase(databasePath).InitializeSchema(); + + Assert.False(runStore.SupportsRunSelection); + Assert.False(runDirectory.StartsWith(_temporaryDirectory, StringComparison.OrdinalIgnoreCase)); + Assert.Collection(runStore.GetRuns(), run => Assert.True(run.IsCurrent)); + Assert.True(File.Exists(databasePath)); + } + + Assert.False(Directory.Exists(runDirectory)); + Assert.False(File.Exists(databasePath)); + } + + [Fact] + public void AppendMode_ReusesApplicationDatabaseWithoutRunSelection() + { + var options = CreateOptions("My Dashboard", DashboardPersistenceMode.Append); + string firstDatabasePath; + + using (var firstRunStore = new DashboardRunStore(options)) + { + firstDatabasePath = firstRunStore.DatabasePath; + new DashboardSqliteDatabase(firstDatabasePath).InitializeSchema(); + } + + using var secondRunStore = new DashboardRunStore(options); + + Assert.Equal(firstDatabasePath, secondRunStore.DatabasePath); + Assert.False(secondRunStore.SupportsRunSelection); + Assert.Collection(secondRunStore.GetRuns(), run => Assert.True(run.IsCurrent)); + Assert.True(DashboardSqliteDatabase.IsCompatible(secondRunStore.DatabasePath)); + } + + [Fact] + public void AppendMode_DeletesIncompatibleDatabase() + { + var options = CreateOptions(persistenceMode: DashboardPersistenceMode.Append); + string databasePath; + + using (var firstRunStore = new DashboardRunStore(options)) + { + databasePath = firstRunStore.DatabasePath; + using var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TABLE dashboard_schema (version INTEGER NOT NULL); INSERT INTO dashboard_schema VALUES (1);"; + command.ExecuteNonQuery(); + } + + using var secondRunStore = new DashboardRunStore(options); + + Assert.Equal(databasePath, secondRunStore.DatabasePath); + Assert.False(File.Exists(databasePath)); + new DashboardSqliteDatabase(databasePath).InitializeSchema(); + Assert.True(DashboardSqliteDatabase.IsCompatible(databasePath)); + } + [Fact] public void ApplicationDirectoryName_IsSafeBoundedAndUnique() { @@ -205,12 +272,18 @@ public void UnknownRunId_SelectsCurrentRun() Assert.Same(currentTelemetryRepository, dataSource.TelemetryRepository); } - private IOptions CreateOptions(string applicationName = "TestApp") + private IOptions CreateOptions( + string applicationName = "TestApp", + DashboardPersistenceMode persistenceMode = DashboardPersistenceMode.Runs) { return Options.Create(new DashboardOptions { ApplicationName = applicationName, - Data = new DashboardDataOptions { Directory = _temporaryDirectory } + Data = new DashboardDataOptions + { + Directory = _temporaryDirectory, + PersistenceMode = persistenceMode + } }); } diff --git a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs index bcf23d140b0..809656752fb 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs @@ -60,7 +60,7 @@ public async Task ResourceSubscription_ReceivesUpsertAndDelete() } [Fact] - public async Task ConsoleLogs_AreOrderedIdempotentAndReplayable() + public async Task ConsoleLogs_UseInsertionOrderAndAllowLineNumbersToRestart() { var databasePath = Path.Combine(_temporaryDirectory, "console.db"); using (var repository = CreateRepository(databasePath)) @@ -73,6 +73,13 @@ public async Task ConsoleLogs_AreOrderedIdempotentAndReplayable() writer.AddConsoleLogs("api", [new ConsoleLogLine { LineNumber = 2, Text = "second-updated", IsStdErr = true }]); } + using (var restartedRepository = CreateRepository(databasePath)) + { + ((IResourceRepositoryWriter)restartedRepository).AddConsoleLogs( + "api", + [new ConsoleLogLine { LineNumber = 1, Text = "first-after-restart" }]); + } + using var historicalRepository = CreateRepository(databasePath, readOnly: true); var batches = new List>(); await foreach (var batch in historicalRepository.GetConsoleLogs("api", CancellationToken.None)) @@ -81,8 +88,9 @@ public async Task ConsoleLogs_AreOrderedIdempotentAndReplayable() } var lines = Assert.Single(batches); Assert.Collection(lines, + line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(2, "second-updated", true), line), line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(1, "first", false), line), - line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(2, "second-updated", true), line)); + line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(1, "first-after-restart", false), line)); } [Fact] diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs index c56ffdf3929..f1096693d2d 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs @@ -308,8 +308,12 @@ public async Task ConfigureEnvironmentVariables_HasAspireDashboardEnvVars_Copied Assert.Equal("true", envVars.Single(e => e.Key == "ASPIRE_DASHBOARD_PURPLE_MONKEY_DISHWASHER").Value); } - [Fact] - public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRootAndApplicationName() + [Theory] + [InlineData(null, "None")] + [InlineData("Runs", "Runs")] + public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRootApplicationNameAndPersistenceMode( + string? configuredPersistenceMode, + string expectedPersistenceMode) { var storeDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-store-tests-"); try @@ -320,7 +324,8 @@ public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRoo .AddInMemoryCollection(new Dictionary { ["Aspire:Store:Path"] = storeDirectory.FullName, - ["AppHost:DashboardApplicationName"] = "My App.AppHost" + ["AppHost:DashboardApplicationName"] = "My App.AppHost", + ["Aspire:Dashboard:PersistenceMode"] = configuredPersistenceMode }) .Build(); var dashboardOptions = Options.Create(new DashboardOptions @@ -344,6 +349,7 @@ public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRoo Path.Combine(storeDirectory.FullName, ".aspire", "dashboard"), environmentVariables[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName]); Assert.Equal("My App", environmentVariables[DashboardConfigNames.DashboardApplicationName.EnvVarName]); + Assert.Equal(expectedPersistenceMode, environmentVariables[DashboardConfigNames.DashboardPersistenceModeName.EnvVarName]); } finally { From e9d36ccb6778e5a04f8cbc1688d360c493175aa4 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 14 Jul 2026 14:11:22 +0800 Subject: [PATCH 04/87] Embed native libraries in managed bundle --- src/Aspire.Managed/Aspire.Managed.csproj | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Managed/Aspire.Managed.csproj b/src/Aspire.Managed/Aspire.Managed.csproj index d0a4bc92ed5..d91ee57cb7e 100644 --- a/src/Aspire.Managed/Aspire.Managed.csproj +++ b/src/Aspire.Managed/Aspire.Managed.csproj @@ -19,8 +19,9 @@ true - + true + true <_SdkTrustedRootsDir>$([System.IO.Path]::Combine($([System.IO.Path]::GetDirectoryName($(BundledRuntimeIdentifierGraphFile))), 'trustedroots')) @@ -76,4 +77,9 @@
+ + + + From 7cfecfedb94fa1aafb34d4d387a27f88b9490ef5 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 14 Jul 2026 14:21:39 +0800 Subject: [PATCH 05/87] Default AppHost dashboard persistence to runs --- src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs | 2 +- .../Dashboard/DashboardEventHandlersTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index d44c71a142c..4fcf2bbc714 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -608,7 +608,7 @@ internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext con context.EnvironmentVariables[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] = Path.Combine(aspireStorePath, ".aspire", "dashboard"); } context.EnvironmentVariables[DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = - configuration["Aspire:Dashboard:PersistenceMode"] ?? "None"; + configuration["Aspire:Dashboard:PersistenceMode"] ?? "Runs"; PopulateDashboardUrls(context); diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs index f1096693d2d..c6157f9f2d4 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs @@ -309,7 +309,7 @@ public async Task ConfigureEnvironmentVariables_HasAspireDashboardEnvVars_Copied } [Theory] - [InlineData(null, "None")] + [InlineData(null, "Runs")] [InlineData("Runs", "Runs")] public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRootApplicationNameAndPersistenceMode( string? configuredPersistenceMode, From af7bbc65f85e38b3c3ea0b93b80b2dce77d853b9 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 14 Jul 2026 16:20:03 +0800 Subject: [PATCH 06/87] Fix dashboard persistence tests --- .../Layout/MainLayoutTests.cs | 2 +- .../Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index 732b38c0b77..fb02945163a 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -389,7 +389,7 @@ public void HistoricalRun_DisplaysLocalStartTimeAfterApplicationName() builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); - Assert.Equal("Started 1/2/2025 1:30 PM", cut.Find(".application-run-start").TextContent); + Assert.Equal("Started 1/2/2025 1:30 PM", cut.Find(".application-run-start").TextContent, ignoreWhiteSpaceDifferences: true); } [Theory] diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs index 32f14158c39..c3bd32fc38d 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs @@ -132,6 +132,11 @@ public async Task DashboardDoesNotAddResource_ConfiguresExistingDashboard(string Assert.Equal("http://localhost:5001", e.Value); }, e => + { + Assert.Equal(DashboardConfigNames.DashboardPersistenceModeName.EnvVarName, e.Key); + Assert.Equal("Runs", e.Value); + }, + e => { Assert.Equal(KnownConfigNames.ResourceServiceEndpointUrl, e.Key); Assert.Equal("http://localhost:5000", e.Value); From fdc4e59904e5327116f40c9525409b1a84c8ccb7 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 14 Jul 2026 21:58:00 +0800 Subject: [PATCH 07/87] Address dashboard persistence review feedback --- .../Controls/Chart/ChartContainer.razor.cs | 14 +----- .../Components/Layout/MainLayout.razor | 12 +++-- .../Configuration/DashboardOptions.cs | 1 + .../Otlp/Storage/ITelemetryRepository.cs | 11 +++++ .../SqliteTelemetryRepository.Metrics.cs | 40 ++++++++++++---- .../Otlp/Storage/SqliteTelemetryRepository.cs | 5 +- .../ServiceClient/DashboardRunStore.cs | 16 +++++++ .../ServiceClient/SelectedDashboardClient.cs | 26 ++++++++--- .../Layout/MainLayoutTests.cs | 34 ++++++++++++++ .../Model/DashboardDataSourceTests.cs | 46 ++++++++++++++++++- .../TelemetryRepositoryTests/MetricsTests.cs | 29 ++++++++++++ .../Telemetry/InMemoryTelemetryRepository.cs | 13 +++++- tests/Shared/TestSessionStorage.cs | 13 ++++-- 13 files changed, 222 insertions(+), 38 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs index fe8f0187424..f1cd60efddc 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs @@ -237,18 +237,8 @@ private void EnsureDataEndTime() return; } - var instrument = TelemetryRepository.GetInstrument(new GetInstrumentRequest - { - ResourceKey = ResourceKey, - MeterName = MeterName, - InstrumentName = InstrumentName, - StartTime = DateTime.MinValue, - EndTime = DateTime.MaxValue, - }); - var values = instrument?.Dimensions.SelectMany(dimension => dimension.Values).ToList(); - _dataEndTime = values is { Count: > 0 } - ? new DateTimeOffset(values.Max(value => value.End)) - : null; + var latestEndTime = TelemetryRepository.GetInstrumentLatestEndTime(ResourceKey, MeterName, InstrumentName); + _dataEndTime = latestEndTime is not null ? new DateTimeOffset(latestEndTime.Value) : null; _dataEndTimeKey = key; } diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index c8c0acbdb01..f4cf8dbaad0 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -117,15 +117,21 @@
- @Body + @if (_selectedRun is not null) + { + @Body + }
- - + @if (_selectedRun is not null) + { + + + }
@Loc[nameof(Layout.MainLayoutUnhandledErrorMessage)] @Loc[nameof(Layout.MainLayoutUnhandledErrorReload)] diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index aa3528fc185..1af0a4de36b 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -24,6 +24,7 @@ public sealed class DashboardOptions public sealed class DashboardDataOptions { + // Configure this to a location whose permissions protect persisted Dashboard data from undesirable accounts. public string? Directory { get; set; } public DashboardPersistenceMode PersistenceMode { get; set; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs index 1c03135b621..f1b1fc0dd5a 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs @@ -62,4 +62,15 @@ public interface ITelemetryRepository : IDisposable void ClearTraces(ResourceKey? resourceKey = null); void ClearStructuredLogs(ResourceKey? resourceKey = null); void ClearMetrics(ResourceKey? resourceKey = null); +} + +internal interface IMetricTelemetryRepository +{ + DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName); +} + +internal static class MetricTelemetryRepositoryExtensions +{ + public static DateTime? GetInstrumentLatestEndTime(this ITelemetryRepository repository, ResourceKey resourceKey, string meterName, string instrumentName) => + ((IMetricTelemetryRepository)repository).GetInstrumentLatestEndTime(resourceKey, meterName, instrumentName); } \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs index eb56def7734..874b4a302d3 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs @@ -323,14 +323,17 @@ private long GetOrAddMetricDimension( OtlpHelpers.CopyKeyValuePairs(pointAttributes, scope.Attributes, _otlpContext, out var copyCount, ref temporaryAttributes); Array.Sort(temporaryAttributes, 0, copyCount, MetricAttributeComparer.Instance); var attributes = temporaryAttributes.AsSpan(0, copyCount).ToArray(); - foreach (var existingDimensionId in connection.Query("SELECT dimension_id FROM telemetry_metric_dimensions WHERE instrument_id = @InstrumentId ORDER BY dimension_id;", new { InstrumentId = instrumentId }, transaction)) - { - var existingAttributes = connection.Query(""" - SELECT attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_metric_dimension_attributes - WHERE dimension_id = @DimensionId - ORDER BY ordinal; - """, new { DimensionId = existingDimensionId }, transaction) + var existingDimensionIds = connection.Query("SELECT dimension_id FROM telemetry_metric_dimensions WHERE instrument_id = @InstrumentId ORDER BY dimension_id;", new { InstrumentId = instrumentId }, transaction).AsList(); + var existingAttributesByDimension = connection.Query(""" + SELECT a.dimension_id AS OwnerId, a.attribute_key AS AttributeKey, a.attribute_value AS AttributeValue + FROM telemetry_metric_dimension_attributes a + JOIN telemetry_metric_dimensions d ON d.dimension_id = a.dimension_id + WHERE d.instrument_id = @InstrumentId + ORDER BY a.dimension_id, a.ordinal; + """, new { InstrumentId = instrumentId }, transaction).ToLookup(attribute => attribute.OwnerId); + foreach (var existingDimensionId in existingDimensionIds) + { + var existingAttributes = existingAttributesByDimension[existingDimensionId] .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)); if (existingAttributes.SequenceEqual(attributes)) { @@ -338,8 +341,7 @@ FROM telemetry_metric_dimension_attributes } } - var dimensionCount = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_metric_dimensions WHERE instrument_id = @InstrumentId;", new { InstrumentId = instrumentId }, transaction); - if (dimensionCount >= TelemetryRepositoryLimits.MaxDimensionCount) + if (existingDimensionIds.Count >= TelemetryRepositoryLimits.MaxDimensionCount) { throw new InvalidOperationException($"Dimension limit of {TelemetryRepositoryLimits.MaxDimensionCount} reached."); } @@ -488,6 +490,24 @@ private List GetInstrumentsSummariesFromDatabase(Resource }; } + private DateTime? GetInstrumentLatestEndTimeFromDatabase(ResourceKey resourceKey, string meterName, string instrumentName) + { + using var connection = _database.OpenConnection(); + var endTimeTicks = connection.QuerySingleOrDefault(""" + SELECT MAX(p.end_time_ticks) + FROM telemetry_metric_points p + JOIN telemetry_metric_dimensions d ON d.dimension_id = p.dimension_id + JOIN telemetry_metric_instruments i ON i.instrument_id = d.instrument_id + JOIN telemetry_resources r ON r.resource_id = i.resource_id + JOIN telemetry_scopes s ON s.scope_id = i.scope_id + WHERE r.resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE + AND (@InstanceId IS NULL OR (r.instance_id_is_null = 0 AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE)) + AND s.scope_name = @MeterName + AND i.instrument_name = @InstrumentName; + """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId, MeterName = meterName, InstrumentName = instrumentName }); + return endTimeTicks is not null ? new DateTime(endTimeTicks.Value, DateTimeKind.Utc) : null; + } + private OtlpInstrument? GetResourceInstrumentFromDatabase( ResourceKey resourceKey, string meterName, diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index d482e24e66a..ea4bda8cf54 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -15,7 +15,7 @@ namespace Aspire.Dashboard.Otlp.Storage; /// /// Persists telemetry to SQLite and exposes it through the dashboard telemetry model. /// -public sealed partial class SqliteTelemetryRepository : ITelemetryRepository +public sealed partial class SqliteTelemetryRepository : ITelemetryRepository, IMetricTelemetryRepository { private readonly DashboardSqliteDatabase _database; private readonly OtlpContext _otlpContext; @@ -149,8 +149,11 @@ public void AddTraces(AddContext context, RepeatedField resourceS } public List GetInstrumentsSummaries(ResourceKey key) => GetInstrumentsSummariesFromDatabase(key); public OtlpInstrumentData? GetInstrument(GetInstrumentRequest request) => GetInstrumentFromDatabase(request); + DateTime? IMetricTelemetryRepository.GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) => + GetInstrumentLatestEndTimeFromDatabase(resourceKey, meterName, instrumentName); public void ClearSelectedSignals(Dictionary> selectedResources) { + EnsureWritable(); ClearSelectedLogsFromDatabase(selectedResources); ClearSelectedTracesFromDatabase(selectedResources); ClearSelectedMetricsFromDatabase(selectedResources); diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index 4363c78ace7..6f8ff38437e 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -18,6 +18,7 @@ internal interface IDashboardRunStore internal sealed class DashboardRunStore : IDashboardRunStore, IDisposable { internal const int MaxApplicationDirectoryNameLength = 80; + internal const int MaxRuns = 10; internal const int SchemaVersion = DashboardSqliteDatabase.SchemaVersion; private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; @@ -73,6 +74,7 @@ public DashboardRunStore(IOptions options) if (_metadataPath is not null) { WriteMetadata(_metadata); + PruneRuns(); } } @@ -140,6 +142,20 @@ private void WriteMetadata(DashboardRunMetadata metadata) File.WriteAllText(_metadataPath!, JsonSerializer.Serialize(metadata, s_jsonOptions)); } + private void PruneRuns() + { + // Run directory names start with a fixed-width UTC timestamp, so ordinal ordering matches creation order. + var expiredRunDirectories = Directory.EnumerateDirectories(_runsDirectory!) + .Where(directory => !string.Equals(directory, RunDirectory, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(Path.GetFileName, StringComparer.Ordinal) + .Skip(MaxRuns - 1); + + foreach (var directory in expiredRunDirectories) + { + Directory.Delete(directory, recursive: true); + } + } + private static DashboardRunDescriptor CreateDescriptor(DashboardRunMetadata metadata, string runDirectory, bool isCurrent) { return new DashboardRunDescriptor( diff --git a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs index aa2fbf0443d..6e7513cef95 100644 --- a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs @@ -8,20 +8,32 @@ namespace Aspire.Dashboard.ServiceClient; internal sealed class SelectedDashboardClient(DashboardClient currentClient, DashboardDataSource dataSource) : IDashboardClient { - public Task WhenConnected => currentClient.WhenConnected; - public bool IsEnabled => currentClient.IsEnabled; + public Task WhenConnected => IsReadOnly ? Task.CompletedTask : currentClient.WhenConnected; + public bool IsEnabled => IsReadOnly || currentClient.IsEnabled; public bool IsReadOnly => dataSource.IsReadOnly; - public DashboardConnectionState ConnectionState => currentClient.ConnectionState; + public DashboardConnectionState ConnectionState => IsReadOnly ? DashboardConnectionState.Connected : currentClient.ConnectionState; public string ApplicationName => dataSource.SelectedRun.ApplicationName ?? currentClient.ApplicationName; - public string? MinRequiredVersion => currentClient.MinRequiredVersion; + public string? MinRequiredVersion => IsReadOnly ? null : currentClient.MinRequiredVersion; public event Action? ConnectionStateChanged { - add => currentClient.ConnectionStateChanged += value; - remove => currentClient.ConnectionStateChanged -= value; + add + { + if (!IsReadOnly) + { + currentClient.ConnectionStateChanged += value; + } + } + remove + { + if (!IsReadOnly) + { + currentClient.ConnectionStateChanged -= value; + } + } } - public Task ReconnectAsync() => currentClient.ReconnectAsync(); + public Task ReconnectAsync() => IsReadOnly ? Task.CompletedTask : currentClient.ReconnectAsync(); public Task SubscribeResourcesAsync(CancellationToken cancellationToken) => dataSource.ResourceRepository.SubscribeResourcesAsync(cancellationToken); public ResourceViewModel? GetResource(string resourceName) => dataSource.ResourceRepository.GetResource(resourceName); public IReadOnlyList GetResources() => dataSource.ResourceRepository.GetResources(); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index fb02945163a..569a275482a 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -392,6 +392,40 @@ public void HistoricalRun_DisplaysLocalStartTimeAfterApplicationName() Assert.Equal("Started 1/2/2025 1:30 PM", cut.Find(".application-run-start").TextContent, ignoreWhiteSpaceDifferences: true); } + [Fact] + public async Task RunSelectionPending_DoesNotRenderBody() + { + var runStore = new FluentUISetupHelpers.TestDashboardRunStore( + [ + new( + RunId: "current", + StartedAtUtc: DateTimeOffset.UnixEpoch, + EndedAtUtc: null, + CleanShutdown: false, + ApplicationName: "TestApp", + DatabasePath: string.Empty, + IsCurrent: true) + ]); + var runSelectionSource = new TaskCompletionSource<(bool Success, object? Value)>(TaskCreationOptions.RunContinuationsAsynchronously); + var sessionStorage = new TestSessionStorage + { + OnGetTaskAsync = _ => runSelectionSource.Task + }; + SetupMainLayoutServices(dashboardRunStore: runStore, sessionStorage: sessionStorage); + + var cut = RenderComponent(builder => + { + builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); + builder.Add(p => p.Body, bodyBuilder => bodyBuilder.AddMarkupContent(0, "
")); + }); + + Assert.Empty(cut.FindAll("#body-content")); + + await cut.InvokeAsync(() => runSelectionSource.SetResult((false, null))); + + Assert.NotNull(cut.WaitForElement("#body-content")); + } + [Theory] [InlineData(true, false, "dashboard-help-button", "HelpDialog", "dashboard-navigation-button")] [InlineData(true, false, "dashboard-settings-button", "SettingsDialog", "dashboard-navigation-button")] diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index fe80cd78714..200ec4d16eb 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -9,6 +9,7 @@ using Google.Protobuf.Collections; using Google.Protobuf.WellKnownTypes; using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using OpenTelemetry.Proto.Logs.V1; @@ -148,6 +149,30 @@ public void GetRuns_ReturnsCurrentThenCompletedHistoricalRun() }); } + [Fact] + public void RunsMode_DeletesOldestRunWhenLimitIsExceeded() + { + var applicationDirectory = Path.Combine(_temporaryDirectory, DashboardRunStore.GetApplicationDirectoryName("TestApp")); + var runsDirectory = Path.Combine(applicationDirectory, "runs"); + var historicalRunDirectories = Enumerable.Range(1, DashboardRunStore.MaxRuns) + .Select(index => Path.Combine( + runsDirectory, + $"{DateTimeOffset.UtcNow.AddDays(-index):yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}")) + .ToList(); + + foreach (var directory in historicalRunDirectories) + { + Directory.CreateDirectory(directory); + } + + using var currentRunStore = new DashboardRunStore(CreateOptions()); + + Assert.Equal(DashboardRunStore.MaxRuns, Directory.GetDirectories(runsDirectory).Length); + Assert.False(Directory.Exists(historicalRunDirectories[^1])); + Assert.All(historicalRunDirectories[..^1], directory => Assert.True(Directory.Exists(directory))); + Assert.True(Directory.Exists(currentRunStore.RunDirectory)); + } + [Fact] public void GetRuns_ExcludesIncompatibleDatabaseWithoutDeletingIt() { @@ -193,7 +218,7 @@ public void SqliteDatabase_ConfiguresExactStringFunctionsAndForeignKeys() } [Fact] - public void SelectedHistoricalRun_ReplaysDataAndRejectsMutation() + public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() { var options = CreateOptions(); string historicalRunId; @@ -249,6 +274,25 @@ public void SelectedHistoricalRun_ReplaysDataAndRejectsMutation() Filters = [] }).Items).Message); Assert.Throws(() => dataSource.TelemetryRepository.ClearMetrics()); + Assert.Throws(() => dataSource.TelemetryRepository.ClearSelectedSignals([])); + + await using var currentClient = new DashboardClient( + NullLoggerFactory.Instance, + new ConfigurationManager(), + options, + new MockKnownPropertyLookup(), + new TestStringLocalizer()); + IDashboardClient selectedClient = new SelectedDashboardClient(currentClient, dataSource); + var connectionStateChangedCount = 0; + selectedClient.ConnectionStateChanged += _ => connectionStateChangedCount++; + + currentClient.SetConnectionStateForTesting(DashboardConnectionState.Disconnected); + + Assert.True(selectedClient.IsEnabled); + Assert.True(selectedClient.WhenConnected.IsCompletedSuccessfully); + Assert.Equal(DashboardConnectionState.Connected, selectedClient.ConnectionState); + Assert.Equal(0, connectionStateChangedCount); + await selectedClient.ReconnectAsync(); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index f4afeefa2c8..bf4cb96a66a 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -406,6 +406,35 @@ public void GetInstrument() AssertDimensionValues(instrument.Dimensions, new KeyValuePair[] { KeyValuePair.Create("key1", "value1"), KeyValuePair.Create("key2", "value1") }, valueCount: 1); } + [Fact] + public void GetInstrumentLatestEndTime() + { + var repository = CreateRepository(); + repository.AddMetrics(new AddContext(), new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateSumMetric(metricName: "test", startTime: s_testTime.AddMinutes(1)), + CreateSumMetric(metricName: "test", startTime: s_testTime.AddMinutes(2), attributes: [KeyValuePair.Create("key", "value")]) + } + } + } + } + }); + var resourceKey = Assert.Single(repository.GetResources()).ResourceKey; + + Assert.Equal(s_testTime.AddMinutes(2), repository.GetInstrumentLatestEndTime(resourceKey, "test-meter", "test")); + Assert.Null(repository.GetInstrumentLatestEndTime(resourceKey, "test-meter", "missing")); + } + private static Exemplar CreateExemplar(DateTime startTime, double value, IEnumerable>? attributes = null) { var exemplar = new Exemplar diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index 7c18d807753..0b424cafe54 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -25,7 +25,7 @@ namespace Aspire.Dashboard.Otlp.Storage; -public sealed partial class InMemoryTelemetryRepository : ITelemetryRepository +public sealed partial class InMemoryTelemetryRepository : ITelemetryRepository, IMetricTelemetryRepository { internal const int MaxResourceViewCount = TelemetryRepositoryLimits.MaxResourceViewCount; internal const int MaxInstrumentCount = TelemetryRepositoryLimits.MaxInstrumentCount; @@ -2172,6 +2172,17 @@ public List GetInstrumentsSummaries(ResourceKey key) } } + DateTime? IMetricTelemetryRepository.GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) + { + return GetResources(resourceKey) + .Select(resource => resource.GetInstrument(meterName, instrumentName, DateTime.MinValue, DateTime.MaxValue)) + .OfType() + .SelectMany(instrument => instrument.Dimensions.Values) + .SelectMany(dimension => dimension.Values) + .Select(value => (DateTime?)value.End) + .Max(); + } + private Task OnPeerChanged() { _tracesLock.EnterWriteLock(); diff --git a/tests/Shared/TestSessionStorage.cs b/tests/Shared/TestSessionStorage.cs index 39f6c42410e..aee7cd1135b 100644 --- a/tests/Shared/TestSessionStorage.cs +++ b/tests/Shared/TestSessionStorage.cs @@ -8,17 +8,24 @@ namespace Aspire.Dashboard.Tests.Shared; public sealed class TestSessionStorage : ISessionStorage { public Func? OnGetAsync { get; set; } + public Func>? OnGetTaskAsync { get; set; } public Action? OnSetAsync { get; set; } - public Task> GetAsync(string key) + public async Task> GetAsync(string key) { + if (OnGetTaskAsync is { } asyncCallback) + { + var (success, value) = await asyncCallback(key).ConfigureAwait(false); + return new StorageResult(success: success, value: (T)(value ?? default(T))!); + } + if (OnGetAsync is { } callback) { var (success, value) = callback(key); - return Task.FromResult(new StorageResult(success: success, value: (T)(value ?? default(T))!)); + return new StorageResult(success: success, value: (T)(value ?? default(T))!); } - return Task.FromResult>(new StorageResult(success: false, value: default)); + return new StorageResult(success: false, value: default); } public Task SetAsync(string key, T value) From 884a6fec8f183a398c2e41fb11f64dcb43ad3f87 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 14 Jul 2026 22:53:12 +0800 Subject: [PATCH 08/87] Optimize dashboard SQLite repositories --- .../Storage/SqliteTelemetryRepository.Logs.cs | 40 +- .../SqliteTelemetryRepository.Metrics.cs | 10 +- .../SqliteTelemetryRepository.Traces.cs | 172 ++++- .../ServiceClient/DashboardSqliteDatabase.cs | 2 +- .../ServiceClient/DatabaseSchema/003.Logs.sql | 13 +- .../SqliteResourceRepository.Storage.cs | 588 +++++++++--------- .../Model/SqliteResourceRepositoryTests.cs | 77 +++ .../TelemetryRepositoryTests/TraceTests.cs | 94 +++ 8 files changed, 629 insertions(+), 367 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs index 79edf05afd1..fbe94783eb3 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs @@ -146,15 +146,12 @@ INSERT INTO telemetry_log_attributes (log_id, ordinal, attribute_key, attribute_ private long GetOrAddTelemetryResource(SqliteConnection connection, IDbTransaction transaction, ResourceKey resourceKey) { - var instanceIdIsNull = resourceKey.InstanceId is null; - var instanceId = resourceKey.InstanceId ?? string.Empty; var resourceId = connection.QuerySingleOrDefault(""" SELECT resource_id FROM telemetry_resources WHERE resource_name = @ResourceName - AND instance_id_is_null = @InstanceIdIsNull - AND instance_id = @InstanceId; - """, new { ResourceName = resourceKey.Name, InstanceIdIsNull = instanceIdIsNull, InstanceId = instanceId }, transaction); + AND instance_id IS @InstanceId; + """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId }, transaction); if (resourceId is not null) { return resourceId.Value; @@ -167,10 +164,10 @@ FROM telemetry_resources } return connection.QuerySingle(""" - INSERT INTO telemetry_resources (resource_name, instance_id, instance_id_is_null) - VALUES (@ResourceName, @InstanceId, @InstanceIdIsNull) + INSERT INTO telemetry_resources (resource_name, instance_id) + VALUES (@ResourceName, @InstanceId) RETURNING resource_id; - """, new { ResourceName = resourceKey.Name, InstanceId = instanceId, InstanceIdIsNull = instanceIdIsNull }, transaction); + """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId }, transaction); } private static long InsertResourceView( @@ -302,7 +299,6 @@ private PagedResult GetLogsFromDatabase(GetLogsContext context) l.event_name AS EventName, r.resource_name AS ResourceName, r.instance_id AS InstanceId, - r.instance_id_is_null AS InstanceIdIsNull, r.uninstrumented_peer AS UninstrumentedPeer, r.has_logs AS HasLogs, r.has_traces AS HasTraces, @@ -344,7 +340,6 @@ s.scope_version AS ScopeVersion l.event_name AS EventName, r.resource_name AS ResourceName, r.instance_id AS InstanceId, - r.instance_id_is_null AS InstanceIdIsNull, r.uninstrumented_peer AS UninstrumentedPeer, r.has_logs AS HasLogs, r.has_traces AS HasTraces, @@ -404,7 +399,7 @@ FROM telemetry_log_attributes a parameters.Add("ResourceName", resourceKey.Value.Name); if (resourceKey.Value.InstanceId is not null) { - sql.Append(" AND r.instance_id_is_null = 0 AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"); + sql.Append(" AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"); parameters.Add("InstanceId", resourceKey.Value.InstanceId); } } @@ -442,7 +437,7 @@ private static LogQuery BuildLogQuery(GetLogsContext context) parameters.Add($"ResourceName{i}", key.Name); if (key.InstanceId is not null) { - predicate += $" AND r.instance_id_is_null = 0 AND r.instance_id = @InstanceId{i} COLLATE ORDINAL_IGNORE_CASE"; + predicate += $" AND r.instance_id = @InstanceId{i} COLLATE ORDINAL_IGNORE_CASE"; parameters.Add($"InstanceId{i}", key.InstanceId); } resourcePredicates.Add($"({predicate})"); @@ -632,7 +627,7 @@ WHERE scope_id IN @Ids { resource = new OtlpResource( record.ResourceName, - record.InstanceIdIsNull ? null : record.InstanceId, + record.InstanceId, record.UninstrumentedPeer, _otlpContext) { @@ -686,12 +681,12 @@ private void ClearSelectedLogsFromDatabase(Dictionary(""" - SELECT resource_name AS ResourceName, instance_id AS InstanceId, instance_id_is_null AS InstanceIdIsNull + SELECT resource_name AS ResourceName, instance_id AS InstanceId FROM telemetry_resources; """); foreach (var resource in resources) { - var key = new ResourceKey(resource.ResourceName, resource.InstanceIdIsNull ? null : resource.InstanceId); + var key = new ResourceKey(resource.ResourceName, resource.InstanceId); if (!selectedResources.TryGetValue(key.GetCompositeName(), out var dataTypes)) { continue; @@ -722,7 +717,7 @@ DELETE FROM telemetry_resources parameters.Add("ResourceName", resourceKey.Name); if (resourceKey.InstanceId is not null) { - sql.Append(" AND instance_id_is_null = 0 AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"); + sql.Append(" AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"); parameters.Add("InstanceId", resourceKey.InstanceId); } sql.Append(';'); @@ -752,7 +747,7 @@ private void ClearStructuredLogsFromDatabase(ResourceKey? resourceKey) parameters.Add("ResourceName", resourceKey.Value.Name); if (resourceKey.Value.InstanceId is not null) { - where += " AND instance_id_is_null = 0 AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; + where += " AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; parameters.Add("InstanceId", resourceKey.Value.InstanceId); } } @@ -795,7 +790,6 @@ private List GetTelemetryResources(bool includeUninstrumentedPeers SELECT resource_name AS ResourceName, instance_id AS InstanceId, - instance_id_is_null AS InstanceIdIsNull, uninstrumented_peer AS UninstrumentedPeer, has_logs AS HasLogs, has_traces AS HasTraces, @@ -803,7 +797,7 @@ has_metrics AS HasMetrics FROM telemetry_resources; """)) { - var key = new ResourceKey(record.ResourceName, record.InstanceIdIsNull ? null : record.InstanceId); + var key = new ResourceKey(record.ResourceName, record.InstanceId); var resource = _resourceCache.GetOrAdd(key, resourceKey => { var newResource = new OtlpResource(resourceKey.Name, resourceKey.InstanceId, record.UninstrumentedPeer, _otlpContext); @@ -835,7 +829,7 @@ SELECT v.resource_view_id FROM telemetry_resource_views v JOIN telemetry_resources r ON r.resource_id = v.resource_id WHERE r.resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE - AND (@InstanceId IS NULL OR (r.instance_id_is_null = 0 AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE)) + AND (@InstanceId IS NULL OR r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE) ORDER BY v.resource_view_id; """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId }).AsList(); var attributes = connection.Query(""" @@ -907,8 +901,7 @@ private sealed class LogRecord public string? OriginalFormat { get; init; } public string? EventName { get; init; } public required string ResourceName { get; init; } - public required string InstanceId { get; init; } - public required bool InstanceIdIsNull { get; init; } + public string? InstanceId { get; init; } public required bool UninstrumentedPeer { get; init; } public required bool HasLogs { get; init; } public required bool HasTraces { get; init; } @@ -926,8 +919,7 @@ private sealed class FieldValueRecord private class TelemetryResourceRecord { public required string ResourceName { get; init; } - public required string InstanceId { get; init; } - public required bool InstanceIdIsNull { get; init; } + public string? InstanceId { get; init; } } private sealed class TelemetryResourceStateRecord : TelemetryResourceRecord diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs index 874b4a302d3..8cf3f0240b9 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs @@ -501,7 +501,7 @@ FROM telemetry_metric_points p JOIN telemetry_resources r ON r.resource_id = i.resource_id JOIN telemetry_scopes s ON s.scope_id = i.scope_id WHERE r.resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE - AND (@InstanceId IS NULL OR (r.instance_id_is_null = 0 AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE)) + AND (@InstanceId IS NULL OR r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE) AND s.scope_name = @MeterName AND i.instrument_name = @InstrumentName; """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId, MeterName = meterName, InstrumentName = instrumentName }); @@ -565,7 +565,7 @@ FROM telemetry_metric_instruments i JOIN telemetry_resources r ON r.resource_id = i.resource_id JOIN telemetry_scopes s ON s.scope_id = i.scope_id WHERE r.resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE - AND (@InstanceId IS NULL OR (r.instance_id_is_null = 0 AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE)) + AND (@InstanceId IS NULL OR r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE) AND (@MeterName IS NULL OR s.scope_name = @MeterName) AND (@InstrumentName IS NULL OR i.instrument_name = @InstrumentName) ORDER BY i.instrument_id; @@ -711,9 +711,9 @@ WHERE exemplar_id IN @Ids private void ClearSelectedMetricsFromDatabase(Dictionary> selectedResources) { using var connection = _database.OpenConnection(); - foreach (var resource in connection.Query("SELECT resource_name AS ResourceName, instance_id AS InstanceId, instance_id_is_null AS InstanceIdIsNull FROM telemetry_resources;")) + foreach (var resource in connection.Query("SELECT resource_name AS ResourceName, instance_id AS InstanceId FROM telemetry_resources;")) { - var key = new ResourceKey(resource.ResourceName, resource.InstanceIdIsNull ? null : resource.InstanceId); + var key = new ResourceKey(resource.ResourceName, resource.InstanceId); if (selectedResources.TryGetValue(key.GetCompositeName(), out var dataTypes) && dataTypes.Contains(AspireDataType.Metrics) && !dataTypes.Contains(AspireDataType.Resource)) { ClearMetricsFromDatabase(key); @@ -735,7 +735,7 @@ private void ClearMetricsFromDatabase(ResourceKey? resourceKey) parameters.Add("ResourceName", resourceKey.Value.Name); if (resourceKey.Value.InstanceId is not null) { - where += " AND instance_id_is_null = 0 AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; + where += " AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; parameters.Add("InstanceId", resourceKey.Value.InstanceId); } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs index a564b3761a5..e563029e983 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs @@ -398,8 +398,8 @@ private static TraceQuery BuildTraceQuery(GetTracesRequest context) if (key.InstanceId is not null) { parameters.Add($"InstanceId{index}", key.InstanceId); - sourcePredicate += $" AND r.instance_id_is_null = 0 AND r.instance_id = @InstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; - peerPredicate += $" AND pr.instance_id_is_null = 0 AND pr.instance_id = @InstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; + sourcePredicate += $" AND r.instance_id = @InstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; + peerPredicate += $" AND pr.instance_id = @InstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; } resourcePredicates.Add($"(({sourcePredicate}) OR ({peerPredicate}))"); } @@ -595,8 +595,8 @@ FROM telemetry_spans s if (key.InstanceId is not null) { parameters.Add($"SpanInstanceId{index}", key.InstanceId); - source += $" AND r.instance_id_is_null = 0 AND r.instance_id = @SpanInstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; - peer += $" AND pr.instance_id_is_null = 0 AND pr.instance_id = @SpanInstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; + source += $" AND r.instance_id = @SpanInstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; + peer += $" AND pr.instance_id = @SpanInstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; } predicates.Add($"(({source}) OR ({peer}))"); } @@ -693,7 +693,7 @@ private List GetTracePropertyKeysFromDatabase(ResourceKey? resourceKey) parameters.Add("ResourceName", resourceKey.Value.Name); if (resourceKey.Value.InstanceId is not null) { - resourceWhere += " AND r.instance_id_is_null = 0 AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; + resourceWhere += " AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; parameters.Add("InstanceId", resourceKey.Value.InstanceId); } } @@ -710,10 +710,40 @@ FROM telemetry_span_attributes a private Dictionary GetTraceFieldValuesFromDatabase(string attributeName) { using var connection = _database.OpenConnection(); - var traces = connection.Query("SELECT trace_id FROM telemetry_traces ORDER BY first_span_timestamp_ticks, insertion_sequence DESC;") - .Select(traceId => MaterializeTrace(connection, traceId)!) - .ToList(); - return OtlpSpan.GetFieldValuesFromTraces(traces, attributeName); + IEnumerable values = attributeName switch + { + KnownResourceFields.ServiceNameField => connection.Query(""" + SELECT r.resource_name + FROM telemetry_spans s + JOIN telemetry_resources r ON r.resource_id = s.resource_id + UNION ALL + SELECT r.resource_name + FROM telemetry_spans s + JOIN telemetry_resources r ON r.resource_id = s.uninstrumented_peer_resource_id; + """), + KnownTraceFields.TraceIdField => connection.Query("SELECT trace_id FROM telemetry_spans;"), + KnownTraceFields.SpanIdField => connection.Query("SELECT span_id FROM telemetry_spans;"), + KnownTraceFields.KindField => connection.Query("SELECT kind FROM telemetry_spans;").Select(kind => ((OtlpSpanKind)kind).ToString()), + KnownTraceFields.StatusField => connection.Query("SELECT status FROM telemetry_spans;").Select(status => ((OtlpSpanStatusCode)status).ToString()), + KnownSourceFields.NameField => connection.Query(""" + SELECT sc.scope_name + FROM telemetry_spans s + JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id; + """), + KnownTraceFields.NameField => connection.Query("SELECT name FROM telemetry_spans;"), + KnownTraceFields.DurationField => connection.Query("SELECT end_time_ticks - start_time_ticks FROM telemetry_spans;") + .Select(ticks => TimeSpan.FromTicks(ticks).TotalMilliseconds.ToString("R", CultureInfo.InvariantCulture)), + KnownTraceFields.TimestampField => connection.Query("SELECT start_time_ticks FROM telemetry_spans;") + .Select(ticks => (ticks / TimeSpan.TicksPerMillisecond).ToString(CultureInfo.InvariantCulture)), + _ => connection.Query(""" + SELECT attribute_value + FROM telemetry_span_attributes + WHERE attribute_key = @AttributeName COLLATE ORDINAL_IGNORE_CASE; + """, new { AttributeName = attributeName }) + }; + + return values.GroupBy(value => value, StringComparers.OtlpAttribute) + .ToDictionary(group => group.Key, group => group.Count(), StringComparers.OtlpAttribute); } private bool HasUpdatedTraceInDatabase(OtlpTrace trace) @@ -763,7 +793,6 @@ FROM telemetry_traces t.last_updated_timestamp_ticks AS LastUpdatedTimestampTicks, r.resource_name AS ResourceName, r.instance_id AS InstanceId, - r.instance_id_is_null AS InstanceIdIsNull, r.uninstrumented_peer AS UninstrumentedPeer, r.has_logs AS HasLogs, r.has_traces AS HasTraces, @@ -771,8 +800,7 @@ FROM telemetry_traces sc.scope_name AS ScopeName, sc.scope_version AS ScopeVersion, pr.resource_name AS PeerResourceName, - pr.instance_id AS PeerInstanceId, - pr.instance_id_is_null AS PeerInstanceIdIsNull + pr.instance_id AS PeerInstanceId FROM telemetry_spans s JOIN telemetry_traces t ON t.trace_id = s.trace_id JOIN telemetry_resources r ON r.resource_id = s.resource_id @@ -849,7 +877,7 @@ WHERE link_id IN @Ids { if (!resources.TryGetValue(record.ResourceId, out var resource)) { - resource = new OtlpResource(record.ResourceName, record.InstanceIdIsNull ? null : record.InstanceId, record.UninstrumentedPeer, _otlpContext) + resource = new OtlpResource(record.ResourceName, record.InstanceId, record.UninstrumentedPeer, _otlpContext) { HasLogs = record.HasLogs, HasTraces = record.HasTraces, @@ -891,7 +919,7 @@ WHERE link_id IN @Ids { modelSpan.SetUninstrumentedPeer(new OtlpResource( record.PeerResourceName!, - record.PeerInstanceIdIsNull ? null : record.PeerInstanceId, + record.PeerInstanceId, uninstrumentedPeer: true, _otlpContext)); } @@ -929,12 +957,12 @@ private void ClearSelectedTracesFromDatabase(Dictionary(""" - SELECT resource_name AS ResourceName, instance_id AS InstanceId, instance_id_is_null AS InstanceIdIsNull + SELECT resource_name AS ResourceName, instance_id AS InstanceId FROM telemetry_resources; """); foreach (var resource in resources) { - var key = new ResourceKey(resource.ResourceName, resource.InstanceIdIsNull ? null : resource.InstanceId); + var key = new ResourceKey(resource.ResourceName, resource.InstanceId); if (selectedResources.TryGetValue(key.GetCompositeName(), out var dataTypes) && dataTypes.Contains(AspireDataType.Traces) && !dataTypes.Contains(AspireDataType.Resource)) @@ -950,16 +978,85 @@ private void RecalculateUninstrumentedPeers() { using var connection = _database.OpenConnection(); using var transaction = connection.BeginTransaction(); - foreach (var traceId in connection.Query("SELECT trace_id FROM telemetry_traces;", transaction: transaction)) + var spans = connection.Query(""" + SELECT + trace_id AS TraceId, + span_id AS SpanId, + parent_span_id AS ParentSpanId, + kind AS Kind + FROM telemetry_spans; + """, transaction: transaction).AsList(); + var attributes = connection.Query(""" + SELECT + trace_id AS TraceId, + span_id AS SpanId, + attribute_key AS AttributeKey, + attribute_value AS AttributeValue + FROM telemetry_span_attributes + ORDER BY trace_id, span_id, ordinal; + """, transaction: transaction).ToLookup(record => (record.TraceId, record.SpanId)); + var parents = spans + .Where(span => span.ParentSpanId is not null) + .Select(span => (span.TraceId, SpanId: span.ParentSpanId!)) + .ToHashSet(); + var peerResourceIds = new Dictionary(); + var spanUpdates = new List(spans.Count); + + foreach (var span in spans) { - var trace = MaterializeTrace(connection, traceId, transaction)!; - UpdateUninstrumentedPeers(connection, transaction, trace); - connection.Execute(""" - UPDATE telemetry_traces - SET last_updated_timestamp_ticks = @LastUpdatedTimestampTicks - WHERE trace_id = @TraceId; - """, new { TraceId = traceId, LastUpdatedTimestampTicks = trace.LastUpdatedDate.Ticks }, transaction); + var spanAttributes = attributes[(span.TraceId, span.SpanId)] + .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)) + .ToArray(); + long? peerResourceId = null; + if (spanAttributes.GetPeerAddress() is not null && + (OtlpSpanKind)span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && + !parents.Contains((span.TraceId, span.SpanId))) + { + foreach (var resolver in _outgoingPeerResolvers) + { + if (!resolver.TryResolvePeer(spanAttributes, out _, out var matchedResource) || matchedResource is null) + { + continue; + } + + var peerKey = ResourceKey.Create(matchedResource.DisplayName, matchedResource.Name); + if (!peerResourceIds.TryGetValue(peerKey, out var resourceId)) + { + resourceId = GetOrAddTelemetryResource(connection, transaction, peerKey); + peerResourceIds.Add(peerKey, resourceId); + connection.Execute( + "UPDATE telemetry_resources SET uninstrumented_peer = 1 WHERE resource_id = @ResourceId;", + new { ResourceId = resourceId }, + transaction); + } + peerResourceId = resourceId; + break; + } + } + + spanUpdates.Add(new PeerSpanUpdateRecord + { + PeerResourceId = peerResourceId, + TraceId = span.TraceId, + SpanId = span.SpanId + }); } + + connection.Execute(""" + UPDATE telemetry_spans + SET uninstrumented_peer_resource_id = @PeerResourceId + WHERE trace_id = @TraceId AND span_id = @SpanId; + """, spanUpdates, transaction); + var lastUpdatedTimestampTicks = DateTime.UtcNow.Ticks; + connection.Execute(""" + UPDATE telemetry_traces + SET last_updated_timestamp_ticks = @LastUpdatedTimestampTicks + WHERE trace_id = @TraceId; + """, spans.Select(span => span.TraceId).Distinct(StringComparer.Ordinal).Select(traceId => new + { + TraceId = traceId, + LastUpdatedTimestampTicks = lastUpdatedTimestampTicks + }), transaction); transaction.Commit(); } } @@ -978,7 +1075,7 @@ private void ClearTracesFromDatabase(ResourceKey? resourceKey) parameters.Add("ResourceName", resourceKey.Value.Name); if (resourceKey.Value.InstanceId is not null) { - where += " AND instance_id_is_null = 0 AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; + where += " AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; parameters.Add("InstanceId", resourceKey.Value.InstanceId); } } @@ -1024,6 +1121,27 @@ private sealed class SpanIdentityRecord public required string SpanId { get; init; } } + private sealed class PeerRecalculationSpanRecord + { + public required string TraceId { get; init; } + public required string SpanId { get; init; } + public string? ParentSpanId { get; init; } + public required int Kind { get; init; } + } + + private sealed class PeerRecalculationAttributeRecord : AttributeRecord + { + public required string TraceId { get; init; } + public required string SpanId { get; init; } + } + + private sealed class PeerSpanUpdateRecord + { + public required string TraceId { get; init; } + public required string SpanId { get; init; } + public long? PeerResourceId { get; init; } + } + private sealed class TraceOwnedAttributeRecord : AttributeRecord { public required string OwnerId { get; init; } @@ -1075,8 +1193,7 @@ private sealed class SpanRecord public long? PeerResourceId { get; init; } public required long LastUpdatedTimestampTicks { get; init; } public required string ResourceName { get; init; } - public required string InstanceId { get; init; } - public required bool InstanceIdIsNull { get; init; } + public string? InstanceId { get; init; } public required bool UninstrumentedPeer { get; init; } public required bool HasLogs { get; init; } public required bool HasTraces { get; init; } @@ -1085,6 +1202,5 @@ private sealed class SpanRecord public required string ScopeVersion { get; init; } public string? PeerResourceName { get; init; } public string? PeerInstanceId { get; init; } - public bool PeerInstanceIdIsNull { get; init; } } } \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index 27844eabd2e..6b81872183a 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -13,7 +13,7 @@ internal sealed class DashboardSqliteDatabase { private const string SchemaResourcePrefix = "Aspire.Dashboard.ServiceClient.DatabaseSchema."; - internal const int SchemaVersion = 6; + internal const int SchemaVersion = 7; internal const string OrdinalIgnoreCaseCollation = "ORDINAL_IGNORE_CASE"; internal const string OrdinalContainsFunction = "ordinal_contains"; internal const string OrdinalStartsWithFunction = "ordinal_starts_with"; diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql index 080fa50d2cf..f6f354a19df 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql @@ -4,15 +4,20 @@ CREATE TABLE IF NOT EXISTS telemetry_resources ( resource_id INTEGER PRIMARY KEY AUTOINCREMENT, resource_name TEXT NOT NULL, - instance_id TEXT NOT NULL, - instance_id_is_null INTEGER NOT NULL, + instance_id TEXT NULL, uninstrumented_peer INTEGER NOT NULL DEFAULT 0, has_logs INTEGER NOT NULL DEFAULT 0, has_traces INTEGER NOT NULL DEFAULT 0, - has_metrics INTEGER NOT NULL DEFAULT 0, - UNIQUE (resource_name, instance_id_is_null, instance_id) + has_metrics INTEGER NOT NULL DEFAULT 0 ) STRICT; +CREATE UNIQUE INDEX IF NOT EXISTS ix_telemetry_resources_name_without_instance + ON telemetry_resources(resource_name) + WHERE instance_id IS NULL; +CREATE UNIQUE INDEX IF NOT EXISTS ix_telemetry_resources_name_with_instance + ON telemetry_resources(resource_name, instance_id) + WHERE instance_id IS NOT NULL; + CREATE TABLE IF NOT EXISTS telemetry_resource_views ( resource_view_id INTEGER PRIMARY KEY AUTOINCREMENT, resource_id INTEGER NOT NULL REFERENCES telemetry_resources(resource_id) ON DELETE CASCADE diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs index 6f096cf7c22..297727e8c79 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs @@ -303,7 +303,7 @@ INSERT INTO dashboard_value_list_items (parent_value_id, ordinal, child_value_id private static IEnumerable LoadResourceRecords(SqliteConnection connection) { - var resourceRecords = connection.Query(""" + using var reader = connection.QueryMultiple(""" SELECT resource_name AS ResourceName, replica_index AS ReplicaIndex, @@ -324,329 +324,247 @@ private static IEnumerable LoadResourceRecords(SqliteConnection icon_variant AS IconVariant FROM dashboard_resources ORDER BY rowid; - """); - - foreach (var record in resourceRecords) - { - var resource = new Resource - { - Name = record.ResourceName, - ResourceType = record.ResourceType, - DisplayName = record.DisplayName, - Uid = record.Uid, - IsHidden = record.IsHidden, - SupportsDetailedTelemetry = record.SupportsDetailedTelemetry - }; - - SetOptionalResourceFields(resource, record); - LoadEnvironment(connection, resource); - LoadUrls(connection, resource); - LoadVolumes(connection, resource); - LoadHealthReports(connection, resource); - LoadRelationships(connection, resource); - LoadProperties(connection, resource); - LoadCommands(connection, resource); - yield return new StoredResource(resource, record.ReplicaIndex); - } - } - - private static void SetOptionalResourceFields(Resource resource, ResourceRecord record) - { - if (record.State is not null) - { - resource.State = record.State; - } - if (record.CreatedAtSeconds is not null) - { - resource.CreatedAt = CreateTimestamp(record.CreatedAtSeconds.Value, record.CreatedAtNanos); - } - if (record.StateStyle is not null) - { - resource.StateStyle = record.StateStyle; - } - if (record.StartedAtSeconds is not null) - { - resource.StartedAt = CreateTimestamp(record.StartedAtSeconds.Value, record.StartedAtNanos); - } - if (record.StoppedAtSeconds is not null) - { - resource.StoppedAt = CreateTimestamp(record.StoppedAtSeconds.Value, record.StoppedAtNanos); - } - if (record.IconName is not null) - { - resource.IconName = record.IconName; - } - if (record.IconVariant is not null) - { - resource.IconVariant = (IconVariant)record.IconVariant.Value; - } - } - - private static void LoadEnvironment(SqliteConnection connection, Resource resource) - { - foreach (var record in connection.Query(""" - SELECT name AS Name, value AS Value, is_from_spec AS IsFromSpec + SELECT resource_name AS ResourceName, name AS Name, value AS Value, is_from_spec AS IsFromSpec FROM dashboard_resource_environment - WHERE resource_name = @ResourceName - ORDER BY ordinal; - """, new { ResourceName = resource.Name })) - { - var item = new EnvironmentVariable { Name = record.Name, IsFromSpec = record.IsFromSpec }; - if (record.Value is not null) - { - item.Value = record.Value; - } - resource.Environment.Add(item); - } - } + ORDER BY resource_name, ordinal; - private static void LoadUrls(SqliteConnection connection, Resource resource) - { - foreach (var record in connection.Query(""" - SELECT - endpoint_name AS EndpointName, - full_url AS FullUrl, - is_internal AS IsInternal, - is_inactive AS IsInactive, - display_sort_order AS DisplaySortOrder, - display_name AS DisplayName + SELECT resource_name AS ResourceName, endpoint_name AS EndpointName, full_url AS FullUrl, + is_internal AS IsInternal, is_inactive AS IsInactive, display_sort_order AS DisplaySortOrder, display_name AS DisplayName FROM dashboard_resource_urls - WHERE resource_name = @ResourceName - ORDER BY ordinal; - """, new { ResourceName = resource.Name })) - { - var item = new Url - { - FullUrl = record.FullUrl, - IsInternal = record.IsInternal, - IsInactive = record.IsInactive, - DisplayProperties = new UrlDisplayProperties { SortOrder = record.DisplaySortOrder, DisplayName = record.DisplayName } - }; - if (record.EndpointName is not null) - { - item.EndpointName = record.EndpointName; - } - resource.Urls.Add(item); - } - } + ORDER BY resource_name, ordinal; - private static void LoadVolumes(SqliteConnection connection, Resource resource) - { - resource.Volumes.Add(connection.Query(""" - SELECT source AS Source, target AS Target, mount_type AS MountType, is_read_only AS IsReadOnly + SELECT resource_name AS ResourceName, source AS Source, target AS Target, mount_type AS MountType, is_read_only AS IsReadOnly FROM dashboard_resource_volumes - WHERE resource_name = @ResourceName - ORDER BY ordinal; - """, new { ResourceName = resource.Name })); - } + ORDER BY resource_name, ordinal; - private static void LoadHealthReports(SqliteConnection connection, Resource resource) - { - foreach (var record in connection.Query(""" - SELECT - status AS Status, - key AS Key, - description AS Description, - exception AS Exception, - last_run_at_seconds AS LastRunAtSeconds, - last_run_at_nanos AS LastRunAtNanos + SELECT resource_name AS ResourceName, status AS Status, key AS Key, description AS Description, + exception AS Exception, last_run_at_seconds AS LastRunAtSeconds, last_run_at_nanos AS LastRunAtNanos FROM dashboard_resource_health_reports - WHERE resource_name = @ResourceName - ORDER BY ordinal; - """, new { ResourceName = resource.Name })) - { - var item = new HealthReport { Key = record.Key, Description = record.Description, Exception = record.Exception }; - if (record.Status is not null) - { - item.Status = (HealthStatus)record.Status.Value; - } - if (record.LastRunAtSeconds is not null) - { - item.LastRunAt = CreateTimestamp(record.LastRunAtSeconds.Value, record.LastRunAtNanos); - } - resource.HealthReports.Add(item); - } - } + ORDER BY resource_name, ordinal; - private static void LoadRelationships(SqliteConnection connection, Resource resource) - { - resource.Relationships.Add(connection.Query(""" - SELECT related_resource_name AS ResourceName, relationship_type AS Type + SELECT resource_name AS ResourceName, related_resource_name AS RelatedResourceName, relationship_type AS RelationshipType FROM dashboard_resource_relationships - WHERE resource_name = @ResourceName - ORDER BY ordinal; - """, new { ResourceName = resource.Name })); - } + ORDER BY resource_name, ordinal; - private static void LoadProperties(SqliteConnection connection, Resource resource) - { - foreach (var record in connection.Query(""" - SELECT - name AS Name, - display_name AS DisplayName, - value_id AS ValueId, - is_sensitive AS IsSensitive, - is_highlighted AS IsHighlighted, - sort_order AS SortOrder + SELECT resource_name AS ResourceName, name AS Name, display_name AS DisplayName, value_id AS ValueId, + is_sensitive AS IsSensitive, is_highlighted AS IsHighlighted, sort_order AS SortOrder FROM dashboard_resource_properties - WHERE resource_name = @ResourceName - ORDER BY ordinal; - """, new { ResourceName = resource.Name })) - { - var item = new ResourceProperty - { - Name = record.Name, - Value = LoadValue(connection, record.ValueId), - IsHighlighted = record.IsHighlighted - }; - if (record.DisplayName is not null) - { - item.DisplayName = record.DisplayName; - } - if (record.IsSensitive is not null) - { - item.IsSensitive = record.IsSensitive.Value; - } - if (record.SortOrder is not null) - { - item.SortOrder = record.SortOrder.Value; - } - resource.Properties.Add(item); - } - } + ORDER BY resource_name, ordinal; - private static void LoadCommands(SqliteConnection connection, Resource resource) - { -#pragma warning disable CS0612 // ResourceCommand.Parameter must be restored for compatibility with older AppHosts. - foreach (var record in connection.Query(""" - SELECT - ordinal AS Ordinal, - name AS Name, - display_name AS DisplayName, - confirmation_message AS ConfirmationMessage, - parameter_value_id AS ParameterValueId, - is_highlighted AS IsHighlighted, - icon_name AS IconName, - icon_variant AS IconVariant, - display_description AS DisplayDescription, - state AS State + SELECT resource_name AS ResourceName, ordinal AS Ordinal, name AS Name, display_name AS DisplayName, + confirmation_message AS ConfirmationMessage, parameter_value_id AS ParameterValueId, + is_highlighted AS IsHighlighted, icon_name AS IconName, icon_variant AS IconVariant, + display_description AS DisplayDescription, state AS State FROM dashboard_resource_commands - WHERE resource_name = @ResourceName - ORDER BY ordinal; - """, new { ResourceName = resource.Name })) + ORDER BY resource_name, ordinal; + + SELECT resource_name AS ResourceName, command_ordinal AS CommandOrdinal, ordinal AS Ordinal, + label AS Label, placeholder AS Placeholder, input_type AS InputType, required AS Required, + value AS Value, description AS Description, enable_description_markdown AS EnableDescriptionMarkdown, + max_length AS MaxLength, allow_custom_choice AS AllowCustomChoice, loading AS Loading, + update_state_on_change AS UpdateStateOnChange, name AS Name, disabled AS Disabled, + max_file_size AS MaxFileSize, allow_multiple_files AS AllowMultipleFiles, file_filter AS FileFilter + FROM dashboard_resource_command_inputs + ORDER BY resource_name, command_ordinal, ordinal; + + SELECT resource_name AS ResourceName, command_ordinal AS CommandOrdinal, input_ordinal AS InputOrdinal, + option_key AS OptionKey, option_value AS OptionValue + FROM dashboard_resource_command_input_options + ORDER BY resource_name, command_ordinal, input_ordinal, option_key; + + SELECT resource_name AS ResourceName, command_ordinal AS CommandOrdinal, input_ordinal AS InputOrdinal, + validation_error AS ValidationError + FROM dashboard_resource_command_input_validation_errors + ORDER BY resource_name, command_ordinal, input_ordinal, ordinal; + + SELECT value_id AS ValueId, value_kind AS ValueKind, string_value AS StringValue, + number_value AS NumberValue, bool_value AS BoolValue + FROM dashboard_values; + + SELECT parent_value_id AS ParentValueId, map_key AS MapKey, child_value_id AS ChildValueId + FROM dashboard_value_map_entries + ORDER BY parent_value_id, ordinal; + + SELECT parent_value_id AS ParentValueId, child_value_id AS ChildValueId + FROM dashboard_value_list_items + ORDER BY parent_value_id, ordinal; + """); + + var resourceRecords = reader.Read().AsList(); + var environments = reader.Read().ToLookup(record => record.ResourceName, StringComparers.ResourceName); + var urls = reader.Read().ToLookup(record => record.ResourceName, StringComparers.ResourceName); + var volumes = reader.Read().ToLookup(record => record.ResourceName, StringComparers.ResourceName); + var healthReports = reader.Read().ToLookup(record => record.ResourceName, StringComparers.ResourceName); + var relationships = reader.Read().ToLookup(record => record.ResourceName, StringComparers.ResourceName); + var properties = reader.Read().ToLookup(record => record.ResourceName, StringComparers.ResourceName); + var commands = reader.Read().ToLookup(record => record.ResourceName, StringComparers.ResourceName); + var inputs = reader.Read().ToLookup(record => (record.ResourceName, record.CommandOrdinal)); + var options = reader.Read().ToLookup(record => (record.ResourceName, record.CommandOrdinal, record.InputOrdinal)); + var validationErrors = reader.Read().ToLookup(record => (record.ResourceName, record.CommandOrdinal, record.InputOrdinal)); + var values = reader.Read().ToDictionary(record => record.ValueId); + var mapValues = reader.Read().ToLookup(record => record.ParentValueId); + var listValues = reader.Read().ToLookup(record => record.ParentValueId); + + foreach (var record in resourceRecords) { - var command = new ResourceCommand + var resource = new Resource { - Name = record.Name, + Name = record.ResourceName, + ResourceType = record.ResourceType, DisplayName = record.DisplayName, - IsHighlighted = record.IsHighlighted, - State = (ResourceCommandState)record.State + Uid = record.Uid, + IsHidden = record.IsHidden, + SupportsDetailedTelemetry = record.SupportsDetailedTelemetry }; - if (record.ConfirmationMessage is not null) + + SetOptionalResourceFields(resource, record); + foreach (var environment in environments[record.ResourceName]) { - command.ConfirmationMessage = record.ConfirmationMessage; + var item = new EnvironmentVariable { Name = environment.Name, IsFromSpec = environment.IsFromSpec }; + if (environment.Value is not null) + { + item.Value = environment.Value; + } + resource.Environment.Add(item); } - if (record.ParameterValueId is not null) + foreach (var url in urls[record.ResourceName]) { - command.Parameter = LoadValue(connection, record.ParameterValueId.Value); + var item = new Url + { + FullUrl = url.FullUrl, + IsInternal = url.IsInternal, + IsInactive = url.IsInactive, + DisplayProperties = new UrlDisplayProperties { SortOrder = url.DisplaySortOrder, DisplayName = url.DisplayName } + }; + if (url.EndpointName is not null) + { + item.EndpointName = url.EndpointName; + } + resource.Urls.Add(item); } - if (record.IconName is not null) + resource.Volumes.Add(volumes[record.ResourceName].Select(volume => new Volume { - command.IconName = record.IconName; - } - if (record.IconVariant is not null) + Source = volume.Source, + Target = volume.Target, + MountType = volume.MountType, + IsReadOnly = volume.IsReadOnly + })); + foreach (var healthReport in healthReports[record.ResourceName]) { - command.IconVariant = (IconVariant)record.IconVariant.Value; + var item = new HealthReport { Key = healthReport.Key, Description = healthReport.Description, Exception = healthReport.Exception }; + if (healthReport.Status is not null) + { + item.Status = (HealthStatus)healthReport.Status.Value; + } + if (healthReport.LastRunAtSeconds is not null) + { + item.LastRunAt = CreateTimestamp(healthReport.LastRunAtSeconds.Value, healthReport.LastRunAtNanos); + } + resource.HealthReports.Add(item); } - if (record.DisplayDescription is not null) + resource.Relationships.Add(relationships[record.ResourceName].Select(relationship => new ResourceRelationship { - command.DisplayDescription = record.DisplayDescription; + ResourceName = relationship.RelatedResourceName, + Type = relationship.RelationshipType + })); + foreach (var property in properties[record.ResourceName]) + { + var item = new ResourceProperty + { + Name = property.Name, + Value = MaterializeValue(property.ValueId, values, mapValues, listValues), + IsHighlighted = property.IsHighlighted + }; + if (property.DisplayName is not null) + { + item.DisplayName = property.DisplayName; + } + if (property.IsSensitive is not null) + { + item.IsSensitive = property.IsSensitive.Value; + } + if (property.SortOrder is not null) + { + item.SortOrder = property.SortOrder.Value; + } + resource.Properties.Add(item); } - LoadCommandInputs(connection, resource.Name, record.Ordinal, command); - resource.Commands.Add(command); - } -#pragma warning restore CS0612 - } - - private static void LoadCommandInputs(SqliteConnection connection, string resourceName, int commandOrdinal, ResourceCommand command) - { - foreach (var record in connection.Query(""" - SELECT - ordinal AS Ordinal, - label AS Label, - placeholder AS Placeholder, - input_type AS InputType, - required AS Required, - value AS Value, - description AS Description, - enable_description_markdown AS EnableDescriptionMarkdown, - max_length AS MaxLength, - allow_custom_choice AS AllowCustomChoice, - loading AS Loading, - update_state_on_change AS UpdateStateOnChange, - name AS Name, - disabled AS Disabled, - max_file_size AS MaxFileSize, - allow_multiple_files AS AllowMultipleFiles, - file_filter AS FileFilter - FROM dashboard_resource_command_inputs - WHERE resource_name = @ResourceName AND command_ordinal = @CommandOrdinal - ORDER BY ordinal; - """, new { ResourceName = resourceName, CommandOrdinal = commandOrdinal })) - { - var input = new InteractionInput +#pragma warning disable CS0612 // ResourceCommand.Parameter must be restored for compatibility with older AppHosts. + foreach (var commandRecord in commands[record.ResourceName]) { - Label = record.Label, - Placeholder = record.Placeholder, - InputType = (InputType)record.InputType, - Required = record.Required, - Value = record.Value, - Description = record.Description, - EnableDescriptionMarkdown = record.EnableDescriptionMarkdown, - MaxLength = record.MaxLength, - AllowCustomChoice = record.AllowCustomChoice, - Loading = record.Loading, - UpdateStateOnChange = record.UpdateStateOnChange, - Name = record.Name, - Disabled = record.Disabled, - MaxFileSize = record.MaxFileSize, - AllowMultipleFiles = record.AllowMultipleFiles, - FileFilter = record.FileFilter - }; + var command = new ResourceCommand + { + Name = commandRecord.Name, + DisplayName = commandRecord.DisplayName, + IsHighlighted = commandRecord.IsHighlighted, + State = (ResourceCommandState)commandRecord.State + }; + if (commandRecord.ConfirmationMessage is not null) + { + command.ConfirmationMessage = commandRecord.ConfirmationMessage; + } + if (commandRecord.ParameterValueId is not null) + { + command.Parameter = MaterializeValue(commandRecord.ParameterValueId.Value, values, mapValues, listValues); + } + if (commandRecord.IconName is not null) + { + command.IconName = commandRecord.IconName; + } + if (commandRecord.IconVariant is not null) + { + command.IconVariant = (IconVariant)commandRecord.IconVariant.Value; + } + if (commandRecord.DisplayDescription is not null) + { + command.DisplayDescription = commandRecord.DisplayDescription; + } - foreach (var option in connection.Query(""" - SELECT option_key AS OptionKey, option_value AS OptionValue - FROM dashboard_resource_command_input_options - WHERE resource_name = @ResourceName AND command_ordinal = @CommandOrdinal AND input_ordinal = @InputOrdinal; - """, new { ResourceName = resourceName, CommandOrdinal = commandOrdinal, InputOrdinal = record.Ordinal })) - { - input.Options.Add(option.OptionKey, option.OptionValue); + foreach (var inputRecord in inputs[(record.ResourceName, commandRecord.Ordinal)]) + { + var input = new InteractionInput + { + Label = inputRecord.Label, + Placeholder = inputRecord.Placeholder, + InputType = (InputType)inputRecord.InputType, + Required = inputRecord.Required, + Value = inputRecord.Value, + Description = inputRecord.Description, + EnableDescriptionMarkdown = inputRecord.EnableDescriptionMarkdown, + MaxLength = inputRecord.MaxLength, + AllowCustomChoice = inputRecord.AllowCustomChoice, + Loading = inputRecord.Loading, + UpdateStateOnChange = inputRecord.UpdateStateOnChange, + Name = inputRecord.Name, + Disabled = inputRecord.Disabled, + MaxFileSize = inputRecord.MaxFileSize, + AllowMultipleFiles = inputRecord.AllowMultipleFiles, + FileFilter = inputRecord.FileFilter + }; + foreach (var option in options[(record.ResourceName, commandRecord.Ordinal, inputRecord.Ordinal)]) + { + input.Options.Add(option.OptionKey, option.OptionValue); + } + input.ValidationErrors.Add(validationErrors[(record.ResourceName, commandRecord.Ordinal, inputRecord.Ordinal)].Select(error => error.ValidationError)); + command.ArgumentInputs.Add(input); + } + resource.Commands.Add(command); } - input.ValidationErrors.Add(connection.Query(""" - SELECT validation_error - FROM dashboard_resource_command_input_validation_errors - WHERE resource_name = @ResourceName AND command_ordinal = @CommandOrdinal AND input_ordinal = @InputOrdinal - ORDER BY ordinal; - """, new { ResourceName = resourceName, CommandOrdinal = commandOrdinal, InputOrdinal = record.Ordinal })); - command.ArgumentInputs.Add(input); +#pragma warning restore CS0612 + + yield return new StoredResource(resource, record.ReplicaIndex); } } - private static Value LoadValue(SqliteConnection connection, long valueId) + private static Value MaterializeValue( + long valueId, + IReadOnlyDictionary values, + ILookup mapValues, + ILookup listValues) { - var record = connection.QuerySingle(""" - SELECT - value_id AS ValueId, - value_kind AS ValueKind, - string_value AS StringValue, - number_value AS NumberValue, - bool_value AS BoolValue - FROM dashboard_values - WHERE value_id = @ValueId; - """, new { ValueId = valueId }); - + var record = values[valueId]; var value = new Value(); switch ((Value.KindOneofCase)record.ValueKind) { @@ -664,27 +582,14 @@ FROM dashboard_values break; case Value.KindOneofCase.StructValue: value.StructValue = new Struct(); - foreach (var child in connection.Query(""" - SELECT map_key AS MapKey, child_value_id AS ChildValueId - FROM dashboard_value_map_entries - WHERE parent_value_id = @ValueId - ORDER BY ordinal; - """, new { ValueId = valueId })) + foreach (var child in mapValues[valueId]) { - value.StructValue.Fields.Add(child.MapKey, LoadValue(connection, child.ChildValueId)); + value.StructValue.Fields.Add(child.MapKey, MaterializeValue(child.ChildValueId, values, mapValues, listValues)); } break; case Value.KindOneofCase.ListValue: value.ListValue = new ListValue(); - foreach (var childValueId in connection.Query(""" - SELECT child_value_id - FROM dashboard_value_list_items - WHERE parent_value_id = @ValueId - ORDER BY ordinal; - """, new { ValueId = valueId })) - { - value.ListValue.Values.Add(LoadValue(connection, childValueId)); - } + value.ListValue.Values.Add(listValues[valueId].Select(child => MaterializeValue(child.ChildValueId, values, mapValues, listValues))); break; case Value.KindOneofCase.None: break; @@ -695,6 +600,38 @@ FROM dashboard_value_list_items return value; } + private static void SetOptionalResourceFields(Resource resource, ResourceRecord record) + { + if (record.State is not null) + { + resource.State = record.State; + } + if (record.CreatedAtSeconds is not null) + { + resource.CreatedAt = CreateTimestamp(record.CreatedAtSeconds.Value, record.CreatedAtNanos); + } + if (record.StateStyle is not null) + { + resource.StateStyle = record.StateStyle; + } + if (record.StartedAtSeconds is not null) + { + resource.StartedAt = CreateTimestamp(record.StartedAtSeconds.Value, record.StartedAtNanos); + } + if (record.StoppedAtSeconds is not null) + { + resource.StoppedAt = CreateTimestamp(record.StoppedAtSeconds.Value, record.StoppedAtNanos); + } + if (record.IconName is not null) + { + resource.IconName = record.IconName; + } + if (record.IconVariant is not null) + { + resource.IconVariant = (IconVariant)record.IconVariant.Value; + } + } + private static Timestamp CreateTimestamp(long seconds, int? nanos) { return new Timestamp { Seconds = seconds, Nanos = nanos ?? 0 }; @@ -725,6 +662,7 @@ private sealed class ResourceRecord private sealed class EnvironmentRecord { + public required string ResourceName { get; init; } public required string Name { get; init; } public string? Value { get; init; } public required bool IsFromSpec { get; init; } @@ -732,6 +670,7 @@ private sealed class EnvironmentRecord private sealed class UrlRecord { + public required string ResourceName { get; init; } public string? EndpointName { get; init; } public required string FullUrl { get; init; } public required bool IsInternal { get; init; } @@ -740,8 +679,18 @@ private sealed class UrlRecord public required string DisplayName { get; init; } } + private sealed class VolumeRecord + { + public required string ResourceName { get; init; } + public required string Source { get; init; } + public required string Target { get; init; } + public required string MountType { get; init; } + public required bool IsReadOnly { get; init; } + } + private sealed class HealthReportRecord { + public required string ResourceName { get; init; } public int? Status { get; init; } public required string Key { get; init; } public required string Description { get; init; } @@ -752,6 +701,7 @@ private sealed class HealthReportRecord private sealed class PropertyRecord { + public required string ResourceName { get; init; } public required string Name { get; init; } public string? DisplayName { get; init; } public required long ValueId { get; init; } @@ -762,6 +712,7 @@ private sealed class PropertyRecord private sealed class CommandRecord { + public required string ResourceName { get; init; } public required int Ordinal { get; init; } public required string Name { get; init; } public required string DisplayName { get; init; } @@ -776,6 +727,8 @@ private sealed class CommandRecord private sealed class InputRecord { + public required string ResourceName { get; init; } + public required int CommandOrdinal { get; init; } public required int Ordinal { get; init; } public required string Label { get; init; } public required string Placeholder { get; init; } @@ -797,10 +750,28 @@ private sealed class InputRecord private sealed class OptionRecord { + public required string ResourceName { get; init; } + public required int CommandOrdinal { get; init; } + public required int InputOrdinal { get; init; } public required string OptionKey { get; init; } public required string OptionValue { get; init; } } + private sealed class RelationshipRecord + { + public required string ResourceName { get; init; } + public required string RelatedResourceName { get; init; } + public required string RelationshipType { get; init; } + } + + private sealed class ValidationErrorRecord + { + public required string ResourceName { get; init; } + public required int CommandOrdinal { get; init; } + public required int InputOrdinal { get; init; } + public required string ValidationError { get; init; } + } + private sealed class ValueRecord { public required long ValueId { get; init; } @@ -812,7 +783,14 @@ private sealed class ValueRecord private sealed class MapValueRecord { + public required long ParentValueId { get; init; } public required string MapKey { get; init; } public required long ChildValueId { get; init; } } + + private sealed class ListValueRecord + { + public required long ParentValueId { get; init; } + public required long ChildValueId { get; init; } + } } \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs index 809656752fb..5a590f9f446 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs @@ -228,6 +228,28 @@ public void Resources_AllFieldsAndRecursiveValuesRoundTrip() Assert.Equal("ready", Assert.Single(actual.HealthReports).Name); } + [Fact] + public void Resources_BulkLoadKeepsChildRecordsIsolated() + { + var databasePath = Path.Combine(_temporaryDirectory, "multiple-resources.db"); + var resources = new[] + { + CreateResourceWithChildren("api", "API", "api-value"), + CreateResourceWithChildren("worker", "Worker", "worker-value") + }; + + using (var repository = CreateRepository(databasePath)) + { + ((IResourceRepositoryWriter)repository).ReplaceResources(resources); + } + + using var historicalRepository = CreateRepository(databasePath, readOnly: true); + var actualResources = historicalRepository.GetResources().OrderBy(resource => resource.Name).ToList(); + Assert.Collection(actualResources, + resource => AssertResourceChildren(resource, "api-value"), + resource => AssertResourceChildren(resource, "worker-value")); + } + [Fact] public void Schema_HasNoSerializedResourceColumns() { @@ -294,6 +316,29 @@ FROM sqlite_schema ], tableNames); } + [Fact] + public void Schema_TelemetryResourceInstanceIdUniquenessPreservesNullAndEmpty() + { + var databasePath = Path.Combine(_temporaryDirectory, "resource-instance-ids.db"); + using (CreateRepository(databasePath)) + { + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "INSERT INTO telemetry_resources (resource_name, instance_id) VALUES ('api', NULL);"; + command.ExecuteNonQuery(); + Assert.Throws(() => command.ExecuteNonQuery()); + + command.CommandText = "INSERT INTO telemetry_resources (resource_name, instance_id) VALUES ('api', '');"; + command.ExecuteNonQuery(); + Assert.Throws(() => command.ExecuteNonQuery()); + + command.CommandText = "SELECT COUNT(*) FROM telemetry_resources WHERE resource_name = 'api';"; + Assert.Equal(2L, command.ExecuteScalar()); + } + [Fact] public void Schema_AllDashboardTablesAreStrict() { @@ -342,6 +387,38 @@ private static Resource CreateResource(string name, string displayName) }; } + private static Resource CreateResourceWithChildren(string name, string displayName, string value) + { + var resource = CreateResource(name, displayName); + resource.Environment.Add(new EnvironmentVariable { Name = "VALUE", Value = value }); + resource.Properties.Add(new ResourceProperty { Name = "property", Value = Value.ForString(value) }); + resource.Commands.Add(new ResourceCommand + { + Name = "command", + DisplayName = "Command", + ArgumentInputs = + { + new InteractionInput + { + Name = "input", + Label = "Input", + Options = { [value] = value }, + ValidationErrors = { value } + } + } + }); + return resource; + } + + private static void AssertResourceChildren(global::Aspire.Dashboard.Model.ResourceViewModel resource, string expected) + { + Assert.Equal(expected, Assert.Single(resource.Environment).Value); + Assert.Equal(expected, resource.Properties["property"].Value.StringValue); + var input = Assert.Single(Assert.Single(resource.Commands).ArgumentInputs); + Assert.Equal(expected, input.Options[expected]); + Assert.Equal(expected, Assert.Single(input.ValidationErrors)); + } + private static void AssertResource(global::Aspire.Dashboard.Model.ResourceViewModel actual, Resource expected, int replicaIndex, string? state = null) { Assert.Equal(expected.Name, actual.Name); diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index 195e6f5f08e..3ff74648a2e 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -1095,6 +1095,96 @@ public void GetTraces_MultipleInstances() s => Assert.Equal("key-2", s)); } + [Fact] + public void AddTraces_MissingAndEmptyInstanceIdsAreDistinct() + { + var repository = CreateRepository(); + var missingInstanceIdResource = CreateResource(name: "resource", instanceId: "placeholder"); + missingInstanceIdResource.Attributes.Remove(missingInstanceIdResource.Attributes.Single(attribute => attribute.Key == OtlpResource.SERVICE_INSTANCE_ID)); + var addContext = new AddContext(); + + repository.AddTraces(addContext, new RepeatedField + { + new ResourceSpans + { + Resource = missingInstanceIdResource, + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = { CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)) } + } + } + }, + new ResourceSpans + { + Resource = CreateResource(name: "resource", instanceId: string.Empty), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = { CreateSpan(traceId: "2", spanId: "2-1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)) } + } + } + } + }); + + Assert.Equal(0, addContext.FailureCount); + var resources = repository.GetResources(); + Assert.Equal(2, resources.Count); + Assert.Contains(resources, resource => resource.ResourceKey == new ResourceKey("resource", InstanceId: null)); + Assert.Contains(resources, resource => resource.ResourceKey == new ResourceKey("resource", InstanceId: string.Empty)); + } + + [Fact] + public void GetTraceFieldValues_AllFieldsMatchMaterializedTraces() + { + var repository = CreateRepository(); + var addContext = new AddContext(); + repository.AddTraces(addContext, new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(3), attributes: [KeyValuePair.Create("custom", "one")], kind: Span.Types.SpanKind.Client), + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(5), parentSpanId: "1-1", attributes: [KeyValuePair.Create("custom", "two")], kind: Span.Types.SpanKind.Server) + } + } + } + } + }); + Assert.Equal(0, addContext.FailureCount); + + var resource = Assert.Single(repository.GetResources()); + var traces = repository.GetTraces(new GetTracesRequest + { + ResourceKeys = [resource.ResourceKey], + StartIndex = 0, + Count = 10, + Filters = [] + }).PagedResult.Items; + + foreach (var field in KnownTraceFields.AllFields.Append("custom")) + { + var expected = OtlpSpan.GetFieldValuesFromTraces(traces, field); + var actual = repository.GetTraceFieldValues(field); + Assert.Equal(expected.Count, actual.Count); + foreach (var (value, count) in expected) + { + Assert.Equal(count, actual[value]); + } + } + } + [Fact] public void GetTraces_AttributeFilters() { @@ -2109,6 +2199,10 @@ public void AddTraces_HaveUninstrumentedPeers() Assert.NotNull(s.UninstrumentedPeer); Assert.Equal("TestPeer", s.UninstrumentedPeer.ResourceName); }); + + var serviceNames = repository.GetTraceFieldValues(KnownResourceFields.ServiceNameField); + Assert.Equal(2, serviceNames["TestService"]); + Assert.Equal(1, serviceNames["TestPeer"]); } [Fact] From 2ddb14a8df7819d413317b43300fb410e30596fc Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 15 Jul 2026 12:27:15 +0800 Subject: [PATCH 09/87] Optimize telemetry field value queries --- src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs | 4 + .../Storage/SqliteTelemetryRepository.Logs.cs | 40 +++++--- .../SqliteTelemetryRepository.Traces.cs | 92 +++++++++++++------ .../TelemetryRepositoryTests/LogTests.cs | 56 +++++++++++ .../TelemetryRepositoryTests/TraceTests.cs | 3 + .../Telemetry/InMemoryTelemetryRepository.cs | 8 +- 6 files changed, 160 insertions(+), 43 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs index 2ec325d4e2e..cf285687e3a 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpSpan.cs @@ -249,6 +249,10 @@ public static FieldValues GetFieldValue(OtlpSpan span, string field) public static Dictionary GetFieldValuesFromTraces(IEnumerable traces, string attributeName) { var attributeValues = new Dictionary(StringComparers.OtlpAttribute); + if (attributeName is KnownTraceFields.DurationField or KnownTraceFields.TimestampField) + { + return attributeValues; + } foreach (var trace in traces) { diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs index fbe94783eb3..833cdb1ecad 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs @@ -409,17 +409,26 @@ FROM telemetry_log_attributes a private Dictionary GetLogsFieldValuesFromDatabase(string attributeName) { + if (attributeName == KnownStructuredLogFields.TimestampField) + { + return new Dictionary(StringComparers.OtlpAttribute); + } + using var connection = _database.OpenConnection(); var parameters = new DynamicParameters(); - var expression = GetLogFieldExpression(attributeName, parameters, "FieldValue"); + var expression = GetLogFieldExpression(attributeName, parameters, "FieldValue", coalesceMissing: false); return connection.Query($""" - SELECT {expression} AS FieldValue, COUNT(*) AS ValueCount - FROM telemetry_logs l - JOIN telemetry_resources r ON r.resource_id = l.resource_id - JOIN telemetry_scopes s ON s.scope_id = l.scope_id - GROUP BY {expression}; + WITH field_values AS ( + SELECT {expression} AS FieldValue + FROM telemetry_logs l + JOIN telemetry_resources r ON r.resource_id = l.resource_id + JOIN telemetry_scopes s ON s.scope_id = l.scope_id + ) + SELECT FieldValue, COUNT(*) AS ValueCount + FROM field_values + WHERE FieldValue IS NOT NULL + GROUP BY FieldValue; """, parameters) - .Where(record => record.FieldValue is not null) .ToDictionary(record => record.FieldValue!, record => record.ValueCount, StringComparers.OtlpAttribute); } @@ -528,34 +537,35 @@ private static string BuildLogFilterPredicate(FieldTelemetryFilter filter, Dynam return BuildStringPredicate(expression, filter.Condition, parameterName); } - private static string GetLogFieldExpression(string field, DynamicParameters parameters, string attributeParameterName) + private static string GetLogFieldExpression(string field, DynamicParameters parameters, string attributeParameterName, bool coalesceMissing = true) { return field switch { nameof(OtlpLogEntry.Message) or KnownStructuredLogFields.MessageField => "l.message", KnownStructuredLogFields.TraceIdField => "l.trace_id", KnownStructuredLogFields.SpanIdField => "l.span_id", - KnownStructuredLogFields.OriginalFormatField => "COALESCE(l.original_format, '')", + KnownStructuredLogFields.OriginalFormatField => coalesceMissing ? "COALESCE(l.original_format, '')" : "l.original_format", KnownStructuredLogFields.CategoryField => "s.scope_name", - KnownStructuredLogFields.EventNameField => "COALESCE(l.event_name, '')", + KnownStructuredLogFields.EventNameField => coalesceMissing ? "COALESCE(l.event_name, '')" : "l.event_name", KnownStructuredLogFields.LevelField => "l.severity_name", KnownStructuredLogFields.TimestampField => $"CAST(l.timestamp_ticks / {TimeSpan.TicksPerMillisecond} AS TEXT)", KnownResourceFields.ServiceNameField => "r.resource_name", - _ => GetAttributeExpression(field, parameters, attributeParameterName) + _ => GetAttributeExpression(field, parameters, attributeParameterName, coalesceMissing) }; - static string GetAttributeExpression(string field, DynamicParameters parameters, string parameterName) + static string GetAttributeExpression(string field, DynamicParameters parameters, string parameterName, bool coalesceMissing) { parameters.Add(parameterName, field); - return $""" - COALESCE(( + var expression = $""" + ( SELECT attribute.attribute_value FROM telemetry_log_attributes attribute WHERE attribute.log_id = l.log_id AND attribute.attribute_key = @{parameterName} LIMIT 1 - ), '') + ) """; + return coalesceMissing ? $"COALESCE({expression}, '')" : expression; } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs index e563029e983..58afc2258de 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs @@ -709,41 +709,81 @@ FROM telemetry_span_attributes a private Dictionary GetTraceFieldValuesFromDatabase(string attributeName) { + if (attributeName is KnownTraceFields.DurationField or KnownTraceFields.TimestampField) + { + return new Dictionary(StringComparers.OtlpAttribute); + } + using var connection = _database.OpenConnection(); - IEnumerable values = attributeName switch + IEnumerable values = attributeName switch { - KnownResourceFields.ServiceNameField => connection.Query(""" - SELECT r.resource_name - FROM telemetry_spans s - JOIN telemetry_resources r ON r.resource_id = s.resource_id - UNION ALL - SELECT r.resource_name - FROM telemetry_spans s - JOIN telemetry_resources r ON r.resource_id = s.uninstrumented_peer_resource_id; + KnownResourceFields.ServiceNameField => connection.Query(""" + SELECT resource_name AS FieldValue, COUNT(*) AS ValueCount + FROM ( + SELECT r.resource_name + FROM telemetry_spans s + JOIN telemetry_resources r ON r.resource_id = s.resource_id + UNION ALL + SELECT r.resource_name + FROM telemetry_spans s + JOIN telemetry_resources r ON r.resource_id = s.uninstrumented_peer_resource_id + ) + GROUP BY resource_name; + """), + KnownTraceFields.TraceIdField => QueryFieldValues("trace_id", "telemetry_spans"), + KnownTraceFields.SpanIdField => QueryFieldValues("span_id", "telemetry_spans"), + KnownTraceFields.KindField => connection.Query(""" + SELECT + CASE kind + WHEN 0 THEN 'Unspecified' + WHEN 1 THEN 'Internal' + WHEN 2 THEN 'Server' + WHEN 3 THEN 'Client' + WHEN 4 THEN 'Producer' + WHEN 5 THEN 'Consumer' + ELSE CAST(kind AS TEXT) + END AS FieldValue, + COUNT(*) AS ValueCount + FROM telemetry_spans + GROUP BY kind; + """), + KnownTraceFields.StatusField => connection.Query(""" + SELECT + CASE status + WHEN 0 THEN 'Unset' + WHEN 1 THEN 'Ok' + WHEN 2 THEN 'Error' + ELSE CAST(status AS TEXT) + END AS FieldValue, + COUNT(*) AS ValueCount + FROM telemetry_spans + GROUP BY status; """), - KnownTraceFields.TraceIdField => connection.Query("SELECT trace_id FROM telemetry_spans;"), - KnownTraceFields.SpanIdField => connection.Query("SELECT span_id FROM telemetry_spans;"), - KnownTraceFields.KindField => connection.Query("SELECT kind FROM telemetry_spans;").Select(kind => ((OtlpSpanKind)kind).ToString()), - KnownTraceFields.StatusField => connection.Query("SELECT status FROM telemetry_spans;").Select(status => ((OtlpSpanStatusCode)status).ToString()), - KnownSourceFields.NameField => connection.Query(""" - SELECT sc.scope_name + KnownSourceFields.NameField => connection.Query(""" + SELECT sc.scope_name AS FieldValue, COUNT(*) AS ValueCount FROM telemetry_spans s - JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id; + JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id + GROUP BY sc.scope_name; """), - KnownTraceFields.NameField => connection.Query("SELECT name FROM telemetry_spans;"), - KnownTraceFields.DurationField => connection.Query("SELECT end_time_ticks - start_time_ticks FROM telemetry_spans;") - .Select(ticks => TimeSpan.FromTicks(ticks).TotalMilliseconds.ToString("R", CultureInfo.InvariantCulture)), - KnownTraceFields.TimestampField => connection.Query("SELECT start_time_ticks FROM telemetry_spans;") - .Select(ticks => (ticks / TimeSpan.TicksPerMillisecond).ToString(CultureInfo.InvariantCulture)), - _ => connection.Query(""" - SELECT attribute_value + KnownTraceFields.NameField => QueryFieldValues("name", "telemetry_spans"), + _ => connection.Query(""" + SELECT attribute_value AS FieldValue, COUNT(*) AS ValueCount FROM telemetry_span_attributes - WHERE attribute_key = @AttributeName COLLATE ORDINAL_IGNORE_CASE; + WHERE attribute_key = @AttributeName COLLATE ORDINAL_IGNORE_CASE + GROUP BY attribute_value; """, new { AttributeName = attributeName }) }; - return values.GroupBy(value => value, StringComparers.OtlpAttribute) - .ToDictionary(group => group.Key, group => group.Count(), StringComparers.OtlpAttribute); + return values.ToDictionary(record => record.FieldValue!, record => record.ValueCount, StringComparers.OtlpAttribute); + + IEnumerable QueryFieldValues(string expression, string table) + { + return connection.Query($""" + SELECT {expression} AS FieldValue, COUNT(*) AS ValueCount + FROM {table} + GROUP BY {expression}; + """); + } } private bool HasUpdatedTraceInDatabase(OtlpTrace trace) diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs index 464873508b0..02e89290ee2 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs @@ -90,6 +90,62 @@ public void AddLogs() s => Assert.Equal("Log", s)); } + [Fact] + public void GetLogsFieldValues_AllFieldsMatchMaterializedLogs() + { + var repository = CreateRepository(); + var addContext = new AddContext(); + repository.AddLogs(addContext, new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = + { + CreateLogRecord(time: s_testTime, message: "Message", attributes: [KeyValuePair.Create("custom", "Value")], severity: SeverityNumber.Info, eventName: "Event"), + CreateLogRecord(time: s_testTime, message: "message", attributes: [KeyValuePair.Create("custom", "value")], severity: SeverityNumber.Info2) + } + } + } + } + }); + Assert.Equal(0, addContext.FailureCount); + + var logs = repository.GetLogs(new GetLogsContext + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + Filters = [] + }).Items; + + foreach (var field in KnownStructuredLogFields.AllFields + .Except([KnownStructuredLogFields.TimestampField]) + .Append(KnownStructuredLogFields.LevelField) + .Append("custom")) + { + var expected = logs + .Select(log => OtlpLogEntry.GetFieldValue(log, field)) + .Where(value => value is not null) + .GroupBy(value => value!, StringComparers.OtlpAttribute) + .ToDictionary(group => group.Key, group => group.Count(), StringComparers.OtlpAttribute); + var actual = repository.GetLogsFieldValues(field); + Assert.True(expected.Count == actual.Count, $"Field '{field}' expected {expected.Count} values but found {actual.Count}."); + foreach (var (value, count) in expected) + { + Assert.True(actual.TryGetValue(value, out var actualCount), $"Field '{field}' is missing value '{value}'."); + Assert.True(count == actualCount, $"Field '{field}' value '{value}' expected count {count} but found {actualCount}."); + } + } + + Assert.Empty(repository.GetLogsFieldValues(KnownStructuredLogFields.TimestampField)); + } + [Fact] public void AddLogs_NoBody_EmptyMessage() { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index 3ff74648a2e..9e006f6fdc5 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -1183,6 +1183,9 @@ public void GetTraceFieldValues_AllFieldsMatchMaterializedTraces() Assert.Equal(count, actual[value]); } } + + Assert.Empty(repository.GetTraceFieldValues(KnownTraceFields.DurationField)); + Assert.Empty(repository.GetTraceFieldValues(KnownTraceFields.TimestampField)); } [Fact] diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index 0b424cafe54..dd2cfce94b1 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -1538,9 +1538,13 @@ public Dictionary GetTraceFieldValues(string attributeName) public Dictionary GetLogsFieldValues(string attributeName) { - _logsLock.EnterReadLock(); - var attributesValues = new Dictionary(StringComparers.OtlpAttribute); + if (attributeName == KnownStructuredLogFields.TimestampField) + { + return attributesValues; + } + + _logsLock.EnterReadLock(); try { From 05d0b77ebf6f7ac872950ef5930649e822a959b6 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 15 Jul 2026 12:50:43 +0800 Subject: [PATCH 10/87] Address dashboard persistence review feedback --- .../Components/Pages/ConsoleLogs.razor.cs | 3 +- .../Storage/SqliteTelemetryRepository.Logs.cs | 46 +++++++- .../SqliteTelemetryRepository.Traces.cs | 2 +- .../ServiceClient/DashboardSqliteDatabase.cs | 8 +- .../ServiceClient/DatabaseSchema/003.Logs.sql | 3 + .../Dashboard/DashboardEventHandlers.cs | 3 +- .../Pages/ConsoleLogsTerminalTests.cs | 42 +++++++ .../Model/DashboardDataSourceTests.cs | 17 +++ .../SqliteTelemetryPersistenceTests.cs | 108 ++++++++++++++++++ .../Dashboard/DashboardEventHandlersTests.cs | 2 + 10 files changed, 228 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index a4da52dd92f..a25d0833a74 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -494,7 +494,8 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa // on selection. The user picks Terminal explicitly from the ⋯ menu. _activeView = ConsoleLogsView.Console; - if (!isAllSelected && selectedResourceName is not null && + if (!DashboardClient.IsReadOnly && + !isAllSelected && selectedResourceName is not null && _resourceByName.TryGetValue(selectedResourceName, out var selectedResource) && selectedResource.HasTerminal() && selectedResource.TryGetTerminalReplicaInfo(out var replicaIndex, out _)) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs index 833cdb1ecad..7ca7125d908 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs @@ -39,7 +39,7 @@ private List AddLogsToDatabase(AddContext context, RepeatedField[] properties) { + var parameters = new DynamicParameters(); + parameters.Add("ResourceId", resourceId); + parameters.Add("PropertyCount", properties.Length); + var sql = new StringBuilder(""" + SELECT v.resource_view_id + FROM telemetry_resource_views v + WHERE v.resource_id = @ResourceId + AND (SELECT COUNT(*) FROM telemetry_resource_view_attributes a WHERE a.resource_view_id = v.resource_view_id) = @PropertyCount + """); + for (var index = 0; index < properties.Length; index++) + { + parameters.Add($"PropertyKey{index}", properties[index].Key); + parameters.Add($"PropertyValue{index}", properties[index].Value); + sql.Append(CultureInfo.InvariantCulture, $""" + + AND EXISTS ( + SELECT 1 + FROM telemetry_resource_view_attributes a + WHERE a.resource_view_id = v.resource_view_id + AND a.ordinal = {index} + AND a.attribute_key = @PropertyKey{index} + AND a.attribute_value = @PropertyValue{index} + ) + """); + } + sql.Append(" LIMIT 1;"); + + var existingResourceViewId = connection.QuerySingleOrDefault(sql.ToString(), parameters, transaction); + if (existingResourceViewId is not null) + { + return existingResourceViewId.Value; + } + + var resourceViewCount = connection.QuerySingle( + "SELECT COUNT(*) FROM telemetry_resource_views WHERE resource_id = @ResourceId;", + new { ResourceId = resourceId }, + transaction); + if (resourceViewCount >= TelemetryRepositoryLimits.MaxResourceViewCount) + { + throw new InvalidOperationException($"Resource view limit of {TelemetryRepositoryLimits.MaxResourceViewCount} reached."); + } + var resourceViewId = connection.QuerySingle(""" INSERT INTO telemetry_resource_views (resource_id) VALUES (@ResourceId) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs index 58afc2258de..2fc33fe5ac1 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs @@ -39,7 +39,7 @@ private List AddTracesToDatabase(AddContext context, RepeatedField("SELECT version FROM dashboard_schema;"); + var version = connection.QuerySingleOrDefault(""" + SELECT CASE + WHEN COUNT(*) = 1 AND typeof(MAX(version)) = 'integer' THEN MAX(version) + ELSE NULL + END + FROM dashboard_schema; + """); return version == SchemaVersion; } catch (SqliteException) diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql index f6f354a19df..ff13c31d34f 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql @@ -23,6 +23,9 @@ CREATE TABLE IF NOT EXISTS telemetry_resource_views ( resource_id INTEGER NOT NULL REFERENCES telemetry_resources(resource_id) ON DELETE CASCADE ) STRICT; +CREATE INDEX IF NOT EXISTS ix_telemetry_resource_views_resource + ON telemetry_resource_views(resource_id); + CREATE TABLE IF NOT EXISTS telemetry_resource_view_attributes ( resource_view_id INTEGER NOT NULL REFERENCES telemetry_resource_views(resource_view_id) ON DELETE CASCADE, ordinal INTEGER NOT NULL, diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 4fcf2bbc714..7edac4115bf 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -607,8 +607,9 @@ internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext con { context.EnvironmentVariables[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] = Path.Combine(aspireStorePath, ".aspire", "dashboard"); } + var persistenceMode = configuration["Aspire:Dashboard:PersistenceMode"]; context.EnvironmentVariables[DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = - configuration["Aspire:Dashboard:PersistenceMode"] ?? "Runs"; + string.IsNullOrWhiteSpace(persistenceMode) ? "Runs" : persistenceMode; PopulateDashboardUrls(context); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs index 5307cd1e345..ee114673f1c 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs @@ -86,6 +86,48 @@ public async Task TerminalResource_Selected_RendersBothViews_DefaultsToConsole() await Task.CompletedTask; } + [Fact] + public async Task HistoricalTerminalResource_Selected_DoesNotRenderTerminalView() + { + var consoleLogsChannel = Channel.CreateUnbounded>(); + var resourceChannel = Channel.CreateUnbounded>(); + var subscriptionStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var terminalResource = CreateTerminalResource("terminal-resource", replicaIndex: 0, replicaCount: 1); + var dashboardClient = new TestDashboardClient( + isEnabled: true, + isReadOnly: true, + consoleLogsChannelProvider: _ => + { + subscriptionStarted.TrySetResult(); + return consoleLogsChannel; + }, + resourceChannelProvider: () => resourceChannel, + initialResources: [terminalResource]); + + SetupConsoleLogsServices(dashboardClient); + SetupTerminalViewJsInterop(); + + var navigationManager = Services.GetRequiredService(); + navigationManager.NavigateTo(DashboardUrls.ConsoleLogsUrl(resource: "terminal-resource")); + + var dimensionManager = Services.GetRequiredService(); + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + dimensionManager.InvokeOnViewportInformationChanged(viewport); + + var cut = RenderComponent(builder => + { + builder.Add(p => p.ResourceName, "terminal-resource"); + builder.Add(p => p.ViewportInformation, viewport); + }); + + await subscriptionStarted.Task.WaitAsync(DefaultWaitTimeout); + cut.WaitForAssertion(() => + { + Assert.Empty(cut.FindComponents()); + Assert.Single(cut.FindComponents()); + }); + } + [Fact] public async Task SwitchingFromTerminalToNonTerminalResource_TearsDownTerminalView() { diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index 200ec4d16eb..376a2cc2379 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -101,6 +101,23 @@ public void AppendMode_DeletesIncompatibleDatabase() Assert.True(DashboardSqliteDatabase.IsCompatible(databasePath)); } + [Theory] + [InlineData("CREATE TABLE dashboard_schema (version INTEGER NOT NULL); INSERT INTO dashboard_schema VALUES (7), (7);")] + [InlineData("CREATE TABLE dashboard_schema (version); INSERT INTO dashboard_schema VALUES ('invalid');")] + public void IsCompatible_ReturnsFalseForMalformedSchema(string schemaSql) + { + var databasePath = Path.Combine(_temporaryDirectory, $"malformed-{Guid.NewGuid():N}.db"); + using (var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False")) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = schemaSql; + command.ExecuteNonQuery(); + } + + Assert.False(DashboardSqliteDatabase.IsCompatible(databasePath)); + } + [Fact] public void ApplicationDirectoryName_IsSafeBoundedAndUnique() { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs index 5247bbfaba7..e7c945401c3 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs @@ -277,6 +277,114 @@ FROM sqlite_schema Assert.Equal(0L, command.ExecuteScalar()); } + [Fact] + public void ResourceViews_EquivalentAttributesShareNormalizedRows() + { + var databasePath = Path.Combine(_temporaryDirectory, "resource-views.db"); + using (var repository = CreateRepository(databasePath)) + { + var addContext = new AddContext(); + repository.AddLogs(addContext, new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(attributes: [KeyValuePair.Create("second", "2"), KeyValuePair.Create("first", "1")]), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope(), + LogRecords = { CreateLogRecord() } + } + } + }, + new ResourceLogs + { + Resource = CreateResource(attributes: [KeyValuePair.Create("first", "1"), KeyValuePair.Create("second", "2")]), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope(), + LogRecords = { CreateLogRecord() } + } + } + } + }); + Assert.Equal(0, addContext.FailureCount); + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM telemetry_resource_views;"; + Assert.Equal(1L, command.ExecuteScalar()); + command.CommandText = "SELECT COUNT(*) FROM telemetry_resource_view_attributes;"; + Assert.Equal(2L, command.ExecuteScalar()); + } + + [Fact] + public void ResourceViews_LimitRejectsNewNormalizedRow() + { + var databasePath = Path.Combine(_temporaryDirectory, "resource-view-limit.db"); + using var repository = CreateRepository(databasePath); + var addContext = new AddContext(); + repository.AddLogs(addContext, new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope(), + LogRecords = { CreateLogRecord() } + } + } + } + }); + Assert.Equal(0, addContext.FailureCount); + + using (var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False")) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = $""" + WITH RECURSIVE numbers(value) AS ( + SELECT 1 + UNION ALL + SELECT value + 1 FROM numbers WHERE value < {TelemetryRepositoryLimits.MaxResourceViewCount - 1} + ) + INSERT INTO telemetry_resource_views (resource_id) + SELECT resource_id + FROM telemetry_resources + CROSS JOIN numbers; + """; + command.ExecuteNonQuery(); + command.CommandText = "SELECT COUNT(*) FROM telemetry_resource_views;"; + Assert.Equal((long)TelemetryRepositoryLimits.MaxResourceViewCount, command.ExecuteScalar()); + } + + repository.AddLogs(addContext, new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(attributes: [KeyValuePair.Create("new", "value")]), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope(), + LogRecords = { CreateLogRecord() } + } + } + } + }); + + Assert.Equal(1, addContext.FailureCount); + } + [Fact] public void Scopes_AreDeletedAfterTheirFinalOwnerIsCleared() { diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs index c6157f9f2d4..bfd6480b49d 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs @@ -310,6 +310,8 @@ public async Task ConfigureEnvironmentVariables_HasAspireDashboardEnvVars_Copied [Theory] [InlineData(null, "Runs")] + [InlineData("", "Runs")] + [InlineData(" ", "Runs")] [InlineData("Runs", "Runs")] public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRootApplicationNameAndPersistenceMode( string? configuredPersistenceMode, From 9c89b70a75e464c0030b6e9e597b844d17ae8df2 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 15 Jul 2026 13:21:09 +0800 Subject: [PATCH 11/87] Handle in-use dashboard run directories --- .../ServiceClient/DashboardRunStore.cs | 30 ++++++-- .../Model/DashboardDataSourceTests.cs | 70 +++++++++++++++---- 2 files changed, 82 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index 6f8ff38437e..4bee993d467 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -27,14 +27,26 @@ internal sealed class DashboardRunStore : IDashboardRunStore, IDisposable private readonly string? _metadataPath; private readonly string? _temporaryDirectory; private readonly DashboardRunMetadata _metadata; + private readonly ILogger _logger; - public DashboardRunStore(IOptions options) + public DashboardRunStore(IOptions options, ILogger logger) + : this(options, logger, static directory => Directory.Delete(directory, recursive: true)) { + } + + internal DashboardRunStore( + IOptions options, + ILogger logger, + Action deleteRunDirectory) + { + _logger = logger; var applicationName = string.IsNullOrWhiteSpace(options.Value.ApplicationName) ? "Aspire" : options.Value.ApplicationName; var startedAt = DateTimeOffset.UtcNow; var runId = $"{startedAt:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}"; PersistenceMode = options.Value.Data.PersistenceMode; + // Persistent run directories should be located under a directory scoped to the current user. Rely on that + // directory's inherited permissions instead of modifying the SQLite database, WAL, and shared-memory files individually. switch (PersistenceMode) { case DashboardPersistenceMode.None: @@ -74,7 +86,7 @@ public DashboardRunStore(IOptions options) if (_metadataPath is not null) { WriteMetadata(_metadata); - PruneRuns(); + PruneRuns(deleteRunDirectory); } } @@ -142,7 +154,7 @@ private void WriteMetadata(DashboardRunMetadata metadata) File.WriteAllText(_metadataPath!, JsonSerializer.Serialize(metadata, s_jsonOptions)); } - private void PruneRuns() + private void PruneRuns(Action deleteRunDirectory) { // Run directory names start with a fixed-width UTC timestamp, so ordinal ordering matches creation order. var expiredRunDirectories = Directory.EnumerateDirectories(_runsDirectory!) @@ -152,7 +164,17 @@ private void PruneRuns() foreach (var directory in expiredRunDirectories) { - Directory.Delete(directory, recursive: true); + try + { + deleteRunDirectory(directory); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + _logger.LogWarning( + exception, + "Failed to delete expired dashboard run directory '{RunDirectory}'. The directory may still be in use by another dashboard process.", + directory); + } } } diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index 376a2cc2379..0d215b6248d 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -10,7 +10,9 @@ using Google.Protobuf.WellKnownTypes; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Options; using OpenTelemetry.Proto.Logs.V1; using Xunit; @@ -27,7 +29,7 @@ public void RunDirectory_IsNestedUnderApplicationDirectoryAndRuns() { var options = CreateOptions("My Dashboard"); - using var runStore = new DashboardRunStore(options); + using var runStore = CreateRunStore(options); var applicationDirectoryName = DashboardRunStore.GetApplicationDirectoryName("My Dashboard"); var expectedRunsDirectory = Path.Combine(_temporaryDirectory, applicationDirectoryName, "runs"); @@ -41,7 +43,7 @@ public void NoneMode_UsesTemporaryDatabaseAndDeletesItOnDispose() string runDirectory; string databasePath; - using (var runStore = new DashboardRunStore(options)) + using (var runStore = CreateRunStore(options)) { runDirectory = runStore.RunDirectory; databasePath = runStore.DatabasePath; @@ -63,13 +65,13 @@ public void AppendMode_ReusesApplicationDatabaseWithoutRunSelection() var options = CreateOptions("My Dashboard", DashboardPersistenceMode.Append); string firstDatabasePath; - using (var firstRunStore = new DashboardRunStore(options)) + using (var firstRunStore = CreateRunStore(options)) { firstDatabasePath = firstRunStore.DatabasePath; new DashboardSqliteDatabase(firstDatabasePath).InitializeSchema(); } - using var secondRunStore = new DashboardRunStore(options); + using var secondRunStore = CreateRunStore(options); Assert.Equal(firstDatabasePath, secondRunStore.DatabasePath); Assert.False(secondRunStore.SupportsRunSelection); @@ -83,7 +85,7 @@ public void AppendMode_DeletesIncompatibleDatabase() var options = CreateOptions(persistenceMode: DashboardPersistenceMode.Append); string databasePath; - using (var firstRunStore = new DashboardRunStore(options)) + using (var firstRunStore = CreateRunStore(options)) { databasePath = firstRunStore.DatabasePath; using var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False"); @@ -93,7 +95,7 @@ public void AppendMode_DeletesIncompatibleDatabase() command.ExecuteNonQuery(); } - using var secondRunStore = new DashboardRunStore(options); + using var secondRunStore = CreateRunStore(options); Assert.Equal(databasePath, secondRunStore.DatabasePath); Assert.False(File.Exists(databasePath)); @@ -141,13 +143,13 @@ public void GetRuns_ReturnsCurrentThenCompletedHistoricalRun() var options = CreateOptions(); string historicalRunId; - using (var historicalRunStore = new DashboardRunStore(options)) + using (var historicalRunStore = CreateRunStore(options)) { historicalRunId = historicalRunStore.RunId; using var telemetryRepository = CreateTelemetryRepository(historicalRunStore.DatabasePath, options); } - using var currentRunStore = new DashboardRunStore(options); + using var currentRunStore = CreateRunStore(options); using var currentTelemetryRepository = CreateTelemetryRepository(currentRunStore.DatabasePath, options); Assert.Collection( @@ -182,7 +184,7 @@ public void RunsMode_DeletesOldestRunWhenLimitIsExceeded() Directory.CreateDirectory(directory); } - using var currentRunStore = new DashboardRunStore(CreateOptions()); + using var currentRunStore = CreateRunStore(CreateOptions()); Assert.Equal(DashboardRunStore.MaxRuns, Directory.GetDirectories(runsDirectory).Length); Assert.False(Directory.Exists(historicalRunDirectories[^1])); @@ -190,13 +192,48 @@ public void RunsMode_DeletesOldestRunWhenLimitIsExceeded() Assert.True(Directory.Exists(currentRunStore.RunDirectory)); } + [Fact] + public void RunsMode_DeleteExpiredRunFails_LogsWarningAndContinues() + { + var applicationDirectory = Path.Combine(_temporaryDirectory, DashboardRunStore.GetApplicationDirectoryName("TestApp")); + var runsDirectory = Path.Combine(applicationDirectory, "runs"); + var historicalRunDirectories = Enumerable.Range(1, DashboardRunStore.MaxRuns) + .Select(index => Path.Combine( + runsDirectory, + $"{DateTimeOffset.UtcNow.AddDays(-index):yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}")) + .ToList(); + + foreach (var directory in historicalRunDirectories) + { + Directory.CreateDirectory(directory); + } + + var testSink = new TestSink(); + using var loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(new TestLoggerProvider(testSink))); + var logger = loggerFactory.CreateLogger(); + var expiredRunDirectory = historicalRunDirectories[^1]; + + using var currentRunStore = new DashboardRunStore( + CreateOptions(), + logger, + directory => throw new IOException($"The directory '{directory}' is in use.")); + + var warning = Assert.Single(testSink.Writes); + Assert.Equal(LogLevel.Warning, warning.LogLevel); + Assert.Equal(typeof(DashboardRunStore).FullName, warning.LoggerName); + Assert.Contains(expiredRunDirectory, warning.Message, StringComparison.Ordinal); + Assert.IsType(warning.Exception); + Assert.True(Directory.Exists(expiredRunDirectory)); + Assert.True(Directory.Exists(currentRunStore.RunDirectory)); + } + [Fact] public void GetRuns_ExcludesIncompatibleDatabaseWithoutDeletingIt() { var options = CreateOptions(); string incompatibleDatabasePath; - using (var incompatibleRunStore = new DashboardRunStore(options)) + using (var incompatibleRunStore = CreateRunStore(options)) { incompatibleDatabasePath = incompatibleRunStore.DatabasePath; using var connection = new SqliteConnection($"Data Source={incompatibleDatabasePath};Pooling=False"); @@ -206,7 +243,7 @@ public void GetRuns_ExcludesIncompatibleDatabaseWithoutDeletingIt() command.ExecuteNonQuery(); } - using var currentRunStore = new DashboardRunStore(options); + using var currentRunStore = CreateRunStore(options); Assert.Collection(currentRunStore.GetRuns(), run => Assert.True(run.IsCurrent)); Assert.True(File.Exists(incompatibleDatabasePath)); @@ -240,7 +277,7 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() var options = CreateOptions(); string historicalRunId; - using (var historicalRunStore = new DashboardRunStore(options)) + using (var historicalRunStore = CreateRunStore(options)) { historicalRunId = historicalRunStore.RunId; using var telemetryRepository = CreateTelemetryRepository(historicalRunStore.DatabasePath, options); @@ -269,7 +306,7 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() }]); } - using var currentRunStore = new DashboardRunStore(options); + using var currentRunStore = CreateRunStore(options); using var currentTelemetryRepository = CreateTelemetryRepository(currentRunStore.DatabasePath, options); using var currentResourceRepository = CreateResourceRepository(currentRunStore.DatabasePath); using var dataSource = CreateDataSource( @@ -316,7 +353,7 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() public void UnknownRunId_SelectsCurrentRun() { var options = CreateOptions(); - using var currentRunStore = new DashboardRunStore(options); + using var currentRunStore = CreateRunStore(options); using var currentTelemetryRepository = CreateTelemetryRepository(currentRunStore.DatabasePath, options); using var currentResourceRepository = CreateResourceRepository(currentRunStore.DatabasePath); using var dataSource = CreateDataSource( @@ -363,6 +400,11 @@ private static SqliteResourceRepository CreateResourceRepository(string database return new SqliteResourceRepository(databasePath, new MockKnownPropertyLookup(), NullLoggerFactory.Instance); } + private static DashboardRunStore CreateRunStore(IOptions options) + { + return new DashboardRunStore(options, NullLogger.Instance); + } + private static DashboardDataSource CreateDataSource( DashboardRunStore runStore, SqliteTelemetryRepository telemetryRepository, From 0c4847a11426f2e1a408d0a663b67620e44b47c5 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 15 Jul 2026 13:47:43 +0800 Subject: [PATCH 12/87] Use native SQLite string matching --- .../Storage/SqliteTelemetryRepository.Logs.cs | 54 +++++----- .../SqliteTelemetryRepository.Metrics.cs | 12 +-- .../SqliteTelemetryRepository.Traces.cs | 101 ++++++++++-------- .../Otlp/Storage/SqliteTelemetryRepository.cs | 12 +++ .../ServiceClient/DashboardSqliteDatabase.cs | 14 --- .../ServiceClient/DatabaseSchema/003.Logs.sql | 2 +- .../DatabaseSchema/004.Traces.sql | 2 +- .../Model/DashboardDataSourceTests.cs | 8 +- .../TelemetryRepositoryTests/LogTests.cs | 42 +++++++- 9 files changed, 149 insertions(+), 98 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs index 7ca7125d908..85536769695 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs @@ -437,11 +437,11 @@ FROM telemetry_log_attributes a var parameters = new DynamicParameters(); if (resourceKey is not null) { - sql.Append(" WHERE r.resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE"); + sql.Append(" WHERE r.resource_name = @ResourceName COLLATE NOCASE"); parameters.Add("ResourceName", resourceKey.Value.Name); if (resourceKey.Value.InstanceId is not null) { - sql.Append(" AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"); + sql.Append(" AND r.instance_id = @InstanceId COLLATE NOCASE"); parameters.Add("InstanceId", resourceKey.Value.InstanceId); } } @@ -484,11 +484,11 @@ private static LogQuery BuildLogQuery(GetLogsContext context) for (var i = 0; i < context.ResourceKeys.Count; i++) { var key = context.ResourceKeys[i]; - var predicate = $"r.resource_name = @ResourceName{i} COLLATE ORDINAL_IGNORE_CASE"; + var predicate = $"r.resource_name = @ResourceName{i} COLLATE NOCASE"; parameters.Add($"ResourceName{i}", key.Name); if (key.InstanceId is not null) { - predicate += $" AND r.instance_id = @InstanceId{i} COLLATE ORDINAL_IGNORE_CASE"; + predicate += $" AND r.instance_id = @InstanceId{i} COLLATE NOCASE"; parameters.Add($"InstanceId{i}", key.InstanceId); } resourcePredicates.Add($"({predicate})"); @@ -511,23 +511,23 @@ private static LogQuery BuildLogQuery(GetLogsContext context) for (var i = 0; i < context.TextFragments.Length; i++) { var parameterName = $"TextFragment{i}"; - parameters.Add(parameterName, context.TextFragments[i]); + parameters.Add(parameterName, CreateContainsLikePattern(context.TextFragments[i])); predicates.Add($$""" ( - ordinal_contains(l.message, @{{parameterName}}) - OR ordinal_contains(s.scope_name, @{{parameterName}}) - OR ordinal_contains(l.trace_id, @{{parameterName}}) - OR ordinal_contains(l.span_id, @{{parameterName}}) - OR ordinal_contains(l.severity_name, @{{parameterName}}) - OR ordinal_contains(r.resource_name, @{{parameterName}}) - OR ordinal_contains(COALESCE(l.event_name, ''), @{{parameterName}}) + l.message LIKE @{{parameterName}} ESCAPE '!' + OR s.scope_name LIKE @{{parameterName}} ESCAPE '!' + OR l.trace_id LIKE @{{parameterName}} ESCAPE '!' + OR l.span_id LIKE @{{parameterName}} ESCAPE '!' + OR l.severity_name LIKE @{{parameterName}} ESCAPE '!' + OR r.resource_name LIKE @{{parameterName}} ESCAPE '!' + OR COALESCE(l.event_name, '') LIKE @{{parameterName}} ESCAPE '!' OR EXISTS ( SELECT 1 FROM telemetry_log_attributes text_attribute WHERE text_attribute.log_id = l.log_id AND ( - ordinal_contains(text_attribute.attribute_key, @{{parameterName}}) - OR ordinal_contains(text_attribute.attribute_value, @{{parameterName}}) + text_attribute.attribute_key LIKE @{{parameterName}} ESCAPE '!' + OR text_attribute.attribute_value LIKE @{{parameterName}} ESCAPE '!' ) ) ) @@ -575,7 +575,11 @@ private static string BuildLogFilterPredicate(FieldTelemetryFilter filter, Dynam } var expression = GetLogFieldExpression(filter.Field, parameters, $"AttributeName{index}"); - parameters.Add(parameterName, filter.Value); + parameters.Add( + parameterName, + filter.Condition is FilterCondition.Contains or FilterCondition.NotContains + ? CreateContainsLikePattern(filter.Value) + : filter.Value); return BuildStringPredicate(expression, filter.Condition, parameterName); } @@ -615,11 +619,11 @@ private static string BuildStringPredicate(string expression, FilterCondition co { return condition switch { - FilterCondition.Equals => $"{expression} = @{parameterName} COLLATE ORDINAL_IGNORE_CASE", - FilterCondition.Contains => $"ordinal_contains({expression}, @{parameterName})", + FilterCondition.Equals => $"{expression} = @{parameterName} COLLATE NOCASE", + FilterCondition.Contains => $"{expression} LIKE @{parameterName} ESCAPE '!'", FilterCondition.GreaterThan or FilterCondition.LessThan or FilterCondition.GreaterThanOrEqual or FilterCondition.LessThanOrEqual => "0 = 1", - FilterCondition.NotEqual => $"{expression} <> @{parameterName} COLLATE ORDINAL_IGNORE_CASE", - FilterCondition.NotContains => $"NOT ordinal_contains({expression}, @{parameterName})", + FilterCondition.NotEqual => $"{expression} <> @{parameterName} COLLATE NOCASE", + FilterCondition.NotContains => $"{expression} NOT LIKE @{parameterName} ESCAPE '!'", _ => throw new ArgumentOutOfRangeException(nameof(condition), condition, null) }; } @@ -763,13 +767,13 @@ private void DeleteTelemetryResourceFromDatabase(ResourceKey resourceKey) using var transaction = connection.BeginTransaction(); var sql = new StringBuilder(""" DELETE FROM telemetry_resources - WHERE resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE + WHERE resource_name = @ResourceName COLLATE NOCASE """); var parameters = new DynamicParameters(); parameters.Add("ResourceName", resourceKey.Name); if (resourceKey.InstanceId is not null) { - sql.Append(" AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"); + sql.Append(" AND instance_id = @InstanceId COLLATE NOCASE"); parameters.Add("InstanceId", resourceKey.InstanceId); } sql.Append(';'); @@ -795,11 +799,11 @@ private void ClearStructuredLogsFromDatabase(ResourceKey? resourceKey) var where = string.Empty; if (resourceKey is not null) { - where = " WHERE resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE"; + where = " WHERE resource_name = @ResourceName COLLATE NOCASE"; parameters.Add("ResourceName", resourceKey.Value.Name); if (resourceKey.Value.InstanceId is not null) { - where += " AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; + where += " AND instance_id = @InstanceId COLLATE NOCASE"; parameters.Add("InstanceId", resourceKey.Value.InstanceId); } } @@ -880,8 +884,8 @@ private List GetResourceViewsFromDatabase(ResourceKey resource SELECT v.resource_view_id FROM telemetry_resource_views v JOIN telemetry_resources r ON r.resource_id = v.resource_id - WHERE r.resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE - AND (@InstanceId IS NULL OR r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE) + WHERE r.resource_name = @ResourceName COLLATE NOCASE + AND (@InstanceId IS NULL OR r.instance_id = @InstanceId COLLATE NOCASE) ORDER BY v.resource_view_id; """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId }).AsList(); var attributes = connection.Query(""" diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs index 8cf3f0240b9..2bd6fc266bd 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs @@ -500,8 +500,8 @@ FROM telemetry_metric_points p JOIN telemetry_metric_instruments i ON i.instrument_id = d.instrument_id JOIN telemetry_resources r ON r.resource_id = i.resource_id JOIN telemetry_scopes s ON s.scope_id = i.scope_id - WHERE r.resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE - AND (@InstanceId IS NULL OR r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE) + WHERE r.resource_name = @ResourceName COLLATE NOCASE + AND (@InstanceId IS NULL OR r.instance_id = @InstanceId COLLATE NOCASE) AND s.scope_name = @MeterName AND i.instrument_name = @InstrumentName; """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId, MeterName = meterName, InstrumentName = instrumentName }); @@ -564,8 +564,8 @@ i.has_overflow AS HasOverflow FROM telemetry_metric_instruments i JOIN telemetry_resources r ON r.resource_id = i.resource_id JOIN telemetry_scopes s ON s.scope_id = i.scope_id - WHERE r.resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE - AND (@InstanceId IS NULL OR r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE) + WHERE r.resource_name = @ResourceName COLLATE NOCASE + AND (@InstanceId IS NULL OR r.instance_id = @InstanceId COLLATE NOCASE) AND (@MeterName IS NULL OR s.scope_name = @MeterName) AND (@InstrumentName IS NULL OR i.instrument_name = @InstrumentName) ORDER BY i.instrument_id; @@ -731,11 +731,11 @@ private void ClearMetricsFromDatabase(ResourceKey? resourceKey) var where = string.Empty; if (resourceKey is not null) { - where = " WHERE resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE"; + where = " WHERE resource_name = @ResourceName COLLATE NOCASE"; parameters.Add("ResourceName", resourceKey.Value.Name); if (resourceKey.Value.InstanceId is not null) { - where += " AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; + where += " AND instance_id = @InstanceId COLLATE NOCASE"; parameters.Add("InstanceId", resourceKey.Value.InstanceId); } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs index 2fc33fe5ac1..ce598436139 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs @@ -393,13 +393,13 @@ private static TraceQuery BuildTraceQuery(GetTracesRequest context) { var key = context.ResourceKeys[index]; parameters.Add($"ResourceName{index}", key.Name); - var sourcePredicate = $"r.resource_name = @ResourceName{index} COLLATE ORDINAL_IGNORE_CASE"; - var peerPredicate = $"pr.resource_name = @ResourceName{index} COLLATE ORDINAL_IGNORE_CASE"; + var sourcePredicate = $"r.resource_name = @ResourceName{index} COLLATE NOCASE"; + var peerPredicate = $"pr.resource_name = @ResourceName{index} COLLATE NOCASE"; if (key.InstanceId is not null) { parameters.Add($"InstanceId{index}", key.InstanceId); - sourcePredicate += $" AND r.instance_id = @InstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; - peerPredicate += $" AND pr.instance_id = @InstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; + sourcePredicate += $" AND r.instance_id = @InstanceId{index} COLLATE NOCASE"; + peerPredicate += $" AND pr.instance_id = @InstanceId{index} COLLATE NOCASE"; } resourcePredicates.Add($"(({sourcePredicate}) OR ({peerPredicate}))"); } @@ -409,8 +409,8 @@ private static TraceQuery BuildTraceQuery(GetTracesRequest context) } if (!string.IsNullOrWhiteSpace(context.TraceNameFilterText)) { - sql.Append(" AND ordinal_contains(t.full_name, @TraceNameFilterText)"); - parameters.Add("TraceNameFilterText", context.TraceNameFilterText); + sql.Append(" AND t.full_name LIKE @TraceNameFilterText ESCAPE '!'"); + parameters.Add("TraceNameFilterText", CreateContainsLikePattern(context.TraceNameFilterText)); } var positivePredicates = new List(); @@ -454,20 +454,20 @@ private static TraceQuery BuildTraceQuery(GetTracesRequest context) for (var index = 0; index < context.TextFragments.Length; index++) { var parameterName = $"TextFragment{index}"; - parameters.Add(parameterName, context.TextFragments[index]); - fullNamePredicates.Add($"ordinal_contains(t.full_name, @{parameterName})"); + parameters.Add(parameterName, CreateContainsLikePattern(context.TextFragments[index])); + fullNamePredicates.Add($"t.full_name LIKE @{parameterName} ESCAPE '!'"); spanPredicates.Add($""" ( - ordinal_contains(s.name, @{parameterName}) OR - ordinal_contains(s.span_id, @{parameterName}) OR - ordinal_contains(s.trace_id, @{parameterName}) OR - ordinal_contains(sc.scope_name, @{parameterName}) OR - ordinal_contains(r.resource_name, @{parameterName}) OR - ordinal_contains(CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END, @{parameterName}) OR - ordinal_contains(CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END, @{parameterName}) OR - ordinal_contains(COALESCE(s.status_message, ''), @{parameterName}) OR - EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND (ordinal_contains(a.attribute_key, @{parameterName}) OR ordinal_contains(a.attribute_value, @{parameterName}))) OR - EXISTS (SELECT 1 FROM telemetry_span_events e WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id AND ordinal_contains(e.event_name, @{parameterName})) + s.name LIKE @{parameterName} ESCAPE '!' OR + s.span_id LIKE @{parameterName} ESCAPE '!' OR + s.trace_id LIKE @{parameterName} ESCAPE '!' OR + sc.scope_name LIKE @{parameterName} ESCAPE '!' OR + r.resource_name LIKE @{parameterName} ESCAPE '!' OR + CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END LIKE @{parameterName} ESCAPE '!' OR + CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END LIKE @{parameterName} ESCAPE '!' OR + COALESCE(s.status_message, '') LIKE @{parameterName} ESCAPE '!' OR + EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND (a.attribute_key LIKE @{parameterName} ESCAPE '!' OR a.attribute_value LIKE @{parameterName} ESCAPE '!')) OR + EXISTS (SELECT 1 FROM telemetry_span_events e WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id AND e.event_name LIKE @{parameterName} ESCAPE '!') ) """); } @@ -507,7 +507,11 @@ private static string BuildSpanFieldPredicate( _ => filter.Condition } : filter.Condition; - parameters.Add(parameterName, filter.Value); + parameters.Add( + parameterName, + condition is FilterCondition.Contains or FilterCondition.NotContains + ? CreateContainsLikePattern(filter.Value) + : filter.Value); var expression = filter.Field switch { @@ -543,7 +547,7 @@ private static string BuildSpanFieldPredicate( var attributePredicate = BuildStringPredicate("a.attribute_value", condition, parameterName); parameters.Add($"TraceField{filterIndex}", filter.Field); - return $"EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND a.attribute_key = @TraceField{filterIndex} COLLATE ORDINAL_IGNORE_CASE AND {attributePredicate})"; + return $"EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND a.attribute_key = @TraceField{filterIndex} COLLATE NOCASE AND {attributePredicate})"; } private GetSpansResponse GetSpansFromDatabase(GetSpansRequest context) @@ -590,13 +594,13 @@ FROM telemetry_spans s { var key = context.ResourceKeys[index]; parameters.Add($"SpanResourceName{index}", key.Name); - var source = $"r.resource_name = @SpanResourceName{index} COLLATE ORDINAL_IGNORE_CASE"; - var peer = $"pr.resource_name = @SpanResourceName{index} COLLATE ORDINAL_IGNORE_CASE"; + var source = $"r.resource_name = @SpanResourceName{index} COLLATE NOCASE"; + var peer = $"pr.resource_name = @SpanResourceName{index} COLLATE NOCASE"; if (key.InstanceId is not null) { parameters.Add($"SpanInstanceId{index}", key.InstanceId); - source += $" AND r.instance_id = @SpanInstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; - peer += $" AND pr.instance_id = @SpanInstanceId{index} COLLATE ORDINAL_IGNORE_CASE"; + source += $" AND r.instance_id = @SpanInstanceId{index} COLLATE NOCASE"; + peer += $" AND pr.instance_id = @SpanInstanceId{index} COLLATE NOCASE"; } predicates.Add($"(({source}) OR ({peer}))"); } @@ -606,10 +610,14 @@ FROM telemetry_spans s } if (!string.IsNullOrEmpty(context.TraceId)) { - parameters.Add("SpanTraceId", context.TraceId); + parameters.Add( + "SpanTraceId", + context.TraceId.Length >= OtlpHelpers.ShortenedIdLength + ? CreateStartsWithLikePattern(context.TraceId) + : context.TraceId); sql.Append(context.TraceId.Length >= OtlpHelpers.ShortenedIdLength - ? " AND ordinal_starts_with(s.trace_id, @SpanTraceId)" - : " AND s.trace_id = @SpanTraceId COLLATE ORDINAL_IGNORE_CASE"); + ? " AND s.trace_id LIKE @SpanTraceId ESCAPE '!'" + : " AND s.trace_id = @SpanTraceId COLLATE NOCASE"); } if (context.HasError is not null) { @@ -662,19 +670,19 @@ fieldFilter.Field is not (KnownResourceFields.ServiceNameField or KnownTraceFiel for (var index = 0; index < context.TextFragments.Length; index++) { var parameterName = $"SpanTextFragment{index}"; - parameters.Add(parameterName, context.TextFragments[index]); + parameters.Add(parameterName, CreateContainsLikePattern(context.TextFragments[index])); sql.Append(CultureInfo.InvariantCulture, $""" AND ( - ordinal_contains(s.name, @{parameterName}) OR - ordinal_contains(s.span_id, @{parameterName}) OR - ordinal_contains(s.trace_id, @{parameterName}) OR - ordinal_contains(sc.scope_name, @{parameterName}) OR - ordinal_contains(r.resource_name, @{parameterName}) OR - ordinal_contains(CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END, @{parameterName}) OR - ordinal_contains(CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END, @{parameterName}) OR - ordinal_contains(COALESCE(s.status_message, ''), @{parameterName}) OR - EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND (ordinal_contains(a.attribute_key, @{parameterName}) OR ordinal_contains(a.attribute_value, @{parameterName}))) OR - EXISTS (SELECT 1 FROM telemetry_span_events e WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id AND ordinal_contains(e.event_name, @{parameterName})) + s.name LIKE @{parameterName} ESCAPE '!' OR + s.span_id LIKE @{parameterName} ESCAPE '!' OR + s.trace_id LIKE @{parameterName} ESCAPE '!' OR + sc.scope_name LIKE @{parameterName} ESCAPE '!' OR + r.resource_name LIKE @{parameterName} ESCAPE '!' OR + CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END LIKE @{parameterName} ESCAPE '!' OR + CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END LIKE @{parameterName} ESCAPE '!' OR + COALESCE(s.status_message, '') LIKE @{parameterName} ESCAPE '!' OR + EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND (a.attribute_key LIKE @{parameterName} ESCAPE '!' OR a.attribute_value LIKE @{parameterName} ESCAPE '!')) OR + EXISTS (SELECT 1 FROM telemetry_span_events e WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id AND e.event_name LIKE @{parameterName} ESCAPE '!') ) """); } @@ -689,11 +697,11 @@ private List GetTracePropertyKeysFromDatabase(ResourceKey? resourceKey) var resourceWhere = string.Empty; if (resourceKey is not null) { - resourceWhere = " AND r.resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE"; + resourceWhere = " AND r.resource_name = @ResourceName COLLATE NOCASE"; parameters.Add("ResourceName", resourceKey.Value.Name); if (resourceKey.Value.InstanceId is not null) { - resourceWhere += " AND r.instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; + resourceWhere += " AND r.instance_id = @InstanceId COLLATE NOCASE"; parameters.Add("InstanceId", resourceKey.Value.InstanceId); } } @@ -769,7 +777,7 @@ FROM telemetry_spans s _ => connection.Query(""" SELECT attribute_value AS FieldValue, COUNT(*) AS ValueCount FROM telemetry_span_attributes - WHERE attribute_key = @AttributeName COLLATE ORDINAL_IGNORE_CASE + WHERE attribute_key = @AttributeName COLLATE NOCASE GROUP BY attribute_value; """, new { AttributeName = attributeName }) }; @@ -800,9 +808,10 @@ FROM telemetry_traces private OtlpTrace? GetTraceFromDatabase(string traceId) { using var connection = _database.OpenConnection(); - var storedTraceId = connection.QueryFirstOrDefault(traceId.Length >= OtlpHelpers.ShortenedIdLength - ? "SELECT trace_id FROM telemetry_traces WHERE ordinal_starts_with(trace_id, @TraceId) ORDER BY first_span_timestamp_ticks, insertion_sequence DESC LIMIT 1;" - : "SELECT trace_id FROM telemetry_traces WHERE trace_id = @TraceId COLLATE ORDINAL_IGNORE_CASE ORDER BY first_span_timestamp_ticks, insertion_sequence DESC LIMIT 1;", new { TraceId = traceId }); + var usePrefix = traceId.Length >= OtlpHelpers.ShortenedIdLength; + var storedTraceId = connection.QueryFirstOrDefault(usePrefix + ? "SELECT trace_id FROM telemetry_traces WHERE trace_id LIKE @TraceId ESCAPE '!' ORDER BY first_span_timestamp_ticks, insertion_sequence DESC LIMIT 1;" + : "SELECT trace_id FROM telemetry_traces WHERE trace_id = @TraceId COLLATE NOCASE ORDER BY first_span_timestamp_ticks, insertion_sequence DESC LIMIT 1;", new { TraceId = usePrefix ? CreateStartsWithLikePattern(traceId) : traceId }); return storedTraceId is null ? null : MaterializeTrace(connection, storedTraceId); } @@ -1111,11 +1120,11 @@ private void ClearTracesFromDatabase(ResourceKey? resourceKey) var where = string.Empty; if (resourceKey is not null) { - where = " WHERE resource_name = @ResourceName COLLATE ORDINAL_IGNORE_CASE"; + where = " WHERE resource_name = @ResourceName COLLATE NOCASE"; parameters.Add("ResourceName", resourceKey.Value.Name); if (resourceKey.Value.InstanceId is not null) { - where += " AND instance_id = @InstanceId COLLATE ORDINAL_IGNORE_CASE"; + where += " AND instance_id = @InstanceId COLLATE NOCASE"; parameters.Add("InstanceId", resourceKey.Value.InstanceId); } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index ea4bda8cf54..35c5a9da021 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -24,6 +24,18 @@ public sealed partial class SqliteTelemetryRepository : ITelemetryRepository, IM private readonly List _outgoingPeerSubscriptions = []; private readonly object _writeLock = new(); + private static string CreateContainsLikePattern(string value) => $"%{EscapeLikePattern(value)}%"; + + private static string CreateStartsWithLikePattern(string value) => $"{EscapeLikePattern(value)}%"; + + private static string EscapeLikePattern(string value) + { + return value + .Replace("!", "!!", StringComparison.Ordinal) + .Replace("%", "!%", StringComparison.Ordinal) + .Replace("_", "!_", StringComparison.Ordinal); + } + public SqliteTelemetryRepository( string databasePath, ILoggerFactory loggerFactory, diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index 690a31be99e..6a58c5384c2 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -14,9 +14,6 @@ internal sealed class DashboardSqliteDatabase private const string SchemaResourcePrefix = "Aspire.Dashboard.ServiceClient.DatabaseSchema."; internal const int SchemaVersion = 7; - internal const string OrdinalIgnoreCaseCollation = "ORDINAL_IGNORE_CASE"; - internal const string OrdinalContainsFunction = "ordinal_contains"; - internal const string OrdinalStartsWithFunction = "ordinal_starts_with"; private static readonly Lazy> s_schemaScripts = new(LoadSchemaScripts); @@ -78,17 +75,6 @@ ELSE NULL public SqliteConnection OpenConnection() { var connection = new SqliteConnection(_connectionString); - connection.CreateCollation( - OrdinalIgnoreCaseCollation, - (left, right) => string.Compare(left, right, StringComparison.OrdinalIgnoreCase)); - connection.CreateFunction( - OrdinalContainsFunction, - (value, fragment) => value?.Contains(fragment ?? string.Empty, StringComparison.OrdinalIgnoreCase) ?? false, - isDeterministic: true); - connection.CreateFunction( - OrdinalStartsWithFunction, - (value, prefix) => value?.StartsWith(prefix ?? string.Empty, StringComparison.OrdinalIgnoreCase) ?? false, - isDeterministic: true); connection.Open(); connection.Execute("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;"); diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql index ff13c31d34f..d8498a1cacf 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql @@ -85,4 +85,4 @@ CREATE INDEX IF NOT EXISTS ix_telemetry_logs_trace_order CREATE INDEX IF NOT EXISTS ix_telemetry_log_attributes_owner_key ON telemetry_log_attributes(log_id, attribute_key); CREATE INDEX IF NOT EXISTS ix_telemetry_log_attributes_key_value_owner - ON telemetry_log_attributes(attribute_key COLLATE ORDINAL_IGNORE_CASE, attribute_value COLLATE ORDINAL_IGNORE_CASE, log_id); \ No newline at end of file + ON telemetry_log_attributes(attribute_key COLLATE NOCASE, attribute_value COLLATE NOCASE, log_id); \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql index fb47a9e6612..c73fa46b5e3 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql @@ -84,6 +84,6 @@ CREATE INDEX IF NOT EXISTS ix_telemetry_spans_trace_order CREATE INDEX IF NOT EXISTS ix_telemetry_span_attributes_owner_key ON telemetry_span_attributes(trace_id, span_id, attribute_key); CREATE INDEX IF NOT EXISTS ix_telemetry_span_attributes_key_value_owner - ON telemetry_span_attributes(attribute_key COLLATE ORDINAL_IGNORE_CASE, attribute_value COLLATE ORDINAL_IGNORE_CASE, trace_id, span_id); + ON telemetry_span_attributes(attribute_key COLLATE NOCASE, attribute_value COLLATE NOCASE, trace_id, span_id); CREATE INDEX IF NOT EXISTS ix_telemetry_span_links_target ON telemetry_span_links(target_trace_id, target_span_id); \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index 0d215b6248d..bbab8153eeb 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -250,16 +250,16 @@ public void GetRuns_ExcludesIncompatibleDatabaseWithoutDeletingIt() } [Fact] - public void SqliteDatabase_ConfiguresExactStringFunctionsAndForeignKeys() + public void SqliteDatabase_ConfiguresLikeAndForeignKeys() { var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "connection.db")); using var connection = database.OpenConnection(); using var command = connection.CreateCommand(); command.CommandText = """ SELECT - 'Ångström' = 'ångström' COLLATE ORDINAL_IGNORE_CASE, - ordinal_contains('CAFÉ au lait', 'fé AU'), - ordinal_starts_with('Δelta', 'δE'), + 'Dashboard' = 'dashboard' COLLATE NOCASE, + 'CAFE au lait' LIKE '%fe AU%', + 'Delta' LIKE 'dE%', (SELECT foreign_keys FROM pragma_foreign_keys()); """; using var reader = command.ExecuteReader(); diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs index 02e89290ee2..ae0a358f668 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs @@ -874,10 +874,50 @@ public void FilterLogs_With_Message_Returns_CorrectLog() ResourceKeys = [resourceKey], StartIndex = 0, Count = 1, - Filters = [new FieldTelemetryFilter { Condition = FilterCondition.Contains, Field = nameof(OtlpLogEntry.Message), Value = "message" }] + Filters = [new FieldTelemetryFilter { Condition = FilterCondition.Contains, Field = nameof(OtlpLogEntry.Message), Value = "MESSAGE" }] }).Items); } + [Theory] + [InlineData("%")] + [InlineData("_")] + [InlineData("!")] + public void FilterLogs_WithLikeMetacharacter_TreatsValueAsLiteral(string fragment) + { + var repository = CreateRepository(); + var expectedMessage = $"matches-{fragment}-literal"; + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(instanceId: "1"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = + { + CreateLogRecord(time: s_testTime.AddMinutes(1), message: expectedMessage), + CreateLogRecord(time: s_testTime.AddMinutes(2), message: "matches-x-literal") + } + } + } + } + }); + + var result = repository.GetLogs(new GetLogsContext + { + ResourceKeys = [repository.GetResources().Single().ResourceKey], + StartIndex = 0, + Count = int.MaxValue, + Filters = [new FieldTelemetryFilter { Condition = FilterCondition.Contains, Field = nameof(OtlpLogEntry.Message), Value = fragment }] + }); + + var log = Assert.Single(result.Items); + Assert.Equal(expectedMessage, log.Message); + } + [Fact] public void FilterLogs_With_EventName_Returns_CorrectLog() { From c73fd2c7525bd703ab2b3b07fbb28c1b22a8daf1 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 15 Jul 2026 14:40:27 +0800 Subject: [PATCH 13/87] Push trace error filtering into repository --- .../Api/TelemetryApiService.cs | 26 ++++----- src/Aspire.Dashboard/Model/TracesViewModel.cs | 26 --------- .../SqliteTelemetryRepository.Traces.cs | 2 +- .../TelemetryRepositoryTests/TraceTests.cs | 53 +++++++++++++++++++ 4 files changed, 65 insertions(+), 42 deletions(-) diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index a83cb19b960..daf0d790027 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -96,6 +96,15 @@ internal sealed class TelemetryApiService( // Convert structured search qualifiers into TelemetryFilter objects for repository-level filtering var traceFilters = new List(); + if (hasError is not null) + { + traceFilters.Add(new FieldTelemetryFilter + { + Field = KnownTraceFields.StatusField, + Value = nameof(OtlpSpanStatusCode.Error), + Condition = hasError.Value ? FilterCondition.Equals : FilterCondition.NotEqual + }); + } var searchTextFragments = ParseAndApplySearchFilters(search, traceFilters, AddSpanFiltersFromQualifiers, key => ResolveSpanFieldKey(key) is not null); // Get traces for all resource keys (empty list means no filter / all resources) @@ -107,21 +116,8 @@ internal sealed class TelemetryApiService( Filters = traceFilters, TextFragments = searchTextFragments }); - var allTraces = result.PagedResult.Items; - - var traces = allTraces; - - // Filter traces by hasError - if (hasError == true) - { - traces = traces.Where(t => t.Spans.Any(s => s.Status == OtlpSpanStatusCode.Error)).ToList(); - } - else if (hasError == false) - { - traces = traces.Where(t => !t.Spans.Any(s => s.Status == OtlpSpanStatusCode.Error)).ToList(); - } - - var totalCount = traces.Count; + var traces = result.PagedResult.Items; + var totalCount = result.PagedResult.TotalItemCount; // Apply limit (take from end for most recent) if (traces.Count > effectiveLimit) diff --git a/src/Aspire.Dashboard/Model/TracesViewModel.cs b/src/Aspire.Dashboard/Model/TracesViewModel.cs index 94823076441..75072e33bc5 100644 --- a/src/Aspire.Dashboard/Model/TracesViewModel.cs +++ b/src/Aspire.Dashboard/Model/TracesViewModel.cs @@ -100,32 +100,6 @@ public PagedResult GetTraces() return traces; } - // First check if there were any errors in already available data. Avoid fetching data again. - public bool HasErrors() => _currentDataHasErrors || GetErrorTraces(count: 0).TotalItemCount > 0; - - public PagedResult GetErrorTraces(int count) - { - var filters = Filters.Cast().ToList(); - - if (SpanType?.Filter is { } typeFilter) - { - filters.Add(typeFilter); - } - - filters.Add(new FieldTelemetryFilter { Field = KnownTraceFields.StatusField, Condition = FilterCondition.Equals, Value = OtlpSpanStatusCode.Error.ToString() }); - - var errorTraces = _telemetryRepository.GetTraces(new GetTracesRequest - { - ResourceKeys = ResourceKey is { } key ? [key] : [], - StartIndex = 0, - Count = count, - Filters = filters, - TraceNameFilterText = FilterText - }); - - return errorTraces.PagedResult; - } - private List GetFilters() { var filters = Filters.Cast().ToList(); diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs index ce598436139..ebf8157da28 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs @@ -384,7 +384,7 @@ t.last_updated_timestamp_ticks AS LastUpdatedTimestampTicks private static TraceQuery BuildTraceQuery(GetTracesRequest context) { - var sql = new StringBuilder("FROM telemetry_traces t WHERE EXISTS (SELECT 1 FROM telemetry_spans existing_span WHERE existing_span.trace_id = t.trace_id)"); + var sql = new StringBuilder("FROM telemetry_traces t WHERE 1 = 1"); var parameters = new DynamicParameters(); if (context.ResourceKeys.Count > 0) { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index 9e006f6fdc5..efdd4ffd84e 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -2536,6 +2536,59 @@ public void GetSpans_FilterByHasErrorFalse_ReturnsNonErrorSpansOnly() AssertId("1-2", result.PagedResult.Items[0].SpanId); } + [Theory] + [InlineData(FilterCondition.Equals, "1", "3")] + [InlineData(FilterCondition.NotEqual, "2")] + public void GetTraces_StatusFilter_ReturnsMatchingTraces(FilterCondition condition, params string[] expectedTraceIds) + { + var repository = CreateRepository(); + + repository.AddTraces(new AddContext(), new RepeatedField() + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(2), status: new Status { Code = Status.Types.StatusCode.Error }), + CreateSpan(traceId: "2", spanId: "2-1", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3), status: new Status { Code = Status.Types.StatusCode.Ok }), + CreateSpan(traceId: "3", spanId: "3-1", startTime: s_testTime.AddMinutes(3), endTime: s_testTime.AddMinutes(4), status: new Status { Code = Status.Types.StatusCode.Error }), + CreateSpan(traceId: "3", spanId: "3-2", startTime: s_testTime.AddMinutes(4), endTime: s_testTime.AddMinutes(5), status: new Status { Code = Status.Types.StatusCode.Ok }) + } + } + } + } + }); + + var result = repository.GetTraces(new GetTracesRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = int.MaxValue, + Filters = + [ + new FieldTelemetryFilter + { + Field = KnownTraceFields.StatusField, + Value = nameof(OtlpSpanStatusCode.Error), + Condition = condition + } + ] + }); + + Assert.Equal(expectedTraceIds.Length, result.PagedResult.TotalItemCount); + Assert.Equal(expectedTraceIds.Length, result.PagedResult.Items.Count); + for (var index = 0; index < expectedTraceIds.Length; index++) + { + AssertId(expectedTraceIds[index], result.PagedResult.Items[index].TraceId); + } + } + [Fact] public void GetSpans_FilterByResource_ReturnsMatchingSpans() { From 53407a876fc1ab0bfe468a25267e3b9155f579c2 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 15 Jul 2026 15:46:28 +0800 Subject: [PATCH 14/87] Optimize traces page queries --- .../Components/Controls/TraceActions.razor.cs | 6 +- .../Components/Pages/Traces.razor | 15 +- .../Components/Pages/Traces.razor.cs | 30 +-- .../Model/TraceMenuBuilder.cs | 33 ++- src/Aspire.Dashboard/Model/TracesViewModel.cs | 10 +- src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs | 2 + .../Otlp/Storage/GetTraceSummariesResponse.cs | 13 ++ .../Otlp/Storage/ITelemetryRepository.cs | 1 + .../SqliteTelemetryRepository.Traces.cs | 206 ++++++++++++++++++ .../Otlp/Storage/SqliteTelemetryRepository.cs | 1 + .../Otlp/Storage/TraceSummary.cs | 31 +++ .../TelemetryRepositoryTests/TraceTests.cs | 119 ++++++++++ .../Telemetry/InMemoryTelemetryRepository.cs | 30 +++ 13 files changed, 459 insertions(+), 38 deletions(-) create mode 100644 src/Aspire.Dashboard/Otlp/Storage/GetTraceSummariesResponse.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/TraceSummary.cs diff --git a/src/Aspire.Dashboard/Components/Controls/TraceActions.razor.cs b/src/Aspire.Dashboard/Components/Controls/TraceActions.razor.cs index 20b4c0b7ed6..a7135ec3e77 100644 --- a/src/Aspire.Dashboard/Components/Controls/TraceActions.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TraceActions.razor.cs @@ -2,7 +2,7 @@ // 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 Aspire.Dashboard.Resources; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; @@ -20,7 +20,7 @@ public partial class TraceActions : ComponentBase public required IStringLocalizer ControlsLoc { get; init; } [Parameter] - public required OtlpTrace Trace { get; set; } + public required TraceSummary Summary { get; set; } private readonly List _menuItems = new(); @@ -28,6 +28,6 @@ protected override void OnParametersSet() { _menuItems.Clear(); - TraceMenuBuilder.AddMenuItems(_menuItems, Trace); + TraceMenuBuilder.AddMenuItems(_menuItems, Summary); } } diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor b/src/Aspire.Dashboard/Components/Pages/Traces.razor index ed416eca813..e661c006116 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor @@ -3,6 +3,7 @@ @using Aspire.Dashboard.Resources @using Aspire.Dashboard.Components.Controls.Grid +@using Aspire.Dashboard.Otlp.Storage @inject IJSRuntime JS @inject IStringLocalizer Loc @@ -100,26 +101,26 @@ ResizableColumns="true" ResizeColumnOnAllRows="false" ItemsProvider="@GetData" - TGridItem="OtlpTrace" + TGridItem="TraceSummary" GridTemplateColumns="@_manager.GetGridTemplateColumns()" ShowHover="true" ItemKey="@(r => r.TraceId)" OnRowClick="@(r => r.ExecuteOnDefault(d => NavigationManager.NavigateTo(DashboardUrls.TraceDetailUrl(d.TraceId))))" Class="main-grid enable-row-click"> - - @FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, context.FirstSpan.StartTime, MillisecondsDisplay.Truncated) + + @FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, context.StartTime, MillisecondsDisplay.Truncated)
- + @OtlpHelpers.ToShortenedId(context.TraceId) - @if (HasGenAISpans(context)) + @if (context.HasGenAI) { - @foreach (var item in TraceHelpers.GetOrderedResources(context)) + @foreach (var item in context.Resources) { @@ -192,7 +193,7 @@
- +
diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs index 37c8a744075..bd0065d7010 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs @@ -42,7 +42,7 @@ public partial class Traces : IComponentWithTelemetry, IPageWithSessionAndUrlSta private bool _resourceChanged; private string _filter = string.Empty; private AspirePageContentLayout? _contentLayout; - private FluentDataGrid _dataGrid = null!; + private FluentDataGrid _dataGrid = null!; private GridColumnManager _manager = null!; private ColumnResizeLabels _resizeLabels = ColumnResizeLabels.Default; @@ -105,7 +105,7 @@ public partial class Traces : IComponentWithTelemetry, IPageWithSessionAndUrlSta [SupplyParameterFromQuery(Name = "filters")] public string? SerializedFilters { get; set; } - private string GetNameTooltip(OtlpTrace trace) + private string GetNameTooltip(TraceSummary trace) { var tooltip = string.Format(CultureInfo.InvariantCulture, Loc[nameof(Dashboard.Resources.Traces.TracesFullName)], trace.FullName); tooltip += Environment.NewLine + string.Format(CultureInfo.InvariantCulture, Loc[nameof(Dashboard.Resources.Traces.TracesTraceId)], trace.TraceId); @@ -113,7 +113,7 @@ private string GetNameTooltip(OtlpTrace trace) return tooltip; } - private string GetSpansTooltip(OrderedResource resourceSpans) + private string GetSpansTooltip(TraceResourceSummary resourceSpans) { var count = resourceSpans.TotalSpans; var errorCount = resourceSpans.ErroredSpans; @@ -128,7 +128,7 @@ private string GetSpansTooltip(OrderedResource resourceSpans) return tooltip; } - private async ValueTask> GetData(GridItemsProviderRequest request) + private async ValueTask> GetData(GridItemsProviderRequest request) { TracesViewModel.StartIndex = request.StartIndex; TracesViewModel.Count = request.Count ?? DashboardUIHelpers.DefaultDataGridResultCount; @@ -240,11 +240,10 @@ private async Task HandleAfterFilterBindAsync() } private string GetResourceName(OtlpResource app) => OtlpHelpers.GetResourceName(app, _resources); - private string GetResourceName(OtlpResourceView app) => OtlpHelpers.GetResourceName(app.Resource, _resources); - private static string GetRowClass(OtlpTrace entry) + private static string GetRowClass(TraceSummary entry) { - if (entry.Spans.Any(span => span.Status == OtlpSpanStatusCode.Error)) + if (entry.HasError) { return "trace-row-error"; } @@ -256,7 +255,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { // Check to see whether max item count should be set on every render. // This is required because the data grid's virtualize component can be recreated on data change. - if (_dataGrid != null && FluentDataGridHelper.TrySetMaxItemCount(_dataGrid, 10_000)) + if (_dataGrid != null && FluentDataGridHelper.TrySetMaxItemCount(_dataGrid, 10_000)) { StateHasChanged(); } @@ -403,21 +402,14 @@ private List GetFilterMenuItems() dialogsLoc: DialogsLoc); } - private static bool HasGenAISpans(OtlpTrace trace) + private async Task OnGenAIClickedAsync(TraceSummary summary) { - foreach (var span in trace.Spans) + var trace = TelemetryRepository.GetTrace(summary.TraceId); + if (trace is null) { - if (GenAIHelpers.HasGenAIAttribute(span.Attributes)) - { - return true; - } + return; } - return false; - } - - private async Task OnGenAIClickedAsync(OtlpTrace trace) - { var firstSpan = trace.Spans.FirstOrDefault(s => GenAIHelpers.HasGenAIAttribute(s.Attributes)); if (firstSpan == null) { diff --git a/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs b/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs index fbfa0347d48..8b0cabf7d33 100644 --- a/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs @@ -55,6 +55,29 @@ public void AddMenuItems( List menuItems, OtlpTrace trace, bool showViewDetails = true) + { + AddMenuItems(menuItems, trace.TraceId, () => trace, showViewDetails); + } + + /// + /// Adds menu items for a trace summary to the provided list. + /// + /// The list to add menu items to. + /// The trace summary to create menu items for. + /// Whether to include the View Details menu item. Defaults to true. + public void AddMenuItems( + List menuItems, + TraceSummary summary, + bool showViewDetails = true) + { + AddMenuItems(menuItems, summary.TraceId, () => _telemetryRepository.GetTrace(summary.TraceId), showViewDetails); + } + + private void AddMenuItems( + List menuItems, + string traceId, + Func getTrace, + bool showViewDetails) { if (showViewDetails) { @@ -64,7 +87,7 @@ public void AddMenuItems( Icon = s_viewDetailsIcon, OnClick = () => { - _navigationManager.NavigateTo(DashboardUrls.TraceDetailUrl(trace.TraceId)); + _navigationManager.NavigateTo(DashboardUrls.TraceDetailUrl(traceId)); return Task.CompletedTask; } }); @@ -76,7 +99,7 @@ public void AddMenuItems( Icon = s_structuredLogsIcon, OnClick = () => { - _navigationManager.NavigateTo(DashboardUrls.StructuredLogsUrl(traceId: trace.TraceId)); + _navigationManager.NavigateTo(DashboardUrls.StructuredLogsUrl(traceId: traceId)); return Task.CompletedTask; } }); @@ -87,6 +110,12 @@ public void AddMenuItems( Icon = s_bracesIcon, OnClick = async () => { + var trace = getTrace(); + if (trace is null) + { + return; + } + var result = ExportHelpers.GetTraceAsJson(trace, _telemetryRepository, _outgoingPeerResolvers); await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { diff --git a/src/Aspire.Dashboard/Model/TracesViewModel.cs b/src/Aspire.Dashboard/Model/TracesViewModel.cs index 75072e33bc5..6c9987317eb 100644 --- a/src/Aspire.Dashboard/Model/TracesViewModel.cs +++ b/src/Aspire.Dashboard/Model/TracesViewModel.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Model.Otlp; -using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; namespace Aspire.Dashboard.Model; @@ -12,12 +11,11 @@ public class TracesViewModel private readonly ITelemetryRepository _telemetryRepository; private readonly List _filters = new(); - private PagedResult? _traces; + private PagedResult? _traces; private ResourceKey? _resourceKey; private string _filterText = string.Empty; private int _startIndex; private int _count; - private bool _currentDataHasErrors; private SpanType? _spanType; public TracesViewModel(ITelemetryRepository telemetryRepository) @@ -75,14 +73,14 @@ private void SetValue(ref T field, T value) _traces = null; } - public PagedResult GetTraces() + public PagedResult GetTraces() { var traces = _traces; if (traces == null) { var filters = GetFilters(); - var result = _telemetryRepository.GetTraces(new GetTracesRequest + var result = _telemetryRepository.GetTraceSummaries(new GetTracesRequest { ResourceKeys = ResourceKey is { } key ? [key] : [], StartIndex = StartIndex, @@ -93,8 +91,6 @@ public PagedResult GetTraces() traces = result.PagedResult; MaxDuration = result.MaxDuration; - - _currentDataHasErrors = result.PagedResult.Items.Any(t => t.Spans.Any(s => s.Status == OtlpSpanStatusCode.Error)); } return traces; diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs index 41c67607c78..790fb203812 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs @@ -65,6 +65,8 @@ public void AddSpan(OtlpSpan span, bool skipLastUpdatedDate = false) throw new InvalidOperationException($"Circular loop detected for span '{span.SpanId}' with parent '{span.ParentSpanId}'."); } + _duration = null; + if (string.IsNullOrEmpty(span.ParentSpanId)) { // There should only be one span with no parent span ID. diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetTraceSummariesResponse.cs b/src/Aspire.Dashboard/Otlp/Storage/GetTraceSummariesResponse.cs new file mode 100644 index 00000000000..4aed8bce8df --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/GetTraceSummariesResponse.cs @@ -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. + +namespace Aspire.Dashboard.Otlp.Storage; + +/// +/// Contains a page of trace summaries and aggregate information for the matching traces. +/// +public sealed class GetTraceSummariesResponse +{ + public required PagedResult PagedResult { get; init; } + public required TimeSpan MaxDuration { get; init; } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs index f1b1fc0dd5a..f1ad214afca 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs @@ -45,6 +45,7 @@ public interface ITelemetryRepository : IDisposable List GetLogPropertyKeys(ResourceKey? resourceKey); List GetTracePropertyKeys(ResourceKey? resourceKey); GetTracesResponse GetTraces(GetTracesRequest context); + GetTraceSummariesResponse GetTraceSummaries(GetTracesRequest context); GetSpansResponse GetSpans(GetSpansRequest context); Dictionary GetTraceFieldValues(string attributeName); Dictionary GetLogsFieldValues(string attributeName); diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs index ebf8157da28..df18e68cf0d 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs @@ -382,6 +382,191 @@ t.last_updated_timestamp_ticks AS LastUpdatedTimestampTicks }; } + private GetTraceSummariesResponse GetTraceSummariesFromDatabase(GetTracesRequest context) + { + using var connection = _database.OpenConnection(); + var query = BuildTraceQuery(context); + query.Parameters.Add("StartIndex", Math.Max(context.StartIndex, 0)); + query.Parameters.Add("Count", Math.Max(context.Count, 0)); + query.Parameters.Add("MaxTraceCount", _otlpContext.Options.MaxTraceCount); + + // Build the page and its resource groups in one query. The recursive span tree preserves + // the resource ordering used by TraceHelpers when a child span starts before its parent. + var records = connection.Query($""" + WITH RECURSIVE + filtered_traces AS ( + SELECT t.* + {query.FromAndWhere} + ), + trace_aggregate AS ( + SELECT COUNT(*) AS TotalItemCount, COALESCE(MAX(duration_ticks), 0) AS MaxDurationTicks + FROM filtered_traces + ), + paged_traces AS ( + SELECT * + FROM filtered_traces + ORDER BY first_span_timestamp_ticks, insertion_sequence DESC + LIMIT @Count OFFSET @StartIndex + ), + span_tree AS ( + SELECT + s.trace_id, + s.span_id, + s.resource_id, + s.uninstrumented_peer_resource_id, + s.status, + s.start_time_ticks AS resource_order_ticks + FROM telemetry_spans s + JOIN paged_traces pt ON pt.trace_id = s.trace_id + WHERE NOT EXISTS ( + SELECT 1 + FROM telemetry_spans parent + WHERE parent.trace_id = s.trace_id AND parent.span_id = s.parent_span_id + ) + + UNION ALL + + SELECT + child.trace_id, + child.span_id, + child.resource_id, + child.uninstrumented_peer_resource_id, + child.status, + MAX(child.start_time_ticks, parent.resource_order_ticks) + FROM telemetry_spans child + JOIN span_tree parent ON parent.trace_id = child.trace_id AND parent.span_id = child.parent_span_id + ), + span_resources AS ( + SELECT + st.trace_id, + st.resource_id, + st.status, + st.resource_order_ticks + FROM span_tree st + + UNION ALL + + SELECT + st.trace_id, + st.uninstrumented_peer_resource_id, + st.status, + st.resource_order_ticks + FROM span_tree st + WHERE st.uninstrumented_peer_resource_id IS NOT NULL + ), + resource_summaries AS ( + SELECT + sr.trace_id, + r.resource_name, + r.instance_id, + r.uninstrumented_peer, + MIN(sr.resource_order_ticks) AS resource_order_ticks, + COUNT(*) AS total_spans, + SUM(CASE WHEN sr.status = 2 THEN 1 ELSE 0 END) AS errored_spans + FROM span_resources sr + JOIN telemetry_resources r ON r.resource_id = sr.resource_id + GROUP BY sr.trace_id, sr.resource_id + ), + primary_spans AS ( + SELECT + s.trace_id, + r.resource_name, + r.instance_id, + r.uninstrumented_peer, + ROW_NUMBER() OVER ( + PARTITION BY s.trace_id + ORDER BY CASE WHEN s.parent_span_id IS NULL OR s.parent_span_id = '' THEN 0 ELSE 1 END, s.start_time_ticks, s.span_id + ) AS row_number + FROM telemetry_spans s + JOIN paged_traces pt ON pt.trace_id = s.trace_id + JOIN telemetry_resources r ON r.resource_id = s.resource_id + ), + trace_summaries AS ( + SELECT + pt.trace_id, + pt.full_name, + pt.first_span_timestamp_ticks, + pt.duration_ticks, + ps.resource_name AS root_resource_name, + ps.instance_id AS root_instance_id, + ps.uninstrumented_peer AS root_uninstrumented_peer, + EXISTS (SELECT 1 FROM telemetry_spans s WHERE s.trace_id = pt.trace_id AND s.status = 2) AS has_error, + EXISTS ( + SELECT 1 + FROM telemetry_span_attributes a + WHERE a.trace_id = pt.trace_id + AND a.attribute_key IN ('gen_ai.system', 'gen_ai.provider.name') + AND LENGTH(a.attribute_value) > 0 + ) AS has_gen_ai, + pt.first_span_timestamp_ticks AS trace_order_ticks, + pt.insertion_sequence + FROM paged_traces pt + JOIN primary_spans ps ON ps.trace_id = pt.trace_id AND ps.row_number = 1 + ) + SELECT + a.TotalItemCount, + a.MaxDurationTicks, + (SELECT COUNT(*) FROM telemetry_traces) >= @MaxTraceCount AS IsFull, + ts.trace_id AS TraceId, + ts.full_name AS FullName, + ts.first_span_timestamp_ticks AS StartTimeTicks, + ts.duration_ticks AS DurationTicks, + ts.root_resource_name AS RootResourceName, + ts.root_instance_id AS RootInstanceId, + ts.root_uninstrumented_peer AS RootUninstrumentedPeer, + ts.has_error AS HasError, + ts.has_gen_ai AS HasGenAI, + rs.resource_name AS ResourceName, + rs.instance_id AS InstanceId, + rs.uninstrumented_peer AS UninstrumentedPeer, + rs.total_spans AS TotalSpans, + rs.errored_spans AS ErroredSpans + FROM trace_aggregate a + LEFT JOIN trace_summaries ts ON 1 = 1 + LEFT JOIN resource_summaries rs ON rs.trace_id = ts.trace_id + ORDER BY ts.trace_order_ticks, ts.insertion_sequence DESC, rs.resource_order_ticks, rs.resource_name, rs.instance_id; + """, query.Parameters).AsList(); + + var firstRecord = records[0]; + var summaries = records + .Where(record => record.TraceId is not null) + .GroupBy(record => record.TraceId!, StringComparer.Ordinal) + .Select(group => + { + var trace = group.First(); + return new TraceSummary + { + TraceId = trace.TraceId!, + FullName = trace.FullName!, + StartTime = new DateTime(trace.StartTimeTicks!.Value, DateTimeKind.Utc), + Duration = TimeSpan.FromTicks(trace.DurationTicks!.Value), + RootResource = CreateSummaryResource(trace.RootResourceName!, trace.RootInstanceId, trace.RootUninstrumentedPeer!.Value), + Resources = group.Select(resource => new TraceResourceSummary + { + Resource = CreateSummaryResource(resource.ResourceName!, resource.InstanceId, resource.UninstrumentedPeer!.Value), + TotalSpans = resource.TotalSpans!.Value, + ErroredSpans = resource.ErroredSpans!.Value + }).ToList(), + HasError = trace.HasError!.Value, + HasGenAI = trace.HasGenAI!.Value + }; + }).ToList(); + + return new GetTraceSummariesResponse + { + PagedResult = new PagedResult + { + Items = summaries, + TotalItemCount = firstRecord.TotalItemCount, + IsFull = firstRecord.IsFull + }, + MaxDuration = TimeSpan.FromTicks(firstRecord.MaxDurationTicks) + }; + + OtlpResource CreateSummaryResource(string resourceName, string? instanceId, bool uninstrumentedPeer) => + new(resourceName, instanceId, uninstrumentedPeer, _otlpContext); + } + private static TraceQuery BuildTraceQuery(GetTracesRequest context) { var sql = new StringBuilder("FROM telemetry_traces t WHERE 1 = 1"); @@ -1164,6 +1349,27 @@ private sealed class TraceSummaryRecord public required long LastUpdatedTimestampTicks { get; init; } } + private sealed class TracePageSummaryRecord + { + public required int TotalItemCount { get; init; } + public required long MaxDurationTicks { get; init; } + public required bool IsFull { get; init; } + public string? TraceId { get; init; } + public string? FullName { get; init; } + public long? StartTimeTicks { get; init; } + public long? DurationTicks { get; init; } + public string? RootResourceName { get; init; } + public string? RootInstanceId { get; init; } + public bool? RootUninstrumentedPeer { get; init; } + public bool? HasError { get; init; } + public bool? HasGenAI { get; init; } + public string? ResourceName { get; init; } + public string? InstanceId { get; init; } + public bool? UninstrumentedPeer { get; init; } + public int? TotalSpans { get; init; } + public int? ErroredSpans { get; init; } + } + private sealed class SpanIdentityRecord { public required string TraceId { get; init; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index 35c5a9da021..be523b734ca 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -136,6 +136,7 @@ public void AddTraces(AddContext context, RepeatedField resourceS public List GetLogPropertyKeys(ResourceKey? resourceKey) => GetLogPropertyKeysFromDatabase(resourceKey); public List GetTracePropertyKeys(ResourceKey? resourceKey) => GetTracePropertyKeysFromDatabase(resourceKey); public GetTracesResponse GetTraces(GetTracesRequest context) => GetTracesFromDatabase(context); + public GetTraceSummariesResponse GetTraceSummaries(GetTracesRequest context) => GetTraceSummariesFromDatabase(context); public GetSpansResponse GetSpans(GetSpansRequest context) => GetSpansFromDatabase(context); public Dictionary GetTraceFieldValues(string attributeName) => GetTraceFieldValuesFromDatabase(attributeName); public Dictionary GetLogsFieldValues(string attributeName) => GetLogsFieldValuesFromDatabase(attributeName); diff --git a/src/Aspire.Dashboard/Otlp/Storage/TraceSummary.cs b/src/Aspire.Dashboard/Otlp/Storage/TraceSummary.cs new file mode 100644 index 00000000000..496984adeb4 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/TraceSummary.cs @@ -0,0 +1,31 @@ +// 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.Otlp.Storage; + +/// +/// Contains the trace information displayed on the traces page. +/// +public sealed class TraceSummary +{ + public required string TraceId { get; init; } + public required string FullName { get; init; } + public required DateTime StartTime { get; init; } + public required TimeSpan Duration { get; init; } + public required OtlpResource RootResource { get; init; } + public required IReadOnlyList Resources { get; init; } + public required bool HasError { get; init; } + public required bool HasGenAI { get; init; } +} + +/// +/// Contains the resource information displayed for a trace on the traces page. +/// +public sealed class TraceResourceSummary +{ + public required OtlpResource Resource { get; init; } + public required int TotalSpans { get; init; } + public required int ErroredSpans { get; init; } +} \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index efdd4ffd84e..b32143d962e 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -93,6 +93,125 @@ public void AddTraces() }); } + [Fact] + public void GetTraceSummaries_ReturnsPageData() + { + var repository = CreateRepository(); + var addContext = new AddContext(); + repository.AddTraces(addContext, new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "frontend", instanceId: "frontend-1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-1", + startTime: s_testTime.AddMinutes(1), + endTime: s_testTime.AddMinutes(3), + attributes: + [ + KeyValuePair.Create("custom", "match"), + KeyValuePair.Create("gen_ai.system", "test") + ], + status: new Status { Code = Status.Types.StatusCode.Error }), + CreateSpan( + traceId: "2", + spanId: "2-1", + startTime: s_testTime.AddMinutes(2), + endTime: s_testTime.AddMinutes(4), + attributes: [KeyValuePair.Create("custom", "other")]) + } + } + } + }, + new ResourceSpans + { + Resource = CreateResource(name: "backend", instanceId: "backend-1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-2", + parentSpanId: "1-1", + startTime: s_testTime.AddMinutes(2), + endTime: s_testTime.AddMinutes(6), + status: new Status { Code = Status.Types.StatusCode.Ok }) + } + } + } + } + }); + + var request = new GetTracesRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + TraceNameFilterText = "frontend", + Filters = + [ + new FieldTelemetryFilter + { + Field = "custom", + Condition = FilterCondition.Equals, + Value = "match" + } + ] + }; + + var summaries = repository.GetTraceSummaries(request); + var traces = repository.GetTraces(request); + + Assert.Equal(traces.PagedResult.TotalItemCount, summaries.PagedResult.TotalItemCount); + Assert.Equal(traces.MaxDuration, summaries.MaxDuration); + var summary = Assert.Single(summaries.PagedResult.Items); + AssertId("1", summary.TraceId); + Assert.Equal("frontend: Test span. Id: 1-1", summary.FullName); + Assert.Equal(s_testTime.AddMinutes(1), summary.StartTime); + Assert.Equal(TimeSpan.FromMinutes(5), summary.Duration); + Assert.Equal(new ResourceKey("frontend", "frontend-1"), summary.RootResource.ResourceKey); + Assert.True(summary.HasError); + Assert.True(summary.HasGenAI); + Assert.Collection(summary.Resources, + resource => + { + Assert.Equal(new ResourceKey("frontend", "frontend-1"), resource.Resource.ResourceKey); + Assert.Equal(1, resource.TotalSpans); + Assert.Equal(1, resource.ErroredSpans); + }, + resource => + { + Assert.Equal(new ResourceKey("backend", "backend-1"), resource.Resource.ResourceKey); + Assert.Equal(1, resource.TotalSpans); + Assert.Equal(0, resource.ErroredSpans); + }); + + var emptyPage = repository.GetTraceSummaries(new GetTracesRequest + { + ResourceKeys = request.ResourceKeys, + StartIndex = 10, + Count = request.Count, + TraceNameFilterText = request.TraceNameFilterText, + Filters = request.Filters + }); + + Assert.Empty(emptyPage.PagedResult.Items); + Assert.Equal(1, emptyPage.PagedResult.TotalItemCount); + Assert.Equal(TimeSpan.FromMinutes(5), emptyPage.MaxDuration); + } + [Fact] public void AddTraces_SelfParent_Reject() { diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index dd2cfce94b1..8c8df104d40 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -727,6 +727,36 @@ public GetTracesResponse GetTraces(GetTracesRequest context) } } + public GetTraceSummariesResponse GetTraceSummaries(GetTracesRequest context) + { + var result = GetTraces(context); + return new GetTraceSummariesResponse + { + PagedResult = new PagedResult + { + Items = result.PagedResult.Items.Select(trace => new TraceSummary + { + TraceId = trace.TraceId, + FullName = trace.FullName, + StartTime = trace.FirstSpan.StartTime, + Duration = trace.Duration, + RootResource = trace.RootOrFirstSpan.Source.Resource, + Resources = TraceHelpers.GetOrderedResources(trace).Select(resource => new TraceResourceSummary + { + Resource = resource.Resource, + TotalSpans = resource.TotalSpans, + ErroredSpans = resource.ErroredSpans + }).ToList(), + HasError = trace.Spans.Any(span => span.Status == OtlpSpanStatusCode.Error), + HasGenAI = trace.Spans.Any(span => global::Aspire.Dashboard.Model.GenAI.GenAIHelpers.HasGenAIAttribute(span.Attributes)) + }).ToList(), + TotalItemCount = result.PagedResult.TotalItemCount, + IsFull = result.PagedResult.IsFull + }, + MaxDuration = result.MaxDuration + }; + } + public GetSpansResponse GetSpans(GetSpansRequest context) { List? resources = null; From 4e2d2b2f1cb817618920bf99e9966d9aa92ff4da Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 15 Jul 2026 16:23:43 +0800 Subject: [PATCH 15/87] Order traces by trace ID --- .../SqliteTelemetryRepository.Traces.cs | 20 +++++++++---------- .../DatabaseSchema/004.Traces.sql | 3 +-- .../TelemetryRepositoryTests/TraceTests.cs | 18 ++++++++--------- .../Telemetry/InMemoryTelemetryRepository.cs | 12 ++++++++--- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs index df18e68cf0d..24401ee4ff7 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs @@ -116,10 +116,9 @@ private OtlpSpan AddSpanToDatabase( { connection.Execute(""" INSERT INTO telemetry_traces ( - trace_id, insertion_sequence, first_span_timestamp_ticks, duration_ticks, last_updated_timestamp_ticks, full_name) + trace_id, first_span_timestamp_ticks, duration_ticks, last_updated_timestamp_ticks, full_name) VALUES ( @TraceId, - COALESCE((SELECT MAX(insertion_sequence) + 1 FROM telemetry_traces), 1), @FirstSpanTimestampTicks, @DurationTicks, @LastUpdatedTimestampTicks, @@ -338,7 +337,7 @@ DELETE FROM telemetry_traces WHERE trace_id IN ( SELECT trace_id FROM telemetry_traces - ORDER BY first_span_timestamp_ticks, insertion_sequence DESC + ORDER BY first_span_timestamp_ticks, trace_id LIMIT MAX((SELECT COUNT(*) FROM telemetry_traces) - @MaxTraceCount, 0) ); @@ -366,7 +365,7 @@ SELECT COUNT(*) AS TotalItemCount, COALESCE(MAX(t.duration_ticks), 0) AS MaxDura t.trace_id AS TraceId, t.last_updated_timestamp_ticks AS LastUpdatedTimestampTicks {query.FromAndWhere} - ORDER BY t.first_span_timestamp_ticks, t.insertion_sequence DESC + ORDER BY t.first_span_timestamp_ticks, t.trace_id LIMIT @Count OFFSET @StartIndex; """, query.Parameters).AsList(); var traces = records.Select(record => MaterializeTrace(connection, record.TraceId)!).ToList(); @@ -405,7 +404,7 @@ FROM filtered_traces paged_traces AS ( SELECT * FROM filtered_traces - ORDER BY first_span_timestamp_ticks, insertion_sequence DESC + ORDER BY first_span_timestamp_ticks, trace_id LIMIT @Count OFFSET @StartIndex ), span_tree AS ( @@ -498,8 +497,7 @@ FROM telemetry_span_attributes a AND a.attribute_key IN ('gen_ai.system', 'gen_ai.provider.name') AND LENGTH(a.attribute_value) > 0 ) AS has_gen_ai, - pt.first_span_timestamp_ticks AS trace_order_ticks, - pt.insertion_sequence + pt.first_span_timestamp_ticks AS trace_order_ticks FROM paged_traces pt JOIN primary_spans ps ON ps.trace_id = pt.trace_id AND ps.row_number = 1 ) @@ -524,7 +522,7 @@ rs.errored_spans AS ErroredSpans FROM trace_aggregate a LEFT JOIN trace_summaries ts ON 1 = 1 LEFT JOIN resource_summaries rs ON rs.trace_id = ts.trace_id - ORDER BY ts.trace_order_ticks, ts.insertion_sequence DESC, rs.resource_order_ticks, rs.resource_name, rs.instance_id; + ORDER BY ts.trace_order_ticks, ts.trace_id, rs.resource_order_ticks, rs.resource_name, rs.instance_id; """, query.Parameters).AsList(); var firstRecord = records[0]; @@ -745,7 +743,7 @@ private GetSpansResponse GetSpansFromDatabase(GetSpansRequest context) var identities = connection.Query($""" SELECT s.trace_id AS TraceId, s.span_id AS SpanId {query.FromAndWhere} - ORDER BY t.first_span_timestamp_ticks, t.insertion_sequence DESC, s.start_time_ticks, s.span_id + ORDER BY t.first_span_timestamp_ticks, t.trace_id, s.start_time_ticks, s.span_id LIMIT @Count OFFSET @StartIndex; """, query.Parameters).AsList(); var traces = identities.Select(identity => identity.TraceId).Distinct(StringComparer.Ordinal) @@ -995,8 +993,8 @@ FROM telemetry_traces using var connection = _database.OpenConnection(); var usePrefix = traceId.Length >= OtlpHelpers.ShortenedIdLength; var storedTraceId = connection.QueryFirstOrDefault(usePrefix - ? "SELECT trace_id FROM telemetry_traces WHERE trace_id LIKE @TraceId ESCAPE '!' ORDER BY first_span_timestamp_ticks, insertion_sequence DESC LIMIT 1;" - : "SELECT trace_id FROM telemetry_traces WHERE trace_id = @TraceId COLLATE NOCASE ORDER BY first_span_timestamp_ticks, insertion_sequence DESC LIMIT 1;", new { TraceId = usePrefix ? CreateStartsWithLikePattern(traceId) : traceId }); + ? "SELECT trace_id FROM telemetry_traces WHERE trace_id LIKE @TraceId ESCAPE '!' ORDER BY first_span_timestamp_ticks, trace_id LIMIT 1;" + : "SELECT trace_id FROM telemetry_traces WHERE trace_id = @TraceId COLLATE NOCASE ORDER BY first_span_timestamp_ticks, trace_id LIMIT 1;", new { TraceId = usePrefix ? CreateStartsWithLikePattern(traceId) : traceId }); return storedTraceId is null ? null : MaterializeTrace(connection, storedTraceId); } diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql index c73fa46b5e3..0fd49abb64c 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql @@ -3,7 +3,6 @@ CREATE TABLE IF NOT EXISTS telemetry_traces ( trace_id TEXT PRIMARY KEY, - insertion_sequence INTEGER NOT NULL UNIQUE, first_span_timestamp_ticks INTEGER NOT NULL, duration_ticks INTEGER NOT NULL, last_updated_timestamp_ticks INTEGER NOT NULL, @@ -76,7 +75,7 @@ CREATE TABLE IF NOT EXISTS telemetry_span_link_attributes ( ) STRICT; CREATE INDEX IF NOT EXISTS ix_telemetry_traces_order - ON telemetry_traces(first_span_timestamp_ticks, insertion_sequence DESC); + ON telemetry_traces(first_span_timestamp_ticks, trace_id); CREATE INDEX IF NOT EXISTS ix_telemetry_spans_resource_order ON telemetry_spans(resource_id, start_time_ticks, trace_id, span_id); CREATE INDEX IF NOT EXISTS ix_telemetry_spans_trace_order diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index b32143d962e..db5cdb0a366 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -980,13 +980,14 @@ public void AddTraces_Links_BacklinksPopulated() } [Fact] - public void AddTraces_ExceedLimit_FirstInFirstOut() + public void AddTraces_ExceedLimit_OrderedByTimestampAndTraceId() { // Arrange const int MaxTraceCount = 10; var repository = CreateRepository(maxTraceCount: MaxTraceCount); var testTime = s_testTime.AddDays(1); + var expectedTraces = new List<(string TraceId, DateTime StartTime)>(); // Act for (var i = 0; i < 2000; i++) @@ -996,6 +997,7 @@ public void AddTraces_ExceedLimit_FirstInFirstOut() // Insert traces out of order to stress the circular buffer type. var startTime = testTime.AddMinutes(i + (i % 2 == 0 ? -5 : 0)); + expectedTraces.Add((GetHexId(traceId), startTime)); try { @@ -1024,15 +1026,13 @@ public void AddTraces_ExceedLimit_FirstInFirstOut() Filters = [] }); - // Most recent traces are returned. - var first = GetStringId(traces.PagedResult.Items.First().TraceId); - var last = GetStringId(traces.PagedResult.Items.Last().TraceId); - Assert.Equal("1988", first); - Assert.Equal("2000", last); - - // Traces returned are ordered by start time. var actualOrder = traces.PagedResult.Items.Select(t => t.TraceId).ToList(); - var expectedOrder = traces.PagedResult.Items.OrderBy(t => t.FirstSpan.StartTime).Select(t => t.TraceId).ToList(); + var expectedOrder = expectedTraces + .OrderBy(trace => trace.StartTime) + .ThenBy(trace => trace.TraceId, StringComparer.Ordinal) + .TakeLast(MaxTraceCount) + .Select(trace => trace.TraceId) + .ToList(); Assert.Equal(expectedOrder, actualOrder); Assert.Equal(MaxTraceCount * 2, traces.PagedResult.Items.SelectMany(t => t.Spans).SelectMany(s => s.Links).Count()); diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index 8c8df104d40..047cc1ae014 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -1825,7 +1825,7 @@ internal void AddTracesCore(AddContext context, OtlpResourceView resourceView, R linkedSpan?.BackLinks.Add(link); } - // Traces are sorted by the start time of the first span. + // Traces are sorted by the start time of the first span, then by trace ID. // We need to ensure traces are in the correct order if we're: // 1. Adding a new trace. // 2. The first span of the trace has changed. @@ -1835,7 +1835,7 @@ internal void AddTracesCore(AddContext context, OtlpResourceView resourceView, R for (var i = _traces.Count - 1; i >= 0; i--) { var currentTrace = _traces[i]; - if (trace.FirstSpan.StartTime > currentTrace.FirstSpan.StartTime) + if (CompareTraceOrder(trace, currentTrace) > 0) { _traces.Insert(i + 1, trace); added = true; @@ -1857,7 +1857,7 @@ internal void AddTracesCore(AddContext context, OtlpResourceView resourceView, R for (var i = index - 1; i >= 0; i--) { var currentTrace = _traces[i]; - if (trace.FirstSpan.StartTime > currentTrace.FirstSpan.StartTime) + if (CompareTraceOrder(trace, currentTrace) > 0) { var insertPosition = i + 1; if (index != insertPosition) @@ -1942,6 +1942,12 @@ static bool TryGetTraceById(CircularBuffer traces, ReadOnlyMemory Date: Wed, 15 Jul 2026 19:44:56 +0800 Subject: [PATCH 16/87] Optimize structured logs page queries --- .../Controls/StructuredLogActions.razor.cs | 4 +- .../Components/Pages/StructuredLogs.razor | 11 +- .../Components/Pages/StructuredLogs.razor.cs | 34 ++-- .../LogLevelColumnDisplay.razor | 3 +- .../LogMessageColumnDisplay.razor | 2 +- .../LogMessageColumnDisplay.razor.cs | 15 +- .../Model/StructuredLogMenuBuilder.cs | 40 ++++- .../Model/StructuredLogsViewModel.cs | 60 +------ .../Otlp/Storage/ITelemetryRepository.cs | 1 + .../Otlp/Storage/LogSummary.cs | 24 +++ .../Storage/SqliteTelemetryRepository.Logs.cs | 138 ++++++++++++++++ .../Otlp/Storage/SqliteTelemetryRepository.cs | 1 + .../TelemetryRepositoryTests/LogTests.cs | 152 ++++++++++++++++++ .../TelemetryRepositoryTests.cs | 12 +- .../Telemetry/InMemoryTelemetryRepository.cs | 23 +++ 15 files changed, 413 insertions(+), 107 deletions(-) create mode 100644 src/Aspire.Dashboard/Otlp/Storage/LogSummary.cs diff --git a/src/Aspire.Dashboard/Components/Controls/StructuredLogActions.razor.cs b/src/Aspire.Dashboard/Components/Controls/StructuredLogActions.razor.cs index de7b00c993d..4ffd6ea3c79 100644 --- a/src/Aspire.Dashboard/Components/Controls/StructuredLogActions.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/StructuredLogActions.razor.cs @@ -2,7 +2,7 @@ // 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 Aspire.Dashboard.Resources; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; @@ -23,7 +23,7 @@ public partial class StructuredLogActions : ComponentBase public required EventCallback OnViewDetails { get; set; } [Parameter] - public required OtlpLogEntry LogEntry { get; set; } + public required LogSummary LogEntry { get; set; } private readonly List _menuItems = new(); diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor index cb4a726ebb4..4679abd13f5 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor @@ -3,6 +3,7 @@ @using Aspire.Dashboard.Resources @using Aspire.Dashboard.Components.Controls.Grid +@using Aspire.Dashboard.Otlp.Storage @inject IJSRuntime JS @implements IDisposable @@ -123,16 +124,16 @@ ResizableColumns="true" ResizeColumnOnAllRows="false" ItemsProvider="@GetData" - TGridItem="OtlpLogEntry" + TGridItem="LogSummary" GridTemplateColumns="@_manager.GetGridTemplateColumns()" ShowHover="true" ItemKey="@(r => r.InternalId)" OnRowClick="@(r => r.ExecuteOnDefault(d => OnShowPropertiesAsync(d, focusElementId: ScrollContainerId)))" Class="main-grid enable-row-click"> - - - @GetResourceName(context.ResourceView) + + + @GetResourceName(context.Resource) @@ -143,7 +144,7 @@ @* Tooltip is displayed by the message GridValue instance *@ - + @if (!string.IsNullOrEmpty(context.TraceId)) diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index 11fe289504d..af6f1dcd34b 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -46,7 +46,7 @@ public partial class StructuredLogs : IComponentWithTelemetry, IPageWithSessionA private string? _pendingFocusElementId; private AspirePageContentLayout? _contentLayout; private string _filter = string.Empty; - private FluentDataGrid? _dataGrid; + private FluentDataGrid? _dataGrid; private GridColumnManager _manager = null!; private IList _gridColumns = null!; @@ -122,7 +122,7 @@ public partial class StructuredLogs : IComponentWithTelemetry, IPageWithSessionA [SupplyParameterFromQuery] public long? LogEntryId { get; set; } - private async ValueTask> GetData(GridItemsProviderRequest request) + private async ValueTask> GetData(GridItemsProviderRequest request) { ViewModel.StartIndex = request.StartIndex; ViewModel.Count = request.Count ?? DashboardUIHelpers.DefaultDataGridResultCount; @@ -300,6 +300,12 @@ private async Task OnShowPropertiesAsync(OtlpLogEntry entry, string? focusElemen } } + private Task OnShowPropertiesAsync(LogSummary summary, string? focusElementId) + { + var entry = TelemetryRepository.GetLog(summary.InternalId); + return entry is null ? Task.CompletedTask : OnShowPropertiesAsync(entry, focusElementId); + } + private Task ClearSelectedLogEntryAsync(bool causedByUserAction = false) { PageViewModel.SelectedLogEntry = null; @@ -372,9 +378,9 @@ private async Task HandleAfterFilterBindAsync() } } - private string GetResourceName(OtlpResourceView app) => OtlpHelpers.GetResourceName(app.Resource, _resources); + private string GetResourceName(OtlpResource resource) => OtlpHelpers.GetResourceName(resource, _resources); - private string GetRowClass(OtlpLogEntry entry) + private string GetRowClass(LogSummary entry) { if (entry.InternalId == PageViewModel.SelectedLogEntry?.LogEntry.InternalId) { @@ -401,7 +407,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { // Check to see whether max item count should be set on every render. // This is required because the data grid's virtualize component can be recreated on data change. - if (_dataGrid != null && FluentDataGridHelper.TrySetMaxItemCount(_dataGrid, 10_000)) + if (_dataGrid != null && FluentDataGridHelper.TrySetMaxItemCount(_dataGrid, 10_000)) { StateHasChanged(); } @@ -507,23 +513,7 @@ public async Task UpdateViewModelFromQueryAsync(StructuredLogsPageViewModel view await InvokeAsync(_dataGrid.SafeRefreshDataAsync); } - private bool IsGenAILogEntry(OtlpLogEntry logEntry) - { - if (string.IsNullOrEmpty(logEntry.SpanId) || string.IsNullOrEmpty(logEntry.TraceId)) - { - return false; - } - - if (GenAIHelpers.HasGenAIAttribute(logEntry.Attributes)) - { - // GenAI telemetry is on the log entry. - return true; - } - - return ViewModel.HasGenAISpan(logEntry.TraceId, logEntry.SpanId); - } - - private async Task LaunchGenAIVisualizerAsync(OtlpLogEntry logEntry) + private async Task LaunchGenAIVisualizerAsync(LogSummary logEntry) { var available = await TraceLinkHelpers.WaitForSpanToBeAvailableAsync( logEntry.TraceId, diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogLevelColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogLevelColumnDisplay.razor index 39a05a8b9cc..4d1c5e72231 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogLevelColumnDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogLevelColumnDisplay.razor @@ -1,6 +1,7 @@ @namespace Aspire.Dashboard.Components @using Aspire.Dashboard.Resources +@using Aspire.Dashboard.Otlp.Storage @using Microsoft.Extensions.Logging @if (LogEntry.IsError) @@ -16,5 +17,5 @@ else if (LogEntry.IsWarning) @code { [Parameter, EditorRequired] - public required OtlpLogEntry LogEntry { get; set; } + public required LogSummary LogEntry { get; set; } } diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor index dd6d7bd23fc..b59ffc821fc 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor @@ -23,6 +23,6 @@ Color="Color.Accent" />
} - + diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor.cs b/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor.cs index 41acadc6bc5..f1a4b941920 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor.cs +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/LogMessageColumnDisplay.razor.cs @@ -1,7 +1,7 @@ // 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; +using Aspire.Dashboard.Otlp.Storage; using Microsoft.AspNetCore.Components; namespace Aspire.Dashboard.Components; @@ -9,23 +9,16 @@ namespace Aspire.Dashboard.Components; public partial class LogMessageColumnDisplay { [Parameter, EditorRequired] - public required OtlpLogEntry LogEntry { get; set; } + public required LogSummary LogEntry { get; set; } [Parameter, EditorRequired] public required string FilterText { get; set; } [Parameter, EditorRequired] - public required EventCallback LaunchGenAIVisualizerCallback { get; set; } + public required EventCallback LaunchGenAIVisualizerCallback { get; set; } [Parameter, EditorRequired] - public required Func IsGenAILogCallback { get; set; } - - private string? _exceptionText; - - protected override void OnInitialized() - { - _exceptionText = OtlpLogEntry.GetExceptionText(LogEntry); - } + public required Func IsGenAILogCallback { get; set; } private Task OnLaunchGenAIVisualizerAsync() => LaunchGenAIVisualizerCallback.InvokeAsync(LogEntry); } diff --git a/src/Aspire.Dashboard/Model/StructuredLogMenuBuilder.cs b/src/Aspire.Dashboard/Model/StructuredLogMenuBuilder.cs index c166fc1296c..b9ac248ad83 100644 --- a/src/Aspire.Dashboard/Model/StructuredLogMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/StructuredLogMenuBuilder.cs @@ -3,6 +3,7 @@ using Aspire.Dashboard.Components.Dialogs; using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Resources; using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; @@ -24,6 +25,7 @@ public sealed class StructuredLogMenuBuilder private readonly IStringLocalizer _loc; private readonly IStringLocalizer _controlsLoc; private readonly DashboardDialogService _dialogService; + private readonly ITelemetryRepository _telemetryRepository; /// /// Initializes a new instance of the class. @@ -31,11 +33,13 @@ public sealed class StructuredLogMenuBuilder public StructuredLogMenuBuilder( IStringLocalizer loc, IStringLocalizer controlsLoc, - DashboardDialogService dialogService) + DashboardDialogService dialogService, + ITelemetryRepository telemetryRepository) { _loc = loc; _controlsLoc = controlsLoc; _dialogService = dialogService; + _telemetryRepository = telemetryRepository; } /// @@ -50,6 +54,32 @@ public void AddMenuItems( OtlpLogEntry logEntry, EventCallback onViewDetails, bool showViewDetails = true) + { + AddMenuItems(menuItems, logEntry.Message, () => logEntry, onViewDetails, showViewDetails); + } + + /// + /// Adds menu items for a structured log summary to the provided list. + /// + /// The list to add menu items to. + /// The log summary to create menu items for. + /// Callback when View Details is clicked. Ignored when is false. + /// Whether to include the View Details menu item. Defaults to true. + public void AddMenuItems( + List menuItems, + LogSummary summary, + EventCallback onViewDetails, + bool showViewDetails = true) + { + AddMenuItems(menuItems, summary.Message, () => _telemetryRepository.GetLog(summary.InternalId), onViewDetails, showViewDetails); + } + + private void AddMenuItems( + List menuItems, + string message, + Func getLogEntry, + EventCallback onViewDetails, + bool showViewDetails) { if (showViewDetails) { @@ -72,7 +102,7 @@ await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { DialogService = _dialogService, ValueDescription = header, - Value = logEntry.Message + Value = message }).ConfigureAwait(false); } }); @@ -83,6 +113,12 @@ await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions Icon = s_bracesIcon, OnClick = async () => { + var logEntry = getLogEntry(); + if (logEntry is null) + { + return; + } + var result = ExportHelpers.GetLogEntryAsJson(logEntry); await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { diff --git a/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs b/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs index e734c253010..9630d18bf2d 100644 --- a/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs +++ b/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Concurrent; -using Aspire.Dashboard.Model.GenAI; using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; @@ -13,10 +11,8 @@ public class StructuredLogsViewModel { private readonly ITelemetryRepository _telemetryRepository; private readonly List _filters = new(); - // Cache span lookups for GenAI attributes to avoid repeated lookups. - private readonly ConcurrentDictionary _spanGenAICache = new(); - private PagedResult? _logs; + private PagedResult? _logs; private ResourceKey? _resourceKey; private string _filterText = string.Empty; private int _logsStartIndex; @@ -33,33 +29,6 @@ public StructuredLogsViewModel(ITelemetryRepository telemetryRepository) public string FilterText { get => _filterText; set => SetValue(ref _filterText, value); } public IReadOnlyList Filters => _filters; - public bool HasGenAISpan(string traceId, string spanId) - { - // Get a flag indicating whether the span has GenAI telemetry on it. - // This is cached to avoid repeated lookups. The cache is cleared when logs change. - // It's ok that this isn't completely thread safe, i.e. get and a clear happen at the same time. - - var spanKey = new SpanKey(traceId, spanId); - - if (_spanGenAICache.TryGetValue(spanKey, out var value)) - { - return value; - } - - var span = _telemetryRepository.GetSpan(spanKey.TraceId, spanKey.SpanId); - var hasGenAISpan = false; - - if (span != null) - { - // Only cache a value if a span is present. - // We don't want to cache false if there is no span because the span may be added later. - hasGenAISpan = GenAIHelpers.HasGenAIAttribute(span.Attributes); - _spanGenAICache.TryAdd(spanKey, hasGenAISpan); - } - - return hasGenAISpan; - } - public void ClearFilters() { _filters.Clear(); @@ -106,14 +75,14 @@ private void SetValue(ref T field, T value) ClearData(); } - public PagedResult GetLogs() + public PagedResult GetLogs() { var logs = _logs; if (logs == null) { var filters = GetFilters(); - logs = _telemetryRepository.GetLogs(new GetLogsContext + logs = _telemetryRepository.GetLogSummaries(new GetLogsContext { ResourceKeys = ResourceKey is { } key ? [key] : [], StartIndex = StartIndex, @@ -149,31 +118,8 @@ internal static List BuildFilters(IReadOnlyList _currentDataHasErrors || GetErrorLogs(count: 0).TotalItemCount > 0; - - public PagedResult GetErrorLogs(int count) - { - var filters = GetFilters(); - filters.RemoveAll(f => f is FieldTelemetryFilter fieldFilter && fieldFilter.Field == nameof(OtlpLogEntry.Severity)); - filters.Add(new FieldTelemetryFilter { Field = nameof(OtlpLogEntry.Severity), Condition = FilterCondition.GreaterThanOrEqual, Value = Microsoft.Extensions.Logging.LogLevel.Error.ToString() }); - - var errorLogs = _telemetryRepository.GetLogs(new GetLogsContext - { - ResourceKeys = ResourceKey is { } key ? [key] : [], - StartIndex = 0, - Count = count, - Filters = filters - }); - - return errorLogs; - } - public void ClearData() { _logs = null; - - // Clear cache whenever log data changes to prevent it growing forever. - _spanGenAICache.Clear(); } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs index f1ad214afca..009eabe4fbd 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs @@ -39,6 +39,7 @@ public interface ITelemetryRepository : IDisposable void AddTraces(AddContext context, RepeatedField resourceSpans); PagedResult GetLogs(GetLogsContext context); + PagedResult GetLogSummaries(GetLogsContext context); OtlpLogEntry? GetLog(long logId); List GetLogsForSpan(string traceId, string spanId); List GetLogsForTrace(string traceId); diff --git a/src/Aspire.Dashboard/Otlp/Storage/LogSummary.cs b/src/Aspire.Dashboard/Otlp/Storage/LogSummary.cs new file mode 100644 index 00000000000..7cde3a2f95e --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/LogSummary.cs @@ -0,0 +1,24 @@ +// 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.Otlp.Storage; + +/// +/// Contains the log information displayed on the structured logs page. +/// +public sealed class LogSummary +{ + public required long InternalId { get; init; } + public required DateTime TimeStamp { get; init; } + public required LogLevel Severity { get; init; } + public required string Message { get; init; } + public required string SpanId { get; init; } + public required string TraceId { get; init; } + public required OtlpResource Resource { get; init; } + public required string? ExceptionText { get; init; } + public required bool HasGenAI { get; init; } + public bool IsError => Severity is LogLevel.Error or LogLevel.Critical; + public bool IsWarning => Severity is LogLevel.Warning; +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs index 85536769695..216da31acb2 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs @@ -361,6 +361,127 @@ s.scope_version AS ScopeVersion }; } + private PagedResult GetLogSummariesFromDatabase(GetLogsContext context) + { + using var connection = _database.OpenConnection(); + var query = BuildLogQuery(context); + query.Parameters.Add("StartIndex", Math.Max(context.StartIndex, 0)); + query.Parameters.Add("Count", Math.Max(context.Count, 0)); + query.Parameters.Add("MaxLogCount", _otlpContext.Options.MaxLogCount); + + // Return the aggregate and page display data together. Only attributes that affect the + // message column are aggregated, avoiding the extra materialization queries for every page. + var records = connection.Query($""" + WITH + filtered_logs AS ( + SELECT + l.*, + r.resource_name, + r.instance_id, + r.uninstrumented_peer + {query.FromAndWhere} + ), + log_aggregate AS ( + SELECT COUNT(*) AS TotalItemCount + FROM filtered_logs + ), + paged_logs AS ( + SELECT * + FROM filtered_logs + ORDER BY timestamp_ticks, log_id DESC + LIMIT @Count OFFSET @StartIndex + ), + log_attribute_summaries AS ( + SELECT + a.log_id, + MAX(CASE WHEN a.attribute_key = 'exception.stacktrace' THEN a.attribute_value END) AS exception_stacktrace, + MAX(CASE WHEN a.attribute_key = 'exception.message' THEN a.attribute_value END) AS exception_message, + MAX(CASE WHEN a.attribute_key = 'exception.type' THEN a.attribute_value END) AS exception_type, + MAX(CASE WHEN a.attribute_key = 'gen_ai.system' THEN 1 ELSE 0 END) AS has_gen_ai_system, + MAX(CASE WHEN a.attribute_key = 'gen_ai.system' AND LENGTH(a.attribute_value) > 0 THEN 1 ELSE 0 END) AS has_non_empty_gen_ai_system, + MAX(CASE WHEN a.attribute_key = 'gen_ai.provider.name' AND LENGTH(a.attribute_value) > 0 THEN 1 ELSE 0 END) AS has_non_empty_gen_ai_provider + FROM telemetry_log_attributes a + JOIN paged_logs pl ON pl.log_id = a.log_id + WHERE a.attribute_key IN ( + 'exception.stacktrace', + 'exception.message', + 'exception.type', + 'gen_ai.system', + 'gen_ai.provider.name') + GROUP BY a.log_id + ) + SELECT + a.TotalItemCount, + (SELECT COUNT(*) FROM telemetry_logs) >= @MaxLogCount AS IsFull, + pl.log_id AS InternalId, + pl.timestamp_ticks AS TimestampTicks, + pl.severity AS Severity, + pl.message AS Message, + pl.span_id AS SpanId, + pl.trace_id AS TraceId, + pl.resource_name AS ResourceName, + pl.instance_id AS InstanceId, + pl.uninstrumented_peer AS UninstrumentedPeer, + CASE + WHEN LENGTH(las.exception_stacktrace) > 0 THEN las.exception_stacktrace + WHEN LENGTH(las.exception_message) > 0 AND LENGTH(las.exception_type) > 0 THEN las.exception_type || ': ' || las.exception_message + WHEN LENGTH(las.exception_message) > 0 THEN las.exception_message + END AS ExceptionText, + COALESCE( + CASE WHEN las.has_gen_ai_system = 1 + THEN las.has_non_empty_gen_ai_system + ELSE las.has_non_empty_gen_ai_provider + END, + 0) = 1 OR + CASE WHEN EXISTS ( + SELECT 1 + FROM telemetry_span_attributes sa + WHERE sa.trace_id = pl.trace_id + AND sa.span_id = pl.span_id + AND sa.attribute_key = 'gen_ai.system' + ) THEN EXISTS ( + SELECT 1 + FROM telemetry_span_attributes sa + WHERE sa.trace_id = pl.trace_id + AND sa.span_id = pl.span_id + AND sa.attribute_key = 'gen_ai.system' + AND LENGTH(sa.attribute_value) > 0 + ) ELSE EXISTS ( + SELECT 1 + FROM telemetry_span_attributes sa + WHERE sa.trace_id = pl.trace_id + AND sa.span_id = pl.span_id + AND sa.attribute_key = 'gen_ai.provider.name' + AND LENGTH(sa.attribute_value) > 0 + ) END AS HasGenAI + FROM log_aggregate a + LEFT JOIN paged_logs pl ON 1 = 1 + LEFT JOIN log_attribute_summaries las ON las.log_id = pl.log_id + ORDER BY pl.timestamp_ticks, pl.log_id DESC; + """, query.Parameters).AsList(); + + var firstRecord = records[0]; + return new PagedResult + { + Items = records + .Where(record => record.InternalId is not null) + .Select(record => new LogSummary + { + InternalId = record.InternalId!.Value, + TimeStamp = new DateTime(record.TimestampTicks!.Value, DateTimeKind.Utc), + Severity = (LogLevel)record.Severity!.Value, + Message = record.Message!, + SpanId = record.SpanId!, + TraceId = record.TraceId!, + Resource = new OtlpResource(record.ResourceName!, record.InstanceId, record.UninstrumentedPeer!.Value, _otlpContext), + ExceptionText = record.ExceptionText, + HasGenAI = record.HasGenAI!.Value + }).ToList(), + TotalItemCount = firstRecord.TotalItemCount, + IsFull = firstRecord.IsFull + }; + } + private OtlpLogEntry? GetLogFromDatabase(long logId) { using var connection = _database.OpenConnection(); @@ -966,6 +1087,23 @@ private sealed class LogRecord public required string ScopeVersion { get; init; } } + private sealed class LogSummaryRecord + { + public required int TotalItemCount { get; init; } + public required bool IsFull { get; init; } + public long? InternalId { get; init; } + public long? TimestampTicks { get; init; } + public int? Severity { get; init; } + public string? Message { get; init; } + public string? SpanId { get; init; } + public string? TraceId { get; init; } + public string? ResourceName { get; init; } + public string? InstanceId { get; init; } + public bool? UninstrumentedPeer { get; init; } + public string? ExceptionText { get; init; } + public bool? HasGenAI { get; init; } + } + private sealed class FieldValueRecord { public string? FieldValue { get; init; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index be523b734ca..de86d32565e 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -130,6 +130,7 @@ public void AddTraces(AddContext context, RepeatedField resourceS } public PagedResult GetLogs(GetLogsContext context) => GetLogsFromDatabase(context); + public PagedResult GetLogSummaries(GetLogsContext context) => GetLogSummariesFromDatabase(context); public OtlpLogEntry? GetLog(long logId) => GetLogFromDatabase(logId); public List GetLogsForSpan(string traceId, string spanId) => GetLogsForSpanFromDatabase(traceId, spanId); public List GetLogsForTrace(string traceId) => GetLogsForTraceFromDatabase(traceId); diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs index ae0a358f668..82182156331 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging; using OpenTelemetry.Proto.Logs.V1; +using OpenTelemetry.Proto.Trace.V1; using Xunit; using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers; @@ -90,6 +91,157 @@ public void AddLogs() s => Assert.Equal("Log", s)); } + [Fact] + public void GetLogSummaries_ReturnsPageData() + { + var repository = CreateRepository(); + var addContext = new AddContext(); + repository.AddTraces(addContext, new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(name: "frontend", instanceId: "frontend-1"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "trace", + spanId: "span", + startTime: s_testTime, + endTime: s_testTime.AddMinutes(1), + attributes: [KeyValuePair.Create("gen_ai.provider.name", "test")]) + } + } + } + } + }); + repository.AddLogs(addContext, new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(name: "frontend", instanceId: "frontend-1"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = + { + CreateLogRecord( + time: s_testTime.AddMinutes(1), + message: "direct", + severity: SeverityNumber.Warn, + traceId: "direct-trace", + spanId: "direct-span", + attributes: + [ + KeyValuePair.Create("custom", "match"), + KeyValuePair.Create("exception.stacktrace", "stack trace"), + KeyValuePair.Create("exception.message", "ignored message"), + KeyValuePair.Create("gen_ai.system", "test") + ]), + CreateLogRecord( + time: s_testTime.AddMinutes(2), + message: "linked", + severity: SeverityNumber.Error, + traceId: "trace", + spanId: "span", + attributes: + [ + KeyValuePair.Create("custom", "other"), + KeyValuePair.Create("exception.type", "TestException"), + KeyValuePair.Create("exception.message", "test message") + ]), + CreateLogRecord( + time: s_testTime.AddMinutes(3), + message: "ordinary", + traceId: "ordinary-trace", + spanId: "ordinary-span", + attributes: + [ + KeyValuePair.Create("custom", "other"), + KeyValuePair.Create("gen_ai.system", string.Empty), + KeyValuePair.Create("gen_ai.provider.name", "ignored fallback") + ]) + } + } + } + } + }); + Assert.Equal(0, addContext.FailureCount); + + var context = new GetLogsContext + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + Filters = [] + }; + var summaries = repository.GetLogSummaries(context); + var logs = repository.GetLogs(context); + + Assert.Equal(logs.TotalItemCount, summaries.TotalItemCount); + Assert.Equal(logs.IsFull, summaries.IsFull); + Assert.Collection(summaries.Items, + summary => + { + var log = logs.Items[0]; + Assert.Equal(log.InternalId, summary.InternalId); + Assert.Equal(log.TimeStamp, summary.TimeStamp); + Assert.Equal(log.Severity, summary.Severity); + Assert.Equal(log.Message, summary.Message); + Assert.Equal(log.TraceId, summary.TraceId); + Assert.Equal(log.SpanId, summary.SpanId); + Assert.Equal(new ResourceKey("frontend", "frontend-1"), summary.Resource.ResourceKey); + Assert.Equal("stack trace", summary.ExceptionText); + Assert.True(summary.HasGenAI); + }, + summary => + { + Assert.Equal("linked", summary.Message); + Assert.Equal("TestException: test message", summary.ExceptionText); + Assert.True(summary.HasGenAI); + }, + summary => + { + Assert.Equal("ordinary", summary.Message); + Assert.Null(summary.ExceptionText); + Assert.False(summary.HasGenAI); + }); + + var filtered = repository.GetLogSummaries(new GetLogsContext + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + Filters = + [ + new FieldTelemetryFilter + { + Field = "custom", + Condition = FilterCondition.Equals, + Value = "match" + } + ] + }); + Assert.Equal("direct", Assert.Single(filtered.Items).Message); + Assert.Equal(1, filtered.TotalItemCount); + + var emptyPage = repository.GetLogSummaries(new GetLogsContext + { + ResourceKeys = [], + StartIndex = 10, + Count = 10, + Filters = [] + }); + Assert.Empty(emptyPage.Items); + Assert.Equal(3, emptyPage.TotalItemCount); + } + [Fact] public void GetLogsFieldValues_AllFieldsMatchMaterializedLogs() { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs index 72b81971f32..dc4e8e66a23 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs @@ -841,8 +841,8 @@ public void GetTraces_MultipleResourceKeys_ReturnsMatchingTracesOnly() // Assert - should return traces from both resource1 and resource2, but not resource3 Assert.Collection(traces.PagedResult.Items, - t => AssertId("resource2-inst2", t.TraceId), - t => AssertId("resource1-inst1", t.TraceId)); + t => AssertId("resource1-inst1", t.TraceId), + t => AssertId("resource2-inst2", t.TraceId)); } [Fact] @@ -865,8 +865,8 @@ public void GetSpans_MultipleResourceKeys_ReturnsMatchingSpansOnly() // Assert - should return spans from service1 and service2, not service3 Assert.Collection(result.PagedResult.Items, - s => Assert.Equal("Test span. Id: service2-inst2-1", s.Name), - s => Assert.Equal("Test span. Id: service1-inst1-1", s.Name)); + s => Assert.Equal("Test span. Id: service1-inst1-1", s.Name), + s => Assert.Equal("Test span. Id: service2-inst2-1", s.Name)); } [Fact] @@ -896,8 +896,8 @@ public async Task WatchSpansAsync_MultipleResourceKeys_FiltersCorrectly() // Assert - should receive spans from service1 and service2, not service3 Assert.Collection(receivedSpans, - s => Assert.Equal("Test span. Id: service2-inst2-1", s.Name), - s => Assert.Equal("Test span. Id: service1-inst1-1", s.Name)); + s => Assert.Equal("Test span. Id: service1-inst1-1", s.Name), + s => Assert.Equal("Test span. Id: service2-inst2-1", s.Name)); } [Fact] diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index 047cc1ae014..ede3d03bf2a 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -498,6 +498,29 @@ public PagedResult GetLogs(GetLogsContext context) } } + public PagedResult GetLogSummaries(GetLogsContext context) + { + var result = GetLogs(context); + return new PagedResult + { + Items = result.Items.Select(log => new LogSummary + { + InternalId = log.InternalId, + TimeStamp = log.TimeStamp, + Severity = log.Severity, + Message = log.Message, + SpanId = log.SpanId, + TraceId = log.TraceId, + Resource = log.ResourceView.Resource, + ExceptionText = OtlpLogEntry.GetExceptionText(log), + HasGenAI = global::Aspire.Dashboard.Model.GenAI.GenAIHelpers.HasGenAIAttribute(log.Attributes) || + GetSpan(log.TraceId, log.SpanId) is { } span && global::Aspire.Dashboard.Model.GenAI.GenAIHelpers.HasGenAIAttribute(span.Attributes) + }).ToList(), + TotalItemCount = result.TotalItemCount, + IsFull = result.IsFull + }; + } + public OtlpLogEntry? GetLog(long logId) { _logsLock.EnterReadLock(); From ea5cf90e73ca402d2d751c6ae47fd662ffd6ddf5 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 16 Jul 2026 10:58:55 +0800 Subject: [PATCH 17/87] Show dashboard run selector in header --- .../Controls/DashboardRunSelect.razor | 56 ++++++++++++++ .../Controls/DashboardRunSelect.razor.css | 4 + .../Dialogs/DashboardRunsDialog.razor | 17 ----- .../Dialogs/DashboardRunsDialog.razor.cs | 62 --------------- .../Components/Layout/MainLayout.razor | 31 +++----- .../Components/Layout/MainLayout.razor.cs | 65 +++++----------- .../Components/Layout/MainLayout.razor.css | 6 -- .../Dialogs/DashboardRunsDialogTests.cs | 75 ------------------- .../Layout/MainLayoutTests.cs | 63 +++++++--------- 9 files changed, 118 insertions(+), 261 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor create mode 100644 src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css delete mode 100644 src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor delete mode 100644 src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor.cs delete mode 100644 tests/Aspire.Dashboard.Components.Tests/Dialogs/DashboardRunsDialogTests.cs diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor new file mode 100644 index 00000000000..a26a59c8330 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor @@ -0,0 +1,56 @@ +@using Aspire.Dashboard.Resources +@inject IStringLocalizer Loc +@inject BrowserTimeProvider TimeProvider + + + + @foreach (var run in _runs) + { + @FormatRunOption(run) + } + + +@code { + private IReadOnlyList _runs = []; + + [Parameter, EditorRequired] + public required string SelectedRunId { get; set; } + + [Parameter] + public bool SelectedRunIsCurrent { get; set; } + + [Parameter] + public EventCallback SelectedRunIdChanged { get; set; } + + [Inject] + internal IDashboardRunStore RunStore { get; init; } = null!; + + protected override void OnInitialized() + { + _runs = RunStore.GetRuns(); + } + + private string FormatRunOption(DashboardRunDescriptor run) + { + if (run.IsCurrent) + { + return Loc[nameof(Dialogs.SettingsDialogDashboardRunCurrent)]; + } + + var localStartedAt = TimeZoneInfo.ConvertTime(run.StartedAtUtc, TimeProvider.LocalTimeZone); + return string.Format( + CultureInfo.CurrentCulture, + Loc[nameof(Dialogs.DashboardRunsDialogStartedAt)], + localStartedAt.ToString("g", CultureInfo.CurrentCulture)); + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css new file mode 100644 index 00000000000..2e1f1531a28 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css @@ -0,0 +1,4 @@ +.application-run-select { + width: min(240px, 30vw); + margin-right: 8px; +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor deleted file mode 100644 index 86363a69940..00000000000 --- a/src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor +++ /dev/null @@ -1,17 +0,0 @@ -@using Aspire.Dashboard.Resources -@implements IDialogContentComponent -@inject IStringLocalizer Loc - - - - -

@Loc[nameof(Dialogs.SettingsDialogDashboardRunPageReloads)]

-
-
\ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor.cs deleted file mode 100644 index de133da86ef..00000000000 --- a/src/Aspire.Dashboard/Components/Dialogs/DashboardRunsDialog.razor.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Globalization; -using Aspire.Dashboard.Model; -using Microsoft.AspNetCore.Components; -using Microsoft.FluentUI.AspNetCore.Components; - -namespace Aspire.Dashboard.Components.Dialogs; - -public partial class DashboardRunsDialog : IDialogContentComponent -{ - private IReadOnlyList _runOptions = []; - - [Parameter] - public required DashboardRunsDialogViewModel Content { get; set; } - - [Inject] - public required BrowserTimeProvider TimeProvider { get; init; } - - [Inject] - internal IDashboardRunStore RunStore { get; init; } = null!; - - protected override void OnInitialized() - { - _runOptions = RunStore.GetRuns(); - if (!_runOptions.Any(run => string.Equals(run.RunId, Content.SelectedRun.RunId, StringComparison.Ordinal))) - { - Content.SelectedRun = _runOptions.Single(run => run.IsCurrent); - } - } - - private string FormatRunOption(DashboardRunDescriptor run) - { - if (run.IsCurrent) - { - return Loc[nameof(Dashboard.Resources.Dialogs.SettingsDialogDashboardRunCurrent)]; - } - - var localStartedAt = TimeZoneInfo.ConvertTime(run.StartedAtUtc, TimeProvider.LocalTimeZone); - var startedAtText = localStartedAt.ToString("g", CultureInfo.CurrentCulture); - return string.Format( - CultureInfo.CurrentCulture, - Loc[nameof(Dashboard.Resources.Dialogs.DashboardRunsDialogStartedAt)], - startedAtText); - } - - private string FormatRunsLabel() - { - var applicationName = _runOptions.Single(run => run.IsCurrent).ApplicationName - ?? Loc[nameof(Dashboard.Resources.Dialogs.SettingsDialogDashboardRunUnknownApplication)]; - return string.Format( - CultureInfo.CurrentCulture, - Loc[nameof(Dashboard.Resources.Dialogs.SettingsDialogDashboardRun)], - applicationName); - } -} - -public sealed class DashboardRunsDialogViewModel -{ - internal DashboardRunDescriptor SelectedRun { get; set; } = null!; -} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index f4cf8dbaad0..9818aff2b5f 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -24,17 +24,14 @@ @startText } - @if (RunStore.SupportsRunSelection) - { - - - - }
+ @if (RunStore.SupportsRunSelection && _selectedRun is { } selectedRun) + { + + } @startText } - @if (RunStore.SupportsRunSelection) - { - - - - }
- + @if (RunStore.SupportsRunSelection && _selectedRun is { } selectedRun) + { + + } _runOptions = []; private DashboardRunDescriptor? _selectedRun; [Inject] @@ -90,15 +89,15 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable protected override async Task OnInitializedAsync() { - var runs = RunStore.GetRuns(); + _runOptions = RunStore.GetRuns(); string? selectedRunId = null; if (RunStore.SupportsRunSelection) { var selectedRunResult = await SessionStorage.GetAsync(BrowserStorageKeys.SelectedDashboardRunId); selectedRunId = selectedRunResult is { Success: true } ? selectedRunResult.Value : null; } - _selectedRun = runs.FirstOrDefault(run => string.Equals(run.RunId, selectedRunId, StringComparison.Ordinal)) - ?? runs.Single(run => run.IsCurrent); + _selectedRun = _runOptions.FirstOrDefault(run => string.Equals(run.RunId, selectedRunId, StringComparison.Ordinal)) + ?? _runOptions.Single(run => run.IsCurrent); RunSelection.SelectRun(_selectedRun.IsCurrent ? null : _selectedRun.RunId); // Theme change can be triggered from the settings dialog. This logic applies the new theme to the browser window. @@ -251,6 +250,19 @@ protected override void OnParametersSet() localStartedAt.ToString("g", CultureInfo.CurrentCulture)); } + private async Task SwitchDashboardRunAsync(string? runId) + { + var selectedRun = _runOptions.FirstOrDefault(run => string.Equals(run.RunId, runId, StringComparison.Ordinal)); + if (selectedRun is null || string.Equals(selectedRun.RunId, _selectedRun?.RunId, StringComparison.Ordinal)) + { + return; + } + + _selectedRun = selectedRun; + await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, selectedRun.IsCurrent ? string.Empty : selectedRun.RunId); + NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true); + } + private string? GetVisibleReturnFocusElementId(string? returnFocusElementId, string desktopButtonId) { // Dialog launchers move between the desktop header and the mobile navigation menu. @@ -347,49 +359,6 @@ public async Task LaunchAIAgentsAsync() public Task LaunchSettingsAsync() => LaunchSettingsAsync(GetDefaultReturnFocusElementId(SettingsButtonId)); - private async Task LaunchDashboardRunsAsync() - { - if (!RunStore.SupportsRunSelection) - { - return; - } - - var content = new DashboardRunsDialogViewModel - { - SelectedRun = _selectedRun ?? RunStore.GetRuns().Single(run => run.IsCurrent) - }; - var parameters = new DialogParameters - { - Title = DialogsLoc[nameof(Resources.Dialogs.DashboardRunsDialogTitle)], - PrimaryAction = DialogsLoc[nameof(Resources.Dialogs.InteractionButtonOk)], - SecondaryAction = DialogsLoc[nameof(Resources.Dialogs.InteractionButtonCancel)], - TrapFocus = true, - Modal = true, - Alignment = HorizontalAlignment.Center, - Width = "500px", - Height = "auto", - Id = DashboardRunsDialogId, - OnDialogClosing = EventCallback.Factory.Create(this, _ => HandleDialogClose(DashboardRunsButtonId)), - OnDialogResult = EventCallback.Factory.Create(this, async result => - { - if (result.Cancelled || string.Equals(content.SelectedRun.RunId, _selectedRun?.RunId, StringComparison.Ordinal)) - { - return; - } - - await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, content.SelectedRun.IsCurrent ? string.Empty : content.SelectedRun.RunId); - NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true); - }) - }; - - if (!await CloseOpenPageDialogForReplacementAsync(DashboardRunsDialogId).ConfigureAwait(true)) - { - return; - } - - _openPageDialog = await DialogService.ShowDialogAsync(content, parameters).ConfigureAwait(true); - } - private async Task LaunchSettingsAsync(string? returnFocusElementId) { var parameters = new DialogParameters diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css index 726ab2748c2..4254f43fdfb 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css @@ -201,12 +201,6 @@ white-space: nowrap; } -::deep .header-gutters .application-run-button::part(control) { - width: 32px; - height: 32px; - padding: 0; -} - ::deep .header-gutters .header-button { height: 100%; display: block; diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/DashboardRunsDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/DashboardRunsDialogTests.cs deleted file mode 100644 index e693f54578c..00000000000 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/DashboardRunsDialogTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Globalization; -using Aspire.Dashboard.Components.Dialogs; -using Aspire.Dashboard.Components.Tests.Shared; -using Aspire.Dashboard.Model; -using Aspire.Dashboard.Tests.Shared; -using Aspire.Dashboard.Utils; -using Bunit; -using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.FluentUI.AspNetCore.Components; -using Xunit; - -namespace Aspire.Dashboard.Components.Tests.Dialogs; - -public class DashboardRunsDialogTests : DashboardTestContext -{ - [Fact] - public async Task SelectHistoricalRun_OnlyUpdatesPendingSelection() - { - var historicalRun = new DashboardRunDescriptor( - RunId: "historical", - StartedAtUtc: new DateTimeOffset(2025, 1, 2, 12, 30, 0, TimeSpan.Zero), - EndedAtUtc: new DateTimeOffset(2025, 1, 2, 13, 30, 0, TimeSpan.Zero), - CleanShutdown: true, - ApplicationName: "TestApp", - DatabasePath: string.Empty, - IsCurrent: false); - var runStore = new FluentUISetupHelpers.TestDashboardRunStore( - [ - new( - RunId: "current", - StartedAtUtc: DateTimeOffset.UnixEpoch, - EndedAtUtc: null, - CleanShutdown: false, - ApplicationName: "TestApp", - DatabasePath: string.Empty, - IsCurrent: true), - historicalRun - ]); - string? storedRunId = null; - var sessionStorage = new TestSessionStorage - { - OnSetAsync = (key, value) => - { - Assert.Equal(BrowserStorageKeys.SelectedDashboardRunId, key); - storedRunId = Assert.IsType(value); - } - }; - - FluentUISetupHelpers.AddCommonDashboardServices(this, sessionStorage: sessionStorage, dashboardRunStore: runStore); - FluentUISetupHelpers.SetupFluentUIComponents(this); - FluentUISetupHelpers.SetupFluentInputLabel(this); - FluentUISetupHelpers.SetupFluentList(this); - FluentUISetupHelpers.SetupFluentCombobox(this); - - var content = new DashboardRunsDialogViewModel { SelectedRun = runStore.GetRuns().Single(run => run.IsCurrent) }; - var cut = RenderComponent(builder => builder.Add(component => component.Content, content)); - var select = Assert.Single(cut.FindComponents>()); - - Assert.Equal("TestApp runs:", select.Instance.Label); - Assert.Equal("Current", select.Instance.OptionText(runStore.GetRuns().Single(run => run.IsCurrent))); - var localStartedAt = TimeZoneInfo.ConvertTime(historicalRun.StartedAtUtc, Services.GetRequiredService().LocalTimeZone); - Assert.Equal($"Started {localStartedAt.ToString("g", CultureInfo.CurrentCulture)}", select.Instance.OptionText(historicalRun)); - - await cut.InvokeAsync(() => select.Instance.SelectedOptionChanged.InvokeAsync(historicalRun)); - - Assert.Same(historicalRun, content.SelectedRun); - Assert.Null(storedRunId); - var navigationManager = Services.GetRequiredService(); - Assert.Empty(new Uri(navigationManager.Uri).Query); - } -} \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index 569a275482a..611ec04b18e 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -1,6 +1,7 @@ // 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.Components.Controls; using Aspire.Dashboard.Components.Layout; using Aspire.Dashboard.Components.Resize; using Aspire.Dashboard.Components.Tests.Shared; @@ -18,8 +19,6 @@ using Microsoft.FluentUI.AspNetCore.Components.Components.Tooltip; using Microsoft.JSInterop; using Xunit; -using DashboardRunsDialog = Aspire.Dashboard.Components.Dialogs.DashboardRunsDialog; -using DashboardRunsDialogViewModel = Aspire.Dashboard.Components.Dialogs.DashboardRunsDialogViewModel; namespace Aspire.Dashboard.Components.Tests.Layout; @@ -241,34 +240,31 @@ public async Task HeaderDialogClose_RestoresFocusToLaunchButton(bool isDesktop, }); } - [Fact] - public void DashboardRunsButton_Click_OpensDashboardRunsDialog() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DashboardRunSelect_Supported_IsDisplayedBeforeHeaderButtons(bool isDesktop) { - DialogParameters? capturedParameters = null; - TestDialogService? dialogService = null; - dialogService = new TestDialogService(onShowDialog: (_, parameters) => - { - capturedParameters = parameters; - return Task.FromResult(new DialogReference(parameters.Id, dialogService!)); - }); - - SetupMainLayoutServices(dialogService: dialogService); + SetupMainLayoutServices(); var cut = RenderComponent(builder => { - builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); + builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: isDesktop, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); - cut.Find("#dashboard-runs-button").Click(); + var existingButtonId = isDesktop ? "dashboard-help-button" : "dashboard-navigation-button"; - Assert.NotNull(capturedParameters); - Assert.Equal(nameof(DashboardRunsDialog), capturedParameters.Id); + Assert.Single(cut.FindComponents()); + Assert.Contains("id=\"dashboard-runs-select\"", cut.Markup, StringComparison.Ordinal); + Assert.True( + cut.Markup.IndexOf("id=\"dashboard-runs-select\"", StringComparison.Ordinal) < + cut.Markup.IndexOf($"id=\"{existingButtonId}\"", StringComparison.Ordinal)); } [Theory] [InlineData(true)] [InlineData(false)] - public void DashboardRunsButton_Unsupported_IsHiddenAndStaleSelectionIsIgnored(bool isDesktop) + public void DashboardRunSelect_Unsupported_IsHiddenAndStaleSelectionIsIgnored(bool isDesktop) { var runStore = new FluentUISetupHelpers.TestDashboardRunStore( [ @@ -295,13 +291,13 @@ public void DashboardRunsButton_Unsupported_IsHiddenAndStaleSelectionIsIgnored(b new ViewportInformation(IsDesktop: isDesktop, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); - Assert.Empty(cut.FindAll("#dashboard-runs-button")); + Assert.Empty(cut.FindAll("#dashboard-runs-select")); var runSelection = Assert.IsType(Services.GetRequiredService()); Assert.Null(runSelection.SelectedRunId); } [Fact] - public async Task DashboardRunsDialog_OkStoresSelectionAndReloadsWithoutQueryString() + public async Task DashboardRunSelect_ChangeStoresSelectionAndReloadsWithoutQueryString() { var historicalRun = new DashboardRunDescriptor( RunId: "historical", @@ -328,26 +324,22 @@ public async Task DashboardRunsDialog_OkStoresSelectionAndReloadsWithoutQueryStr { OnSetAsync = (_, value) => storedRunId = Assert.IsType(value) }; - DashboardRunsDialogViewModel? content = null; - DialogParameters? capturedParameters = null; - TestDialogService? dialogService = null; - dialogService = new TestDialogService(onShowDialog: (dialogContent, parameters) => - { - content = Assert.IsType(dialogContent); - capturedParameters = parameters; - return Task.FromResult(new DialogReference(parameters.Id, dialogService!)); - }); - - SetupMainLayoutServices(dialogService: dialogService, dashboardRunStore: runStore, sessionStorage: sessionStorage); + SetupMainLayoutServices(dashboardRunStore: runStore, sessionStorage: sessionStorage); var cut = RenderComponent(builder => { builder.Add(component => component.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); - cut.Find("#dashboard-runs-button").Click(); - content!.SelectedRun = historicalRun; - await cut.InvokeAsync(() => capturedParameters!.OnDialogResult.InvokeAsync(DialogResult.Ok(true))); + var select = Assert.Single(cut.FindComponents>()); + var statusIcon = Assert.Single(select.FindAll(".application-run-status")); + Assert.Equal("start", statusIcon.GetAttribute("slot")); + Assert.Contains("fill: var(--success)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); + Assert.Empty(select.FindAll("fluent-option .application-run-status")); + + await cut.InvokeAsync(() => select.Instance.ValueChanged.InvokeAsync(historicalRun.RunId)); + statusIcon = Assert.Single(select.FindAll(".application-run-status")); + Assert.Contains("fill: var(--warning)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); Assert.Equal("historical", storedRunId); Assert.Empty(new Uri(Services.GetRequiredService().Uri).Query); } @@ -570,6 +562,9 @@ private void SetupMainLayoutServices( FluentUISetupHelpers.SetupFluentMenu(this); FluentUISetupHelpers.SetupFluentAnchoredRegion(this); FluentUISetupHelpers.SetupFluentDivider(this); + FluentUISetupHelpers.SetupFluentInputLabel(this); + FluentUISetupHelpers.SetupFluentList(this); + FluentUISetupHelpers.SetupFluentCombobox(this); var themeModule = JSInterop.SetupModule("/js/app-theme.js"); From 5efcea4959df33314e9c574f380789c16d040a40 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 16 Jul 2026 11:25:17 +0800 Subject: [PATCH 18/87] Update selector --- .../Controls/DashboardRunSelect.razor | 68 +++++-------------- .../Controls/DashboardRunSelect.razor.cs | 52 ++++++++++++++ .../Controls/DashboardRunSelect.razor.css | 9 ++- .../Components/Layout/MainLayout.razor | 24 ++----- .../Components/Layout/MainLayout.razor.cs | 15 ---- .../Components/Layout/MainLayout.razor.css | 17 +---- .../Layout/MainLayoutTests.cs | 47 ++----------- 7 files changed, 87 insertions(+), 145 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor index a26a59c8330..eea4599afc4 100644 --- a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor @@ -1,56 +1,20 @@ @using Aspire.Dashboard.Resources -@inject IStringLocalizer Loc -@inject BrowserTimeProvider TimeProvider - - - @foreach (var run in _runs) - { - @FormatRunOption(run) - } - - -@code { - private IReadOnlyList _runs = []; - - [Parameter, EditorRequired] - public required string SelectedRunId { get; set; } - - [Parameter] - public bool SelectedRunIsCurrent { get; set; } - - [Parameter] - public EventCallback SelectedRunIdChanged { get; set; } - - [Inject] - internal IDashboardRunStore RunStore { get; init; } = null!; - - protected override void OnInitialized() - { - _runs = RunStore.GetRuns(); - } - - private string FormatRunOption(DashboardRunDescriptor run) - { - if (run.IsCurrent) +
+ + + + @foreach (var run in _runs) { - return Loc[nameof(Dialogs.SettingsDialogDashboardRunCurrent)]; + @FormatRunOption(run) } - - var localStartedAt = TimeZoneInfo.ConvertTime(run.StartedAtUtc, TimeProvider.LocalTimeZone); - return string.Format( - CultureInfo.CurrentCulture, - Loc[nameof(Dialogs.DashboardRunsDialogStartedAt)], - localStartedAt.ToString("g", CultureInfo.CurrentCulture)); - } -} \ No newline at end of file + +
diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs new file mode 100644 index 00000000000..20492fe30e7 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Aspire.Dashboard.Model; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using DialogsResources = Aspire.Dashboard.Resources.Dialogs; + +namespace Aspire.Dashboard.Components.Controls; + +public partial class DashboardRunSelect : ComponentBase +{ + private IReadOnlyList _runs = []; + + [Parameter, EditorRequired] + public required string SelectedRunId { get; set; } + + [Parameter] + public bool SelectedRunIsCurrent { get; set; } + + [Parameter] + public EventCallback SelectedRunIdChanged { get; set; } + + [Inject] + public required IStringLocalizer Loc { get; init; } + + [Inject] + public required BrowserTimeProvider TimeProvider { get; init; } + + [Inject] + internal IDashboardRunStore RunStore { get; init; } = null!; + + protected override void OnInitialized() + { + _runs = RunStore.GetRuns(); + } + + private string FormatRunOption(DashboardRunDescriptor run) + { + if (run.IsCurrent) + { + return Loc[nameof(DialogsResources.SettingsDialogDashboardRunCurrent)]; + } + + var localStartedAt = TimeZoneInfo.ConvertTime(run.StartedAtUtc, TimeProvider.LocalTimeZone); + return string.Format( + CultureInfo.CurrentCulture, + Loc[nameof(DialogsResources.DashboardRunsDialogStartedAt)], + localStartedAt.ToString("g", CultureInfo.CurrentCulture)); + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css index 2e1f1531a28..b5fec43eeb2 100644 --- a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css @@ -1,4 +1,9 @@ -.application-run-select { - width: min(240px, 30vw); +.application-run-select ::deep fluent-select { + min-width: 225px; + width: min(225px, 30vw); margin-right: 8px; +} + +.application-run-select ::deep fluent-select::part(indicator) { + display: none; } \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index 9818aff2b5f..ba8efeddfdc 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -16,15 +16,9 @@ @if (ViewportInformation.IsDesktop) { -
- -
+
@if (RunStore.SupportsRunSelection && _selectedRun is { } selectedRun) { @@ -67,15 +61,9 @@ else { -
- -
+
@if (RunStore.SupportsRunSelection && _selectedRun is { } selectedRun) { diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index efbff603c0d..73f2f12832c 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Globalization; using System.Text; using Aspire.Dashboard.Components.Dialogs; using Aspire.Dashboard.Components.Pages; @@ -236,20 +235,6 @@ protected override void OnParametersSet() private string GetDefaultReturnFocusElementId(string desktopButtonId) => ViewportInformation.IsDesktop ? desktopButtonId : NavigationButtonId; - private string? GetHistoricalRunStartText() - { - if (_selectedRun is not { IsCurrent: false } selectedRun) - { - return null; - } - - var localStartedAt = TimeZoneInfo.ConvertTime(selectedRun.StartedAtUtc, TimeProvider.LocalTimeZone); - return string.Format( - CultureInfo.CurrentCulture, - DialogsLoc[nameof(Resources.Dialogs.DashboardRunsDialogStartedAt)], - localStartedAt.ToString("g", CultureInfo.CurrentCulture)); - } - private async Task SwitchDashboardRunAsync(string? runId) { var selectedRun = _runOptions.FirstOrDefault(run => string.Equals(run.RunId, runId, StringComparison.Ordinal)); diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css index 4254f43fdfb..92a56860df0 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css @@ -56,7 +56,7 @@ margin-bottom: 0; } - ::deep.layout > header > .header-gutters > .application-header > fluent-anchor { + ::deep.layout > header > .header-gutters > fluent-anchor { font-size: var(--type-ramp-plus-2-font-size); } @@ -152,7 +152,7 @@ margin-bottom: 0; } - ::deep.layout > header > .header-gutters > .application-header > fluent-anchor { + ::deep.layout > header > .header-gutters > fluent-anchor { font-size: var(--type-ramp-plus-2-font-size); } @@ -183,24 +183,11 @@ ::deep.layout > header > .header-gutters > fluent-button[appearance=stealth]:not(:hover)::part(control), ::deep.layout > header > .header-gutters > fluent-anchor[appearance=stealth]:not(:hover)::part(control), -::deep.layout > header > .header-gutters > .application-header > fluent-anchor[appearance=stealth]:not(:hover)::part(control), ::deep.layout > header > .header-gutters > fluent-anchor[appearance=stealth].logo::part(control), ::deep.layout > .aspire-icon fluent-anchor[appearance=stealth].logo::part(control) { background-color: var(--neutral-layer-4); } -::deep .header-gutters .application-header { - display: flex; - align-items: center; - min-width: 0; -} - -::deep .header-gutters .application-run-start { - margin-left: 8px; - font-size: var(--type-ramp-base-font-size); - white-space: nowrap; -} - ::deep .header-gutters .header-button { height: 100%; display: block; diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index 611ec04b18e..6ef59320d8d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -255,9 +255,9 @@ public void DashboardRunSelect_Supported_IsDisplayedBeforeHeaderButtons(bool isD var existingButtonId = isDesktop ? "dashboard-help-button" : "dashboard-navigation-button"; Assert.Single(cut.FindComponents()); - Assert.Contains("id=\"dashboard-runs-select\"", cut.Markup, StringComparison.Ordinal); + Assert.Contains("class=\"application-run-select\"", cut.Markup, StringComparison.Ordinal); Assert.True( - cut.Markup.IndexOf("id=\"dashboard-runs-select\"", StringComparison.Ordinal) < + cut.Markup.IndexOf("class=\"application-run-select\"", StringComparison.Ordinal) < cut.Markup.IndexOf($"id=\"{existingButtonId}\"", StringComparison.Ordinal)); } @@ -291,7 +291,7 @@ public void DashboardRunSelect_Unsupported_IsHiddenAndStaleSelectionIsIgnored(bo new ViewportInformation(IsDesktop: isDesktop, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); - Assert.Empty(cut.FindAll("#dashboard-runs-select")); + Assert.Empty(cut.FindComponents()); var runSelection = Assert.IsType(Services.GetRequiredService()); Assert.Null(runSelection.SelectedRunId); } @@ -335,6 +335,7 @@ public async Task DashboardRunSelect_ChangeStoresSelectionAndReloadsWithoutQuery Assert.Equal("start", statusIcon.GetAttribute("slot")); Assert.Contains("fill: var(--success)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); Assert.Empty(select.FindAll("fluent-option .application-run-status")); + Assert.True(select.Find("[slot='indicator']").HasAttribute("hidden")); await cut.InvokeAsync(() => select.Instance.ValueChanged.InvokeAsync(historicalRun.RunId)); @@ -344,46 +345,6 @@ public async Task DashboardRunSelect_ChangeStoresSelectionAndReloadsWithoutQuery Assert.Empty(new Uri(Services.GetRequiredService().Uri).Query); } - [Fact] - public void HistoricalRun_DisplaysLocalStartTimeAfterApplicationName() - { - var timeProvider = new TestTimeProvider(); - var runStore = new FluentUISetupHelpers.TestDashboardRunStore( - [ - new( - RunId: "current", - StartedAtUtc: DateTimeOffset.UnixEpoch, - EndedAtUtc: null, - CleanShutdown: false, - ApplicationName: "TestApp", - DatabasePath: string.Empty, - IsCurrent: true), - new( - RunId: "historical", - StartedAtUtc: new DateTimeOffset(2025, 1, 2, 12, 30, 0, TimeSpan.Zero), - EndedAtUtc: new DateTimeOffset(2025, 1, 2, 13, 30, 0, TimeSpan.Zero), - CleanShutdown: true, - ApplicationName: "TestApp", - DatabasePath: string.Empty, - IsCurrent: false) - ]); - - var sessionStorage = new TestSessionStorage - { - OnGetAsync = key => key == BrowserStorageKeys.SelectedDashboardRunId - ? (true, "historical") - : (false, null) - }; - SetupMainLayoutServices(browserTimeProvider: timeProvider, dashboardRunStore: runStore, sessionStorage: sessionStorage); - - var cut = RenderComponent(builder => - { - builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); - }); - - Assert.Equal("Started 1/2/2025 1:30 PM", cut.Find(".application-run-start").TextContent, ignoreWhiteSpaceDifferences: true); - } - [Fact] public async Task RunSelectionPending_DoesNotRenderBody() { From f44c609cda1664b178225e3fcbaef022a883af8b Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 16 Jul 2026 18:34:52 +0800 Subject: [PATCH 19/87] Switch dashboard runs without reloading --- .../Controls/DashboardRunSelect.razor | 6 +- .../Controls/DashboardRunSelect.razor.cs | 12 +-- .../Controls/DashboardRunSelect.razor.css | 4 +- .../Components/Layout/MainLayout.razor | 20 ++-- .../Components/Layout/MainLayout.razor.cs | 17 +++- .../DashboardWebApplication.cs | 4 +- .../Storage/SelectedTelemetryRepository.cs | 91 +++++++++++++++++++ .../Resources/Dialogs.Designer.cs | 63 ------------- src/Aspire.Dashboard/Resources/Dialogs.resx | 23 ----- .../Resources/Layout.Designer.cs | 20 +++- src/Aspire.Dashboard/Resources/Layout.resx | 6 ++ .../Resources/xlf/Dialogs.cs.xlf | 35 ------- .../Resources/xlf/Dialogs.de.xlf | 35 ------- .../Resources/xlf/Dialogs.es.xlf | 35 ------- .../Resources/xlf/Dialogs.fr.xlf | 35 ------- .../Resources/xlf/Dialogs.it.xlf | 35 ------- .../Resources/xlf/Dialogs.ja.xlf | 35 ------- .../Resources/xlf/Dialogs.ko.xlf | 35 ------- .../Resources/xlf/Dialogs.pl.xlf | 35 ------- .../Resources/xlf/Dialogs.pt-BR.xlf | 35 ------- .../Resources/xlf/Dialogs.ru.xlf | 35 ------- .../Resources/xlf/Dialogs.tr.xlf | 35 ------- .../Resources/xlf/Dialogs.zh-Hans.xlf | 35 ------- .../Resources/xlf/Dialogs.zh-Hant.xlf | 35 ------- .../Resources/xlf/Layout.cs.xlf | 10 ++ .../Resources/xlf/Layout.de.xlf | 10 ++ .../Resources/xlf/Layout.es.xlf | 10 ++ .../Resources/xlf/Layout.fr.xlf | 10 ++ .../Resources/xlf/Layout.it.xlf | 10 ++ .../Resources/xlf/Layout.ja.xlf | 10 ++ .../Resources/xlf/Layout.ko.xlf | 10 ++ .../Resources/xlf/Layout.pl.xlf | 10 ++ .../Resources/xlf/Layout.pt-BR.xlf | 10 ++ .../Resources/xlf/Layout.ru.xlf | 10 ++ .../Resources/xlf/Layout.tr.xlf | 10 ++ .../Resources/xlf/Layout.zh-Hans.xlf | 10 ++ .../Resources/xlf/Layout.zh-Hant.xlf | 10 ++ .../Layout/MainLayoutTests.cs | 39 +++++++- .../Shared/LifecycleTestComponent.cs | 25 +++++ .../Model/DashboardDataSourceTests.cs | 12 ++- 40 files changed, 354 insertions(+), 573 deletions(-) create mode 100644 src/Aspire.Dashboard/Otlp/Storage/SelectedTelemetryRepository.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Shared/LifecycleTestComponent.cs diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor index eea4599afc4..d96d8f16d3e 100644 --- a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor @@ -1,11 +1,9 @@ -@using Aspire.Dashboard.Resources -
_runs = []; + private string RunSelectAriaLabel => Loc[nameof(LayoutResources.DashboardRunSelectAriaLabel)]; [Parameter, EditorRequired] public required string SelectedRunId { get; set; } @@ -23,7 +24,7 @@ public partial class DashboardRunSelect : ComponentBase public EventCallback SelectedRunIdChanged { get; set; } [Inject] - public required IStringLocalizer Loc { get; init; } + public required IStringLocalizer Loc { get; init; } [Inject] public required BrowserTimeProvider TimeProvider { get; init; } @@ -40,13 +41,10 @@ private string FormatRunOption(DashboardRunDescriptor run) { if (run.IsCurrent) { - return Loc[nameof(DialogsResources.SettingsDialogDashboardRunCurrent)]; + return Loc[nameof(LayoutResources.DashboardRunSelectCurrent)]; } var localStartedAt = TimeZoneInfo.ConvertTime(run.StartedAtUtc, TimeProvider.LocalTimeZone); - return string.Format( - CultureInfo.CurrentCulture, - Loc[nameof(DialogsResources.DashboardRunsDialogStartedAt)], - localStartedAt.ToString("g", CultureInfo.CurrentCulture)); + return localStartedAt.ToString("g", CultureInfo.CurrentCulture); } } \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css index b5fec43eeb2..57810988bb7 100644 --- a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css @@ -1,6 +1,6 @@ .application-run-select ::deep fluent-select { - min-width: 225px; - width: min(225px, 30vw); + min-width: 200px; + width: min(200px, 30vw); margin-right: 8px; } diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index ba8efeddfdc..cf62989cdfa 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -22,7 +22,8 @@
@if (RunStore.SupportsRunSelection && _selectedRun is { } selectedRun) { - } @@ -67,7 +68,8 @@
@if (RunStore.SupportsRunSelection && _selectedRun is { } selectedRun) { - } @@ -98,9 +100,11 @@
- @if (_selectedRun is not null) + @if (!_isSwitchingRuns && _selectedRun is { } selectedRun) { - @Body + + @Body + }
@@ -108,10 +112,12 @@ - @if (_selectedRun is not null) + @if (!_isSwitchingRuns && _selectedRun is not null) { - - + + + + }
@Loc[nameof(Layout.MainLayoutUnhandledErrorMessage)] diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 73f2f12832c..4adcab5703c 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -18,6 +18,7 @@ namespace Aspire.Dashboard.Components.Layout; public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable { private bool _isNavMenuOpen; + private bool _isSwitchingRuns; private IDisposable? _themeChangedSubscription; private IDisposable? _locationChangingRegistration; @@ -243,9 +244,21 @@ private async Task SwitchDashboardRunAsync(string? runId) return; } - _selectedRun = selectedRun; + _isSwitchingRuns = true; + await InvokeAsync(StateHasChanged); + + try + { + RunSelection.SelectRun(selectedRun.IsCurrent ? null : selectedRun.RunId); + _selectedRun = selectedRun; + } + finally + { + _isSwitchingRuns = false; + await InvokeAsync(StateHasChanged); + } + await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, selectedRun.IsCurrent ? string.Empty : selectedRun.RunId); - NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true); } private string? GetVisibleReturnFocusElementId(string? returnFocusElementId, string desktopButtonId) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index a889da1ddf7..4f65daac559 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -310,7 +310,7 @@ public DashboardWebApplication( services.GetRequiredService(), services.GetServices()); }); - builder.Services.AddScoped(services => services.GetRequiredService().TelemetryRepository); + builder.Services.AddScoped(); builder.Services.AddSingleton(services => { return new SqliteResourceRepository( @@ -319,7 +319,7 @@ public DashboardWebApplication( services.GetRequiredService()); }); builder.Services.AddSingleton(services => services.GetRequiredService()); - builder.Services.AddScoped(services => services.GetRequiredService().ResourceRepository); + builder.Services.AddScoped(services => services.GetRequiredService()); builder.Services.AddTransient(); builder.Services.AddTransient(services => new OtlpLogsService( diff --git a/src/Aspire.Dashboard/Otlp/Storage/SelectedTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SelectedTelemetryRepository.cs new file mode 100644 index 00000000000..19b34354943 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/SelectedTelemetryRepository.cs @@ -0,0 +1,91 @@ +// 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 Google.Protobuf.Collections; +using Microsoft.FluentUI.AspNetCore.Components; +using OpenTelemetry.Proto.Logs.V1; +using OpenTelemetry.Proto.Metrics.V1; +using OpenTelemetry.Proto.Trace.V1; + +namespace Aspire.Dashboard.Otlp.Storage; + +/// +/// Forwards telemetry operations to the repository for the selected dashboard run. +/// +internal sealed class SelectedTelemetryRepository(DashboardDataSource dataSource) : ITelemetryRepository, IMetricTelemetryRepository +{ + private ITelemetryRepository Repository => dataSource.TelemetryRepository; + + public bool HasDisplayedMaxLogLimitMessage + { + get => Repository.HasDisplayedMaxLogLimitMessage; + set => Repository.HasDisplayedMaxLogLimitMessage = value; + } + + public Message? MaxLogLimitMessage + { + get => Repository.MaxLogLimitMessage; + set => Repository.MaxLogLimitMessage = value; + } + + public bool HasDisplayedMaxTraceLimitMessage + { + get => Repository.HasDisplayedMaxTraceLimitMessage; + set => Repository.HasDisplayedMaxTraceLimitMessage = value; + } + + public Message? MaxTraceLimitMessage + { + get => Repository.MaxTraceLimitMessage; + set => Repository.MaxTraceLimitMessage = value; + } + + public List GetResources(bool includeUninstrumentedPeers = false) => Repository.GetResources(includeUninstrumentedPeers); + public List GetResourcesByName(string name, bool includeUninstrumentedPeers = false) => Repository.GetResourcesByName(name, includeUninstrumentedPeers); + public OtlpResource? GetResourceByCompositeName(string compositeName) => Repository.GetResourceByCompositeName(compositeName); + public OtlpResource? GetResource(ResourceKey key) => Repository.GetResource(key); + public List GetResources(ResourceKey key, bool includeUninstrumentedPeers = false) => Repository.GetResources(key, includeUninstrumentedPeers); + public Dictionary GetResourceUnviewedErrorLogsCount() => Repository.GetResourceUnviewedErrorLogsCount(); + public void MarkViewedErrorLogs(ResourceKey? key) => Repository.MarkViewedErrorLogs(key); + public Subscription OnNewResources(Func callback) => Repository.OnNewResources(callback); + public Subscription OnNewLogs(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback) => Repository.OnNewLogs(resourceKey, subscriptionType, callback); + public Subscription OnNewMetrics(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback) => Repository.OnNewMetrics(resourceKey, subscriptionType, callback); + public Subscription OnNewTraces(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback) => Repository.OnNewTraces(resourceKey, subscriptionType, callback); + public void AddLogs(AddContext context, RepeatedField resourceLogs) => Repository.AddLogs(context, resourceLogs); + public void AddMetrics(AddContext context, RepeatedField resourceMetrics) => Repository.AddMetrics(context, resourceMetrics); + public void AddTraces(AddContext context, RepeatedField resourceSpans) => Repository.AddTraces(context, resourceSpans); + public PagedResult GetLogs(GetLogsContext context) => Repository.GetLogs(context); + public PagedResult GetLogSummaries(GetLogsContext context) => Repository.GetLogSummaries(context); + public OtlpLogEntry? GetLog(long logId) => Repository.GetLog(logId); + public List GetLogsForSpan(string traceId, string spanId) => Repository.GetLogsForSpan(traceId, spanId); + public List GetLogsForTrace(string traceId) => Repository.GetLogsForTrace(traceId); + public List GetLogPropertyKeys(ResourceKey? resourceKey) => Repository.GetLogPropertyKeys(resourceKey); + public List GetTracePropertyKeys(ResourceKey? resourceKey) => Repository.GetTracePropertyKeys(resourceKey); + public GetTracesResponse GetTraces(GetTracesRequest context) => Repository.GetTraces(context); + public GetTraceSummariesResponse GetTraceSummaries(GetTracesRequest context) => Repository.GetTraceSummaries(context); + public GetSpansResponse GetSpans(GetSpansRequest context) => Repository.GetSpans(context); + public Dictionary GetTraceFieldValues(string attributeName) => Repository.GetTraceFieldValues(attributeName); + public Dictionary GetLogsFieldValues(string attributeName) => Repository.GetLogsFieldValues(attributeName); + public bool HasUpdatedTrace(OtlpTrace trace) => Repository.HasUpdatedTrace(trace); + public OtlpTrace? GetTrace(string traceId) => Repository.GetTrace(traceId); + public OtlpSpan? GetSpan(string traceId, string spanId) => Repository.GetSpan(traceId, spanId); + public OtlpResource? GetPeerResource(OtlpSpan span) => Repository.GetPeerResource(span); + public List GetInstrumentsSummaries(ResourceKey key) => Repository.GetInstrumentsSummaries(key); + public OtlpInstrumentData? GetInstrument(GetInstrumentRequest request) => Repository.GetInstrument(request); + public IAsyncEnumerable WatchSpansAsync(WatchSpansRequest request, CancellationToken cancellationToken) => Repository.WatchSpansAsync(request, cancellationToken); + public IAsyncEnumerable WatchLogsAsync(WatchLogsRequest request, CancellationToken cancellationToken) => Repository.WatchLogsAsync(request, cancellationToken); + public void ClearSelectedSignals(Dictionary> selectedResources) => Repository.ClearSelectedSignals(selectedResources); + public void ClearTraces(ResourceKey? resourceKey = null) => Repository.ClearTraces(resourceKey); + public void ClearStructuredLogs(ResourceKey? resourceKey = null) => Repository.ClearStructuredLogs(resourceKey); + public void ClearMetrics(ResourceKey? resourceKey = null) => Repository.ClearMetrics(resourceKey); + + DateTime? IMetricTelemetryRepository.GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) => + Repository.GetInstrumentLatestEndTime(resourceKey, meterName, instrumentName); + + public void Dispose() + { + // DashboardDataSource owns the selected repository and disposes historical instances when selection changes. + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs index ffca273d72c..85b97acd4eb 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Dialogs.Designer.cs @@ -1116,69 +1116,6 @@ public static string SettingsDialogDashboardLogsAndTelemetry { } } - /// - /// Looks up a localized string similar to Select dashboard run. - /// - public static string DashboardRunsDialogLaunchButton { - get { - return ResourceManager.GetString("DashboardRunsDialogLaunchButton", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Started {0}. - /// - public static string DashboardRunsDialogStartedAt { - get { - return ResourceManager.GetString("DashboardRunsDialogStartedAt", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Dashboard runs. - /// - public static string DashboardRunsDialogTitle { - get { - return ResourceManager.GetString("DashboardRunsDialogTitle", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to {0} runs:. - /// - public static string SettingsDialogDashboardRun { - get { - return ResourceManager.GetString("SettingsDialogDashboardRun", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Current. - /// - public static string SettingsDialogDashboardRunCurrent { - get { - return ResourceManager.GetString("SettingsDialogDashboardRunCurrent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Historical runs are read-only. The page will reload when the selected run changes.. - /// - public static string SettingsDialogDashboardRunPageReloads { - get { - return ResourceManager.GetString("SettingsDialogDashboardRunPageReloads", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Aspire. - /// - public static string SettingsDialogDashboardRunUnknownApplication { - get { - return ResourceManager.GetString("SettingsDialogDashboardRunUnknownApplication", resourceCulture); - } - } - /// /// Looks up a localized string similar to Runtime: {0}. /// diff --git a/src/Aspire.Dashboard/Resources/Dialogs.resx b/src/Aspire.Dashboard/Resources/Dialogs.resx index df4d8c806c1..93a016c52f7 100644 --- a/src/Aspire.Dashboard/Resources/Dialogs.resx +++ b/src/Aspire.Dashboard/Resources/Dialogs.resx @@ -159,29 +159,6 @@ Theme - - Select dashboard run - - - Started {0} - {0} is the run start time. - - - Dashboard runs - - - {0} runs: - {0} is the application name. - - - Current - - - Historical runs are read-only. The page will reload when the selected run changes. - - - Aspire - Version: {0} {0} is the dashboard version string diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index 10ff59ca19a..2e728f182af 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -59,7 +59,25 @@ internal Layout() { resourceCulture = value; } } - + + /// + /// Looks up a localized string similar to Select dashboard run. + /// + public static string DashboardRunSelectAriaLabel { + get { + return ResourceManager.GetString("DashboardRunSelectAriaLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Current. + /// + public static string DashboardRunSelectCurrent { + get { + return ResourceManager.GetString("DashboardRunSelectCurrent", resourceCulture); + } + } + /// /// Looks up a localized string similar to Aspire. /// diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index 3dce165420e..6a00b7dccc1 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -180,4 +180,10 @@ AI agents + + Select dashboard run + + + Current + diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf index 70f3be57115..b2e6c8455e5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.cs.xlf @@ -89,21 +89,6 @@ For more information about using AI agents with the dashboard, including setting AI agents - - Select dashboard run - Select dashboard run - - - - Started {0} - Started {0} - {0} is the run start time. - - - Dashboard runs - Dashboard runs - - Close Zavřít @@ -649,26 +634,6 @@ For more information about using AI agents with the dashboard, including setting Protokoly prostředků a telemetrie - - {0} runs: - {0} runs: - {0} is the application name. - - - Current - Current - - - - Historical runs are read-only. The page will reload when the selected run changes. - Historical runs are read-only. The page will reload when the selected run changes. - - - - Aspire - Aspire - - Runtime: {0} Modul runtime: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf index 0cf7ee39da5..c9e3664a8b4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.de.xlf @@ -89,21 +89,6 @@ For more information about using AI agents with the dashboard, including setting AI agents - - Select dashboard run - Select dashboard run - - - - Started {0} - Started {0} - {0} is the run start time. - - - Dashboard runs - Dashboard runs - - Close Schließen @@ -649,26 +634,6 @@ For more information about using AI agents with the dashboard, including setting Ressourcenprotokolle und Telemetrie - - {0} runs: - {0} runs: - {0} is the application name. - - - Current - Current - - - - Historical runs are read-only. The page will reload when the selected run changes. - Historical runs are read-only. The page will reload when the selected run changes. - - - - Aspire - Aspire - - Runtime: {0} Laufzeit: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf index 1f53182c55f..f5e3f5388df 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.es.xlf @@ -89,21 +89,6 @@ For more information about using AI agents with the dashboard, including setting AI agents - - Select dashboard run - Select dashboard run - - - - Started {0} - Started {0} - {0} is the run start time. - - - Dashboard runs - Dashboard runs - - Close Cerrar @@ -649,26 +634,6 @@ For more information about using AI agents with the dashboard, including setting Registros de recursos y telemetría - - {0} runs: - {0} runs: - {0} is the application name. - - - Current - Current - - - - Historical runs are read-only. The page will reload when the selected run changes. - Historical runs are read-only. The page will reload when the selected run changes. - - - - Aspire - Aspire - - Runtime: {0} Runtime: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf index 15dfe235aa1..b0f9201311a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.fr.xlf @@ -89,21 +89,6 @@ For more information about using AI agents with the dashboard, including setting AI agents - - Select dashboard run - Select dashboard run - - - - Started {0} - Started {0} - {0} is the run start time. - - - Dashboard runs - Dashboard runs - - Close Fermer @@ -649,26 +634,6 @@ For more information about using AI agents with the dashboard, including setting Journaux d’activité de ressources et télémétrie - - {0} runs: - {0} runs: - {0} is the application name. - - - Current - Current - - - - Historical runs are read-only. The page will reload when the selected run changes. - Historical runs are read-only. The page will reload when the selected run changes. - - - - Aspire - Aspire - - Runtime: {0} Runtime : {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf index e90bcd9b3e7..bbe37fdcd76 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.it.xlf @@ -89,21 +89,6 @@ For more information about using AI agents with the dashboard, including setting AI agents - - Select dashboard run - Select dashboard run - - - - Started {0} - Started {0} - {0} is the run start time. - - - Dashboard runs - Dashboard runs - - Close Chiudi @@ -649,26 +634,6 @@ For more information about using AI agents with the dashboard, including setting Log delle risorse e telemetria - - {0} runs: - {0} runs: - {0} is the application name. - - - Current - Current - - - - Historical runs are read-only. The page will reload when the selected run changes. - Historical runs are read-only. The page will reload when the selected run changes. - - - - Aspire - Aspire - - Runtime: {0} Runtime: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf index 05d5e624083..b013d625481 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ja.xlf @@ -89,21 +89,6 @@ For more information about using AI agents with the dashboard, including setting AI agents - - Select dashboard run - Select dashboard run - - - - Started {0} - Started {0} - {0} is the run start time. - - - Dashboard runs - Dashboard runs - - Close 閉じる @@ -649,26 +634,6 @@ For more information about using AI agents with the dashboard, including setting リソース ログとテレメトリ - - {0} runs: - {0} runs: - {0} is the application name. - - - Current - Current - - - - Historical runs are read-only. The page will reload when the selected run changes. - Historical runs are read-only. The page will reload when the selected run changes. - - - - Aspire - Aspire - - Runtime: {0} ランタイム: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf index db01d0def43..d4194f4f585 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ko.xlf @@ -89,21 +89,6 @@ For more information about using AI agents with the dashboard, including setting AI agents - - Select dashboard run - Select dashboard run - - - - Started {0} - Started {0} - {0} is the run start time. - - - Dashboard runs - Dashboard runs - - Close 닫기 @@ -649,26 +634,6 @@ For more information about using AI agents with the dashboard, including setting 리소스 로그 및 원격 분석 - - {0} runs: - {0} runs: - {0} is the application name. - - - Current - Current - - - - Historical runs are read-only. The page will reload when the selected run changes. - Historical runs are read-only. The page will reload when the selected run changes. - - - - Aspire - Aspire - - Runtime: {0} 런타임: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf index e979d07602e..83b3a751b7c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pl.xlf @@ -89,21 +89,6 @@ For more information about using AI agents with the dashboard, including setting AI agents - - Select dashboard run - Select dashboard run - - - - Started {0} - Started {0} - {0} is the run start time. - - - Dashboard runs - Dashboard runs - - Close Zamknij @@ -649,26 +634,6 @@ For more information about using AI agents with the dashboard, including setting Dzienniki zasobów i dane telemetryczne - - {0} runs: - {0} runs: - {0} is the application name. - - - Current - Current - - - - Historical runs are read-only. The page will reload when the selected run changes. - Historical runs are read-only. The page will reload when the selected run changes. - - - - Aspire - Aspire - - Runtime: {0} Środowisko uruchomieniowe: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf index f5ef4cb7302..c63e3536bbd 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.pt-BR.xlf @@ -89,21 +89,6 @@ For more information about using AI agents with the dashboard, including setting AI agents - - Select dashboard run - Select dashboard run - - - - Started {0} - Started {0} - {0} is the run start time. - - - Dashboard runs - Dashboard runs - - Close Fechar @@ -649,26 +634,6 @@ For more information about using AI agents with the dashboard, including setting Logs e telemetria do recurso - - {0} runs: - {0} runs: - {0} is the application name. - - - Current - Current - - - - Historical runs are read-only. The page will reload when the selected run changes. - Historical runs are read-only. The page will reload when the selected run changes. - - - - Aspire - Aspire - - Runtime: {0} Tempo de execução: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf index 46329ceb3fe..4b47e0562b6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.ru.xlf @@ -89,21 +89,6 @@ For more information about using AI agents with the dashboard, including setting AI agents - - Select dashboard run - Select dashboard run - - - - Started {0} - Started {0} - {0} is the run start time. - - - Dashboard runs - Dashboard runs - - Close Закрыть @@ -649,26 +634,6 @@ For more information about using AI agents with the dashboard, including setting Журналы ресурсов и телеметрия - - {0} runs: - {0} runs: - {0} is the application name. - - - Current - Current - - - - Historical runs are read-only. The page will reload when the selected run changes. - Historical runs are read-only. The page will reload when the selected run changes. - - - - Aspire - Aspire - - Runtime: {0} Среда выполнения: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf index 0cdd6722177..108cfc53ec4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.tr.xlf @@ -89,21 +89,6 @@ For more information about using AI agents with the dashboard, including setting AI agents - - Select dashboard run - Select dashboard run - - - - Started {0} - Started {0} - {0} is the run start time. - - - Dashboard runs - Dashboard runs - - Close Kapat @@ -649,26 +634,6 @@ For more information about using AI agents with the dashboard, including setting Kaynak günlükleri ve telemetri - - {0} runs: - {0} runs: - {0} is the application name. - - - Current - Current - - - - Historical runs are read-only. The page will reload when the selected run changes. - Historical runs are read-only. The page will reload when the selected run changes. - - - - Aspire - Aspire - - Runtime: {0} Çalışma Zamanı: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf index f36ccb1a428..a07b6d553ae 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hans.xlf @@ -89,21 +89,6 @@ For more information about using AI agents with the dashboard, including setting AI agents - - Select dashboard run - Select dashboard run - - - - Started {0} - Started {0} - {0} is the run start time. - - - Dashboard runs - Dashboard runs - - Close 关闭 @@ -649,26 +634,6 @@ For more information about using AI agents with the dashboard, including setting 资源日志和遥测 - - {0} runs: - {0} runs: - {0} is the application name. - - - Current - Current - - - - Historical runs are read-only. The page will reload when the selected run changes. - Historical runs are read-only. The page will reload when the selected run changes. - - - - Aspire - Aspire - - Runtime: {0} 运行时: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf index 6ef54a17ea3..4d7d545e332 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Dialogs.zh-Hant.xlf @@ -89,21 +89,6 @@ For more information about using AI agents with the dashboard, including setting AI agents - - Select dashboard run - Select dashboard run - - - - Started {0} - Started {0} - {0} is the run start time. - - - Dashboard runs - Dashboard runs - - Close 關閉 @@ -649,26 +634,6 @@ For more information about using AI agents with the dashboard, including setting 資源記錄與遙測 - - {0} runs: - {0} runs: - {0} is the application name. - - - Current - Current - - - - Historical runs are read-only. The page will reload when the selected run changes. - Historical runs are read-only. The page will reload when the selected run changes. - - - - Aspire - Aspire - - Runtime: {0} 執行階段: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index d44bf73252c..b8538a7ba0e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -2,6 +2,16 @@ + + Select dashboard run + Select dashboard run + + + + Current + Current + + Aspire Aspire diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index 42c0c91a7d7..2abe7946bc6 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -2,6 +2,16 @@ + + Select dashboard run + Select dashboard run + + + + Current + Current + + Aspire Aspire diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index 62832e767b7..141386285c3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -2,6 +2,16 @@ + + Select dashboard run + Select dashboard run + + + + Current + Current + + Aspire Aspire diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index 20134e32c30..37afaef92ca 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -2,6 +2,16 @@ + + Select dashboard run + Select dashboard run + + + + Current + Current + + Aspire Aspire diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index ea950947fda..4d1d5ef3b9c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -2,6 +2,16 @@ + + Select dashboard run + Select dashboard run + + + + Current + Current + + Aspire Aspire diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index 50fc0b67c51..de5ae1f81f9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -2,6 +2,16 @@ + + Select dashboard run + Select dashboard run + + + + Current + Current + + Aspire Aspire diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index 738ecacb666..be5164d87c1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -2,6 +2,16 @@ + + Select dashboard run + Select dashboard run + + + + Current + Current + + Aspire Aspire diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index d95ce8d8565..e4a686b811e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -2,6 +2,16 @@ + + Select dashboard run + Select dashboard run + + + + Current + Current + + Aspire Aspire diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index d92aae205b5..55152b91b4a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -2,6 +2,16 @@ + + Select dashboard run + Select dashboard run + + + + Current + Current + + Aspire Aspire diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index d67208252cc..7b202c8a12a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -2,6 +2,16 @@ + + Select dashboard run + Select dashboard run + + + + Current + Current + + Aspire Aspire diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index e07ef114b65..db6fe41e176 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -2,6 +2,16 @@ + + Select dashboard run + Select dashboard run + + + + Current + Current + + Aspire Aspire diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index 4be4bf4d679..f2429d870a8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -2,6 +2,16 @@ + + Select dashboard run + Select dashboard run + + + + Current + Current + + Aspire Aspire diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index e54a199e5a6..3391a0802b3 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -2,6 +2,16 @@ + + Select dashboard run + Select dashboard run + + + + Current + Current + + Aspire Aspire diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index 6ef59320d8d..3f1387a4cb3 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -297,7 +297,7 @@ public void DashboardRunSelect_Unsupported_IsHiddenAndStaleSelectionIsIgnored(bo } [Fact] - public async Task DashboardRunSelect_ChangeStoresSelectionAndReloadsWithoutQueryString() + public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavigation() { var historicalRun = new DashboardRunDescriptor( RunId: "historical", @@ -325,9 +325,18 @@ public async Task DashboardRunSelect_ChangeStoresSelectionAndReloadsWithoutQuery OnSetAsync = (_, value) => storedRunId = Assert.IsType(value) }; SetupMainLayoutServices(dashboardRunStore: runStore, sessionStorage: sessionStorage); + var initializedCount = 0; + var disposedCount = 0; var cut = RenderComponent(builder => { builder.Add(component => component.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); + builder.Add(component => component.Body, bodyBuilder => + { + bodyBuilder.OpenComponent(0); + bodyBuilder.AddComponentParameter(1, nameof(LifecycleTestComponent.Initialized), (Action)(() => initializedCount++)); + bodyBuilder.AddComponentParameter(2, nameof(LifecycleTestComponent.Disposed), (Action)(() => disposedCount++)); + bodyBuilder.CloseComponent(); + }); }); var select = Assert.Single(cut.FindComponents>()); @@ -336,13 +345,33 @@ public async Task DashboardRunSelect_ChangeStoresSelectionAndReloadsWithoutQuery Assert.Contains("fill: var(--success)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); Assert.Empty(select.FindAll("fluent-option .application-run-status")); Assert.True(select.Find("[slot='indicator']").HasAttribute("hidden")); + Assert.Collection( + select.FindAll("fluent-option"), + option => Assert.Equal("Current", option.TextContent), + option => Assert.Equal("1/2/2025 1:30 PM", option.TextContent)); - await cut.InvokeAsync(() => select.Instance.ValueChanged.InvokeAsync(historicalRun.RunId)); + var navigationOccurred = false; + Services.GetRequiredService().LocationChanged += (_, _) => navigationOccurred = true; + select.Find("fluent-select").Change(historicalRun.RunId); - statusIcon = Assert.Single(select.FindAll(".application-run-status")); - Assert.Contains("fill: var(--warning)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); Assert.Equal("historical", storedRunId); - Assert.Empty(new Uri(Services.GetRequiredService().Uri).Query); + var runSelection = Assert.IsType(Services.GetRequiredService()); + Assert.Equal("historical", runSelection.SelectedRunId); + Assert.False(navigationOccurred); + Assert.Equal(2, initializedCount); + Assert.Equal(1, disposedCount); + statusIcon = cut.Find(".application-run-status"); + Assert.Contains("fill: var(--warning)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); + + cut.Find("fluent-select").Change("current"); + + Assert.Equal(string.Empty, storedRunId); + Assert.Null(runSelection.SelectedRunId); + Assert.False(navigationOccurred); + Assert.Equal(3, initializedCount); + Assert.Equal(2, disposedCount); + statusIcon = cut.Find(".application-run-status"); + Assert.Contains("fill: var(--success)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); } [Fact] diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/LifecycleTestComponent.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/LifecycleTestComponent.cs new file mode 100644 index 00000000000..b9d6a4c8f51 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/LifecycleTestComponent.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components; + +namespace Aspire.Dashboard.Components.Tests.Shared; + +internal sealed class LifecycleTestComponent : ComponentBase, IDisposable +{ + [Parameter, EditorRequired] + public required Action Initialized { get; init; } + + [Parameter, EditorRequired] + public required Action Disposed { get; init; } + + protected override void OnInitialized() + { + Initialized(); + } + + public void Dispose() + { + Disposed(); + } +} \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index bbab8153eeb..f5e811d6867 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -314,13 +314,16 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() currentTelemetryRepository, currentResourceRepository, options); + using ITelemetryRepository selectedTelemetryRepository = new SelectedTelemetryRepository(dataSource); + Assert.Empty(selectedTelemetryRepository.GetResources()); + dataSource.SelectRun(historicalRunId); Assert.True(dataSource.IsReadOnly); Assert.Equal(historicalRunId, dataSource.SelectedRun.RunId); Assert.Equal("api", Assert.Single(dataSource.ResourceRepository.GetResources()).Name); - Assert.Equal("TestService", Assert.Single(dataSource.TelemetryRepository.GetResources()).ResourceName); - Assert.Equal("Test Value!", Assert.Single(dataSource.TelemetryRepository.GetLogs(new GetLogsContext + Assert.Equal("TestService", Assert.Single(selectedTelemetryRepository.GetResources()).ResourceName); + Assert.Equal("Test Value!", Assert.Single(selectedTelemetryRepository.GetLogs(new GetLogsContext { ResourceKeys = [], StartIndex = 0, @@ -347,6 +350,11 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() Assert.Equal(DashboardConnectionState.Connected, selectedClient.ConnectionState); Assert.Equal(0, connectionStateChangedCount); await selectedClient.ReconnectAsync(); + + dataSource.SelectRun(runId: null); + + Assert.Empty(selectedTelemetryRepository.GetResources()); + Assert.False(dataSource.IsReadOnly); } [Fact] From 2f8f442b7d454fd0b45a67b508fc564709379dc1 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 17 Jul 2026 05:14:50 +0800 Subject: [PATCH 20/87] Update --- .../Controls/AspireMenuButton.razor | 7 ++- .../Controls/AspireMenuButton.razor.cs | 9 +++ .../Controls/DashboardRunSelect.razor | 26 +++----- .../Controls/DashboardRunSelect.razor.cs | 29 ++++++++- .../Controls/DashboardRunSelect.razor.css | 8 +-- .../Resources/Layout.Designer.cs | 2 +- src/Aspire.Dashboard/Resources/Layout.resx | 2 +- .../Resources/xlf/Layout.cs.xlf | 4 +- .../Resources/xlf/Layout.de.xlf | 4 +- .../Resources/xlf/Layout.es.xlf | 4 +- .../Resources/xlf/Layout.fr.xlf | 4 +- .../Resources/xlf/Layout.it.xlf | 4 +- .../Resources/xlf/Layout.ja.xlf | 4 +- .../Resources/xlf/Layout.ko.xlf | 4 +- .../Resources/xlf/Layout.pl.xlf | 4 +- .../Resources/xlf/Layout.pt-BR.xlf | 4 +- .../Resources/xlf/Layout.ru.xlf | 4 +- .../Resources/xlf/Layout.tr.xlf | 4 +- .../Resources/xlf/Layout.zh-Hans.xlf | 4 +- .../Resources/xlf/Layout.zh-Hant.xlf | 4 +- .../Layout/MainLayoutTests.cs | 62 +++++++++++++++---- 21 files changed, 130 insertions(+), 67 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor index b81cab8bfaa..5bc644e6162 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor @@ -11,7 +11,12 @@ }; } - + + @if (IconStart is not null) + { + + } + @if (string.IsNullOrWhiteSpace(Text)) { diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs index e92ced9e774..be3be50b528 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs @@ -24,6 +24,15 @@ public partial class AspireMenuButton : FluentComponentBase [Parameter] public Icon? IconStart { get; set; } + [Parameter] + public string? IconStartClass { get; set; } + + [Parameter] + public Color? IconStartColor { get; set; } + + [Parameter] + public string? IconStartCustomColor { get; set; } + [Parameter] public Icon? Icon { get; set; } diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor index d96d8f16d3e..2d8f161523e 100644 --- a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor @@ -1,18 +1,12 @@
- - - - @foreach (var run in _runs) - { - @FormatRunOption(run) - } - +
diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs index 189b8614ed0..ccc1d6c4f83 100644 --- a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs @@ -1,10 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Globalization; using Aspire.Dashboard.Model; +using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; +using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; using LayoutResources = Aspire.Dashboard.Resources.Layout; namespace Aspire.Dashboard.Components.Controls; @@ -12,7 +13,9 @@ namespace Aspire.Dashboard.Components.Controls; public partial class DashboardRunSelect : ComponentBase { private IReadOnlyList _runs = []; + private readonly List _menuItems = []; private string RunSelectAriaLabel => Loc[nameof(LayoutResources.DashboardRunSelectAriaLabel)]; + private string SelectedRunText => FormatRunOption(_runs.Single(run => string.Equals(run.RunId, SelectedRunId, StringComparison.Ordinal))); [Parameter, EditorRequired] public required string SelectedRunId { get; set; } @@ -37,6 +40,27 @@ protected override void OnInitialized() _runs = RunStore.GetRuns(); } + protected override void OnParametersSet() + { + _menuItems.Clear(); + foreach (var run in _runs) + { + _menuItems.Add(new MenuButtonItem + { + Text = FormatRunOption(run), + Icon = string.Equals(run.RunId, SelectedRunId, StringComparison.Ordinal) + ? new Icons.Regular.Size16.Checkmark() + : null, + OnClick = () => SelectedRunIdChanged.InvokeAsync(run.RunId) + }); + + if (run.IsCurrent && _runs.Any(candidate => !candidate.IsCurrent)) + { + _menuItems.Add(new MenuButtonItem { IsDivider = true }); + } + } + } + private string FormatRunOption(DashboardRunDescriptor run) { if (run.IsCurrent) @@ -44,7 +68,6 @@ private string FormatRunOption(DashboardRunDescriptor run) return Loc[nameof(LayoutResources.DashboardRunSelectCurrent)]; } - var localStartedAt = TimeZoneInfo.ConvertTime(run.StartedAtUtc, TimeProvider.LocalTimeZone); - return localStartedAt.ToString("g", CultureInfo.CurrentCulture); + return FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, run.StartedAtUtc.UtcDateTime); } } \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css index 57810988bb7..5837f06977f 100644 --- a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.css @@ -1,9 +1,3 @@ -.application-run-select ::deep fluent-select { - min-width: 200px; - width: min(200px, 30vw); +.application-run-select ::deep .application-run-select-button { margin-right: 8px; -} - -.application-run-select ::deep fluent-select::part(indicator) { - display: none; } \ No newline at end of file diff --git a/src/Aspire.Dashboard/Resources/Layout.Designer.cs b/src/Aspire.Dashboard/Resources/Layout.Designer.cs index 2e728f182af..65b1fba6f8e 100644 --- a/src/Aspire.Dashboard/Resources/Layout.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Layout.Designer.cs @@ -70,7 +70,7 @@ public static string DashboardRunSelectAriaLabel { } /// - /// Looks up a localized string similar to Current. + /// Looks up a localized string similar to Live run. /// public static string DashboardRunSelectCurrent { get { diff --git a/src/Aspire.Dashboard/Resources/Layout.resx b/src/Aspire.Dashboard/Resources/Layout.resx index 6a00b7dccc1..c1748bd8648 100644 --- a/src/Aspire.Dashboard/Resources/Layout.resx +++ b/src/Aspire.Dashboard/Resources/Layout.resx @@ -184,6 +184,6 @@ Select dashboard run
- Current + Live run diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf index b8538a7ba0e..652f6e8f31c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf @@ -8,8 +8,8 @@ - Current - Current + Live run + Live run diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf index 2abe7946bc6..215779b31aa 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf @@ -8,8 +8,8 @@ - Current - Current + Live run + Live run diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf index 141386285c3..eee66a748b1 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf @@ -8,8 +8,8 @@ - Current - Current + Live run + Live run diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf index 37afaef92ca..8159e5e67c5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf @@ -8,8 +8,8 @@ - Current - Current + Live run + Live run diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf index 4d1d5ef3b9c..6afad191832 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf @@ -8,8 +8,8 @@ - Current - Current + Live run + Live run diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf index de5ae1f81f9..74101dc718a 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf @@ -8,8 +8,8 @@ - Current - Current + Live run + Live run diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf index be5164d87c1..37a25ed8b2d 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf @@ -8,8 +8,8 @@ - Current - Current + Live run + Live run diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf index e4a686b811e..5bedfdd6baa 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pl.xlf @@ -8,8 +8,8 @@ - Current - Current + Live run + Live run diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf index 55152b91b4a..6188c593f04 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.pt-BR.xlf @@ -8,8 +8,8 @@ - Current - Current + Live run + Live run diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf index 7b202c8a12a..f3e5916681b 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.ru.xlf @@ -8,8 +8,8 @@ - Current - Current + Live run + Live run diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf index db6fe41e176..9fd1bdfaed9 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.tr.xlf @@ -8,8 +8,8 @@ - Current - Current + Live run + Live run diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf index f2429d870a8..f36eb079eb8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hans.xlf @@ -8,8 +8,8 @@ - Current - Current + Live run + Live run diff --git a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf index 3391a0802b3..f84a66c9960 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Layout.zh-Hant.xlf @@ -8,8 +8,8 @@ - Current - Current + Live run + Live run diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index 3f1387a4cb3..13f43c4af56 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -19,6 +19,7 @@ using Microsoft.FluentUI.AspNetCore.Components.Components.Tooltip; using Microsoft.JSInterop; using Xunit; +using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; namespace Aspire.Dashboard.Components.Tests.Layout; @@ -325,6 +326,7 @@ public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavig OnSetAsync = (_, value) => storedRunId = Assert.IsType(value) }; SetupMainLayoutServices(dashboardRunStore: runStore, sessionStorage: sessionStorage); + JSInterop.SetupVoid("focusElement", _ => true); var initializedCount = 0; var disposedCount = 0; var cut = RenderComponent(builder => @@ -339,20 +341,35 @@ public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavig }); }); - var select = Assert.Single(cut.FindComponents>()); - var statusIcon = Assert.Single(select.FindAll(".application-run-status")); + var runSelect = cut.FindComponent(); + var menuButton = runSelect.FindComponent(); + var statusIcon = runSelect.Find(".application-run-status"); Assert.Equal("start", statusIcon.GetAttribute("slot")); Assert.Contains("fill: var(--success)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); - Assert.Empty(select.FindAll("fluent-option .application-run-status")); - Assert.True(select.Find("[slot='indicator']").HasAttribute("hidden")); + Assert.Equal("Live run", menuButton.Instance.Text); + Assert.True(menuButton.Instance.HideIcon); Assert.Collection( - select.FindAll("fluent-option"), - option => Assert.Equal("Current", option.TextContent), - option => Assert.Equal("1/2/2025 1:30 PM", option.TextContent)); + menuButton.Instance.Items, + item => + { + Assert.Equal("Live run", item.Text); + Assert.IsType(item.Icon); + }, + item => Assert.True(item.IsDivider), + item => + { + Assert.Equal("1/2/2025 1:30:00 PM", item.Text); + Assert.Null(item.Icon); + }); var navigationOccurred = false; Services.GetRequiredService().LocationChanged += (_, _) => navigationOccurred = true; - select.Find("fluent-select").Change(historicalRun.RunId); + runSelect.Find("fluent-button").Click(); + var menuItems = runSelect.WaitForElements("fluent-menu-item"); + Assert.Single(runSelect.FindAll("fluent-divider")); + Assert.Single(menuItems[0].QuerySelectorAll("span[slot='start']")); + Assert.Empty(menuItems[1].QuerySelectorAll("span[slot='start']")); + menuItems[1].Click(); Assert.Equal("historical", storedRunId); var runSelection = Assert.IsType(Services.GetRequiredService()); @@ -360,18 +377,39 @@ public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavig Assert.False(navigationOccurred); Assert.Equal(2, initializedCount); Assert.Equal(1, disposedCount); - statusIcon = cut.Find(".application-run-status"); + runSelect = cut.FindComponent(); + menuButton = runSelect.FindComponent(); + statusIcon = runSelect.Find(".application-run-status"); Assert.Contains("fill: var(--warning)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); - - cut.Find("fluent-select").Change("current"); + Assert.Equal("1/2/2025 1:30:00 PM", menuButton.Instance.Text); + Assert.Collection( + menuButton.Instance.Items, + item => Assert.Null(item.Icon), + item => Assert.True(item.IsDivider), + item => Assert.IsType(item.Icon)); + + runSelect.Find("fluent-button").Click(); + menuItems = runSelect.WaitForElements("fluent-menu-item"); + Assert.Single(runSelect.FindAll("fluent-divider")); + Assert.Empty(menuItems[0].QuerySelectorAll("span[slot='start']")); + Assert.Single(menuItems[1].QuerySelectorAll("span[slot='start']")); + menuItems[0].Click(); Assert.Equal(string.Empty, storedRunId); Assert.Null(runSelection.SelectedRunId); Assert.False(navigationOccurred); Assert.Equal(3, initializedCount); Assert.Equal(2, disposedCount); - statusIcon = cut.Find(".application-run-status"); + runSelect = cut.FindComponent(); + menuButton = runSelect.FindComponent(); + statusIcon = runSelect.Find(".application-run-status"); Assert.Contains("fill: var(--success)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); + Assert.Equal("Live run", menuButton.Instance.Text); + Assert.Collection( + menuButton.Instance.Items, + item => Assert.IsType(item.Icon), + item => Assert.True(item.IsDivider), + item => Assert.Null(item.Icon)); } [Fact] From fc0608cc562990b6c3271d9b11dab40208d75de1 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 17 Jul 2026 06:34:33 +0800 Subject: [PATCH 21/87] Address dashboard persistence review feedback --- .../SqliteTelemetryRepository.Metrics.cs | 72 ++++++++++++++----- .../ServiceClient/DashboardSqliteDatabase.cs | 2 +- .../DatabaseSchema/005.Metrics.sql | 5 +- .../Dashboard/DashboardEventHandlers.cs | 4 ++ .../Model/DashboardDataSourceTests.cs | 2 +- .../SqliteTelemetryPersistenceTests.cs | 43 +++++++++++ .../Dashboard/DashboardEventHandlersTests.cs | 14 ++-- 7 files changed, 118 insertions(+), 24 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs index 2bd6fc266bd..6cf3ca66bd0 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs @@ -1,8 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers.Binary; using System.Data; using System.Globalization; +using System.IO.Hashing; +using System.Text; using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.MetricValues; @@ -323,33 +326,42 @@ private long GetOrAddMetricDimension( OtlpHelpers.CopyKeyValuePairs(pointAttributes, scope.Attributes, _otlpContext, out var copyCount, ref temporaryAttributes); Array.Sort(temporaryAttributes, 0, copyCount, MetricAttributeComparer.Instance); var attributes = temporaryAttributes.AsSpan(0, copyCount).ToArray(); - var existingDimensionIds = connection.Query("SELECT dimension_id FROM telemetry_metric_dimensions WHERE instrument_id = @InstrumentId ORDER BY dimension_id;", new { InstrumentId = instrumentId }, transaction).AsList(); - var existingAttributesByDimension = connection.Query(""" - SELECT a.dimension_id AS OwnerId, a.attribute_key AS AttributeKey, a.attribute_value AS AttributeValue - FROM telemetry_metric_dimension_attributes a - JOIN telemetry_metric_dimensions d ON d.dimension_id = a.dimension_id + var attributeHash = GetMetricDimensionAttributeHash(attributes); + var candidates = connection.Query(""" + SELECT + d.dimension_id AS DimensionId, + a.attribute_key AS AttributeKey, + a.attribute_value AS AttributeValue + FROM telemetry_metric_dimensions d + LEFT JOIN telemetry_metric_dimension_attributes a ON a.dimension_id = d.dimension_id WHERE d.instrument_id = @InstrumentId - ORDER BY a.dimension_id, a.ordinal; - """, new { InstrumentId = instrumentId }, transaction).ToLookup(attribute => attribute.OwnerId); - foreach (var existingDimensionId in existingDimensionIds) - { - var existingAttributes = existingAttributesByDimension[existingDimensionId] - .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)); + AND d.attribute_hash = @AttributeHash + ORDER BY d.dimension_id, a.ordinal; + """, new { InstrumentId = instrumentId, AttributeHash = attributeHash }, transaction).ToLookup(attribute => attribute.DimensionId); + foreach (var candidate in candidates) + { + var existingAttributes = candidate + .Where(attribute => attribute.AttributeKey is not null) + .Select(attribute => KeyValuePair.Create(attribute.AttributeKey!, attribute.AttributeValue!)); if (existingAttributes.SequenceEqual(attributes)) { - return existingDimensionId; + return candidate.Key; } } - if (existingDimensionIds.Count >= TelemetryRepositoryLimits.MaxDimensionCount) + var dimensionCount = connection.QuerySingle( + "SELECT COUNT(*) FROM telemetry_metric_dimensions WHERE instrument_id = @InstrumentId;", + new { InstrumentId = instrumentId }, + transaction); + if (dimensionCount >= TelemetryRepositoryLimits.MaxDimensionCount) { throw new InvalidOperationException($"Dimension limit of {TelemetryRepositoryLimits.MaxDimensionCount} reached."); } var dimensionId = connection.QuerySingle(""" - INSERT INTO telemetry_metric_dimensions (instrument_id) - VALUES (@InstrumentId) + INSERT INTO telemetry_metric_dimensions (instrument_id, attribute_hash) + VALUES (@InstrumentId, @AttributeHash) RETURNING dimension_id; - """, new { InstrumentId = instrumentId }, transaction); + """, new { InstrumentId = instrumentId, AttributeHash = attributeHash }, transaction); connection.Execute(""" INSERT INTO telemetry_metric_dimension_attributes (dimension_id, ordinal, attribute_key, attribute_value) VALUES (@DimensionId, @Ordinal, @Key, @Value); @@ -362,6 +374,27 @@ INSERT INTO telemetry_metric_dimension_attributes (dimension_id, ordinal, attrib return dimensionId; } + private static long GetMetricDimensionAttributeHash(ReadOnlySpan> attributes) + { + var hash = new XxHash3(); + foreach (var attribute in attributes) + { + AppendHashValue(hash, attribute.Key); + AppendHashValue(hash, attribute.Value); + } + + return BinaryPrimitives.ReadInt64LittleEndian(hash.GetCurrentHash()); + + static void AppendHashValue(XxHash3 hash, string value) + { + var valueBytes = Encoding.UTF8.GetBytes(value); + Span lengthBytes = stackalloc byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, valueBytes.Length); + hash.Append(lengthBytes); + hash.Append(valueBytes); + } + } + private static MetricPointRecord? GetLatestMetricPoint(SqliteConnection connection, IDbTransaction transaction, long dimensionId) { return connection.QuerySingleOrDefault(""" @@ -800,6 +833,13 @@ private sealed class MetricAttributeComparer : IComparer x, KeyValuePair y) => string.Compare(x.Key, y.Key, StringComparison.Ordinal); } + private sealed class MetricDimensionAttributeRecord + { + public required long DimensionId { get; init; } + public string? AttributeKey { get; init; } + public string? AttributeValue { get; init; } + } + private class MetricPointRecord { public required long PointId { get; init; } diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index 6a58c5384c2..e9914319f3b 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -13,7 +13,7 @@ internal sealed class DashboardSqliteDatabase { private const string SchemaResourcePrefix = "Aspire.Dashboard.ServiceClient.DatabaseSchema."; - internal const int SchemaVersion = 7; + internal const int SchemaVersion = 8; private static readonly Lazy> s_schemaScripts = new(LoadSchemaScripts); diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql index 91ba2f3080c..8857af79894 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql @@ -17,7 +17,8 @@ CREATE TABLE IF NOT EXISTS telemetry_metric_instruments ( CREATE TABLE IF NOT EXISTS telemetry_metric_dimensions ( dimension_id INTEGER PRIMARY KEY AUTOINCREMENT, - instrument_id INTEGER NOT NULL REFERENCES telemetry_metric_instruments(instrument_id) ON DELETE CASCADE + instrument_id INTEGER NOT NULL REFERENCES telemetry_metric_instruments(instrument_id) ON DELETE CASCADE, + attribute_hash INTEGER NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS telemetry_metric_dimension_attributes ( @@ -77,6 +78,8 @@ CREATE INDEX IF NOT EXISTS ix_telemetry_metric_instruments_lookup ON telemetry_metric_instruments(resource_id, scope_id, instrument_name); CREATE INDEX IF NOT EXISTS ix_telemetry_metric_dimensions_instrument ON telemetry_metric_dimensions(instrument_id, dimension_id); +CREATE INDEX IF NOT EXISTS ix_telemetry_metric_dimensions_hash + ON telemetry_metric_dimensions(instrument_id, attribute_hash); CREATE INDEX IF NOT EXISTS ix_telemetry_metric_points_dimension_order ON telemetry_metric_points(dimension_id, point_id); CREATE INDEX IF NOT EXISTS ix_telemetry_metric_points_time diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 7edac4115bf..612e5e9da76 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -608,6 +608,10 @@ internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext con context.EnvironmentVariables[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] = Path.Combine(aspireStorePath, ".aspire", "dashboard"); } var persistenceMode = configuration["Aspire:Dashboard:PersistenceMode"]; + if (string.IsNullOrWhiteSpace(persistenceMode)) + { + persistenceMode = configuration[DashboardConfigNames.DashboardPersistenceModeName.EnvVarName]; + } context.EnvironmentVariables[DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = string.IsNullOrWhiteSpace(persistenceMode) ? "Runs" : persistenceMode; diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index f5e811d6867..b8f792d34bc 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -104,7 +104,7 @@ public void AppendMode_DeletesIncompatibleDatabase() } [Theory] - [InlineData("CREATE TABLE dashboard_schema (version INTEGER NOT NULL); INSERT INTO dashboard_schema VALUES (7), (7);")] + [InlineData("CREATE TABLE dashboard_schema (version INTEGER NOT NULL); INSERT INTO dashboard_schema VALUES (8), (8);")] [InlineData("CREATE TABLE dashboard_schema (version); INSERT INTO dashboard_schema VALUES ('invalid');")] public void IsCompatible_ReturnsFalseForMalformedSchema(string schemaSql) { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs index e7c945401c3..4b2e1fe582c 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs @@ -186,6 +186,49 @@ public void Metrics_ReopenFromNormalizedRows() Assert.Equal(1L, command.ExecuteScalar()); } + [Fact] + public void Metrics_EquivalentAttributesShareIndexedDimension() + { + var databasePath = Path.Combine(_temporaryDirectory, "metric-dimensions.db"); + var startTime = new DateTime(2025, 2, 3, 4, 5, 6, DateTimeKind.Utc); + using (var repository = CreateRepository(databasePath)) + { + var addContext = new AddContext(); + foreach (var attributes in new[] + { + new[] { KeyValuePair.Create("second", "2"), KeyValuePair.Create("first", "1") }, + new[] { KeyValuePair.Create("first", "1"), KeyValuePair.Create("second", "2") }, + new[] { KeyValuePair.Create("first", "different") } + }) + { + repository.AddMetrics(addContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope("TestMeter"), + Metrics = { CreateSumMetric("requests", startTime, attributes: attributes) } + } + } + } + }); + } + Assert.Equal(0, addContext.FailureCount); + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM telemetry_metric_dimensions;"; + Assert.Equal(2L, command.ExecuteScalar()); + command.CommandText = "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'index' AND name = 'ix_telemetry_metric_dimensions_hash';"; + Assert.Equal(1L, command.ExecuteScalar()); + } + [Fact] public void Scopes_AreSharedAcrossLogsTracesAndMetrics() { diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs index bfd6480b49d..7020efb7620 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs @@ -309,12 +309,15 @@ public async Task ConfigureEnvironmentVariables_HasAspireDashboardEnvVars_Copied } [Theory] - [InlineData(null, "Runs")] - [InlineData("", "Runs")] - [InlineData(" ", "Runs")] - [InlineData("Runs", "Runs")] + [InlineData(null, null, "Runs")] + [InlineData("", null, "Runs")] + [InlineData(" ", null, "Runs")] + [InlineData(null, " ", "Runs")] + [InlineData(null, "None", "None")] + [InlineData("Runs", "None", "Runs")] public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRootApplicationNameAndPersistenceMode( string? configuredPersistenceMode, + string? configuredPersistenceModeEnvironmentAlias, string expectedPersistenceMode) { var storeDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-store-tests-"); @@ -327,7 +330,8 @@ public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRoo { ["Aspire:Store:Path"] = storeDirectory.FullName, ["AppHost:DashboardApplicationName"] = "My App.AppHost", - ["Aspire:Dashboard:PersistenceMode"] = configuredPersistenceMode + ["Aspire:Dashboard:PersistenceMode"] = configuredPersistenceMode, + [DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = configuredPersistenceModeEnvironmentAlias }) .Build(); var dashboardOptions = Options.Create(new DashboardOptions From db3864bb6c589f9eb3dc0c416c3b275fb145fe4c Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 17 Jul 2026 06:42:51 +0800 Subject: [PATCH 22/87] Fix culture-sensitive dashboard run test --- .../Layout/MainLayoutTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index 13f43c4af56..cbcb7ca9e40 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -326,6 +326,9 @@ public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavig OnSetAsync = (_, value) => storedRunId = Assert.IsType(value) }; SetupMainLayoutServices(dashboardRunStore: runStore, sessionStorage: sessionStorage); + var expectedHistoricalRunText = FormatHelpers.FormatTimeWithOptionalDate( + Services.GetRequiredService(), + historicalRun.StartedAtUtc.UtcDateTime); JSInterop.SetupVoid("focusElement", _ => true); var initializedCount = 0; var disposedCount = 0; @@ -358,7 +361,7 @@ public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavig item => Assert.True(item.IsDivider), item => { - Assert.Equal("1/2/2025 1:30:00 PM", item.Text); + Assert.Equal(expectedHistoricalRunText, item.Text); Assert.Null(item.Icon); }); @@ -381,7 +384,7 @@ public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavig menuButton = runSelect.FindComponent(); statusIcon = runSelect.Find(".application-run-status"); Assert.Contains("fill: var(--warning)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); - Assert.Equal("1/2/2025 1:30:00 PM", menuButton.Instance.Text); + Assert.Equal(expectedHistoricalRunText, menuButton.Instance.Text); Assert.Collection( menuButton.Instance.Items, item => Assert.Null(item.Icon), From 4d94581b1e335be150ad21514a25a0552666f565 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 17 Jul 2026 08:17:30 +0800 Subject: [PATCH 23/87] Simplify metric telemetry repository API --- .../Otlp/Storage/ITelemetryRepository.cs | 12 +----------- .../Otlp/Storage/SelectedTelemetryRepository.cs | 6 ++---- .../Otlp/Storage/SqliteTelemetryRepository.cs | 4 ++-- .../Shared/Telemetry/InMemoryTelemetryRepository.cs | 4 ++-- 4 files changed, 7 insertions(+), 19 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs index 009eabe4fbd..7340115f05d 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs @@ -56,6 +56,7 @@ public interface ITelemetryRepository : IDisposable OtlpResource? GetPeerResource(OtlpSpan span); List GetInstrumentsSummaries(ResourceKey key); OtlpInstrumentData? GetInstrument(GetInstrumentRequest request); + DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName); IAsyncEnumerable WatchSpansAsync(WatchSpansRequest request, CancellationToken cancellationToken); IAsyncEnumerable WatchLogsAsync(WatchLogsRequest request, CancellationToken cancellationToken); @@ -65,14 +66,3 @@ public interface ITelemetryRepository : IDisposable void ClearStructuredLogs(ResourceKey? resourceKey = null); void ClearMetrics(ResourceKey? resourceKey = null); } - -internal interface IMetricTelemetryRepository -{ - DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName); -} - -internal static class MetricTelemetryRepositoryExtensions -{ - public static DateTime? GetInstrumentLatestEndTime(this ITelemetryRepository repository, ResourceKey resourceKey, string meterName, string instrumentName) => - ((IMetricTelemetryRepository)repository).GetInstrumentLatestEndTime(resourceKey, meterName, instrumentName); -} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SelectedTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SelectedTelemetryRepository.cs index 19b34354943..d2fcc7c0f2a 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SelectedTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SelectedTelemetryRepository.cs @@ -14,7 +14,7 @@ namespace Aspire.Dashboard.Otlp.Storage; /// /// Forwards telemetry operations to the repository for the selected dashboard run. /// -internal sealed class SelectedTelemetryRepository(DashboardDataSource dataSource) : ITelemetryRepository, IMetricTelemetryRepository +internal sealed class SelectedTelemetryRepository(DashboardDataSource dataSource) : ITelemetryRepository { private ITelemetryRepository Repository => dataSource.TelemetryRepository; @@ -74,6 +74,7 @@ public Message? MaxTraceLimitMessage public OtlpResource? GetPeerResource(OtlpSpan span) => Repository.GetPeerResource(span); public List GetInstrumentsSummaries(ResourceKey key) => Repository.GetInstrumentsSummaries(key); public OtlpInstrumentData? GetInstrument(GetInstrumentRequest request) => Repository.GetInstrument(request); + public DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) => Repository.GetInstrumentLatestEndTime(resourceKey, meterName, instrumentName); public IAsyncEnumerable WatchSpansAsync(WatchSpansRequest request, CancellationToken cancellationToken) => Repository.WatchSpansAsync(request, cancellationToken); public IAsyncEnumerable WatchLogsAsync(WatchLogsRequest request, CancellationToken cancellationToken) => Repository.WatchLogsAsync(request, cancellationToken); public void ClearSelectedSignals(Dictionary> selectedResources) => Repository.ClearSelectedSignals(selectedResources); @@ -81,9 +82,6 @@ public Message? MaxTraceLimitMessage public void ClearStructuredLogs(ResourceKey? resourceKey = null) => Repository.ClearStructuredLogs(resourceKey); public void ClearMetrics(ResourceKey? resourceKey = null) => Repository.ClearMetrics(resourceKey); - DateTime? IMetricTelemetryRepository.GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) => - Repository.GetInstrumentLatestEndTime(resourceKey, meterName, instrumentName); - public void Dispose() { // DashboardDataSource owns the selected repository and disposes historical instances when selection changes. diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index de86d32565e..209197cad1e 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -15,7 +15,7 @@ namespace Aspire.Dashboard.Otlp.Storage; /// /// Persists telemetry to SQLite and exposes it through the dashboard telemetry model. /// -public sealed partial class SqliteTelemetryRepository : ITelemetryRepository, IMetricTelemetryRepository +public sealed partial class SqliteTelemetryRepository : ITelemetryRepository { private readonly DashboardSqliteDatabase _database; private readonly OtlpContext _otlpContext; @@ -163,7 +163,7 @@ public void AddTraces(AddContext context, RepeatedField resourceS } public List GetInstrumentsSummaries(ResourceKey key) => GetInstrumentsSummariesFromDatabase(key); public OtlpInstrumentData? GetInstrument(GetInstrumentRequest request) => GetInstrumentFromDatabase(request); - DateTime? IMetricTelemetryRepository.GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) => + public DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) => GetInstrumentLatestEndTimeFromDatabase(resourceKey, meterName, instrumentName); public void ClearSelectedSignals(Dictionary> selectedResources) { diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index ede3d03bf2a..890e93c9e6a 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -25,7 +25,7 @@ namespace Aspire.Dashboard.Otlp.Storage; -public sealed partial class InMemoryTelemetryRepository : ITelemetryRepository, IMetricTelemetryRepository +public sealed partial class InMemoryTelemetryRepository : ITelemetryRepository { internal const int MaxResourceViewCount = TelemetryRepositoryLimits.MaxResourceViewCount; internal const int MaxInstrumentCount = TelemetryRepositoryLimits.MaxInstrumentCount; @@ -2235,7 +2235,7 @@ public List GetInstrumentsSummaries(ResourceKey key) } } - DateTime? IMetricTelemetryRepository.GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) + public DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) { return GetResources(resourceKey) .Select(resource => resource.GetInstrument(meterName, instrumentName, DateTime.MinValue, DateTime.MaxValue)) From 6bb9c7a0be898118646ab2ee4f51576c04800aa0 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 17 Jul 2026 11:21:36 +0800 Subject: [PATCH 24/87] Refactor dashboard repository ownership --- .../Api/TelemetryApiService.cs | 14 +- .../Components/Controls/Chart/ChartBase.cs | 4 +- .../Controls/Chart/ChartContainer.razor.cs | 4 +- .../ResourceNameButtonValue.razor.cs | 5 +- .../PropertyValues/SpanIdButtonValue.razor.cs | 4 +- .../Components/Controls/SpanDetails.razor.cs | 4 +- .../Controls/TreeMetricSelector.razor.cs | 4 +- .../Dialogs/ExemplarsDialog.razor.cs | 4 +- .../Components/Dialogs/FilterDialog.razor.cs | 4 +- .../Dialogs/GenAIVisualizerDialog.razor.cs | 4 +- .../Dialogs/ManageDataDialog.razor.cs | 11 +- .../Components/Pages/ConsoleLogs.razor.cs | 8 +- .../Components/Pages/Metrics.razor.cs | 9 +- .../Components/Pages/Resources.razor.cs | 6 +- .../Components/Pages/StructuredLogs.razor.cs | 9 +- .../Components/Pages/TraceDetail.razor.cs | 22 +- .../Components/Pages/Traces.razor.cs | 9 +- .../UnreadLogErrorsBadge.razor.cs | 4 +- .../DashboardWebApplication.cs | 51 +++-- .../Model/ConsoleLogsFetcher.cs | 9 +- src/Aspire.Dashboard/Model/ExportHelpers.cs | 8 +- .../Model/Otlp/SpanWaterfallViewModel.cs | 16 +- .../Model/ResourceMenuBuilder.cs | 9 +- src/Aspire.Dashboard/Model/SpanMenuBuilder.cs | 12 +- .../Model/StructuredLogMenuBuilder.cs | 8 +- .../Model/StructuredLogsViewModel.cs | 8 +- .../Model/TelemetryExportService.cs | 63 ++---- .../Model/TelemetryImportService.cs | 14 +- .../Model/TraceMenuBuilder.cs | 13 +- src/Aspire.Dashboard/Model/TracesViewModel.cs | 8 +- src/Aspire.Dashboard/Otlp/OtlpLogsService.cs | 8 +- .../Otlp/OtlpMetricsService.cs | 8 +- src/Aspire.Dashboard/Otlp/OtlpTraceService.cs | 8 +- .../Otlp/Storage/ITelemetryRepository.cs | 13 -- .../Storage/ITelemetryRepositoryWriter.cs | 62 +++++ .../Storage/SelectedTelemetryRepository.cs | 89 -------- .../SqliteTelemetryRepository.Runtime.cs | 6 + .../SqliteTelemetryRepository.Traces.cs | 44 ++-- .../Otlp/Storage/SqliteTelemetryRepository.cs | 27 +-- .../ServiceClient/DashboardDataSource.cs | 89 ++++---- .../ServiceClient/IRepositoryFactory.cs | 22 ++ .../ServiceClient/RepositoryFactory.cs | 80 +++++++ .../Dialogs/ManageDataDialogTests.cs | 4 +- .../Pages/StructuredLogsTests.cs | 10 +- .../Pages/TracesTests.cs | 2 +- .../Shared/FluentUISetupHelpers.cs | 19 ++ .../Shared/ResourceSetupHelpers.cs | 2 +- .../FrontendBrowserTokenAuthTests.cs | 2 + .../Model/DashboardDataSourceTests.cs | 51 ++--- .../Model/ResourceMenuBuilderTests.cs | 5 +- .../Model/SpanWaterfallViewModelTests.cs | 50 +++-- .../Model/TelemetryExportServiceTests.cs | 78 +++++-- .../Model/TelemetryImportServiceTests.cs | 2 +- .../Shared/TestDashboardDataSource.cs | 40 ++++ .../TelemetryApiServiceTests.cs | 9 +- .../TelemetryRepositoryTests/LogTests.cs | 68 +++--- .../TelemetryRepositoryTests/MetricsTests.cs | 34 +-- .../TelemetryRepositoryTests/ResourceTests.cs | 2 +- .../TelemetryLimitTests.cs | 36 +-- .../TelemetryRepositoryTestBase.cs | 5 + .../TelemetryRepositoryTests.cs | 54 ++--- .../TelemetryRepositoryTests/TraceTests.cs | 211 +++++++++++++----- .../Telemetry/InMemoryTelemetryRepository.cs | 46 ++-- 63 files changed, 895 insertions(+), 639 deletions(-) create mode 100644 src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepositoryWriter.cs delete mode 100644 src/Aspire.Dashboard/Otlp/Storage/SelectedTelemetryRepository.cs create mode 100644 src/Aspire.Dashboard/ServiceClient/IRepositoryFactory.cs create mode 100644 src/Aspire.Dashboard/ServiceClient/RepositoryFactory.cs create mode 100644 tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs diff --git a/src/Aspire.Dashboard/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs index daf0d790027..61b451229cd 100644 --- a/src/Aspire.Dashboard/Api/TelemetryApiService.cs +++ b/src/Aspire.Dashboard/Api/TelemetryApiService.cs @@ -15,15 +15,11 @@ namespace Aspire.Dashboard.Api; /// /// Handles telemetry API requests, returning data in OTLP JSON format. /// -internal sealed class TelemetryApiService( - ITelemetryRepository telemetryRepository, - IEnumerable outgoingPeerResolvers) +internal sealed class TelemetryApiService(ITelemetryRepository telemetryRepository) { private const int DefaultLimit = 200; private const int DefaultTraceLimit = 100; - private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); - /// /// Gets spans in OTLP JSON format. /// Returns null if resource filter is specified but not found. @@ -67,7 +63,7 @@ internal sealed class TelemetryApiService( spans = spans.Skip(spans.Count - effectiveLimit).ToList(); } - var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); + var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans); return new TelemetryApiResponse { @@ -128,7 +124,7 @@ internal sealed class TelemetryApiService( var spans = traces.SelectMany(t => t.Spans).ToList(); var returnedCount = traces.Count; - var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); + var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans); return new TelemetryApiResponse { @@ -152,7 +148,7 @@ internal sealed class TelemetryApiService( var spans = trace.Spans.ToList(); - var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans, _outgoingPeerResolvers); + var otlpData = TelemetryExportService.ConvertSpansToOtlpJson(spans); return new TelemetryApiResponse { @@ -271,7 +267,7 @@ public async IAsyncEnumerable FollowSpansAsync( await foreach (var span in telemetryRepository.WatchSpansAsync(watchRequest, cancellationToken).ConfigureAwait(false)) { // Use compact JSON for NDJSON streaming (no indentation) - yield return TelemetryExportService.ConvertSpanToJson(span, _outgoingPeerResolvers, logs: null, indent: false); + yield return TelemetryExportService.ConvertSpanToJson(span, logs: null, indent: false); } } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs index 38140efe02d..f475d2d6f88 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs @@ -41,7 +41,9 @@ public abstract class ChartBase : ComponentBase, IAsyncDisposable public required BrowserTimeProvider TimeProvider { get; init; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required PauseManager PauseManager { get; init; } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs index f1cd60efddc..77f22955b69 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs @@ -49,7 +49,9 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable public required string? PauseText { get; set; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required ILogger Logger { get; init; } diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceNameButtonValue.razor.cs b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceNameButtonValue.razor.cs index 4d460ca7eca..d4d574e4073 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceNameButtonValue.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceNameButtonValue.razor.cs @@ -27,6 +27,9 @@ public partial class ResourceNameButtonValue [Inject] public required IDashboardClient DashboardClient { get; init; } + [Inject] + private DashboardDataSource DataSource { get; set; } = null!; + [Inject] public required IconResolver IconResolver { get; init; } @@ -39,7 +42,7 @@ protected override void OnParametersSet() if (DashboardClient.IsEnabled) { - _resource = DashboardClient.GetResource(Resource.ResourceKey.ToString()); + _resource = DataSource.ResourceRepository.GetResource(Resource.ResourceKey.ToString()); if (_resource != null) { _resourceIcon = ResourceIconHelpers.GetIconForResource(IconResolver, _resource, IconSize.Size16, IconVariant.Regular); diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs b/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs index f7c7b482f3f..2ea456e88e6 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs @@ -24,7 +24,9 @@ public partial class SpanIdButtonValue public required DashboardDialogService DialogService { get; init; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required IStringLocalizer Loc { get; init; } diff --git a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs index df50f70656d..7f8891eea85 100644 --- a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs @@ -34,7 +34,9 @@ public partial class SpanDetails : IDisposable public required NavigationManager NavigationManager { get; init; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required IJSRuntime JS { get; init; } diff --git a/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs b/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs index 2a19127794c..12a1f988dc9 100644 --- a/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs @@ -19,7 +19,9 @@ public partial class TreeMetricSelector public bool IncludeLabel { get; set; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; public void OnResourceChanged() { diff --git a/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs index e2a896813c1..e69a423c284 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs @@ -27,7 +27,9 @@ public partial class ExemplarsDialog : IDisposable public required NavigationManager NavigationManager { get; init; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; public IQueryable MetricView => Content.Exemplars.AsQueryable(); diff --git a/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs index 59681dd0a74..ac56584d9af 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs @@ -30,7 +30,9 @@ private SelectViewModel CreateFilterSelectViewModel(FilterCondi public FilterDialogViewModel Content { get; set; } = default!; [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required IJSRuntime JS { get; init; } diff --git a/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs index 91d24c179e8..acb0a68eb21 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs @@ -47,7 +47,9 @@ public partial class GenAIVisualizerDialog : ComponentBase, IComponentWithTeleme public required BrowserTimeProvider TimeProvider { get; init; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required IStringLocalizer Loc { get; init; } diff --git a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs index d50fa017662..570a18cbfd0 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs @@ -30,7 +30,12 @@ public partial class ManageDataDialog : IDialogContentComponent, IAsyncDisposabl public required NavigationManager NavigationManager { get; init; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; + + [Inject] + public required ITelemetryRepositoryWriter TelemetryRepositoryWriter { get; init; } [Inject] public required IDashboardClient DashboardClient { get; init; } @@ -150,7 +155,7 @@ private void UpdateData() private async Task SubscribeResourcesAsync() { - var (snapshot, subscription) = await DashboardClient.SubscribeResourcesAsync(_cts.Token); + var (snapshot, subscription) = await DataSource.ResourceRepository.SubscribeResourcesAsync(_cts.Token); // Apply snapshot. foreach (var resource in snapshot) @@ -552,7 +557,7 @@ private async Task RemoveSelectedAsync() var selectedResources = GetSelectedResourcesAndDataTypes(); // Clear telemetry signals via repository - TelemetryRepository.ClearSelectedSignals(selectedResources); + TelemetryRepositoryWriter.ClearSelectedSignals(selectedResources); // Handle console logs filtering separately (not stored in TelemetryRepository) // Console logs are only available when the dashboard client is enabled diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index a25d0833a74..f8073242a7d 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -81,7 +81,9 @@ public void Cancel() public required ISessionStorage SessionStorage { get; init; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required ILogger Logger { get; init; } @@ -254,7 +256,7 @@ async Task TrackResourceSnapshotsAsync() return; } - var (snapshot, subscription) = await DashboardClient.SubscribeResourcesAsync(_resourceSubscriptionToken); + var (snapshot, subscription) = await DataSource.ResourceRepository.SubscribeResourcesAsync(_resourceSubscriptionToken); Logger.LogDebug("Received initial resource snapshot with {ResourceCount} resources.", snapshot.Length); @@ -1003,7 +1005,7 @@ private void LoadLogsForResource(ConsoleLogsSubscription subscription) Logger.LogDebug("Subscribing to console logs with subscription {SubscriptionId} to resource {ResourceName}.", subscription.SubscriptionId, subscription.Resource.Name); - var logSubscription = DashboardClient.SubscribeConsoleLogs(subscription.Resource.Name, subscription.CancellationToken); + var logSubscription = DataSource.ResourceRepository.SubscribeConsoleLogs(subscription.Resource.Name, subscription.CancellationToken); // For "All" subscriptions, only update status once when starting if (_isSubscribedToAll && _consoleLogsSubscriptions.Count == 1) diff --git a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs index bd42b257a45..a4fa94790f1 100644 --- a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs @@ -60,7 +60,12 @@ public partial class Metrics : IDisposable, IComponentWithTelemetry, IPageWithSe public required ISessionStorage SessionStorage { get; init; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; + + [Inject] + public required ITelemetryRepositoryWriter TelemetryRepositoryWriter { get; init; } [Inject] public required ILogger Logger { get; init; } @@ -236,7 +241,7 @@ private bool ShouldClearSelectedMetrics(List instruments) private Task ClearMetrics(ResourceKey? key) { - TelemetryRepository.ClearMetrics(key); + TelemetryRepositoryWriter.ClearMetrics(key); return Task.CompletedTask; } diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs index 1e5884649dd..558577d4f27 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs @@ -45,7 +45,9 @@ public partial class Resources : ComponentBase, IComponentWithTelemetry, IAsyncD [Inject] public required IDashboardClient DashboardClient { get; init; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required NavigationManager NavigationManager { get; init; } [Inject] @@ -256,7 +258,7 @@ protected override async Task OnInitializedAsync() async Task SubscribeResourcesAsync() { - var (snapshot, subscription) = await DashboardClient.SubscribeResourcesAsync(_cts.Token); + var (snapshot, subscription) = await DataSource.ResourceRepository.SubscribeResourcesAsync(_cts.Token); // Apply snapshot. foreach (var resource in snapshot) diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index af6f1dcd34b..e863da6e301 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -58,7 +58,12 @@ public partial class StructuredLogs : IComponentWithTelemetry, IPageWithSessionA public StructuredLogsPageViewModel PageViewModel { get; set; } = null!; [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; + + [Inject] + public required ITelemetryRepositoryWriter TelemetryRepositoryWriter { get; init; } [Inject] public required StructuredLogsViewModel ViewModel { get; init; } @@ -559,7 +564,7 @@ await GenAIVisualizerDialog.OpenDialogAsync( private Task ClearStructureLogs(ResourceKey? key) { - TelemetryRepository.ClearStructuredLogs(key); + TelemetryRepositoryWriter.ClearStructuredLogs(key); return Task.CompletedTask; } diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs index f98fc985cb2..6b0ab3c299d 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs @@ -32,7 +32,6 @@ public partial class TraceDetail : ComponentBase, IComponentWithTelemetry, IDisp private const int RootSpanDepth = 1; private readonly CancellationTokenSource _cts = new(); - private readonly List _peerChangesSubscriptions = new(); private OtlpTrace? _trace; private Subscription? _tracesSubscription; private int _maxDepth; @@ -64,10 +63,9 @@ public partial class TraceDetail : ComponentBase, IComponentWithTelemetry, IDisp public required ITelemetryErrorRecorder ErrorRecorder { get; init; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; - [Inject] - public required IEnumerable OutgoingPeerResolvers { get; init; } + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] public required BrowserTimeProvider TimeProvider { get; init; } @@ -110,16 +108,6 @@ protected override void OnInitialized() new GridColumn(Name: ActionsColumn, DesktopWidth: "100px", MobileWidth: null) ]; - foreach (var resolver in OutgoingPeerResolvers) - { - _peerChangesSubscriptions.Add(resolver.OnPeerChanges(async () => - { - UpdateDetailViewData(); - await InvokeAsync(StateHasChanged); - await InvokeAsync(_dataGrid.SafeRefreshDataAsync); - })); - } - UpdateTraceActionsMenu(); _spanTypes = SpanType.CreateKnownSpanTypes(ControlStringsLoc); @@ -278,7 +266,7 @@ private void UpdateDetailViewData() var result = TelemetryRepository.GetLogsForTrace(_trace.TraceId); Logger.LogInformation("Trace '{TraceId}' has {SpanCount} spans.", _trace.TraceId, _trace.Spans.Count); - PageViewModel.SpanWaterfallViewModels = SpanWaterfallViewModel.Create(_trace, result, new SpanWaterfallViewModel.TraceDetailState(OutgoingPeerResolvers.ToArray(), _collapsedSpanIds, _resources)); + PageViewModel.SpanWaterfallViewModels = SpanWaterfallViewModel.Create(_trace, result, new SpanWaterfallViewModel.TraceDetailState(_collapsedSpanIds, _resources)); _maxDepth = PageViewModel.SpanWaterfallViewModels.Max(s => s.Depth); var apps = new HashSet(); @@ -647,10 +635,6 @@ private List GetFilterMenuItems() public void Dispose() { _cts.Cancel(); - foreach (var subscription in _peerChangesSubscriptions) - { - subscription.Dispose(); - } _tracesSubscription?.Dispose(); TelemetryContext.Dispose(); } diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs index bd0065d7010..b3b2ae8833f 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs @@ -56,7 +56,12 @@ public partial class Traces : IComponentWithTelemetry, IPageWithSessionAndUrlSta public string? ResourceName { get; set; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; + + [Inject] + public required ITelemetryRepositoryWriter TelemetryRepositoryWriter { get; init; } [Inject] public required TracesViewModel TracesViewModel { get; init; } @@ -387,7 +392,7 @@ private async Task HandleFilterDialog(DialogResult result) private Task ClearTraces(ResourceKey? key) { - TelemetryRepository.ClearTraces(key); + TelemetryRepositoryWriter.ClearTraces(key); return Task.CompletedTask; } diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs index 161cc221c8a..357a3bc4033 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs @@ -20,7 +20,9 @@ public partial class UnreadLogErrorsBadge public required Dictionary? UnviewedErrorCounts { get; set; } [Inject] - public required ITelemetryRepository TelemetryRepository { get; init; } + private DashboardDataSource DataSource { get; set; } = null!; + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; protected override void OnParametersSet() { diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 4f65daac559..2401b5143cb 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -278,7 +278,11 @@ public DashboardWebApplication( // Data from the server. builder.Services.TryAddSingleton(); - builder.Services.AddScoped(); + builder.Services.AddScoped(services => new DashboardDataSource( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService())); builder.Services.AddScoped(services => services.GetRequiredService()); builder.Services.AddScoped(); @@ -301,41 +305,36 @@ public DashboardWebApplication( builder.Services.AddSingleton(services => services.GetRequiredService()); builder.Services.AddSingleton(services => new DashboardSqliteDatabase( services.GetRequiredService().DatabasePath)); - builder.Services.AddSingleton(services => - { - return new SqliteTelemetryRepository( - services.GetRequiredService(), - services.GetRequiredService(), - services.GetRequiredService>(), - services.GetRequiredService(), - services.GetServices()); - }); - builder.Services.AddScoped(); - builder.Services.AddSingleton(services => - { - return new SqliteResourceRepository( - services.GetRequiredService(), - services.GetRequiredService(), - services.GetRequiredService()); - }); - builder.Services.AddSingleton(services => services.GetRequiredService()); - builder.Services.AddScoped(services => services.GetRequiredService()); + builder.Services.AddSingleton(services => new RepositoryFactory( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService>(), + services.GetRequiredService(), + services.GetServices, + services.GetRequiredService())); + builder.Services.AddSingleton(services => services.GetRequiredService() + .CreateTelemetryRepository(services.GetRequiredService())); + builder.Services.AddSingleton(services => + (ITelemetryRepositoryWriter)services.GetRequiredService()); + builder.Services.AddSingleton(services => services.GetRequiredService() + .CreateResourceRepository(services.GetRequiredService())); + builder.Services.AddSingleton(services => + (IResourceRepositoryWriter)services.GetRequiredService()); builder.Services.AddTransient(); builder.Services.AddTransient(services => new OtlpLogsService( services.GetRequiredService>(), - services.GetRequiredService())); + services.GetRequiredService())); builder.Services.AddTransient(services => new OtlpTraceService( services.GetRequiredService>(), - services.GetRequiredService())); + services.GetRequiredService())); builder.Services.AddTransient(services => new OtlpMetricsService( services.GetRequiredService>(), - services.GetRequiredService())); + services.GetRequiredService())); // Telemetry API. builder.Services.AddSingleton(services => new TelemetryApiService( - services.GetRequiredService(), - services.GetServices())); + services.GetRequiredService())); builder.Services.AddTransient(); builder.Services.AddSingleton(services => @@ -354,7 +353,7 @@ public DashboardWebApplication( builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(services => new TelemetryImportService( - services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService>(), services.GetRequiredService>())); builder.Services.AddSingleton(); diff --git a/src/Aspire.Dashboard/Model/ConsoleLogsFetcher.cs b/src/Aspire.Dashboard/Model/ConsoleLogsFetcher.cs index b6245c697a0..8561e607348 100644 --- a/src/Aspire.Dashboard/Model/ConsoleLogsFetcher.cs +++ b/src/Aspire.Dashboard/Model/ConsoleLogsFetcher.cs @@ -11,6 +11,7 @@ namespace Aspire.Dashboard.Model; public sealed class ConsoleLogsFetcher { private readonly IDashboardClient _dashboardClient; + private readonly DashboardDataSource _dataSource; private readonly ConsoleLogsManager _consoleLogsManager; public bool IsEnabled => _dashboardClient.IsEnabled; @@ -18,11 +19,13 @@ public sealed class ConsoleLogsFetcher /// /// Initializes a new instance of the class. /// + /// The selected dashboard run data source. /// The dashboard client. /// The console logs manager. - public ConsoleLogsFetcher(IDashboardClient dashboardClient, ConsoleLogsManager consoleLogsManager) + public ConsoleLogsFetcher(DashboardDataSource dataSource, IDashboardClient dashboardClient, ConsoleLogsManager consoleLogsManager) { _dashboardClient = dashboardClient; + _dataSource = dataSource; _consoleLogsManager = consoleLogsManager; } @@ -31,7 +34,7 @@ private async Task> FetchLogEntriesAsync(string resourceName, Dat var logEntries = new List(); var logParser = new LogParser(ConsoleColor.Black); - await foreach (var batch in _dashboardClient.GetConsoleLogs(resourceName, cancellationToken).ConfigureAwait(false)) + await foreach (var batch in _dataSource.ResourceRepository.GetConsoleLogs(resourceName, cancellationToken).ConfigureAwait(false)) { foreach (var logLine in batch) { @@ -60,7 +63,7 @@ public async Task>> FetchLogEntriesAsync(HashS throw new InvalidOperationException("Can't fetch console logs when the dashboard client is not enabled."); } - var resources = _dashboardClient.GetResources().Where(r => resourceNames.Contains(r.Name)).ToList(); + var resources = _dataSource.ResourceRepository.GetResources().Where(r => resourceNames.Contains(r.Name)).ToList(); var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); // Fetch logs for all resources in parallel diff --git a/src/Aspire.Dashboard/Model/ExportHelpers.cs b/src/Aspire.Dashboard/Model/ExportHelpers.cs index 82c72697cb8..283441d018a 100644 --- a/src/Aspire.Dashboard/Model/ExportHelpers.cs +++ b/src/Aspire.Dashboard/Model/ExportHelpers.cs @@ -21,10 +21,10 @@ internal static class ExportHelpers /// /// Gets a span as a JSON export result, including associated log entries. /// - public static ExportResult GetSpanAsJson(OtlpSpan span, ITelemetryRepository telemetryRepository, IOutgoingPeerResolver[] outgoingPeerResolvers) + public static ExportResult GetSpanAsJson(OtlpSpan span, ITelemetryRepository telemetryRepository) { var logs = telemetryRepository.GetLogsForSpan(span.TraceId, span.SpanId); - var json = TelemetryExportService.ConvertSpanToJson(span, outgoingPeerResolvers, logs); + var json = TelemetryExportService.ConvertSpanToJson(span, logs); var fileName = $"span-{OtlpHelpers.ToShortenedId(span.SpanId)}.json"; return new ExportResult(json, fileName); } @@ -44,10 +44,10 @@ public static ExportResult GetLogEntryAsJson(OtlpLogEntry logEntry) /// /// Gets all spans in a trace as a JSON export result, including associated log entries. /// - public static ExportResult GetTraceAsJson(OtlpTrace trace, ITelemetryRepository telemetryRepository, IOutgoingPeerResolver[] outgoingPeerResolvers) + public static ExportResult GetTraceAsJson(OtlpTrace trace, ITelemetryRepository telemetryRepository) { var logs = telemetryRepository.GetLogsForTrace(trace.TraceId); - var json = TelemetryExportService.ConvertTraceToJson(trace, outgoingPeerResolvers, logs); + var json = TelemetryExportService.ConvertTraceToJson(trace, logs); var fileName = $"trace-{OtlpHelpers.ToShortenedId(trace.TraceId)}.json"; return new ExportResult(json, fileName); } diff --git a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs index 1563fb3a107..98637b1f7b6 100644 --- a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs +++ b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs @@ -153,7 +153,7 @@ private void UpdateHidden(bool isParentCollapsed = false) private readonly record struct SpanWaterfallViewModelState(SpanWaterfallViewModel? Parent, int Depth, bool Hidden); - public sealed record TraceDetailState(IOutgoingPeerResolver[] OutgoingPeerResolvers, List CollapsedSpanIds, List AllResources); + public sealed record TraceDetailState(List CollapsedSpanIds, List AllResources); public static string GetTitle(OtlpSpan span, List allResources) { @@ -195,7 +195,7 @@ static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, bool hid // 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.OutgoingPeerResolvers, state.AllResources) : null; + var uninstrumentedPeer = isUninstrumentedPeer ? ResolveUninstrumentedPeerName(span, state.AllResources) : null; var spanLogVms = new List(); if (spanLogs != null) @@ -248,7 +248,7 @@ static double CalculatePercent(double value, double total) } } - private static string? ResolveUninstrumentedPeerName(OtlpSpan span, IOutgoingPeerResolver[] outgoingPeerResolvers, List allResources) + private static string? ResolveUninstrumentedPeerName(OtlpSpan span, List allResources) { if (span.UninstrumentedPeer != null) { @@ -258,16 +258,6 @@ static double CalculatePercent(double value, double total) return OtlpHelpers.GetResourceName(span.UninstrumentedPeer, allResources); } - // Attempt to resolve uninstrumented peer to a friendly name from the span. - // This should only match non-resource returning resolves such as Browser Link. - foreach (var resolver in outgoingPeerResolvers) - { - if (resolver.TryResolvePeer(span.Attributes, out var name, out _)) - { - return name; - } - } - // Fallback to the peer address. return OtlpHelpers.GetPeerAddress(span.Attributes); } diff --git a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs index 57b562c8fa3..50661d087d8 100644 --- a/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/ResourceMenuBuilder.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Components.Dialogs; -using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Resources; using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; @@ -29,7 +28,7 @@ public sealed class ResourceMenuBuilder private static readonly Icon s_exportEnvIcon = new Icons.Regular.Size16.DocumentText(); private readonly NavigationManager _navigationManager; - private readonly ITelemetryRepository _telemetryRepository; + private readonly DashboardDataSource _dataSource; private readonly IStringLocalizer _controlLoc; private readonly IStringLocalizer _loc; private readonly IconResolver _iconResolver; @@ -41,7 +40,7 @@ public sealed class ResourceMenuBuilder /// public ResourceMenuBuilder( NavigationManager navigationManager, - ITelemetryRepository telemetryRepository, + DashboardDataSource dataSource, IStringLocalizer controlLoc, IStringLocalizer loc, IconResolver iconResolver, @@ -49,7 +48,7 @@ public ResourceMenuBuilder( IDashboardClient dashboardClient) { _navigationManager = navigationManager; - _telemetryRepository = telemetryRepository; + _dataSource = dataSource; _controlLoc = controlLoc; _loc = loc; _iconResolver = iconResolver; @@ -206,7 +205,7 @@ private static MenuButtonItem CreateUrlMenuItem(DisplayedUrl url) private void AddTelemetryMenuItems(List menuItems, ResourceViewModel resource, IDictionary resourceByName) { // Show telemetry menu items if there is telemetry for the resource. - var telemetryResource = _telemetryRepository.GetResourceByCompositeName(resource.Name); + var telemetryResource = _dataSource.TelemetryRepository.GetResourceByCompositeName(resource.Name); if (telemetryResource != null) { menuItems.Add(new MenuButtonItem { IsDivider = true }); diff --git a/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs b/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs index edda12fe402..61b3618e2ef 100644 --- a/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/SpanMenuBuilder.cs @@ -4,7 +4,6 @@ using Aspire.Dashboard.Components.Dialogs; using Aspire.Dashboard.Model.GenAI; using Aspire.Dashboard.Otlp.Model; -using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Resources; using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.Components; @@ -27,8 +26,7 @@ public sealed class SpanMenuBuilder private readonly IStringLocalizer _controlsLoc; private readonly NavigationManager _navigationManager; private readonly DashboardDialogService _dialogService; - private readonly ITelemetryRepository _telemetryRepository; - private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; + private readonly DashboardDataSource _dataSource; /// /// Initializes a new instance of the class. @@ -37,14 +35,12 @@ public SpanMenuBuilder( IStringLocalizer controlsLoc, NavigationManager navigationManager, DashboardDialogService dialogService, - ITelemetryRepository telemetryRepository, - IEnumerable outgoingPeerResolvers) + DashboardDataSource dataSource) { _controlsLoc = controlsLoc; _navigationManager = navigationManager; _dialogService = dialogService; - _telemetryRepository = telemetryRepository; - _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); + _dataSource = dataSource; } /// @@ -99,7 +95,7 @@ public void AddMenuItems( Icon = s_bracesIcon, OnClick = async () => { - var result = ExportHelpers.GetSpanAsJson(span, _telemetryRepository, _outgoingPeerResolvers); + var result = ExportHelpers.GetSpanAsJson(span, _dataSource.TelemetryRepository); await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { DialogService = _dialogService, diff --git a/src/Aspire.Dashboard/Model/StructuredLogMenuBuilder.cs b/src/Aspire.Dashboard/Model/StructuredLogMenuBuilder.cs index b9ac248ad83..42cf3c0e680 100644 --- a/src/Aspire.Dashboard/Model/StructuredLogMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/StructuredLogMenuBuilder.cs @@ -25,7 +25,7 @@ public sealed class StructuredLogMenuBuilder private readonly IStringLocalizer _loc; private readonly IStringLocalizer _controlsLoc; private readonly DashboardDialogService _dialogService; - private readonly ITelemetryRepository _telemetryRepository; + private readonly DashboardDataSource _dataSource; /// /// Initializes a new instance of the class. @@ -34,12 +34,12 @@ public StructuredLogMenuBuilder( IStringLocalizer loc, IStringLocalizer controlsLoc, DashboardDialogService dialogService, - ITelemetryRepository telemetryRepository) + DashboardDataSource dataSource) { _loc = loc; _controlsLoc = controlsLoc; _dialogService = dialogService; - _telemetryRepository = telemetryRepository; + _dataSource = dataSource; } /// @@ -71,7 +71,7 @@ public void AddMenuItems( EventCallback onViewDetails, bool showViewDetails = true) { - AddMenuItems(menuItems, summary.Message, () => _telemetryRepository.GetLog(summary.InternalId), onViewDetails, showViewDetails); + AddMenuItems(menuItems, summary.Message, () => _dataSource.TelemetryRepository.GetLog(summary.InternalId), onViewDetails, showViewDetails); } private void AddMenuItems( diff --git a/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs b/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs index 9630d18bf2d..5ff8ede2ddd 100644 --- a/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs +++ b/src/Aspire.Dashboard/Model/StructuredLogsViewModel.cs @@ -9,7 +9,7 @@ namespace Aspire.Dashboard.Model; public class StructuredLogsViewModel { - private readonly ITelemetryRepository _telemetryRepository; + private readonly DashboardDataSource _dataSource; private readonly List _filters = new(); private PagedResult? _logs; @@ -20,9 +20,9 @@ public class StructuredLogsViewModel private LogLevel? _logLevel; private bool _currentDataHasErrors; - public StructuredLogsViewModel(ITelemetryRepository telemetryRepository) + public StructuredLogsViewModel(DashboardDataSource dataSource) { - _telemetryRepository = telemetryRepository; + _dataSource = dataSource; } public ResourceKey? ResourceKey { get => _resourceKey; set => SetValue(ref _resourceKey, value); } @@ -82,7 +82,7 @@ public PagedResult GetLogs() { var filters = GetFilters(); - logs = _telemetryRepository.GetLogSummaries(new GetLogsContext + logs = _dataSource.TelemetryRepository.GetLogSummaries(new GetLogsContext { ResourceKeys = ResourceKey is { } key ? [key] : [], StartIndex = StartIndex, diff --git a/src/Aspire.Dashboard/Model/TelemetryExportService.cs b/src/Aspire.Dashboard/Model/TelemetryExportService.cs index 91f13ea53fd..a2f4d087379 100644 --- a/src/Aspire.Dashboard/Model/TelemetryExportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryExportService.cs @@ -22,24 +22,21 @@ namespace Aspire.Dashboard.Model; /// public sealed class TelemetryExportService { - private readonly ITelemetryRepository _telemetryRepository; + private readonly DashboardDataSource _dataSource; private readonly ConsoleLogsFetcher _consoleLogsFetcher; private readonly IDashboardClient _dashboardClient; - private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; /// /// Initializes a new instance of the class. /// - /// The telemetry repository. + /// The selected dashboard run data source. /// The console log fetcher. /// The dashboard client for fetching resources. - /// The outgoing peer resolvers for destination name resolution. - public TelemetryExportService(ITelemetryRepository telemetryRepository, ConsoleLogsFetcher consoleLogsFetcher, IDashboardClient dashboardClient, IEnumerable outgoingPeerResolvers) + public TelemetryExportService(DashboardDataSource dataSource, ConsoleLogsFetcher consoleLogsFetcher, IDashboardClient dashboardClient) { - _telemetryRepository = telemetryRepository; + _dataSource = dataSource; _consoleLogsFetcher = consoleLogsFetcher; _dashboardClient = dashboardClient; - _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); } /// @@ -56,14 +53,14 @@ public async Task ExportSelectedAsync( var exportArchive = new ExportArchive(); - var allOtlpResources = _telemetryRepository.GetResources(); + var allOtlpResources = _dataSource.TelemetryRepository.GetResources(); // Get resources from dashboard client if enabled and ResourceDetails is selected List resourceDetailsResources = []; var hasResourceDetailsSelected = selectedResources.Any(kvp => kvp.Value.Contains(AspireDataType.ResourceDetails)); if (_dashboardClient.IsEnabled && hasResourceDetailsSelected) { - var snapshot = _dashboardClient.GetResources(); + var snapshot = _dataSource.ResourceRepository.GetResources(); var resourcesByName = snapshot.ToDictionary(r => r.Name, StringComparers.ResourceName); resourceDetailsResources = selectedResources @@ -158,7 +155,7 @@ private void AddStructuredLogs(ExportArchive exportArchive, List r { foreach (var resource in resources) { - var logs = _telemetryRepository.GetLogs(GetLogsContext.ForResourceKey(resource.ResourceKey)); + var logs = _dataSource.TelemetryRepository.GetLogs(GetLogsContext.ForResourceKey(resource.ResourceKey)); if (logs.Items.Count == 0) { @@ -174,7 +171,7 @@ private void AddTraces(ExportArchive exportArchive, List resources { foreach (var resource in resources) { - var tracesResponse = _telemetryRepository.GetTraces(GetTracesRequest.ForResourceKey(resource.ResourceKey)); + var tracesResponse = _dataSource.TelemetryRepository.GetTraces(GetTracesRequest.ForResourceKey(resource.ResourceKey)); if (tracesResponse.PagedResult.Items.Count == 0) { @@ -182,7 +179,7 @@ private void AddTraces(ExportArchive exportArchive, List resources } var resourceName = OtlpHelpers.GetResourceName(resource, resources); - exportArchive.Traces[resourceName] = ConvertTracesToOtlpJson(tracesResponse.PagedResult.Items, _outgoingPeerResolvers); + exportArchive.Traces[resourceName] = ConvertTracesToOtlpJson(tracesResponse.PagedResult.Items); } } @@ -190,7 +187,7 @@ private void AddMetrics(ExportArchive exportArchive, List resource { foreach (var resource in resources) { - var instrumentSummaries = _telemetryRepository.GetInstrumentsSummaries(resource.ResourceKey); + var instrumentSummaries = _dataSource.TelemetryRepository.GetInstrumentsSummaries(resource.ResourceKey); if (instrumentSummaries.Count == 0) { @@ -201,7 +198,7 @@ private void AddMetrics(ExportArchive exportArchive, List resource var instrumentsData = new List(); foreach (var summary in instrumentSummaries) { - var instrumentData = _telemetryRepository.GetInstrument(new GetInstrumentRequest + var instrumentData = _dataSource.TelemetryRepository.GetInstrument(new GetInstrumentRequest { ResourceKey = resource.ResourceKey, MeterName = summary.Parent.Name, @@ -272,7 +269,7 @@ private static OtlpLogRecordJson ConvertLogEntry(OtlpLogEntry log) }; } - internal static OtlpTelemetryDataJson ConvertSpansToOtlpJson(IReadOnlyList spans, IOutgoingPeerResolver[] outgoingPeerResolvers) + internal static OtlpTelemetryDataJson ConvertSpansToOtlpJson(IReadOnlyList spans) { // Group spans by resource and scope var resourceSpans = spans @@ -288,7 +285,7 @@ internal static OtlpTelemetryDataJson ConvertSpansToOtlpJson(IReadOnlyList new OtlpScopeSpansJson { Scope = ConvertScope(scopeGroup.Key), - Spans = scopeGroup.Select(s => ConvertSpan(s, outgoingPeerResolvers)).ToArray() + Spans = scopeGroup.Select(ConvertSpan).ToArray() }).ToArray() }; }).ToArray(); @@ -299,14 +296,14 @@ internal static OtlpTelemetryDataJson ConvertSpansToOtlpJson(IReadOnlyList traces, IOutgoingPeerResolver[] outgoingPeerResolvers) + internal static OtlpTelemetryDataJson ConvertTracesToOtlpJson(IReadOnlyList traces) { // Group spans by resource and scope var allSpans = traces.SelectMany(t => t.Spans).ToList(); - return ConvertSpansToOtlpJson(allSpans, outgoingPeerResolvers); + return ConvertSpansToOtlpJson(allSpans); } - internal static string ConvertSpanToJson(OtlpSpan span, IOutgoingPeerResolver[] outgoingPeerResolvers, List? logs = null, bool indent = true) + internal static string ConvertSpanToJson(OtlpSpan span, List? logs = null, bool indent = true) { var data = new OtlpTelemetryDataJson { @@ -320,7 +317,7 @@ internal static string ConvertSpanToJson(OtlpSpan span, IOutgoingPeerResolver[] new OtlpScopeSpansJson { Scope = ConvertScope(span.Scope), - Spans = [ConvertSpan(span, outgoingPeerResolvers)] + Spans = [ConvertSpan(span)] } ] } @@ -331,7 +328,7 @@ internal static string ConvertSpanToJson(OtlpSpan span, IOutgoingPeerResolver[] return JsonSerializer.Serialize(data, options); } - internal static string ConvertTraceToJson(OtlpTrace trace, IOutgoingPeerResolver[] outgoingPeerResolvers, List? logs = null) + internal static string ConvertTraceToJson(OtlpTrace trace, List? logs = null) { // Group spans by resource and scope var spansByResourceAndScope = trace.Spans @@ -347,7 +344,7 @@ internal static string ConvertTraceToJson(OtlpTrace trace, IOutgoingPeerResolver .Select(scopeGroup => new OtlpScopeSpansJson { Scope = ConvertScope(scopeGroup.Key), - Spans = scopeGroup.Select(s => ConvertSpan(s, outgoingPeerResolvers)).ToArray() + Spans = scopeGroup.Select(ConvertSpan).ToArray() }).ToArray() }; }).ToArray(); @@ -383,11 +380,9 @@ internal static string ConvertLogEntryToJson(OtlpLogEntry logEntry) return JsonSerializer.Serialize(data, OtlpJsonSerializerContext.IndentedOptions); } - private static OtlpSpanJson ConvertSpan(OtlpSpan span, IOutgoingPeerResolver[] outgoingPeerResolvers) + private static OtlpSpanJson ConvertSpan(OtlpSpan span) { - var destinationName = outgoingPeerResolvers.Length > 0 - ? GetDestination(span, outgoingPeerResolvers) - : null; + var destinationName = GetDestination(span); return new OtlpSpanJson { @@ -817,20 +812,10 @@ internal static ResourceJson CreateResourceJson(ResourceViewModel resource, IRea } /// - /// Gets the destination name for a span by resolving uninstrumented peer names. + /// Gets the destination name stored for a span. /// - private static string? GetDestination(OtlpSpan span, IEnumerable outgoingPeerResolvers) + private static string? GetDestination(OtlpSpan span) { - // Attempt to resolve uninstrumented peer to a friendly name from the span. - foreach (var resolver in outgoingPeerResolvers) - { - if (resolver.TryResolvePeer(span.Attributes, out var name, out _)) - { - return name; - } - } - - // Fallback to the peer address. - return span.Attributes.GetPeerAddress(); + return span.GetDestination()?.ResourceKey.GetCompositeName() ?? span.Attributes.GetPeerAddress(); } } diff --git a/src/Aspire.Dashboard/Model/TelemetryImportService.cs b/src/Aspire.Dashboard/Model/TelemetryImportService.cs index ecde29bd8fb..ab99a0be8a6 100644 --- a/src/Aspire.Dashboard/Model/TelemetryImportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryImportService.cs @@ -17,7 +17,7 @@ namespace Aspire.Dashboard.Model; /// public sealed class TelemetryImportService { - private readonly ITelemetryRepository _telemetryRepository; + private readonly ITelemetryRepositoryWriter _telemetryRepositoryWriter; private readonly IOptionsMonitor _options; private readonly ILogger _logger; @@ -29,12 +29,12 @@ public sealed class TelemetryImportService /// /// Initializes a new instance of the class. /// - /// The telemetry repository. + /// The telemetry repository writer. /// The dashboard options. /// The logger. - public TelemetryImportService(ITelemetryRepository telemetryRepository, IOptionsMonitor options, ILogger logger) + public TelemetryImportService(ITelemetryRepositoryWriter telemetryRepositoryWriter, IOptionsMonitor options, ILogger logger) { - _telemetryRepository = telemetryRepository; + _telemetryRepositoryWriter = telemetryRepositoryWriter; _options = options; _logger = logger; } @@ -165,7 +165,7 @@ private void ImportLogs(OtlpResourceLogsJson[] resourceLogs) var protobufRequest = OtlpJsonToProtobufConverter.ToProtobuf(exportRequest); var addContext = new AddContext(); - _telemetryRepository.AddLogs(addContext, protobufRequest.ResourceLogs); + _telemetryRepositoryWriter.AddLogs(addContext, protobufRequest.ResourceLogs); _logger.LogDebug("Imported logs: {SuccessCount} succeeded, {FailureCount} failed", addContext.SuccessCount, addContext.FailureCount); } @@ -176,7 +176,7 @@ private void ImportTraces(OtlpResourceSpansJson[] resourceSpans) var protobufRequest = OtlpJsonToProtobufConverter.ToProtobuf(exportRequest); var addContext = new AddContext(); - _telemetryRepository.AddTraces(addContext, protobufRequest.ResourceSpans); + _telemetryRepositoryWriter.AddTraces(addContext, protobufRequest.ResourceSpans); _logger.LogDebug("Imported traces: {SuccessCount} succeeded, {FailureCount} failed", addContext.SuccessCount, addContext.FailureCount); } @@ -187,7 +187,7 @@ private void ImportMetrics(OtlpResourceMetricsJson[] resourceMetrics) var protobufRequest = OtlpJsonToProtobufConverter.ToProtobuf(exportRequest); var addContext = new AddContext(); - _telemetryRepository.AddMetrics(addContext, protobufRequest.ResourceMetrics); + _telemetryRepositoryWriter.AddMetrics(addContext, protobufRequest.ResourceMetrics); _logger.LogDebug("Imported metrics: {SuccessCount} succeeded, {FailureCount} failed", addContext.SuccessCount, addContext.FailureCount); } diff --git a/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs b/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs index 8b0cabf7d33..cc76b3c12d0 100644 --- a/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs +++ b/src/Aspire.Dashboard/Model/TraceMenuBuilder.cs @@ -25,8 +25,7 @@ public sealed class TraceMenuBuilder private readonly IStringLocalizer _controlsLoc; private readonly NavigationManager _navigationManager; private readonly DashboardDialogService _dialogService; - private readonly ITelemetryRepository _telemetryRepository; - private readonly IOutgoingPeerResolver[] _outgoingPeerResolvers; + private readonly DashboardDataSource _dataSource; /// /// Initializes a new instance of the class. @@ -35,14 +34,12 @@ public TraceMenuBuilder( IStringLocalizer controlsLoc, NavigationManager navigationManager, DashboardDialogService dialogService, - ITelemetryRepository telemetryRepository, - IEnumerable outgoingPeerResolvers) + DashboardDataSource dataSource) { _controlsLoc = controlsLoc; _navigationManager = navigationManager; _dialogService = dialogService; - _telemetryRepository = telemetryRepository; - _outgoingPeerResolvers = outgoingPeerResolvers.ToArray(); + _dataSource = dataSource; } /// @@ -70,7 +67,7 @@ public void AddMenuItems( TraceSummary summary, bool showViewDetails = true) { - AddMenuItems(menuItems, summary.TraceId, () => _telemetryRepository.GetTrace(summary.TraceId), showViewDetails); + AddMenuItems(menuItems, summary.TraceId, () => _dataSource.TelemetryRepository.GetTrace(summary.TraceId), showViewDetails); } private void AddMenuItems( @@ -116,7 +113,7 @@ private void AddMenuItems( return; } - var result = ExportHelpers.GetTraceAsJson(trace, _telemetryRepository, _outgoingPeerResolvers); + var result = ExportHelpers.GetTraceAsJson(trace, _dataSource.TelemetryRepository); await TextVisualizerDialog.OpenDialogAsync(new OpenTextVisualizerDialogOptions { DialogService = _dialogService, diff --git a/src/Aspire.Dashboard/Model/TracesViewModel.cs b/src/Aspire.Dashboard/Model/TracesViewModel.cs index 6c9987317eb..1879d7895e3 100644 --- a/src/Aspire.Dashboard/Model/TracesViewModel.cs +++ b/src/Aspire.Dashboard/Model/TracesViewModel.cs @@ -8,7 +8,7 @@ namespace Aspire.Dashboard.Model; public class TracesViewModel { - private readonly ITelemetryRepository _telemetryRepository; + private readonly DashboardDataSource _dataSource; private readonly List _filters = new(); private PagedResult? _traces; @@ -18,9 +18,9 @@ public class TracesViewModel private int _count; private SpanType? _spanType; - public TracesViewModel(ITelemetryRepository telemetryRepository) + public TracesViewModel(DashboardDataSource dataSource) { - _telemetryRepository = telemetryRepository; + _dataSource = dataSource; } public ResourceKey? ResourceKey { get => _resourceKey; set => SetValue(ref _resourceKey, value); } @@ -80,7 +80,7 @@ public PagedResult GetTraces() { var filters = GetFilters(); - var result = _telemetryRepository.GetTraceSummaries(new GetTracesRequest + var result = _dataSource.TelemetryRepository.GetTraceSummaries(new GetTracesRequest { ResourceKeys = ResourceKey is { } key ? [key] : [], StartIndex = StartIndex, diff --git a/src/Aspire.Dashboard/Otlp/OtlpLogsService.cs b/src/Aspire.Dashboard/Otlp/OtlpLogsService.cs index 80416549dcc..f5c52957206 100644 --- a/src/Aspire.Dashboard/Otlp/OtlpLogsService.cs +++ b/src/Aspire.Dashboard/Otlp/OtlpLogsService.cs @@ -12,18 +12,18 @@ namespace Aspire.Dashboard.Otlp; public sealed class OtlpLogsService { private readonly ILogger _logger; - private readonly ITelemetryRepository _telemetryRepository; + private readonly ITelemetryRepositoryWriter _telemetryRepositoryWriter; - public OtlpLogsService(ILogger logger, ITelemetryRepository telemetryRepository) + public OtlpLogsService(ILogger logger, ITelemetryRepositoryWriter telemetryRepositoryWriter) { _logger = logger; - _telemetryRepository = telemetryRepository; + _telemetryRepositoryWriter = telemetryRepositoryWriter; } public ExportLogsServiceResponse Export(ExportLogsServiceRequest request) { var addContext = new AddContext(); - _telemetryRepository.AddLogs(addContext, request.ResourceLogs); + _telemetryRepositoryWriter.AddLogs(addContext, request.ResourceLogs); _logger.LogDebug("Processed logs export. Success count: {SuccessCount}, failure count: {FailureCount}", addContext.SuccessCount, addContext.FailureCount); diff --git a/src/Aspire.Dashboard/Otlp/OtlpMetricsService.cs b/src/Aspire.Dashboard/Otlp/OtlpMetricsService.cs index 1247305eeaa..c1501855570 100644 --- a/src/Aspire.Dashboard/Otlp/OtlpMetricsService.cs +++ b/src/Aspire.Dashboard/Otlp/OtlpMetricsService.cs @@ -12,18 +12,18 @@ namespace Aspire.Dashboard.Otlp; public sealed class OtlpMetricsService { private readonly ILogger _logger; - private readonly ITelemetryRepository _telemetryRepository; + private readonly ITelemetryRepositoryWriter _telemetryRepositoryWriter; - public OtlpMetricsService(ILogger logger, ITelemetryRepository telemetryRepository) + public OtlpMetricsService(ILogger logger, ITelemetryRepositoryWriter telemetryRepositoryWriter) { _logger = logger; - _telemetryRepository = telemetryRepository; + _telemetryRepositoryWriter = telemetryRepositoryWriter; } public ExportMetricsServiceResponse Export(ExportMetricsServiceRequest request) { var addContext = new AddContext(); - _telemetryRepository.AddMetrics(addContext, request.ResourceMetrics); + _telemetryRepositoryWriter.AddMetrics(addContext, request.ResourceMetrics); _logger.LogDebug("Processed metrics export. Success count: {SuccessCount}, failure count: {FailureCount}", addContext.SuccessCount, addContext.FailureCount); diff --git a/src/Aspire.Dashboard/Otlp/OtlpTraceService.cs b/src/Aspire.Dashboard/Otlp/OtlpTraceService.cs index 1f2675860b9..a7766f3af21 100644 --- a/src/Aspire.Dashboard/Otlp/OtlpTraceService.cs +++ b/src/Aspire.Dashboard/Otlp/OtlpTraceService.cs @@ -12,18 +12,18 @@ namespace Aspire.Dashboard.Otlp; public sealed class OtlpTraceService { private readonly ILogger _logger; - private readonly ITelemetryRepository _telemetryRepository; + private readonly ITelemetryRepositoryWriter _telemetryRepositoryWriter; - public OtlpTraceService(ILogger logger, ITelemetryRepository telemetryRepository) + public OtlpTraceService(ILogger logger, ITelemetryRepositoryWriter telemetryRepositoryWriter) { _logger = logger; - _telemetryRepository = telemetryRepository; + _telemetryRepositoryWriter = telemetryRepositoryWriter; } public ExportTraceServiceResponse Export(ExportTraceServiceRequest request) { var addContext = new AddContext(); - _telemetryRepository.AddTraces(addContext, request.ResourceSpans); + _telemetryRepositoryWriter.AddTraces(addContext, request.ResourceSpans); _logger.LogDebug("Processed trace export. Success count: {SuccessCount}, failure count: {FailureCount}", addContext.SuccessCount, addContext.FailureCount); diff --git a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs index 7340115f05d..afa552d6f9a 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs @@ -1,13 +1,8 @@ // 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 Google.Protobuf.Collections; using Microsoft.FluentUI.AspNetCore.Components; -using OpenTelemetry.Proto.Logs.V1; -using OpenTelemetry.Proto.Metrics.V1; -using OpenTelemetry.Proto.Trace.V1; namespace Aspire.Dashboard.Otlp.Storage; @@ -34,10 +29,6 @@ public interface ITelemetryRepository : IDisposable Subscription OnNewMetrics(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback); Subscription OnNewTraces(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback); - void AddLogs(AddContext context, RepeatedField resourceLogs); - void AddMetrics(AddContext context, RepeatedField resourceMetrics); - void AddTraces(AddContext context, RepeatedField resourceSpans); - PagedResult GetLogs(GetLogsContext context); PagedResult GetLogSummaries(GetLogsContext context); OtlpLogEntry? GetLog(long logId); @@ -61,8 +52,4 @@ public interface ITelemetryRepository : IDisposable IAsyncEnumerable WatchSpansAsync(WatchSpansRequest request, CancellationToken cancellationToken); IAsyncEnumerable WatchLogsAsync(WatchLogsRequest request, CancellationToken cancellationToken); - void ClearSelectedSignals(Dictionary> selectedResources); - void ClearTraces(ResourceKey? resourceKey = null); - void ClearStructuredLogs(ResourceKey? resourceKey = null); - void ClearMetrics(ResourceKey? resourceKey = null); } diff --git a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepositoryWriter.cs b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepositoryWriter.cs new file mode 100644 index 00000000000..6b5c34a904c --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepositoryWriter.cs @@ -0,0 +1,62 @@ +// 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 Google.Protobuf.Collections; +using OpenTelemetry.Proto.Logs.V1; +using OpenTelemetry.Proto.Metrics.V1; +using OpenTelemetry.Proto.Trace.V1; + +namespace Aspire.Dashboard.Otlp.Storage; + +/// +/// Adds and removes telemetry in the writable dashboard telemetry store. +/// +public interface ITelemetryRepositoryWriter +{ + /// + /// Adds log records to the telemetry store. + /// + /// The context that records the result of adding telemetry. + /// The resource log records to add. + void AddLogs(AddContext context, RepeatedField resourceLogs); + + /// + /// Adds metric data to the telemetry store. + /// + /// The context that records the result of adding telemetry. + /// The resource metric data to add. + void AddMetrics(AddContext context, RepeatedField resourceMetrics); + + /// + /// Adds spans to the telemetry store. + /// + /// The context that records the result of adding telemetry. + /// The resource spans to add. + void AddTraces(AddContext context, RepeatedField resourceSpans); + + /// + /// Removes the selected telemetry signals for each resource. + /// + /// The telemetry signal types selected for removal, keyed by resource name. + void ClearSelectedSignals(Dictionary> selectedResources); + + /// + /// Removes traces, optionally limited to a resource. + /// + /// The resource to clear, or to clear all resources. + void ClearTraces(ResourceKey? resourceKey = null); + + /// + /// Removes structured logs, optionally limited to a resource. + /// + /// The resource to clear, or to clear all resources. + void ClearStructuredLogs(ResourceKey? resourceKey = null); + + /// + /// Removes metrics, optionally limited to a resource. + /// + /// The resource to clear, or to clear all resources. + void ClearMetrics(ResourceKey? resourceKey = null); +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SelectedTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SelectedTelemetryRepository.cs deleted file mode 100644 index d2fcc7c0f2a..00000000000 --- a/src/Aspire.Dashboard/Otlp/Storage/SelectedTelemetryRepository.cs +++ /dev/null @@ -1,89 +0,0 @@ -// 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 Google.Protobuf.Collections; -using Microsoft.FluentUI.AspNetCore.Components; -using OpenTelemetry.Proto.Logs.V1; -using OpenTelemetry.Proto.Metrics.V1; -using OpenTelemetry.Proto.Trace.V1; - -namespace Aspire.Dashboard.Otlp.Storage; - -/// -/// Forwards telemetry operations to the repository for the selected dashboard run. -/// -internal sealed class SelectedTelemetryRepository(DashboardDataSource dataSource) : ITelemetryRepository -{ - private ITelemetryRepository Repository => dataSource.TelemetryRepository; - - public bool HasDisplayedMaxLogLimitMessage - { - get => Repository.HasDisplayedMaxLogLimitMessage; - set => Repository.HasDisplayedMaxLogLimitMessage = value; - } - - public Message? MaxLogLimitMessage - { - get => Repository.MaxLogLimitMessage; - set => Repository.MaxLogLimitMessage = value; - } - - public bool HasDisplayedMaxTraceLimitMessage - { - get => Repository.HasDisplayedMaxTraceLimitMessage; - set => Repository.HasDisplayedMaxTraceLimitMessage = value; - } - - public Message? MaxTraceLimitMessage - { - get => Repository.MaxTraceLimitMessage; - set => Repository.MaxTraceLimitMessage = value; - } - - public List GetResources(bool includeUninstrumentedPeers = false) => Repository.GetResources(includeUninstrumentedPeers); - public List GetResourcesByName(string name, bool includeUninstrumentedPeers = false) => Repository.GetResourcesByName(name, includeUninstrumentedPeers); - public OtlpResource? GetResourceByCompositeName(string compositeName) => Repository.GetResourceByCompositeName(compositeName); - public OtlpResource? GetResource(ResourceKey key) => Repository.GetResource(key); - public List GetResources(ResourceKey key, bool includeUninstrumentedPeers = false) => Repository.GetResources(key, includeUninstrumentedPeers); - public Dictionary GetResourceUnviewedErrorLogsCount() => Repository.GetResourceUnviewedErrorLogsCount(); - public void MarkViewedErrorLogs(ResourceKey? key) => Repository.MarkViewedErrorLogs(key); - public Subscription OnNewResources(Func callback) => Repository.OnNewResources(callback); - public Subscription OnNewLogs(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback) => Repository.OnNewLogs(resourceKey, subscriptionType, callback); - public Subscription OnNewMetrics(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback) => Repository.OnNewMetrics(resourceKey, subscriptionType, callback); - public Subscription OnNewTraces(ResourceKey? resourceKey, SubscriptionType subscriptionType, Func callback) => Repository.OnNewTraces(resourceKey, subscriptionType, callback); - public void AddLogs(AddContext context, RepeatedField resourceLogs) => Repository.AddLogs(context, resourceLogs); - public void AddMetrics(AddContext context, RepeatedField resourceMetrics) => Repository.AddMetrics(context, resourceMetrics); - public void AddTraces(AddContext context, RepeatedField resourceSpans) => Repository.AddTraces(context, resourceSpans); - public PagedResult GetLogs(GetLogsContext context) => Repository.GetLogs(context); - public PagedResult GetLogSummaries(GetLogsContext context) => Repository.GetLogSummaries(context); - public OtlpLogEntry? GetLog(long logId) => Repository.GetLog(logId); - public List GetLogsForSpan(string traceId, string spanId) => Repository.GetLogsForSpan(traceId, spanId); - public List GetLogsForTrace(string traceId) => Repository.GetLogsForTrace(traceId); - public List GetLogPropertyKeys(ResourceKey? resourceKey) => Repository.GetLogPropertyKeys(resourceKey); - public List GetTracePropertyKeys(ResourceKey? resourceKey) => Repository.GetTracePropertyKeys(resourceKey); - public GetTracesResponse GetTraces(GetTracesRequest context) => Repository.GetTraces(context); - public GetTraceSummariesResponse GetTraceSummaries(GetTracesRequest context) => Repository.GetTraceSummaries(context); - public GetSpansResponse GetSpans(GetSpansRequest context) => Repository.GetSpans(context); - public Dictionary GetTraceFieldValues(string attributeName) => Repository.GetTraceFieldValues(attributeName); - public Dictionary GetLogsFieldValues(string attributeName) => Repository.GetLogsFieldValues(attributeName); - public bool HasUpdatedTrace(OtlpTrace trace) => Repository.HasUpdatedTrace(trace); - public OtlpTrace? GetTrace(string traceId) => Repository.GetTrace(traceId); - public OtlpSpan? GetSpan(string traceId, string spanId) => Repository.GetSpan(traceId, spanId); - public OtlpResource? GetPeerResource(OtlpSpan span) => Repository.GetPeerResource(span); - public List GetInstrumentsSummaries(ResourceKey key) => Repository.GetInstrumentsSummaries(key); - public OtlpInstrumentData? GetInstrument(GetInstrumentRequest request) => Repository.GetInstrument(request); - public DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) => Repository.GetInstrumentLatestEndTime(resourceKey, meterName, instrumentName); - public IAsyncEnumerable WatchSpansAsync(WatchSpansRequest request, CancellationToken cancellationToken) => Repository.WatchSpansAsync(request, cancellationToken); - public IAsyncEnumerable WatchLogsAsync(WatchLogsRequest request, CancellationToken cancellationToken) => Repository.WatchLogsAsync(request, cancellationToken); - public void ClearSelectedSignals(Dictionary> selectedResources) => Repository.ClearSelectedSignals(selectedResources); - public void ClearTraces(ResourceKey? resourceKey = null) => Repository.ClearTraces(resourceKey); - public void ClearStructuredLogs(ResourceKey? resourceKey = null) => Repository.ClearStructuredLogs(resourceKey); - public void ClearMetrics(ResourceKey? resourceKey = null) => Repository.ClearMetrics(resourceKey); - - public void Dispose() - { - // DashboardDataSource owns the selected repository and disposes historical instances when selection changes. - } -} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs index 86f4ffef770..e80a3c383b1 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs @@ -190,6 +190,12 @@ private void NotifySpansAdded(List spans) RaiseSubscriptionChanged(_resourceSubscriptions); } + private void NotifyPeersChanged() + { + RaiseSubscriptionChanged(_tracesSubscriptions); + RaiseSubscriptionChanged(_resourceSubscriptions); + } + private void NotifyMetricsAdded() { RaiseSubscriptionChanged(_metricsSubscriptions); diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs index 24401ee4ff7..5a53e8b4ae4 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs @@ -187,21 +187,14 @@ private void UpdateUninstrumentedPeers(SqliteConnection connection, IDbTransacti var hasPeerAddress = OtlpHelpers.GetPeerAddress(span.Attributes) is not null; if (hasPeerAddress && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !span.GetChildSpans().Any()) { - foreach (var resolver in _outgoingPeerResolvers) + if (TryResolvePeerResourceKey(span.Attributes, out var peerKey)) { - if (!resolver.TryResolvePeer(span.Attributes, out _, out var matchedResource) || matchedResource is null) - { - continue; - } - - var peerKey = ResourceKey.Create(matchedResource.DisplayName, matchedResource.Name); peerResourceId = GetOrAddTelemetryResource(connection, transaction, peerKey); connection.Execute( "UPDATE telemetry_resources SET uninstrumented_peer = 1 WHERE resource_id = @ResourceId;", new { ResourceId = peerResourceId }, transaction); peer = new OtlpResource(peerKey.Name, peerKey.InstanceId, uninstrumentedPeer: true, _otlpContext); - break; } } @@ -1244,14 +1237,8 @@ FROM telemetry_span_attributes (OtlpSpanKind)span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !parents.Contains((span.TraceId, span.SpanId))) { - foreach (var resolver in _outgoingPeerResolvers) + if (TryResolvePeerResourceKey(spanAttributes, out var peerKey)) { - if (!resolver.TryResolvePeer(spanAttributes, out _, out var matchedResource) || matchedResource is null) - { - continue; - } - - var peerKey = ResourceKey.Create(matchedResource.DisplayName, matchedResource.Name); if (!peerResourceIds.TryGetValue(peerKey, out var resourceId)) { resourceId = GetOrAddTelemetryResource(connection, transaction, peerKey); @@ -1262,7 +1249,6 @@ FROM telemetry_span_attributes transaction); } peerResourceId = resourceId; - break; } } @@ -1293,6 +1279,32 @@ UPDATE telemetry_traces } } + private bool TryResolvePeerResourceKey(KeyValuePair[] attributes, out ResourceKey peerKey) + { + foreach (var resolver in _outgoingPeerResolvers) + { + if (!resolver.TryResolvePeer(attributes, out var name, out var matchedResource)) + { + continue; + } + + if (matchedResource is not null) + { + peerKey = ResourceKey.Create(matchedResource.DisplayName, matchedResource.Name); + return true; + } + + if (!string.IsNullOrEmpty(name)) + { + peerKey = new ResourceKey(name, InstanceId: null); + return true; + } + } + + peerKey = default; + return false; + } + private void ClearTracesFromDatabase(ResourceKey? resourceKey) { lock (_writeLock) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index 209197cad1e..66fba6e86c3 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -15,7 +15,7 @@ namespace Aspire.Dashboard.Otlp.Storage; /// /// Persists telemetry to SQLite and exposes it through the dashboard telemetry model. /// -public sealed partial class SqliteTelemetryRepository : ITelemetryRepository +public sealed partial class SqliteTelemetryRepository : ITelemetryRepository, ITelemetryRepositoryWriter { private readonly DashboardSqliteDatabase _database; private readonly OtlpContext _otlpContext; @@ -23,6 +23,7 @@ public sealed partial class SqliteTelemetryRepository : ITelemetryRepository private readonly IReadOnlyList _outgoingPeerResolvers; private readonly List _outgoingPeerSubscriptions = []; private readonly object _writeLock = new(); + private int _disposed; private static string CreateContainsLikePattern(string value) => $"%{EscapeLikePattern(value)}%"; @@ -71,6 +72,7 @@ internal SqliteTelemetryRepository( _outgoingPeerSubscriptions.Add(resolver.OnPeerChanges(() => { RecalculateUninstrumentedPeers(); + NotifyPeersChanged(); return Task.CompletedTask; })); } @@ -144,23 +146,7 @@ public void AddTraces(AddContext context, RepeatedField resourceS public bool HasUpdatedTrace(OtlpTrace trace) => HasUpdatedTraceInDatabase(trace); public OtlpTrace? GetTrace(string traceId) => GetTraceFromDatabase(traceId); public OtlpSpan? GetSpan(string traceId, string spanId) => GetSpanFromDatabase(traceId, spanId); - public OtlpResource? GetPeerResource(OtlpSpan span) - { - if (span.UninstrumentedPeer is not null) - { - return span.UninstrumentedPeer; - } - - foreach (var resolver in _outgoingPeerResolvers) - { - if (resolver.TryResolvePeer(span.Attributes, out _, out var matchedResource) && matchedResource is not null) - { - return GetResource(ResourceKey.Create(matchedResource.DisplayName, matchedResource.Name)); - } - } - - return null; - } + public OtlpResource? GetPeerResource(OtlpSpan span) => span.UninstrumentedPeer; public List GetInstrumentsSummaries(ResourceKey key) => GetInstrumentsSummariesFromDatabase(key); public OtlpInstrumentData? GetInstrument(GetInstrumentRequest request) => GetInstrumentFromDatabase(request); public DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) => @@ -210,6 +196,11 @@ private void EnsureWritable() public void Dispose() { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + foreach (var subscription in _outgoingPeerSubscriptions) { subscription.Dispose(); diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs index 26ceba3acc8..2a17b8fc253 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs @@ -1,10 +1,7 @@ // 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.Configuration; -using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Storage; -using Microsoft.Extensions.Options; namespace Aspire.Dashboard.ServiceClient; @@ -13,48 +10,50 @@ internal interface IDashboardRunSelection void SelectRun(string? runId); } -internal sealed class DashboardDataSource : IDashboardRunSelection, IDisposable +/// +/// Provides repositories for the dashboard run selected in the current scope. +/// +public sealed class DashboardDataSource : IDashboardRunSelection, IDisposable { - private readonly DashboardRunStore _runStore; - private readonly SqliteTelemetryRepository _currentTelemetryRepository; - private readonly SqliteResourceRepository _currentResourceRepository; - private readonly ILoggerFactory _loggerFactory; - private readonly IOptions _dashboardOptions; - private readonly PauseManager _pauseManager; - private readonly IEnumerable _outgoingPeerResolvers; - private readonly IKnownPropertyLookup _knownPropertyLookup; - - private SqliteTelemetryRepository? _historicalTelemetryRepository; - private SqliteResourceRepository? _historicalResourceRepository; - - public DashboardDataSource( - DashboardRunStore runStore, - SqliteTelemetryRepository currentTelemetryRepository, - SqliteResourceRepository currentResourceRepository, - ILoggerFactory loggerFactory, - IOptions dashboardOptions, - PauseManager pauseManager, - IEnumerable outgoingPeerResolvers, - IKnownPropertyLookup knownPropertyLookup) + private readonly IDashboardRunStore _runStore; + private readonly ITelemetryRepository _currentTelemetryRepository; + private readonly IResourceRepository _currentResourceRepository; + private readonly IRepositoryFactory _repositoryFactory; + + private ITelemetryRepository? _historicalTelemetryRepository; + private IResourceRepository? _historicalResourceRepository; + + internal DashboardDataSource( + IDashboardRunStore runStore, + ITelemetryRepository currentTelemetryRepository, + IResourceRepository currentResourceRepository, + IRepositoryFactory repositoryFactory) { _runStore = runStore; _currentTelemetryRepository = currentTelemetryRepository; _currentResourceRepository = currentResourceRepository; - _loggerFactory = loggerFactory; - _dashboardOptions = dashboardOptions; - _pauseManager = pauseManager; - _outgoingPeerResolvers = outgoingPeerResolvers; - _knownPropertyLookup = knownPropertyLookup; + _repositoryFactory = repositoryFactory; SelectRun(runId: null); } - public DashboardRunDescriptor SelectedRun { get; private set; } = null!; + internal DashboardRunDescriptor SelectedRun { get; private set; } = null!; + + /// + /// Gets the telemetry repository for the selected dashboard run. + /// public ITelemetryRepository TelemetryRepository { get; private set; } = null!; + + /// + /// Gets the resource repository for the selected dashboard run. + /// public IResourceRepository ResourceRepository { get; private set; } = null!; - public bool IsReadOnly { get; private set; } - public void SelectRun(string? runId) + internal bool IsReadOnly { get; private set; } + + void IDashboardRunSelection.SelectRun(string? runId) => SelectRun(runId); + + internal void SelectRun(string? runId) { var runs = _runStore.GetRuns(); var selectedRun = runs.FirstOrDefault(run => string.Equals(run.RunId, runId, StringComparison.Ordinal)) @@ -64,24 +63,15 @@ public void SelectRun(string? runId) return; } - _historicalTelemetryRepository?.Dispose(); - _historicalResourceRepository?.Dispose(); + DisposeHistoricalRepositories(); _historicalTelemetryRepository = null; _historicalResourceRepository = null; if (!selectedRun.IsCurrent) { var historicalDatabase = new DashboardSqliteDatabase(selectedRun.DatabasePath, readOnly: true); - _historicalTelemetryRepository = new SqliteTelemetryRepository( - historicalDatabase, - _loggerFactory, - _dashboardOptions, - _pauseManager, - _outgoingPeerResolvers); - _historicalResourceRepository = new SqliteResourceRepository( - historicalDatabase, - _knownPropertyLookup, - _loggerFactory); + _historicalTelemetryRepository = _repositoryFactory.CreateTelemetryRepository(historicalDatabase); + _historicalResourceRepository = _repositoryFactory.CreateResourceRepository(historicalDatabase); TelemetryRepository = _historicalTelemetryRepository; ResourceRepository = _historicalResourceRepository; IsReadOnly = true; @@ -97,8 +87,13 @@ public void SelectRun(string? runId) } public void Dispose() + { + DisposeHistoricalRepositories(); + } + + private void DisposeHistoricalRepositories() { _historicalTelemetryRepository?.Dispose(); - _historicalResourceRepository?.Dispose(); + (_historicalResourceRepository as IDisposable)?.Dispose(); } -} \ No newline at end of file +} diff --git a/src/Aspire.Dashboard/ServiceClient/IRepositoryFactory.cs b/src/Aspire.Dashboard/ServiceClient/IRepositoryFactory.cs new file mode 100644 index 00000000000..74af0bd53b8 --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/IRepositoryFactory.cs @@ -0,0 +1,22 @@ +// 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.Storage; + +namespace Aspire.Dashboard.ServiceClient; + +/// +/// Creates repositories for current and historical dashboard run databases. +/// +internal interface IRepositoryFactory : IDisposable +{ + /// + /// Creates a telemetry repository for the specified database. + /// + ITelemetryRepository CreateTelemetryRepository(DashboardSqliteDatabase database); + + /// + /// Creates a resource repository for the specified database. + /// + IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database); +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/RepositoryFactory.cs b/src/Aspire.Dashboard/ServiceClient/RepositoryFactory.cs new file mode 100644 index 00000000000..cbf96741f4d --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/RepositoryFactory.cs @@ -0,0 +1,80 @@ +// 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.Configuration; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Storage; +using Microsoft.Extensions.Options; + +namespace Aspire.Dashboard.ServiceClient; + +/// +/// Shares repositories for the current database and creates dedicated repositories for historical databases. +/// +internal sealed class RepositoryFactory( + DashboardSqliteDatabase currentDatabase, + ILoggerFactory loggerFactory, + IOptions dashboardOptions, + PauseManager pauseManager, + Func> outgoingPeerResolversAccessor, + IKnownPropertyLookup knownPropertyLookup) : IRepositoryFactory +{ + private readonly object _telemetryLock = new(); + private readonly object _resourceLock = new(); + private ITelemetryRepository? _currentTelemetryRepository; + private IResourceRepository? _currentResourceRepository; + private int _disposed; + + public ITelemetryRepository CreateTelemetryRepository(DashboardSqliteDatabase database) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + if (!ReferenceEquals(database, currentDatabase)) + { + return CreateTelemetryRepositoryCore(database); + } + + lock (_telemetryLock) + { + ObjectDisposedException.ThrowIf(_disposed != 0, this); + return _currentTelemetryRepository ??= CreateTelemetryRepositoryCore(database); + } + } + + public IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + if (!ReferenceEquals(database, currentDatabase)) + { + return CreateResourceRepositoryCore(database); + } + + lock (_resourceLock) + { + ObjectDisposedException.ThrowIf(_disposed != 0, this); + return _currentResourceRepository ??= CreateResourceRepositoryCore(database); + } + } + + private SqliteTelemetryRepository CreateTelemetryRepositoryCore(DashboardSqliteDatabase database) => + new(database, loggerFactory, dashboardOptions, pauseManager, outgoingPeerResolversAccessor()); + + private SqliteResourceRepository CreateResourceRepositoryCore(DashboardSqliteDatabase database) => + new(database, knownPropertyLookup, loggerFactory); + + public void Dispose() + { + lock (_telemetryLock) + { + lock (_resourceLock) + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _currentTelemetryRepository?.Dispose(); + (_currentResourceRepository as IDisposable)?.Dispose(); + } + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs index 254be6977cd..64c09f1a95d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs @@ -249,8 +249,8 @@ private void SetupManageDataDialogServices(TestDashboardClient dashboardClient) Services.AddSingleton(dashboardClient); Services.AddSingleton(); Services.AddSingleton(); - Services.AddSingleton(); - Services.AddSingleton(); + Services.AddScoped(); + Services.AddScoped(); Services.AddSingleton(); FluentUISetupHelpers.SetupFluentUIComponents(this); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs index 9f4b07e5fea..146735ce086 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs @@ -70,7 +70,7 @@ public void Render_ResourceInstanceHasDashes_AppKeyResolvedCorrectly() }); // Assert - var viewModel = Services.GetRequiredService(); + var viewModel = cut.Instance.ViewModel; Assert.NotNull(viewModel.ResourceKey); Assert.Equal("TestApp", viewModel.ResourceKey.Value.Name); @@ -99,7 +99,7 @@ public void Render_TraceIdAndSpanId_FilterAdded() }); // Assert - var viewModel = Services.GetRequiredService(); + var viewModel = cut.Instance.ViewModel; Assert.Collection(viewModel.Filters, f => @@ -139,7 +139,7 @@ public void Render_DuplicateFilters_SingleFilterAdded() }); // Assert - var viewModel = Services.GetRequiredService(); + var viewModel = cut.Instance.ViewModel; Assert.Collection(viewModel.Filters, f => @@ -177,7 +177,7 @@ public void Render_FiltersWithSpecialCharacters_SuccessfullyParsed() }); // Assert - var viewModel = Services.GetRequiredService(); + var viewModel = cut.Instance.ViewModel; Assert.Collection(viewModel.Filters, f => @@ -269,6 +269,6 @@ private void SetupStructureLogsServices() FluentUISetupHelpers.AddCommonDashboardServices(this); Services.AddSingleton>(NullLogger.Instance); - Services.AddSingleton(); + Services.AddTransient(); } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs index f924f0ace81..5104be14c24 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs @@ -67,6 +67,6 @@ private void SetupTracesServices() FluentUISetupHelpers.AddCommonDashboardServices(this); Services.AddSingleton>(NullLogger.Instance); - Services.AddSingleton(); + Services.AddTransient(); } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index 801e41bb938..36e5ad459de 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -158,6 +158,7 @@ public static void AddCommonDashboardServices( context.Services.AddSingleton(browserTimeProvider ?? new TestTimeProvider()); context.Services.AddSingleton(); context.Services.AddSingleton(services => services.GetRequiredService()); + context.Services.AddSingleton(services => services.GetRequiredService()); context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(localStorage ?? new TestLocalStorage()); @@ -165,6 +166,13 @@ public static void AddCommonDashboardServices( context.Services.AddSingleton(dashboardRunStore ?? new TestDashboardRunStore()); context.Services.AddSingleton(); context.Services.AddSingleton(); + context.Services.AddSingleton(services => services.GetRequiredService()); + context.Services.AddSingleton(); + context.Services.AddScoped(services => new DashboardDataSource( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService())); context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(); @@ -217,6 +225,17 @@ public void SelectRun(string? runId) } } + private sealed class TestRepositoryFactory( + ITelemetryRepository telemetryRepository, + IDashboardClient dashboardClient) : IRepositoryFactory + { + public ITelemetryRepository CreateTelemetryRepository(DashboardSqliteDatabase database) => telemetryRepository; + public IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database) => dashboardClient; + public void Dispose() + { + } + } + public static void SetupFluentUIComponents(TestContext context) { context.Services.AddFluentUIComponents(); diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs index acddb2860be..fd61716c0d2 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs @@ -59,7 +59,7 @@ public static void SetupResourcesPage(TestContext context, ViewportInformation v context.JSInterop.SetupVoid("focusElement", _ => true); context.Services.AddSingleton(); context.Services.AddSingleton>(NullLogger.Instance); - context.Services.AddSingleton(); + context.Services.AddTransient(); context.Services.AddScoped(); context.Services.AddSingleton(dashboardClient ?? new TestDashboardClient(isEnabled: true, initialResources: [], resourceChannelProvider: Channel.CreateUnbounded>)); diff --git a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs index 325df3ad705..028cd372336 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs @@ -161,6 +161,7 @@ public async Task Get_LoginPage_ValidToken_HttpEndpointWithHttpsEndpoint_UsesHtt Assert.DoesNotContain("; secure", httpCookie, StringComparison.OrdinalIgnoreCase); } +#if DEBUG [Fact] public async Task Get_Signout_HttpsEndpointWithHttpAndHttpsCookies_DeletesBothCookies() { @@ -210,6 +211,7 @@ public async Task Get_Signout_HttpsEndpointWithHttpAndHttpsCookies_DeletesBothCo Assert.Contains("expires=Thu, 01 Jan 1970", c, StringComparison.OrdinalIgnoreCase); }); } + #endif [Fact] public async Task Get_LoginPage_ValidToken_HttpEndpointWithHttpsEndpoint_RedirectToApp() diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index b8f792d34bc..bfe12a5b907 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -307,31 +307,28 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() } using var currentRunStore = CreateRunStore(options); - using var currentTelemetryRepository = CreateTelemetryRepository(currentRunStore.DatabasePath, options); - using var currentResourceRepository = CreateResourceRepository(currentRunStore.DatabasePath); - using var dataSource = CreateDataSource( - currentRunStore, - currentTelemetryRepository, - currentResourceRepository, - options); - using ITelemetryRepository selectedTelemetryRepository = new SelectedTelemetryRepository(dataSource); - Assert.Empty(selectedTelemetryRepository.GetResources()); + var currentDatabase = new DashboardSqliteDatabase(currentRunStore.DatabasePath); + using var repositoryFactory = CreateRepositoryFactory(currentDatabase, options); + var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); + var currentResourceRepository = repositoryFactory.CreateResourceRepository(currentDatabase); + using var dataSource = new DashboardDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); + Assert.Empty(dataSource.TelemetryRepository.GetResources()); dataSource.SelectRun(historicalRunId); Assert.True(dataSource.IsReadOnly); Assert.Equal(historicalRunId, dataSource.SelectedRun.RunId); Assert.Equal("api", Assert.Single(dataSource.ResourceRepository.GetResources()).Name); - Assert.Equal("TestService", Assert.Single(selectedTelemetryRepository.GetResources()).ResourceName); - Assert.Equal("Test Value!", Assert.Single(selectedTelemetryRepository.GetLogs(new GetLogsContext + Assert.Equal("TestService", Assert.Single(dataSource.TelemetryRepository.GetResources()).ResourceName); + Assert.Equal("Test Value!", Assert.Single(dataSource.TelemetryRepository.GetLogs(new GetLogsContext { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }).Items).Message); - Assert.Throws(() => dataSource.TelemetryRepository.ClearMetrics()); - Assert.Throws(() => dataSource.TelemetryRepository.ClearSelectedSignals([])); + Assert.Empty(currentTelemetryRepository.GetResources()); + Assert.Empty(currentResourceRepository.GetResources()); await using var currentClient = new DashboardClient( NullLoggerFactory.Instance, @@ -353,7 +350,7 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() dataSource.SelectRun(runId: null); - Assert.Empty(selectedTelemetryRepository.GetResources()); + Assert.Empty(dataSource.TelemetryRepository.GetResources()); Assert.False(dataSource.IsReadOnly); } @@ -362,13 +359,11 @@ public void UnknownRunId_SelectsCurrentRun() { var options = CreateOptions(); using var currentRunStore = CreateRunStore(options); - using var currentTelemetryRepository = CreateTelemetryRepository(currentRunStore.DatabasePath, options); - using var currentResourceRepository = CreateResourceRepository(currentRunStore.DatabasePath); - using var dataSource = CreateDataSource( - currentRunStore, - currentTelemetryRepository, - currentResourceRepository, - options); + var currentDatabase = new DashboardSqliteDatabase(currentRunStore.DatabasePath); + using var repositoryFactory = CreateRepositoryFactory(currentDatabase, options); + var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); + var currentResourceRepository = repositoryFactory.CreateResourceRepository(currentDatabase); + using var dataSource = new DashboardDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); dataSource.SelectRun("missing"); Assert.False(dataSource.IsReadOnly); @@ -413,20 +408,16 @@ private static DashboardRunStore CreateRunStore(IOptions optio return new DashboardRunStore(options, NullLogger.Instance); } - private static DashboardDataSource CreateDataSource( - DashboardRunStore runStore, - SqliteTelemetryRepository telemetryRepository, - SqliteResourceRepository resourceRepository, + private static RepositoryFactory CreateRepositoryFactory( + DashboardSqliteDatabase currentDatabase, IOptions options) { - return new DashboardDataSource( - runStore, - telemetryRepository, - resourceRepository, + return new RepositoryFactory( + currentDatabase, NullLoggerFactory.Instance, options, new PauseManager(), - [], + static () => [], new MockKnownPropertyLookup()); } diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs index 3be7be48df9..2345a4c485e 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs @@ -40,14 +40,15 @@ private ResourceMenuBuilder CreateResourceMenuBuilder( InMemoryTelemetryRepository repository, IDashboardClient? dashboardClient = null) { + dashboardClient ??= new TestDashboardClient(); return new ResourceMenuBuilder( new TestNavigationManager(), - repository, + TestDashboardDataSource.Create(repository, dashboardClient), new TestStringLocalizer(), new TestStringLocalizer(), _iconResolver, _dialogService, - dashboardClient ?? new TestDashboardClient()); + dashboardClient); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Model/SpanWaterfallViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/SpanWaterfallViewModelTests.cs index 172336ffb58..a233e354aa4 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SpanWaterfallViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SpanWaterfallViewModelTests.cs @@ -29,7 +29,7 @@ public void Create_HasChildren_ChildrenPopulated() trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(app2, trace, scope, spanId: "1-1", parentSpanId: "1", startDate: new DateTime(2001, 1, 1, 1, 1, 3, DateTimeKind.Utc))); // Act - var vm = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [], [])); + var vm = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [])); // Assert Assert.Collection(vm, @@ -60,7 +60,7 @@ public void Create_RootSpanZeroDuration_ZeroPercentage() var log = new OtlpLogEntry(TelemetryTestHelpers.CreateLogRecord(traceId: trace.TraceId, spanId: "1"), app1View, scope, context); // Act - var vm = SpanWaterfallViewModel.Create(trace, [log], new SpanWaterfallViewModel.TraceDetailState([], [], [])); + var vm = SpanWaterfallViewModel.Create(trace, [log], new SpanWaterfallViewModel.TraceDetailState([], [])); // Assert Assert.Collection(vm, @@ -76,7 +76,7 @@ public void Create_RootSpanZeroDuration_ZeroPercentage() } [Fact] - public void Create_OutgoingPeers_BrowserLink() + public void Create_OutgoingPeers_UsesPeerAddressWhenPeerIsNotPersisted() { // Arrange var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; @@ -89,14 +89,14 @@ public void Create_OutgoingPeers_BrowserLink() trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(app2, trace, scope, spanId: "2", parentSpanId: null, startDate: new DateTime(2001, 2, 1, 1, 1, 2, DateTimeKind.Utc), kind: OtlpSpanKind.Client)); // Act - var vm = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([new BrowserLinkOutgoingPeerResolver()], [], [])); + var vm = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [])); // Assert Assert.Collection(vm, e => { Assert.Equal("1", e.Span.SpanId); - Assert.Equal("Browser Link", e.UninstrumentedPeer); + Assert.Equal("localhost", e.UninstrumentedPeer); }, e => { @@ -105,6 +105,30 @@ public void Create_OutgoingPeers_BrowserLink() }); } + [Fact] + public void Create_OutgoingPeers_UsesPersistedNameOnlyPeer() + { + var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; + var app = new OtlpResource("app", "instance", uninstrumentedPeer: false, context); + var browserLink = new OtlpResource("Browser Link", instanceId: null, uninstrumentedPeer: true, context); + var trace = new OtlpTrace(new byte[] { 1, 2, 3 }, DateTime.MinValue); + var scope = TelemetryTestHelpers.CreateOtlpScope(context); + trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan( + app, + trace, + scope, + spanId: "1", + parentSpanId: null, + startDate: new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc), + kind: OtlpSpanKind.Client, + attributes: [KeyValuePair.Create(OtlpSpan.ServerAddressAttributeKey, "localhost")], + uninstrumentedPeer: browserLink)); + + var viewModel = Assert.Single(SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [app, browserLink]))); + + Assert.Equal("Browser Link", viewModel.UninstrumentedPeer); + } + [Theory] [InlineData("1234", true)] // Matches span ID [InlineData("app1", true)] // Matches resource name @@ -142,7 +166,7 @@ public void MatchesFilter_VariousCases_ReturnsExpected(string filter, bool expec var vm = SpanWaterfallViewModel.Create( trace, [], - new SpanWaterfallViewModel.TraceDetailState([], [], [])).First(); + new SpanWaterfallViewModel.TraceDetailState([], [])).First(); // Act var result = vm.MatchesFilter(filter, typeFilter: null, structuredFilters: null, a => a.Resource.ResourceName, out _); @@ -202,7 +226,7 @@ public void MatchesFilter_SpanType_ReturnsExpected(string spanTypeName, string? var vm = SpanWaterfallViewModel.Create( trace, [], - new SpanWaterfallViewModel.TraceDetailState([], [], [])).First(); + new SpanWaterfallViewModel.TraceDetailState([], [])).First(); // Act 1 var result1 = vm.MatchesFilter(string.Empty, typeFilter: spanType.Id?.Filter, structuredFilters: null, a => a.Resource.ResourceName, out _); @@ -234,7 +258,7 @@ public void MatchesFilter_ParentSpanIncludedWhenChildMatched() trace.AddSpan(parentSpan); trace.AddSpan(childSpan); - var vms = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [], [])); + var vms = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [])); var parent = vms[0]; var child = vms[1]; @@ -256,7 +280,7 @@ public void MatchesFilter_ChildSpanIncludedWhenParentMatched() trace.AddSpan(parentSpan); trace.AddSpan(childSpan); - var vms = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [], [])); + var vms = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [])); var parent = vms[0]; var child = vms[1]; @@ -297,7 +321,7 @@ public void MatchesFilter_StructuredFilter_SingleFilter_ReturnsExpected(string f statusCode: OtlpSpanStatusCode.Unset, statusMessage: null, kind: OtlpSpanKind.Client); trace.AddSpan(span); - var vm = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [], [])).First(); + var vm = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [])).First(); var filters = new List { @@ -333,7 +357,7 @@ public void MatchesFilter_StructuredFilter_MultipleFilters_AllMustMatch() statusCode: OtlpSpanStatusCode.Unset, statusMessage: null, kind: OtlpSpanKind.Client); trace.AddSpan(span); - var vm = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [], [])).First(); + var vm = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [])).First(); // One filter matches, one doesn't — AND logic means the span shouldn't match. var filters = new List @@ -370,7 +394,7 @@ public void MatchesFilter_StructuredFilter_DisabledFilterIsIgnored() statusCode: OtlpSpanStatusCode.Unset, statusMessage: null, kind: OtlpSpanKind.Client); trace.AddSpan(span); - var vm = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [], [])).First(); + var vm = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [])).First(); // Filter would exclude the span, but it's disabled. var filters = new List @@ -409,7 +433,7 @@ public void MatchesFilter_StructuredFilter_ParentMatchesWhenChildHasMatchingFilt trace.AddSpan(parentSpan); trace.AddSpan(childSpan); - var vms = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [], [])); + var vms = SpanWaterfallViewModel.Create(trace, [], new SpanWaterfallViewModel.TraceDetailState([], [])); var parent = vms[0]; var child = vms[1]; diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index 157b18d8dbc..fbcfa8ea1aa 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs @@ -11,11 +11,11 @@ using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Tests.Shared; using Aspire.Otlp.Serialization; -using Aspire.Dashboard.Tests.TelemetryRepositoryTests; using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf.Collections; using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; using OpenTelemetry.Proto.Logs.V1; using OpenTelemetry.Proto.Trace.V1; using Xunit; @@ -293,7 +293,7 @@ public void ConvertTracesToOtlpJson_SingleTrace_ReturnsCorrectStructure() }); // Act - var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, []); + var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items); // Assert Assert.NotNull(result.ResourceSpans); @@ -359,7 +359,7 @@ public void ConvertTracesToOtlpJson_SpanWithParent_IncludesParentSpanId() var traces = repository.GetTraces(GetTracesRequest.ForResourceKey(resource.ResourceKey)); // Act - var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, []); + var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items); // Assert var spans = result.ResourceSpans![0].ScopeSpans![0].Spans!; @@ -372,7 +372,7 @@ public void ConvertTracesToOtlpJson_SpanWithParent_IncludesParentSpanId() } [Fact] - public void ConvertTracesToOtlpJson_WithPeerResolvers_AddsDestinationNameAttribute() + public void ConvertTracesToOtlpJson_WithPersistedPeer_AddsDestinationNameAttribute() { // Arrange var repository = CreateRepository(); @@ -405,14 +405,14 @@ public void ConvertTracesToOtlpJson_WithPeerResolvers_AddsDestinationNameAttribu var resource = resources[0]; var traces = repository.GetTraces(GetTracesRequest.ForResourceKey(resource.ResourceKey)); - var outgoingPeerResolver = new TestOutgoingPeerResolver(onResolve: attributes => - { - var peerService = attributes.FirstOrDefault(a => a.Key == "peer.service"); - return (peerService.Value, null); - }); + traces.PagedResult.Items[0].Spans[0].SetUninstrumentedPeer(new OtlpResource( + "target-service", + instanceId: null, + uninstrumentedPeer: true, + new OtlpContext { Logger = NullLogger.Instance, Options = new() })); // Act - var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, [outgoingPeerResolver]); + var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items); // Assert var span = result.ResourceSpans![0].ScopeSpans![0].Spans![0]; @@ -421,7 +421,7 @@ public void ConvertTracesToOtlpJson_WithPeerResolvers_AddsDestinationNameAttribu } [Fact] - public void ConvertTracesToOtlpJson_WithoutPeerResolvers_DoesNotAddDestinationNameAttribute() + public void ConvertTracesToOtlpJson_WithoutPersistedPeer_AddsPeerAddressAttribute() { // Arrange var repository = CreateRepository(); @@ -455,12 +455,51 @@ public void ConvertTracesToOtlpJson_WithoutPeerResolvers_DoesNotAddDestinationNa var traces = repository.GetTraces(GetTracesRequest.ForResourceKey(resource.ResourceKey)); // Act - var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, []); + var result = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items); // Assert var span = result.ResourceSpans![0].ScopeSpans![0].Spans![0]; Assert.NotNull(span.Attributes); - Assert.DoesNotContain(span.Attributes, a => a.Key == OtlpHelpers.AspireDestinationNameAttribute); + Assert.Contains(span.Attributes, a => a.Key == OtlpHelpers.AspireDestinationNameAttribute && a.Value?.StringValue == "target-service"); + } + + [Fact] + public void ConvertTracesToOtlpJson_WithInstrumentedChild_AddsDestinationResourceAttribute() + { + var context = CreateContext(); + var source = new OtlpResource("source", instanceId: null, uninstrumentedPeer: false, context); + var destination = new OtlpResource("destination", instanceId: "replica-1", uninstrumentedPeer: false, context); + var trace = new OtlpTrace(new byte[] { 1, 2, 3 }, s_testTime); + var scope = CreateOtlpScope(context); + var parentSpan = CreateOtlpSpan( + source, + trace, + scope, + spanId: "parent", + parentSpanId: null, + startDate: s_testTime, + attributes: [KeyValuePair.Create(OtlpSpan.PeerServiceAttributeKey, "raw-address")], + kind: OtlpSpanKind.Client); + var childSpan = CreateOtlpSpan( + destination, + trace, + scope, + spanId: "child", + parentSpanId: "parent", + startDate: s_testTime.AddSeconds(1), + kind: OtlpSpanKind.Server); + trace.AddSpan(parentSpan); + trace.AddSpan(childSpan); + + var result = TelemetryExportService.ConvertTracesToOtlpJson([trace]); + + var exportedParent = result.ResourceSpans! + .SelectMany(resourceSpans => resourceSpans.ScopeSpans!) + .SelectMany(scopeSpans => scopeSpans.Spans!) + .Single(span => span.SpanId == "parent"); + Assert.Contains( + exportedParent.Attributes!, + attribute => attribute.Key == OtlpHelpers.AspireDestinationNameAttribute && attribute.Value?.StringValue == "destination-replica-1"); } [Fact] @@ -891,7 +930,7 @@ public void ConvertSpanToJson_ReturnsValidOtlpTelemetryDataJson() var span = repository.GetTraces(GetTracesRequest.ForResourceKey(repository.GetResources()[0].ResourceKey)).PagedResult.Items[0].Spans[0]; // Act - var json = TelemetryExportService.ConvertSpanToJson(span, []); + var json = TelemetryExportService.ConvertSpanToJson(span); // Assert - deserialize back to verify OtlpTelemetryDataJson structure var data = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); @@ -944,7 +983,7 @@ public void ConvertSpanToJson_WithLogs_IncludesLogsInOutput() var logs = repository.GetLogs(GetLogsContext.ForResourceKey(repository.GetResources()[0].ResourceKey)).Items; // Act - var json = TelemetryExportService.ConvertSpanToJson(span, [], logs); + var json = TelemetryExportService.ConvertSpanToJson(span, logs); // Assert - verify both spans and logs are in the output var data = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); @@ -1004,7 +1043,7 @@ public void ConvertTraceToJson_WithLogs_IncludesLogsInOutput() var logs = repository.GetLogs(GetLogsContext.ForResourceKey(repository.GetResources()[0].ResourceKey)).Items; // Act - var json = TelemetryExportService.ConvertTraceToJson(trace, [], logs); + var json = TelemetryExportService.ConvertTraceToJson(trace, logs); // Assert - verify both spans and logs are in the output var data = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); @@ -1044,7 +1083,7 @@ public void ConvertTraceToJson_ReturnsValidOtlpTelemetryDataJson() var trace = repository.GetTraces(GetTracesRequest.ForResourceKey(repository.GetResources()[0].ResourceKey)).PagedResult.Items[0]; // Act - var json = TelemetryExportService.ConvertTraceToJson(trace, []); + var json = TelemetryExportService.ConvertTraceToJson(trace); // Assert - deserialize back to verify OtlpTelemetryDataJson structure var data = JsonSerializer.Deserialize(json, OtlpJsonSerializerContext.Default.OtlpTelemetryDataJson); @@ -1097,8 +1136,9 @@ private static async Task CreateExportServiceAsync(InMem var sessionStorage = new TestSessionStorage(); var consoleLogsManager = new ConsoleLogsManager(sessionStorage); await consoleLogsManager.EnsureInitializedAsync(); - var consoleLogsFetcher = new ConsoleLogsFetcher(dashboardClient, consoleLogsManager); - return new TelemetryExportService(repository, consoleLogsFetcher, dashboardClient, Array.Empty()); + var dataSource = TestDashboardDataSource.Create(repository, dashboardClient); + var consoleLogsFetcher = new ConsoleLogsFetcher(dataSource, dashboardClient, consoleLogsManager); + return new TelemetryExportService(dataSource, consoleLogsFetcher, dashboardClient); } private static Dictionary> BuildAllResourcesSelection(InMemoryTelemetryRepository repository) diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs index 73864373f15..bd1d06e871c 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs @@ -401,7 +401,7 @@ public async Task ImportAsync_RoundTrip_TracesExportAndImport_PreservesData() var traces = sourceRepository.GetTraces(GetTracesRequest.ForResourceKey(resources[0].ResourceKey)); // Export - var exportedJson = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items, []); + var exportedJson = TelemetryExportService.ConvertTracesToOtlpJson(traces.PagedResult.Items); var jsonString = JsonSerializer.Serialize(exportedJson, OtlpJsonSerializerContext.DefaultOptions); // Import diff --git a/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs b/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs new file mode 100644 index 00000000000..1fee6fc5ce3 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs @@ -0,0 +1,40 @@ +// 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.Storage; +using Aspire.Dashboard.ServiceClient; + +namespace Aspire.Dashboard.Tests.Shared; + +internal static class TestDashboardDataSource +{ + public static DashboardDataSource Create( + ITelemetryRepository telemetryRepository, + IResourceRepository resourceRepository) + { + return new DashboardDataSource( + new TestRunStore(), + telemetryRepository, + resourceRepository, + new TestRepositoryFactory(telemetryRepository, resourceRepository)); + } + + private sealed class TestRunStore : IDashboardRunStore + { + public bool SupportsRunSelection => false; + + public IReadOnlyList GetRuns() => + [new("current", DateTimeOffset.UnixEpoch, null, false, "TestApp", string.Empty, IsCurrent: true)]; + } + + private sealed class TestRepositoryFactory( + ITelemetryRepository telemetryRepository, + IResourceRepository resourceRepository) : IRepositoryFactory + { + public ITelemetryRepository CreateTelemetryRepository(DashboardSqliteDatabase database) => telemetryRepository; + public IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database) => resourceRepository; + public void Dispose() + { + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs index c1efa5a3bc7..c9f74d9b67c 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs @@ -3,7 +3,6 @@ using System.Text; using Aspire.Dashboard.Api; -using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; using Aspire.Otlp.Serialization; @@ -781,13 +780,9 @@ private static void AddLogsToRepository(InMemoryTelemetryRepository repository, }); } - private static TelemetryApiService CreateService( - InMemoryTelemetryRepository? repository = null, - IOutgoingPeerResolver[]? peerResolvers = null) + private static TelemetryApiService CreateService(InMemoryTelemetryRepository? repository = null) { - return new TelemetryApiService( - repository ?? CreateRepository(), - peerResolvers ?? []); + return new TelemetryApiService(repository ?? CreateRepository()); } private static List GetAllSpans(TelemetryApiResponse result) diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs index 82182156331..c01ec361469 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs @@ -36,7 +36,7 @@ public void AddLogs() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -96,7 +96,7 @@ public void GetLogSummaries_ReturnsPageData() { var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField + repository.AsWriter().AddTraces(addContext, new RepeatedField { new ResourceSpans { @@ -119,7 +119,7 @@ public void GetLogSummaries_ReturnsPageData() } } }); - repository.AddLogs(addContext, new RepeatedField + repository.AsWriter().AddLogs(addContext, new RepeatedField { new ResourceLogs { @@ -247,7 +247,7 @@ public void GetLogsFieldValues_AllFieldsMatchMaterializedLogs() { var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField + repository.AsWriter().AddLogs(addContext, new RepeatedField { new ResourceLogs { @@ -306,7 +306,7 @@ public void AddLogs_NoBody_EmptyMessage() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -347,7 +347,7 @@ public void AddLogs_MultipleOutOfOrder() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -409,7 +409,7 @@ public void AddLogs_Error_UnviewedCount() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -485,7 +485,7 @@ public void AddLogs_Error_UnviewedCount_WithReadSubscriptionAll() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -537,7 +537,7 @@ public void AddLogs_Error_UnviewedCount_WithReadSubscriptionOneApp() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -590,7 +590,7 @@ public void AddLogs_Error_UnviewedCount_WithNonReadSubscription() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -665,7 +665,7 @@ public async Task Subscriptions_AddLog() // Act 1 var addContext1 = new AddContext(); - repository.AddLogs(addContext1, new RepeatedField() + repository.AsWriter().AddLogs(addContext1, new RepeatedField() { new ResourceLogs { @@ -702,7 +702,7 @@ public async Task Subscriptions_AddLog() }); var addContext2 = new AddContext(); - repository.AddLogs(addContext2, new RepeatedField() + repository.AsWriter().AddLogs(addContext2, new RepeatedField() { new ResourceLogs { @@ -750,7 +750,7 @@ public void Unsubscribe() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -794,7 +794,7 @@ public async Task Subscription_RaisedFromDifferentContext_InitialContextPreserve task = Task.Run(() => { var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -838,7 +838,7 @@ public void AddLogs_AttributeLimits_LimitsApplied() } var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -936,7 +936,7 @@ public async Task Subscription_MultipleUpdates_MinExecuteIntervalApplied() // Act var addContext = new AddContext(); logger.LogInformation("Writing log 1"); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -958,7 +958,7 @@ public async Task Subscription_MultipleUpdates_MinExecuteIntervalApplied() logger.LogInformation("Received log 1 callback"); logger.LogInformation("Writing log 2"); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -991,7 +991,7 @@ public void FilterLogs_With_Message_Returns_CorrectLog() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -1038,7 +1038,7 @@ public void FilterLogs_WithLikeMetacharacter_TreatsValueAsLiteral(string fragmen { var repository = CreateRepository(); var expectedMessage = $"matches-{fragment}-literal"; - repository.AddLogs(new AddContext(), new RepeatedField + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1078,7 +1078,7 @@ public void FilterLogs_With_EventName_Returns_CorrectLog() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -1125,7 +1125,7 @@ public void AddLogs_MultipleResources_SameInstanceId_CreateMultipleResources() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -1224,7 +1224,7 @@ public void GetLogs_MultipleInstances() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -1312,7 +1312,7 @@ public void RemoveLogs_All() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -1353,7 +1353,7 @@ public void RemoveLogs_All() }); // Act - repository.ClearStructuredLogs(); + repository.AsWriter().ClearStructuredLogs(); // Assert Assert.Equal(0, addContext.FailureCount); @@ -1377,7 +1377,7 @@ public void RemoveLogs_SelectedResource() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -1418,7 +1418,7 @@ public void RemoveLogs_SelectedResource() }); // Act - repository.ClearStructuredLogs(new ResourceKey("resource1", "123")); + repository.AsWriter().ClearStructuredLogs(new ResourceKey("resource1", "123")); // Assert Assert.Equal(0, addContext.FailureCount); @@ -1451,7 +1451,7 @@ public void RemoveLogs_MultipleSelectedResources() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -1492,7 +1492,7 @@ public void RemoveLogs_MultipleSelectedResources() }); // Act - repository.ClearStructuredLogs(new ResourceKey("resource1", null)); + repository.AsWriter().ClearStructuredLogs(new ResourceKey("resource1", null)); // Assert Assert.Equal(0, addContext.FailureCount); @@ -1518,7 +1518,7 @@ public void AddLogs_ObservedUnixTimeNanos() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -1559,7 +1559,7 @@ public void AddLogs_EventName_FromLogRecordField() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -1600,7 +1600,7 @@ public void AddLogs_EventName_FromLegacyAttribute() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -1643,7 +1643,7 @@ public void AddLogs_EventName_FieldTakesPrecedenceOverAttribute() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -1685,7 +1685,7 @@ public void AddLogs_EventName_NullWhenNotSet() // Act var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -1723,7 +1723,7 @@ public void GetLogs_DisabledFiltersAreIgnored() { var repository = CreateRepository(); - repository.AddLogs(new AddContext(), new RepeatedField + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField { new ResourceLogs { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index bf4cb96a66a..ccc512f83cf 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -27,7 +27,7 @@ public void AddMetrics() // Act var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { @@ -118,7 +118,7 @@ public void AddMetrics_MeterAttributeLimits_LimitsApplied() // Act var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { @@ -234,7 +234,7 @@ public void AddMetrics_MetricAttributeLimits_LimitsApplied() // Act var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { @@ -327,7 +327,7 @@ public void GetInstrument() // Act var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { @@ -410,7 +410,7 @@ public void GetInstrument() public void GetInstrumentLatestEndTime() { var repository = CreateRepository(); - repository.AddMetrics(new AddContext(), new RepeatedField + repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -464,7 +464,7 @@ public void AddMetrics_Capacity_ValuesRemoved() // Act var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { @@ -543,7 +543,7 @@ public void GetMetrics_MultipleInstances() // Act var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { @@ -661,7 +661,7 @@ public void RemoveMetrics_All() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { @@ -714,7 +714,7 @@ public void RemoveMetrics_All() }); // Act - repository.ClearMetrics(); + repository.AsWriter().ClearMetrics(); // Assert Assert.Equal(0, addContext.FailureCount); @@ -736,7 +736,7 @@ public void RemoveMetrics_SelectedResource() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { @@ -789,7 +789,7 @@ public void RemoveMetrics_SelectedResource() }); // Act - repository.ClearMetrics(new ResourceKey("resource1", "456")); + repository.AsWriter().ClearMetrics(new ResourceKey("resource1", "456")); // Assert Assert.Equal(0, addContext.FailureCount); @@ -894,7 +894,7 @@ public void RemoveMetrics_MultipleSelectedResources() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { @@ -947,7 +947,7 @@ public void RemoveMetrics_MultipleSelectedResources() }); // Act - repository.ClearMetrics(new ResourceKey("resource1", null)); + repository.AsWriter().ClearMetrics(new ResourceKey("resource1", null)); // Assert Assert.Equal(0, addContext.FailureCount); @@ -1036,7 +1036,7 @@ public void AddMetrics_InvalidInstrument() var addContext = new AddContext(); // Act - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { @@ -1118,7 +1118,7 @@ public void AddMetrics_InvalidHistogramDataPoints() } }; - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { @@ -1166,7 +1166,7 @@ public void AddMetrics_OverflowDimension() // Act var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { @@ -1229,7 +1229,7 @@ public void AddMetrics_NoScope() // Act var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs index cf69d81c9b8..31a6f0c9339 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs @@ -176,7 +176,7 @@ public void GetResourceName_Version7GuidInstanceId_ShortenedNamesDiffer() private static void AddResource(ITelemetryRepository repository, string name, string? instanceId = null) { var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs index 9da4c3cd3d6..f34e2173f26 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs @@ -24,7 +24,7 @@ public void AddTraces_ExceedsResourceLimit_ReportsFailure() for (var i = 0; i < 3; i++) { var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField + repository.AsWriter().AddTraces(addContext, new RepeatedField { new ResourceSpans { @@ -46,7 +46,7 @@ public void AddTraces_ExceedsResourceLimit_ReportsFailure() // Adding a 4th resource should fail. var failContext = new AddContext(); - repository.AddTraces(failContext, new RepeatedField + repository.AsWriter().AddTraces(failContext, new RepeatedField { new ResourceSpans { @@ -76,7 +76,7 @@ public void AddTraces_ExistingResourceAfterLimitReached_Succeeds() for (var i = 0; i < 2; i++) { var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField + repository.AsWriter().AddTraces(addContext, new RepeatedField { new ResourceSpans { @@ -96,7 +96,7 @@ public void AddTraces_ExistingResourceAfterLimitReached_Succeeds() // Adding data for an existing resource should still succeed. var successContext = new AddContext(); - repository.AddTraces(successContext, new RepeatedField + repository.AsWriter().AddTraces(successContext, new RepeatedField { new ResourceSpans { @@ -129,7 +129,7 @@ public void AddMetrics_ExceedsInstrumentLimit_ReportsFailure() } var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField + repository.AsWriter().AddMetrics(addContext, new RepeatedField { new ResourceMetrics { @@ -153,7 +153,7 @@ public void AddMetrics_ExceedsInstrumentLimit_ReportsFailure() // Adding one more instrument should fail. var failContext = new AddContext(); - repository.AddMetrics(failContext, new RepeatedField + repository.AsWriter().AddMetrics(failContext, new RepeatedField { new ResourceMetrics { @@ -183,7 +183,7 @@ public void AddLogs_ExceedsResourceLimit_FailureCountIsLogRecordCount() // Fill the single resource slot. var setupContext = new AddContext(); - repository.AddLogs(setupContext, new RepeatedField + repository.AsWriter().AddLogs(setupContext, new RepeatedField { new ResourceLogs { @@ -203,7 +203,7 @@ public void AddLogs_ExceedsResourceLimit_FailureCountIsLogRecordCount() // Attempt to add logs for a new resource with multiple scopes and records. // FailureCount must equal total log records, not number of scopes. var failContext = new AddContext(); - repository.AddLogs(failContext, new RepeatedField + repository.AsWriter().AddLogs(failContext, new RepeatedField { new ResourceLogs { @@ -244,7 +244,7 @@ public void AddMetrics_ExceedsResourceLimit_FailureCountIsDataPointCount() // Fill the single resource slot. var setupContext = new AddContext(); - repository.AddMetrics(setupContext, new RepeatedField + repository.AsWriter().AddMetrics(setupContext, new RepeatedField { new ResourceMetrics { @@ -264,7 +264,7 @@ public void AddMetrics_ExceedsResourceLimit_FailureCountIsDataPointCount() // Attempt to add metrics for a new resource with multiple scopes and metrics. // FailureCount must equal total data points, not number of metrics. var failContext = new AddContext(); - repository.AddMetrics(failContext, new RepeatedField + repository.AsWriter().AddMetrics(failContext, new RepeatedField { new ResourceMetrics { @@ -306,7 +306,7 @@ public void AddTraces_ExceedsResourceLimit_FailureCountIsSpanCount() // Fill the single resource slot. var setupContext = new AddContext(); - repository.AddTraces(setupContext, new RepeatedField + repository.AsWriter().AddTraces(setupContext, new RepeatedField { new ResourceSpans { @@ -326,7 +326,7 @@ public void AddTraces_ExceedsResourceLimit_FailureCountIsSpanCount() // Attempt to add traces for a new resource with multiple scopes and spans. // FailureCount must equal total spans, not number of scopes. var failContext = new AddContext(); - repository.AddTraces(failContext, new RepeatedField + repository.AsWriter().AddTraces(failContext, new RepeatedField { new ResourceSpans { @@ -377,12 +377,12 @@ public void AddLogs_ExceedsScopeLimit_ReportsFailure() scopeLogs.Add(rl); var addContext = new AddContext(); - repository.AddLogs(addContext, scopeLogs); + repository.AsWriter().AddLogs(addContext, scopeLogs); Assert.Equal(0, addContext.FailureCount); // Adding one more scope should fail. var failContext = new AddContext(); - repository.AddLogs(failContext, new RepeatedField + repository.AsWriter().AddLogs(failContext, new RepeatedField { new ResourceLogs { @@ -424,12 +424,12 @@ public void AddTraces_ExceedsScopeLimit_ReportsFailure() } var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField { rs }); + repository.AsWriter().AddTraces(addContext, new RepeatedField { rs }); Assert.Equal(0, addContext.FailureCount); // Adding one more scope should fail. var failContext = new AddContext(); - repository.AddTraces(failContext, new RepeatedField + repository.AsWriter().AddTraces(failContext, new RepeatedField { new ResourceSpans { @@ -470,12 +470,12 @@ public void AddMetrics_ExceedsScopeLimit_ReportsFailure() } var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField { rm }); + repository.AsWriter().AddMetrics(addContext, new RepeatedField { rm }); Assert.Equal(0, addContext.FailureCount); // Adding one more scope should fail. Each metric has 1 data point. var failContext = new AddContext(); - repository.AddMetrics(failContext, new RepeatedField + repository.AsWriter().AddMetrics(failContext, new RepeatedField { new ResourceMetrics { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTestBase.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTestBase.cs index 312b2a6f484..91b9f6f528d 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTestBase.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTestBase.cs @@ -85,4 +85,9 @@ public void Dispose() Directory.Delete(temporaryDirectory, recursive: true); } } +} + +internal static class TelemetryRepositoryTestExtensions +{ + public static ITelemetryRepositoryWriter AsWriter(this ITelemetryRepository repository) => (ITelemetryRepositoryWriter)repository; } \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs index dc4e8e66a23..4bc216ddc34 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs @@ -59,7 +59,7 @@ public void AddData_WhilePaused_IsDiscarded() void AddLog() { var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + repository.AsWriter().AddLogs(addContext, new RepeatedField() { new ResourceLogs { @@ -82,7 +82,7 @@ void AddLog() void AddMetric() { var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + repository.AsWriter().AddMetrics(addContext, new RepeatedField() { new ResourceMetrics { @@ -116,7 +116,7 @@ void AddMetric() void AddTrace() { var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -221,7 +221,7 @@ public void ClearSelectedSignals_ClearsSelectedDataTypes_ForSpecificResources() { ["resource1-123"] = [AspireDataType.StructuredLogs] }; - repository.ClearSelectedSignals(selectedResources); + repository.AsWriter().ClearSelectedSignals(selectedResources); // Assert - resource1 unviewed error logs cleared var unviewedAfter = repository.GetResourceUnviewedErrorLogsCount(); @@ -268,7 +268,7 @@ public void ClearSelectedSignals_OtherResourcesRemainUnaffected() { ["resource2-222"] = [AspireDataType.StructuredLogs, AspireDataType.Traces, AspireDataType.Metrics, AspireDataType.Resource] }; - repository.ClearSelectedSignals(selectedResources); + repository.AsWriter().ClearSelectedSignals(selectedResources); // Assert - resource1 and resource3 data is unaffected var logs = repository.GetLogs(new GetLogsContext { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); @@ -308,7 +308,7 @@ public void ClearSelectedSignals_ResourceRemovedWhenAllDataTypesCleared() { ["resource1-123"] = [AspireDataType.StructuredLogs, AspireDataType.Traces, AspireDataType.Metrics, AspireDataType.Resource] }; - repository.ClearSelectedSignals(selectedResources); + repository.AsWriter().ClearSelectedSignals(selectedResources); // Assert - Resource is removed from the repository var resourceAfter = repository.GetResource(new ResourceKey("resource1", "123")); @@ -339,7 +339,7 @@ public void ClearSelectedSignals_PartialClear_ResourceNotRemoved() { ["resource1-123"] = [AspireDataType.StructuredLogs, AspireDataType.Traces] }; - repository.ClearSelectedSignals(selectedResources); + repository.AsWriter().ClearSelectedSignals(selectedResources); // Assert - Resource still exists because not all data types were cleared var resourceAfter = repository.GetResource(new ResourceKey("resource1", "123")); @@ -365,7 +365,7 @@ public async Task WatchSpansAsync_ReturnsExistingSpans_ThenNewSpans() var repository = CreateRepository(); // Add initial span - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -409,7 +409,7 @@ public async Task WatchSpansAsync_ReturnsExistingSpans_ThenNewSpans() await firstSpanReceived.Task; // Add another span while watching - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -483,7 +483,7 @@ public async Task WatchLogsAsync_ReturnsExistingLogs_ThenNewLogs() var repository = CreateRepository(); // Add initial log - repository.AddLogs(new AddContext(), new RepeatedField + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField { new ResourceLogs { @@ -527,7 +527,7 @@ public async Task WatchLogsAsync_ReturnsExistingLogs_ThenNewLogs() await firstLogReceived.Task; // Add another log while watching - repository.AddLogs(new AddContext(), new RepeatedField + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField { new ResourceLogs { @@ -600,7 +600,7 @@ public async Task WatchSpansAsync_ReturnsExistingSpans_OrderedByStartTime() var repository = CreateRepository(); // Add spans with non-chronological start times across different traces - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -694,7 +694,7 @@ public async Task WatchSpansAsync_ReturnsExistingSpans_OrderedByStartTime_Across // Without explicit sorting, iterating trace-by-trace would yield: // T=1 (trace1), T=8 (trace1), then T=3 (trace2), T=5 (trace2) // Correct chronological order is: T=1, T=3, T=5, T=8 - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -769,7 +769,7 @@ public async Task WatchSpansAsync_FiltersById_WhenResourceKeyProvided() var repository = CreateRepository(); // Add spans for two different resources - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -949,7 +949,7 @@ public async Task WatchLogsAsync_FiltersAppliedWhenPushing() }; // Add an initial matching log so we know when watcher is ready - repository.AddLogs(new AddContext(), new RepeatedField + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField { new ResourceLogs { @@ -994,7 +994,7 @@ public async Task WatchLogsAsync_FiltersAppliedWhenPushing() await firstLogReceived.Task; // Add more logs - one matches filter, one doesn't - repository.AddLogs(new AddContext(), new RepeatedField + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1040,7 +1040,7 @@ public async Task WatchLogsAsync_SeverityFilterApplied() }; // Add an initial error log so we know when watcher is ready - repository.AddLogs(new AddContext(), new RepeatedField + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1085,7 +1085,7 @@ public async Task WatchLogsAsync_SeverityFilterApplied() await firstLogReceived.Task; // Add logs with different severity levels - repository.AddLogs(new AddContext(), new RepeatedField + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1122,7 +1122,7 @@ public async Task WatchLogsAsync_TextFragmentsFilterApplied() var repository = CreateRepository(); // Add initial logs — one matches text fragments, one doesn't - repository.AddLogs(new AddContext(), new RepeatedField + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1172,7 +1172,7 @@ public async Task WatchLogsAsync_TextFragmentsFilterApplied() await firstLogReceived.Task; // Add more logs — one matches both fragments, one matches only one - repository.AddLogs(new AddContext(), new RepeatedField + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1225,7 +1225,7 @@ public async Task WatchLogsAsync_DisabledFiltersAreIgnored() }; // Add a matching log - repository.AddLogs(new AddContext(), new RepeatedField + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1268,7 +1268,7 @@ public async Task WatchLogsAsync_DisabledFiltersAreIgnored() await firstLogReceived.Task; // Push a new matching log - repository.AddLogs(new AddContext(), new RepeatedField + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1322,7 +1322,7 @@ public async Task WatchSpansAsync_DisabledFiltersAreIgnored() }; // Add spans — one whose name contains "span1", one that doesn't - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -1365,7 +1365,7 @@ public async Task WatchSpansAsync_DisabledFiltersAreIgnored() await firstSpanReceived.Task; // Push a new span that matches the enabled filter - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -1399,7 +1399,7 @@ private static void AddTestData(ITelemetryRepository repository, string resource { var compositeName = $"{resourceName}-{instanceId}"; - repository.AddLogs(new AddContext(), new RepeatedField() + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField() { new ResourceLogs { @@ -1415,7 +1415,7 @@ private static void AddTestData(ITelemetryRepository repository, string resource } }); - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -1434,7 +1434,7 @@ private static void AddTestData(ITelemetryRepository repository, string resource } }); - repository.AddMetrics(new AddContext(), new RepeatedField() + repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField() { new ResourceMetrics { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index db5cdb0a366..3d1881fcdee 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -44,7 +44,7 @@ public void AddTraces() // Act var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -98,7 +98,7 @@ public void GetTraceSummaries_ReturnsPageData() { var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField + repository.AsWriter().AddTraces(addContext, new RepeatedField { new ResourceSpans { @@ -223,7 +223,7 @@ public void AddTraces_SelfParent_Reject() // Act var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -275,7 +275,7 @@ public void AddTraces_MultipleSpansLoop_Reject() // Act var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -329,7 +329,7 @@ public void AddTraces_DuplicateTraceIds_Reject() // Act var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -383,7 +383,7 @@ public void AddTraces_Scope_Multiple() // Act var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -401,7 +401,7 @@ public void AddTraces_Scope_Multiple() } } }); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -460,7 +460,7 @@ public void AddTraces_Traces_MultipleOutOrOrder() // Act var addContext1 = new AddContext(); - repository.AddTraces(addContext1, new RepeatedField() + repository.AsWriter().AddTraces(addContext1, new RepeatedField() { new ResourceSpans { @@ -480,7 +480,7 @@ public void AddTraces_Traces_MultipleOutOrOrder() Assert.Equal(0, addContext1.FailureCount); var addContext2 = new AddContext(); - repository.AddTraces(addContext2, new RepeatedField() + repository.AsWriter().AddTraces(addContext2, new RepeatedField() { new ResourceSpans { @@ -529,7 +529,7 @@ public void AddTraces_Traces_MultipleOutOrOrder() }); var addContext3 = new AddContext(); - repository.AddTraces(addContext3, new RepeatedField() + repository.AsWriter().AddTraces(addContext3, new RepeatedField() { new ResourceSpans { @@ -579,7 +579,7 @@ public void AddTraces_Spans_MultipleOutOrOrder() var repository = CreateRepository(); // Act - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -631,7 +631,7 @@ public void AddTraces_SpanEvents_ReturnData() var repository = CreateRepository(); // Act - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -707,7 +707,7 @@ public void AddTraces_SpanLinks_ReturnData() var repository = CreateRepository(); // Act - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -793,7 +793,7 @@ public void GetTraces_ReturnCopies() // Act var addContext1 = new AddContext(); - repository.AddTraces(addContext1, new RepeatedField() + repository.AsWriter().AddTraces(addContext1, new RepeatedField() { new ResourceSpans { @@ -864,7 +864,7 @@ public void AddTraces_AttributeAndEventLimits_LimitsApplied() // Act var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1061,7 +1061,7 @@ private static void AddTrace(ITelemetryRepository repository, string traceId, Da } }; - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1098,7 +1098,7 @@ public void AddTraces_MultipleRootSpans_RootSpanIsEarliestWithoutParent() // Act var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1147,7 +1147,7 @@ public void GetTraces_MultipleInstances() // Act var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1222,7 +1222,7 @@ public void AddTraces_MissingAndEmptyInstanceIdsAreDistinct() missingInstanceIdResource.Attributes.Remove(missingInstanceIdResource.Attributes.Single(attribute => attribute.Key == OtlpResource.SERVICE_INSTANCE_ID)); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField + repository.AsWriter().AddTraces(addContext, new RepeatedField { new ResourceSpans { @@ -1262,7 +1262,7 @@ public void GetTraceFieldValues_AllFieldsMatchMaterializedTraces() { var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField + repository.AsWriter().AddTraces(addContext, new RepeatedField { new ResourceSpans { @@ -1314,7 +1314,7 @@ public void GetTraces_AttributeFilters() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1414,7 +1414,7 @@ public void GetTraces_KnownFilters(string name, string value) var repository = CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1475,7 +1475,7 @@ public void GetTraces_FiltersPagingAndMaxDuration_ComputedFromAllMatchingTraces( var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1536,7 +1536,7 @@ public void GetTraces_DurationFilter_AppliesTraceLevelDuration() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1593,7 +1593,7 @@ public void GetTraces_NotEqualFilter_NonMatchingValue_ReturnsTrace() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1645,7 +1645,7 @@ public void AddTraces_OutOfOrder_FullName() // Act 1 var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1670,7 +1670,7 @@ public void AddTraces_OutOfOrder_FullName() Assert.Equal("TestService: Test span. Id: 1-3", trace.FullName); // Act 2 - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1695,7 +1695,7 @@ public void AddTraces_OutOfOrder_FullName() Assert.Equal("TestService: Test span. Id: 1-2", trace.FullName); // Act 3 - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1720,7 +1720,7 @@ public void AddTraces_OutOfOrder_FullName() Assert.Equal("TestService: Test span. Id: 1-1", trace.FullName); // Act 4 - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1753,7 +1753,7 @@ public void AddTraces_SameResourceDifferentProperties_MultipleResourceViews() // Act var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1896,7 +1896,7 @@ public void RemoveTraces_All() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -1949,7 +1949,7 @@ public void RemoveTraces_All() }); // Act - repository.ClearTraces(); + repository.AsWriter().ClearTraces(); // Assert Assert.Equal(0, addContext.FailureCount); @@ -1974,7 +1974,7 @@ public void RemoveTraces_SelectedResource() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -2027,7 +2027,7 @@ public void RemoveTraces_SelectedResource() }); // Act - repository.ClearTraces(new ResourceKey("resource1", "123")); + repository.AsWriter().ClearTraces(new ResourceKey("resource1", "123")); // Assert Assert.Equal(0, addContext.FailureCount); @@ -2079,7 +2079,7 @@ public void RemoveTraces_MultipleSelectedResources() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -2132,7 +2132,7 @@ public void RemoveTraces_MultipleSelectedResources() }); // Act - repository.ClearTraces(new ResourceKey("resource1", null)); + repository.AsWriter().ClearTraces(new ResourceKey("resource1", null)); // Assert Assert.Equal(0, addContext.FailureCount); @@ -2167,7 +2167,7 @@ public void RemoveTraces_SelectedResource_SpansFromDifferentTrace() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -2223,7 +2223,7 @@ public void RemoveTraces_SelectedResource_SpansFromDifferentTrace() }); // Act - repository.ClearTraces(new ResourceKey("resource1", null)); + repository.AsWriter().ClearTraces(new ResourceKey("resource1", null)); // Assert Assert.Equal(0, addContext.FailureCount); @@ -2260,7 +2260,7 @@ public void AddTraces_HaveUninstrumentedPeers() // Act var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -2350,7 +2350,7 @@ public async Task AddTraces_OnPeerUpdated_HaveUninstrumentedPeers() // Act var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -2446,6 +2446,93 @@ public async Task AddTraces_OnPeerUpdated_HaveUninstrumentedPeers() }); } + [Fact] + public void AddTraces_NameOnlyPeerResolver_PersistsUninstrumentedPeer() + { + var outgoingPeerResolver = new TestOutgoingPeerResolver(onResolve: _ => ("Browser Link", null)); + var repository = CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); + + var addContext = new AddContext(); + repository.AsWriter().AddTraces(addContext, new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-1", + startTime: s_testTime, + endTime: s_testTime.AddMinutes(1), + attributes: [KeyValuePair.Create(OtlpSpan.PeerServiceAttributeKey, "localhost")], + kind: Span.Types.SpanKind.Client) + } + } + } + } + }); + + Assert.Equal(0, addContext.FailureCount); + var peerResource = Assert.Single(repository.GetResources(includeUninstrumentedPeers: true), resource => resource.UninstrumentedPeer); + Assert.Equal("Browser Link", peerResource.ResourceName); + Assert.Null(peerResource.InstanceId); + + var trace = Assert.IsType(repository.GetTrace(GetHexId("1"))); + var span = Assert.Single(trace.Spans); + Assert.Equal(peerResource.ResourceKey, span.UninstrumentedPeer?.ResourceKey); + } + + [Fact] + public async Task AddTraces_NameOnlyPeerResolverChange_PersistsUninstrumentedPeer() + { + var matchPeer = false; + var outgoingPeerResolver = new TestOutgoingPeerResolver(onResolve: _ => matchPeer ? ("Browser Link", null) : (null, null)); + var repository = CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); + + var addContext = new AddContext(); + repository.AsWriter().AddTraces(addContext, new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-1", + startTime: s_testTime, + endTime: s_testTime.AddMinutes(1), + attributes: [KeyValuePair.Create(OtlpSpan.PeerServiceAttributeKey, "localhost")], + kind: Span.Types.SpanKind.Client) + } + } + } + } + }); + + Assert.Equal(0, addContext.FailureCount); + Assert.Null(Assert.Single(repository.GetTrace(GetHexId("1"))!.Spans).UninstrumentedPeer); + + matchPeer = true; + await outgoingPeerResolver.InvokePeerChanges(); + + var peerResource = Assert.Single(repository.GetResources(includeUninstrumentedPeers: true), resource => resource.UninstrumentedPeer); + Assert.Equal("Browser Link", peerResource.ResourceName); + var span = Assert.Single(repository.GetTrace(GetHexId("1"))!.Spans); + Assert.Equal(peerResource.ResourceKey, span.UninstrumentedPeer?.ResourceKey); + } + [Fact] public void AddTraces_UninstrumentedPeer_InstanceIdDashes_AppKeyResolvedCorrectly() { @@ -2454,7 +2541,7 @@ public void AddTraces_UninstrumentedPeer_InstanceIdDashes_AppKeyResolvedCorrectl var outgoingPeerResolver = new TestOutgoingPeerResolver(onResolve: attributes => (resource.Name, resource)); var repository = CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + repository.AsWriter().AddTraces(addContext, new RepeatedField() { new ResourceSpans { @@ -2496,7 +2583,7 @@ public void GetSpans_ReturnsAllSpans() // Arrange var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -2537,7 +2624,7 @@ public void GetSpans_FilterByTraceId_ReturnsMatchingSpans() // Arrange var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -2579,7 +2666,7 @@ public void GetSpans_FilterByHasError_ReturnsErrorSpansOnly() // Arrange var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -2620,7 +2707,7 @@ public void GetSpans_FilterByHasErrorFalse_ReturnsNonErrorSpansOnly() // Arrange var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -2662,7 +2749,7 @@ public void GetTraces_StatusFilter_ReturnsMatchingTraces(FilterCondition conditi { var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -2714,7 +2801,7 @@ public void GetSpans_FilterByResource_ReturnsMatchingSpans() // Arrange var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -2771,7 +2858,7 @@ public void GetSpans_FilterByDuration_ReturnsMatchingSpans() // Arrange var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -2821,7 +2908,7 @@ public void GetSpans_FilterByTextFragments_ReturnsMatchingSpans() // Arrange var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -2862,7 +2949,7 @@ public void GetSpans_Pagination_ReturnsCorrectPage() // Arrange var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -2904,7 +2991,7 @@ public void GetSpans_CombinedFilters_ReturnsMatchingSpans() // Arrange var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -2967,7 +3054,7 @@ public void GetSpans_UnknownResource_ReturnsEmpty() // Arrange var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -3012,7 +3099,7 @@ public void GetSpans_TraceIdPrefixLength_MatchesShortenedIds(string traceIdFilte // Use a trace ID whose hex representation is "747261636531" (UTF-8 bytes of "trace1") var traceId = Encoding.UTF8.GetString(Convert.FromHexString("747261636531")); - repository.AddTraces(new AddContext(), new RepeatedField() + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -3051,7 +3138,7 @@ public void GetSpans_DisabledFiltersAreIgnored() { var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3109,7 +3196,7 @@ public void GetTraces_NotContainsFilter_ExcludesTraceWhenAnySpanMatches() // trace do not contain it. This is the fix for https://github.com/microsoft/aspire/issues/18684. var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3161,7 +3248,7 @@ public void GetTraces_NotContainsFilter_IncludesTraceWhenNoSpanMatches() // names contain the filtered text. var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3212,7 +3299,7 @@ public void GetTraces_NotEqualFilter_ExcludesTraceWhenAnySpanMatches() // equals the filtered text. var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3263,7 +3350,7 @@ public void GetTraces_NotContainsWithPositiveFilter_CombinesCorrectly() // must satisfy the negative filter. var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3349,7 +3436,7 @@ public void GetTraces_NotContainsFilter_AbsentAttributeDoesNotExcludeTrace() // satisfies "not contains X" — it cannot contain the value. var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3404,7 +3491,7 @@ public void GetTraces_NotContainsFilter_AbsentAttributeWithViolatingSpanExcludes // the negative condition, even though another span lacks the attribute entirely. var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3456,7 +3543,7 @@ public void GetSpans_NotContainsFilter_AbsentAttributeIncludesSpan() // filtered attribute. A span without the field trivially satisfies "not contains X". var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3514,7 +3601,7 @@ public void GetTraces_NotEqualTimestampFilter_ExcludesTraceViaUnoptimizedPath() // filter excludes a trace when any span's timestamp matches the filter value. var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index 890e93c9e6a..0b60cbaab1f 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -25,7 +25,7 @@ namespace Aspire.Dashboard.Otlp.Storage; -public sealed partial class InMemoryTelemetryRepository : ITelemetryRepository +public sealed partial class InMemoryTelemetryRepository : ITelemetryRepository, ITelemetryRepositoryWriter { internal const int MaxResourceViewCount = TelemetryRepositoryLimits.MaxResourceViewCount; internal const int MaxInstrumentCount = TelemetryRepositoryLimits.MaxInstrumentCount; @@ -1973,23 +1973,7 @@ private static int CompareTraceOrder(OtlpTrace left, OtlpTrace right) public OtlpResource? GetPeerResource(OtlpSpan span) { - var peer = ResolveUninstrumentedPeerResource(span, _outgoingPeerResolvers); - if (peer == null) - { - return null; - } - - try - { - var resourceKey = ResourceKey.Create(name: peer.DisplayName, instanceId: peer.Name); - var (resource, _) = GetOrAddResource(resourceKey, uninstrumentedPeer: true); - return resource; - } - catch (Exception ex) - { - _logger.LogInformation(ex, "Error adding peer resource."); - return null; - } + return span.UninstrumentedPeer; } private void CalculateTraceUninstrumentedPeers(OtlpTrace trace) @@ -1999,11 +1983,11 @@ private void CalculateTraceUninstrumentedPeers(OtlpTrace trace) // A span may indicate a call to another service but the service isn't instrumented. var hasPeerService = OtlpHelpers.GetPeerAddress(span.Attributes) != null; var hasUninstrumentedPeer = hasPeerService && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !span.GetChildSpans().Any(); - var uninstrumentedPeer = hasUninstrumentedPeer ? ResolveUninstrumentedPeerResource(span, _outgoingPeerResolvers) : null; + var uninstrumentedPeerKey = hasUninstrumentedPeer ? ResolveUninstrumentedPeerResourceKey(span, _outgoingPeerResolvers) : null; - if (uninstrumentedPeer != null) + if (uninstrumentedPeerKey is { } peerKey) { - if (span.UninstrumentedPeer?.ResourceKey.EqualsCompositeName(uninstrumentedPeer.Name) ?? false) + if (span.UninstrumentedPeer?.ResourceKey == peerKey) { // Already the correct value. No changes needed. continue; @@ -2011,8 +1995,7 @@ private void CalculateTraceUninstrumentedPeers(OtlpTrace trace) try { - var resourceKey = ResourceKey.Create(name: uninstrumentedPeer.DisplayName, instanceId: uninstrumentedPeer.Name); - var (resource, _) = GetOrAddResource(resourceKey, uninstrumentedPeer: true); + var (resource, _) = GetOrAddResource(peerKey, uninstrumentedPeer: true); trace.SetSpanUninstrumentedPeer(span, resource); } catch (Exception ex) @@ -2027,14 +2010,23 @@ private void CalculateTraceUninstrumentedPeers(OtlpTrace trace) } } - private static ResourceViewModel? ResolveUninstrumentedPeerResource(OtlpSpan span, IEnumerable outgoingPeerResolvers) + private static ResourceKey? ResolveUninstrumentedPeerResourceKey(OtlpSpan span, IEnumerable outgoingPeerResolvers) { - // Attempt to resolve uninstrumented peer to a friendly name from the span. foreach (var resolver in outgoingPeerResolvers) { - if (resolver.TryResolvePeer(span.Attributes, out _, out var matchedResourced)) + if (!resolver.TryResolvePeer(span.Attributes, out var name, out var matchedResource)) + { + continue; + } + + if (matchedResource is not null) + { + return ResourceKey.Create(matchedResource.DisplayName, matchedResource.Name); + } + + if (!string.IsNullOrEmpty(name)) { - return matchedResourced; + return new ResourceKey(name, InstanceId: null); } } From 1ca6b9c826312cb18c3998191343688bd5505445 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 17 Jul 2026 11:48:53 +0800 Subject: [PATCH 25/87] Fix current-run console log subscription --- .../Components/Pages/ConsoleLogs.razor.cs | 2 +- .../Pages/ConsoleLogsTests.cs | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index f8073242a7d..9e0976dfda0 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -1005,7 +1005,7 @@ private void LoadLogsForResource(ConsoleLogsSubscription subscription) Logger.LogDebug("Subscribing to console logs with subscription {SubscriptionId} to resource {ResourceName}.", subscription.SubscriptionId, subscription.Resource.Name); - var logSubscription = DataSource.ResourceRepository.SubscribeConsoleLogs(subscription.Resource.Name, subscription.CancellationToken); + var logSubscription = DashboardClient.SubscribeConsoleLogs(subscription.Resource.Name, subscription.CancellationToken); // For "All" subscriptions, only update status once when starting if (_isSubscribedToAll && _consoleLogsSubscriptions.Count == 1) diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs index 358612982eb..d0e750863d6 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs @@ -368,6 +368,46 @@ public async Task ResourceName_ViaUrlAndResourceLoaded_LogViewerUpdated() cut.WaitForState(() => instance._logEntries.EntriesCount > 0); } + [Fact] + public async Task CurrentRun_SubscribesToLiveConsoleLogs() + { + var testResource = ModelTestHelpers.CreateResource(resourceName: "test-resource", state: KnownResourceState.Running); + var liveSubscriptionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var liveConsoleLogsChannel = Channel.CreateUnbounded>(); + var repositoryConsoleLogsChannel = Channel.CreateUnbounded>(); + var resourceChannel = Channel.CreateUnbounded>(); + var repositorySubscriptionCount = 0; + var liveClient = new TestDashboardClient( + isEnabled: true, + consoleLogsChannelProvider: resourceName => + { + liveSubscriptionTcs.TrySetResult(resourceName); + return liveConsoleLogsChannel; + }); + var currentResourceRepository = new TestDashboardClient( + isEnabled: true, + consoleLogsChannelProvider: _ => + { + Interlocked.Increment(ref repositorySubscriptionCount); + return repositoryConsoleLogsChannel; + }, + resourceChannelProvider: () => resourceChannel, + initialResources: [testResource]); + + SetupConsoleLogsServices(liveClient); + Services.AddSingleton(currentResourceRepository); + + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + var cut = RenderConsoleLogsPage(viewport, testResource.Name); + + Assert.Equal(testResource.Name, await liveSubscriptionTcs.Task.DefaultTimeout()); + Assert.Equal(0, Volatile.Read(ref repositorySubscriptionCount)); + + liveConsoleLogsChannel.Writer.TryWrite([new ResourceLogLine(1, "Live log", IsErrorMessage: false)]); + cut.WaitForState(() => cut.Instance._logEntries.EntriesCount == 1); + Assert.Equal("Live log", Assert.Single(cut.Instance._logEntries.GetEntries()).RawContent); + } + [Theory] [InlineData(true)] [InlineData(false)] From dc34175929aaf7e7069a9ee69f0110632b350133 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 17 Jul 2026 12:50:44 +0800 Subject: [PATCH 26/87] Track console log capture by run --- .../Components/Controls/LogViewer.razor | 2 +- .../Components/Controls/LogViewer.razor.cs | 3 ++ .../Components/Pages/ConsoleLogs.razor | 2 + .../Components/Pages/ConsoleLogs.razor.cs | 19 ++++++++ .../Resources/ConsoleLogs.Designer.cs | 6 +++ .../Resources/ConsoleLogs.resx | 3 ++ .../Resources/xlf/ConsoleLogs.cs.xlf | 5 +++ .../Resources/xlf/ConsoleLogs.de.xlf | 5 +++ .../Resources/xlf/ConsoleLogs.es.xlf | 5 +++ .../Resources/xlf/ConsoleLogs.fr.xlf | 5 +++ .../Resources/xlf/ConsoleLogs.it.xlf | 5 +++ .../Resources/xlf/ConsoleLogs.ja.xlf | 5 +++ .../Resources/xlf/ConsoleLogs.ko.xlf | 5 +++ .../Resources/xlf/ConsoleLogs.pl.xlf | 5 +++ .../Resources/xlf/ConsoleLogs.pt-BR.xlf | 5 +++ .../Resources/xlf/ConsoleLogs.ru.xlf | 5 +++ .../Resources/xlf/ConsoleLogs.tr.xlf | 5 +++ .../Resources/xlf/ConsoleLogs.zh-Hans.xlf | 5 +++ .../Resources/xlf/ConsoleLogs.zh-Hant.xlf | 5 +++ .../ServiceClient/DashboardClient.cs | 23 ++++++++++ .../ServiceClient/DashboardSqliteDatabase.cs | 2 +- .../DatabaseSchema/002.Resources.sql | 5 ++- .../ServiceClient/IResourceRepository.cs | 5 +++ .../IResourceRepositoryWriter.cs | 1 + .../ServiceClient/SelectedDashboardClient.cs | 1 + .../SqliteResourceRepository.Storage.cs | 17 ++++++-- .../ServiceClient/SqliteResourceRepository.cs | 43 ++++++++++++++++++- .../Pages/ConsoleLogsTests.cs | 21 +++++++++ .../Model/DashboardClientTests.cs | 7 +++ .../Model/SqliteResourceRepositoryTests.cs | 27 ++++++++++++ tests/Shared/TestDashboardClient.cs | 7 ++- 31 files changed, 250 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor index 23a9cc0d5ba..b93a9136b1a 100644 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor +++ b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor @@ -16,7 +16,7 @@ {
} else if (!string.IsNullOrWhiteSpace(FilterText) && logEntries.EntriesCount > 0 && GetVisibleEntries().Count == 0) diff --git a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs index ee5bf9952e0..fd5ec215650 100644 --- a/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/LogViewer.razor.cs @@ -58,6 +58,9 @@ public sealed partial class LogViewer [Parameter] public bool ShowNoLogsMessage { get; set; } + [Parameter] + public string? NoLogsMessage { get; set; } + [Parameter] public string? FilterText { get; set; } diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor index 31282c95add..7ff9dffdeee 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor @@ -151,6 +151,7 @@ IsTimestampUtc="@_isTimestampUtc" NoWrapLogs="@_noWrapLogs" ShowNoLogsMessage="@_showNoLogsMessage" + NoLogsMessage="@GetNoLogsMessage()" ShowResourcePrefix="@_isSubscribedToAll"/>
@@ -171,6 +172,7 @@ IsTimestampUtc="@_isTimestampUtc" NoWrapLogs="@_noWrapLogs" ShowNoLogsMessage="@_showNoLogsMessage" + NoLogsMessage="@GetNoLogsMessage()" ShowResourcePrefix="@_isSubscribedToAll"/> } diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 9e0976dfda0..e3280bb19c5 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -189,6 +189,7 @@ private record struct LogEntryToWrite(string ResourceName, LogEntry LogEntry, in private bool _isTimestampUtc; private bool _noWrapLogs; private bool _showNoLogsMessage; + private bool _consoleLogsWereLoaded = true; private string _logFilter = string.Empty; public ConsoleLogsViewModel PageViewModel { get; set; } = null!; private IDisposable? _consoleLogsFiltersChangedSubscription; @@ -526,6 +527,8 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa } ResetNoLogsMessage(); + _consoleLogsWereLoaded = !DashboardClient.IsReadOnly || WereConsoleLogsLoaded(isAllSelected, selectedResourceName); + await InvokeAsync(_logViewerRef.SafeRefreshDataAsync); if (isAllSelected) @@ -559,6 +562,22 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa UpdateMenuButtons(); } + private bool WereConsoleLogsLoaded(bool isAllSelected, string? selectedResourceName) + { + if (isAllSelected) + { + return _resourceByName.Values + .Where(resource => !resource.IsResourceHidden(_showHiddenResources)) + .Any(resource => DashboardClient.HaveConsoleLogsBeenLoaded(resource.Name)); + } + + return selectedResourceName is not null && DashboardClient.HaveConsoleLogsBeenLoaded(selectedResourceName); + } + + private string GetNoLogsMessage() => _consoleLogsWereLoaded + ? Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsNoLogsFound)] + : Loc[nameof(Dashboard.Resources.ConsoleLogs.ConsoleLogsNotCapturedForRun)]; + private bool IsAllSelected() { return PageViewModel?.SelectedResource is not null && PageViewModel.SelectedResource == _allResource; diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs index ff19d46fa5b..85d3cbc860e 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.Designer.cs @@ -74,6 +74,12 @@ public static string ConsoleLogsNoLogsFound { return ResourceManager.GetString("ConsoleLogsNoLogsFound", resourceCulture); } } + + public static string ConsoleLogsNotCapturedForRun { + get { + return ResourceManager.GetString("ConsoleLogsNotCapturedForRun", resourceCulture); + } + } public static string ConsoleLogsNoLogsMatchFilter { get { diff --git a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx index 9288b6421cb..b3d8c3e5186 100644 --- a/src/Aspire.Dashboard/Resources/ConsoleLogs.resx +++ b/src/Aspire.Dashboard/Resources/ConsoleLogs.resx @@ -133,6 +133,9 @@ No logs found + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + No logs match the filter diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf index 3dd7a86b9ee..73bccf7f315 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.cs.xlf @@ -47,6 +47,11 @@ Nezalamovat řádky protokolu + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + + {0} console logs Protokoly konzoly aplikace {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf index 71ad2eafd72..ea4d45f4da8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.de.xlf @@ -47,6 +47,11 @@ Protokollzeilen nicht umbrechen + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + + {0} console logs {0} Konsolenprotokolle diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf index e361d6ee099..4cb38a616f0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.es.xlf @@ -47,6 +47,11 @@ No ajustar líneas de registro + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + + {0} console logs Registros de consola de {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf index 1dcbe8f181c..5c18b939539 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.fr.xlf @@ -47,6 +47,11 @@ Ne pas envelopper les lignes de journal + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + + {0} console logs Journaux de console {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf index de00e65ba04..474879d6460 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.it.xlf @@ -47,6 +47,11 @@ Non eseguire il wrapping delle righe di log + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + + {0} console logs {0} log della console diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf index 397bdf9c58e..59c71978f20 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ja.xlf @@ -47,6 +47,11 @@ ログ行を折り返さない + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + + {0} console logs {0} のコンソール ログ diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf index d9908c7a459..bb05bfbf223 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ko.xlf @@ -47,6 +47,11 @@ 로그 줄 래핑 안 함 + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + + {0} console logs {0} 콘솔 로그 diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf index b1ca6622bd7..9428097ac72 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pl.xlf @@ -47,6 +47,11 @@ Nie zawijaj wierszy dziennika + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + + {0} console logs Dzienniki konsoli: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf index 58182d67e5e..5358c3e9659 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.pt-BR.xlf @@ -47,6 +47,11 @@ Não empacotar linhas de log + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + + {0} console logs {0} logs do console diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf index 00a64167336..d59fd615927 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.ru.xlf @@ -47,6 +47,11 @@ Не переносить строки в журнале + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + + {0} console logs Журналов консоли: {0} diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf index 5ef8ce0ff35..af5c35f0425 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.tr.xlf @@ -47,6 +47,11 @@ Günlük satırlarını sarmalama + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + + {0} console logs {0} konsol günlükleri diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf index dad70d2a09c..1bb4c0478d7 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hans.xlf @@ -47,6 +47,11 @@ 不将日志换行 + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + + {0} console logs {0} 控制台日志 diff --git a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf index 2aec48e1d44..78507fbf6d0 100644 --- a/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/ConsoleLogs.zh-Hant.xlf @@ -47,6 +47,11 @@ 不要自動換行 + + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running. + + {0} console logs {0} 主控台記錄 diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index aebeb734a15..7fd195dba19 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -51,6 +51,7 @@ internal sealed class DashboardClient : IDashboardClient private static readonly SemVersion? s_dashboardVersion = GetDashboardVersion(); private readonly Dictionary _resourceByName = new(StringComparers.ResourceName); + private readonly HashSet _loadedConsoleLogResources = new(StringComparers.ResourceName); private readonly InteractionCollection _pendingInteractionCollection = new(); private readonly CancellationTokenSource _cts = new(); private readonly CancellationToken _clientCancellationToken; @@ -906,6 +907,8 @@ public async IAsyncEnumerable> SubscribeConsoleLo { EnsureInitialized(); + MarkConsoleLogsLoaded(resourceName); + // It's ok to dispose CTS with using because this method exits after it is finished being used. using var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); @@ -948,6 +951,8 @@ public async IAsyncEnumerable> GetConsoleLogs(str { EnsureInitialized(); + MarkConsoleLogsLoaded(resourceName); + using var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); var call = _client!.WatchResourceConsoleLogs( @@ -962,6 +967,24 @@ public async IAsyncEnumerable> GetConsoleLogs(str } } + public bool HaveConsoleLogsBeenLoaded(string resourceName) + { + lock (_lock) + { + return _loadedConsoleLogResources.Contains(resourceName); + } + } + + private void MarkConsoleLogsLoaded(string resourceName) + { + lock (_lock) + { + _loadedConsoleLogResources.Add(resourceName); + } + + _resourceRepositoryWriter?.MarkConsoleLogsLoaded(resourceName); + } + private static ResourceLogLine[] CreateLogLines(IList logLines) { var resourceLogLines = new ResourceLogLine[logLines.Count]; diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index e9914319f3b..4d56a6f4e1c 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -13,7 +13,7 @@ internal sealed class DashboardSqliteDatabase { private const string SchemaResourcePrefix = "Aspire.Dashboard.ServiceClient.DatabaseSchema."; - internal const int SchemaVersion = 8; + internal const int SchemaVersion = 9; private static readonly Lazy> s_schemaScripts = new(LoadSchemaScripts); diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql index 91145b959a1..9357236b2ba 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql @@ -18,7 +18,8 @@ CREATE TABLE IF NOT EXISTS dashboard_resources ( is_hidden INTEGER NOT NULL, supports_detailed_telemetry INTEGER NOT NULL, icon_name TEXT NULL, - icon_variant INTEGER NULL + icon_variant INTEGER NULL, + console_logs_loaded INTEGER NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS dashboard_resource_environment ( @@ -177,4 +178,4 @@ CREATE TABLE IF NOT EXISTS console_logs ( ) STRICT; CREATE INDEX IF NOT EXISTS ix_console_logs_resource_id -ON console_logs(resource_name, console_log_id); \ No newline at end of file +ON console_logs(resource_name, console_log_id); diff --git a/src/Aspire.Dashboard/ServiceClient/IResourceRepository.cs b/src/Aspire.Dashboard/ServiceClient/IResourceRepository.cs index c75c9693606..b5393553ad2 100644 --- a/src/Aspire.Dashboard/ServiceClient/IResourceRepository.cs +++ b/src/Aspire.Dashboard/ServiceClient/IResourceRepository.cs @@ -30,6 +30,11 @@ public interface IResourceRepository /// IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken); + /// + /// Gets whether console logs have been loaded for a resource. + /// + bool HaveConsoleLogsBeenLoaded(string resourceName) => false; + /// /// Gets existing console log messages for a resource. /// diff --git a/src/Aspire.Dashboard/ServiceClient/IResourceRepositoryWriter.cs b/src/Aspire.Dashboard/ServiceClient/IResourceRepositoryWriter.cs index 62c542ba552..1f575bd3725 100644 --- a/src/Aspire.Dashboard/ServiceClient/IResourceRepositoryWriter.cs +++ b/src/Aspire.Dashboard/ServiceClient/IResourceRepositoryWriter.cs @@ -9,5 +9,6 @@ internal interface IResourceRepositoryWriter { void ReplaceResources(IReadOnlyList resources); void ApplyChanges(IReadOnlyList changes); + void MarkConsoleLogsLoaded(string resourceName); void AddConsoleLogs(string resourceName, IReadOnlyList logLines); } \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs index 6e7513cef95..82f8bf14450 100644 --- a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs @@ -37,6 +37,7 @@ public event Action? ConnectionStateChanged public Task SubscribeResourcesAsync(CancellationToken cancellationToken) => dataSource.ResourceRepository.SubscribeResourcesAsync(cancellationToken); public ResourceViewModel? GetResource(string resourceName) => dataSource.ResourceRepository.GetResource(resourceName); public IReadOnlyList GetResources() => dataSource.ResourceRepository.GetResources(); + public bool HaveConsoleLogsBeenLoaded(string resourceName) => dataSource.ResourceRepository.HaveConsoleLogsBeenLoaded(resourceName); public IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => IsReadOnly ? dataSource.ResourceRepository.SubscribeConsoleLogs(resourceName, cancellationToken) diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs index 297727e8c79..a58cf91a0dd 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs @@ -12,6 +12,16 @@ namespace Aspire.Dashboard.ServiceClient; public sealed partial class SqliteResourceRepository { private static void SaveResource(SqliteConnection connection, IDbTransaction transaction, Resource resource, int replicaIndex) + { + var consoleLogsLoaded = connection.QuerySingleOrDefault(""" + SELECT console_logs_loaded + FROM dashboard_resources + WHERE resource_name = @ResourceName; + """, new { ResourceName = resource.Name }, transaction); + SaveResource(connection, transaction, resource, replicaIndex, consoleLogsLoaded); + } + + private static void SaveResource(SqliteConnection connection, IDbTransaction transaction, Resource resource, int replicaIndex, bool consoleLogsLoaded) { connection.Execute("DELETE FROM dashboard_resources WHERE resource_name = @ResourceName;", new { ResourceName = resource.Name }, transaction); connection.Execute(""" @@ -19,12 +29,12 @@ INSERT INTO dashboard_resources ( resource_name, replica_index, resource_type, display_name, uid, state, created_at_seconds, created_at_nanos, state_style, started_at_seconds, started_at_nanos, stopped_at_seconds, stopped_at_nanos, - is_hidden, supports_detailed_telemetry, icon_name, icon_variant) + is_hidden, supports_detailed_telemetry, icon_name, icon_variant, console_logs_loaded) VALUES ( @Name, @ReplicaIndex, @ResourceType, @DisplayName, @Uid, @State, @CreatedAtSeconds, @CreatedAtNanos, @StateStyle, @StartedAtSeconds, @StartedAtNanos, @StoppedAtSeconds, @StoppedAtNanos, - @IsHidden, @SupportsDetailedTelemetry, @IconName, @IconVariant); + @IsHidden, @SupportsDetailedTelemetry, @IconName, @IconVariant, @ConsoleLogsLoaded); """, new { resource.Name, @@ -43,7 +53,8 @@ INSERT INTO dashboard_resources ( resource.IsHidden, resource.SupportsDetailedTelemetry, IconName = resource.HasIconName ? resource.IconName : null, - IconVariant = resource.HasIconVariant ? (int?)resource.IconVariant : null + IconVariant = resource.HasIconVariant ? (int?)resource.IconVariant : null, + ConsoleLogsLoaded = consoleLogsLoaded }, transaction); InsertEnvironment(connection, transaction, resource); diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs index c7c65c188e1..d698a95a6c4 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs @@ -69,6 +69,22 @@ public IReadOnlyList GetResources() } } + public bool HaveConsoleLogsBeenLoaded(string resourceName) + { + lock (_lock) + { + ThrowIfDisposed(); + using var connection = _database.OpenConnection(); + return connection.QuerySingle(""" + SELECT COALESCE(( + SELECT console_logs_loaded + FROM dashboard_resources + WHERE resource_name = @ResourceName + ), 0); + """, new { ResourceName = resourceName }); + } + } + public Task SubscribeResourcesAsync(CancellationToken cancellationToken) { lock (_lock) @@ -184,13 +200,18 @@ void IResourceRepositoryWriter.ReplaceResources(IReadOnlyList resource ThrowIfDisposed(); using var connection = _database.OpenConnection(); using var transaction = connection.BeginTransaction(); + var resourcesWithLoadedConsoleLogs = connection.Query(""" + SELECT resource_name + FROM dashboard_resources + WHERE console_logs_loaded = 1; + """, transaction: transaction).ToHashSet(StringComparers.ResourceName); connection.Execute("DELETE FROM dashboard_resources;", transaction: transaction); _resources.Clear(); foreach (var resource in resources) { var viewModel = CreateViewModel(resource); - SaveResource(connection, transaction, resource, viewModel.ReplicaIndex); + SaveResource(connection, transaction, resource, viewModel.ReplicaIndex, resourcesWithLoadedConsoleLogs.Contains(resource.Name)); _resources[resource.Name] = viewModel; changes.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, viewModel)); } @@ -235,6 +256,21 @@ void IResourceRepositoryWriter.ApplyChanges(IReadOnlyList PublishResourceChanges(viewModelChanges); } + void IResourceRepositoryWriter.MarkConsoleLogsLoaded(string resourceName) + { + EnsureWritable(); + lock (_lock) + { + ThrowIfDisposed(); + using var connection = _database.OpenConnection(); + connection.Execute(""" + UPDATE dashboard_resources + SET console_logs_loaded = 1 + WHERE resource_name = @ResourceName; + """, new { ResourceName = resourceName }); + } + } + void IResourceRepositoryWriter.AddConsoleLogs(string resourceName, IReadOnlyList logLines) { EnsureWritable(); @@ -250,6 +286,11 @@ void IResourceRepositoryWriter.AddConsoleLogs(string resourceName, IReadOnlyList ThrowIfDisposed(); using var connection = _database.OpenConnection(); using var transaction = connection.BeginTransaction(); + connection.Execute(""" + UPDATE dashboard_resources + SET console_logs_loaded = 1 + WHERE resource_name = @ResourceName; + """, new { ResourceName = resourceName }, transaction); if (!_consoleLogIds.TryGetValue(resourceName, out var resourceLogIds)) { resourceLogIds = []; diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs index d0e750863d6..54135545105 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs @@ -408,6 +408,27 @@ public async Task CurrentRun_SubscribesToLiveConsoleLogs() Assert.Equal("Live log", Assert.Single(cut.Instance._logEntries.GetEntries()).RawContent); } + [Theory] + [InlineData(false, "Console logs weren't captured for this run. Console logs are only captured if this page is visited while the AppHost is running.")] + [InlineData(true, "No logs found")] + public void HistoricalRun_NoLogs_DisplaysCaptureStatus(bool consoleLogsWereLoaded, string expectedMessage) + { + var testResource = ModelTestHelpers.CreateResource(resourceName: "test-resource", state: KnownResourceState.Running); + var dashboardClient = new TestDashboardClient( + isEnabled: true, + consoleLogsChannelProvider: _ => Channel.CreateUnbounded>(), + resourceChannelProvider: Channel.CreateUnbounded>, + initialResources: [testResource], + isReadOnly: true, + loadedConsoleLogResourceNames: consoleLogsWereLoaded ? new HashSet(StringComparers.ResourceName) { testResource.Name } : null); + SetupConsoleLogsServices(dashboardClient); + + var cut = RenderConsoleLogsPage(CreateViewport(isDesktop: true), testResource.Name); + + var emptyMessage = cut.WaitForElement(".console-empty-message", TimeSpan.FromSeconds(3)); + Assert.Equal(expectedMessage, emptyMessage.TextContent.Trim()); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index 62944acc504..13f4567a86f 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -183,6 +183,7 @@ public async Task SubscribeConsoleLogs_ReceivesAndPersistsLogs() var persistedLogs = Assert.Single(repositoryWriter.ConsoleLogs); Assert.Equal("api", persistedLogs.ResourceName); Assert.Equal("Hello", Assert.Single(persistedLogs.LogLines).Text); + Assert.Equal("api", Assert.Single(repositoryWriter.LoadedConsoleLogs)); } [Fact] @@ -699,6 +700,7 @@ public Task MoveNext(CancellationToken cancellationToken) private sealed class RecordingResourceRepositoryWriter : IResourceRepositoryWriter { public List<(string ResourceName, IReadOnlyList LogLines)> ConsoleLogs { get; } = []; + public List LoadedConsoleLogs { get; } = []; public void ReplaceResources(IReadOnlyList resources) { @@ -708,6 +710,11 @@ public void ApplyChanges(IReadOnlyList changes) { } + public void MarkConsoleLogsLoaded(string resourceName) + { + LoadedConsoleLogs.Add(resourceName); + } + public void AddConsoleLogs(string resourceName, IReadOnlyList logLines) { ConsoleLogs.Add((resourceName, logLines)); diff --git a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs index 5a590f9f446..45d10e5e1b9 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs @@ -93,6 +93,33 @@ public async Task ConsoleLogs_UseInsertionOrderAndAllowLineNumbersToRestart() line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(1, "first-after-restart", false), line)); } + [Fact] + public void ConsoleLogsLoaded_PersistsWithoutLogLines() + { + var databasePath = Path.Combine(_temporaryDirectory, "console-loaded.db"); + using (var repository = CreateRepository(databasePath)) + { + var writer = (IResourceRepositoryWriter)repository; + writer.ReplaceResources([CreateResource("api", "api"), CreateResource("worker", "worker")]); + Assert.False(repository.HaveConsoleLogsBeenLoaded("api")); + + writer.MarkConsoleLogsLoaded("api"); + + Assert.True(repository.HaveConsoleLogsBeenLoaded("api")); + Assert.False(repository.HaveConsoleLogsBeenLoaded("worker")); + + writer.ApplyChanges([new WatchResourcesChange { Upsert = CreateResource("api", "api") }]); + Assert.True(repository.HaveConsoleLogsBeenLoaded("api")); + + writer.ReplaceResources([CreateResource("api", "api"), CreateResource("worker", "worker")]); + Assert.True(repository.HaveConsoleLogsBeenLoaded("api")); + } + + using var historicalRepository = CreateRepository(databasePath, readOnly: true); + Assert.True(historicalRepository.HaveConsoleLogsBeenLoaded("api")); + Assert.False(historicalRepository.HaveConsoleLogsBeenLoaded("worker")); + } + [Fact] public void Resources_AllFieldsAndRecursiveValuesRoundTrip() { diff --git a/tests/Shared/TestDashboardClient.cs b/tests/Shared/TestDashboardClient.cs index c1383744525..aac5f63339e 100644 --- a/tests/Shared/TestDashboardClient.cs +++ b/tests/Shared/TestDashboardClient.cs @@ -20,6 +20,7 @@ public class TestDashboardClient : IDashboardClient private readonly Func>? _executeResourceCommand; private readonly Channel? _sendInteractionUpdateChannel; private readonly IList? _initialResources; + private readonly IReadOnlySet _loadedConsoleLogResourceNames; public bool IsEnabled { get; } public bool IsReadOnly { get; } @@ -43,7 +44,8 @@ public TestDashboardClient( Channel? sendInteractionUpdateChannel = null, IList? initialResources = null, Task? whenConnected = null, - bool isReadOnly = false) + bool isReadOnly = false, + IReadOnlySet? loadedConsoleLogResourceNames = null) { IsEnabled = isEnabled ?? false; IsReadOnly = isReadOnly; @@ -56,6 +58,7 @@ public TestDashboardClient( _executeResourceCommand = executeResourceCommand; _sendInteractionUpdateChannel = sendInteractionUpdateChannel; _initialResources = initialResources; + _loadedConsoleLogResourceNames = loadedConsoleLogResourceNames ?? new HashSet(StringComparers.ResourceName); } public ValueTask DisposeAsync() @@ -98,6 +101,8 @@ public async IAsyncEnumerable> SubscribeConsoleLo } } + public bool HaveConsoleLogsBeenLoaded(string resourceName) => _loadedConsoleLogResourceNames.Contains(resourceName); + public async IAsyncEnumerable> GetConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) { if (_consoleLogsChannelProvider == null) From 9032210a22fd88714008292a702559ff0a803355 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 17 Jul 2026 13:03:54 +0800 Subject: [PATCH 27/87] Enable dashboard SQLite connection pooling --- .../Otlp/Storage/SqliteTelemetryRepository.cs | 1 + .../ServiceClient/DashboardRunStore.cs | 3 ++ .../ServiceClient/DashboardSqliteDatabase.cs | 14 +++++-- .../ServiceClient/SqliteResourceRepository.cs | 2 + .../Model/DashboardDataSourceTests.cs | 38 +++++++++++-------- 5 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index 66fba6e86c3..7662798ab7e 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -206,6 +206,7 @@ public void Dispose() subscription.Dispose(); } DisposeWatchers(); + _database.ClearPool(); } } \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index 4bee993d467..e9fee6fdb48 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -68,6 +68,7 @@ internal DashboardRunStore( DatabasePath = Path.Combine(RunDirectory, "dashboard.db"); if (File.Exists(DatabasePath) && !DashboardSqliteDatabase.IsCompatible(DatabasePath)) { + DashboardSqliteDatabase.ClearPools(); DeleteDatabaseFiles(DatabasePath); } break; @@ -139,6 +140,8 @@ public IReadOnlyList GetRuns() public void Dispose() { + DashboardSqliteDatabase.ClearPools(); + if (_metadataPath is not null) { WriteMetadata(_metadata with { EndedAtUtc = DateTimeOffset.UtcNow, CleanShutdown = true }); diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index 4d56a6f4e1c..5c0792f4888 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -21,7 +21,7 @@ internal sealed class DashboardSqliteDatabase private readonly object _schemaLock = new(); private bool _schemaInitialized; - public DashboardSqliteDatabase(string databasePath, bool readOnly = false) + public DashboardSqliteDatabase(string databasePath, bool readOnly = false, bool pooling = true) { ArgumentException.ThrowIfNullOrWhiteSpace(databasePath); @@ -37,7 +37,7 @@ public DashboardSqliteDatabase(string databasePath, bool readOnly = false) { DataSource = DatabasePath, Mode = readOnly ? SqliteOpenMode.ReadOnly : SqliteOpenMode.ReadWriteCreate, - Pooling = false, + Pooling = pooling, DefaultTimeout = 5 }.ToString(); } @@ -53,9 +53,9 @@ public static bool IsCompatible(string databasePath) return false; } + var database = new DashboardSqliteDatabase(databasePath, readOnly: true, pooling: false); try { - var database = new DashboardSqliteDatabase(databasePath, readOnly: true); using var connection = database.OpenConnection(); var version = connection.QuerySingleOrDefault(""" SELECT CASE @@ -81,6 +81,14 @@ public SqliteConnection OpenConnection() return connection; } + public static void ClearPools() => SqliteConnection.ClearAllPools(); + + public void ClearPool() + { + using var connection = new SqliteConnection(_connectionString); + SqliteConnection.ClearPool(connection); + } + public void InitializeSchema() { EnsureWritable("Historical dashboard data is read-only."); diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs index d698a95a6c4..3370c93e9b1 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs @@ -420,6 +420,8 @@ public void Dispose() channel.Writer.TryComplete(); } } + + _database.ClearPool(); } } diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index bfe12a5b907..440a4a0cdbf 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -253,22 +253,28 @@ public void GetRuns_ExcludesIncompatibleDatabaseWithoutDeletingIt() public void SqliteDatabase_ConfiguresLikeAndForeignKeys() { var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "connection.db")); - using var connection = database.OpenConnection(); - using var command = connection.CreateCommand(); - command.CommandText = """ - SELECT - 'Dashboard' = 'dashboard' COLLATE NOCASE, - 'CAFE au lait' LIKE '%fe AU%', - 'Delta' LIKE 'dE%', - (SELECT foreign_keys FROM pragma_foreign_keys()); - """; - using var reader = command.ExecuteReader(); - - Assert.True(reader.Read()); - Assert.Equal(1, reader.GetInt64(0)); - Assert.Equal(1, reader.GetInt64(1)); - Assert.Equal(1, reader.GetInt64(2)); - Assert.Equal(1, reader.GetInt64(3)); + using (var connection = database.OpenConnection()) + using (var command = connection.CreateCommand()) + { + Assert.True(new SqliteConnectionStringBuilder(connection.ConnectionString).Pooling); + + command.CommandText = """ + SELECT + 'Dashboard' = 'dashboard' COLLATE NOCASE, + 'CAFE au lait' LIKE '%fe AU%', + 'Delta' LIKE 'dE%', + (SELECT foreign_keys FROM pragma_foreign_keys()); + """; + using var reader = command.ExecuteReader(); + + Assert.True(reader.Read()); + Assert.Equal(1, reader.GetInt64(0)); + Assert.Equal(1, reader.GetInt64(1)); + Assert.Equal(1, reader.GetInt64(2)); + Assert.Equal(1, reader.GetInt64(3)); + } + + database.ClearPool(); } [Fact] From 72f35f19292c0f13ed6024d4cbd8e04a8aba03a5 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 17 Jul 2026 13:10:34 +0800 Subject: [PATCH 28/87] Address dashboard run lifecycle review feedback --- .../ServiceClient/DashboardRunStore.cs | 106 ++++++++++++++++-- .../ServiceClient/SelectedDashboardClient.cs | 5 +- .../Model/DashboardDataSourceTests.cs | 92 +++++++++++++++ 3 files changed, 190 insertions(+), 13 deletions(-) diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index e9fee6fdb48..ee0c3c32ce1 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -17,6 +17,8 @@ internal interface IDashboardRunStore internal sealed class DashboardRunStore : IDashboardRunStore, IDisposable { + private const string TemporaryDirectoryPrefix = "aspire-dashboard-"; + internal const int MaxApplicationDirectoryNameLength = 80; internal const int MaxRuns = 10; internal const int SchemaVersion = DashboardSqliteDatabase.SchemaVersion; @@ -26,6 +28,7 @@ internal sealed class DashboardRunStore : IDashboardRunStore, IDisposable private readonly string? _runsDirectory; private readonly string? _metadataPath; private readonly string? _temporaryDirectory; + private readonly FileStream? _runLock; private readonly DashboardRunMetadata _metadata; private readonly ILogger _logger; @@ -45,13 +48,15 @@ internal DashboardRunStore( var runId = $"{startedAt:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}"; PersistenceMode = options.Value.Data.PersistenceMode; - // Persistent run directories should be located under a directory scoped to the current user. Rely on that - // directory's inherited permissions instead of modifying the SQLite database, WAL, and shared-memory files individually. + // Persistent run directories should be located under a directory scoped to the current user. Do not set Unix modes here; + // rely on the AppHost-managed data root's inherited permissions for the database, WAL, and shared-memory files. switch (PersistenceMode) { case DashboardPersistenceMode.None: - _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-").FullName; + _temporaryDirectory = Directory.CreateTempSubdirectory(TemporaryDirectoryPrefix).FullName; RunDirectory = _temporaryDirectory; + _runLock = OpenRunLock(RunDirectory); + DeleteAbandonedTemporaryDirectories(deleteRunDirectory); DatabasePath = Path.Combine(RunDirectory, "dashboard.db"); break; case DashboardPersistenceMode.Runs: @@ -59,6 +64,7 @@ internal DashboardRunStore( _runsDirectory = Path.Combine(applicationDirectory, "runs"); RunDirectory = Path.Combine(_runsDirectory, runId); Directory.CreateDirectory(RunDirectory); + _runLock = OpenRunLock(RunDirectory); DatabasePath = Path.Combine(RunDirectory, "dashboard.db"); _metadataPath = Path.Combine(RunDirectory, "run.json"); break; @@ -91,6 +97,47 @@ internal DashboardRunStore( } } + private void DeleteAbandonedTemporaryDirectories(Action deleteRunDirectory) + { + var temporaryRoot = Directory.GetParent(RunDirectory)!.FullName; + foreach (var directory in Directory.EnumerateDirectories(temporaryRoot, $"{TemporaryDirectoryPrefix}*")) + { + if (!IsTemporaryDatabaseDirectory(directory) || + string.Equals(directory, RunDirectory, StringComparison.OrdinalIgnoreCase) || + !File.Exists(Path.Combine(directory, "dashboard.db"))) + { + continue; + } + + using var runLock = TryOpenRunLock(directory); + if (runLock is null) + { + continue; + } + + try + { + deleteRunDirectory(directory); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + _logger.LogWarning( + exception, + "Failed to delete abandoned dashboard temporary directory '{RunDirectory}'. The directory may still be in use by another dashboard process.", + directory); + } + } + } + + private static bool IsTemporaryDatabaseDirectory(string directory) + { + // Directory.CreateTempSubdirectory appends a Path.GetRandomFileName value such as "a1b2c3d4.e5f". + // Checking that exact shape avoids matching other Aspire test and tool directories with longer prefixes. + var directoryName = Path.GetFileName(directory); + var randomName = directoryName.AsSpan(TemporaryDirectoryPrefix.Length); + return randomName is { Length: 12 } && randomName[8] == '.'; + } + public string RunDirectory { get; } public string DatabasePath { get; } public string RunId => _metadata.RunId; @@ -140,15 +187,22 @@ public IReadOnlyList GetRuns() public void Dispose() { - DashboardSqliteDatabase.ClearPools(); - - if (_metadataPath is not null) + try { - WriteMetadata(_metadata with { EndedAtUtc = DateTimeOffset.UtcNow, CleanShutdown = true }); + DashboardSqliteDatabase.ClearPools(); + + if (_metadataPath is not null) + { + WriteMetadata(_metadata with { EndedAtUtc = DateTimeOffset.UtcNow, CleanShutdown = true }); + } + else if (_temporaryDirectory is not null && Directory.Exists(_temporaryDirectory)) + { + Directory.Delete(_temporaryDirectory, recursive: true); + } } - else if (_temporaryDirectory is not null && Directory.Exists(_temporaryDirectory)) + finally { - Directory.Delete(_temporaryDirectory, recursive: true); + _runLock?.Dispose(); } } @@ -167,6 +221,12 @@ private void PruneRuns(Action deleteRunDirectory) foreach (var directory in expiredRunDirectories) { + using var runLock = TryOpenRunLock(directory); + if (runLock is null) + { + continue; + } + try { deleteRunDirectory(directory); @@ -181,6 +241,34 @@ private void PruneRuns(Action deleteRunDirectory) } } + private static FileStream OpenRunLock(string runDirectory) + { + // An exclusive FileStream is the best cross-platform option for locking across processes because .NET named + // semaphores are only supported on Windows. Keep the lock beside the run directory so pruning can hold it + // while recursively deleting the directory on Windows. + return new FileStream( + GetRunLockPath(runDirectory), + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.DeleteOnClose); + } + + private static FileStream? TryOpenRunLock(string runDirectory) + { + try + { + return OpenRunLock(runDirectory); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return null; + } + } + + internal static string GetRunLockPath(string runDirectory) => $"{runDirectory}.lock"; + private static DashboardRunDescriptor CreateDescriptor(DashboardRunMetadata metadata, string runDirectory, bool isCurrent) { return new DashboardRunDescriptor( diff --git a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs index 82f8bf14450..45cf43668d4 100644 --- a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs @@ -26,10 +26,7 @@ public event Action? ConnectionStateChanged } remove { - if (!IsReadOnly) - { - currentClient.ConnectionStateChanged -= value; - } + currentClient.ConnectionStateChanged -= value; } } diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index 440a4a0cdbf..cfac025ca0a 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -59,6 +59,61 @@ public void NoneMode_UsesTemporaryDatabaseAndDeletesItOnDispose() Assert.False(File.Exists(databasePath)); } + [Fact] + public void NoneMode_DeletesAbandonedTemporaryDirectories() + { + var abandonedDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-").FullName; + File.WriteAllText(Path.Combine(abandonedDirectory, "dashboard.db"), string.Empty); + + try + { + using var runStore = CreateRunStore(CreateOptions(persistenceMode: DashboardPersistenceMode.None)); + + Assert.False(Directory.Exists(abandonedDirectory)); + } + finally + { + if (Directory.Exists(abandonedDirectory)) + { + Directory.Delete(abandonedDirectory, recursive: true); + } + } + } + + [Fact] + public void NoneMode_DoesNotDeleteActiveTemporaryDirectories() + { + var options = CreateOptions(persistenceMode: DashboardPersistenceMode.None); + using var activeRunStore = CreateRunStore(options); + new DashboardSqliteDatabase(activeRunStore.DatabasePath).InitializeSchema(); + + using var secondRunStore = CreateRunStore(options); + + Assert.True(Directory.Exists(activeRunStore.RunDirectory)); + Assert.True(Directory.Exists(secondRunStore.RunDirectory)); + } + + [Fact] + public void NoneMode_DoesNotDeleteOtherDashboardTemporaryDirectories() + { + var otherDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-telemetry-tests-").FullName; + File.WriteAllText(Path.Combine(otherDirectory, "dashboard.db"), string.Empty); + + try + { + using var runStore = CreateRunStore(CreateOptions(persistenceMode: DashboardPersistenceMode.None)); + + Assert.True(Directory.Exists(otherDirectory)); + } + finally + { + if (Directory.Exists(otherDirectory)) + { + Directory.Delete(otherDirectory, recursive: true); + } + } + } + [Fact] public void AppendMode_ReusesApplicationDatabaseWithoutRunSelection() { @@ -192,6 +247,35 @@ public void RunsMode_DeletesOldestRunWhenLimitIsExceeded() Assert.True(Directory.Exists(currentRunStore.RunDirectory)); } + [Fact] + public void RunsMode_DoesNotDeleteActiveExpiredRun() + { + var applicationDirectory = Path.Combine(_temporaryDirectory, DashboardRunStore.GetApplicationDirectoryName("TestApp")); + var runsDirectory = Path.Combine(applicationDirectory, "runs"); + var historicalRunDirectories = Enumerable.Range(1, DashboardRunStore.MaxRuns) + .Select(index => Path.Combine( + runsDirectory, + $"{DateTimeOffset.UtcNow.AddDays(-index):yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}")) + .ToList(); + + foreach (var directory in historicalRunDirectories) + { + Directory.CreateDirectory(directory); + } + + var activeExpiredRun = historicalRunDirectories[^1]; + using var activeRunLock = new FileStream( + DashboardRunStore.GetRunLockPath(activeExpiredRun), + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None); + + using var currentRunStore = CreateRunStore(CreateOptions()); + + Assert.True(Directory.Exists(activeExpiredRun)); + Assert.Equal(DashboardRunStore.MaxRuns + 1, Directory.GetDirectories(runsDirectory).Length); + } + [Fact] public void RunsMode_DeleteExpiredRunFails_LogsWarningAndContinues() { @@ -358,6 +442,14 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() Assert.Empty(dataSource.TelemetryRepository.GetResources()); Assert.False(dataSource.IsReadOnly); + + Action handler = _ => connectionStateChangedCount++; + selectedClient.ConnectionStateChanged += handler; + dataSource.SelectRun(historicalRunId); + selectedClient.ConnectionStateChanged -= handler; + + currentClient.SetConnectionStateForTesting(DashboardConnectionState.Connected); + Assert.Equal(0, connectionStateChangedCount); } [Fact] From 89ff07c746587d3291b34b01080b97147ebdeb1f Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 17 Jul 2026 16:02:18 +0800 Subject: [PATCH 29/87] Add dashboard SQLite tracing and optimize metric ingestion --- playground/Stress/Stress.AppHost/AppHost.cs | 11 +- .../TestShop/TestShop.AppHost/AppHost.cs | 11 +- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 2 + .../DashboardWebApplication.cs | 9 + .../Storage/SqliteTelemetryRepository.Logs.cs | 37 +- .../SqliteTelemetryRepository.Metrics.cs | 635 +++++++++++++----- .../Otlp/Storage/SqliteTelemetryRepository.cs | 35 +- .../ServiceClient/DashboardDataSource.cs | 9 +- .../ServiceClient/DashboardSqliteDatabase.cs | 12 +- .../ServiceClient/SqliteResourceRepository.cs | 6 + .../ServiceClient/TracingSqliteConnection.cs | 195 ++++++ .../Model/DashboardSqliteDatabaseTests.cs | 78 +++ .../TelemetryRepositoryTests/MetricsTests.cs | 181 +++++ 13 files changed, 1053 insertions(+), 168 deletions(-) create mode 100644 src/Aspire.Dashboard/ServiceClient/TracingSqliteConnection.cs create mode 100644 tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs diff --git a/playground/Stress/Stress.AppHost/AppHost.cs b/playground/Stress/Stress.AppHost/AppHost.cs index d64551bf923..148e9710bd9 100644 --- a/playground/Stress/Stress.AppHost/AppHost.cs +++ b/playground/Stress/Stress.AppHost/AppHost.cs @@ -99,7 +99,16 @@ // dashboard launch experience, Refer to Directory.Build.props for the path to // the dashboard binary (defaults to the Aspire.Dashboard bin output in the // artifacts dir). -builder.AddProject(KnownResourceNames.AspireDashboard); +var dashboardBuilder = builder.AddProject(KnownResourceNames.AspireDashboard); +if (builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] is { Length: > 0 } dashboardOtlpEndpoint) +{ + // The AppHost normally points every project at its own dashboard. Preserve an explicitly configured + // external endpoint for dashboard self-telemetry so its activities can be inspected separately. + dashboardBuilder + .WithEnvironment("OTEL_EXPORTER_OTLP_ENDPOINT", dashboardOtlpEndpoint) + .WithEnvironment("OTEL_EXPORTER_OTLP_PROTOCOL", builder.Configuration["OTEL_EXPORTER_OTLP_PROTOCOL"] ?? "grpc") + .WithEnvironment("OTEL_EXPORTER_OTLP_HEADERS", builder.Configuration["OTEL_EXPORTER_OTLP_HEADERS"] ?? string.Empty); +} #endif builder.AddExecutable("executableWithSingleArg", "dotnet", Environment.CurrentDirectory, "--version"); diff --git a/playground/TestShop/TestShop.AppHost/AppHost.cs b/playground/TestShop/TestShop.AppHost/AppHost.cs index 2be43b45244..479ba73dfd8 100644 --- a/playground/TestShop/TestShop.AppHost/AppHost.cs +++ b/playground/TestShop/TestShop.AppHost/AppHost.cs @@ -136,7 +136,16 @@ // dashboard launch experience, Refer to Directory.Build.props for the path to // the dashboard binary (defaults to the Aspire.Dashboard bin output in the // artifacts dir). -builder.AddProject(KnownResourceNames.AspireDashboard); +var dashboardBuilder = builder.AddProject(KnownResourceNames.AspireDashboard); +if (builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] is { Length: > 0 } dashboardOtlpEndpoint) +{ + // The AppHost normally points every project at its own dashboard. Preserve an explicitly configured + // external endpoint for dashboard self-telemetry so its activities can be inspected separately. + dashboardBuilder + .WithEnvironment("OTEL_EXPORTER_OTLP_ENDPOINT", dashboardOtlpEndpoint) + .WithEnvironment("OTEL_EXPORTER_OTLP_PROTOCOL", builder.Configuration["OTEL_EXPORTER_OTLP_PROTOCOL"] ?? "grpc") + .WithEnvironment("OTEL_EXPORTER_OTLP_HEADERS", builder.Configuration["OTEL_EXPORTER_OTLP_HEADERS"] ?? string.Empty); +} #endif builder.Build().Run(); diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 7477f0dc22c..923ac76fa9a 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -65,6 +65,8 @@ + + diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 2401b5143cb..1526066eca9 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -40,6 +40,7 @@ using Microsoft.Extensions.Options; using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using OpenTelemetry.Trace; using OpenIdConnectOptions = Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions; namespace Aspire.Dashboard; @@ -64,6 +65,7 @@ public sealed class DashboardWebApplication : IAsyncDisposable private const string DashboardAuthCookieName = ".Aspire.Dashboard.Auth"; private const string DashboardHttpAuthCookieName = ".Aspire.Dashboard.Auth.Http"; private const string DashboardAntiForgeryCookieName = ".Aspire.Dashboard.Antiforgery"; + private const string OtlpExporterEndpointConfigurationKey = "OTEL_EXPORTER_OTLP_ENDPOINT"; private readonly WebApplication _app; private readonly ILogger _logger; private readonly IOptionsMonitor _dashboardOptionsMonitor; @@ -298,6 +300,13 @@ public DashboardWebApplication( builder.Services.TryAddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + if (!string.IsNullOrWhiteSpace(builder.Configuration[OtlpExporterEndpointConfigurationKey])) + { + builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing + .AddSource(TracingSqliteConnection.ActivitySourceName) + .AddOtlpExporter()); + } // OTLP services. builder.Services.AddGrpc(); diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs index 216da31acb2..44f60a88807 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs @@ -17,6 +17,9 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed partial class SqliteTelemetryRepository { + private readonly Dictionary _resourceIdsByKey = []; + private readonly Dictionary _scopesByName = new(StringComparer.Ordinal); + private List AddLogsToDatabase(AddContext context, RepeatedField resourceLogs) { var addedLogs = new List(); @@ -146,6 +149,11 @@ INSERT INTO telemetry_log_attributes (log_id, ordinal, attribute_key, attribute_ private long GetOrAddTelemetryResource(SqliteConnection connection, IDbTransaction transaction, ResourceKey resourceKey) { + if (_resourceIdsByKey.TryGetValue(resourceKey, out var cachedResourceId)) + { + return cachedResourceId; + } + var resourceId = connection.QuerySingleOrDefault(""" SELECT resource_id FROM telemetry_resources @@ -154,6 +162,7 @@ FROM telemetry_resources """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId }, transaction); if (resourceId is not null) { + _resourceIdsByKey.Add(resourceKey, resourceId.Value); return resourceId.Value; } @@ -163,11 +172,20 @@ FROM telemetry_resources throw new InvalidOperationException($"Resource limit of {_otlpContext.Options.MaxResourceCount} reached. Resource '{resourceKey}' will not be added."); } - return connection.QuerySingle(""" + resourceId = connection.QuerySingle(""" INSERT INTO telemetry_resources (resource_name, instance_id) VALUES (@ResourceName, @InstanceId) RETURNING resource_id; """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId }, transaction); + _resourceIdsByKey.Add(resourceKey, resourceId.Value); + return resourceId.Value; + } + + private void ClearIngestionCaches() + { + _resourceIdsByKey.Clear(); + _scopesByName.Clear(); + _metricIngestionState.Clear(); } private static long GetOrAddResourceView( @@ -241,6 +259,12 @@ INSERT INTO telemetry_resource_view_attributes (resource_view_id, ordinal, attri IDbTransaction transaction, InstrumentationScope? instrumentationScope) { + var scopeName = instrumentationScope?.Name ?? OtlpScope.Empty.Name; + if (_scopesByName.TryGetValue(scopeName, out var cachedScope)) + { + return (cachedScope.ScopeId, cachedScope.Scope); + } + var incomingScope = instrumentationScope is null ? OtlpScope.Empty : new OtlpScope( @@ -262,7 +286,9 @@ FROM telemetry_scope_attributes """, new { existing.ScopeId }, transaction) .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)) .ToArray(); - return (existing.ScopeId, new OtlpScope(existing.ScopeName, existing.ScopeVersion, attributes)); + var scope = new OtlpScope(existing.ScopeName, existing.ScopeVersion, attributes); + _scopesByName.Add(scopeName, new ScopeCacheEntry(existing.ScopeId, scope)); + return (existing.ScopeId, scope); } var scopeCount = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_scopes;", transaction: transaction); @@ -286,6 +312,7 @@ INSERT INTO telemetry_scope_attributes (scope_id, ordinal, attribute_key, attrib attribute.Key, attribute.Value }), transaction); + _scopesByName.Add(scopeName, new ScopeCacheEntry(scopeId, incomingScope)); return (scopeId, incomingScope); } @@ -905,6 +932,7 @@ DELETE FROM telemetry_traces """, transaction: transaction); DeleteOrphanedScopes(connection, transaction); transaction.Commit(); + ClearIngestionCaches(); } _resourceCache.TryRemove(resourceKey, out _); @@ -949,7 +977,7 @@ UPDATE telemetry_resources } } - private static void DeleteOrphanedScopes(SqliteConnection connection, IDbTransaction transaction) + private void DeleteOrphanedScopes(SqliteConnection connection, IDbTransaction transaction) { connection.Execute(""" DELETE FROM telemetry_scopes @@ -957,6 +985,7 @@ WHERE NOT EXISTS (SELECT 1 FROM telemetry_logs WHERE telemetry_logs.scope_id = t AND NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.scope_id = telemetry_scopes.scope_id) AND NOT EXISTS (SELECT 1 FROM telemetry_metric_instruments WHERE telemetry_metric_instruments.scope_id = telemetry_scopes.scope_id); """, transaction: transaction); + _scopesByName.Clear(); } private List GetTelemetryResources(bool includeUninstrumentedPeers, string? name) @@ -1043,6 +1072,8 @@ public int GetHashCode(KeyValuePair[] properties) private sealed record LogQuery(string FromAndWhere, DynamicParameters Parameters); + private sealed record ScopeCacheEntry(long ScopeId, OtlpScope Scope); + private sealed class ScopeRecord { public required long ScopeId { get; init; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs index 6cf3ca66bd0..9504ad4d08e 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs @@ -22,54 +22,74 @@ public sealed partial class SqliteTelemetryRepository private const int LongPointType = 1; private const int DoublePointType = 2; private const int HistogramPointType = 3; + private const int MaxMetricPointBatchSize = 100; + + private readonly MetricIngestionState _metricIngestionState = new(); private void AddMetricsToDatabase(AddContext context, RepeatedField resourceMetrics) { lock (_writeLock) { - using var connection = _database.OpenConnection(); - using var transaction = connection.BeginTransaction(); - foreach (var resourceMetricsItem in resourceMetrics) + _metricIngestionState.DimensionsToTrim.Clear(); + try { - long resourceId; - try + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + var pointBatch = new MetricPointBatch(); + foreach (var resourceMetricsItem in resourceMetrics) { - resourceId = GetOrAddTelemetryResource(connection, transaction, resourceMetricsItem.Resource.GetResourceKey()); - } - catch (Exception exception) - { - context.FailureCount += resourceMetricsItem.ScopeMetrics.Sum(scope => scope.Metrics.Sum(OtlpResource.GetMetricDataPointCount)); - _otlpContext.Logger.LogInformation(exception, "Error adding resource."); - continue; - } - - foreach (var scopeMetrics in resourceMetricsItem.ScopeMetrics) - { - OtlpScope scope; - long scopeId; + long resourceId; try { - (scopeId, scope) = GetOrAddScope(connection, transaction, scopeMetrics.Scope); + resourceId = GetOrAddTelemetryResource(connection, transaction, resourceMetricsItem.Resource.GetResourceKey()); } catch (Exception exception) { - context.FailureCount += scopeMetrics.Metrics.Sum(OtlpResource.GetMetricDataPointCount); - _otlpContext.Logger.LogInformation(exception, "Error adding metric scope."); + context.FailureCount += resourceMetricsItem.ScopeMetrics.Sum(scope => scope.Metrics.Sum(OtlpResource.GetMetricDataPointCount)); + _otlpContext.Logger.LogInformation(exception, "Error adding resource."); continue; } - foreach (var metric in scopeMetrics.Metrics) + foreach (var scopeMetrics in resourceMetricsItem.ScopeMetrics) { - AddMetricToDatabase(connection, transaction, context, resourceId, scopeId, scope, metric); + OtlpScope scope; + long scopeId; + try + { + (scopeId, scope) = GetOrAddScope(connection, transaction, scopeMetrics.Scope); + } + catch (Exception exception) + { + context.FailureCount += scopeMetrics.Metrics.Sum(OtlpResource.GetMetricDataPointCount); + _otlpContext.Logger.LogInformation(exception, "Error adding metric scope."); + continue; + } + + foreach (var metric in scopeMetrics.Metrics) + { + AddMetricToDatabase(connection, transaction, context, resourceId, scopeId, scope, metric, _metricIngestionState, pointBatch); + } } + + connection.Execute( + "UPDATE telemetry_resources SET has_metrics = 1 WHERE resource_id = @ResourceId;", + new { ResourceId = resourceId }, + transaction); } - connection.Execute( - "UPDATE telemetry_resources SET has_metrics = 1 WHERE resource_id = @ResourceId;", - new { ResourceId = resourceId }, - transaction); + ExecuteMetricPointBatch(connection, transaction, pointBatch); + + TrimMetricDimensions(connection, transaction, _metricIngestionState.DimensionsToTrim); + + transaction.Commit(); + _metricIngestionState.DimensionsToTrim.Clear(); + } + catch + { + // Cache entries can refer to changes that were rolled back with the transaction. + ClearIngestionCaches(); + throw; } - transaction.Commit(); } } @@ -80,7 +100,9 @@ private void AddMetricToDatabase( long resourceId, long scopeId, OtlpScope scope, - Metric metric) + Metric metric, + MetricIngestionState ingestionState, + MetricPointBatch pointBatch) { var pointCount = OtlpResource.GetMetricDataPointCount(metric); if (metric.DataCase is Metric.DataOneofCase.Summary or Metric.DataOneofCase.ExponentialHistogram) @@ -97,7 +119,7 @@ private void AddMetricToDatabase( long instrumentId; try { - instrumentId = GetOrAddMetricInstrument(connection, transaction, resourceId, scopeId, metric); + instrumentId = GetOrAddMetricInstrument(connection, transaction, resourceId, scopeId, metric, ingestionState); } catch (Exception exception) { @@ -111,19 +133,19 @@ private void AddMetricToDatabase( case Metric.DataOneofCase.Gauge: foreach (var point in metric.Gauge.DataPoints) { - AddNumberMetricPoint(connection, transaction, context, instrumentId, scope, point); + AddNumberMetricPoint(connection, transaction, context, instrumentId, scope, point, ingestionState, pointBatch); } break; case Metric.DataOneofCase.Sum: foreach (var point in metric.Sum.DataPoints) { - AddNumberMetricPoint(connection, transaction, context, instrumentId, scope, point); + AddNumberMetricPoint(connection, transaction, context, instrumentId, scope, point, ingestionState, pointBatch); } break; case Metric.DataOneofCase.Histogram: foreach (var point in metric.Histogram.DataPoints) { - AddHistogramMetricPoint(connection, transaction, context, instrumentId, scope, point); + AddHistogramMetricPoint(connection, transaction, context, instrumentId, scope, point, ingestionState, pointBatch); } break; } @@ -134,12 +156,20 @@ private static long GetOrAddMetricInstrument( IDbTransaction transaction, long resourceId, long scopeId, - Metric metric) + Metric metric, + MetricIngestionState ingestionState) { if (string.IsNullOrEmpty(metric.Name)) { throw new InvalidOperationException("Instrument name is required."); } + + var instrumentKey = (resourceId, scopeId, metric.Name); + if (ingestionState.InstrumentIds.TryGetValue(instrumentKey, out var instrumentId)) + { + return instrumentId; + } + var existingId = connection.QuerySingleOrDefault(""" SELECT instrument_id FROM telemetry_metric_instruments @@ -147,6 +177,7 @@ FROM telemetry_metric_instruments """, new { ResourceId = resourceId, ScopeId = scopeId, InstrumentName = metric.Name }, transaction); if (existingId is not null) { + ingestionState.InstrumentIds.Add(instrumentKey, existingId.Value); return existingId.Value; } @@ -156,7 +187,7 @@ FROM telemetry_metric_instruments throw new InvalidOperationException($"Instrument limit of {TelemetryRepositoryLimits.MaxInstrumentCount} reached. Instrument '{metric.Name}' will not be added."); } - return connection.QuerySingle(""" + instrumentId = connection.QuerySingle(""" INSERT INTO telemetry_metric_instruments ( resource_id, scope_id, instrument_name, description, unit, instrument_type, aggregation_temporality, is_monotonic) @@ -175,6 +206,8 @@ INSERT INTO telemetry_metric_instruments ( AggregationTemporality = (int)MapAggregationTemporality(metric), IsMonotonic = metric.DataCase == Metric.DataOneofCase.Sum && metric.Sum.IsMonotonic }, transaction); + ingestionState.InstrumentIds.Add(instrumentKey, instrumentId); + return instrumentId; } private void AddNumberMetricPoint( @@ -183,59 +216,68 @@ private void AddNumberMetricPoint( AddContext context, long instrumentId, OtlpScope scope, - NumberDataPoint point) + NumberDataPoint point, + MetricIngestionState ingestionState, + MetricPointBatch pointBatch) { try { - var dimensionId = GetOrAddMetricDimension(connection, transaction, instrumentId, scope, point.Attributes); + var dimension = GetOrAddMetricDimension(connection, transaction, instrumentId, scope, point.Attributes, ingestionState); var pointType = point.ValueCase switch { NumberDataPoint.ValueOneofCase.AsInt => LongPointType, NumberDataPoint.ValueOneofCase.AsDouble => DoublePointType, _ => throw new InvalidOperationException("Metric data point has no value.") }; - var latest = GetLatestMetricPoint(connection, transaction, dimensionId); - var sameValue = latest is not null && latest.PointType == pointType && - (pointType == LongPointType ? latest.IntegerValue == point.AsInt : latest.DoubleValue == point.AsDouble); - long pointId; + var pendingLatest = dimension.PendingPoint; + var latest = dimension.LatestPoint; + var latestPointType = pendingLatest?.PointType ?? latest?.PointType; + var latestEndTimeTicks = pendingLatest?.EndTimeTicks ?? latest?.EndTimeTicks; + var sameValue = latestPointType == pointType && (pendingLatest is not null + ? pointType == LongPointType ? pendingLatest.IntegerValue == point.AsInt : pendingLatest.DoubleValue == point.AsDouble + : pointType == LongPointType ? latest?.IntegerValue == point.AsInt : latest?.DoubleValue == point.AsDouble); + var endTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks; if (sameValue) { - pointId = latest!.PointId; - connection.Execute(""" - UPDATE telemetry_metric_points - SET end_time_ticks = @EndTimeTicks, repeat_count = repeat_count + 1 - WHERE point_id = @PointId; - """, new { PointId = pointId, EndTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks }, transaction); + if (pendingLatest is not null) + { + pendingLatest.EndTimeTicks = endTimeTicks; + pendingLatest.RepeatCount++; + pendingLatest.SourcePointCount++; + pendingLatest.Exemplars.AddRange(point.Exemplars); + } + else + { + pointBatch.AddUpdate(latest!.PointId, endTimeTicks, incrementRepeatCount: true); + latest.EndTimeTicks = endTimeTicks; + AddMetricExemplars(connection, transaction, latest.PointId, point.Exemplars); + context.SuccessCount++; + } } else { var start = OtlpHelpers.UnixNanoSecondsToDateTime(point.StartTimeUnixNano); - if (latest?.PointType == pointType) + if (latestPointType == pointType) { - start = new DateTime(latest.EndTimeTicks, DateTimeKind.Utc); + start = new DateTime(latestEndTimeTicks!.Value, DateTimeKind.Utc); } - pointId = connection.QuerySingle(""" - INSERT INTO telemetry_metric_points ( - dimension_id, point_type, start_time_ticks, end_time_ticks, repeat_count, - integer_value, double_value, flags) - VALUES ( - @DimensionId, @PointType, @StartTimeTicks, @EndTimeTicks, 1, - @IntegerValue, @DoubleValue, @Flags) - RETURNING point_id; - """, new + var pendingPoint = new PendingMetricPoint { - DimensionId = dimensionId, + Context = context, + Dimension = dimension, PointType = pointType, StartTimeTicks = start.Ticks, - EndTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks, + EndTimeTicks = endTimeTicks, + RepeatCount = 1, IntegerValue = pointType == LongPointType ? point.AsInt : (long?)null, DoubleValue = pointType == DoublePointType ? point.AsDouble : (double?)null, Flags = (long)point.Flags - }, transaction); + }; + pendingPoint.Exemplars.AddRange(point.Exemplars); + pointBatch.Inserts.Add(pendingPoint); + dimension.PendingPoint = pendingPoint; + ingestionState.DimensionsToTrim.Add(dimension.DimensionId); } - AddMetricExemplars(connection, transaction, pointId, point.Exemplars); - TrimMetricDimension(connection, transaction, dimensionId); - context.SuccessCount++; } catch (Exception exception) { @@ -250,7 +292,9 @@ private void AddHistogramMetricPoint( AddContext context, long instrumentId, OtlpScope scope, - HistogramDataPoint point) + HistogramDataPoint point, + MetricIngestionState ingestionState, + MetricPointBatch pointBatch) { try { @@ -258,55 +302,57 @@ private void AddHistogramMetricPoint( { throw new InvalidOperationException("Histogram data point has bucket counts without any explicit bounds."); } - var dimensionId = GetOrAddMetricDimension(connection, transaction, instrumentId, scope, point.Attributes); - var latest = GetLatestMetricPoint(connection, transaction, dimensionId); - var sameCount = latest is not null && latest.PointType == HistogramPointType && latest.HistogramCount == point.Count.ToString(CultureInfo.InvariantCulture); - long pointId; + var dimension = GetOrAddMetricDimension(connection, transaction, instrumentId, scope, point.Attributes, ingestionState); + var pendingLatest = dimension.PendingPoint; + var latest = dimension.LatestPoint; + var latestPointType = pendingLatest?.PointType ?? latest?.PointType; + var latestEndTimeTicks = pendingLatest?.EndTimeTicks ?? latest?.EndTimeTicks; + var histogramCount = point.Count.ToString(CultureInfo.InvariantCulture); + var sameCount = latestPointType == HistogramPointType && + (pendingLatest?.HistogramCount ?? latest?.HistogramCount) == histogramCount; + var endTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks; if (sameCount) { - pointId = latest!.PointId; - connection.Execute( - "UPDATE telemetry_metric_points SET end_time_ticks = @EndTimeTicks WHERE point_id = @PointId;", - new { PointId = pointId, EndTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks }, - transaction); + if (pendingLatest is not null) + { + pendingLatest.EndTimeTicks = endTimeTicks; + pendingLatest.SourcePointCount++; + pendingLatest.Exemplars.AddRange(point.Exemplars); + } + else + { + pointBatch.AddUpdate(latest!.PointId, endTimeTicks, incrementRepeatCount: false); + latest.EndTimeTicks = endTimeTicks; + AddMetricExemplars(connection, transaction, latest.PointId, point.Exemplars); + context.SuccessCount++; + } } else { var start = OtlpHelpers.UnixNanoSecondsToDateTime(point.StartTimeUnixNano); - if (latest?.PointType == HistogramPointType) + if (latestPointType == HistogramPointType) { - start = new DateTime(latest.EndTimeTicks, DateTimeKind.Utc); + start = new DateTime(latestEndTimeTicks!.Value, DateTimeKind.Utc); } - pointId = connection.QuerySingle(""" - INSERT INTO telemetry_metric_points ( - dimension_id, point_type, start_time_ticks, end_time_ticks, repeat_count, - histogram_sum, histogram_count, flags) - VALUES ( - @DimensionId, @PointType, @StartTimeTicks, @EndTimeTicks, 1, - @HistogramSum, @HistogramCount, @Flags) - RETURNING point_id; - """, new + var pendingPoint = new PendingMetricPoint { - DimensionId = dimensionId, + Context = context, + Dimension = dimension, PointType = HistogramPointType, StartTimeTicks = start.Ticks, - EndTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks, + EndTimeTicks = endTimeTicks, + RepeatCount = 1, HistogramSum = point.Sum, - HistogramCount = point.Count.ToString(CultureInfo.InvariantCulture), - Flags = (long)point.Flags - }, transaction); - connection.Execute(""" - INSERT INTO telemetry_metric_histogram_bucket_counts (point_id, ordinal, bucket_count) - VALUES (@PointId, @Ordinal, @BucketCount); - """, point.BucketCounts.Select((count, ordinal) => new { PointId = pointId, Ordinal = ordinal, BucketCount = count.ToString(CultureInfo.InvariantCulture) }), transaction); - connection.Execute(""" - INSERT INTO telemetry_metric_histogram_explicit_bounds (point_id, ordinal, explicit_bound) - VALUES (@PointId, @Ordinal, @ExplicitBound); - """, point.ExplicitBounds.Select((bound, ordinal) => new { PointId = pointId, Ordinal = ordinal, ExplicitBound = bound }), transaction); + HistogramCount = histogramCount, + Flags = (long)point.Flags, + HistogramBucketCounts = point.BucketCounts.Select(count => count.ToString(CultureInfo.InvariantCulture)).ToArray(), + HistogramExplicitBounds = point.ExplicitBounds.ToArray() + }; + pendingPoint.Exemplars.AddRange(point.Exemplars); + pointBatch.Inserts.Add(pendingPoint); + dimension.PendingPoint = pendingPoint; + ingestionState.DimensionsToTrim.Add(dimension.DimensionId); } - AddMetricExemplars(connection, transaction, pointId, point.Exemplars); - TrimMetricDimension(connection, transaction, dimensionId); - context.SuccessCount++; } catch (Exception exception) { @@ -315,44 +361,252 @@ INSERT INTO telemetry_metric_histogram_explicit_bounds (point_id, ordinal, expli } } - private long GetOrAddMetricDimension( + private void ExecuteMetricPointBatch(SqliteConnection connection, IDbTransaction transaction, MetricPointBatch pointBatch) + { + foreach (var updates in pointBatch.Updates.Values.Chunk(MaxMetricPointBatchSize)) + { + var sql = new StringBuilder(""" + WITH updates(point_id, end_time_ticks, repeat_delta) AS ( + VALUES + """); + var parameters = new DynamicParameters(); + var index = 0; + foreach (var update in updates) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@PointId{index}, @EndTimeTicks{index}, @RepeatDelta{index})"); + parameters.Add($"PointId{index}", update.PointId); + parameters.Add($"EndTimeTicks{index}", update.EndTimeTicks); + parameters.Add($"RepeatDelta{index}", update.RepeatDelta); + index++; + } + sql.AppendLine(); + sql.Append(""" + ) + UPDATE telemetry_metric_points + SET end_time_ticks = (SELECT end_time_ticks FROM updates WHERE updates.point_id = telemetry_metric_points.point_id), + repeat_count = repeat_count + (SELECT repeat_delta FROM updates WHERE updates.point_id = telemetry_metric_points.point_id) + WHERE point_id IN (SELECT point_id FROM updates); + """); + connection.Execute(sql.ToString(), parameters, transaction); + } + + // Keep one INSERT statement and RETURNING result set per point inside a single command. This preserves + // deterministic point-to-ID mapping for histogram and exemplar rows without a round trip per point. + foreach (var points in pointBatch.Inserts.Chunk(MaxMetricPointBatchSize)) + { + var insertSql = new StringBuilder(); + var insertParameters = new DynamicParameters(); + for (var i = 0; i < points.Length; i++) + { + var point = points[i]; + insertSql.Append(CultureInfo.InvariantCulture, $$""" + INSERT INTO telemetry_metric_points ( + dimension_id, point_type, start_time_ticks, end_time_ticks, repeat_count, + integer_value, double_value, histogram_sum, histogram_count, flags) + VALUES ( + @DimensionId{{i}}, @PointType{{i}}, @StartTimeTicks{{i}}, @EndTimeTicks{{i}}, @RepeatCount{{i}}, + @IntegerValue{{i}}, @DoubleValue{{i}}, @HistogramSum{{i}}, @HistogramCount{{i}}, @Flags{{i}}) + RETURNING point_id; + """); + insertParameters.Add($"DimensionId{i}", point.Dimension.DimensionId); + insertParameters.Add($"PointType{i}", point.PointType); + insertParameters.Add($"StartTimeTicks{i}", point.StartTimeTicks); + insertParameters.Add($"EndTimeTicks{i}", point.EndTimeTicks); + insertParameters.Add($"RepeatCount{i}", point.RepeatCount); + insertParameters.Add($"IntegerValue{i}", point.IntegerValue); + insertParameters.Add($"DoubleValue{i}", point.DoubleValue); + insertParameters.Add($"HistogramSum{i}", point.HistogramSum); + insertParameters.Add($"HistogramCount{i}", point.HistogramCount); + insertParameters.Add($"Flags{i}", point.Flags); + } + + using var reader = connection.QueryMultiple(insertSql.ToString(), insertParameters, transaction); + foreach (var point in points) + { + point.PointId = reader.ReadSingle(); + } + } + + foreach (var point in pointBatch.Inserts) + { + try + { + InsertHistogramBucketCounts(connection, transaction, point); + InsertHistogramExplicitBounds(connection, transaction, point); + AddMetricExemplars(connection, transaction, point.PointId, point.Exemplars); + point.Context.SuccessCount += point.SourcePointCount; + } + catch (Exception exception) + { + point.Context.FailureCount += point.SourcePointCount; + _otlpContext.Logger.LogInformation(exception, "Error adding metric."); + } + + if (ReferenceEquals(point.Dimension.PendingPoint, point)) + { + point.Dimension.LatestPoint = new MetricPointRecord + { + PointId = point.PointId, + PointType = point.PointType, + EndTimeTicks = point.EndTimeTicks, + IntegerValue = point.IntegerValue, + DoubleValue = point.DoubleValue, + HistogramCount = point.HistogramCount + }; + point.Dimension.PendingPoint = null; + } + } + } + + private static void InsertHistogramBucketCounts(SqliteConnection connection, IDbTransaction transaction, PendingMetricPoint point) + { + if (point.HistogramBucketCounts is not { Length: > 0 } bucketCounts) + { + return; + } + + for (var offset = 0; offset < bucketCounts.Length; offset += MaxMetricPointBatchSize) + { + var count = Math.Min(MaxMetricPointBatchSize, bucketCounts.Length - offset); + var sql = new StringBuilder(""" + INSERT INTO telemetry_metric_histogram_bucket_counts (point_id, ordinal, bucket_count) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < count; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@PointId{index}, @Ordinal{index}, @BucketCount{index})"); + parameters.Add($"PointId{index}", point.PointId); + parameters.Add($"Ordinal{index}", offset + index); + parameters.Add($"BucketCount{index}", bucketCounts[offset + index]); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + + private static void InsertHistogramExplicitBounds(SqliteConnection connection, IDbTransaction transaction, PendingMetricPoint point) + { + if (point.HistogramExplicitBounds is not { Length: > 0 } explicitBounds) + { + return; + } + + for (var offset = 0; offset < explicitBounds.Length; offset += MaxMetricPointBatchSize) + { + var count = Math.Min(MaxMetricPointBatchSize, explicitBounds.Length - offset); + var sql = new StringBuilder(""" + INSERT INTO telemetry_metric_histogram_explicit_bounds (point_id, ordinal, explicit_bound) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < count; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@PointId{index}, @Ordinal{index}, @ExplicitBound{index})"); + parameters.Add($"PointId{index}", point.PointId); + parameters.Add($"Ordinal{index}", offset + index); + parameters.Add($"ExplicitBound{index}", explicitBounds[offset + index]); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + + private MetricDimensionState GetOrAddMetricDimension( SqliteConnection connection, IDbTransaction transaction, long instrumentId, OtlpScope scope, - RepeatedField pointAttributes) + RepeatedField pointAttributes, + MetricIngestionState ingestionState) { KeyValuePair[]? temporaryAttributes = null; OtlpHelpers.CopyKeyValuePairs(pointAttributes, scope.Attributes, _otlpContext, out var copyCount, ref temporaryAttributes); Array.Sort(temporaryAttributes, 0, copyCount, MetricAttributeComparer.Instance); var attributes = temporaryAttributes.AsSpan(0, copyCount).ToArray(); var attributeHash = GetMetricDimensionAttributeHash(attributes); - var candidates = connection.Query(""" - SELECT - d.dimension_id AS DimensionId, - a.attribute_key AS AttributeKey, - a.attribute_value AS AttributeValue - FROM telemetry_metric_dimensions d - LEFT JOIN telemetry_metric_dimension_attributes a ON a.dimension_id = d.dimension_id - WHERE d.instrument_id = @InstrumentId - AND d.attribute_hash = @AttributeHash - ORDER BY d.dimension_id, a.ordinal; - """, new { InstrumentId = instrumentId, AttributeHash = attributeHash }, transaction).ToLookup(attribute => attribute.DimensionId); + var cacheKey = (instrumentId, attributeHash); + if (!ingestionState.Dimensions.TryGetValue(cacheKey, out var candidates)) + { + candidates = connection.Query(""" + SELECT + d.dimension_id AS DimensionId, + a.attribute_key AS AttributeKey, + a.attribute_value AS AttributeValue, + p.point_id AS PointId, + p.point_type AS PointType, + p.end_time_ticks AS EndTimeTicks, + p.integer_value AS IntegerValue, + p.double_value AS DoubleValue, + p.histogram_count AS HistogramCount + FROM telemetry_metric_dimensions d + LEFT JOIN telemetry_metric_dimension_attributes a ON a.dimension_id = d.dimension_id + LEFT JOIN telemetry_metric_points p ON p.point_id = ( + SELECT point_id + FROM telemetry_metric_points + WHERE dimension_id = d.dimension_id + ORDER BY point_id DESC + LIMIT 1 + ) + WHERE d.instrument_id = @InstrumentId + AND d.attribute_hash = @AttributeHash + ORDER BY d.dimension_id, a.ordinal; + """, new { InstrumentId = instrumentId, AttributeHash = attributeHash }, transaction) + .GroupBy(record => record.DimensionId) + .Select(group => + { + var first = group.First(); + return new MetricDimensionState + { + DimensionId = group.Key, + Attributes = group + .Where(record => record.AttributeKey is not null) + .Select(record => KeyValuePair.Create(record.AttributeKey!, record.AttributeValue!)) + .ToArray(), + LatestPoint = first.PointId is not null + ? new MetricPointRecord + { + PointId = first.PointId.Value, + PointType = first.PointType!.Value, + EndTimeTicks = first.EndTimeTicks!.Value, + IntegerValue = first.IntegerValue, + DoubleValue = first.DoubleValue, + HistogramCount = first.HistogramCount + } + : null + }; + }) + .ToList(); + ingestionState.Dimensions.Add(cacheKey, candidates); + } + foreach (var candidate in candidates) { - var existingAttributes = candidate - .Where(attribute => attribute.AttributeKey is not null) - .Select(attribute => KeyValuePair.Create(attribute.AttributeKey!, attribute.AttributeValue!)); - if (existingAttributes.SequenceEqual(attributes)) + if (candidate.Attributes.SequenceEqual(attributes)) { - return candidate.Key; + return candidate; } } - var dimensionCount = connection.QuerySingle( - "SELECT COUNT(*) FROM telemetry_metric_dimensions WHERE instrument_id = @InstrumentId;", - new { InstrumentId = instrumentId }, - transaction); + if (!ingestionState.DimensionCounts.TryGetValue(instrumentId, out var dimensionCount)) + { + dimensionCount = connection.QuerySingle( + "SELECT COUNT(*) FROM telemetry_metric_dimensions WHERE instrument_id = @InstrumentId;", + new { InstrumentId = instrumentId }, + transaction); + } if (dimensionCount >= TelemetryRepositoryLimits.MaxDimensionCount) { throw new InvalidOperationException($"Dimension limit of {TelemetryRepositoryLimits.MaxDimensionCount} reached."); @@ -371,7 +625,10 @@ INSERT INTO telemetry_metric_dimension_attributes (dimension_id, ordinal, attrib { connection.Execute("UPDATE telemetry_metric_instruments SET has_overflow = 1 WHERE instrument_id = @InstrumentId;", new { InstrumentId = instrumentId }, transaction); } - return dimensionId; + var dimension = new MetricDimensionState { DimensionId = dimensionId, Attributes = attributes }; + candidates.Add(dimension); + ingestionState.DimensionCounts[instrumentId] = dimensionCount + 1; + return dimension; } private static long GetMetricDimensionAttributeHash(ReadOnlySpan> attributes) @@ -395,24 +652,7 @@ static void AppendHashValue(XxHash3 hash, string value) } } - private static MetricPointRecord? GetLatestMetricPoint(SqliteConnection connection, IDbTransaction transaction, long dimensionId) - { - return connection.QuerySingleOrDefault(""" - SELECT - point_id AS PointId, - point_type AS PointType, - end_time_ticks AS EndTimeTicks, - integer_value AS IntegerValue, - double_value AS DoubleValue, - histogram_count AS HistogramCount - FROM telemetry_metric_points - WHERE dimension_id = @DimensionId - ORDER BY point_id DESC - LIMIT 1; - """, new { DimensionId = dimensionId }, transaction); - } - - private void AddMetricExemplars(SqliteConnection connection, IDbTransaction transaction, long pointId, RepeatedField exemplars) + private void AddMetricExemplars(SqliteConnection connection, IDbTransaction transaction, long pointId, IEnumerable exemplars) { foreach (var exemplar in exemplars) { @@ -453,18 +693,25 @@ INSERT INTO telemetry_metric_exemplar_attributes (exemplar_id, ordinal, attribut } } - private void TrimMetricDimension(SqliteConnection connection, IDbTransaction transaction, long dimensionId) + private void TrimMetricDimensions(SqliteConnection connection, IDbTransaction transaction, IEnumerable dimensionIds) { - connection.Execute(""" - DELETE FROM telemetry_metric_points - WHERE point_id IN ( - SELECT point_id - FROM telemetry_metric_points - WHERE dimension_id = @DimensionId - ORDER BY point_id - LIMIT MAX((SELECT COUNT(*) FROM telemetry_metric_points WHERE dimension_id = @DimensionId) - @MaxMetricsCount, 0) - ); - """, new { DimensionId = dimensionId, _otlpContext.Options.MaxMetricsCount }, transaction); + foreach (var ids in dimensionIds.Chunk(MaxMetricPointBatchSize)) + { + connection.Execute(""" + DELETE FROM telemetry_metric_points + WHERE point_id IN ( + SELECT point_id + FROM ( + SELECT + point_id, + ROW_NUMBER() OVER (PARTITION BY dimension_id ORDER BY point_id DESC) AS point_rank + FROM telemetry_metric_points + WHERE dimension_id IN @DimensionIds + ) + WHERE point_rank > @MaxMetricsCount + ); + """, new { DimensionIds = ids, _otlpContext.Options.MaxMetricsCount }, transaction); + } } private List GetInstrumentsSummariesFromDatabase(ResourceKey key) @@ -781,6 +1028,7 @@ UPDATE telemetry_resources """, parameters, transaction); DeleteOrphanedScopes(connection, transaction); transaction.Commit(); + _metricIngestionState.Clear(); } } @@ -833,18 +1081,95 @@ private sealed class MetricAttributeComparer : IComparer x, KeyValuePair y) => string.Compare(x.Key, y.Key, StringComparison.Ordinal); } - private sealed class MetricDimensionAttributeRecord + private sealed class MetricIngestionState + { + public Dictionary<(long ResourceId, long ScopeId, string InstrumentName), long> InstrumentIds { get; } = []; + public Dictionary<(long InstrumentId, long AttributeHash), List> Dimensions { get; } = []; + public Dictionary DimensionCounts { get; } = []; + public HashSet DimensionsToTrim { get; } = []; + + public void Clear() + { + InstrumentIds.Clear(); + Dimensions.Clear(); + DimensionCounts.Clear(); + DimensionsToTrim.Clear(); + } + } + + private sealed class MetricDimensionState + { + public required long DimensionId { get; init; } + public required KeyValuePair[] Attributes { get; init; } + public MetricPointRecord? LatestPoint { get; set; } + public PendingMetricPoint? PendingPoint { get; set; } + } + + private sealed class MetricPointBatch + { + public Dictionary Updates { get; } = []; + public List Inserts { get; } = []; + + public void AddUpdate(long pointId, long endTimeTicks, bool incrementRepeatCount) + { + if (!Updates.TryGetValue(pointId, out var update)) + { + update = new MetricPointUpdate { PointId = pointId }; + Updates.Add(pointId, update); + } + update.EndTimeTicks = endTimeTicks; + if (incrementRepeatCount) + { + update.RepeatDelta++; + } + } + } + + private sealed class MetricPointUpdate + { + public required long PointId { get; init; } + public long EndTimeTicks { get; set; } + public long RepeatDelta { get; set; } + } + + private sealed class PendingMetricPoint + { + public required AddContext Context { get; init; } + public required MetricDimensionState Dimension { get; init; } + public required int PointType { get; init; } + public required long StartTimeTicks { get; init; } + public required long EndTimeTicks { get; set; } + public required long RepeatCount { get; set; } + public long? IntegerValue { get; init; } + public double? DoubleValue { get; init; } + public double? HistogramSum { get; init; } + public string? HistogramCount { get; init; } + public required long Flags { get; init; } + public long PointId { get; set; } + public int SourcePointCount { get; set; } = 1; + public string[]? HistogramBucketCounts { get; init; } + public double[]? HistogramExplicitBounds { get; init; } + public List Exemplars { get; } = []; + } + + private sealed class MetricDimensionStateRecord { public required long DimensionId { get; init; } public string? AttributeKey { get; init; } public string? AttributeValue { get; init; } + public long? PointId { get; init; } + public int? PointType { get; init; } + public long? EndTimeTicks { get; init; } + public long? IntegerValue { get; init; } + public double? DoubleValue { get; init; } + public string? HistogramCount { get; init; } } private class MetricPointRecord { public required long PointId { get; init; } public required int PointType { get; init; } - public required long EndTimeTicks { get; init; } + public required long EndTimeTicks { get; set; } public long? IntegerValue { get; init; } public double? DoubleValue { get; init; } public string? HistogramCount { get; init; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index 7662798ab7e..5130fa34209 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -4,6 +4,7 @@ using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; +using System.Diagnostics; using Google.Protobuf.Collections; using Microsoft.Extensions.Options; using OpenTelemetry.Proto.Logs.V1; @@ -22,6 +23,7 @@ public sealed partial class SqliteTelemetryRepository : ITelemetryRepository, IT private readonly PauseManager _pauseManager; private readonly IReadOnlyList _outgoingPeerResolvers; private readonly List _outgoingPeerSubscriptions = []; + private readonly bool _ownsDatabase; private readonly object _writeLock = new(); private int _disposed; @@ -37,6 +39,8 @@ private static string EscapeLikePattern(string value) .Replace("_", "!_", StringComparison.Ordinal); } + internal ActivitySource SqlActivitySource => _database.ActivitySource; + public SqliteTelemetryRepository( string databasePath, ILoggerFactory loggerFactory, @@ -46,6 +50,7 @@ public SqliteTelemetryRepository( bool readOnly = false) : this(new DashboardSqliteDatabase(databasePath, readOnly), loggerFactory, dashboardOptions, pauseManager, outgoingPeerResolvers) { + _ownsDatabase = true; } internal SqliteTelemetryRepository( @@ -99,7 +104,18 @@ public void AddLogs(AddContext context, RepeatedField resourceLogs } EnsureWritable(); - NotifyLogsAdded(AddLogsToDatabase(context, resourceLogs)); + try + { + NotifyLogsAdded(AddLogsToDatabase(context, resourceLogs)); + } + catch + { + lock (_writeLock) + { + ClearIngestionCaches(); + } + throw; + } } public void AddMetrics(AddContext context, RepeatedField resourceMetrics) @@ -128,7 +144,18 @@ public void AddTraces(AddContext context, RepeatedField resourceS } EnsureWritable(); - NotifySpansAdded(AddTracesToDatabase(context, resourceSpans)); + try + { + NotifySpansAdded(AddTracesToDatabase(context, resourceSpans)); + } + catch + { + lock (_writeLock) + { + ClearIngestionCaches(); + } + throw; + } } public PagedResult GetLogs(GetLogsContext context) => GetLogsFromDatabase(context); @@ -207,6 +234,10 @@ public void Dispose() } DisposeWatchers(); _database.ClearPool(); + if (_ownsDatabase) + { + _database.Dispose(); + } } } \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs index 2a17b8fc253..5eed2274cbf 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs @@ -22,6 +22,7 @@ public sealed class DashboardDataSource : IDashboardRunSelection, IDisposable private ITelemetryRepository? _historicalTelemetryRepository; private IResourceRepository? _historicalResourceRepository; + private DashboardSqliteDatabase? _historicalDatabase; internal DashboardDataSource( IDashboardRunStore runStore, @@ -69,9 +70,9 @@ internal void SelectRun(string? runId) if (!selectedRun.IsCurrent) { - var historicalDatabase = new DashboardSqliteDatabase(selectedRun.DatabasePath, readOnly: true); - _historicalTelemetryRepository = _repositoryFactory.CreateTelemetryRepository(historicalDatabase); - _historicalResourceRepository = _repositoryFactory.CreateResourceRepository(historicalDatabase); + _historicalDatabase = new DashboardSqliteDatabase(selectedRun.DatabasePath, readOnly: true); + _historicalTelemetryRepository = _repositoryFactory.CreateTelemetryRepository(_historicalDatabase); + _historicalResourceRepository = _repositoryFactory.CreateResourceRepository(_historicalDatabase); TelemetryRepository = _historicalTelemetryRepository; ResourceRepository = _historicalResourceRepository; IsReadOnly = true; @@ -95,5 +96,7 @@ private void DisposeHistoricalRepositories() { _historicalTelemetryRepository?.Dispose(); (_historicalResourceRepository as IDisposable)?.Dispose(); + _historicalDatabase?.Dispose(); + _historicalDatabase = null; } } diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index 5c0792f4888..541204326a6 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Dapper; +using System.Diagnostics; using Microsoft.Data.Sqlite; namespace Aspire.Dashboard.ServiceClient; @@ -9,7 +10,7 @@ namespace Aspire.Dashboard.ServiceClient; /// /// Creates consistently configured connections to a dashboard run database. /// -internal sealed class DashboardSqliteDatabase +internal sealed class DashboardSqliteDatabase : IDisposable { private const string SchemaResourcePrefix = "Aspire.Dashboard.ServiceClient.DatabaseSchema."; @@ -18,6 +19,7 @@ internal sealed class DashboardSqliteDatabase private static readonly Lazy> s_schemaScripts = new(LoadSchemaScripts); private readonly string _connectionString; + private readonly ActivitySource _activitySource = new(TracingSqliteConnection.ActivitySourceName); private readonly object _schemaLock = new(); private bool _schemaInitialized; @@ -46,6 +48,8 @@ public DashboardSqliteDatabase(string databasePath, bool readOnly = false, bool public bool IsReadOnly { get; } + internal ActivitySource ActivitySource => _activitySource; + public static bool IsCompatible(string databasePath) { if (!File.Exists(databasePath)) @@ -53,7 +57,7 @@ public static bool IsCompatible(string databasePath) return false; } - var database = new DashboardSqliteDatabase(databasePath, readOnly: true, pooling: false); + using var database = new DashboardSqliteDatabase(databasePath, readOnly: true, pooling: false); try { using var connection = database.OpenConnection(); @@ -74,7 +78,7 @@ ELSE NULL public SqliteConnection OpenConnection() { - var connection = new SqliteConnection(_connectionString); + var connection = new TracingSqliteConnection(_connectionString, DatabasePath, _activitySource); connection.Open(); connection.Execute("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;"); @@ -89,6 +93,8 @@ public void ClearPool() SqliteConnection.ClearPool(connection); } + public void Dispose() => _activitySource.Dispose(); + public void InitializeSchema() { EnsureWritable("Historical dashboard data is read-only."); diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs index 3370c93e9b1..10757c08b8f 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs @@ -18,6 +18,7 @@ public sealed partial class SqliteResourceRepository : IResourceRepository, IRes private readonly DashboardSqliteDatabase _database; private readonly IKnownPropertyLookup _knownPropertyLookup; private readonly ILogger _logger; + private readonly bool _ownsDatabase; private readonly object _lock = new(); private readonly Dictionary _resources = new(StringComparers.ResourceName); private ImmutableHashSet>> _resourceChannels = []; @@ -32,6 +33,7 @@ public SqliteResourceRepository( bool readOnly = false) : this(new DashboardSqliteDatabase(databasePath, readOnly), knownPropertyLookup, loggerFactory) { + _ownsDatabase = true; } internal SqliteResourceRepository( @@ -422,6 +424,10 @@ public void Dispose() } _database.ClearPool(); + if (_ownsDatabase) + { + _database.Dispose(); + } } } diff --git a/src/Aspire.Dashboard/ServiceClient/TracingSqliteConnection.cs b/src/Aspire.Dashboard/ServiceClient/TracingSqliteConnection.cs new file mode 100644 index 00000000000..1fd88a7d250 --- /dev/null +++ b/src/Aspire.Dashboard/ServiceClient/TracingSqliteConnection.cs @@ -0,0 +1,195 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data; +using System.Data.Common; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Data.Sqlite; + +namespace Aspire.Dashboard.ServiceClient; + +/// +/// Adds tracing to commands executed by Dapper through a SQLite connection. +/// +internal sealed class TracingSqliteConnection(string connectionString, string databasePath, ActivitySource activitySource) : SqliteConnection(connectionString) +{ + internal const string ActivitySourceName = "Aspire.Dashboard.Sqlite"; + + protected override DbCommand CreateDbCommand() => new TracingDbCommand(base.CreateDbCommand(), Path.GetFileName(databasePath), activitySource); + + private sealed class TracingDbCommand(DbCommand command, string databaseName, ActivitySource activitySource) : DbCommand + { + [AllowNull] + public override string CommandText + { + get => command.CommandText; + set => command.CommandText = value; + } + + public override int CommandTimeout + { + get => command.CommandTimeout; + set => command.CommandTimeout = value; + } + + public override CommandType CommandType + { + get => command.CommandType; + set => command.CommandType = value; + } + + public override bool DesignTimeVisible + { + get => command.DesignTimeVisible; + set => command.DesignTimeVisible = value; + } + + public override UpdateRowSource UpdatedRowSource + { + get => command.UpdatedRowSource; + set => command.UpdatedRowSource = value; + } + + protected override DbConnection? DbConnection + { + get => command.Connection; + set => command.Connection = value; + } + + protected override DbParameterCollection DbParameterCollection => command.Parameters; + + protected override DbTransaction? DbTransaction + { + get => command.Transaction; + set => command.Transaction = value; + } + + public override void Cancel() => command.Cancel(); + + public override int ExecuteNonQuery() => ExecuteWithActivity(command.ExecuteNonQuery); + + public override object? ExecuteScalar() => ExecuteWithActivity(command.ExecuteScalar); + + public override void Prepare() => command.Prepare(); + + public override Task ExecuteNonQueryAsync(CancellationToken cancellationToken) => + ExecuteWithActivityAsync(() => command.ExecuteNonQueryAsync(cancellationToken)); + + public override Task ExecuteScalarAsync(CancellationToken cancellationToken) => + ExecuteWithActivityAsync(() => command.ExecuteScalarAsync(cancellationToken)); + + public override Task PrepareAsync(CancellationToken cancellationToken = default) => command.PrepareAsync(cancellationToken); + + public override ValueTask DisposeAsync() => command.DisposeAsync(); + + protected override DbParameter CreateDbParameter() => command.CreateParameter(); + + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => + ExecuteWithActivity(() => command.ExecuteReader(behavior)); + + protected override Task ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken) => + ExecuteWithActivityAsync(() => command.ExecuteReaderAsync(behavior, cancellationToken)); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + command.Dispose(); + } + + base.Dispose(disposing); + } + + private T ExecuteWithActivity(Func execute) + { + using var activity = StartActivity(); + try + { + return execute(); + } + catch (Exception exception) + { + RecordException(activity, exception); + throw; + } + } + + private async Task ExecuteWithActivityAsync(Func> execute) + { + using var activity = StartActivity(); + try + { + return await execute().ConfigureAwait(false); + } + catch (Exception exception) + { + RecordException(activity, exception); + throw; + } + } + + private Activity? StartActivity() + { + var operationName = GetOperationName(CommandText); + var activity = activitySource.StartActivity( + operationName is null ? "sqlite query" : $"{operationName} sqlite", + ActivityKind.Client); + if (activity is not null) + { + activity.SetTag("db.system.name", "sqlite"); + activity.SetTag("db.namespace", databaseName); + activity.SetTag("db.query.text", CommandText); + activity.SetTag("db.operation.name", operationName); + } + + return activity; + } + + private static string? GetOperationName(string query) + { + var querySpan = query.AsSpan(); + while (true) + { + querySpan = querySpan.TrimStart(); + + // Embedded schema scripts start with license comments before the first SQL statement. + if (querySpan.StartsWith("--")) + { + var lineEndIndex = querySpan.IndexOfAny('\r', '\n'); + if (lineEndIndex < 0) + { + return null; + } + + querySpan = querySpan[(lineEndIndex + 1)..]; + continue; + } + + if (querySpan.StartsWith("/*")) + { + var commentEndIndex = querySpan.IndexOf("*/"); + if (commentEndIndex < 0) + { + return null; + } + + querySpan = querySpan[(commentEndIndex + 2)..]; + continue; + } + + break; + } + + var separatorIndex = querySpan.IndexOfAny(" \t\r\n;"); + var operationSpan = separatorIndex >= 0 ? querySpan[..separatorIndex] : querySpan; + return operationSpan.IsEmpty ? null : operationSpan.ToString().ToUpperInvariant(); + } + + private static void RecordException(Activity? activity, Exception exception) + { + activity?.SetTag("error.type", exception.GetType().FullName); + activity?.SetStatus(ActivityStatusCode.Error, exception.Message); + } + } +} \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs new file mode 100644 index 00000000000..306dad73864 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; +using Aspire.Tests; +using Dapper; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace Aspire.Dashboard.Tests.Model; + +public sealed class DashboardSqliteDatabaseTests : IDisposable +{ + private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-sqlite-tests-").FullName; + + [Fact] + public async Task DapperQuery_CreatesActivityWithQueryInformation() + { + using var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "dashboard.db"), pooling: false); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue); + using var connection = database.OpenConnection(); + var query = $"-- {Guid.NewGuid():N}{Environment.NewLine}SELECT 42;"; + + var result = await connection.QuerySingleAsync(query); + + Assert.Equal(42, result); + var activity = Assert.Single(activities, activity => Equals(activity.GetTagItem("db.query.text"), query)); + Assert.Equal(TracingSqliteConnection.ActivitySourceName, activity.Source.Name); + Assert.Equal("SELECT sqlite", activity.OperationName); + Assert.Equal(ActivityKind.Client, activity.Kind); + Assert.Equal("sqlite", activity.GetTagItem("db.system.name")); + Assert.Equal("dashboard.db", activity.GetTagItem("db.namespace")); + Assert.Equal("SELECT", activity.GetTagItem("db.operation.name")); + Assert.Equal(ActivityStatusCode.Unset, activity.Status); + } + + [Fact] + public void DapperFailure_SetsActivityErrorInformation() + { + using var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "dashboard.db"), pooling: false); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue); + using var connection = database.OpenConnection(); + var query = $"SELECT * FROM missing_{Guid.NewGuid():N};"; + + var exception = Assert.Throws(() => connection.Query(query)); + + var activity = Assert.Single(activities, activity => Equals(activity.GetTagItem("db.query.text"), query)); + Assert.Equal(ActivityStatusCode.Error, activity.Status); + Assert.Equal(exception.Message, activity.StatusDescription); + Assert.Equal(typeof(SqliteException).FullName, activity.GetTagItem("error.type")); + } + + [Fact] + public void ActivityListener_DoesNotCaptureOtherDatabaseActivities() + { + using var observedDatabase = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "observed.db"), pooling: false); + using var otherDatabase = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "other.db"), pooling: false); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(observedDatabase.ActivitySource, onActivityStopped: activities.Enqueue); + using var observedConnection = observedDatabase.OpenConnection(); + using var otherConnection = otherDatabase.OpenConnection(); + + observedConnection.QuerySingle("SELECT 1;"); + otherConnection.QuerySingle("SELECT 2;"); + + Assert.Single(activities, activity => Equals(activity.GetTagItem("db.query.text"), "SELECT 1;")); + Assert.DoesNotContain(activities, activity => Equals(activity.GetTagItem("db.query.text"), "SELECT 2;")); + } + + public void Dispose() + { + DashboardSqliteDatabase.ClearPools(); + Directory.Delete(_temporaryDirectory, recursive: true); + } +} \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index ccc512f83cf..b0c47307621 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -1,11 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; using System.Runtime.InteropServices; using System.Text; +using System.Diagnostics; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.MetricValues; using Aspire.Dashboard.Otlp.Storage; +using Aspire.Tests; using Google.Protobuf; using Google.Protobuf.Collections; using OpenTelemetry.Proto.Common.V1; @@ -1286,5 +1289,183 @@ public sealed class InMemoryMetricsTests : MetricsTests public sealed class SqliteMetricsTests : MetricsTests { + private static readonly DateTime s_queryTestTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + protected override bool UseSqlite => true; + + [Fact] + public void AddMetrics_ReusesInstrumentAndDimensionLookupsWithinBatch() + { + var repository = Assert.IsType(CreateRepository()); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); + using var parent = new Activity("metric ingestion test").Start(); + + var context = new AddContext(); + repository.AsWriter().AddMetrics(context, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(1), value: 1), + CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(2), value: 2), + CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(3), value: 2) + } + } + } + } + }); + + var queries = activities + .Where(activity => activity.ParentSpanId == parent.SpanId) + .Select(activity => (string)activity.GetTagItem("db.query.text")!) + .ToList(); + Assert.Single(queries, query => query.Contains("SELECT instrument_id", StringComparison.Ordinal)); + Assert.Single(queries, query => query.Contains("FROM telemetry_metric_dimensions d", StringComparison.Ordinal)); + Assert.Single(queries, query => query.StartsWith("DELETE FROM telemetry_metric_points", StringComparison.Ordinal)); + var insertQuery = Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_points", StringComparison.Ordinal)); + Assert.Equal(2, insertQuery.Split("INSERT INTO telemetry_metric_points", StringSplitOptions.None).Length - 1); + Assert.Equal(3, context.SuccessCount); + } + + [Fact] + public void AddMetrics_BatchesHistogramChildrenAndDimensionTrimming() + { + var repository = Assert.IsType(CreateRepository()); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); + using var parent = new Activity("metric ingestion test").Start(); + + var context = new AddContext(); + repository.AsWriter().AddMetrics(context, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateHistogramMetric(metricName: "histogram", startTime: s_queryTestTime.AddMinutes(1)), + CreateSumMetric(metricName: "sum", startTime: s_queryTestTime.AddMinutes(1), attributes: [KeyValuePair.Create("series", "one")]), + CreateSumMetric(metricName: "sum", startTime: s_queryTestTime.AddMinutes(1), attributes: [KeyValuePair.Create("series", "two")]) + } + } + } + } + }); + + var queries = activities + .Where(activity => activity.ParentSpanId == parent.SpanId) + .Select(activity => (string)activity.GetTagItem("db.query.text")!) + .ToList(); + Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_histogram_bucket_counts", StringComparison.Ordinal)); + Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_histogram_explicit_bounds", StringComparison.Ordinal)); + Assert.Single(queries, query => query.StartsWith("DELETE FROM telemetry_metric_points", StringComparison.Ordinal)); + Assert.Equal(3, context.SuccessCount); + Assert.Equal(0, context.FailureCount); + } + + [Fact] + public void AddMetrics_UpdateOnlyBatch_DoesNotTrimMetricPoints() + { + var repository = Assert.IsType(CreateRepository()); + repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField + { + CreateResourceMetrics(CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(1), value: 1)) + }); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); + using var parent = new Activity("metric update test").Start(); + + repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(2), value: 1), + CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(3), value: 1), + CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(4), value: 1) + } + } + } + } + }); + + var queries = activities + .Where(activity => activity.ParentSpanId == parent.SpanId) + .Select(activity => (string)activity.GetTagItem("db.query.text")!) + .ToList(); + Assert.DoesNotContain(queries, query => query.Contains("SELECT resource_id", StringComparison.Ordinal)); + Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_scopes", StringComparison.Ordinal)); + Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_scope_attributes", StringComparison.Ordinal)); + Assert.DoesNotContain(queries, query => query.Contains("SELECT instrument_id", StringComparison.Ordinal)); + Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_metric_dimensions d", StringComparison.Ordinal)); + Assert.DoesNotContain(queries, query => query.StartsWith("DELETE FROM telemetry_metric_points", StringComparison.Ordinal)); + var updateQuery = Assert.Single(queries, query => query.Contains("UPDATE telemetry_metric_points", StringComparison.Ordinal)); + Assert.Contains("WITH updates", updateQuery, StringComparison.Ordinal); + var instrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = CreateResource().GetResourceKey(), + MeterName = "test-meter", + InstrumentName = "test", + StartTime = DateTime.MinValue, + EndTime = DateTime.MaxValue + }); + var value = Assert.IsType>(Assert.Single(Assert.Single(instrument!.Dimensions).Values)); + Assert.Equal(4UL, value.Count); + Assert.Equal(s_queryTestTime.AddMinutes(4), value.End); + } + + [Fact] + public void ClearMetrics_InvalidatesMetricIngestionCache() + { + var repository = CreateRepository(); + repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField + { + CreateResourceMetrics(CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(1), value: 1)) + }); + + repository.AsWriter().ClearMetrics(); + + var context = new AddContext(); + repository.AsWriter().AddMetrics(context, new RepeatedField + { + CreateResourceMetrics(CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(2), value: 2)) + }); + + Assert.Equal(1, context.SuccessCount); + Assert.Equal(0, context.FailureCount); + Assert.Single(repository.GetInstrumentsSummaries(CreateResource().GetResourceKey())); + } + + private static ResourceMetrics CreateResourceMetrics(Metric metric) => new() + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = { metric } + } + } + }; } From 3106ab5db4c07cc908840bea7d43350d7b5b6c24 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sat, 18 Jul 2026 07:47:23 +0800 Subject: [PATCH 30/87] Optimize dashboard telemetry storage and self-tracing --- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 1 + .../DashboardWebApplication.cs | 2 + .../DashboardSqliteOutgoingPeerResolver.cs | 33 + .../Otlp/Model/OtlpResource.cs | 11 +- .../SqliteTelemetryRepository.Caching.cs | 732 ++++++++++++++++++ .../Storage/SqliteTelemetryRepository.Logs.cs | 475 ++++-------- .../SqliteTelemetryRepository.Metrics.cs | 558 ++++++------- .../SqliteTelemetryRepository.Runtime.cs | 2 - .../SqliteTelemetryRepository.Traces.cs | 644 ++++++++++----- .../Otlp/Storage/SqliteTelemetryRepository.cs | 2 +- .../ServiceClient/DashboardRunStore.cs | 9 +- .../DatabaseSchema/005.Metrics.sql | 4 +- .../ServiceClient/TracingSqliteConnection.cs | 2 + ...ashboardSqliteOutgoingPeerResolverTests.cs | 48 ++ .../Integration/HealthTests.cs | 41 +- .../Model/DashboardSqliteDatabaseTests.cs | 4 +- .../TelemetryRepositoryTests/LogTests.cs | 102 +++ .../TelemetryRepositoryTests/MetricsTests.cs | 69 +- .../SqliteTelemetryPersistenceTests.cs | 99 +++ .../TelemetryRepositoryTests/TraceTests.cs | 94 ++- 20 files changed, 2130 insertions(+), 802 deletions(-) create mode 100644 src/Aspire.Dashboard/Model/DashboardSqliteOutgoingPeerResolver.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs create mode 100644 tests/Aspire.Dashboard.Tests/DashboardSqliteOutgoingPeerResolverTests.cs diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index 923ac76fa9a..cf07f509766 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -67,6 +67,7 @@ + diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 1526066eca9..ff0ada4c4f7 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -304,6 +304,7 @@ public DashboardWebApplication( { builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() .AddSource(TracingSqliteConnection.ActivitySourceName) .AddOtlpExporter()); } @@ -348,6 +349,7 @@ public DashboardWebApplication( builder.Services.AddTransient(); builder.Services.AddSingleton(services => new ResourceOutgoingPeerResolver(services.GetRequiredService())); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.AddFluentUIComponents(); diff --git a/src/Aspire.Dashboard/Model/DashboardSqliteOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/DashboardSqliteOutgoingPeerResolver.cs new file mode 100644 index 00000000000..b8b9e2bb869 --- /dev/null +++ b/src/Aspire.Dashboard/Model/DashboardSqliteOutgoingPeerResolver.cs @@ -0,0 +1,33 @@ +// 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; + +internal sealed class DashboardSqliteOutgoingPeerResolver : IOutgoingPeerResolver +{ + private const string SqliteSystemName = "sqlite"; + + public bool TryResolvePeer(KeyValuePair[] attributes, out string? name, out ResourceViewModel? matchedResource) + { + var isDashboardSqlite = string.Equals(attributes.GetValue(OtlpSpan.PeerServiceAttributeKey), DashboardRunStore.DatabaseFileName, StringComparison.Ordinal) && + string.Equals(attributes.GetValue("db.system.name"), SqliteSystemName, StringComparison.Ordinal) && + string.Equals(attributes.GetValue("db.namespace"), DashboardRunStore.DatabaseFileName, StringComparison.Ordinal); + + name = isDashboardSqlite ? DashboardRunStore.DatabaseFileName : null; + matchedResource = null; + return isDashboardSqlite; + } + + public IDisposable OnPeerChanges(Func callback) => NullDisposable.Instance; + + private sealed class NullDisposable : IDisposable + { + public static NullDisposable Instance { get; } = new(); + + public void Dispose() + { + } + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs index 920aa6248ad..e5a00887566 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs @@ -318,9 +318,18 @@ internal void ConfigureDataProviders( } internal OtlpResourceView GetView(RepeatedField attributes) + { + return GetView(new OtlpResourceView(this, attributes)); + } + + internal OtlpResourceView GetViewFromProperties(KeyValuePair[] properties) + { + return GetView(new OtlpResourceView(this, properties)); + } + + private OtlpResourceView GetView(OtlpResourceView view) { // Inefficient to create this to possibly throw it away. - var view = new OtlpResourceView(this, attributes); if (_resourceViews.TryGetValue(view.Properties, out var resourceView)) { diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs new file mode 100644 index 00000000000..4c994d25150 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs @@ -0,0 +1,732 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data; +using System.Globalization; +using System.Text; +using Aspire.Dashboard.Otlp.Model; +using Dapper; +using Google.Protobuf.Collections; +using Microsoft.Data.Sqlite; +using OpenTelemetry.Proto.Common.V1; +using OpenTelemetry.Proto.Metrics.V1; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed partial class SqliteTelemetryRepository +{ + private readonly object _cacheLock = new(); + private readonly Dictionary _cachedResourcesByKey = []; + private readonly Dictionary _cachedResourcesById = []; + private readonly Dictionary _cachedScopesByName = new(StringComparer.Ordinal); + private readonly Dictionary _cachedScopesById = []; + private readonly Dictionary _cachedInstrumentsById = []; + private bool _cachePopulated; + + private CachedResource GetOrAddCachedResource( + SqliteConnection connection, + IDbTransaction transaction, + ResourceKey resourceKey, + bool uninstrumentedPeer = false) + { + lock (_cacheLock) + { + if (_cachedResourcesByKey.TryGetValue(resourceKey, out var cachedResource)) + { + if (cachedResource.Resource.UninstrumentedPeer && !uninstrumentedPeer) + { + connection.Execute( + "UPDATE telemetry_resources SET uninstrumented_peer = 0 WHERE resource_id = @ResourceId;", + new { cachedResource.ResourceId }, + transaction); + } + cachedResource.Resource.SetUninstrumentedPeer(uninstrumentedPeer); + return cachedResource; + } + + var record = connection.QuerySingleOrDefault(""" + SELECT + resource_id AS ResourceId, + resource_name AS ResourceName, + instance_id AS InstanceId, + uninstrumented_peer AS UninstrumentedPeer, + has_logs AS HasLogs, + has_traces AS HasTraces, + has_metrics AS HasMetrics + FROM telemetry_resources + WHERE resource_name = @ResourceName + AND instance_id IS @InstanceId; + """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId }, transaction); + if (record is not null) + { + cachedResource = GetOrAddCachedResource(record); + if (cachedResource.Resource.UninstrumentedPeer && !uninstrumentedPeer) + { + connection.Execute( + "UPDATE telemetry_resources SET uninstrumented_peer = 0 WHERE resource_id = @ResourceId;", + new { cachedResource.ResourceId }, + transaction); + } + cachedResource.Resource.SetUninstrumentedPeer(uninstrumentedPeer); + return cachedResource; + } + + var resourceCount = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_resources;", transaction: transaction); + if (resourceCount >= _otlpContext.Options.MaxResourceCount) + { + throw new InvalidOperationException($"Resource limit of {_otlpContext.Options.MaxResourceCount} reached. Resource '{resourceKey}' will not be added."); + } + + var resourceId = connection.QuerySingle(""" + INSERT INTO telemetry_resources (resource_name, instance_id) + VALUES (@ResourceName, @InstanceId) + RETURNING resource_id; + """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId }, transaction); + return CreateCachedResource(resourceId, resourceKey, uninstrumentedPeer); + } + } + + private CachedResourceView GetOrAddCachedResourceView( + SqliteConnection connection, + IDbTransaction transaction, + CachedResource resource, + RepeatedField attributes) + { + var incomingView = new OtlpResourceView(resource.Resource, attributes); + lock (_cacheLock) + { + if (resource.ViewsByProperties.TryGetValue(incomingView.Properties, out var cachedView)) + { + return cachedView; + } + + var parameters = new DynamicParameters(); + parameters.Add("ResourceId", resource.ResourceId); + parameters.Add("PropertyCount", incomingView.Properties.Length); + var sql = new StringBuilder(""" + SELECT v.resource_view_id + FROM telemetry_resource_views v + WHERE v.resource_id = @ResourceId + AND (SELECT COUNT(*) FROM telemetry_resource_view_attributes a WHERE a.resource_view_id = v.resource_view_id) = @PropertyCount + """); + for (var index = 0; index < incomingView.Properties.Length; index++) + { + parameters.Add($"PropertyKey{index}", incomingView.Properties[index].Key); + parameters.Add($"PropertyValue{index}", incomingView.Properties[index].Value); + sql.Append(CultureInfo.InvariantCulture, $""" + + AND EXISTS ( + SELECT 1 + FROM telemetry_resource_view_attributes a + WHERE a.resource_view_id = v.resource_view_id + AND a.ordinal = {index} + AND a.attribute_key = @PropertyKey{index} + AND a.attribute_value = @PropertyValue{index} + ) + """); + } + sql.Append(" LIMIT 1;"); + + var resourceViewId = connection.QuerySingleOrDefault(sql.ToString(), parameters, transaction); + if (resourceViewId is null) + { + var resourceViewCount = connection.QuerySingle( + "SELECT COUNT(*) FROM telemetry_resource_views WHERE resource_id = @ResourceId;", + new { resource.ResourceId }, + transaction); + if (resourceViewCount >= TelemetryRepositoryLimits.MaxResourceViewCount) + { + throw new InvalidOperationException($"Resource view limit of {TelemetryRepositoryLimits.MaxResourceViewCount} reached."); + } + + resourceViewId = connection.QuerySingle(""" + INSERT INTO telemetry_resource_views (resource_id) + VALUES (@ResourceId) + RETURNING resource_view_id; + """, new { resource.ResourceId }, transaction); + connection.Execute(""" + INSERT INTO telemetry_resource_view_attributes (resource_view_id, ordinal, attribute_key, attribute_value) + VALUES (@ResourceViewId, @Ordinal, @Key, @Value); + """, incomingView.Properties.Select((property, ordinal) => new + { + ResourceViewId = resourceViewId.Value, + Ordinal = ordinal, + property.Key, + property.Value + }), transaction); + } + + return AddCachedResourceView(resource, resourceViewId.Value, incomingView.Properties); + } + } + + private CachedResourceScope GetOrAddCachedScope( + SqliteConnection connection, + IDbTransaction transaction, + CachedResource resource, + InstrumentationScope? instrumentationScope, + CachedTelemetryType telemetryType) + { + var incomingScope = instrumentationScope is null + ? OtlpScope.Empty + : new OtlpScope( + instrumentationScope.Name, + instrumentationScope.Version, + instrumentationScope.Attributes.ToKeyValuePairs(_otlpContext)); + lock (_cacheLock) + { + if (resource.Scopes.TryGetValue(incomingScope.Name, out var resourceScope)) + { + resourceScope.TelemetryTypes |= telemetryType; + return resourceScope; + } + + if (!_cachedScopesByName.TryGetValue(incomingScope.Name, out var cachedScope)) + { + var existing = connection.QuerySingleOrDefault(""" + SELECT scope_id AS ScopeId, scope_name AS ScopeName, scope_version AS ScopeVersion + FROM telemetry_scopes + WHERE scope_name = @ScopeName; + """, new { ScopeName = incomingScope.Name }, transaction); + if (existing is not null) + { + var attributes = connection.Query(""" + SELECT attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_scope_attributes + WHERE scope_id = @ScopeId + ORDER BY ordinal; + """, new { existing.ScopeId }, transaction) + .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)) + .ToArray(); + cachedScope = GetOrAddCachedScope(existing.ScopeId, existing.ScopeName, existing.ScopeVersion, attributes); + } + else + { + var scopeCount = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_scopes;", transaction: transaction); + if (scopeCount >= TelemetryRepositoryLimits.MaxScopeCount) + { + throw new InvalidOperationException($"Scope limit of {TelemetryRepositoryLimits.MaxScopeCount} reached. Scope '{incomingScope.Name}' will not be added."); + } + + var scopeId = connection.QuerySingle(""" + INSERT INTO telemetry_scopes (scope_name, scope_version) + VALUES (@ScopeName, @ScopeVersion) + RETURNING scope_id; + """, new { ScopeName = incomingScope.Name, ScopeVersion = incomingScope.Version }, transaction); + connection.Execute(""" + INSERT INTO telemetry_scope_attributes (scope_id, ordinal, attribute_key, attribute_value) + VALUES (@ScopeId, @Ordinal, @Key, @Value); + """, incomingScope.Attributes.Select((attribute, ordinal) => new + { + ScopeId = scopeId, + Ordinal = ordinal, + attribute.Key, + attribute.Value + }), transaction); + cachedScope = GetOrAddCachedScope(scopeId, incomingScope.Name, incomingScope.Version, incomingScope.Attributes); + } + } + + return AddCachedScope(resource, cachedScope, telemetryType); + } + } + + private CachedInstrument GetOrAddCachedInstrument( + SqliteConnection connection, + IDbTransaction transaction, + CachedResource resource, + CachedResourceScope resourceScope, + Metric metric) + { + if (string.IsNullOrEmpty(metric.Name)) + { + throw new InvalidOperationException("Instrument name is required."); + } + + lock (_cacheLock) + { + if (resourceScope.Instruments.TryGetValue(metric.Name, out var instrument)) + { + return instrument; + } + + if (!resourceScope.InstrumentsLoaded) + { + var records = connection.Query(""" + SELECT instrument_id AS InstrumentId, + resource_id AS ResourceId, + scope_id AS ScopeId, + instrument_name AS InstrumentName, + description AS Description, + unit AS Unit, + instrument_type AS InstrumentType, + aggregation_temporality AS AggregationTemporality, + has_overflow AS HasOverflow + FROM telemetry_metric_instruments + WHERE resource_id = @ResourceId AND scope_id = @ScopeId; + """, new + { + resource.ResourceId, + ScopeId = resourceScope.Scope.ScopeId + }, transaction); + foreach (var loadedRecord in records) + { + AddCachedInstrument(resourceScope, loadedRecord); + } + resourceScope.InstrumentsLoaded = true; + if (resourceScope.Instruments.TryGetValue(metric.Name, out instrument)) + { + return instrument; + } + } + + var instrumentCount = resource.InstrumentCount ??= connection.QuerySingle( + "SELECT COUNT(*) FROM telemetry_metric_instruments WHERE resource_id = @ResourceId;", + new { resource.ResourceId }, + transaction); + if (instrumentCount >= TelemetryRepositoryLimits.MaxInstrumentCount) + { + throw new InvalidOperationException($"Instrument limit of {TelemetryRepositoryLimits.MaxInstrumentCount} reached. Instrument '{metric.Name}' will not be added."); + } + + var instrumentId = connection.QuerySingle(""" + INSERT INTO telemetry_metric_instruments ( + resource_id, scope_id, instrument_name, description, unit, instrument_type, + aggregation_temporality, is_monotonic) + VALUES ( + @ResourceId, @ScopeId, @InstrumentName, @Description, @Unit, @InstrumentType, + @AggregationTemporality, @IsMonotonic) + RETURNING instrument_id; + """, new + { + resource.ResourceId, + ScopeId = resourceScope.Scope.ScopeId, + InstrumentName = metric.Name, + metric.Description, + metric.Unit, + InstrumentType = (int)MapMetricType(metric.DataCase), + AggregationTemporality = (int)MapAggregationTemporality(metric), + IsMonotonic = metric.DataCase == Metric.DataOneofCase.Sum && metric.Sum.IsMonotonic + }, transaction); + var record = new CachedInstrumentRecord + { + InstrumentId = instrumentId, + ResourceId = resource.ResourceId, + ScopeId = resourceScope.Scope.ScopeId, + InstrumentName = metric.Name, + Description = metric.Description, + Unit = metric.Unit, + InstrumentType = (int)MapMetricType(metric.DataCase), + AggregationTemporality = (int)MapAggregationTemporality(metric), + HasOverflow = false + }; + resource.InstrumentCount++; + return AddCachedInstrument(resourceScope, record); + } + } + + private void ClearIngestionCaches() + { + ClearMetadataCache(); + _metricIngestionState.Clear(); + } + + private void EnsureCachePopulated() + { + lock (_writeLock) + { + lock (_cacheLock) + { + if (_cachePopulated) + { + return; + } + + using var connection = _database.OpenConnection(); + using var reader = connection.QueryMultiple(""" + SELECT + resource_id AS ResourceId, + resource_name AS ResourceName, + instance_id AS InstanceId, + uninstrumented_peer AS UninstrumentedPeer, + has_logs AS HasLogs, + has_traces AS HasTraces, + has_metrics AS HasMetrics + FROM telemetry_resources; + + SELECT + v.resource_view_id AS ResourceViewId, + v.resource_id AS ResourceId, + a.attribute_key AS AttributeKey, + a.attribute_value AS AttributeValue + FROM telemetry_resource_views v + LEFT JOIN telemetry_resource_view_attributes a ON a.resource_view_id = v.resource_view_id + ORDER BY v.resource_view_id, a.ordinal; + + SELECT DISTINCT + u.resource_id AS ResourceId, + u.scope_id AS ScopeId, + u.telemetry_type AS TelemetryType, + s.scope_name AS ScopeName, + s.scope_version AS ScopeVersion + FROM ( + SELECT resource_id, scope_id, 1 AS telemetry_type FROM telemetry_logs + UNION ALL + SELECT resource_id, scope_id, 2 AS telemetry_type FROM telemetry_spans + UNION ALL + SELECT resource_id, scope_id, 4 AS telemetry_type FROM telemetry_metric_instruments + ) u + JOIN telemetry_scopes s ON s.scope_id = u.scope_id; + + SELECT + scope_id AS ScopeId, + attribute_key AS AttributeKey, + attribute_value AS AttributeValue + FROM telemetry_scope_attributes + ORDER BY scope_id, ordinal; + + SELECT + instrument_id AS InstrumentId, + resource_id AS ResourceId, + scope_id AS ScopeId, + instrument_name AS InstrumentName, + description AS Description, + unit AS Unit, + instrument_type AS InstrumentType, + aggregation_temporality AS AggregationTemporality, + has_overflow AS HasOverflow + FROM telemetry_metric_instruments + ORDER BY instrument_id; + """); + + var resources = reader.Read().AsList(); + var resourceViews = reader.Read().AsList(); + var scopeUsages = reader.Read().AsList(); + var scopeAttributes = reader.Read().ToLookup(record => record.ScopeId); + var instruments = reader.Read().AsList(); + + foreach (var record in resources) + { + GetOrAddCachedResource(record); + } + + foreach (var group in resourceViews.GroupBy(record => (record.ResourceId, record.ResourceViewId))) + { + if (_cachedResourcesById.TryGetValue(group.Key.ResourceId, out var resource)) + { + var properties = group + .Where(record => record.AttributeKey is not null) + .Select(record => KeyValuePair.Create(record.AttributeKey!, record.AttributeValue!)) + .ToArray(); + AddCachedResourceView(resource, group.Key.ResourceViewId, properties); + } + } + + foreach (var record in scopeUsages) + { + if (_cachedResourcesById.TryGetValue(record.ResourceId, out var resource)) + { + var scope = GetOrAddCachedScope( + record.ScopeId, + record.ScopeName, + record.ScopeVersion, + scopeAttributes[record.ScopeId] + .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)) + .ToArray()); + AddCachedScope(resource, scope, (CachedTelemetryType)record.TelemetryType); + } + } + + foreach (var record in instruments) + { + if (_cachedResourcesById.TryGetValue(record.ResourceId, out var resource) && + _cachedScopesById.TryGetValue(record.ScopeId, out var scope)) + { + var resourceScope = AddCachedScope(resource, scope, CachedTelemetryType.Metrics); + AddCachedInstrument(resourceScope, record); + } + } + + foreach (var resource in _cachedResourcesById.Values) + { + resource.InstrumentCount = resource.Scopes.Values.Sum(scope => scope.Instruments.Count); + foreach (var resourceScope in resource.Scopes.Values.Where(scope => scope.TelemetryTypes.HasFlag(CachedTelemetryType.Metrics))) + { + resourceScope.InstrumentsLoaded = true; + } + } + + _cachePopulated = true; + } + } + } + + private CachedResource GetOrAddCachedResource(CachedResourceRecord record) + { + var key = new ResourceKey(record.ResourceName, record.InstanceId); + if (!_cachedResourcesByKey.TryGetValue(key, out var cachedResource)) + { + cachedResource = CreateCachedResource(record.ResourceId, key, record.UninstrumentedPeer); + } + + cachedResource.Resource.SetUninstrumentedPeer(record.UninstrumentedPeer); + cachedResource.Resource.HasLogs = record.HasLogs; + cachedResource.Resource.HasTraces = record.HasTraces; + cachedResource.Resource.HasMetrics = record.HasMetrics; + return cachedResource; + } + + private CachedResource CreateCachedResource(long resourceId, ResourceKey key, bool uninstrumentedPeer) + { + var resource = new OtlpResource(key.Name, key.InstanceId, uninstrumentedPeer, _otlpContext); + resource.ConfigureDataProviders( + (meterName, instrumentName, startTime, endTime) => GetResourceInstrumentFromDatabase(key, meterName, instrumentName, startTime, endTime), + () => GetCachedInstrumentSummaries(key), + () => GetCachedResourceViews(key)); + var cachedResource = new CachedResource(resourceId, resource); + _cachedResourcesByKey.Add(key, cachedResource); + _cachedResourcesById.Add(resourceId, cachedResource); + return cachedResource; + } + + private static CachedResourceView AddCachedResourceView(CachedResource resource, long resourceViewId, KeyValuePair[] properties) + { + if (resource.ViewsById.TryGetValue(resourceViewId, out var cachedView)) + { + return cachedView; + } + + var view = resource.Resource.GetViewFromProperties(properties); + cachedView = new CachedResourceView(resourceViewId, view); + resource.ViewsById.Add(resourceViewId, cachedView); + resource.ViewsByProperties[view.Properties] = cachedView; + return cachedView; + } + + private CachedScope GetOrAddCachedScope(long scopeId, string scopeName, string scopeVersion, KeyValuePair[] attributes) + { + if (_cachedScopesById.TryGetValue(scopeId, out var cachedScope)) + { + return cachedScope; + } + + var scope = CreateScope(scopeName, scopeVersion, attributes); + cachedScope = new CachedScope(scopeId, scope); + _cachedScopesById.Add(scopeId, cachedScope); + _cachedScopesByName[scope.Name] = cachedScope; + return cachedScope; + } + + private static CachedResourceScope AddCachedScope(CachedResource resource, CachedScope scope, CachedTelemetryType telemetryType) + { + if (!resource.Scopes.TryGetValue(scope.Scope.Name, out var resourceScope)) + { + resourceScope = new CachedResourceScope(scope); + resource.Scopes.Add(scope.Scope.Name, resourceScope); + } + resourceScope.TelemetryTypes |= telemetryType; + return resourceScope; + } + + private CachedInstrument AddCachedInstrument(CachedResourceScope resourceScope, CachedInstrumentRecord record) + { + if (!resourceScope.Instruments.TryGetValue(record.InstrumentName, out var instrument)) + { + instrument = new CachedInstrument( + record.InstrumentId, + new OtlpInstrumentSummary + { + Name = record.InstrumentName, + Description = record.Description, + Unit = record.Unit, + Type = (OtlpInstrumentType)record.InstrumentType, + AggregationTemporality = (OtlpAggregationTemporality)record.AggregationTemporality, + Parent = resourceScope.Scope.Scope + }, + record.HasOverflow); + resourceScope.Instruments.Add(record.InstrumentName, instrument); + _cachedInstrumentsById.Add(record.InstrumentId, instrument); + } + return instrument; + } + + private List GetCachedResourceViews(ResourceKey key) + { + EnsureCachePopulated(); + lock (_cacheLock) + { + return _cachedResourcesByKey.TryGetValue(key, out var resource) + ? resource.ViewsById.Values.Select(view => view.View).ToList() + : []; + } + } + + private List GetCachedInstrumentSummaries(ResourceKey key) + { + EnsureCachePopulated(); + lock (_cacheLock) + { + return GetCachedResources(key) + .SelectMany(resource => resource.Scopes.Values) + .SelectMany(scope => scope.Instruments.Values) + .Select(instrument => instrument.Summary) + .DistinctBy(summary => summary.GetKey()) + .ToList(); + } + } + + private List GetCachedInstruments(ResourceKey resourceKey, string meterName, string instrumentName) + { + EnsureCachePopulated(); + lock (_cacheLock) + { + return GetCachedResources(resourceKey) + .SelectMany(resource => resource.Scopes.Values) + .Where(scope => string.Equals(scope.Scope.Scope.Name, meterName, StringComparison.Ordinal)) + .SelectMany(scope => scope.Instruments.Values) + .Where(instrument => string.Equals(instrument.Summary.Name, instrumentName, StringComparison.Ordinal)) + .ToList(); + } + } + + private void MarkCachedInstrumentHasOverflow(long instrumentId) + { + lock (_cacheLock) + { + if (_cachedInstrumentsById.TryGetValue(instrumentId, out var instrument)) + { + instrument.HasOverflow = true; + } + } + } + + private (OtlpResource Resource, OtlpResourceView View, OtlpScope Scope) GetCachedTelemetryMetadata( + long resourceId, + long resourceViewId, + long scopeId, + CachedTelemetryType telemetryType) + { + EnsureCachePopulated(); + lock (_cacheLock) + { + var resource = _cachedResourcesById[resourceId]; + var view = resource.ViewsById[resourceViewId]; + var scope = _cachedScopesById[scopeId]; + AddCachedScope(resource, scope, telemetryType); + return (resource.Resource, view.View, scope.Scope); + } + } + + private OtlpResource? GetCachedResource(long resourceId) + { + EnsureCachePopulated(); + lock (_cacheLock) + { + return _cachedResourcesById.TryGetValue(resourceId, out var resource) ? resource.Resource : null; + } + } + + private IEnumerable GetCachedResources(ResourceKey key) + { + return key.InstanceId is null + ? _cachedResourcesByKey.Values.Where(resource => string.Equals(resource.Resource.ResourceName, key.Name, StringComparisons.ResourceName)) + : _cachedResourcesByKey.TryGetValue(key, out var resource) ? [resource] : []; + } + + private void ClearMetadataCache() + { + lock (_cacheLock) + { + _cachedResourcesByKey.Clear(); + _cachedResourcesById.Clear(); + _cachedScopesByName.Clear(); + _cachedScopesById.Clear(); + _cachedInstrumentsById.Clear(); + _cachePopulated = false; + } + } + + private sealed class CachedResource(long resourceId, OtlpResource resource) + { + public long ResourceId { get; } = resourceId; + public OtlpResource Resource { get; } = resource; + public Dictionary ViewsById { get; } = []; + public Dictionary[], CachedResourceView> ViewsByProperties { get; } = new(ResourceViewPropertiesComparer.Instance); + public Dictionary Scopes { get; } = new(StringComparer.Ordinal); + public int? InstrumentCount { get; set; } + } + + private sealed record CachedResourceView(long ResourceViewId, OtlpResourceView View); + private sealed record CachedScope(long ScopeId, OtlpScope Scope); + + private sealed class CachedResourceScope(CachedScope scope) + { + public CachedScope Scope { get; } = scope; + public CachedTelemetryType TelemetryTypes { get; set; } + public Dictionary Instruments { get; } = new(StringComparer.Ordinal); + public bool InstrumentsLoaded { get; set; } + } + + private sealed class CachedInstrument(long instrumentId, OtlpInstrumentSummary summary, bool hasOverflow) + { + public long InstrumentId { get; } = instrumentId; + public OtlpInstrumentSummary Summary { get; } = summary; + public bool HasOverflow { get; set; } = hasOverflow; + } + + [Flags] + private enum CachedTelemetryType + { + Logs = 1, + Traces = 2, + Metrics = 4 + } + + private sealed class CachedResourceRecord + { + public required long ResourceId { get; init; } + public required string ResourceName { get; init; } + public string? InstanceId { get; init; } + public required bool UninstrumentedPeer { get; init; } + public required bool HasLogs { get; init; } + public required bool HasTraces { get; init; } + public required bool HasMetrics { get; init; } + } + + private sealed class CachedResourceViewRecord + { + public required long ResourceViewId { get; init; } + public required long ResourceId { get; init; } + public string? AttributeKey { get; init; } + public string? AttributeValue { get; init; } + } + + private sealed class CachedScopeUsageRecord + { + public required long ResourceId { get; init; } + public required long ScopeId { get; init; } + public required int TelemetryType { get; init; } + public required string ScopeName { get; init; } + public required string ScopeVersion { get; init; } + } + + private sealed class CachedScopeAttributeRecord + { + public required long ScopeId { get; init; } + public required string AttributeKey { get; init; } + public required string AttributeValue { get; init; } + } + + private sealed class CachedInstrumentRecord + { + public required long InstrumentId { get; init; } + public required long ResourceId { get; init; } + public required long ScopeId { get; init; } + public required string InstrumentName { get; init; } + public required string Description { get; init; } + public required string Unit { get; init; } + public required int InstrumentType { get; init; } + public required int AggregationTemporality { get; init; } + public required bool HasOverflow { get; init; } + } +} diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs index 44f60a88807..995fe4ac318 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs @@ -10,15 +10,14 @@ using Dapper; using Google.Protobuf.Collections; using Microsoft.Data.Sqlite; -using OpenTelemetry.Proto.Common.V1; using OpenTelemetry.Proto.Logs.V1; namespace Aspire.Dashboard.Otlp.Storage; public sealed partial class SqliteTelemetryRepository { - private readonly Dictionary _resourceIdsByKey = []; - private readonly Dictionary _scopesByName = new(StringComparer.Ordinal); + private const int MaxLogBatchSize = 50; + private const int MaxLogAttributeBatchSize = 200; private List AddLogsToDatabase(AddContext context, RepeatedField resourceLogs) { @@ -27,22 +26,21 @@ private List AddLogsToDatabase(AddContext context, RepeatedField(); + var resourcesWithLogs = new HashSet(); foreach (var resourceLogsItem in resourceLogs) { + CachedResource cachedResource; OtlpResourceView resourceView; - long resourceId; long resourceViewId; try { var resourceKey = resourceLogsItem.Resource.GetResourceKey(); - resourceId = GetOrAddTelemetryResource(connection, transaction, resourceKey); - var resource = new OtlpResource(resourceKey.Name, resourceKey.InstanceId, uninstrumentedPeer: false, _otlpContext) - { - HasLogs = true - }; - resourceView = new OtlpResourceView(resource, resourceLogsItem.Resource.Attributes); - resourceViewId = GetOrAddResourceView(connection, transaction, resourceId, resourceView.Properties); + cachedResource = GetOrAddCachedResource(connection, transaction, resourceKey); + var cachedView = GetOrAddCachedResourceView(connection, transaction, cachedResource, resourceLogsItem.Resource.Attributes); + resourceView = cachedView.View; + resourceViewId = cachedView.ResourceViewId; } catch (Exception exception) { @@ -50,6 +48,7 @@ private List AddLogsToDatabase(AddContext context, RepeatedField AddLogsToDatabase(AddContext context, RepeatedField AddLogsToDatabase(AddContext context, RepeatedField(""" - INSERT INTO telemetry_logs ( - resource_id, resource_view_id, scope_id, timestamp_ticks, flags, severity, - severity_name, severity_number, message, span_id, trace_id, parent_id, - original_format, event_name) - VALUES ( - @ResourceId, @ResourceViewId, @ScopeId, @TimestampTicks, @Flags, @Severity, - @SeverityName, @SeverityNumber, @Message, @SpanId, @TraceId, @ParentId, - @OriginalFormat, @EventName) - RETURNING log_id; - """, new - { - ResourceId = resourceId, - ResourceViewId = resourceViewId, - ScopeId = scopeId, - TimestampTicks = log.TimeStamp.Ticks, - Flags = (long)log.Flags, - Severity = (int)log.Severity, - SeverityName = log.Severity.ToString(), - log.SeverityNumber, - log.Message, - log.SpanId, - log.TraceId, - log.ParentId, - log.OriginalFormat, - log.EventName - }, transaction); - - connection.Execute(""" - INSERT INTO telemetry_log_attributes (log_id, ordinal, attribute_key, attribute_value) - VALUES (@LogId, @Ordinal, @Key, @Value); - """, log.Attributes.Select((attribute, ordinal) => new - { - LogId = logId, - Ordinal = ordinal, - attribute.Key, - attribute.Value - }), transaction); - addedLogs.Add(new OtlpLogEntry( - logId, - log.TimeStamp, - log.Flags, - log.Severity, - log.SeverityNumber, - log.Message, - log.SpanId, - log.TraceId, - log.ParentId, - log.OriginalFormat, - resourceView, - scope, - log.Attributes, - log.EventName)); - context.SuccessCount++; + pendingLogs.Add(new PendingLog( + cachedResource.ResourceId, + resourceViewId, + scopeId, + new OtlpLogEntry(record, resourceView, scope, _otlpContext))); } catch (Exception exception) { @@ -133,13 +84,11 @@ INSERT INTO telemetry_log_attributes (log_id, ordinal, attribute_key, attribute_ } } } - - connection.Execute( - "UPDATE telemetry_resources SET has_logs = 1 WHERE resource_id = @ResourceId;", - new { ResourceId = resourceId }, - transaction); } + InsertLogs(connection, transaction, pendingLogs, addedLogs); + context.SuccessCount += pendingLogs.Count; + MarkResourcesHaveLogs(connection, transaction, resourcesWithLogs); TrimLogsToCapacity(connection, transaction); transaction.Commit(); } @@ -147,173 +96,124 @@ INSERT INTO telemetry_log_attributes (log_id, ordinal, attribute_key, attribute_ return addedLogs; } - private long GetOrAddTelemetryResource(SqliteConnection connection, IDbTransaction transaction, ResourceKey resourceKey) + private static void InsertLogs( + SqliteConnection connection, + IDbTransaction transaction, + List logs, + List addedLogs) { - if (_resourceIdsByKey.TryGetValue(resourceKey, out var cachedResourceId)) + foreach (var logBatch in logs.Chunk(MaxLogBatchSize)) { - return cachedResourceId; - } + var sql = new StringBuilder(); + var parameters = new DynamicParameters(); + for (var index = 0; index < logBatch.Length; index++) + { + var pendingLog = logBatch[index]; + var log = pendingLog.Log; + sql.Append(CultureInfo.InvariantCulture, $$""" + INSERT INTO telemetry_logs ( + resource_id, resource_view_id, scope_id, timestamp_ticks, flags, severity, + severity_name, severity_number, message, span_id, trace_id, parent_id, + original_format, event_name) + VALUES ( + @ResourceId{{index}}, @ResourceViewId{{index}}, @ScopeId{{index}}, @TimestampTicks{{index}}, @Flags{{index}}, @Severity{{index}}, + @SeverityName{{index}}, @SeverityNumber{{index}}, @Message{{index}}, @SpanId{{index}}, @TraceId{{index}}, @ParentId{{index}}, + @OriginalFormat{{index}}, @EventName{{index}}) + RETURNING log_id; + """); + parameters.Add($"ResourceId{index}", pendingLog.ResourceId); + parameters.Add($"ResourceViewId{index}", pendingLog.ResourceViewId); + parameters.Add($"ScopeId{index}", pendingLog.ScopeId); + parameters.Add($"TimestampTicks{index}", log.TimeStamp.Ticks); + parameters.Add($"Flags{index}", (long)log.Flags); + parameters.Add($"Severity{index}", (int)log.Severity); + parameters.Add($"SeverityName{index}", log.Severity.ToString()); + parameters.Add($"SeverityNumber{index}", log.SeverityNumber); + parameters.Add($"Message{index}", log.Message); + parameters.Add($"SpanId{index}", log.SpanId); + parameters.Add($"TraceId{index}", log.TraceId); + parameters.Add($"ParentId{index}", log.ParentId); + parameters.Add($"OriginalFormat{index}", log.OriginalFormat); + parameters.Add($"EventName{index}", log.EventName); + } - var resourceId = connection.QuerySingleOrDefault(""" - SELECT resource_id - FROM telemetry_resources - WHERE resource_name = @ResourceName - AND instance_id IS @InstanceId; - """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId }, transaction); - if (resourceId is not null) - { - _resourceIdsByKey.Add(resourceKey, resourceId.Value); - return resourceId.Value; - } + var logIds = new long[logBatch.Length]; + // Keep one RETURNING result set per log in a single command so each generated ID maps + // deterministically to its source log without relying on SQLite RETURNING row order. + using (var reader = connection.QueryMultiple(sql.ToString(), parameters, transaction)) + { + for (var index = 0; index < logBatch.Length; index++) + { + logIds[index] = reader.ReadSingle(); + } + } - var resourceCount = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_resources;", transaction: transaction); - if (resourceCount >= _otlpContext.Options.MaxResourceCount) - { - throw new InvalidOperationException($"Resource limit of {_otlpContext.Options.MaxResourceCount} reached. Resource '{resourceKey}' will not be added."); + InsertLogAttributes(connection, transaction, logBatch, logIds); + for (var index = 0; index < logBatch.Length; index++) + { + var log = logBatch[index].Log; + addedLogs.Add(new OtlpLogEntry( + logIds[index], + log.TimeStamp, + log.Flags, + log.Severity, + log.SeverityNumber, + log.Message, + log.SpanId, + log.TraceId, + log.ParentId, + log.OriginalFormat, + log.ResourceView, + log.Scope, + log.Attributes, + log.EventName)); + } } - - resourceId = connection.QuerySingle(""" - INSERT INTO telemetry_resources (resource_name, instance_id) - VALUES (@ResourceName, @InstanceId) - RETURNING resource_id; - """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId }, transaction); - _resourceIdsByKey.Add(resourceKey, resourceId.Value); - return resourceId.Value; } - private void ClearIngestionCaches() + private static void InsertLogAttributes(SqliteConnection connection, IDbTransaction transaction, PendingLog[] logs, long[] logIds) { - _resourceIdsByKey.Clear(); - _scopesByName.Clear(); - _metricIngestionState.Clear(); - } - - private static long GetOrAddResourceView( - SqliteConnection connection, - IDbTransaction transaction, - long resourceId, - KeyValuePair[] properties) - { - var parameters = new DynamicParameters(); - parameters.Add("ResourceId", resourceId); - parameters.Add("PropertyCount", properties.Length); - var sql = new StringBuilder(""" - SELECT v.resource_view_id - FROM telemetry_resource_views v - WHERE v.resource_id = @ResourceId - AND (SELECT COUNT(*) FROM telemetry_resource_view_attributes a WHERE a.resource_view_id = v.resource_view_id) = @PropertyCount - """); - for (var index = 0; index < properties.Length; index++) + var attributes = logs + .SelectMany((pendingLog, logIndex) => pendingLog.Log.Attributes.Select((attribute, ordinal) => (LogId: logIds[logIndex], Ordinal: ordinal, Attribute: attribute))) + .ToArray(); + foreach (var attributeBatch in attributes.Chunk(MaxLogAttributeBatchSize)) { - parameters.Add($"PropertyKey{index}", properties[index].Key); - parameters.Add($"PropertyValue{index}", properties[index].Value); - sql.Append(CultureInfo.InvariantCulture, $""" - - AND EXISTS ( - SELECT 1 - FROM telemetry_resource_view_attributes a - WHERE a.resource_view_id = v.resource_view_id - AND a.ordinal = {index} - AND a.attribute_key = @PropertyKey{index} - AND a.attribute_value = @PropertyValue{index} - ) + var sql = new StringBuilder(""" + INSERT INTO telemetry_log_attributes (log_id, ordinal, attribute_key, attribute_value) + VALUES """); + var parameters = new DynamicParameters(); + for (var index = 0; index < attributeBatch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@LogId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); + parameters.Add($"LogId{index}", attributeBatch[index].LogId); + parameters.Add($"Ordinal{index}", attributeBatch[index].Ordinal); + parameters.Add($"Key{index}", attributeBatch[index].Attribute.Key); + parameters.Add($"Value{index}", attributeBatch[index].Attribute.Value); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); } - sql.Append(" LIMIT 1;"); - - var existingResourceViewId = connection.QuerySingleOrDefault(sql.ToString(), parameters, transaction); - if (existingResourceViewId is not null) - { - return existingResourceViewId.Value; - } - - var resourceViewCount = connection.QuerySingle( - "SELECT COUNT(*) FROM telemetry_resource_views WHERE resource_id = @ResourceId;", - new { ResourceId = resourceId }, - transaction); - if (resourceViewCount >= TelemetryRepositoryLimits.MaxResourceViewCount) - { - throw new InvalidOperationException($"Resource view limit of {TelemetryRepositoryLimits.MaxResourceViewCount} reached."); - } - - var resourceViewId = connection.QuerySingle(""" - INSERT INTO telemetry_resource_views (resource_id) - VALUES (@ResourceId) - RETURNING resource_view_id; - """, new { ResourceId = resourceId }, transaction); - connection.Execute(""" - INSERT INTO telemetry_resource_view_attributes (resource_view_id, ordinal, attribute_key, attribute_value) - VALUES (@ResourceViewId, @Ordinal, @Key, @Value); - """, properties.Select((property, ordinal) => new - { - ResourceViewId = resourceViewId, - Ordinal = ordinal, - property.Key, - property.Value - }), transaction); - return resourceViewId; } - private (long ScopeId, OtlpScope Scope) GetOrAddScope( - SqliteConnection connection, - IDbTransaction transaction, - InstrumentationScope? instrumentationScope) + private static void MarkResourcesHaveLogs(SqliteConnection connection, IDbTransaction transaction, HashSet resources) { - var scopeName = instrumentationScope?.Name ?? OtlpScope.Empty.Name; - if (_scopesByName.TryGetValue(scopeName, out var cachedScope)) + var resourcesToUpdate = resources.Where(resource => !resource.Resource.HasLogs).ToArray(); + foreach (var batch in resourcesToUpdate.Chunk(MaxLogAttributeBatchSize)) { - return (cachedScope.ScopeId, cachedScope.Scope); + connection.Execute( + "UPDATE telemetry_resources SET has_logs = 1 WHERE resource_id IN @ResourceIds;", + new { ResourceIds = batch.Select(resource => resource.ResourceId).ToArray() }, + transaction); } - - var incomingScope = instrumentationScope is null - ? OtlpScope.Empty - : new OtlpScope( - instrumentationScope.Name, - instrumentationScope.Version, - instrumentationScope.Attributes.ToKeyValuePairs(_otlpContext)); - var existing = connection.QuerySingleOrDefault(""" - SELECT scope_id AS ScopeId, scope_name AS ScopeName, scope_version AS ScopeVersion - FROM telemetry_scopes - WHERE scope_name = @ScopeName; - """, new { ScopeName = incomingScope.Name }, transaction); - if (existing is not null) + foreach (var resource in resourcesToUpdate) { - var attributes = connection.Query(""" - SELECT attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_scope_attributes - WHERE scope_id = @ScopeId - ORDER BY ordinal; - """, new { existing.ScopeId }, transaction) - .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)) - .ToArray(); - var scope = new OtlpScope(existing.ScopeName, existing.ScopeVersion, attributes); - _scopesByName.Add(scopeName, new ScopeCacheEntry(existing.ScopeId, scope)); - return (existing.ScopeId, scope); + resource.Resource.HasLogs = true; } - - var scopeCount = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_scopes;", transaction: transaction); - if (scopeCount >= TelemetryRepositoryLimits.MaxScopeCount) - { - throw new InvalidOperationException($"Scope limit of {TelemetryRepositoryLimits.MaxScopeCount} reached. Scope '{incomingScope.Name}' will not be added."); - } - - var scopeId = connection.QuerySingle(""" - INSERT INTO telemetry_scopes (scope_name, scope_version) - VALUES (@ScopeName, @ScopeVersion) - RETURNING scope_id; - """, new { ScopeName = incomingScope.Name, ScopeVersion = incomingScope.Version }, transaction); - connection.Execute(""" - INSERT INTO telemetry_scope_attributes (scope_id, ordinal, attribute_key, attribute_value) - VALUES (@ScopeId, @Ordinal, @Key, @Value); - """, incomingScope.Attributes.Select((attribute, ordinal) => new - { - ScopeId = scopeId, - Ordinal = ordinal, - attribute.Key, - attribute.Value - }), transaction); - _scopesByName.Add(scopeName, new ScopeCacheEntry(scopeId, incomingScope)); - return (scopeId, incomingScope); } private void TrimLogsToCapacity(SqliteConnection connection, IDbTransaction transaction) @@ -446,6 +346,7 @@ GROUP BY a.log_id pl.message AS Message, pl.span_id AS SpanId, pl.trace_id AS TraceId, + pl.resource_id AS ResourceId, pl.resource_name AS ResourceName, pl.instance_id AS InstanceId, pl.uninstrumented_peer AS UninstrumentedPeer, @@ -500,7 +401,7 @@ FROM log_aggregate a Message = record.Message!, SpanId = record.SpanId!, TraceId = record.TraceId!, - Resource = new OtlpResource(record.ResourceName!, record.InstanceId, record.UninstrumentedPeer!.Value, _otlpContext), + Resource = GetCachedResource(record.ResourceId!.Value)!, ExceptionText = record.ExceptionText, HasGenAI = record.HasGenAI!.Value }).ToList(), @@ -800,60 +701,16 @@ private List MaterializeLogs(SqliteConnection connection, List record.LogId).Distinct().ToArray(); - var viewIds = records.Select(record => record.ResourceViewId).Distinct().ToArray(); - var scopeIds = records.Select(record => record.ScopeId).Distinct().ToArray(); var logAttributes = connection.Query(""" SELECT log_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue FROM telemetry_log_attributes WHERE log_id IN @Ids ORDER BY log_id, ordinal; """, new { Ids = logIds }).ToLookup(record => record.OwnerId); - var viewAttributes = connection.Query(""" - SELECT resource_view_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_resource_view_attributes - WHERE resource_view_id IN @Ids - ORDER BY resource_view_id, ordinal; - """, new { Ids = viewIds }).ToLookup(record => record.OwnerId); - var scopeAttributes = connection.Query(""" - SELECT scope_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_scope_attributes - WHERE scope_id IN @Ids - ORDER BY scope_id, ordinal; - """, new { Ids = scopeIds }).ToLookup(record => record.OwnerId); - - var resources = new Dictionary(); - var views = new Dictionary(); - var scopes = new Dictionary(); var results = new List(records.Count); foreach (var record in records) { - if (!resources.TryGetValue(record.ResourceId, out var resource)) - { - resource = new OtlpResource( - record.ResourceName, - record.InstanceId, - record.UninstrumentedPeer, - _otlpContext) - { - HasLogs = record.HasLogs, - HasTraces = record.HasTraces, - HasMetrics = record.HasMetrics - }; - resources.Add(record.ResourceId, resource); - } - if (!views.TryGetValue(record.ResourceViewId, out var view)) - { - view = new OtlpResourceView(resource, ToPairs(viewAttributes[record.ResourceViewId])); - views.Add(record.ResourceViewId, view); - } - if (!scopes.TryGetValue(record.ScopeId, out var scope)) - { - var attributes = ToPairs(scopeAttributes[record.ScopeId]); - scope = record.ScopeName == OtlpScope.Empty.Name && record.ScopeVersion.Length == 0 && attributes.Length == 0 - ? OtlpScope.Empty - : new OtlpScope(record.ScopeName, record.ScopeVersion, attributes); - scopes.Add(record.ScopeId, scope); - } + var (_, view, scope) = GetCachedTelemetryMetadata(record.ResourceId, record.ResourceViewId, record.ScopeId, CachedTelemetryType.Logs); results.Add(new OtlpLogEntry( record.LogId, @@ -934,8 +791,6 @@ DELETE FROM telemetry_traces transaction.Commit(); ClearIngestionCaches(); } - - _resourceCache.TryRemove(resourceKey, out _); } private void ClearStructuredLogsFromDatabase(ResourceKey? resourceKey) @@ -974,10 +829,11 @@ UPDATE telemetry_resources """, parameters, transaction); DeleteOrphanedScopes(connection, transaction); transaction.Commit(); + ClearMetadataCache(); } } - private void DeleteOrphanedScopes(SqliteConnection connection, IDbTransaction transaction) + private static void DeleteOrphanedScopes(SqliteConnection connection, IDbTransaction transaction) { connection.Execute(""" DELETE FROM telemetry_scopes @@ -985,71 +841,20 @@ WHERE NOT EXISTS (SELECT 1 FROM telemetry_logs WHERE telemetry_logs.scope_id = t AND NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.scope_id = telemetry_scopes.scope_id) AND NOT EXISTS (SELECT 1 FROM telemetry_metric_instruments WHERE telemetry_metric_instruments.scope_id = telemetry_scopes.scope_id); """, transaction: transaction); - _scopesByName.Clear(); } private List GetTelemetryResources(bool includeUninstrumentedPeers, string? name) { - var resources = new Dictionary(); - using var connection = _database.OpenConnection(); - foreach (var record in connection.Query(""" - SELECT - resource_name AS ResourceName, - instance_id AS InstanceId, - uninstrumented_peer AS UninstrumentedPeer, - has_logs AS HasLogs, - has_traces AS HasTraces, - has_metrics AS HasMetrics - FROM telemetry_resources; - """)) + EnsureCachePopulated(); + lock (_cacheLock) { - var key = new ResourceKey(record.ResourceName, record.InstanceId); - var resource = _resourceCache.GetOrAdd(key, resourceKey => - { - var newResource = new OtlpResource(resourceKey.Name, resourceKey.InstanceId, record.UninstrumentedPeer, _otlpContext); - newResource.ConfigureDataProviders( - (meterName, instrumentName, startTime, endTime) => GetResourceInstrumentFromDatabase(resourceKey, meterName, instrumentName, startTime, endTime), - () => GetInstrumentsSummariesFromDatabase(resourceKey), - () => GetResourceViewsFromDatabase(resourceKey, newResource)); - return newResource; - }); - resource.SetUninstrumentedPeer(record.UninstrumentedPeer); - resource.HasLogs = record.HasLogs; - resource.HasTraces = record.HasTraces; - resource.HasMetrics = record.HasMetrics; - resources.Add(key, resource); + return _cachedResourcesByKey.Values + .Select(resource => resource.Resource) + .Where(resource => includeUninstrumentedPeers || !resource.UninstrumentedPeer) + .Where(resource => name is null || string.Equals(resource.ResourceName, name, StringComparisons.ResourceName)) + .OrderBy(resource => resource.ResourceKey) + .ToList(); } - - return resources.Values - .Where(resource => includeUninstrumentedPeers || !resource.UninstrumentedPeer) - .Where(resource => name is null || string.Equals(resource.ResourceName, name, StringComparisons.ResourceName)) - .OrderBy(resource => resource.ResourceKey) - .ToList(); - } - - private List GetResourceViewsFromDatabase(ResourceKey resourceKey, OtlpResource resource) - { - using var connection = _database.OpenConnection(); - var viewIds = connection.Query(""" - SELECT v.resource_view_id - FROM telemetry_resource_views v - JOIN telemetry_resources r ON r.resource_id = v.resource_id - WHERE r.resource_name = @ResourceName COLLATE NOCASE - AND (@InstanceId IS NULL OR r.instance_id = @InstanceId COLLATE NOCASE) - ORDER BY v.resource_view_id; - """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId }).AsList(); - var attributes = connection.Query(""" - SELECT resource_view_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_resource_view_attributes - WHERE resource_view_id IN @Ids - ORDER BY resource_view_id, ordinal; - """, new { Ids = viewIds }).ToLookup(attribute => attribute.OwnerId); - return viewIds - .Select(viewId => new OtlpResourceView(resource, attributes[viewId] - .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)) - .ToArray())) - .DistinctBy(view => view.Properties, ResourceViewPropertiesComparer.Instance) - .ToList(); } private sealed class ResourceViewPropertiesComparer : IEqualityComparer[]> @@ -1072,7 +877,7 @@ public int GetHashCode(KeyValuePair[] properties) private sealed record LogQuery(string FromAndWhere, DynamicParameters Parameters); - private sealed record ScopeCacheEntry(long ScopeId, OtlpScope Scope); + private sealed record PendingLog(long ResourceId, long ResourceViewId, long ScopeId, OtlpLogEntry Log); private sealed class ScopeRecord { @@ -1128,6 +933,7 @@ private sealed class LogSummaryRecord public string? Message { get; init; } public string? SpanId { get; init; } public string? TraceId { get; init; } + public long? ResourceId { get; init; } public string? ResourceName { get; init; } public string? InstanceId { get; init; } public bool? UninstrumentedPeer { get; init; } @@ -1147,11 +953,4 @@ private class TelemetryResourceRecord public string? InstanceId { get; init; } } - private sealed class TelemetryResourceStateRecord : TelemetryResourceRecord - { - public required bool UninstrumentedPeer { get; init; } - public required bool HasLogs { get; init; } - public required bool HasTraces { get; init; } - public required bool HasMetrics { get; init; } - } -} \ No newline at end of file +} diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs index 9504ad4d08e..87711abd03a 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs @@ -31,6 +31,8 @@ private void AddMetricsToDatabase(AddContext context, RepeatedField(""" - SELECT instrument_id - FROM telemetry_metric_instruments - WHERE resource_id = @ResourceId AND scope_id = @ScopeId AND instrument_name = @InstrumentName; - """, new { ResourceId = resourceId, ScopeId = scopeId, InstrumentName = metric.Name }, transaction); - if (existingId is not null) - { - ingestionState.InstrumentIds.Add(instrumentKey, existingId.Value); - return existingId.Value; - } - - var instrumentCount = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_metric_instruments WHERE resource_id = @ResourceId;", new { ResourceId = resourceId }, transaction); - if (instrumentCount >= TelemetryRepositoryLimits.MaxInstrumentCount) - { - throw new InvalidOperationException($"Instrument limit of {TelemetryRepositoryLimits.MaxInstrumentCount} reached. Instrument '{metric.Name}' will not be added."); - } - - instrumentId = connection.QuerySingle(""" - INSERT INTO telemetry_metric_instruments ( - resource_id, scope_id, instrument_name, description, unit, instrument_type, - aggregation_temporality, is_monotonic) - VALUES ( - @ResourceId, @ScopeId, @InstrumentName, @Description, @Unit, @InstrumentType, - @AggregationTemporality, @IsMonotonic) - RETURNING instrument_id; - """, new - { - ResourceId = resourceId, - ScopeId = scopeId, - InstrumentName = metric.Name, - metric.Description, - metric.Unit, - InstrumentType = (int)MapMetricType(metric.DataCase), - AggregationTemporality = (int)MapAggregationTemporality(metric), - IsMonotonic = metric.DataCase == Metric.DataOneofCase.Sum && metric.Sum.IsMonotonic - }, transaction); - ingestionState.InstrumentIds.Add(instrumentKey, instrumentId); - return instrumentId; - } - private void AddNumberMetricPoint( SqliteConnection connection, IDbTransaction transaction, @@ -250,7 +199,7 @@ private void AddNumberMetricPoint( { pointBatch.AddUpdate(latest!.PointId, endTimeTicks, incrementRepeatCount: true); latest.EndTimeTicks = endTimeTicks; - AddMetricExemplars(connection, transaction, latest.PointId, point.Exemplars); + QueueMetricExemplars(pointBatch, latest.PointId, point.Exemplars); context.SuccessCount++; } } @@ -276,7 +225,7 @@ private void AddNumberMetricPoint( pendingPoint.Exemplars.AddRange(point.Exemplars); pointBatch.Inserts.Add(pendingPoint); dimension.PendingPoint = pendingPoint; - ingestionState.DimensionsToTrim.Add(dimension.DimensionId); + ingestionState.DimensionsToTrim.Add(dimension); } } catch (Exception exception) @@ -323,7 +272,7 @@ private void AddHistogramMetricPoint( { pointBatch.AddUpdate(latest!.PointId, endTimeTicks, incrementRepeatCount: false); latest.EndTimeTicks = endTimeTicks; - AddMetricExemplars(connection, transaction, latest.PointId, point.Exemplars); + QueueMetricExemplars(pointBatch, latest.PointId, point.Exemplars); context.SuccessCount++; } } @@ -351,7 +300,7 @@ private void AddHistogramMetricPoint( pendingPoint.Exemplars.AddRange(point.Exemplars); pointBatch.Inserts.Add(pendingPoint); dimension.PendingPoint = pendingPoint; - ingestionState.DimensionsToTrim.Add(dimension.DimensionId); + ingestionState.DimensionsToTrim.Add(dimension); } } catch (Exception exception) @@ -386,10 +335,11 @@ WITH updates(point_id, end_time_ticks, repeat_delta) AS ( sql.AppendLine(); sql.Append(""" ) - UPDATE telemetry_metric_points - SET end_time_ticks = (SELECT end_time_ticks FROM updates WHERE updates.point_id = telemetry_metric_points.point_id), - repeat_count = repeat_count + (SELECT repeat_delta FROM updates WHERE updates.point_id = telemetry_metric_points.point_id) - WHERE point_id IN (SELECT point_id FROM updates); + UPDATE telemetry_metric_points AS points + SET end_time_ticks = updates.end_time_ticks, + repeat_count = points.repeat_count + updates.repeat_delta + FROM updates + WHERE points.point_id = updates.point_id; """); connection.Execute(sql.ToString(), parameters, transaction); } @@ -431,20 +381,17 @@ INSERT INTO telemetry_metric_points ( } } + InsertHistogramBucketCounts(connection, transaction, pointBatch.Inserts); + InsertHistogramExplicitBounds(connection, transaction, pointBatch.Inserts); foreach (var point in pointBatch.Inserts) { - try - { - InsertHistogramBucketCounts(connection, transaction, point); - InsertHistogramExplicitBounds(connection, transaction, point); - AddMetricExemplars(connection, transaction, point.PointId, point.Exemplars); - point.Context.SuccessCount += point.SourcePointCount; - } - catch (Exception exception) - { - point.Context.FailureCount += point.SourcePointCount; - _otlpContext.Logger.LogInformation(exception, "Error adding metric."); - } + QueueMetricExemplars(pointBatch, point.PointId, point.Exemplars); + } + InsertMetricExemplars(connection, transaction, pointBatch.Exemplars); + + foreach (var point in pointBatch.Inserts) + { + point.Context.SuccessCount += point.SourcePointCount; if (ReferenceEquals(point.Dimension.PendingPoint, point)) { @@ -462,62 +409,64 @@ INSERT INTO telemetry_metric_points ( } } - private static void InsertHistogramBucketCounts(SqliteConnection connection, IDbTransaction transaction, PendingMetricPoint point) + private static void InsertHistogramBucketCounts( + SqliteConnection connection, + IDbTransaction transaction, + List points) { - if (point.HistogramBucketCounts is not { Length: > 0 } bucketCounts) + var bucketCounts = points + .Where(point => point.HistogramBucketCounts is { Length: > 0 }) + .SelectMany(point => point.HistogramBucketCounts!.Select((bucketCount, ordinal) => new PendingHistogramBucketCount(point.PointId, ordinal, bucketCount))) + .ToArray(); + foreach (var batch in bucketCounts.Chunk(MaxMetricPointBatchSize)) { - return; - } - - for (var offset = 0; offset < bucketCounts.Length; offset += MaxMetricPointBatchSize) - { - var count = Math.Min(MaxMetricPointBatchSize, bucketCounts.Length - offset); var sql = new StringBuilder(""" INSERT INTO telemetry_metric_histogram_bucket_counts (point_id, ordinal, bucket_count) VALUES """); var parameters = new DynamicParameters(); - for (var index = 0; index < count; index++) + for (var index = 0; index < batch.Length; index++) { if (index > 0) { sql.AppendLine(","); } sql.Append(CultureInfo.InvariantCulture, $" (@PointId{index}, @Ordinal{index}, @BucketCount{index})"); - parameters.Add($"PointId{index}", point.PointId); - parameters.Add($"Ordinal{index}", offset + index); - parameters.Add($"BucketCount{index}", bucketCounts[offset + index]); + parameters.Add($"PointId{index}", batch[index].PointId); + parameters.Add($"Ordinal{index}", batch[index].Ordinal); + parameters.Add($"BucketCount{index}", batch[index].BucketCount); } sql.Append(';'); connection.Execute(sql.ToString(), parameters, transaction); } } - private static void InsertHistogramExplicitBounds(SqliteConnection connection, IDbTransaction transaction, PendingMetricPoint point) + private static void InsertHistogramExplicitBounds( + SqliteConnection connection, + IDbTransaction transaction, + List points) { - if (point.HistogramExplicitBounds is not { Length: > 0 } explicitBounds) + var explicitBounds = points + .Where(point => point.HistogramExplicitBounds is { Length: > 0 }) + .SelectMany(point => point.HistogramExplicitBounds!.Select((explicitBound, ordinal) => new PendingHistogramExplicitBound(point.PointId, ordinal, explicitBound))) + .ToArray(); + foreach (var batch in explicitBounds.Chunk(MaxMetricPointBatchSize)) { - return; - } - - for (var offset = 0; offset < explicitBounds.Length; offset += MaxMetricPointBatchSize) - { - var count = Math.Min(MaxMetricPointBatchSize, explicitBounds.Length - offset); var sql = new StringBuilder(""" INSERT INTO telemetry_metric_histogram_explicit_bounds (point_id, ordinal, explicit_bound) VALUES """); var parameters = new DynamicParameters(); - for (var index = 0; index < count; index++) + for (var index = 0; index < batch.Length; index++) { if (index > 0) { sql.AppendLine(","); } sql.Append(CultureInfo.InvariantCulture, $" (@PointId{index}, @Ordinal{index}, @ExplicitBound{index})"); - parameters.Add($"PointId{index}", point.PointId); - parameters.Add($"Ordinal{index}", offset + index); - parameters.Add($"ExplicitBound{index}", explicitBounds[offset + index]); + parameters.Add($"PointId{index}", batch[index].PointId); + parameters.Add($"Ordinal{index}", batch[index].Ordinal); + parameters.Add($"ExplicitBound{index}", batch[index].ExplicitBound); } sql.Append(';'); connection.Execute(sql.ToString(), parameters, transaction); @@ -538,9 +487,9 @@ private MetricDimensionState GetOrAddMetricDimension( var attributes = temporaryAttributes.AsSpan(0, copyCount).ToArray(); var attributeHash = GetMetricDimensionAttributeHash(attributes); var cacheKey = (instrumentId, attributeHash); - if (!ingestionState.Dimensions.TryGetValue(cacheKey, out var candidates)) + if (ingestionState.LoadedDimensionInstruments.Add(instrumentId)) { - candidates = connection.Query(""" + var dimensions = connection.Query(""" SELECT d.dimension_id AS DimensionId, a.attribute_key AS AttributeKey, @@ -561,9 +510,8 @@ ORDER BY point_id DESC LIMIT 1 ) WHERE d.instrument_id = @InstrumentId - AND d.attribute_hash = @AttributeHash ORDER BY d.dimension_id, a.ordinal; - """, new { InstrumentId = instrumentId, AttributeHash = attributeHash }, transaction) + """, new { InstrumentId = instrumentId }, transaction) .GroupBy(record => record.DimensionId) .Select(group => { @@ -589,6 +537,22 @@ LIMIT 1 }; }) .ToList(); + foreach (var loadedDimension in dimensions) + { + var dimensionCacheKey = (instrumentId, GetMetricDimensionAttributeHash(loadedDimension.Attributes)); + if (!ingestionState.Dimensions.TryGetValue(dimensionCacheKey, out var dimensionCandidates)) + { + dimensionCandidates = []; + ingestionState.Dimensions.Add(dimensionCacheKey, dimensionCandidates); + } + dimensionCandidates.Add(loadedDimension); + } + ingestionState.DimensionCounts[instrumentId] = dimensions.Count; + } + + if (!ingestionState.Dimensions.TryGetValue(cacheKey, out var candidates)) + { + candidates = []; ingestionState.Dimensions.Add(cacheKey, candidates); } @@ -600,37 +564,86 @@ LIMIT 1 } } - if (!ingestionState.DimensionCounts.TryGetValue(instrumentId, out var dimensionCount)) - { - dimensionCount = connection.QuerySingle( - "SELECT COUNT(*) FROM telemetry_metric_dimensions WHERE instrument_id = @InstrumentId;", - new { InstrumentId = instrumentId }, - transaction); - } + var dimensionCount = ingestionState.DimensionCounts[instrumentId]; if (dimensionCount >= TelemetryRepositoryLimits.MaxDimensionCount) { throw new InvalidOperationException($"Dimension limit of {TelemetryRepositoryLimits.MaxDimensionCount} reached."); } - var dimensionId = connection.QuerySingle(""" - INSERT INTO telemetry_metric_dimensions (instrument_id, attribute_hash) - VALUES (@InstrumentId, @AttributeHash) - RETURNING dimension_id; - """, new { InstrumentId = instrumentId, AttributeHash = attributeHash }, transaction); - connection.Execute(""" - INSERT INTO telemetry_metric_dimension_attributes (dimension_id, ordinal, attribute_key, attribute_value) - VALUES (@DimensionId, @Ordinal, @Key, @Value); - """, attributes.Select((attribute, ordinal) => new { DimensionId = dimensionId, Ordinal = ordinal, attribute.Key, attribute.Value }), transaction); + var dimension = new MetricDimensionState { Attributes = attributes }; + ingestionState.PendingDimensions.Add(new PendingMetricDimension(instrumentId, attributeHash, dimension)); + ingestionState.PendingDimensionAttributes.AddRange(attributes.Select((attribute, ordinal) => new PendingMetricDimensionAttribute( + dimension, + ordinal, + attribute.Key, + attribute.Value))); if (pointAttributes.Count == 1 && pointAttributes[0].Key == "otel.metric.overflow" && pointAttributes[0].Value.GetString() == "true") { connection.Execute("UPDATE telemetry_metric_instruments SET has_overflow = 1 WHERE instrument_id = @InstrumentId;", new { InstrumentId = instrumentId }, transaction); + MarkCachedInstrumentHasOverflow(instrumentId); } - var dimension = new MetricDimensionState { DimensionId = dimensionId, Attributes = attributes }; candidates.Add(dimension); ingestionState.DimensionCounts[instrumentId] = dimensionCount + 1; return dimension; } + private static void InsertMetricDimensions( + SqliteConnection connection, + IDbTransaction transaction, + List dimensions) + { + foreach (var batch in dimensions.Chunk(MaxMetricPointBatchSize)) + { + var sql = new StringBuilder(); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + sql.Append(CultureInfo.InvariantCulture, $$""" + INSERT INTO telemetry_metric_dimensions (instrument_id, attribute_hash) + VALUES (@InstrumentId{{index}}, @AttributeHash{{index}}) + RETURNING dimension_id; + """); + parameters.Add($"InstrumentId{index}", batch[index].InstrumentId); + parameters.Add($"AttributeHash{index}", batch[index].AttributeHash); + } + + using var reader = connection.QueryMultiple(sql.ToString(), parameters, transaction); + for (var index = 0; index < batch.Length; index++) + { + batch[index].Dimension.DimensionId = reader.ReadSingle(); + } + } + } + + private static void InsertMetricDimensionAttributes( + SqliteConnection connection, + IDbTransaction transaction, + List attributes) + { + foreach (var batch in attributes.Chunk(MaxMetricPointBatchSize)) + { + var sql = new StringBuilder(""" + INSERT INTO telemetry_metric_dimension_attributes (dimension_id, ordinal, attribute_key, attribute_value) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@DimensionId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); + parameters.Add($"DimensionId{index}", batch[index].Dimension.DimensionId); + parameters.Add($"Ordinal{index}", batch[index].Ordinal); + parameters.Add($"Key{index}", batch[index].Key); + parameters.Add($"Value{index}", batch[index].Value); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + private static long GetMetricDimensionAttributeHash(ReadOnlySpan> attributes) { var hash = new XxHash3(); @@ -652,7 +665,7 @@ static void AppendHashValue(XxHash3 hash, string value) } } - private void AddMetricExemplars(SqliteConnection connection, IDbTransaction transaction, long pointId, IEnumerable exemplars) + private void QueueMetricExemplars(MetricPointBatch pointBatch, long pointId, IEnumerable exemplars) { foreach (var exemplar in exemplars) { @@ -662,40 +675,94 @@ private void AddMetricExemplars(SqliteConnection connection, IDbTransaction tran } var startTicks = OtlpHelpers.UnixNanoSecondsToDateTime(exemplar.TimeUnixNano).Ticks; var value = exemplar.HasAsDouble ? exemplar.AsDouble : exemplar.AsInt; - var exists = connection.QuerySingle(""" - SELECT EXISTS ( - SELECT 1 FROM telemetry_metric_exemplars - WHERE point_id = @PointId AND start_time_ticks = @StartTimeTicks AND exemplar_value = @Value - ); - """, new { PointId = pointId, StartTimeTicks = startTicks, Value = value }, transaction); - if (exists) + pointBatch.Exemplars.TryAdd( + new MetricExemplarKey(pointId, startTicks, value), + new PendingMetricExemplar + { + PointId = pointId, + StartTimeTicks = startTicks, + Value = value, + SpanId = exemplar.SpanId.ToHexString(), + TraceId = exemplar.TraceId.ToHexString(), + Attributes = exemplar.FilteredAttributes.ToKeyValuePairs(_otlpContext) + }); + } + } + + private static void InsertMetricExemplars( + SqliteConnection connection, + IDbTransaction transaction, + Dictionary exemplars) + { + foreach (var batch in exemplars.Values.Chunk(MaxMetricPointBatchSize)) + { + var sql = new StringBuilder(""" + INSERT OR IGNORE INTO telemetry_metric_exemplars ( + point_id, start_time_ticks, exemplar_value, span_id, trace_id) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) { - continue; + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@PointId{index}, @StartTimeTicks{index}, @Value{index}, @SpanId{index}, @TraceId{index})"); + parameters.Add($"PointId{index}", batch[index].PointId); + parameters.Add($"StartTimeTicks{index}", batch[index].StartTimeTicks); + parameters.Add($"Value{index}", batch[index].Value); + parameters.Add($"SpanId{index}", batch[index].SpanId); + parameters.Add($"TraceId{index}", batch[index].TraceId); } - var exemplarId = connection.QuerySingle(""" - INSERT INTO telemetry_metric_exemplars ( - point_id, start_time_ticks, exemplar_value, span_id, trace_id) - VALUES (@PointId, @StartTimeTicks, @Value, @SpanId, @TraceId) - RETURNING exemplar_id; - """, new + sql.Append(""" + RETURNING + exemplar_id AS ExemplarId, + point_id AS PointId, + start_time_ticks AS StartTimeTicks, + exemplar_value AS ExemplarValue; + """); + foreach (var inserted in connection.Query(sql.ToString(), parameters, transaction)) { - PointId = pointId, - StartTimeTicks = startTicks, - Value = value, - SpanId = exemplar.SpanId.ToHexString(), - TraceId = exemplar.TraceId.ToHexString() - }, transaction); - var attributes = exemplar.FilteredAttributes.ToKeyValuePairs(_otlpContext); - connection.Execute(""" + exemplars[new MetricExemplarKey(inserted.PointId, inserted.StartTimeTicks, inserted.ExemplarValue)].ExemplarId = inserted.ExemplarId; + } + } + + var attributes = exemplars.Values + .Where(exemplar => exemplar.ExemplarId is not null) + .SelectMany(exemplar => exemplar.Attributes.Select((attribute, ordinal) => new PendingMetricExemplarAttribute( + exemplar.ExemplarId!.Value, + ordinal, + attribute.Key, + attribute.Value))) + .ToArray(); + foreach (var batch in attributes.Chunk(MaxMetricPointBatchSize)) + { + var sql = new StringBuilder(""" INSERT INTO telemetry_metric_exemplar_attributes (exemplar_id, ordinal, attribute_key, attribute_value) - VALUES (@ExemplarId, @Ordinal, @Key, @Value); - """, attributes.Select((attribute, ordinal) => new { ExemplarId = exemplarId, Ordinal = ordinal, attribute.Key, attribute.Value }), transaction); + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@ExemplarId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); + parameters.Add($"ExemplarId{index}", batch[index].ExemplarId); + parameters.Add($"Ordinal{index}", batch[index].Ordinal); + parameters.Add($"Key{index}", batch[index].Key); + parameters.Add($"Value{index}", batch[index].Value); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); } } - private void TrimMetricDimensions(SqliteConnection connection, IDbTransaction transaction, IEnumerable dimensionIds) + private void TrimMetricDimensions(SqliteConnection connection, IDbTransaction transaction, IEnumerable dimensions) { - foreach (var ids in dimensionIds.Chunk(MaxMetricPointBatchSize)) + foreach (var batch in dimensions.Chunk(MaxMetricPointBatchSize)) { connection.Execute(""" DELETE FROM telemetry_metric_points @@ -710,35 +777,24 @@ WHERE dimension_id IN @DimensionIds ) WHERE point_rank > @MaxMetricsCount ); - """, new { DimensionIds = ids, _otlpContext.Options.MaxMetricsCount }, transaction); + """, new { DimensionIds = batch.Select(dimension => dimension.DimensionId).ToArray(), _otlpContext.Options.MaxMetricsCount }, transaction); } } - private List GetInstrumentsSummariesFromDatabase(ResourceKey key) - { - using var connection = _database.OpenConnection(); - var records = QueryMetricInstruments(connection, key, meterName: null, instrumentName: null); - var scopes = MaterializeMetricScopes(connection, records); - return records - .Select(record => CreateMetricSummary(record, scopes[record.ScopeId])) - .DistinctBy(summary => summary.GetKey()) - .ToList(); - } - private OtlpInstrumentData? GetInstrumentFromDatabase(GetInstrumentRequest request) { - using var connection = _database.OpenConnection(); - var records = QueryMetricInstruments(connection, request.ResourceKey, request.MeterName, request.InstrumentName); - if (records.Count == 0) + var instruments = GetCachedInstruments(request.ResourceKey, request.MeterName, request.InstrumentName); + if (instruments.Count == 0) { return null; } - var scopes = MaterializeMetricScopes(connection, records); + + using var connection = _database.OpenConnection(); var dimensions = new List(); var knownAttributeValues = new Dictionary>(); - foreach (var record in records) + foreach (var instrument in instruments) { - foreach (var dimension in MaterializeMetricDimensions(connection, record.InstrumentId, request.StartTime, request.EndTime)) + foreach (var dimension in MaterializeMetricDimensions(connection, instrument.InstrumentId, request.StartTime, request.EndTime)) { var isFirst = dimensions.Count == 0; foreach (var key in knownAttributeValues.Keys.Union(dimension.Attributes.Select(attribute => attribute.Key)).Distinct().ToList()) @@ -763,10 +819,10 @@ private List GetInstrumentsSummariesFromDatabase(Resource } return new OtlpInstrumentData { - Summary = CreateMetricSummary(records[0], scopes[records[0].ScopeId]), + Summary = instruments[0].Summary, Dimensions = dimensions, KnownAttributeValues = knownAttributeValues, - HasOverflow = records.Any(record => record.HasOverflow) + HasOverflow = instruments.Any(instrument => instrument.HasOverflow) }; } @@ -825,52 +881,6 @@ FROM telemetry_metric_points p return instrument; } - private static List QueryMetricInstruments( - SqliteConnection connection, - ResourceKey key, - string? meterName, - string? instrumentName) - { - return connection.Query(""" - SELECT - i.instrument_id AS InstrumentId, - i.scope_id AS ScopeId, - i.instrument_name AS InstrumentName, - i.description AS Description, - i.unit AS Unit, - i.instrument_type AS InstrumentType, - i.aggregation_temporality AS AggregationTemporality, - i.has_overflow AS HasOverflow - FROM telemetry_metric_instruments i - JOIN telemetry_resources r ON r.resource_id = i.resource_id - JOIN telemetry_scopes s ON s.scope_id = i.scope_id - WHERE r.resource_name = @ResourceName COLLATE NOCASE - AND (@InstanceId IS NULL OR r.instance_id = @InstanceId COLLATE NOCASE) - AND (@MeterName IS NULL OR s.scope_name = @MeterName) - AND (@InstrumentName IS NULL OR i.instrument_name = @InstrumentName) - ORDER BY i.instrument_id; - """, new { ResourceName = key.Name, key.InstanceId, MeterName = meterName, InstrumentName = instrumentName }).AsList(); - } - - private static Dictionary MaterializeMetricScopes(SqliteConnection connection, List records) - { - var scopeIds = records.Select(record => record.ScopeId).Distinct().ToArray(); - var scopeRecords = connection.Query(""" - SELECT scope_id AS ScopeId, scope_name AS ScopeName, scope_version AS ScopeVersion - FROM telemetry_scopes - WHERE scope_id IN @Ids; - """, new { Ids = scopeIds }).ToDictionary(record => record.ScopeId); - var attributes = connection.Query(""" - SELECT scope_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_scope_attributes - WHERE scope_id IN @Ids - ORDER BY scope_id, ordinal; - """, new { Ids = scopeIds }).ToLookup(record => record.OwnerId); - return scopeRecords.ToDictionary( - pair => pair.Key, - pair => CreateScope(pair.Value.ScopeName, pair.Value.ScopeVersion, attributes[pair.Key].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray())); - } - private List MaterializeMetricDimensions( SqliteConnection connection, long instrumentId, @@ -1028,23 +1038,10 @@ UPDATE telemetry_resources """, parameters, transaction); DeleteOrphanedScopes(connection, transaction); transaction.Commit(); - _metricIngestionState.Clear(); + ClearIngestionCaches(); } } - private static OtlpInstrumentSummary CreateMetricSummary(MetricInstrumentRecord record, OtlpScope scope) - { - return new OtlpInstrumentSummary - { - Name = record.InstrumentName, - Description = record.Description, - Unit = record.Unit, - Type = (OtlpInstrumentType)record.InstrumentType, - AggregationTemporality = (OtlpAggregationTemporality)record.AggregationTemporality, - Parent = scope - }; - } - private static OtlpScope CreateScope(string name, string version, KeyValuePair[] attributes) { return name == OtlpScope.Empty.Name && version.Length == 0 && attributes.Length == 0 @@ -1083,23 +1080,31 @@ private sealed class MetricAttributeComparer : IComparer InstrumentIds { get; } = []; public Dictionary<(long InstrumentId, long AttributeHash), List> Dimensions { get; } = []; public Dictionary DimensionCounts { get; } = []; - public HashSet DimensionsToTrim { get; } = []; + public HashSet LoadedDimensionInstruments { get; } = []; + public HashSet DimensionsToTrim { get; } = []; + public List PendingDimensions { get; } = []; + public List PendingDimensionAttributes { get; } = []; public void Clear() { - InstrumentIds.Clear(); Dimensions.Clear(); DimensionCounts.Clear(); + LoadedDimensionInstruments.Clear(); DimensionsToTrim.Clear(); + PendingDimensions.Clear(); + PendingDimensionAttributes.Clear(); } } + private sealed record PendingMetricDimension(long InstrumentId, long AttributeHash, MetricDimensionState Dimension); + + private sealed record PendingMetricDimensionAttribute(MetricDimensionState Dimension, int Ordinal, string Key, string Value); + private sealed class MetricDimensionState { - public required long DimensionId { get; init; } + public long DimensionId { get; set; } public required KeyValuePair[] Attributes { get; init; } public MetricPointRecord? LatestPoint { get; set; } public PendingMetricPoint? PendingPoint { get; set; } @@ -1109,6 +1114,7 @@ private sealed class MetricPointBatch { public Dictionary Updates { get; } = []; public List Inserts { get; } = []; + public Dictionary Exemplars { get; } = []; public void AddUpdate(long pointId, long endTimeTicks, bool incrementRepeatCount) { @@ -1125,6 +1131,33 @@ public void AddUpdate(long pointId, long endTimeTicks, bool incrementRepeatCount } } + private readonly record struct MetricExemplarKey(long PointId, long StartTimeTicks, double Value); + + private sealed class PendingMetricExemplar + { + public required long PointId { get; init; } + public required long StartTimeTicks { get; init; } + public required double Value { get; init; } + public required string SpanId { get; init; } + public required string TraceId { get; init; } + public required KeyValuePair[] Attributes { get; init; } + public long? ExemplarId { get; set; } + } + + private sealed record PendingMetricExemplarAttribute(long ExemplarId, int Ordinal, string Key, string Value); + + private sealed record PendingHistogramBucketCount(long PointId, int Ordinal, string BucketCount); + + private sealed record PendingHistogramExplicitBound(long PointId, int Ordinal, double ExplicitBound); + + private sealed class InsertedMetricExemplarRecord + { + public required long ExemplarId { get; init; } + public required long PointId { get; init; } + public required long StartTimeTicks { get; init; } + public required double ExemplarValue { get; init; } + } + private sealed class MetricPointUpdate { public required long PointId { get; init; } @@ -1175,25 +1208,6 @@ private class MetricPointRecord public string? HistogramCount { get; init; } } - private sealed class MetricInstrumentRecord - { - public required long InstrumentId { get; init; } - public required long ScopeId { get; init; } - public required string InstrumentName { get; init; } - public required string Description { get; init; } - public required string Unit { get; init; } - public required int InstrumentType { get; init; } - public required int AggregationTemporality { get; init; } - public required bool HasOverflow { get; init; } - } - - private sealed class MetricScopeRecord - { - public required long ScopeId { get; init; } - public required string ScopeName { get; init; } - public required string ScopeVersion { get; init; } - } - private sealed class MetricPointDataRecord : MetricPointRecord { public required long DimensionId { get; init; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs index e80a3c383b1..4960e493f2b 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs @@ -3,7 +3,6 @@ using System.Runtime.CompilerServices; using System.Threading.Channels; -using System.Collections.Concurrent; using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; using Microsoft.FluentUI.AspNetCore.Components; @@ -20,7 +19,6 @@ public sealed partial class SqliteTelemetryRepository private readonly List _metricsSubscriptions = []; private readonly List _tracesSubscriptions = []; private readonly Dictionary _resourceUnviewedErrorLogs = []; - private readonly ConcurrentDictionary _resourceCache = []; private TimeSpan _subscriptionMinExecuteInterval = TimeSpan.FromMilliseconds(100); private readonly object _watchersLock = new(); diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs index 5a53e8b4ae4..9444ad2752c 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs @@ -16,6 +16,10 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed partial class SqliteTelemetryRepository { + private const int MaxTraceBatchSize = 100; + private const int MaxSpanBatchSize = 50; + private const int MaxSpanDetailBatchSize = 100; + private List AddTracesToDatabase(AddContext context, RepeatedField resourceSpans) { var addedSpans = new List(); @@ -23,23 +27,29 @@ private List AddTracesToDatabase(AddContext context, RepeatedField(StringComparer.Ordinal); + var traceIds = resourceSpans + .SelectMany(resource => resource.ScopeSpans) + .SelectMany(scope => scope.Spans) + .Select(span => span.TraceId.ToHexString()) + .Distinct(StringComparer.Ordinal); + var traces = LoadTracesForIngestion(connection, transaction, traceIds); + var pendingSpans = new List(); + var resourcesWithTraces = new HashSet(); foreach (var resourceSpansItem in resourceSpans) { OtlpResourceView resourceView; + CachedResource cachedResource; long resourceId; long resourceViewId; try { var resourceKey = resourceSpansItem.Resource.GetResourceKey(); - resourceId = GetOrAddTelemetryResource(connection, transaction, resourceKey); - var resource = new OtlpResource(resourceKey.Name, resourceKey.InstanceId, uninstrumentedPeer: false, _otlpContext) - { - HasTraces = true - }; - resourceView = new OtlpResourceView(resource, resourceSpansItem.Resource.Attributes); - resourceViewId = GetOrAddResourceView(connection, transaction, resourceId, resourceView.Properties); + cachedResource = GetOrAddCachedResource(connection, transaction, resourceKey); + resourceId = cachedResource.ResourceId; + var cachedView = GetOrAddCachedResourceView(connection, transaction, cachedResource, resourceSpansItem.Resource.Attributes); + resourceView = cachedView.View; + resourceViewId = cachedView.ResourceViewId; } catch (Exception exception) { @@ -47,6 +57,7 @@ private List AddTracesToDatabase(AddContext context, RepeatedField AddTracesToDatabase(AddContext context, RepeatedField AddTracesToDatabase(AddContext context, RepeatedField AddTracesToDatabase(AddContext context, RepeatedField AddTracesToDatabase(AddContext context, RepeatedField LoadTracesForIngestion( SqliteConnection connection, IDbTransaction transaction, + IEnumerable traceIds) + { + var traces = new Dictionary(StringComparer.Ordinal); + foreach (var batch in traceIds.Chunk(MaxTraceBatchSize)) + { + List spanRecords; + List attributeRecords; + using (var reader = connection.QueryMultiple(""" + SELECT + s.trace_id AS TraceId, + s.span_id AS SpanId, + s.parent_span_id AS ParentSpanId, + s.resource_id AS ResourceId, + s.resource_view_id AS ResourceViewId, + s.scope_id AS ScopeId, + s.name AS Name, + s.kind AS Kind, + s.start_time_ticks AS StartTimeTicks, + s.end_time_ticks AS EndTimeTicks, + s.status AS Status, + s.status_message AS StatusMessage, + s.trace_state AS State, + t.last_updated_timestamp_ticks AS LastUpdatedTimestampTicks + FROM telemetry_spans s + JOIN telemetry_traces t ON t.trace_id = s.trace_id + WHERE s.trace_id IN @TraceIds + ORDER BY s.trace_id, s.start_time_ticks, s.span_id; + + SELECT + trace_id AS TraceId, + span_id AS SpanId, + ordinal AS Ordinal, + attribute_key AS AttributeKey, + attribute_value AS AttributeValue + FROM telemetry_span_attributes + WHERE trace_id IN @TraceIds + ORDER BY trace_id, span_id, ordinal; + """, new { TraceIds = batch }, transaction)) + { + spanRecords = reader.Read().AsList(); + attributeRecords = reader.Read().AsList(); + } + + var attributes = attributeRecords.ToLookup(record => (record.TraceId, record.SpanId)); + foreach (var traceRecords in spanRecords.GroupBy(record => record.TraceId, StringComparer.Ordinal)) + { + var firstRecord = traceRecords.First(); + var trace = new OtlpTrace(Convert.FromHexString(firstRecord.TraceId), new DateTime(firstRecord.LastUpdatedTimestampTicks, DateTimeKind.Utc)); + foreach (var record in traceRecords) + { + var (_, view, scope) = GetCachedTelemetryMetadata(record.ResourceId, record.ResourceViewId, record.ScopeId, CachedTelemetryType.Traces); + trace.AddSpan(new OtlpSpan(view, trace, scope) + { + SpanId = record.SpanId, + ParentSpanId = record.ParentSpanId, + Name = record.Name, + Kind = (OtlpSpanKind)record.Kind, + StartTime = new DateTime(record.StartTimeTicks, DateTimeKind.Utc), + EndTime = new DateTime(record.EndTimeTicks, DateTimeKind.Utc), + Status = (OtlpSpanStatusCode)record.Status, + StatusMessage = record.StatusMessage, + State = record.State, + Attributes = attributes[(record.TraceId, record.SpanId)] + .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)) + .ToArray(), + Events = [], + Links = [], + BackLinks = [] + }, skipLastUpdatedDate: true); + } + traces.Add(trace.TraceId, trace); + } + } + + return traces; + } + + private PendingSpan PrepareSpan( Dictionary traces, long resourceId, long resourceViewId, @@ -103,161 +197,366 @@ private OtlpSpan AddSpanToDatabase( Span span) { var traceId = span.TraceId.ToHexString(); + var registerTrace = false; if (!traces.TryGetValue(traceId, out var trace)) { - trace = MaterializeTrace(connection, traceId, transaction) ?? new OtlpTrace(span.TraceId.Memory, DateTime.UtcNow); - traces.Add(traceId, trace); + trace = new OtlpTrace(span.TraceId.Memory, DateTime.UtcNow); + registerTrace = true; } - var newTrace = trace.Spans.Count == 0; var modelSpan = CreateSqliteSpan(resourceView, trace, scope, span); trace.AddSpan(modelSpan); + if (registerTrace) + { + traces.Add(traceId, trace); + } - if (newTrace) + return new PendingSpan(resourceId, resourceViewId, scopeId, modelSpan); + } + + private static void UpsertTraces(SqliteConnection connection, IDbTransaction transaction, IEnumerable traces) + { + foreach (var batch in traces.Chunk(MaxTraceBatchSize)) { - connection.Execute(""" + var sql = new StringBuilder(""" INSERT INTO telemetry_traces ( trace_id, first_span_timestamp_ticks, duration_ticks, last_updated_timestamp_ticks, full_name) - VALUES ( - @TraceId, - @FirstSpanTimestampTicks, - @DurationTicks, - @LastUpdatedTimestampTicks, - @FullName); - """, new + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) { - trace.TraceId, - FirstSpanTimestampTicks = trace.TimeStamp.Ticks, - DurationTicks = trace.Duration.Ticks, - LastUpdatedTimestampTicks = trace.LastUpdatedDate.Ticks, - trace.FullName - }, transaction); + if (index > 0) + { + sql.AppendLine(","); + } + var trace = batch[index]; + sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @FirstSpanTimestampTicks{index}, @DurationTicks{index}, @LastUpdatedTimestampTicks{index}, @FullName{index})"); + parameters.Add($"TraceId{index}", trace.TraceId); + parameters.Add($"FirstSpanTimestampTicks{index}", trace.TimeStamp.Ticks); + parameters.Add($"DurationTicks{index}", trace.Duration.Ticks); + parameters.Add($"LastUpdatedTimestampTicks{index}", trace.LastUpdatedDate.Ticks); + parameters.Add($"FullName{index}", trace.FullName); + } + sql.Append(""" + ON CONFLICT(trace_id) DO UPDATE SET + first_span_timestamp_ticks = excluded.first_span_timestamp_ticks, + duration_ticks = excluded.duration_ticks, + last_updated_timestamp_ticks = excluded.last_updated_timestamp_ticks, + full_name = excluded.full_name; + """); + connection.Execute(sql.ToString(), parameters, transaction); } + } - connection.Execute(""" - INSERT INTO telemetry_spans ( - trace_id, span_id, parent_span_id, resource_id, resource_view_id, scope_id, name, kind, - start_time_ticks, end_time_ticks, status, status_message, trace_state) - VALUES ( - @TraceId, @SpanId, @ParentSpanId, @ResourceId, @ResourceViewId, @ScopeId, @Name, @Kind, - @StartTimeTicks, @EndTimeTicks, @Status, @StatusMessage, @State); - """, new - { - trace.TraceId, - modelSpan.SpanId, - modelSpan.ParentSpanId, - ResourceId = resourceId, - ResourceViewId = resourceViewId, - ScopeId = scopeId, - modelSpan.Name, - Kind = (int)modelSpan.Kind, - StartTimeTicks = modelSpan.StartTime.Ticks, - EndTimeTicks = modelSpan.EndTime.Ticks, - Status = (int)modelSpan.Status, - modelSpan.StatusMessage, - modelSpan.State - }, transaction); - - InsertSpanDetails(connection, transaction, modelSpan); - UpdateUninstrumentedPeers(connection, transaction, trace); - connection.Execute(""" - UPDATE telemetry_traces - SET first_span_timestamp_ticks = @FirstSpanTimestampTicks, - duration_ticks = @DurationTicks, - last_updated_timestamp_ticks = @LastUpdatedTimestampTicks, - full_name = @FullName - WHERE trace_id = @TraceId; - """, new + private static void InsertSpans(SqliteConnection connection, IDbTransaction transaction, List spans) + { + foreach (var batch in spans.Chunk(MaxSpanBatchSize)) { - trace.TraceId, - FirstSpanTimestampTicks = trace.TimeStamp.Ticks, - DurationTicks = trace.Duration.Ticks, - LastUpdatedTimestampTicks = trace.LastUpdatedDate.Ticks, - trace.FullName - }, transaction); + var sql = new StringBuilder(""" + INSERT INTO telemetry_spans ( + trace_id, span_id, parent_span_id, resource_id, resource_view_id, scope_id, name, kind, + start_time_ticks, end_time_ticks, status, status_message, trace_state) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + var pendingSpan = batch[index]; + var span = pendingSpan.Span; + sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @ParentSpanId{index}, @ResourceId{index}, @ResourceViewId{index}, @ScopeId{index}, @Name{index}, @Kind{index}, @StartTimeTicks{index}, @EndTimeTicks{index}, @Status{index}, @StatusMessage{index}, @State{index})"); + parameters.Add($"TraceId{index}", span.TraceId); + parameters.Add($"SpanId{index}", span.SpanId); + parameters.Add($"ParentSpanId{index}", span.ParentSpanId); + parameters.Add($"ResourceId{index}", pendingSpan.ResourceId); + parameters.Add($"ResourceViewId{index}", pendingSpan.ResourceViewId); + parameters.Add($"ScopeId{index}", pendingSpan.ScopeId); + parameters.Add($"Name{index}", span.Name); + parameters.Add($"Kind{index}", (int)span.Kind); + parameters.Add($"StartTimeTicks{index}", span.StartTime.Ticks); + parameters.Add($"EndTimeTicks{index}", span.EndTime.Ticks); + parameters.Add($"Status{index}", (int)span.Status); + parameters.Add($"StatusMessage{index}", span.StatusMessage); + parameters.Add($"State{index}", span.State); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + } + } - return modelSpan; + private static void MarkResourcesHaveTraces(SqliteConnection connection, IDbTransaction transaction, HashSet resources) + { + var resourcesToUpdate = resources.Where(resource => !resource.Resource.HasTraces).ToArray(); + foreach (var batch in resourcesToUpdate.Chunk(MaxTraceBatchSize)) + { + connection.Execute( + "UPDATE telemetry_resources SET has_traces = 1 WHERE resource_id IN @ResourceIds;", + new { ResourceIds = batch.Select(resource => resource.ResourceId).ToArray() }, + transaction); + } + foreach (var resource in resourcesToUpdate) + { + resource.Resource.HasTraces = true; + } } - private void UpdateUninstrumentedPeers(SqliteConnection connection, IDbTransaction transaction, OtlpTrace trace) + private void UpdateUninstrumentedPeers(SqliteConnection connection, IDbTransaction transaction, IEnumerable traces) { - foreach (var span in trace.Spans) + var peerResourceIds = new HashSet(); + var spanUpdates = new List(); + foreach (var trace in traces) { - OtlpResource? peer = null; - long? peerResourceId = null; - var hasPeerAddress = OtlpHelpers.GetPeerAddress(span.Attributes) is not null; - if (hasPeerAddress && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !span.GetChildSpans().Any()) + foreach (var span in trace.Spans) { - if (TryResolvePeerResourceKey(span.Attributes, out var peerKey)) + OtlpResource? peer = null; + long? peerResourceId = null; + var hasPeerAddress = OtlpHelpers.GetPeerAddress(span.Attributes) is not null; + if (hasPeerAddress && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !span.GetChildSpans().Any()) { - peerResourceId = GetOrAddTelemetryResource(connection, transaction, peerKey); - connection.Execute( - "UPDATE telemetry_resources SET uninstrumented_peer = 1 WHERE resource_id = @ResourceId;", - new { ResourceId = peerResourceId }, - transaction); - peer = new OtlpResource(peerKey.Name, peerKey.InstanceId, uninstrumentedPeer: true, _otlpContext); + if (TryResolvePeerResourceKey(span.Attributes, out var peerKey)) + { + var cachedPeerResource = GetOrAddCachedResource(connection, transaction, peerKey, uninstrumentedPeer: true); + peerResourceId = cachedPeerResource.ResourceId; + peerResourceIds.Add(cachedPeerResource.ResourceId); + peer = cachedPeerResource.Resource; + } } + + trace.SetSpanUninstrumentedPeer(span, peer); + spanUpdates.Add(new PeerSpanUpdateRecord + { + PeerResourceId = peerResourceId, + TraceId = span.TraceId, + SpanId = span.SpanId + }); } + } - trace.SetSpanUninstrumentedPeer(span, peer); - connection.Execute(""" - UPDATE telemetry_spans - SET uninstrumented_peer_resource_id = @PeerResourceId - WHERE trace_id = @TraceId AND span_id = @SpanId; - """, new { PeerResourceId = peerResourceId, span.TraceId, span.SpanId }, transaction); + if (peerResourceIds.Count > 0) + { + connection.Execute( + "UPDATE telemetry_resources SET uninstrumented_peer = 1 WHERE resource_id IN @ResourceIds;", + new { ResourceIds = peerResourceIds.ToArray() }, + transaction); + } + + foreach (var batch in spanUpdates.Chunk(MaxSpanDetailBatchSize)) + { + var sql = new StringBuilder(""" + WITH peer_updates(trace_id, span_id, peer_resource_id) AS ( + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @PeerResourceId{index})"); + parameters.Add($"TraceId{index}", batch[index].TraceId); + parameters.Add($"SpanId{index}", batch[index].SpanId); + parameters.Add($"PeerResourceId{index}", batch[index].PeerResourceId); + } + sql.Append(""" + ) + UPDATE telemetry_spans AS spans + SET uninstrumented_peer_resource_id = peer_updates.peer_resource_id + FROM peer_updates + WHERE spans.trace_id = peer_updates.trace_id + AND spans.span_id = peer_updates.span_id; + """); + connection.Execute(sql.ToString(), parameters, transaction); } } - private static void InsertSpanDetails(SqliteConnection connection, IDbTransaction transaction, OtlpSpan span) + private static void InsertSpanDetails(SqliteConnection connection, IDbTransaction transaction, List pendingSpans) { - connection.Execute(""" - INSERT INTO telemetry_span_attributes (trace_id, span_id, ordinal, attribute_key, attribute_value) - VALUES (@TraceId, @SpanId, @Ordinal, @Key, @Value); - """, span.Attributes.Select((attribute, ordinal) => new + InsertSpanAttributes(connection, transaction, pendingSpans); + + var events = pendingSpans + .SelectMany(pendingSpan => pendingSpan.Span.Events.Select((spanEvent, ordinal) => new PendingSpanEvent( + spanEvent.InternalId.ToString("D"), + pendingSpan.Span.TraceId, + pendingSpan.Span.SpanId, + ordinal, + spanEvent))) + .ToArray(); + InsertSpanEvents(connection, transaction, events); + InsertSpanEventAttributes(connection, transaction, events); + + var links = pendingSpans + .SelectMany(pendingSpan => pendingSpan.Span.Links.Select(link => new PendingSpanLink(link))) + .ToArray(); + InsertSpanLinks(connection, transaction, links); + InsertSpanLinkAttributes(connection, transaction, links); + } + + private static void InsertSpanAttributes(SqliteConnection connection, IDbTransaction transaction, List pendingSpans) + { + var attributes = pendingSpans + .SelectMany(pendingSpan => pendingSpan.Span.Attributes.Select((attribute, ordinal) => new + { + pendingSpan.Span.TraceId, + pendingSpan.Span.SpanId, + Ordinal = ordinal, + attribute.Key, + attribute.Value + })) + .ToArray(); + foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) { - span.TraceId, - span.SpanId, - Ordinal = ordinal, - attribute.Key, - attribute.Value - }), transaction); - - foreach (var (spanEvent, ordinal) in span.Events.Select((spanEvent, ordinal) => (spanEvent, ordinal))) + var sql = new StringBuilder(""" + INSERT INTO telemetry_span_attributes (trace_id, span_id, ordinal, attribute_key, attribute_value) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); + parameters.Add($"TraceId{index}", batch[index].TraceId); + parameters.Add($"SpanId{index}", batch[index].SpanId); + parameters.Add($"Ordinal{index}", batch[index].Ordinal); + parameters.Add($"Key{index}", batch[index].Key); + parameters.Add($"Value{index}", batch[index].Value); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + + private static void InsertSpanEvents(SqliteConnection connection, IDbTransaction transaction, PendingSpanEvent[] events) + { + foreach (var batch in events.Chunk(MaxSpanDetailBatchSize)) { - var eventId = spanEvent.InternalId.ToString("D"); - connection.Execute(""" + var sql = new StringBuilder(""" INSERT INTO telemetry_span_events (event_id, trace_id, span_id, ordinal, event_name, event_time_ticks) - VALUES (@EventId, @TraceId, @SpanId, @Ordinal, @Name, @TimeTicks); - """, new { EventId = eventId, span.TraceId, span.SpanId, Ordinal = ordinal, spanEvent.Name, TimeTicks = spanEvent.Time.Ticks }, transaction); - connection.Execute(""" - INSERT INTO telemetry_span_event_attributes (event_id, ordinal, attribute_key, attribute_value) - VALUES (@EventId, @Ordinal, @Key, @Value); - """, spanEvent.Attributes.Select((attribute, attributeOrdinal) => new + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@EventId{index}, @TraceId{index}, @SpanId{index}, @Ordinal{index}, @Name{index}, @TimeTicks{index})"); + parameters.Add($"EventId{index}", batch[index].EventId); + parameters.Add($"TraceId{index}", batch[index].TraceId); + parameters.Add($"SpanId{index}", batch[index].SpanId); + parameters.Add($"Ordinal{index}", batch[index].Ordinal); + parameters.Add($"Name{index}", batch[index].Event.Name); + parameters.Add($"TimeTicks{index}", batch[index].Event.Time.Ticks); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + + private static void InsertSpanEventAttributes(SqliteConnection connection, IDbTransaction transaction, PendingSpanEvent[] events) + { + var attributes = events + .SelectMany(spanEvent => spanEvent.Event.Attributes.Select((attribute, ordinal) => new { - EventId = eventId, - Ordinal = attributeOrdinal, + spanEvent.EventId, + Ordinal = ordinal, attribute.Key, attribute.Value - }), transaction); + })) + .ToArray(); + foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) + { + var sql = new StringBuilder(""" + INSERT INTO telemetry_span_event_attributes (event_id, ordinal, attribute_key, attribute_value) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@EventId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); + parameters.Add($"EventId{index}", batch[index].EventId); + parameters.Add($"Ordinal{index}", batch[index].Ordinal); + parameters.Add($"Key{index}", batch[index].Key); + parameters.Add($"Value{index}", batch[index].Value); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); } + } - foreach (var link in span.Links) + private static void InsertSpanLinks(SqliteConnection connection, IDbTransaction transaction, PendingSpanLink[] links) + { + foreach (var batch in links.Chunk(MaxSpanDetailBatchSize)) { - var linkId = connection.QuerySingle(""" - INSERT INTO telemetry_span_links ( - source_trace_id, source_span_id, target_trace_id, target_span_id, trace_state) - VALUES (@SourceTraceId, @SourceSpanId, @TraceId, @SpanId, @TraceState) - RETURNING link_id; - """, link, transaction); - connection.Execute(""" - INSERT INTO telemetry_span_link_attributes (link_id, ordinal, attribute_key, attribute_value) - VALUES (@LinkId, @Ordinal, @Key, @Value); - """, link.Attributes.Select((attribute, ordinal) => new + var sql = new StringBuilder(); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + var link = batch[index].Link; + sql.Append(CultureInfo.InvariantCulture, $$""" + INSERT INTO telemetry_span_links ( + source_trace_id, source_span_id, target_trace_id, target_span_id, trace_state) + VALUES (@SourceTraceId{{index}}, @SourceSpanId{{index}}, @TraceId{{index}}, @SpanId{{index}}, @TraceState{{index}}) + RETURNING link_id; + """); + parameters.Add($"SourceTraceId{index}", link.SourceTraceId); + parameters.Add($"SourceSpanId{index}", link.SourceSpanId); + parameters.Add($"TraceId{index}", link.TraceId); + parameters.Add($"SpanId{index}", link.SpanId); + parameters.Add($"TraceState{index}", link.TraceState); + } + + using var reader = connection.QueryMultiple(sql.ToString(), parameters, transaction); + for (var index = 0; index < batch.Length; index++) + { + batch[index].LinkId = reader.ReadSingle(); + } + } + } + + private static void InsertSpanLinkAttributes(SqliteConnection connection, IDbTransaction transaction, PendingSpanLink[] links) + { + var attributes = links + .SelectMany(link => link.Link.Attributes.Select((attribute, ordinal) => new { - LinkId = linkId, + link.LinkId, Ordinal = ordinal, attribute.Key, attribute.Value - }), transaction); + })) + .ToArray(); + foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) + { + var sql = new StringBuilder(""" + INSERT INTO telemetry_span_link_attributes (link_id, ordinal, attribute_key, attribute_value) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@LinkId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); + parameters.Add($"LinkId{index}", batch[index].LinkId); + parameters.Add($"Ordinal{index}", batch[index].Ordinal); + parameters.Add($"Key{index}", batch[index].Key); + parameters.Add($"Value{index}", batch[index].Value); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); } } @@ -1040,26 +1339,12 @@ FROM telemetry_spans s } var spanIds = records.Select(record => record.SpanId).ToArray(); - var viewIds = records.Select(record => record.ResourceViewId).Distinct().ToArray(); - var scopeIds = records.Select(record => record.ScopeId).Distinct().ToArray(); var spanAttributes = connection.Query(""" SELECT span_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue FROM telemetry_span_attributes WHERE trace_id = @TraceId AND span_id IN @SpanIds ORDER BY span_id, ordinal; """, new { TraceId = traceId, SpanIds = spanIds }, transaction).ToLookup(record => record.OwnerId); - var viewAttributes = connection.Query(""" - SELECT resource_view_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_resource_view_attributes - WHERE resource_view_id IN @Ids - ORDER BY resource_view_id, ordinal; - """, new { Ids = viewIds }, transaction).ToLookup(record => record.OwnerId); - var scopeAttributes = connection.Query(""" - SELECT scope_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_scope_attributes - WHERE scope_id IN @Ids - ORDER BY scope_id, ordinal; - """, new { Ids = scopeIds }, transaction).ToLookup(record => record.OwnerId); var eventRecords = connection.Query(""" SELECT event_id AS EventId, span_id AS SpanId, event_name AS EventName, event_time_ticks AS EventTimeTicks FROM telemetry_span_events @@ -1095,34 +1380,9 @@ WHERE link_id IN @Ids var incomingLinks = linkRecords.Where(record => record.TraceId == traceId).ToLookup(record => record.SpanId); var trace = new OtlpTrace(Convert.FromHexString(traceId), new DateTime(records[0].LastUpdatedTimestampTicks, DateTimeKind.Utc)); - var resources = new Dictionary(); - var views = new Dictionary(); - var scopes = new Dictionary(); foreach (var record in records) { - if (!resources.TryGetValue(record.ResourceId, out var resource)) - { - resource = new OtlpResource(record.ResourceName, record.InstanceId, record.UninstrumentedPeer, _otlpContext) - { - HasLogs = record.HasLogs, - HasTraces = record.HasTraces, - HasMetrics = record.HasMetrics - }; - resources.Add(record.ResourceId, resource); - } - if (!views.TryGetValue(record.ResourceViewId, out var view)) - { - view = new OtlpResourceView(resource, ToPairs(viewAttributes[record.ResourceViewId])); - views.Add(record.ResourceViewId, view); - } - if (!scopes.TryGetValue(record.ScopeId, out var scope)) - { - var attributes = ToPairs(scopeAttributes[record.ScopeId]); - scope = record.ScopeName == OtlpScope.Empty.Name && record.ScopeVersion.Length == 0 && attributes.Length == 0 - ? OtlpScope.Empty - : new OtlpScope(record.ScopeName, record.ScopeVersion, attributes); - scopes.Add(record.ScopeId, scope); - } + var (_, view, scope) = GetCachedTelemetryMetadata(record.ResourceId, record.ResourceViewId, record.ScopeId, CachedTelemetryType.Traces); var modelSpan = new OtlpSpan(view, trace, scope) { @@ -1142,11 +1402,7 @@ WHERE link_id IN @Ids }; if (record.PeerResourceId is not null) { - modelSpan.SetUninstrumentedPeer(new OtlpResource( - record.PeerResourceName!, - record.PeerInstanceId, - uninstrumentedPeer: true, - _otlpContext)); + modelSpan.SetUninstrumentedPeer(GetCachedResource(record.PeerResourceId.Value)); } modelSpan.Events.AddRange(events[record.SpanId].Select(spanEvent => new OtlpSpanEvent(modelSpan) { @@ -1241,7 +1497,7 @@ FROM telemetry_span_attributes { if (!peerResourceIds.TryGetValue(peerKey, out var resourceId)) { - resourceId = GetOrAddTelemetryResource(connection, transaction, peerKey); + resourceId = GetOrAddCachedResource(connection, transaction, peerKey, uninstrumentedPeer: true).ResourceId; peerResourceIds.Add(peerKey, resourceId); connection.Execute( "UPDATE telemetry_resources SET uninstrumented_peer = 1 WHERE resource_id = @ResourceId;", @@ -1342,11 +1598,47 @@ UPDATE telemetry_resources """, parameters, transaction); DeleteOrphanedScopes(connection, transaction); transaction.Commit(); + ClearMetadataCache(); } } private sealed record TraceQuery(string FromAndWhere, DynamicParameters Parameters); + private sealed record PendingSpan(long ResourceId, long ResourceViewId, long ScopeId, OtlpSpan Span); + + private sealed record PendingSpanEvent(string EventId, string TraceId, string SpanId, int Ordinal, OtlpSpanEvent Event); + + private sealed class IngestionSpanAttributeRecord : AttributeRecord + { + public required string TraceId { get; init; } + public required string SpanId { get; init; } + public required int Ordinal { get; init; } + } + + private sealed class IngestionSpanRecord + { + public required string TraceId { get; init; } + public required string SpanId { get; init; } + public string? ParentSpanId { get; init; } + public required long ResourceId { get; init; } + public required long ResourceViewId { get; init; } + public required long ScopeId { get; init; } + public required string Name { get; init; } + public required int Kind { get; init; } + public required long StartTimeTicks { get; init; } + public required long EndTimeTicks { get; init; } + public required int Status { get; init; } + public string? StatusMessage { get; init; } + public string? State { get; init; } + public required long LastUpdatedTimestampTicks { get; init; } + } + + private sealed class PendingSpanLink(OtlpSpanLink link) + { + public OtlpSpanLink Link { get; } = link; + public long LinkId { get; set; } + } + private sealed class TraceAggregateRecord { public required int TotalItemCount { get; init; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index 5130fa34209..f2a83f2a28d 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -174,7 +174,7 @@ public void AddTraces(AddContext context, RepeatedField resourceS public OtlpTrace? GetTrace(string traceId) => GetTraceFromDatabase(traceId); public OtlpSpan? GetSpan(string traceId, string spanId) => GetSpanFromDatabase(traceId, spanId); public OtlpResource? GetPeerResource(OtlpSpan span) => span.UninstrumentedPeer; - public List GetInstrumentsSummaries(ResourceKey key) => GetInstrumentsSummariesFromDatabase(key); + public List GetInstrumentsSummaries(ResourceKey key) => GetCachedInstrumentSummaries(key); public OtlpInstrumentData? GetInstrument(GetInstrumentRequest request) => GetInstrumentFromDatabase(request); public DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) => GetInstrumentLatestEndTimeFromDatabase(resourceKey, meterName, instrumentName); diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index ee0c3c32ce1..27de9e4ccfb 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -19,6 +19,7 @@ internal sealed class DashboardRunStore : IDashboardRunStore, IDisposable { private const string TemporaryDirectoryPrefix = "aspire-dashboard-"; + internal const string DatabaseFileName = "dashboard.db"; internal const int MaxApplicationDirectoryNameLength = 80; internal const int MaxRuns = 10; internal const int SchemaVersion = DashboardSqliteDatabase.SchemaVersion; @@ -57,7 +58,7 @@ internal DashboardRunStore( RunDirectory = _temporaryDirectory; _runLock = OpenRunLock(RunDirectory); DeleteAbandonedTemporaryDirectories(deleteRunDirectory); - DatabasePath = Path.Combine(RunDirectory, "dashboard.db"); + DatabasePath = Path.Combine(RunDirectory, DatabaseFileName); break; case DashboardPersistenceMode.Runs: var applicationDirectory = GetApplicationDirectory(options.Value.Data.Directory, applicationName); @@ -65,13 +66,13 @@ internal DashboardRunStore( RunDirectory = Path.Combine(_runsDirectory, runId); Directory.CreateDirectory(RunDirectory); _runLock = OpenRunLock(RunDirectory); - DatabasePath = Path.Combine(RunDirectory, "dashboard.db"); + DatabasePath = Path.Combine(RunDirectory, DatabaseFileName); _metadataPath = Path.Combine(RunDirectory, "run.json"); break; case DashboardPersistenceMode.Append: RunDirectory = GetApplicationDirectory(options.Value.Data.Directory, applicationName); Directory.CreateDirectory(RunDirectory); - DatabasePath = Path.Combine(RunDirectory, "dashboard.db"); + DatabasePath = Path.Combine(RunDirectory, DatabaseFileName); if (File.Exists(DatabasePath) && !DashboardSqliteDatabase.IsCompatible(DatabasePath)) { DashboardSqliteDatabase.ClearPools(); @@ -104,7 +105,7 @@ private void DeleteAbandonedTemporaryDirectories(Action deleteRunDirecto { if (!IsTemporaryDatabaseDirectory(directory) || string.Equals(directory, RunDirectory, StringComparison.OrdinalIgnoreCase) || - !File.Exists(Path.Combine(directory, "dashboard.db"))) + !File.Exists(Path.Combine(directory, DatabaseFileName))) { continue; } diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql index 8857af79894..1c64353b8af 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql @@ -83,4 +83,6 @@ CREATE INDEX IF NOT EXISTS ix_telemetry_metric_dimensions_hash CREATE INDEX IF NOT EXISTS ix_telemetry_metric_points_dimension_order ON telemetry_metric_points(dimension_id, point_id); CREATE INDEX IF NOT EXISTS ix_telemetry_metric_points_time - ON telemetry_metric_points(dimension_id, start_time_ticks, end_time_ticks); \ No newline at end of file + ON telemetry_metric_points(dimension_id, start_time_ticks, end_time_ticks); +CREATE UNIQUE INDEX IF NOT EXISTS ix_telemetry_metric_exemplars_identity + ON telemetry_metric_exemplars(point_id, start_time_ticks, exemplar_value); \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/TracingSqliteConnection.cs b/src/Aspire.Dashboard/ServiceClient/TracingSqliteConnection.cs index 1fd88a7d250..68b3b9b51c7 100644 --- a/src/Aspire.Dashboard/ServiceClient/TracingSqliteConnection.cs +++ b/src/Aspire.Dashboard/ServiceClient/TracingSqliteConnection.cs @@ -5,6 +5,7 @@ using System.Data.Common; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using Aspire.Dashboard.Otlp.Model; using Microsoft.Data.Sqlite; namespace Aspire.Dashboard.ServiceClient; @@ -138,6 +139,7 @@ private async Task ExecuteWithActivityAsync(Func> execute) if (activity is not null) { activity.SetTag("db.system.name", "sqlite"); + activity.SetTag(OtlpSpan.PeerServiceAttributeKey, databaseName); activity.SetTag("db.namespace", databaseName); activity.SetTag("db.query.text", CommandText); activity.SetTag("db.operation.name", operationName); diff --git a/tests/Aspire.Dashboard.Tests/DashboardSqliteOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/DashboardSqliteOutgoingPeerResolverTests.cs new file mode 100644 index 00000000000..68560191943 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/DashboardSqliteOutgoingPeerResolverTests.cs @@ -0,0 +1,48 @@ +// 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 Xunit; + +namespace Aspire.Dashboard.Tests; + +public class DashboardSqliteOutgoingPeerResolverTests +{ + [Fact] + public void TryResolvePeer_DashboardSqlite_ReturnsDatabaseName() + { + var resolver = new DashboardSqliteOutgoingPeerResolver(); + + var result = resolver.TryResolvePeer(CreateAttributes(), out var name, out var resource); + + Assert.True(result); + Assert.Equal("dashboard.db", name); + Assert.Null(resource); + } + + [Theory] + [InlineData(OtlpSpan.PeerServiceAttributeKey, "postgresql")] + [InlineData("db.system.name", "postgresql")] + [InlineData("db.namespace", "application.db")] + public void TryResolvePeer_NonDashboardSqlite_ReturnsFalse(string attributeName, string attributeValue) + { + var resolver = new DashboardSqliteOutgoingPeerResolver(); + var attributes = CreateAttributes(); + var attributeIndex = Array.FindIndex(attributes, attribute => attribute.Key == attributeName); + attributes[attributeIndex] = KeyValuePair.Create(attributeName, attributeValue); + + var result = resolver.TryResolvePeer(attributes, out var name, out var resource); + + Assert.False(result); + Assert.Null(name); + Assert.Null(resource); + } + + private static KeyValuePair[] CreateAttributes() => + [ + KeyValuePair.Create(OtlpSpan.PeerServiceAttributeKey, "dashboard.db"), + KeyValuePair.Create("db.system.name", "sqlite"), + KeyValuePair.Create("db.namespace", "dashboard.db") + ]; +} \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs index 4c5fd587dc7..617c74cf133 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs @@ -1,9 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; using Aspire.Dashboard.Utils; using Microsoft.AspNetCore.InternalTesting; -using System.Net; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry; +using OpenTelemetry.Trace; using Xunit; namespace Aspire.Dashboard.Tests.Integration; @@ -36,4 +41,38 @@ static async Task MakeRequestAndAssert(string basePath, Version httpVersion) Assert.Equal(HttpStatusCode.OK, response.StatusCode); } } + + [Fact] + public async Task HealthEndpoint_OtlpExporterConfigured_ExportsAspNetCoreActivity() + { + var exportedActivities = new ConcurrentQueue(); + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication( + testOutputHelper, + config => config["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://127.0.0.1:1", + builder => builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing.AddProcessor( + new SimpleActivityExportProcessor(new TestActivityExporter(exportedActivities))))); + await app.StartAsync().DefaultTimeout(); + + using var client = new HttpClient { BaseAddress = new Uri($"http://{app.FrontendSingleEndPointAccessor().EndPoint}") }; + var response = await client.GetAsync($"/{DashboardUrls.HealthBasePath}").DefaultTimeout(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains(exportedActivities, activity => + activity.Source.Name == "Microsoft.AspNetCore" && + activity.Kind == ActivityKind.Server); + } + + private sealed class TestActivityExporter(ConcurrentQueue exportedActivities) : BaseExporter + { + public override ExportResult Export(in Batch batch) + { + foreach (var activity in batch) + { + exportedActivities.Enqueue(activity); + } + + return ExportResult.Success; + } + } } diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs index 306dad73864..dd4830847fb 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Diagnostics; +using Aspire.Dashboard.Otlp.Model; using Aspire.Tests; using Dapper; using Microsoft.Data.Sqlite; @@ -31,6 +32,7 @@ public async Task DapperQuery_CreatesActivityWithQueryInformation() Assert.Equal("SELECT sqlite", activity.OperationName); Assert.Equal(ActivityKind.Client, activity.Kind); Assert.Equal("sqlite", activity.GetTagItem("db.system.name")); + Assert.Equal("dashboard.db", activity.GetTagItem(OtlpSpan.PeerServiceAttributeKey)); Assert.Equal("dashboard.db", activity.GetTagItem("db.namespace")); Assert.Equal("SELECT", activity.GetTagItem("db.operation.name")); Assert.Equal(ActivityStatusCode.Unset, activity.Status); @@ -75,4 +77,4 @@ public void Dispose() DashboardSqliteDatabase.ClearPools(); Directory.Delete(_temporaryDirectory, recursive: true); } -} \ No newline at end of file +} diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs index c01ec361469..6b735e8a4e8 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs @@ -1,12 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; using System.Diagnostics; using System.Threading.Channels; using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Tests.Integration; +using Aspire.Tests; using Google.Protobuf.Collections; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging; @@ -1784,4 +1786,104 @@ public sealed class InMemoryLogTests(ITestOutputHelper testOutputHelper) : LogTe public sealed class SqliteLogTests(ITestOutputHelper testOutputHelper) : LogTests(testOutputHelper) { protected override bool UseSqlite => true; + + [Fact] + public void AddLogs_BatchesRowsAndAttributesAcrossResourcesAndSkipsResourceUpdateWhenCached() + { + var repository = Assert.IsType(CreateRepository()); + repository.AsWriter().AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(name: "app-one"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = { CreateLogRecord() } + } + } + }, + new ResourceLogs + { + Resource = CreateResource(name: "app-two"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = { CreateLogRecord() } + } + } + } + }); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); + using var parent = new Activity("log ingestion test").Start(); + + var context = new AddContext(); + repository.AsWriter().AddLogs(context, new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(name: "app-one"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = + { + CreateLogRecord(message: "one", attributes: [KeyValuePair.Create("key", "one")]), + CreateLogRecord(message: "two", attributes: [KeyValuePair.Create("key", "two")]) + } + } + } + }, + new ResourceLogs + { + Resource = CreateResource(name: "app-two"), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope("TestLogger"), + LogRecords = + { + CreateLogRecord(message: "three", attributes: [KeyValuePair.Create("key", "three")]), + CreateLogRecord(message: "four", attributes: [KeyValuePair.Create("key", "four")]) + } + } + } + } + }); + + var queries = activities + .Where(activity => activity.ParentSpanId == parent.SpanId) + .Select(activity => (string)activity.GetTagItem("db.query.text")!) + .ToList(); + var logInsert = Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_logs", StringComparison.Ordinal)); + Assert.Equal(4, logInsert.Split("INSERT INTO telemetry_logs", StringSplitOptions.None).Length - 1); + var attributeInsert = Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_log_attributes", StringComparison.Ordinal)); + Assert.Equal(4, attributeInsert.Split("@LogId", StringSplitOptions.None).Length - 1); + Assert.DoesNotContain(queries, query => query.StartsWith("UPDATE telemetry_resources SET has_logs", StringComparison.Ordinal)); + Assert.Equal(4, context.SuccessCount); + Assert.Equal(0, context.FailureCount); + + Assert.Equal(3, repository.GetLogs(new GetLogsContext + { + ResourceKeys = [new ResourceKey("app-one", null)], + StartIndex = 0, + Count = 10, + Filters = [] + }).Items.Count); + Assert.Equal(3, repository.GetLogs(new GetLogsContext + { + ResourceKeys = [new ResourceKey("app-two", null)], + StartIndex = 0, + Count = 10, + Filters = [] + }).Items.Count); + } } diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index b0c47307621..f343d55315a 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -2,9 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Concurrent; +using System.Diagnostics; using System.Runtime.InteropServices; using System.Text; -using System.Diagnostics; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.MetricValues; using Aspire.Dashboard.Otlp.Storage; @@ -438,7 +438,7 @@ public void GetInstrumentLatestEndTime() Assert.Null(repository.GetInstrumentLatestEndTime(resourceKey, "test-meter", "missing")); } - private static Exemplar CreateExemplar(DateTime startTime, double value, IEnumerable>? attributes = null) + protected static Exemplar CreateExemplar(DateTime startTime, double value, IEnumerable>? attributes = null) { var exemplar = new Exemplar { @@ -1369,6 +1369,13 @@ public void AddMetrics_BatchesHistogramChildrenAndDimensionTrimming() .Where(activity => activity.ParentSpanId == parent.SpanId) .Select(activity => (string)activity.GetTagItem("db.query.text")!) .ToList(); + Assert.Single(queries, query => query.Contains("SELECT instrument_id", StringComparison.Ordinal)); + Assert.Single(queries, query => query.StartsWith("SELECT COUNT(*) FROM telemetry_metric_instruments", StringComparison.Ordinal)); + Assert.Equal(2, queries.Count(query => query.Contains("FROM telemetry_metric_dimensions d", StringComparison.Ordinal))); + Assert.DoesNotContain(queries, query => query.StartsWith("SELECT COUNT(*) FROM telemetry_metric_dimensions", StringComparison.Ordinal)); + var dimensionInsert = Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_dimensions", StringComparison.Ordinal)); + Assert.Equal(3, dimensionInsert.Split("INSERT INTO telemetry_metric_dimensions", StringSplitOptions.None).Length - 1); + Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_dimension_attributes", StringComparison.Ordinal)); Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_histogram_bucket_counts", StringComparison.Ordinal)); Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_histogram_explicit_bounds", StringComparison.Ordinal)); Assert.Single(queries, query => query.StartsWith("DELETE FROM telemetry_metric_points", StringComparison.Ordinal)); @@ -1376,6 +1383,61 @@ public void AddMetrics_BatchesHistogramChildrenAndDimensionTrimming() Assert.Equal(0, context.FailureCount); } + [Fact] + public void AddMetrics_BatchesAndDeduplicatesExemplars() + { + var repository = Assert.IsType(CreateRepository()); + repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField + { + CreateResourceMetrics(CreateSumMetric( + metricName: "test", + startTime: s_queryTestTime.AddMinutes(1), + value: 1, + exemplars: [CreateExemplar(s_queryTestTime.AddMinutes(1), 2, [KeyValuePair.Create("first", "value")])])) + }); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); + using var parent = new Activity("metric exemplar test").Start(); + + var context = new AddContext(); + repository.AsWriter().AddMetrics(context, new RepeatedField + { + CreateResourceMetrics(CreateSumMetric( + metricName: "test", + startTime: s_queryTestTime.AddMinutes(2), + value: 1, + exemplars: + [ + CreateExemplar(s_queryTestTime.AddMinutes(1), 2, [KeyValuePair.Create("first", "value")]), + CreateExemplar(s_queryTestTime.AddMinutes(2), 3, [KeyValuePair.Create("second", "value")]) + ])) + }); + + var queries = activities + .Where(activity => activity.ParentSpanId == parent.SpanId) + .Select(activity => (string)activity.GetTagItem("db.query.text")!) + .ToList(); + Assert.DoesNotContain(queries, query => query.Contains("SELECT EXISTS", StringComparison.Ordinal)); + var exemplarInsert = Assert.Single(queries, query => query.StartsWith("INSERT OR IGNORE INTO telemetry_metric_exemplars", StringComparison.Ordinal)); + Assert.Equal(2, exemplarInsert.Split("@PointId", StringSplitOptions.None).Length - 1); + Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_exemplar_attributes", StringComparison.Ordinal)); + Assert.Equal(1, context.SuccessCount); + Assert.Equal(0, context.FailureCount); + + var instrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = CreateResource().GetResourceKey(), + MeterName = "test-meter", + InstrumentName = "test", + StartTime = DateTime.MinValue, + EndTime = DateTime.MaxValue + }); + var value = Assert.IsType>(Assert.Single(Assert.Single(instrument!.Dimensions).Values)); + Assert.Collection(value.Exemplars, + exemplar => Assert.Equal("first", Assert.Single(exemplar.Attributes).Key), + exemplar => Assert.Equal("second", Assert.Single(exemplar.Attributes).Key)); + } + [Fact] public void AddMetrics_UpdateOnlyBatch_DoesNotTrimMetricPoints() { @@ -1418,9 +1480,12 @@ public void AddMetrics_UpdateOnlyBatch_DoesNotTrimMetricPoints() Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_scope_attributes", StringComparison.Ordinal)); Assert.DoesNotContain(queries, query => query.Contains("SELECT instrument_id", StringComparison.Ordinal)); Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_metric_dimensions d", StringComparison.Ordinal)); + Assert.DoesNotContain(queries, query => query.StartsWith("UPDATE telemetry_resources SET has_metrics", StringComparison.Ordinal)); Assert.DoesNotContain(queries, query => query.StartsWith("DELETE FROM telemetry_metric_points", StringComparison.Ordinal)); var updateQuery = Assert.Single(queries, query => query.Contains("UPDATE telemetry_metric_points", StringComparison.Ordinal)); Assert.Contains("WITH updates", updateQuery, StringComparison.Ordinal); + Assert.Contains("FROM updates", updateQuery, StringComparison.Ordinal); + Assert.DoesNotContain("SELECT end_time_ticks FROM updates", updateQuery, StringComparison.Ordinal); var instrument = repository.GetInstrument(new GetInstrumentRequest { ResourceKey = CreateResource().GetResourceKey(), diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs index 4b2e1fe582c..839d0a2bf01 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs @@ -6,6 +6,9 @@ using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.MetricValues; using Aspire.Dashboard.Otlp.Storage; +using Aspire.Tests; +using System.Collections.Concurrent; +using System.Diagnostics; using Google.Protobuf; using Google.Protobuf.Collections; using Microsoft.Data.Sqlite; @@ -23,6 +26,102 @@ public sealed class SqliteTelemetryPersistenceTests : IDisposable { private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-telemetry-persistence-tests-").FullName; + [Fact] + public void Cache_UsesCanonicalResourceViewAndScopeAcrossSignals() + { + var databasePath = Path.Combine(_temporaryDirectory, "canonical-cache.db"); + var startTime = new DateTime(2025, 4, 5, 6, 7, 8, DateTimeKind.Utc); + var resource = CreateResource(attributes: [KeyValuePair.Create("resource-key", "resource-value")]); + var scope = CreateScope(name: "SharedScope", attributes: [KeyValuePair.Create("scope-key", "scope-value")]); + using var repository = CreateRepository(databasePath); + + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = resource, + ScopeLogs = { new ScopeLogs { Scope = scope, LogRecords = { CreateLogRecord() } } } + } + }); + repository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = resource, + ScopeSpans = + { + new ScopeSpans + { + Scope = scope, + Spans = { CreateSpan("cache-trace", "cache-span", startTime, startTime.AddSeconds(1)) } + } + } + } + }); + repository.AddMetrics(new AddContext(), new RepeatedField + { + new ResourceMetrics + { + Resource = resource, + ScopeMetrics = { new ScopeMetrics { Scope = scope, Metrics = { CreateSumMetric("requests", startTime) } } } + } + }); + + var cachedResource = Assert.Single(repository.GetResources()); + var log = Assert.Single(repository.GetLogs(CreateLogsContext()).Items); + var span = Assert.Single(Assert.Single(repository.GetTraces(GetTracesRequest.ForResourceKey(cachedResource.ResourceKey)).PagedResult.Items).Spans); + var instrument = Assert.Single(repository.GetInstrumentsSummaries(cachedResource.ResourceKey)); + + Assert.Same(cachedResource, log.ResourceView.Resource); + Assert.Same(cachedResource, span.Source.Resource); + Assert.Same(log.ResourceView, span.Source); + Assert.Same(log.Scope, span.Scope); + Assert.Same(log.Scope, instrument.Parent); + } + + [Fact] + public void Cache_HydratesPersistedMetadataOnce() + { + var databasePath = Path.Combine(_temporaryDirectory, "lazy-cache.db"); + var startTime = new DateTime(2025, 4, 5, 6, 7, 8, DateTimeKind.Utc); + using (var repository = CreateRepository(databasePath)) + { + repository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(attributes: [KeyValuePair.Create("resource-key", "resource-value")]), + ScopeLogs = { new ScopeLogs { Scope = CreateScope("TestScope"), LogRecords = { CreateLogRecord() } } } + } + }); + repository.AddMetrics(new AddContext(), new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = { new ScopeMetrics { Scope = CreateScope("TestScope"), Metrics = { CreateSumMetric("requests", startTime) } } } + } + }); + } + + using var historicalRepository = CreateRepository(databasePath, readOnly: true); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(historicalRepository.SqlActivitySource, onActivityStopped: activities.Enqueue); + using var parent = new Activity("cache hydration test").Start(); + var firstResource = Assert.Single(historicalRepository.GetResources()); + Assert.NotEmpty(activities); + activities.Clear(); + + var secondResource = Assert.Single(historicalRepository.GetResources()); + var summary = Assert.Single(historicalRepository.GetInstrumentsSummaries(firstResource.ResourceKey)); + var view = Assert.Single(firstResource.GetViews()); + + Assert.Same(firstResource, secondResource); + Assert.Same(firstResource, view.Resource); + Assert.Equal("requests", summary.Name); + Assert.Empty(activities); + } + [Fact] public void Logs_ReopenFromNormalizedRowsWithStableIds() { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index 3d1881fcdee..6b9ba460c0a 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -1,11 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; +using System.Diagnostics; using System.Globalization; using System.Text; using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; +using Aspire.Tests; using Aspire.Tests.Shared.DashboardModel; using Google.Protobuf; using Google.Protobuf.Collections; @@ -2322,9 +2325,9 @@ public void AddTraces_HaveUninstrumentedPeers() Assert.Equal("TestPeer", s.UninstrumentedPeer.ResourceName); }); - var serviceNames = repository.GetTraceFieldValues(KnownResourceFields.ServiceNameField); - Assert.Equal(2, serviceNames["TestService"]); - Assert.Equal(1, serviceNames["TestPeer"]); + var serviceNames = repository.GetTraceFieldValues(KnownResourceFields.ServiceNameField); + Assert.Equal(2, serviceNames["TestService"]); + Assert.Equal(1, serviceNames["TestPeer"]); } [Fact] @@ -3675,4 +3678,89 @@ public sealed class InMemoryTraceTests : TraceTests public sealed class SqliteTraceTests : TraceTests { protected override bool UseSqlite => true; + + [Fact] + public void AddTraces_BatchesSpansAndDetailsAcrossResources() + { + var repository = Assert.IsType(CreateRepository()); + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + { + CreateResourceSpans("app-one", "trace-one", "warm-one-span"), + CreateResourceSpans("app-two", "trace-two", "warm-two-span") + }); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); + using var parent = new Activity("trace ingestion test").Start(); + + var context = new AddContext(); + repository.AsWriter().AddTraces(context, new RepeatedField + { + CreateResourceSpans("app-one", "trace-one", "span-one"), + CreateResourceSpans("app-two", "trace-two", "span-two") + }); + + var queries = activities + .Where(activity => activity.ParentSpanId == parent.SpanId) + .Select(activity => (string)activity.GetTagItem("db.query.text")!) + .ToList(); + Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_spans", StringComparison.Ordinal)); + Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_span_attributes", StringComparison.Ordinal)); + Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_span_events", StringComparison.Ordinal)); + Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_span_event_attributes", StringComparison.Ordinal)); + Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_span_links", StringComparison.Ordinal)); + Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_span_link_attributes", StringComparison.Ordinal)); + Assert.Single(queries, query => query.StartsWith("WITH peer_updates", StringComparison.Ordinal)); + Assert.Single(queries, query => + query.StartsWith("SELECT", StringComparison.Ordinal) && + query.Contains("s.trace_id AS TraceId", StringComparison.Ordinal) && + query.Contains("FROM telemetry_span_attributes", StringComparison.Ordinal)); + Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_span_events", StringComparison.Ordinal)); + Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_span_links", StringComparison.Ordinal)); + Assert.DoesNotContain(queries, query => query.StartsWith("UPDATE telemetry_resources SET has_traces", StringComparison.Ordinal)); + Assert.Equal(2, context.SuccessCount); + Assert.Equal(0, context.FailureCount); + + static ResourceSpans CreateResourceSpans(string resourceName, string traceId, string spanId) + { + var testTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + return new ResourceSpans + { + Resource = CreateResource(name: resourceName), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: traceId, + spanId: spanId, + startTime: testTime, + endTime: testTime.AddMinutes(1), + attributes: [KeyValuePair.Create("span-key", "span-value")], + events: + [ + new Span.Types.Event + { + Name = "event", + TimeUnixNano = 1, + Attributes = { new KeyValue { Key = "event-key", Value = new AnyValue { StringValue = "event-value" } } } + } + ], + links: + [ + new Span.Types.Link + { + TraceId = ByteString.CopyFromUtf8("target-trace"), + SpanId = ByteString.CopyFromUtf8("target-span"), + Attributes = { new KeyValue { Key = "link-key", Value = new AnyValue { StringValue = "link-value" } } } + } + ]) + } + } + } + }; + } + } } From c38c3b5caad2c4aa3e8817cc51a4179f42b40510 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sat, 18 Jul 2026 11:39:18 +0800 Subject: [PATCH 31/87] Address dashboard run history review feedback --- .../Model/ConsoleLogsFetcher.cs | 3 + .../Otlp/Storage/SqliteTelemetryRepository.cs | 2 +- .../ServiceClient/DashboardClient.cs | 4 + .../ServiceClient/DashboardRunStore.cs | 19 ++--- .../Aspire.Dashboard.Tests.csproj | 2 + .../Integration/HealthTests.cs | 22 ++++-- .../Model/DashboardDataSourceTests.cs | 58 +++++++++++---- .../Model/DashboardSqliteDatabaseTests.cs | 2 +- .../Model/SqliteResourceRepositoryTests.cs | 74 ++++++++++--------- .../SqliteTelemetryPersistenceTests.cs | 71 +++++++++--------- .../TelemetryRepositoryTestBase.cs | 2 +- .../Dashboard/DashboardEventHandlersTests.cs | 69 ++++++++--------- 12 files changed, 186 insertions(+), 142 deletions(-) diff --git a/src/Aspire.Dashboard/Model/ConsoleLogsFetcher.cs b/src/Aspire.Dashboard/Model/ConsoleLogsFetcher.cs index 8561e607348..cd31154ba6d 100644 --- a/src/Aspire.Dashboard/Model/ConsoleLogsFetcher.cs +++ b/src/Aspire.Dashboard/Model/ConsoleLogsFetcher.cs @@ -34,6 +34,9 @@ private async Task> FetchLogEntriesAsync(string resourceName, Dat var logEntries = new List(); var logParser = new LogParser(ConsoleColor.Black); + // Export uses the persisted snapshot, which can omit logs that weren't captured by a demand-driven + // subscription. This is a known limitation; the historical Console Logs page displays a notice when + // logs aren't available for the selected run. See https://github.com/microsoft/aspire/issues/18823. await foreach (var batch in _dataSource.ResourceRepository.GetConsoleLogs(resourceName, cancellationToken).ConfigureAwait(false)) { foreach (var logLine in batch) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index f2a83f2a28d..43a7db975b0 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -39,7 +39,7 @@ private static string EscapeLikePattern(string value) .Replace("_", "!_", StringComparison.Ordinal); } - internal ActivitySource SqlActivitySource => _database.ActivitySource; + internal ActivitySource SqlActivitySource => _database.ActivitySource; public SqliteTelemetryRepository( string databasePath, diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index 7fd195dba19..4943aff2e81 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -907,6 +907,10 @@ public async IAsyncEnumerable> SubscribeConsoleLo { EnsureInitialized(); + // Console-log persistence is demand-driven rather than always-on. This known limitation means + // historical runs can omit logs for resources that were never viewed or exported. The historical + // Console Logs page checks this capture state and displays a notice when logs aren't available. + // See https://github.com/microsoft/aspire/issues/18823. MarkConsoleLogsLoaded(resourceName); // It's ok to dispose CTS with using because this method exits after it is finished being used. diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index 27de9e4ccfb..85f35c3e29f 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -103,8 +103,7 @@ private void DeleteAbandonedTemporaryDirectories(Action deleteRunDirecto var temporaryRoot = Directory.GetParent(RunDirectory)!.FullName; foreach (var directory in Directory.EnumerateDirectories(temporaryRoot, $"{TemporaryDirectoryPrefix}*")) { - if (!IsTemporaryDatabaseDirectory(directory) || - string.Equals(directory, RunDirectory, StringComparison.OrdinalIgnoreCase) || + if (string.Equals(directory, RunDirectory, StringComparison.OrdinalIgnoreCase) || !File.Exists(Path.Combine(directory, DatabaseFileName))) { continue; @@ -130,15 +129,6 @@ private void DeleteAbandonedTemporaryDirectories(Action deleteRunDirecto } } - private static bool IsTemporaryDatabaseDirectory(string directory) - { - // Directory.CreateTempSubdirectory appends a Path.GetRandomFileName value such as "a1b2c3d4.e5f". - // Checking that exact shape avoids matching other Aspire test and tool directories with longer prefixes. - var directoryName = Path.GetFileName(directory); - var randomName = directoryName.AsSpan(TemporaryDirectoryPrefix.Length); - return randomName is { Length: 12 } && randomName[8] == '.'; - } - public string RunDirectory { get; } public string DatabasePath { get; } public string RunId => _metadata.RunId; @@ -164,6 +154,13 @@ public IReadOnlyList GetRuns() continue; } + // Filter out in-progress runs that are owned by other Dashboard instances. + using var runLock = TryOpenRunLock(directory); + if (runLock is null) + { + continue; + } + var metadataPath = Path.Combine(directory, "run.json"); try { diff --git a/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj b/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj index 6acb6e732a6..76c27150cab 100644 --- a/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj +++ b/tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj @@ -42,6 +42,7 @@ + @@ -53,6 +54,7 @@ + diff --git a/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs index 617c74cf133..770872463ec 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Concurrent; using System.Diagnostics; using System.Net; using Aspire.Dashboard.Utils; @@ -45,31 +44,38 @@ static async Task MakeRequestAndAssert(string basePath, Version httpVersion) [Fact] public async Task HealthEndpoint_OtlpExporterConfigured_ExportsAspNetCoreActivity() { - var exportedActivities = new ConcurrentQueue(); + var exportedActivity = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using var app = IntegrationTestHelpers.CreateDashboardWebApplication( testOutputHelper, config => config["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://127.0.0.1:1", builder => builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing.AddProcessor( - new SimpleActivityExportProcessor(new TestActivityExporter(exportedActivities))))); + new SimpleActivityExportProcessor(new TestActivityExporter( + exportedActivity, + activity => activity.Source.Name == "Microsoft.AspNetCore" && activity.Kind == ActivityKind.Server))))); await app.StartAsync().DefaultTimeout(); using var client = new HttpClient { BaseAddress = new Uri($"http://{app.FrontendSingleEndPointAccessor().EndPoint}") }; var response = await client.GetAsync($"/{DashboardUrls.HealthBasePath}").DefaultTimeout(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Contains(exportedActivities, activity => - activity.Source.Name == "Microsoft.AspNetCore" && - activity.Kind == ActivityKind.Server); + var activity = await exportedActivity.Task.DefaultTimeout(); + Assert.Equal("Microsoft.AspNetCore", activity.Source.Name); + Assert.Equal(ActivityKind.Server, activity.Kind); } - private sealed class TestActivityExporter(ConcurrentQueue exportedActivities) : BaseExporter + private sealed class TestActivityExporter( + TaskCompletionSource exportedActivity, + Func predicate) : BaseExporter { public override ExportResult Export(in Batch batch) { foreach (var activity in batch) { - exportedActivities.Enqueue(activity); + if (predicate(activity)) + { + exportedActivity.TrySetResult(activity); + } } return ExportResult.Success; diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index cfac025ca0a..d71e6a7a0ea 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -20,9 +20,9 @@ namespace Aspire.Dashboard.Tests.Model; -public sealed class DashboardDataSourceTests : IDisposable +public sealed class DashboardDataSourceTests(ITestOutputHelper testOutputHelper) : IDisposable { - private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-runs-tests-").FullName; + private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(testOutputHelper); [Fact] public void RunDirectory_IsNestedUnderApplicationDirectoryAndRuns() @@ -32,7 +32,7 @@ public void RunDirectory_IsNestedUnderApplicationDirectoryAndRuns() using var runStore = CreateRunStore(options); var applicationDirectoryName = DashboardRunStore.GetApplicationDirectoryName("My Dashboard"); - var expectedRunsDirectory = Path.Combine(_temporaryDirectory, applicationDirectoryName, "runs"); + var expectedRunsDirectory = Path.Combine(_workspace.Path, applicationDirectoryName, "runs"); Assert.Equal(expectedRunsDirectory, Directory.GetParent(runStore.RunDirectory)!.FullName); } @@ -50,7 +50,7 @@ public void NoneMode_UsesTemporaryDatabaseAndDeletesItOnDispose() new DashboardSqliteDatabase(databasePath).InitializeSchema(); Assert.False(runStore.SupportsRunSelection); - Assert.False(runDirectory.StartsWith(_temporaryDirectory, StringComparison.OrdinalIgnoreCase)); + Assert.False(runDirectory.StartsWith(_workspace.Path, StringComparison.OrdinalIgnoreCase)); Assert.Collection(runStore.GetRuns(), run => Assert.True(run.IsCurrent)); Assert.True(File.Exists(databasePath)); } @@ -94,9 +94,9 @@ public void NoneMode_DoesNotDeleteActiveTemporaryDirectories() } [Fact] - public void NoneMode_DoesNotDeleteOtherDashboardTemporaryDirectories() + public void NoneMode_DoesNotDeleteTemporaryDirectoriesWithOtherNames() { - var otherDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-telemetry-tests-").FullName; + var otherDirectory = Directory.CreateTempSubdirectory("unrelated-").FullName; File.WriteAllText(Path.Combine(otherDirectory, "dashboard.db"), string.Empty); try @@ -163,7 +163,7 @@ public void AppendMode_DeletesIncompatibleDatabase() [InlineData("CREATE TABLE dashboard_schema (version); INSERT INTO dashboard_schema VALUES ('invalid');")] public void IsCompatible_ReturnsFalseForMalformedSchema(string schemaSql) { - var databasePath = Path.Combine(_temporaryDirectory, $"malformed-{Guid.NewGuid():N}.db"); + var databasePath = Path.Combine(_workspace.Path, $"malformed-{Guid.NewGuid():N}.db"); using (var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False")) { connection.Open(); @@ -223,10 +223,42 @@ public void GetRuns_ReturnsCurrentThenCompletedHistoricalRun() }); } + [Fact] + public void GetRuns_ExcludesRunOwnedByAnotherDashboard() + { + var options = CreateOptions(); + string historicalRunId; + + using (var historicalRunStore = CreateRunStore(options)) + { + historicalRunId = historicalRunStore.RunId; + using var historicalTelemetryRepository = CreateTelemetryRepository(historicalRunStore.DatabasePath, options); + } + + using var activeRunStore = CreateRunStore(options); + using var activeTelemetryRepository = CreateTelemetryRepository(activeRunStore.DatabasePath, options); + using var currentRunStore = CreateRunStore(options); + using var currentTelemetryRepository = CreateTelemetryRepository(currentRunStore.DatabasePath, options); + + Assert.Collection( + currentRunStore.GetRuns(), + currentRun => + { + Assert.True(currentRun.IsCurrent); + Assert.Equal(currentRunStore.RunId, currentRun.RunId); + }, + historicalRun => + { + Assert.False(historicalRun.IsCurrent); + Assert.Equal(historicalRunId, historicalRun.RunId); + Assert.NotEqual(activeRunStore.RunId, historicalRun.RunId); + }); + } + [Fact] public void RunsMode_DeletesOldestRunWhenLimitIsExceeded() { - var applicationDirectory = Path.Combine(_temporaryDirectory, DashboardRunStore.GetApplicationDirectoryName("TestApp")); + var applicationDirectory = Path.Combine(_workspace.Path, DashboardRunStore.GetApplicationDirectoryName("TestApp")); var runsDirectory = Path.Combine(applicationDirectory, "runs"); var historicalRunDirectories = Enumerable.Range(1, DashboardRunStore.MaxRuns) .Select(index => Path.Combine( @@ -250,7 +282,7 @@ public void RunsMode_DeletesOldestRunWhenLimitIsExceeded() [Fact] public void RunsMode_DoesNotDeleteActiveExpiredRun() { - var applicationDirectory = Path.Combine(_temporaryDirectory, DashboardRunStore.GetApplicationDirectoryName("TestApp")); + var applicationDirectory = Path.Combine(_workspace.Path, DashboardRunStore.GetApplicationDirectoryName("TestApp")); var runsDirectory = Path.Combine(applicationDirectory, "runs"); var historicalRunDirectories = Enumerable.Range(1, DashboardRunStore.MaxRuns) .Select(index => Path.Combine( @@ -279,7 +311,7 @@ public void RunsMode_DoesNotDeleteActiveExpiredRun() [Fact] public void RunsMode_DeleteExpiredRunFails_LogsWarningAndContinues() { - var applicationDirectory = Path.Combine(_temporaryDirectory, DashboardRunStore.GetApplicationDirectoryName("TestApp")); + var applicationDirectory = Path.Combine(_workspace.Path, DashboardRunStore.GetApplicationDirectoryName("TestApp")); var runsDirectory = Path.Combine(applicationDirectory, "runs"); var historicalRunDirectories = Enumerable.Range(1, DashboardRunStore.MaxRuns) .Select(index => Path.Combine( @@ -336,7 +368,7 @@ public void GetRuns_ExcludesIncompatibleDatabaseWithoutDeletingIt() [Fact] public void SqliteDatabase_ConfiguresLikeAndForeignKeys() { - var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "connection.db")); + var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "connection.db")); using (var connection = database.OpenConnection()) using (var command = connection.CreateCommand()) { @@ -480,7 +512,7 @@ private IOptions CreateOptions( ApplicationName = applicationName, Data = new DashboardDataOptions { - Directory = _temporaryDirectory, + Directory = _workspace.Path, PersistenceMode = persistenceMode } }); @@ -521,6 +553,6 @@ private static RepositoryFactory CreateRepositoryFactory( public void Dispose() { - Directory.Delete(_temporaryDirectory, recursive: true); + _workspace.Dispose(); } } \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs index dd4830847fb..d6da32afb21 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs @@ -13,7 +13,7 @@ namespace Aspire.Dashboard.Tests.Model; public sealed class DashboardSqliteDatabaseTests : IDisposable { - private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-sqlite-tests-").FullName; + private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-tests-dashboard-sqlite-").FullName; [Fact] public async Task DapperQuery_CreatesActivityWithQueryInformation() diff --git a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs index 45d10e5e1b9..95d15fad8fd 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs @@ -10,17 +10,15 @@ namespace Aspire.Dashboard.Tests.Model; -public sealed class SqliteResourceRepositoryTests : IDisposable +public sealed class SqliteResourceRepositoryTests(ITestOutputHelper testOutputHelper) { - private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-resource-tests-").FullName; - [Fact] public void Resources_PersistAndReplayWithEquivalentValues() { - var databasePath = Path.Combine(_temporaryDirectory, "dashboard.db"); + using var workspace = TemporaryWorkspace.Create(testOutputHelper); var resource = CreateResource("api-123", "api"); - using (var repository = CreateRepository(databasePath)) + using (var repository = CreateRepository(workspace.Path)) { var writer = (IResourceRepositoryWriter)repository; writer.ReplaceResources([resource]); @@ -33,14 +31,15 @@ public void Resources_PersistAndReplayWithEquivalentValues() Assert.Equal("Running", repository.GetResource(resource.Name)!.State); } - using var historicalRepository = CreateRepository(databasePath, readOnly: true); + using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); AssertResource(Assert.Single(historicalRepository.GetResources()), resource, replicaIndex: 1, state: "Running"); } [Fact] public async Task ResourceSubscription_ReceivesUpsertAndDelete() { - using var repository = CreateRepository(Path.Combine(_temporaryDirectory, "subscription.db")); + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + using var repository = CreateRepository(workspace.Path); var writer = (IResourceRepositoryWriter)repository; var subscription = await repository.SubscribeResourcesAsync(CancellationToken.None); Assert.Empty(subscription.InitialState); @@ -62,8 +61,8 @@ public async Task ResourceSubscription_ReceivesUpsertAndDelete() [Fact] public async Task ConsoleLogs_UseInsertionOrderAndAllowLineNumbersToRestart() { - var databasePath = Path.Combine(_temporaryDirectory, "console.db"); - using (var repository = CreateRepository(databasePath)) + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + using (var repository = CreateRepository(workspace.Path)) { var writer = (IResourceRepositoryWriter)repository; writer.AddConsoleLogs("api", [ @@ -73,14 +72,14 @@ public async Task ConsoleLogs_UseInsertionOrderAndAllowLineNumbersToRestart() writer.AddConsoleLogs("api", [new ConsoleLogLine { LineNumber = 2, Text = "second-updated", IsStdErr = true }]); } - using (var restartedRepository = CreateRepository(databasePath)) + using (var restartedRepository = CreateRepository(workspace.Path)) { ((IResourceRepositoryWriter)restartedRepository).AddConsoleLogs( "api", [new ConsoleLogLine { LineNumber = 1, Text = "first-after-restart" }]); } - using var historicalRepository = CreateRepository(databasePath, readOnly: true); + using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); var batches = new List>(); await foreach (var batch in historicalRepository.GetConsoleLogs("api", CancellationToken.None)) { @@ -96,8 +95,8 @@ public async Task ConsoleLogs_UseInsertionOrderAndAllowLineNumbersToRestart() [Fact] public void ConsoleLogsLoaded_PersistsWithoutLogLines() { - var databasePath = Path.Combine(_temporaryDirectory, "console-loaded.db"); - using (var repository = CreateRepository(databasePath)) + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + using (var repository = CreateRepository(workspace.Path)) { var writer = (IResourceRepositoryWriter)repository; writer.ReplaceResources([CreateResource("api", "api"), CreateResource("worker", "worker")]); @@ -115,7 +114,7 @@ public void ConsoleLogsLoaded_PersistsWithoutLogLines() Assert.True(repository.HaveConsoleLogsBeenLoaded("api")); } - using var historicalRepository = CreateRepository(databasePath, readOnly: true); + using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); Assert.True(historicalRepository.HaveConsoleLogsBeenLoaded("api")); Assert.False(historicalRepository.HaveConsoleLogsBeenLoaded("worker")); } @@ -123,7 +122,7 @@ public void ConsoleLogsLoaded_PersistsWithoutLogLines() [Fact] public void Resources_AllFieldsAndRecursiveValuesRoundTrip() { - var databasePath = Path.Combine(_temporaryDirectory, "all-fields.db"); + using var workspace = TemporaryWorkspace.Create(testOutputHelper); var nestedValue = new Value { StructValue = new Struct @@ -219,12 +218,12 @@ public void Resources_AllFieldsAndRecursiveValuesRoundTrip() } }); - using (var repository = CreateRepository(databasePath)) + using (var repository = CreateRepository(workspace.Path)) { ((IResourceRepositoryWriter)repository).ReplaceResources([resource]); } - using var historicalRepository = CreateRepository(databasePath, readOnly: true); + using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); var actual = Assert.Single(historicalRepository.GetResources()); Assert.Equal("Running", actual.State); Assert.Equal("success", actual.StateStyle); @@ -258,19 +257,19 @@ public void Resources_AllFieldsAndRecursiveValuesRoundTrip() [Fact] public void Resources_BulkLoadKeepsChildRecordsIsolated() { - var databasePath = Path.Combine(_temporaryDirectory, "multiple-resources.db"); + using var workspace = TemporaryWorkspace.Create(testOutputHelper); var resources = new[] { CreateResourceWithChildren("api", "API", "api-value"), CreateResourceWithChildren("worker", "Worker", "worker-value") }; - using (var repository = CreateRepository(databasePath)) + using (var repository = CreateRepository(workspace.Path)) { ((IResourceRepositoryWriter)repository).ReplaceResources(resources); } - using var historicalRepository = CreateRepository(databasePath, readOnly: true); + using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); var actualResources = historicalRepository.GetResources().OrderBy(resource => resource.Name).ToList(); Assert.Collection(actualResources, resource => AssertResourceChildren(resource, "api-value"), @@ -280,8 +279,9 @@ public void Resources_BulkLoadKeepsChildRecordsIsolated() [Fact] public void Schema_HasNoSerializedResourceColumns() { - var databasePath = Path.Combine(_temporaryDirectory, "schema.db"); - using (CreateRepository(databasePath)) + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); + using (CreateRepository(workspace.Path)) { } @@ -306,8 +306,9 @@ FROM pragma_table_info('dashboard_resources') [Fact] public void Schema_ResourceRepositoryInitializesAllEmbeddedScripts() { - var databasePath = Path.Combine(_temporaryDirectory, "complete-schema.db"); - using (CreateRepository(databasePath)) + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); + using (CreateRepository(workspace.Path)) { } @@ -346,8 +347,9 @@ FROM sqlite_schema [Fact] public void Schema_TelemetryResourceInstanceIdUniquenessPreservesNullAndEmpty() { - var databasePath = Path.Combine(_temporaryDirectory, "resource-instance-ids.db"); - using (CreateRepository(databasePath)) + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); + using (CreateRepository(workspace.Path)) { } @@ -369,8 +371,9 @@ public void Schema_TelemetryResourceInstanceIdUniquenessPreservesNullAndEmpty() [Fact] public void Schema_AllDashboardTablesAreStrict() { - var databasePath = Path.Combine(_temporaryDirectory, "strict-schema.db"); - using (CreateRepository(databasePath)) + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); + using (CreateRepository(workspace.Path)) { } @@ -397,9 +400,15 @@ AND name NOT LIKE 'sqlite_%' Assert.Empty(nonStrictTableNames); } - private static SqliteResourceRepository CreateRepository(string databasePath, bool readOnly = false) + private static string GetDatabasePath(string workspacePath) => Path.Combine(workspacePath, "dashboard.db"); + + private static SqliteResourceRepository CreateRepository(string workspacePath, bool readOnly = false) { - return new SqliteResourceRepository(databasePath, new MockKnownPropertyLookup(), NullLoggerFactory.Instance, readOnly); + return new SqliteResourceRepository( + GetDatabasePath(workspacePath), + new MockKnownPropertyLookup(), + NullLoggerFactory.Instance, + readOnly); } private static Resource CreateResource(string name, string displayName) @@ -455,9 +464,4 @@ private static void AssertResource(global::Aspire.Dashboard.Model.ResourceViewMo Assert.Equal(replicaIndex, actual.ReplicaIndex); Assert.Equal(state, actual.State); } - - public void Dispose() - { - Directory.Delete(_temporaryDirectory, recursive: true); - } } \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs index 839d0a2bf01..44e4f854fcc 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs @@ -22,18 +22,16 @@ namespace Aspire.Dashboard.Tests.TelemetryRepositoryTests; -public sealed class SqliteTelemetryPersistenceTests : IDisposable +public sealed class SqliteTelemetryPersistenceTests(ITestOutputHelper testOutputHelper) { - private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-telemetry-persistence-tests-").FullName; - [Fact] public void Cache_UsesCanonicalResourceViewAndScopeAcrossSignals() { - var databasePath = Path.Combine(_temporaryDirectory, "canonical-cache.db"); + using var workspace = TemporaryWorkspace.Create(testOutputHelper); var startTime = new DateTime(2025, 4, 5, 6, 7, 8, DateTimeKind.Utc); var resource = CreateResource(attributes: [KeyValuePair.Create("resource-key", "resource-value")]); var scope = CreateScope(name: "SharedScope", attributes: [KeyValuePair.Create("scope-key", "scope-value")]); - using var repository = CreateRepository(databasePath); + using var repository = CreateRepository(workspace.Path); repository.AddLogs(new AddContext(), new RepeatedField { @@ -82,9 +80,9 @@ public void Cache_UsesCanonicalResourceViewAndScopeAcrossSignals() [Fact] public void Cache_HydratesPersistedMetadataOnce() { - var databasePath = Path.Combine(_temporaryDirectory, "lazy-cache.db"); + using var workspace = TemporaryWorkspace.Create(testOutputHelper); var startTime = new DateTime(2025, 4, 5, 6, 7, 8, DateTimeKind.Utc); - using (var repository = CreateRepository(databasePath)) + using (var repository = CreateRepository(workspace.Path)) { repository.AddLogs(new AddContext(), new RepeatedField { @@ -104,7 +102,7 @@ public void Cache_HydratesPersistedMetadataOnce() }); } - using var historicalRepository = CreateRepository(databasePath, readOnly: true); + using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); var activities = new ConcurrentQueue(); using var listener = ActivityListenerHelper.Create(historicalRepository.SqlActivitySource, onActivityStopped: activities.Enqueue); using var parent = new Activity("cache hydration test").Start(); @@ -125,9 +123,10 @@ public void Cache_HydratesPersistedMetadataOnce() [Fact] public void Logs_ReopenFromNormalizedRowsWithStableIds() { - var databasePath = Path.Combine(_temporaryDirectory, "dashboard.db"); + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); long logId; - using (var repository = CreateRepository(databasePath)) + using (var repository = CreateRepository(workspace.Path)) { repository.AddLogs(new AddContext(), new RepeatedField { @@ -149,7 +148,7 @@ public void Logs_ReopenFromNormalizedRowsWithStableIds() logId = log.InternalId; } - using (var historicalRepository = CreateRepository(databasePath, readOnly: true)) + using (var historicalRepository = CreateRepository(workspace.Path, readOnly: true)) { var log = Assert.Single(historicalRepository.GetLogs(CreateLogsContext()).Items); Assert.Equal(logId, log.InternalId); @@ -170,7 +169,8 @@ public void Logs_ReopenFromNormalizedRowsWithStableIds() [Fact] public void Traces_ReopenFromNormalizedRowsWithStableEventIds() { - var databasePath = Path.Combine(_temporaryDirectory, "dashboard.db"); + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); var startTime = new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc); var link = new Span.Types.Link { @@ -179,7 +179,7 @@ public void Traces_ReopenFromNormalizedRowsWithStableEventIds() TraceState = "state" }; Guid eventId; - using (var repository = CreateRepository(databasePath)) + using (var repository = CreateRepository(workspace.Path)) { repository.AddTraces(new AddContext(), new RepeatedField { @@ -212,7 +212,7 @@ public void Traces_ReopenFromNormalizedRowsWithStableEventIds() eventId = Assert.Single(trace.FirstSpan.Events).InternalId; } - using (var historicalRepository = CreateRepository(databasePath, readOnly: true)) + using (var historicalRepository = CreateRepository(workspace.Path, readOnly: true)) { var trace = Assert.Single(historicalRepository.GetTraces(GetTracesRequest.ForResourceKey(new ResourceKey("TestService", "TestId"))).PagedResult.Items); Assert.Equal("TestSource", trace.FirstSpan.Scope.Name); @@ -237,9 +237,10 @@ public void Traces_ReopenFromNormalizedRowsWithStableEventIds() [Fact] public void Metrics_ReopenFromNormalizedRows() { - var databasePath = Path.Combine(_temporaryDirectory, "dashboard.db"); + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); var startTime = new DateTime(2025, 2, 3, 4, 5, 6, DateTimeKind.Utc); - using (var repository = CreateRepository(databasePath)) + using (var repository = CreateRepository(workspace.Path)) { repository.AddMetrics(new AddContext(), new RepeatedField { @@ -258,7 +259,7 @@ public void Metrics_ReopenFromNormalizedRows() }); } - using (var historicalRepository = CreateRepository(databasePath, readOnly: true)) + using (var historicalRepository = CreateRepository(workspace.Path, readOnly: true)) { var resourceKey = new ResourceKey("TestService", "TestId"); var summary = Assert.Single(historicalRepository.GetInstrumentsSummaries(resourceKey)); @@ -288,9 +289,10 @@ public void Metrics_ReopenFromNormalizedRows() [Fact] public void Metrics_EquivalentAttributesShareIndexedDimension() { - var databasePath = Path.Combine(_temporaryDirectory, "metric-dimensions.db"); + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); var startTime = new DateTime(2025, 2, 3, 4, 5, 6, DateTimeKind.Utc); - using (var repository = CreateRepository(databasePath)) + using (var repository = CreateRepository(workspace.Path)) { var addContext = new AddContext(); foreach (var attributes in new[] @@ -331,10 +333,11 @@ public void Metrics_EquivalentAttributesShareIndexedDimension() [Fact] public void Scopes_AreSharedAcrossLogsTracesAndMetrics() { - var databasePath = Path.Combine(_temporaryDirectory, "shared-scopes.db"); + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); var startTime = new DateTime(2025, 3, 4, 5, 6, 7, DateTimeKind.Utc); var scope = CreateScope(name: "SharedScope", attributes: [KeyValuePair.Create("scope-key", "scope-value")]); - using (var repository = CreateRepository(databasePath)) + using (var repository = CreateRepository(workspace.Path)) { repository.AddLogs(new AddContext(), new RepeatedField { @@ -422,8 +425,9 @@ FROM sqlite_schema [Fact] public void ResourceViews_EquivalentAttributesShareNormalizedRows() { - var databasePath = Path.Combine(_temporaryDirectory, "resource-views.db"); - using (var repository = CreateRepository(databasePath)) + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); + using (var repository = CreateRepository(workspace.Path)) { var addContext = new AddContext(); repository.AddLogs(addContext, new RepeatedField @@ -468,8 +472,9 @@ public void ResourceViews_EquivalentAttributesShareNormalizedRows() [Fact] public void ResourceViews_LimitRejectsNewNormalizedRow() { - var databasePath = Path.Combine(_temporaryDirectory, "resource-view-limit.db"); - using var repository = CreateRepository(databasePath); + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); + using var repository = CreateRepository(workspace.Path); var addContext = new AddContext(); repository.AddLogs(addContext, new RepeatedField { @@ -530,10 +535,11 @@ FROM telemetry_resources [Fact] public void Scopes_AreDeletedAfterTheirFinalOwnerIsCleared() { - var databasePath = Path.Combine(_temporaryDirectory, "scope-cleanup.db"); + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); var startTime = new DateTime(2025, 3, 4, 5, 6, 7, DateTimeKind.Utc); var scope = CreateScope("SharedScope"); - using var repository = CreateRepository(databasePath); + using var repository = CreateRepository(workspace.Path); repository.AddLogs(new AddContext(), new RepeatedField { new ResourceLogs @@ -590,10 +596,12 @@ private static long GetScopeCount(string databasePath) return (long)command.ExecuteScalar()!; } - private static SqliteTelemetryRepository CreateRepository(string databasePath, bool readOnly = false) + private static string GetDatabasePath(string workspacePath) => Path.Combine(workspacePath, "dashboard.db"); + + private static SqliteTelemetryRepository CreateRepository(string workspacePath, bool readOnly = false) { return new SqliteTelemetryRepository( - databasePath, + GetDatabasePath(workspacePath), NullLoggerFactory.Instance, Options.Create(new DashboardOptions()), new PauseManager(), @@ -611,9 +619,4 @@ private static GetLogsContext CreateLogsContext() Filters = [] }; } - - public void Dispose() - { - Directory.Delete(_temporaryDirectory, recursive: true); - } } \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTestBase.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTestBase.cs index 91b9f6f528d..0725181015b 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTestBase.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTestBase.cs @@ -45,7 +45,7 @@ protected ITelemetryRepository CreateRepository( ITelemetryRepository repository; if (UseSqlite) { - var temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-telemetry-tests-").FullName; + var temporaryDirectory = Directory.CreateTempSubdirectory("aspire-tests-dashboard-telemetry-").FullName; _temporaryDirectories.Add(temporaryDirectory); var sqliteRepository = new SqliteTelemetryRepository( Path.Combine(temporaryDirectory, "dashboard.db"), diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs index 7020efb7620..cfe6ef4fde9 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs @@ -320,47 +320,40 @@ public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRoo string? configuredPersistenceModeEnvironmentAlias, string expectedPersistenceMode) { - var storeDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-store-tests-"); - try - { - var resourceLoggerService = new ResourceLoggerService(); - var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Aspire:Store:Path"] = storeDirectory.FullName, - ["AppHost:DashboardApplicationName"] = "My App.AppHost", - ["Aspire:Dashboard:PersistenceMode"] = configuredPersistenceMode, - [DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = configuredPersistenceModeEnvironmentAlias - }) - .Build(); - var dashboardOptions = Options.Create(new DashboardOptions - { - DashboardPath = "test.dll", - DashboardUrl = "http://localhost:8080", - OtlpGrpcEndpointUrl = "http://localhost:4317", - }); - var hook = CreateHook(resourceLoggerService, resourceNotificationService, configuration, dashboardOptions: dashboardOptions); - var environmentVariables = new Dictionary(); - var dashboardResource = new ExecutableResource("aspire-dashboard", "dashboard.exe", "."); - var model = new DistributedApplicationModel([dashboardResource]); - var context = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var resourceLoggerService = new ResourceLoggerService(); + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { - Services = new TestServiceProvider().AddService(model) - }); + ["Aspire:Store:Path"] = workspace.Path, + ["AppHost:DashboardApplicationName"] = "My App.AppHost", + ["Aspire:Dashboard:PersistenceMode"] = configuredPersistenceMode, + [DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = configuredPersistenceModeEnvironmentAlias + }) + .Build(); + var dashboardOptions = Options.Create(new DashboardOptions + { + DashboardPath = "test.dll", + DashboardUrl = "http://localhost:8080", + OtlpGrpcEndpointUrl = "http://localhost:4317", + }); + var hook = CreateHook(resourceLoggerService, resourceNotificationService, configuration, dashboardOptions: dashboardOptions); + var environmentVariables = new Dictionary(); + var dashboardResource = new ExecutableResource("aspire-dashboard", "dashboard.exe", "."); + var model = new DistributedApplicationModel([dashboardResource]); + var context = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) + { + Services = new TestServiceProvider().AddService(model) + }); - await hook.ConfigureEnvironmentVariables(new EnvironmentCallbackContext(context, environmentVariables: environmentVariables, resource: dashboardResource)); + await hook.ConfigureEnvironmentVariables(new EnvironmentCallbackContext(context, environmentVariables: environmentVariables, resource: dashboardResource)); - Assert.Equal( - Path.Combine(storeDirectory.FullName, ".aspire", "dashboard"), - environmentVariables[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName]); - Assert.Equal("My App", environmentVariables[DashboardConfigNames.DashboardApplicationName.EnvVarName]); - Assert.Equal(expectedPersistenceMode, environmentVariables[DashboardConfigNames.DashboardPersistenceModeName.EnvVarName]); - } - finally - { - storeDirectory.Delete(recursive: true); - } + Assert.Equal( + Path.Combine(workspace.Path, ".aspire", "dashboard"), + environmentVariables[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName]); + Assert.Equal("My App", environmentVariables[DashboardConfigNames.DashboardApplicationName.EnvVarName]); + Assert.Equal(expectedPersistenceMode, environmentVariables[DashboardConfigNames.DashboardPersistenceModeName.EnvVarName]); } [Theory] From 8cb31bac1b2e679dd2a954b1150525027c574bd7 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sat, 18 Jul 2026 11:41:21 +0800 Subject: [PATCH 32/87] Clean up --- src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs index f475d2d6f88..408613be958 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs @@ -41,7 +41,7 @@ public abstract class ChartBase : ComponentBase, IAsyncDisposable public required BrowserTimeProvider TimeProvider { get; init; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; From 1642af8cd454dd85bb0343f59061feef6cb14bc8 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sat, 18 Jul 2026 13:29:12 +0800 Subject: [PATCH 33/87] Optimize dashboard SQLite metric ingestion --- .../SqliteTelemetryRepository.Caching.cs | 158 ++++++-- .../SqliteTelemetryRepository.Metrics.cs | 39 +- .../ServiceClient/DashboardSqliteDatabase.cs | 5 +- .../ServiceClient/TracingSqliteConnection.cs | 373 +++++++++++++++--- .../Model/DashboardSqliteDatabaseTests.cs | 61 +++ .../TelemetryRepositoryTests/MetricsTests.cs | 8 +- 6 files changed, 551 insertions(+), 93 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs index 4c994d25150..7c0dfff5e9a 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs @@ -15,6 +15,8 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed partial class SqliteTelemetryRepository { + private const int MaxInstrumentBatchSize = 100; + private readonly object _cacheLock = new(); private readonly Dictionary _cachedResourcesByKey = []; private readonly Dictionary _cachedResourcesById = []; @@ -250,34 +252,10 @@ private CachedInstrument GetOrAddCachedInstrument( return instrument; } - if (!resourceScope.InstrumentsLoaded) + EnsureCachedInstrumentsLoaded(connection, transaction, resource, resourceScope); + if (resourceScope.Instruments.TryGetValue(metric.Name, out instrument)) { - var records = connection.Query(""" - SELECT instrument_id AS InstrumentId, - resource_id AS ResourceId, - scope_id AS ScopeId, - instrument_name AS InstrumentName, - description AS Description, - unit AS Unit, - instrument_type AS InstrumentType, - aggregation_temporality AS AggregationTemporality, - has_overflow AS HasOverflow - FROM telemetry_metric_instruments - WHERE resource_id = @ResourceId AND scope_id = @ScopeId; - """, new - { - resource.ResourceId, - ScopeId = resourceScope.Scope.ScopeId - }, transaction); - foreach (var loadedRecord in records) - { - AddCachedInstrument(resourceScope, loadedRecord); - } - resourceScope.InstrumentsLoaded = true; - if (resourceScope.Instruments.TryGetValue(metric.Name, out instrument)) - { - return instrument; - } + return instrument; } var instrumentCount = resource.InstrumentCount ??= connection.QuerySingle( @@ -321,10 +299,130 @@ INSERT INTO telemetry_metric_instruments ( HasOverflow = false }; resource.InstrumentCount++; + _metricIngestionState.LoadedDimensionInstruments.Add(instrumentId); + _metricIngestionState.DimensionCounts[instrumentId] = 0; return AddCachedInstrument(resourceScope, record); } } + private void EnsureCachedInstruments( + SqliteConnection connection, + IDbTransaction transaction, + CachedResource resource, + CachedResourceScope resourceScope, + RepeatedField metrics) + { + lock (_cacheLock) + { + EnsureCachedInstrumentsLoaded(connection, transaction, resource, resourceScope); + + var pendingMetrics = metrics + .Where(metric => !string.IsNullOrEmpty(metric.Name) && metric.DataCase is + Metric.DataOneofCase.Gauge or Metric.DataOneofCase.Sum or Metric.DataOneofCase.Histogram) + .DistinctBy(metric => metric.Name, StringComparer.Ordinal) + .Where(metric => !resourceScope.Instruments.ContainsKey(metric.Name)) + .ToList(); + if (pendingMetrics.Count == 0) + { + return; + } + + var instrumentCount = resource.InstrumentCount ??= connection.QuerySingle( + "SELECT COUNT(*) FROM telemetry_metric_instruments WHERE resource_id = @ResourceId;", + new { resource.ResourceId }, + transaction); + var availableCount = Math.Max(0, TelemetryRepositoryLimits.MaxInstrumentCount - instrumentCount); + foreach (var batch in pendingMetrics.Take(availableCount).Chunk(MaxInstrumentBatchSize)) + { + var sql = new StringBuilder(""" + INSERT INTO telemetry_metric_instruments ( + resource_id, scope_id, instrument_name, description, unit, instrument_type, + aggregation_temporality, is_monotonic) + VALUES + """); + var parameters = new DynamicParameters(); + parameters.Add("ResourceId", resource.ResourceId); + parameters.Add("ScopeId", resourceScope.Scope.ScopeId); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@ResourceId, @ScopeId, @InstrumentName{index}, @Description{index}, @Unit{index}, @InstrumentType{index}, @AggregationTemporality{index}, @IsMonotonic{index})"); + parameters.Add($"InstrumentName{index}", batch[index].Name); + parameters.Add($"Description{index}", batch[index].Description); + parameters.Add($"Unit{index}", batch[index].Unit); + parameters.Add($"InstrumentType{index}", (int)MapMetricType(batch[index].DataCase)); + parameters.Add($"AggregationTemporality{index}", (int)MapAggregationTemporality(batch[index])); + parameters.Add($"IsMonotonic{index}", batch[index].DataCase == Metric.DataOneofCase.Sum && batch[index].Sum.IsMonotonic); + } + sql.Append(""" + + RETURNING instrument_id AS InstrumentId, instrument_name AS InstrumentName; + """); + + var metricsByName = batch.ToDictionary(metric => metric.Name, StringComparer.Ordinal); + var insertedRecords = connection.Query(sql.ToString(), parameters, transaction).ToList(); + + foreach (var insertedRecord in insertedRecords) + { + var metric = metricsByName[insertedRecord.InstrumentName]; + AddCachedInstrument(resourceScope, new CachedInstrumentRecord + { + InstrumentId = insertedRecord.InstrumentId, + ResourceId = resource.ResourceId, + ScopeId = resourceScope.Scope.ScopeId, + InstrumentName = metric.Name, + Description = metric.Description, + Unit = metric.Unit, + InstrumentType = (int)MapMetricType(metric.DataCase), + AggregationTemporality = (int)MapAggregationTemporality(metric), + HasOverflow = false + }); + _metricIngestionState.LoadedDimensionInstruments.Add(insertedRecord.InstrumentId); + _metricIngestionState.DimensionCounts[insertedRecord.InstrumentId] = 0; + } + resource.InstrumentCount += insertedRecords.Count; + } + } + } + + private void EnsureCachedInstrumentsLoaded( + SqliteConnection connection, + IDbTransaction transaction, + CachedResource resource, + CachedResourceScope resourceScope) + { + if (resourceScope.InstrumentsLoaded) + { + return; + } + + var records = connection.Query(""" + SELECT instrument_id AS InstrumentId, + resource_id AS ResourceId, + scope_id AS ScopeId, + instrument_name AS InstrumentName, + description AS Description, + unit AS Unit, + instrument_type AS InstrumentType, + aggregation_temporality AS AggregationTemporality, + has_overflow AS HasOverflow + FROM telemetry_metric_instruments + WHERE resource_id = @ResourceId AND scope_id = @ScopeId; + """, new + { + resource.ResourceId, + ScopeId = resourceScope.Scope.ScopeId + }, transaction); + foreach (var loadedRecord in records) + { + AddCachedInstrument(resourceScope, loadedRecord); + } + resourceScope.InstrumentsLoaded = true; + } + private void ClearIngestionCaches() { ClearMetadataCache(); @@ -729,4 +827,10 @@ private sealed class CachedInstrumentRecord public required int AggregationTemporality { get; init; } public required bool HasOverflow { get; init; } } + + private sealed class InsertedInstrumentRecord + { + public required long InstrumentId { get; init; } + public required string InstrumentName { get; init; } + } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs index 87711abd03a..a8a96d245ca 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs @@ -66,6 +66,7 @@ private void AddMetricsToDatabase(AddContext context, RepeatedField 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@InstrumentId{index}, @AttributeHash{index})"); parameters.Add($"InstrumentId{index}", batch[index].InstrumentId); parameters.Add($"AttributeHash{index}", batch[index].AttributeHash); } + sql.Append(""" - using var reader = connection.QueryMultiple(sql.ToString(), parameters, transaction); - for (var index = 0; index < batch.Length; index++) + RETURNING dimension_id AS DimensionId, instrument_id AS InstrumentId, attribute_hash AS AttributeHash; + """); + + // Hash collisions can produce indistinguishable inserted rows. Their IDs are interchangeable here + // because attributes and points are associated only after an ID is assigned to each pending dimension. + var pendingDimensions = batch + .GroupBy(dimension => (dimension.InstrumentId, dimension.AttributeHash)) + .ToDictionary(group => group.Key, group => new Queue(group)); + foreach (var insertedDimension in connection.Query(sql.ToString(), parameters, transaction)) { - batch[index].Dimension.DimensionId = reader.ReadSingle(); + pendingDimensions[(insertedDimension.InstrumentId, insertedDimension.AttributeHash)] + .Dequeue() + .Dimension.DimensionId = insertedDimension.DimensionId; } } } @@ -1100,6 +1114,13 @@ public void Clear() private sealed record PendingMetricDimension(long InstrumentId, long AttributeHash, MetricDimensionState Dimension); + private sealed class InsertedMetricDimensionRecord + { + public required long DimensionId { get; init; } + public required long InstrumentId { get; init; } + public required long AttributeHash { get; init; } + } + private sealed record PendingMetricDimensionAttribute(MetricDimensionState Dimension, int Ordinal, string Key, string Value); private sealed class MetricDimensionState diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index 541204326a6..25128c9016d 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Dapper; +using System.Data; using System.Diagnostics; using Microsoft.Data.Sqlite; @@ -76,7 +77,7 @@ ELSE NULL } } - public SqliteConnection OpenConnection() + public TracingSqliteConnection OpenConnection() { var connection = new TracingSqliteConnection(_connectionString, DatabasePath, _activitySource); connection.Open(); @@ -165,7 +166,7 @@ private static IReadOnlyList LoadSchemaScripts() return scripts; } - private static void ValidateSchemaVersion(SqliteConnection connection, SqliteTransaction? transaction) + private static void ValidateSchemaVersion(SqliteConnection connection, IDbTransaction? transaction) { var version = connection.QuerySingle("SELECT version FROM dashboard_schema;", transaction: transaction); if (version != SchemaVersion) diff --git a/src/Aspire.Dashboard/ServiceClient/TracingSqliteConnection.cs b/src/Aspire.Dashboard/ServiceClient/TracingSqliteConnection.cs index 68b3b9b51c7..3d322b2b63b 100644 --- a/src/Aspire.Dashboard/ServiceClient/TracingSqliteConnection.cs +++ b/src/Aspire.Dashboard/ServiceClient/TracingSqliteConnection.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections; using System.Data; using System.Data.Common; using System.Diagnostics; @@ -17,10 +18,160 @@ internal sealed class TracingSqliteConnection(string connectionString, string da { internal const string ActivitySourceName = "Aspire.Dashboard.Sqlite"; + internal new TracingSqliteTransaction BeginTransaction() => + new(base.BeginTransaction(), Path.GetFileName(databasePath), activitySource); + + internal new TracingSqliteTransaction BeginTransaction(IsolationLevel isolationLevel) => + new(base.BeginTransaction(isolationLevel), Path.GetFileName(databasePath), activitySource); + protected override DbCommand CreateDbCommand() => new TracingDbCommand(base.CreateDbCommand(), Path.GetFileName(databasePath), activitySource); + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => BeginTransaction(isolationLevel); + + private static Activity? StartActivity(string query, string databaseName, ActivitySource activitySource) + { + var operationName = GetOperationName(query); + var activity = activitySource.StartActivity( + operationName is null ? "sqlite query" : $"{operationName} sqlite", + ActivityKind.Client); + if (activity is not null) + { + activity.SetTag("db.system.name", "sqlite"); + activity.SetTag(OtlpSpan.PeerServiceAttributeKey, databaseName); + activity.SetTag("db.namespace", databaseName); + activity.SetTag("db.query.text", query); + activity.SetTag("db.operation.name", operationName); + } + + return activity; + } + + private static string? GetOperationName(string query) + { + var querySpan = query.AsSpan(); + while (true) + { + querySpan = querySpan.TrimStart(); + + // Embedded schema scripts start with license comments before the first SQL statement. + if (querySpan.StartsWith("--")) + { + var lineEndIndex = querySpan.IndexOfAny('\r', '\n'); + if (lineEndIndex < 0) + { + return null; + } + + querySpan = querySpan[(lineEndIndex + 1)..]; + continue; + } + + if (querySpan.StartsWith("/*")) + { + var commentEndIndex = querySpan.IndexOf("*/"); + if (commentEndIndex < 0) + { + return null; + } + + querySpan = querySpan[(commentEndIndex + 2)..]; + continue; + } + + break; + } + + var separatorIndex = querySpan.IndexOfAny(" \t\r\n;"); + var operationSpan = separatorIndex >= 0 ? querySpan[..separatorIndex] : querySpan; + return operationSpan.IsEmpty ? null : operationSpan.ToString().ToUpperInvariant(); + } + + private static void RecordException(Activity? activity, Exception exception) + { + activity?.SetTag("error.type", exception.GetType().FullName); + activity?.SetStatus(ActivityStatusCode.Error, exception.Message); + } + + internal sealed class TracingSqliteTransaction(SqliteTransaction transaction, string databaseName, ActivitySource activitySource) : DbTransaction + { + internal SqliteTransaction InnerTransaction => transaction; + + public override IsolationLevel IsolationLevel => transaction.IsolationLevel; + + protected override DbConnection? DbConnection => transaction.Connection; + + public override bool SupportsSavepoints => transaction.SupportsSavepoints; + + public override void Commit() => ExecuteWithActivity("COMMIT;", transaction.Commit); + + public override Task CommitAsync(CancellationToken cancellationToken = default) => + ExecuteWithActivityAsync("COMMIT;", () => transaction.CommitAsync(cancellationToken)); + + public override void Rollback() => ExecuteWithActivity("ROLLBACK;", transaction.Rollback); + + public override Task RollbackAsync(CancellationToken cancellationToken = default) => + ExecuteWithActivityAsync("ROLLBACK;", () => transaction.RollbackAsync(cancellationToken)); + + public override void Save(string savepointName) => transaction.Save(savepointName); + + public override Task SaveAsync(string savepointName, CancellationToken cancellationToken = default) => + transaction.SaveAsync(savepointName, cancellationToken); + + public override void Rollback(string savepointName) => transaction.Rollback(savepointName); + + public override Task RollbackAsync(string savepointName, CancellationToken cancellationToken = default) => + transaction.RollbackAsync(savepointName, cancellationToken); + + public override void Release(string savepointName) => transaction.Release(savepointName); + + public override Task ReleaseAsync(string savepointName, CancellationToken cancellationToken = default) => + transaction.ReleaseAsync(savepointName, cancellationToken); + + public override ValueTask DisposeAsync() => transaction.DisposeAsync(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + transaction.Dispose(); + } + + base.Dispose(disposing); + } + + private void ExecuteWithActivity(string query, Action execute) + { + using var activity = StartActivity(query, databaseName, activitySource); + try + { + execute(); + } + catch (Exception exception) + { + RecordException(activity, exception); + throw; + } + } + + private async Task ExecuteWithActivityAsync(string query, Func execute) + { + using var activity = StartActivity(query, databaseName, activitySource); + try + { + await execute().ConfigureAwait(false); + } + catch (Exception exception) + { + RecordException(activity, exception); + throw; + } + } + } + private sealed class TracingDbCommand(DbCommand command, string databaseName, ActivitySource activitySource) : DbCommand { + private DbTransaction? _transaction; + [AllowNull] public override string CommandText { @@ -62,8 +213,14 @@ protected override DbConnection? DbConnection protected override DbTransaction? DbTransaction { - get => command.Transaction; - set => command.Transaction = value; + get => _transaction ?? command.Transaction; + set + { + _transaction = value; + command.Transaction = value is TracingSqliteTransaction tracingTransaction + ? tracingTransaction.InnerTransaction + : value; + } } public override void Cancel() => command.Cancel(); @@ -86,11 +243,37 @@ public override Task ExecuteNonQueryAsync(CancellationToken cancellationTok protected override DbParameter CreateDbParameter() => command.CreateParameter(); - protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => - ExecuteWithActivity(() => command.ExecuteReader(behavior)); + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) + { + var activity = StartActivity(); + try + { + var reader = command.ExecuteReader(behavior); + return activity is null ? reader : new TracingDbDataReader(reader, activity); + } + catch (Exception exception) + { + RecordException(activity, exception); + activity?.Dispose(); + throw; + } + } - protected override Task ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken) => - ExecuteWithActivityAsync(() => command.ExecuteReaderAsync(behavior, cancellationToken)); + protected override async Task ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken) + { + var activity = StartActivity(); + try + { + var reader = await command.ExecuteReaderAsync(behavior, cancellationToken).ConfigureAwait(false); + return activity is null ? reader : new TracingDbDataReader(reader, activity); + } + catch (Exception exception) + { + RecordException(activity, exception); + activity?.Dispose(); + throw; + } + } protected override void Dispose(bool disposing) { @@ -130,68 +313,154 @@ private async Task ExecuteWithActivityAsync(Func> execute) } } - private Activity? StartActivity() + private Activity? StartActivity() => TracingSqliteConnection.StartActivity(CommandText, databaseName, activitySource); + + private sealed class TracingDbDataReader(DbDataReader reader, Activity activity) : DbDataReader { - var operationName = GetOperationName(CommandText); - var activity = activitySource.StartActivity( - operationName is null ? "sqlite query" : $"{operationName} sqlite", - ActivityKind.Client); - if (activity is not null) + private Activity? _activity = activity; + + public override object this[int ordinal] => reader[ordinal]; + public override object this[string name] => reader[name]; + public override int Depth => reader.Depth; + public override int FieldCount => reader.FieldCount; + public override bool HasRows => reader.HasRows; + public override bool IsClosed => reader.IsClosed; + public override int RecordsAffected => reader.RecordsAffected; + public override int VisibleFieldCount => reader.VisibleFieldCount; + + public override void Close() { - activity.SetTag("db.system.name", "sqlite"); - activity.SetTag(OtlpSpan.PeerServiceAttributeKey, databaseName); - activity.SetTag("db.namespace", databaseName); - activity.SetTag("db.query.text", CommandText); - activity.SetTag("db.operation.name", operationName); + try + { + reader.Close(); + } + catch (Exception exception) + { + RecordException(_activity, exception); + throw; + } + finally + { + CompleteActivity(); + } } - return activity; - } - - private static string? GetOperationName(string query) - { - var querySpan = query.AsSpan(); - while (true) + public override bool GetBoolean(int ordinal) => reader.GetBoolean(ordinal); + public override byte GetByte(int ordinal) => reader.GetByte(ordinal); + public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length) => reader.GetBytes(ordinal, dataOffset, buffer, bufferOffset, length); + public override char GetChar(int ordinal) => reader.GetChar(ordinal); + public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) => reader.GetChars(ordinal, dataOffset, buffer, bufferOffset, length); + public override string GetDataTypeName(int ordinal) => reader.GetDataTypeName(ordinal); + public override DateTime GetDateTime(int ordinal) => reader.GetDateTime(ordinal); + public override decimal GetDecimal(int ordinal) => reader.GetDecimal(ordinal); + public override double GetDouble(int ordinal) => reader.GetDouble(ordinal); + public override IEnumerator GetEnumerator() => reader.GetEnumerator(); + public override Type GetFieldType(int ordinal) => reader.GetFieldType(ordinal); + public override T GetFieldValue(int ordinal) => reader.GetFieldValue(ordinal); + public override float GetFloat(int ordinal) => reader.GetFloat(ordinal); + public override Guid GetGuid(int ordinal) => reader.GetGuid(ordinal); + public override short GetInt16(int ordinal) => reader.GetInt16(ordinal); + public override int GetInt32(int ordinal) => reader.GetInt32(ordinal); + public override long GetInt64(int ordinal) => reader.GetInt64(ordinal); + public override string GetName(int ordinal) => reader.GetName(ordinal); + public override int GetOrdinal(string name) => reader.GetOrdinal(name); + public override Type GetProviderSpecificFieldType(int ordinal) => reader.GetProviderSpecificFieldType(ordinal); + public override object GetProviderSpecificValue(int ordinal) => reader.GetProviderSpecificValue(ordinal); + public override int GetProviderSpecificValues(object[] values) => reader.GetProviderSpecificValues(values); + public override DataTable? GetSchemaTable() => reader.GetSchemaTable(); + public override Stream GetStream(int ordinal) => reader.GetStream(ordinal); + public override string GetString(int ordinal) => reader.GetString(ordinal); + public override TextReader GetTextReader(int ordinal) => reader.GetTextReader(ordinal); + public override object GetValue(int ordinal) => reader.GetValue(ordinal); + public override int GetValues(object[] values) => reader.GetValues(values); + public override bool IsDBNull(int ordinal) => reader.IsDBNull(ordinal); + + public override bool NextResult() => ExecuteReaderOperation(reader.NextResult); + + public override Task NextResultAsync(CancellationToken cancellationToken) => + ExecuteReaderOperationAsync(() => reader.NextResultAsync(cancellationToken)); + + public override bool Read() => ExecuteReaderOperation(reader.Read); + + public override Task ReadAsync(CancellationToken cancellationToken) => + ExecuteReaderOperationAsync(() => reader.ReadAsync(cancellationToken)); + + public override Task GetFieldValueAsync(int ordinal, CancellationToken cancellationToken) => + reader.GetFieldValueAsync(ordinal, cancellationToken); + + public override Task IsDBNullAsync(int ordinal, CancellationToken cancellationToken) => + reader.IsDBNullAsync(ordinal, cancellationToken); + + public override async ValueTask DisposeAsync() { - querySpan = querySpan.TrimStart(); - - // Embedded schema scripts start with license comments before the first SQL statement. - if (querySpan.StartsWith("--")) + try { - var lineEndIndex = querySpan.IndexOfAny('\r', '\n'); - if (lineEndIndex < 0) - { - return null; - } - - querySpan = querySpan[(lineEndIndex + 1)..]; - continue; + await reader.DisposeAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + RecordException(_activity, exception); + throw; + } + finally + { + CompleteActivity(); } - if (querySpan.StartsWith("/*")) + GC.SuppressFinalize(this); + } + + protected override void Dispose(bool disposing) + { + if (disposing) { - var commentEndIndex = querySpan.IndexOf("*/"); - if (commentEndIndex < 0) + try { - return null; + reader.Dispose(); + } + catch (Exception exception) + { + RecordException(_activity, exception); + throw; + } + finally + { + CompleteActivity(); } - - querySpan = querySpan[(commentEndIndex + 2)..]; - continue; } - break; + base.Dispose(disposing); } - var separatorIndex = querySpan.IndexOfAny(" \t\r\n;"); - var operationSpan = separatorIndex >= 0 ? querySpan[..separatorIndex] : querySpan; - return operationSpan.IsEmpty ? null : operationSpan.ToString().ToUpperInvariant(); - } + private T ExecuteReaderOperation(Func operation) + { + try + { + return operation(); + } + catch (Exception exception) + { + RecordException(_activity, exception); + CompleteActivity(); + throw; + } + } - private static void RecordException(Activity? activity, Exception exception) - { - activity?.SetTag("error.type", exception.GetType().FullName); - activity?.SetStatus(ActivityStatusCode.Error, exception.Message); + private async Task ExecuteReaderOperationAsync(Func> operation) + { + try + { + return await operation().ConfigureAwait(false); + } + catch (Exception exception) + { + RecordException(_activity, exception); + CompleteActivity(); + throw; + } + } + + private void CompleteActivity() => Interlocked.Exchange(ref _activity, null)?.Dispose(); } } } \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs index d6da32afb21..b87b7bd2a3f 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Concurrent; +using System.Data.Common; using System.Diagnostics; using Aspire.Dashboard.Otlp.Model; using Aspire.Tests; @@ -55,6 +56,66 @@ public void DapperFailure_SetsActivityErrorInformation() Assert.Equal(typeof(SqliteException).FullName, activity.GetTagItem("error.type")); } + [Fact] + public void DataReader_ActivityStopsWhenReaderIsDisposed() + { + using var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "dashboard.db"), pooling: false); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue); + using DbConnection connection = database.OpenConnection(); + using var command = connection.CreateCommand(); + var query = $"SELECT '{Guid.NewGuid():N}';"; + command.CommandText = query; + + var reader = command.ExecuteReader(); + + Assert.DoesNotContain(activities, activity => Equals(activity.GetTagItem("db.query.text"), query)); + Assert.True(reader.Read()); + reader.Dispose(); + Assert.Single(activities, activity => Equals(activity.GetTagItem("db.query.text"), query)); + } + + [Fact] + public void DapperQueryMultiple_ActivitySpansAllResultSets() + { + using var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "dashboard.db"), pooling: false); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue); + using var connection = database.OpenConnection(); + var query = $"SELECT '{Guid.NewGuid():N}'; SELECT '{Guid.NewGuid():N}';"; + + using (var results = connection.QueryMultiple(query)) + { + Assert.DoesNotContain(activities, activity => Equals(activity.GetTagItem("db.query.text"), query)); + Assert.NotEmpty(results.ReadSingle()); + Assert.DoesNotContain(activities, activity => Equals(activity.GetTagItem("db.query.text"), query)); + Assert.NotEmpty(results.ReadSingle()); + } + + Assert.Single(activities, activity => Equals(activity.GetTagItem("db.query.text"), query)); + } + + [Fact] + public void CommitTransaction_CreatesActivityWithDatabaseInformation() + { + using var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "dashboard.db"), pooling: false); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue); + using var connection = database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + + connection.Execute("SELECT 1;", transaction: transaction); + transaction.Commit(); + + var activity = Assert.Single(activities, activity => Equals(activity.GetTagItem("db.query.text"), "COMMIT;")); + Assert.Equal("COMMIT sqlite", activity.OperationName); + Assert.Equal(ActivityKind.Client, activity.Kind); + Assert.Equal("sqlite", activity.GetTagItem("db.system.name")); + Assert.Equal("dashboard.db", activity.GetTagItem(OtlpSpan.PeerServiceAttributeKey)); + Assert.Equal("dashboard.db", activity.GetTagItem("db.namespace")); + Assert.Equal("COMMIT", activity.GetTagItem("db.operation.name")); + } + [Fact] public void ActivityListener_DoesNotCaptureOtherDatabaseActivities() { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index f343d55315a..95664b9209d 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -1328,7 +1328,7 @@ public void AddMetrics_ReusesInstrumentAndDimensionLookupsWithinBatch() .Select(activity => (string)activity.GetTagItem("db.query.text")!) .ToList(); Assert.Single(queries, query => query.Contains("SELECT instrument_id", StringComparison.Ordinal)); - Assert.Single(queries, query => query.Contains("FROM telemetry_metric_dimensions d", StringComparison.Ordinal)); + Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_metric_dimensions d", StringComparison.Ordinal)); Assert.Single(queries, query => query.StartsWith("DELETE FROM telemetry_metric_points", StringComparison.Ordinal)); var insertQuery = Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_points", StringComparison.Ordinal)); Assert.Equal(2, insertQuery.Split("INSERT INTO telemetry_metric_points", StringSplitOptions.None).Length - 1); @@ -1371,10 +1371,12 @@ public void AddMetrics_BatchesHistogramChildrenAndDimensionTrimming() .ToList(); Assert.Single(queries, query => query.Contains("SELECT instrument_id", StringComparison.Ordinal)); Assert.Single(queries, query => query.StartsWith("SELECT COUNT(*) FROM telemetry_metric_instruments", StringComparison.Ordinal)); - Assert.Equal(2, queries.Count(query => query.Contains("FROM telemetry_metric_dimensions d", StringComparison.Ordinal))); + Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_metric_dimensions d", StringComparison.Ordinal)); Assert.DoesNotContain(queries, query => query.StartsWith("SELECT COUNT(*) FROM telemetry_metric_dimensions", StringComparison.Ordinal)); + var instrumentInsert = Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_instruments", StringComparison.Ordinal)); + Assert.Equal(2, instrumentInsert.Split("@InstrumentName", StringSplitOptions.None).Length - 1); var dimensionInsert = Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_dimensions", StringComparison.Ordinal)); - Assert.Equal(3, dimensionInsert.Split("INSERT INTO telemetry_metric_dimensions", StringSplitOptions.None).Length - 1); + Assert.Equal(3, dimensionInsert.Split("@InstrumentId", StringSplitOptions.None).Length - 1); Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_dimension_attributes", StringComparison.Ordinal)); Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_histogram_bucket_counts", StringComparison.Ordinal)); Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_histogram_explicit_bounds", StringComparison.Ordinal)); From 9c71f3b50c84bdc78320334e6e8566140a3550b1 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sat, 18 Jul 2026 22:22:18 +0800 Subject: [PATCH 34/87] WIP --- .../TestShop/TestShop.AppHost/AppHost.cs | 16 +- .../Properties/launchSettings.json | 12 + .../Controls/DashboardRunSelect.razor.cs | 2 +- .../Components/Layout/MainLayout.razor | 14 +- .../Components/Layout/MainLayout.razor.cs | 24 +- .../DashboardWebApplication.cs | 1 + .../Model/ResourceOutgoingPeerResolver.cs | 71 +- .../ServiceClient/DashboardClient.cs | 15 +- .../ServiceClient/DashboardDataSource.cs | 19 +- .../ServiceClient/DashboardRunStore.cs | 19 +- .../ServiceClient/DashboardSqliteDatabase.cs | 22 +- .../DatabaseSchema/002.Resources.sql | 29 +- .../SqliteResourceRepository.Storage.cs | 605 ++++++++---------- .../ServiceClient/SqliteResourceRepository.cs | 26 +- .../Layout/MainLayoutTests.cs | 8 +- .../Shared/FluentUISetupHelpers.cs | 18 +- .../Integration/HealthTests.cs | 25 + .../Model/DashboardClientTests.cs | 64 +- .../Model/DashboardDataSourceTests.cs | 54 +- .../Model/SqliteResourceRepositoryTests.cs | 168 ++++- .../ResourceOutgoingPeerResolverTests.cs | 24 +- .../Shared/TestDashboardDataSource.cs | 2 +- 22 files changed, 789 insertions(+), 449 deletions(-) diff --git a/playground/TestShop/TestShop.AppHost/AppHost.cs b/playground/TestShop/TestShop.AppHost/AppHost.cs index 479ba73dfd8..7c18c53c21c 100644 --- a/playground/TestShop/TestShop.AppHost/AppHost.cs +++ b/playground/TestShop/TestShop.AppHost/AppHost.cs @@ -92,10 +92,18 @@ .WaitFor(basketService) .WithReference(catalogService) .WaitFor(catalogService) - // Modify the display text of the URLs - .WithUrls(c => c.Urls.ForEach(u => u.DisplayText = $"Online store ({u.Endpoint?.EndpointName})")) - // Don't show the non-HTTPS link on the resources page (details only) - .WithUrlForEndpoint("http", url => url.DisplayLocation = UrlDisplayLocation.DetailsOnly) + .WithUrls(c => + { + var hasHttpsUrl = c.Urls.Any(u => string.Equals(u.Endpoint?.EndpointName, "https", StringComparison.OrdinalIgnoreCase)); + c.Urls.ForEach(u => + { + u.DisplayText = $"Online store ({u.Endpoint?.EndpointName})"; + if (hasHttpsUrl && string.Equals(u.Endpoint?.EndpointName, "http", StringComparison.OrdinalIgnoreCase)) + { + u.DisplayLocation = UrlDisplayLocation.DetailsOnly; + } + }); + }) // Add health relative URL (show in details only) .WithUrlForEndpoint("https", ep => new() { Url = "/health", DisplayText = "Health", DisplayLocation = UrlDisplayLocation.DetailsOnly }) .WithHttpHealthCheck("/health"); diff --git a/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json b/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json index 880a3578bc0..f9d3fdd8e0a 100644 --- a/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json +++ b/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json @@ -10,6 +10,18 @@ "DOTNET_ENVIRONMENT": "Development" } }, + "https (profiling)": { + "commandName": "Project", + "launchBrowser": true, + "dotnetRunMessages": true, + "applicationUrl": "https://TestShop.dev.localhost:16319;http://TestShop.dev.localhost:16320", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc" + } + }, "http": { "commandName": "Project", "launchBrowser": true, diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs index ccc1d6c4f83..84d12148b78 100644 --- a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs @@ -51,7 +51,7 @@ protected override void OnParametersSet() Icon = string.Equals(run.RunId, SelectedRunId, StringComparison.Ordinal) ? new Icons.Regular.Size16.Checkmark() : null, - OnClick = () => SelectedRunIdChanged.InvokeAsync(run.RunId) + OnClick = () => SelectedRunIdChanged.InvokeAsync(run.IsCurrent ? null : run.RunId) }); if (run.IsCurrent && _runs.Any(candidate => !candidate.IsCurrent)) diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index cf62989cdfa..a2a0f0b1d84 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -20,8 +20,9 @@
- @if (RunStore.SupportsRunSelection && _selectedRun is { } selectedRun) + @if (RunStore.SupportsRunSelection && _isRunSelectionInitialized) { + var selectedRun = RunSelection.SelectedRun;
- @if (RunStore.SupportsRunSelection && _selectedRun is { } selectedRun) + @if (RunStore.SupportsRunSelection && _isRunSelectionInitialized) { + var selectedRun = RunSelection.SelectedRun; - @if (!_isSwitchingRuns && _selectedRun is { } selectedRun) + @if (!_isSwitchingRuns && _isRunSelectionInitialized) { + var selectedRun = RunSelection.SelectedRun; @Body @@ -112,9 +115,10 @@ - @if (!_isSwitchingRuns && _selectedRun is not null) + @if (!_isSwitchingRuns && _isRunSelectionInitialized) { - + var selectedRun = RunSelection.SelectedRun; + diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 4adcab5703c..21b43cf710b 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -18,6 +18,7 @@ namespace Aspire.Dashboard.Components.Layout; public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable { private bool _isNavMenuOpen; + private bool _isRunSelectionInitialized; private bool _isSwitchingRuns; private IDisposable? _themeChangedSubscription; @@ -36,8 +37,6 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable internal const string HelpButtonId = "dashboard-help-button"; internal const string SettingsButtonId = "dashboard-settings-button"; internal const string NavigationButtonId = "dashboard-navigation-button"; - private IReadOnlyList _runOptions = []; - private DashboardRunDescriptor? _selectedRun; [Inject] public required ThemeManager ThemeManager { get; init; } @@ -89,16 +88,16 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable protected override async Task OnInitializedAsync() { - _runOptions = RunStore.GetRuns(); - string? selectedRunId = null; if (RunStore.SupportsRunSelection) { + var runOptions = RunStore.GetRuns(); var selectedRunResult = await SessionStorage.GetAsync(BrowserStorageKeys.SelectedDashboardRunId); - selectedRunId = selectedRunResult is { Success: true } ? selectedRunResult.Value : null; + var selectedRunId = selectedRunResult is { Success: true } ? selectedRunResult.Value : null; + var selectedRun = runOptions.FirstOrDefault(run => string.Equals(run.RunId, selectedRunId, StringComparison.Ordinal)) + ?? runOptions.Single(run => run.IsCurrent); + RunSelection.SelectRun(selectedRun.IsCurrent ? null : selectedRun.RunId); } - _selectedRun = _runOptions.FirstOrDefault(run => string.Equals(run.RunId, selectedRunId, StringComparison.Ordinal)) - ?? _runOptions.Single(run => run.IsCurrent); - RunSelection.SelectRun(_selectedRun.IsCurrent ? null : _selectedRun.RunId); + _isRunSelectionInitialized = true; // Theme change can be triggered from the settings dialog. This logic applies the new theme to the browser window. // Note that this event could be raised from a settings dialog opened in a different browser window. @@ -238,8 +237,8 @@ protected override void OnParametersSet() private async Task SwitchDashboardRunAsync(string? runId) { - var selectedRun = _runOptions.FirstOrDefault(run => string.Equals(run.RunId, runId, StringComparison.Ordinal)); - if (selectedRun is null || string.Equals(selectedRun.RunId, _selectedRun?.RunId, StringComparison.Ordinal)) + var selectedRunId = RunSelection.SelectedRun is { IsCurrent: false } selectedRun ? selectedRun.RunId : null; + if (string.Equals(runId, selectedRunId, StringComparison.Ordinal)) { return; } @@ -249,8 +248,7 @@ private async Task SwitchDashboardRunAsync(string? runId) try { - RunSelection.SelectRun(selectedRun.IsCurrent ? null : selectedRun.RunId); - _selectedRun = selectedRun; + RunSelection.SelectRun(runId); } finally { @@ -258,7 +256,7 @@ private async Task SwitchDashboardRunAsync(string? runId) await InvokeAsync(StateHasChanged); } - await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, selectedRun.IsCurrent ? string.Empty : selectedRun.RunId); + await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, runId ?? string.Empty); } private string? GetVisibleReturnFocusElementId(string? returnFocusElementId, string desktopButtonId) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index ff0ada4c4f7..9d0f673e29e 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -305,6 +305,7 @@ public DashboardWebApplication( builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing .AddAspNetCoreInstrumentation() + .AddSource(DashboardClient.ActivitySourceName) .AddSource(TracingSqliteConnection.ActivitySourceName) .AddOtlpExporter()); } diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index 4731f97b0b5..df9527f86db 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -31,52 +31,59 @@ public ResourceOutgoingPeerResolver(IDashboardClient resourceService) return; } - _watchTask = Task.Run(async () => + // This watcher lives for the lifetime of the resolver. Don't let the request or component that + // causes the resolver to be constructed become the parent of that long-running operation. + using (ExecutionContext.SuppressFlow()) { - var (snapshot, subscription) = await resourceService.SubscribeResourcesAsync(_watchContainersTokenSource.Token).ConfigureAwait(false); + _watchTask = Task.Run(() => WatchResourcesAsync(resourceService)); + } + } - if (snapshot.Length > 0) - { - foreach (var resource in snapshot) - { - var added = _resourceByName.TryAdd(resource.Name, resource); - Debug.Assert(added, "Should not receive duplicate resources in initial snapshot data."); - } + private async Task WatchResourcesAsync(IDashboardClient resourceService) + { + var (snapshot, subscription) = await resourceService.SubscribeResourcesAsync(_watchContainersTokenSource.Token).ConfigureAwait(false); - await RaisePeerChangesAsync().ConfigureAwait(false); + if (snapshot.Length > 0) + { + foreach (var resource in snapshot) + { + var added = _resourceByName.TryAdd(resource.Name, resource); + Debug.Assert(added, "Should not receive duplicate resources in initial snapshot data."); } - await foreach (var changes in subscription.WithCancellation(_watchContainersTokenSource.Token).ConfigureAwait(false)) - { - var hasPeerRelevantChanges = false; + await RaisePeerChangesAsync().ConfigureAwait(false); + } - foreach (var (changeType, resource) in changes) - { - if (changeType == ResourceViewModelChangeType.Upsert) - { - if (!_resourceByName.TryGetValue(resource.Name, out var existingResource) || - !ArePeerRelevantPropertiesEquivalent(resource, existingResource)) - { - hasPeerRelevantChanges = true; - } + await foreach (var changes in subscription.WithCancellation(_watchContainersTokenSource.Token).ConfigureAwait(false)) + { + var hasPeerRelevantChanges = false; - _resourceByName[resource.Name] = resource; - } - else if (changeType == ResourceViewModelChangeType.Delete) + foreach (var (changeType, resource) in changes) + { + if (changeType == ResourceViewModelChangeType.Upsert) + { + if (!_resourceByName.TryGetValue(resource.Name, out var existingResource) || + !ArePeerRelevantPropertiesEquivalent(resource, existingResource)) { hasPeerRelevantChanges = true; - - var removed = _resourceByName.TryRemove(resource.Name, out _); - Debug.Assert(removed, "Cannot remove unknown resource."); } - } - if (hasPeerRelevantChanges) + _resourceByName[resource.Name] = resource; + } + else if (changeType == ResourceViewModelChangeType.Delete) { - await RaisePeerChangesAsync().ConfigureAwait(false); + hasPeerRelevantChanges = true; + + var removed = _resourceByName.TryRemove(resource.Name, out _); + Debug.Assert(removed, "Cannot remove unknown resource."); } } - }); + + if (hasPeerRelevantChanges) + { + await RaisePeerChangesAsync().ConfigureAwait(false); + } + } } private static bool ArePeerRelevantPropertiesEquivalent(ResourceViewModel resource1, ResourceViewModel resource2) diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index 4943aff2e81..dbd9f0b0352 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -43,6 +43,8 @@ namespace Aspire.Dashboard.ServiceClient; /// internal sealed class DashboardClient : IDashboardClient { + internal const string ActivitySourceName = "Aspire.Dashboard.ResourceService"; + private const string ApiKeyHeaderName = "x-resource-service-api-key"; private const string TroubleshootingUrl = "https://aka.ms/aspire/dashboard-apphost-connection-failed"; @@ -52,6 +54,7 @@ internal sealed class DashboardClient : IDashboardClient private readonly Dictionary _resourceByName = new(StringComparers.ResourceName); private readonly HashSet _loadedConsoleLogResources = new(StringComparers.ResourceName); + private readonly ActivitySource _activitySource = new(ActivitySourceName); private readonly InteractionCollection _pendingInteractionCollection = new(); private readonly CancellationTokenSource _cts = new(); private readonly CancellationToken _clientCancellationToken; @@ -242,6 +245,7 @@ internal sealed class KeyStoreProperties // For testing purposes internal int OutgoingResourceSubscriberCount => _outgoingResourceChannels.Count; internal int OutgoingInteractionSubscriberCount => _outgoingInteractionChannels.Count; + internal ActivitySource ActivitySource => _activitySource; internal void SetDashboardServiceClient(Aspire.DashboardService.Proto.V1.DashboardService.DashboardServiceClient client) => _client = client; internal Task ResourceWatchCompleteTask => _resourceWatchCompleteTcs.Task; internal Task InteractionWatchCompleteTask => _interactionWatchCompleteTcs.Task; @@ -328,7 +332,12 @@ private void EnsureInitialized() } SetConnectionState(DashboardConnectionState.Connecting); - _connection = Task.Run(() => ConnectAndWatchAsync(_clientCancellationToken), _clientCancellationToken); + // The connection watches resources for the lifetime of the dashboard. Don't let the request or + // component that first accesses the client become the parent of that long-running operation. + using (ExecutionContext.SuppressFlow()) + { + _connection = Task.Run(() => ConnectAndWatchAsync(_clientCancellationToken), _clientCancellationToken); + } } async Task ConnectAndWatchAsync(CancellationToken cancellationToken) @@ -555,6 +564,9 @@ private async Task WatchResourcesAsync(RetryContext retryContext, C await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false)) { + using var activity = _activitySource.StartActivity("Process resource update", ActivityKind.Consumer); + activity?.SetTag("aspire.dashboard.resource_update.type", response.KindCase.ToString()); + List? changes = null; var shouldUpdateConnectionState = false; @@ -1136,6 +1148,7 @@ public async ValueTask DisposeAsync() _channel?.Dispose(); await TaskHelpers.WaitIgnoreCancelAsync(_connection, _logger, "Unexpected error from connection task.").ConfigureAwait(false); + _activitySource.Dispose(); } } diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs index 5eed2274cbf..6ce123b79c1 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs @@ -7,6 +7,7 @@ namespace Aspire.Dashboard.ServiceClient; internal interface IDashboardRunSelection { + DashboardRunDescriptor SelectedRun { get; } void SelectRun(string? runId); } @@ -52,6 +53,8 @@ internal DashboardDataSource( internal bool IsReadOnly { get; private set; } + DashboardRunDescriptor IDashboardRunSelection.SelectedRun => SelectedRun; + void IDashboardRunSelection.SelectRun(string? runId) => SelectRun(runId); internal void SelectRun(string? runId) @@ -70,9 +73,19 @@ internal void SelectRun(string? runId) if (!selectedRun.IsCurrent) { - _historicalDatabase = new DashboardSqliteDatabase(selectedRun.DatabasePath, readOnly: true); - _historicalTelemetryRepository = _repositoryFactory.CreateTelemetryRepository(_historicalDatabase); - _historicalResourceRepository = _repositoryFactory.CreateResourceRepository(_historicalDatabase); + var historicalDatabase = new DashboardSqliteDatabase(selectedRun.DatabasePath, readOnly: true); + try + { + historicalDatabase.ValidateSchemaVersion(selectedRun.SchemaVersion); + _historicalTelemetryRepository = _repositoryFactory.CreateTelemetryRepository(historicalDatabase); + _historicalResourceRepository = _repositoryFactory.CreateResourceRepository(historicalDatabase); + _historicalDatabase = historicalDatabase; + } + catch + { + historicalDatabase.Dispose(); + throw; + } TelemetryRepository = _historicalTelemetryRepository; ResourceRepository = _historicalResourceRepository; IsReadOnly = true; diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index 85f35c3e29f..51d485dfec3 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -32,6 +32,7 @@ internal sealed class DashboardRunStore : IDashboardRunStore, IDisposable private readonly FileStream? _runLock; private readonly DashboardRunMetadata _metadata; private readonly ILogger _logger; + private readonly Lazy> _runs; public DashboardRunStore(IOptions options, ILogger logger) : this(options, logger, static directory => Directory.Delete(directory, recursive: true)) @@ -96,6 +97,8 @@ internal DashboardRunStore( WriteMetadata(_metadata); PruneRuns(deleteRunDirectory); } + + _runs = new(LoadRuns); } private void DeleteAbandonedTemporaryDirectories(Action deleteRunDirectory) @@ -135,7 +138,9 @@ private void DeleteAbandonedTemporaryDirectories(Action deleteRunDirecto public DashboardPersistenceMode PersistenceMode { get; } public bool SupportsRunSelection => PersistenceMode == DashboardPersistenceMode.Runs; - public IReadOnlyList GetRuns() + public IReadOnlyList GetRuns() => _runs.Value; + + private IReadOnlyList LoadRuns() { var runs = new List { @@ -144,7 +149,7 @@ public IReadOnlyList GetRuns() if (!SupportsRunSelection || !Directory.Exists(_runsDirectory)) { - return runs; + return runs.ToArray(); } foreach (var directory in Directory.EnumerateDirectories(_runsDirectory)) @@ -167,11 +172,7 @@ public IReadOnlyList GetRuns() var metadata = JsonSerializer.Deserialize(File.ReadAllText(metadataPath)); if (metadata is { SchemaVersion: SchemaVersion }) { - var descriptor = CreateDescriptor(metadata, directory, isCurrent: false); - if (DashboardSqliteDatabase.IsCompatible(descriptor.DatabasePath)) - { - runs.Add(descriptor); - } + runs.Add(CreateDescriptor(metadata, directory, isCurrent: false)); } } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) @@ -180,7 +181,7 @@ public IReadOnlyList GetRuns() } } - return runs.OrderByDescending(run => run.IsCurrent).ThenByDescending(run => run.StartedAtUtc).ToList(); + return runs.OrderByDescending(run => run.IsCurrent).ThenByDescending(run => run.StartedAtUtc).ToArray(); } public void Dispose() @@ -271,6 +272,7 @@ private static DashboardRunDescriptor CreateDescriptor(DashboardRunMetadata meta { return new DashboardRunDescriptor( metadata.RunId, + metadata.SchemaVersion, metadata.StartedAtUtc, metadata.EndedAtUtc, metadata.CleanShutdown, @@ -342,6 +344,7 @@ private sealed record DashboardRunMetadata internal sealed record DashboardRunDescriptor( string RunId, + int SchemaVersion, DateTimeOffset StartedAtUtc, DateTimeOffset? EndedAtUtc, bool CleanShutdown, diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index 25128c9016d..9b51c0b0310 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -4,6 +4,7 @@ using Dapper; using System.Data; using System.Diagnostics; +using System.Globalization; using Microsoft.Data.Sqlite; namespace Aspire.Dashboard.ServiceClient; @@ -15,7 +16,7 @@ internal sealed class DashboardSqliteDatabase : IDisposable { private const string SchemaResourcePrefix = "Aspire.Dashboard.ServiceClient.DatabaseSchema."; - internal const int SchemaVersion = 9; + internal const int SchemaVersion = 10; private static readonly Lazy> s_schemaScripts = new(LoadSchemaScripts); @@ -64,7 +65,7 @@ public static bool IsCompatible(string databasePath) using var connection = database.OpenConnection(); var version = connection.QuerySingleOrDefault(""" SELECT CASE - WHEN COUNT(*) = 1 AND typeof(MAX(version)) = 'integer' THEN MAX(version) + WHEN COUNT(*) = 1 THEN MAX(version) ELSE NULL END FROM dashboard_schema; @@ -86,6 +87,23 @@ public TracingSqliteConnection OpenConnection() return connection; } + internal void ValidateSchemaVersion(int metadataSchemaVersion) + { + using var connection = OpenConnection(); + var schemaVersion = connection.QuerySingleOrDefault(""" + SELECT CASE + WHEN COUNT(*) = 1 THEN MAX(version) + ELSE NULL + END + FROM dashboard_schema; + """); + if (schemaVersion != metadataSchemaVersion) + { + throw new InvalidOperationException( + $"Dashboard database schema version '{schemaVersion?.ToString(CultureInfo.InvariantCulture) ?? "invalid"}' does not match run metadata schema version '{metadataSchemaVersion}'."); + } + } + public static void ClearPools() => SqliteConnection.ClearAllPools(); public void ClearPool() diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql index 9357236b2ba..c5c9ee8ce82 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/002.Resources.sql @@ -73,37 +73,12 @@ CREATE TABLE IF NOT EXISTS dashboard_resource_relationships ( PRIMARY KEY (resource_name, ordinal) ) STRICT; -CREATE TABLE IF NOT EXISTS dashboard_values ( - value_id INTEGER PRIMARY KEY AUTOINCREMENT, - resource_name TEXT NOT NULL REFERENCES dashboard_resources(resource_name) ON DELETE CASCADE, - value_kind INTEGER NOT NULL, - string_value TEXT NULL, - number_value REAL NULL, - bool_value INTEGER NULL -) STRICT; - -CREATE TABLE IF NOT EXISTS dashboard_value_map_entries ( - parent_value_id INTEGER NOT NULL REFERENCES dashboard_values(value_id) ON DELETE CASCADE, - ordinal INTEGER NOT NULL, - map_key TEXT NOT NULL, - child_value_id INTEGER NOT NULL REFERENCES dashboard_values(value_id) ON DELETE CASCADE, - PRIMARY KEY (parent_value_id, ordinal), - UNIQUE (parent_value_id, map_key) -) STRICT; - -CREATE TABLE IF NOT EXISTS dashboard_value_list_items ( - parent_value_id INTEGER NOT NULL REFERENCES dashboard_values(value_id) ON DELETE CASCADE, - ordinal INTEGER NOT NULL, - child_value_id INTEGER NOT NULL REFERENCES dashboard_values(value_id) ON DELETE CASCADE, - PRIMARY KEY (parent_value_id, ordinal) -) STRICT; - CREATE TABLE IF NOT EXISTS dashboard_resource_properties ( resource_name TEXT NOT NULL REFERENCES dashboard_resources(resource_name) ON DELETE CASCADE, ordinal INTEGER NOT NULL, name TEXT NOT NULL, display_name TEXT NULL, - value_id INTEGER NOT NULL REFERENCES dashboard_values(value_id), + value TEXT NOT NULL CHECK (json_valid(value)), is_sensitive INTEGER NULL, is_highlighted INTEGER NOT NULL, sort_order INTEGER NULL, @@ -116,7 +91,7 @@ CREATE TABLE IF NOT EXISTS dashboard_resource_commands ( name TEXT NOT NULL, display_name TEXT NOT NULL, confirmation_message TEXT NULL, - parameter_value_id INTEGER NULL REFERENCES dashboard_values(value_id), + parameter_value TEXT NULL CHECK (parameter_value IS NULL OR json_valid(parameter_value)), is_highlighted INTEGER NOT NULL, icon_name TEXT NULL, icon_variant INTEGER NULL, diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs index a58cf91a0dd..653a10d3384 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs @@ -2,8 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Data; +using System.Globalization; +using System.Text; using Aspire.DashboardService.Proto.V1; using Dapper; +using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Data.Sqlite; @@ -11,305 +14,306 @@ namespace Aspire.Dashboard.ServiceClient; public sealed partial class SqliteResourceRepository { - private static void SaveResource(SqliteConnection connection, IDbTransaction transaction, Resource resource, int replicaIndex) - { - var consoleLogsLoaded = connection.QuerySingleOrDefault(""" - SELECT console_logs_loaded - FROM dashboard_resources - WHERE resource_name = @ResourceName; - """, new { ResourceName = resource.Name }, transaction); - SaveResource(connection, transaction, resource, replicaIndex, consoleLogsLoaded); - } + private const int MaxWriteBatchSize = 100; - private static void SaveResource(SqliteConnection connection, IDbTransaction transaction, Resource resource, int replicaIndex, bool consoleLogsLoaded) + private static void InsertResources(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList resources) { - connection.Execute("DELETE FROM dashboard_resources WHERE resource_name = @ResourceName;", new { ResourceName = resource.Name }, transaction); - connection.Execute(""" - INSERT INTO dashboard_resources ( - resource_name, replica_index, resource_type, display_name, uid, state, - created_at_seconds, created_at_nanos, state_style, - started_at_seconds, started_at_nanos, stopped_at_seconds, stopped_at_nanos, - is_hidden, supports_detailed_telemetry, icon_name, icon_variant, console_logs_loaded) - VALUES ( - @Name, @ReplicaIndex, @ResourceType, @DisplayName, @Uid, @State, - @CreatedAtSeconds, @CreatedAtNanos, @StateStyle, - @StartedAtSeconds, @StartedAtNanos, @StoppedAtSeconds, @StoppedAtNanos, - @IsHidden, @SupportsDetailedTelemetry, @IconName, @IconVariant, @ConsoleLogsLoaded); - """, new + foreach (var batch in resources.Chunk(MaxWriteBatchSize)) + { + var sql = new StringBuilder(""" + INSERT INTO dashboard_resources ( + resource_name, replica_index, resource_type, display_name, uid, state, + created_at_seconds, created_at_nanos, state_style, + started_at_seconds, started_at_nanos, stopped_at_seconds, stopped_at_nanos, + is_hidden, supports_detailed_telemetry, icon_name, icon_variant, console_logs_loaded) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + var resourceToSave = batch[index]; + var resource = resourceToSave.Resource; + if (index > 0) + { + sql.AppendLine(","); + } + + sql.Append(CultureInfo.InvariantCulture, $""" + (@Name{index}, @ReplicaIndex{index}, @ResourceType{index}, @DisplayName{index}, @Uid{index}, @State{index}, + @CreatedAtSeconds{index}, @CreatedAtNanos{index}, @StateStyle{index}, + @StartedAtSeconds{index}, @StartedAtNanos{index}, @StoppedAtSeconds{index}, @StoppedAtNanos{index}, + @IsHidden{index}, @SupportsDetailedTelemetry{index}, @IconName{index}, @IconVariant{index}, @ConsoleLogsLoaded{index}) + """); + parameters.Add($"Name{index}", resource.Name); + parameters.Add($"ReplicaIndex{index}", resourceToSave.ReplicaIndex); + parameters.Add($"ResourceType{index}", resource.ResourceType); + parameters.Add($"DisplayName{index}", resource.DisplayName); + parameters.Add($"Uid{index}", resource.Uid); + parameters.Add($"State{index}", resource.HasState ? resource.State : null); + parameters.Add($"CreatedAtSeconds{index}", resource.CreatedAt?.Seconds); + parameters.Add($"CreatedAtNanos{index}", resource.CreatedAt?.Nanos); + parameters.Add($"StateStyle{index}", resource.HasStateStyle ? resource.StateStyle : null); + parameters.Add($"StartedAtSeconds{index}", resource.StartedAt?.Seconds); + parameters.Add($"StartedAtNanos{index}", resource.StartedAt?.Nanos); + parameters.Add($"StoppedAtSeconds{index}", resource.StoppedAt?.Seconds); + parameters.Add($"StoppedAtNanos{index}", resource.StoppedAt?.Nanos); + parameters.Add($"IsHidden{index}", resource.IsHidden); + parameters.Add($"SupportsDetailedTelemetry{index}", resource.SupportsDetailedTelemetry); + parameters.Add($"IconName{index}", resource.HasIconName ? resource.IconName : null); + parameters.Add($"IconVariant{index}", resource.HasIconVariant ? (int?)resource.IconVariant : null); + parameters.Add($"ConsoleLogsLoaded{index}", resourceToSave.ConsoleLogsLoaded); + } + + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + } + + var resourceModels = resources.Select(resource => resource.Resource).ToArray(); + InsertEnvironment(connection, transaction, resourceModels); + InsertUrls(connection, transaction, resourceModels); + InsertVolumes(connection, transaction, resourceModels); + InsertHealthReports(connection, transaction, resourceModels); + InsertRelationships(connection, transaction, resourceModels); + var properties = new List(); + var commands = new List(); + foreach (var resource in resourceModels) { - resource.Name, - ReplicaIndex = replicaIndex, - resource.ResourceType, - resource.DisplayName, - resource.Uid, - State = resource.HasState ? resource.State : null, - CreatedAtSeconds = resource.CreatedAt?.Seconds, - CreatedAtNanos = resource.CreatedAt?.Nanos, - StateStyle = resource.HasStateStyle ? resource.StateStyle : null, - StartedAtSeconds = resource.StartedAt?.Seconds, - StartedAtNanos = resource.StartedAt?.Nanos, - StoppedAtSeconds = resource.StoppedAt?.Seconds, - StoppedAtNanos = resource.StoppedAt?.Nanos, - resource.IsHidden, - resource.SupportsDetailedTelemetry, - IconName = resource.HasIconName ? resource.IconName : null, - IconVariant = resource.HasIconVariant ? (int?)resource.IconVariant : null, - ConsoleLogsLoaded = consoleLogsLoaded - }, transaction); - - InsertEnvironment(connection, transaction, resource); - InsertUrls(connection, transaction, resource); - InsertVolumes(connection, transaction, resource); - InsertHealthReports(connection, transaction, resource); - InsertRelationships(connection, transaction, resource); - InsertProperties(connection, transaction, resource); - InsertCommands(connection, transaction, resource); + foreach (var (property, ordinal) in resource.Properties.Select((item, ordinal) => (item, ordinal))) + { + properties.Add(new(resource.Name, ordinal, property)); + } + +#pragma warning disable CS0612 // ResourceCommand.Parameter must be persisted for compatibility with older AppHosts. + foreach (var (command, ordinal) in resource.Commands.Select((item, ordinal) => (item, ordinal))) + { + var parameterJsonValue = command.Parameter is not null ? JsonFormatter.Default.Format(command.Parameter) : null; + commands.Add(new(resource.Name, ordinal, command, parameterJsonValue)); + } +#pragma warning restore CS0612 + } + + InsertProperties(connection, transaction, properties); + InsertCommands(connection, transaction, commands); } - private static void InsertEnvironment(SqliteConnection connection, IDbTransaction transaction, Resource resource) + private static void InsertEnvironment(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList resources) { - connection.Execute(""" + ExecuteInsertBatches(connection, transaction, resources.SelectMany(resource => resource.Environment.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))), """ INSERT INTO dashboard_resource_environment (resource_name, ordinal, name, value, is_from_spec) - VALUES (@ResourceName, @Ordinal, @Name, @Value, @IsFromSpec); - """, resource.Environment.Select((item, ordinal) => new + VALUES + """, static (sql, parameters, row, index) => { - ResourceName = resource.Name, - Ordinal = ordinal, - item.Name, - Value = item.HasValue ? item.Value : null, - item.IsFromSpec - }), transaction); + sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @Name{index}, @Value{index}, @IsFromSpec{index})"); + parameters.Add($"ResourceName{index}", row.ResourceName); + parameters.Add($"Ordinal{index}", row.Ordinal); + parameters.Add($"Name{index}", row.Item.Name); + parameters.Add($"Value{index}", row.Item.HasValue ? row.Item.Value : null); + parameters.Add($"IsFromSpec{index}", row.Item.IsFromSpec); + }); } - private static void InsertUrls(SqliteConnection connection, IDbTransaction transaction, Resource resource) + private static void InsertUrls(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList resources) { - connection.Execute(""" + ExecuteInsertBatches(connection, transaction, resources.SelectMany(resource => resource.Urls.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))), """ INSERT INTO dashboard_resource_urls ( resource_name, ordinal, endpoint_name, full_url, is_internal, is_inactive, display_sort_order, display_name) - VALUES ( - @ResourceName, @Ordinal, @EndpointName, @FullUrl, @IsInternal, @IsInactive, @DisplaySortOrder, @DisplayName); - """, resource.Urls.Select((item, ordinal) => new + VALUES + """, static (sql, parameters, row, index) => { - ResourceName = resource.Name, - Ordinal = ordinal, - EndpointName = item.HasEndpointName ? item.EndpointName : null, - item.FullUrl, - item.IsInternal, - item.IsInactive, - DisplaySortOrder = item.DisplayProperties.SortOrder, - DisplayName = item.DisplayProperties.DisplayName - }), transaction); + sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @EndpointName{index}, @FullUrl{index}, @IsInternal{index}, @IsInactive{index}, @DisplaySortOrder{index}, @DisplayName{index})"); + parameters.Add($"ResourceName{index}", row.ResourceName); + parameters.Add($"Ordinal{index}", row.Ordinal); + parameters.Add($"EndpointName{index}", row.Item.HasEndpointName ? row.Item.EndpointName : null); + parameters.Add($"FullUrl{index}", row.Item.FullUrl); + parameters.Add($"IsInternal{index}", row.Item.IsInternal); + parameters.Add($"IsInactive{index}", row.Item.IsInactive); + parameters.Add($"DisplaySortOrder{index}", row.Item.DisplayProperties.SortOrder); + parameters.Add($"DisplayName{index}", row.Item.DisplayProperties.DisplayName); + }); } - private static void InsertVolumes(SqliteConnection connection, IDbTransaction transaction, Resource resource) + private static void InsertVolumes(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList resources) { - connection.Execute(""" + ExecuteInsertBatches(connection, transaction, resources.SelectMany(resource => resource.Volumes.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))), """ INSERT INTO dashboard_resource_volumes (resource_name, ordinal, source, target, mount_type, is_read_only) - VALUES (@ResourceName, @Ordinal, @Source, @Target, @MountType, @IsReadOnly); - """, resource.Volumes.Select((item, ordinal) => new + VALUES + """, static (sql, parameters, row, index) => { - ResourceName = resource.Name, - Ordinal = ordinal, - item.Source, - item.Target, - item.MountType, - item.IsReadOnly - }), transaction); + sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @Source{index}, @Target{index}, @MountType{index}, @IsReadOnly{index})"); + parameters.Add($"ResourceName{index}", row.ResourceName); + parameters.Add($"Ordinal{index}", row.Ordinal); + parameters.Add($"Source{index}", row.Item.Source); + parameters.Add($"Target{index}", row.Item.Target); + parameters.Add($"MountType{index}", row.Item.MountType); + parameters.Add($"IsReadOnly{index}", row.Item.IsReadOnly); + }); } - private static void InsertHealthReports(SqliteConnection connection, IDbTransaction transaction, Resource resource) + private static void InsertHealthReports(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList resources) { - connection.Execute(""" + ExecuteInsertBatches(connection, transaction, resources.SelectMany(resource => resource.HealthReports.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))), """ INSERT INTO dashboard_resource_health_reports ( resource_name, ordinal, status, key, description, exception, last_run_at_seconds, last_run_at_nanos) - VALUES ( - @ResourceName, @Ordinal, @Status, @Key, @Description, @Exception, @LastRunAtSeconds, @LastRunAtNanos); - """, resource.HealthReports.Select((item, ordinal) => new + VALUES + """, static (sql, parameters, row, index) => { - ResourceName = resource.Name, - Ordinal = ordinal, - Status = item.HasStatus ? (int?)item.Status : null, - item.Key, - item.Description, - item.Exception, - LastRunAtSeconds = item.LastRunAt?.Seconds, - LastRunAtNanos = item.LastRunAt?.Nanos - }), transaction); + sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @Status{index}, @Key{index}, @Description{index}, @Exception{index}, @LastRunAtSeconds{index}, @LastRunAtNanos{index})"); + parameters.Add($"ResourceName{index}", row.ResourceName); + parameters.Add($"Ordinal{index}", row.Ordinal); + parameters.Add($"Status{index}", row.Item.HasStatus ? (int?)row.Item.Status : null); + parameters.Add($"Key{index}", row.Item.Key); + parameters.Add($"Description{index}", row.Item.Description); + parameters.Add($"Exception{index}", row.Item.Exception); + parameters.Add($"LastRunAtSeconds{index}", row.Item.LastRunAt?.Seconds); + parameters.Add($"LastRunAtNanos{index}", row.Item.LastRunAt?.Nanos); + }); } - private static void InsertRelationships(SqliteConnection connection, IDbTransaction transaction, Resource resource) + private static void InsertRelationships(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList resources) { - connection.Execute(""" + ExecuteInsertBatches(connection, transaction, resources.SelectMany(resource => resource.Relationships.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))), """ INSERT INTO dashboard_resource_relationships ( resource_name, ordinal, related_resource_name, relationship_type) - VALUES (@ResourceName, @Ordinal, @RelatedResourceName, @RelationshipType); - """, resource.Relationships.Select((item, ordinal) => new + VALUES + """, static (sql, parameters, row, index) => { - ResourceName = resource.Name, - Ordinal = ordinal, - RelatedResourceName = item.ResourceName, - RelationshipType = item.Type - }), transaction); + sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @RelatedResourceName{index}, @RelationshipType{index})"); + parameters.Add($"ResourceName{index}", row.ResourceName); + parameters.Add($"Ordinal{index}", row.Ordinal); + parameters.Add($"RelatedResourceName{index}", row.Item.ResourceName); + parameters.Add($"RelationshipType{index}", row.Item.Type); + }); } - private static void InsertProperties(SqliteConnection connection, IDbTransaction transaction, Resource resource) + private static void ExecuteInsertBatches( + SqliteConnection connection, + IDbTransaction transaction, + IEnumerable rows, + string insertPrefix, + Action appendValues) { - foreach (var (property, ordinal) in resource.Properties.Select((item, ordinal) => (item, ordinal))) + foreach (var batch in rows.Chunk(MaxWriteBatchSize)) { - var valueId = InsertValue(connection, transaction, resource.Name, property.Value); - connection.Execute(""" - INSERT INTO dashboard_resource_properties ( - resource_name, ordinal, name, display_name, value_id, is_sensitive, is_highlighted, sort_order) - VALUES ( - @ResourceName, @Ordinal, @Name, @DisplayName, @ValueId, @IsSensitive, @IsHighlighted, @SortOrder); - """, new + var sql = new StringBuilder(insertPrefix); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) { - ResourceName = resource.Name, - Ordinal = ordinal, - property.Name, - DisplayName = property.HasDisplayName ? property.DisplayName : null, - ValueId = valueId, - IsSensitive = property.HasIsSensitive ? (bool?)property.IsSensitive : null, - property.IsHighlighted, - SortOrder = property.HasSortOrder ? (int?)property.SortOrder : null - }, transaction); + if (index > 0) + { + sql.AppendLine(","); + } + + appendValues(sql, parameters, batch[index], index); + } + + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); } } - private static void InsertCommands(SqliteConnection connection, IDbTransaction transaction, Resource resource) + private static void InsertProperties(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList properties) { -#pragma warning disable CS0612 // ResourceCommand.Parameter must be persisted for compatibility with older AppHosts. - foreach (var (command, commandOrdinal) in resource.Commands.Select((item, ordinal) => (item, ordinal))) + ExecuteInsertBatches(connection, transaction, properties, """ + INSERT INTO dashboard_resource_properties ( + resource_name, ordinal, name, display_name, value, is_sensitive, is_highlighted, sort_order) + VALUES + """, static (sql, parameters, row, index) => { - long? parameterValueId = command.Parameter is not null - ? InsertValue(connection, transaction, resource.Name, command.Parameter) - : null; - connection.Execute(""" - INSERT INTO dashboard_resource_commands ( - resource_name, ordinal, name, display_name, confirmation_message, parameter_value_id, - is_highlighted, icon_name, icon_variant, display_description, state) - VALUES ( - @ResourceName, @Ordinal, @Name, @DisplayName, @ConfirmationMessage, @ParameterValueId, - @IsHighlighted, @IconName, @IconVariant, @DisplayDescription, @State); - """, new - { - ResourceName = resource.Name, - Ordinal = commandOrdinal, - command.Name, - command.DisplayName, - ConfirmationMessage = command.HasConfirmationMessage ? command.ConfirmationMessage : null, - ParameterValueId = parameterValueId, - command.IsHighlighted, - IconName = command.HasIconName ? command.IconName : null, - IconVariant = command.HasIconVariant ? (int?)command.IconVariant : null, - DisplayDescription = command.HasDisplayDescription ? command.DisplayDescription : null, - State = (int)command.State - }, transaction); - - foreach (var (input, inputOrdinal) in command.ArgumentInputs.Select((item, ordinal) => (item, ordinal))) - { - connection.Execute(""" - INSERT INTO dashboard_resource_command_inputs ( - resource_name, command_ordinal, ordinal, label, placeholder, input_type, required, value, - description, enable_description_markdown, max_length, allow_custom_choice, loading, - update_state_on_change, name, disabled, max_file_size, allow_multiple_files, file_filter) - VALUES ( - @ResourceName, @CommandOrdinal, @Ordinal, @Label, @Placeholder, @InputType, @Required, @Value, - @Description, @EnableDescriptionMarkdown, @MaxLength, @AllowCustomChoice, @Loading, - @UpdateStateOnChange, @Name, @Disabled, @MaxFileSize, @AllowMultipleFiles, @FileFilter); - """, new - { - ResourceName = resource.Name, - CommandOrdinal = commandOrdinal, - Ordinal = inputOrdinal, - input.Label, - input.Placeholder, - InputType = (int)input.InputType, - input.Required, - input.Value, - input.Description, - input.EnableDescriptionMarkdown, - input.MaxLength, - input.AllowCustomChoice, - input.Loading, - input.UpdateStateOnChange, - input.Name, - input.Disabled, - input.MaxFileSize, - input.AllowMultipleFiles, - input.FileFilter - }, transaction); - - connection.Execute(""" - INSERT INTO dashboard_resource_command_input_options ( - resource_name, command_ordinal, input_ordinal, option_key, option_value) - VALUES (@ResourceName, @CommandOrdinal, @InputOrdinal, @OptionKey, @OptionValue); - """, input.Options.Select(option => new - { - ResourceName = resource.Name, - CommandOrdinal = commandOrdinal, - InputOrdinal = inputOrdinal, - OptionKey = option.Key, - OptionValue = option.Value - }), transaction); - - connection.Execute(""" - INSERT INTO dashboard_resource_command_input_validation_errors ( - resource_name, command_ordinal, input_ordinal, ordinal, validation_error) - VALUES (@ResourceName, @CommandOrdinal, @InputOrdinal, @Ordinal, @ValidationError); - """, input.ValidationErrors.Select((validationError, ordinal) => new - { - ResourceName = resource.Name, - CommandOrdinal = commandOrdinal, - InputOrdinal = inputOrdinal, - Ordinal = ordinal, - ValidationError = validationError - }), transaction); - } - } -#pragma warning restore CS0612 + sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @Name{index}, @DisplayName{index}, @Value{index}, @IsSensitive{index}, @IsHighlighted{index}, @SortOrder{index})"); + parameters.Add($"ResourceName{index}", row.ResourceName); + parameters.Add($"Ordinal{index}", row.Ordinal); + parameters.Add($"Name{index}", row.Property.Name); + parameters.Add($"DisplayName{index}", row.Property.HasDisplayName ? row.Property.DisplayName : null); + parameters.Add($"Value{index}", JsonFormatter.Default.Format(row.Property.Value)); + parameters.Add($"IsSensitive{index}", row.Property.HasIsSensitive ? (bool?)row.Property.IsSensitive : null); + parameters.Add($"IsHighlighted{index}", row.Property.IsHighlighted); + parameters.Add($"SortOrder{index}", row.Property.HasSortOrder ? (int?)row.Property.SortOrder : null); + }); } - private static long InsertValue(SqliteConnection connection, IDbTransaction transaction, string resourceName, Value value) + private static void InsertCommands(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList commands) { - var valueId = connection.QuerySingle(""" - INSERT INTO dashboard_values (resource_name, value_kind, string_value, number_value, bool_value) - VALUES (@ResourceName, @ValueKind, @StringValue, @NumberValue, @BoolValue) - RETURNING value_id; - """, new + ExecuteInsertBatches(connection, transaction, commands, """ + INSERT INTO dashboard_resource_commands ( + resource_name, ordinal, name, display_name, confirmation_message, parameter_value, + is_highlighted, icon_name, icon_variant, display_description, state) + VALUES + """, static (sql, parameters, row, index) => { - ResourceName = resourceName, - ValueKind = (int)value.KindCase, - StringValue = value.KindCase == Value.KindOneofCase.StringValue ? value.StringValue : null, - NumberValue = value.KindCase == Value.KindOneofCase.NumberValue ? (double?)value.NumberValue : null, - BoolValue = value.KindCase == Value.KindOneofCase.BoolValue ? (bool?)value.BoolValue : null - }, transaction); - - if (value.KindCase == Value.KindOneofCase.StructValue) + var command = row.Command; + sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @Name{index}, @DisplayName{index}, @ConfirmationMessage{index}, @ParameterValue{index}, @IsHighlighted{index}, @IconName{index}, @IconVariant{index}, @DisplayDescription{index}, @State{index})"); + parameters.Add($"ResourceName{index}", row.ResourceName); + parameters.Add($"Ordinal{index}", row.Ordinal); + parameters.Add($"Name{index}", command.Name); + parameters.Add($"DisplayName{index}", command.DisplayName); + parameters.Add($"ConfirmationMessage{index}", command.HasConfirmationMessage ? command.ConfirmationMessage : null); + parameters.Add($"ParameterValue{index}", row.ParameterJsonValue); + parameters.Add($"IsHighlighted{index}", command.IsHighlighted); + parameters.Add($"IconName{index}", command.HasIconName ? command.IconName : null); + parameters.Add($"IconVariant{index}", command.HasIconVariant ? (int?)command.IconVariant : null); + parameters.Add($"DisplayDescription{index}", command.HasDisplayDescription ? command.DisplayDescription : null); + parameters.Add($"State{index}", (int)command.State); + }); + + var inputs = commands.SelectMany(command => command.Command.ArgumentInputs.Select((input, ordinal) => (Command: command, Ordinal: ordinal, Input: input))).ToArray(); + ExecuteInsertBatches(connection, transaction, inputs, """ + INSERT INTO dashboard_resource_command_inputs ( + resource_name, command_ordinal, ordinal, label, placeholder, input_type, required, value, + description, enable_description_markdown, max_length, allow_custom_choice, loading, + update_state_on_change, name, disabled, max_file_size, allow_multiple_files, file_filter) + VALUES + """, static (sql, parameters, row, index) => { - var ordinal = 0; - foreach (var field in value.StructValue.Fields) - { - var childValueId = InsertValue(connection, transaction, resourceName, field.Value); - connection.Execute(""" - INSERT INTO dashboard_value_map_entries (parent_value_id, ordinal, map_key, child_value_id) - VALUES (@ParentValueId, @Ordinal, @MapKey, @ChildValueId); - """, new { ParentValueId = valueId, Ordinal = ordinal++, MapKey = field.Key, ChildValueId = childValueId }, transaction); - } - } - else if (value.KindCase == Value.KindOneofCase.ListValue) + var input = row.Input; + sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @CommandOrdinal{index}, @Ordinal{index}, @Label{index}, @Placeholder{index}, @InputType{index}, @Required{index}, @Value{index}, @Description{index}, @EnableDescriptionMarkdown{index}, @MaxLength{index}, @AllowCustomChoice{index}, @Loading{index}, @UpdateStateOnChange{index}, @Name{index}, @Disabled{index}, @MaxFileSize{index}, @AllowMultipleFiles{index}, @FileFilter{index})"); + parameters.Add($"ResourceName{index}", row.Command.ResourceName); + parameters.Add($"CommandOrdinal{index}", row.Command.Ordinal); + parameters.Add($"Ordinal{index}", row.Ordinal); + parameters.Add($"Label{index}", input.Label); + parameters.Add($"Placeholder{index}", input.Placeholder); + parameters.Add($"InputType{index}", (int)input.InputType); + parameters.Add($"Required{index}", input.Required); + parameters.Add($"Value{index}", input.Value); + parameters.Add($"Description{index}", input.Description); + parameters.Add($"EnableDescriptionMarkdown{index}", input.EnableDescriptionMarkdown); + parameters.Add($"MaxLength{index}", input.MaxLength); + parameters.Add($"AllowCustomChoice{index}", input.AllowCustomChoice); + parameters.Add($"Loading{index}", input.Loading); + parameters.Add($"UpdateStateOnChange{index}", input.UpdateStateOnChange); + parameters.Add($"Name{index}", input.Name); + parameters.Add($"Disabled{index}", input.Disabled); + parameters.Add($"MaxFileSize{index}", input.MaxFileSize); + parameters.Add($"AllowMultipleFiles{index}", input.AllowMultipleFiles); + parameters.Add($"FileFilter{index}", input.FileFilter); + }); + + ExecuteInsertBatches(connection, transaction, inputs.SelectMany(row => row.Input.Options.Select(option => (row.Command, InputOrdinal: row.Ordinal, Option: option))), """ + INSERT INTO dashboard_resource_command_input_options ( + resource_name, command_ordinal, input_ordinal, option_key, option_value) + VALUES + """, static (sql, parameters, row, index) => { - foreach (var (item, ordinal) in value.ListValue.Values.Select((item, ordinal) => (item, ordinal))) - { - var childValueId = InsertValue(connection, transaction, resourceName, item); - connection.Execute(""" - INSERT INTO dashboard_value_list_items (parent_value_id, ordinal, child_value_id) - VALUES (@ParentValueId, @Ordinal, @ChildValueId); - """, new { ParentValueId = valueId, Ordinal = ordinal, ChildValueId = childValueId }, transaction); - } - } - - return valueId; + sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @CommandOrdinal{index}, @InputOrdinal{index}, @OptionKey{index}, @OptionValue{index})"); + parameters.Add($"ResourceName{index}", row.Command.ResourceName); + parameters.Add($"CommandOrdinal{index}", row.Command.Ordinal); + parameters.Add($"InputOrdinal{index}", row.InputOrdinal); + parameters.Add($"OptionKey{index}", row.Option.Key); + parameters.Add($"OptionValue{index}", row.Option.Value); + }); + + ExecuteInsertBatches(connection, transaction, inputs.SelectMany(row => row.Input.ValidationErrors.Select((validationError, ordinal) => (row.Command, InputOrdinal: row.Ordinal, Ordinal: ordinal, ValidationError: validationError))), """ + INSERT INTO dashboard_resource_command_input_validation_errors ( + resource_name, command_ordinal, input_ordinal, ordinal, validation_error) + VALUES + """, static (sql, parameters, row, index) => + { + sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @CommandOrdinal{index}, @InputOrdinal{index}, @Ordinal{index}, @ValidationError{index})"); + parameters.Add($"ResourceName{index}", row.Command.ResourceName); + parameters.Add($"CommandOrdinal{index}", row.Command.Ordinal); + parameters.Add($"InputOrdinal{index}", row.InputOrdinal); + parameters.Add($"Ordinal{index}", row.Ordinal); + parameters.Add($"ValidationError{index}", row.ValidationError); + }); } private static IEnumerable LoadResourceRecords(SqliteConnection connection) @@ -358,13 +362,13 @@ FROM dashboard_resource_health_reports FROM dashboard_resource_relationships ORDER BY resource_name, ordinal; - SELECT resource_name AS ResourceName, name AS Name, display_name AS DisplayName, value_id AS ValueId, + SELECT resource_name AS ResourceName, name AS Name, display_name AS DisplayName, value AS JsonValue, is_sensitive AS IsSensitive, is_highlighted AS IsHighlighted, sort_order AS SortOrder FROM dashboard_resource_properties ORDER BY resource_name, ordinal; SELECT resource_name AS ResourceName, ordinal AS Ordinal, name AS Name, display_name AS DisplayName, - confirmation_message AS ConfirmationMessage, parameter_value_id AS ParameterValueId, + confirmation_message AS ConfirmationMessage, parameter_value AS ParameterJsonValue, is_highlighted AS IsHighlighted, icon_name AS IconName, icon_variant AS IconVariant, display_description AS DisplayDescription, state AS State FROM dashboard_resource_commands @@ -389,17 +393,6 @@ validation_error AS ValidationError FROM dashboard_resource_command_input_validation_errors ORDER BY resource_name, command_ordinal, input_ordinal, ordinal; - SELECT value_id AS ValueId, value_kind AS ValueKind, string_value AS StringValue, - number_value AS NumberValue, bool_value AS BoolValue - FROM dashboard_values; - - SELECT parent_value_id AS ParentValueId, map_key AS MapKey, child_value_id AS ChildValueId - FROM dashboard_value_map_entries - ORDER BY parent_value_id, ordinal; - - SELECT parent_value_id AS ParentValueId, child_value_id AS ChildValueId - FROM dashboard_value_list_items - ORDER BY parent_value_id, ordinal; """); var resourceRecords = reader.Read().AsList(); @@ -413,9 +406,6 @@ FROM dashboard_value_list_items var inputs = reader.Read().ToLookup(record => (record.ResourceName, record.CommandOrdinal)); var options = reader.Read().ToLookup(record => (record.ResourceName, record.CommandOrdinal, record.InputOrdinal)); var validationErrors = reader.Read().ToLookup(record => (record.ResourceName, record.CommandOrdinal, record.InputOrdinal)); - var values = reader.Read().ToDictionary(record => record.ValueId); - var mapValues = reader.Read().ToLookup(record => record.ParentValueId); - var listValues = reader.Read().ToLookup(record => record.ParentValueId); foreach (var record in resourceRecords) { @@ -484,7 +474,7 @@ FROM dashboard_value_list_items var item = new ResourceProperty { Name = property.Name, - Value = MaterializeValue(property.ValueId, values, mapValues, listValues), + Value = MaterializeValue(property.JsonValue), IsHighlighted = property.IsHighlighted }; if (property.DisplayName is not null) @@ -516,9 +506,9 @@ FROM dashboard_value_list_items { command.ConfirmationMessage = commandRecord.ConfirmationMessage; } - if (commandRecord.ParameterValueId is not null) + if (commandRecord.ParameterJsonValue is not null) { - command.Parameter = MaterializeValue(commandRecord.ParameterValueId.Value, values, mapValues, listValues); + command.Parameter = MaterializeValue(commandRecord.ParameterJsonValue); } if (commandRecord.IconName is not null) { @@ -569,47 +559,7 @@ FROM dashboard_value_list_items } } - private static Value MaterializeValue( - long valueId, - IReadOnlyDictionary values, - ILookup mapValues, - ILookup listValues) - { - var record = values[valueId]; - var value = new Value(); - switch ((Value.KindOneofCase)record.ValueKind) - { - case Value.KindOneofCase.NullValue: - value.NullValue = NullValue.NullValue; - break; - case Value.KindOneofCase.NumberValue: - value.NumberValue = record.NumberValue!.Value; - break; - case Value.KindOneofCase.StringValue: - value.StringValue = record.StringValue!; - break; - case Value.KindOneofCase.BoolValue: - value.BoolValue = record.BoolValue!.Value; - break; - case Value.KindOneofCase.StructValue: - value.StructValue = new Struct(); - foreach (var child in mapValues[valueId]) - { - value.StructValue.Fields.Add(child.MapKey, MaterializeValue(child.ChildValueId, values, mapValues, listValues)); - } - break; - case Value.KindOneofCase.ListValue: - value.ListValue = new ListValue(); - value.ListValue.Values.Add(listValues[valueId].Select(child => MaterializeValue(child.ChildValueId, values, mapValues, listValues))); - break; - case Value.KindOneofCase.None: - break; - default: - throw new InvalidOperationException($"Unknown dashboard value kind '{record.ValueKind}'."); - } - - return value; - } + private static Value MaterializeValue(string jsonValue) => JsonParser.Default.Parse(jsonValue); private static void SetOptionalResourceFields(Resource resource, ResourceRecord record) { @@ -648,6 +598,12 @@ private static Timestamp CreateTimestamp(long seconds, int? nanos) return new Timestamp { Seconds = seconds, Nanos = nanos ?? 0 }; } + private sealed record ResourceToSave(Resource Resource, int ReplicaIndex, bool ConsoleLogsLoaded); + + private sealed record PropertyToSave(string ResourceName, int Ordinal, ResourceProperty Property); + + private sealed record CommandToSave(string ResourceName, int Ordinal, ResourceCommand Command, string? ParameterJsonValue); + private sealed record StoredResource(Resource Resource, int ReplicaIndex); private sealed class ResourceRecord @@ -715,7 +671,7 @@ private sealed class PropertyRecord public required string ResourceName { get; init; } public required string Name { get; init; } public string? DisplayName { get; init; } - public required long ValueId { get; init; } + public required string JsonValue { get; init; } public bool? IsSensitive { get; init; } public required bool IsHighlighted { get; init; } public int? SortOrder { get; init; } @@ -728,7 +684,7 @@ private sealed class CommandRecord public required string Name { get; init; } public required string DisplayName { get; init; } public string? ConfirmationMessage { get; init; } - public long? ParameterValueId { get; init; } + public string? ParameterJsonValue { get; init; } public required bool IsHighlighted { get; init; } public string? IconName { get; init; } public int? IconVariant { get; init; } @@ -783,25 +739,4 @@ private sealed class ValidationErrorRecord public required string ValidationError { get; init; } } - private sealed class ValueRecord - { - public required long ValueId { get; init; } - public required int ValueKind { get; init; } - public string? StringValue { get; init; } - public double? NumberValue { get; init; } - public bool? BoolValue { get; init; } - } - - private sealed class MapValueRecord - { - public required long ParentValueId { get; init; } - public required string MapKey { get; init; } - public required long ChildValueId { get; init; } - } - - private sealed class ListValueRecord - { - public required long ParentValueId { get; init; } - public required long ChildValueId { get; init; } - } } \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs index 10757c08b8f..a836ead4346 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs @@ -209,15 +209,17 @@ FROM dashboard_resources """, transaction: transaction).ToHashSet(StringComparers.ResourceName); connection.Execute("DELETE FROM dashboard_resources;", transaction: transaction); _resources.Clear(); + var resourcesToSave = new List(resources.Count); foreach (var resource in resources) { var viewModel = CreateViewModel(resource); - SaveResource(connection, transaction, resource, viewModel.ReplicaIndex, resourcesWithLoadedConsoleLogs.Contains(resource.Name)); + resourcesToSave.Add(new(resource, viewModel.ReplicaIndex, resourcesWithLoadedConsoleLogs.Contains(resource.Name))); _resources[resource.Name] = viewModel; changes.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, viewModel)); } + InsertResources(connection, transaction, resourcesToSave); transaction.Commit(); } @@ -234,6 +236,22 @@ void IResourceRepositoryWriter.ApplyChanges(IReadOnlyList ThrowIfDisposed(); using var connection = _database.OpenConnection(); using var transaction = connection.BeginTransaction(); + var affectedResourceNames = changes + .Select(change => change.KindCase switch + { + WatchResourcesChange.KindOneofCase.Upsert => change.Upsert.Name, + WatchResourcesChange.KindOneofCase.Delete => change.Delete.ResourceName, + _ => null + }) + .Where(name => name is not null) + .Distinct(StringComparers.ResourceName) + .ToArray(); + var resourcesWithLoadedConsoleLogs = connection.Query(""" + SELECT resource_name + FROM dashboard_resources + WHERE resource_name IN @ResourceNames AND console_logs_loaded = 1; + """, new { ResourceNames = affectedResourceNames }, transaction).ToHashSet(StringComparers.ResourceName); + var resourcesToSave = new Dictionary(StringComparers.ResourceName); foreach (var change in changes) { @@ -241,17 +259,19 @@ void IResourceRepositoryWriter.ApplyChanges(IReadOnlyList { var resource = change.Upsert; var viewModel = CreateViewModel(resource); - SaveResource(connection, transaction, resource, viewModel.ReplicaIndex); + resourcesToSave[resource.Name] = new(resource, viewModel.ReplicaIndex, resourcesWithLoadedConsoleLogs.Contains(resource.Name)); _resources[resource.Name] = viewModel; viewModelChanges.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, viewModel)); } else if (change.KindCase == WatchResourcesChange.KindOneofCase.Delete && _resources.Remove(change.Delete.ResourceName, out var removed)) { - connection.Execute("DELETE FROM dashboard_resources WHERE resource_name = @ResourceName;", new { ResourceName = change.Delete.ResourceName }, transaction); + resourcesToSave.Remove(change.Delete.ResourceName); viewModelChanges.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Delete, removed)); } } + connection.Execute("DELETE FROM dashboard_resources WHERE resource_name IN @ResourceNames;", new { ResourceNames = affectedResourceNames }, transaction); + InsertResources(connection, transaction, resourcesToSave.Values.ToArray()); transaction.Commit(); } diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index cbcb7ca9e40..adfed40099b 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -271,6 +271,7 @@ public void DashboardRunSelect_Unsupported_IsHiddenAndStaleSelectionIsIgnored(bo [ new( RunId: "current", + SchemaVersion: DashboardRunStore.SchemaVersion, StartedAtUtc: DateTimeOffset.UnixEpoch, EndedAtUtc: null, CleanShutdown: false, @@ -284,6 +285,8 @@ public void DashboardRunSelect_Unsupported_IsHiddenAndStaleSelectionIsIgnored(bo OnGetAsync = _ => throw new InvalidOperationException("Run selection should not be read.") }; SetupMainLayoutServices(dashboardRunStore: runStore, sessionStorage: sessionStorage); + var runSelection = Assert.IsType(Services.GetRequiredService()); + var getRunsCallCount = runStore.GetRunsCallCount; var cut = RenderComponent(builder => { @@ -293,7 +296,7 @@ public void DashboardRunSelect_Unsupported_IsHiddenAndStaleSelectionIsIgnored(bo }); Assert.Empty(cut.FindComponents()); - var runSelection = Assert.IsType(Services.GetRequiredService()); + Assert.Equal(getRunsCallCount, runStore.GetRunsCallCount); Assert.Null(runSelection.SelectedRunId); } @@ -302,6 +305,7 @@ public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavig { var historicalRun = new DashboardRunDescriptor( RunId: "historical", + SchemaVersion: DashboardRunStore.SchemaVersion, StartedAtUtc: new DateTimeOffset(2025, 1, 2, 12, 30, 0, TimeSpan.Zero), EndedAtUtc: new DateTimeOffset(2025, 1, 2, 13, 30, 0, TimeSpan.Zero), CleanShutdown: true, @@ -312,6 +316,7 @@ public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavig [ new( RunId: "current", + SchemaVersion: DashboardRunStore.SchemaVersion, StartedAtUtc: DateTimeOffset.UnixEpoch, EndedAtUtc: null, CleanShutdown: false, @@ -422,6 +427,7 @@ public async Task RunSelectionPending_DoesNotRenderBody() [ new( RunId: "current", + SchemaVersion: DashboardRunStore.SchemaVersion, StartedAtUtc: DateTimeOffset.UnixEpoch, EndedAtUtc: null, CleanShutdown: false, diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index 36e5ad459de..a82d255c2ac 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -202,6 +202,7 @@ internal sealed class TestDashboardRunStore( [ new( RunId: "current", + SchemaVersion: DashboardRunStore.SchemaVersion, StartedAtUtc: DateTimeOffset.UnixEpoch, EndedAtUtc: null, CleanShutdown: false, @@ -210,18 +211,29 @@ internal sealed class TestDashboardRunStore( IsCurrent: true) ]; - public IReadOnlyList GetRuns() => _runs; + public int GetRunsCallCount { get; private set; } + + public IReadOnlyList GetRuns() + { + GetRunsCallCount++; + return _runs; + } public bool SupportsRunSelection => supportsRunSelection; } - internal sealed class TestDashboardRunSelection : IDashboardRunSelection + internal sealed class TestDashboardRunSelection(IDashboardRunStore runStore) : IDashboardRunSelection { + public DashboardRunDescriptor SelectedRun { get; private set; } = runStore.GetRuns().Single(run => run.IsCurrent); + public string? SelectedRunId { get; private set; } public void SelectRun(string? runId) { - SelectedRunId = runId; + var runs = runStore.GetRuns(); + SelectedRun = runs.FirstOrDefault(run => string.Equals(run.RunId, runId, StringComparison.Ordinal)) + ?? runs.Single(run => run.IsCurrent); + SelectedRunId = SelectedRun.IsCurrent ? null : SelectedRun.RunId; } } diff --git a/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs index 770872463ec..ff5c462d208 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs @@ -64,6 +64,31 @@ public async Task HealthEndpoint_OtlpExporterConfigured_ExportsAspNetCoreActivit Assert.Equal(ActivityKind.Server, activity.Kind); } + [Fact] + public async Task OtlpExporterConfigured_ExportsResourceServiceActivity() + { + var exportedActivity = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication( + testOutputHelper, + config => config["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://127.0.0.1:1", + builder => builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing.AddProcessor( + new SimpleActivityExportProcessor(new TestActivityExporter( + exportedActivity, + activity => activity.Source.Name == DashboardClient.ActivitySourceName))))); + await app.StartAsync().DefaultTimeout(); + var dashboardClient = app.Services.GetRequiredService(); + + using (var activity = dashboardClient.ActivitySource.StartActivity("Test resource update", ActivityKind.Consumer)) + { + Assert.NotNull(activity); + } + + var exported = await exportedActivity.Task.DefaultTimeout(); + Assert.Equal(DashboardClient.ActivitySourceName, exported.Source.Name); + Assert.Equal(ActivityKind.Consumer, exported.Kind); + } + private sealed class TestActivityExporter( TaskCompletionSource exportedActivity, Func predicate) : BaseExporter diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index 13f4567a86f..92d3c412615 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -1,10 +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 System.Collections.Concurrent; +using System.Diagnostics; using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; using Aspire.Dashboard.Utils; using Aspire.DashboardService.Proto.V1; +using Aspire.Tests; using Google.Protobuf.WellKnownTypes; using Grpc.Core; using Microsoft.AspNetCore.InternalTesting; @@ -291,6 +294,61 @@ public async Task ConnectionState_InitialState_IsConnecting() Assert.Equal(DashboardConnectionState.Connecting, client.ConnectionState); } + [Fact] + public async Task WhenConnected_AmbientActivity_DoesNotFlowToConnection() + { + await using var instance = CreateResourceServiceClient(); + var serviceClient = new MockDashboardServiceClient(); + instance.SetDashboardServiceClient(serviceClient); + + using var activity = new Activity("request").Start(); + + await instance.WhenConnected.DefaultTimeout(); + + Assert.Null(serviceClient.ActivityOnGetApplicationInformation); + } + + [Fact] + public async Task WatchResources_ResponseCreatesActivity() + { + await using var instance = CreateResourceServiceClient(); + instance.SetDashboardServiceClient(new MockDashboardServiceClient + { + ResourceUpdates = + [ + new WatchResourcesUpdate { InitialData = new InitialResourceData() }, + new WatchResourcesUpdate { Changes = new WatchResourcesChanges() } + ] + }); + var activities = new ConcurrentQueue(); + var activitiesReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var listener = ActivityListenerHelper.Create(instance.ActivitySource, onActivityStopped: activity => + { + activities.Enqueue(activity); + if (activities.Count == 2) + { + activitiesReceived.TrySetResult(); + } + }); + + _ = instance.WhenConnected; + await activitiesReceived.Task.DefaultTimeout(); + await instance.DisposeAsync().DefaultTimeout(); + + Assert.Collection( + activities, + activity => AssertActivity(activity, WatchResourcesUpdate.KindOneofCase.InitialData), + activity => AssertActivity(activity, WatchResourcesUpdate.KindOneofCase.Changes)); + + static void AssertActivity(Activity activity, WatchResourcesUpdate.KindOneofCase kind) + { + Assert.Equal(DashboardClient.ActivitySourceName, activity.Source.Name); + Assert.Equal("Process resource update", activity.OperationName); + Assert.Equal(ActivityKind.Consumer, activity.Kind); + Assert.Equal(kind.ToString(), activity.GetTagItem("aspire.dashboard.resource_update.type")); + } + } + [Fact] public async Task ConnectionState_SetConnected_FiresEvent() { @@ -556,6 +614,9 @@ private sealed class MockDashboardServiceClient : Aspire.DashboardService.Proto. public bool CancelExecuteResourceCommandOnCallCancellation { get; init; } public string MinDashboardVersion { get; init; } = ""; public IReadOnlyList ConsoleLogUpdates { get; init; } = []; + public IReadOnlyList ResourceUpdates { get; init; } = []; + public Activity? ActivityOnGetApplicationInformation { get; private set; } + private int _resourceUpdatesReturned; public override AsyncServerStreamingCall WatchResourceConsoleLogs(WatchResourceConsoleLogsRequest request, CallOptions options) { @@ -580,6 +641,7 @@ public override AsyncDuplexStreamingCall GetApplicationInformationAsync(ApplicationInformationRequest request, CallOptions options) { + ActivityOnGetApplicationInformation = Activity.Current; if (FailOnGetApplicationInformation) { return new AsyncUnaryCall( @@ -653,7 +715,7 @@ public override AsyncServerStreamingCall WatchResources(Wa { var reader = FailOnWatchResources ? (IAsyncStreamReader)new FailingAsyncStreamReader() - : new AsyncStreamReader(); + : new AsyncStreamReader(Interlocked.Exchange(ref _resourceUpdatesReturned, 1) == 0 ? ResourceUpdates : []); return new AsyncServerStreamingCall( reader, diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index d71e6a7a0ea..4af3eb30259 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json; using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; @@ -36,6 +37,16 @@ public void RunDirectory_IsNestedUnderApplicationDirectoryAndRuns() Assert.Equal(expectedRunsDirectory, Directory.GetParent(runStore.RunDirectory)!.FullName); } + [Fact] + public void RunMetadata_IncludesSchemaVersion() + { + using var runStore = CreateRunStore(CreateOptions()); + using var metadata = JsonDocument.Parse(File.ReadAllText(Path.Combine(runStore.RunDirectory, "run.json"))); + + Assert.Equal(DashboardRunStore.SchemaVersion, metadata.RootElement.GetProperty("SchemaVersion").GetInt32()); + Assert.Equal(DashboardRunStore.SchemaVersion, Assert.Single(runStore.GetRuns()).SchemaVersion); + } + [Fact] public void NoneMode_UsesTemporaryDatabaseAndDeletesItOnDispose() { @@ -158,17 +169,15 @@ public void AppendMode_DeletesIncompatibleDatabase() Assert.True(DashboardSqliteDatabase.IsCompatible(databasePath)); } - [Theory] - [InlineData("CREATE TABLE dashboard_schema (version INTEGER NOT NULL); INSERT INTO dashboard_schema VALUES (8), (8);")] - [InlineData("CREATE TABLE dashboard_schema (version); INSERT INTO dashboard_schema VALUES ('invalid');")] - public void IsCompatible_ReturnsFalseForMalformedSchema(string schemaSql) + [Fact] + public void IsCompatible_ReturnsFalseForMultipleSchemaVersions() { var databasePath = Path.Combine(_workspace.Path, $"malformed-{Guid.NewGuid():N}.db"); using (var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False")) { connection.Open(); using var command = connection.CreateCommand(); - command.CommandText = schemaSql; + command.CommandText = "CREATE TABLE dashboard_schema (version INTEGER NOT NULL) STRICT; INSERT INTO dashboard_schema VALUES (8), (8);"; command.ExecuteNonQuery(); } @@ -223,6 +232,17 @@ public void GetRuns_ReturnsCurrentThenCompletedHistoricalRun() }); } + [Fact] + public void GetRuns_ReusesLazySnapshot() + { + using var runStore = CreateRunStore(CreateOptions()); + + var first = runStore.GetRuns(); + var second = runStore.GetRuns(); + + Assert.Same(first, second); + } + [Fact] public void GetRuns_ExcludesRunOwnedByAnotherDashboard() { @@ -344,14 +364,16 @@ public void RunsMode_DeleteExpiredRunFails_LogsWarningAndContinues() } [Fact] - public void GetRuns_ExcludesIncompatibleDatabaseWithoutDeletingIt() + public void GetRuns_DoesNotReadDatabaseSchemaUntilRunIsSelected() { var options = CreateOptions(); string incompatibleDatabasePath; + string incompatibleRunId; using (var incompatibleRunStore = CreateRunStore(options)) { incompatibleDatabasePath = incompatibleRunStore.DatabasePath; + incompatibleRunId = incompatibleRunStore.RunId; using var connection = new SqliteConnection($"Data Source={incompatibleDatabasePath};Pooling=False"); connection.Open(); using var command = connection.CreateCommand(); @@ -361,8 +383,26 @@ public void GetRuns_ExcludesIncompatibleDatabaseWithoutDeletingIt() using var currentRunStore = CreateRunStore(options); - Assert.Collection(currentRunStore.GetRuns(), run => Assert.True(run.IsCurrent)); + Assert.Collection( + currentRunStore.GetRuns(), + run => Assert.True(run.IsCurrent), + run => + { + Assert.Equal(incompatibleRunId, run.RunId); + Assert.Equal(DashboardRunStore.SchemaVersion, run.SchemaVersion); + }); Assert.True(File.Exists(incompatibleDatabasePath)); + + var currentDatabase = new DashboardSqliteDatabase(currentRunStore.DatabasePath); + using var repositoryFactory = CreateRepositoryFactory(currentDatabase, options); + var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); + var currentResourceRepository = repositoryFactory.CreateResourceRepository(currentDatabase); + using var dataSource = new DashboardDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); + + var exception = Assert.Throws(() => dataSource.SelectRun(incompatibleRunId)); + Assert.Equal( + $"Dashboard database schema version '1' does not match run metadata schema version '{DashboardRunStore.SchemaVersion}'.", + exception.Message); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs index 95d15fad8fd..35dcb856acb 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs @@ -1,6 +1,7 @@ // 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 Aspire.DashboardService.Proto.V1; using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.InternalTesting; @@ -157,6 +158,12 @@ public void Resources_AllFieldsAndRecursiveValuesRoundTrip() resource.Environment.Add(new EnvironmentVariable { Name = "OPTIONAL", IsFromSpec = true }); resource.Environment.Add(new EnvironmentVariable { Name = "VALUE", Value = "set" }); resource.Urls.Add(new Url + { + EndpointName = "https", + FullUrl = "https://api.dev.localhost:5001/path", + DisplayProperties = new UrlDisplayProperties { SortOrder = 3, DisplayName = "Secure endpoint" } + }); + resource.Urls.Add(new Url { EndpointName = "https", FullUrl = "https://localhost:5001/path", @@ -182,10 +189,12 @@ public void Resources_AllFieldsAndRecursiveValuesRoundTrip() IsHighlighted = true, SortOrder = 7 }); +#pragma warning disable CS0612 // ResourceCommand.Parameter must be persisted for compatibility with older AppHosts. resource.Commands.Add(new ResourceCommand { Name = "configure", DisplayName = "Configure", + Parameter = nestedValue.Clone(), DisplayDescription = "Configure the resource", ConfirmationMessage = "Continue?", IsHighlighted = true, @@ -217,12 +226,25 @@ public void Resources_AllFieldsAndRecursiveValuesRoundTrip() } } }); +#pragma warning restore CS0612 using (var repository = CreateRepository(workspace.Path)) { ((IResourceRepositoryWriter)repository).ReplaceResources([resource]); } + using (var connection = new SqliteConnection($"Data Source={GetDatabasePath(workspace.Path)};Mode=ReadOnly;Pooling=False")) + { + connection.Open(); + using var sqliteCommand = connection.CreateCommand(); + sqliteCommand.CommandText = """ + SELECT COUNT(*) + FROM dashboard_resource_commands + WHERE json_extract(parameter_value, '$.name') = 'database'; + """; + Assert.Equal(1L, sqliteCommand.ExecuteScalar()); + } + using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); var actual = Assert.Single(historicalRepository.GetResources()); Assert.Equal("Running", actual.State); @@ -248,7 +270,19 @@ public void Resources_AllFieldsAndRecursiveValuesRoundTrip() Assert.Equal("Safe", input.Options["safe"]); Assert.Equal("Fast", input.Options["fast"]); Assert.Equal("Choose a mode", Assert.Single(input.ValidationErrors)); - Assert.Equal("https", Assert.Single(actual.Urls).EndpointName); + Assert.Collection(actual.Urls, + url => + { + Assert.Equal("https", url.EndpointName); + Assert.Equal("api.dev.localhost", url.Url.Host); + Assert.False(url.IsInternal); + }, + url => + { + Assert.Equal("https", url.EndpointName); + Assert.Equal("localhost", url.Url.Host); + Assert.True(url.IsInternal); + }); Assert.Equal("/data", Assert.Single(actual.Volumes).Target); Assert.Equal("database", Assert.Single(actual.Relationships).ResourceName); Assert.Equal("ready", Assert.Single(actual.HealthReports).Name); @@ -276,6 +310,31 @@ public void Resources_BulkLoadKeepsChildRecordsIsolated() resource => AssertResourceChildren(resource, "worker-value")); } + [Fact] + public void Resources_MultipleResourcesArePersistedWithBatchedQueries() + { + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + using var repository = CreateRepository(workspace.Path); + var writer = (IResourceRepositoryWriter)repository; + + var replaceQueries = CaptureSqlQueries(() => writer.ReplaceResources([ + CreateResourceWithChildren("api", "API", "api-value"), + CreateResourceWithChildren("worker", "Worker", "worker-value") + ])); + AssertBatchedResourceQueries(replaceQueries); + + var applyQueries = CaptureSqlQueries(() => writer.ApplyChanges([ + new WatchResourcesChange { Upsert = CreateResourceWithChildren("api", "API", "api-updated") }, + new WatchResourcesChange { Upsert = CreateResourceWithChildren("worker", "Worker", "worker-updated") } + ])); + AssertBatchedResourceQueries(applyQueries); + + var resources = repository.GetResources().OrderBy(resource => resource.Name).ToArray(); + Assert.Collection(resources, + resource => AssertResourceChildren(resource, "api-updated"), + resource => AssertResourceChildren(resource, "worker-updated")); + } + [Fact] public void Schema_HasNoSerializedResourceColumns() { @@ -303,6 +362,69 @@ FROM pragma_table_info('dashboard_resources') Assert.Equal(0L, command.ExecuteScalar()); } + [Fact] + public void Values_AreStoredOnOwnerRowsAsValidatedJson() + { + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); + var resource = CreateResource("api", "API"); + resource.Properties.Add(new ResourceProperty + { + Name = "nested", + Value = new Value + { + StructValue = new Struct + { + Fields = + { + ["name"] = Value.ForString("database"), + ["values"] = new Value + { + ListValue = new ListValue + { + Values = + { + Value.ForNumber(42.5), + Value.ForBool(true), + new Value { NullValue = NullValue.NullValue } + } + } + } + } + } + } + }); + + using (var repository = CreateRepository(workspace.Path)) + { + ((IResourceRepositoryWriter)repository).ReplaceResources([resource]); + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT COUNT(*) + FROM dashboard_resource_properties + WHERE typeof(value) = 'text' + AND json_valid(value) + AND json_extract(value, '$.name') = 'database' + AND json_array_length(value, '$.values') = 3; + """; + Assert.Equal(1L, command.ExecuteScalar()); + + command.CommandText = """ + SELECT COUNT(*) + FROM sqlite_schema + WHERE type = 'table' + AND name IN ('dashboard_values', 'dashboard_value_map_entries', 'dashboard_value_list_items'); + """; + Assert.Equal(0L, command.ExecuteScalar()); + + command.CommandText = "UPDATE dashboard_resource_properties SET value = 'invalid' WHERE resource_name = 'api';"; + Assert.Throws(() => command.ExecuteNonQuery()); + } + [Fact] public void Schema_ResourceRepositoryInitializesAllEmbeddedScripts() { @@ -446,6 +568,50 @@ private static Resource CreateResourceWithChildren(string name, string displayNa return resource; } + private static IReadOnlyList CaptureSqlQueries(Action action) + { + var queries = new List(); + using var operation = new Activity("Capture resource persistence queries").Start(); + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == TracingSqliteConnection.ActivitySourceName, + Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + ActivityStopped = activity => + { + if (activity.TraceId == operation.TraceId && activity.GetTagItem("db.query.text") is string query) + { + queries.Add(query); + } + } + }; + ActivitySource.AddActivityListener(listener); + + action(); + + return queries; + } + + private static void AssertBatchedResourceQueries(IReadOnlyList queries) + { + Assert.Equal(11, queries.Count); + + string[] insertedTables = + [ + "dashboard_resources", + "dashboard_resource_environment", + "dashboard_resource_properties", + "dashboard_resource_commands", + "dashboard_resource_command_inputs", + "dashboard_resource_command_input_options", + "dashboard_resource_command_input_validation_errors" + ]; + + foreach (var table in insertedTables) + { + Assert.Single(queries, query => query.TrimStart().StartsWith($"INSERT INTO {table} ", StringComparison.Ordinal)); + } + } + private static void AssertResourceChildren(global::Aspire.Dashboard.Model.ResourceViewModel resource, string expected) { Assert.Equal(expected, Assert.Single(resource.Environment).Value); diff --git a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs index d9192ce5aaf..1d85b788737 100644 --- a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs @@ -1,6 +1,7 @@ // 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.Runtime.CompilerServices; using System.Threading.Channels; using Aspire.Dashboard.Model; @@ -188,6 +189,22 @@ async IAsyncEnumerable> GetChanges([Enume } } + [Fact] + public async Task Constructor_AmbientActivity_DoesNotFlowToResourceWatch() + { + var subscribeResult = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dashboardClient = new MockDashboardClient(subscribeResult.Task); + using var activity = new Activity("request").Start(); + var resolver = new ResourceOutgoingPeerResolver(dashboardClient); + + Assert.Null(await dashboardClient.ActivityOnSubscribe.Task.DefaultTimeout()); + + var sourceChannel = Channel.CreateUnbounded>(); + sourceChannel.Writer.Complete(); + subscribeResult.SetResult(new ResourceViewModelSubscription([], sourceChannel.Reader.ReadAllAsync())); + await resolver.DisposeAsync().DefaultTimeout(); + } + [Fact] public void NameAndDisplayNameDifferent_OneInstance_ReturnDisplayName() { @@ -415,6 +432,7 @@ public void SingleResourceAfterTransformation_ReturnsTrue() private sealed class MockDashboardClient(Task subscribeResult) : IDashboardClient { + public TaskCompletionSource ActivityOnSubscribe { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public bool IsEnabled => true; public Task WhenConnected => Task.CompletedTask; public string ApplicationName => "ApplicationName"; @@ -433,6 +451,10 @@ private sealed class MockDashboardClient(Task sub public Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException(); public IAsyncEnumerable SubscribeInteractionsAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); - public Task SubscribeResourcesAsync(CancellationToken cancellationToken) => subscribeResult; + public Task SubscribeResourcesAsync(CancellationToken cancellationToken) + { + ActivityOnSubscribe.TrySetResult(Activity.Current); + return subscribeResult; + } } } diff --git a/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs b/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs index 1fee6fc5ce3..c8f799bada0 100644 --- a/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs +++ b/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs @@ -24,7 +24,7 @@ private sealed class TestRunStore : IDashboardRunStore public bool SupportsRunSelection => false; public IReadOnlyList GetRuns() => - [new("current", DateTimeOffset.UnixEpoch, null, false, "TestApp", string.Empty, IsCurrent: true)]; + [new("current", DashboardRunStore.SchemaVersion, DateTimeOffset.UnixEpoch, null, false, "TestApp", string.Empty, IsCurrent: true)]; } private sealed class TestRepositoryFactory( From 65ab73a9b5a175fba950b633bbf4fbdb3bb75512 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sat, 18 Jul 2026 22:37:06 +0800 Subject: [PATCH 35/87] WIP --- playground/TestShop/TestShop.AppHost/AppHost.cs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/playground/TestShop/TestShop.AppHost/AppHost.cs b/playground/TestShop/TestShop.AppHost/AppHost.cs index 7c18c53c21c..479ba73dfd8 100644 --- a/playground/TestShop/TestShop.AppHost/AppHost.cs +++ b/playground/TestShop/TestShop.AppHost/AppHost.cs @@ -92,18 +92,10 @@ .WaitFor(basketService) .WithReference(catalogService) .WaitFor(catalogService) - .WithUrls(c => - { - var hasHttpsUrl = c.Urls.Any(u => string.Equals(u.Endpoint?.EndpointName, "https", StringComparison.OrdinalIgnoreCase)); - c.Urls.ForEach(u => - { - u.DisplayText = $"Online store ({u.Endpoint?.EndpointName})"; - if (hasHttpsUrl && string.Equals(u.Endpoint?.EndpointName, "http", StringComparison.OrdinalIgnoreCase)) - { - u.DisplayLocation = UrlDisplayLocation.DetailsOnly; - } - }); - }) + // Modify the display text of the URLs + .WithUrls(c => c.Urls.ForEach(u => u.DisplayText = $"Online store ({u.Endpoint?.EndpointName})")) + // Don't show the non-HTTPS link on the resources page (details only) + .WithUrlForEndpoint("http", url => url.DisplayLocation = UrlDisplayLocation.DetailsOnly) // Add health relative URL (show in details only) .WithUrlForEndpoint("https", ep => new() { Url = "/health", DisplayText = "Health", DisplayLocation = UrlDisplayLocation.DetailsOnly }) .WithHttpHealthCheck("/health"); From 51611866bab9ae91ddb03dbd2d96f30ca6584052 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sat, 18 Jul 2026 23:36:20 +0800 Subject: [PATCH 36/87] Fix dashboard run schema and URL handling --- .../Properties/launchSettings.json | 1 + .../ServiceClient/DashboardDataSource.cs | 6 ++- .../ServiceClient/DashboardSqliteDatabase.cs | 51 +++++++------------ .../Model/DashboardDataSourceTests.cs | 2 +- .../Model/SqliteResourceRepositoryTests.cs | 48 +++++++++++++++++ 5 files changed, 74 insertions(+), 34 deletions(-) diff --git a/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json b/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json index f9d3fdd8e0a..888be547e88 100644 --- a/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json +++ b/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json @@ -18,6 +18,7 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", + "AppHost__DefaultLaunchProfileName": "https", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc" } diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs index 6ce123b79c1..8004e646826 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs @@ -76,7 +76,11 @@ internal void SelectRun(string? runId) var historicalDatabase = new DashboardSqliteDatabase(selectedRun.DatabasePath, readOnly: true); try { - historicalDatabase.ValidateSchemaVersion(selectedRun.SchemaVersion); + if (!historicalDatabase.ValidateSchemaVersion(selectedRun.SchemaVersion)) + { + throw new InvalidOperationException( + $"Dashboard database for run '{selectedRun.RunId}' does not match run metadata schema version '{selectedRun.SchemaVersion}'."); + } _historicalTelemetryRepository = _repositoryFactory.CreateTelemetryRepository(historicalDatabase); _historicalResourceRepository = _repositoryFactory.CreateResourceRepository(historicalDatabase); _historicalDatabase = historicalDatabase; diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index 9b51c0b0310..a3ec0bba8e9 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -4,7 +4,6 @@ using Dapper; using System.Data; using System.Diagnostics; -using System.Globalization; using Microsoft.Data.Sqlite; namespace Aspire.Dashboard.ServiceClient; @@ -63,14 +62,7 @@ public static bool IsCompatible(string databasePath) try { using var connection = database.OpenConnection(); - var version = connection.QuerySingleOrDefault(""" - SELECT CASE - WHEN COUNT(*) = 1 THEN MAX(version) - ELSE NULL - END - FROM dashboard_schema; - """); - return version == SchemaVersion; + return ValidateSchemaVersion(connection, transaction: null, SchemaVersion); } catch (SqliteException) { @@ -87,21 +79,10 @@ public TracingSqliteConnection OpenConnection() return connection; } - internal void ValidateSchemaVersion(int metadataSchemaVersion) + internal bool ValidateSchemaVersion(int metadataSchemaVersion) { using var connection = OpenConnection(); - var schemaVersion = connection.QuerySingleOrDefault(""" - SELECT CASE - WHEN COUNT(*) = 1 THEN MAX(version) - ELSE NULL - END - FROM dashboard_schema; - """); - if (schemaVersion != metadataSchemaVersion) - { - throw new InvalidOperationException( - $"Dashboard database schema version '{schemaVersion?.ToString(CultureInfo.InvariantCulture) ?? "invalid"}' does not match run metadata schema version '{metadataSchemaVersion}'."); - } + return ValidateSchemaVersion(connection, transaction: null, metadataSchemaVersion); } public static void ClearPools() => SqliteConnection.ClearAllPools(); @@ -133,9 +114,9 @@ SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name = 'dashboard_schema'; """) != 0; - if (schemaTableExists) + if (schemaTableExists && !ValidateSchemaVersion(connection, transaction: null, SchemaVersion)) { - ValidateSchemaVersion(connection, transaction: null); + throw new InvalidOperationException("The dashboard database schema does not match the expected version."); } using var transaction = connection.BeginTransaction(); @@ -144,7 +125,10 @@ FROM sqlite_schema connection.Execute(script, new { SchemaVersion }, transaction); } - ValidateSchemaVersion(connection, transaction); + if (!ValidateSchemaVersion(connection, transaction, SchemaVersion)) + { + throw new InvalidOperationException("The dashboard database schema was not initialized to the expected version."); + } transaction.Commit(); _schemaInitialized = true; } @@ -184,12 +168,15 @@ private static IReadOnlyList LoadSchemaScripts() return scripts; } - private static void ValidateSchemaVersion(SqliteConnection connection, IDbTransaction? transaction) + private static bool ValidateSchemaVersion(SqliteConnection connection, IDbTransaction? transaction, int expectedVersion) { - var version = connection.QuerySingle("SELECT version FROM dashboard_schema;", transaction: transaction); - if (version != SchemaVersion) - { - throw new InvalidOperationException($"Unsupported dashboard database schema version '{version}'."); - } + var version = connection.QuerySingleOrDefault(""" + SELECT CASE + WHEN COUNT(*) = 1 THEN MAX(version) + ELSE NULL + END + FROM dashboard_schema; + """, transaction: transaction); + return version == expectedVersion; } -} \ No newline at end of file +} diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index 4af3eb30259..59b38abe581 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -401,7 +401,7 @@ public void GetRuns_DoesNotReadDatabaseSchemaUntilRunIsSelected() var exception = Assert.Throws(() => dataSource.SelectRun(incompatibleRunId)); Assert.Equal( - $"Dashboard database schema version '1' does not match run metadata schema version '{DashboardRunStore.SchemaVersion}'.", + $"Dashboard database for run '{incompatibleRunId}' does not match run metadata schema version '{DashboardRunStore.SchemaVersion}'.", exception.Message); } diff --git a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs index 35dcb856acb..2f5284c810a 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs @@ -288,6 +288,43 @@ FROM dashboard_resource_commands Assert.Equal("ready", Assert.Single(actual.HealthReports).Name); } + [Fact] + public void Resources_DuplicateEndpointUrlsRoundTrip() + { + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var resource = CreateResource("frontend-cqgvshvm", "frontend"); + resource.Urls.AddRange( + [ + CreateUrl("http", "Online store (http)", "http://frontend-testshop.dev.localhost:5266/"), + CreateUrl("http", "Online store (http)", "http://localhost:5266/", isInternal: true), + CreateUrl("https", "Online store (https)", "https://frontend-testshop.dev.localhost:7269/"), + CreateUrl("https", "Online store (https)", "https://localhost:7269/", isInternal: true), + CreateUrl("https", "Health", "https://localhost:7269/health", isInternal: true) + ]); + + using (var repository = CreateRepository(workspace.Path)) + { + ((IResourceRepositoryWriter)repository).ReplaceResources([resource]); + } + + using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); + var actual = Assert.Single(historicalRepository.GetResources()); + Assert.Collection(actual.Urls, + url => AssertUrl(url, "http", "Online store (http)", "http://frontend-testshop.dev.localhost:5266/", isInternal: false), + url => AssertUrl(url, "http", "Online store (http)", "http://localhost:5266/", isInternal: true), + url => AssertUrl(url, "https", "Online store (https)", "https://frontend-testshop.dev.localhost:7269/", isInternal: false), + url => AssertUrl(url, "https", "Online store (https)", "https://localhost:7269/", isInternal: true), + url => AssertUrl(url, "https", "Health", "https://localhost:7269/health", isInternal: true)); + + static void AssertUrl(global::Aspire.Dashboard.Model.UrlViewModel actual, string endpointName, string displayName, string url, bool isInternal) + { + Assert.Equal(endpointName, actual.EndpointName); + Assert.Equal(displayName, actual.DisplayProperties.DisplayName); + Assert.Equal(url, actual.Url.ToString()); + Assert.Equal(isInternal, actual.IsInternal); + } + } + [Fact] public void Resources_BulkLoadKeepsChildRecordsIsolated() { @@ -545,6 +582,17 @@ private static Resource CreateResource(string name, string displayName) }; } + private static Url CreateUrl(string endpointName, string displayName, string url, bool isInternal = false) + { + return new Url + { + EndpointName = endpointName, + FullUrl = url, + IsInternal = isInternal, + DisplayProperties = new UrlDisplayProperties { DisplayName = displayName } + }; + } + private static Resource CreateResourceWithChildren(string name, string displayName, string value) { var resource = CreateResource(name, displayName); From 2a806511555a58eb6e5896ab06fb511f88db8635 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sun, 19 Jul 2026 13:43:24 +0800 Subject: [PATCH 37/87] Address dashboard run history review feedback --- .../ServiceClient/DashboardDataSource.cs | 7 +++ .../ServiceClient/DashboardRunStore.cs | 18 ++++++- .../ServiceClient/DatabaseSchema/003.Logs.sql | 2 + .../DatabaseSchema/005.Metrics.sql | 2 + .../Shared/FluentUISetupHelpers.cs | 2 + .../Model/DashboardDataSourceTests.cs | 48 +++++++++++++++++++ .../Shared/TestDashboardDataSource.cs | 2 + 7 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs index 8004e646826..bd0d64bd28d 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs @@ -24,6 +24,7 @@ public sealed class DashboardDataSource : IDashboardRunSelection, IDisposable private ITelemetryRepository? _historicalTelemetryRepository; private IResourceRepository? _historicalResourceRepository; private DashboardSqliteDatabase? _historicalDatabase; + private IDisposable? _historicalRunLease; internal DashboardDataSource( IDashboardRunStore runStore, @@ -73,6 +74,8 @@ internal void SelectRun(string? runId) if (!selectedRun.IsCurrent) { + var historicalRunLease = _runStore.TryAcquireRunLease(selectedRun) + ?? throw new InvalidOperationException($"Dashboard run '{selectedRun.RunId}' is no longer available."); var historicalDatabase = new DashboardSqliteDatabase(selectedRun.DatabasePath, readOnly: true); try { @@ -84,10 +87,12 @@ internal void SelectRun(string? runId) _historicalTelemetryRepository = _repositoryFactory.CreateTelemetryRepository(historicalDatabase); _historicalResourceRepository = _repositoryFactory.CreateResourceRepository(historicalDatabase); _historicalDatabase = historicalDatabase; + _historicalRunLease = historicalRunLease; } catch { historicalDatabase.Dispose(); + historicalRunLease.Dispose(); throw; } TelemetryRepository = _historicalTelemetryRepository; @@ -115,5 +120,7 @@ private void DisposeHistoricalRepositories() (_historicalResourceRepository as IDisposable)?.Dispose(); _historicalDatabase?.Dispose(); _historicalDatabase = null; + _historicalRunLease?.Dispose(); + _historicalRunLease = null; } } diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index 51d485dfec3..d1bbee21617 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -13,6 +13,7 @@ internal interface IDashboardRunStore { bool SupportsRunSelection { get; } IReadOnlyList GetRuns(); + IDisposable? TryAcquireRunLease(DashboardRunDescriptor run); } internal sealed class DashboardRunStore : IDashboardRunStore, IDisposable @@ -140,6 +141,12 @@ private void DeleteAbandonedTemporaryDirectories(Action deleteRunDirecto public IReadOnlyList GetRuns() => _runs.Value; + public IDisposable? TryAcquireRunLease(DashboardRunDescriptor run) + { + var runDirectory = Path.GetDirectoryName(run.DatabasePath)!; + return TryOpenRunLock(runDirectory); + } + private IReadOnlyList LoadRuns() { var runs = new List @@ -258,7 +265,16 @@ private static FileStream OpenRunLock(string runDirectory) { try { - return OpenRunLock(runDirectory); + var runLock = OpenRunLock(runDirectory); + // The lock file is adjacent to the run directory, so OpenOrCreate can recreate it after pruning has already + // deleted the directory. Check after acquiring the lock to avoid racing with a cooperating pruner. + if (!Directory.Exists(runDirectory)) + { + runLock.Dispose(); + return null; + } + + return runLock; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql index d8498a1cacf..ee9798b4abe 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/003.Logs.sql @@ -34,6 +34,8 @@ CREATE TABLE IF NOT EXISTS telemetry_resource_view_attributes ( PRIMARY KEY (resource_view_id, ordinal) ) STRICT; +-- Scope identity intentionally uses the name only. Scope attributes aren't used by the Dashboard today, +-- so the version and attributes from the first observed scope are retained for later telemetry with the same name. CREATE TABLE IF NOT EXISTS telemetry_scopes ( scope_id INTEGER PRIMARY KEY AUTOINCREMENT, scope_name TEXT NOT NULL UNIQUE, diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql index 1c64353b8af..b9cc6b3fded 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql @@ -84,5 +84,7 @@ CREATE INDEX IF NOT EXISTS ix_telemetry_metric_points_dimension_order ON telemetry_metric_points(dimension_id, point_id); CREATE INDEX IF NOT EXISTS ix_telemetry_metric_points_time ON telemetry_metric_points(dimension_id, start_time_ticks, end_time_ticks); +-- Exemplar identity intentionally omits span/trace IDs and filtered attributes. Distinct exemplars that share a +-- point, timestamp, and value can be collapsed, but that combination is unlikely to occur in real-world telemetry. CREATE UNIQUE INDEX IF NOT EXISTS ix_telemetry_metric_exemplars_identity ON telemetry_metric_exemplars(point_id, start_time_ticks, exemplar_value); \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index a82d255c2ac..4a14c0d7d8f 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -219,6 +219,8 @@ public IReadOnlyList GetRuns() return _runs; } + public IDisposable? TryAcquireRunLease(DashboardRunDescriptor run) => null; + public bool SupportsRunSelection => supportsRunSelection; } diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index 59b38abe581..cbe3160a25f 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -328,6 +328,54 @@ public void RunsMode_DoesNotDeleteActiveExpiredRun() Assert.Equal(DashboardRunStore.MaxRuns + 1, Directory.GetDirectories(runsDirectory).Length); } + [Fact] + public void SelectedHistoricalRun_HoldsLeaseUntilSelectionChanges() + { + var options = CreateOptions(); + string historicalRunId; + string historicalRunDirectory; + + using (var historicalRunStore = CreateRunStore(options)) + { + historicalRunId = historicalRunStore.RunId; + historicalRunDirectory = historicalRunStore.RunDirectory; + using var historicalTelemetryRepository = CreateTelemetryRepository(historicalRunStore.DatabasePath, options); + } + + using var currentRunStore = CreateRunStore(options); + var currentDatabase = new DashboardSqliteDatabase(currentRunStore.DatabasePath); + using var repositoryFactory = CreateRepositoryFactory(currentDatabase, options); + var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); + var currentResourceRepository = repositoryFactory.CreateResourceRepository(currentDatabase); + using var dataSource = new DashboardDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); + dataSource.SelectRun(historicalRunId); + + var runsDirectory = Path.GetDirectoryName(historicalRunDirectory)!; + foreach (var index in Enumerable.Range(1, DashboardRunStore.MaxRuns - 2)) + { + Directory.CreateDirectory(Path.Combine( + runsDirectory, + $"{DateTimeOffset.UtcNow.AddDays(index):yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}")); + } + + var deletedRunDirectories = new List(); + using var pruningRunStore = new DashboardRunStore( + options, + NullLogger.Instance, + deletedRunDirectories.Add); + + Assert.Empty(deletedRunDirectories); + Assert.True(Directory.Exists(historicalRunDirectory)); + + dataSource.SelectRun(runId: null); + using var nextPruningRunStore = new DashboardRunStore( + options, + NullLogger.Instance, + deletedRunDirectories.Add); + + Assert.Equal(historicalRunDirectory, Assert.Single(deletedRunDirectories)); + } + [Fact] public void RunsMode_DeleteExpiredRunFails_LogsWarningAndContinues() { diff --git a/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs b/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs index c8f799bada0..a31f895fc80 100644 --- a/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs +++ b/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs @@ -25,6 +25,8 @@ private sealed class TestRunStore : IDashboardRunStore public IReadOnlyList GetRuns() => [new("current", DashboardRunStore.SchemaVersion, DateTimeOffset.UnixEpoch, null, false, "TestApp", string.Empty, IsCurrent: true)]; + + public IDisposable? TryAcquireRunLease(DashboardRunDescriptor run) => null; } private sealed class TestRepositoryFactory( From 7bfa93d33f2a9b1a90fab84c45ef0513001020d0 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sun, 19 Jul 2026 14:07:46 +0800 Subject: [PATCH 38/87] Fix dashboard resource snapshot state tracking --- .../Components/Pages/ConsoleLogs.razor.cs | 18 +++--- .../Model/ResourceViewModel.cs | 1 + .../ServiceClient/DashboardClient.cs | 24 ++++---- .../ServiceClient/IResourceRepository.cs | 5 -- .../ServiceClient/SelectedDashboardClient.cs | 1 - .../SqliteResourceRepository.Storage.cs | 8 ++- .../ServiceClient/SqliteResourceRepository.cs | 60 +++++++++---------- .../Pages/ConsoleLogsTests.cs | 4 +- .../Model/SqliteResourceRepositoryTests.cs | 47 ++++++++++++--- tests/Shared/TestDashboardClient.cs | 7 +-- 10 files changed, 102 insertions(+), 73 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index e3280bb19c5..585251619aa 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -496,10 +496,12 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa // hosting messages (WaitFor, startup failures) are visible immediately // on selection. The user picks Terminal explicitly from the ⋯ menu. _activeView = ConsoleLogsView.Console; + var selectedResource = selectedResourceName is not null + ? _resourceByName.GetValueOrDefault(selectedResourceName) + : null; if (!DashboardClient.IsReadOnly && - !isAllSelected && selectedResourceName is not null && - _resourceByName.TryGetValue(selectedResourceName, out var selectedResource) && + !isAllSelected && selectedResource is not null && selectedResource.HasTerminal() && selectedResource.TryGetTerminalReplicaInfo(out var replicaIndex, out _)) { @@ -527,7 +529,7 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa } ResetNoLogsMessage(); - _consoleLogsWereLoaded = !DashboardClient.IsReadOnly || WereConsoleLogsLoaded(isAllSelected, selectedResourceName); + _consoleLogsWereLoaded = !DashboardClient.IsReadOnly || WereConsoleLogsLoaded(isAllSelected, selectedResource); await InvokeAsync(_logViewerRef.SafeRefreshDataAsync); @@ -537,11 +539,11 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa _isSubscribedToAll = true; await SubscribeToAllResourcesAsync(); } - else if (selectedResourceName is not null && _resourceByName.TryGetValue(selectedResourceName, out var resource)) + else if (selectedResource is not null) { // Subscribe to single resource _isSubscribedToAll = false; - await SubscribeToSingleResourceAsync(resource); + await SubscribeToSingleResourceAsync(selectedResource); } else { @@ -562,16 +564,16 @@ private async Task SubscribeAsync(bool isAllSelected, string? selectedResourceNa UpdateMenuButtons(); } - private bool WereConsoleLogsLoaded(bool isAllSelected, string? selectedResourceName) + private bool WereConsoleLogsLoaded(bool isAllSelected, ResourceViewModel? selectedResource) { if (isAllSelected) { return _resourceByName.Values .Where(resource => !resource.IsResourceHidden(_showHiddenResources)) - .Any(resource => DashboardClient.HaveConsoleLogsBeenLoaded(resource.Name)); + .Any(resource => resource.ConsoleLogsLoaded); } - return selectedResourceName is not null && DashboardClient.HaveConsoleLogsBeenLoaded(selectedResourceName); + return selectedResource?.ConsoleLogsLoaded == true; } private string GetNoLogsMessage() => _consoleLogsWereLoaded diff --git a/src/Aspire.Dashboard/Model/ResourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceViewModel.cs index 03e1f68c7f1..67d38d504af 100644 --- a/src/Aspire.Dashboard/Model/ResourceViewModel.cs +++ b/src/Aspire.Dashboard/Model/ResourceViewModel.cs @@ -45,6 +45,7 @@ public sealed class ResourceViewModel public bool SupportsDetailedTelemetry { get; init; } public string? IconName { get; init; } public IconVariant? IconVariant { get; init; } + internal bool ConsoleLogsLoaded { get; set; } public bool IsParameter => string.Equals(ResourceType, KnownResourceTypes.Parameter, StringComparison.Ordinal); /// diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index dbd9f0b0352..2c26f45f2ee 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -53,7 +53,6 @@ internal sealed class DashboardClient : IDashboardClient private static readonly SemVersion? s_dashboardVersion = GetDashboardVersion(); private readonly Dictionary _resourceByName = new(StringComparers.ResourceName); - private readonly HashSet _loadedConsoleLogResources = new(StringComparers.ResourceName); private readonly ActivitySource _activitySource = new(ActivitySourceName); private readonly InteractionCollection _pendingInteractionCollection = new(); private readonly CancellationTokenSource _cts = new(); @@ -581,6 +580,11 @@ private async Task WatchResourcesAsync(RetryContext retryContext, C if (response.KindCase == WatchResourcesUpdate.KindOneofCase.InitialData) { + var resourcesWithLoadedConsoleLogs = _resourceByName.Values + .Where(resource => resource.ConsoleLogsLoaded) + .Select(resource => resource.Name) + .ToHashSet(StringComparers.ResourceName); + // Populate our map using the initial data. _resourceByName.Clear(); @@ -590,6 +594,7 @@ private async Task WatchResourcesAsync(RetryContext retryContext, C { // Add to map. var viewModel = resource.ToViewModel(CalculateReplicaIndex(resource.DisplayName), _knownPropertyLookup, _logger); + viewModel.ConsoleLogsLoaded = resourcesWithLoadedConsoleLogs.Contains(resource.Name); _resourceByName[resource.Name] = viewModel; // Send this update to any subscribers too. @@ -610,6 +615,10 @@ private async Task WatchResourcesAsync(RetryContext retryContext, C { // Upsert (i.e. add or replace) var viewModel = change.Upsert.ToViewModel(CalculateReplicaIndex(change.Upsert.DisplayName), _knownPropertyLookup, _logger); + if (_resourceByName.TryGetValue(change.Upsert.Name, out var existingResource)) + { + viewModel.ConsoleLogsLoaded = existingResource.ConsoleLogsLoaded; + } _resourceByName[change.Upsert.Name] = viewModel; changes.Add(new(ResourceViewModelChangeType.Upsert, viewModel)); } @@ -983,19 +992,14 @@ public async IAsyncEnumerable> GetConsoleLogs(str } } - public bool HaveConsoleLogsBeenLoaded(string resourceName) - { - lock (_lock) - { - return _loadedConsoleLogResources.Contains(resourceName); - } - } - private void MarkConsoleLogsLoaded(string resourceName) { lock (_lock) { - _loadedConsoleLogResources.Add(resourceName); + if (_resourceByName.TryGetValue(resourceName, out var resource)) + { + resource.ConsoleLogsLoaded = true; + } } _resourceRepositoryWriter?.MarkConsoleLogsLoaded(resourceName); diff --git a/src/Aspire.Dashboard/ServiceClient/IResourceRepository.cs b/src/Aspire.Dashboard/ServiceClient/IResourceRepository.cs index b5393553ad2..c75c9693606 100644 --- a/src/Aspire.Dashboard/ServiceClient/IResourceRepository.cs +++ b/src/Aspire.Dashboard/ServiceClient/IResourceRepository.cs @@ -30,11 +30,6 @@ public interface IResourceRepository /// IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken); - /// - /// Gets whether console logs have been loaded for a resource. - /// - bool HaveConsoleLogsBeenLoaded(string resourceName) => false; - /// /// Gets existing console log messages for a resource. /// diff --git a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs index 45cf43668d4..72d5c232ffc 100644 --- a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs @@ -34,7 +34,6 @@ public event Action? ConnectionStateChanged public Task SubscribeResourcesAsync(CancellationToken cancellationToken) => dataSource.ResourceRepository.SubscribeResourcesAsync(cancellationToken); public ResourceViewModel? GetResource(string resourceName) => dataSource.ResourceRepository.GetResource(resourceName); public IReadOnlyList GetResources() => dataSource.ResourceRepository.GetResources(); - public bool HaveConsoleLogsBeenLoaded(string resourceName) => dataSource.ResourceRepository.HaveConsoleLogsBeenLoaded(resourceName); public IAsyncEnumerable> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => IsReadOnly ? dataSource.ResourceRepository.SubscribeConsoleLogs(resourceName, cancellationToken) diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs index 653a10d3384..3bede6df0e0 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs @@ -336,7 +336,8 @@ private static IEnumerable LoadResourceRecords(SqliteConnection is_hidden AS IsHidden, supports_detailed_telemetry AS SupportsDetailedTelemetry, icon_name AS IconName, - icon_variant AS IconVariant + icon_variant AS IconVariant, + console_logs_loaded AS ConsoleLogsLoaded FROM dashboard_resources ORDER BY rowid; @@ -555,7 +556,7 @@ FROM dashboard_resource_command_input_validation_errors } #pragma warning restore CS0612 - yield return new StoredResource(resource, record.ReplicaIndex); + yield return new StoredResource(resource, record.ReplicaIndex, record.ConsoleLogsLoaded); } } @@ -604,7 +605,7 @@ private sealed record PropertyToSave(string ResourceName, int Ordinal, ResourceP private sealed record CommandToSave(string ResourceName, int Ordinal, ResourceCommand Command, string? ParameterJsonValue); - private sealed record StoredResource(Resource Resource, int ReplicaIndex); + private sealed record StoredResource(Resource Resource, int ReplicaIndex, bool ConsoleLogsLoaded); private sealed class ResourceRecord { @@ -625,6 +626,7 @@ private sealed class ResourceRecord public required bool SupportsDetailedTelemetry { get; init; } public string? IconName { get; init; } public int? IconVariant { get; init; } + public required bool ConsoleLogsLoaded { get; init; } } private sealed class EnvironmentRecord diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs index a836ead4346..89274dfaabe 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs @@ -71,22 +71,6 @@ public IReadOnlyList GetResources() } } - public bool HaveConsoleLogsBeenLoaded(string resourceName) - { - lock (_lock) - { - ThrowIfDisposed(); - using var connection = _database.OpenConnection(); - return connection.QuerySingle(""" - SELECT COALESCE(( - SELECT console_logs_loaded - FROM dashboard_resources - WHERE resource_name = @ResourceName - ), 0); - """, new { ResourceName = resourceName }); - } - } - public Task SubscribeResourcesAsync(CancellationToken cancellationToken) { lock (_lock) @@ -202,11 +186,19 @@ void IResourceRepositoryWriter.ReplaceResources(IReadOnlyList resource ThrowIfDisposed(); using var connection = _database.OpenConnection(); using var transaction = connection.BeginTransaction(); - var resourcesWithLoadedConsoleLogs = connection.Query(""" - SELECT resource_name - FROM dashboard_resources - WHERE console_logs_loaded = 1; - """, transaction: transaction).ToHashSet(StringComparers.ResourceName); + var replacementResourceNames = resources.Select(resource => resource.Name).ToHashSet(StringComparers.ResourceName); + foreach (var (resourceName, viewModel) in _resources) + { + if (!replacementResourceNames.Contains(resourceName)) + { + changes.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Delete, viewModel)); + } + } + var resourcesWithLoadedConsoleLogs = _resources.Values + .Where(resource => resource.ConsoleLogsLoaded) + .Select(resource => resource.Name) + .ToHashSet(StringComparers.ResourceName); + connection.Execute("DELETE FROM dashboard_resources;", transaction: transaction); _resources.Clear(); var resourcesToSave = new List(resources.Count); @@ -214,7 +206,8 @@ FROM dashboard_resources foreach (var resource in resources) { var viewModel = CreateViewModel(resource); - resourcesToSave.Add(new(resource, viewModel.ReplicaIndex, resourcesWithLoadedConsoleLogs.Contains(resource.Name))); + viewModel.ConsoleLogsLoaded = resourcesWithLoadedConsoleLogs.Contains(resource.Name); + resourcesToSave.Add(new(resource, viewModel.ReplicaIndex, viewModel.ConsoleLogsLoaded)); _resources[resource.Name] = viewModel; changes.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, viewModel)); } @@ -246,11 +239,6 @@ void IResourceRepositoryWriter.ApplyChanges(IReadOnlyList .Where(name => name is not null) .Distinct(StringComparers.ResourceName) .ToArray(); - var resourcesWithLoadedConsoleLogs = connection.Query(""" - SELECT resource_name - FROM dashboard_resources - WHERE resource_name IN @ResourceNames AND console_logs_loaded = 1; - """, new { ResourceNames = affectedResourceNames }, transaction).ToHashSet(StringComparers.ResourceName); var resourcesToSave = new Dictionary(StringComparers.ResourceName); foreach (var change in changes) @@ -259,7 +247,7 @@ FROM dashboard_resources { var resource = change.Upsert; var viewModel = CreateViewModel(resource); - resourcesToSave[resource.Name] = new(resource, viewModel.ReplicaIndex, resourcesWithLoadedConsoleLogs.Contains(resource.Name)); + resourcesToSave[resource.Name] = new(resource, viewModel.ReplicaIndex, viewModel.ConsoleLogsLoaded); _resources[resource.Name] = viewModel; viewModelChanges.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, viewModel)); } @@ -290,6 +278,10 @@ UPDATE dashboard_resources SET console_logs_loaded = 1 WHERE resource_name = @ResourceName; """, new { ResourceName = resourceName }); + if (_resources.TryGetValue(resourceName, out var resource)) + { + resource.ConsoleLogsLoaded = true; + } } } @@ -347,6 +339,10 @@ INSERT INTO console_logs (resource_name, line_number, content, is_stderr) } } transaction.Commit(); + if (_resources.TryGetValue(resourceName, out var resource)) + { + resource.ConsoleLogsLoaded = true; + } channels = (_consoleChannels.GetValueOrDefault(resourceName) ?? []).ToArray(); } @@ -360,7 +356,9 @@ private ResourceViewModel CreateViewModel(Resource resource) { if (_resources.TryGetValue(resource.Name, out var existingResource)) { - return resource.ToViewModel(existingResource.ReplicaIndex, _knownPropertyLookup, _logger); + var viewModel = resource.ToViewModel(existingResource.ReplicaIndex, _knownPropertyLookup, _logger); + viewModel.ConsoleLogsLoaded = existingResource.ConsoleLogsLoaded; + return viewModel; } var replicaIndex = _resources.Values.Count(r => string.Equals(r.DisplayName, resource.DisplayName, StringComparisons.ResourceName)) + 1; @@ -372,7 +370,9 @@ private void LoadResources() using var connection = _database.OpenConnection(); foreach (var storedResource in LoadResourceRecords(connection)) { - _resources[storedResource.Resource.Name] = storedResource.Resource.ToViewModel(storedResource.ReplicaIndex, _knownPropertyLookup, _logger); + var viewModel = storedResource.Resource.ToViewModel(storedResource.ReplicaIndex, _knownPropertyLookup, _logger); + viewModel.ConsoleLogsLoaded = storedResource.ConsoleLogsLoaded; + _resources[storedResource.Resource.Name] = viewModel; } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs index 54135545105..b145aac257c 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs @@ -414,13 +414,13 @@ public async Task CurrentRun_SubscribesToLiveConsoleLogs() public void HistoricalRun_NoLogs_DisplaysCaptureStatus(bool consoleLogsWereLoaded, string expectedMessage) { var testResource = ModelTestHelpers.CreateResource(resourceName: "test-resource", state: KnownResourceState.Running); + testResource.ConsoleLogsLoaded = consoleLogsWereLoaded; var dashboardClient = new TestDashboardClient( isEnabled: true, consoleLogsChannelProvider: _ => Channel.CreateUnbounded>(), resourceChannelProvider: Channel.CreateUnbounded>, initialResources: [testResource], - isReadOnly: true, - loadedConsoleLogResourceNames: consoleLogsWereLoaded ? new HashSet(StringComparers.ResourceName) { testResource.Name } : null); + isReadOnly: true); SetupConsoleLogsServices(dashboardClient); var cut = RenderConsoleLogsPage(CreateViewport(isDesktop: true), testResource.Name); diff --git a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs index 2f5284c810a..9b430b7f4e2 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs @@ -59,6 +59,36 @@ public async Task ResourceSubscription_ReceivesUpsertAndDelete() Assert.Empty(repository.GetResources()); } + [Fact] + public async Task ResourceSubscription_ReplaceResourcesDeletesOmittedResources() + { + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + using var repository = CreateRepository(workspace.Path); + var writer = (IResourceRepositoryWriter)repository; + writer.ReplaceResources([CreateResource("api", "api"), CreateResource("worker", "worker")]); + + var subscription = await repository.SubscribeResourcesAsync(CancellationToken.None); + Assert.Equal(2, subscription.InitialState.Length); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await using var enumerator = subscription.Subscription.GetAsyncEnumerator(cts.Token); + + writer.ReplaceResources([CreateResource("api", "api")]); + + Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); + Assert.Collection( + enumerator.Current, + change => + { + Assert.Equal(ResourceViewModelChangeType.Delete, change.ChangeType); + Assert.Equal("worker", change.Resource.Name); + }, + change => + { + Assert.Equal(ResourceViewModelChangeType.Upsert, change.ChangeType); + Assert.Equal("api", change.Resource.Name); + }); + } + [Fact] public async Task ConsoleLogs_UseInsertionOrderAndAllowLineNumbersToRestart() { @@ -101,23 +131,24 @@ public void ConsoleLogsLoaded_PersistsWithoutLogLines() { var writer = (IResourceRepositoryWriter)repository; writer.ReplaceResources([CreateResource("api", "api"), CreateResource("worker", "worker")]); - Assert.False(repository.HaveConsoleLogsBeenLoaded("api")); + Assert.False(repository.GetResource("api")!.ConsoleLogsLoaded); writer.MarkConsoleLogsLoaded("api"); - Assert.True(repository.HaveConsoleLogsBeenLoaded("api")); - Assert.False(repository.HaveConsoleLogsBeenLoaded("worker")); + var readQueries = CaptureSqlQueries(() => Assert.True(repository.GetResource("api")!.ConsoleLogsLoaded)); + Assert.Empty(readQueries); + Assert.False(repository.GetResource("worker")!.ConsoleLogsLoaded); writer.ApplyChanges([new WatchResourcesChange { Upsert = CreateResource("api", "api") }]); - Assert.True(repository.HaveConsoleLogsBeenLoaded("api")); + Assert.True(repository.GetResource("api")!.ConsoleLogsLoaded); writer.ReplaceResources([CreateResource("api", "api"), CreateResource("worker", "worker")]); - Assert.True(repository.HaveConsoleLogsBeenLoaded("api")); + Assert.True(repository.GetResource("api")!.ConsoleLogsLoaded); } using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); - Assert.True(historicalRepository.HaveConsoleLogsBeenLoaded("api")); - Assert.False(historicalRepository.HaveConsoleLogsBeenLoaded("worker")); + Assert.True(historicalRepository.GetResource("api")!.ConsoleLogsLoaded); + Assert.False(historicalRepository.GetResource("worker")!.ConsoleLogsLoaded); } [Fact] @@ -641,7 +672,7 @@ private static IReadOnlyList CaptureSqlQueries(Action action) private static void AssertBatchedResourceQueries(IReadOnlyList queries) { - Assert.Equal(11, queries.Count); + Assert.Equal(10, queries.Count); string[] insertedTables = [ diff --git a/tests/Shared/TestDashboardClient.cs b/tests/Shared/TestDashboardClient.cs index aac5f63339e..c1383744525 100644 --- a/tests/Shared/TestDashboardClient.cs +++ b/tests/Shared/TestDashboardClient.cs @@ -20,7 +20,6 @@ public class TestDashboardClient : IDashboardClient private readonly Func>? _executeResourceCommand; private readonly Channel? _sendInteractionUpdateChannel; private readonly IList? _initialResources; - private readonly IReadOnlySet _loadedConsoleLogResourceNames; public bool IsEnabled { get; } public bool IsReadOnly { get; } @@ -44,8 +43,7 @@ public TestDashboardClient( Channel? sendInteractionUpdateChannel = null, IList? initialResources = null, Task? whenConnected = null, - bool isReadOnly = false, - IReadOnlySet? loadedConsoleLogResourceNames = null) + bool isReadOnly = false) { IsEnabled = isEnabled ?? false; IsReadOnly = isReadOnly; @@ -58,7 +56,6 @@ public TestDashboardClient( _executeResourceCommand = executeResourceCommand; _sendInteractionUpdateChannel = sendInteractionUpdateChannel; _initialResources = initialResources; - _loadedConsoleLogResourceNames = loadedConsoleLogResourceNames ?? new HashSet(StringComparers.ResourceName); } public ValueTask DisposeAsync() @@ -101,8 +98,6 @@ public async IAsyncEnumerable> SubscribeConsoleLo } } - public bool HaveConsoleLogsBeenLoaded(string resourceName) => _loadedConsoleLogResourceNames.Contains(resourceName); - public async IAsyncEnumerable> GetConsoleLogs(string resourceName, [EnumeratorCancellation] CancellationToken cancellationToken) { if (_consoleLogsChannelProvider == null) From 93f3b8fac2bbc5a41456900d6ce48f80418d1cf3 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Sun, 19 Jul 2026 14:44:52 +0800 Subject: [PATCH 39/87] Respect explicit dashboard persistence configuration --- .../Dashboard/DashboardEventHandlers.cs | 57 ++++++++++++++----- .../Dashboard/DashboardEventHandlersTests.cs | 28 +++++---- 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 612e5e9da76..6fe593a95b9 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -599,21 +599,21 @@ internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext con context.EnvironmentVariables[KnownAspNetCoreConfigNames.Environment] = environment; context.EnvironmentVariables[DashboardConfigNames.ResourceServiceUrlName.EnvVarName] = resourceServiceUrl; - if (configuration["AppHost:DashboardApplicationName"] is { Length: > 0 } applicationName) - { - context.EnvironmentVariables[DashboardConfigNames.DashboardApplicationName.EnvVarName] = DashboardService.GetDashboardApplicationName(applicationName); - } - if (configuration["Aspire:Store:Path"] is { Length: > 0 } aspireStorePath) - { - context.EnvironmentVariables[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] = Path.Combine(aspireStorePath, ".aspire", "dashboard"); - } - var persistenceMode = configuration["Aspire:Dashboard:PersistenceMode"]; - if (string.IsNullOrWhiteSpace(persistenceMode)) - { - persistenceMode = configuration[DashboardConfigNames.DashboardPersistenceModeName.EnvVarName]; - } - context.EnvironmentVariables[DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = - string.IsNullOrWhiteSpace(persistenceMode) ? "Runs" : persistenceMode; + SetEnvironmentVariableWithFallback( + context, + DashboardConfigNames.DashboardApplicationName, + "AppHost:DashboardApplicationName", + transform: DashboardService.GetDashboardApplicationName); + SetEnvironmentVariableWithFallback( + context, + DashboardConfigNames.DashboardDataDirectoryName, + "Aspire:Store:Path", + transform: static aspireStorePath => Path.Combine(aspireStorePath, ".aspire", "dashboard")); + SetEnvironmentVariableWithFallback( + context, + DashboardConfigNames.DashboardPersistenceModeName, + "Aspire:Dashboard:PersistenceMode", + defaultValue: "Runs"); PopulateDashboardUrls(context); @@ -724,7 +724,34 @@ internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext con { context.EnvironmentVariables[DashboardConfigNames.DebugSessionTelemetryOptOutName.EnvVarName] = optOutValue; } + } + private void SetEnvironmentVariableWithFallback( + EnvironmentCallbackContext context, + ConfigName configName, + string fallbackConfigurationKey, + string? defaultValue = null, + Func? transform = null) + { + if (!string.IsNullOrWhiteSpace(configuration[configName.EnvVarName])) + { + return; + } + + var value = configuration[fallbackConfigurationKey]; + if (string.IsNullOrWhiteSpace(value)) + { + value = defaultValue; + } + else if (transform is not null) + { + value = transform(value); + } + + if (!string.IsNullOrWhiteSpace(value)) + { + context.EnvironmentVariables[configName.EnvVarName] = value; + } } private class EndpointGenerationContext diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs index cfe6ef4fde9..6d2a9941308 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs @@ -309,18 +309,22 @@ public async Task ConfigureEnvironmentVariables_HasAspireDashboardEnvVars_Copied } [Theory] - [InlineData(null, null, "Runs")] - [InlineData("", null, "Runs")] - [InlineData(" ", null, "Runs")] - [InlineData(null, " ", "Runs")] - [InlineData(null, "None", "None")] - [InlineData("Runs", "None", "Runs")] + [InlineData(null, null, "Runs", false)] + [InlineData("", null, "Runs", false)] + [InlineData(" ", null, "Runs", false)] + [InlineData(null, " ", "Runs", false)] + [InlineData(null, "None", "None", false)] + [InlineData("Runs", "None", "None", false)] + [InlineData("Runs", "None", "None", true)] public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRootApplicationNameAndPersistenceMode( string? configuredPersistenceMode, string? configuredPersistenceModeEnvironmentAlias, - string expectedPersistenceMode) + string expectedPersistenceMode, + bool configureExplicitAliases) { using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var explicitDataDirectory = Path.Combine(workspace.Path, "custom-dashboard"); + var explicitApplicationName = "Explicit Dashboard"; var resourceLoggerService = new ResourceLoggerService(); var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); var configuration = new ConfigurationBuilder() @@ -329,7 +333,9 @@ public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRoo ["Aspire:Store:Path"] = workspace.Path, ["AppHost:DashboardApplicationName"] = "My App.AppHost", ["Aspire:Dashboard:PersistenceMode"] = configuredPersistenceMode, - [DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = configuredPersistenceModeEnvironmentAlias + [DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = configuredPersistenceModeEnvironmentAlias, + [DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] = configureExplicitAliases ? explicitDataDirectory : null, + [DashboardConfigNames.DashboardApplicationName.EnvVarName] = configureExplicitAliases ? explicitApplicationName : null }) .Build(); var dashboardOptions = Options.Create(new DashboardOptions @@ -350,9 +356,11 @@ public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRoo await hook.ConfigureEnvironmentVariables(new EnvironmentCallbackContext(context, environmentVariables: environmentVariables, resource: dashboardResource)); Assert.Equal( - Path.Combine(workspace.Path, ".aspire", "dashboard"), + configureExplicitAliases ? explicitDataDirectory : Path.Combine(workspace.Path, ".aspire", "dashboard"), environmentVariables[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName]); - Assert.Equal("My App", environmentVariables[DashboardConfigNames.DashboardApplicationName.EnvVarName]); + Assert.Equal( + configureExplicitAliases ? explicitApplicationName : "My App", + environmentVariables[DashboardConfigNames.DashboardApplicationName.EnvVarName]); Assert.Equal(expectedPersistenceMode, environmentVariables[DashboardConfigNames.DashboardPersistenceModeName.EnvVarName]); } From 7ed8004972899acb80e5e87066097264c53a4db4 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 20 Jul 2026 09:18:08 +0800 Subject: [PATCH 40/87] Fix resource watcher disposal race --- .../Model/ResourceOutgoingPeerResolver.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index df9527f86db..2167eda4d8a 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -19,13 +19,17 @@ public sealed partial class ResourceOutgoingPeerResolver : IOutgoingPeerResolver private static partial Regex HostRegex(); private readonly ConcurrentDictionary _resourceByName = new(StringComparers.ResourceName); - private readonly CancellationTokenSource _watchContainersTokenSource = new(); + private readonly CancellationTokenSource _watchContainersTokenSource; + private readonly CancellationToken _watchContainersToken; private readonly List _subscriptions = []; private readonly object _lock = new(); private readonly Task? _watchTask; public ResourceOutgoingPeerResolver(IDashboardClient resourceService) { + _watchContainersTokenSource = new(); + _watchContainersToken = _watchContainersTokenSource.Token; + if (!resourceService.IsEnabled) { return; @@ -41,7 +45,7 @@ public ResourceOutgoingPeerResolver(IDashboardClient resourceService) private async Task WatchResourcesAsync(IDashboardClient resourceService) { - var (snapshot, subscription) = await resourceService.SubscribeResourcesAsync(_watchContainersTokenSource.Token).ConfigureAwait(false); + var (snapshot, subscription) = await resourceService.SubscribeResourcesAsync(_watchContainersToken).ConfigureAwait(false); if (snapshot.Length > 0) { @@ -54,7 +58,7 @@ private async Task WatchResourcesAsync(IDashboardClient resourceService) await RaisePeerChangesAsync().ConfigureAwait(false); } - await foreach (var changes in subscription.WithCancellation(_watchContainersTokenSource.Token).ConfigureAwait(false)) + await foreach (var changes in subscription.WithCancellation(_watchContainersToken).ConfigureAwait(false)) { var hasPeerRelevantChanges = false; From 0caeaed3b49b030ce7e8c3763317711919280c6f Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 20 Jul 2026 10:02:01 +0800 Subject: [PATCH 41/87] Add shared dashboard activity source --- .../DashboardActivitySource.cs | 25 +++++++ .../DashboardWebApplication.cs | 26 ++----- .../Model/ResourceOutgoingPeerResolver.cs | 6 +- .../ServiceClient/DashboardClient.cs | 8 +- .../Controls/ApplicationNameTests.cs | 1 + .../Integration/DashboardClientAuthTests.cs | 8 +- .../Integration/HealthTests.cs | 8 +- .../Model/DashboardClientTests.cs | 74 ++++++++++++------- .../Model/DashboardDataSourceTests.cs | 2 + .../ResourceOutgoingPeerResolverTests.cs | 49 +++++++++++- 10 files changed, 147 insertions(+), 60 deletions(-) create mode 100644 src/Aspire.Dashboard/DashboardActivitySource.cs diff --git a/src/Aspire.Dashboard/DashboardActivitySource.cs b/src/Aspire.Dashboard/DashboardActivitySource.cs new file mode 100644 index 00000000000..934ac24b05b --- /dev/null +++ b/src/Aspire.Dashboard/DashboardActivitySource.cs @@ -0,0 +1,25 @@ +// 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; + +namespace Aspire.Dashboard; + +/// +/// Provides the shared activity source for dashboard operations. +/// +public sealed class DashboardActivitySource : IDisposable +{ + /// + /// The name of the dashboard activity source. + /// + public const string ActivitySourceName = "Aspire.Dashboard"; + + /// + /// Gets the shared activity source for dashboard operations. + /// + public ActivitySource ActivitySource { get; } = new(ActivitySourceName); + + /// + public void Dispose() => ActivitySource.Dispose(); +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 9d0f673e29e..f59fc906046 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -279,6 +279,7 @@ public DashboardWebApplication( } // Data from the server. + builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.AddScoped(services => new DashboardDataSource( services.GetRequiredService(), @@ -305,7 +306,7 @@ public DashboardWebApplication( builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing .AddAspNetCoreInstrumentation() - .AddSource(DashboardClient.ActivitySourceName) + .AddSource(DashboardActivitySource.ActivitySourceName) .AddSource(TracingSqliteConnection.ActivitySourceName) .AddOtlpExporter()); } @@ -333,23 +334,15 @@ public DashboardWebApplication( (IResourceRepositoryWriter)services.GetRequiredService()); builder.Services.AddTransient(); - builder.Services.AddTransient(services => new OtlpLogsService( - services.GetRequiredService>(), - services.GetRequiredService())); - builder.Services.AddTransient(services => new OtlpTraceService( - services.GetRequiredService>(), - services.GetRequiredService())); - builder.Services.AddTransient(services => new OtlpMetricsService( - services.GetRequiredService>(), - services.GetRequiredService())); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); // Telemetry API. - builder.Services.AddSingleton(services => new TelemetryApiService( - services.GetRequiredService())); + builder.Services.AddSingleton(); builder.Services.AddTransient(); - builder.Services.AddSingleton(services => - new ResourceOutgoingPeerResolver(services.GetRequiredService())); + builder.Services.AddSingleton(); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); @@ -364,10 +357,7 @@ public DashboardWebApplication( builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(services => new TelemetryImportService( - services.GetRequiredService(), - services.GetRequiredService>(), - services.GetRequiredService>())); + builder.Services.AddScoped(); builder.Services.AddSingleton(); // Time zone is set by the browser. diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index 2167eda4d8a..f5bd8e1a3bd 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -19,14 +19,16 @@ public sealed partial class ResourceOutgoingPeerResolver : IOutgoingPeerResolver private static partial Regex HostRegex(); private readonly ConcurrentDictionary _resourceByName = new(StringComparers.ResourceName); + private readonly ActivitySource _activitySource; private readonly CancellationTokenSource _watchContainersTokenSource; private readonly CancellationToken _watchContainersToken; private readonly List _subscriptions = []; private readonly object _lock = new(); private readonly Task? _watchTask; - public ResourceOutgoingPeerResolver(IDashboardClient resourceService) + public ResourceOutgoingPeerResolver(IDashboardClient resourceService, DashboardActivitySource activitySource) { + _activitySource = activitySource.ActivitySource; _watchContainersTokenSource = new(); _watchContainersToken = _watchContainersTokenSource.Token; @@ -60,6 +62,8 @@ private async Task WatchResourcesAsync(IDashboardClient resourceService) await foreach (var changes in subscription.WithCancellation(_watchContainersToken).ConfigureAwait(false)) { + using var activity = _activitySource.StartActivity("Process resource subscription changes", ActivityKind.Consumer); + var hasPeerRelevantChanges = false; foreach (var (changeType, resource) in changes) diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index 2c26f45f2ee..d70beb1174d 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -43,8 +43,6 @@ namespace Aspire.Dashboard.ServiceClient; /// internal sealed class DashboardClient : IDashboardClient { - internal const string ActivitySourceName = "Aspire.Dashboard.ResourceService"; - private const string ApiKeyHeaderName = "x-resource-service-api-key"; private const string TroubleshootingUrl = "https://aka.ms/aspire/dashboard-apphost-connection-failed"; @@ -53,7 +51,7 @@ internal sealed class DashboardClient : IDashboardClient private static readonly SemVersion? s_dashboardVersion = GetDashboardVersion(); private readonly Dictionary _resourceByName = new(StringComparers.ResourceName); - private readonly ActivitySource _activitySource = new(ActivitySourceName); + private readonly ActivitySource _activitySource; private readonly InteractionCollection _pendingInteractionCollection = new(); private readonly CancellationTokenSource _cts = new(); private readonly CancellationToken _clientCancellationToken; @@ -94,6 +92,7 @@ internal sealed class DashboardClient : IDashboardClient private Task? _connection; public DashboardClient( + DashboardActivitySource activitySource, ILoggerFactory loggerFactory, IConfiguration configuration, IOptions dashboardOptions, @@ -102,6 +101,7 @@ public DashboardClient( Action? configureHttpHandler = null, IResourceRepositoryWriter? resourceRepositoryWriter = null) { + _activitySource = activitySource.ActivitySource; _loggerFactory = loggerFactory; _knownPropertyLookup = knownPropertyLookup; _dashboardOptions = dashboardOptions.Value; @@ -244,7 +244,6 @@ internal sealed class KeyStoreProperties // For testing purposes internal int OutgoingResourceSubscriberCount => _outgoingResourceChannels.Count; internal int OutgoingInteractionSubscriberCount => _outgoingInteractionChannels.Count; - internal ActivitySource ActivitySource => _activitySource; internal void SetDashboardServiceClient(Aspire.DashboardService.Proto.V1.DashboardService.DashboardServiceClient client) => _client = client; internal Task ResourceWatchCompleteTask => _resourceWatchCompleteTcs.Task; internal Task InteractionWatchCompleteTask => _interactionWatchCompleteTcs.Task; @@ -1152,7 +1151,6 @@ public async ValueTask DisposeAsync() _channel?.Dispose(); await TaskHelpers.WaitIgnoreCancelAsync(_connection, _logger, "Unexpected error from connection task.").ConfigureAwait(false); - _activitySource.Dispose(); } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs index 354798fb545..1330d38eb58 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs @@ -68,6 +68,7 @@ private void AddDashboardClientServices() Services.AddLocalization(); Services.AddSingleton(new ConfigurationManager()); Services.AddSingleton(NullLoggerFactory.Instance); + Services.AddSingleton(); Services.AddSingleton(); Services.AddSingleton(); Services.AddSingleton(new MockKnownPropertyLookup()); diff --git a/tests/Aspire.Dashboard.Tests/Integration/DashboardClientAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/DashboardClientAuthTests.cs index d0cdafa3f29..22e526631d4 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/DashboardClientAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/DashboardClientAuthTests.cs @@ -37,9 +37,10 @@ public DashboardClientAuthTests(ITestOutputHelper testOutputHelper) [InlineData(false)] public async Task ConnectsToResourceService_Unsecured(bool useHttps) { + using var activitySource = new DashboardActivitySource(); var loggerFactory = IntegrationTestHelpers.CreateLoggerFactory(_testOutputHelper); await using var server = await CreateResourceServiceServerAsync(loggerFactory, useHttps).DefaultTimeout(); - await using var client = await CreateDashboardClientAsync(loggerFactory, server.Url, authMode: ResourceClientAuthMode.Unsecured).DefaultTimeout(); + await using var client = await CreateDashboardClientAsync(activitySource, loggerFactory, server.Url, authMode: ResourceClientAuthMode.Unsecured).DefaultTimeout(); var call = await server.Calls.ResourceInformationCallsChannel.Reader.ReadAsync().DefaultTimeout(); @@ -53,9 +54,10 @@ public async Task ConnectsToResourceService_Unsecured(bool useHttps) [InlineData(false)] public async Task ConnectsToResourceService_ApiKey(bool useHttps) { + using var activitySource = new DashboardActivitySource(); var loggerFactory = IntegrationTestHelpers.CreateLoggerFactory(_testOutputHelper); await using var server = await CreateResourceServiceServerAsync(loggerFactory, useHttps).DefaultTimeout(); - await using var client = await CreateDashboardClientAsync(loggerFactory, server.Url, authMode: ResourceClientAuthMode.ApiKey, configureOptions: options => options.ResourceServiceClient.ApiKey = "TestApiKey!").DefaultTimeout(); + await using var client = await CreateDashboardClientAsync(activitySource, loggerFactory, server.Url, authMode: ResourceClientAuthMode.ApiKey, configureOptions: options => options.ResourceServiceClient.ApiKey = "TestApiKey!").DefaultTimeout(); var call = await server.Calls.ResourceInformationCallsChannel.Reader.ReadAsync().DefaultTimeout(); @@ -106,6 +108,7 @@ void ConfigureListen(ListenOptions options) } private static async Task CreateDashboardClientAsync( + DashboardActivitySource activitySource, ILoggerFactory loggerFactory, string serverAddress, ResourceClientAuthMode authMode = ResourceClientAuthMode.Unsecured, @@ -125,6 +128,7 @@ private static async Task CreateDashboardClientAsync( options.ResourceServiceClient.TryParseOptions(out _); var client = new DashboardClient( + activitySource: activitySource, loggerFactory: loggerFactory, configuration: new ConfigurationManager(), dashboardOptions: Options.Create(options), diff --git a/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs index ff5c462d208..87c131ac661 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/HealthTests.cs @@ -75,17 +75,17 @@ public async Task OtlpExporterConfigured_ExportsResourceServiceActivity() .WithTracing(tracing => tracing.AddProcessor( new SimpleActivityExportProcessor(new TestActivityExporter( exportedActivity, - activity => activity.Source.Name == DashboardClient.ActivitySourceName))))); + activity => activity.Source.Name == DashboardActivitySource.ActivitySourceName))))); await app.StartAsync().DefaultTimeout(); - var dashboardClient = app.Services.GetRequiredService(); + var activitySource = app.Services.GetRequiredService(); - using (var activity = dashboardClient.ActivitySource.StartActivity("Test resource update", ActivityKind.Consumer)) + using (var activity = activitySource.ActivitySource.StartActivity("Test resource update", ActivityKind.Consumer)) { Assert.NotNull(activity); } var exported = await exportedActivity.Task.DefaultTimeout(); - Assert.Equal(DashboardClient.ActivitySourceName, exported.Source.Name); + Assert.Equal(DashboardActivitySource.ActivitySourceName, exported.Source.Name); Assert.Equal(ActivityKind.Consumer, exported.Kind); } diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index 92d3c412615..48f5f67171d 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -13,7 +13,6 @@ using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Options; using Semver; @@ -22,27 +21,13 @@ namespace Aspire.Dashboard.Tests.Model; -public sealed class DashboardClientTests +public sealed class DashboardClientTests(ITestOutputHelper testOutputHelper) : IDisposable { - private readonly IConfiguration _configuration; - private readonly IOptions _dashboardOptions; - - public DashboardClientTests() + private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(builder => { - _configuration = new ConfigurationManager(); - - var options = new DashboardOptions - { - ResourceServiceClient = - { - AuthMode = ResourceClientAuthMode.Unsecured, - Url = "http://localhost:12345" - } - }; - options.ResourceServiceClient.TryParseOptions(out _); - - _dashboardOptions = Options.Create(options); - } + builder.AddXunit(testOutputHelper, LogLevel.Trace, DateTimeOffset.UtcNow); + builder.SetMinimumLevel(LogLevel.Trace); + }); [Fact] public async Task SubscribeResources_OnCancel_ChannelRemoved() @@ -160,7 +145,7 @@ public async Task SubscribeResources_HasInitialData_InitialDataReturned() public async Task SubscribeConsoleLogs_ReceivesAndPersistsLogs() { var repositoryWriter = new RecordingResourceRepositoryWriter(); - await using var instance = CreateResourceServiceClient(repositoryWriter); + await using var instance = CreateResourceServiceClient(resourceRepositoryWriter: repositoryWriter); instance.SetDashboardServiceClient(new MockDashboardServiceClient { ConsoleLogUpdates = @@ -311,7 +296,8 @@ public async Task WhenConnected_AmbientActivity_DoesNotFlowToConnection() [Fact] public async Task WatchResources_ResponseCreatesActivity() { - await using var instance = CreateResourceServiceClient(); + using var activitySource = new DashboardActivitySource(); + await using var instance = CreateResourceServiceClient(activitySource); instance.SetDashboardServiceClient(new MockDashboardServiceClient { ResourceUpdates = @@ -322,7 +308,7 @@ public async Task WatchResources_ResponseCreatesActivity() }); var activities = new ConcurrentQueue(); var activitiesReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var listener = ActivityListenerHelper.Create(instance.ActivitySource, onActivityStopped: activity => + using var listener = ActivityListenerHelper.Create(activitySource.ActivitySource, onActivityStopped: activity => { activities.Enqueue(activity); if (activities.Count == 2) @@ -342,7 +328,7 @@ public async Task WatchResources_ResponseCreatesActivity() static void AssertActivity(Activity activity, WatchResourcesUpdate.KindOneofCase kind) { - Assert.Equal(DashboardClient.ActivitySourceName, activity.Source.Name); + Assert.Equal(DashboardActivitySource.ActivitySourceName, activity.Source.Name); Assert.Equal("Process resource update", activity.OperationName); Assert.Equal(ActivityKind.Consumer, activity.Kind); Assert.Equal(kind.ToString(), activity.GetTagItem("aspire.dashboard.resource_update.type")); @@ -465,9 +451,9 @@ public async Task WatchWithRecovery_RepeatedFailures_FiresMultipleDisconnectedEv public async Task ConnectWithRetry_LogsErrorWithTroubleshootingLink() { var testSink = new TestSink(); - var loggerFactory = LoggerFactory.Create(b => b.AddProvider(new TestLoggerProvider(testSink))); + _loggerFactory.AddProvider(new TestLoggerProvider(testSink)); - await using var instance = new DashboardClient(loggerFactory, _configuration, _dashboardOptions, new MockKnownPropertyLookup(), new TestStringLocalizer()); + await using var instance = CreateResourceServiceClient(); instance.SetDashboardServiceClient(new MockDashboardServiceClient { FailOnGetApplicationInformation = true }); IDashboardClient client = instance; @@ -798,9 +784,41 @@ public Task WriteAsync(T message) } } - private DashboardClient CreateResourceServiceClient(IResourceRepositoryWriter? resourceRepositoryWriter = null) + private DashboardClient CreateResourceServiceClient( + DashboardActivitySource? activitySource = null, + IResourceRepositoryWriter? resourceRepositoryWriter = null) + { + return CreateResourceServiceClient(_loggerFactory, activitySource, resourceRepositoryWriter); + } + + private static DashboardClient CreateResourceServiceClient( + ILoggerFactory loggerFactory, + DashboardActivitySource? activitySource, + IResourceRepositoryWriter? resourceRepositoryWriter) + { + var options = new DashboardOptions + { + ResourceServiceClient = + { + AuthMode = ResourceClientAuthMode.Unsecured, + Url = "http://localhost:12345" + } + }; + options.ResourceServiceClient.TryParseOptions(out _); + + return new DashboardClient( + activitySource ?? new DashboardActivitySource(), + loggerFactory, + new ConfigurationManager(), + Options.Create(options), + new MockKnownPropertyLookup(), + new TestStringLocalizer(), + resourceRepositoryWriter: resourceRepositoryWriter); + } + + public void Dispose() { - return new DashboardClient(NullLoggerFactory.Instance, _configuration, _dashboardOptions, new MockKnownPropertyLookup(), new TestStringLocalizer(), resourceRepositoryWriter: resourceRepositoryWriter); + _loggerFactory.Dispose(); } private static CommandViewModel CreateCommand() diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index cbe3160a25f..83f98a4118b 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -540,7 +540,9 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() Assert.Empty(currentTelemetryRepository.GetResources()); Assert.Empty(currentResourceRepository.GetResources()); + using var activitySource = new DashboardActivitySource(); await using var currentClient = new DashboardClient( + activitySource, NullLoggerFactory.Instance, new ConfigurationManager(), options, diff --git a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs index 1d85b788737..174c999f431 100644 --- a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs @@ -1,11 +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 System.Collections.Concurrent; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Threading.Channels; using Aspire.Dashboard.Model; using Aspire.DashboardService.Proto.V1; +using Aspire.Tests; using Aspire.Tests.Shared.DashboardModel; using Microsoft.AspNetCore.InternalTesting; using Xunit; @@ -132,7 +134,7 @@ public async Task OnPeerChanges_DataUpdates_EventRaised() var sourceChannel = Channel.CreateUnbounded(); var resultChannel = Channel.CreateUnbounded(); var dashboardClient = new MockDashboardClient(tcs.Task); - var resolver = new ResourceOutgoingPeerResolver(dashboardClient); + var resolver = CreateResolver(dashboardClient); var changeCount = 0; resolver.OnPeerChanges(async () => { @@ -195,7 +197,7 @@ public async Task Constructor_AmbientActivity_DoesNotFlowToResourceWatch() var subscribeResult = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var dashboardClient = new MockDashboardClient(subscribeResult.Task); using var activity = new Activity("request").Start(); - var resolver = new ResourceOutgoingPeerResolver(dashboardClient); + var resolver = CreateResolver(dashboardClient); Assert.Null(await dashboardClient.ActivityOnSubscribe.Task.DefaultTimeout()); @@ -205,6 +207,44 @@ public async Task Constructor_AmbientActivity_DoesNotFlowToResourceWatch() await resolver.DisposeAsync().DefaultTimeout(); } + [Fact] + public async Task ResourceChanges_CreateActivityForEachBatch() + { + using var activitySource = new DashboardActivitySource(); + var subscribeResult = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sourceChannel = Channel.CreateUnbounded>(); + var activities = new ConcurrentQueue(); + var activitiesReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var listener = ActivityListenerHelper.Create(activitySource.ActivitySource, onActivityStopped: activity => + { + activities.Enqueue(activity); + if (activities.Count == 2) + { + activitiesReceived.TrySetResult(); + } + }); + var resolver = CreateResolver(new MockDashboardClient(subscribeResult.Task), activitySource); + subscribeResult.SetResult(new ResourceViewModelSubscription([], sourceChannel.Reader.ReadAllAsync())); + + await sourceChannel.Writer.WriteAsync([new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, CreateResource("test", state: KnownResourceState.Starting))]); + await sourceChannel.Writer.WriteAsync([new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, CreateResource("test", state: KnownResourceState.Running))]); + await activitiesReceived.Task.DefaultTimeout(); + sourceChannel.Writer.Complete(); + await resolver.DisposeAsync().DefaultTimeout(); + + Assert.Collection( + activities, + AssertActivity, + AssertActivity); + + static void AssertActivity(Activity activity) + { + Assert.Equal(DashboardActivitySource.ActivitySourceName, activity.Source.Name); + Assert.Equal("Process resource subscription changes", activity.OperationName); + Assert.Equal(ActivityKind.Consumer, activity.Kind); + } + } + [Fact] public void NameAndDisplayNameDifferent_OneInstance_ReturnDisplayName() { @@ -242,6 +282,11 @@ private static bool TryResolvePeerName(IDictionary re return ResourceOutgoingPeerResolver.TryResolvePeerCore(resources, attributes, out peerName, out _); } + private static ResourceOutgoingPeerResolver CreateResolver(IDashboardClient dashboardClient, DashboardActivitySource? activitySource = null) + { + return new ResourceOutgoingPeerResolver(dashboardClient, activitySource ?? new DashboardActivitySource()); + } + [Fact] public void ConnectionStringWithEndpoint_Match() { From 4f375d1b1661bce6343fe38cb5a1af4b5f309d1f Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 20 Jul 2026 11:15:28 +0800 Subject: [PATCH 42/87] Fix dashboard repository resolution hang --- .../DashboardWebApplication.cs | 1 - .../Model/ResourceOutgoingPeerResolver.cs | 10 +-- .../ServiceClient/IRepositoryFactory.cs | 2 +- .../ServiceClient/RepositoryFactory.cs | 64 ++----------------- .../Shared/FluentUISetupHelpers.cs | 3 - .../Integration/TelemetryApiTests.cs | 2 +- .../Model/DashboardDataSourceTests.cs | 37 +++++------ .../ResourceOutgoingPeerResolverTests.cs | 4 +- .../Shared/TestDashboardDataSource.cs | 3 - 9 files changed, 31 insertions(+), 95 deletions(-) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index f59fc906046..9cbf283d91e 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -318,7 +318,6 @@ public DashboardWebApplication( builder.Services.AddSingleton(services => new DashboardSqliteDatabase( services.GetRequiredService().DatabasePath)); builder.Services.AddSingleton(services => new RepositoryFactory( - services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService>(), services.GetRequiredService(), diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index f5bd8e1a3bd..ddb98ab8454 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -26,13 +26,13 @@ public sealed partial class ResourceOutgoingPeerResolver : IOutgoingPeerResolver private readonly object _lock = new(); private readonly Task? _watchTask; - public ResourceOutgoingPeerResolver(IDashboardClient resourceService, DashboardActivitySource activitySource) + public ResourceOutgoingPeerResolver(IResourceRepository resourceRepository, DashboardActivitySource activitySource) { _activitySource = activitySource.ActivitySource; _watchContainersTokenSource = new(); _watchContainersToken = _watchContainersTokenSource.Token; - if (!resourceService.IsEnabled) + if (resourceRepository is IDashboardClient { IsEnabled: false }) { return; } @@ -41,13 +41,13 @@ public ResourceOutgoingPeerResolver(IDashboardClient resourceService, DashboardA // causes the resolver to be constructed become the parent of that long-running operation. using (ExecutionContext.SuppressFlow()) { - _watchTask = Task.Run(() => WatchResourcesAsync(resourceService)); + _watchTask = Task.Run(() => WatchResourcesAsync(resourceRepository)); } } - private async Task WatchResourcesAsync(IDashboardClient resourceService) + private async Task WatchResourcesAsync(IResourceRepository resourceRepository) { - var (snapshot, subscription) = await resourceService.SubscribeResourcesAsync(_watchContainersToken).ConfigureAwait(false); + var (snapshot, subscription) = await resourceRepository.SubscribeResourcesAsync(_watchContainersToken).ConfigureAwait(false); if (snapshot.Length > 0) { diff --git a/src/Aspire.Dashboard/ServiceClient/IRepositoryFactory.cs b/src/Aspire.Dashboard/ServiceClient/IRepositoryFactory.cs index 74af0bd53b8..6d4a2dfc365 100644 --- a/src/Aspire.Dashboard/ServiceClient/IRepositoryFactory.cs +++ b/src/Aspire.Dashboard/ServiceClient/IRepositoryFactory.cs @@ -8,7 +8,7 @@ namespace Aspire.Dashboard.ServiceClient; /// /// Creates repositories for current and historical dashboard run databases. /// -internal interface IRepositoryFactory : IDisposable +internal interface IRepositoryFactory { /// /// Creates a telemetry repository for the specified database. diff --git a/src/Aspire.Dashboard/ServiceClient/RepositoryFactory.cs b/src/Aspire.Dashboard/ServiceClient/RepositoryFactory.cs index cbf96741f4d..21dfe5a8e9a 100644 --- a/src/Aspire.Dashboard/ServiceClient/RepositoryFactory.cs +++ b/src/Aspire.Dashboard/ServiceClient/RepositoryFactory.cs @@ -9,72 +9,18 @@ namespace Aspire.Dashboard.ServiceClient; /// -/// Shares repositories for the current database and creates dedicated repositories for historical databases. +/// Creates telemetry and resource repositories for dashboard run databases. /// internal sealed class RepositoryFactory( - DashboardSqliteDatabase currentDatabase, ILoggerFactory loggerFactory, IOptions dashboardOptions, PauseManager pauseManager, Func> outgoingPeerResolversAccessor, IKnownPropertyLookup knownPropertyLookup) : IRepositoryFactory { - private readonly object _telemetryLock = new(); - private readonly object _resourceLock = new(); - private ITelemetryRepository? _currentTelemetryRepository; - private IResourceRepository? _currentResourceRepository; - private int _disposed; + public ITelemetryRepository CreateTelemetryRepository(DashboardSqliteDatabase database) => + new SqliteTelemetryRepository(database, loggerFactory, dashboardOptions, pauseManager, outgoingPeerResolversAccessor()); - public ITelemetryRepository CreateTelemetryRepository(DashboardSqliteDatabase database) - { - ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); - if (!ReferenceEquals(database, currentDatabase)) - { - return CreateTelemetryRepositoryCore(database); - } - - lock (_telemetryLock) - { - ObjectDisposedException.ThrowIf(_disposed != 0, this); - return _currentTelemetryRepository ??= CreateTelemetryRepositoryCore(database); - } - } - - public IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database) - { - ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); - if (!ReferenceEquals(database, currentDatabase)) - { - return CreateResourceRepositoryCore(database); - } - - lock (_resourceLock) - { - ObjectDisposedException.ThrowIf(_disposed != 0, this); - return _currentResourceRepository ??= CreateResourceRepositoryCore(database); - } - } - - private SqliteTelemetryRepository CreateTelemetryRepositoryCore(DashboardSqliteDatabase database) => - new(database, loggerFactory, dashboardOptions, pauseManager, outgoingPeerResolversAccessor()); - - private SqliteResourceRepository CreateResourceRepositoryCore(DashboardSqliteDatabase database) => - new(database, knownPropertyLookup, loggerFactory); - - public void Dispose() - { - lock (_telemetryLock) - { - lock (_resourceLock) - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _currentTelemetryRepository?.Dispose(); - (_currentResourceRepository as IDisposable)?.Dispose(); - } - } - } + public IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database) => + new SqliteResourceRepository(database, knownPropertyLookup, loggerFactory); } \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index 4a14c0d7d8f..5f57b36a2a4 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -245,9 +245,6 @@ private sealed class TestRepositoryFactory( { public ITelemetryRepository CreateTelemetryRepository(DashboardSqliteDatabase database) => telemetryRepository; public IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database) => dashboardClient; - public void Dispose() - { - } } public static void SetupFluentUIComponents(TestContext context) diff --git a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs index bcd5435727e..2bee34bb40e 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs @@ -354,7 +354,7 @@ public async Task GetSpans_StreamingMode_ReturnsNdjsonContentType() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); try { - var response = await httpClient.GetAsync("/api/telemetry/spans?follow=true", HttpCompletionOption.ResponseHeadersRead, cts.Token).DefaultTimeout(); + using var response = await httpClient.GetAsync("/api/telemetry/spans?follow=true", HttpCompletionOption.ResponseHeadersRead, cts.Token).DefaultTimeout(); // Assert - should have NDJSON content type and streaming headers Assert.Equal(HttpStatusCode.OK, response.StatusCode); diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index 83f98a4118b..3899424995d 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -343,10 +343,10 @@ public void SelectedHistoricalRun_HoldsLeaseUntilSelectionChanges() } using var currentRunStore = CreateRunStore(options); - var currentDatabase = new DashboardSqliteDatabase(currentRunStore.DatabasePath); - using var repositoryFactory = CreateRepositoryFactory(currentDatabase, options); - var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); - var currentResourceRepository = repositoryFactory.CreateResourceRepository(currentDatabase); + using var currentDatabase = new DashboardSqliteDatabase(currentRunStore.DatabasePath); + var repositoryFactory = CreateRepositoryFactory(options); + using var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); + using var currentResourceRepository = (SqliteResourceRepository)repositoryFactory.CreateResourceRepository(currentDatabase); using var dataSource = new DashboardDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); dataSource.SelectRun(historicalRunId); @@ -441,10 +441,10 @@ public void GetRuns_DoesNotReadDatabaseSchemaUntilRunIsSelected() }); Assert.True(File.Exists(incompatibleDatabasePath)); - var currentDatabase = new DashboardSqliteDatabase(currentRunStore.DatabasePath); - using var repositoryFactory = CreateRepositoryFactory(currentDatabase, options); - var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); - var currentResourceRepository = repositoryFactory.CreateResourceRepository(currentDatabase); + using var currentDatabase = new DashboardSqliteDatabase(currentRunStore.DatabasePath); + var repositoryFactory = CreateRepositoryFactory(options); + using var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); + using var currentResourceRepository = (SqliteResourceRepository)repositoryFactory.CreateResourceRepository(currentDatabase); using var dataSource = new DashboardDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); var exception = Assert.Throws(() => dataSource.SelectRun(incompatibleRunId)); @@ -517,10 +517,10 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() } using var currentRunStore = CreateRunStore(options); - var currentDatabase = new DashboardSqliteDatabase(currentRunStore.DatabasePath); - using var repositoryFactory = CreateRepositoryFactory(currentDatabase, options); - var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); - var currentResourceRepository = repositoryFactory.CreateResourceRepository(currentDatabase); + using var currentDatabase = new DashboardSqliteDatabase(currentRunStore.DatabasePath); + var repositoryFactory = CreateRepositoryFactory(options); + using var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); + using var currentResourceRepository = (SqliteResourceRepository)repositoryFactory.CreateResourceRepository(currentDatabase); using var dataSource = new DashboardDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); Assert.Empty(dataSource.TelemetryRepository.GetResources()); @@ -579,10 +579,10 @@ public void UnknownRunId_SelectsCurrentRun() { var options = CreateOptions(); using var currentRunStore = CreateRunStore(options); - var currentDatabase = new DashboardSqliteDatabase(currentRunStore.DatabasePath); - using var repositoryFactory = CreateRepositoryFactory(currentDatabase, options); - var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); - var currentResourceRepository = repositoryFactory.CreateResourceRepository(currentDatabase); + using var currentDatabase = new DashboardSqliteDatabase(currentRunStore.DatabasePath); + var repositoryFactory = CreateRepositoryFactory(options); + using var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); + using var currentResourceRepository = (SqliteResourceRepository)repositoryFactory.CreateResourceRepository(currentDatabase); using var dataSource = new DashboardDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); dataSource.SelectRun("missing"); @@ -628,12 +628,9 @@ private static DashboardRunStore CreateRunStore(IOptions optio return new DashboardRunStore(options, NullLogger.Instance); } - private static RepositoryFactory CreateRepositoryFactory( - DashboardSqliteDatabase currentDatabase, - IOptions options) + private static RepositoryFactory CreateRepositoryFactory(IOptions options) { return new RepositoryFactory( - currentDatabase, NullLoggerFactory.Instance, options, new PauseManager(), diff --git a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs index 174c999f431..ab54c936383 100644 --- a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs @@ -282,9 +282,9 @@ private static bool TryResolvePeerName(IDictionary re return ResourceOutgoingPeerResolver.TryResolvePeerCore(resources, attributes, out peerName, out _); } - private static ResourceOutgoingPeerResolver CreateResolver(IDashboardClient dashboardClient, DashboardActivitySource? activitySource = null) + private static ResourceOutgoingPeerResolver CreateResolver(IResourceRepository resourceRepository, DashboardActivitySource? activitySource = null) { - return new ResourceOutgoingPeerResolver(dashboardClient, activitySource ?? new DashboardActivitySource()); + return new ResourceOutgoingPeerResolver(resourceRepository, activitySource ?? new DashboardActivitySource()); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs b/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs index a31f895fc80..fbe964011bf 100644 --- a/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs +++ b/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs @@ -35,8 +35,5 @@ private sealed class TestRepositoryFactory( { public ITelemetryRepository CreateTelemetryRepository(DashboardSqliteDatabase database) => telemetryRepository; public IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database) => resourceRepository; - public void Dispose() - { - } } } \ No newline at end of file From bee6b2f2a0bd225233fe2f6189a17cadc099b57e Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 20 Jul 2026 11:41:53 +0800 Subject: [PATCH 43/87] Defer dashboard repository dependencies --- .../DashboardWebApplication.cs | 7 +----- .../ServiceClient/RepositoryFactory.cs | 21 +++++++++------- .../Model/DashboardDataSourceTests.cs | 24 +++++++++++++------ 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 9cbf283d91e..1cc9b2b3db8 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -317,12 +317,7 @@ public DashboardWebApplication( builder.Services.AddSingleton(services => services.GetRequiredService()); builder.Services.AddSingleton(services => new DashboardSqliteDatabase( services.GetRequiredService().DatabasePath)); - builder.Services.AddSingleton(services => new RepositoryFactory( - services.GetRequiredService(), - services.GetRequiredService>(), - services.GetRequiredService(), - services.GetServices, - services.GetRequiredService())); + builder.Services.AddSingleton(services => new RepositoryFactory(services)); builder.Services.AddSingleton(services => services.GetRequiredService() .CreateTelemetryRepository(services.GetRequiredService())); builder.Services.AddSingleton(services => diff --git a/src/Aspire.Dashboard/ServiceClient/RepositoryFactory.cs b/src/Aspire.Dashboard/ServiceClient/RepositoryFactory.cs index 21dfe5a8e9a..efa6df63672 100644 --- a/src/Aspire.Dashboard/ServiceClient/RepositoryFactory.cs +++ b/src/Aspire.Dashboard/ServiceClient/RepositoryFactory.cs @@ -11,16 +11,21 @@ namespace Aspire.Dashboard.ServiceClient; /// /// Creates telemetry and resource repositories for dashboard run databases. /// -internal sealed class RepositoryFactory( - ILoggerFactory loggerFactory, - IOptions dashboardOptions, - PauseManager pauseManager, - Func> outgoingPeerResolversAccessor, - IKnownPropertyLookup knownPropertyLookup) : IRepositoryFactory +// IServiceProvider is required to defer resolving IOutgoingPeerResolver until a telemetry repository is created. +// Constructor injection would create a cycle through ResourceOutgoingPeerResolver and IResourceRepository. +internal sealed class RepositoryFactory(IServiceProvider serviceProvider) : IRepositoryFactory { public ITelemetryRepository CreateTelemetryRepository(DashboardSqliteDatabase database) => - new SqliteTelemetryRepository(database, loggerFactory, dashboardOptions, pauseManager, outgoingPeerResolversAccessor()); + new SqliteTelemetryRepository( + database, + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService>(), + serviceProvider.GetRequiredService(), + serviceProvider.GetServices()); public IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database) => - new SqliteResourceRepository(database, knownPropertyLookup, loggerFactory); + new SqliteResourceRepository( + database, + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService()); } \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index 3899424995d..a2cecb15a56 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -11,6 +11,7 @@ using Google.Protobuf.WellKnownTypes; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; @@ -24,6 +25,7 @@ namespace Aspire.Dashboard.Tests.Model; public sealed class DashboardDataSourceTests(ITestOutputHelper testOutputHelper) : IDisposable { private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(testOutputHelper); + private readonly List _serviceProviders = []; [Fact] public void RunDirectory_IsNestedUnderApplicationDirectoryAndRuns() @@ -628,18 +630,26 @@ private static DashboardRunStore CreateRunStore(IOptions optio return new DashboardRunStore(options, NullLogger.Instance); } - private static RepositoryFactory CreateRepositoryFactory(IOptions options) + private RepositoryFactory CreateRepositoryFactory(IOptions options) { - return new RepositoryFactory( - NullLoggerFactory.Instance, - options, - new PauseManager(), - static () => [], - new MockKnownPropertyLookup()); + var serviceProvider = new ServiceCollection() + .AddSingleton(NullLoggerFactory.Instance) + .AddSingleton(options) + .AddSingleton() + .AddSingleton() + .BuildServiceProvider(); + _serviceProviders.Add(serviceProvider); + + return new RepositoryFactory(serviceProvider); } public void Dispose() { + foreach (var serviceProvider in _serviceProviders) + { + serviceProvider.Dispose(); + } + _workspace.Dispose(); } } \ No newline at end of file From e4bafe39fc34a4d31888c7a5775e778b86b16657 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 20 Jul 2026 12:05:55 +0800 Subject: [PATCH 44/87] Scope dashboard SQLite pool cleanup --- .../ServiceClient/DashboardRunStore.cs | 11 ++++++++-- .../ServiceClient/DashboardSqliteDatabase.cs | 2 -- .../Model/DashboardSqliteDatabaseTests.cs | 21 +++++++++---------- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index d1bbee21617..60d31cdc2db 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -77,7 +77,7 @@ internal DashboardRunStore( DatabasePath = Path.Combine(RunDirectory, DatabaseFileName); if (File.Exists(DatabasePath) && !DashboardSqliteDatabase.IsCompatible(DatabasePath)) { - DashboardSqliteDatabase.ClearPools(); + ClearDatabasePool(); DeleteDatabaseFiles(DatabasePath); } break; @@ -195,7 +195,7 @@ public void Dispose() { try { - DashboardSqliteDatabase.ClearPools(); + ClearDatabasePool(); if (_metadataPath is not null) { @@ -212,6 +212,13 @@ public void Dispose() } } + private void ClearDatabasePool() + { + // Clearing every pool can invalidate connections used by unrelated dashboard instances in the same process. + using var database = new DashboardSqliteDatabase(DatabasePath); + database.ClearPool(); + } + private void WriteMetadata(DashboardRunMetadata metadata) { File.WriteAllText(_metadataPath!, JsonSerializer.Serialize(metadata, s_jsonOptions)); diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index a3ec0bba8e9..b37b2542e3a 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -85,8 +85,6 @@ internal bool ValidateSchemaVersion(int metadataSchemaVersion) return ValidateSchemaVersion(connection, transaction: null, metadataSchemaVersion); } - public static void ClearPools() => SqliteConnection.ClearAllPools(); - public void ClearPool() { using var connection = new SqliteConnection(_connectionString); diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs index b87b7bd2a3f..4f9755527ad 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs @@ -12,14 +12,14 @@ namespace Aspire.Dashboard.Tests.Model; -public sealed class DashboardSqliteDatabaseTests : IDisposable +public sealed class DashboardSqliteDatabaseTests(ITestOutputHelper testOutputHelper) : IDisposable { - private readonly string _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-tests-dashboard-sqlite-").FullName; + private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(testOutputHelper); [Fact] public async Task DapperQuery_CreatesActivityWithQueryInformation() { - using var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "dashboard.db"), pooling: false); + using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false); var activities = new ConcurrentQueue(); using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue); using var connection = database.OpenConnection(); @@ -42,7 +42,7 @@ public async Task DapperQuery_CreatesActivityWithQueryInformation() [Fact] public void DapperFailure_SetsActivityErrorInformation() { - using var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "dashboard.db"), pooling: false); + using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false); var activities = new ConcurrentQueue(); using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue); using var connection = database.OpenConnection(); @@ -59,7 +59,7 @@ public void DapperFailure_SetsActivityErrorInformation() [Fact] public void DataReader_ActivityStopsWhenReaderIsDisposed() { - using var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "dashboard.db"), pooling: false); + using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false); var activities = new ConcurrentQueue(); using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue); using DbConnection connection = database.OpenConnection(); @@ -78,7 +78,7 @@ public void DataReader_ActivityStopsWhenReaderIsDisposed() [Fact] public void DapperQueryMultiple_ActivitySpansAllResultSets() { - using var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "dashboard.db"), pooling: false); + using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false); var activities = new ConcurrentQueue(); using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue); using var connection = database.OpenConnection(); @@ -98,7 +98,7 @@ public void DapperQueryMultiple_ActivitySpansAllResultSets() [Fact] public void CommitTransaction_CreatesActivityWithDatabaseInformation() { - using var database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "dashboard.db"), pooling: false); + using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false); var activities = new ConcurrentQueue(); using var listener = ActivityListenerHelper.Create(database.ActivitySource, onActivityStopped: activities.Enqueue); using var connection = database.OpenConnection(); @@ -119,8 +119,8 @@ public void CommitTransaction_CreatesActivityWithDatabaseInformation() [Fact] public void ActivityListener_DoesNotCaptureOtherDatabaseActivities() { - using var observedDatabase = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "observed.db"), pooling: false); - using var otherDatabase = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "other.db"), pooling: false); + using var observedDatabase = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "observed.db"), pooling: false); + using var otherDatabase = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "other.db"), pooling: false); var activities = new ConcurrentQueue(); using var listener = ActivityListenerHelper.Create(observedDatabase.ActivitySource, onActivityStopped: activities.Enqueue); using var observedConnection = observedDatabase.OpenConnection(); @@ -135,7 +135,6 @@ public void ActivityListener_DoesNotCaptureOtherDatabaseActivities() public void Dispose() { - DashboardSqliteDatabase.ClearPools(); - Directory.Delete(_temporaryDirectory, recursive: true); + _workspace.Dispose(); } } From 7b5c453bec84f2fa6cf2a4c3af70ccbb4ce709b1 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 20 Jul 2026 12:21:09 +0800 Subject: [PATCH 45/87] Batch dashboard console log inserts --- .../SqliteResourceRepository.Storage.cs | 35 +++++++++++++++ .../ServiceClient/SqliteResourceRepository.cs | 44 ++++++++----------- .../Model/SqliteResourceRepositoryTests.cs | 36 +++++++++++++-- 3 files changed, 87 insertions(+), 28 deletions(-) diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs index 3bede6df0e0..b6f32955bd1 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs @@ -96,6 +96,39 @@ INSERT INTO dashboard_resources ( InsertCommands(connection, transaction, commands); } + private static void InsertConsoleLogs( + SqliteConnection connection, + IDbTransaction transaction, + string resourceName, + IReadOnlyList consoleLogs) + { + foreach (var batch in consoleLogs.Chunk(MaxWriteBatchSize)) + { + var sql = new StringBuilder(""" + INSERT INTO console_logs (resource_name, line_number, content, is_stderr) + VALUES + """); + var parameters = new DynamicParameters(); + parameters.Add("ResourceName", resourceName); + for (var index = 0; index < batch.Length; index++) + { + var consoleLog = batch[index]; + if (index > 0) + { + sql.AppendLine(","); + } + + sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName, @LineNumber{index}, @Content{index}, @IsStdErr{index})"); + parameters.Add($"LineNumber{index}", consoleLog.LineNumber); + parameters.Add($"Content{index}", consoleLog.Content); + parameters.Add($"IsStdErr{index}", consoleLog.IsStdErr); + } + + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + private static void InsertEnvironment(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList resources) { ExecuteInsertBatches(connection, transaction, resources.SelectMany(resource => resource.Environment.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))), """ @@ -601,6 +634,8 @@ private static Timestamp CreateTimestamp(long seconds, int? nanos) private sealed record ResourceToSave(Resource Resource, int ReplicaIndex, bool ConsoleLogsLoaded); + private sealed record ConsoleLogToInsert(int LineNumber, string Content, bool IsStdErr); + private sealed record PropertyToSave(string ResourceName, int Ordinal, ResourceProperty Property); private sealed record CommandToSave(string ResourceName, int Ordinal, ResourceCommand Command, string? ParameterJsonValue); diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs index 89274dfaabe..f5f0dac6d52 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs @@ -23,7 +23,7 @@ public sealed partial class SqliteResourceRepository : IResourceRepository, IRes private readonly Dictionary _resources = new(StringComparers.ResourceName); private ImmutableHashSet>> _resourceChannels = []; private readonly Dictionary>>> _consoleChannels = new(StringComparers.ResourceName); - private readonly Dictionary> _consoleLogIds = new(StringComparers.ResourceName); + private readonly Dictionary _lastConsoleLogLineNumbers = new(StringComparers.ResourceName); private bool _disposed; public SqliteResourceRepository( @@ -305,40 +305,34 @@ UPDATE dashboard_resources SET console_logs_loaded = 1 WHERE resource_name = @ResourceName; """, new { ResourceName = resourceName }, transaction); - if (!_consoleLogIds.TryGetValue(resourceName, out var resourceLogIds)) - { - resourceLogIds = []; - _consoleLogIds.Add(resourceName, resourceLogIds); - } - + var lastLineNumber = _lastConsoleLogLineNumbers.GetValueOrDefault(resourceName, int.MinValue); + // A response can overlap a previously persisted response or repeat a line number within the + // same batch. Persist only new line numbers and keep the latest content for an in-batch repeat. + var consoleLogsToInsert = new List(logLines.Count); + var consoleLogToInsertIndexes = new Dictionary(); foreach (var line in logLines) { - var parameters = new + if (line.LineNumber <= lastLineNumber) { - ResourceName = resourceName, - line.LineNumber, - Content = line.Text, - line.IsStdErr - }; - if (resourceLogIds.TryGetValue(line.LineNumber, out var consoleLogId)) + continue; + } + + if (consoleLogToInsertIndexes.TryGetValue(line.LineNumber, out var index)) { - connection.Execute(""" - UPDATE console_logs - SET content = @Content, is_stderr = @IsStdErr - WHERE console_log_id = @ConsoleLogId; - """, new { parameters.Content, parameters.IsStdErr, ConsoleLogId = consoleLogId }, transaction); + consoleLogsToInsert[index] = new(line.LineNumber, line.Text, line.IsStdErr); } else { - consoleLogId = connection.QuerySingle(""" - INSERT INTO console_logs (resource_name, line_number, content, is_stderr) - VALUES (@ResourceName, @LineNumber, @Content, @IsStdErr) - RETURNING console_log_id; - """, parameters, transaction); - resourceLogIds.Add(line.LineNumber, consoleLogId); + consoleLogToInsertIndexes.Add(line.LineNumber, consoleLogsToInsert.Count); + consoleLogsToInsert.Add(new(line.LineNumber, line.Text, line.IsStdErr)); } } + InsertConsoleLogs(connection, transaction, resourceName, consoleLogsToInsert); transaction.Commit(); + if (consoleLogsToInsert.Count > 0) + { + _lastConsoleLogLineNumbers[resourceName] = consoleLogsToInsert.Max(line => line.LineNumber); + } if (_resources.TryGetValue(resourceName, out var resource)) { resource.ConsoleLogsLoaded = true; diff --git a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs index 9b430b7f4e2..bb812fcd0fa 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs @@ -96,11 +96,17 @@ public async Task ConsoleLogs_UseInsertionOrderAndAllowLineNumbersToRestart() using (var repository = CreateRepository(workspace.Path)) { var writer = (IResourceRepositoryWriter)repository; - writer.AddConsoleLogs("api", [ + var insertQueries = CaptureSqlQueries(() => writer.AddConsoleLogs("api", [ new ConsoleLogLine { LineNumber = 2, Text = "second", IsStdErr = true }, new ConsoleLogLine { LineNumber = 1, Text = "first" } + ])); + var insertQuery = Assert.Single(insertQueries, query => query.TrimStart().StartsWith("INSERT INTO console_logs ", StringComparison.Ordinal)); + Assert.Contains("(@ResourceName, @LineNumber0, @Content0, @IsStdErr0),", insertQuery, StringComparison.Ordinal); + Assert.Contains("(@ResourceName, @LineNumber1, @Content1, @IsStdErr1)", insertQuery, StringComparison.Ordinal); + writer.AddConsoleLogs("api", [ + new ConsoleLogLine { LineNumber = 2, Text = "second-updated", IsStdErr = true }, + new ConsoleLogLine { LineNumber = 3, Text = "third" } ]); - writer.AddConsoleLogs("api", [new ConsoleLogLine { LineNumber = 2, Text = "second-updated", IsStdErr = true }]); } using (var restartedRepository = CreateRepository(workspace.Path)) @@ -118,11 +124,35 @@ public async Task ConsoleLogs_UseInsertionOrderAndAllowLineNumbersToRestart() } var lines = Assert.Single(batches); Assert.Collection(lines, - line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(2, "second-updated", true), line), + line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(2, "second", true), line), line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(1, "first", false), line), + line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(3, "third", false), line), line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(1, "first-after-restart", false), line)); } + [Fact] + public async Task ConsoleLogs_InsertsAreBatched() + { + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + using var repository = CreateRepository(workspace.Path); + var writer = (IResourceRepositoryWriter)repository; + var logLines = Enumerable.Range(1, 101) + .Select(lineNumber => new ConsoleLogLine { LineNumber = lineNumber, Text = $"Line {lineNumber}" }) + .ToArray(); + + var queries = CaptureSqlQueries(() => writer.AddConsoleLogs("api", logLines)); + + Assert.Equal(2, queries.Count(query => query.TrimStart().StartsWith("INSERT INTO console_logs ", StringComparison.Ordinal))); + var batches = new List>(); + await foreach (var batch in repository.GetConsoleLogs("api", CancellationToken.None)) + { + batches.Add(batch); + } + var persistedLines = Assert.Single(batches); + Assert.Equal(Enumerable.Range(1, 101), persistedLines.Select(line => line.LineNumber)); + Assert.Equal(logLines.Select(line => line.Text), persistedLines.Select(line => line.Content)); + } + [Fact] public void ConsoleLogsLoaded_PersistsWithoutLogLines() { From 98dbb84250ffca99914828a9a5747c70ef533d16 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 20 Jul 2026 13:54:29 +0800 Subject: [PATCH 46/87] Stream dashboard peer recalculation --- .../SqliteTelemetryRepository.Traces.cs | 173 +++++++++++------- 1 file changed, 107 insertions(+), 66 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs index 9444ad2752c..d23eb00e376 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs @@ -343,6 +343,11 @@ private void UpdateUninstrumentedPeers(SqliteConnection connection, IDbTransacti transaction); } + UpdatePeerSpans(connection, transaction, spanUpdates); + } + + private static void UpdatePeerSpans(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList spanUpdates) + { foreach (var batch in spanUpdates.Chunk(MaxSpanDetailBatchSize)) { var sql = new StringBuilder(""" @@ -1457,54 +1462,96 @@ private void RecalculateUninstrumentedPeers() { lock (_writeLock) { - using var connection = _database.OpenConnection(); - using var transaction = connection.BeginTransaction(); - var spans = connection.Query(""" - SELECT - trace_id AS TraceId, - span_id AS SpanId, - parent_span_id AS ParentSpanId, - kind AS Kind - FROM telemetry_spans; - """, transaction: transaction).AsList(); - var attributes = connection.Query(""" + using var writeConnection = _database.OpenConnection(); + using var transaction = writeConnection.BeginTransaction(); + using var readConnection = _database.OpenConnection(); + // Return one row per span attribute (or one row with null attributes for spans without any). + // The parents CTE marks spans that have children so processing below can restrict peer + // resolution to client/producer leaf spans that have a peer address. Ordering keeps every + // span's attributes contiguous, which lets the loop finalize one span when its identity + // changes instead of buffering all spans and attributes. A separate connection keeps the + // unbuffered reader open while completed spans are written in batches through the transaction. + var rows = readConnection.Query(""" + WITH parents AS ( + SELECT DISTINCT trace_id, parent_span_id AS span_id + FROM telemetry_spans + WHERE parent_span_id IS NOT NULL + ) SELECT - trace_id AS TraceId, - span_id AS SpanId, - attribute_key AS AttributeKey, - attribute_value AS AttributeValue - FROM telemetry_span_attributes - ORDER BY trace_id, span_id, ordinal; - """, transaction: transaction).ToLookup(record => (record.TraceId, record.SpanId)); - var parents = spans - .Where(span => span.ParentSpanId is not null) - .Select(span => (span.TraceId, SpanId: span.ParentSpanId!)) - .ToHashSet(); - var peerResourceIds = new Dictionary(); - var spanUpdates = new List(spans.Count); - - foreach (var span in spans) + spans.trace_id AS TraceId, + spans.span_id AS SpanId, + spans.kind AS Kind, + parents.span_id IS NOT NULL AS HasChildren, + attributes.attribute_key AS AttributeKey, + attributes.attribute_value AS AttributeValue + FROM telemetry_spans AS spans + LEFT JOIN parents + ON parents.trace_id = spans.trace_id + AND parents.span_id = spans.span_id + LEFT JOIN telemetry_span_attributes AS attributes + ON attributes.trace_id = spans.trace_id + AND attributes.span_id = spans.span_id + ORDER BY spans.trace_id, spans.span_id, attributes.ordinal; + """, buffered: false); + var spanUpdates = new List(MaxSpanDetailBatchSize); + var spanAttributes = new List>(); + PeerRecalculationRowRecord? currentSpan = null; + + foreach (var row in rows) + { + if (currentSpan is not null && + (!string.Equals(currentSpan.TraceId, row.TraceId, StringComparison.Ordinal) || + !string.Equals(currentSpan.SpanId, row.SpanId, StringComparison.Ordinal))) + { + ProcessSpan(currentSpan, spanAttributes); + spanAttributes.Clear(); + } + + currentSpan = row; + if (row.AttributeKey is not null) + { + spanAttributes.Add(KeyValuePair.Create(row.AttributeKey, row.AttributeValue!)); + } + } + if (currentSpan is not null) + { + ProcessSpan(currentSpan, spanAttributes); + } + FlushSpanUpdates(); + + writeConnection.Execute(""" + UPDATE telemetry_resources + SET uninstrumented_peer = 1 + WHERE resource_id IN ( + SELECT uninstrumented_peer_resource_id + FROM telemetry_spans + WHERE uninstrumented_peer_resource_id IS NOT NULL + ); + """, transaction: transaction); + var lastUpdatedTimestampTicks = DateTime.UtcNow.Ticks; + writeConnection.Execute(""" + UPDATE telemetry_traces + SET last_updated_timestamp_ticks = @LastUpdatedTimestampTicks + WHERE trace_id IN (SELECT trace_id FROM telemetry_spans); + """, new + { + LastUpdatedTimestampTicks = lastUpdatedTimestampTicks + }, transaction); + transaction.Commit(); + + void ProcessSpan(PeerRecalculationRowRecord span, IReadOnlyList> attributes) { - var spanAttributes = attributes[(span.TraceId, span.SpanId)] - .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)) - .ToArray(); long? peerResourceId = null; - if (spanAttributes.GetPeerAddress() is not null && - (OtlpSpanKind)span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && - !parents.Contains((span.TraceId, span.SpanId))) + if ((OtlpSpanKind)span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && + !span.HasChildren && + attributes.Count > 0) { - if (TryResolvePeerResourceKey(spanAttributes, out var peerKey)) + var attributeArray = attributes.ToArray(); + if (attributeArray.GetPeerAddress() is not null && + TryResolvePeerResourceKey(attributeArray, out var peerKey)) { - if (!peerResourceIds.TryGetValue(peerKey, out var resourceId)) - { - resourceId = GetOrAddCachedResource(connection, transaction, peerKey, uninstrumentedPeer: true).ResourceId; - peerResourceIds.Add(peerKey, resourceId); - connection.Execute( - "UPDATE telemetry_resources SET uninstrumented_peer = 1 WHERE resource_id = @ResourceId;", - new { ResourceId = resourceId }, - transaction); - } - peerResourceId = resourceId; + var cachedPeerResource = GetOrAddCachedResource(writeConnection, transaction, peerKey, uninstrumentedPeer: true); + peerResourceId = cachedPeerResource.ResourceId; } } @@ -1514,24 +1561,22 @@ FROM telemetry_span_attributes TraceId = span.TraceId, SpanId = span.SpanId }); + if (spanUpdates.Count == MaxSpanDetailBatchSize) + { + FlushSpanUpdates(); + } } - connection.Execute(""" - UPDATE telemetry_spans - SET uninstrumented_peer_resource_id = @PeerResourceId - WHERE trace_id = @TraceId AND span_id = @SpanId; - """, spanUpdates, transaction); - var lastUpdatedTimestampTicks = DateTime.UtcNow.Ticks; - connection.Execute(""" - UPDATE telemetry_traces - SET last_updated_timestamp_ticks = @LastUpdatedTimestampTicks - WHERE trace_id = @TraceId; - """, spans.Select(span => span.TraceId).Distinct(StringComparer.Ordinal).Select(traceId => new + void FlushSpanUpdates() { - TraceId = traceId, - LastUpdatedTimestampTicks = lastUpdatedTimestampTicks - }), transaction); - transaction.Commit(); + if (spanUpdates.Count == 0) + { + return; + } + + UpdatePeerSpans(writeConnection, transaction, spanUpdates); + spanUpdates.Clear(); + } } } @@ -1678,18 +1723,14 @@ private sealed class SpanIdentityRecord public required string SpanId { get; init; } } - private sealed class PeerRecalculationSpanRecord + private sealed class PeerRecalculationRowRecord { public required string TraceId { get; init; } public required string SpanId { get; init; } - public string? ParentSpanId { get; init; } public required int Kind { get; init; } - } - - private sealed class PeerRecalculationAttributeRecord : AttributeRecord - { - public required string TraceId { get; init; } - public required string SpanId { get; init; } + public required bool HasChildren { get; init; } + public string? AttributeKey { get; init; } + public string? AttributeValue { get; init; } } private sealed class PeerSpanUpdateRecord From bddf914ab8792cb40a421e3decd5abb349782a37 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 20 Jul 2026 14:47:30 +0800 Subject: [PATCH 47/87] Optimize dashboard run selection loading --- .../Controls/AspireMenuButton.razor | 2 +- .../Controls/AspireMenuButton.razor.cs | 24 ++- .../Controls/DashboardRunSelect.razor | 1 + .../Controls/DashboardRunSelect.razor.cs | 20 +-- .../Components/Layout/MainLayout.razor | 10 +- .../Components/Layout/MainLayout.razor.cs | 13 +- .../Controls/AspireMenuButtonTests.cs | 33 ++++ .../Layout/MainLayoutTests.cs | 151 +++++++++++++++--- 8 files changed, 211 insertions(+), 43 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor index 5bc644e6162..743de923f42 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor @@ -11,7 +11,7 @@ }; } - + @if (IconStart is not null) { diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs index be3be50b528..1198c4f6f95 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs @@ -63,6 +63,12 @@ public partial class AspireMenuButton : FluentComponentBase [Parameter] public bool Disabled { get; set; } + /// + /// Gets or sets the callback invoked immediately before the menu is opened. + /// + [Parameter] + public EventCallback OnOpening { get; set; } + /// /// Gets or sets a value indicating whether focus should return to this menu button after a menu item is clicked. /// @@ -76,18 +82,30 @@ public partial class AspireMenuButton : FluentComponentBase protected override void OnParametersSet() { _icon = Icon ?? s_defaultIcon; + UpdateItems(); + } + private void UpdateItems() + { if (Items != null && !_items.SequenceEqual(Items)) { _items = Items.ToArray(); } - _disabled = Disabled || !_items.Any(i => !i.IsDivider); + _disabled = Disabled || (!OnOpening.HasDelegate && !_items.Any(i => !i.IsDivider)); } - private void ToggleMenu() + private async Task ToggleMenuAsync() { - _visible = !_visible; + if (_visible) + { + _visible = false; + return; + } + + await OnOpening.InvokeAsync(); + UpdateItems(); + _visible = true; } private void OnKeyDown(KeyboardEventArgs args) diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor index 2d8f161523e..d746eb5f471 100644 --- a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor @@ -5,6 +5,7 @@ IconStartClass="application-run-status" IconStartColor="@(SelectedRunIsCurrent ? Color.Success : Color.Warning)" Items="@_menuItems" + OnOpening="@LoadRuns" ButtonAppearance="Appearance.Stealth" ButtonClass="application-run-select-button" HideIcon="true" diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs index 84d12148b78..88d9bcd78d0 100644 --- a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs @@ -12,10 +12,11 @@ namespace Aspire.Dashboard.Components.Controls; public partial class DashboardRunSelect : ComponentBase { - private IReadOnlyList _runs = []; private readonly List _menuItems = []; private string RunSelectAriaLabel => Loc[nameof(LayoutResources.DashboardRunSelectAriaLabel)]; - private string SelectedRunText => FormatRunOption(_runs.Single(run => string.Equals(run.RunId, SelectedRunId, StringComparison.Ordinal))); + private string SelectedRunText => SelectedRunIsCurrent + ? Loc[nameof(LayoutResources.DashboardRunSelectCurrent)] + : FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, SelectedRunStartedAtUtc.UtcDateTime); [Parameter, EditorRequired] public required string SelectedRunId { get; set; } @@ -23,6 +24,9 @@ public partial class DashboardRunSelect : ComponentBase [Parameter] public bool SelectedRunIsCurrent { get; set; } + [Parameter] + public DateTimeOffset SelectedRunStartedAtUtc { get; set; } + [Parameter] public EventCallback SelectedRunIdChanged { get; set; } @@ -35,15 +39,11 @@ public partial class DashboardRunSelect : ComponentBase [Inject] internal IDashboardRunStore RunStore { get; init; } = null!; - protected override void OnInitialized() - { - _runs = RunStore.GetRuns(); - } - - protected override void OnParametersSet() + private void LoadRuns() { + var runs = RunStore.GetRuns(); _menuItems.Clear(); - foreach (var run in _runs) + foreach (var run in runs) { _menuItems.Add(new MenuButtonItem { @@ -54,7 +54,7 @@ protected override void OnParametersSet() OnClick = () => SelectedRunIdChanged.InvokeAsync(run.IsCurrent ? null : run.RunId) }); - if (run.IsCurrent && _runs.Any(candidate => !candidate.IsCurrent)) + if (run.IsCurrent && runs.Any(candidate => !candidate.IsCurrent)) { _menuItems.Add(new MenuButtonItem { IsDivider = true }); } diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index a2a0f0b1d84..d21a831a35c 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -20,12 +20,13 @@
- @if (RunStore.SupportsRunSelection && _isRunSelectionInitialized) + @if (RunStore.SupportsRunSelection) { var selectedRun = RunSelection.SelectedRun; }
- @if (RunStore.SupportsRunSelection && _isRunSelectionInitialized) + @if (RunStore.SupportsRunSelection) { var selectedRun = RunSelection.SelectedRun; } @@ -102,7 +104,7 @@
- @if (!_isSwitchingRuns && _isRunSelectionInitialized) + @if (!_isSwitchingRuns) { var selectedRun = RunSelection.SelectedRun; @@ -115,7 +117,7 @@ - @if (!_isSwitchingRuns && _isRunSelectionInitialized) + @if (!_isSwitchingRuns) { var selectedRun = RunSelection.SelectedRun; diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 21b43cf710b..075171a5f8f 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -18,7 +18,7 @@ namespace Aspire.Dashboard.Components.Layout; public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable { private bool _isNavMenuOpen; - private bool _isRunSelectionInitialized; + private bool _runSelectionChanged; private bool _isSwitchingRuns; private IDisposable? _themeChangedSubscription; @@ -90,14 +90,13 @@ protected override async Task OnInitializedAsync() { if (RunStore.SupportsRunSelection) { - var runOptions = RunStore.GetRuns(); var selectedRunResult = await SessionStorage.GetAsync(BrowserStorageKeys.SelectedDashboardRunId); var selectedRunId = selectedRunResult is { Success: true } ? selectedRunResult.Value : null; - var selectedRun = runOptions.FirstOrDefault(run => string.Equals(run.RunId, selectedRunId, StringComparison.Ordinal)) - ?? runOptions.Single(run => run.IsCurrent); - RunSelection.SelectRun(selectedRun.IsCurrent ? null : selectedRun.RunId); + if (!_runSelectionChanged && !string.IsNullOrEmpty(selectedRunId)) + { + RunSelection.SelectRun(selectedRunId); + } } - _isRunSelectionInitialized = true; // Theme change can be triggered from the settings dialog. This logic applies the new theme to the browser window. // Note that this event could be raised from a settings dialog opened in a different browser window. @@ -237,9 +236,11 @@ protected override void OnParametersSet() private async Task SwitchDashboardRunAsync(string? runId) { + _runSelectionChanged = true; var selectedRunId = RunSelection.SelectedRun is { IsCurrent: false } selectedRun ? selectedRun.RunId : null; if (string.Equals(runId, selectedRunId, StringComparison.Ordinal)) { + await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, runId ?? string.Empty); return; } diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuButtonTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuButtonTests.cs index d7ef0cf23fa..789c41fe1dc 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuButtonTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuButtonTests.cs @@ -68,4 +68,37 @@ public void ToggleMenu_UpdatesAriaExpandedState() Assert.Equal("false", cut.Find("#view-options-button").GetAttribute("aria-expanded")); }); } + + [Fact] + public void OnOpening_LoadsItemsBeforeMenuIsDisplayed() + { + FluentUISetupHelpers.SetupFluentUIComponents(this); + FluentUISetupHelpers.SetupFluentAnchoredRegion(this); + FluentUISetupHelpers.SetupFluentButton(this); + FluentUISetupHelpers.SetupFluentMenu(this); + + var items = new List(); + var cut = Render(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + builder.OpenComponent(1); + builder.AddAttribute(2, nameof(AspireMenuButton.MenuButtonId), "lazy-menu-button"); + builder.AddAttribute(3, nameof(AspireMenuButton.Items), items); + builder.AddAttribute(4, nameof(AspireMenuButton.OnOpening), + Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this, () => items.Add(new MenuButtonItem { Text = "Loaded item" }))); + builder.CloseComponent(); + }); + + var button = cut.Find("#lazy-menu-button"); + Assert.DoesNotContain("disabled", button.Attributes.Select(attribute => attribute.Name)); + + button.Click(); + + cut.WaitForAssertion(() => + { + Assert.Equal("true", cut.Find("#lazy-menu-button").GetAttribute("aria-expanded")); + Assert.Equal("Loaded item", cut.Find("fluent-menu-item").TextContent.Trim()); + }); + } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index adfed40099b..7580d1fc91b 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -296,7 +296,7 @@ public void DashboardRunSelect_Unsupported_IsHiddenAndStaleSelectionIsIgnored(bo }); Assert.Empty(cut.FindComponents()); - Assert.Equal(getRunsCallCount, runStore.GetRunsCallCount); + Assert.Equal(getRunsCallCount, runStore.GetRunsCallCount); Assert.Null(runSelection.SelectedRunId); } @@ -356,6 +356,11 @@ public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavig Assert.Contains("fill: var(--success)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); Assert.Equal("Live run", menuButton.Instance.Text); Assert.True(menuButton.Instance.HideIcon); + Assert.Empty(menuButton.Instance.Items); + + var navigationOccurred = false; + Services.GetRequiredService().LocationChanged += (_, _) => navigationOccurred = true; + runSelect.Find("fluent-button").Click(); Assert.Collection( menuButton.Instance.Items, item => @@ -370,9 +375,6 @@ public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavig Assert.Null(item.Icon); }); - var navigationOccurred = false; - Services.GetRequiredService().LocationChanged += (_, _) => navigationOccurred = true; - runSelect.Find("fluent-button").Click(); var menuItems = runSelect.WaitForElements("fluent-menu-item"); Assert.Single(runSelect.FindAll("fluent-divider")); Assert.Single(menuItems[0].QuerySelectorAll("span[slot='start']")); @@ -390,18 +392,19 @@ public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavig statusIcon = runSelect.Find(".application-run-status"); Assert.Contains("fill: var(--warning)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); Assert.Equal(expectedHistoricalRunText, menuButton.Instance.Text); + Assert.Empty(menuButton.Instance.Items); + + runSelect.Find("fluent-button").Click(); Assert.Collection( menuButton.Instance.Items, item => Assert.Null(item.Icon), item => Assert.True(item.IsDivider), item => Assert.IsType(item.Icon)); - - runSelect.Find("fluent-button").Click(); - menuItems = runSelect.WaitForElements("fluent-menu-item"); - Assert.Single(runSelect.FindAll("fluent-divider")); - Assert.Empty(menuItems[0].QuerySelectorAll("span[slot='start']")); - Assert.Single(menuItems[1].QuerySelectorAll("span[slot='start']")); - menuItems[0].Click(); + menuItems = runSelect.WaitForElements("fluent-menu-item"); + Assert.Single(runSelect.FindAll("fluent-divider")); + Assert.Empty(menuItems[0].QuerySelectorAll("span[slot='start']")); + Assert.Single(menuItems[1].QuerySelectorAll("span[slot='start']")); + menuItems[0].Click(); Assert.Equal(string.Empty, storedRunId); Assert.Null(runSelection.SelectedRunId); @@ -413,15 +416,11 @@ public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavig statusIcon = runSelect.Find(".application-run-status"); Assert.Contains("fill: var(--success)", statusIcon.GetAttribute("style"), StringComparison.Ordinal); Assert.Equal("Live run", menuButton.Instance.Text); - Assert.Collection( - menuButton.Instance.Items, - item => Assert.IsType(item.Icon), - item => Assert.True(item.IsDivider), - item => Assert.Null(item.Icon)); + Assert.Empty(menuButton.Instance.Items); } [Fact] - public async Task RunSelectionPending_DoesNotRenderBody() + public async Task RunSelectionPending_RendersCurrentRunWithoutLoadingRuns() { var runStore = new FluentUISetupHelpers.TestDashboardRunStore( [ @@ -441,6 +440,8 @@ public async Task RunSelectionPending_DoesNotRenderBody() OnGetTaskAsync = _ => runSelectionSource.Task }; SetupMainLayoutServices(dashboardRunStore: runStore, sessionStorage: sessionStorage); + var runSelection = Assert.IsType(Services.GetRequiredService()); + var getRunsCallCount = runStore.GetRunsCallCount; var cut = RenderComponent(builder => { @@ -448,11 +449,123 @@ public async Task RunSelectionPending_DoesNotRenderBody() builder.Add(p => p.Body, bodyBuilder => bodyBuilder.AddMarkupContent(0, "
")); }); - Assert.Empty(cut.FindAll("#body-content")); + Assert.NotNull(cut.Find("#body-content")); + Assert.True(runSelection.SelectedRun.IsCurrent); + var runSelect = cut.FindComponent(); + var menuButton = runSelect.FindComponent(); + Assert.Equal("Live run", menuButton.Instance.Text); + Assert.Empty(menuButton.Instance.Items); + Assert.Equal(getRunsCallCount, runStore.GetRunsCallCount); + + runSelect.Find("fluent-button").Click(); + + Assert.Single(runSelect.WaitForElements("fluent-menu-item")); + Assert.Equal(getRunsCallCount + 1, runStore.GetRunsCallCount); await cut.InvokeAsync(() => runSelectionSource.SetResult((false, null))); + } + + [Fact] + public async Task RunSelectionPending_StoredHistoricalRunReplacesCurrentRun() + { + var historicalRun = new DashboardRunDescriptor( + RunId: "historical", + SchemaVersion: DashboardRunStore.SchemaVersion, + StartedAtUtc: new DateTimeOffset(2025, 1, 2, 12, 30, 0, TimeSpan.Zero), + EndedAtUtc: new DateTimeOffset(2025, 1, 2, 13, 30, 0, TimeSpan.Zero), + CleanShutdown: true, + ApplicationName: "TestApp", + DatabasePath: string.Empty, + IsCurrent: false); + var runStore = new FluentUISetupHelpers.TestDashboardRunStore( + [ + new( + RunId: "current", + SchemaVersion: DashboardRunStore.SchemaVersion, + StartedAtUtc: DateTimeOffset.UnixEpoch, + EndedAtUtc: null, + CleanShutdown: false, + ApplicationName: "TestApp", + DatabasePath: string.Empty, + IsCurrent: true), + historicalRun + ]); + var runSelectionSource = new TaskCompletionSource<(bool Success, object? Value)>(TaskCreationOptions.RunContinuationsAsynchronously); + var sessionStorage = new TestSessionStorage + { + OnGetTaskAsync = _ => runSelectionSource.Task + }; + SetupMainLayoutServices(dashboardRunStore: runStore, sessionStorage: sessionStorage); + + var cut = RenderComponent(builder => + { + builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); + builder.Add(p => p.Body, bodyBuilder => bodyBuilder.AddMarkupContent(0, "
")); + }); - Assert.NotNull(cut.WaitForElement("#body-content")); + Assert.Equal("Live run", cut.FindComponent().FindComponent().Instance.Text); + + await cut.InvokeAsync(() => runSelectionSource.SetResult((true, "historical"))); + + var expectedHistoricalRunText = FormatHelpers.FormatTimeWithOptionalDate( + Services.GetRequiredService(), + historicalRun.StartedAtUtc.UtcDateTime); + cut.WaitForAssertion(() => + { + var menuButton = cut.FindComponent().FindComponent(); + Assert.Equal(expectedHistoricalRunText, menuButton.Instance.Text); + }); + } + + [Fact] + public async Task RunSelectionPending_UserSelectionWinsOverStoredSelection() + { + var historicalRun = new DashboardRunDescriptor( + RunId: "historical", + SchemaVersion: DashboardRunStore.SchemaVersion, + StartedAtUtc: new DateTimeOffset(2025, 1, 2, 12, 30, 0, TimeSpan.Zero), + EndedAtUtc: new DateTimeOffset(2025, 1, 2, 13, 30, 0, TimeSpan.Zero), + CleanShutdown: true, + ApplicationName: "TestApp", + DatabasePath: string.Empty, + IsCurrent: false); + var runStore = new FluentUISetupHelpers.TestDashboardRunStore( + [ + new( + RunId: "current", + SchemaVersion: DashboardRunStore.SchemaVersion, + StartedAtUtc: DateTimeOffset.UnixEpoch, + EndedAtUtc: null, + CleanShutdown: false, + ApplicationName: "TestApp", + DatabasePath: string.Empty, + IsCurrent: true), + historicalRun + ]); + var runSelectionSource = new TaskCompletionSource<(bool Success, object? Value)>(TaskCreationOptions.RunContinuationsAsynchronously); + string? storedRunId = null; + var sessionStorage = new TestSessionStorage + { + OnGetTaskAsync = _ => runSelectionSource.Task, + OnSetAsync = (_, value) => storedRunId = Assert.IsType(value) + }; + SetupMainLayoutServices(dashboardRunStore: runStore, sessionStorage: sessionStorage); + JSInterop.SetupVoid("focusElement", _ => true); + + var cut = RenderComponent(builder => + { + builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); + }); + var runSelect = cut.FindComponent(); + runSelect.Find("fluent-button").Click(); + runSelect.WaitForElements("fluent-menu-item")[0].Click(); + + Assert.Equal(string.Empty, storedRunId); + await cut.InvokeAsync(() => runSelectionSource.SetResult((true, "historical"))); + + var runSelection = Assert.IsType(Services.GetRequiredService()); + Assert.True(runSelection.SelectedRun.IsCurrent); + Assert.Equal("Live run", cut.FindComponent().FindComponent().Instance.Text); } [Theory] From a1efd7b59fe259c05ccf69a3dfdf68f88ca7d3ad Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 20 Jul 2026 14:47:38 +0800 Subject: [PATCH 48/87] Simplify dashboard run IDs --- .../ServiceClient/DashboardRunStore.cs | 13 +++++--- .../Model/DashboardDataSourceTests.cs | 31 +++++++++++++++---- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index 60d31cdc2db..a89690c2b86 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -33,22 +33,25 @@ internal sealed class DashboardRunStore : IDashboardRunStore, IDisposable private readonly FileStream? _runLock; private readonly DashboardRunMetadata _metadata; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private readonly Lazy> _runs; - public DashboardRunStore(IOptions options, ILogger logger) - : this(options, logger, static directory => Directory.Delete(directory, recursive: true)) + public DashboardRunStore(IOptions options, ILogger logger, TimeProvider timeProvider) + : this(options, logger, timeProvider, static directory => Directory.Delete(directory, recursive: true)) { } internal DashboardRunStore( IOptions options, ILogger logger, + TimeProvider timeProvider, Action deleteRunDirectory) { _logger = logger; + _timeProvider = timeProvider; var applicationName = string.IsNullOrWhiteSpace(options.Value.ApplicationName) ? "Aspire" : options.Value.ApplicationName; - var startedAt = DateTimeOffset.UtcNow; - var runId = $"{startedAt:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}"; + var startedAt = timeProvider.GetUtcNow(); + var runId = $"{startedAt:yyyyMMddTHHmmssfffZ}"; PersistenceMode = options.Value.Data.PersistenceMode; // Persistent run directories should be located under a directory scoped to the current user. Do not set Unix modes here; @@ -199,7 +202,7 @@ public void Dispose() if (_metadataPath is not null) { - WriteMetadata(_metadata with { EndedAtUtc = DateTimeOffset.UtcNow, CleanShutdown = true }); + WriteMetadata(_metadata with { EndedAtUtc = _timeProvider.GetUtcNow(), CleanShutdown = true }); } else if (_temporaryDirectory is not null && Directory.Exists(_temporaryDirectory)) { diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index a2cecb15a56..c12ee347906 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -39,6 +39,17 @@ public void RunDirectory_IsNestedUnderApplicationDirectoryAndRuns() Assert.Equal(expectedRunsDirectory, Directory.GetParent(runStore.RunDirectory)!.FullName); } + [Fact] + public void RunId_IsUtcTimestampWithMillisecondPrecision() + { + var timeProvider = new FixedTimeProvider(new DateTimeOffset(2026, 7, 20, 12, 34, 56, 789, TimeSpan.Zero)); + + using var runStore = CreateRunStore(CreateOptions(), timeProvider); + + Assert.Equal("20260720T123456789Z", runStore.RunId); + Assert.Equal(runStore.RunId, Path.GetFileName(runStore.RunDirectory)); + } + [Fact] public void RunMetadata_IncludesSchemaVersion() { @@ -285,7 +296,7 @@ public void RunsMode_DeletesOldestRunWhenLimitIsExceeded() var historicalRunDirectories = Enumerable.Range(1, DashboardRunStore.MaxRuns) .Select(index => Path.Combine( runsDirectory, - $"{DateTimeOffset.UtcNow.AddDays(-index):yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}")) + $"{DateTimeOffset.UtcNow.AddDays(-index):yyyyMMddTHHmmssfffZ}")) .ToList(); foreach (var directory in historicalRunDirectories) @@ -309,7 +320,7 @@ public void RunsMode_DoesNotDeleteActiveExpiredRun() var historicalRunDirectories = Enumerable.Range(1, DashboardRunStore.MaxRuns) .Select(index => Path.Combine( runsDirectory, - $"{DateTimeOffset.UtcNow.AddDays(-index):yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}")) + $"{DateTimeOffset.UtcNow.AddDays(-index):yyyyMMddTHHmmssfffZ}")) .ToList(); foreach (var directory in historicalRunDirectories) @@ -357,13 +368,14 @@ public void SelectedHistoricalRun_HoldsLeaseUntilSelectionChanges() { Directory.CreateDirectory(Path.Combine( runsDirectory, - $"{DateTimeOffset.UtcNow.AddDays(index):yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}")); + $"{DateTimeOffset.UtcNow.AddDays(index):yyyyMMddTHHmmssfffZ}")); } var deletedRunDirectories = new List(); using var pruningRunStore = new DashboardRunStore( options, NullLogger.Instance, + TimeProvider.System, deletedRunDirectories.Add); Assert.Empty(deletedRunDirectories); @@ -373,6 +385,7 @@ public void SelectedHistoricalRun_HoldsLeaseUntilSelectionChanges() using var nextPruningRunStore = new DashboardRunStore( options, NullLogger.Instance, + TimeProvider.System, deletedRunDirectories.Add); Assert.Equal(historicalRunDirectory, Assert.Single(deletedRunDirectories)); @@ -386,7 +399,7 @@ public void RunsMode_DeleteExpiredRunFails_LogsWarningAndContinues() var historicalRunDirectories = Enumerable.Range(1, DashboardRunStore.MaxRuns) .Select(index => Path.Combine( runsDirectory, - $"{DateTimeOffset.UtcNow.AddDays(-index):yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}")) + $"{DateTimeOffset.UtcNow.AddDays(-index):yyyyMMddTHHmmssfffZ}")) .ToList(); foreach (var directory in historicalRunDirectories) @@ -402,6 +415,7 @@ public void RunsMode_DeleteExpiredRunFails_LogsWarningAndContinues() using var currentRunStore = new DashboardRunStore( CreateOptions(), logger, + TimeProvider.System, directory => throw new IOException($"The directory '{directory}' is in use.")); var warning = Assert.Single(testSink.Writes); @@ -625,9 +639,9 @@ private static SqliteResourceRepository CreateResourceRepository(string database return new SqliteResourceRepository(databasePath, new MockKnownPropertyLookup(), NullLoggerFactory.Instance); } - private static DashboardRunStore CreateRunStore(IOptions options) + private static DashboardRunStore CreateRunStore(IOptions options, TimeProvider? timeProvider = null) { - return new DashboardRunStore(options, NullLogger.Instance); + return new DashboardRunStore(options, NullLogger.Instance, timeProvider ?? TimeProvider.System); } private RepositoryFactory CreateRepositoryFactory(IOptions options) @@ -652,4 +666,9 @@ public void Dispose() _workspace.Dispose(); } + + private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => utcNow; + } } \ No newline at end of file From 6388684b0697b759fd090c967d51f070feb8c7bd Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Mon, 20 Jul 2026 17:39:07 +0800 Subject: [PATCH 49/87] Add dashboard run persistence controls --- src/Aspire.Cli/Aspire.Cli.csproj | 1 + .../Commands/DashboardRunCommand.cs | 21 ++- .../DashboardCommandStrings.Designer.cs | 12 ++ .../Resources/DashboardCommandStrings.resx | 6 + .../xlf/DashboardCommandStrings.cs.xlf | 10 ++ .../xlf/DashboardCommandStrings.de.xlf | 10 ++ .../xlf/DashboardCommandStrings.es.xlf | 10 ++ .../xlf/DashboardCommandStrings.fr.xlf | 10 ++ .../xlf/DashboardCommandStrings.it.xlf | 10 ++ .../xlf/DashboardCommandStrings.ja.xlf | 10 ++ .../xlf/DashboardCommandStrings.ko.xlf | 10 ++ .../xlf/DashboardCommandStrings.pl.xlf | 10 ++ .../xlf/DashboardCommandStrings.pt-BR.xlf | 10 ++ .../xlf/DashboardCommandStrings.ru.xlf | 10 ++ .../xlf/DashboardCommandStrings.tr.xlf | 10 ++ .../xlf/DashboardCommandStrings.zh-Hans.xlf | 10 ++ .../xlf/DashboardCommandStrings.zh-Hant.xlf | 10 ++ src/Aspire.Cli/Utils/CliPathHelper.cs | 14 +- src/Aspire.Dashboard/Aspire.Dashboard.csproj | 1 + .../Components/Layout/MainLayout.razor.cs | 7 +- .../Configuration/DashboardOptions.cs | 4 +- .../DashboardWebApplication.cs | 2 + .../ServiceClient/DashboardDataSource.cs | 21 ++- .../ServiceClient/DashboardRunStore.cs | 97 +++++++++----- .../Dashboard/DashboardEventHandlers.cs | 5 +- src/Shared/AspireHomeDirectory.cs | 35 +++++ .../Commands/DashboardRunCommandTests.cs | 30 ++++- .../DashboardOptionsTests.cs | 6 +- .../Model/DashboardDataSourceTests.cs | 125 ++++++++++++++++-- .../Shared/TestDashboardDataSource.cs | 26 ++-- .../Dashboard/DashboardEventHandlersTests.cs | 64 +++++++-- .../Dashboard/DashboardResourceTests.cs | 2 +- 32 files changed, 514 insertions(+), 95 deletions(-) create mode 100644 src/Shared/AspireHomeDirectory.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index d8889b489ec..7b6b103b55d 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -136,6 +136,7 @@ + diff --git a/src/Aspire.Cli/Commands/DashboardRunCommand.cs b/src/Aspire.Cli/Commands/DashboardRunCommand.cs index 2fb506ee86f..6c5dace0b0b 100644 --- a/src/Aspire.Cli/Commands/DashboardRunCommand.cs +++ b/src/Aspire.Cli/Commands/DashboardRunCommand.cs @@ -53,6 +53,16 @@ internal sealed class DashboardRunCommand : BaseCommand Description = DashboardCommandStrings.AllowAnonymousOptionDescription }; + private static readonly Option s_applicationNameOption = new("--application-name") + { + Description = DashboardCommandStrings.ApplicationNameOptionDescription + }; + + private static readonly Option s_persistenceModeOption = new("--persistence") + { + Description = DashboardCommandStrings.PersistenceModeOptionDescription + }; + private static readonly Option s_configFilePathOption = new("--config-file-path") { Description = DashboardCommandStrings.ConfigFilePathOptionDescription @@ -77,6 +87,8 @@ public DashboardRunCommand( Options.Add(s_otlpGrpcUrlOption); Options.Add(s_otlpHttpUrlOption); Options.Add(s_allowAnonymousOption); + Options.Add(s_applicationNameOption); + Options.Add(s_persistenceModeOption); Options.Add(s_configFilePathOption); TreatUnmatchedTokensAsErrors = false; } @@ -108,7 +120,12 @@ protected override async Task ExecuteAsync(ParseResult parseResul // Tokens and keys are passed via environment variables (not command-line args) // to avoid exposing them in process listings (e.g. ps, Task Manager). string? browserToken = null; - var environmentVariables = new Dictionary(); + var environmentVariables = new Dictionary + { + // Dashboard output is captured in the CLI log instead of written to the console, + // so include debug details without increasing console verbosity. + ["Logging__LogLevel__Default"] = LogLevel.Debug.ToString() + }; layoutLease?.AddEnvironment(environmentVariables); if (!allowAnonymous && !ConfigSettingHasValue(unmatchedTokens, _environment, KnownConfigNames.DashboardUnsecuredAllowAnonymous)) { @@ -146,6 +163,8 @@ private static void AddOptionArgs(ParseResult parseResult, List args, IR AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_otlpGrpcUrlOption, KnownConfigNames.DashboardOtlpGrpcEndpointUrl, defaultValue: "http://localhost:4317"); AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_otlpHttpUrlOption, KnownConfigNames.DashboardOtlpHttpEndpointUrl, defaultValue: "http://localhost:4318"); AddBoolOptionArg(parseResult, args, unmatchedTokens, environment, s_allowAnonymousOption, KnownConfigNames.DashboardUnsecuredAllowAnonymous); + AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_applicationNameOption, DashboardConfigNames.DashboardApplicationName.EnvVarName, defaultValue: null); + AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_persistenceModeOption, DashboardConfigNames.DashboardPersistenceModeName.EnvVarName, defaultValue: null); // Always enable the telemetry API so CLI commands (e.g. aspire otel) can query the dashboard, // unless the user has explicitly configured either the enabled or disabled setting. diff --git a/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs index 2c3d133d1df..3d483fabdd4 100644 --- a/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs @@ -123,6 +123,18 @@ public static string AllowAnonymousOptionDescription { } } + public static string ApplicationNameOptionDescription { + get { + return ResourceManager.GetString("ApplicationNameOptionDescription", resourceCulture); + } + } + + public static string PersistenceModeOptionDescription { + get { + return ResourceManager.GetString("PersistenceModeOptionDescription", resourceCulture); + } + } + public static string ConfigFilePathOptionDescription { get { return ResourceManager.GetString("ConfigFilePathOptionDescription", resourceCulture); diff --git a/src/Aspire.Cli/Resources/DashboardCommandStrings.resx b/src/Aspire.Cli/Resources/DashboardCommandStrings.resx index 8980e3db482..ace8df4e4ec 100644 --- a/src/Aspire.Cli/Resources/DashboardCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DashboardCommandStrings.resx @@ -156,6 +156,12 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + + + The dashboard data persistence mode: None, Run, or Resume + The path for a JSON configuration file diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf index a0bf40721eb..dfc8059f0b8 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + The application name displayed in the dashboard + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -102,6 +107,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf index be5c1dfe154..7cfb482be59 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + The application name displayed in the dashboard + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -102,6 +107,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf index 29f803eaede..90f4f530004 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + The application name displayed in the dashboard + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -102,6 +107,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf index 7f284cdd6ae..e2b7bf097a7 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + The application name displayed in the dashboard + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -102,6 +107,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf index c347a854d30..26ed2021acc 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + The application name displayed in the dashboard + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -102,6 +107,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf index cbefc65051b..1539fb641d0 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + The application name displayed in the dashboard + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -102,6 +107,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf index 8d94679f91d..39e1e28b1e4 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + The application name displayed in the dashboard + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -102,6 +107,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf index 89adb78c19a..5dc475e4fe6 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + The application name displayed in the dashboard + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -102,6 +107,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf index 3f0c68537df..1a17385f100 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + The application name displayed in the dashboard + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -102,6 +107,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf index bd962f3aae3..69d8b1cba2a 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + The application name displayed in the dashboard + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -102,6 +107,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf index 9ba06488e3a..1add94d52ee 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + The application name displayed in the dashboard + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -102,6 +107,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf index a90ec95d60f..946d89d9fe4 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + The application name displayed in the dashboard + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -102,6 +107,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf index abad5d743de..35d983acdcf 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf @@ -7,6 +7,11 @@ Allow anonymous access to the dashboard + + The application name displayed in the dashboard + The application name displayed in the dashboard + + The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. The 'aspire dashboard' command requires the CLI bundle, but no bundle layout was found. The bundle provides the dashboard binary and supporting files. Run 'aspire setup --force' to extract the bundle, or reinstall the Aspire CLI. @@ -102,6 +107,11 @@ The OTLP/HTTP endpoint. This endpoint hosts an OTLP service and receives telemetry using Protobuf over HTTP + + The dashboard data persistence mode: None, Run, or Resume + The dashboard data persistence mode: None, Run, or Resume + + Start the Aspire dashboard (Preview) Start the Aspire dashboard (Preview) diff --git a/src/Aspire.Cli/Utils/CliPathHelper.cs b/src/Aspire.Cli/Utils/CliPathHelper.cs index 32001109622..5ed66775e08 100644 --- a/src/Aspire.Cli/Utils/CliPathHelper.cs +++ b/src/Aspire.Cli/Utils/CliPathHelper.cs @@ -5,13 +5,14 @@ using System.Text; using Aspire.Cli.Acquisition; using Aspire.Hosting.Backchannel; +using Aspire.Shared; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Utils; internal static class CliPathHelper { - internal const string AspireHomeEnvironmentVariable = "ASPIRE_HOME"; + internal const string AspireHomeEnvironmentVariable = AspireHomeDirectory.EnvironmentVariable; /// /// Name of the directory under ASPIRE_HOME that holds NuGet package caches keyed by @@ -49,15 +50,11 @@ internal static string GetAspireHomeDirectory(string? processPath = null, ILogge } internal static string GetDefaultAspireHomeDirectory() - => GetDefaultAspireHomeDirectory( - Environment.GetEnvironmentVariable(AspireHomeEnvironmentVariable), - GetUserProfileDirectory()); + => AspireHomeDirectory.GetDefault(); internal static string GetDefaultAspireHomeDirectory(string? configuredAspireHome, string userProfileDirectory) { - return string.IsNullOrWhiteSpace(configuredAspireHome) - ? Path.Combine(userProfileDirectory, ".aspire") - : configuredAspireHome; + return AspireHomeDirectory.GetDefault(configuredAspireHome, userProfileDirectory); } /// @@ -151,9 +148,6 @@ internal static string GetStagingNuGetPackagesDirectory(DirectoryInfo aspireHome return Path.GetDirectoryName(dogfoodDir); } - internal static string GetUserProfileDirectory() - => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - /// /// Normalizes the casing of a filesystem so that paths which differ only /// by case collapse to a single value when used as a comparison or hash key. diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index cf07f509766..63d555a69f7 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -272,6 +272,7 @@ + diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 075171a5f8f..8931910ba6b 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -95,6 +95,10 @@ protected override async Task OnInitializedAsync() if (!_runSelectionChanged && !string.IsNullOrEmpty(selectedRunId)) { RunSelection.SelectRun(selectedRunId); + if (RunSelection.SelectedRun.IsCurrent) + { + await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, string.Empty); + } } } @@ -257,7 +261,8 @@ private async Task SwitchDashboardRunAsync(string? runId) await InvokeAsync(StateHasChanged); } - await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, runId ?? string.Empty); + var persistedRunId = RunSelection.SelectedRun is { IsCurrent: false } actualSelectedRun ? actualSelectedRun.RunId : string.Empty; + await SessionStorage.SetAsync(BrowserStorageKeys.SelectedDashboardRunId, persistedRunId); } private string? GetVisibleReturnFocusElementId(string? returnFocusElementId, string desktopButtonId) diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index 1af0a4de36b..bfa7151fdb8 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -34,8 +34,8 @@ public sealed class DashboardDataOptions public enum DashboardPersistenceMode { None, - Runs, - Append + Run, + Resume } // Don't set values after validating/parsing options. diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 1cc9b2b3db8..d4c6b554d05 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -154,6 +154,7 @@ public DashboardWebApplication( // Silently ignore and allow anti-forgery to automatically create a new valid cookie. builder.Logging.AddFilter("Microsoft.AspNetCore.Antiforgery.DefaultAntiforgery", LogLevel.None); builder.Logging.AddFilter("Microsoft.AspNetCore.Server.Kestrel", LogLevel.Error); + builder.Logging.AddFilter("Microsoft.Extensions.Localization", LogLevel.Information); builder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.None); #else @@ -167,6 +168,7 @@ public DashboardWebApplication( builder.Logging.AddFilter("Aspire.Dashboard.Authentication", LogLevel.Information); builder.Logging.AddFilter("Aspire.Dashboard.Otlp", LogLevel.Information); builder.Logging.AddFilter("Microsoft", LogLevel.Information); + builder.Logging.AddFilter("Microsoft.Extensions.Localization", LogLevel.Information); builder.Logging.AddFilter("Microsoft.AspNetCore.Cors", LogLevel.Warning); builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); builder.Logging.AddFilter("Microsoft.AspNetCore.Routing.EndpointMiddleware", LogLevel.Warning); diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs index bd0d64bd28d..54f03f21550 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs @@ -74,8 +74,13 @@ internal void SelectRun(string? runId) if (!selectedRun.IsCurrent) { - var historicalRunLease = _runStore.TryAcquireRunLease(selectedRun) - ?? throw new InvalidOperationException($"Dashboard run '{selectedRun.RunId}' is no longer available."); + var historicalRunLease = _runStore.TryAcquireRunLease(selectedRun); + if (historicalRunLease is null) + { + SelectCurrentRun(runs.Single(run => run.IsCurrent)); + return; + } + var historicalDatabase = new DashboardSqliteDatabase(selectedRun.DatabasePath, readOnly: true); try { @@ -101,9 +106,7 @@ internal void SelectRun(string? runId) } else { - TelemetryRepository = _currentTelemetryRepository; - ResourceRepository = _currentResourceRepository; - IsReadOnly = false; + SelectCurrentRun(selectedRun); } SelectedRun = selectedRun; @@ -123,4 +126,12 @@ private void DisposeHistoricalRepositories() _historicalRunLease?.Dispose(); _historicalRunLease = null; } + + private void SelectCurrentRun(DashboardRunDescriptor currentRun) + { + TelemetryRepository = _currentTelemetryRepository; + ResourceRepository = _currentResourceRepository; + IsReadOnly = false; + SelectedRun = currentRun; + } } diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index a89690c2b86..5f93e764321 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -5,6 +5,7 @@ using System.Text; using System.Text.Json; using Aspire.Dashboard.Configuration; +using Aspire.Shared; using Microsoft.Extensions.Options; namespace Aspire.Dashboard.ServiceClient; @@ -61,28 +62,43 @@ internal DashboardRunStore( case DashboardPersistenceMode.None: _temporaryDirectory = Directory.CreateTempSubdirectory(TemporaryDirectoryPrefix).FullName; RunDirectory = _temporaryDirectory; + DatabasePath = Path.Combine(RunDirectory, DatabaseFileName); _runLock = OpenRunLock(RunDirectory); DeleteAbandonedTemporaryDirectories(deleteRunDirectory); - DatabasePath = Path.Combine(RunDirectory, DatabaseFileName); break; - case DashboardPersistenceMode.Runs: + case DashboardPersistenceMode.Run: var applicationDirectory = GetApplicationDirectory(options.Value.Data.Directory, applicationName); _runsDirectory = Path.Combine(applicationDirectory, "runs"); RunDirectory = Path.Combine(_runsDirectory, runId); + DatabasePath = Path.Combine(RunDirectory, DatabaseFileName); Directory.CreateDirectory(RunDirectory); _runLock = OpenRunLock(RunDirectory); - DatabasePath = Path.Combine(RunDirectory, DatabaseFileName); _metadataPath = Path.Combine(RunDirectory, "run.json"); break; - case DashboardPersistenceMode.Append: + case DashboardPersistenceMode.Resume: + // Resume mode uses a stable directory derived from the application name, so two dashboard instances + // with the same name can target the same database. Consider acquiring an exclusive process-lifetime + // lock here and failing the second instance at startup when it cannot acquire the lock. RunDirectory = GetApplicationDirectory(options.Value.Data.Directory, applicationName); - Directory.CreateDirectory(RunDirectory); DatabasePath = Path.Combine(RunDirectory, DatabaseFileName); - if (File.Exists(DatabasePath) && !DashboardSqliteDatabase.IsCompatible(DatabasePath)) + Directory.CreateDirectory(RunDirectory); + if (!File.Exists(DatabasePath)) + { + _logger.LogDebug("Creating dashboard database at '{DatabasePath}'.", DatabasePath); + } + else if (!DashboardSqliteDatabase.IsCompatible(DatabasePath)) { + _logger.LogInformation( + "Existing dashboard database at '{DatabasePath}' is incompatible with schema version {SchemaVersion} and will be replaced.", + DatabasePath, + SchemaVersion); ClearDatabasePool(); DeleteDatabaseFiles(DatabasePath); } + else + { + _logger.LogDebug("Resuming dashboard database at '{DatabasePath}'.", DatabasePath); + } break; default: throw new InvalidOperationException($"Unexpected dashboard persistence mode: {PersistenceMode}"); @@ -103,6 +119,12 @@ internal DashboardRunStore( } _runs = new(LoadRuns); + + _logger.LogDebug( + "Dashboard run store initialized with persistence mode '{PersistenceMode}'. Run directory: '{RunDirectory}'. Database path: '{DatabasePath}'.", + PersistenceMode, + RunDirectory, + DatabasePath); } private void DeleteAbandonedTemporaryDirectories(Action deleteRunDirectory) @@ -140,7 +162,7 @@ private void DeleteAbandonedTemporaryDirectories(Action deleteRunDirecto public string DatabasePath { get; } public string RunId => _metadata.RunId; public DashboardPersistenceMode PersistenceMode { get; } - public bool SupportsRunSelection => PersistenceMode == DashboardPersistenceMode.Runs; + public bool SupportsRunSelection => PersistenceMode == DashboardPersistenceMode.Run; public IReadOnlyList GetRuns() => _runs.Value; @@ -157,41 +179,46 @@ private IReadOnlyList LoadRuns() CreateDescriptor(_metadata, RunDirectory, isCurrent: true) }; - if (!SupportsRunSelection || !Directory.Exists(_runsDirectory)) - { - return runs.ToArray(); - } - - foreach (var directory in Directory.EnumerateDirectories(_runsDirectory)) + if (SupportsRunSelection && Directory.Exists(_runsDirectory)) { - if (string.Equals(directory, RunDirectory, StringComparison.OrdinalIgnoreCase)) + foreach (var directory in Directory.EnumerateDirectories(_runsDirectory)) { - continue; - } + if (string.Equals(directory, RunDirectory, StringComparison.OrdinalIgnoreCase)) + { + continue; + } - // Filter out in-progress runs that are owned by other Dashboard instances. - using var runLock = TryOpenRunLock(directory); - if (runLock is null) - { - continue; - } + // Filter out in-progress runs that are owned by other Dashboard instances. + using var runLock = TryOpenRunLock(directory); + if (runLock is null) + { + continue; + } - var metadataPath = Path.Combine(directory, "run.json"); - try - { - var metadata = JsonSerializer.Deserialize(File.ReadAllText(metadataPath)); - if (metadata is { SchemaVersion: SchemaVersion }) + var metadataPath = Path.Combine(directory, "run.json"); + try { - runs.Add(CreateDescriptor(metadata, directory, isCurrent: false)); + var metadata = JsonSerializer.Deserialize(File.ReadAllText(metadataPath)); + if (metadata is { SchemaVersion: SchemaVersion }) + { + runs.Add(CreateDescriptor(metadata, directory, isCurrent: false)); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + // Ignore incomplete or unreadable run metadata. A later dashboard process may still be writing it. } - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) - { - // Ignore incomplete or unreadable run metadata. A later dashboard process may still be writing it. } } - return runs.OrderByDescending(run => run.IsCurrent).ThenByDescending(run => run.StartedAtUtc).ToArray(); + var orderedRuns = runs.OrderByDescending(run => run.IsCurrent).ThenByDescending(run => run.StartedAtUtc).ToArray(); + _logger.LogDebug( + "Dashboard run discovery completed in directory '{RunsDirectory}'. Run count: {RunCount}. Run IDs: {RunIds}.", + _runsDirectory ?? RunDirectory, + orderedRuns.Length, + string.Join(", ", orderedRuns.Select(run => run.RunId))); + + return orderedRuns; } public void Dispose() @@ -307,11 +334,11 @@ private static DashboardRunDescriptor CreateDescriptor(DashboardRunMetadata meta isCurrent); } - private static string GetApplicationDirectory(string? dataRoot, string applicationName) + internal static string GetApplicationDirectory(string? dataRoot, string applicationName) { if (string.IsNullOrWhiteSpace(dataRoot)) { - dataRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Aspire", "Dashboard"); + dataRoot = Path.Combine(AspireHomeDirectory.GetDefault(), "dashboard"); } return Path.Combine(Path.GetFullPath(dataRoot), GetApplicationDirectoryName(applicationName)); diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 6fe593a95b9..b0803cc054b 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -607,13 +607,12 @@ internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext con SetEnvironmentVariableWithFallback( context, DashboardConfigNames.DashboardDataDirectoryName, - "Aspire:Store:Path", - transform: static aspireStorePath => Path.Combine(aspireStorePath, ".aspire", "dashboard")); + DashboardConfigNames.DashboardDataDirectoryName.ConfigKey); SetEnvironmentVariableWithFallback( context, DashboardConfigNames.DashboardPersistenceModeName, "Aspire:Dashboard:PersistenceMode", - defaultValue: "Runs"); + defaultValue: "Run"); PopulateDashboardUrls(context); diff --git a/src/Shared/AspireHomeDirectory.cs b/src/Shared/AspireHomeDirectory.cs new file mode 100644 index 00000000000..c05f55f3f43 --- /dev/null +++ b/src/Shared/AspireHomeDirectory.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Shared; + +/// +/// Resolves the default home directory used by Aspire tools. +/// +internal static class AspireHomeDirectory +{ + /// + /// The environment variable that overrides the default Aspire home directory. + /// + internal const string EnvironmentVariable = "ASPIRE_HOME"; + + /// + /// Gets the configured Aspire home directory, or the default directory in the current user's profile. + /// + internal static string GetDefault() + { + return GetDefault( + Environment.GetEnvironmentVariable(EnvironmentVariable), + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + } + + /// + /// Gets the configured Aspire home directory, or the default directory in . + /// + internal static string GetDefault(string? configuredAspireHome, string userProfileDirectory) + { + return string.IsNullOrWhiteSpace(configuredAspireHome) + ? Path.Combine(userProfileDirectory, ".aspire") + : configuredAspireHome; + } +} \ No newline at end of file diff --git a/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs index 47ee1662287..ccfca12b9f8 100644 --- a/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs @@ -65,6 +65,9 @@ public async Task DashboardRunCommand_Help_ReturnsSuccess() [InlineData("--otlp-grpc-url http://localhost:4317")] [InlineData("--otlp-http-url http://localhost:4318")] [InlineData("--allow-anonymous")] + [InlineData("--application-name TestApp")] + [InlineData("--persistence Run")] + [InlineData("--persistence Resume")] [InlineData("--config-file-path /path/to/config.json")] public void DashboardRunCommand_ParsesOptionsWithoutErrors(string args) { @@ -177,6 +180,9 @@ public async Task DashboardRunCommand_DefaultOptions_PassesDefaultArgsToProcess( [InlineData("--otlp-grpc-url http://localhost:9317", "--ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL=http://localhost:9317")] [InlineData("--otlp-http-url http://localhost:9318", "--ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL=http://localhost:9318")] [InlineData("--allow-anonymous", "--ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true")] + [InlineData("--application-name TestApp", "--ASPIRE_DASHBOARD_APPLICATION_NAME=TestApp")] + [InlineData("--persistence Run", "--ASPIRE_DASHBOARD_PERSISTENCE_MODE=Run")] + [InlineData("--persistence Resume", "--ASPIRE_DASHBOARD_PERSISTENCE_MODE=Resume")] [InlineData("--config-file-path /path/to/config.json", "--ASPIRE_DASHBOARD_CONFIG_FILE_PATH=/path/to/config.json")] public async Task DashboardRunCommand_IndividualOption_PassesCorrectArgToProcess(string cliArgs, string expectedArg) { @@ -221,6 +227,26 @@ public async Task DashboardRunCommand_WithoutAllowAnonymous_SetsBrowserTokenEnvV Assert.Equal("ApiKey", capturedEnv["DASHBOARD__API__AUTHMODE"]); } + [Fact] + public async Task DashboardRunCommand_ConfiguresDashboardDebugLogging() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + IDictionary? capturedEnv = null; + var (services, _, executionFactory) = CreateServicesWithLayout(workspace); + executionFactory.AssertionCallback = (_, env, _, _) => { capturedEnv = env; }; + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("dashboard run"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.NotNull(capturedEnv); + Assert.Equal("Debug", capturedEnv["Logging__LogLevel__Default"]); + } + [Fact] public async Task DashboardRunCommand_UnmatchedTokens_ForwardedToProcess() { @@ -258,7 +284,7 @@ public async Task DashboardRunCommand_CombinedOptions_PassesAllArgsToProcess() using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("dashboard run --frontend-url http://localhost:5000 --otlp-grpc-url http://localhost:9317 --otlp-http-url http://localhost:9318 --allow-anonymous --config-file-path /my/config.json"); + var result = command.Parse("dashboard run --frontend-url http://localhost:5000 --otlp-grpc-url http://localhost:9317 --otlp-http-url http://localhost:9318 --allow-anonymous --application-name TestApp --persistence Run --config-file-path /my/config.json"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -270,6 +296,8 @@ public async Task DashboardRunCommand_CombinedOptions_PassesAllArgsToProcess() arg => Assert.Equal("--ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL=http://localhost:9317", arg), arg => Assert.Equal("--ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL=http://localhost:9318", arg), arg => Assert.Equal("--ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true", arg), + arg => Assert.Equal("--ASPIRE_DASHBOARD_APPLICATION_NAME=TestApp", arg), + arg => Assert.Equal("--ASPIRE_DASHBOARD_PERSISTENCE_MODE=Run", arg), arg => Assert.Equal("--ASPIRE_DASHBOARD_API_ENABLED=true", arg), arg => Assert.Equal("--ASPIRE_DASHBOARD_CONFIG_FILE_PATH=/my/config.json", arg)); } diff --git a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs index 9e39ade54d0..40227175ce8 100644 --- a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs +++ b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs @@ -54,7 +54,7 @@ public void PostConfigure_MapsDashboardRunStorageEnvironmentAliases() { [DashboardConfigNames.DashboardApplicationName.EnvVarName] = "My Dashboard", [DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] = "/data/.aspire/dashboard", - [DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = "append" + [DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = "resume" }) .Build(); var options = new DashboardOptions @@ -67,7 +67,7 @@ public void PostConfigure_MapsDashboardRunStorageEnvironmentAliases() Assert.Equal("My Dashboard", options.ApplicationName); Assert.Equal("/data/.aspire/dashboard", options.Data.Directory); - Assert.Equal(DashboardPersistenceMode.Append, options.Data.PersistenceMode); + Assert.Equal(DashboardPersistenceMode.Resume, options.Data.PersistenceMode); } [Fact] @@ -86,7 +86,7 @@ public void PostConfigure_InvalidDashboardPersistenceMode_IsInvalid() Assert.False(result.Succeeded); Assert.Equal( - "Failed to parse dashboard persistence mode 'invalid'. Possible values: None, Runs, Append.", + "Failed to parse dashboard persistence mode 'invalid'. Possible values: None, Run, Resume.", result.FailureMessage); } diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index c12ee347906..3059060ad25 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -7,6 +7,8 @@ using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; using Aspire.DashboardService.Proto.V1; +using Aspire.Dashboard.Tests.Shared; +using Aspire.Shared; using Google.Protobuf.Collections; using Google.Protobuf.WellKnownTypes; using Microsoft.Data.Sqlite; @@ -39,6 +41,18 @@ public void RunDirectory_IsNestedUnderApplicationDirectoryAndRuns() Assert.Equal(expectedRunsDirectory, Directory.GetParent(runStore.RunDirectory)!.FullName); } + [Fact] + public void ApplicationDirectory_WithoutDataDirectory_UsesDashboardDirectoryInAspireHome() + { + var applicationDirectoryName = DashboardRunStore.GetApplicationDirectoryName("My Dashboard"); + var expectedDirectory = Path.Combine( + AspireHomeDirectory.GetDefault(), + "dashboard", + applicationDirectoryName); + + Assert.Equal(expectedDirectory, DashboardRunStore.GetApplicationDirectory(dataRoot: null, "My Dashboard")); + } + [Fact] public void RunId_IsUtcTimestampWithMillisecondPrecision() { @@ -60,6 +74,40 @@ public void RunMetadata_IncludesSchemaVersion() Assert.Equal(DashboardRunStore.SchemaVersion, Assert.Single(runStore.GetRuns()).SchemaVersion); } + [Fact] + public void ConstructionAndGetRuns_LogResolvedStorageAndDiscoveredRuns() + { + var testSink = new TestSink(); + using var loggerFactory = LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(LogLevel.Debug); + builder.AddProvider(new TestLoggerProvider(testSink)); + }); + using var runStore = new DashboardRunStore( + CreateOptions(), + loggerFactory.CreateLogger(), + TimeProvider.System); + + Assert.Single(runStore.GetRuns()); + + Assert.Collection( + testSink.Writes, + initializationLog => + { + Assert.Equal(LogLevel.Debug, initializationLog.LogLevel); + Assert.Equal( + $"Dashboard run store initialized with persistence mode 'Run'. Run directory: '{runStore.RunDirectory}'. Database path: '{runStore.DatabasePath}'.", + initializationLog.Message); + }, + discoveryLog => + { + Assert.Equal(LogLevel.Debug, discoveryLog.LogLevel); + Assert.Equal( + $"Dashboard run discovery completed in directory '{Directory.GetParent(runStore.RunDirectory)!.FullName}'. Run count: 1. Run IDs: {runStore.RunId}.", + discoveryLog.Message); + }); + } + [Fact] public void NoneMode_UsesTemporaryDatabaseAndDeletesItOnDispose() { @@ -139,9 +187,26 @@ public void NoneMode_DoesNotDeleteTemporaryDirectoriesWithOtherNames() } [Fact] - public void AppendMode_ReusesApplicationDatabaseWithoutRunSelection() + public void ResumeMode_LogsCreatingDatabase() + { + var testSink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(testSink, enabled: true)); + + using var runStore = new DashboardRunStore( + CreateOptions($"Create-{Guid.NewGuid():N}", DashboardPersistenceMode.Resume), + logger, + TimeProvider.System); + + var creationLog = Assert.Single( + testSink.Writes, + write => write.Message == $"Creating dashboard database at '{runStore.DatabasePath}'."); + Assert.Equal(LogLevel.Debug, creationLog.LogLevel); + } + + [Fact] + public void ResumeMode_ReusesApplicationDatabaseWithoutRunSelection() { - var options = CreateOptions("My Dashboard", DashboardPersistenceMode.Append); + var options = CreateOptions("My Dashboard", DashboardPersistenceMode.Resume); string firstDatabasePath; using (var firstRunStore = CreateRunStore(options)) @@ -150,18 +215,24 @@ public void AppendMode_ReusesApplicationDatabaseWithoutRunSelection() new DashboardSqliteDatabase(firstDatabasePath).InitializeSchema(); } - using var secondRunStore = CreateRunStore(options); + var testSink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(testSink, enabled: true)); + using var secondRunStore = new DashboardRunStore(options, logger, TimeProvider.System); Assert.Equal(firstDatabasePath, secondRunStore.DatabasePath); Assert.False(secondRunStore.SupportsRunSelection); Assert.Collection(secondRunStore.GetRuns(), run => Assert.True(run.IsCurrent)); Assert.True(DashboardSqliteDatabase.IsCompatible(secondRunStore.DatabasePath)); + var resumeLog = Assert.Single( + testSink.Writes, + write => write.Message == $"Resuming dashboard database at '{secondRunStore.DatabasePath}'."); + Assert.Equal(LogLevel.Debug, resumeLog.LogLevel); } [Fact] - public void AppendMode_DeletesIncompatibleDatabase() + public void ResumeMode_DeletesIncompatibleDatabase() { - var options = CreateOptions(persistenceMode: DashboardPersistenceMode.Append); + var options = CreateOptions(persistenceMode: DashboardPersistenceMode.Resume); string databasePath; using (var firstRunStore = CreateRunStore(options)) @@ -174,10 +245,16 @@ public void AppendMode_DeletesIncompatibleDatabase() command.ExecuteNonQuery(); } - using var secondRunStore = CreateRunStore(options); + var testSink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(testSink, enabled: true)); + using var secondRunStore = new DashboardRunStore(options, logger, TimeProvider.System); Assert.Equal(databasePath, secondRunStore.DatabasePath); Assert.False(File.Exists(databasePath)); + var incompatibleLog = Assert.Single( + testSink.Writes, + write => write.Message == $"Existing dashboard database at '{databasePath}' is incompatible with schema version {DashboardRunStore.SchemaVersion} and will be replaced."); + Assert.Equal(LogLevel.Information, incompatibleLog.LogLevel); new DashboardSqliteDatabase(databasePath).InitializeSchema(); Assert.True(DashboardSqliteDatabase.IsCompatible(databasePath)); } @@ -289,7 +366,7 @@ public void GetRuns_ExcludesRunOwnedByAnotherDashboard() } [Fact] - public void RunsMode_DeletesOldestRunWhenLimitIsExceeded() + public void RunMode_DeletesOldestRunWhenLimitIsExceeded() { var applicationDirectory = Path.Combine(_workspace.Path, DashboardRunStore.GetApplicationDirectoryName("TestApp")); var runsDirectory = Path.Combine(applicationDirectory, "runs"); @@ -313,7 +390,7 @@ public void RunsMode_DeletesOldestRunWhenLimitIsExceeded() } [Fact] - public void RunsMode_DoesNotDeleteActiveExpiredRun() + public void RunMode_DoesNotDeleteActiveExpiredRun() { var applicationDirectory = Path.Combine(_workspace.Path, DashboardRunStore.GetApplicationDirectoryName("TestApp")); var runsDirectory = Path.Combine(applicationDirectory, "runs"); @@ -392,7 +469,7 @@ public void SelectedHistoricalRun_HoldsLeaseUntilSelectionChanges() } [Fact] - public void RunsMode_DeleteExpiredRunFails_LogsWarningAndContinues() + public void RunMode_DeleteExpiredRunFails_LogsWarningAndContinues() { var applicationDirectory = Path.Combine(_workspace.Path, DashboardRunStore.GetApplicationDirectoryName("TestApp")); var runsDirectory = Path.Combine(applicationDirectory, "runs"); @@ -609,9 +686,37 @@ public void UnknownRunId_SelectsCurrentRun() Assert.Same(currentTelemetryRepository, dataSource.TelemetryRepository); } + [Fact] + public void UnavailableHistoricalRun_SelectsCurrentRun() + { + var options = CreateOptions(); + string historicalRunId; + + using (var historicalRunStore = CreateRunStore(options)) + { + historicalRunId = historicalRunStore.RunId; + } + + using var currentRunStore = CreateRunStore(options); + var runStore = new TestDashboardRunStore(currentRunStore.GetRuns(), tryAcquireRunLease: _ => null); + using var currentDatabase = new DashboardSqliteDatabase(currentRunStore.DatabasePath); + var repositoryFactory = CreateRepositoryFactory(options); + using var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); + using var currentResourceRepository = (SqliteResourceRepository)repositoryFactory.CreateResourceRepository(currentDatabase); + using var dataSource = new DashboardDataSource(runStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); + + dataSource.SelectRun(historicalRunId); + + Assert.False(dataSource.IsReadOnly); + Assert.True(dataSource.SelectedRun.IsCurrent); + Assert.Equal(currentRunStore.RunId, dataSource.SelectedRun.RunId); + Assert.Same(currentResourceRepository, dataSource.ResourceRepository); + Assert.Same(currentTelemetryRepository, dataSource.TelemetryRepository); + } + private IOptions CreateOptions( string applicationName = "TestApp", - DashboardPersistenceMode persistenceMode = DashboardPersistenceMode.Runs) + DashboardPersistenceMode persistenceMode = DashboardPersistenceMode.Run) { return Options.Create(new DashboardOptions { diff --git a/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs b/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs index fbe964011bf..10931a0a853 100644 --- a/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs +++ b/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs @@ -13,22 +13,12 @@ public static DashboardDataSource Create( IResourceRepository resourceRepository) { return new DashboardDataSource( - new TestRunStore(), + new TestDashboardRunStore(), telemetryRepository, resourceRepository, new TestRepositoryFactory(telemetryRepository, resourceRepository)); } - private sealed class TestRunStore : IDashboardRunStore - { - public bool SupportsRunSelection => false; - - public IReadOnlyList GetRuns() => - [new("current", DashboardRunStore.SchemaVersion, DateTimeOffset.UnixEpoch, null, false, "TestApp", string.Empty, IsCurrent: true)]; - - public IDisposable? TryAcquireRunLease(DashboardRunDescriptor run) => null; - } - private sealed class TestRepositoryFactory( ITelemetryRepository telemetryRepository, IResourceRepository resourceRepository) : IRepositoryFactory @@ -36,4 +26,18 @@ private sealed class TestRepositoryFactory( public ITelemetryRepository CreateTelemetryRepository(DashboardSqliteDatabase database) => telemetryRepository; public IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database) => resourceRepository; } +} + +internal sealed class TestDashboardRunStore( + IReadOnlyList? runs = null, + Func? tryAcquireRunLease = null) : IDashboardRunStore +{ + private readonly IReadOnlyList _runs = runs ?? + [new("current", DashboardRunStore.SchemaVersion, DateTimeOffset.UnixEpoch, null, false, "TestApp", string.Empty, IsCurrent: true)]; + + public bool SupportsRunSelection => _runs.Any(run => !run.IsCurrent); + + public IReadOnlyList GetRuns() => _runs; + + public IDisposable? TryAcquireRunLease(DashboardRunDescriptor run) => tryAcquireRunLease?.Invoke(run); } \ No newline at end of file diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs index 6d2a9941308..0b0f8e4f20a 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardEventHandlersTests.cs @@ -309,21 +309,20 @@ public async Task ConfigureEnvironmentVariables_HasAspireDashboardEnvVars_Copied } [Theory] - [InlineData(null, null, "Runs", false)] - [InlineData("", null, "Runs", false)] - [InlineData(" ", null, "Runs", false)] - [InlineData(null, " ", "Runs", false)] + [InlineData(null, null, "Run", false)] + [InlineData("", null, "Run", false)] + [InlineData(" ", null, "Run", false)] + [InlineData(null, " ", "Run", false)] [InlineData(null, "None", "None", false)] - [InlineData("Runs", "None", "None", false)] - [InlineData("Runs", "None", "None", true)] - public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRootApplicationNameAndPersistenceMode( + [InlineData("Run", "None", "None", false)] + [InlineData("Run", "None", "None", true)] + public async Task ConfigureEnvironmentVariables_ConfiguresDashboardApplicationNameAndPersistenceMode( string? configuredPersistenceMode, string? configuredPersistenceModeEnvironmentAlias, string expectedPersistenceMode, bool configureExplicitAliases) { using var workspace = TemporaryWorkspace.Create(testOutputHelper); - var explicitDataDirectory = Path.Combine(workspace.Path, "custom-dashboard"); var explicitApplicationName = "Explicit Dashboard"; var resourceLoggerService = new ResourceLoggerService(); var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); @@ -334,7 +333,6 @@ public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRoo ["AppHost:DashboardApplicationName"] = "My App.AppHost", ["Aspire:Dashboard:PersistenceMode"] = configuredPersistenceMode, [DashboardConfigNames.DashboardPersistenceModeName.EnvVarName] = configuredPersistenceModeEnvironmentAlias, - [DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] = configureExplicitAliases ? explicitDataDirectory : null, [DashboardConfigNames.DashboardApplicationName.EnvVarName] = configureExplicitAliases ? explicitApplicationName : null }) .Build(); @@ -355,15 +353,57 @@ public async Task ConfigureEnvironmentVariables_ConfiguresDashboardRunStorageRoo await hook.ConfigureEnvironmentVariables(new EnvironmentCallbackContext(context, environmentVariables: environmentVariables, resource: dashboardResource)); - Assert.Equal( - configureExplicitAliases ? explicitDataDirectory : Path.Combine(workspace.Path, ".aspire", "dashboard"), - environmentVariables[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName]); Assert.Equal( configureExplicitAliases ? explicitApplicationName : "My App", environmentVariables[DashboardConfigNames.DashboardApplicationName.EnvVarName]); Assert.Equal(expectedPersistenceMode, environmentVariables[DashboardConfigNames.DashboardPersistenceModeName.EnvVarName]); } + [Theory] + [InlineData(null, null, null)] + [InlineData("configured-dashboard", null, "configured-dashboard")] + [InlineData("configured-dashboard", "environment-dashboard", "environment-dashboard")] + public async Task ConfigureEnvironmentVariables_ConfiguresExplicitDashboardDataDirectoryWithoutDefault( + string? configuredDataDirectory, + string? configuredDataDirectoryEnvironmentAlias, + string? expectedDataDirectory) + { + var resourceLoggerService = new ResourceLoggerService(); + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [DashboardConfigNames.DashboardDataDirectoryName.ConfigKey] = configuredDataDirectory, + [DashboardConfigNames.DashboardDataDirectoryName.EnvVarName] = configuredDataDirectoryEnvironmentAlias + }) + .Build(); + var dashboardOptions = Options.Create(new DashboardOptions + { + DashboardPath = "test.dll", + DashboardUrl = "http://localhost:8080", + OtlpGrpcEndpointUrl = "http://localhost:4317", + }); + var hook = CreateHook(resourceLoggerService, resourceNotificationService, configuration, dashboardOptions: dashboardOptions); + var environmentVariables = new Dictionary(); + var dashboardResource = new ExecutableResource("aspire-dashboard", "dashboard.exe", "."); + var model = new DistributedApplicationModel([dashboardResource]); + var context = new DistributedApplicationExecutionContext(new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) + { + Services = new TestServiceProvider().AddService(model) + }); + + await hook.ConfigureEnvironmentVariables(new EnvironmentCallbackContext(context, environmentVariables: environmentVariables, resource: dashboardResource)); + + if (expectedDataDirectory is null) + { + Assert.False(environmentVariables.ContainsKey(DashboardConfigNames.DashboardDataDirectoryName.EnvVarName)); + } + else + { + Assert.Equal(expectedDataDirectory, environmentVariables[DashboardConfigNames.DashboardDataDirectoryName.EnvVarName]); + } + } + [Theory] [InlineData("https://localhost:17131", "localhost", 9999, "https", "localhost")] [InlineData("https://aspire-dashboard.dev.localhost:17131", "aspire-dashboard.dev.localhost", 9999, "https", "aspire-dashboard.dev.localhost")] diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs index c3bd32fc38d..10d1fee78d1 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs @@ -134,7 +134,7 @@ public async Task DashboardDoesNotAddResource_ConfiguresExistingDashboard(string e => { Assert.Equal(DashboardConfigNames.DashboardPersistenceModeName.EnvVarName, e.Key); - Assert.Equal("Runs", e.Value); + Assert.Equal("Run", e.Value); }, e => { From da8639e1854d2d698b44d74d65a660c7d028cb83 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 21 Jul 2026 09:27:53 +0800 Subject: [PATCH 50/87] Optimize dashboard trace persistence --- .../Aspire.Dashboard.Benchmarks.csproj | 1 + .../Aspire.Dashboard.Benchmarks/Program.cs | 1 + .../SqliteTraceBenchmarks.cs | 211 ++ .../TelemetryRepositoryBenchmarks.cs | 38 +- .../DashboardWebApplication.cs | 6 +- .../Storage/SqliteTelemetryRepository.Logs.cs | 77 - .../SqliteTelemetryRepository.Resources.cs | 121 ++ .../SqliteTelemetryRepository.Traces.Reads.cs | 842 ++++++++ ...SqliteTelemetryRepository.Traces.Writes.cs | 1361 +++++++++++++ .../SqliteTelemetryRepository.Traces.cs | 1804 ----------------- .../Otlp/Storage/SqliteTelemetryRepository.cs | 10 - .../ServiceClient/DashboardDataSource.cs | 54 +- .../ServiceClient/DashboardRunStore.cs | 32 +- .../ServiceClient/DashboardSqliteDatabase.cs | 34 +- .../DatabaseSchema/004.Traces.sql | 20 +- .../ServiceClient/IRepositoryFactory.cs | 6 +- .../Shared/FluentUISetupHelpers.cs | 6 +- .../Model/DashboardDataSourceTests.cs | 42 +- .../Model/SqliteResourceRepositoryTests.cs | 37 + .../Shared/TestDashboardDataSource.cs | 4 +- .../TelemetryRepositoryTests/TraceTests.cs | 463 ++++- 21 files changed, 3230 insertions(+), 1940 deletions(-) create mode 100644 benchmarks/Aspire.Dashboard.Benchmarks/SqliteTraceBenchmarks.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Resources.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs delete mode 100644 src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj b/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj index d075e13ae28..7ca5c79dab5 100644 --- a/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj +++ b/benchmarks/Aspire.Dashboard.Benchmarks/Aspire.Dashboard.Benchmarks.csproj @@ -14,6 +14,7 @@ + diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/Program.cs b/benchmarks/Aspire.Dashboard.Benchmarks/Program.cs index 858a15a4ed3..403362a5fa2 100644 --- a/benchmarks/Aspire.Dashboard.Benchmarks/Program.cs +++ b/benchmarks/Aspire.Dashboard.Benchmarks/Program.cs @@ -9,6 +9,7 @@ internal static class Program { private static void Main(string[] args) { + SQLitePCL.Batteries_V2.Init(); BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); } } diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/SqliteTraceBenchmarks.cs b/benchmarks/Aspire.Dashboard.Benchmarks/SqliteTraceBenchmarks.cs new file mode 100644 index 00000000000..bf1132795a2 --- /dev/null +++ b/benchmarks/Aspire.Dashboard.Benchmarks/SqliteTraceBenchmarks.cs @@ -0,0 +1,211 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.NoEmit; +using Google.Protobuf; +using Google.Protobuf.Collections; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using OpenTelemetry.Proto.Collector.Trace.V1; +using OpenTelemetry.Proto.Common.V1; +using OpenTelemetry.Proto.Resource.V1; +using OpenTelemetry.Proto.Trace.V1; +using OtlpProtoSpan = OpenTelemetry.Proto.Trace.V1.Span; + +namespace Aspire.Dashboard.Benchmarks; + +[MemoryDiagnoser] +[Config(typeof(Config))] +public class SqliteTraceBenchmarks +{ + private const string TraceFileEnvironmentVariable = "ASPIRE_DASHBOARD_TRACE_BENCHMARK_FILE"; + private const int GeneratedSpanCount = 10_000; + + private string _temporaryDirectory = null!; + private SqliteTelemetryRepository _repository = null!; + private RepeatedField _appendResourceSpans = null!; + private long _appendIndex; + + [GlobalSetup] + public void Setup() + { + _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-trace-benchmark-").FullName; + _repository = CreateRepository(Path.Combine(_temporaryDirectory, "dashboard.db")); + + var resourceSpans = LoadResourceSpans(); + var context = new AddContext(); + _repository.AddTraces(context, resourceSpans); + if (context.FailureCount != 0) + { + throw new InvalidOperationException($"Failed to ingest {context.FailureCount} benchmark spans."); + } + + _appendResourceSpans = CreateAppendResourceSpans(resourceSpans); + } + + [GlobalCleanup] + public void Cleanup() + { + _repository.Dispose(); + Directory.Delete(_temporaryDirectory, recursive: true); + } + + [Benchmark(Description = "SQLite: summarize large trace")] + public int GetTraceSummaries() + { + var response = _repository.GetTraceSummaries(new GetTracesRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = 100, + Filters = [] + }); + + return response.PagedResult.Items.Count; + } + + [Benchmark(Description = "SQLite: append one span to large trace")] + public int AppendSpan() + { + var appendSpan = _appendResourceSpans[0].ScopeSpans[0].Spans[0]; + appendSpan.SpanId = ByteString.CopyFrom(BitConverter.GetBytes(long.MaxValue - Interlocked.Increment(ref _appendIndex))); + var context = new AddContext(); + _repository.AddTraces(context, _appendResourceSpans); + return context.SuccessCount; + } + + private static RepeatedField LoadResourceSpans() + { + var traceFile = Environment.GetEnvironmentVariable(TraceFileEnvironmentVariable); + if (string.IsNullOrWhiteSpace(traceFile)) + { + return CreateGeneratedResourceSpans(); + } + + var request = JsonParser.Default.Parse(File.ReadAllText(traceFile)); + return request.ResourceSpans; + } + + private static RepeatedField CreateGeneratedResourceSpans() + { + var resourceSpans = new ResourceSpans + { + Resource = CreateResource("benchmark-app"), + ScopeSpans = + { + new ScopeSpans + { + Scope = new InstrumentationScope { Name = "BenchmarkScope" } + } + } + }; + var scopeSpans = resourceSpans.ScopeSpans[0]; + var traceId = ByteString.CopyFrom(Encoding.UTF8.GetBytes("benchmark-trace")); + var startTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + for (var spanIndex = 0; spanIndex < GeneratedSpanCount; spanIndex++) + { + var spanStartTime = startTime.AddTicks(spanIndex); + scopeSpans.Spans.Add(new OtlpProtoSpan + { + TraceId = traceId, + SpanId = CreateSpanId(spanIndex), + ParentSpanId = spanIndex == 0 ? ByteString.Empty : CreateSpanId(spanIndex - 1), + Name = spanIndex == 0 ? "root-span" : $"span-{spanIndex}", + Kind = OtlpProtoSpan.Types.SpanKind.Internal, + StartTimeUnixNano = DateTimeToUnixNanoseconds(spanStartTime), + EndTimeUnixNano = DateTimeToUnixNanoseconds(spanStartTime.AddMilliseconds(5)) + }); + } + + return [resourceSpans]; + } + + private static RepeatedField CreateAppendResourceSpans(RepeatedField resourceSpans) + { + var firstSpan = resourceSpans.SelectMany(resource => resource.ScopeSpans).SelectMany(scope => scope.Spans).First(); + return + [ + new ResourceSpans + { + Resource = CreateResource("append-app"), + ScopeSpans = + { + new ScopeSpans + { + Scope = new InstrumentationScope { Name = "AppendScope" }, + Spans = + { + new OtlpProtoSpan + { + TraceId = firstSpan.TraceId, + SpanId = ByteString.CopyFrom(Encoding.UTF8.GetBytes("append-span-0001")), + ParentSpanId = firstSpan.SpanId, + Name = "appended-span", + Kind = OtlpProtoSpan.Types.SpanKind.Internal, + StartTimeUnixNano = firstSpan.EndTimeUnixNano + 100, + EndTimeUnixNano = firstSpan.EndTimeUnixNano + 200 + } + } + } + } + } + ]; + } + + private static SqliteTelemetryRepository CreateRepository(string databasePath) + { + return new SqliteTelemetryRepository( + databasePath, + NullLoggerFactory.Instance, + Options.Create(new DashboardOptions + { + TelemetryLimits = new TelemetryLimitOptions { MaxTraceCount = 1_000 } + }), + new PauseManager(), + []); + } + + private static Resource CreateResource(string name) + { + return new Resource + { + Attributes = + { + new KeyValue { Key = "service.name", Value = new AnyValue { StringValue = name } } + } + }; + } + + private static ByteString CreateSpanId(int spanIndex) => + ByteString.CopyFrom(Encoding.UTF8.GetBytes($"span-{spanIndex:0000}")); + + private static ulong DateTimeToUnixNanoseconds(DateTime dateTime) + { + var timeSinceEpoch = dateTime.ToUniversalTime() - DateTime.UnixEpoch; + return (ulong)timeSinceEpoch.Ticks * 100; + } + + private sealed class Config : ManualConfig + { + public Config() + { + AddJob(Job.Default + .WithToolchain(InProcessNoEmitToolchain.Instance) + .WithWarmupCount(1) + .WithIterationCount(3) + .WithInvocationCount(1) + .WithUnrollFactor(1)); + + AddDiagnoser(MemoryDiagnoser.Default); + } + } +} \ No newline at end of file diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryBenchmarks.cs b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryBenchmarks.cs index 74995d7d651..deb70b880c5 100644 --- a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryBenchmarks.cs +++ b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryBenchmarks.cs @@ -63,13 +63,15 @@ public class TelemetryRepositoryBenchmarks ]; private RepeatedField _resourceSpans = []; - private TelemetryRepository _queryRepository = null!; + private string _temporaryDirectory = null!; + private SqliteTelemetryRepository _queryRepository = null!; [GlobalSetup] public void Setup() { _resourceSpans = CreateResourceSpans(TraceCount, SpansPerTrace); - _queryRepository = CreateRepository(); + _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-telemetry-benchmark-").FullName; + _queryRepository = CreateRepository(Path.Combine(_temporaryDirectory, "query.db")); _queryRepository.AddTraces(new AddContext(), _resourceSpans); } @@ -77,16 +79,23 @@ public void Setup() public void Cleanup() { _queryRepository.Dispose(); + Directory.Delete(_temporaryDirectory, recursive: true); } [Benchmark(Description = "TelemetryRepository: add 10k spans")] public int AddTracesLargeBatch() { - using var repository = CreateRepository(); - var context = new AddContext(); - repository.AddTraces(context, _resourceSpans); + var temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-telemetry-add-benchmark-"); + int successCount; + using (var repository = CreateRepository(Path.Combine(temporaryDirectory.FullName, "add.db"))) + { + var context = new AddContext(); + repository.AddTraces(context, _resourceSpans); + successCount = context.SuccessCount; + } + Directory.Delete(temporaryDirectory.FullName, recursive: true); - return context.SuccessCount; + return successCount; } [Benchmark(Description = "TelemetryRepository: query no filters")] @@ -94,8 +103,7 @@ public int GetTracesNoFilters() { var result = _queryRepository.GetTraces(new GetTracesRequest { - ResourceKey = null, - FilterText = string.Empty, + ResourceKeys = [], Filters = [], StartIndex = 0, Count = 100 @@ -109,8 +117,7 @@ public int GetTracesDurationFilter() { var result = _queryRepository.GetTraces(new GetTracesRequest { - ResourceKey = null, - FilterText = string.Empty, + ResourceKeys = [], Filters = _durationFilters, StartIndex = 0, Count = 100 @@ -124,8 +131,7 @@ public int GetTracesNoMatchDurationFilter() { var result = _queryRepository.GetTraces(new GetTracesRequest { - ResourceKey = null, - FilterText = string.Empty, + ResourceKeys = [], Filters = _noMatchDurationFilters, StartIndex = 0, Count = 100 @@ -139,8 +145,7 @@ public int GetTracesNoMatchAttributeFilter() { var result = _queryRepository.GetTraces(new GetTracesRequest { - ResourceKey = null, - FilterText = string.Empty, + ResourceKeys = [], Filters = _noMatchAttributeFilters, StartIndex = 0, Count = 100 @@ -149,9 +154,10 @@ public int GetTracesNoMatchAttributeFilter() return result.PagedResult.Items.Count; } - private static TelemetryRepository CreateRepository() + private static SqliteTelemetryRepository CreateRepository(string databasePath) { - return new TelemetryRepository( + return new SqliteTelemetryRepository( + databasePath, NullLoggerFactory.Instance, Options.Create(new DashboardOptions { diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index d4c6b554d05..011ff165e07 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -283,11 +283,7 @@ public DashboardWebApplication( // Data from the server. builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); - builder.Services.AddScoped(services => new DashboardDataSource( - services.GetRequiredService(), - services.GetRequiredService(), - services.GetRequiredService(), - services.GetRequiredService())); + builder.Services.AddScoped(); builder.Services.AddScoped(services => services.GetRequiredService()); builder.Services.AddScoped(); diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs index 995fe4ac318..1987d77421a 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs @@ -764,35 +764,6 @@ private void ClearSelectedLogsFromDatabase(Dictionary GetTelemetryResources(bool includeUninstrumentedPeers, string? name) - { - EnsureCachePopulated(); - lock (_cacheLock) - { - return _cachedResourcesByKey.Values - .Select(resource => resource.Resource) - .Where(resource => includeUninstrumentedPeers || !resource.UninstrumentedPeer) - .Where(resource => name is null || string.Equals(resource.ResourceName, name, StringComparisons.ResourceName)) - .OrderBy(resource => resource.ResourceKey) - .ToList(); - } - } - - private sealed class ResourceViewPropertiesComparer : IEqualityComparer[]> - { - public static readonly ResourceViewPropertiesComparer Instance = new(); - - public bool Equals(KeyValuePair[]? left, KeyValuePair[]? right) => - ReferenceEquals(left, right) || left is not null && right is not null && left.SequenceEqual(right); - - public int GetHashCode(KeyValuePair[] properties) - { - var hash = new HashCode(); - foreach (var property in properties) - { - hash.Add(property); - } - return hash.ToHashCode(); - } - } - private sealed record LogQuery(string FromAndWhere, DynamicParameters Parameters); private sealed record PendingLog(long ResourceId, long ResourceViewId, long ScopeId, OtlpLogEntry Log); @@ -947,10 +876,4 @@ private sealed class FieldValueRecord public required int ValueCount { get; init; } } - private class TelemetryResourceRecord - { - public required string ResourceName { get; init; } - public string? InstanceId { get; init; } - } - } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Resources.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Resources.cs new file mode 100644 index 00000000000..32a7ae27d67 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Resources.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data; +using System.Text; +using Aspire.Dashboard.Otlp.Model; +using Dapper; +using Microsoft.Data.Sqlite; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed partial class SqliteTelemetryRepository +{ + public List GetResources(bool includeUninstrumentedPeers = false) => GetTelemetryResources(includeUninstrumentedPeers, name: null); + + public List GetResourcesByName(string name, bool includeUninstrumentedPeers = false) => GetTelemetryResources(includeUninstrumentedPeers, name); + + public OtlpResource? GetResourceByCompositeName(string compositeName) => GetResources(includeUninstrumentedPeers: true).SingleOrDefault(resource => resource.ResourceKey.EqualsCompositeName(compositeName)); + + public OtlpResource? GetResource(ResourceKey key) => GetResources(includeUninstrumentedPeers: true).SingleOrDefault(resource => resource.ResourceKey == key); + + public List GetResources(ResourceKey key, bool includeUninstrumentedPeers = false) + { + return key.InstanceId is null + ? GetResourcesByName(key.Name, includeUninstrumentedPeers) + : GetResources(includeUninstrumentedPeers).Where(resource => resource.ResourceKey == key).ToList(); + } + + private void DeleteTelemetryResourceFromDatabase(ResourceKey resourceKey) + { + lock (_writeLock) + { + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + var sql = new StringBuilder(""" + DELETE FROM telemetry_resources + WHERE resource_name = @ResourceName COLLATE NOCASE + """); + var parameters = new DynamicParameters(); + parameters.Add("ResourceName", resourceKey.Name); + if (resourceKey.InstanceId is not null) + { + sql.Append(" AND instance_id = @InstanceId COLLATE NOCASE"); + parameters.Add("InstanceId", resourceKey.InstanceId); + } + sql.Append(';'); + // A trace can contain spans from multiple resources. Capture affected trace IDs before + // resource deletion cascades its spans, then delete each complete trace to avoid retaining partial trace data. + var affectedTraceIds = connection.Query($""" + SELECT DISTINCT spans.trace_id + FROM telemetry_spans spans + JOIN telemetry_resources resources ON resources.resource_id = spans.resource_id + WHERE resources.resource_name = @ResourceName COLLATE NOCASE + {(resourceKey.InstanceId is null ? string.Empty : "AND resources.instance_id = @InstanceId COLLATE NOCASE")}; + """, parameters, transaction).AsList(); + connection.Execute(sql.ToString(), parameters, transaction); + foreach (var traceBatch in affectedTraceIds.Chunk(MaxTraceBatchSize)) + { + connection.Execute( + "DELETE FROM telemetry_traces WHERE trace_id IN @TraceIds;", + new { TraceIds = traceBatch }, + transaction); + } + connection.Execute(""" + UPDATE telemetry_resources + SET has_traces = EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_id = telemetry_resources.resource_id); + """, transaction: transaction); + DeleteOrphanedScopes(connection, transaction); + transaction.Commit(); + ClearIngestionCaches(); + } + } + + private static void DeleteOrphanedScopes(SqliteConnection connection, IDbTransaction transaction) + { + connection.Execute(""" + DELETE FROM telemetry_scopes + WHERE NOT EXISTS (SELECT 1 FROM telemetry_logs WHERE telemetry_logs.scope_id = telemetry_scopes.scope_id) + AND NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.scope_id = telemetry_scopes.scope_id) + AND NOT EXISTS (SELECT 1 FROM telemetry_metric_instruments WHERE telemetry_metric_instruments.scope_id = telemetry_scopes.scope_id); + """, transaction: transaction); + } + + private List GetTelemetryResources(bool includeUninstrumentedPeers, string? name) + { + EnsureCachePopulated(); + lock (_cacheLock) + { + return _cachedResourcesByKey.Values + .Select(resource => resource.Resource) + .Where(resource => includeUninstrumentedPeers || !resource.UninstrumentedPeer) + .Where(resource => name is null || string.Equals(resource.ResourceName, name, StringComparisons.ResourceName)) + .OrderBy(resource => resource.ResourceKey) + .ToList(); + } + } + + private sealed class ResourceViewPropertiesComparer : IEqualityComparer[]> + { + public static readonly ResourceViewPropertiesComparer Instance = new(); + + public bool Equals(KeyValuePair[]? left, KeyValuePair[]? right) => + ReferenceEquals(left, right) || left is not null && right is not null && left.SequenceEqual(right); + + public int GetHashCode(KeyValuePair[] properties) + { + var hash = new HashCode(); + foreach (var property in properties) + { + hash.Add(property); + } + return hash.ToHashCode(); + } + } + + private class TelemetryResourceRecord + { + public required string ResourceName { get; init; } + public string? InstanceId { get; init; } + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs new file mode 100644 index 00000000000..36252cbd8f9 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs @@ -0,0 +1,842 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data; +using System.Globalization; +using System.Text; +using Aspire.Dashboard.Model.Otlp; +using Aspire.Dashboard.Otlp.Model; +using Dapper; +using Microsoft.Data.Sqlite; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed partial class SqliteTelemetryRepository +{ + private GetTracesResponse GetTracesFromDatabase(GetTracesRequest context) + { + using var connection = _database.OpenConnection(); + var query = BuildTraceQuery(context); + var aggregate = connection.QuerySingle($""" + SELECT COUNT(*) AS TotalItemCount, COALESCE(MAX(t.duration_ticks), 0) AS MaxDurationTicks + {query.FromAndWhere}; + """, query.Parameters); + query.Parameters.Add("StartIndex", Math.Max(context.StartIndex, 0)); + query.Parameters.Add("Count", Math.Max(context.Count, 0)); + var records = connection.Query($""" + SELECT + t.trace_id AS TraceId, + t.last_updated_timestamp_ticks AS LastUpdatedTimestampTicks + {query.FromAndWhere} + ORDER BY t.first_span_timestamp_ticks, t.trace_id + LIMIT @Count OFFSET @StartIndex; + """, query.Parameters).AsList(); + var traces = records.Select(record => MaterializeTrace(connection, record.TraceId)!).ToList(); + return new GetTracesResponse + { + PagedResult = new PagedResult + { + Items = traces, + TotalItemCount = aggregate.TotalItemCount, + IsFull = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_traces;") >= _otlpContext.Options.MaxTraceCount + }, + MaxDuration = TimeSpan.FromTicks(aggregate.MaxDurationTicks) + }; + } + + private GetTraceSummariesResponse GetTraceSummariesFromDatabase(GetTracesRequest context) + { + using var connection = _database.OpenConnection(); + var query = BuildTraceQuery(context); + query.Parameters.Add("StartIndex", Math.Max(context.StartIndex, 0)); + query.Parameters.Add("Count", Math.Max(context.Count, 0)); + query.Parameters.Add("MaxTraceCount", _otlpContext.Options.MaxTraceCount); + + // Build the page and its pre-aggregated resource groups in one query. + var records = connection.Query($""" + WITH + filtered_traces AS ( + SELECT t.* + {query.FromAndWhere} + ), + trace_aggregate AS ( + SELECT COUNT(*) AS TotalItemCount, COALESCE(MAX(duration_ticks), 0) AS MaxDurationTicks + FROM filtered_traces + ), + paged_traces AS ( + SELECT * + FROM filtered_traces + ORDER BY first_span_timestamp_ticks, trace_id + LIMIT @Count OFFSET @StartIndex + ), + resource_summaries AS ( + SELECT + tr.trace_id, + r.resource_name, + r.instance_id, + r.uninstrumented_peer, + tr.resource_order_ticks, + tr.total_spans, + tr.errored_spans + FROM telemetry_trace_resources tr + JOIN paged_traces pt ON pt.trace_id = tr.trace_id + JOIN telemetry_resources r ON r.resource_id = tr.resource_id + ), + trace_summaries AS ( + SELECT + pt.trace_id, + pt.full_name, + pt.first_span_timestamp_ticks, + pt.duration_ticks, + r.resource_name AS root_resource_name, + r.instance_id AS root_instance_id, + r.uninstrumented_peer AS root_uninstrumented_peer, + pt.has_error, + pt.has_gen_ai, + pt.first_span_timestamp_ticks AS trace_order_ticks + FROM paged_traces pt + JOIN telemetry_spans s ON s.trace_id = pt.trace_id AND s.span_id = pt.primary_span_id + JOIN telemetry_resources r ON r.resource_id = s.resource_id + ) + SELECT + a.TotalItemCount, + a.MaxDurationTicks, + (SELECT COUNT(*) FROM telemetry_traces) >= @MaxTraceCount AS IsFull, + ts.trace_id AS TraceId, + ts.full_name AS FullName, + ts.first_span_timestamp_ticks AS StartTimeTicks, + ts.duration_ticks AS DurationTicks, + ts.root_resource_name AS RootResourceName, + ts.root_instance_id AS RootInstanceId, + ts.root_uninstrumented_peer AS RootUninstrumentedPeer, + ts.has_error AS HasError, + ts.has_gen_ai AS HasGenAI, + rs.resource_name AS ResourceName, + rs.instance_id AS InstanceId, + rs.uninstrumented_peer AS UninstrumentedPeer, + rs.total_spans AS TotalSpans, + rs.errored_spans AS ErroredSpans + FROM trace_aggregate a + LEFT JOIN trace_summaries ts ON 1 = 1 + LEFT JOIN resource_summaries rs ON rs.trace_id = ts.trace_id + ORDER BY ts.trace_order_ticks, ts.trace_id, rs.resource_order_ticks, rs.resource_name, rs.instance_id; + """, query.Parameters).AsList(); + + var firstRecord = records[0]; + var summaries = records + .Where(record => record.TraceId is not null) + .GroupBy(record => record.TraceId!, StringComparer.Ordinal) + .Select(group => + { + var trace = group.First(); + return new TraceSummary + { + TraceId = trace.TraceId!, + FullName = trace.FullName!, + StartTime = new DateTime(trace.StartTimeTicks!.Value, DateTimeKind.Utc), + Duration = TimeSpan.FromTicks(trace.DurationTicks!.Value), + RootResource = CreateSummaryResource(trace.RootResourceName!, trace.RootInstanceId, trace.RootUninstrumentedPeer!.Value), + Resources = group.Select(resource => new TraceResourceSummary + { + Resource = CreateSummaryResource(resource.ResourceName!, resource.InstanceId, resource.UninstrumentedPeer!.Value), + TotalSpans = resource.TotalSpans!.Value, + ErroredSpans = resource.ErroredSpans!.Value + }).ToList(), + HasError = trace.HasError!.Value, + HasGenAI = trace.HasGenAI!.Value + }; + }).ToList(); + + return new GetTraceSummariesResponse + { + PagedResult = new PagedResult + { + Items = summaries, + TotalItemCount = firstRecord.TotalItemCount, + IsFull = firstRecord.IsFull + }, + MaxDuration = TimeSpan.FromTicks(firstRecord.MaxDurationTicks) + }; + + OtlpResource CreateSummaryResource(string resourceName, string? instanceId, bool uninstrumentedPeer) => + new(resourceName, instanceId, uninstrumentedPeer, _otlpContext); + } + + private static TraceQuery BuildTraceQuery(GetTracesRequest context) + { + var sql = new StringBuilder("FROM telemetry_traces t WHERE 1 = 1"); + var parameters = new DynamicParameters(); + if (context.ResourceKeys.Count > 0) + { + var resourcePredicates = new List(context.ResourceKeys.Count); + for (var index = 0; index < context.ResourceKeys.Count; index++) + { + var key = context.ResourceKeys[index]; + parameters.Add($"ResourceName{index}", key.Name); + var resourcePredicate = $"r.resource_name = @ResourceName{index} COLLATE NOCASE"; + if (key.InstanceId is not null) + { + parameters.Add($"InstanceId{index}", key.InstanceId); + resourcePredicate += $" AND r.instance_id = @InstanceId{index} COLLATE NOCASE"; + } + resourcePredicates.Add($"({resourcePredicate})"); + } + sql.Append(" AND EXISTS (SELECT 1 FROM telemetry_trace_resources tr JOIN telemetry_resources r ON r.resource_id = tr.resource_id WHERE tr.trace_id = t.trace_id AND ("); + sql.AppendJoin(" OR ", resourcePredicates); + sql.Append("))"); + } + if (!string.IsNullOrWhiteSpace(context.TraceNameFilterText)) + { + sql.Append(" AND t.full_name LIKE @TraceNameFilterText ESCAPE '!'"); + parameters.Add("TraceNameFilterText", CreateContainsLikePattern(context.TraceNameFilterText)); + } + + var positivePredicates = new List(); + var filterIndex = 0; + foreach (var filter in context.Filters.Where(filter => filter.Enabled)) + { + if (filter is not FieldTelemetryFilter fieldFilter) + { + continue; + } + + if (fieldFilter.Field == KnownTraceFields.DurationField) + { + sql.Append(" AND "); + sql.Append(BuildTraceDurationPredicate(fieldFilter, parameters, filterIndex++)); + continue; + } + + if (fieldFilter.Condition is FilterCondition.NotEqual or FilterCondition.NotContains) + { + sql.Append(" AND NOT EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id WHERE s.trace_id = t.trace_id AND "); + sql.Append(BuildSpanFieldPredicate(fieldFilter, parameters, filterIndex++, invertNegative: true)); + sql.Append(')'); + } + else + { + positivePredicates.Add(BuildSpanFieldPredicate(fieldFilter, parameters, filterIndex++, invertNegative: false)); + } + } + if (positivePredicates.Count > 0) + { + sql.Append(" AND EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id WHERE s.trace_id = t.trace_id AND "); + sql.AppendJoin(" AND ", positivePredicates); + sql.Append(')'); + } + + if (context.TextFragments is { Length: > 0 }) + { + var fullNamePredicates = new List(context.TextFragments.Length); + var spanPredicates = new List(context.TextFragments.Length); + for (var index = 0; index < context.TextFragments.Length; index++) + { + var parameterName = $"TextFragment{index}"; + parameters.Add(parameterName, CreateContainsLikePattern(context.TextFragments[index])); + fullNamePredicates.Add($"t.full_name LIKE @{parameterName} ESCAPE '!'"); + spanPredicates.Add($""" + ( + s.name LIKE @{parameterName} ESCAPE '!' OR + s.span_id LIKE @{parameterName} ESCAPE '!' OR + s.trace_id LIKE @{parameterName} ESCAPE '!' OR + sc.scope_name LIKE @{parameterName} ESCAPE '!' OR + r.resource_name LIKE @{parameterName} ESCAPE '!' OR + CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END LIKE @{parameterName} ESCAPE '!' OR + CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END LIKE @{parameterName} ESCAPE '!' OR + COALESCE(s.status_message, '') LIKE @{parameterName} ESCAPE '!' OR + EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND (a.attribute_key LIKE @{parameterName} ESCAPE '!' OR a.attribute_value LIKE @{parameterName} ESCAPE '!')) OR + EXISTS (SELECT 1 FROM telemetry_span_events e WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id AND e.event_name LIKE @{parameterName} ESCAPE '!') + ) + """); + } + sql.Append(" AND (("); + sql.AppendJoin(" AND ", fullNamePredicates); + sql.Append(") OR EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id WHERE s.trace_id = t.trace_id AND "); + sql.AppendJoin(" AND ", spanPredicates); + sql.Append("))"); + } + return new TraceQuery(sql.ToString(), parameters); + } + + private static string BuildTraceDurationPredicate(FieldTelemetryFilter filter, DynamicParameters parameters, int filterIndex) + { + var parameterName = $"TraceDuration{filterIndex}"; + if (!double.TryParse(filter.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var milliseconds) || !double.IsFinite(milliseconds)) + { + return "0 = 1"; + } + + parameters.Add(parameterName, milliseconds); + return BuildNumericPredicate($"(CAST(t.duration_ticks AS REAL) / {TimeSpan.TicksPerMillisecond})", filter.Condition, parameterName); + } + + private static string BuildSpanFieldPredicate( + FieldTelemetryFilter filter, + DynamicParameters parameters, + int filterIndex, + bool invertNegative) + { + var parameterName = $"TraceFilter{filterIndex}"; + var condition = invertNegative + ? filter.Condition switch + { + FilterCondition.NotEqual => FilterCondition.Equals, + FilterCondition.NotContains => FilterCondition.Contains, + _ => filter.Condition + } + : filter.Condition; + parameters.Add( + parameterName, + condition is FilterCondition.Contains or FilterCondition.NotContains + ? CreateContainsLikePattern(filter.Value) + : filter.Value); + + var expression = filter.Field switch + { + KnownResourceFields.ServiceNameField => null, + KnownTraceFields.TraceIdField => "s.trace_id", + KnownTraceFields.SpanIdField => "s.span_id", + KnownTraceFields.NameField => "s.name", + KnownTraceFields.KindField => "CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END", + KnownTraceFields.StatusField => "CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END", + KnownSourceFields.NameField => "sc.scope_name", + KnownTraceFields.TimestampField => "s.start_time_ticks / 10000", + _ => null + }; + if (filter.Field == KnownTraceFields.TimestampField) + { + if (!DateTime.TryParse(filter.Value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeLocal, out var date)) + { + return "0 = 1"; + } + parameters.Add(parameterName, date.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond); + return BuildNumericPredicate(expression!, condition, parameterName); + } + if (expression is not null) + { + return BuildStringPredicate(expression, condition, parameterName); + } + if (filter.Field == KnownResourceFields.ServiceNameField) + { + var sourcePredicate = BuildStringPredicate("r.resource_name", condition, parameterName); + var peerPredicate = BuildStringPredicate("pr.resource_name", condition, parameterName); + return $"({sourcePredicate} OR (pr.resource_id IS NOT NULL AND {peerPredicate}))"; + } + + var attributePredicate = BuildStringPredicate("a.attribute_value", condition, parameterName); + parameters.Add($"TraceField{filterIndex}", filter.Field); + return $"EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND a.attribute_key = @TraceField{filterIndex} COLLATE NOCASE AND {attributePredicate})"; + } + + private GetSpansResponse GetSpansFromDatabase(GetSpansRequest context) + { + using var connection = _database.OpenConnection(); + var query = BuildSpanQuery(context); + var totalCount = connection.QuerySingle($"SELECT COUNT(*) {query.FromAndWhere};", query.Parameters); + query.Parameters.Add("StartIndex", Math.Max(context.StartIndex, 0)); + query.Parameters.Add("Count", Math.Max(context.Count, 0)); + var identities = connection.Query($""" + SELECT s.trace_id AS TraceId, s.span_id AS SpanId + {query.FromAndWhere} + ORDER BY t.first_span_timestamp_ticks, t.trace_id, s.start_time_ticks, s.span_id + LIMIT @Count OFFSET @StartIndex; + """, query.Parameters).AsList(); + var traces = identities.Select(identity => identity.TraceId).Distinct(StringComparer.Ordinal) + .ToDictionary(traceId => traceId, traceId => MaterializeTrace(connection, traceId)!, StringComparer.Ordinal); + return new GetSpansResponse + { + PagedResult = new PagedResult + { + Items = identities.Select(identity => traces[identity.TraceId].Spans.Single(span => span.SpanId == identity.SpanId)).ToList(), + TotalItemCount = totalCount, + IsFull = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_traces;") >= _otlpContext.Options.MaxTraceCount + } + }; + } + + private static TraceQuery BuildSpanQuery(GetSpansRequest context) + { + var sql = new StringBuilder(""" + FROM telemetry_spans s + JOIN telemetry_traces t ON t.trace_id = s.trace_id + JOIN telemetry_resources r ON r.resource_id = s.resource_id + JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id + LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id + WHERE 1 = 1 + """); + var parameters = new DynamicParameters(); + if (context.ResourceKeys.Count > 0) + { + var predicates = new List(context.ResourceKeys.Count); + for (var index = 0; index < context.ResourceKeys.Count; index++) + { + var key = context.ResourceKeys[index]; + parameters.Add($"SpanResourceName{index}", key.Name); + var source = $"r.resource_name = @SpanResourceName{index} COLLATE NOCASE"; + var peer = $"pr.resource_name = @SpanResourceName{index} COLLATE NOCASE"; + if (key.InstanceId is not null) + { + parameters.Add($"SpanInstanceId{index}", key.InstanceId); + source += $" AND r.instance_id = @SpanInstanceId{index} COLLATE NOCASE"; + peer += $" AND pr.instance_id = @SpanInstanceId{index} COLLATE NOCASE"; + } + predicates.Add($"(({source}) OR ({peer}))"); + } + sql.Append(" AND ("); + sql.AppendJoin(" OR ", predicates); + sql.Append(')'); + } + if (!string.IsNullOrEmpty(context.TraceId)) + { + parameters.Add( + "SpanTraceId", + context.TraceId.Length >= OtlpHelpers.ShortenedIdLength + ? CreateStartsWithLikePattern(context.TraceId) + : context.TraceId); + sql.Append(context.TraceId.Length >= OtlpHelpers.ShortenedIdLength + ? " AND s.trace_id LIKE @SpanTraceId ESCAPE '!'" + : " AND s.trace_id = @SpanTraceId COLLATE NOCASE"); + } + if (context.HasError is not null) + { + parameters.Add("SpanErrorStatus", (int)OtlpSpanStatusCode.Error); + sql.Append(context.HasError.Value ? " AND s.status = @SpanErrorStatus" : " AND s.status <> @SpanErrorStatus"); + } + + var filterIndex = 0; + foreach (var filter in context.Filters.Where(filter => filter.Enabled)) + { + if (filter is not FieldTelemetryFilter fieldFilter) + { + continue; + } + if (fieldFilter.Field == KnownTraceFields.DurationField) + { + var parameterName = $"SpanDuration{filterIndex++}"; + if (!double.TryParse(fieldFilter.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var milliseconds) || !double.IsFinite(milliseconds)) + { + sql.Append(" AND 0 = 1"); + continue; + } + parameters.Add(parameterName, milliseconds); + sql.Append(" AND "); + sql.Append(BuildNumericPredicate($"(CAST(s.end_time_ticks - s.start_time_ticks AS REAL) / {TimeSpan.TicksPerMillisecond})", fieldFilter.Condition, parameterName)); + continue; + } + + if (fieldFilter.Condition is FilterCondition.NotEqual or FilterCondition.NotContains && + fieldFilter.Field is not (KnownResourceFields.ServiceNameField or KnownTraceFields.TraceIdField or KnownTraceFields.SpanIdField or KnownTraceFields.NameField or KnownTraceFields.KindField or KnownTraceFields.StatusField or KnownSourceFields.NameField or KnownTraceFields.TimestampField)) + { + var violationFilter = new FieldTelemetryFilter + { + Field = fieldFilter.Field, + Condition = fieldFilter.Condition == FilterCondition.NotEqual ? FilterCondition.Equals : FilterCondition.Contains, + Value = fieldFilter.Value + }; + sql.Append(" AND NOT "); + sql.Append(BuildSpanFieldPredicate(violationFilter, parameters, filterIndex++, invertNegative: false)); + } + else + { + sql.Append(" AND "); + sql.Append(BuildSpanFieldPredicate(fieldFilter, parameters, filterIndex++, invertNegative: false)); + } + } + + if (context.TextFragments is { Length: > 0 }) + { + for (var index = 0; index < context.TextFragments.Length; index++) + { + var parameterName = $"SpanTextFragment{index}"; + parameters.Add(parameterName, CreateContainsLikePattern(context.TextFragments[index])); + sql.Append(CultureInfo.InvariantCulture, $""" + AND ( + s.name LIKE @{parameterName} ESCAPE '!' OR + s.span_id LIKE @{parameterName} ESCAPE '!' OR + s.trace_id LIKE @{parameterName} ESCAPE '!' OR + sc.scope_name LIKE @{parameterName} ESCAPE '!' OR + r.resource_name LIKE @{parameterName} ESCAPE '!' OR + CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END LIKE @{parameterName} ESCAPE '!' OR + CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END LIKE @{parameterName} ESCAPE '!' OR + COALESCE(s.status_message, '') LIKE @{parameterName} ESCAPE '!' OR + EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND (a.attribute_key LIKE @{parameterName} ESCAPE '!' OR a.attribute_value LIKE @{parameterName} ESCAPE '!')) OR + EXISTS (SELECT 1 FROM telemetry_span_events e WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id AND e.event_name LIKE @{parameterName} ESCAPE '!') + ) + """); + } + } + return new TraceQuery(sql.ToString(), parameters); + } + + private List GetTracePropertyKeysFromDatabase(ResourceKey? resourceKey) + { + using var connection = _database.OpenConnection(); + var parameters = new DynamicParameters(); + var resourceWhere = string.Empty; + if (resourceKey is not null) + { + resourceWhere = " AND r.resource_name = @ResourceName COLLATE NOCASE"; + parameters.Add("ResourceName", resourceKey.Value.Name); + if (resourceKey.Value.InstanceId is not null) + { + resourceWhere += " AND r.instance_id = @InstanceId COLLATE NOCASE"; + parameters.Add("InstanceId", resourceKey.Value.InstanceId); + } + } + return connection.Query($""" + SELECT DISTINCT a.attribute_key + FROM telemetry_span_attributes a + JOIN telemetry_spans s ON s.trace_id = a.trace_id AND s.span_id = a.span_id + JOIN telemetry_resources r ON r.resource_id = s.resource_id + WHERE 1 = 1{resourceWhere} + ORDER BY a.attribute_key; + """, parameters).AsList(); + } + + private Dictionary GetTraceFieldValuesFromDatabase(string attributeName) + { + if (attributeName is KnownTraceFields.DurationField or KnownTraceFields.TimestampField) + { + return new Dictionary(StringComparers.OtlpAttribute); + } + + using var connection = _database.OpenConnection(); + IEnumerable values = attributeName switch + { + KnownResourceFields.ServiceNameField => connection.Query(""" + SELECT resource_name AS FieldValue, COUNT(*) AS ValueCount + FROM ( + SELECT r.resource_name + FROM telemetry_spans s + JOIN telemetry_resources r ON r.resource_id = s.resource_id + UNION ALL + SELECT r.resource_name + FROM telemetry_spans s + JOIN telemetry_resources r ON r.resource_id = s.uninstrumented_peer_resource_id + ) + GROUP BY resource_name; + """), + KnownTraceFields.TraceIdField => QueryFieldValues("trace_id", "telemetry_spans"), + KnownTraceFields.SpanIdField => QueryFieldValues("span_id", "telemetry_spans"), + KnownTraceFields.KindField => connection.Query(""" + SELECT + CASE kind + WHEN 0 THEN 'Unspecified' + WHEN 1 THEN 'Internal' + WHEN 2 THEN 'Server' + WHEN 3 THEN 'Client' + WHEN 4 THEN 'Producer' + WHEN 5 THEN 'Consumer' + ELSE CAST(kind AS TEXT) + END AS FieldValue, + COUNT(*) AS ValueCount + FROM telemetry_spans + GROUP BY kind; + """), + KnownTraceFields.StatusField => connection.Query(""" + SELECT + CASE status + WHEN 0 THEN 'Unset' + WHEN 1 THEN 'Ok' + WHEN 2 THEN 'Error' + ELSE CAST(status AS TEXT) + END AS FieldValue, + COUNT(*) AS ValueCount + FROM telemetry_spans + GROUP BY status; + """), + KnownSourceFields.NameField => connection.Query(""" + SELECT sc.scope_name AS FieldValue, COUNT(*) AS ValueCount + FROM telemetry_spans s + JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id + GROUP BY sc.scope_name; + """), + KnownTraceFields.NameField => QueryFieldValues("name", "telemetry_spans"), + _ => connection.Query(""" + SELECT attribute_value AS FieldValue, COUNT(*) AS ValueCount + FROM telemetry_span_attributes + WHERE attribute_key = @AttributeName COLLATE NOCASE + GROUP BY attribute_value; + """, new { AttributeName = attributeName }) + }; + + return values.ToDictionary(record => record.FieldValue!, record => record.ValueCount, StringComparers.OtlpAttribute); + + IEnumerable QueryFieldValues(string expression, string table) + { + return connection.Query($""" + SELECT {expression} AS FieldValue, COUNT(*) AS ValueCount + FROM {table} + GROUP BY {expression}; + """); + } + } + + private bool HasUpdatedTraceInDatabase(OtlpTrace trace) + { + using var connection = _database.OpenConnection(); + var lastUpdatedTicks = connection.QuerySingleOrDefault(""" + SELECT last_updated_timestamp_ticks + FROM telemetry_traces + WHERE trace_id = @TraceId; + """, new { trace.TraceId }); + return lastUpdatedTicks is null || lastUpdatedTicks.Value > trace.LastUpdatedDate.Ticks; + } + + private OtlpTrace? GetTraceFromDatabase(string traceId) + { + using var connection = _database.OpenConnection(); + var usePrefix = traceId.Length >= OtlpHelpers.ShortenedIdLength; + var storedTraceId = connection.QueryFirstOrDefault(usePrefix + ? "SELECT trace_id FROM telemetry_traces WHERE trace_id LIKE @TraceId ESCAPE '!' ORDER BY first_span_timestamp_ticks, trace_id LIMIT 1;" + : "SELECT trace_id FROM telemetry_traces WHERE trace_id = @TraceId COLLATE NOCASE ORDER BY first_span_timestamp_ticks, trace_id LIMIT 1;", new { TraceId = usePrefix ? CreateStartsWithLikePattern(traceId) : traceId }); + return storedTraceId is null ? null : MaterializeTrace(connection, storedTraceId); + } + + private OtlpSpan? GetSpanFromDatabase(string traceId, string spanId) + { + var trace = GetTraceFromDatabase(traceId); + return trace?.Spans.FirstOrDefault(span => span.SpanId == spanId); + } + + private OtlpTrace? MaterializeTrace(SqliteConnection connection, string traceId, IDbTransaction? transaction = null) + { + var records = connection.Query(""" + SELECT + s.trace_id AS TraceId, + s.span_id AS SpanId, + s.parent_span_id AS ParentSpanId, + s.resource_id AS ResourceId, + s.resource_view_id AS ResourceViewId, + s.scope_id AS ScopeId, + s.name AS Name, + s.kind AS Kind, + s.start_time_ticks AS StartTimeTicks, + s.end_time_ticks AS EndTimeTicks, + s.status AS Status, + s.status_message AS StatusMessage, + s.trace_state AS State, + s.uninstrumented_peer_resource_id AS PeerResourceId, + t.last_updated_timestamp_ticks AS LastUpdatedTimestampTicks, + r.resource_name AS ResourceName, + r.instance_id AS InstanceId, + r.uninstrumented_peer AS UninstrumentedPeer, + r.has_logs AS HasLogs, + r.has_traces AS HasTraces, + r.has_metrics AS HasMetrics, + sc.scope_name AS ScopeName, + sc.scope_version AS ScopeVersion, + pr.resource_name AS PeerResourceName, + pr.instance_id AS PeerInstanceId + FROM telemetry_spans s + JOIN telemetry_traces t ON t.trace_id = s.trace_id + JOIN telemetry_resources r ON r.resource_id = s.resource_id + JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id + LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id + WHERE s.trace_id = @TraceId + ORDER BY s.start_time_ticks, s.span_id; + """, new { TraceId = traceId }, transaction).AsList(); + if (records.Count == 0) + { + return null; + } + + var spanIds = records.Select(record => record.SpanId).ToArray(); + var spanAttributes = connection.Query(""" + SELECT span_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_span_attributes + WHERE trace_id = @TraceId AND span_id IN @SpanIds + ORDER BY span_id, ordinal; + """, new { TraceId = traceId, SpanIds = spanIds }, transaction).ToLookup(record => record.OwnerId); + var eventRecords = connection.Query(""" + SELECT event_id AS EventId, span_id AS SpanId, event_name AS EventName, event_time_ticks AS EventTimeTicks + FROM telemetry_span_events + WHERE trace_id = @TraceId + ORDER BY span_id, ordinal; + """, new { TraceId = traceId }, transaction).AsList(); + var eventAttributes = connection.Query(""" + SELECT event_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_span_event_attributes + WHERE event_id IN @Ids + ORDER BY event_id, ordinal; + """, new { Ids = eventRecords.Select(record => record.EventId).ToArray() }, transaction).ToLookup(record => record.OwnerId); + var events = eventRecords.ToLookup(record => record.SpanId); + var linkRecords = connection.Query(""" + SELECT + link_id AS LinkId, + source_trace_id AS SourceTraceId, + source_span_id AS SourceSpanId, + target_trace_id AS TraceId, + target_span_id AS SpanId, + trace_state AS TraceState + FROM telemetry_span_links + WHERE source_trace_id = @TraceId OR target_trace_id = @TraceId + ORDER BY link_id; + """, new { TraceId = traceId }, transaction).AsList(); + var linkAttributes = connection.Query(""" + SELECT link_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_span_link_attributes + WHERE link_id IN @Ids + ORDER BY link_id, ordinal; + """, new { Ids = linkRecords.Select(record => record.LinkId).ToArray() }, transaction).ToLookup(record => record.OwnerId); + var outgoingLinks = linkRecords.Where(record => record.SourceTraceId == traceId).ToLookup(record => record.SourceSpanId); + var incomingLinks = linkRecords.Where(record => record.TraceId == traceId).ToLookup(record => record.SpanId); + + var trace = new OtlpTrace(Convert.FromHexString(traceId), new DateTime(records[0].LastUpdatedTimestampTicks, DateTimeKind.Utc)); + foreach (var record in records) + { + var (_, view, scope) = GetCachedTelemetryMetadata(record.ResourceId, record.ResourceViewId, record.ScopeId, CachedTelemetryType.Traces); + + var modelSpan = new OtlpSpan(view, trace, scope) + { + SpanId = record.SpanId, + ParentSpanId = record.ParentSpanId, + Name = record.Name, + Kind = (OtlpSpanKind)record.Kind, + StartTime = new DateTime(record.StartTimeTicks, DateTimeKind.Utc), + EndTime = new DateTime(record.EndTimeTicks, DateTimeKind.Utc), + Status = (OtlpSpanStatusCode)record.Status, + StatusMessage = record.StatusMessage, + State = record.State, + Attributes = ToPairs(spanAttributes[record.SpanId]), + Events = [], + Links = outgoingLinks[record.SpanId].Select(CreateLink).ToList(), + BackLinks = incomingLinks[record.SpanId].Select(CreateLink).ToList() + }; + if (record.PeerResourceId is not null) + { + modelSpan.SetUninstrumentedPeer(GetCachedResource(record.PeerResourceId.Value)); + } + modelSpan.Events.AddRange(events[record.SpanId].Select(spanEvent => new OtlpSpanEvent(modelSpan) + { + InternalId = Guid.Parse(spanEvent.EventId), + Name = spanEvent.EventName, + Time = new DateTime(spanEvent.EventTimeTicks, DateTimeKind.Utc), + Attributes = ToPairs(eventAttributes[spanEvent.EventId]) + })); + trace.AddSpan(modelSpan, skipLastUpdatedDate: true); + } + return trace; + + OtlpSpanLink CreateLink(SpanLinkRecord link) + { + return new OtlpSpanLink + { + SourceTraceId = link.SourceTraceId, + SourceSpanId = link.SourceSpanId, + TraceId = link.TraceId, + SpanId = link.SpanId, + TraceState = link.TraceState, + Attributes = ToPairs(linkAttributes[link.LinkId]) + }; + } + + static KeyValuePair[] ToPairs(IEnumerable attributes) + { + return attributes.Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray(); + } + } + + private sealed record TraceQuery(string FromAndWhere, DynamicParameters Parameters); + + private sealed class TraceAggregateRecord + { + public required int TotalItemCount { get; init; } + public required long MaxDurationTicks { get; init; } + } + + private sealed class TraceSummaryRecord + { + public required string TraceId { get; init; } + public required long LastUpdatedTimestampTicks { get; init; } + } + + private sealed class TracePageSummaryRecord + { + public required int TotalItemCount { get; init; } + public required long MaxDurationTicks { get; init; } + public required bool IsFull { get; init; } + public string? TraceId { get; init; } + public string? FullName { get; init; } + public long? StartTimeTicks { get; init; } + public long? DurationTicks { get; init; } + public string? RootResourceName { get; init; } + public string? RootInstanceId { get; init; } + public bool? RootUninstrumentedPeer { get; init; } + public bool? HasError { get; init; } + public bool? HasGenAI { get; init; } + public string? ResourceName { get; init; } + public string? InstanceId { get; init; } + public bool? UninstrumentedPeer { get; init; } + public int? TotalSpans { get; init; } + public int? ErroredSpans { get; init; } + } + + private sealed class SpanIdentityRecord + { + public required string TraceId { get; init; } + public required string SpanId { get; init; } + } + + private sealed class TraceOwnedAttributeRecord : AttributeRecord + { + public required string OwnerId { get; init; } + } + + private sealed class TextOwnedAttributeRecord : AttributeRecord + { + public required string OwnerId { get; init; } + } + + private sealed class LongOwnedAttributeRecord : AttributeRecord + { + public required long OwnerId { get; init; } + } + + private sealed class SpanEventRecord + { + public required string EventId { get; init; } + public required string SpanId { get; init; } + public required string EventName { get; init; } + public required long EventTimeTicks { get; init; } + } + + private sealed class SpanLinkRecord + { + public required long LinkId { get; init; } + public required string SourceTraceId { get; init; } + public required string SourceSpanId { get; init; } + public required string TraceId { get; init; } + public required string SpanId { get; init; } + public required string TraceState { get; init; } + } + + private sealed class SpanRecord + { + public required string TraceId { get; init; } + public required string SpanId { get; init; } + public string? ParentSpanId { get; init; } + public required long ResourceId { get; init; } + public required long ResourceViewId { get; init; } + public required long ScopeId { get; init; } + public required string Name { get; init; } + public required int Kind { get; init; } + public required long StartTimeTicks { get; init; } + public required long EndTimeTicks { get; init; } + public required int Status { get; init; } + public string? StatusMessage { get; init; } + public string? State { get; init; } + public long? PeerResourceId { get; init; } + public required long LastUpdatedTimestampTicks { get; init; } + public required string ResourceName { get; init; } + public string? InstanceId { get; init; } + public required bool UninstrumentedPeer { get; init; } + public required bool HasLogs { get; init; } + public required bool HasTraces { get; init; } + public required bool HasMetrics { get; init; } + public required string ScopeName { get; init; } + public required string ScopeVersion { get; init; } + public string? PeerResourceName { get; init; } + public string? PeerInstanceId { get; init; } + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs new file mode 100644 index 00000000000..6c0c1b9109e --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs @@ -0,0 +1,1361 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data; +using System.Globalization; +using System.Text; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Dapper; +using Google.Protobuf.Collections; +using Microsoft.Data.Sqlite; +using OpenTelemetry.Proto.Trace.V1; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed partial class SqliteTelemetryRepository +{ + private const int MaxTraceBatchSize = 100; + private const int MaxSpanBatchSize = 50; + private const int MaxSpanDetailBatchSize = 100; + + private List AddTracesToDatabase(AddContext context, RepeatedField resourceSpans) + { + var addedSpans = new List(); + lock (_writeLock) + { + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + var incomingSpans = resourceSpans + .SelectMany(resource => resource.ScopeSpans) + .SelectMany(scope => scope.Spans) + .Select(span => new IncomingSpanIdentity( + span.TraceId.ToHexString(), + span.SpanId.ToHexString(), + span.ParentSpanId.IsEmpty ? null : span.ParentSpanId.ToHexString())) + .ToArray(); + var traceIds = incomingSpans + .Select(span => span.TraceId) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var ingestionState = LoadTraceIngestionState(connection, transaction, incomingSpans, traceIds); + var traces = new Dictionary(StringComparer.Ordinal); + var pendingSpans = new List(); + var resourcesWithTraces = new HashSet(); + + foreach (var resourceSpansItem in resourceSpans) + { + OtlpResourceView resourceView; + CachedResource cachedResource; + long resourceId; + long resourceViewId; + try + { + var resourceKey = resourceSpansItem.Resource.GetResourceKey(); + cachedResource = GetOrAddCachedResource(connection, transaction, resourceKey); + resourceId = cachedResource.ResourceId; + var cachedView = GetOrAddCachedResourceView(connection, transaction, cachedResource, resourceSpansItem.Resource.Attributes); + resourceView = cachedView.View; + resourceViewId = cachedView.ResourceViewId; + } + catch (Exception exception) + { + context.FailureCount += resourceSpansItem.ScopeSpans.Sum(scope => scope.Spans.Count); + _otlpContext.Logger.LogInformation(exception, "Error adding resource."); + continue; + } + resourcesWithTraces.Add(cachedResource); + + foreach (var scopeSpans in resourceSpansItem.ScopeSpans) + { + OtlpScope scope; + long scopeId; + try + { + var cachedScope = GetOrAddCachedScope(connection, transaction, cachedResource, scopeSpans.Scope, CachedTelemetryType.Traces); + scopeId = cachedScope.Scope.ScopeId; + scope = cachedScope.Scope.Scope; + } + catch (Exception exception) + { + context.FailureCount += scopeSpans.Spans.Count; + _otlpContext.Logger.LogInformation(exception, "Error adding trace scope."); + continue; + } + + foreach (var span in scopeSpans.Spans) + { + try + { + var pendingSpan = PrepareSpan(traces, ingestionState, resourceId, resourceViewId, resourceView, scopeId, scope, span); + pendingSpans.Add(pendingSpan); + addedSpans.Add(pendingSpan.Span); + context.SuccessCount++; + } + catch (Exception exception) + { + context.FailureCount++; + _otlpContext.Logger.LogInformation(exception, "Error adding span."); + } + } + } + + } + + UpsertTraces(connection, transaction, traces.Values, ingestionState.ExistingTraces); + InsertSpans(connection, transaction, pendingSpans); + InsertSpanDetails(connection, transaction, pendingSpans); + var peerChangedTraceIds = UpdateUninstrumentedPeers(connection, transaction, traces.Values, ingestionState); + UpdateTraceResourceSummaries( + connection, + transaction, + pendingSpans, + ingestionState, + peerChangedTraceIds); + MarkResourcesHaveTraces(connection, transaction, resourcesWithTraces); + TrimTracesToCapacity(connection, transaction); + transaction.Commit(); + } + + return addedSpans; + } + + private static TraceIngestionState LoadTraceIngestionState( + SqliteConnection connection, + IDbTransaction transaction, + IReadOnlyList incomingSpans, + IReadOnlyList traceIds) + { + var existingTraces = new Dictionary(StringComparer.Ordinal); + foreach (var batch in traceIds.Chunk(MaxTraceBatchSize)) + { + foreach (var record in connection.Query(""" + SELECT + t.trace_id AS TraceId, + t.first_span_timestamp_ticks AS FirstSpanTimestampTicks, + t.last_span_end_timestamp_ticks AS LastSpanEndTimestampTicks, + t.last_updated_timestamp_ticks AS LastUpdatedTimestampTicks, + t.full_name AS FullName, + t.primary_span_id AS PrimarySpanId, + t.has_error AS HasError, + t.has_gen_ai AS HasGenAI, + p.parent_span_id AS PrimaryParentSpanId, + p.start_time_ticks AS PrimaryStartTimeTicks + FROM telemetry_traces t + JOIN telemetry_spans p ON p.trace_id = t.trace_id AND p.span_id = t.primary_span_id + WHERE t.trace_id IN @TraceIds; + """, new { TraceIds = batch }, transaction)) + { + existingTraces.Add(record.TraceId, record); + } + } + + var existingSpans = new Dictionary<(string TraceId, string SpanId), IngestionExistingSpanRecord>(); + var existingParentReferences = new HashSet<(string TraceId, string ParentSpanId)>(); + var circularSpanIds = new HashSet<(string TraceId, string SpanId)>(); + if (existingTraces.Count == 0) + { + return new TraceIngestionState + { + ExistingTraces = existingTraces, + ExistingSpans = existingSpans, + ExistingParentReferences = existingParentReferences, + CircularSpanIds = circularSpanIds + }; + } + + var incomingSpansForExistingTraces = incomingSpans + .Where(span => existingTraces.ContainsKey(span.TraceId)) + .ToArray(); + var incomingIdentities = incomingSpansForExistingTraces + .Select(span => (span.TraceId, span.SpanId)) + .ToHashSet(); + var parentIdentities = incomingSpansForExistingTraces + .Where(span => span.ParentSpanId is not null) + .Select(span => (span.TraceId, SpanId: span.ParentSpanId!)) + .ToHashSet(); + var ancestors = new List(); + foreach (var traceBatch in existingTraces.Keys.Chunk(MaxTraceBatchSize)) + { + var traceIdSet = traceBatch.ToHashSet(StringComparer.Ordinal); + var traceIncomingSpans = incomingSpansForExistingTraces + .Where(span => traceIdSet.Contains(span.TraceId)) + .ToArray(); + + var relevantSpanIds = traceIncomingSpans + .Select(span => span.SpanId) + .Concat(traceIncomingSpans.Where(span => span.ParentSpanId is not null).Select(span => span.ParentSpanId!)) + .Distinct(StringComparer.Ordinal); + foreach (var spanIdBatch in relevantSpanIds.Chunk(MaxSpanDetailBatchSize)) + { + foreach (var record in connection.Query(""" + SELECT + s.trace_id AS TraceId, + s.span_id AS SpanId, + s.resource_order_ticks AS ResourceOrderTicks, + s.uninstrumented_peer_resource_id AS UninstrumentedPeerResourceId + FROM telemetry_spans s + WHERE s.trace_id IN @TraceIds AND s.span_id IN @SpanIds; + """, new { TraceIds = traceBatch, SpanIds = spanIdBatch }, transaction)) + { + var identity = (record.TraceId, record.SpanId); + if (incomingIdentities.Contains(identity) || parentIdentities.Contains(identity)) + { + existingSpans.TryAdd(identity, record); + } + } + } + + foreach (var spanIdBatch in traceIncomingSpans + .Select(span => span.SpanId) + .Distinct(StringComparer.Ordinal) + .Chunk(MaxSpanDetailBatchSize)) + { + foreach (var record in connection.Query(""" + SELECT + s.trace_id AS TraceId, + s.parent_span_id AS ParentSpanId + FROM telemetry_spans s + WHERE s.trace_id IN @TraceIds AND s.parent_span_id IN @ParentSpanIds; + """, new { TraceIds = traceBatch, ParentSpanIds = spanIdBatch }, transaction)) + { + var parentIdentity = (record.TraceId, record.ParentSpanId); + if (incomingIdentities.Contains(parentIdentity)) + { + existingParentReferences.Add(parentIdentity); + } + } + } + + foreach (var parentSpanIdBatch in traceIncomingSpans + .Where(span => span.ParentSpanId is not null) + .Select(span => span.ParentSpanId!) + .Distinct(StringComparer.Ordinal) + .Chunk(MaxSpanDetailBatchSize)) + { + ancestors.AddRange(connection.Query(""" + WITH RECURSIVE ancestors(trace_id, origin_span_id, span_id, parent_span_id) AS ( + SELECT s.trace_id, s.span_id, s.span_id, s.parent_span_id + FROM telemetry_spans s + WHERE s.trace_id IN @TraceIds AND s.span_id IN @ParentSpanIds + UNION ALL + SELECT parent.trace_id, child.origin_span_id, parent.span_id, parent.parent_span_id + FROM ancestors child + JOIN telemetry_spans parent ON parent.trace_id = child.trace_id AND parent.span_id = child.parent_span_id + ) + SELECT + trace_id AS TraceId, + origin_span_id AS OriginSpanId, + span_id AS SpanId, + parent_span_id AS ParentSpanId + FROM ancestors; + """, new { TraceIds = traceBatch, ParentSpanIds = parentSpanIdBatch }, transaction)); + } + } + + var ancestorParentReferences = ancestors + .Where(ancestor => ancestor.ParentSpanId is not null) + .Select(ancestor => (ancestor.TraceId, ancestor.OriginSpanId, ParentSpanId: ancestor.ParentSpanId!)) + .ToHashSet(); + circularSpanIds.UnionWith(incomingSpansForExistingTraces + .Where(span => span.ParentSpanId is not null) + .Where(span => ancestorParentReferences.Contains((span.TraceId, span.ParentSpanId!, span.SpanId))) + .Select(span => (span.TraceId, span.SpanId))); + + return new TraceIngestionState + { + ExistingTraces = existingTraces, + ExistingSpans = existingSpans, + ExistingParentReferences = existingParentReferences, + CircularSpanIds = circularSpanIds + }; + } + + private PendingSpan PrepareSpan( + Dictionary traces, + TraceIngestionState ingestionState, + long resourceId, + long resourceViewId, + OtlpResourceView resourceView, + long scopeId, + OtlpScope scope, + Span span) + { + var traceId = span.TraceId.ToHexString(); + var spanId = span.SpanId.ToHexString(); + if (ingestionState.ExistingSpans.ContainsKey((traceId, spanId))) + { + throw new InvalidOperationException($"Duplicate span id '{spanId}' detected."); + } + if (ingestionState.CircularSpanIds.Contains((traceId, spanId))) + { + throw new InvalidOperationException($"Circular loop detected for span '{spanId}' with parent '{span.ParentSpanId.ToHexString()}'."); + } + + var registerTrace = false; + if (!traces.TryGetValue(traceId, out var trace)) + { + var lastUpdatedDate = ingestionState.ExistingTraces.TryGetValue(traceId, out var existingTrace) + ? new DateTime(existingTrace.LastUpdatedTimestampTicks, DateTimeKind.Utc) + : DateTime.UtcNow; + trace = new OtlpTrace(span.TraceId.Memory, lastUpdatedDate); + registerTrace = true; + } + var modelSpan = CreateSqliteSpan(resourceView, trace, scope, span); + trace.AddSpan(modelSpan); + if (registerTrace) + { + traces.Add(traceId, trace); + } + + return new PendingSpan(resourceId, resourceViewId, scopeId, modelSpan); + } + + private static void UpsertTraces( + SqliteConnection connection, + IDbTransaction transaction, + IEnumerable traces, + IReadOnlyDictionary existingTraces) + { + foreach (var batch in traces + .Select(trace => CreateTraceUpsertRecord(trace, existingTraces.GetValueOrDefault(trace.TraceId))) + .Chunk(MaxTraceBatchSize)) + { + var sql = new StringBuilder(""" + INSERT INTO telemetry_traces ( + trace_id, first_span_timestamp_ticks, last_span_end_timestamp_ticks, duration_ticks, last_updated_timestamp_ticks, full_name, + primary_span_id, has_error, has_gen_ai) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + var trace = batch[index]; + sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @FirstSpanTimestampTicks{index}, @LastSpanEndTimestampTicks{index}, @DurationTicks{index}, @LastUpdatedTimestampTicks{index}, @FullName{index}, @PrimarySpanId{index}, @HasError{index}, @HasGenAI{index})"); + parameters.Add($"TraceId{index}", trace.TraceId); + parameters.Add($"FirstSpanTimestampTicks{index}", trace.FirstSpanTimestampTicks); + parameters.Add($"LastSpanEndTimestampTicks{index}", trace.LastSpanEndTimestampTicks); + parameters.Add($"DurationTicks{index}", trace.LastSpanEndTimestampTicks - trace.FirstSpanTimestampTicks); + parameters.Add($"LastUpdatedTimestampTicks{index}", trace.LastUpdatedTimestampTicks); + parameters.Add($"FullName{index}", trace.FullName); + parameters.Add($"PrimarySpanId{index}", trace.PrimarySpanId); + parameters.Add($"HasError{index}", trace.HasError); + parameters.Add($"HasGenAI{index}", trace.HasGenAI); + } + sql.Append(""" + ON CONFLICT(trace_id) DO UPDATE SET + first_span_timestamp_ticks = excluded.first_span_timestamp_ticks, + last_span_end_timestamp_ticks = excluded.last_span_end_timestamp_ticks, + duration_ticks = excluded.duration_ticks, + last_updated_timestamp_ticks = excluded.last_updated_timestamp_ticks, + full_name = excluded.full_name, + primary_span_id = excluded.primary_span_id, + has_error = excluded.has_error, + has_gen_ai = excluded.has_gen_ai; + """); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + + private static TraceUpsertRecord CreateTraceUpsertRecord(OtlpTrace trace, IngestionTraceRecord? existingTrace) + { + var incomingPrimarySpan = GetPrimarySpan(trace); + if (existingTrace is null) + { + return new TraceUpsertRecord( + trace.TraceId, + trace.TimeStamp.Ticks, + trace.Spans.Max(span => span.EndTime.Ticks), + trace.LastUpdatedDate.Ticks, + trace.FullName, + incomingPrimarySpan.SpanId, + trace.Spans.Any(span => span.Status == OtlpSpanStatusCode.Error), + HasGenAI(trace.Spans)); + } + + var existingPrimaryIsRoot = string.IsNullOrEmpty(existingTrace.PrimaryParentSpanId); + var incomingPrimaryIsRoot = string.IsNullOrEmpty(incomingPrimarySpan.ParentSpanId); + var useIncomingPrimary = IsPreferredPrimarySpan( + incomingPrimaryIsRoot, + incomingPrimarySpan.StartTime.Ticks, + incomingPrimarySpan.SpanId, + existingPrimaryIsRoot, + existingTrace.PrimaryStartTimeTicks, + existingTrace.PrimarySpanId); + return new TraceUpsertRecord( + trace.TraceId, + Math.Min(existingTrace.FirstSpanTimestampTicks, trace.TimeStamp.Ticks), + Math.Max(existingTrace.LastSpanEndTimestampTicks, trace.Spans.Max(span => span.EndTime.Ticks)), + trace.LastUpdatedDate.Ticks, + useIncomingPrimary + ? $"{incomingPrimarySpan.Source.Resource.ResourceName}: {incomingPrimarySpan.Name}" + : existingTrace.FullName, + useIncomingPrimary ? incomingPrimarySpan.SpanId : existingTrace.PrimarySpanId, + existingTrace.HasError || trace.Spans.Any(span => span.Status == OtlpSpanStatusCode.Error), + existingTrace.HasGenAI || HasGenAI(trace.Spans)); + + static bool HasGenAI(IEnumerable spans) => spans.Any(span => span.Attributes.Any(attribute => + (attribute.Key is "gen_ai.system" or "gen_ai.provider.name") && attribute.Value.Length > 0)); + } + + private static OtlpSpan GetPrimarySpan(OtlpTrace trace) + { + var primarySpan = trace.Spans[0]; + foreach (var span in trace.Spans.Skip(1)) + { + if (IsPreferredPrimarySpan( + string.IsNullOrEmpty(span.ParentSpanId), + span.StartTime.Ticks, + span.SpanId, + string.IsNullOrEmpty(primarySpan.ParentSpanId), + primarySpan.StartTime.Ticks, + primarySpan.SpanId)) + { + primarySpan = span; + } + } + + return primarySpan; + } + + private static bool IsPreferredPrimarySpan( + bool candidateIsRoot, + long candidateStartTimeTicks, + string candidateSpanId, + bool currentIsRoot, + long currentStartTimeTicks, + string currentSpanId) + { + if (candidateIsRoot != currentIsRoot) + { + return candidateIsRoot; + } + + if (candidateStartTimeTicks != currentStartTimeTicks) + { + return candidateStartTimeTicks < currentStartTimeTicks; + } + + // Equal-time spans are inserted before existing spans by OtlpTrace, so the last span ID in + // database order becomes primary when a trace is materialized. + return string.CompareOrdinal(candidateSpanId, currentSpanId) > 0; + } + + private static void InsertSpans(SqliteConnection connection, IDbTransaction transaction, List spans) + { + foreach (var batch in spans.Chunk(MaxSpanBatchSize)) + { + var sql = new StringBuilder(""" + INSERT INTO telemetry_spans ( + trace_id, span_id, parent_span_id, resource_id, resource_view_id, scope_id, name, kind, + start_time_ticks, end_time_ticks, status, status_message, trace_state, resource_order_ticks) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + var pendingSpan = batch[index]; + var span = pendingSpan.Span; + sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @ParentSpanId{index}, @ResourceId{index}, @ResourceViewId{index}, @ScopeId{index}, @Name{index}, @Kind{index}, @StartTimeTicks{index}, @EndTimeTicks{index}, @Status{index}, @StatusMessage{index}, @State{index}, @StartTimeTicks{index})"); + parameters.Add($"TraceId{index}", span.TraceId); + parameters.Add($"SpanId{index}", span.SpanId); + parameters.Add($"ParentSpanId{index}", span.ParentSpanId); + parameters.Add($"ResourceId{index}", pendingSpan.ResourceId); + parameters.Add($"ResourceViewId{index}", pendingSpan.ResourceViewId); + parameters.Add($"ScopeId{index}", pendingSpan.ScopeId); + parameters.Add($"Name{index}", span.Name); + parameters.Add($"Kind{index}", (int)span.Kind); + parameters.Add($"StartTimeTicks{index}", span.StartTime.Ticks); + parameters.Add($"EndTimeTicks{index}", span.EndTime.Ticks); + parameters.Add($"Status{index}", (int)span.Status); + parameters.Add($"StatusMessage{index}", span.StatusMessage); + parameters.Add($"State{index}", span.State); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + + private static void MarkResourcesHaveTraces(SqliteConnection connection, IDbTransaction transaction, HashSet resources) + { + var resourcesToUpdate = resources.Where(resource => !resource.Resource.HasTraces).ToArray(); + foreach (var batch in resourcesToUpdate.Chunk(MaxTraceBatchSize)) + { + connection.Execute( + "UPDATE telemetry_resources SET has_traces = 1 WHERE resource_id IN @ResourceIds;", + new { ResourceIds = batch.Select(resource => resource.ResourceId).ToArray() }, + transaction); + } + foreach (var resource in resourcesToUpdate) + { + resource.Resource.HasTraces = true; + } + } + + private HashSet UpdateUninstrumentedPeers( + SqliteConnection connection, + IDbTransaction transaction, + IEnumerable traces, + TraceIngestionState ingestionState) + { + var peerResourceIds = new HashSet(); + var spanUpdates = new List(); + var peerChangedTraceIds = new HashSet(StringComparer.Ordinal); + foreach (var trace in traces) + { + foreach (var span in trace.Spans) + { + OtlpResource? peer = null; + long? peerResourceId = null; + var hasPeerAddress = OtlpHelpers.GetPeerAddress(span.Attributes) is not null; + var hasChildren = span.GetChildSpans().Any() || + ingestionState.ExistingParentReferences.Contains((span.TraceId, span.SpanId)); + if (hasPeerAddress && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !hasChildren) + { + if (TryResolvePeerResourceKey(span.Attributes, out var peerKey)) + { + var cachedPeerResource = GetOrAddCachedResource(connection, transaction, peerKey, uninstrumentedPeer: true); + peerResourceId = cachedPeerResource.ResourceId; + peerResourceIds.Add(cachedPeerResource.ResourceId); + peer = cachedPeerResource.Resource; + } + } + + trace.SetSpanUninstrumentedPeer(span, peer); + spanUpdates.Add(new PeerSpanUpdateRecord + { + PeerResourceId = peerResourceId, + TraceId = span.TraceId, + SpanId = span.SpanId + }); + } + + foreach (var span in trace.Spans) + { + if (span.ParentSpanId is not null && + ingestionState.ExistingSpans.TryGetValue((trace.TraceId, span.ParentSpanId), out var existingParent) && + existingParent.UninstrumentedPeerResourceId is not null) + { + spanUpdates.Add(new PeerSpanUpdateRecord + { + PeerResourceId = null, + TraceId = trace.TraceId, + SpanId = span.ParentSpanId! + }); + peerChangedTraceIds.Add(trace.TraceId); + } + } + } + + if (peerResourceIds.Count > 0) + { + connection.Execute( + "UPDATE telemetry_resources SET uninstrumented_peer = 1 WHERE resource_id IN @ResourceIds;", + new { ResourceIds = peerResourceIds.ToArray() }, + transaction); + } + + UpdatePeerSpans(connection, transaction, spanUpdates); + return peerChangedTraceIds; + } + + private static void UpdatePeerSpans(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList spanUpdates) + { + foreach (var batch in spanUpdates.Chunk(MaxSpanDetailBatchSize)) + { + var sql = new StringBuilder(""" + WITH peer_updates(trace_id, span_id, peer_resource_id) AS ( + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @PeerResourceId{index})"); + parameters.Add($"TraceId{index}", batch[index].TraceId); + parameters.Add($"SpanId{index}", batch[index].SpanId); + parameters.Add($"PeerResourceId{index}", batch[index].PeerResourceId); + } + sql.Append(""" + ) + UPDATE telemetry_spans AS spans + SET uninstrumented_peer_resource_id = peer_updates.peer_resource_id + FROM peer_updates + WHERE spans.trace_id = peer_updates.trace_id + AND spans.span_id = peer_updates.span_id; + """); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + + private static void UpdateTraceResourceSummaries( + SqliteConnection connection, + IDbTransaction transaction, + IReadOnlyList pendingSpans, + TraceIngestionState ingestionState, + IReadOnlySet peerChangedTraceIds) + { + var rebuildTraceIds = new HashSet(peerChangedTraceIds, StringComparer.Ordinal); + foreach (var pendingSpan in pendingSpans) + { + var span = pendingSpan.Span; + if (!ingestionState.ExistingTraces.ContainsKey(span.TraceId) || + ingestionState.ExistingParentReferences.Contains((span.TraceId, span.SpanId))) + { + rebuildTraceIds.Add(span.TraceId); + } + } + + RebuildTraceResourceSummaries(connection, transaction, rebuildTraceIds); + + var incrementalSpans = pendingSpans + .Where(pendingSpan => !rebuildTraceIds.Contains(pendingSpan.Span.TraceId)) + .ToArray(); + UpdateSpanOrders( + connection, + transaction, + incrementalSpans.Select(pendingSpan => + { + var span = pendingSpan.Span; + var resourceOrderTicks = span.StartTime.Ticks; + var currentSpan = span; + while (currentSpan.ParentSpanId is { } parentSpanId) + { + if (!currentSpan.Trace.Spans.TryGetValue(parentSpanId, out var parent)) + { + if (ingestionState.ExistingSpans.TryGetValue((span.TraceId, parentSpanId), out var existingParent)) + { + resourceOrderTicks = Math.Max(resourceOrderTicks, existingParent.ResourceOrderTicks); + } + break; + } + + resourceOrderTicks = Math.Max(resourceOrderTicks, parent.StartTime.Ticks); + currentSpan = parent; + } + return new SpanOrderUpdateRecord(span.TraceId, span.SpanId, resourceOrderTicks); + })); + AddTraceResourceAggregateDeltas(connection, transaction, incrementalSpans); + } + + private static void RebuildTraceResourceSummaries(SqliteConnection connection, IDbTransaction transaction, IEnumerable traceIds) + { + foreach (var traceBatch in traceIds.Chunk(MaxTraceBatchSize)) + { + connection.Execute(""" + WITH RECURSIVE span_tree(trace_id, span_id, resource_order_ticks) AS ( + SELECT s.trace_id, s.span_id, s.start_time_ticks + FROM telemetry_spans s + WHERE s.trace_id IN @TraceIds + AND (s.parent_span_id IS NULL OR NOT EXISTS ( + SELECT 1 + FROM telemetry_spans parent + WHERE parent.trace_id = s.trace_id AND parent.span_id = s.parent_span_id)) + UNION ALL + SELECT child.trace_id, child.span_id, MAX(child.start_time_ticks, parent.resource_order_ticks) + FROM span_tree parent + JOIN telemetry_spans child ON child.trace_id = parent.trace_id AND child.parent_span_id = parent.span_id + ) + UPDATE telemetry_spans AS spans + SET resource_order_ticks = span_tree.resource_order_ticks + FROM span_tree + WHERE spans.trace_id = span_tree.trace_id AND spans.span_id = span_tree.span_id; + """, new { TraceIds = traceBatch }, transaction); + + RebuildTraceResourceAggregates(connection, transaction, traceBatch); + } + } + + private static void UpdateSpanOrders( + SqliteConnection connection, + IDbTransaction transaction, + IEnumerable orderUpdates) + { + foreach (var orderBatch in orderUpdates.Chunk(MaxSpanDetailBatchSize)) + { + var sql = new StringBuilder("WITH span_orders(trace_id, span_id, resource_order_ticks) AS (VALUES\n"); + var parameters = new DynamicParameters(); + for (var index = 0; index < orderBatch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @ResourceOrderTicks{index})"); + parameters.Add($"TraceId{index}", orderBatch[index].TraceId); + parameters.Add($"SpanId{index}", orderBatch[index].SpanId); + parameters.Add($"ResourceOrderTicks{index}", orderBatch[index].ResourceOrderTicks); + } + sql.Append(""" + ) + UPDATE telemetry_spans AS spans + SET resource_order_ticks = span_orders.resource_order_ticks + FROM span_orders + WHERE spans.trace_id = span_orders.trace_id + AND spans.span_id = span_orders.span_id; + """); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + + private static void AddTraceResourceAggregateDeltas( + SqliteConnection connection, + IDbTransaction transaction, + IEnumerable pendingSpans) + { + foreach (var spanBatch in pendingSpans.Chunk(MaxSpanDetailBatchSize)) + { + var sql = new StringBuilder("WITH new_spans(trace_id, span_id) AS (VALUES\n"); + var parameters = new DynamicParameters(); + for (var index = 0; index < spanBatch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index})"); + parameters.Add($"TraceId{index}", spanBatch[index].Span.TraceId); + parameters.Add($"SpanId{index}", spanBatch[index].Span.SpanId); + } + sql.Append(""" + ) + INSERT INTO telemetry_trace_resources ( + trace_id, resource_id, resource_order_ticks, total_spans, errored_spans) + SELECT + resources.trace_id, + resources.resource_id, + MIN(resources.resource_order_ticks), + COUNT(*), + SUM(CASE WHEN resources.status = 2 THEN 1 ELSE 0 END) + FROM ( + SELECT s.trace_id, s.resource_id, s.resource_order_ticks, s.status + FROM telemetry_spans s + JOIN new_spans n ON n.trace_id = s.trace_id AND n.span_id = s.span_id + + UNION ALL + + SELECT s.trace_id, s.uninstrumented_peer_resource_id, s.resource_order_ticks, s.status + FROM telemetry_spans s + JOIN new_spans n ON n.trace_id = s.trace_id AND n.span_id = s.span_id + WHERE s.uninstrumented_peer_resource_id IS NOT NULL + ) resources + GROUP BY resources.trace_id, resources.resource_id + ON CONFLICT(trace_id, resource_id) DO UPDATE SET + resource_order_ticks = MIN(telemetry_trace_resources.resource_order_ticks, excluded.resource_order_ticks), + total_spans = telemetry_trace_resources.total_spans + excluded.total_spans, + errored_spans = telemetry_trace_resources.errored_spans + excluded.errored_spans; + """); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + + private static void RebuildTraceResourceAggregates(SqliteConnection connection, IDbTransaction transaction, IEnumerable traceIds) + { + foreach (var traceIdBatch in traceIds.Chunk(MaxTraceBatchSize)) + { + connection.Execute(""" + DELETE FROM telemetry_trace_resources + WHERE trace_id IN @TraceIds; + + INSERT INTO telemetry_trace_resources ( + trace_id, resource_id, resource_order_ticks, total_spans, errored_spans) + SELECT + resources.trace_id, + resources.resource_id, + MIN(resources.resource_order_ticks), + COUNT(*), + SUM(CASE WHEN resources.status = 2 THEN 1 ELSE 0 END) + FROM ( + SELECT trace_id, resource_id, resource_order_ticks, status + FROM telemetry_spans + WHERE trace_id IN @TraceIds + + UNION ALL + + SELECT trace_id, uninstrumented_peer_resource_id, resource_order_ticks, status + FROM telemetry_spans + WHERE trace_id IN @TraceIds + AND uninstrumented_peer_resource_id IS NOT NULL + ) resources + GROUP BY resources.trace_id, resources.resource_id; + """, new { TraceIds = traceIdBatch }, transaction); + } + } + + private static void InsertSpanDetails(SqliteConnection connection, IDbTransaction transaction, List pendingSpans) + { + InsertSpanAttributes(connection, transaction, pendingSpans); + + var events = pendingSpans + .SelectMany(pendingSpan => pendingSpan.Span.Events.Select((spanEvent, ordinal) => new PendingSpanEvent( + spanEvent.InternalId.ToString("D"), + pendingSpan.Span.TraceId, + pendingSpan.Span.SpanId, + ordinal, + spanEvent))) + .ToArray(); + InsertSpanEvents(connection, transaction, events); + InsertSpanEventAttributes(connection, transaction, events); + + var links = pendingSpans + .SelectMany(pendingSpan => pendingSpan.Span.Links.Select(link => new PendingSpanLink(link))) + .ToArray(); + InsertSpanLinks(connection, transaction, links); + InsertSpanLinkAttributes(connection, transaction, links); + } + + private static void InsertSpanAttributes(SqliteConnection connection, IDbTransaction transaction, List pendingSpans) + { + var attributes = pendingSpans + .SelectMany(pendingSpan => pendingSpan.Span.Attributes.Select((attribute, ordinal) => new + { + pendingSpan.Span.TraceId, + pendingSpan.Span.SpanId, + Ordinal = ordinal, + attribute.Key, + attribute.Value + })) + .ToArray(); + foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) + { + var sql = new StringBuilder(""" + INSERT INTO telemetry_span_attributes (trace_id, span_id, ordinal, attribute_key, attribute_value) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); + parameters.Add($"TraceId{index}", batch[index].TraceId); + parameters.Add($"SpanId{index}", batch[index].SpanId); + parameters.Add($"Ordinal{index}", batch[index].Ordinal); + parameters.Add($"Key{index}", batch[index].Key); + parameters.Add($"Value{index}", batch[index].Value); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + + private static void InsertSpanEvents(SqliteConnection connection, IDbTransaction transaction, PendingSpanEvent[] events) + { + foreach (var batch in events.Chunk(MaxSpanDetailBatchSize)) + { + var sql = new StringBuilder(""" + INSERT INTO telemetry_span_events (event_id, trace_id, span_id, ordinal, event_name, event_time_ticks) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@EventId{index}, @TraceId{index}, @SpanId{index}, @Ordinal{index}, @Name{index}, @TimeTicks{index})"); + parameters.Add($"EventId{index}", batch[index].EventId); + parameters.Add($"TraceId{index}", batch[index].TraceId); + parameters.Add($"SpanId{index}", batch[index].SpanId); + parameters.Add($"Ordinal{index}", batch[index].Ordinal); + parameters.Add($"Name{index}", batch[index].Event.Name); + parameters.Add($"TimeTicks{index}", batch[index].Event.Time.Ticks); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + + private static void InsertSpanEventAttributes(SqliteConnection connection, IDbTransaction transaction, PendingSpanEvent[] events) + { + var attributes = events + .SelectMany(spanEvent => spanEvent.Event.Attributes.Select((attribute, ordinal) => new + { + spanEvent.EventId, + Ordinal = ordinal, + attribute.Key, + attribute.Value + })) + .ToArray(); + foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) + { + var sql = new StringBuilder(""" + INSERT INTO telemetry_span_event_attributes (event_id, ordinal, attribute_key, attribute_value) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@EventId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); + parameters.Add($"EventId{index}", batch[index].EventId); + parameters.Add($"Ordinal{index}", batch[index].Ordinal); + parameters.Add($"Key{index}", batch[index].Key); + parameters.Add($"Value{index}", batch[index].Value); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + + private static void InsertSpanLinks(SqliteConnection connection, IDbTransaction transaction, PendingSpanLink[] links) + { + foreach (var batch in links.Chunk(MaxSpanDetailBatchSize)) + { + var sql = new StringBuilder(); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + var link = batch[index].Link; + sql.Append(CultureInfo.InvariantCulture, $$""" + INSERT INTO telemetry_span_links ( + source_trace_id, source_span_id, target_trace_id, target_span_id, trace_state) + VALUES (@SourceTraceId{{index}}, @SourceSpanId{{index}}, @TraceId{{index}}, @SpanId{{index}}, @TraceState{{index}}) + RETURNING link_id; + """); + parameters.Add($"SourceTraceId{index}", link.SourceTraceId); + parameters.Add($"SourceSpanId{index}", link.SourceSpanId); + parameters.Add($"TraceId{index}", link.TraceId); + parameters.Add($"SpanId{index}", link.SpanId); + parameters.Add($"TraceState{index}", link.TraceState); + } + + using var reader = connection.QueryMultiple(sql.ToString(), parameters, transaction); + for (var index = 0; index < batch.Length; index++) + { + batch[index].LinkId = reader.ReadSingle(); + } + } + } + + private static void InsertSpanLinkAttributes(SqliteConnection connection, IDbTransaction transaction, PendingSpanLink[] links) + { + var attributes = links + .SelectMany(link => link.Link.Attributes.Select((attribute, ordinal) => new + { + link.LinkId, + Ordinal = ordinal, + attribute.Key, + attribute.Value + })) + .ToArray(); + foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) + { + var sql = new StringBuilder(""" + INSERT INTO telemetry_span_link_attributes (link_id, ordinal, attribute_key, attribute_value) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@LinkId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); + parameters.Add($"LinkId{index}", batch[index].LinkId); + parameters.Add($"Ordinal{index}", batch[index].Ordinal); + parameters.Add($"Key{index}", batch[index].Key); + parameters.Add($"Value{index}", batch[index].Value); + } + sql.Append(';'); + connection.Execute(sql.ToString(), parameters, transaction); + } + } + + private OtlpSpan CreateSqliteSpan(OtlpResourceView resourceView, OtlpTrace trace, OtlpScope scope, Span span) + { + var spanId = span.SpanId?.ToHexString(); + if (spanId is null) + { + throw new ArgumentException("Span has no SpanId"); + } + + var modelSpan = new OtlpSpan(resourceView, trace, scope) + { + SpanId = spanId, + ParentSpanId = span.ParentSpanId?.ToHexString(), + Name = span.Name, + Kind = OtlpHelpers.ConvertSpanKind(span.Kind), + StartTime = OtlpHelpers.UnixNanoSecondsToDateTime(span.StartTimeUnixNano), + EndTime = OtlpHelpers.UnixNanoSecondsToDateTime(span.EndTimeUnixNano), + Status = ConvertSqliteStatus(span.Status), + StatusMessage = span.Status?.Message, + Attributes = span.Attributes.ToKeyValuePairs(_otlpContext, filter: attribute => attribute.Key != OtlpHelpers.AspireDestinationNameAttribute), + State = !string.IsNullOrEmpty(span.TraceState) ? span.TraceState : null, + Events = [], + Links = [], + BackLinks = [] + }; + + foreach (var spanEvent in span.Events.OrderBy(spanEvent => spanEvent.TimeUnixNano).Take(_otlpContext.Options.MaxSpanEventCount)) + { + modelSpan.Events.Add(new OtlpSpanEvent(modelSpan) + { + InternalId = Guid.NewGuid(), + Name = spanEvent.Name, + Time = OtlpHelpers.UnixNanoSecondsToDateTime(spanEvent.TimeUnixNano), + Attributes = spanEvent.Attributes.ToKeyValuePairs(_otlpContext) + }); + } + + foreach (var link in span.Links) + { + modelSpan.Links.Add(new OtlpSpanLink + { + SourceSpanId = spanId, + SourceTraceId = trace.TraceId, + TraceState = link.TraceState, + SpanId = link.SpanId.ToHexString(), + TraceId = link.TraceId.ToHexString(), + Attributes = link.Attributes.ToKeyValuePairs(_otlpContext) + }); + } + + return modelSpan; + } + + private static OtlpSpanStatusCode ConvertSqliteStatus(Status? status) + { + return status?.Code switch + { + Status.Types.StatusCode.Ok => OtlpSpanStatusCode.Ok, + Status.Types.StatusCode.Error => OtlpSpanStatusCode.Error, + _ => OtlpSpanStatusCode.Unset + }; + } + + private void TrimTracesToCapacity(SqliteConnection connection, IDbTransaction transaction) + { + connection.Execute(""" + DELETE FROM telemetry_traces + WHERE trace_id IN ( + SELECT trace_id + FROM telemetry_traces + ORDER BY first_span_timestamp_ticks, trace_id + LIMIT MAX((SELECT COUNT(*) FROM telemetry_traces) - @MaxTraceCount, 0) + ); + + DELETE FROM telemetry_resource_views + WHERE NOT EXISTS (SELECT 1 FROM telemetry_logs WHERE telemetry_logs.resource_view_id = telemetry_resource_views.resource_view_id) + AND NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_view_id = telemetry_resource_views.resource_view_id); + + UPDATE telemetry_resources + SET has_traces = EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_id = telemetry_resources.resource_id); + """, new { _otlpContext.Options.MaxTraceCount }, transaction); + } + + private void ClearSelectedTracesFromDatabase(Dictionary> selectedResources) + { + using var connection = _database.OpenConnection(); + var resources = connection.Query(""" + SELECT resource_name AS ResourceName, instance_id AS InstanceId + FROM telemetry_resources; + """); + foreach (var resource in resources) + { + var key = new ResourceKey(resource.ResourceName, resource.InstanceId); + if (selectedResources.TryGetValue(key.GetCompositeName(), out var dataTypes) && + dataTypes.Contains(AspireDataType.Traces) && + !dataTypes.Contains(AspireDataType.Resource)) + { + ClearTracesFromDatabase(key); + } + } + } + + private void RecalculateUninstrumentedPeers() + { + lock (_writeLock) + { + using var writeConnection = _database.OpenConnection(); + using var transaction = writeConnection.BeginTransaction(); + using var readConnection = _database.OpenConnection(); + // Return one row per span attribute (or one row with null attributes for spans without any). + // The parents CTE marks spans that have children so processing below can restrict peer + // resolution to client/producer leaf spans that have a peer address. Ordering keeps every + // span's attributes contiguous, which lets the loop finalize one span when its identity + // changes instead of buffering all spans and attributes. A separate connection keeps the + // unbuffered reader open while completed spans are written in batches through the transaction. + var rows = readConnection.Query(""" + WITH parents AS ( + SELECT DISTINCT trace_id, parent_span_id AS span_id + FROM telemetry_spans + WHERE parent_span_id IS NOT NULL + ) + SELECT + spans.trace_id AS TraceId, + spans.span_id AS SpanId, + spans.kind AS Kind, + parents.span_id IS NOT NULL AS HasChildren, + attributes.attribute_key AS AttributeKey, + attributes.attribute_value AS AttributeValue + FROM telemetry_spans AS spans + LEFT JOIN parents + ON parents.trace_id = spans.trace_id + AND parents.span_id = spans.span_id + LEFT JOIN telemetry_span_attributes AS attributes + ON attributes.trace_id = spans.trace_id + AND attributes.span_id = spans.span_id + ORDER BY spans.trace_id, spans.span_id, attributes.ordinal; + """, buffered: false); + var spanUpdates = new List(MaxSpanDetailBatchSize); + var spanAttributes = new List>(); + PeerRecalculationRowRecord? currentSpan = null; + + foreach (var row in rows) + { + if (currentSpan is not null && + (!string.Equals(currentSpan.TraceId, row.TraceId, StringComparison.Ordinal) || + !string.Equals(currentSpan.SpanId, row.SpanId, StringComparison.Ordinal))) + { + ProcessSpan(currentSpan, spanAttributes); + spanAttributes.Clear(); + } + + currentSpan = row; + if (row.AttributeKey is not null) + { + spanAttributes.Add(KeyValuePair.Create(row.AttributeKey, row.AttributeValue!)); + } + } + if (currentSpan is not null) + { + ProcessSpan(currentSpan, spanAttributes); + } + FlushSpanUpdates(); + RebuildTraceResourceAggregates( + writeConnection, + transaction, + writeConnection.Query("SELECT trace_id FROM telemetry_traces;", transaction: transaction)); + + writeConnection.Execute(""" + UPDATE telemetry_resources + SET uninstrumented_peer = 1 + WHERE resource_id IN ( + SELECT uninstrumented_peer_resource_id + FROM telemetry_spans + WHERE uninstrumented_peer_resource_id IS NOT NULL + ); + """, transaction: transaction); + var lastUpdatedTimestampTicks = DateTime.UtcNow.Ticks; + writeConnection.Execute(""" + UPDATE telemetry_traces + SET last_updated_timestamp_ticks = @LastUpdatedTimestampTicks + WHERE trace_id IN (SELECT trace_id FROM telemetry_spans); + """, new + { + LastUpdatedTimestampTicks = lastUpdatedTimestampTicks + }, transaction); + transaction.Commit(); + + void ProcessSpan(PeerRecalculationRowRecord span, IReadOnlyList> attributes) + { + long? peerResourceId = null; + if ((OtlpSpanKind)span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && + !span.HasChildren && + attributes.Count > 0) + { + var attributeArray = attributes.ToArray(); + if (attributeArray.GetPeerAddress() is not null && + TryResolvePeerResourceKey(attributeArray, out var peerKey)) + { + var cachedPeerResource = GetOrAddCachedResource(writeConnection, transaction, peerKey, uninstrumentedPeer: true); + peerResourceId = cachedPeerResource.ResourceId; + } + } + + spanUpdates.Add(new PeerSpanUpdateRecord + { + PeerResourceId = peerResourceId, + TraceId = span.TraceId, + SpanId = span.SpanId + }); + if (spanUpdates.Count == MaxSpanDetailBatchSize) + { + FlushSpanUpdates(); + } + } + + void FlushSpanUpdates() + { + if (spanUpdates.Count == 0) + { + return; + } + + UpdatePeerSpans(writeConnection, transaction, spanUpdates); + spanUpdates.Clear(); + } + } + } + + private bool TryResolvePeerResourceKey(KeyValuePair[] attributes, out ResourceKey peerKey) + { + foreach (var resolver in _outgoingPeerResolvers) + { + if (!resolver.TryResolvePeer(attributes, out var name, out var matchedResource)) + { + continue; + } + + if (matchedResource is not null) + { + peerKey = ResourceKey.Create(matchedResource.DisplayName, matchedResource.Name); + return true; + } + + if (!string.IsNullOrEmpty(name)) + { + peerKey = new ResourceKey(name, InstanceId: null); + return true; + } + } + + peerKey = default; + return false; + } + + private void ClearTracesFromDatabase(ResourceKey? resourceKey) + { + lock (_writeLock) + { + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + var parameters = new DynamicParameters(); + var where = string.Empty; + if (resourceKey is not null) + { + where = " WHERE resource_name = @ResourceName COLLATE NOCASE"; + parameters.Add("ResourceName", resourceKey.Value.Name); + if (resourceKey.Value.InstanceId is not null) + { + where += " AND instance_id = @InstanceId COLLATE NOCASE"; + parameters.Add("InstanceId", resourceKey.Value.InstanceId); + } + } + parameters.Add("ClearAll", resourceKey is null); + + connection.Execute($""" + DELETE FROM telemetry_traces + WHERE @ClearAll OR trace_id IN ( + SELECT DISTINCT trace_id + FROM telemetry_spans + WHERE resource_id IN (SELECT resource_id FROM telemetry_resources{where}) + ); + + DELETE FROM telemetry_resource_views + WHERE NOT EXISTS (SELECT 1 FROM telemetry_logs WHERE telemetry_logs.resource_view_id = telemetry_resource_views.resource_view_id) + AND NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_view_id = telemetry_resource_views.resource_view_id); + + UPDATE telemetry_resources + SET has_traces = EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_id = telemetry_resources.resource_id); + """, parameters, transaction); + DeleteOrphanedScopes(connection, transaction); + transaction.Commit(); + ClearMetadataCache(); + } + } + + private sealed record PendingSpan(long ResourceId, long ResourceViewId, long ScopeId, OtlpSpan Span); + + private readonly record struct IncomingSpanIdentity(string TraceId, string SpanId, string? ParentSpanId); + + private sealed record SpanOrderUpdateRecord(string TraceId, string SpanId, long ResourceOrderTicks); + + private sealed record PendingSpanEvent(string EventId, string TraceId, string SpanId, int Ordinal, OtlpSpanEvent Event); + + private sealed class TraceIngestionState + { + public required IReadOnlyDictionary ExistingTraces { get; init; } + public required IReadOnlyDictionary<(string TraceId, string SpanId), IngestionExistingSpanRecord> ExistingSpans { get; init; } + public required IReadOnlySet<(string TraceId, string ParentSpanId)> ExistingParentReferences { get; init; } + public required IReadOnlySet<(string TraceId, string SpanId)> CircularSpanIds { get; init; } + } + + private sealed class IngestionTraceRecord + { + public required string TraceId { get; init; } + public required long FirstSpanTimestampTicks { get; init; } + public required long LastSpanEndTimestampTicks { get; init; } + public required long LastUpdatedTimestampTicks { get; init; } + public required string FullName { get; init; } + public required string PrimarySpanId { get; init; } + public required bool HasError { get; init; } + public required bool HasGenAI { get; init; } + public string? PrimaryParentSpanId { get; init; } + public required long PrimaryStartTimeTicks { get; init; } + } + + private sealed class IngestionExistingSpanRecord + { + public required string TraceId { get; init; } + public required string SpanId { get; init; } + public required long ResourceOrderTicks { get; init; } + public long? UninstrumentedPeerResourceId { get; init; } + } + + private sealed class IngestionParentReferenceRecord + { + public required string TraceId { get; init; } + public required string ParentSpanId { get; init; } + } + + private sealed class IngestionAncestorRecord + { + public required string TraceId { get; init; } + public required string OriginSpanId { get; init; } + public required string SpanId { get; init; } + public string? ParentSpanId { get; init; } + } + + private sealed record TraceUpsertRecord( + string TraceId, + long FirstSpanTimestampTicks, + long LastSpanEndTimestampTicks, + long LastUpdatedTimestampTicks, + string FullName, + string PrimarySpanId, + bool HasError, + bool HasGenAI); + + private sealed class PendingSpanLink(OtlpSpanLink link) + { + public OtlpSpanLink Link { get; } = link; + public long LinkId { get; set; } + } + + private sealed class PeerRecalculationRowRecord + { + public required string TraceId { get; init; } + public required string SpanId { get; init; } + public required int Kind { get; init; } + public required bool HasChildren { get; init; } + public string? AttributeKey { get; init; } + public string? AttributeValue { get; init; } + } + + private sealed class PeerSpanUpdateRecord + { + public required string TraceId { get; init; } + public required string SpanId { get; init; } + public long? PeerResourceId { get; init; } + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs deleted file mode 100644 index d23eb00e376..00000000000 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.cs +++ /dev/null @@ -1,1804 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Data; -using System.Globalization; -using System.Text; -using Aspire.Dashboard.Model; -using Aspire.Dashboard.Model.Otlp; -using Aspire.Dashboard.Otlp.Model; -using Dapper; -using Google.Protobuf.Collections; -using Microsoft.Data.Sqlite; -using OpenTelemetry.Proto.Trace.V1; - -namespace Aspire.Dashboard.Otlp.Storage; - -public sealed partial class SqliteTelemetryRepository -{ - private const int MaxTraceBatchSize = 100; - private const int MaxSpanBatchSize = 50; - private const int MaxSpanDetailBatchSize = 100; - - private List AddTracesToDatabase(AddContext context, RepeatedField resourceSpans) - { - var addedSpans = new List(); - lock (_writeLock) - { - using var connection = _database.OpenConnection(); - using var transaction = connection.BeginTransaction(); - var traceIds = resourceSpans - .SelectMany(resource => resource.ScopeSpans) - .SelectMany(scope => scope.Spans) - .Select(span => span.TraceId.ToHexString()) - .Distinct(StringComparer.Ordinal); - var traces = LoadTracesForIngestion(connection, transaction, traceIds); - var pendingSpans = new List(); - var resourcesWithTraces = new HashSet(); - - foreach (var resourceSpansItem in resourceSpans) - { - OtlpResourceView resourceView; - CachedResource cachedResource; - long resourceId; - long resourceViewId; - try - { - var resourceKey = resourceSpansItem.Resource.GetResourceKey(); - cachedResource = GetOrAddCachedResource(connection, transaction, resourceKey); - resourceId = cachedResource.ResourceId; - var cachedView = GetOrAddCachedResourceView(connection, transaction, cachedResource, resourceSpansItem.Resource.Attributes); - resourceView = cachedView.View; - resourceViewId = cachedView.ResourceViewId; - } - catch (Exception exception) - { - context.FailureCount += resourceSpansItem.ScopeSpans.Sum(scope => scope.Spans.Count); - _otlpContext.Logger.LogInformation(exception, "Error adding resource."); - continue; - } - resourcesWithTraces.Add(cachedResource); - - foreach (var scopeSpans in resourceSpansItem.ScopeSpans) - { - OtlpScope scope; - long scopeId; - try - { - var cachedScope = GetOrAddCachedScope(connection, transaction, cachedResource, scopeSpans.Scope, CachedTelemetryType.Traces); - scopeId = cachedScope.Scope.ScopeId; - scope = cachedScope.Scope.Scope; - } - catch (Exception exception) - { - context.FailureCount += scopeSpans.Spans.Count; - _otlpContext.Logger.LogInformation(exception, "Error adding trace scope."); - continue; - } - - foreach (var span in scopeSpans.Spans) - { - try - { - var pendingSpan = PrepareSpan(traces, resourceId, resourceViewId, resourceView, scopeId, scope, span); - pendingSpans.Add(pendingSpan); - addedSpans.Add(pendingSpan.Span); - context.SuccessCount++; - } - catch (Exception exception) - { - context.FailureCount++; - _otlpContext.Logger.LogInformation(exception, "Error adding span."); - } - } - } - - } - - UpsertTraces(connection, transaction, traces.Values); - InsertSpans(connection, transaction, pendingSpans); - InsertSpanDetails(connection, transaction, pendingSpans); - UpdateUninstrumentedPeers(connection, transaction, traces.Values); - MarkResourcesHaveTraces(connection, transaction, resourcesWithTraces); - TrimTracesToCapacity(connection, transaction); - transaction.Commit(); - } - - return addedSpans; - } - - private Dictionary LoadTracesForIngestion( - SqliteConnection connection, - IDbTransaction transaction, - IEnumerable traceIds) - { - var traces = new Dictionary(StringComparer.Ordinal); - foreach (var batch in traceIds.Chunk(MaxTraceBatchSize)) - { - List spanRecords; - List attributeRecords; - using (var reader = connection.QueryMultiple(""" - SELECT - s.trace_id AS TraceId, - s.span_id AS SpanId, - s.parent_span_id AS ParentSpanId, - s.resource_id AS ResourceId, - s.resource_view_id AS ResourceViewId, - s.scope_id AS ScopeId, - s.name AS Name, - s.kind AS Kind, - s.start_time_ticks AS StartTimeTicks, - s.end_time_ticks AS EndTimeTicks, - s.status AS Status, - s.status_message AS StatusMessage, - s.trace_state AS State, - t.last_updated_timestamp_ticks AS LastUpdatedTimestampTicks - FROM telemetry_spans s - JOIN telemetry_traces t ON t.trace_id = s.trace_id - WHERE s.trace_id IN @TraceIds - ORDER BY s.trace_id, s.start_time_ticks, s.span_id; - - SELECT - trace_id AS TraceId, - span_id AS SpanId, - ordinal AS Ordinal, - attribute_key AS AttributeKey, - attribute_value AS AttributeValue - FROM telemetry_span_attributes - WHERE trace_id IN @TraceIds - ORDER BY trace_id, span_id, ordinal; - """, new { TraceIds = batch }, transaction)) - { - spanRecords = reader.Read().AsList(); - attributeRecords = reader.Read().AsList(); - } - - var attributes = attributeRecords.ToLookup(record => (record.TraceId, record.SpanId)); - foreach (var traceRecords in spanRecords.GroupBy(record => record.TraceId, StringComparer.Ordinal)) - { - var firstRecord = traceRecords.First(); - var trace = new OtlpTrace(Convert.FromHexString(firstRecord.TraceId), new DateTime(firstRecord.LastUpdatedTimestampTicks, DateTimeKind.Utc)); - foreach (var record in traceRecords) - { - var (_, view, scope) = GetCachedTelemetryMetadata(record.ResourceId, record.ResourceViewId, record.ScopeId, CachedTelemetryType.Traces); - trace.AddSpan(new OtlpSpan(view, trace, scope) - { - SpanId = record.SpanId, - ParentSpanId = record.ParentSpanId, - Name = record.Name, - Kind = (OtlpSpanKind)record.Kind, - StartTime = new DateTime(record.StartTimeTicks, DateTimeKind.Utc), - EndTime = new DateTime(record.EndTimeTicks, DateTimeKind.Utc), - Status = (OtlpSpanStatusCode)record.Status, - StatusMessage = record.StatusMessage, - State = record.State, - Attributes = attributes[(record.TraceId, record.SpanId)] - .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)) - .ToArray(), - Events = [], - Links = [], - BackLinks = [] - }, skipLastUpdatedDate: true); - } - traces.Add(trace.TraceId, trace); - } - } - - return traces; - } - - private PendingSpan PrepareSpan( - Dictionary traces, - long resourceId, - long resourceViewId, - OtlpResourceView resourceView, - long scopeId, - OtlpScope scope, - Span span) - { - var traceId = span.TraceId.ToHexString(); - var registerTrace = false; - if (!traces.TryGetValue(traceId, out var trace)) - { - trace = new OtlpTrace(span.TraceId.Memory, DateTime.UtcNow); - registerTrace = true; - } - var modelSpan = CreateSqliteSpan(resourceView, trace, scope, span); - trace.AddSpan(modelSpan); - if (registerTrace) - { - traces.Add(traceId, trace); - } - - return new PendingSpan(resourceId, resourceViewId, scopeId, modelSpan); - } - - private static void UpsertTraces(SqliteConnection connection, IDbTransaction transaction, IEnumerable traces) - { - foreach (var batch in traces.Chunk(MaxTraceBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_traces ( - trace_id, first_span_timestamp_ticks, duration_ticks, last_updated_timestamp_ticks, full_name) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) - { - if (index > 0) - { - sql.AppendLine(","); - } - var trace = batch[index]; - sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @FirstSpanTimestampTicks{index}, @DurationTicks{index}, @LastUpdatedTimestampTicks{index}, @FullName{index})"); - parameters.Add($"TraceId{index}", trace.TraceId); - parameters.Add($"FirstSpanTimestampTicks{index}", trace.TimeStamp.Ticks); - parameters.Add($"DurationTicks{index}", trace.Duration.Ticks); - parameters.Add($"LastUpdatedTimestampTicks{index}", trace.LastUpdatedDate.Ticks); - parameters.Add($"FullName{index}", trace.FullName); - } - sql.Append(""" - ON CONFLICT(trace_id) DO UPDATE SET - first_span_timestamp_ticks = excluded.first_span_timestamp_ticks, - duration_ticks = excluded.duration_ticks, - last_updated_timestamp_ticks = excluded.last_updated_timestamp_ticks, - full_name = excluded.full_name; - """); - connection.Execute(sql.ToString(), parameters, transaction); - } - } - - private static void InsertSpans(SqliteConnection connection, IDbTransaction transaction, List spans) - { - foreach (var batch in spans.Chunk(MaxSpanBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_spans ( - trace_id, span_id, parent_span_id, resource_id, resource_view_id, scope_id, name, kind, - start_time_ticks, end_time_ticks, status, status_message, trace_state) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) - { - if (index > 0) - { - sql.AppendLine(","); - } - var pendingSpan = batch[index]; - var span = pendingSpan.Span; - sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @ParentSpanId{index}, @ResourceId{index}, @ResourceViewId{index}, @ScopeId{index}, @Name{index}, @Kind{index}, @StartTimeTicks{index}, @EndTimeTicks{index}, @Status{index}, @StatusMessage{index}, @State{index})"); - parameters.Add($"TraceId{index}", span.TraceId); - parameters.Add($"SpanId{index}", span.SpanId); - parameters.Add($"ParentSpanId{index}", span.ParentSpanId); - parameters.Add($"ResourceId{index}", pendingSpan.ResourceId); - parameters.Add($"ResourceViewId{index}", pendingSpan.ResourceViewId); - parameters.Add($"ScopeId{index}", pendingSpan.ScopeId); - parameters.Add($"Name{index}", span.Name); - parameters.Add($"Kind{index}", (int)span.Kind); - parameters.Add($"StartTimeTicks{index}", span.StartTime.Ticks); - parameters.Add($"EndTimeTicks{index}", span.EndTime.Ticks); - parameters.Add($"Status{index}", (int)span.Status); - parameters.Add($"StatusMessage{index}", span.StatusMessage); - parameters.Add($"State{index}", span.State); - } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } - } - - private static void MarkResourcesHaveTraces(SqliteConnection connection, IDbTransaction transaction, HashSet resources) - { - var resourcesToUpdate = resources.Where(resource => !resource.Resource.HasTraces).ToArray(); - foreach (var batch in resourcesToUpdate.Chunk(MaxTraceBatchSize)) - { - connection.Execute( - "UPDATE telemetry_resources SET has_traces = 1 WHERE resource_id IN @ResourceIds;", - new { ResourceIds = batch.Select(resource => resource.ResourceId).ToArray() }, - transaction); - } - foreach (var resource in resourcesToUpdate) - { - resource.Resource.HasTraces = true; - } - } - - private void UpdateUninstrumentedPeers(SqliteConnection connection, IDbTransaction transaction, IEnumerable traces) - { - var peerResourceIds = new HashSet(); - var spanUpdates = new List(); - foreach (var trace in traces) - { - foreach (var span in trace.Spans) - { - OtlpResource? peer = null; - long? peerResourceId = null; - var hasPeerAddress = OtlpHelpers.GetPeerAddress(span.Attributes) is not null; - if (hasPeerAddress && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !span.GetChildSpans().Any()) - { - if (TryResolvePeerResourceKey(span.Attributes, out var peerKey)) - { - var cachedPeerResource = GetOrAddCachedResource(connection, transaction, peerKey, uninstrumentedPeer: true); - peerResourceId = cachedPeerResource.ResourceId; - peerResourceIds.Add(cachedPeerResource.ResourceId); - peer = cachedPeerResource.Resource; - } - } - - trace.SetSpanUninstrumentedPeer(span, peer); - spanUpdates.Add(new PeerSpanUpdateRecord - { - PeerResourceId = peerResourceId, - TraceId = span.TraceId, - SpanId = span.SpanId - }); - } - } - - if (peerResourceIds.Count > 0) - { - connection.Execute( - "UPDATE telemetry_resources SET uninstrumented_peer = 1 WHERE resource_id IN @ResourceIds;", - new { ResourceIds = peerResourceIds.ToArray() }, - transaction); - } - - UpdatePeerSpans(connection, transaction, spanUpdates); - } - - private static void UpdatePeerSpans(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList spanUpdates) - { - foreach (var batch in spanUpdates.Chunk(MaxSpanDetailBatchSize)) - { - var sql = new StringBuilder(""" - WITH peer_updates(trace_id, span_id, peer_resource_id) AS ( - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) - { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @PeerResourceId{index})"); - parameters.Add($"TraceId{index}", batch[index].TraceId); - parameters.Add($"SpanId{index}", batch[index].SpanId); - parameters.Add($"PeerResourceId{index}", batch[index].PeerResourceId); - } - sql.Append(""" - ) - UPDATE telemetry_spans AS spans - SET uninstrumented_peer_resource_id = peer_updates.peer_resource_id - FROM peer_updates - WHERE spans.trace_id = peer_updates.trace_id - AND spans.span_id = peer_updates.span_id; - """); - connection.Execute(sql.ToString(), parameters, transaction); - } - } - - private static void InsertSpanDetails(SqliteConnection connection, IDbTransaction transaction, List pendingSpans) - { - InsertSpanAttributes(connection, transaction, pendingSpans); - - var events = pendingSpans - .SelectMany(pendingSpan => pendingSpan.Span.Events.Select((spanEvent, ordinal) => new PendingSpanEvent( - spanEvent.InternalId.ToString("D"), - pendingSpan.Span.TraceId, - pendingSpan.Span.SpanId, - ordinal, - spanEvent))) - .ToArray(); - InsertSpanEvents(connection, transaction, events); - InsertSpanEventAttributes(connection, transaction, events); - - var links = pendingSpans - .SelectMany(pendingSpan => pendingSpan.Span.Links.Select(link => new PendingSpanLink(link))) - .ToArray(); - InsertSpanLinks(connection, transaction, links); - InsertSpanLinkAttributes(connection, transaction, links); - } - - private static void InsertSpanAttributes(SqliteConnection connection, IDbTransaction transaction, List pendingSpans) - { - var attributes = pendingSpans - .SelectMany(pendingSpan => pendingSpan.Span.Attributes.Select((attribute, ordinal) => new - { - pendingSpan.Span.TraceId, - pendingSpan.Span.SpanId, - Ordinal = ordinal, - attribute.Key, - attribute.Value - })) - .ToArray(); - foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_span_attributes (trace_id, span_id, ordinal, attribute_key, attribute_value) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) - { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); - parameters.Add($"TraceId{index}", batch[index].TraceId); - parameters.Add($"SpanId{index}", batch[index].SpanId); - parameters.Add($"Ordinal{index}", batch[index].Ordinal); - parameters.Add($"Key{index}", batch[index].Key); - parameters.Add($"Value{index}", batch[index].Value); - } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } - } - - private static void InsertSpanEvents(SqliteConnection connection, IDbTransaction transaction, PendingSpanEvent[] events) - { - foreach (var batch in events.Chunk(MaxSpanDetailBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_span_events (event_id, trace_id, span_id, ordinal, event_name, event_time_ticks) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) - { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@EventId{index}, @TraceId{index}, @SpanId{index}, @Ordinal{index}, @Name{index}, @TimeTicks{index})"); - parameters.Add($"EventId{index}", batch[index].EventId); - parameters.Add($"TraceId{index}", batch[index].TraceId); - parameters.Add($"SpanId{index}", batch[index].SpanId); - parameters.Add($"Ordinal{index}", batch[index].Ordinal); - parameters.Add($"Name{index}", batch[index].Event.Name); - parameters.Add($"TimeTicks{index}", batch[index].Event.Time.Ticks); - } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } - } - - private static void InsertSpanEventAttributes(SqliteConnection connection, IDbTransaction transaction, PendingSpanEvent[] events) - { - var attributes = events - .SelectMany(spanEvent => spanEvent.Event.Attributes.Select((attribute, ordinal) => new - { - spanEvent.EventId, - Ordinal = ordinal, - attribute.Key, - attribute.Value - })) - .ToArray(); - foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_span_event_attributes (event_id, ordinal, attribute_key, attribute_value) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) - { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@EventId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); - parameters.Add($"EventId{index}", batch[index].EventId); - parameters.Add($"Ordinal{index}", batch[index].Ordinal); - parameters.Add($"Key{index}", batch[index].Key); - parameters.Add($"Value{index}", batch[index].Value); - } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } - } - - private static void InsertSpanLinks(SqliteConnection connection, IDbTransaction transaction, PendingSpanLink[] links) - { - foreach (var batch in links.Chunk(MaxSpanDetailBatchSize)) - { - var sql = new StringBuilder(); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) - { - var link = batch[index].Link; - sql.Append(CultureInfo.InvariantCulture, $$""" - INSERT INTO telemetry_span_links ( - source_trace_id, source_span_id, target_trace_id, target_span_id, trace_state) - VALUES (@SourceTraceId{{index}}, @SourceSpanId{{index}}, @TraceId{{index}}, @SpanId{{index}}, @TraceState{{index}}) - RETURNING link_id; - """); - parameters.Add($"SourceTraceId{index}", link.SourceTraceId); - parameters.Add($"SourceSpanId{index}", link.SourceSpanId); - parameters.Add($"TraceId{index}", link.TraceId); - parameters.Add($"SpanId{index}", link.SpanId); - parameters.Add($"TraceState{index}", link.TraceState); - } - - using var reader = connection.QueryMultiple(sql.ToString(), parameters, transaction); - for (var index = 0; index < batch.Length; index++) - { - batch[index].LinkId = reader.ReadSingle(); - } - } - } - - private static void InsertSpanLinkAttributes(SqliteConnection connection, IDbTransaction transaction, PendingSpanLink[] links) - { - var attributes = links - .SelectMany(link => link.Link.Attributes.Select((attribute, ordinal) => new - { - link.LinkId, - Ordinal = ordinal, - attribute.Key, - attribute.Value - })) - .ToArray(); - foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_span_link_attributes (link_id, ordinal, attribute_key, attribute_value) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) - { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@LinkId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); - parameters.Add($"LinkId{index}", batch[index].LinkId); - parameters.Add($"Ordinal{index}", batch[index].Ordinal); - parameters.Add($"Key{index}", batch[index].Key); - parameters.Add($"Value{index}", batch[index].Value); - } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } - } - - private OtlpSpan CreateSqliteSpan(OtlpResourceView resourceView, OtlpTrace trace, OtlpScope scope, Span span) - { - var spanId = span.SpanId?.ToHexString(); - if (spanId is null) - { - throw new ArgumentException("Span has no SpanId"); - } - - var modelSpan = new OtlpSpan(resourceView, trace, scope) - { - SpanId = spanId, - ParentSpanId = span.ParentSpanId?.ToHexString(), - Name = span.Name, - Kind = OtlpHelpers.ConvertSpanKind(span.Kind), - StartTime = OtlpHelpers.UnixNanoSecondsToDateTime(span.StartTimeUnixNano), - EndTime = OtlpHelpers.UnixNanoSecondsToDateTime(span.EndTimeUnixNano), - Status = ConvertSqliteStatus(span.Status), - StatusMessage = span.Status?.Message, - Attributes = span.Attributes.ToKeyValuePairs(_otlpContext, filter: attribute => attribute.Key != OtlpHelpers.AspireDestinationNameAttribute), - State = !string.IsNullOrEmpty(span.TraceState) ? span.TraceState : null, - Events = [], - Links = [], - BackLinks = [] - }; - - foreach (var spanEvent in span.Events.OrderBy(spanEvent => spanEvent.TimeUnixNano).Take(_otlpContext.Options.MaxSpanEventCount)) - { - modelSpan.Events.Add(new OtlpSpanEvent(modelSpan) - { - InternalId = Guid.NewGuid(), - Name = spanEvent.Name, - Time = OtlpHelpers.UnixNanoSecondsToDateTime(spanEvent.TimeUnixNano), - Attributes = spanEvent.Attributes.ToKeyValuePairs(_otlpContext) - }); - } - - foreach (var link in span.Links) - { - modelSpan.Links.Add(new OtlpSpanLink - { - SourceSpanId = spanId, - SourceTraceId = trace.TraceId, - TraceState = link.TraceState, - SpanId = link.SpanId.ToHexString(), - TraceId = link.TraceId.ToHexString(), - Attributes = link.Attributes.ToKeyValuePairs(_otlpContext) - }); - } - - return modelSpan; - } - - private static OtlpSpanStatusCode ConvertSqliteStatus(Status? status) - { - return status?.Code switch - { - Status.Types.StatusCode.Ok => OtlpSpanStatusCode.Ok, - Status.Types.StatusCode.Error => OtlpSpanStatusCode.Error, - _ => OtlpSpanStatusCode.Unset - }; - } - - private void TrimTracesToCapacity(SqliteConnection connection, IDbTransaction transaction) - { - connection.Execute(""" - DELETE FROM telemetry_traces - WHERE trace_id IN ( - SELECT trace_id - FROM telemetry_traces - ORDER BY first_span_timestamp_ticks, trace_id - LIMIT MAX((SELECT COUNT(*) FROM telemetry_traces) - @MaxTraceCount, 0) - ); - - DELETE FROM telemetry_resource_views - WHERE NOT EXISTS (SELECT 1 FROM telemetry_logs WHERE telemetry_logs.resource_view_id = telemetry_resource_views.resource_view_id) - AND NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_view_id = telemetry_resource_views.resource_view_id); - - UPDATE telemetry_resources - SET has_traces = EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_id = telemetry_resources.resource_id); - """, new { _otlpContext.Options.MaxTraceCount }, transaction); - } - - private GetTracesResponse GetTracesFromDatabase(GetTracesRequest context) - { - using var connection = _database.OpenConnection(); - var query = BuildTraceQuery(context); - var aggregate = connection.QuerySingle($""" - SELECT COUNT(*) AS TotalItemCount, COALESCE(MAX(t.duration_ticks), 0) AS MaxDurationTicks - {query.FromAndWhere}; - """, query.Parameters); - query.Parameters.Add("StartIndex", Math.Max(context.StartIndex, 0)); - query.Parameters.Add("Count", Math.Max(context.Count, 0)); - var records = connection.Query($""" - SELECT - t.trace_id AS TraceId, - t.last_updated_timestamp_ticks AS LastUpdatedTimestampTicks - {query.FromAndWhere} - ORDER BY t.first_span_timestamp_ticks, t.trace_id - LIMIT @Count OFFSET @StartIndex; - """, query.Parameters).AsList(); - var traces = records.Select(record => MaterializeTrace(connection, record.TraceId)!).ToList(); - return new GetTracesResponse - { - PagedResult = new PagedResult - { - Items = traces, - TotalItemCount = aggregate.TotalItemCount, - IsFull = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_traces;") >= _otlpContext.Options.MaxTraceCount - }, - MaxDuration = TimeSpan.FromTicks(aggregate.MaxDurationTicks) - }; - } - - private GetTraceSummariesResponse GetTraceSummariesFromDatabase(GetTracesRequest context) - { - using var connection = _database.OpenConnection(); - var query = BuildTraceQuery(context); - query.Parameters.Add("StartIndex", Math.Max(context.StartIndex, 0)); - query.Parameters.Add("Count", Math.Max(context.Count, 0)); - query.Parameters.Add("MaxTraceCount", _otlpContext.Options.MaxTraceCount); - - // Build the page and its resource groups in one query. The recursive span tree preserves - // the resource ordering used by TraceHelpers when a child span starts before its parent. - var records = connection.Query($""" - WITH RECURSIVE - filtered_traces AS ( - SELECT t.* - {query.FromAndWhere} - ), - trace_aggregate AS ( - SELECT COUNT(*) AS TotalItemCount, COALESCE(MAX(duration_ticks), 0) AS MaxDurationTicks - FROM filtered_traces - ), - paged_traces AS ( - SELECT * - FROM filtered_traces - ORDER BY first_span_timestamp_ticks, trace_id - LIMIT @Count OFFSET @StartIndex - ), - span_tree AS ( - SELECT - s.trace_id, - s.span_id, - s.resource_id, - s.uninstrumented_peer_resource_id, - s.status, - s.start_time_ticks AS resource_order_ticks - FROM telemetry_spans s - JOIN paged_traces pt ON pt.trace_id = s.trace_id - WHERE NOT EXISTS ( - SELECT 1 - FROM telemetry_spans parent - WHERE parent.trace_id = s.trace_id AND parent.span_id = s.parent_span_id - ) - - UNION ALL - - SELECT - child.trace_id, - child.span_id, - child.resource_id, - child.uninstrumented_peer_resource_id, - child.status, - MAX(child.start_time_ticks, parent.resource_order_ticks) - FROM telemetry_spans child - JOIN span_tree parent ON parent.trace_id = child.trace_id AND parent.span_id = child.parent_span_id - ), - span_resources AS ( - SELECT - st.trace_id, - st.resource_id, - st.status, - st.resource_order_ticks - FROM span_tree st - - UNION ALL - - SELECT - st.trace_id, - st.uninstrumented_peer_resource_id, - st.status, - st.resource_order_ticks - FROM span_tree st - WHERE st.uninstrumented_peer_resource_id IS NOT NULL - ), - resource_summaries AS ( - SELECT - sr.trace_id, - r.resource_name, - r.instance_id, - r.uninstrumented_peer, - MIN(sr.resource_order_ticks) AS resource_order_ticks, - COUNT(*) AS total_spans, - SUM(CASE WHEN sr.status = 2 THEN 1 ELSE 0 END) AS errored_spans - FROM span_resources sr - JOIN telemetry_resources r ON r.resource_id = sr.resource_id - GROUP BY sr.trace_id, sr.resource_id - ), - primary_spans AS ( - SELECT - s.trace_id, - r.resource_name, - r.instance_id, - r.uninstrumented_peer, - ROW_NUMBER() OVER ( - PARTITION BY s.trace_id - ORDER BY CASE WHEN s.parent_span_id IS NULL OR s.parent_span_id = '' THEN 0 ELSE 1 END, s.start_time_ticks, s.span_id - ) AS row_number - FROM telemetry_spans s - JOIN paged_traces pt ON pt.trace_id = s.trace_id - JOIN telemetry_resources r ON r.resource_id = s.resource_id - ), - trace_summaries AS ( - SELECT - pt.trace_id, - pt.full_name, - pt.first_span_timestamp_ticks, - pt.duration_ticks, - ps.resource_name AS root_resource_name, - ps.instance_id AS root_instance_id, - ps.uninstrumented_peer AS root_uninstrumented_peer, - EXISTS (SELECT 1 FROM telemetry_spans s WHERE s.trace_id = pt.trace_id AND s.status = 2) AS has_error, - EXISTS ( - SELECT 1 - FROM telemetry_span_attributes a - WHERE a.trace_id = pt.trace_id - AND a.attribute_key IN ('gen_ai.system', 'gen_ai.provider.name') - AND LENGTH(a.attribute_value) > 0 - ) AS has_gen_ai, - pt.first_span_timestamp_ticks AS trace_order_ticks - FROM paged_traces pt - JOIN primary_spans ps ON ps.trace_id = pt.trace_id AND ps.row_number = 1 - ) - SELECT - a.TotalItemCount, - a.MaxDurationTicks, - (SELECT COUNT(*) FROM telemetry_traces) >= @MaxTraceCount AS IsFull, - ts.trace_id AS TraceId, - ts.full_name AS FullName, - ts.first_span_timestamp_ticks AS StartTimeTicks, - ts.duration_ticks AS DurationTicks, - ts.root_resource_name AS RootResourceName, - ts.root_instance_id AS RootInstanceId, - ts.root_uninstrumented_peer AS RootUninstrumentedPeer, - ts.has_error AS HasError, - ts.has_gen_ai AS HasGenAI, - rs.resource_name AS ResourceName, - rs.instance_id AS InstanceId, - rs.uninstrumented_peer AS UninstrumentedPeer, - rs.total_spans AS TotalSpans, - rs.errored_spans AS ErroredSpans - FROM trace_aggregate a - LEFT JOIN trace_summaries ts ON 1 = 1 - LEFT JOIN resource_summaries rs ON rs.trace_id = ts.trace_id - ORDER BY ts.trace_order_ticks, ts.trace_id, rs.resource_order_ticks, rs.resource_name, rs.instance_id; - """, query.Parameters).AsList(); - - var firstRecord = records[0]; - var summaries = records - .Where(record => record.TraceId is not null) - .GroupBy(record => record.TraceId!, StringComparer.Ordinal) - .Select(group => - { - var trace = group.First(); - return new TraceSummary - { - TraceId = trace.TraceId!, - FullName = trace.FullName!, - StartTime = new DateTime(trace.StartTimeTicks!.Value, DateTimeKind.Utc), - Duration = TimeSpan.FromTicks(trace.DurationTicks!.Value), - RootResource = CreateSummaryResource(trace.RootResourceName!, trace.RootInstanceId, trace.RootUninstrumentedPeer!.Value), - Resources = group.Select(resource => new TraceResourceSummary - { - Resource = CreateSummaryResource(resource.ResourceName!, resource.InstanceId, resource.UninstrumentedPeer!.Value), - TotalSpans = resource.TotalSpans!.Value, - ErroredSpans = resource.ErroredSpans!.Value - }).ToList(), - HasError = trace.HasError!.Value, - HasGenAI = trace.HasGenAI!.Value - }; - }).ToList(); - - return new GetTraceSummariesResponse - { - PagedResult = new PagedResult - { - Items = summaries, - TotalItemCount = firstRecord.TotalItemCount, - IsFull = firstRecord.IsFull - }, - MaxDuration = TimeSpan.FromTicks(firstRecord.MaxDurationTicks) - }; - - OtlpResource CreateSummaryResource(string resourceName, string? instanceId, bool uninstrumentedPeer) => - new(resourceName, instanceId, uninstrumentedPeer, _otlpContext); - } - - private static TraceQuery BuildTraceQuery(GetTracesRequest context) - { - var sql = new StringBuilder("FROM telemetry_traces t WHERE 1 = 1"); - var parameters = new DynamicParameters(); - if (context.ResourceKeys.Count > 0) - { - var resourcePredicates = new List(context.ResourceKeys.Count); - for (var index = 0; index < context.ResourceKeys.Count; index++) - { - var key = context.ResourceKeys[index]; - parameters.Add($"ResourceName{index}", key.Name); - var sourcePredicate = $"r.resource_name = @ResourceName{index} COLLATE NOCASE"; - var peerPredicate = $"pr.resource_name = @ResourceName{index} COLLATE NOCASE"; - if (key.InstanceId is not null) - { - parameters.Add($"InstanceId{index}", key.InstanceId); - sourcePredicate += $" AND r.instance_id = @InstanceId{index} COLLATE NOCASE"; - peerPredicate += $" AND pr.instance_id = @InstanceId{index} COLLATE NOCASE"; - } - resourcePredicates.Add($"(({sourcePredicate}) OR ({peerPredicate}))"); - } - sql.Append(" AND EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id WHERE s.trace_id = t.trace_id AND ("); - sql.AppendJoin(" OR ", resourcePredicates); - sql.Append("))"); - } - if (!string.IsNullOrWhiteSpace(context.TraceNameFilterText)) - { - sql.Append(" AND t.full_name LIKE @TraceNameFilterText ESCAPE '!'"); - parameters.Add("TraceNameFilterText", CreateContainsLikePattern(context.TraceNameFilterText)); - } - - var positivePredicates = new List(); - var filterIndex = 0; - foreach (var filter in context.Filters.Where(filter => filter.Enabled)) - { - if (filter is not FieldTelemetryFilter fieldFilter) - { - continue; - } - - if (fieldFilter.Field == KnownTraceFields.DurationField) - { - sql.Append(" AND "); - sql.Append(BuildTraceDurationPredicate(fieldFilter, parameters, filterIndex++)); - continue; - } - - if (fieldFilter.Condition is FilterCondition.NotEqual or FilterCondition.NotContains) - { - sql.Append(" AND NOT EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id WHERE s.trace_id = t.trace_id AND "); - sql.Append(BuildSpanFieldPredicate(fieldFilter, parameters, filterIndex++, invertNegative: true)); - sql.Append(')'); - } - else - { - positivePredicates.Add(BuildSpanFieldPredicate(fieldFilter, parameters, filterIndex++, invertNegative: false)); - } - } - if (positivePredicates.Count > 0) - { - sql.Append(" AND EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id WHERE s.trace_id = t.trace_id AND "); - sql.AppendJoin(" AND ", positivePredicates); - sql.Append(')'); - } - - if (context.TextFragments is { Length: > 0 }) - { - var fullNamePredicates = new List(context.TextFragments.Length); - var spanPredicates = new List(context.TextFragments.Length); - for (var index = 0; index < context.TextFragments.Length; index++) - { - var parameterName = $"TextFragment{index}"; - parameters.Add(parameterName, CreateContainsLikePattern(context.TextFragments[index])); - fullNamePredicates.Add($"t.full_name LIKE @{parameterName} ESCAPE '!'"); - spanPredicates.Add($""" - ( - s.name LIKE @{parameterName} ESCAPE '!' OR - s.span_id LIKE @{parameterName} ESCAPE '!' OR - s.trace_id LIKE @{parameterName} ESCAPE '!' OR - sc.scope_name LIKE @{parameterName} ESCAPE '!' OR - r.resource_name LIKE @{parameterName} ESCAPE '!' OR - CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END LIKE @{parameterName} ESCAPE '!' OR - CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END LIKE @{parameterName} ESCAPE '!' OR - COALESCE(s.status_message, '') LIKE @{parameterName} ESCAPE '!' OR - EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND (a.attribute_key LIKE @{parameterName} ESCAPE '!' OR a.attribute_value LIKE @{parameterName} ESCAPE '!')) OR - EXISTS (SELECT 1 FROM telemetry_span_events e WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id AND e.event_name LIKE @{parameterName} ESCAPE '!') - ) - """); - } - sql.Append(" AND (("); - sql.AppendJoin(" AND ", fullNamePredicates); - sql.Append(") OR EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id WHERE s.trace_id = t.trace_id AND "); - sql.AppendJoin(" AND ", spanPredicates); - sql.Append("))"); - } - return new TraceQuery(sql.ToString(), parameters); - } - - private static string BuildTraceDurationPredicate(FieldTelemetryFilter filter, DynamicParameters parameters, int filterIndex) - { - var parameterName = $"TraceDuration{filterIndex}"; - if (!double.TryParse(filter.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var milliseconds) || !double.IsFinite(milliseconds)) - { - return "0 = 1"; - } - - parameters.Add(parameterName, milliseconds); - return BuildNumericPredicate($"(CAST(t.duration_ticks AS REAL) / {TimeSpan.TicksPerMillisecond})", filter.Condition, parameterName); - } - - private static string BuildSpanFieldPredicate( - FieldTelemetryFilter filter, - DynamicParameters parameters, - int filterIndex, - bool invertNegative) - { - var parameterName = $"TraceFilter{filterIndex}"; - var condition = invertNegative - ? filter.Condition switch - { - FilterCondition.NotEqual => FilterCondition.Equals, - FilterCondition.NotContains => FilterCondition.Contains, - _ => filter.Condition - } - : filter.Condition; - parameters.Add( - parameterName, - condition is FilterCondition.Contains or FilterCondition.NotContains - ? CreateContainsLikePattern(filter.Value) - : filter.Value); - - var expression = filter.Field switch - { - KnownResourceFields.ServiceNameField => null, - KnownTraceFields.TraceIdField => "s.trace_id", - KnownTraceFields.SpanIdField => "s.span_id", - KnownTraceFields.NameField => "s.name", - KnownTraceFields.KindField => "CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END", - KnownTraceFields.StatusField => "CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END", - KnownSourceFields.NameField => "sc.scope_name", - KnownTraceFields.TimestampField => "s.start_time_ticks / 10000", - _ => null - }; - if (filter.Field == KnownTraceFields.TimestampField) - { - if (!DateTime.TryParse(filter.Value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeLocal, out var date)) - { - return "0 = 1"; - } - parameters.Add(parameterName, date.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond); - return BuildNumericPredicate(expression!, condition, parameterName); - } - if (expression is not null) - { - return BuildStringPredicate(expression, condition, parameterName); - } - if (filter.Field == KnownResourceFields.ServiceNameField) - { - var sourcePredicate = BuildStringPredicate("r.resource_name", condition, parameterName); - var peerPredicate = BuildStringPredicate("pr.resource_name", condition, parameterName); - return $"({sourcePredicate} OR (pr.resource_id IS NOT NULL AND {peerPredicate}))"; - } - - var attributePredicate = BuildStringPredicate("a.attribute_value", condition, parameterName); - parameters.Add($"TraceField{filterIndex}", filter.Field); - return $"EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND a.attribute_key = @TraceField{filterIndex} COLLATE NOCASE AND {attributePredicate})"; - } - - private GetSpansResponse GetSpansFromDatabase(GetSpansRequest context) - { - using var connection = _database.OpenConnection(); - var query = BuildSpanQuery(context); - var totalCount = connection.QuerySingle($"SELECT COUNT(*) {query.FromAndWhere};", query.Parameters); - query.Parameters.Add("StartIndex", Math.Max(context.StartIndex, 0)); - query.Parameters.Add("Count", Math.Max(context.Count, 0)); - var identities = connection.Query($""" - SELECT s.trace_id AS TraceId, s.span_id AS SpanId - {query.FromAndWhere} - ORDER BY t.first_span_timestamp_ticks, t.trace_id, s.start_time_ticks, s.span_id - LIMIT @Count OFFSET @StartIndex; - """, query.Parameters).AsList(); - var traces = identities.Select(identity => identity.TraceId).Distinct(StringComparer.Ordinal) - .ToDictionary(traceId => traceId, traceId => MaterializeTrace(connection, traceId)!, StringComparer.Ordinal); - return new GetSpansResponse - { - PagedResult = new PagedResult - { - Items = identities.Select(identity => traces[identity.TraceId].Spans.Single(span => span.SpanId == identity.SpanId)).ToList(), - TotalItemCount = totalCount, - IsFull = connection.QuerySingle("SELECT COUNT(*) FROM telemetry_traces;") >= _otlpContext.Options.MaxTraceCount - } - }; - } - - private static TraceQuery BuildSpanQuery(GetSpansRequest context) - { - var sql = new StringBuilder(""" - FROM telemetry_spans s - JOIN telemetry_traces t ON t.trace_id = s.trace_id - JOIN telemetry_resources r ON r.resource_id = s.resource_id - JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id - LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id - WHERE 1 = 1 - """); - var parameters = new DynamicParameters(); - if (context.ResourceKeys.Count > 0) - { - var predicates = new List(context.ResourceKeys.Count); - for (var index = 0; index < context.ResourceKeys.Count; index++) - { - var key = context.ResourceKeys[index]; - parameters.Add($"SpanResourceName{index}", key.Name); - var source = $"r.resource_name = @SpanResourceName{index} COLLATE NOCASE"; - var peer = $"pr.resource_name = @SpanResourceName{index} COLLATE NOCASE"; - if (key.InstanceId is not null) - { - parameters.Add($"SpanInstanceId{index}", key.InstanceId); - source += $" AND r.instance_id = @SpanInstanceId{index} COLLATE NOCASE"; - peer += $" AND pr.instance_id = @SpanInstanceId{index} COLLATE NOCASE"; - } - predicates.Add($"(({source}) OR ({peer}))"); - } - sql.Append(" AND ("); - sql.AppendJoin(" OR ", predicates); - sql.Append(')'); - } - if (!string.IsNullOrEmpty(context.TraceId)) - { - parameters.Add( - "SpanTraceId", - context.TraceId.Length >= OtlpHelpers.ShortenedIdLength - ? CreateStartsWithLikePattern(context.TraceId) - : context.TraceId); - sql.Append(context.TraceId.Length >= OtlpHelpers.ShortenedIdLength - ? " AND s.trace_id LIKE @SpanTraceId ESCAPE '!'" - : " AND s.trace_id = @SpanTraceId COLLATE NOCASE"); - } - if (context.HasError is not null) - { - parameters.Add("SpanErrorStatus", (int)OtlpSpanStatusCode.Error); - sql.Append(context.HasError.Value ? " AND s.status = @SpanErrorStatus" : " AND s.status <> @SpanErrorStatus"); - } - - var filterIndex = 0; - foreach (var filter in context.Filters.Where(filter => filter.Enabled)) - { - if (filter is not FieldTelemetryFilter fieldFilter) - { - continue; - } - if (fieldFilter.Field == KnownTraceFields.DurationField) - { - var parameterName = $"SpanDuration{filterIndex++}"; - if (!double.TryParse(fieldFilter.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var milliseconds) || !double.IsFinite(milliseconds)) - { - sql.Append(" AND 0 = 1"); - continue; - } - parameters.Add(parameterName, milliseconds); - sql.Append(" AND "); - sql.Append(BuildNumericPredicate($"(CAST(s.end_time_ticks - s.start_time_ticks AS REAL) / {TimeSpan.TicksPerMillisecond})", fieldFilter.Condition, parameterName)); - continue; - } - - if (fieldFilter.Condition is FilterCondition.NotEqual or FilterCondition.NotContains && - fieldFilter.Field is not (KnownResourceFields.ServiceNameField or KnownTraceFields.TraceIdField or KnownTraceFields.SpanIdField or KnownTraceFields.NameField or KnownTraceFields.KindField or KnownTraceFields.StatusField or KnownSourceFields.NameField or KnownTraceFields.TimestampField)) - { - var violationFilter = new FieldTelemetryFilter - { - Field = fieldFilter.Field, - Condition = fieldFilter.Condition == FilterCondition.NotEqual ? FilterCondition.Equals : FilterCondition.Contains, - Value = fieldFilter.Value - }; - sql.Append(" AND NOT "); - sql.Append(BuildSpanFieldPredicate(violationFilter, parameters, filterIndex++, invertNegative: false)); - } - else - { - sql.Append(" AND "); - sql.Append(BuildSpanFieldPredicate(fieldFilter, parameters, filterIndex++, invertNegative: false)); - } - } - - if (context.TextFragments is { Length: > 0 }) - { - for (var index = 0; index < context.TextFragments.Length; index++) - { - var parameterName = $"SpanTextFragment{index}"; - parameters.Add(parameterName, CreateContainsLikePattern(context.TextFragments[index])); - sql.Append(CultureInfo.InvariantCulture, $""" - AND ( - s.name LIKE @{parameterName} ESCAPE '!' OR - s.span_id LIKE @{parameterName} ESCAPE '!' OR - s.trace_id LIKE @{parameterName} ESCAPE '!' OR - sc.scope_name LIKE @{parameterName} ESCAPE '!' OR - r.resource_name LIKE @{parameterName} ESCAPE '!' OR - CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END LIKE @{parameterName} ESCAPE '!' OR - CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END LIKE @{parameterName} ESCAPE '!' OR - COALESCE(s.status_message, '') LIKE @{parameterName} ESCAPE '!' OR - EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND (a.attribute_key LIKE @{parameterName} ESCAPE '!' OR a.attribute_value LIKE @{parameterName} ESCAPE '!')) OR - EXISTS (SELECT 1 FROM telemetry_span_events e WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id AND e.event_name LIKE @{parameterName} ESCAPE '!') - ) - """); - } - } - return new TraceQuery(sql.ToString(), parameters); - } - - private List GetTracePropertyKeysFromDatabase(ResourceKey? resourceKey) - { - using var connection = _database.OpenConnection(); - var parameters = new DynamicParameters(); - var resourceWhere = string.Empty; - if (resourceKey is not null) - { - resourceWhere = " AND r.resource_name = @ResourceName COLLATE NOCASE"; - parameters.Add("ResourceName", resourceKey.Value.Name); - if (resourceKey.Value.InstanceId is not null) - { - resourceWhere += " AND r.instance_id = @InstanceId COLLATE NOCASE"; - parameters.Add("InstanceId", resourceKey.Value.InstanceId); - } - } - return connection.Query($""" - SELECT DISTINCT a.attribute_key - FROM telemetry_span_attributes a - JOIN telemetry_spans s ON s.trace_id = a.trace_id AND s.span_id = a.span_id - JOIN telemetry_resources r ON r.resource_id = s.resource_id - WHERE 1 = 1{resourceWhere} - ORDER BY a.attribute_key; - """, parameters).AsList(); - } - - private Dictionary GetTraceFieldValuesFromDatabase(string attributeName) - { - if (attributeName is KnownTraceFields.DurationField or KnownTraceFields.TimestampField) - { - return new Dictionary(StringComparers.OtlpAttribute); - } - - using var connection = _database.OpenConnection(); - IEnumerable values = attributeName switch - { - KnownResourceFields.ServiceNameField => connection.Query(""" - SELECT resource_name AS FieldValue, COUNT(*) AS ValueCount - FROM ( - SELECT r.resource_name - FROM telemetry_spans s - JOIN telemetry_resources r ON r.resource_id = s.resource_id - UNION ALL - SELECT r.resource_name - FROM telemetry_spans s - JOIN telemetry_resources r ON r.resource_id = s.uninstrumented_peer_resource_id - ) - GROUP BY resource_name; - """), - KnownTraceFields.TraceIdField => QueryFieldValues("trace_id", "telemetry_spans"), - KnownTraceFields.SpanIdField => QueryFieldValues("span_id", "telemetry_spans"), - KnownTraceFields.KindField => connection.Query(""" - SELECT - CASE kind - WHEN 0 THEN 'Unspecified' - WHEN 1 THEN 'Internal' - WHEN 2 THEN 'Server' - WHEN 3 THEN 'Client' - WHEN 4 THEN 'Producer' - WHEN 5 THEN 'Consumer' - ELSE CAST(kind AS TEXT) - END AS FieldValue, - COUNT(*) AS ValueCount - FROM telemetry_spans - GROUP BY kind; - """), - KnownTraceFields.StatusField => connection.Query(""" - SELECT - CASE status - WHEN 0 THEN 'Unset' - WHEN 1 THEN 'Ok' - WHEN 2 THEN 'Error' - ELSE CAST(status AS TEXT) - END AS FieldValue, - COUNT(*) AS ValueCount - FROM telemetry_spans - GROUP BY status; - """), - KnownSourceFields.NameField => connection.Query(""" - SELECT sc.scope_name AS FieldValue, COUNT(*) AS ValueCount - FROM telemetry_spans s - JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id - GROUP BY sc.scope_name; - """), - KnownTraceFields.NameField => QueryFieldValues("name", "telemetry_spans"), - _ => connection.Query(""" - SELECT attribute_value AS FieldValue, COUNT(*) AS ValueCount - FROM telemetry_span_attributes - WHERE attribute_key = @AttributeName COLLATE NOCASE - GROUP BY attribute_value; - """, new { AttributeName = attributeName }) - }; - - return values.ToDictionary(record => record.FieldValue!, record => record.ValueCount, StringComparers.OtlpAttribute); - - IEnumerable QueryFieldValues(string expression, string table) - { - return connection.Query($""" - SELECT {expression} AS FieldValue, COUNT(*) AS ValueCount - FROM {table} - GROUP BY {expression}; - """); - } - } - - private bool HasUpdatedTraceInDatabase(OtlpTrace trace) - { - using var connection = _database.OpenConnection(); - var lastUpdatedTicks = connection.QuerySingleOrDefault(""" - SELECT last_updated_timestamp_ticks - FROM telemetry_traces - WHERE trace_id = @TraceId; - """, new { trace.TraceId }); - return lastUpdatedTicks is null || lastUpdatedTicks.Value > trace.LastUpdatedDate.Ticks; - } - - private OtlpTrace? GetTraceFromDatabase(string traceId) - { - using var connection = _database.OpenConnection(); - var usePrefix = traceId.Length >= OtlpHelpers.ShortenedIdLength; - var storedTraceId = connection.QueryFirstOrDefault(usePrefix - ? "SELECT trace_id FROM telemetry_traces WHERE trace_id LIKE @TraceId ESCAPE '!' ORDER BY first_span_timestamp_ticks, trace_id LIMIT 1;" - : "SELECT trace_id FROM telemetry_traces WHERE trace_id = @TraceId COLLATE NOCASE ORDER BY first_span_timestamp_ticks, trace_id LIMIT 1;", new { TraceId = usePrefix ? CreateStartsWithLikePattern(traceId) : traceId }); - return storedTraceId is null ? null : MaterializeTrace(connection, storedTraceId); - } - - private OtlpSpan? GetSpanFromDatabase(string traceId, string spanId) - { - var trace = GetTraceFromDatabase(traceId); - return trace?.Spans.FirstOrDefault(span => span.SpanId == spanId); - } - - private OtlpTrace? MaterializeTrace(SqliteConnection connection, string traceId, IDbTransaction? transaction = null) - { - var records = connection.Query(""" - SELECT - s.trace_id AS TraceId, - s.span_id AS SpanId, - s.parent_span_id AS ParentSpanId, - s.resource_id AS ResourceId, - s.resource_view_id AS ResourceViewId, - s.scope_id AS ScopeId, - s.name AS Name, - s.kind AS Kind, - s.start_time_ticks AS StartTimeTicks, - s.end_time_ticks AS EndTimeTicks, - s.status AS Status, - s.status_message AS StatusMessage, - s.trace_state AS State, - s.uninstrumented_peer_resource_id AS PeerResourceId, - t.last_updated_timestamp_ticks AS LastUpdatedTimestampTicks, - r.resource_name AS ResourceName, - r.instance_id AS InstanceId, - r.uninstrumented_peer AS UninstrumentedPeer, - r.has_logs AS HasLogs, - r.has_traces AS HasTraces, - r.has_metrics AS HasMetrics, - sc.scope_name AS ScopeName, - sc.scope_version AS ScopeVersion, - pr.resource_name AS PeerResourceName, - pr.instance_id AS PeerInstanceId - FROM telemetry_spans s - JOIN telemetry_traces t ON t.trace_id = s.trace_id - JOIN telemetry_resources r ON r.resource_id = s.resource_id - JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id - LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id - WHERE s.trace_id = @TraceId - ORDER BY s.start_time_ticks, s.span_id; - """, new { TraceId = traceId }, transaction).AsList(); - if (records.Count == 0) - { - return null; - } - - var spanIds = records.Select(record => record.SpanId).ToArray(); - var spanAttributes = connection.Query(""" - SELECT span_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_span_attributes - WHERE trace_id = @TraceId AND span_id IN @SpanIds - ORDER BY span_id, ordinal; - """, new { TraceId = traceId, SpanIds = spanIds }, transaction).ToLookup(record => record.OwnerId); - var eventRecords = connection.Query(""" - SELECT event_id AS EventId, span_id AS SpanId, event_name AS EventName, event_time_ticks AS EventTimeTicks - FROM telemetry_span_events - WHERE trace_id = @TraceId - ORDER BY span_id, ordinal; - """, new { TraceId = traceId }, transaction).AsList(); - var eventAttributes = connection.Query(""" - SELECT event_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_span_event_attributes - WHERE event_id IN @Ids - ORDER BY event_id, ordinal; - """, new { Ids = eventRecords.Select(record => record.EventId).ToArray() }, transaction).ToLookup(record => record.OwnerId); - var events = eventRecords.ToLookup(record => record.SpanId); - var linkRecords = connection.Query(""" - SELECT - link_id AS LinkId, - source_trace_id AS SourceTraceId, - source_span_id AS SourceSpanId, - target_trace_id AS TraceId, - target_span_id AS SpanId, - trace_state AS TraceState - FROM telemetry_span_links - WHERE source_trace_id = @TraceId OR target_trace_id = @TraceId - ORDER BY link_id; - """, new { TraceId = traceId }, transaction).AsList(); - var linkAttributes = connection.Query(""" - SELECT link_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_span_link_attributes - WHERE link_id IN @Ids - ORDER BY link_id, ordinal; - """, new { Ids = linkRecords.Select(record => record.LinkId).ToArray() }, transaction).ToLookup(record => record.OwnerId); - var outgoingLinks = linkRecords.Where(record => record.SourceTraceId == traceId).ToLookup(record => record.SourceSpanId); - var incomingLinks = linkRecords.Where(record => record.TraceId == traceId).ToLookup(record => record.SpanId); - - var trace = new OtlpTrace(Convert.FromHexString(traceId), new DateTime(records[0].LastUpdatedTimestampTicks, DateTimeKind.Utc)); - foreach (var record in records) - { - var (_, view, scope) = GetCachedTelemetryMetadata(record.ResourceId, record.ResourceViewId, record.ScopeId, CachedTelemetryType.Traces); - - var modelSpan = new OtlpSpan(view, trace, scope) - { - SpanId = record.SpanId, - ParentSpanId = record.ParentSpanId, - Name = record.Name, - Kind = (OtlpSpanKind)record.Kind, - StartTime = new DateTime(record.StartTimeTicks, DateTimeKind.Utc), - EndTime = new DateTime(record.EndTimeTicks, DateTimeKind.Utc), - Status = (OtlpSpanStatusCode)record.Status, - StatusMessage = record.StatusMessage, - State = record.State, - Attributes = ToPairs(spanAttributes[record.SpanId]), - Events = [], - Links = outgoingLinks[record.SpanId].Select(CreateLink).ToList(), - BackLinks = incomingLinks[record.SpanId].Select(CreateLink).ToList() - }; - if (record.PeerResourceId is not null) - { - modelSpan.SetUninstrumentedPeer(GetCachedResource(record.PeerResourceId.Value)); - } - modelSpan.Events.AddRange(events[record.SpanId].Select(spanEvent => new OtlpSpanEvent(modelSpan) - { - InternalId = Guid.Parse(spanEvent.EventId), - Name = spanEvent.EventName, - Time = new DateTime(spanEvent.EventTimeTicks, DateTimeKind.Utc), - Attributes = ToPairs(eventAttributes[spanEvent.EventId]) - })); - trace.AddSpan(modelSpan, skipLastUpdatedDate: true); - } - return trace; - - OtlpSpanLink CreateLink(SpanLinkRecord link) - { - return new OtlpSpanLink - { - SourceTraceId = link.SourceTraceId, - SourceSpanId = link.SourceSpanId, - TraceId = link.TraceId, - SpanId = link.SpanId, - TraceState = link.TraceState, - Attributes = ToPairs(linkAttributes[link.LinkId]) - }; - } - - static KeyValuePair[] ToPairs(IEnumerable attributes) - { - return attributes.Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray(); - } - } - - private void ClearSelectedTracesFromDatabase(Dictionary> selectedResources) - { - using var connection = _database.OpenConnection(); - var resources = connection.Query(""" - SELECT resource_name AS ResourceName, instance_id AS InstanceId - FROM telemetry_resources; - """); - foreach (var resource in resources) - { - var key = new ResourceKey(resource.ResourceName, resource.InstanceId); - if (selectedResources.TryGetValue(key.GetCompositeName(), out var dataTypes) && - dataTypes.Contains(AspireDataType.Traces) && - !dataTypes.Contains(AspireDataType.Resource)) - { - ClearTracesFromDatabase(key); - } - } - } - - private void RecalculateUninstrumentedPeers() - { - lock (_writeLock) - { - using var writeConnection = _database.OpenConnection(); - using var transaction = writeConnection.BeginTransaction(); - using var readConnection = _database.OpenConnection(); - // Return one row per span attribute (or one row with null attributes for spans without any). - // The parents CTE marks spans that have children so processing below can restrict peer - // resolution to client/producer leaf spans that have a peer address. Ordering keeps every - // span's attributes contiguous, which lets the loop finalize one span when its identity - // changes instead of buffering all spans and attributes. A separate connection keeps the - // unbuffered reader open while completed spans are written in batches through the transaction. - var rows = readConnection.Query(""" - WITH parents AS ( - SELECT DISTINCT trace_id, parent_span_id AS span_id - FROM telemetry_spans - WHERE parent_span_id IS NOT NULL - ) - SELECT - spans.trace_id AS TraceId, - spans.span_id AS SpanId, - spans.kind AS Kind, - parents.span_id IS NOT NULL AS HasChildren, - attributes.attribute_key AS AttributeKey, - attributes.attribute_value AS AttributeValue - FROM telemetry_spans AS spans - LEFT JOIN parents - ON parents.trace_id = spans.trace_id - AND parents.span_id = spans.span_id - LEFT JOIN telemetry_span_attributes AS attributes - ON attributes.trace_id = spans.trace_id - AND attributes.span_id = spans.span_id - ORDER BY spans.trace_id, spans.span_id, attributes.ordinal; - """, buffered: false); - var spanUpdates = new List(MaxSpanDetailBatchSize); - var spanAttributes = new List>(); - PeerRecalculationRowRecord? currentSpan = null; - - foreach (var row in rows) - { - if (currentSpan is not null && - (!string.Equals(currentSpan.TraceId, row.TraceId, StringComparison.Ordinal) || - !string.Equals(currentSpan.SpanId, row.SpanId, StringComparison.Ordinal))) - { - ProcessSpan(currentSpan, spanAttributes); - spanAttributes.Clear(); - } - - currentSpan = row; - if (row.AttributeKey is not null) - { - spanAttributes.Add(KeyValuePair.Create(row.AttributeKey, row.AttributeValue!)); - } - } - if (currentSpan is not null) - { - ProcessSpan(currentSpan, spanAttributes); - } - FlushSpanUpdates(); - - writeConnection.Execute(""" - UPDATE telemetry_resources - SET uninstrumented_peer = 1 - WHERE resource_id IN ( - SELECT uninstrumented_peer_resource_id - FROM telemetry_spans - WHERE uninstrumented_peer_resource_id IS NOT NULL - ); - """, transaction: transaction); - var lastUpdatedTimestampTicks = DateTime.UtcNow.Ticks; - writeConnection.Execute(""" - UPDATE telemetry_traces - SET last_updated_timestamp_ticks = @LastUpdatedTimestampTicks - WHERE trace_id IN (SELECT trace_id FROM telemetry_spans); - """, new - { - LastUpdatedTimestampTicks = lastUpdatedTimestampTicks - }, transaction); - transaction.Commit(); - - void ProcessSpan(PeerRecalculationRowRecord span, IReadOnlyList> attributes) - { - long? peerResourceId = null; - if ((OtlpSpanKind)span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && - !span.HasChildren && - attributes.Count > 0) - { - var attributeArray = attributes.ToArray(); - if (attributeArray.GetPeerAddress() is not null && - TryResolvePeerResourceKey(attributeArray, out var peerKey)) - { - var cachedPeerResource = GetOrAddCachedResource(writeConnection, transaction, peerKey, uninstrumentedPeer: true); - peerResourceId = cachedPeerResource.ResourceId; - } - } - - spanUpdates.Add(new PeerSpanUpdateRecord - { - PeerResourceId = peerResourceId, - TraceId = span.TraceId, - SpanId = span.SpanId - }); - if (spanUpdates.Count == MaxSpanDetailBatchSize) - { - FlushSpanUpdates(); - } - } - - void FlushSpanUpdates() - { - if (spanUpdates.Count == 0) - { - return; - } - - UpdatePeerSpans(writeConnection, transaction, spanUpdates); - spanUpdates.Clear(); - } - } - } - - private bool TryResolvePeerResourceKey(KeyValuePair[] attributes, out ResourceKey peerKey) - { - foreach (var resolver in _outgoingPeerResolvers) - { - if (!resolver.TryResolvePeer(attributes, out var name, out var matchedResource)) - { - continue; - } - - if (matchedResource is not null) - { - peerKey = ResourceKey.Create(matchedResource.DisplayName, matchedResource.Name); - return true; - } - - if (!string.IsNullOrEmpty(name)) - { - peerKey = new ResourceKey(name, InstanceId: null); - return true; - } - } - - peerKey = default; - return false; - } - - private void ClearTracesFromDatabase(ResourceKey? resourceKey) - { - lock (_writeLock) - { - using var connection = _database.OpenConnection(); - using var transaction = connection.BeginTransaction(); - var parameters = new DynamicParameters(); - var where = string.Empty; - if (resourceKey is not null) - { - where = " WHERE resource_name = @ResourceName COLLATE NOCASE"; - parameters.Add("ResourceName", resourceKey.Value.Name); - if (resourceKey.Value.InstanceId is not null) - { - where += " AND instance_id = @InstanceId COLLATE NOCASE"; - parameters.Add("InstanceId", resourceKey.Value.InstanceId); - } - } - parameters.Add("ClearAll", resourceKey is null); - - connection.Execute($""" - DELETE FROM telemetry_traces - WHERE @ClearAll OR trace_id IN ( - SELECT DISTINCT trace_id - FROM telemetry_spans - WHERE resource_id IN (SELECT resource_id FROM telemetry_resources{where}) - ); - - DELETE FROM telemetry_resource_views - WHERE NOT EXISTS (SELECT 1 FROM telemetry_logs WHERE telemetry_logs.resource_view_id = telemetry_resource_views.resource_view_id) - AND NOT EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_view_id = telemetry_resource_views.resource_view_id); - - UPDATE telemetry_resources - SET has_traces = EXISTS (SELECT 1 FROM telemetry_spans WHERE telemetry_spans.resource_id = telemetry_resources.resource_id); - """, parameters, transaction); - DeleteOrphanedScopes(connection, transaction); - transaction.Commit(); - ClearMetadataCache(); - } - } - - private sealed record TraceQuery(string FromAndWhere, DynamicParameters Parameters); - - private sealed record PendingSpan(long ResourceId, long ResourceViewId, long ScopeId, OtlpSpan Span); - - private sealed record PendingSpanEvent(string EventId, string TraceId, string SpanId, int Ordinal, OtlpSpanEvent Event); - - private sealed class IngestionSpanAttributeRecord : AttributeRecord - { - public required string TraceId { get; init; } - public required string SpanId { get; init; } - public required int Ordinal { get; init; } - } - - private sealed class IngestionSpanRecord - { - public required string TraceId { get; init; } - public required string SpanId { get; init; } - public string? ParentSpanId { get; init; } - public required long ResourceId { get; init; } - public required long ResourceViewId { get; init; } - public required long ScopeId { get; init; } - public required string Name { get; init; } - public required int Kind { get; init; } - public required long StartTimeTicks { get; init; } - public required long EndTimeTicks { get; init; } - public required int Status { get; init; } - public string? StatusMessage { get; init; } - public string? State { get; init; } - public required long LastUpdatedTimestampTicks { get; init; } - } - - private sealed class PendingSpanLink(OtlpSpanLink link) - { - public OtlpSpanLink Link { get; } = link; - public long LinkId { get; set; } - } - - private sealed class TraceAggregateRecord - { - public required int TotalItemCount { get; init; } - public required long MaxDurationTicks { get; init; } - } - - private sealed class TraceSummaryRecord - { - public required string TraceId { get; init; } - public required long LastUpdatedTimestampTicks { get; init; } - } - - private sealed class TracePageSummaryRecord - { - public required int TotalItemCount { get; init; } - public required long MaxDurationTicks { get; init; } - public required bool IsFull { get; init; } - public string? TraceId { get; init; } - public string? FullName { get; init; } - public long? StartTimeTicks { get; init; } - public long? DurationTicks { get; init; } - public string? RootResourceName { get; init; } - public string? RootInstanceId { get; init; } - public bool? RootUninstrumentedPeer { get; init; } - public bool? HasError { get; init; } - public bool? HasGenAI { get; init; } - public string? ResourceName { get; init; } - public string? InstanceId { get; init; } - public bool? UninstrumentedPeer { get; init; } - public int? TotalSpans { get; init; } - public int? ErroredSpans { get; init; } - } - - private sealed class SpanIdentityRecord - { - public required string TraceId { get; init; } - public required string SpanId { get; init; } - } - - private sealed class PeerRecalculationRowRecord - { - public required string TraceId { get; init; } - public required string SpanId { get; init; } - public required int Kind { get; init; } - public required bool HasChildren { get; init; } - public string? AttributeKey { get; init; } - public string? AttributeValue { get; init; } - } - - private sealed class PeerSpanUpdateRecord - { - public required string TraceId { get; init; } - public required string SpanId { get; init; } - public long? PeerResourceId { get; init; } - } - - private sealed class TraceOwnedAttributeRecord : AttributeRecord - { - public required string OwnerId { get; init; } - } - - private sealed class TextOwnedAttributeRecord : AttributeRecord - { - public required string OwnerId { get; init; } - } - - private sealed class LongOwnedAttributeRecord : AttributeRecord - { - public required long OwnerId { get; init; } - } - - private sealed class SpanEventRecord - { - public required string EventId { get; init; } - public required string SpanId { get; init; } - public required string EventName { get; init; } - public required long EventTimeTicks { get; init; } - } - - private sealed class SpanLinkRecord - { - public required long LinkId { get; init; } - public required string SourceTraceId { get; init; } - public required string SourceSpanId { get; init; } - public required string TraceId { get; init; } - public required string SpanId { get; init; } - public required string TraceState { get; init; } - } - - private sealed class SpanRecord - { - public required string TraceId { get; init; } - public required string SpanId { get; init; } - public string? ParentSpanId { get; init; } - public required long ResourceId { get; init; } - public required long ResourceViewId { get; init; } - public required long ScopeId { get; init; } - public required string Name { get; init; } - public required int Kind { get; init; } - public required long StartTimeTicks { get; init; } - public required long EndTimeTicks { get; init; } - public required int Status { get; init; } - public string? StatusMessage { get; init; } - public string? State { get; init; } - public long? PeerResourceId { get; init; } - public required long LastUpdatedTimestampTicks { get; init; } - public required string ResourceName { get; init; } - public string? InstanceId { get; init; } - public required bool UninstrumentedPeer { get; init; } - public required bool HasLogs { get; init; } - public required bool HasTraces { get; init; } - public required bool HasMetrics { get; init; } - public required string ScopeName { get; init; } - public required string ScopeVersion { get; init; } - public string? PeerResourceName { get; init; } - public string? PeerInstanceId { get; init; } - } -} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index 43a7db975b0..059d11480dc 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -85,16 +85,6 @@ internal SqliteTelemetryRepository( } - public List GetResources(bool includeUninstrumentedPeers = false) => GetTelemetryResources(includeUninstrumentedPeers, name: null); - public List GetResourcesByName(string name, bool includeUninstrumentedPeers = false) => GetTelemetryResources(includeUninstrumentedPeers, name); - public OtlpResource? GetResourceByCompositeName(string compositeName) => GetResources(includeUninstrumentedPeers: true).SingleOrDefault(resource => resource.ResourceKey.EqualsCompositeName(compositeName)); - public OtlpResource? GetResource(ResourceKey key) => GetResources(includeUninstrumentedPeers: true).SingleOrDefault(resource => resource.ResourceKey == key); - public List GetResources(ResourceKey key, bool includeUninstrumentedPeers = false) - { - return key.InstanceId is null - ? GetResourcesByName(key.Name, includeUninstrumentedPeers) - : GetResources(includeUninstrumentedPeers).Where(resource => resource.ResourceKey == key).ToList(); - } public void AddLogs(AddContext context, RepeatedField resourceLogs) { if (_pauseManager.AreStructuredLogsPaused(out _)) diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs index 54f03f21550..8c2761b0e4b 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardDataSource.cs @@ -20,22 +20,33 @@ public sealed class DashboardDataSource : IDashboardRunSelection, IDisposable private readonly ITelemetryRepository _currentTelemetryRepository; private readonly IResourceRepository _currentResourceRepository; private readonly IRepositoryFactory _repositoryFactory; + private readonly ILogger _logger; private ITelemetryRepository? _historicalTelemetryRepository; private IResourceRepository? _historicalResourceRepository; private DashboardSqliteDatabase? _historicalDatabase; private IDisposable? _historicalRunLease; - internal DashboardDataSource( + /// + /// Initializes a new instance of the class. + /// + /// The store that provides available dashboard runs. + /// The telemetry repository for the current dashboard run. + /// The resource repository for the current dashboard run. + /// The factory used to create repositories for historical dashboard runs. + /// The logger used to record dashboard run selection. + public DashboardDataSource( IDashboardRunStore runStore, ITelemetryRepository currentTelemetryRepository, IResourceRepository currentResourceRepository, - IRepositoryFactory repositoryFactory) + IRepositoryFactory repositoryFactory, + ILogger logger) { _runStore = runStore; _currentTelemetryRepository = currentTelemetryRepository; _currentResourceRepository = currentResourceRepository; _repositoryFactory = repositoryFactory; + _logger = logger; SelectRun(runId: null); } @@ -61,13 +72,24 @@ internal DashboardDataSource( internal void SelectRun(string? runId) { var runs = _runStore.GetRuns(); - var selectedRun = runs.FirstOrDefault(run => string.Equals(run.RunId, runId, StringComparison.Ordinal)) - ?? runs.Single(run => run.IsCurrent); + var currentRun = runs.Single(run => run.IsCurrent); + var selectedRun = runs.FirstOrDefault(run => string.Equals(run.RunId, runId, StringComparison.Ordinal)); + if (selectedRun is null) + { + if (!string.IsNullOrEmpty(runId)) + { + _logger.LogWarning("Failed to switch to dashboard run '{RunId}' because it is no longer available.", runId); + } + + selectedRun = currentRun; + } + if (SelectedRun?.RunId == selectedRun.RunId) { return; } + var previousRun = SelectedRun; DisposeHistoricalRepositories(); _historicalTelemetryRepository = null; _historicalResourceRepository = null; @@ -77,13 +99,16 @@ internal void SelectRun(string? runId) var historicalRunLease = _runStore.TryAcquireRunLease(selectedRun); if (historicalRunLease is null) { - SelectCurrentRun(runs.Single(run => run.IsCurrent)); + _logger.LogWarning("Failed to switch to dashboard run '{RunId}' because it is no longer available.", selectedRun.RunId); + SelectCurrentRun(currentRun); + LogRunSwitch(previousRun, currentRun); return; } - var historicalDatabase = new DashboardSqliteDatabase(selectedRun.DatabasePath, readOnly: true); + DashboardSqliteDatabase? historicalDatabase = null; try { + historicalDatabase = new DashboardSqliteDatabase(selectedRun.DatabasePath, readOnly: true); if (!historicalDatabase.ValidateSchemaVersion(selectedRun.SchemaVersion)) { throw new InvalidOperationException( @@ -94,10 +119,11 @@ internal void SelectRun(string? runId) _historicalDatabase = historicalDatabase; _historicalRunLease = historicalRunLease; } - catch + catch (Exception exception) { - historicalDatabase.Dispose(); + historicalDatabase?.Dispose(); historicalRunLease.Dispose(); + _logger.LogWarning(exception, "Failed to switch to dashboard run '{RunId}'.", selectedRun.RunId); throw; } TelemetryRepository = _historicalTelemetryRepository; @@ -110,6 +136,7 @@ internal void SelectRun(string? runId) } SelectedRun = selectedRun; + LogRunSwitch(previousRun, selectedRun); } public void Dispose() @@ -134,4 +161,15 @@ private void SelectCurrentRun(DashboardRunDescriptor currentRun) IsReadOnly = false; SelectedRun = currentRun; } + + private void LogRunSwitch(DashboardRunDescriptor? previousRun, DashboardRunDescriptor selectedRun) + { + if (previousRun is not null && !string.Equals(previousRun.RunId, selectedRun.RunId, StringComparison.Ordinal)) + { + _logger.LogDebug( + "Switched dashboard run from '{PreviousRunId}' to '{RunId}'.", + previousRun.RunId, + selectedRun.RunId); + } + } } diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index 5f93e764321..6dcda9cce72 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -10,10 +10,27 @@ namespace Aspire.Dashboard.ServiceClient; -internal interface IDashboardRunStore +/// +/// Provides the dashboard runs available for selection. +/// +public interface IDashboardRunStore { + /// + /// Gets a value indicating whether historical dashboard runs can be selected. + /// bool SupportsRunSelection { get; } + + /// + /// Gets the current and historical dashboard runs available for selection. + /// + /// The available dashboard runs. IReadOnlyList GetRuns(); + + /// + /// Attempts to acquire a lease that keeps the specified dashboard run available while it is selected. + /// + /// The dashboard run to lease. + /// A lease for the dashboard run, or when the run is no longer available. IDisposable? TryAcquireRunLease(DashboardRunDescriptor run); } @@ -395,7 +412,18 @@ private sealed record DashboardRunMetadata } } -internal sealed record DashboardRunDescriptor( +/// +/// Describes a dashboard run available for selection. +/// +/// The unique identifier for the dashboard run. +/// The dashboard database schema version used by the run. +/// The time at which the run started. +/// The time at which the run ended, or when it has not ended. +/// A value indicating whether the run shut down cleanly. +/// The application name associated with the run. +/// The path to the dashboard database for the run. +/// A value indicating whether this is the current dashboard run. +public sealed record DashboardRunDescriptor( string RunId, int SchemaVersion, DateTimeOffset StartedAtUtc, diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index b37b2542e3a..f43996a55e7 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -11,11 +11,11 @@ namespace Aspire.Dashboard.ServiceClient; /// /// Creates consistently configured connections to a dashboard run database. /// -internal sealed class DashboardSqliteDatabase : IDisposable +public sealed class DashboardSqliteDatabase : IDisposable { private const string SchemaResourcePrefix = "Aspire.Dashboard.ServiceClient.DatabaseSchema."; - internal const int SchemaVersion = 10; + internal const int SchemaVersion = 11; private static readonly Lazy> s_schemaScripts = new(LoadSchemaScripts); @@ -24,6 +24,12 @@ internal sealed class DashboardSqliteDatabase : IDisposable private readonly object _schemaLock = new(); private bool _schemaInitialized; + /// + /// Initializes a new instance of the class. + /// + /// The path to the dashboard database. + /// A value indicating whether the database is opened for read-only access. + /// A value indicating whether SQLite connection pooling is enabled. public DashboardSqliteDatabase(string databasePath, bool readOnly = false, bool pooling = true) { ArgumentException.ThrowIfNullOrWhiteSpace(databasePath); @@ -45,12 +51,23 @@ public DashboardSqliteDatabase(string databasePath, bool readOnly = false, bool }.ToString(); } + /// + /// Gets the full path to the dashboard database. + /// public string DatabasePath { get; } + /// + /// Gets a value indicating whether the database is opened for read-only access. + /// public bool IsReadOnly { get; } internal ActivitySource ActivitySource => _activitySource; + /// + /// Determines whether a dashboard database uses the current schema version. + /// + /// The path to the dashboard database. + /// when the database is compatible; otherwise, . public static bool IsCompatible(string databasePath) { if (!File.Exists(databasePath)) @@ -70,7 +87,7 @@ public static bool IsCompatible(string databasePath) } } - public TracingSqliteConnection OpenConnection() + internal TracingSqliteConnection OpenConnection() { var connection = new TracingSqliteConnection(_connectionString, DatabasePath, _activitySource); connection.Open(); @@ -85,6 +102,9 @@ internal bool ValidateSchemaVersion(int metadataSchemaVersion) return ValidateSchemaVersion(connection, transaction: null, metadataSchemaVersion); } + /// + /// Clears pooled SQLite connections associated with this database. + /// public void ClearPool() { using var connection = new SqliteConnection(_connectionString); @@ -93,6 +113,9 @@ public void ClearPool() public void Dispose() => _activitySource.Dispose(); + /// + /// Initializes the dashboard database schema when it has not already been initialized. + /// public void InitializeSchema() { EnsureWritable("Historical dashboard data is read-only."); @@ -132,6 +155,11 @@ FROM sqlite_schema } } + /// + /// Throws an exception with the specified message when the database is read-only. + /// + /// The exception message used when the database is read-only. + /// The database is read-only. public void EnsureWritable(string message) { if (IsReadOnly) diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql index 0fd49abb64c..a8bd1248884 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql @@ -4,9 +4,13 @@ CREATE TABLE IF NOT EXISTS telemetry_traces ( trace_id TEXT PRIMARY KEY, first_span_timestamp_ticks INTEGER NOT NULL, + last_span_end_timestamp_ticks INTEGER NOT NULL, duration_ticks INTEGER NOT NULL, last_updated_timestamp_ticks INTEGER NOT NULL, - full_name TEXT NOT NULL + full_name TEXT NOT NULL, + primary_span_id TEXT NOT NULL, + has_error INTEGER NOT NULL CHECK (has_error IN (0, 1)), + has_gen_ai INTEGER NOT NULL CHECK (has_gen_ai IN (0, 1)) ) STRICT; CREATE TABLE IF NOT EXISTS telemetry_spans ( @@ -24,9 +28,19 @@ CREATE TABLE IF NOT EXISTS telemetry_spans ( status_message TEXT NULL, trace_state TEXT NULL, uninstrumented_peer_resource_id INTEGER NULL REFERENCES telemetry_resources(resource_id) ON DELETE SET NULL, + resource_order_ticks INTEGER NOT NULL, PRIMARY KEY (trace_id, span_id) ) STRICT; +CREATE TABLE IF NOT EXISTS telemetry_trace_resources ( + trace_id TEXT NOT NULL REFERENCES telemetry_traces(trace_id) ON DELETE CASCADE, + resource_id INTEGER NOT NULL REFERENCES telemetry_resources(resource_id) ON DELETE CASCADE, + resource_order_ticks INTEGER NOT NULL, + total_spans INTEGER NOT NULL CHECK (total_spans > 0), + errored_spans INTEGER NOT NULL CHECK (errored_spans >= 0 AND errored_spans <= total_spans), + PRIMARY KEY (trace_id, resource_id) +) STRICT; + CREATE TABLE IF NOT EXISTS telemetry_span_attributes ( trace_id TEXT NOT NULL, span_id TEXT NOT NULL, @@ -80,6 +94,10 @@ CREATE INDEX IF NOT EXISTS ix_telemetry_spans_resource_order ON telemetry_spans(resource_id, start_time_ticks, trace_id, span_id); CREATE INDEX IF NOT EXISTS ix_telemetry_spans_trace_order ON telemetry_spans(trace_id, start_time_ticks, span_id); +CREATE INDEX IF NOT EXISTS ix_telemetry_spans_parent + ON telemetry_spans(trace_id, parent_span_id); +CREATE INDEX IF NOT EXISTS ix_telemetry_trace_resources_order + ON telemetry_trace_resources(trace_id, resource_order_ticks, resource_id); CREATE INDEX IF NOT EXISTS ix_telemetry_span_attributes_owner_key ON telemetry_span_attributes(trace_id, span_id, attribute_key); CREATE INDEX IF NOT EXISTS ix_telemetry_span_attributes_key_value_owner diff --git a/src/Aspire.Dashboard/ServiceClient/IRepositoryFactory.cs b/src/Aspire.Dashboard/ServiceClient/IRepositoryFactory.cs index 6d4a2dfc365..d8242ff548d 100644 --- a/src/Aspire.Dashboard/ServiceClient/IRepositoryFactory.cs +++ b/src/Aspire.Dashboard/ServiceClient/IRepositoryFactory.cs @@ -8,15 +8,19 @@ namespace Aspire.Dashboard.ServiceClient; /// /// Creates repositories for current and historical dashboard run databases. /// -internal interface IRepositoryFactory +public interface IRepositoryFactory { /// /// Creates a telemetry repository for the specified database. /// + /// The dashboard database used by the repository. + /// A telemetry repository for the specified database. ITelemetryRepository CreateTelemetryRepository(DashboardSqliteDatabase database); /// /// Creates a resource repository for the specified database. /// + /// The dashboard database used by the repository. + /// A resource repository for the specified database. IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database); } \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index 5f57b36a2a4..57212fd282d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -168,11 +168,7 @@ public static void AddCommonDashboardServices( context.Services.AddSingleton(); context.Services.AddSingleton(services => services.GetRequiredService()); context.Services.AddSingleton(); - context.Services.AddScoped(services => new DashboardDataSource( - services.GetRequiredService(), - services.GetRequiredService(), - services.GetRequiredService(), - services.GetRequiredService())); + context.Services.AddScoped(); context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(); diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index 3059060ad25..a56a962d649 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -437,7 +437,7 @@ public void SelectedHistoricalRun_HoldsLeaseUntilSelectionChanges() var repositoryFactory = CreateRepositoryFactory(options); using var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); using var currentResourceRepository = (SqliteResourceRepository)repositoryFactory.CreateResourceRepository(currentDatabase); - using var dataSource = new DashboardDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); + using var dataSource = CreateDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); dataSource.SelectRun(historicalRunId); var runsDirectory = Path.GetDirectoryName(historicalRunDirectory)!; @@ -538,12 +538,18 @@ public void GetRuns_DoesNotReadDatabaseSchemaUntilRunIsSelected() var repositoryFactory = CreateRepositoryFactory(options); using var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); using var currentResourceRepository = (SqliteResourceRepository)repositoryFactory.CreateResourceRepository(currentDatabase); - using var dataSource = new DashboardDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); + var testSink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(testSink, enabled: true)); + using var dataSource = CreateDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory, logger); var exception = Assert.Throws(() => dataSource.SelectRun(incompatibleRunId)); Assert.Equal( $"Dashboard database for run '{incompatibleRunId}' does not match run metadata schema version '{DashboardRunStore.SchemaVersion}'.", exception.Message); + var failureLog = Assert.Single(testSink.Writes); + Assert.Equal(LogLevel.Warning, failureLog.LogLevel); + Assert.Equal($"Failed to switch to dashboard run '{incompatibleRunId}'.", failureLog.Message); + Assert.Same(exception, failureLog.Exception); } [Fact] @@ -614,11 +620,17 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() var repositoryFactory = CreateRepositoryFactory(options); using var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); using var currentResourceRepository = (SqliteResourceRepository)repositoryFactory.CreateResourceRepository(currentDatabase); - using var dataSource = new DashboardDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); + var testSink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(testSink, enabled: true)); + using var dataSource = CreateDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory, logger); Assert.Empty(dataSource.TelemetryRepository.GetResources()); dataSource.SelectRun(historicalRunId); + var switchLog = Assert.Single(testSink.Writes); + Assert.Equal(LogLevel.Debug, switchLog.LogLevel); + Assert.Equal($"Switched dashboard run from '{currentRunStore.RunId}' to '{historicalRunId}'.", switchLog.Message); + Assert.True(dataSource.IsReadOnly); Assert.Equal(historicalRunId, dataSource.SelectedRun.RunId); Assert.Equal("api", Assert.Single(dataSource.ResourceRepository.GetResources()).Name); @@ -676,7 +688,7 @@ public void UnknownRunId_SelectsCurrentRun() var repositoryFactory = CreateRepositoryFactory(options); using var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); using var currentResourceRepository = (SqliteResourceRepository)repositoryFactory.CreateResourceRepository(currentDatabase); - using var dataSource = new DashboardDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); + using var dataSource = CreateDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); dataSource.SelectRun("missing"); Assert.False(dataSource.IsReadOnly); @@ -703,7 +715,9 @@ public void UnavailableHistoricalRun_SelectsCurrentRun() var repositoryFactory = CreateRepositoryFactory(options); using var currentTelemetryRepository = repositoryFactory.CreateTelemetryRepository(currentDatabase); using var currentResourceRepository = (SqliteResourceRepository)repositoryFactory.CreateResourceRepository(currentDatabase); - using var dataSource = new DashboardDataSource(runStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory); + var testSink = new TestSink(); + var logger = new TestLogger(new TestLoggerFactory(testSink, enabled: true)); + using var dataSource = CreateDataSource(runStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory, logger); dataSource.SelectRun(historicalRunId); @@ -712,6 +726,9 @@ public void UnavailableHistoricalRun_SelectsCurrentRun() Assert.Equal(currentRunStore.RunId, dataSource.SelectedRun.RunId); Assert.Same(currentResourceRepository, dataSource.ResourceRepository); Assert.Same(currentTelemetryRepository, dataSource.TelemetryRepository); + var failureLog = Assert.Single(testSink.Writes); + Assert.Equal(LogLevel.Warning, failureLog.LogLevel); + Assert.Equal($"Failed to switch to dashboard run '{historicalRunId}' because it is no longer available.", failureLog.Message); } private IOptions CreateOptions( @@ -749,6 +766,21 @@ private static DashboardRunStore CreateRunStore(IOptions optio return new DashboardRunStore(options, NullLogger.Instance, timeProvider ?? TimeProvider.System); } + private static DashboardDataSource CreateDataSource( + IDashboardRunStore runStore, + ITelemetryRepository currentTelemetryRepository, + IResourceRepository currentResourceRepository, + IRepositoryFactory repositoryFactory, + ILogger? logger = null) + { + return new DashboardDataSource( + runStore, + currentTelemetryRepository, + currentResourceRepository, + repositoryFactory, + logger ?? NullLogger.Instance); + } + private RepositoryFactory CreateRepositoryFactory(IOptions options) { var serviceProvider = new ServiceCollection() diff --git a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs index bb812fcd0fa..717d4f7ba92 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs @@ -542,6 +542,7 @@ FROM sqlite_schema 'dashboard_schema', 'dashboard_resources', 'telemetry_logs', + 'telemetry_trace_resources', 'telemetry_traces', 'telemetry_metric_instruments') ORDER BY name; @@ -560,10 +561,46 @@ FROM sqlite_schema "dashboard_schema", "telemetry_logs", "telemetry_metric_instruments", + "telemetry_trace_resources", "telemetry_traces" ], tableNames); } + [Fact] + public void Schema_TraceSummaryIndexesExist() + { + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); + using (CreateRepository(workspace.Path)) + { + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT name + FROM sqlite_schema + WHERE type = 'index' AND name IN ( + 'ix_telemetry_spans_parent', + 'ix_telemetry_trace_resources_order') + ORDER BY name; + """; + + using var reader = command.ExecuteReader(); + var indexNames = new List(); + while (reader.Read()) + { + indexNames.Add(reader.GetString(0)); + } + + Assert.Equal( + [ + "ix_telemetry_spans_parent", + "ix_telemetry_trace_resources_order" + ], indexNames); + } + [Fact] public void Schema_TelemetryResourceInstanceIdUniquenessPreservesNullAndEmpty() { diff --git a/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs b/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs index 10931a0a853..0c0ac15b086 100644 --- a/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs +++ b/tests/Aspire.Dashboard.Tests/Shared/TestDashboardDataSource.cs @@ -3,6 +3,7 @@ using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.ServiceClient; +using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Dashboard.Tests.Shared; @@ -16,7 +17,8 @@ public static DashboardDataSource Create( new TestDashboardRunStore(), telemetryRepository, resourceRepository, - new TestRepositoryFactory(telemetryRepository, resourceRepository)); + new TestRepositoryFactory(telemetryRepository, resourceRepository), + NullLogger.Instance); } private sealed class TestRepositoryFactory( diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index 6b9ba460c0a..321c1618c22 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Globalization; using System.Text; +using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; @@ -215,6 +216,150 @@ public void GetTraceSummaries_ReturnsPageData() Assert.Equal(TimeSpan.FromMinutes(5), emptyPage.MaxDuration); } + [Fact] + public void GetTraceSummaries_LateParent_PreservesResourceOrder() + { + var repository = CreateRepository(); + repository.AsWriter().AddTraces(new AddContext(), + [ + new ResourceSpans + { + Resource = CreateResource(name: "z-child"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-2", + parentSpanId: "1-1", + startTime: s_testTime.AddMinutes(1), + endTime: s_testTime.AddMinutes(10)) + } + } + } + } + ]); + repository.AsWriter().AddTraces(new AddContext(), + [ + new ResourceSpans + { + Resource = CreateResource(name: "a-parent"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-1", + startTime: s_testTime.AddMinutes(5), + endTime: s_testTime.AddMinutes(10)) + } + } + } + } + ]); + + var summary = Assert.Single(repository.GetTraceSummaries(new GetTracesRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + Filters = [] + }).PagedResult.Items); + var trace = Assert.IsType(repository.GetTrace(summary.TraceId)); + + Assert.Equal( + TraceHelpers.GetOrderedResources(trace).Select(resource => resource.Resource.ResourceKey), + summary.Resources.Select(resource => resource.Resource.ResourceKey)); + Assert.Collection(summary.Resources, + resource => Assert.Equal("a-parent", resource.Resource.ResourceName), + resource => Assert.Equal("z-child", resource.Resource.ResourceName)); + } + + [Fact] + public void GetTraceSummaries_IncrementalAppend_UpdatesSummaryValues() + { + var repository = CreateRepository(); + repository.AsWriter().AddTraces(new AddContext(), + [ + new ResourceSpans + { + Resource = CreateResource(name: "frontend"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-1", + startTime: s_testTime, + endTime: s_testTime.AddMinutes(1)) + } + } + } + } + ]); + repository.AsWriter().AddTraces(new AddContext(), + [ + new ResourceSpans + { + Resource = CreateResource(name: "backend"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-2", + parentSpanId: "1-1", + startTime: s_testTime.AddSeconds(1), + endTime: s_testTime.AddMinutes(2), + attributes: [KeyValuePair.Create("gen_ai.provider.name", "test")], + status: new Status { Code = Status.Types.StatusCode.Error }) + } + } + } + } + ]); + + var summary = Assert.Single(repository.GetTraceSummaries(new GetTracesRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + Filters = [] + }).PagedResult.Items); + + Assert.True(summary.HasError); + Assert.True(summary.HasGenAI); + Assert.Collection(summary.Resources, + resource => + { + Assert.Equal("frontend", resource.Resource.ResourceName); + Assert.Equal(1, resource.TotalSpans); + Assert.Equal(0, resource.ErroredSpans); + }, + resource => + { + Assert.Equal("backend", resource.Resource.ResourceName); + Assert.Equal(1, resource.TotalSpans); + Assert.Equal(1, resource.ErroredSpans); + }); + } + [Fact] public void AddTraces_SelfParent_Reject() { @@ -324,6 +469,63 @@ public void AddTraces_MultipleSpansLoop_Reject() }); } + [Fact] + public void AddTraces_CircularReferenceAcrossIngestionCalls_Reject() + { + var repository = CreateRepository(); + repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-2", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3), parentSpanId: "1-1"), + CreateSpan(traceId: "1", spanId: "1-3", startTime: s_testTime.AddMinutes(3), endTime: s_testTime.AddMinutes(4), parentSpanId: "1-2") + } + } + } + } + }); + + var context = new AddContext(); + repository.AsWriter().AddTraces(context, new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: s_testTime.AddMinutes(1), endTime: s_testTime.AddMinutes(5), parentSpanId: "1-3") + } + } + } + } + }); + + Assert.Equal(1, context.FailureCount); + var trace = Assert.Single(repository.GetTraces(new GetTracesRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + Filters = [] + }).PagedResult.Items); + Assert.Collection(trace.Spans, + span => AssertId("1-2", span.SpanId), + span => AssertId("1-3", span.SpanId)); + } + [Fact] public void AddTraces_DuplicateTraceIds_Reject() { @@ -3679,6 +3881,205 @@ public sealed class SqliteTraceTests : TraceTests { protected override bool UseSqlite => true; + [Fact] + public void GetTraceSummaries_EqualStartTime_MatchesMaterializedTrace() + { + var testTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var repository = CreateRepository(); + repository.AsWriter().AddTraces(new AddContext(), + [ + new ResourceSpans + { + Resource = CreateResource(name: "first"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-1", startTime: testTime, endTime: testTime.AddMinutes(1)) + } + } + } + } + ]); + repository.AsWriter().AddTraces(new AddContext(), + [ + new ResourceSpans + { + Resource = CreateResource(name: "second"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "1-2", startTime: testTime, endTime: testTime.AddMinutes(1)) + } + } + } + } + ]); + + var trace = Assert.IsType(repository.GetTrace(GetHexId("1"))); + var summary = Assert.Single(repository.GetTraceSummaries(new GetTracesRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + Filters = [] + }).PagedResult.Items); + + Assert.Equal(trace.FullName, summary.FullName); + Assert.Equal(trace.RootOrFirstSpan.Source.Resource.ResourceKey, summary.RootResource.ResourceKey); + } + + [Fact] + public void AddTraces_LargeAppend_DoesNotExceedSqliteParameterLimit() + { + const int appendedSpanCount = 8_192; + var testTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var repository = CreateRepository(); + repository.AsWriter().AddTraces(new AddContext(), + [ + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan(traceId: "1", spanId: "root", startTime: testTime, endTime: testTime.AddMinutes(1)) + } + } + } + } + ]); + var resourceSpans = new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope() + } + } + }; + for (var index = 0; index < appendedSpanCount; index++) + { + resourceSpans.ScopeSpans[0].Spans.Add(CreateSpan( + traceId: "1", + spanId: $"child-{index}", + parentSpanId: $"missing-parent-{index}", + startTime: testTime.AddSeconds(1), + endTime: testTime.AddMinutes(1))); + } + + var context = new AddContext(); + repository.AsWriter().AddTraces(context, [resourceSpans]); + + Assert.Equal(appendedSpanCount, context.SuccessCount); + Assert.Equal(0, context.FailureCount); + var summary = Assert.Single(repository.GetTraceSummaries(new GetTracesRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + Filters = [] + }).PagedResult.Items); + Assert.Collection(summary.Resources, resource => Assert.Equal(appendedSpanCount + 1, resource.TotalSpans)); + } + + [Fact] + public void GetTraceSummaries_AfterResourceDeletion_DeletesAffectedTraces() + { + var testTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var repository = CreateRepository(); + repository.AsWriter().AddTraces(new AddContext(), + [ + new ResourceSpans + { + Resource = CreateResource(name: "frontend"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-1", + startTime: testTime, + endTime: testTime.AddMinutes(5), + attributes: [KeyValuePair.Create("gen_ai.system", "test")], + status: new Status { Code = Status.Types.StatusCode.Error }) + } + } + } + }, + new ResourceSpans + { + Resource = CreateResource(name: "backend"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-2", + parentSpanId: "1-1", + startTime: testTime.AddMinutes(1), + endTime: testTime.AddMinutes(2)), + CreateSpan( + traceId: "2", + spanId: "2-1", + startTime: testTime.AddMinutes(3), + endTime: testTime.AddMinutes(4)) + } + } + } + } + ]); + + repository.AsWriter().ClearSelectedSignals(new Dictionary> + { + [new ResourceKey("frontend", "TestId").GetCompositeName()] = [AspireDataType.Resource] + }); + + Assert.Null(repository.GetTrace(GetHexId("1"))); + Assert.NotNull(repository.GetTrace(GetHexId("2"))); + + var summary = Assert.Single(repository.GetTraceSummaries(new GetTracesRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + Filters = [] + }).PagedResult.Items); + AssertId("2", summary.TraceId); + Assert.Equal("backend: Test span. Id: 2-1", summary.FullName); + Assert.Equal(testTime.AddMinutes(3), summary.StartTime); + Assert.Equal(TimeSpan.FromMinutes(1), summary.Duration); + Assert.False(summary.HasError); + Assert.False(summary.HasGenAI); + Assert.Collection(summary.Resources, resource => + { + Assert.Equal("backend", resource.Resource.ResourceName); + Assert.Equal(1, resource.TotalSpans); + Assert.Equal(0, resource.ErroredSpans); + }); + } + [Fact] public void AddTraces_BatchesSpansAndDetailsAcrossResources() { @@ -3710,10 +4111,19 @@ public void AddTraces_BatchesSpansAndDetailsAcrossResources() Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_span_links", StringComparison.Ordinal)); Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_span_link_attributes", StringComparison.Ordinal)); Assert.Single(queries, query => query.StartsWith("WITH peer_updates", StringComparison.Ordinal)); + Assert.Single(queries, query => query.StartsWith("WITH span_orders", StringComparison.Ordinal)); + Assert.Single(queries, query => query.StartsWith("WITH new_spans", StringComparison.Ordinal)); + Assert.DoesNotContain(queries, query => query.StartsWith("DELETE FROM telemetry_trace_resources", StringComparison.Ordinal)); + Assert.Single(queries, query => + query.StartsWith("SELECT", StringComparison.Ordinal) && + query.Contains("t.first_span_timestamp_ticks AS FirstSpanTimestampTicks", StringComparison.Ordinal)); + Assert.Single(queries, query => + query.StartsWith("SELECT", StringComparison.Ordinal) && + query.Contains("s.resource_order_ticks AS ResourceOrderTicks", StringComparison.Ordinal)); Assert.Single(queries, query => query.StartsWith("SELECT", StringComparison.Ordinal) && - query.Contains("s.trace_id AS TraceId", StringComparison.Ordinal) && - query.Contains("FROM telemetry_span_attributes", StringComparison.Ordinal)); + query.Contains("s.parent_span_id AS ParentSpanId", StringComparison.Ordinal)); + Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_span_attributes", StringComparison.Ordinal)); Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_span_events", StringComparison.Ordinal)); Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_span_links", StringComparison.Ordinal)); Assert.DoesNotContain(queries, query => query.StartsWith("UPDATE telemetry_resources SET has_traces", StringComparison.Ordinal)); @@ -3763,4 +4173,53 @@ static ResourceSpans CreateResourceSpans(string resourceName, string traceId, st }; } } + + [Fact] + public void GetTraceSummaries_UsesPersistedResourceSummaries() + { + var repository = Assert.IsType(CreateRepository()); + var testTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + repository.AsWriter().AddTraces(new AddContext(), + [ + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-1", + startTime: testTime, + endTime: testTime.AddMinutes(1)) + } + } + } + } + ]); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); + using var parent = new Activity("trace summary query test").Start(); + + repository.GetTraceSummaries(new GetTracesRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + Filters = [] + }); + + var query = Assert.Single(activities, activity => + activity.ParentSpanId == parent.SpanId && + activity.GetTagItem("db.query.text") is string text && + text.Contains("FROM telemetry_trace_resources", StringComparison.Ordinal)); + var queryText = Assert.IsType(query.GetTagItem("db.query.text")); + Assert.Contains("FROM telemetry_trace_resources", queryText, StringComparison.Ordinal); + Assert.DoesNotContain("RECURSIVE", queryText, StringComparison.Ordinal); + Assert.DoesNotContain("span_tree", queryText, StringComparison.Ordinal); + } } From dd69dff82c8fe57ac6714eb69c91d771f1da0e64 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 21 Jul 2026 09:48:40 +0800 Subject: [PATCH 51/87] Validate dashboard span relationships --- .../SqliteTelemetryRepository.Traces.Reads.cs | 51 +++---- ...SqliteTelemetryRepository.Traces.Writes.cs | 47 ++++-- .../ServiceClient/DashboardSqliteDatabase.cs | 2 +- .../DatabaseSchema/004.Traces.sql | 29 +++- .../Model/SqliteResourceRepositoryTests.cs | 66 +++++++++ .../TelemetryRepositoryTests/TraceTests.cs | 137 ++++++++++++++++++ 6 files changed, 283 insertions(+), 49 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs index 36252cbd8f9..f86fb205fe0 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs @@ -209,7 +209,7 @@ private static TraceQuery BuildTraceQuery(GetTracesRequest context) if (fieldFilter.Condition is FilterCondition.NotEqual or FilterCondition.NotContains) { - sql.Append(" AND NOT EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id WHERE s.trace_id = t.trace_id AND "); + sql.Append(" AND NOT EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id JOIN telemetry_span_kinds sk ON sk.kind = s.kind JOIN telemetry_span_statuses ss ON ss.status = s.status LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id WHERE s.trace_id = t.trace_id AND "); sql.Append(BuildSpanFieldPredicate(fieldFilter, parameters, filterIndex++, invertNegative: true)); sql.Append(')'); } @@ -220,7 +220,7 @@ private static TraceQuery BuildTraceQuery(GetTracesRequest context) } if (positivePredicates.Count > 0) { - sql.Append(" AND EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id WHERE s.trace_id = t.trace_id AND "); + sql.Append(" AND EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id JOIN telemetry_span_kinds sk ON sk.kind = s.kind JOIN telemetry_span_statuses ss ON ss.status = s.status LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id WHERE s.trace_id = t.trace_id AND "); sql.AppendJoin(" AND ", positivePredicates); sql.Append(')'); } @@ -241,8 +241,8 @@ s.span_id LIKE @{parameterName} ESCAPE '!' OR s.trace_id LIKE @{parameterName} ESCAPE '!' OR sc.scope_name LIKE @{parameterName} ESCAPE '!' OR r.resource_name LIKE @{parameterName} ESCAPE '!' OR - CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END LIKE @{parameterName} ESCAPE '!' OR - CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END LIKE @{parameterName} ESCAPE '!' OR + ss.status_name LIKE @{parameterName} ESCAPE '!' OR + sk.kind_name LIKE @{parameterName} ESCAPE '!' OR COALESCE(s.status_message, '') LIKE @{parameterName} ESCAPE '!' OR EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND (a.attribute_key LIKE @{parameterName} ESCAPE '!' OR a.attribute_value LIKE @{parameterName} ESCAPE '!')) OR EXISTS (SELECT 1 FROM telemetry_span_events e WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id AND e.event_name LIKE @{parameterName} ESCAPE '!') @@ -251,7 +251,7 @@ CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server } sql.Append(" AND (("); sql.AppendJoin(" AND ", fullNamePredicates); - sql.Append(") OR EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id WHERE s.trace_id = t.trace_id AND "); + sql.Append(") OR EXISTS (SELECT 1 FROM telemetry_spans s JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id JOIN telemetry_span_kinds sk ON sk.kind = s.kind JOIN telemetry_span_statuses ss ON ss.status = s.status WHERE s.trace_id = t.trace_id AND "); sql.AppendJoin(" AND ", spanPredicates); sql.Append("))"); } @@ -297,8 +297,8 @@ condition is FilterCondition.Contains or FilterCondition.NotContains KnownTraceFields.TraceIdField => "s.trace_id", KnownTraceFields.SpanIdField => "s.span_id", KnownTraceFields.NameField => "s.name", - KnownTraceFields.KindField => "CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END", - KnownTraceFields.StatusField => "CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END", + KnownTraceFields.KindField => "sk.kind_name", + KnownTraceFields.StatusField => "ss.status_name", KnownSourceFields.NameField => "sc.scope_name", KnownTraceFields.TimestampField => "s.start_time_ticks / 10000", _ => null @@ -361,6 +361,8 @@ FROM telemetry_spans s JOIN telemetry_traces t ON t.trace_id = s.trace_id JOIN telemetry_resources r ON r.resource_id = s.resource_id JOIN telemetry_scopes sc ON sc.scope_id = s.scope_id + JOIN telemetry_span_kinds sk ON sk.kind = s.kind + JOIN telemetry_span_statuses ss ON ss.status = s.status LEFT JOIN telemetry_resources pr ON pr.resource_id = s.uninstrumented_peer_resource_id WHERE 1 = 1 """); @@ -456,8 +458,8 @@ s.span_id LIKE @{parameterName} ESCAPE '!' OR s.trace_id LIKE @{parameterName} ESCAPE '!' OR sc.scope_name LIKE @{parameterName} ESCAPE '!' OR r.resource_name LIKE @{parameterName} ESCAPE '!' OR - CASE s.status WHEN 0 THEN 'Unset' WHEN 1 THEN 'Ok' WHEN 2 THEN 'Error' END LIKE @{parameterName} ESCAPE '!' OR - CASE s.kind WHEN 0 THEN 'Unspecified' WHEN 1 THEN 'Internal' WHEN 2 THEN 'Server' WHEN 3 THEN 'Client' WHEN 4 THEN 'Producer' WHEN 5 THEN 'Consumer' END LIKE @{parameterName} ESCAPE '!' OR + ss.status_name LIKE @{parameterName} ESCAPE '!' OR + sk.kind_name LIKE @{parameterName} ESCAPE '!' OR COALESCE(s.status_message, '') LIKE @{parameterName} ESCAPE '!' OR EXISTS (SELECT 1 FROM telemetry_span_attributes a WHERE a.trace_id = s.trace_id AND a.span_id = s.span_id AND (a.attribute_key LIKE @{parameterName} ESCAPE '!' OR a.attribute_value LIKE @{parameterName} ESCAPE '!')) OR EXISTS (SELECT 1 FROM telemetry_span_events e WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id AND e.event_name LIKE @{parameterName} ESCAPE '!') @@ -519,31 +521,16 @@ FROM telemetry_spans s KnownTraceFields.TraceIdField => QueryFieldValues("trace_id", "telemetry_spans"), KnownTraceFields.SpanIdField => QueryFieldValues("span_id", "telemetry_spans"), KnownTraceFields.KindField => connection.Query(""" - SELECT - CASE kind - WHEN 0 THEN 'Unspecified' - WHEN 1 THEN 'Internal' - WHEN 2 THEN 'Server' - WHEN 3 THEN 'Client' - WHEN 4 THEN 'Producer' - WHEN 5 THEN 'Consumer' - ELSE CAST(kind AS TEXT) - END AS FieldValue, - COUNT(*) AS ValueCount - FROM telemetry_spans - GROUP BY kind; + SELECT sk.kind_name AS FieldValue, COUNT(*) AS ValueCount + FROM telemetry_spans s + JOIN telemetry_span_kinds sk ON sk.kind = s.kind + GROUP BY sk.kind, sk.kind_name; """), KnownTraceFields.StatusField => connection.Query(""" - SELECT - CASE status - WHEN 0 THEN 'Unset' - WHEN 1 THEN 'Ok' - WHEN 2 THEN 'Error' - ELSE CAST(status AS TEXT) - END AS FieldValue, - COUNT(*) AS ValueCount - FROM telemetry_spans - GROUP BY status; + SELECT ss.status_name AS FieldValue, COUNT(*) AS ValueCount + FROM telemetry_spans s + JOIN telemetry_span_statuses ss ON ss.status = s.status + GROUP BY ss.status, ss.status_name; """), KnownSourceFields.NameField => connection.Query(""" SELECT sc.scope_name AS FieldValue, COUNT(*) AS ValueCount diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs index 6c0c1b9109e..aab93992a5e 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs @@ -234,18 +234,17 @@ FROM telemetry_spans s .Chunk(MaxSpanDetailBatchSize)) { ancestors.AddRange(connection.Query(""" - WITH RECURSIVE ancestors(trace_id, origin_span_id, span_id, parent_span_id) AS ( - SELECT s.trace_id, s.span_id, s.span_id, s.parent_span_id + WITH RECURSIVE ancestors(trace_id, span_id, parent_span_id) AS ( + SELECT s.trace_id, s.span_id, s.parent_span_id FROM telemetry_spans s WHERE s.trace_id IN @TraceIds AND s.span_id IN @ParentSpanIds - UNION ALL - SELECT parent.trace_id, child.origin_span_id, parent.span_id, parent.parent_span_id + UNION + SELECT parent.trace_id, parent.span_id, parent.parent_span_id FROM ancestors child JOIN telemetry_spans parent ON parent.trace_id = child.trace_id AND parent.span_id = child.parent_span_id ) SELECT trace_id AS TraceId, - origin_span_id AS OriginSpanId, span_id AS SpanId, parent_span_id AS ParentSpanId FROM ancestors; @@ -253,14 +252,35 @@ parent_span_id AS ParentSpanId } } - var ancestorParentReferences = ancestors - .Where(ancestor => ancestor.ParentSpanId is not null) - .Select(ancestor => (ancestor.TraceId, ancestor.OriginSpanId, ParentSpanId: ancestor.ParentSpanId!)) - .ToHashSet(); - circularSpanIds.UnionWith(incomingSpansForExistingTraces - .Where(span => span.ParentSpanId is not null) - .Where(span => ancestorParentReferences.Contains((span.TraceId, span.ParentSpanId!, span.SpanId))) - .Select(span => (span.TraceId, span.SpanId))); + var parentSpanIds = new Dictionary<(string TraceId, string SpanId), string?>(); + foreach (var ancestor in ancestors) + { + parentSpanIds.TryAdd((ancestor.TraceId, ancestor.SpanId), ancestor.ParentSpanId); + } + foreach (var span in incomingSpansForExistingTraces) + { + var identity = (span.TraceId, span.SpanId); + if (span.ParentSpanId is null || existingSpans.ContainsKey(identity) || !parentSpanIds.TryAdd(identity, span.ParentSpanId)) + { + continue; + } + + // Persisted and incoming edges can jointly complete a cycle. Add incoming edges in ingestion order so + // only the edge that closes the cycle is rejected, matching OtlpTrace.AddSpan's partial-batch behavior. + var visitedSpanIds = new HashSet(StringComparer.Ordinal); + var parentSpanId = span.ParentSpanId; + while (parentSpanId is not null && visitedSpanIds.Add(parentSpanId)) + { + if (string.Equals(parentSpanId, span.SpanId, StringComparison.Ordinal)) + { + circularSpanIds.Add(identity); + parentSpanIds.Remove(identity); + break; + } + + parentSpanIds.TryGetValue((span.TraceId, parentSpanId), out parentSpanId); + } + } return new TraceIngestionState { @@ -1321,7 +1341,6 @@ private sealed class IngestionParentReferenceRecord private sealed class IngestionAncestorRecord { public required string TraceId { get; init; } - public required string OriginSpanId { get; init; } public required string SpanId { get; init; } public string? ParentSpanId { get; init; } } diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index f43996a55e7..a0640c0d171 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -15,7 +15,7 @@ public sealed class DashboardSqliteDatabase : IDisposable { private const string SchemaResourcePrefix = "Aspire.Dashboard.ServiceClient.DatabaseSchema."; - internal const int SchemaVersion = 11; + internal const int SchemaVersion = 12; private static readonly Lazy> s_schemaScripts = new(LoadSchemaScripts); diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql index a8bd1248884..e01be381fd6 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/004.Traces.sql @@ -13,6 +13,31 @@ CREATE TABLE IF NOT EXISTS telemetry_traces ( has_gen_ai INTEGER NOT NULL CHECK (has_gen_ai IN (0, 1)) ) STRICT; +CREATE TABLE IF NOT EXISTS telemetry_span_kinds ( + kind INTEGER PRIMARY KEY, + kind_name TEXT NOT NULL UNIQUE +) STRICT; + +INSERT OR IGNORE INTO telemetry_span_kinds (kind, kind_name) +VALUES + (0, 'Unspecified'), + (1, 'Internal'), + (2, 'Server'), + (3, 'Client'), + (4, 'Producer'), + (5, 'Consumer'); + +CREATE TABLE IF NOT EXISTS telemetry_span_statuses ( + status INTEGER PRIMARY KEY, + status_name TEXT NOT NULL UNIQUE +) STRICT; + +INSERT OR IGNORE INTO telemetry_span_statuses (status, status_name) +VALUES + (0, 'Unset'), + (1, 'Ok'), + (2, 'Error'); + CREATE TABLE IF NOT EXISTS telemetry_spans ( trace_id TEXT NOT NULL REFERENCES telemetry_traces(trace_id) ON DELETE CASCADE, span_id TEXT NOT NULL, @@ -21,10 +46,10 @@ CREATE TABLE IF NOT EXISTS telemetry_spans ( resource_view_id INTEGER NOT NULL REFERENCES telemetry_resource_views(resource_view_id) ON DELETE CASCADE, scope_id INTEGER NOT NULL REFERENCES telemetry_scopes(scope_id), name TEXT NOT NULL, - kind INTEGER NOT NULL, + kind INTEGER NOT NULL REFERENCES telemetry_span_kinds(kind), start_time_ticks INTEGER NOT NULL, end_time_ticks INTEGER NOT NULL, - status INTEGER NOT NULL, + status INTEGER NOT NULL REFERENCES telemetry_span_statuses(status), status_message TEXT NULL, trace_state TEXT NULL, uninstrumented_peer_resource_id INTEGER NULL REFERENCES telemetry_resources(resource_id) ON DELETE SET NULL, diff --git a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs index 717d4f7ba92..02206f47bf9 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs @@ -601,6 +601,72 @@ FROM sqlite_schema ], indexNames); } + [Fact] + public void Schema_SpanKindAndStatusLookupsExist() + { + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); + using (CreateRepository(workspace.Path)) + { + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT kind || ':' || kind_name + FROM telemetry_span_kinds + ORDER BY kind; + """; + using (var reader = command.ExecuteReader()) + { + Assert.Equal( + [ + "0:Unspecified", + "1:Internal", + "2:Server", + "3:Client", + "4:Producer", + "5:Consumer" + ], ReadValues(reader)); + } + + command.CommandText = """ + SELECT status || ':' || status_name + FROM telemetry_span_statuses + ORDER BY status; + """; + using (var reader = command.ExecuteReader()) + { + Assert.Equal(["0:Unset", "1:Ok", "2:Error"], ReadValues(reader)); + } + + command.CommandText = """ + SELECT "table" || ':' || "from" || ':' || "to" + FROM pragma_foreign_key_list('telemetry_spans') + WHERE "from" IN ('kind', 'status') + ORDER BY "from"; + """; + using (var reader = command.ExecuteReader()) + { + Assert.Equal( + [ + "telemetry_span_kinds:kind:kind", + "telemetry_span_statuses:status:status" + ], ReadValues(reader)); + } + + static List ReadValues(SqliteDataReader reader) + { + var values = new List(); + while (reader.Read()) + { + values.Add(reader.GetString(0)); + } + return values; + } + } + [Fact] public void Schema_TelemetryResourceInstanceIdUniquenessPreservesNullAndEmpty() { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index 321c1618c22..65d705dd1db 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -3148,6 +3148,75 @@ public void GetSpans_FilterByTextFragments_ReturnsMatchingSpans() AssertId("1-1", result.PagedResult.Items[0].SpanId); } + [Theory] + [InlineData(KnownTraceFields.KindField, "Client")] + [InlineData(KnownTraceFields.StatusField, "Error")] + public void GetSpans_FilterByKindOrStatusText_ReturnsMatchingSpans(string field, string value) + { + var repository = CreateRepository(); + repository.AsWriter().AddTraces(new AddContext(), + [ + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-1", + startTime: s_testTime.AddMinutes(1), + endTime: s_testTime.AddMinutes(2), + kind: Span.Types.SpanKind.Client, + status: new Status { Code = Status.Types.StatusCode.Error }), + CreateSpan( + traceId: "1", + spanId: "1-2", + parentSpanId: "1-1", + startTime: s_testTime.AddMinutes(2), + endTime: s_testTime.AddMinutes(3), + kind: Span.Types.SpanKind.Server, + status: new Status { Code = Status.Types.StatusCode.Ok }) + } + } + } + } + ]); + + var fieldResult = repository.GetSpans(new GetSpansRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = int.MaxValue, + Filters = + [ + new FieldTelemetryFilter + { + Field = field, + Condition = FilterCondition.Equals, + Value = value + } + ] + }); + var textResult = repository.GetSpans(new GetSpansRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = int.MaxValue, + Filters = [], + TextFragments = [value] + }); + + Assert.Equal(1, fieldResult.PagedResult.TotalItemCount); + AssertId("1-1", Assert.Single(fieldResult.PagedResult.Items).SpanId); + Assert.Equal(1, textResult.PagedResult.TotalItemCount); + AssertId("1-1", Assert.Single(textResult.PagedResult.Items).SpanId); + } + [Fact] public void GetSpans_Pagination_ReturnsCorrectPage() { @@ -3881,6 +3950,74 @@ public sealed class SqliteTraceTests : TraceTests { protected override bool UseSqlite => true; + [Fact] + public void AddTraces_CircularReferenceAcrossPersistedAndIncomingSpans_Reject() + { + var testTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var repository = CreateRepository(); + repository.AsWriter().AddTraces(new AddContext(), + [ + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-1", + parentSpanId: "1-2", + startTime: testTime.AddMinutes(1), + endTime: testTime.AddMinutes(2)) + } + } + } + } + ]); + + var context = new AddContext(); + repository.AsWriter().AddTraces(context, + [ + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-2", + parentSpanId: "1-3", + startTime: testTime.AddMinutes(2), + endTime: testTime.AddMinutes(3)), + CreateSpan( + traceId: "1", + spanId: "1-3", + parentSpanId: "1-1", + startTime: testTime.AddMinutes(3), + endTime: testTime.AddMinutes(4)) + } + } + } + } + ]); + + Assert.Equal(1, context.SuccessCount); + Assert.Equal(1, context.FailureCount); + var trace = Assert.IsType(repository.GetTrace(GetHexId("1"))); + Assert.Collection(trace.Spans, + span => AssertId("1-1", span.SpanId), + span => AssertId("1-2", span.SpanId)); + } + [Fact] public void GetTraceSummaries_EqualStartTime_MatchesMaterializedTrace() { From 4a780b1eee988d5d5b5ce9bfc35fa5c8a570a804 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 21 Jul 2026 13:02:41 +0800 Subject: [PATCH 52/87] Improve dashboard telemetry import profiling --- .../Model/TelemetryImportService.cs | 12 +++++++++- .../SqliteTelemetryRepository.Traces.Reads.cs | 5 ++-- .../Properties/launchSettings.json | 10 ++++++++ .../Model/TelemetryImportServiceTests.cs | 24 +++++++++++++++++-- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Dashboard/Model/TelemetryImportService.cs b/src/Aspire.Dashboard/Model/TelemetryImportService.cs index ab99a0be8a6..8a3bf906222 100644 --- a/src/Aspire.Dashboard/Model/TelemetryImportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryImportService.cs @@ -1,6 +1,7 @@ // 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.IO.Compression; using System.Text.Json; using Aspire.Dashboard.Configuration; @@ -20,6 +21,7 @@ public sealed class TelemetryImportService private readonly ITelemetryRepositoryWriter _telemetryRepositoryWriter; private readonly IOptionsMonitor _options; private readonly ILogger _logger; + private readonly ActivitySource _activitySource; /// /// Gets a value indicating whether import is enabled. @@ -32,11 +34,17 @@ public sealed class TelemetryImportService /// The telemetry repository writer. /// The dashboard options. /// The logger. - public TelemetryImportService(ITelemetryRepositoryWriter telemetryRepositoryWriter, IOptionsMonitor options, ILogger logger) + /// The dashboard activity source. + public TelemetryImportService( + ITelemetryRepositoryWriter telemetryRepositoryWriter, + IOptionsMonitor options, + ILogger logger, + DashboardActivitySource activitySource) { _telemetryRepositoryWriter = telemetryRepositoryWriter; _options = options; _logger = logger; + _activitySource = activitySource.ActivitySource; } /// @@ -49,6 +57,8 @@ public TelemetryImportService(ITelemetryRepositoryWriter telemetryRepositoryWrit /// Thrown when import is disabled. public async Task ImportAsync(string fileName, Stream stream, CancellationToken cancellationToken) { + using var activity = _activitySource.StartActivity("Import telemetry data", ActivityKind.Internal); + if (!IsImportEnabled) { throw new InvalidOperationException("Import is disabled."); diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs index f86fb205fe0..b0266676c85 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs @@ -628,13 +628,12 @@ FROM telemetry_spans s return null; } - var spanIds = records.Select(record => record.SpanId).ToArray(); var spanAttributes = connection.Query(""" SELECT span_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue FROM telemetry_span_attributes - WHERE trace_id = @TraceId AND span_id IN @SpanIds + WHERE trace_id = @TraceId ORDER BY span_id, ordinal; - """, new { TraceId = traceId, SpanIds = spanIds }, transaction).ToLookup(record => record.OwnerId); + """, new { TraceId = traceId }, transaction).ToLookup(record => record.OwnerId); var eventRecords = connection.Query(""" SELECT event_id AS EventId, span_id AS SpanId, event_name AS EventName, event_time_ticks AS EventTimeTicks FROM telemetry_span_events diff --git a/src/Aspire.Dashboard/Properties/launchSettings.json b/src/Aspire.Dashboard/Properties/launchSettings.json index a98431f23ff..52ca5169eea 100644 --- a/src/Aspire.Dashboard/Properties/launchSettings.json +++ b/src/Aspire.Dashboard/Properties/launchSettings.json @@ -8,6 +8,16 @@ }, "applicationUrl": "https://localhost:15888" }, + "https (browser only + telemetry)": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc" + }, + "applicationUrl": "https://localhost:15888" + }, "http (browser only)": { "commandName": "Project", "launchBrowser": true, diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs index bd1d06e871c..8eb9675fc9e 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs @@ -1,6 +1,7 @@ // 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.Globalization; using System.IO.Compression; using System.Text; @@ -10,6 +11,7 @@ using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; using Aspire.Otlp.Serialization; +using Aspire.Tests; using Google.Protobuf.Collections; using Microsoft.Extensions.Logging.Abstractions; using OpenTelemetry.Proto.Logs.V1; @@ -23,11 +25,29 @@ public sealed class TelemetryImportServiceTests { private static readonly DateTime s_testTime = new(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc); - private static TelemetryImportService CreateImportService(InMemoryTelemetryRepository repository, bool disableImport = false) + private static TelemetryImportService CreateImportService(InMemoryTelemetryRepository repository, bool disableImport = false, DashboardActivitySource? activitySource = null) { var options = new DashboardOptions { UI = new UIOptions { DisableImport = disableImport } }; var optionsMonitor = new TestOptionsMonitor(options); - return new TelemetryImportService(repository, optionsMonitor, NullLogger.Instance); + return new TelemetryImportService(repository, optionsMonitor, NullLogger.Instance, activitySource ?? new DashboardActivitySource()); + } + + [Fact] + public async Task ImportAsync_CreatesActivity() + { + var repository = CreateRepository(); + using var activitySource = new DashboardActivitySource(); + var service = CreateImportService(repository, activitySource: activitySource); + var activities = new List(); + using var listener = ActivityListenerHelper.Create(activitySource.ActivitySource, onActivityStopped: activities.Add); + var stream = new MemoryStream(Encoding.UTF8.GetBytes(CreateLogsJson("TestService", "instance-1", "Test log message"))); + + await service.ImportAsync("logs.json", stream, CancellationToken.None); + + var activity = Assert.Single(activities); + Assert.Equal(DashboardActivitySource.ActivitySourceName, activity.Source.Name); + Assert.Equal("Import telemetry data", activity.OperationName); + Assert.Equal(ActivityKind.Internal, activity.Kind); } [Fact] From 3c804daee5c9e8aeb5fd5830753dfdd3dc48f805 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 21 Jul 2026 13:32:25 +0800 Subject: [PATCH 53/87] Optimize dashboard trace ingestion --- src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs | 27 +-- ...SqliteTelemetryRepository.Traces.Writes.cs | 181 +++++++++++++----- .../TelemetryRepositoryTests/TraceTests.cs | 52 +++++ 3 files changed, 196 insertions(+), 64 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs index 790fb203812..f1c72aa2790 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpTrace.cs @@ -44,20 +44,16 @@ public void AddSpan(OtlpSpan span, bool skipLastUpdatedDate = false) throw new InvalidOperationException($"Duplicate span id '{span.SpanId}' detected."); } - var added = false; + var insertIndex = 0; for (var i = Spans.Count - 1; i >= 0; i--) { if (span.StartTime > Spans[i].StartTime) { - Spans.Insert(i + 1, span); - added = true; + insertIndex = i + 1; break; } } - if (!added) - { - Spans.Insert(0, span); - } + Spans.Insert(insertIndex, span); if (HasCircularReference(span)) { @@ -92,7 +88,7 @@ public void AddSpan(OtlpSpan span, bool skipLastUpdatedDate = false) LastUpdatedDate = DateTime.UtcNow; } - AssertSpanOrder(); + AssertSpanOrder(insertIndex); static string BuildFullName(OtlpSpan existingSpan) { @@ -126,18 +122,13 @@ private static bool HasCircularReference(OtlpSpan span) } [Conditional("DEBUG")] - private void AssertSpanOrder() + private void AssertSpanOrder(int index) { - DateTime current = default; - for (var i = 0; i < Spans.Count; i++) + var span = Spans[index]; + if ((index > 0 && span.StartTime < Spans[index - 1].StartTime) || + (index < Spans.Count - 1 && span.StartTime > Spans[index + 1].StartTime)) { - var span = Spans[i]; - if (span.StartTime < current) - { - throw new InvalidOperationException($"Trace {TraceId} spans not in order at index {i}."); - } - - current = span.StartTime; + throw new InvalidOperationException($"Trace {TraceId} spans not in order at index {index}."); } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs index aab93992a5e..08a2da66d3b 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Data; +using System.Data.Common; using System.Globalization; using System.Text; using Aspire.Dashboard.Model; @@ -16,8 +17,10 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed partial class SqliteTelemetryRepository { private const int MaxTraceBatchSize = 100; - private const int MaxSpanBatchSize = 50; - private const int MaxSpanDetailBatchSize = 100; + // Microsoft.Data.Sqlite resolves every named parameter when binding. Reusing prepared commands avoids + // repeated preparation, while these batch sizes keep each binding pass small enough to avoid nonlinear cost. + private const int MaxSpanBatchSize = 25; + private const int MaxSpanDetailBatchSize = 50; private List AddTracesToDatabase(AddContext context, RepeatedField resourceSpans) { @@ -467,41 +470,81 @@ private static bool IsPreferredPrimarySpan( private static void InsertSpans(SqliteConnection connection, IDbTransaction transaction, List spans) { - foreach (var batch in spans.Chunk(MaxSpanBatchSize)) + DbCommand? command = null; + try { - var sql = new StringBuilder(""" + foreach (var batch in spans.Chunk(MaxSpanBatchSize)) + { + if (command is null || command.Parameters.Count != batch.Length * 13) + { + command?.Dispose(); + command = CreateInsertSpansCommand(connection, transaction, batch.Length); + } + + for (var index = 0; index < batch.Length; index++) + { + var parameterIndex = index * 13; + var pendingSpan = batch[index]; + var span = pendingSpan.Span; + command.Parameters[parameterIndex++].Value = span.TraceId; + command.Parameters[parameterIndex++].Value = span.SpanId; + command.Parameters[parameterIndex++].Value = span.ParentSpanId ?? (object)DBNull.Value; + command.Parameters[parameterIndex++].Value = pendingSpan.ResourceId; + command.Parameters[parameterIndex++].Value = pendingSpan.ResourceViewId; + command.Parameters[parameterIndex++].Value = pendingSpan.ScopeId; + command.Parameters[parameterIndex++].Value = span.Name; + command.Parameters[parameterIndex++].Value = (int)span.Kind; + command.Parameters[parameterIndex++].Value = span.StartTime.Ticks; + command.Parameters[parameterIndex++].Value = span.EndTime.Ticks; + command.Parameters[parameterIndex++].Value = (int)span.Status; + command.Parameters[parameterIndex++].Value = span.StatusMessage ?? (object)DBNull.Value; + command.Parameters[parameterIndex].Value = span.State ?? (object)DBNull.Value; + } + + command.ExecuteNonQuery(); + } + } + finally + { + command?.Dispose(); + } + } + + private static DbCommand CreateInsertSpansCommand(SqliteConnection connection, IDbTransaction transaction, int batchSize) + { + var command = ((DbConnection)connection).CreateCommand(); + command.Transaction = (DbTransaction)transaction; + var sql = new StringBuilder(""" INSERT INTO telemetry_spans ( trace_id, span_id, parent_span_id, resource_id, resource_view_id, scope_id, name, kind, start_time_ticks, end_time_ticks, status, status_message, trace_state, resource_order_ticks) VALUES """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) + for (var index = 0; index < batchSize; index++) + { + if (index > 0) { - if (index > 0) - { - sql.AppendLine(","); - } - var pendingSpan = batch[index]; - var span = pendingSpan.Span; - sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @ParentSpanId{index}, @ResourceId{index}, @ResourceViewId{index}, @ScopeId{index}, @Name{index}, @Kind{index}, @StartTimeTicks{index}, @EndTimeTicks{index}, @Status{index}, @StatusMessage{index}, @State{index}, @StartTimeTicks{index})"); - parameters.Add($"TraceId{index}", span.TraceId); - parameters.Add($"SpanId{index}", span.SpanId); - parameters.Add($"ParentSpanId{index}", span.ParentSpanId); - parameters.Add($"ResourceId{index}", pendingSpan.ResourceId); - parameters.Add($"ResourceViewId{index}", pendingSpan.ResourceViewId); - parameters.Add($"ScopeId{index}", pendingSpan.ScopeId); - parameters.Add($"Name{index}", span.Name); - parameters.Add($"Kind{index}", (int)span.Kind); - parameters.Add($"StartTimeTicks{index}", span.StartTime.Ticks); - parameters.Add($"EndTimeTicks{index}", span.EndTime.Ticks); - parameters.Add($"Status{index}", (int)span.Status); - parameters.Add($"StatusMessage{index}", span.StatusMessage); - parameters.Add($"State{index}", span.State); + sql.AppendLine(","); } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); + sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @ParentSpanId{index}, @ResourceId{index}, @ResourceViewId{index}, @ScopeId{index}, @Name{index}, @Kind{index}, @StartTimeTicks{index}, @EndTimeTicks{index}, @Status{index}, @StatusMessage{index}, @State{index}, @StartTimeTicks{index})"); + AddParameter(command, $"@TraceId{index}"); + AddParameter(command, $"@SpanId{index}"); + AddParameter(command, $"@ParentSpanId{index}"); + AddParameter(command, $"@ResourceId{index}"); + AddParameter(command, $"@ResourceViewId{index}"); + AddParameter(command, $"@ScopeId{index}"); + AddParameter(command, $"@Name{index}"); + AddParameter(command, $"@Kind{index}"); + AddParameter(command, $"@StartTimeTicks{index}"); + AddParameter(command, $"@EndTimeTicks{index}"); + AddParameter(command, $"@Status{index}"); + AddParameter(command, $"@StatusMessage{index}"); + AddParameter(command, $"@State{index}"); } + sql.Append(';'); + command.CommandText = sql.ToString(); + command.Prepare(); + return command; } private static void MarkResourcesHaveTraces(SqliteConnection connection, IDbTransaction transaction, HashSet resources) @@ -526,17 +569,23 @@ private HashSet UpdateUninstrumentedPeers( IEnumerable traces, TraceIngestionState ingestionState) { + var traceList = traces.ToList(); + var incomingParentReferences = traceList + .SelectMany(trace => trace.Spans) + .Where(span => span.ParentSpanId is not null) + .Select(span => (span.TraceId, ParentSpanId: span.ParentSpanId!)) + .ToHashSet(); var peerResourceIds = new HashSet(); var spanUpdates = new List(); var peerChangedTraceIds = new HashSet(StringComparer.Ordinal); - foreach (var trace in traces) + foreach (var trace in traceList) { foreach (var span in trace.Spans) { OtlpResource? peer = null; long? peerResourceId = null; var hasPeerAddress = OtlpHelpers.GetPeerAddress(span.Attributes) is not null; - var hasChildren = span.GetChildSpans().Any() || + var hasChildren = incomingParentReferences.Contains((span.TraceId, span.SpanId)) || ingestionState.ExistingParentReferences.Contains((span.TraceId, span.SpanId)); if (hasPeerAddress && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !hasChildren) { @@ -847,29 +896,69 @@ private static void InsertSpanAttributes(SqliteConnection connection, IDbTransac attribute.Value })) .ToArray(); - foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) + DbCommand? command = null; + try { - var sql = new StringBuilder(""" + foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) + { + if (command is null || command.Parameters.Count != batch.Length * 5) + { + command?.Dispose(); + command = CreateInsertSpanAttributesCommand(connection, transaction, batch.Length); + } + + for (var index = 0; index < batch.Length; index++) + { + var parameterIndex = index * 5; + command.Parameters[parameterIndex++].Value = batch[index].TraceId; + command.Parameters[parameterIndex++].Value = batch[index].SpanId; + command.Parameters[parameterIndex++].Value = batch[index].Ordinal; + command.Parameters[parameterIndex++].Value = batch[index].Key; + command.Parameters[parameterIndex].Value = batch[index].Value; + } + + command.ExecuteNonQuery(); + } + } + finally + { + command?.Dispose(); + } + } + + private static DbCommand CreateInsertSpanAttributesCommand(SqliteConnection connection, IDbTransaction transaction, int batchSize) + { + var command = ((DbConnection)connection).CreateCommand(); + command.Transaction = (DbTransaction)transaction; + var sql = new StringBuilder(""" INSERT INTO telemetry_span_attributes (trace_id, span_id, ordinal, attribute_key, attribute_value) VALUES """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) + for (var index = 0; index < batchSize; index++) + { + if (index > 0) { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); - parameters.Add($"TraceId{index}", batch[index].TraceId); - parameters.Add($"SpanId{index}", batch[index].SpanId); - parameters.Add($"Ordinal{index}", batch[index].Ordinal); - parameters.Add($"Key{index}", batch[index].Key); - parameters.Add($"Value{index}", batch[index].Value); + sql.AppendLine(","); } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); + sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); + AddParameter(command, $"@TraceId{index}"); + AddParameter(command, $"@SpanId{index}"); + AddParameter(command, $"@Ordinal{index}"); + AddParameter(command, $"@Key{index}"); + AddParameter(command, $"@Value{index}"); } + sql.Append(';'); + command.CommandText = sql.ToString(); + command.Prepare(); + return command; + } + + private static void AddParameter(DbCommand command, string name) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = DBNull.Value; + command.Parameters.Add(parameter); } private static void InsertSpanEvents(SqliteConnection connection, IDbTransaction transaction, PendingSpanEvent[] events) diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index 65d705dd1db..f2ada7d7a5c 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -4311,6 +4311,58 @@ static ResourceSpans CreateResourceSpans(string resourceName, string traceId, st } } + [Fact] + public void AddTraces_ReusesPreparedCommandsAcrossBatches() + { + const int spanCount = 101; + var repository = Assert.IsType(CreateRepository()); + var resourceSpans = new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans { Scope = CreateScope() } + } + }; + var testTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + for (var index = 0; index < spanCount; index++) + { + resourceSpans.ScopeSpans[0].Spans.Add(CreateSpan( + traceId: "trace", + spanId: $"span-{index}", + startTime: testTime.AddTicks(index), + endTime: testTime.AddTicks(index + 1), + attributes: [KeyValuePair.Create("index", index.ToString(CultureInfo.InvariantCulture))])); + } + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); + + var context = new AddContext(); + repository.AsWriter().AddTraces(context, [resourceSpans]); + + var spanInsertQueries = activities + .Select(activity => (string)activity.GetTagItem("db.query.text")!) + .Where(query => query.StartsWith("INSERT INTO telemetry_spans", StringComparison.Ordinal)) + .ToList(); + Assert.Equal(5, spanInsertQueries.Count); + Assert.All(spanInsertQueries.Skip(1).Take(3), query => Assert.Same(spanInsertQueries[0], query)); + Assert.NotSame(spanInsertQueries[0], spanInsertQueries[4]); + + var attributeInsertQueries = activities + .Select(activity => (string)activity.GetTagItem("db.query.text")!) + .Where(query => query.StartsWith("INSERT INTO telemetry_span_attributes", StringComparison.Ordinal)) + .ToList(); + Assert.Equal(3, attributeInsertQueries.Count); + Assert.Same(attributeInsertQueries[0], attributeInsertQueries[1]); + Assert.NotSame(attributeInsertQueries[0], attributeInsertQueries[2]); + Assert.Equal(spanCount, context.SuccessCount); + Assert.Equal(0, context.FailureCount); + + var trace = Assert.IsType(repository.GetTrace(GetHexId("trace"))); + Assert.Equal(spanCount, trace.Spans.Count); + Assert.All(trace.Spans, span => Assert.Single(span.Attributes)); + } + [Fact] public void GetTraceSummaries_UsesPersistedResourceSummaries() { From 3e9ed8984c4ed74525032fffff789d1dd948df15 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 21 Jul 2026 14:29:12 +0800 Subject: [PATCH 54/87] Optimize dashboard SQLite batch inserts --- .../SqliteTelemetryRepository.Caching.cs | 56 +- .../Storage/SqliteTelemetryRepository.Logs.cs | 35 +- .../SqliteTelemetryRepository.Metrics.cs | 133 ++--- ...SqliteTelemetryRepository.Traces.Writes.cs | 285 +++------- .../SqliteResourceRepository.Storage.cs | 518 +++++++++--------- .../Utils/SqliteBatchInsert.cs | 116 ++++ .../Model/SqliteResourceRepositoryTests.cs | 47 +- .../TelemetryRepositoryTests/LogTests.cs | 82 +-- .../TelemetryRepositoryTests/MetricsTests.cs | 72 ++- .../TelemetryRepositoryTests/TraceTests.cs | 26 +- 10 files changed, 667 insertions(+), 703 deletions(-) create mode 100644 src/Aspire.Dashboard/Utils/SqliteBatchInsert.cs diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs index 7c0dfff5e9a..330c84caa5e 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Text; using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; using Dapper; using Google.Protobuf.Collections; using Microsoft.Data.Sqlite; @@ -16,6 +17,7 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed partial class SqliteTelemetryRepository { private const int MaxInstrumentBatchSize = 100; + private const int MaxMetadataAttributeBatchSize = 200; private readonly object _cacheLock = new(); private readonly Dictionary _cachedResourcesByKey = []; @@ -146,16 +148,23 @@ INSERT INTO telemetry_resource_views (resource_id) VALUES (@ResourceId) RETURNING resource_view_id; """, new { resource.ResourceId }, transaction); - connection.Execute(""" - INSERT INTO telemetry_resource_view_attributes (resource_view_id, ordinal, attribute_key, attribute_value) - VALUES (@ResourceViewId, @Ordinal, @Key, @Value); - """, incomingView.Properties.Select((property, ordinal) => new - { - ResourceViewId = resourceViewId.Value, - Ordinal = ordinal, - property.Key, - property.Value - }), transaction); + var properties = incomingView.Properties + .Select((property, ordinal) => (Ordinal: ordinal, property.Key, property.Value)) + .ToArray(); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + properties, + MaxMetadataAttributeBatchSize, + "telemetry_resource_view_attributes", + ["resource_view_id", "ordinal", "attribute_key", "attribute_value"], + (property, parameters) => + { + parameters[0].Value = resourceViewId.Value; + parameters[1].Value = property.Ordinal; + parameters[2].Value = property.Key; + parameters[3].Value = property.Value; + }); } return AddCachedResourceView(resource, resourceViewId.Value, incomingView.Properties); @@ -215,16 +224,23 @@ INSERT INTO telemetry_scopes (scope_name, scope_version) VALUES (@ScopeName, @ScopeVersion) RETURNING scope_id; """, new { ScopeName = incomingScope.Name, ScopeVersion = incomingScope.Version }, transaction); - connection.Execute(""" - INSERT INTO telemetry_scope_attributes (scope_id, ordinal, attribute_key, attribute_value) - VALUES (@ScopeId, @Ordinal, @Key, @Value); - """, incomingScope.Attributes.Select((attribute, ordinal) => new - { - ScopeId = scopeId, - Ordinal = ordinal, - attribute.Key, - attribute.Value - }), transaction); + var attributes = incomingScope.Attributes + .Select((attribute, ordinal) => (Ordinal: ordinal, attribute.Key, attribute.Value)) + .ToArray(); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + attributes, + MaxMetadataAttributeBatchSize, + "telemetry_scope_attributes", + ["scope_id", "ordinal", "attribute_key", "attribute_value"], + (attribute, parameters) => + { + parameters[0].Value = scopeId; + parameters[1].Value = attribute.Ordinal; + parameters[2].Value = attribute.Key; + parameters[3].Value = attribute.Value; + }); cachedScope = GetOrAddCachedScope(scopeId, incomingScope.Name, incomingScope.Version, incomingScope.Attributes); } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs index 1987d77421a..82653ecb031 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs @@ -7,6 +7,7 @@ using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; using Dapper; using Google.Protobuf.Collections; using Microsoft.Data.Sqlite; @@ -176,28 +177,20 @@ private static void InsertLogAttributes(SqliteConnection connection, IDbTransact var attributes = logs .SelectMany((pendingLog, logIndex) => pendingLog.Log.Attributes.Select((attribute, ordinal) => (LogId: logIds[logIndex], Ordinal: ordinal, Attribute: attribute))) .ToArray(); - foreach (var attributeBatch in attributes.Chunk(MaxLogAttributeBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_log_attributes (log_id, ordinal, attribute_key, attribute_value) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < attributeBatch.Length; index++) + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + attributes, + MaxLogAttributeBatchSize, + "telemetry_log_attributes", + ["log_id", "ordinal", "attribute_key", "attribute_value"], + static (row, parameters) => { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@LogId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); - parameters.Add($"LogId{index}", attributeBatch[index].LogId); - parameters.Add($"Ordinal{index}", attributeBatch[index].Ordinal); - parameters.Add($"Key{index}", attributeBatch[index].Attribute.Key); - parameters.Add($"Value{index}", attributeBatch[index].Attribute.Value); - } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } + parameters[0].Value = row.LogId; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.Attribute.Key; + parameters[3].Value = row.Attribute.Value; + }); } private static void MarkResourcesHaveLogs(SqliteConnection connection, IDbTransaction transaction, HashSet resources) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs index a8a96d245ca..b2ee4a253fa 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs @@ -9,6 +9,7 @@ using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.MetricValues; +using Aspire.Dashboard.Utils; using Dapper; using Google.Protobuf.Collections; using Microsoft.Data.Sqlite; @@ -419,27 +420,19 @@ private static void InsertHistogramBucketCounts( .Where(point => point.HistogramBucketCounts is { Length: > 0 }) .SelectMany(point => point.HistogramBucketCounts!.Select((bucketCount, ordinal) => new PendingHistogramBucketCount(point.PointId, ordinal, bucketCount))) .ToArray(); - foreach (var batch in bucketCounts.Chunk(MaxMetricPointBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_metric_histogram_bucket_counts (point_id, ordinal, bucket_count) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + bucketCounts, + MaxMetricPointBatchSize, + "telemetry_metric_histogram_bucket_counts", + ["point_id", "ordinal", "bucket_count"], + static (row, parameters) => { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@PointId{index}, @Ordinal{index}, @BucketCount{index})"); - parameters.Add($"PointId{index}", batch[index].PointId); - parameters.Add($"Ordinal{index}", batch[index].Ordinal); - parameters.Add($"BucketCount{index}", batch[index].BucketCount); - } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } + parameters[0].Value = row.PointId; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.BucketCount; + }); } private static void InsertHistogramExplicitBounds( @@ -451,27 +444,19 @@ private static void InsertHistogramExplicitBounds( .Where(point => point.HistogramExplicitBounds is { Length: > 0 }) .SelectMany(point => point.HistogramExplicitBounds!.Select((explicitBound, ordinal) => new PendingHistogramExplicitBound(point.PointId, ordinal, explicitBound))) .ToArray(); - foreach (var batch in explicitBounds.Chunk(MaxMetricPointBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_metric_histogram_explicit_bounds (point_id, ordinal, explicit_bound) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + explicitBounds, + MaxMetricPointBatchSize, + "telemetry_metric_histogram_explicit_bounds", + ["point_id", "ordinal", "explicit_bound"], + static (row, parameters) => { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@PointId{index}, @Ordinal{index}, @ExplicitBound{index})"); - parameters.Add($"PointId{index}", batch[index].PointId); - parameters.Add($"Ordinal{index}", batch[index].Ordinal); - parameters.Add($"ExplicitBound{index}", batch[index].ExplicitBound); - } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } + parameters[0].Value = row.PointId; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.ExplicitBound; + }); } private MetricDimensionState GetOrAddMetricDimension( @@ -634,28 +619,20 @@ private static void InsertMetricDimensionAttributes( IDbTransaction transaction, List attributes) { - foreach (var batch in attributes.Chunk(MaxMetricPointBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_metric_dimension_attributes (dimension_id, ordinal, attribute_key, attribute_value) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + attributes, + MaxMetricPointBatchSize, + "telemetry_metric_dimension_attributes", + ["dimension_id", "ordinal", "attribute_key", "attribute_value"], + static (row, parameters) => { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@DimensionId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); - parameters.Add($"DimensionId{index}", batch[index].Dimension.DimensionId); - parameters.Add($"Ordinal{index}", batch[index].Ordinal); - parameters.Add($"Key{index}", batch[index].Key); - parameters.Add($"Value{index}", batch[index].Value); - } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } + parameters[0].Value = row.Dimension.DimensionId; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.Key; + parameters[3].Value = row.Value; + }); } private static long GetMetricDimensionAttributeHash(ReadOnlySpan> attributes) @@ -750,28 +727,20 @@ INSERT OR IGNORE INTO telemetry_metric_exemplars ( attribute.Key, attribute.Value))) .ToArray(); - foreach (var batch in attributes.Chunk(MaxMetricPointBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_metric_exemplar_attributes (exemplar_id, ordinal, attribute_key, attribute_value) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + attributes, + MaxMetricPointBatchSize, + "telemetry_metric_exemplar_attributes", + ["exemplar_id", "ordinal", "attribute_key", "attribute_value"], + static (row, parameters) => { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@ExemplarId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); - parameters.Add($"ExemplarId{index}", batch[index].ExemplarId); - parameters.Add($"Ordinal{index}", batch[index].Ordinal); - parameters.Add($"Key{index}", batch[index].Key); - parameters.Add($"Value{index}", batch[index].Value); - } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } + parameters[0].Value = row.ExemplarId; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.Key; + parameters[3].Value = row.Value; + }); } private void TrimMetricDimensions(SqliteConnection connection, IDbTransaction transaction, IEnumerable dimensions) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs index 08a2da66d3b..a1f7a386ecd 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs @@ -2,11 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Data; -using System.Data.Common; using System.Globalization; using System.Text; using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; using Dapper; using Google.Protobuf.Collections; using Microsoft.Data.Sqlite; @@ -470,81 +470,34 @@ private static bool IsPreferredPrimarySpan( private static void InsertSpans(SqliteConnection connection, IDbTransaction transaction, List spans) { - DbCommand? command = null; - try - { - foreach (var batch in spans.Chunk(MaxSpanBatchSize)) - { - if (command is null || command.Parameters.Count != batch.Length * 13) - { - command?.Dispose(); - command = CreateInsertSpansCommand(connection, transaction, batch.Length); - } - - for (var index = 0; index < batch.Length; index++) - { - var parameterIndex = index * 13; - var pendingSpan = batch[index]; - var span = pendingSpan.Span; - command.Parameters[parameterIndex++].Value = span.TraceId; - command.Parameters[parameterIndex++].Value = span.SpanId; - command.Parameters[parameterIndex++].Value = span.ParentSpanId ?? (object)DBNull.Value; - command.Parameters[parameterIndex++].Value = pendingSpan.ResourceId; - command.Parameters[parameterIndex++].Value = pendingSpan.ResourceViewId; - command.Parameters[parameterIndex++].Value = pendingSpan.ScopeId; - command.Parameters[parameterIndex++].Value = span.Name; - command.Parameters[parameterIndex++].Value = (int)span.Kind; - command.Parameters[parameterIndex++].Value = span.StartTime.Ticks; - command.Parameters[parameterIndex++].Value = span.EndTime.Ticks; - command.Parameters[parameterIndex++].Value = (int)span.Status; - command.Parameters[parameterIndex++].Value = span.StatusMessage ?? (object)DBNull.Value; - command.Parameters[parameterIndex].Value = span.State ?? (object)DBNull.Value; - } - - command.ExecuteNonQuery(); - } - } - finally - { - command?.Dispose(); - } - } - - private static DbCommand CreateInsertSpansCommand(SqliteConnection connection, IDbTransaction transaction, int batchSize) - { - var command = ((DbConnection)connection).CreateCommand(); - command.Transaction = (DbTransaction)transaction; - var sql = new StringBuilder(""" - INSERT INTO telemetry_spans ( - trace_id, span_id, parent_span_id, resource_id, resource_view_id, scope_id, name, kind, - start_time_ticks, end_time_ticks, status, status_message, trace_state, resource_order_ticks) - VALUES - """); - for (var index = 0; index < batchSize; index++) - { - if (index > 0) + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + spans, + MaxSpanBatchSize, + "telemetry_spans", + [ + "trace_id", "span_id", "parent_span_id", "resource_id", "resource_view_id", "scope_id", "name", "kind", + "start_time_ticks", "end_time_ticks", "status", "status_message", "trace_state", "resource_order_ticks" + ], + static (pendingSpan, parameters) => { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @ParentSpanId{index}, @ResourceId{index}, @ResourceViewId{index}, @ScopeId{index}, @Name{index}, @Kind{index}, @StartTimeTicks{index}, @EndTimeTicks{index}, @Status{index}, @StatusMessage{index}, @State{index}, @StartTimeTicks{index})"); - AddParameter(command, $"@TraceId{index}"); - AddParameter(command, $"@SpanId{index}"); - AddParameter(command, $"@ParentSpanId{index}"); - AddParameter(command, $"@ResourceId{index}"); - AddParameter(command, $"@ResourceViewId{index}"); - AddParameter(command, $"@ScopeId{index}"); - AddParameter(command, $"@Name{index}"); - AddParameter(command, $"@Kind{index}"); - AddParameter(command, $"@StartTimeTicks{index}"); - AddParameter(command, $"@EndTimeTicks{index}"); - AddParameter(command, $"@Status{index}"); - AddParameter(command, $"@StatusMessage{index}"); - AddParameter(command, $"@State{index}"); - } - sql.Append(';'); - command.CommandText = sql.ToString(); - command.Prepare(); - return command; + var span = pendingSpan.Span; + parameters[0].Value = span.TraceId; + parameters[1].Value = span.SpanId; + parameters[2].Value = span.ParentSpanId ?? (object)DBNull.Value; + parameters[3].Value = pendingSpan.ResourceId; + parameters[4].Value = pendingSpan.ResourceViewId; + parameters[5].Value = pendingSpan.ScopeId; + parameters[6].Value = span.Name; + parameters[7].Value = (int)span.Kind; + parameters[8].Value = span.StartTime.Ticks; + parameters[9].Value = span.EndTime.Ticks; + parameters[10].Value = (int)span.Status; + parameters[11].Value = span.StatusMessage ?? (object)DBNull.Value; + parameters[12].Value = span.State ?? (object)DBNull.Value; + parameters[13].Value = span.StartTime.Ticks; + }); } private static void MarkResourcesHaveTraces(SqliteConnection connection, IDbTransaction transaction, HashSet resources) @@ -896,97 +849,41 @@ private static void InsertSpanAttributes(SqliteConnection connection, IDbTransac attribute.Value })) .ToArray(); - DbCommand? command = null; - try - { - foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) - { - if (command is null || command.Parameters.Count != batch.Length * 5) - { - command?.Dispose(); - command = CreateInsertSpanAttributesCommand(connection, transaction, batch.Length); - } - - for (var index = 0; index < batch.Length; index++) - { - var parameterIndex = index * 5; - command.Parameters[parameterIndex++].Value = batch[index].TraceId; - command.Parameters[parameterIndex++].Value = batch[index].SpanId; - command.Parameters[parameterIndex++].Value = batch[index].Ordinal; - command.Parameters[parameterIndex++].Value = batch[index].Key; - command.Parameters[parameterIndex].Value = batch[index].Value; - } - - command.ExecuteNonQuery(); - } - } - finally - { - command?.Dispose(); - } - } - - private static DbCommand CreateInsertSpanAttributesCommand(SqliteConnection connection, IDbTransaction transaction, int batchSize) - { - var command = ((DbConnection)connection).CreateCommand(); - command.Transaction = (DbTransaction)transaction; - var sql = new StringBuilder(""" - INSERT INTO telemetry_span_attributes (trace_id, span_id, ordinal, attribute_key, attribute_value) - VALUES - """); - for (var index = 0; index < batchSize; index++) - { - if (index > 0) + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + attributes, + MaxSpanDetailBatchSize, + "telemetry_span_attributes", + ["trace_id", "span_id", "ordinal", "attribute_key", "attribute_value"], + static (attribute, parameters) => { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@TraceId{index}, @SpanId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); - AddParameter(command, $"@TraceId{index}"); - AddParameter(command, $"@SpanId{index}"); - AddParameter(command, $"@Ordinal{index}"); - AddParameter(command, $"@Key{index}"); - AddParameter(command, $"@Value{index}"); - } - sql.Append(';'); - command.CommandText = sql.ToString(); - command.Prepare(); - return command; - } - - private static void AddParameter(DbCommand command, string name) - { - var parameter = command.CreateParameter(); - parameter.ParameterName = name; - parameter.Value = DBNull.Value; - command.Parameters.Add(parameter); + parameters[0].Value = attribute.TraceId; + parameters[1].Value = attribute.SpanId; + parameters[2].Value = attribute.Ordinal; + parameters[3].Value = attribute.Key; + parameters[4].Value = attribute.Value; + }); } private static void InsertSpanEvents(SqliteConnection connection, IDbTransaction transaction, PendingSpanEvent[] events) { - foreach (var batch in events.Chunk(MaxSpanDetailBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_span_events (event_id, trace_id, span_id, ordinal, event_name, event_time_ticks) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + events, + MaxSpanDetailBatchSize, + "telemetry_span_events", + ["event_id", "trace_id", "span_id", "ordinal", "event_name", "event_time_ticks"], + static (spanEvent, parameters) => { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@EventId{index}, @TraceId{index}, @SpanId{index}, @Ordinal{index}, @Name{index}, @TimeTicks{index})"); - parameters.Add($"EventId{index}", batch[index].EventId); - parameters.Add($"TraceId{index}", batch[index].TraceId); - parameters.Add($"SpanId{index}", batch[index].SpanId); - parameters.Add($"Ordinal{index}", batch[index].Ordinal); - parameters.Add($"Name{index}", batch[index].Event.Name); - parameters.Add($"TimeTicks{index}", batch[index].Event.Time.Ticks); - } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } + parameters[0].Value = spanEvent.EventId; + parameters[1].Value = spanEvent.TraceId; + parameters[2].Value = spanEvent.SpanId; + parameters[3].Value = spanEvent.Ordinal; + parameters[4].Value = spanEvent.Event.Name; + parameters[5].Value = spanEvent.Event.Time.Ticks; + }); } private static void InsertSpanEventAttributes(SqliteConnection connection, IDbTransaction transaction, PendingSpanEvent[] events) @@ -1000,28 +897,20 @@ private static void InsertSpanEventAttributes(SqliteConnection connection, IDbTr attribute.Value })) .ToArray(); - foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_span_event_attributes (event_id, ordinal, attribute_key, attribute_value) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + attributes, + MaxSpanDetailBatchSize, + "telemetry_span_event_attributes", + ["event_id", "ordinal", "attribute_key", "attribute_value"], + static (attribute, parameters) => { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@EventId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); - parameters.Add($"EventId{index}", batch[index].EventId); - parameters.Add($"Ordinal{index}", batch[index].Ordinal); - parameters.Add($"Key{index}", batch[index].Key); - parameters.Add($"Value{index}", batch[index].Value); - } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } + parameters[0].Value = attribute.EventId; + parameters[1].Value = attribute.Ordinal; + parameters[2].Value = attribute.Key; + parameters[3].Value = attribute.Value; + }); } private static void InsertSpanLinks(SqliteConnection connection, IDbTransaction transaction, PendingSpanLink[] links) @@ -1065,28 +954,20 @@ private static void InsertSpanLinkAttributes(SqliteConnection connection, IDbTra attribute.Value })) .ToArray(); - foreach (var batch in attributes.Chunk(MaxSpanDetailBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_span_link_attributes (link_id, ordinal, attribute_key, attribute_value) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + attributes, + MaxSpanDetailBatchSize, + "telemetry_span_link_attributes", + ["link_id", "ordinal", "attribute_key", "attribute_value"], + static (attribute, parameters) => { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@LinkId{index}, @Ordinal{index}, @Key{index}, @Value{index})"); - parameters.Add($"LinkId{index}", batch[index].LinkId); - parameters.Add($"Ordinal{index}", batch[index].Ordinal); - parameters.Add($"Key{index}", batch[index].Key); - parameters.Add($"Value{index}", batch[index].Value); - } - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } + parameters[0].Value = attribute.LinkId; + parameters[1].Value = attribute.Ordinal; + parameters[2].Value = attribute.Key; + parameters[3].Value = attribute.Value; + }); } private OtlpSpan CreateSqliteSpan(OtlpResourceView resourceView, OtlpTrace trace, OtlpScope scope, Span span) diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs index b6f32955bd1..b54c884df89 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.Storage.cs @@ -2,8 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Data; -using System.Globalization; -using System.Text; +using Aspire.Dashboard.Utils; using Aspire.DashboardService.Proto.V1; using Dapper; using Google.Protobuf; @@ -18,55 +17,40 @@ public sealed partial class SqliteResourceRepository private static void InsertResources(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList resources) { - foreach (var batch in resources.Chunk(MaxWriteBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO dashboard_resources ( - resource_name, replica_index, resource_type, display_name, uid, state, - created_at_seconds, created_at_nanos, state_style, - started_at_seconds, started_at_nanos, stopped_at_seconds, stopped_at_nanos, - is_hidden, supports_detailed_telemetry, icon_name, icon_variant, console_logs_loaded) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + resources, + MaxWriteBatchSize, + "dashboard_resources", + [ + "resource_name", "replica_index", "resource_type", "display_name", "uid", "state", + "created_at_seconds", "created_at_nanos", "state_style", + "started_at_seconds", "started_at_nanos", "stopped_at_seconds", "stopped_at_nanos", + "is_hidden", "supports_detailed_telemetry", "icon_name", "icon_variant", "console_logs_loaded" + ], + static (resourceToSave, parameters) => { - var resourceToSave = batch[index]; var resource = resourceToSave.Resource; - if (index > 0) - { - sql.AppendLine(","); - } - - sql.Append(CultureInfo.InvariantCulture, $""" - (@Name{index}, @ReplicaIndex{index}, @ResourceType{index}, @DisplayName{index}, @Uid{index}, @State{index}, - @CreatedAtSeconds{index}, @CreatedAtNanos{index}, @StateStyle{index}, - @StartedAtSeconds{index}, @StartedAtNanos{index}, @StoppedAtSeconds{index}, @StoppedAtNanos{index}, - @IsHidden{index}, @SupportsDetailedTelemetry{index}, @IconName{index}, @IconVariant{index}, @ConsoleLogsLoaded{index}) - """); - parameters.Add($"Name{index}", resource.Name); - parameters.Add($"ReplicaIndex{index}", resourceToSave.ReplicaIndex); - parameters.Add($"ResourceType{index}", resource.ResourceType); - parameters.Add($"DisplayName{index}", resource.DisplayName); - parameters.Add($"Uid{index}", resource.Uid); - parameters.Add($"State{index}", resource.HasState ? resource.State : null); - parameters.Add($"CreatedAtSeconds{index}", resource.CreatedAt?.Seconds); - parameters.Add($"CreatedAtNanos{index}", resource.CreatedAt?.Nanos); - parameters.Add($"StateStyle{index}", resource.HasStateStyle ? resource.StateStyle : null); - parameters.Add($"StartedAtSeconds{index}", resource.StartedAt?.Seconds); - parameters.Add($"StartedAtNanos{index}", resource.StartedAt?.Nanos); - parameters.Add($"StoppedAtSeconds{index}", resource.StoppedAt?.Seconds); - parameters.Add($"StoppedAtNanos{index}", resource.StoppedAt?.Nanos); - parameters.Add($"IsHidden{index}", resource.IsHidden); - parameters.Add($"SupportsDetailedTelemetry{index}", resource.SupportsDetailedTelemetry); - parameters.Add($"IconName{index}", resource.HasIconName ? resource.IconName : null); - parameters.Add($"IconVariant{index}", resource.HasIconVariant ? (int?)resource.IconVariant : null); - parameters.Add($"ConsoleLogsLoaded{index}", resourceToSave.ConsoleLogsLoaded); - } - - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } + parameters[0].Value = resource.Name; + parameters[1].Value = resourceToSave.ReplicaIndex; + parameters[2].Value = resource.ResourceType; + parameters[3].Value = resource.DisplayName; + parameters[4].Value = resource.Uid; + parameters[5].Value = resource.HasState ? resource.State : DBNull.Value; + parameters[6].Value = resource.CreatedAt?.Seconds ?? (object)DBNull.Value; + parameters[7].Value = resource.CreatedAt?.Nanos ?? (object)DBNull.Value; + parameters[8].Value = resource.HasStateStyle ? resource.StateStyle : DBNull.Value; + parameters[9].Value = resource.StartedAt?.Seconds ?? (object)DBNull.Value; + parameters[10].Value = resource.StartedAt?.Nanos ?? (object)DBNull.Value; + parameters[11].Value = resource.StoppedAt?.Seconds ?? (object)DBNull.Value; + parameters[12].Value = resource.StoppedAt?.Nanos ?? (object)DBNull.Value; + parameters[13].Value = resource.IsHidden; + parameters[14].Value = resource.SupportsDetailedTelemetry; + parameters[15].Value = resource.HasIconName ? resource.IconName : DBNull.Value; + parameters[16].Value = resource.HasIconVariant ? (int)resource.IconVariant : DBNull.Value; + parameters[17].Value = resourceToSave.ConsoleLogsLoaded; + }); var resourceModels = resources.Select(resource => resource.Resource).ToArray(); InsertEnvironment(connection, transaction, resourceModels); @@ -102,251 +86,261 @@ private static void InsertConsoleLogs( string resourceName, IReadOnlyList consoleLogs) { - foreach (var batch in consoleLogs.Chunk(MaxWriteBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO console_logs (resource_name, line_number, content, is_stderr) - VALUES - """); - var parameters = new DynamicParameters(); - parameters.Add("ResourceName", resourceName); - for (var index = 0; index < batch.Length; index++) + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + consoleLogs, + MaxWriteBatchSize, + "console_logs", + ["resource_name", "line_number", "content", "is_stderr"], + (consoleLog, parameters) => { - var consoleLog = batch[index]; - if (index > 0) - { - sql.AppendLine(","); - } - - sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName, @LineNumber{index}, @Content{index}, @IsStdErr{index})"); - parameters.Add($"LineNumber{index}", consoleLog.LineNumber); - parameters.Add($"Content{index}", consoleLog.Content); - parameters.Add($"IsStdErr{index}", consoleLog.IsStdErr); - } - - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } + parameters[0].Value = resourceName; + parameters[1].Value = consoleLog.LineNumber; + parameters[2].Value = consoleLog.Content; + parameters[3].Value = consoleLog.IsStdErr; + }); } private static void InsertEnvironment(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList resources) { - ExecuteInsertBatches(connection, transaction, resources.SelectMany(resource => resource.Environment.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))), """ - INSERT INTO dashboard_resource_environment (resource_name, ordinal, name, value, is_from_spec) - VALUES - """, static (sql, parameters, row, index) => - { - sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @Name{index}, @Value{index}, @IsFromSpec{index})"); - parameters.Add($"ResourceName{index}", row.ResourceName); - parameters.Add($"Ordinal{index}", row.Ordinal); - parameters.Add($"Name{index}", row.Item.Name); - parameters.Add($"Value{index}", row.Item.HasValue ? row.Item.Value : null); - parameters.Add($"IsFromSpec{index}", row.Item.IsFromSpec); - }); + var rows = resources + .SelectMany(resource => resource.Environment.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))) + .ToArray(); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + rows, + MaxWriteBatchSize, + "dashboard_resource_environment", + ["resource_name", "ordinal", "name", "value", "is_from_spec"], + static (row, parameters) => + { + parameters[0].Value = row.ResourceName; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.Item.Name; + parameters[3].Value = row.Item.HasValue ? row.Item.Value : DBNull.Value; + parameters[4].Value = row.Item.IsFromSpec; + }); } private static void InsertUrls(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList resources) { - ExecuteInsertBatches(connection, transaction, resources.SelectMany(resource => resource.Urls.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))), """ - INSERT INTO dashboard_resource_urls ( - resource_name, ordinal, endpoint_name, full_url, is_internal, is_inactive, display_sort_order, display_name) - VALUES - """, static (sql, parameters, row, index) => - { - sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @EndpointName{index}, @FullUrl{index}, @IsInternal{index}, @IsInactive{index}, @DisplaySortOrder{index}, @DisplayName{index})"); - parameters.Add($"ResourceName{index}", row.ResourceName); - parameters.Add($"Ordinal{index}", row.Ordinal); - parameters.Add($"EndpointName{index}", row.Item.HasEndpointName ? row.Item.EndpointName : null); - parameters.Add($"FullUrl{index}", row.Item.FullUrl); - parameters.Add($"IsInternal{index}", row.Item.IsInternal); - parameters.Add($"IsInactive{index}", row.Item.IsInactive); - parameters.Add($"DisplaySortOrder{index}", row.Item.DisplayProperties.SortOrder); - parameters.Add($"DisplayName{index}", row.Item.DisplayProperties.DisplayName); - }); + var rows = resources + .SelectMany(resource => resource.Urls.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))) + .ToArray(); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + rows, + MaxWriteBatchSize, + "dashboard_resource_urls", + ["resource_name", "ordinal", "endpoint_name", "full_url", "is_internal", "is_inactive", "display_sort_order", "display_name"], + static (row, parameters) => + { + parameters[0].Value = row.ResourceName; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.Item.HasEndpointName ? row.Item.EndpointName : DBNull.Value; + parameters[3].Value = row.Item.FullUrl; + parameters[4].Value = row.Item.IsInternal; + parameters[5].Value = row.Item.IsInactive; + parameters[6].Value = row.Item.DisplayProperties.SortOrder; + parameters[7].Value = row.Item.DisplayProperties.DisplayName; + }); } private static void InsertVolumes(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList resources) { - ExecuteInsertBatches(connection, transaction, resources.SelectMany(resource => resource.Volumes.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))), """ - INSERT INTO dashboard_resource_volumes (resource_name, ordinal, source, target, mount_type, is_read_only) - VALUES - """, static (sql, parameters, row, index) => - { - sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @Source{index}, @Target{index}, @MountType{index}, @IsReadOnly{index})"); - parameters.Add($"ResourceName{index}", row.ResourceName); - parameters.Add($"Ordinal{index}", row.Ordinal); - parameters.Add($"Source{index}", row.Item.Source); - parameters.Add($"Target{index}", row.Item.Target); - parameters.Add($"MountType{index}", row.Item.MountType); - parameters.Add($"IsReadOnly{index}", row.Item.IsReadOnly); - }); + var rows = resources + .SelectMany(resource => resource.Volumes.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))) + .ToArray(); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + rows, + MaxWriteBatchSize, + "dashboard_resource_volumes", + ["resource_name", "ordinal", "source", "target", "mount_type", "is_read_only"], + static (row, parameters) => + { + parameters[0].Value = row.ResourceName; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.Item.Source; + parameters[3].Value = row.Item.Target; + parameters[4].Value = row.Item.MountType; + parameters[5].Value = row.Item.IsReadOnly; + }); } private static void InsertHealthReports(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList resources) { - ExecuteInsertBatches(connection, transaction, resources.SelectMany(resource => resource.HealthReports.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))), """ - INSERT INTO dashboard_resource_health_reports ( - resource_name, ordinal, status, key, description, exception, last_run_at_seconds, last_run_at_nanos) - VALUES - """, static (sql, parameters, row, index) => - { - sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @Status{index}, @Key{index}, @Description{index}, @Exception{index}, @LastRunAtSeconds{index}, @LastRunAtNanos{index})"); - parameters.Add($"ResourceName{index}", row.ResourceName); - parameters.Add($"Ordinal{index}", row.Ordinal); - parameters.Add($"Status{index}", row.Item.HasStatus ? (int?)row.Item.Status : null); - parameters.Add($"Key{index}", row.Item.Key); - parameters.Add($"Description{index}", row.Item.Description); - parameters.Add($"Exception{index}", row.Item.Exception); - parameters.Add($"LastRunAtSeconds{index}", row.Item.LastRunAt?.Seconds); - parameters.Add($"LastRunAtNanos{index}", row.Item.LastRunAt?.Nanos); - }); + var rows = resources + .SelectMany(resource => resource.HealthReports.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))) + .ToArray(); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + rows, + MaxWriteBatchSize, + "dashboard_resource_health_reports", + ["resource_name", "ordinal", "status", "key", "description", "exception", "last_run_at_seconds", "last_run_at_nanos"], + static (row, parameters) => + { + parameters[0].Value = row.ResourceName; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.Item.HasStatus ? (int)row.Item.Status : DBNull.Value; + parameters[3].Value = row.Item.Key; + parameters[4].Value = row.Item.Description; + parameters[5].Value = row.Item.Exception; + parameters[6].Value = row.Item.LastRunAt?.Seconds ?? (object)DBNull.Value; + parameters[7].Value = row.Item.LastRunAt?.Nanos ?? (object)DBNull.Value; + }); } private static void InsertRelationships(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList resources) { - ExecuteInsertBatches(connection, transaction, resources.SelectMany(resource => resource.Relationships.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))), """ - INSERT INTO dashboard_resource_relationships ( - resource_name, ordinal, related_resource_name, relationship_type) - VALUES - """, static (sql, parameters, row, index) => - { - sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @RelatedResourceName{index}, @RelationshipType{index})"); - parameters.Add($"ResourceName{index}", row.ResourceName); - parameters.Add($"Ordinal{index}", row.Ordinal); - parameters.Add($"RelatedResourceName{index}", row.Item.ResourceName); - parameters.Add($"RelationshipType{index}", row.Item.Type); - }); - } - - private static void ExecuteInsertBatches( - SqliteConnection connection, - IDbTransaction transaction, - IEnumerable rows, - string insertPrefix, - Action appendValues) - { - foreach (var batch in rows.Chunk(MaxWriteBatchSize)) - { - var sql = new StringBuilder(insertPrefix); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) + var rows = resources + .SelectMany(resource => resource.Relationships.Select((item, ordinal) => (ResourceName: resource.Name, Ordinal: ordinal, Item: item))) + .ToArray(); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + rows, + MaxWriteBatchSize, + "dashboard_resource_relationships", + ["resource_name", "ordinal", "related_resource_name", "relationship_type"], + static (row, parameters) => { - if (index > 0) - { - sql.AppendLine(","); - } - - appendValues(sql, parameters, batch[index], index); - } - - sql.Append(';'); - connection.Execute(sql.ToString(), parameters, transaction); - } + parameters[0].Value = row.ResourceName; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.Item.ResourceName; + parameters[3].Value = row.Item.Type; + }); } private static void InsertProperties(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList properties) { - ExecuteInsertBatches(connection, transaction, properties, """ - INSERT INTO dashboard_resource_properties ( - resource_name, ordinal, name, display_name, value, is_sensitive, is_highlighted, sort_order) - VALUES - """, static (sql, parameters, row, index) => - { - sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @Name{index}, @DisplayName{index}, @Value{index}, @IsSensitive{index}, @IsHighlighted{index}, @SortOrder{index})"); - parameters.Add($"ResourceName{index}", row.ResourceName); - parameters.Add($"Ordinal{index}", row.Ordinal); - parameters.Add($"Name{index}", row.Property.Name); - parameters.Add($"DisplayName{index}", row.Property.HasDisplayName ? row.Property.DisplayName : null); - parameters.Add($"Value{index}", JsonFormatter.Default.Format(row.Property.Value)); - parameters.Add($"IsSensitive{index}", row.Property.HasIsSensitive ? (bool?)row.Property.IsSensitive : null); - parameters.Add($"IsHighlighted{index}", row.Property.IsHighlighted); - parameters.Add($"SortOrder{index}", row.Property.HasSortOrder ? (int?)row.Property.SortOrder : null); - }); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + properties, + MaxWriteBatchSize, + "dashboard_resource_properties", + ["resource_name", "ordinal", "name", "display_name", "value", "is_sensitive", "is_highlighted", "sort_order"], + static (row, parameters) => + { + parameters[0].Value = row.ResourceName; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.Property.Name; + parameters[3].Value = row.Property.HasDisplayName ? row.Property.DisplayName : DBNull.Value; + parameters[4].Value = JsonFormatter.Default.Format(row.Property.Value); + parameters[5].Value = row.Property.HasIsSensitive ? row.Property.IsSensitive : DBNull.Value; + parameters[6].Value = row.Property.IsHighlighted; + parameters[7].Value = row.Property.HasSortOrder ? row.Property.SortOrder : DBNull.Value; + }); } private static void InsertCommands(SqliteConnection connection, IDbTransaction transaction, IReadOnlyList commands) { - ExecuteInsertBatches(connection, transaction, commands, """ - INSERT INTO dashboard_resource_commands ( - resource_name, ordinal, name, display_name, confirmation_message, parameter_value, - is_highlighted, icon_name, icon_variant, display_description, state) - VALUES - """, static (sql, parameters, row, index) => - { - var command = row.Command; - sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @Ordinal{index}, @Name{index}, @DisplayName{index}, @ConfirmationMessage{index}, @ParameterValue{index}, @IsHighlighted{index}, @IconName{index}, @IconVariant{index}, @DisplayDescription{index}, @State{index})"); - parameters.Add($"ResourceName{index}", row.ResourceName); - parameters.Add($"Ordinal{index}", row.Ordinal); - parameters.Add($"Name{index}", command.Name); - parameters.Add($"DisplayName{index}", command.DisplayName); - parameters.Add($"ConfirmationMessage{index}", command.HasConfirmationMessage ? command.ConfirmationMessage : null); - parameters.Add($"ParameterValue{index}", row.ParameterJsonValue); - parameters.Add($"IsHighlighted{index}", command.IsHighlighted); - parameters.Add($"IconName{index}", command.HasIconName ? command.IconName : null); - parameters.Add($"IconVariant{index}", command.HasIconVariant ? (int?)command.IconVariant : null); - parameters.Add($"DisplayDescription{index}", command.HasDisplayDescription ? command.DisplayDescription : null); - parameters.Add($"State{index}", (int)command.State); - }); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + commands, + MaxWriteBatchSize, + "dashboard_resource_commands", + [ + "resource_name", "ordinal", "name", "display_name", "confirmation_message", "parameter_value", + "is_highlighted", "icon_name", "icon_variant", "display_description", "state" + ], + static (row, parameters) => + { + var command = row.Command; + parameters[0].Value = row.ResourceName; + parameters[1].Value = row.Ordinal; + parameters[2].Value = command.Name; + parameters[3].Value = command.DisplayName; + parameters[4].Value = command.HasConfirmationMessage ? command.ConfirmationMessage : DBNull.Value; + parameters[5].Value = row.ParameterJsonValue ?? (object)DBNull.Value; + parameters[6].Value = command.IsHighlighted; + parameters[7].Value = command.HasIconName ? command.IconName : DBNull.Value; + parameters[8].Value = command.HasIconVariant ? (int)command.IconVariant : DBNull.Value; + parameters[9].Value = command.HasDisplayDescription ? command.DisplayDescription : DBNull.Value; + parameters[10].Value = (int)command.State; + }); var inputs = commands.SelectMany(command => command.Command.ArgumentInputs.Select((input, ordinal) => (Command: command, Ordinal: ordinal, Input: input))).ToArray(); - ExecuteInsertBatches(connection, transaction, inputs, """ - INSERT INTO dashboard_resource_command_inputs ( - resource_name, command_ordinal, ordinal, label, placeholder, input_type, required, value, - description, enable_description_markdown, max_length, allow_custom_choice, loading, - update_state_on_change, name, disabled, max_file_size, allow_multiple_files, file_filter) - VALUES - """, static (sql, parameters, row, index) => - { - var input = row.Input; - sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @CommandOrdinal{index}, @Ordinal{index}, @Label{index}, @Placeholder{index}, @InputType{index}, @Required{index}, @Value{index}, @Description{index}, @EnableDescriptionMarkdown{index}, @MaxLength{index}, @AllowCustomChoice{index}, @Loading{index}, @UpdateStateOnChange{index}, @Name{index}, @Disabled{index}, @MaxFileSize{index}, @AllowMultipleFiles{index}, @FileFilter{index})"); - parameters.Add($"ResourceName{index}", row.Command.ResourceName); - parameters.Add($"CommandOrdinal{index}", row.Command.Ordinal); - parameters.Add($"Ordinal{index}", row.Ordinal); - parameters.Add($"Label{index}", input.Label); - parameters.Add($"Placeholder{index}", input.Placeholder); - parameters.Add($"InputType{index}", (int)input.InputType); - parameters.Add($"Required{index}", input.Required); - parameters.Add($"Value{index}", input.Value); - parameters.Add($"Description{index}", input.Description); - parameters.Add($"EnableDescriptionMarkdown{index}", input.EnableDescriptionMarkdown); - parameters.Add($"MaxLength{index}", input.MaxLength); - parameters.Add($"AllowCustomChoice{index}", input.AllowCustomChoice); - parameters.Add($"Loading{index}", input.Loading); - parameters.Add($"UpdateStateOnChange{index}", input.UpdateStateOnChange); - parameters.Add($"Name{index}", input.Name); - parameters.Add($"Disabled{index}", input.Disabled); - parameters.Add($"MaxFileSize{index}", input.MaxFileSize); - parameters.Add($"AllowMultipleFiles{index}", input.AllowMultipleFiles); - parameters.Add($"FileFilter{index}", input.FileFilter); - }); - - ExecuteInsertBatches(connection, transaction, inputs.SelectMany(row => row.Input.Options.Select(option => (row.Command, InputOrdinal: row.Ordinal, Option: option))), """ - INSERT INTO dashboard_resource_command_input_options ( - resource_name, command_ordinal, input_ordinal, option_key, option_value) - VALUES - """, static (sql, parameters, row, index) => - { - sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @CommandOrdinal{index}, @InputOrdinal{index}, @OptionKey{index}, @OptionValue{index})"); - parameters.Add($"ResourceName{index}", row.Command.ResourceName); - parameters.Add($"CommandOrdinal{index}", row.Command.Ordinal); - parameters.Add($"InputOrdinal{index}", row.InputOrdinal); - parameters.Add($"OptionKey{index}", row.Option.Key); - parameters.Add($"OptionValue{index}", row.Option.Value); - }); - - ExecuteInsertBatches(connection, transaction, inputs.SelectMany(row => row.Input.ValidationErrors.Select((validationError, ordinal) => (row.Command, InputOrdinal: row.Ordinal, Ordinal: ordinal, ValidationError: validationError))), """ - INSERT INTO dashboard_resource_command_input_validation_errors ( - resource_name, command_ordinal, input_ordinal, ordinal, validation_error) - VALUES - """, static (sql, parameters, row, index) => - { - sql.Append(CultureInfo.InvariantCulture, $"(@ResourceName{index}, @CommandOrdinal{index}, @InputOrdinal{index}, @Ordinal{index}, @ValidationError{index})"); - parameters.Add($"ResourceName{index}", row.Command.ResourceName); - parameters.Add($"CommandOrdinal{index}", row.Command.Ordinal); - parameters.Add($"InputOrdinal{index}", row.InputOrdinal); - parameters.Add($"Ordinal{index}", row.Ordinal); - parameters.Add($"ValidationError{index}", row.ValidationError); - }); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + inputs, + MaxWriteBatchSize, + "dashboard_resource_command_inputs", + [ + "resource_name", "command_ordinal", "ordinal", "label", "placeholder", "input_type", "required", "value", + "description", "enable_description_markdown", "max_length", "allow_custom_choice", "loading", + "update_state_on_change", "name", "disabled", "max_file_size", "allow_multiple_files", "file_filter" + ], + static (row, parameters) => + { + var input = row.Input; + parameters[0].Value = row.Command.ResourceName; + parameters[1].Value = row.Command.Ordinal; + parameters[2].Value = row.Ordinal; + parameters[3].Value = input.Label; + parameters[4].Value = input.Placeholder; + parameters[5].Value = (int)input.InputType; + parameters[6].Value = input.Required; + parameters[7].Value = input.Value; + parameters[8].Value = input.Description; + parameters[9].Value = input.EnableDescriptionMarkdown; + parameters[10].Value = input.MaxLength; + parameters[11].Value = input.AllowCustomChoice; + parameters[12].Value = input.Loading; + parameters[13].Value = input.UpdateStateOnChange; + parameters[14].Value = input.Name; + parameters[15].Value = input.Disabled; + parameters[16].Value = input.MaxFileSize; + parameters[17].Value = input.AllowMultipleFiles; + parameters[18].Value = input.FileFilter; + }); + + var options = inputs + .SelectMany(row => row.Input.Options.Select(option => (row.Command, InputOrdinal: row.Ordinal, Option: option))) + .ToArray(); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + options, + MaxWriteBatchSize, + "dashboard_resource_command_input_options", + ["resource_name", "command_ordinal", "input_ordinal", "option_key", "option_value"], + static (row, parameters) => + { + parameters[0].Value = row.Command.ResourceName; + parameters[1].Value = row.Command.Ordinal; + parameters[2].Value = row.InputOrdinal; + parameters[3].Value = row.Option.Key; + parameters[4].Value = row.Option.Value; + }); + + var validationErrors = inputs + .SelectMany(row => row.Input.ValidationErrors.Select((validationError, ordinal) => (row.Command, InputOrdinal: row.Ordinal, Ordinal: ordinal, ValidationError: validationError))) + .ToArray(); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + validationErrors, + MaxWriteBatchSize, + "dashboard_resource_command_input_validation_errors", + ["resource_name", "command_ordinal", "input_ordinal", "ordinal", "validation_error"], + static (row, parameters) => + { + parameters[0].Value = row.Command.ResourceName; + parameters[1].Value = row.Command.Ordinal; + parameters[2].Value = row.InputOrdinal; + parameters[3].Value = row.Ordinal; + parameters[4].Value = row.ValidationError; + }); } private static IEnumerable LoadResourceRecords(SqliteConnection connection) diff --git a/src/Aspire.Dashboard/Utils/SqliteBatchInsert.cs b/src/Aspire.Dashboard/Utils/SqliteBatchInsert.cs new file mode 100644 index 00000000000..21e0efde664 --- /dev/null +++ b/src/Aspire.Dashboard/Utils/SqliteBatchInsert.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Text; + +namespace Aspire.Dashboard.Utils; + +internal static class SqliteBatchInsert +{ + internal static DbCommand CreateBatchInsertCommand( + DbConnection connection, + IDbTransaction transaction, + int rowCount, + string tableName, + IReadOnlyList columnNames) + { + var command = connection.CreateCommand(); + command.Transaction = (DbTransaction)transaction; + var sql = new StringBuilder("INSERT INTO "); + sql.Append(tableName); + sql.Append(" (\n "); + sql.AppendJoin(", ", columnNames); + sql.Append("\n)\nVALUES\n"); + for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) + { + if (rowIndex > 0) + { + sql.AppendLine(","); + } + + sql.Append(" ("); + for (var parameterIndex = 0; parameterIndex < columnNames.Count; parameterIndex++) + { + if (parameterIndex > 0) + { + sql.Append(", "); + } + + var parameterName = string.Create(CultureInfo.InvariantCulture, $"@param_{columnNames[parameterIndex]}_{rowIndex + 1}"); + sql.Append(parameterName); + var parameter = command.CreateParameter(); + parameter.ParameterName = parameterName; + command.Parameters.Add(parameter); + } + sql.Append(')'); + } + sql.Append(';'); + command.CommandText = sql.ToString(); + command.Prepare(); + return command; + } + + internal static void BatchInsertRows( + DbConnection connection, + IDbTransaction transaction, + IReadOnlyList data, + int batchSize, + string tableName, + IReadOnlyList columnNames, + BindRowParameters bindRowParameters) + { + BatchInsertRows( + data, + batchSize, + columnNames.Count, + rowCount => CreateBatchInsertCommand(connection, transaction, rowCount, tableName, columnNames), + bindRowParameters); + } + + internal static void BatchInsertRows( + IReadOnlyList data, + int batchSize, + int parametersPerRow, + Func commandFactory, + BindRowParameters bindRowParameters) + { + DbCommand? command = null; + DbParameter[] parameters = []; + try + { + for (var batchStart = 0; batchStart < data.Count; batchStart += batchSize) + { + var rowCount = Math.Min(batchSize, data.Count - batchStart); + var parameterCount = checked(rowCount * parametersPerRow); + if (command is null || parameters.Length != parameterCount) + { + command?.Dispose(); + command = commandFactory(rowCount); + parameters = command.Parameters.Cast().ToArray(); + if (parameters.Length != parameterCount) + { + throw new InvalidOperationException($"The batch insert command has {parameters.Length} parameters; expected {parameterCount}."); + } + } + + for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) + { + bindRowParameters( + data[batchStart + rowIndex], + parameters.AsSpan(rowIndex * parametersPerRow, parametersPerRow)); + } + + command.ExecuteNonQuery(); + } + } + finally + { + command?.Dispose(); + } + } +} + +internal delegate void BindRowParameters(T row, ReadOnlySpan parameters); \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs index 02206f47bf9..a0ee7144cf8 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs @@ -96,13 +96,10 @@ public async Task ConsoleLogs_UseInsertionOrderAndAllowLineNumbersToRestart() using (var repository = CreateRepository(workspace.Path)) { var writer = (IResourceRepositoryWriter)repository; - var insertQueries = CaptureSqlQueries(() => writer.AddConsoleLogs("api", [ + writer.AddConsoleLogs("api", [ new ConsoleLogLine { LineNumber = 2, Text = "second", IsStdErr = true }, new ConsoleLogLine { LineNumber = 1, Text = "first" } - ])); - var insertQuery = Assert.Single(insertQueries, query => query.TrimStart().StartsWith("INSERT INTO console_logs ", StringComparison.Ordinal)); - Assert.Contains("(@ResourceName, @LineNumber0, @Content0, @IsStdErr0),", insertQuery, StringComparison.Ordinal); - Assert.Contains("(@ResourceName, @LineNumber1, @Content1, @IsStdErr1)", insertQuery, StringComparison.Ordinal); + ]); writer.AddConsoleLogs("api", [ new ConsoleLogLine { LineNumber = 2, Text = "second-updated", IsStdErr = true }, new ConsoleLogLine { LineNumber = 3, Text = "third" } @@ -131,28 +128,52 @@ public async Task ConsoleLogs_UseInsertionOrderAndAllowLineNumbersToRestart() } [Fact] - public async Task ConsoleLogs_InsertsAreBatched() + public async Task ConsoleLogs_LargeBatchRoundTrips() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); - using var repository = CreateRepository(workspace.Path); - var writer = (IResourceRepositoryWriter)repository; - var logLines = Enumerable.Range(1, 101) + var logLines = Enumerable.Range(1, 201) .Select(lineNumber => new ConsoleLogLine { LineNumber = lineNumber, Text = $"Line {lineNumber}" }) .ToArray(); - var queries = CaptureSqlQueries(() => writer.AddConsoleLogs("api", logLines)); + using (var repository = CreateRepository(workspace.Path)) + { + ((IResourceRepositoryWriter)repository).AddConsoleLogs("api", logLines); + } - Assert.Equal(2, queries.Count(query => query.TrimStart().StartsWith("INSERT INTO console_logs ", StringComparison.Ordinal))); + using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); var batches = new List>(); - await foreach (var batch in repository.GetConsoleLogs("api", CancellationToken.None)) + await foreach (var batch in historicalRepository.GetConsoleLogs("api", CancellationToken.None)) { batches.Add(batch); } var persistedLines = Assert.Single(batches); - Assert.Equal(Enumerable.Range(1, 101), persistedLines.Select(line => line.LineNumber)); + Assert.Equal(Enumerable.Range(1, 201), persistedLines.Select(line => line.LineNumber)); Assert.Equal(logLines.Select(line => line.Text), persistedLines.Select(line => line.Content)); } + [Fact] + public void Resources_LargeBatchRoundTrips() + { + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var resources = Enumerable.Range(1, 201) + .Select(index => CreateResource($"resource-{index}", $"Resource {index}")) + .ToArray(); + + using (var repository = CreateRepository(workspace.Path)) + { + ((IResourceRepositoryWriter)repository).ReplaceResources(resources); + } + + using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); + var expected = resources + .OrderBy(resource => resource.Name) + .Select(resource => (resource.Name, resource.DisplayName)); + var actual = historicalRepository.GetResources() + .OrderBy(resource => resource.Name) + .Select(resource => (resource.Name, resource.DisplayName)); + Assert.Equal(expected, actual); + } + [Fact] public void ConsoleLogsLoaded_PersistsWithoutLogLines() { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs index 6b735e8a4e8..970a045d59f 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs @@ -1,14 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Concurrent; using System.Diagnostics; using System.Threading.Channels; using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Tests.Integration; -using Aspire.Tests; using Google.Protobuf.Collections; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging; @@ -1788,41 +1786,13 @@ public sealed class SqliteLogTests(ITestOutputHelper testOutputHelper) : LogTest protected override bool UseSqlite => true; [Fact] - public void AddLogs_BatchesRowsAndAttributesAcrossResourcesAndSkipsResourceUpdateWhenCached() + public void AddLogs_LargeAttributeBatchesRoundTripAcrossResources() { var repository = Assert.IsType(CreateRepository()); - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField - { - new ResourceLogs - { - Resource = CreateResource(name: "app-one"), - ScopeLogs = - { - new ScopeLogs - { - Scope = CreateScope("TestLogger"), - LogRecords = { CreateLogRecord() } - } - } - }, - new ResourceLogs - { - Resource = CreateResource(name: "app-two"), - ScopeLogs = - { - new ScopeLogs - { - Scope = CreateScope("TestLogger"), - LogRecords = { CreateLogRecord() } - } - } - } - }); - var activities = new ConcurrentQueue(); - using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); - using var parent = new Activity("log ingestion test").Start(); - var context = new AddContext(); + var attributes = Enumerable.Range(0, 128) + .Select(index => KeyValuePair.Create($"key-{index}", $"value-{index}")) + .ToArray(); repository.AsWriter().AddLogs(context, new RepeatedField { new ResourceLogs @@ -1835,8 +1805,8 @@ public void AddLogs_BatchesRowsAndAttributesAcrossResourcesAndSkipsResourceUpdat Scope = CreateScope("TestLogger"), LogRecords = { - CreateLogRecord(message: "one", attributes: [KeyValuePair.Create("key", "one")]), - CreateLogRecord(message: "two", attributes: [KeyValuePair.Create("key", "two")]) + CreateLogRecord(message: "one", attributes: attributes), + CreateLogRecord(message: "two", attributes: attributes) } } } @@ -1851,39 +1821,31 @@ public void AddLogs_BatchesRowsAndAttributesAcrossResourcesAndSkipsResourceUpdat Scope = CreateScope("TestLogger"), LogRecords = { - CreateLogRecord(message: "three", attributes: [KeyValuePair.Create("key", "three")]), - CreateLogRecord(message: "four", attributes: [KeyValuePair.Create("key", "four")]) + CreateLogRecord(message: "three", attributes: attributes), + CreateLogRecord(message: "four", attributes: attributes) } } } } }); - var queries = activities - .Where(activity => activity.ParentSpanId == parent.SpanId) - .Select(activity => (string)activity.GetTagItem("db.query.text")!) - .ToList(); - var logInsert = Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_logs", StringComparison.Ordinal)); - Assert.Equal(4, logInsert.Split("INSERT INTO telemetry_logs", StringSplitOptions.None).Length - 1); - var attributeInsert = Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_log_attributes", StringComparison.Ordinal)); - Assert.Equal(4, attributeInsert.Split("@LogId", StringSplitOptions.None).Length - 1); - Assert.DoesNotContain(queries, query => query.StartsWith("UPDATE telemetry_resources SET has_logs", StringComparison.Ordinal)); Assert.Equal(4, context.SuccessCount); Assert.Equal(0, context.FailureCount); - Assert.Equal(3, repository.GetLogs(new GetLogsContext - { - ResourceKeys = [new ResourceKey("app-one", null)], - StartIndex = 0, - Count = 10, - Filters = [] - }).Items.Count); - Assert.Equal(3, repository.GetLogs(new GetLogsContext + AssertResourceLogs("app-one", ["one", "two"]); + AssertResourceLogs("app-two", ["three", "four"]); + + void AssertResourceLogs(string resourceName, string[] expectedMessages) { - ResourceKeys = [new ResourceKey("app-two", null)], - StartIndex = 0, - Count = 10, - Filters = [] - }).Items.Count); + var logs = repository.GetLogs(new GetLogsContext + { + ResourceKeys = [new ResourceKey(resourceName, null)], + StartIndex = 0, + Count = 10, + Filters = [] + }).Items; + Assert.Equal(expectedMessages.Order(), logs.Select(log => log.Message).Order()); + Assert.All(logs, log => Assert.Equal(attributes, log.Attributes)); + } } } diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index 95664b9209d..581c24cc966 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -1336,13 +1336,26 @@ public void AddMetrics_ReusesInstrumentAndDimensionLookupsWithinBatch() } [Fact] - public void AddMetrics_BatchesHistogramChildrenAndDimensionTrimming() + public void AddMetrics_LargeHistogramAndDimensionAttributeBatchesRoundTrip() { var repository = Assert.IsType(CreateRepository()); - var activities = new ConcurrentQueue(); - using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); - using var parent = new Activity("metric ingestion test").Start(); - + var histogram = CreateHistogramMetric(metricName: "histogram", startTime: s_queryTestTime.AddMinutes(1)); + var histogramPoint = histogram.Histogram.DataPoints[0]; + histogramPoint.ExplicitBounds.Clear(); + histogramPoint.BucketCounts.Clear(); + for (var index = 0; index < 200; index++) + { + histogramPoint.ExplicitBounds.Add(index + 1); + histogramPoint.BucketCounts.Add(1); + } + histogramPoint.BucketCounts.Add(1); + + var firstDimensionAttributes = Enumerable.Range(0, 128) + .Select(index => KeyValuePair.Create($"key-{index}", $"first-{index}")) + .ToArray(); + var secondDimensionAttributes = Enumerable.Range(0, 128) + .Select(index => KeyValuePair.Create($"key-{index}", $"second-{index}")) + .ToArray(); var context = new AddContext(); repository.AsWriter().AddMetrics(context, new RepeatedField { @@ -1356,33 +1369,44 @@ public void AddMetrics_BatchesHistogramChildrenAndDimensionTrimming() Scope = CreateScope(name: "test-meter"), Metrics = { - CreateHistogramMetric(metricName: "histogram", startTime: s_queryTestTime.AddMinutes(1)), - CreateSumMetric(metricName: "sum", startTime: s_queryTestTime.AddMinutes(1), attributes: [KeyValuePair.Create("series", "one")]), - CreateSumMetric(metricName: "sum", startTime: s_queryTestTime.AddMinutes(1), attributes: [KeyValuePair.Create("series", "two")]) + histogram, + CreateSumMetric(metricName: "sum", startTime: s_queryTestTime.AddMinutes(1), attributes: firstDimensionAttributes), + CreateSumMetric(metricName: "sum", startTime: s_queryTestTime.AddMinutes(1), attributes: secondDimensionAttributes) } } } } }); - var queries = activities - .Where(activity => activity.ParentSpanId == parent.SpanId) - .Select(activity => (string)activity.GetTagItem("db.query.text")!) - .ToList(); - Assert.Single(queries, query => query.Contains("SELECT instrument_id", StringComparison.Ordinal)); - Assert.Single(queries, query => query.StartsWith("SELECT COUNT(*) FROM telemetry_metric_instruments", StringComparison.Ordinal)); - Assert.DoesNotContain(queries, query => query.Contains("FROM telemetry_metric_dimensions d", StringComparison.Ordinal)); - Assert.DoesNotContain(queries, query => query.StartsWith("SELECT COUNT(*) FROM telemetry_metric_dimensions", StringComparison.Ordinal)); - var instrumentInsert = Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_instruments", StringComparison.Ordinal)); - Assert.Equal(2, instrumentInsert.Split("@InstrumentName", StringSplitOptions.None).Length - 1); - var dimensionInsert = Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_dimensions", StringComparison.Ordinal)); - Assert.Equal(3, dimensionInsert.Split("@InstrumentId", StringSplitOptions.None).Length - 1); - Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_dimension_attributes", StringComparison.Ordinal)); - Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_histogram_bucket_counts", StringComparison.Ordinal)); - Assert.Single(queries, query => query.StartsWith("INSERT INTO telemetry_metric_histogram_explicit_bounds", StringComparison.Ordinal)); - Assert.Single(queries, query => query.StartsWith("DELETE FROM telemetry_metric_points", StringComparison.Ordinal)); Assert.Equal(3, context.SuccessCount); Assert.Equal(0, context.FailureCount); + + var resourceKey = CreateResource().GetResourceKey(); + var histogramInstrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = resourceKey, + MeterName = "test-meter", + InstrumentName = "histogram", + StartTime = DateTime.MinValue, + EndTime = DateTime.MaxValue + }); + var histogramValue = Assert.IsType(Assert.Single(Assert.Single(histogramInstrument!.Dimensions).Values)); + Assert.Equal(Enumerable.Repeat(1, 201), histogramValue.Values); + Assert.Equal(Enumerable.Range(1, 200).Select(value => (double)value), histogramValue.ExplicitBounds); + + var sumInstrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = resourceKey, + MeterName = "test-meter", + InstrumentName = "sum", + StartTime = DateTime.MinValue, + EndTime = DateTime.MaxValue + }); + var dimensions = sumInstrument!.Dimensions.OrderBy(dimension => dimension.Attributes[0].Value).ToArray(); + Assert.Collection(dimensions, + dimension => Assert.Equal(firstDimensionAttributes.OrderBy(attribute => attribute.Key), dimension.Attributes.OrderBy(attribute => attribute.Key)), + dimension => Assert.Equal(secondDimensionAttributes.OrderBy(attribute => attribute.Key), dimension.Attributes.OrderBy(attribute => attribute.Key))); + Assert.All(dimensions, dimension => Assert.IsType>(Assert.Single(dimension.Values))); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index f2ada7d7a5c..cde98dd64fe 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -4312,7 +4312,7 @@ static ResourceSpans CreateResourceSpans(string resourceName, string traceId, st } [Fact] - public void AddTraces_ReusesPreparedCommandsAcrossBatches() + public void AddTraces_LargeSpanAndAttributeBatchesRoundTrip() { const int spanCount = 101; var repository = Assert.IsType(CreateRepository()); @@ -4334,33 +4334,21 @@ public void AddTraces_ReusesPreparedCommandsAcrossBatches() endTime: testTime.AddTicks(index + 1), attributes: [KeyValuePair.Create("index", index.ToString(CultureInfo.InvariantCulture))])); } - var activities = new ConcurrentQueue(); - using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); var context = new AddContext(); repository.AsWriter().AddTraces(context, [resourceSpans]); - var spanInsertQueries = activities - .Select(activity => (string)activity.GetTagItem("db.query.text")!) - .Where(query => query.StartsWith("INSERT INTO telemetry_spans", StringComparison.Ordinal)) - .ToList(); - Assert.Equal(5, spanInsertQueries.Count); - Assert.All(spanInsertQueries.Skip(1).Take(3), query => Assert.Same(spanInsertQueries[0], query)); - Assert.NotSame(spanInsertQueries[0], spanInsertQueries[4]); - - var attributeInsertQueries = activities - .Select(activity => (string)activity.GetTagItem("db.query.text")!) - .Where(query => query.StartsWith("INSERT INTO telemetry_span_attributes", StringComparison.Ordinal)) - .ToList(); - Assert.Equal(3, attributeInsertQueries.Count); - Assert.Same(attributeInsertQueries[0], attributeInsertQueries[1]); - Assert.NotSame(attributeInsertQueries[0], attributeInsertQueries[2]); Assert.Equal(spanCount, context.SuccessCount); Assert.Equal(0, context.FailureCount); var trace = Assert.IsType(repository.GetTrace(GetHexId("trace"))); Assert.Equal(spanCount, trace.Spans.Count); - Assert.All(trace.Spans, span => Assert.Single(span.Attributes)); + for (var index = 0; index < spanCount; index++) + { + var span = trace.Spans[index]; + AssertId($"span-{index}", span.SpanId); + Assert.Equal(KeyValuePair.Create("index", index.ToString(CultureInfo.InvariantCulture)), Assert.Single(span.Attributes)); + } } [Fact] From 5d175cd24360107ff5c9984723d0266ff28fd991 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 21 Jul 2026 14:32:16 +0800 Subject: [PATCH 55/87] Fix ManageDataDialog test service registration --- .../Dialogs/ManageDataDialogTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs index 64c09f1a95d..ebed2b3cbc3 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs @@ -251,6 +251,7 @@ private void SetupManageDataDialogServices(TestDashboardClient dashboardClient) Services.AddSingleton(); Services.AddScoped(); Services.AddScoped(); + Services.AddSingleton(); Services.AddSingleton(); FluentUISetupHelpers.SetupFluentUIComponents(this); From 3098ebeee0bca752549f6aa0b8d7bad0f2c16612 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 21 Jul 2026 15:34:22 +0800 Subject: [PATCH 56/87] Throttle dashboard telemetry callbacks --- .../Model/ResourceOutgoingPeerResolver.cs | 65 +++++++++++++++++-- .../SqliteTelemetryRepository.Runtime.cs | 3 +- .../Utils/CallbackThrottler.cs | 5 +- .../ResourceOutgoingPeerResolverTests.cs | 59 ++++++++++++++--- .../TelemetryApiServiceTests.cs | 8 +-- .../TelemetryRepositoryTests/LogTests.cs | 3 +- .../Telemetry/InMemoryTelemetryRepository.cs | 2 +- 7 files changed, 122 insertions(+), 23 deletions(-) diff --git a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs index ddb98ab8454..844fbe0657b 100644 --- a/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs +++ b/src/Aspire.Dashboard/Model/ResourceOutgoingPeerResolver.cs @@ -20,15 +20,20 @@ public sealed partial class ResourceOutgoingPeerResolver : IOutgoingPeerResolver private readonly ConcurrentDictionary _resourceByName = new(StringComparers.ResourceName); private readonly ActivitySource _activitySource; + private readonly ILogger _logger; private readonly CancellationTokenSource _watchContainersTokenSource; private readonly CancellationToken _watchContainersToken; - private readonly List _subscriptions = []; + private readonly List _subscriptions = []; private readonly object _lock = new(); private readonly Task? _watchTask; - public ResourceOutgoingPeerResolver(IResourceRepository resourceRepository, DashboardActivitySource activitySource) + public ResourceOutgoingPeerResolver( + IResourceRepository resourceRepository, + DashboardActivitySource activitySource, + ILogger logger) { _activitySource = activitySource.ActivitySource; + _logger = logger; _watchContainersTokenSource = new(); _watchContainersToken = _watchContainersTokenSource.Token; @@ -266,13 +271,13 @@ public IDisposable OnPeerChanges(Func callback) { lock (_lock) { - var subscription = new ModelSubscription(callback, RemoveSubscription); + var subscription = new PeerChangesSubscription(callback, RemoveSubscription, _logger); _subscriptions.Add(subscription); return subscription; } } - private void RemoveSubscription(ModelSubscription subscription) + private void RemoveSubscription(PeerChangesSubscription subscription) { lock (_lock) { @@ -282,12 +287,12 @@ private void RemoveSubscription(ModelSubscription subscription) private async Task RaisePeerChangesAsync() { - if (_subscriptions.Count == 0 || _watchContainersTokenSource.IsCancellationRequested) + if (_watchContainersTokenSource.IsCancellationRequested) { return; } - ModelSubscription[] subscriptions; + PeerChangesSubscription[] subscriptions; lock (_lock) { subscriptions = _subscriptions.ToArray(); @@ -302,8 +307,56 @@ private async Task RaisePeerChangesAsync() public async ValueTask DisposeAsync() { _watchContainersTokenSource.Cancel(); + + PeerChangesSubscription[] subscriptions; + lock (_lock) + { + subscriptions = _subscriptions.ToArray(); + } + foreach (var subscription in subscriptions) + { + subscription.Dispose(); + } + _watchContainersTokenSource.Dispose(); await TaskHelpers.WaitIgnoreCancelAsync(_watchTask).ConfigureAwait(false); } + + private sealed class PeerChangesSubscription : IDisposable + { + private const int StateNone = 0; + private const int StateDisposed = 1; + + private readonly CallbackThrottler _callbackThrottler; + private readonly Action _onDispose; + private int _disposed; + + public PeerChangesSubscription( + Func callback, + Action onDispose, + ILogger logger) + { + _callbackThrottler = new CallbackThrottler( + nameof(OnPeerChanges), + logger, + CallbackThrottler.DefaultMinExecuteInterval, + callback, + executionContext: null); + _onDispose = onDispose; + } + + public Task ExecuteAsync() => _callbackThrottler.ExecuteAsync(); + + public void Dispose() + { + if (Interlocked.CompareExchange(ref _disposed, StateDisposed, StateNone) == StateDisposed) + { + return; + } + + _onDispose(this); + _callbackThrottler.Dispose(); + } + } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs index 4960e493f2b..0efecd3c5aa 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs @@ -5,6 +5,7 @@ using System.Threading.Channels; using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; using Microsoft.FluentUI.AspNetCore.Components; namespace Aspire.Dashboard.Otlp.Storage; @@ -19,7 +20,7 @@ public sealed partial class SqliteTelemetryRepository private readonly List _metricsSubscriptions = []; private readonly List _tracesSubscriptions = []; private readonly Dictionary _resourceUnviewedErrorLogs = []; - private TimeSpan _subscriptionMinExecuteInterval = TimeSpan.FromMilliseconds(100); + private TimeSpan _subscriptionMinExecuteInterval = CallbackThrottler.DefaultMinExecuteInterval; private readonly object _watchersLock = new(); private List? _spanWatchers; diff --git a/src/Aspire.Dashboard/Utils/CallbackThrottler.cs b/src/Aspire.Dashboard/Utils/CallbackThrottler.cs index a6b952ab689..20a08e6457e 100644 --- a/src/Aspire.Dashboard/Utils/CallbackThrottler.cs +++ b/src/Aspire.Dashboard/Utils/CallbackThrottler.cs @@ -8,6 +8,9 @@ namespace Aspire.Dashboard.Utils; [DebuggerDisplay("Name = {Name}")] public sealed class CallbackThrottler { + // This interval balances responsiveness with the performance cost of repeatedly executing callbacks. + public static readonly TimeSpan DefaultMinExecuteInterval = TimeSpan.FromMilliseconds(500); + private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); private DateTime? _lastExecute; @@ -89,7 +92,7 @@ bool TryAquireLock(CancellationToken cancellationToken) } } - private async Task ExecuteAsync() + public async Task ExecuteAsync() { // Try to queue the subscription callback. // If another caller is already in the queue then exit without calling the callback. diff --git a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs index ab54c936383..59db36b6d48 100644 --- a/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs +++ b/tests/Aspire.Dashboard.Tests/ResourceOutgoingPeerResolverTests.cs @@ -6,10 +6,12 @@ using System.Runtime.CompilerServices; using System.Threading.Channels; using Aspire.Dashboard.Model; +using Aspire.Dashboard.Utils; using Aspire.DashboardService.Proto.V1; using Aspire.Tests; using Aspire.Tests.Shared.DashboardModel; using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; using Value = Google.Protobuf.WellKnownTypes.Value; @@ -130,19 +132,20 @@ public void ServerAddressAndPort_Match() public async Task OnPeerChanges_DataUpdates_EventRaised() { // Arrange + Assert.Equal(TimeSpan.FromMilliseconds(500), CallbackThrottler.DefaultMinExecuteInterval); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var sourceChannel = Channel.CreateUnbounded(); - var resultChannel = Channel.CreateUnbounded(); + var resultChannel = Channel.CreateUnbounded<(int ChangeCount, long Timestamp)>(); var dashboardClient = new MockDashboardClient(tcs.Task); var resolver = CreateResolver(dashboardClient); var changeCount = 0; resolver.OnPeerChanges(async () => { - await resultChannel.Writer.WriteAsync(++changeCount); + await resultChannel.Writer.WriteAsync((++changeCount, Stopwatch.GetTimestamp())); }); - var readValue = 0; - Assert.False(resultChannel.Reader.TryRead(out readValue)); + Assert.False(resultChannel.Reader.TryRead(out _)); // Act 1 // Initial resource causes change. @@ -151,8 +154,9 @@ public async Task OnPeerChanges_DataUpdates_EventRaised() GetChanges())); // Assert 1 - readValue = await resultChannel.Reader.ReadAsync().DefaultTimeout(); - Assert.Equal(1, readValue); + var readValue = await resultChannel.Reader.ReadAsync().DefaultTimeout(); + Assert.Equal(1, readValue.ChangeCount); + var previousTimestamp = readValue.Timestamp; // Act 2 // New resource causes change. @@ -160,7 +164,9 @@ public async Task OnPeerChanges_DataUpdates_EventRaised() // Assert 2 readValue = await resultChannel.Reader.ReadAsync().DefaultTimeout(); - Assert.Equal(2, readValue); + Assert.Equal(2, readValue.ChangeCount); + AssertMinimumExecuteInterval(previousTimestamp, readValue.Timestamp); + previousTimestamp = readValue.Timestamp; // Act 3 // URL change causes change. @@ -168,7 +174,8 @@ public async Task OnPeerChanges_DataUpdates_EventRaised() // Assert 3 readValue = await resultChannel.Reader.ReadAsync().DefaultTimeout(); - Assert.Equal(3, readValue); + Assert.Equal(3, readValue.ChangeCount); + AssertMinimumExecuteInterval(previousTimestamp, readValue.Timestamp); // Act 4 // Resource update doesn't cause change. @@ -189,6 +196,12 @@ async IAsyncEnumerable> GetChanges([Enume yield return [item]; } } + + static void AssertMinimumExecuteInterval(long startTimestamp, long endTimestamp) + { + var elapsed = Stopwatch.GetElapsedTime(startTimestamp, endTimestamp); + Assert.True(elapsed >= CallbackThrottler.DefaultMinExecuteInterval - TimeSpan.FromMilliseconds(50), $"Callbacks executed too quickly: {elapsed}."); + } } [Fact] @@ -207,6 +220,31 @@ public async Task Constructor_AmbientActivity_DoesNotFlowToResourceWatch() await resolver.DisposeAsync().DefaultTimeout(); } + [Fact] + public async Task OnPeerChanges_AmbientExecutionContext_DoesNotFlowToCallback() + { + var subscribeResult = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var resolver = CreateResolver(new MockDashboardClient(subscribeResult.Task)); + var ambientValue = new AsyncLocal { Value = "request" }; + var callbackValue = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + resolver.OnPeerChanges(() => + { + callbackValue.SetResult(ambientValue.Value); + return Task.CompletedTask; + }); + + var sourceChannel = Channel.CreateUnbounded>(); + subscribeResult.SetResult(new ResourceViewModelSubscription( + [CreateResource("test", serviceAddress: "localhost", servicePort: 8080)], + sourceChannel.Reader.ReadAllAsync())); + + Assert.Null(await callbackValue.Task.DefaultTimeout()); + + ambientValue.Value = null; + sourceChannel.Writer.Complete(); + await resolver.DisposeAsync().DefaultTimeout(); + } + [Fact] public async Task ResourceChanges_CreateActivityForEachBatch() { @@ -284,7 +322,10 @@ private static bool TryResolvePeerName(IDictionary re private static ResourceOutgoingPeerResolver CreateResolver(IResourceRepository resourceRepository, DashboardActivitySource? activitySource = null) { - return new ResourceOutgoingPeerResolver(resourceRepository, activitySource ?? new DashboardActivitySource()); + return new ResourceOutgoingPeerResolver( + resourceRepository, + activitySource ?? new DashboardActivitySource(), + NullLogger.Instance); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs index c9f74d9b67c..2daaa940931 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs @@ -22,7 +22,7 @@ public class TelemetryApiServiceTests [Fact] public async Task FollowSpansAsync_StreamsAllSpans() { - var repository = CreateRepository(); + var repository = CreateRepository(subscriptionMinExecuteInterval: TimeSpan.Zero); AddSpans(repository, count: 5); var service = CreateService(repository); @@ -43,7 +43,7 @@ public async Task FollowSpansAsync_StreamsAllSpans() [Fact] public async Task FollowLogsAsync_StreamsAllLogs() { - var repository = CreateRepository(); + var repository = CreateRepository(subscriptionMinExecuteInterval: TimeSpan.Zero); AddLogs(repository, ["log1", "log2", "log3", "log4", "log5"]); var service = CreateService(repository); @@ -799,7 +799,7 @@ private static List GetAllSpans(TelemetryApiResponse result) [Fact] public async Task FollowSpansAsync_WaitsForResourceToAppear_ThenStreams() { - var repository = CreateRepository(subscriptionMinExecuteInterval: TimeSpan.Zero); + var repository = CreateRepository(); var service = CreateService(repository); // Start enumerating - MoveNextAsync will block until data arrives. @@ -821,7 +821,7 @@ public async Task FollowSpansAsync_WaitsForResourceToAppear_ThenStreams() [Fact] public async Task FollowLogsAsync_WaitsForResourceToAppear_ThenStreams() { - var repository = CreateRepository(subscriptionMinExecuteInterval: TimeSpan.Zero); + var repository = CreateRepository(); var service = CreateService(repository); // Start enumerating - MoveNextAsync will block until data arrives. diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs index 970a045d59f..a58444963c0 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs @@ -7,6 +7,7 @@ using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Tests.Integration; +using Aspire.Dashboard.Utils; using Google.Protobuf.Collections; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging; @@ -910,7 +911,7 @@ public void AddLogs_AttributeLimits_LimitsApplied() public async Task Subscription_MultipleUpdates_MinExecuteIntervalApplied() { // Arrange - var minExecuteInterval = TimeSpan.FromMilliseconds(500); + var minExecuteInterval = CallbackThrottler.DefaultMinExecuteInterval; var loggerFactory = IntegrationTestHelpers.CreateLoggerFactory(_testOutputHelper); var logger = loggerFactory.CreateLogger(nameof(LogTests)); var repository = CreateRepository(subscriptionMinExecuteInterval: minExecuteInterval, loggerFactory: loggerFactory); diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index 0b60cbaab1f..ea81d8359e2 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -40,7 +40,7 @@ public sealed partial class InMemoryTelemetryRepository : ITelemetryRepository, private bool _isReadOnly; private readonly object _lock = new(); - internal TimeSpan _subscriptionMinExecuteInterval = TimeSpan.FromMilliseconds(100); + internal TimeSpan _subscriptionMinExecuteInterval = CallbackThrottler.DefaultMinExecuteInterval; private readonly List _resourceSubscriptions = new(); private readonly List _logSubscriptions = new(); From 5f1d25f487eed9aeaa2c39f96fd7d4cd40357b20 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 21 Jul 2026 16:39:14 +0800 Subject: [PATCH 57/87] Order instrumented trace resources before peers --- src/Aspire.Dashboard/Model/TraceHelpers.cs | 1 + .../SqliteTelemetryRepository.Traces.Reads.cs | 2 +- .../Model/TraceHelpersTests.cs | 21 ++++++++ .../TelemetryRepositoryTests/TraceTests.cs | 51 +++++++++++++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/Model/TraceHelpers.cs b/src/Aspire.Dashboard/Model/TraceHelpers.cs index 7d874b11441..802e3b18dee 100644 --- a/src/Aspire.Dashboard/Model/TraceHelpers.cs +++ b/src/Aspire.Dashboard/Model/TraceHelpers.cs @@ -83,6 +83,7 @@ public static IEnumerable GetOrderedResources(OtlpTrace trace) return resourceFirstTimes.Select(kvp => kvp.Value) .OrderBy(s => s.FirstDateTime) + .ThenBy(s => s.Resource.UninstrumentedPeer) .ThenBy(s => s.Index); } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs index b0266676c85..b7d28cebb9a 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Reads.cs @@ -119,7 +119,7 @@ rs.errored_spans AS ErroredSpans FROM trace_aggregate a LEFT JOIN trace_summaries ts ON 1 = 1 LEFT JOIN resource_summaries rs ON rs.trace_id = ts.trace_id - ORDER BY ts.trace_order_ticks, ts.trace_id, rs.resource_order_ticks, rs.resource_name, rs.instance_id; + ORDER BY ts.trace_order_ticks, ts.trace_id, rs.resource_order_ticks, rs.uninstrumented_peer, rs.resource_name, rs.instance_id; """, query.Parameters).AsList(); var firstRecord = records[0]; diff --git a/tests/Aspire.Dashboard.Tests/Model/TraceHelpersTests.cs b/tests/Aspire.Dashboard.Tests/Model/TraceHelpersTests.cs index 08f3d51a371..977614ab288 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TraceHelpersTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TraceHelpersTests.cs @@ -152,6 +152,27 @@ public void GetOrderedResources_HasUninstrumentedPeer_AddedToResults() }); } + [Fact] + public void GetOrderedResources_SameStartTime_UninstrumentedPeerAfterInstrumentedResources() + { + var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; + var app1 = new OtlpResource("app1", "instance", uninstrumentedPeer: false, context); + var app2 = new OtlpResource("app2", "instance", uninstrumentedPeer: false, context); + var peer = new OtlpResource("peer", instanceId: null, uninstrumentedPeer: true, context); + var trace = new OtlpTrace(new byte[] { 1, 2, 3 }, DateTime.MinValue); + var scope = TelemetryTestHelpers.CreateOtlpScope(context); + var startTime = new DateTime(2001, 1, 1, 1, 1, 1, DateTimeKind.Utc); + trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(app1, trace, scope, spanId: "1", parentSpanId: null, startDate: startTime, uninstrumentedPeer: peer)); + trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(app2, trace, scope, spanId: "2", parentSpanId: null, startDate: startTime)); + + var results = TraceHelpers.GetOrderedResources(trace); + + Assert.Collection(results, + resource => Assert.False(resource.Resource.UninstrumentedPeer), + resource => Assert.False(resource.Resource.UninstrumentedPeer), + resource => Assert.Same(peer, resource.Resource)); + } + [Fact] public void GetOrderedResources_DifferentResourceInstancesWithSameKey_GroupedResult() { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index cde98dd64fe..ca9094c03c2 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -283,6 +283,57 @@ public void GetTraceSummaries_LateParent_PreservesResourceOrder() resource => Assert.Equal("z-child", resource.Resource.ResourceName)); } + [Fact] + public void GetTraceSummaries_SameOrderTime_UninstrumentedPeerAfterInstrumentedResource() + { + var outgoingPeerResolver = new TestOutgoingPeerResolver(onResolve: _ => ("dashboard.db", null)); + var repository = CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); + repository.AsWriter().AddTraces(new AddContext(), + [ + new ResourceSpans + { + Resource = CreateResource(name: "unknown_service:Aspire.Dashboard"), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = + { + CreateSpan( + traceId: "1", + spanId: "1-1", + startTime: s_testTime, + endTime: s_testTime.AddMinutes(1), + attributes: [KeyValuePair.Create(OtlpSpan.PeerServiceAttributeKey, "dashboard.db")], + kind: Span.Types.SpanKind.Client) + } + } + } + } + ]); + + var summary = Assert.Single(repository.GetTraceSummaries(new GetTracesRequest + { + ResourceKeys = [], + StartIndex = 0, + Count = 10, + Filters = [] + }).PagedResult.Items); + + Assert.Collection(summary.Resources, + resource => + { + Assert.Equal("unknown_service:Aspire.Dashboard", resource.Resource.ResourceName); + Assert.False(resource.Resource.UninstrumentedPeer); + }, + resource => + { + Assert.Equal("dashboard.db", resource.Resource.ResourceName); + Assert.True(resource.Resource.UninstrumentedPeer); + }); + } + [Fact] public void GetTraceSummaries_IncrementalAppend_UpdatesSummaryValues() { From facd5c29d69717ec20a159c506cf5a6ef05af4c1 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 22 Jul 2026 10:18:09 +0800 Subject: [PATCH 58/87] Fix ManageDataDialog repository test after merge --- .../Dialogs/ManageDataDialogTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs index ebed2b3cbc3..bff5bb02eed 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs @@ -158,7 +158,7 @@ public async Task Render_ClearedSignals_PrunesSelectionsAndSupportsRemovingEmpty var dashboardClient = new TestDashboardClient(isEnabled: false, initialResources: []); SetupManageDataDialogServices(dashboardClient); - var repository = Services.GetRequiredService(); + var repository = Services.GetRequiredService(); var resourceKey = new ResourceKey("orphan", "instance"); repository.AddLogs(new AddContext(), new RepeatedField { From 523a89d1cf2e65cf683ccba66352540931276989 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 22 Jul 2026 13:00:44 +0800 Subject: [PATCH 59/87] Avoid dashboard OTLP telemetry loop --- .../ProjectResourceBuilderExtensions.cs | 8 ++++++- .../ProjectResourceTests.cs | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs index 17172a196c1..1d39461a014 100644 --- a/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs @@ -478,7 +478,13 @@ internal static IResourceBuilder WithProjectDefaults(resourceName, launchProfileName: null); + using var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.GetProjectResources()); + + Assert.Equal(expectedOtlpExporter, resource.Annotations.OfType().Any()); + + var config = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance).DefaultTimeout(); + + Assert.Equal(expectedOtlpExporter, config.ContainsKey(KnownOtelConfigNames.ExporterOtlpEndpoint)); + } + [Theory] [InlineData("true", false)] [InlineData("1", false)] From 6d7fceacfb40a870a7fc7b88361e3d7c70453241 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 22 Jul 2026 13:18:06 +0800 Subject: [PATCH 60/87] WIP --- .../Components/Layout/MainLayout.razor | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index d21a831a35c..30ac82e29ec 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -117,14 +117,8 @@ - @if (!_isSwitchingRuns) - { - var selectedRun = RunSelection.SelectedRun; - - - - - } + +
@Loc[nameof(Layout.MainLayoutUnhandledErrorMessage)] @Loc[nameof(Layout.MainLayoutUnhandledErrorReload)] From 5aa9f43e3f2778b2dc04b7006c4878bc0d08d2cb Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 22 Jul 2026 15:34:57 +0800 Subject: [PATCH 61/87] Improve historical telemetry and metrics queries --- .../TelemetryRepositoryMetricsBenchmarks.cs | 321 ++++++++++++++++++ .../Components/Pages/StructuredLogs.razor.cs | 27 +- .../Components/Pages/Traces.razor.cs | 27 +- .../Otlp/Storage/ITelemetryRepository.cs | 5 + .../SqliteTelemetryRepository.Metrics.cs | 142 ++++---- .../Otlp/Storage/SqliteTelemetryRepository.cs | 2 + .../Pages/StructuredLogsTests.cs | 50 +++ .../Pages/TracesTests.cs | 56 +++ .../Model/DashboardDataSourceTests.cs | 3 + .../Telemetry/InMemoryTelemetryRepository.cs | 2 + 10 files changed, 554 insertions(+), 81 deletions(-) create mode 100644 benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs new file mode 100644 index 00000000000..9078fec8074 --- /dev/null +++ b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs @@ -0,0 +1,321 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Jobs; +using Google.Protobuf; +using Google.Protobuf.Collections; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using OpenTelemetry.Proto.Common.V1; +using OpenTelemetry.Proto.Metrics.V1; +using OpenTelemetry.Proto.Resource.V1; + +namespace Aspire.Dashboard.Benchmarks; + +[MemoryDiagnoser] +[ThreadingDiagnoser] +[Config(typeof(Config))] +public class TelemetryRepositoryMetricsBenchmarks +{ + private const int MetricSamplesPerBatch = 100; + private const string MetricMeterName = "benchmark-meter"; + private const string MetricInstrumentName = "benchmark.metric"; + private const string HistogramMetricInstrumentName = "benchmark.histogram"; + private static readonly TimeSpan s_metricDataDuration = TimeSpan.FromHours(12); + private static readonly TimeSpan s_metricDisplayDuration = TimeSpan.FromHours(12); + private static readonly TimeSpan s_metricInterval = TimeSpan.FromSeconds(2); + private static readonly TimeSpan s_metricExemplarInterval = TimeSpan.FromSeconds(10); + private static readonly ResourceKey s_metricResourceKey = new("benchmark-app", "benchmark-instance"); + + private string _temporaryDirectory = null!; + private SqliteTelemetryRepository _queryRepository = null!; + + [Params(1, 10)] + public int DimensionCount { get; set; } + + [GlobalSetup(Target = nameof(GetMetricsLongDuration))] + public void SetupMetrics() => Setup(isHistogram: false); + + [GlobalSetup(Target = nameof(GetHistogramMetricsLongDuration))] + public void SetupHistogramMetrics() => Setup(isHistogram: true); + + private void Setup(bool isHistogram) + { + _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-metrics-benchmark-").FullName; + _queryRepository = CreateRepository(Path.Combine(_temporaryDirectory, "query.db")); + var addContext = new AddContext(); + foreach (var batch in CreateLongDurationMetricBatches(DimensionCount, isHistogram)) + { + _queryRepository.AddMetrics(addContext, batch); + } + if (addContext.FailureCount > 0) + { + throw new InvalidOperationException($"Failed to add {addContext.FailureCount} benchmark metric points."); + } + } + + [GlobalCleanup] + public void Cleanup() + { + _queryRepository.Dispose(); + Directory.Delete(_temporaryDirectory, recursive: true); + } + + [Benchmark(Description = "TelemetryRepository: query 12h metrics display")] + public int GetMetricsLongDuration() + { + var instrument = GetLongDurationInstrument(MetricInstrumentName); + + return instrument.Dimensions.Sum(dimension => dimension.Values.Count); + } + + [Benchmark(Description = "TelemetryRepository: query 12h histogram metrics display")] + public int GetHistogramMetricsLongDuration() + { + var instrument = GetLongDurationInstrument(HistogramMetricInstrumentName); + + return instrument.Dimensions.Sum(dimension => + dimension.Values.Count + dimension.Values.Sum(value => value.Exemplars.Count)); + } + + private OtlpInstrumentData GetLongDurationInstrument(string instrumentName) + { + var endTime = _queryRepository.GetInstrumentLatestEndTime(s_metricResourceKey, MetricMeterName, instrumentName) + ?? throw new InvalidOperationException($"Unable to find the benchmark metric '{instrumentName}' end time."); + + // Match the dashboard metrics display query, which includes an extra 30 seconds for histogram calculations. + return _queryRepository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = s_metricResourceKey, + MeterName = MetricMeterName, + InstrumentName = instrumentName, + StartTime = endTime.Subtract(s_metricDisplayDuration + TimeSpan.FromSeconds(30)), + EndTime = endTime + }) ?? throw new InvalidOperationException($"Unable to find the benchmark metric '{instrumentName}'."); + } + + private static SqliteTelemetryRepository CreateRepository(string databasePath) + { + return new SqliteTelemetryRepository( + databasePath, + NullLoggerFactory.Instance, + Options.Create(new DashboardOptions()), + new PauseManager(), + []); + } + + private static IEnumerable> CreateLongDurationMetricBatches(int dimensionCount, bool isHistogram) + { + var startTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + double[] observations = [5, 25, 75, 150]; + var bucketCounts = Enumerable.Range(0, dimensionCount).Select(_ => new ulong[observations.Length]).ToArray(); + var sums = new double[dimensionCount]; + var totalSampleCount = (int)(s_metricDataDuration / s_metricInterval); + + for (var firstSampleIndex = 0; firstSampleIndex < totalSampleCount; firstSampleIndex += MetricSamplesPerBatch) + { + var sampleCount = Math.Min(MetricSamplesPerBatch, totalSampleCount - firstSampleIndex); + yield return CreateLongDurationMetrics( + startTime, + dimensionCount, + firstSampleIndex, + sampleCount, + observations, + bucketCounts, + sums, + isHistogram); + } + } + + private static RepeatedField CreateLongDurationMetrics( + DateTime startTime, + int dimensionCount, + int firstSampleIndex, + int sampleCount, + double[] observations, + ulong[][] bucketCounts, + double[] sums, + bool isHistogram) + { + return + [ + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = new InstrumentationScope { Name = MetricMeterName }, + Metrics = + { + CreateMetric(startTime, dimensionCount, firstSampleIndex, sampleCount, observations, bucketCounts, sums, isHistogram) + } + } + } + } + ]; + } + + private static Metric CreateMetric( + DateTime startTime, + int dimensionCount, + int firstSampleIndex, + int sampleCount, + double[] observations, + ulong[][] bucketCounts, + double[] sums, + bool isHistogram) + { + return isHistogram + ? new Metric + { + Name = HistogramMetricInstrumentName, + Description = "Long-running benchmark histogram metric", + Unit = "ms", + Histogram = new Histogram + { + AggregationTemporality = AggregationTemporality.Cumulative, + DataPoints = { CreateHistogramMetricPoints(startTime, dimensionCount, firstSampleIndex, sampleCount, observations, bucketCounts, sums) } + } + } + : new Metric + { + Name = MetricInstrumentName, + Description = "Long-running benchmark metric", + Unit = "requests", + Sum = new Sum + { + AggregationTemporality = AggregationTemporality.Cumulative, + IsMonotonic = true, + DataPoints = { CreateMetricPoints(startTime, dimensionCount, firstSampleIndex, sampleCount) } + } + }; + } + + private static IEnumerable CreateMetricPoints( + DateTime startTime, + int dimensionCount, + int firstSampleIndex, + int sampleCount) + { + for (var sampleOffset = 0; sampleOffset < sampleCount; sampleOffset++) + { + var sampleIndex = firstSampleIndex + sampleOffset; + var pointTime = startTime.AddTicks(s_metricInterval.Ticks * sampleIndex); + for (var dimensionIndex = 0; dimensionIndex < dimensionCount; dimensionIndex++) + { + yield return new NumberDataPoint + { + AsInt = sampleIndex, + StartTimeUnixNano = DateTimeToUnixNanoseconds(pointTime), + TimeUnixNano = DateTimeToUnixNanoseconds(pointTime), + Attributes = { CreateDimensionAttribute(dimensionIndex) } + }; + } + } + } + + private static IEnumerable CreateHistogramMetricPoints( + DateTime startTime, + int dimensionCount, + int firstSampleIndex, + int sampleCount, + double[] observations, + ulong[][] bucketCounts, + double[] sums) + { + for (var sampleOffset = 0; sampleOffset < sampleCount; sampleOffset++) + { + var sampleIndex = firstSampleIndex + sampleOffset; + var pointTime = startTime.AddTicks(s_metricInterval.Ticks * sampleIndex); + var observationIndex = sampleIndex % observations.Length; + var observation = observations[observationIndex]; + for (var dimensionIndex = 0; dimensionIndex < dimensionCount; dimensionIndex++) + { + bucketCounts[dimensionIndex][observationIndex]++; + sums[dimensionIndex] += observation; + + var point = new HistogramDataPoint + { + Count = checked((ulong)sampleIndex + 1), + Sum = sums[dimensionIndex], + StartTimeUnixNano = DateTimeToUnixNanoseconds(startTime), + TimeUnixNano = DateTimeToUnixNanoseconds(pointTime), + ExplicitBounds = { 10, 50, 100 }, + Attributes = { CreateDimensionAttribute(dimensionIndex) } + }; + point.BucketCounts.Add(bucketCounts[dimensionIndex]); + + if ((pointTime - startTime).Ticks % s_metricExemplarInterval.Ticks == 0) + { + point.Exemplars.Add(CreateMetricExemplar(pointTime, observation)); + } + + yield return point; + } + } + } + + private static KeyValue CreateDimensionAttribute(int dimensionIndex) + { + return new KeyValue + { + Key = "benchmark.dimension", + Value = new AnyValue { StringValue = dimensionIndex.ToString(CultureInfo.InvariantCulture) } + }; + } + + private static Exemplar CreateMetricExemplar(DateTime pointTime, double value) + { + return new Exemplar + { + TimeUnixNano = DateTimeToUnixNanoseconds(pointTime), + AsDouble = value, + SpanId = ByteString.CopyFromUtf8("span-id0"), + TraceId = ByteString.CopyFromUtf8("trace-id00000000") + }; + } + + private static Resource CreateResource() + { + return new Resource + { + Attributes = + { + new KeyValue { Key = "service.name", Value = new AnyValue { StringValue = "benchmark-app" } }, + new KeyValue { Key = "service.instance.id", Value = new AnyValue { StringValue = "benchmark-instance" } } + } + }; + } + + private static ulong DateTimeToUnixNanoseconds(DateTime dateTime) + { + var unixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var timeSinceEpoch = dateTime.ToUniversalTime() - unixEpoch; + + return (ulong)timeSinceEpoch.Ticks * 100; + } + + private sealed class Config : ManualConfig + { + public Config() + { + AddJob(Job.Default + .WithWarmupCount(1) + .WithIterationCount(1) + .WithInvocationCount(1) + .WithUnrollFactor(1)); + + AddDiagnoser(MemoryDiagnoser.Default); + } + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index e863da6e301..8909be7083a 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -134,20 +134,23 @@ private async ValueTask> GetData(GridItemsPr var logs = ViewModel.GetLogs(); - if (logs.IsFull && !TelemetryRepository.HasDisplayedMaxLogLimitMessage) + if (!TelemetryRepository.IsReadOnly) { - TelemetryRepository.MaxLogLimitMessage = await DashboardUIHelpers.DisplayMaxLimitMessageAsync( - MessageService, - Loc[nameof(Dashboard.Resources.StructuredLogs.MessageExceededLimitTitle)], - string.Format(CultureInfo.InvariantCulture, Loc[nameof(Dashboard.Resources.StructuredLogs.MessageExceededLimitBody)], DashboardOptions.Value.TelemetryLimits.MaxLogCount), - () => TelemetryRepository.MaxLogLimitMessage = null); + if (logs.IsFull && !TelemetryRepository.HasDisplayedMaxLogLimitMessage) + { + TelemetryRepository.MaxLogLimitMessage = await DashboardUIHelpers.DisplayMaxLimitMessageAsync( + MessageService, + Loc[nameof(Dashboard.Resources.StructuredLogs.MessageExceededLimitTitle)], + string.Format(CultureInfo.InvariantCulture, Loc[nameof(Dashboard.Resources.StructuredLogs.MessageExceededLimitBody)], DashboardOptions.Value.TelemetryLimits.MaxLogCount), + () => TelemetryRepository.MaxLogLimitMessage = null); - TelemetryRepository.HasDisplayedMaxLogLimitMessage = true; - } - else if (!logs.IsFull && TelemetryRepository.MaxLogLimitMessage is { } message) - { - // Telemetry could have been cleared from the dashboard. Automatically remove full message on data update. - message.Close(); + TelemetryRepository.HasDisplayedMaxLogLimitMessage = true; + } + else if (!logs.IsFull && TelemetryRepository.MaxLogLimitMessage is { } message) + { + // Telemetry could have been cleared from the dashboard. Automatically remove full message on data update. + message.Close(); + } } // Updating the total item count as a field doesn't work because it isn't updated with the grid. diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs index b3b2ae8833f..c9435ee8eca 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs @@ -139,20 +139,23 @@ private async ValueTask> GetData(GridItems TracesViewModel.Count = request.Count ?? DashboardUIHelpers.DefaultDataGridResultCount; var traces = TracesViewModel.GetTraces(); - if (traces.IsFull && !TelemetryRepository.HasDisplayedMaxTraceLimitMessage) + if (!TelemetryRepository.IsReadOnly) { - TelemetryRepository.MaxTraceLimitMessage = await DashboardUIHelpers.DisplayMaxLimitMessageAsync( - MessageService, - Loc[nameof(Dashboard.Resources.Traces.MessageExceededLimitTitle)], - string.Format(CultureInfo.InvariantCulture, Loc[nameof(Dashboard.Resources.Traces.MessageExceededLimitBody)], DashboardOptions.Value.TelemetryLimits.MaxTraceCount), - () => TelemetryRepository.MaxTraceLimitMessage = null); + if (traces.IsFull && !TelemetryRepository.HasDisplayedMaxTraceLimitMessage) + { + TelemetryRepository.MaxTraceLimitMessage = await DashboardUIHelpers.DisplayMaxLimitMessageAsync( + MessageService, + Loc[nameof(Dashboard.Resources.Traces.MessageExceededLimitTitle)], + string.Format(CultureInfo.InvariantCulture, Loc[nameof(Dashboard.Resources.Traces.MessageExceededLimitBody)], DashboardOptions.Value.TelemetryLimits.MaxTraceCount), + () => TelemetryRepository.MaxTraceLimitMessage = null); - TelemetryRepository.HasDisplayedMaxTraceLimitMessage = true; - } - else if (!traces.IsFull && TelemetryRepository.MaxTraceLimitMessage is { } message) - { - // Telemetry could have been cleared from the dashboard. Automatically remove full message on data update. - message.Close(); + TelemetryRepository.HasDisplayedMaxTraceLimitMessage = true; + } + else if (!traces.IsFull && TelemetryRepository.MaxTraceLimitMessage is { } message) + { + // Telemetry could have been cleared from the dashboard. Automatically remove full message on data update. + message.Close(); + } } // Updating the total item count as a field doesn't work because it isn't updated with the grid. diff --git a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs index afa552d6f9a..54e97ead167 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs @@ -11,6 +11,11 @@ namespace Aspire.Dashboard.Otlp.Storage; ///
public interface ITelemetryRepository : IDisposable { + /// + /// Gets a value indicating whether the repository is read-only. + /// + bool IsReadOnly { get; } + bool HasDisplayedMaxLogLimitMessage { get; set; } Message? MaxLogLimitMessage { get; set; } bool HasDisplayedMaxTraceLimitMessage { get; set; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs index b2ee4a253fa..7d031bed060 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs @@ -24,6 +24,7 @@ public sealed partial class SqliteTelemetryRepository private const int DoublePointType = 2; private const int HistogramPointType = 3; private const int MaxMetricPointBatchSize = 100; + private const string MetricPointRangeFilterSql = "(@ApplyRange = 0 OR (p.start_time_ticks <= @EndTicks AND p.end_time_ticks >= @StartTicks) OR (p.start_time_ticks >= @StartTicks AND p.end_time_ticks <= @EndTicks))"; private readonly MetricIngestionState _metricIngestionState = new(); @@ -777,7 +778,12 @@ WHERE point_rank > @MaxMetricsCount var knownAttributeValues = new Dictionary>(); foreach (var instrument in instruments) { - foreach (var dimension in MaterializeMetricDimensions(connection, instrument.InstrumentId, request.StartTime, request.EndTime)) + foreach (var dimension in MaterializeMetricDimensions( + connection, + instrument.InstrumentId, + instrument.Summary.Type == OtlpInstrumentType.Histogram, + request.StartTime, + request.EndTime)) { var isFirst = dimensions.Count == 0; foreach (var key in knownAttributeValues.Keys.Union(dimension.Attributes.Select(attribute => attribute.Key)).Distinct().ToList()) @@ -867,53 +873,44 @@ FROM telemetry_metric_points p private List MaterializeMetricDimensions( SqliteConnection connection, long instrumentId, + bool isHistogram, DateTime? startTime, DateTime? endTime) { var dimensionIds = connection.Query("SELECT dimension_id FROM telemetry_metric_dimensions WHERE instrument_id = @InstrumentId ORDER BY dimension_id;", new { InstrumentId = instrumentId }).AsList(); + var queryParameters = new DynamicParameters(); + queryParameters.Add("DimensionIds", dimensionIds); + queryParameters.Add("ApplyRange", startTime is not null && endTime is not null); + queryParameters.Add("StartTicks", startTime?.Ticks ?? 0); + queryParameters.Add("EndTicks", endTime?.Ticks ?? 0); var attributes = connection.Query(""" SELECT dimension_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue FROM telemetry_metric_dimension_attributes - WHERE dimension_id IN @Ids + WHERE dimension_id IN @DimensionIds ORDER BY dimension_id, ordinal; - """, new { Ids = dimensionIds }).ToLookup(record => record.OwnerId); - var points = connection.Query(""" + """, queryParameters).ToLookup(record => record.OwnerId); + var points = connection.Query($""" SELECT - point_id AS PointId, - dimension_id AS DimensionId, - point_type AS PointType, - start_time_ticks AS StartTimeTicks, - end_time_ticks AS EndTimeTicks, - repeat_count AS RepeatCount, - integer_value AS IntegerValue, - double_value AS DoubleValue, - histogram_sum AS HistogramSum, - histogram_count AS HistogramCount - FROM telemetry_metric_points - WHERE dimension_id IN @Ids - AND (@ApplyRange = 0 OR (start_time_ticks <= @EndTicks AND end_time_ticks >= @StartTicks) OR (start_time_ticks >= @StartTicks AND end_time_ticks <= @EndTicks)) - ORDER BY dimension_id, point_id; - """, new - { - Ids = dimensionIds, - ApplyRange = startTime is not null && endTime is not null, - StartTicks = startTime?.Ticks ?? 0, - EndTicks = endTime?.Ticks ?? 0 - }).ToLookup(record => record.DimensionId); - var allPointIds = points.SelectMany(group => group).Select(point => point.PointId).ToArray(); - var bucketCounts = connection.Query(""" - SELECT point_id AS PointId, bucket_count AS BucketCount - FROM telemetry_metric_histogram_bucket_counts - WHERE point_id IN @Ids - ORDER BY point_id, ordinal; - """, new { Ids = allPointIds }).ToLookup(record => record.PointId); - var explicitBounds = connection.Query(""" - SELECT point_id AS PointId, explicit_bound AS ExplicitBound - FROM telemetry_metric_histogram_explicit_bounds - WHERE point_id IN @Ids - ORDER BY point_id, ordinal; - """, new { Ids = allPointIds }).ToLookup(record => record.PointId); - var exemplars = MaterializeMetricExemplars(connection, allPointIds); + p.point_id AS PointId, + p.dimension_id AS DimensionId, + p.point_type AS PointType, + p.start_time_ticks AS StartTimeTicks, + p.end_time_ticks AS EndTimeTicks, + p.repeat_count AS RepeatCount, + p.integer_value AS IntegerValue, + p.double_value AS DoubleValue, + p.histogram_sum AS HistogramSum, + p.histogram_count AS HistogramCount + FROM telemetry_metric_points p + WHERE p.dimension_id IN @DimensionIds + AND {MetricPointRangeFilterSql} + ORDER BY p.dimension_id, p.point_id; + """, queryParameters).ToLookup(record => record.DimensionId); + var (bucketCounts, explicitBounds) = isHistogram + ? MaterializeMetricHistogramData(connection, queryParameters) + : (Array.Empty().ToLookup(record => record.PointId), + Array.Empty().ToLookup(record => record.PointId)); + var exemplars = MaterializeMetricExemplars(connection, queryParameters); var results = new List(dimensionIds.Count); foreach (var dimensionId in dimensionIds) @@ -951,26 +948,57 @@ WHERE point_id IN @Ids return results; } - private static ILookup MaterializeMetricExemplars(SqliteConnection connection, long[] pointIds) + private static (ILookup BucketCounts, ILookup ExplicitBounds) MaterializeMetricHistogramData( + SqliteConnection connection, + DynamicParameters queryParameters) + { + var bucketCounts = connection.Query($""" + SELECT b.point_id AS PointId, b.bucket_count AS BucketCount + FROM telemetry_metric_histogram_bucket_counts b + JOIN telemetry_metric_points p ON p.point_id = b.point_id + WHERE p.dimension_id IN @DimensionIds + AND {MetricPointRangeFilterSql} + ORDER BY b.point_id, b.ordinal; + """, queryParameters).ToLookup(record => record.PointId); + var explicitBounds = connection.Query($""" + SELECT b.point_id AS PointId, b.explicit_bound AS ExplicitBound + FROM telemetry_metric_histogram_explicit_bounds b + JOIN telemetry_metric_points p ON p.point_id = b.point_id + WHERE p.dimension_id IN @DimensionIds + AND {MetricPointRangeFilterSql} + ORDER BY b.point_id, b.ordinal; + """, queryParameters).ToLookup(record => record.PointId); + + return (bucketCounts, explicitBounds); + } + + private static ILookup MaterializeMetricExemplars( + SqliteConnection connection, + DynamicParameters queryParameters) { - var records = connection.Query(""" + var records = connection.Query($""" SELECT - exemplar_id AS ExemplarId, - point_id AS PointId, - start_time_ticks AS StartTimeTicks, - exemplar_value AS ExemplarValue, - span_id AS SpanId, - trace_id AS TraceId - FROM telemetry_metric_exemplars - WHERE point_id IN @Ids - ORDER BY point_id, exemplar_id; - """, new { Ids = pointIds }).AsList(); - var attributes = connection.Query(""" - SELECT exemplar_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_metric_exemplar_attributes - WHERE exemplar_id IN @Ids - ORDER BY exemplar_id, ordinal; - """, new { Ids = records.Select(record => record.ExemplarId).ToArray() }).ToLookup(record => record.OwnerId); + e.exemplar_id AS ExemplarId, + e.point_id AS PointId, + e.start_time_ticks AS StartTimeTicks, + e.exemplar_value AS ExemplarValue, + e.span_id AS SpanId, + e.trace_id AS TraceId + FROM telemetry_metric_exemplars e + JOIN telemetry_metric_points p ON p.point_id = e.point_id + WHERE p.dimension_id IN @DimensionIds + AND {MetricPointRangeFilterSql} + ORDER BY e.point_id, e.exemplar_id; + """, queryParameters).AsList(); + var attributes = connection.Query($""" + SELECT a.exemplar_id AS OwnerId, a.attribute_key AS AttributeKey, a.attribute_value AS AttributeValue + FROM telemetry_metric_exemplar_attributes a + JOIN telemetry_metric_exemplars e ON e.exemplar_id = a.exemplar_id + JOIN telemetry_metric_points p ON p.point_id = e.point_id + WHERE p.dimension_id IN @DimensionIds + AND {MetricPointRangeFilterSql} + ORDER BY a.exemplar_id, a.ordinal; + """, queryParameters).ToLookup(record => record.OwnerId); return records.Select(record => new KeyValuePair(record.PointId, new MetricsExemplar { Start = new DateTime(record.StartTimeTicks, DateTimeKind.Utc), diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index 059d11480dc..c702d503270 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -31,6 +31,8 @@ public sealed partial class SqliteTelemetryRepository : ITelemetryRepository, IT private static string CreateStartsWithLikePattern(string value) => $"{EscapeLikePattern(value)}%"; + public bool IsReadOnly => _database.IsReadOnly; + private static string EscapeLikePattern(string value) { return value diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs index 146735ce086..ae75ddd7c16 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs @@ -5,6 +5,7 @@ using Aspire.Dashboard.Components.Pages; using Aspire.Dashboard.Components.Resize; using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Model; using Aspire.Dashboard.Model.Otlp; @@ -18,6 +19,8 @@ using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.FluentUI.AspNetCore.Components; using OpenTelemetry.Proto.Logs.V1; using Xunit; using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers; @@ -252,6 +255,53 @@ public void PauseIncomingData_DisplaysPauseWarningImmediately() Assert.Contains("Capture paused", cut.Markup); } + [Theory] + [InlineData(false, 1)] + [InlineData(true, 0)] + public void Render_AtLogLimit_LimitMessageOnlyDisplayedForLiveRun(bool isReadOnly, int expectedMessageCount) + { + var messageCount = 0; + var messageService = new TestMessageService(_ => + { + messageCount++; + return Task.FromResult(new Message()); + }); + + SetupStructureLogsServices(); + Services.AddSingleton(messageService); + Services.AddSingleton>(Options.Create(new DashboardOptions + { + TelemetryLimits = { MaxLogCount = 1 } + })); + + var telemetryRepository = Services.GetRequiredService(); + telemetryRepository.AddLogs(new AddContext(), new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(), + ScopeLogs = + { + new ScopeLogs + { + Scope = CreateScope(), + LogRecords = { CreateLogRecord() } + } + } + } + }); + if (isReadOnly) + { + telemetryRepository.MakeReadOnly(); + } + + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + Services.GetRequiredService().InvokeOnViewportInformationChanged(viewport); + RenderComponent(builder => builder.Add(p => p.ViewportInformation, viewport)); + + Assert.Equal(expectedMessageCount, messageCount); + } + private void SetupStructureLogsServices() { FluentUISetupHelpers.SetupFluentDivider(this); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs index 5104be14c24..678cabe5a9c 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs @@ -4,13 +4,21 @@ using Aspire.Dashboard.Components.Pages; using Aspire.Dashboard.Components.Resize; using Aspire.Dashboard.Components.Tests.Shared; +using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; using Bunit; +using Google.Protobuf.Collections; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.FluentUI.AspNetCore.Components; +using OpenTelemetry.Proto.Trace.V1; using Xunit; +using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers; namespace Aspire.Dashboard.Components.Tests.Pages; @@ -48,6 +56,54 @@ public void Render_FocusesAccessibleScrollContainerOnInitialRender() }); } + [Theory] + [InlineData(false, 1)] + [InlineData(true, 0)] + public void Render_AtTraceLimit_LimitMessageOnlyDisplayedForLiveRun(bool isReadOnly, int expectedMessageCount) + { + var messageCount = 0; + var messageService = new TestMessageService(_ => + { + messageCount++; + return Task.FromResult(new Message()); + }); + + SetupTracesServices(); + Services.AddSingleton(messageService); + Services.AddSingleton>(Options.Create(new DashboardOptions + { + TelemetryLimits = { MaxTraceCount = 1 } + })); + + var timestamp = DateTime.UnixEpoch; + var telemetryRepository = Services.GetRequiredService(); + telemetryRepository.AddTraces(new AddContext(), new RepeatedField + { + new ResourceSpans + { + Resource = CreateResource(), + ScopeSpans = + { + new ScopeSpans + { + Scope = CreateScope(), + Spans = { CreateSpan(traceId: "trace", spanId: "span", startTime: timestamp, endTime: timestamp.AddSeconds(1)) } + } + } + } + }); + if (isReadOnly) + { + telemetryRepository.MakeReadOnly(); + } + + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + Services.GetRequiredService().InvokeOnViewportInformationChanged(viewport); + RenderComponent(builder => builder.AddCascadingValue(viewport)); + + Assert.Equal(expectedMessageCount, messageCount); + } + private void SetupTracesServices() { FluentUISetupHelpers.SetupFluentOverflow(this); diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index a56a962d649..f147cc23bd5 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -624,6 +624,7 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() var logger = new TestLogger(new TestLoggerFactory(testSink, enabled: true)); using var dataSource = CreateDataSource(currentRunStore, currentTelemetryRepository, currentResourceRepository, repositoryFactory, logger); Assert.Empty(dataSource.TelemetryRepository.GetResources()); + Assert.False(dataSource.TelemetryRepository.IsReadOnly); dataSource.SelectRun(historicalRunId); @@ -632,6 +633,7 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() Assert.Equal($"Switched dashboard run from '{currentRunStore.RunId}' to '{historicalRunId}'.", switchLog.Message); Assert.True(dataSource.IsReadOnly); + Assert.True(dataSource.TelemetryRepository.IsReadOnly); Assert.Equal(historicalRunId, dataSource.SelectedRun.RunId); Assert.Equal("api", Assert.Single(dataSource.ResourceRepository.GetResources()).Name); Assert.Equal("TestService", Assert.Single(dataSource.TelemetryRepository.GetResources()).ResourceName); @@ -669,6 +671,7 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() Assert.Empty(dataSource.TelemetryRepository.GetResources()); Assert.False(dataSource.IsReadOnly); + Assert.False(dataSource.TelemetryRepository.IsReadOnly); Action handler = _ => connectionStateChangedCount++; selectedClient.ConnectionStateChanged += handler; diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index ea81d8359e2..efbdc1d67b1 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -74,6 +74,8 @@ public sealed partial class InMemoryTelemetryRepository : ITelemetryRepository, private readonly List _peerResolverSubscriptions = new(); internal readonly OtlpContext _otlpContext; + public bool IsReadOnly => _isReadOnly; + public bool HasDisplayedMaxLogLimitMessage { get; set; } public Message? MaxLogLimitMessage { get; set; } From 44ebcdc2eb343dd5130cf13471f42b6e57bd2415 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 22 Jul 2026 17:51:22 +0800 Subject: [PATCH 62/87] Improve metrics aggregation and chart updates --- .../Controls/Chart/ChartContainer.razor | 4 +- .../Controls/Chart/ChartContainer.razor.cs | 158 ++++++---- .../Controls/Chart/ChartFilters.razor | 6 +- .../Controls/Chart/ChartFilters.razor.cs | 23 +- .../Otlp/Storage/GetInstrumentRequest.cs | 27 ++ .../SqliteTelemetryRepository.Metrics.cs | 275 +++++++++++------- .../ServiceClient/DashboardSqliteDatabase.cs | 2 +- .../DatabaseSchema/005.Metrics.sql | 4 +- .../Controls/ChartFiltersTests.cs | 26 +- .../Pages/MetricsTests.cs | 52 +++- .../Model/DashboardSqliteDatabaseTests.cs | 14 + .../TelemetryRepositoryTests/MetricsTests.cs | 193 ++++++------ .../SqliteTelemetryPersistenceTests.cs | 5 +- .../Telemetry/InMemoryTelemetryRepository.cs | 108 ++++--- 14 files changed, 552 insertions(+), 345 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor index b3cd920b922..977d58e51b0 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor @@ -38,7 +38,7 @@ else Icon="@(new Icons.Regular.Size24.DataArea())">
- +
- +
diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs index 77f22955b69..2aae3fc10a4 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs @@ -2,10 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Diagnostics; using System.Runtime.InteropServices; using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; -using Aspire.Dashboard.Otlp.Model.MetricValues; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Resources; using Microsoft.AspNetCore.Components; @@ -15,11 +15,13 @@ namespace Aspire.Dashboard.Components; public partial class ChartContainer : ComponentBase, IAsyncDisposable { + private static readonly TimeSpan s_chartUpdateInterval = TimeSpan.FromSeconds(0.2); + private static readonly TimeSpan s_dataFetchInterval = TimeSpan.FromSeconds(10); + private OtlpInstrumentData? _instrument; private PeriodicTimer? _tickTimer; private Task? _tickTask; private IDisposable? _themeChangedSubscription; - private int _renderedDimensionsCount; private readonly InstrumentViewModel _instrumentViewModel = new InstrumentViewModel(); private (ResourceKey ResourceKey, string MeterName, string InstrumentName)? _dataEndTimeKey; private DateTimeOffset? _dataEndTime; @@ -62,9 +64,6 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable [Inject] public required PauseManager PauseManager { get; init; } - [Inject] - public required IDashboardClient DashboardClient { get; init; } - public ImmutableList DimensionFilters { get; set; } = []; public string? PreviousMeterName { get; set; } public string? PreviousInstrumentName { get; set; } @@ -73,10 +72,10 @@ protected override async Task OnInitializedAsync() { await ThemeManager.EnsureInitializedAsync(); - if (!DashboardClient.IsReadOnly) + if (!TelemetryRepository.IsReadOnly) { // Update the graph every 200ms. This displays the latest data and moves time forward. - _tickTimer = new PeriodicTimer(TimeSpan.FromSeconds(0.2)); + _tickTimer = new PeriodicTimer(s_chartUpdateInterval); _tickTask = Task.Run(UpdateDataAsync); } _themeChangedSubscription = ThemeManager.OnThemeChanged(async () => @@ -101,30 +100,41 @@ public async ValueTask DisposeAsync() private async Task UpdateDataAsync() { var timer = _tickTimer; + long? lastDataFetchTimestamp = null; while (await timer!.WaitForNextTickAsync()) { - _instrument = GetInstrument(); - if (_instrument == null || PauseManager.AreMetricsPaused(out _)) + if (lastDataFetchTimestamp is null || Stopwatch.GetElapsedTime(lastDataFetchTimestamp.Value) >= s_dataFetchInterval) { - continue; - } + _instrument = GetInstrument(); + lastDataFetchTimestamp = Stopwatch.GetTimestamp(); - if (_instrument.Dimensions.Count > _renderedDimensionsCount) - { - // Re-render the entire control if the number of dimensions has changed. - _renderedDimensionsCount = _instrument.Dimensions.Count; - await InvokeAsync(StateHasChanged); + if (_instrument is not null && HaveDimensionFilterValuesChanged(_instrument)) + { + await InvokeAsync(() => + { + UpdateDimensionFilters(hasInstrumentChanged: false); + StateHasChanged(); + }); + + // The updated filters can automatically select newly discovered values. + // Refetch so those values are included in the data displayed by this tick. + _instrument = GetInstrument(); + } } - else + + if (_instrument == null || PauseManager.AreMetricsPaused(out _)) { - await UpdateInstrumentDataAsync(_instrument); + continue; } + + await UpdateInstrumentDataAsync(_instrument); } } public async Task DimensionValuesChangedAsync(DimensionFilterViewModel dimensionViewModel) { - if (_instrument == null) + _instrument = GetInstrument(); + if (_instrument is null) { return; } @@ -134,42 +144,22 @@ public async Task DimensionValuesChangedAsync(DimensionFilterViewModel dimension private async Task UpdateInstrumentDataAsync(OtlpInstrumentData instrument) { - var matchedDimensions = instrument.Dimensions.Where(MatchDimension).ToList(); - // Only update data in plotly - await _instrumentViewModel.UpdateDataAsync(instrument.Summary, matchedDimensions); + await _instrumentViewModel.UpdateDataAsync(instrument.Summary, instrument.Dimensions); } - private bool MatchDimension(DimensionScope dimension) + private async Task ShowCountChangedAsync(bool showCount) { - foreach (var dimensionFilter in DimensionFilters) + if (_instrumentViewModel.ShowCount == showCount) { - if (!MatchFilter(dimension.Attributes, dimensionFilter)) - { - return false; - } - } - return true; - } - - private static bool MatchFilter(KeyValuePair[] attributes, DimensionFilterViewModel filter) - { - // No filter selected. - if (filter.SelectedValues.Count == 0) - { - return false; + return; } - var value = OtlpHelpers.GetValue(attributes, filter.Name); - foreach (var item in filter.SelectedValues) + _instrumentViewModel.ShowCount = showCount; + if (_instrument is not null) { - if (item.Value == value) - { - return true; - } + await UpdateInstrumentDataAsync(_instrument); } - - return false; } protected override async Task OnParametersSetAsync() @@ -185,8 +175,7 @@ protected override async Task OnParametersSetAsync() PreviousMeterName = MeterName; PreviousInstrumentName = InstrumentName; - // Replace filters collection on change. Filters can be accessed from a background task so it is immutable for thread safety. - DimensionFilters = ImmutableList.Create(CollectionsMarshal.AsSpan(CreateUpdatedFilters(hasInstrumentChanged))); + UpdateDimensionFilters(hasInstrumentChanged); await UpdateInstrumentDataAsync(_instrument); } @@ -194,7 +183,7 @@ protected override async Task OnParametersSetAsync() private OtlpInstrumentData? GetInstrument() { DateTime endDate; - if (DashboardClient.IsReadOnly) + if (TelemetryRepository.IsReadOnly) { EnsureDataEndTime(); endDate = _dataEndTime?.UtcDateTime ?? DateTime.UtcNow; @@ -217,6 +206,9 @@ protected override async Task OnParametersSetAsync() InstrumentName = InstrumentName, StartTime = startDate, EndTime = endDate, + DimensionFilters = DimensionFilters.ToDictionary( + filter => filter.Name, + filter => (IReadOnlyList)filter.SelectedValues.Select(value => value.Value).ToArray()) }); if (instrument == null) @@ -317,6 +309,74 @@ private List CreateUpdatedFilters(bool hasInstrumentCh return filters; } + private bool UpdateDimensionFilters(bool hasInstrumentChanged) + { + var updatedFilters = ImmutableList.Create(CollectionsMarshal.AsSpan(CreateUpdatedFilters(hasInstrumentChanged))); + if (HaveSameDimensionFilterContent(DimensionFilters, updatedFilters)) + { + return false; + } + + // Filters can be accessed from a background task, so replace the immutable collection atomically. + DimensionFilters = updatedFilters; + return true; + } + + private bool HaveDimensionFilterValuesChanged(OtlpInstrumentData instrument) + { + if (instrument.KnownAttributeValues.Count != DimensionFilters.Count) + { + return true; + } + + var index = 0; + foreach (var attribute in instrument.KnownAttributeValues.OrderBy(attribute => attribute.Key)) + { + var filter = DimensionFilters[index++]; + if (filter.Name != attribute.Key || + !filter.Values.Select(value => value.Value).SequenceEqual(attribute.Value)) + { + return true; + } + } + + return false; + } + + private static bool HaveSameDimensionFilterContent( + ImmutableList currentFilters, + ImmutableList updatedFilters) + { + if (currentFilters.Count != updatedFilters.Count) + { + return false; + } + + for (var filterIndex = 0; filterIndex < currentFilters.Count; filterIndex++) + { + var currentFilter = currentFilters[filterIndex]; + var updatedFilter = updatedFilters[filterIndex]; + if (currentFilter.Name != updatedFilter.Name || currentFilter.Values.Count != updatedFilter.Values.Count) + { + return false; + } + + for (var valueIndex = 0; valueIndex < currentFilter.Values.Count; valueIndex++) + { + var currentValue = currentFilter.Values[valueIndex]; + var updatedValue = updatedFilter.Values[valueIndex]; + if (currentValue.Text != updatedValue.Text || + currentValue.Value != updatedValue.Value || + currentFilter.SelectedValues.Contains(currentValue) != updatedFilter.SelectedValues.Contains(updatedValue)) + { + return false; + } + } + } + + return true; + } + private Task OnTabChangeAsync(FluentTab newTab) { var id = newTab.Id?.Substring("tab-".Length); diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor index c296a4ad120..d1b39955bdd 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor @@ -25,15 +25,15 @@
} - @if (Instrument.Summary.Type == OtlpInstrumentType.Histogram) + @if (InstrumentType == OtlpInstrumentType.Histogram) {
@Loc[nameof(ControlsStrings.ChartContainerOptionsHeader)]
+ Value="@ShowCount" + ValueChanged="@OnShowCountChangedAsync"/>
} diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs index ce253c4c755..fa743e3e9d1 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs @@ -11,10 +11,13 @@ namespace Aspire.Dashboard.Components; public partial class ChartFilters { [Parameter, EditorRequired] - public required OtlpInstrumentData Instrument { get; set; } + public required OtlpInstrumentType InstrumentType { get; set; } [Parameter, EditorRequired] - public required InstrumentViewModel InstrumentViewModel { get; set; } + public required bool ShowCount { get; set; } + + [Parameter] + public EventCallback ShowCountChanged { get; set; } [Parameter, EditorRequired] public required ImmutableList DimensionFilters { get; set; } @@ -22,19 +25,5 @@ public partial class ChartFilters [Parameter] public EventCallback OnDimensionValuesChanged { get; set; } - public bool ShowCounts { get; set; } - - protected override void OnInitialized() - { - InstrumentViewModel.DataUpdateSubscriptions.Add(() => - { - ShowCounts = InstrumentViewModel.ShowCount; - return Task.CompletedTask; - }); - } - - private void ShowCountChanged() - { - InstrumentViewModel.ShowCount = ShowCounts; - } + private Task OnShowCountChangedAsync(bool value) => ShowCountChanged.InvokeAsync(value); } diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs b/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs index a8dd465eaa6..a3a8984c06a 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs @@ -3,11 +3,38 @@ namespace Aspire.Dashboard.Otlp.Storage; +/// +/// Specifies the instrument data to retrieve from a telemetry repository. +/// public sealed class GetInstrumentRequest { + /// + /// Gets the instrument name. + /// public required string InstrumentName { get; init; } + + /// + /// Gets the resource that emitted the instrument data. + /// public required ResourceKey ResourceKey { get; init; } + + /// + /// Gets the meter name. + /// public required string MeterName { get; init; } + + /// + /// Gets the beginning of the time range to retrieve. + /// public DateTime? StartTime { get; init; } + + /// + /// Gets the end of the time range to retrieve. + /// public DateTime? EndTime { get; init; } + + /// + /// Gets the dimension values to retrieve, keyed by dimension name. An empty dictionary retrieves all dimensions. + /// + public IReadOnlyDictionary> DimensionFilters { get; init; } = new Dictionary>(); } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs index 7d031bed060..c0d32e70708 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs @@ -25,6 +25,30 @@ public sealed partial class SqliteTelemetryRepository private const int HistogramPointType = 3; private const int MaxMetricPointBatchSize = 100; private const string MetricPointRangeFilterSql = "(@ApplyRange = 0 OR (p.start_time_ticks <= @EndTicks AND p.end_time_ticks >= @StartTicks) OR (p.start_time_ticks >= @StartTicks AND p.end_time_ticks <= @EndTicks))"; + private const string SelectedMetricPointsCteSql = $""" + selected_metric_points AS ( + SELECT p.* + FROM telemetry_metric_points p + WHERE p.dimension_id IN @DimensionIds + AND {MetricPointRangeFilterSql} + ) + """; + private const string EffectiveMetricPointsCteSql = $""" + {SelectedMetricPointsCteSql}, + ranked_metric_points AS ( + SELECT + p.*, + ROW_NUMBER() OVER ( + PARTITION BY p.start_time_ticks, p.dimension_id + ORDER BY p.point_id DESC) AS point_rank + FROM selected_metric_points p + ), + effective_metric_points AS ( + SELECT * + FROM ranked_metric_points + WHERE point_rank = 1 + ) + """; private readonly MetricIngestionState _metricIngestionState = new(); @@ -259,7 +283,7 @@ private void AddHistogramMetricPoint( var latest = dimension.LatestPoint; var latestPointType = pendingLatest?.PointType ?? latest?.PointType; var latestEndTimeTicks = pendingLatest?.EndTimeTicks ?? latest?.EndTimeTicks; - var histogramCount = point.Count.ToString(CultureInfo.InvariantCulture); + var histogramCount = checked((long)point.Count); var sameCount = latestPointType == HistogramPointType && (pendingLatest?.HistogramCount ?? latest?.HistogramCount) == histogramCount; var endTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks; @@ -297,7 +321,7 @@ private void AddHistogramMetricPoint( HistogramSum = point.Sum, HistogramCount = histogramCount, Flags = (long)point.Flags, - HistogramBucketCounts = point.BucketCounts.Select(count => count.ToString(CultureInfo.InvariantCulture)).ToArray(), + HistogramBucketCounts = point.BucketCounts.Select(count => checked((long)count)).ToArray(), HistogramExplicitBounds = point.ExplicitBounds.ToArray() }; pendingPoint.Exemplars.AddRange(point.Exemplars); @@ -774,42 +798,19 @@ WHERE point_rank > @MaxMetricsCount } using var connection = _database.OpenConnection(); - var dimensions = new List(); var knownAttributeValues = new Dictionary>(); - foreach (var instrument in instruments) - { - foreach (var dimension in MaterializeMetricDimensions( - connection, - instrument.InstrumentId, - instrument.Summary.Type == OtlpInstrumentType.Histogram, - request.StartTime, - request.EndTime)) - { - var isFirst = dimensions.Count == 0; - foreach (var key in knownAttributeValues.Keys.Union(dimension.Attributes.Select(attribute => attribute.Key)).Distinct().ToList()) - { - if (!knownAttributeValues.TryGetValue(key, out var values)) - { - values = []; - knownAttributeValues.Add(key, values); - if (!isFirst) - { - values.Add(null); - } - } - var value = OtlpHelpers.GetValue(dimension.Attributes, key); - if (!values.Contains(value)) - { - values.Add(value); - } - } - dimensions.Add(dimension); - } - } + var dimension = MaterializeMetricDimension( + connection, + instruments.Select(instrument => instrument.InstrumentId).ToArray(), + instruments[0].Summary.Type == OtlpInstrumentType.Histogram, + request.StartTime, + request.EndTime, + request.DimensionFilters, + knownAttributeValues); return new OtlpInstrumentData { Summary = instruments[0].Summary, - Dimensions = dimensions, + Dimensions = [dimension], KnownAttributeValues = knownAttributeValues, HasOverflow = instruments.Any(instrument => instrument.HasOverflow) }; @@ -870,82 +871,136 @@ FROM telemetry_metric_points p return instrument; } - private List MaterializeMetricDimensions( + private DimensionScope MaterializeMetricDimension( SqliteConnection connection, - long instrumentId, + IReadOnlyList instrumentIds, bool isHistogram, DateTime? startTime, - DateTime? endTime) + DateTime? endTime, + IReadOnlyDictionary> dimensionFilters, + Dictionary> knownAttributeValues) { - var dimensionIds = connection.Query("SELECT dimension_id FROM telemetry_metric_dimensions WHERE instrument_id = @InstrumentId ORDER BY dimension_id;", new { InstrumentId = instrumentId }).AsList(); - var queryParameters = new DynamicParameters(); - queryParameters.Add("DimensionIds", dimensionIds); - queryParameters.Add("ApplyRange", startTime is not null && endTime is not null); - queryParameters.Add("StartTicks", startTime?.Ticks ?? 0); - queryParameters.Add("EndTicks", endTime?.Ticks ?? 0); + var dimensionIds = connection.Query("SELECT dimension_id FROM telemetry_metric_dimensions WHERE instrument_id IN @InstrumentIds ORDER BY dimension_id;", new { InstrumentIds = instrumentIds }).AsList(); var attributes = connection.Query(""" SELECT dimension_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue FROM telemetry_metric_dimension_attributes WHERE dimension_id IN @DimensionIds ORDER BY dimension_id, ordinal; - """, queryParameters).ToLookup(record => record.OwnerId); + """, new { DimensionIds = dimensionIds }).ToLookup(record => record.OwnerId); + PopulateKnownAttributeValues(dimensionIds, attributes, knownAttributeValues); + + var selectedDimensionIds = dimensionIds + .Where(dimensionId => MatchesDimensionFilters(attributes[dimensionId], dimensionFilters)) + .ToArray(); + var queryParameters = new DynamicParameters(); + queryParameters.Add("DimensionIds", selectedDimensionIds); + queryParameters.Add("ApplyRange", startTime is not null && endTime is not null); + queryParameters.Add("StartTicks", startTime?.Ticks ?? 0); + queryParameters.Add("EndTicks", endTime?.Ticks ?? 0); var points = connection.Query($""" + WITH {EffectiveMetricPointsCteSql}, + aggregated_metric_points AS ( + SELECT + p.start_time_ticks AS PointId, + p.point_type AS PointType, + p.start_time_ticks AS StartTimeTicks, + MAX(p.end_time_ticks) AS EndTimeTicks, + MAX(p.repeat_count) AS RepeatCount, + SUM(p.integer_value) AS IntegerValue, + SUM(p.double_value) AS DoubleValue, + SUM(p.histogram_sum) AS HistogramSum, + SUM(p.histogram_count) AS HistogramCount + FROM effective_metric_points p + GROUP BY p.point_type, p.start_time_ticks + ) SELECT - p.point_id AS PointId, - p.dimension_id AS DimensionId, - p.point_type AS PointType, - p.start_time_ticks AS StartTimeTicks, - p.end_time_ticks AS EndTimeTicks, - p.repeat_count AS RepeatCount, - p.integer_value AS IntegerValue, - p.double_value AS DoubleValue, - p.histogram_sum AS HistogramSum, - p.histogram_count AS HistogramCount - FROM telemetry_metric_points p - WHERE p.dimension_id IN @DimensionIds - AND {MetricPointRangeFilterSql} - ORDER BY p.dimension_id, p.point_id; - """, queryParameters).ToLookup(record => record.DimensionId); + p.PointId, + 0 AS DimensionId, + p.PointType, + p.StartTimeTicks, + p.EndTimeTicks, + p.RepeatCount, + p.IntegerValue, + p.DoubleValue, + p.HistogramSum, + p.HistogramCount + FROM aggregated_metric_points p + ORDER BY p.StartTimeTicks; + """, queryParameters).AsList(); var (bucketCounts, explicitBounds) = isHistogram ? MaterializeMetricHistogramData(connection, queryParameters) : (Array.Empty().ToLookup(record => record.PointId), Array.Empty().ToLookup(record => record.PointId)); var exemplars = MaterializeMetricExemplars(connection, queryParameters); - var results = new List(dimensionIds.Count); - foreach (var dimensionId in dimensionIds) + var result = new DimensionScope(_otlpContext.Options.MaxMetricsCount, []); + if (startTime is not null && endTime is not null) { - var dimension = new DimensionScope( - _otlpContext.Options.MaxMetricsCount, - attributes[dimensionId].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray()); - if (startTime is not null && endTime is not null) + foreach (var point in points) { - foreach (var point in points[dimensionId]) + MetricValueBase value = point.PointType switch { - MetricValueBase value = point.PointType switch - { - LongPointType => new MetricValue(point.IntegerValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), - DoublePointType => new MetricValue(point.DoubleValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), - HistogramPointType => new HistogramValue( - bucketCounts[point.PointId].Select(bucket => ulong.Parse(bucket.BucketCount, CultureInfo.InvariantCulture)).ToArray(), - point.HistogramSum!.Value, - ulong.Parse(point.HistogramCount!, CultureInfo.InvariantCulture), - new DateTime(point.StartTimeTicks, DateTimeKind.Utc), - new DateTime(point.EndTimeTicks, DateTimeKind.Utc), - explicitBounds[point.PointId].Select(bound => bound.ExplicitBound).ToArray()), - _ => throw new InvalidOperationException($"Unknown metric point type '{point.PointType}'.") - }; - if (point.PointType != HistogramPointType) + LongPointType => new MetricValue(point.IntegerValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), + DoublePointType => new MetricValue(point.DoubleValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), + HistogramPointType => new HistogramValue( + bucketCounts[point.PointId].Select(bucket => checked((ulong)bucket.BucketCount)).ToArray(), + point.HistogramSum!.Value, + checked((ulong)point.HistogramCount!.Value), + new DateTime(point.StartTimeTicks, DateTimeKind.Utc), + new DateTime(point.EndTimeTicks, DateTimeKind.Utc), + explicitBounds[point.PointId].Select(bound => bound.ExplicitBound).ToArray()), + _ => throw new InvalidOperationException($"Unknown metric point type '{point.PointType}'.") + }; + if (point.PointType != HistogramPointType) + { + value.Count = checked((ulong)point.RepeatCount); + } + value.Exemplars.AddRange(exemplars[point.PointId]); + result.Values.Add(value); + } + } + return result; + } + + private static bool MatchesDimensionFilters(IEnumerable attributes, IReadOnlyDictionary> dimensionFilters) + { + foreach (var (key, values) in dimensionFilters) + { + var value = attributes.FirstOrDefault(attribute => attribute.AttributeKey == key)?.AttributeValue; + if (!values.Contains(value)) + { + return false; + } + } + return true; + } + + private static void PopulateKnownAttributeValues( + IReadOnlyList dimensionIds, + ILookup attributes, + Dictionary> knownAttributeValues) + { + for (var dimensionIndex = 0; dimensionIndex < dimensionIds.Count; dimensionIndex++) + { + var dimensionId = dimensionIds[dimensionIndex]; + foreach (var key in knownAttributeValues.Keys.Union(attributes[dimensionId].Select(attribute => attribute.AttributeKey)).Distinct().ToList()) + { + if (!knownAttributeValues.TryGetValue(key, out var values)) + { + values = []; + knownAttributeValues.Add(key, values); + if (dimensionIndex > 0) { - value.Count = checked((ulong)point.RepeatCount); + values.Add(null); } - value.Exemplars.AddRange(exemplars[point.PointId]); - dimension.Values.Add(value); + } + var value = attributes[dimensionId].FirstOrDefault(attribute => attribute.AttributeKey == key)?.AttributeValue; + if (!values.Contains(value)) + { + values.Add(value); } } - results.Add(dimension); } - return results; } private static (ILookup BucketCounts, ILookup ExplicitBounds) MaterializeMetricHistogramData( @@ -953,20 +1008,24 @@ private static (ILookup BucketCounts, ILookup DynamicParameters queryParameters) { var bucketCounts = connection.Query($""" - SELECT b.point_id AS PointId, b.bucket_count AS BucketCount + WITH {EffectiveMetricPointsCteSql} + SELECT + p.start_time_ticks AS PointId, + SUM(b.bucket_count) AS BucketCount FROM telemetry_metric_histogram_bucket_counts b - JOIN telemetry_metric_points p ON p.point_id = b.point_id - WHERE p.dimension_id IN @DimensionIds - AND {MetricPointRangeFilterSql} - ORDER BY b.point_id, b.ordinal; + JOIN effective_metric_points p ON p.point_id = b.point_id + GROUP BY p.start_time_ticks, b.ordinal + ORDER BY p.start_time_ticks, b.ordinal; """, queryParameters).ToLookup(record => record.PointId); var explicitBounds = connection.Query($""" - SELECT b.point_id AS PointId, b.explicit_bound AS ExplicitBound + WITH {EffectiveMetricPointsCteSql} + SELECT + p.start_time_ticks AS PointId, + MAX(b.explicit_bound) AS ExplicitBound FROM telemetry_metric_histogram_explicit_bounds b - JOIN telemetry_metric_points p ON p.point_id = b.point_id - WHERE p.dimension_id IN @DimensionIds - AND {MetricPointRangeFilterSql} - ORDER BY b.point_id, b.ordinal; + JOIN effective_metric_points p ON p.point_id = b.point_id + GROUP BY p.start_time_ticks, b.ordinal + ORDER BY p.start_time_ticks, b.ordinal; """, queryParameters).ToLookup(record => record.PointId); return (bucketCounts, explicitBounds); @@ -977,26 +1036,24 @@ private static ILookup MaterializeMetricExemplars( DynamicParameters queryParameters) { var records = connection.Query($""" + WITH {SelectedMetricPointsCteSql} SELECT e.exemplar_id AS ExemplarId, - e.point_id AS PointId, + p.start_time_ticks AS PointId, e.start_time_ticks AS StartTimeTicks, e.exemplar_value AS ExemplarValue, e.span_id AS SpanId, e.trace_id AS TraceId FROM telemetry_metric_exemplars e - JOIN telemetry_metric_points p ON p.point_id = e.point_id - WHERE p.dimension_id IN @DimensionIds - AND {MetricPointRangeFilterSql} - ORDER BY e.point_id, e.exemplar_id; + JOIN selected_metric_points p ON p.point_id = e.point_id + ORDER BY p.start_time_ticks, e.exemplar_id; """, queryParameters).AsList(); var attributes = connection.Query($""" + WITH {SelectedMetricPointsCteSql} SELECT a.exemplar_id AS OwnerId, a.attribute_key AS AttributeKey, a.attribute_value AS AttributeValue FROM telemetry_metric_exemplar_attributes a JOIN telemetry_metric_exemplars e ON e.exemplar_id = a.exemplar_id - JOIN telemetry_metric_points p ON p.point_id = e.point_id - WHERE p.dimension_id IN @DimensionIds - AND {MetricPointRangeFilterSql} + JOIN selected_metric_points p ON p.point_id = e.point_id ORDER BY a.exemplar_id, a.ordinal; """, queryParameters).ToLookup(record => record.OwnerId); return records.Select(record => new KeyValuePair(record.PointId, new MetricsExemplar @@ -1164,7 +1221,7 @@ private sealed class PendingMetricExemplar private sealed record PendingMetricExemplarAttribute(long ExemplarId, int Ordinal, string Key, string Value); - private sealed record PendingHistogramBucketCount(long PointId, int Ordinal, string BucketCount); + private sealed record PendingHistogramBucketCount(long PointId, int Ordinal, long BucketCount); private sealed record PendingHistogramExplicitBound(long PointId, int Ordinal, double ExplicitBound); @@ -1194,11 +1251,11 @@ private sealed class PendingMetricPoint public long? IntegerValue { get; init; } public double? DoubleValue { get; init; } public double? HistogramSum { get; init; } - public string? HistogramCount { get; init; } + public long? HistogramCount { get; init; } public required long Flags { get; init; } public long PointId { get; set; } public int SourcePointCount { get; set; } = 1; - public string[]? HistogramBucketCounts { get; init; } + public long[]? HistogramBucketCounts { get; init; } public double[]? HistogramExplicitBounds { get; init; } public List Exemplars { get; } = []; } @@ -1213,7 +1270,7 @@ private sealed class MetricDimensionStateRecord public long? EndTimeTicks { get; init; } public long? IntegerValue { get; init; } public double? DoubleValue { get; init; } - public string? HistogramCount { get; init; } + public long? HistogramCount { get; init; } } private class MetricPointRecord @@ -1223,7 +1280,7 @@ private class MetricPointRecord public required long EndTimeTicks { get; set; } public long? IntegerValue { get; init; } public double? DoubleValue { get; init; } - public string? HistogramCount { get; init; } + public long? HistogramCount { get; init; } } private sealed class MetricPointDataRecord : MetricPointRecord @@ -1237,7 +1294,7 @@ private sealed class MetricPointDataRecord : MetricPointRecord private sealed class MetricHistogramBucketRecord { public required long PointId { get; init; } - public required string BucketCount { get; init; } + public required long BucketCount { get; init; } } private sealed class MetricHistogramBoundRecord diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index a0640c0d171..cf274655846 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -15,7 +15,7 @@ public sealed class DashboardSqliteDatabase : IDisposable { private const string SchemaResourcePrefix = "Aspire.Dashboard.ServiceClient.DatabaseSchema."; - internal const int SchemaVersion = 12; + internal const int SchemaVersion = 13; private static readonly Lazy> s_schemaScripts = new(LoadSchemaScripts); diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql index b9cc6b3fded..0a4e4786e29 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql @@ -39,14 +39,14 @@ CREATE TABLE IF NOT EXISTS telemetry_metric_points ( integer_value INTEGER NULL, double_value REAL NULL, histogram_sum REAL NULL, - histogram_count TEXT NULL, + histogram_count INTEGER NULL, flags INTEGER NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS telemetry_metric_histogram_bucket_counts ( point_id INTEGER NOT NULL REFERENCES telemetry_metric_points(point_id) ON DELETE CASCADE, ordinal INTEGER NOT NULL, - bucket_count TEXT NOT NULL, + bucket_count INTEGER NOT NULL, PRIMARY KEY (point_id, ordinal) ) STRICT; diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ChartFiltersTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ChartFiltersTests.cs index e8d80c8e2b4..262b6389eee 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ChartFiltersTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ChartFiltersTests.cs @@ -138,12 +138,14 @@ public void GetOrderedValues_TextValues_OrdersAlphabetically() Assert.Equal(["DELETE", "GET", "POST"], ordered); } - private IRenderedComponent RenderChartFilters(DimensionFilterViewModel dimensionFilter, Action? onDimensionValuesChanged = null) + private IRenderedComponent RenderChartFilters( + DimensionFilterViewModel dimensionFilter, + Action? onDimensionValuesChanged = null) { return RenderComponent(builder => { - builder.Add(p => p.Instrument, CreateInstrument()); - builder.Add(p => p.InstrumentViewModel, new InstrumentViewModel()); + builder.Add(p => p.InstrumentType, OtlpInstrumentType.Sum); + builder.Add(p => p.ShowCount, false); builder.Add(p => p.DimensionFilters, [dimensionFilter]); if (onDimensionValuesChanged is not null) { @@ -171,22 +173,4 @@ private static DimensionFilterViewModel CreateDimensionFilter() return dimensionFilter; } - private static OtlpInstrumentData CreateInstrument() - { - return new OtlpInstrumentData - { - Summary = new OtlpInstrumentSummary - { - Name = "request-duration", - Description = string.Empty, - Unit = "ms", - Type = OtlpInstrumentType.Sum, - AggregationTemporality = OtlpAggregationTemporality.Cumulative, - Parent = new OtlpScope("meter", string.Empty, []) - }, - Dimensions = [], - KnownAttributeValues = [], - HasOverflow = false - }; - } } diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs index 3481ff2045c..530d699992d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs @@ -50,6 +50,56 @@ public void ChangeResource_MeterAndInstrumentNotOnNewResources_InstrumentCleared expectedInstrumentNameAfterChange: null); } + [Fact] + public void ChartContainer_UnchangedDimensionFilters_PreservesFilterParameters() + { + MetricsSetupHelpers.SetupMetricsPage(this); + + var telemetryRepository = Services.GetRequiredService(); + telemetryRepository.AddMetrics(new AddContext(), new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(name: "TestApp"), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateSumMetric( + metricName: "test-instrument", + startTime: s_testTime.AddMinutes(1), + attributes: [new KeyValuePair("http.method", "GET")]) + } + } + } + } + }); + telemetryRepository.MakeReadOnly(); + var resource = telemetryRepository.GetResources().Single(); + + var cut = RenderComponent(builder => + { + builder.Add(component => component.ResourceKey, resource.ResourceKey); + builder.Add(component => component.MeterName, "test-meter"); + builder.Add(component => component.InstrumentName, "test-instrument"); + builder.Add(component => component.Duration, TimeSpan.FromMinutes(5)); + builder.Add(component => component.ActiveView, MetricViewKind.Graph); + builder.Add(component => component.OnViewChangedAsync, _ => Task.CompletedTask); + builder.Add(component => component.Resources, [resource]); + builder.Add(component => component.PauseText, null); + }); + var dimensionFilters = cut.Instance.DimensionFilters; + var dimensionFilter = Assert.Single(dimensionFilters); + + cut.SetParametersAndRender(builder => builder.Add(component => component.Duration, TimeSpan.FromMinutes(5))); + + Assert.Same(dimensionFilters, cut.Instance.DimensionFilters); + Assert.Same(dimensionFilter, Assert.Single(cut.Instance.DimensionFilters)); + } + [Fact] public async Task InitialLoad_SingleResource_RedirectToResource() { @@ -286,7 +336,6 @@ public void MetricsTree_MetricsAdded_TreeUpdated() public void ReadOnly_ChartEndsAtLatestMetricTime() { MetricsSetupHelpers.SetupMetricsPage(this); - Services.AddSingleton(new TestDashboardClient(isReadOnly: true)); var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddMetrics(new AddContext(), new RepeatedField @@ -308,6 +357,7 @@ public void ReadOnly_ChartEndsAtLatestMetricTime() } } }); + telemetryRepository.MakeReadOnly(); Services.GetRequiredService().SetMetricsPaused(true); Services.GetRequiredService().NavigateTo( DashboardUrls.MetricsUrl(resource: "TestApp", meter: "test-meter", instrument: "test-instrument", duration: 5, view: MetricViewKind.Graph.ToString())); diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs index 4f9755527ad..1be1ed44f7b 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs @@ -16,6 +16,20 @@ public sealed class DashboardSqliteDatabaseTests(ITestOutputHelper testOutputHel { private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(testOutputHelper); + [Fact] + public void InitializeSchema_HistogramCountsUseIntegerStorage() + { + using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false); + database.InitializeSchema(); + using var connection = database.OpenConnection(); + + var histogramCountColumnType = connection.QuerySingle("SELECT type FROM pragma_table_info('telemetry_metric_points') WHERE name = 'histogram_count';"); + var bucketCountColumnType = connection.QuerySingle("SELECT type FROM pragma_table_info('telemetry_metric_histogram_bucket_counts') WHERE name = 'bucket_count';"); + + Assert.Equal("INTEGER", histogramCountColumnType); + Assert.Equal("INTEGER", bucketCountColumnType); + } + [Fact] public async Task DapperQuery_CreatesActivityWithQueryInformation() { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index 581c24cc966..534fe61f9a4 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -3,7 +3,6 @@ using System.Collections.Concurrent; using System.Diagnostics; -using System.Runtime.InteropServices; using System.Text; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.MetricValues; @@ -188,33 +187,8 @@ public void AddMetrics_MeterAttributeLimits_LimitsApplied() }); var dimensionAttributes = instrument.Dimensions.Single().Attributes; - - Assert.Collection(dimensionAttributes, - p => - { - Assert.Equal("Meter_Key0", p.Key); - Assert.Equal("01234", p.Value); - }, - p => - { - Assert.Equal("Meter_Key1", p.Key); - Assert.Equal("0123456789", p.Value); - }, - p => - { - Assert.Equal("Meter_Key2", p.Key); - Assert.Equal("012345678901234", p.Value); - }, - p => - { - Assert.Equal("Meter_Key3", p.Key); - Assert.Equal("0123456789012345", p.Value); - }, - p => - { - Assert.Equal("Meter_Key4", p.Key); - Assert.Equal("0123456789012345", p.Value); - }); + Assert.Empty(dimensionAttributes); + Assert.Equal(5, instrument.KnownAttributeValues.Count); } [Fact] @@ -284,33 +258,8 @@ public void AddMetrics_MetricAttributeLimits_LimitsApplied() }); var dimensionAttributes = instrument.Dimensions.Single().Attributes; - - Assert.Collection(dimensionAttributes, - p => - { - Assert.Equal("Meter_Key0", p.Key); - Assert.Equal("01234", p.Value); - }, - p => - { - Assert.Equal("Metric_Key0", p.Key); - Assert.Equal("01234", p.Value); - }, - p => - { - Assert.Equal("Metric_Key1", p.Key); - Assert.Equal("0123456789", p.Value); - }, - p => - { - Assert.Equal("Metric_Key2", p.Key); - Assert.Equal("012345678901234", p.Value); - }, - p => - { - Assert.Equal("Metric_Key3", p.Key); - Assert.Equal("0123456789012345", p.Value); - }); + Assert.Empty(dimensionAttributes); + Assert.Equal(5, instrument.KnownAttributeValues.Count); } [Fact] @@ -392,21 +341,91 @@ public void GetInstrument() Assert.Equal(new[] { null, "value1", "" }, e.Value); }); - Assert.Equal(5, instrumentData.Dimensions.Count); - - var dimension = instrumentData.Dimensions.Single(d => d.Attributes.Length == 0); - var exemplar = Assert.Single(dimension.Values[0].Exemplars); + var dimension = Assert.Single(instrumentData.Dimensions); + Assert.Empty(dimension.Attributes); + Assert.Equal(5, Assert.IsType>(Assert.Single(dimension.Values)).Value); + var exemplar = Assert.Single(dimension.Values.SelectMany(value => value.Exemplars)); Assert.Equal("key1", exemplar.Attributes[0].Key); Assert.Equal("value1", exemplar.Attributes[0].Value); - var instrument = resources.Single().GetInstrument("test-meter", "test", s_testTime.AddMinutes(1), s_testTime.AddMinutes(1.5)); - Assert.NotNull(instrument); + var filteredInstrumentData = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = resources[0].ResourceKey, + InstrumentName = "test", + MeterName = "test-meter", + StartTime = s_testTime.AddMinutes(1), + EndTime = s_testTime.AddMinutes(1.5), + DimensionFilters = new Dictionary> + { + ["key1"] = ["value1"] + } + }); + + Assert.NotNull(filteredInstrumentData); + var filteredDimension = Assert.Single(filteredInstrumentData.Dimensions); + Assert.Empty(filteredDimension.Attributes); + var filteredValue = Assert.IsType>(Assert.Single(filteredDimension.Values)); + Assert.Equal(3, filteredValue.Value); + Assert.Equal(instrumentData.KnownAttributeValues, filteredInstrumentData.KnownAttributeValues); - AssertDimensionValues(instrument.Dimensions, Array.Empty>(), valueCount: 1); - AssertDimensionValues(instrument.Dimensions, new KeyValuePair[] { KeyValuePair.Create("key1", "value1") }, valueCount: 1); - AssertDimensionValues(instrument.Dimensions, new KeyValuePair[] { KeyValuePair.Create("key1", "value2") }, valueCount: 1); - AssertDimensionValues(instrument.Dimensions, new KeyValuePair[] { KeyValuePair.Create("key1", "value1"), KeyValuePair.Create("key2", "value1") }, valueCount: 1); + } + + [Fact] + public void GetInstrument_StaggeredDimensionChanges_AggregatesCurrentValues() + { + var repository = CreateRepository(); + var addContext = new AddContext(); + repository.AsWriter().AddMetrics(addContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateSumMetric(metricName: "test", startTime: s_testTime.AddMinutes(1), value: 10, attributes: [KeyValuePair.Create("dimension", "stable")]), + CreateSumMetric(metricName: "test", startTime: s_testTime.AddMinutes(1), value: 20, attributes: [KeyValuePair.Create("dimension", "changing")]) + } + } + } + }, + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateSumMetric(metricName: "test", startTime: s_testTime.AddMinutes(2), value: 10, attributes: [KeyValuePair.Create("dimension", "stable")]), + CreateSumMetric(metricName: "test", startTime: s_testTime.AddMinutes(2), value: 25, attributes: [KeyValuePair.Create("dimension", "changing")]) + } + } + } + } + }); + + Assert.Equal(0, addContext.FailureCount); + var resource = Assert.Single(repository.GetResources()); + var instrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = resource.ResourceKey, + MeterName = "test-meter", + InstrumentName = "test", + StartTime = s_testTime, + EndTime = s_testTime.AddMinutes(3) + }); + + Assert.NotNull(instrument); + var values = Assert.Single(instrument.Dimensions).Values.Cast>().ToArray(); + Assert.Equal(35, Assert.Single(values).Value); } [Fact] @@ -631,22 +650,9 @@ public void GetMetrics_MultipleInstances() Assert.NotNull(instrument); Assert.Equal("test1", instrument.Summary.Name); - Assert.Collection(instrument.Dimensions.OrderBy(d => d.Name), - d => - { - Assert.Equal(KeyValuePair.Create("key-1", "value-1"), d.Attributes.Single()); - Assert.Equal(1, ((MetricValue)d.Values.Single()).Value); - }, - d => - { - Assert.Equal(KeyValuePair.Create("key-1", "value-2"), d.Attributes.Single()); - Assert.Equal(2, ((MetricValue)d.Values.Single()).Value); - }, - d => - { - Assert.Equal(KeyValuePair.Create("key-1", "value-3"), d.Attributes.Single()); - Assert.Equal(3, ((MetricValue)d.Values.Single()).Value); - }); + var dimension = Assert.Single(instrument.Dimensions); + Assert.Empty(dimension.Attributes); + Assert.Equal(6, Assert.IsType>(Assert.Single(dimension.Values)).Value); var knownValues = Assert.Single(instrument.KnownAttributeValues); Assert.Equal("key-1", knownValues.Key); @@ -819,15 +825,7 @@ public void RemoveMetrics_SelectedResource() Assert.Equal("test1", resource1Test1Instrument.Summary.Name); var resource1Test1Dimensions = Assert.Single(resource1Test1Instrument.Dimensions); - Assert.Collection(resource1Test1Dimensions.Values, - v => - { - Assert.Equal(1, ((MetricValue)v).Value); - }, - v => - { - Assert.Equal(2, ((MetricValue)v).Value); - }); + Assert.Equal(2, Assert.IsType>(Assert.Single(resource1Test1Dimensions.Values)).Value); var resource1Test2Instrument = repository.GetInstrument(new GetInstrumentRequest { @@ -1273,13 +1271,6 @@ public void AddMetrics_NoScope() }); } - private static void AssertDimensionValues(Dictionary>, DimensionScope> dimensions, ReadOnlyMemory> key, int valueCount) - { - var scope = dimensions[key]; - Assert.True(Enumerable.SequenceEqual(MemoryMarshal.ToEnumerable(key), scope.Attributes), "Key and attributes don't match."); - - Assert.Equal(valueCount, scope.Values.Count); - } } public sealed class InMemoryMetricsTests : MetricsTests @@ -1402,11 +1393,9 @@ public void AddMetrics_LargeHistogramAndDimensionAttributeBatchesRoundTrip() StartTime = DateTime.MinValue, EndTime = DateTime.MaxValue }); - var dimensions = sumInstrument!.Dimensions.OrderBy(dimension => dimension.Attributes[0].Value).ToArray(); - Assert.Collection(dimensions, - dimension => Assert.Equal(firstDimensionAttributes.OrderBy(attribute => attribute.Key), dimension.Attributes.OrderBy(attribute => attribute.Key)), - dimension => Assert.Equal(secondDimensionAttributes.OrderBy(attribute => attribute.Key), dimension.Attributes.OrderBy(attribute => attribute.Key))); - Assert.All(dimensions, dimension => Assert.IsType>(Assert.Single(dimension.Values))); + var sumDimension = Assert.Single(sumInstrument!.Dimensions); + Assert.Empty(sumDimension.Attributes); + Assert.Equal(2, Assert.IsType>(Assert.Single(sumDimension.Values)).Value); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs index 44e4f854fcc..a75e78a4d04 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs @@ -275,7 +275,10 @@ public void Metrics_ReopenFromNormalizedRows() EndTime = startTime.AddMinutes(1) }); var dimension = Assert.Single(instrument!.Dimensions); - Assert.Equal(KeyValuePair.Create("route", "/api"), Assert.Single(dimension.Attributes)); + Assert.Empty(dimension.Attributes); + var routeValues = Assert.Single(instrument.KnownAttributeValues); + Assert.Equal("route", routeValues.Key); + Assert.Equal("/api", Assert.Single(routeValues.Value)); Assert.Equal(42, Assert.IsType>(Assert.Single(dimension.Values)).Value); } diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index efbdc1d67b1..763bff75163 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -2181,52 +2181,86 @@ public List GetInstrumentsSummaries(ResourceKey key) { return null; } - else if (instruments.Count == 1) + + var allKnownAttributes = new Dictionary>(); + var matchingDimensions = new List(); + var hasOverflow = false; + foreach (var instrument in instruments) { - var instrument = instruments[0]; - return new OtlpInstrumentData + foreach (var knownAttributeValues in instrument.KnownAttributeValues) { - Summary = instrument.Summary, - KnownAttributeValues = instrument.KnownAttributeValues, - Dimensions = instrument.Dimensions.Values.ToList(), - HasOverflow = instrument.HasOverflow - }; + ref var values = ref CollectionsMarshal.GetValueRefOrAddDefault(allKnownAttributes, knownAttributeValues.Key, out _); + values = values is not null + ? values.Union(knownAttributeValues.Value).ToList() + : knownAttributeValues.Value.ToList(); + } + + matchingDimensions.AddRange(instrument.Dimensions.Values.Where(dimension => MatchesDimensionFilters(dimension.Attributes, request.DimensionFilters))); + hasOverflow = hasOverflow || instrument.HasOverflow; } - else - { - var allDimensions = new List(); - var allKnownAttributes = new Dictionary>(); - var hasOverflow = false; - foreach (var instrument in instruments) - { - allDimensions.AddRange(instrument.Dimensions.Values); + return new OtlpInstrumentData + { + Summary = instruments[0].Summary, + Dimensions = [AggregateDimensions(matchingDimensions)], + KnownAttributeValues = allKnownAttributes, + HasOverflow = hasOverflow + }; + } - foreach (var knownAttributeValues in instrument.KnownAttributeValues) - { - ref var values = ref CollectionsMarshal.GetValueRefOrAddDefault(allKnownAttributes, knownAttributeValues.Key, out _); - // Adds to dictionary if not present. - if (values != null) - { - values = values.Union(knownAttributeValues.Value).ToList(); - } - else - { - values = knownAttributeValues.Value.ToList(); - } - } + private DimensionScope AggregateDimensions(IEnumerable dimensions) + { + var dimensionsList = dimensions.ToList(); + var result = new DimensionScope(_otlpContext.Options.MaxMetricsCount, []); + foreach (var startTime in dimensionsList.SelectMany(dimension => dimension.Values).Select(value => value.Start).Distinct().Order()) + { + var effectiveValues = dimensionsList + .Select(dimension => dimension.Values.LastOrDefault(value => value.Start == startTime)) + .OfType() + .ToList(); + var first = effectiveValues[0]; + MetricValueBase aggregate = first switch + { + MetricValue => new MetricValue(effectiveValues.Cast>().Sum(value => value.Value), startTime, effectiveValues.Max(value => value.End)), + MetricValue => new MetricValue(effectiveValues.Cast>().Sum(value => value.Value), startTime, effectiveValues.Max(value => value.End)), + HistogramValue histogram => new HistogramValue( + effectiveValues.Cast().Select(value => value.Values).Aggregate( + new ulong[histogram.Values.Length], + (totals, values) => + { + for (var index = 0; index < totals.Length; index++) + { + totals[index] += values[index]; + } + return totals; + }), + effectiveValues.Cast().Sum(value => value.Sum), + effectiveValues.Cast().Aggregate(0ul, (count, value) => count + value.Count), + startTime, + effectiveValues.Max(value => value.End), + histogram.ExplicitBounds), + _ => throw new InvalidOperationException($"Unknown metric value type '{first.GetType()}'.") + }; - hasOverflow = hasOverflow || instrument.HasOverflow; - } + aggregate.Count = effectiveValues.Max(value => value.Count); + aggregate.Exemplars.AddRange(dimensionsList.SelectMany(dimension => dimension.Values).Where(value => value.Start == startTime).SelectMany(value => value.Exemplars).Distinct()); + result.Values.Add(aggregate); + } + return result; + } - return new OtlpInstrumentData + private static bool MatchesDimensionFilters( + KeyValuePair[] attributes, + IReadOnlyDictionary> dimensionFilters) + { + foreach (var (key, values) in dimensionFilters) + { + if (!values.Contains(OtlpHelpers.GetValue(attributes, key))) { - Summary = instruments[0].Summary, - Dimensions = allDimensions, - KnownAttributeValues = allKnownAttributes, - HasOverflow = hasOverflow - }; + return false; + } } + return true; } public DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) From 5ebc325c714f2cbe65968099d1c18ab0960ffae7 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 22 Jul 2026 18:46:51 +0800 Subject: [PATCH 63/87] Use SQLite telemetry in dashboard component tests --- .../Controls/Chart/ChartContainer.razor.cs | 8 +-- .../SqliteTelemetryRepository.Runtime.cs | 14 +++++ .../Aspire.Dashboard.Components.Tests.csproj | 4 +- .../Controls/GenAIVisualizerDialogTests.cs | 10 ++-- .../Dialogs/ManageDataDialogTests.cs | 2 +- .../Pages/MetricsTests.cs | 60 ++++++++++++++----- .../Pages/ResourcesTests.cs | 4 +- .../Pages/StructuredLogsTests.cs | 11 +--- .../Pages/TraceDetailsTests.cs | 29 ++++----- .../Pages/TracesTests.cs | 10 +--- .../Shared/FluentUISetupHelpers.cs | 42 ++++++++++++- .../Shared/Telemetry/TelemetryTestHelpers.cs | 8 +++ 12 files changed, 139 insertions(+), 63 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs index 2aae3fc10a4..1a7a170eead 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs @@ -115,10 +115,6 @@ await InvokeAsync(() => UpdateDimensionFilters(hasInstrumentChanged: false); StateHasChanged(); }); - - // The updated filters can automatically select newly discovered values. - // Refetch so those values are included in the data displayed by this tick. - _instrument = GetInstrument(); } } @@ -206,7 +202,9 @@ protected override async Task OnParametersSetAsync() InstrumentName = InstrumentName, StartTime = startDate, EndTime = endDate, - DimensionFilters = DimensionFilters.ToDictionary( + DimensionFilters = DimensionFilters + .Where(filter => filter.AreAllValuesSelected is not true) + .ToDictionary( filter => filter.Name, filter => (IReadOnlyList)filter.SelectedValues.Select(value => value.Value).ToArray()) }); diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs index 0efecd3c5aa..328d9a41df3 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Runtime.cs @@ -31,6 +31,20 @@ internal TimeSpan SubscriptionMinExecuteInterval set => _subscriptionMinExecuteInterval = value; } + /// + /// Gets the number of active trace subscriptions. + /// + internal int TraceSubscriptionCount + { + get + { + lock (_subscriptionLock) + { + return _tracesSubscriptions.Count; + } + } + } + public bool HasDisplayedMaxLogLimitMessage { get; set; } public Message? MaxLogLimitMessage { get; set; } public bool HasDisplayedMaxTraceLimitMessage { get; set; } diff --git a/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj b/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj index 40b2a70805e..62e485c5cdf 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj +++ b/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj @@ -2,6 +2,7 @@ $(DefaultTargetFramework) + $(DefineConstants);ASPIRE_DASHBOARD_COMPONENT_TESTS @@ -29,7 +30,8 @@ - + + diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs index 36c01344ca6..b9b07600ea9 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs @@ -41,7 +41,7 @@ await GenAIVisualizerDialog.OpenDialogAsync( dialogService: dialogService, span: CreateOtlpSpan(resource, trace, scope, spanId: "abc", parentSpanId: null, startDate: s_testTime), selectedLogEntryId: null, - telemetryRepository: Services.GetRequiredService(), + telemetryRepository: Services.GetRequiredService(), errorRecorder: new TestTelemetryErrorRecorder(), resources: [], getContextGenAISpans: () => [] @@ -107,7 +107,7 @@ await GenAIVisualizerDialog.OpenDialogAsync( dialogService: dialogService, span: span, selectedLogEntryId: null, - telemetryRepository: Services.GetRequiredService(), + telemetryRepository: Services.GetRequiredService(), errorRecorder: new TestTelemetryErrorRecorder(), resources: [], getContextGenAISpans: () => [] @@ -129,7 +129,7 @@ public async Task Render_HasGenAIMessages_CopyButtonHasAccessibleName() var span = CreateOtlpSpan(resource, trace, scope, spanId: GetHexId("abc"), parentSpanId: null, startDate: s_testTime); var cut = SetUpDialog(out var dialogService); - var repository = Services.GetRequiredService(); + var repository = Services.GetRequiredService(); repository.AddLogs(new AddContext(), new RepeatedField { new ResourceLogs @@ -177,7 +177,7 @@ public async Task UpdateTelemetry_DifferentTrace_ContentInstanceUnchanged() { // Arrange - Setup dialog infrastructure and repository var cut = SetUpDialog(out var dialogService); - var repository = Services.GetRequiredService(); + var repository = Services.GetRequiredService(); // Add initial trace to repository for the dialog to display var addContext = new AddContext(); @@ -260,7 +260,7 @@ public async Task UpdateTelemetry_SameTrace_ContentInstanceChanged() { // Arrange - Setup dialog infrastructure and repository var cut = SetUpDialog(out var dialogService); - var repository = Services.GetRequiredService(); + var repository = Services.GetRequiredService(); // Add initial trace to repository for the dialog to display var addContext = new AddContext(); diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs index bff5bb02eed..d13e3e45cf3 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs @@ -158,7 +158,7 @@ public async Task Render_ClearedSignals_PrunesSelectionsAndSupportsRemovingEmpty var dashboardClient = new TestDashboardClient(isEnabled: false, initialResources: []); SetupManageDataDialogServices(dashboardClient); - var repository = Services.GetRequiredService(); + var repository = Services.GetRequiredService(); var resourceKey = new ResourceKey("orphan", "instance"); repository.AddLogs(new AddContext(), new RepeatedField { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs index 530d699992d..3b918f2e41b 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs @@ -9,6 +9,7 @@ using Aspire.Dashboard.Extensions; using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Model.MetricValues; using Aspire.Dashboard.Otlp.Storage; using Aspire.Dashboard.Tests.Shared; using Aspire.Dashboard.Utils; @@ -51,11 +52,13 @@ public void ChangeResource_MeterAndInstrumentNotOnNewResources_InstrumentCleared } [Fact] - public void ChartContainer_UnchangedDimensionFilters_PreservesFilterParameters() + public void ChartContainer_AllDimensionsSelected_IncludesNewDimensionInFirstFetch() { + JSInterop.Mode = JSRuntimeMode.Loose; MetricsSetupHelpers.SetupMetricsPage(this); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); + var metricTime = DateTime.UtcNow.AddMinutes(-1); telemetryRepository.AddMetrics(new AddContext(), new RepeatedField { new ResourceMetrics @@ -70,14 +73,14 @@ public void ChartContainer_UnchangedDimensionFilters_PreservesFilterParameters() { CreateSumMetric( metricName: "test-instrument", - startTime: s_testTime.AddMinutes(1), + startTime: metricTime, attributes: [new KeyValuePair("http.method", "GET")]) } } } } }); - telemetryRepository.MakeReadOnly(); + var resource = telemetryRepository.GetResources().Single(); var cut = RenderComponent(builder => @@ -93,11 +96,42 @@ public void ChartContainer_UnchangedDimensionFilters_PreservesFilterParameters() }); var dimensionFilters = cut.Instance.DimensionFilters; var dimensionFilter = Assert.Single(dimensionFilters); + Assert.Equal("GET", Assert.Single(dimensionFilter.SelectedValues).Value); + + telemetryRepository.AddMetrics(new AddContext(), new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(name: "TestApp"), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateSumMetric( + metricName: "test-instrument", + startTime: metricTime, + attributes: [new KeyValuePair("http.method", "POST")]) + } + } + } + } + }); cut.SetParametersAndRender(builder => builder.Add(component => component.Duration, TimeSpan.FromMinutes(5))); - Assert.Same(dimensionFilters, cut.Instance.DimensionFilters); - Assert.Same(dimensionFilter, Assert.Single(cut.Instance.DimensionFilters)); + var updatedFilter = Assert.Single(cut.Instance.DimensionFilters); + Assert.NotSame(dimensionFilters, cut.Instance.DimensionFilters); + Assert.Collection( + updatedFilter.SelectedValues.Select(value => value.Value).Order(), + value => Assert.Equal("GET", value), + value => Assert.Equal("POST", value)); + + var chart = cut.FindComponent(); + var dimension = Assert.Single(chart.Instance.InstrumentViewModel.MatchedDimensions!); + Assert.Equal(2, Assert.IsType>(Assert.Single(dimension.Values)).Value); } [Fact] @@ -115,7 +149,7 @@ public async Task InitialLoad_SingleResource_RedirectToResource() return ValueTask.CompletedTask; }); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddMetrics(new AddContext(), new RepeatedField { new ResourceMetrics @@ -184,7 +218,7 @@ public void InitialLoad_HasSessionState_RedirectUsingState() loadRedirect = new Uri(a.Location); }; - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddMetrics(new AddContext(), new RepeatedField { new ResourceMetrics @@ -242,7 +276,7 @@ public void MetricsTree_MetricsAdded_TreeUpdated() // Arrange MetricsSetupHelpers.SetupMetricsPage(this); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddMetrics(new AddContext(), new RepeatedField { new ResourceMetrics @@ -337,8 +371,7 @@ public void ReadOnly_ChartEndsAtLatestMetricTime() { MetricsSetupHelpers.SetupMetricsPage(this); - var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddMetrics(new AddContext(), new RepeatedField + FluentUISetupHelpers.ConfigureTelemetryRepository(this, readOnly: true, telemetryRepository => telemetryRepository.AddMetrics(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -356,8 +389,7 @@ public void ReadOnly_ChartEndsAtLatestMetricTime() } } } - }); - telemetryRepository.MakeReadOnly(); + })); Services.GetRequiredService().SetMetricsPaused(true); Services.GetRequiredService().NavigateTo( DashboardUrls.MetricsUrl(resource: "TestApp", meter: "test-meter", instrument: "test-instrument", duration: 5, view: MetricViewKind.Graph.ToString())); @@ -386,7 +418,7 @@ private void ChangeResourceAndAssertInstrument(string app1InstrumentName, string var navigationManager = Services.GetRequiredService(); navigationManager.NavigateTo(DashboardUrls.MetricsUrl(resource: "TestApp", meter: "test-meter", instrument: app1InstrumentName, duration: 720, view: MetricViewKind.Table.ToString())); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddMetrics(new AddContext(), new RepeatedField { new ResourceMetrics diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs index 45496f38f04..d612ef66931 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs @@ -519,7 +519,7 @@ public void UnreadLogErrorsBadge_StopsKeyboardPropagation() FluentUISetupHelpers.SetupFluentUIComponents(this); FluentUISetupHelpers.SetupFluentAnchor(this); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); AddErrorLog(telemetryRepository, resourceName: "Resource1"); var unviewedErrorCounts = telemetryRepository.GetResourceUnviewedErrorLogsCount(); var resourceKey = Assert.Single(unviewedErrorCounts.Keys); @@ -571,7 +571,7 @@ private static ResourceViewModel CreateResource( }; } - private static void AddErrorLog(InMemoryTelemetryRepository repository, string resourceName) + private static void AddErrorLog(SqliteTelemetryRepository repository, string resourceName) { var addContext = new AddContext(); var logs = new RepeatedField(); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs index ae75ddd7c16..65aa59ab8a7 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs @@ -36,7 +36,7 @@ public void Render_ResourceInstanceHasDashes_AppKeyResolvedCorrectly() // Arrange SetupStructureLogsServices(); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddLogs(new AddContext(), new RepeatedField { new ResourceLogs @@ -274,8 +274,7 @@ public void Render_AtLogLimit_LimitMessageOnlyDisplayedForLiveRun(bool isReadOnl TelemetryLimits = { MaxLogCount = 1 } })); - var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddLogs(new AddContext(), new RepeatedField + FluentUISetupHelpers.ConfigureTelemetryRepository(this, isReadOnly, telemetryRepository => telemetryRepository.AddLogs(new AddContext(), new RepeatedField { new ResourceLogs { @@ -289,11 +288,7 @@ public void Render_AtLogLimit_LimitMessageOnlyDisplayedForLiveRun(bool isReadOnl } } } - }); - if (isReadOnly) - { - telemetryRepository.MakeReadOnly(); - } + })); var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); Services.GetRequiredService().InvokeOnViewportInformationChanged(viewport); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs index 63f8f0ef7a0..2da20e17ee7 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs @@ -46,7 +46,7 @@ public void Render_HasTrace_SubscriptionRemovedOnDispose() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { new ResourceSpans @@ -76,14 +76,11 @@ public void Render_HasTrace_SubscriptionRemovedOnDispose() }); // Assert - Assert.Collection(telemetryRepository.TracesSubscriptions, t => - { - Assert.Equal(nameof(InMemoryTelemetryRepository.OnNewTraces), t.Name); - }); + Assert.Equal(1, telemetryRepository.TraceSubscriptionCount); DisposeComponents(); - Assert.Empty(telemetryRepository.TracesSubscriptions); + Assert.Equal(0, telemetryRepository.TraceSubscriptionCount); } [Fact] @@ -96,7 +93,7 @@ public void Render_FocusesAccessibleScrollContainerOnInitialRender() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { new ResourceSpans @@ -154,7 +151,7 @@ public async Task Render_ChangeTrace_RowsRendered() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { new ResourceSpans @@ -222,7 +219,7 @@ public async Task Render_TraceUpdateWithNewSpans_RowsRendered() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { new ResourceSpans @@ -304,7 +301,7 @@ public async Task Render_UpdateDifferentTrace_TraceNotUpdated() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { new ResourceSpans @@ -381,7 +378,7 @@ public async Task Render_SpansOrderedByStartTime_RowsRenderedInCorrectOrder() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { @@ -449,7 +446,7 @@ public async Task Render_DurationFilter_FiltersShortSpans() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { @@ -574,7 +571,7 @@ public async Task Render_DurationFilter_LongRoot_DoesNotExposeShortChildren() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { @@ -657,7 +654,7 @@ public void ToggleCollapse_SpanStateChanges() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { @@ -740,7 +737,7 @@ public void CollapseAllSpans_CollapsesAllSpans() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { @@ -815,7 +812,7 @@ public void ExpandAllSpans_ExpandsAllSpans() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var telemetryRepository = Services.GetRequiredService(); + var telemetryRepository = Services.GetRequiredService(); telemetryRepository.AddTraces(new AddContext(), new RepeatedField { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs index 678cabe5a9c..8c0224d5e22 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs @@ -7,7 +7,6 @@ using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; -using Aspire.Dashboard.Otlp.Storage; using Bunit; using Google.Protobuf.Collections; using Microsoft.Extensions.DependencyInjection; @@ -76,8 +75,7 @@ public void Render_AtTraceLimit_LimitMessageOnlyDisplayedForLiveRun(bool isReadO })); var timestamp = DateTime.UnixEpoch; - var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddTraces(new AddContext(), new RepeatedField + FluentUISetupHelpers.ConfigureTelemetryRepository(this, isReadOnly, telemetryRepository => telemetryRepository.AddTraces(new AddContext(), new RepeatedField { new ResourceSpans { @@ -91,11 +89,7 @@ public void Render_AtTraceLimit_LimitMessageOnlyDisplayedForLiveRun(bool isReadO } } } - }); - if (isReadOnly) - { - telemetryRepository.MakeReadOnly(); - } + })); var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); Services.GetRequiredService().InvokeOnViewportInformationChanged(viewport); diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index 57212fd282d..afdedc70c2c 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -11,8 +11,10 @@ using Aspire.Dashboard.Tests.Shared; using Aspire.Dashboard.Telemetry; using Aspire.Dashboard.Tests; +using Aspire.Tests.Utils; using Bunit; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.FluentUI.AspNetCore.Components; @@ -145,6 +147,14 @@ public static void SetupFluentCombobox(TestContext context) comboboxModule.SetupVoid("setControlAttribute", _ => true); } + public static void ConfigureTelemetryRepository( + TestContext context, + bool readOnly, + Action seed) + { + context.Services.AddSingleton(new TelemetryRepositoryConfiguration(readOnly, seed)); + } + public static void AddCommonDashboardServices( TestContext context, ILocalStorage? localStorage = null, @@ -156,9 +166,33 @@ public static void AddCommonDashboardServices( { context.Services.AddLocalization(); context.Services.AddSingleton(browserTimeProvider ?? new TestTimeProvider()); - context.Services.AddSingleton(); - context.Services.AddSingleton(services => services.GetRequiredService()); - context.Services.AddSingleton(services => services.GetRequiredService()); + context.Services.AddSingleton(_ => TemporaryWorkspace.Create( + global::Xunit.TestContext.Current.TestOutputHelper ?? throw new InvalidOperationException("An active test output helper is required."))); + context.Services.AddSingleton(services => + { + var databasePath = Path.Combine(services.GetRequiredService().Path, "dashboard.db"); + var loggerFactory = services.GetRequiredService(); + var options = services.GetRequiredService>(); + var pauseManager = services.GetRequiredService(); + var outgoingPeerResolvers = services.GetServices(); + var configuration = services.GetService(); + + if (configuration is not null) + { + using var writer = new SqliteTelemetryRepository(databasePath, loggerFactory, options, new PauseManager(), outgoingPeerResolvers); + configuration.Seed(writer); + } + + return new SqliteTelemetryRepository( + databasePath, + loggerFactory, + options, + pauseManager, + outgoingPeerResolvers, + readOnly: configuration?.ReadOnly == true); + }); + context.Services.AddSingleton(services => services.GetRequiredService()); + context.Services.AddSingleton(services => services.GetRequiredService()); context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(localStorage ?? new TestLocalStorage()); @@ -271,4 +305,6 @@ public static IRenderedFragment RenderDialogProvider(TestContext context) builder.CloseComponent(); }); } + + private sealed record TelemetryRepositoryConfiguration(bool ReadOnly, Action Seed); } diff --git a/tests/Shared/Telemetry/TelemetryTestHelpers.cs b/tests/Shared/Telemetry/TelemetryTestHelpers.cs index f81827b4762..96f7b269e47 100644 --- a/tests/Shared/Telemetry/TelemetryTestHelpers.cs +++ b/tests/Shared/Telemetry/TelemetryTestHelpers.cs @@ -6,13 +6,19 @@ using System.Security.Cryptography.X509Certificates; using System.Text; using Aspire.Dashboard.Configuration; +#if !ASPIRE_DASHBOARD_COMPONENT_TESTS using Aspire.Dashboard.Model; +#endif using Aspire.Dashboard.Otlp.Model; +#if !ASPIRE_DASHBOARD_COMPONENT_TESTS using Aspire.Dashboard.Otlp.Storage; +#endif using Google.Protobuf; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +#if !ASPIRE_DASHBOARD_COMPONENT_TESTS using Microsoft.Extensions.Options; +#endif using OpenTelemetry.Proto.Common.V1; using OpenTelemetry.Proto.Logs.V1; using OpenTelemetry.Proto.Metrics.V1; @@ -231,6 +237,7 @@ public static Resource CreateResource(string? name = null, string? instanceId = return resource; } +#if !ASPIRE_DASHBOARD_COMPONENT_TESTS public static InMemoryTelemetryRepository CreateRepository( int? maxMetricsCount = null, int? maxAttributeCount = null, @@ -285,6 +292,7 @@ public static InMemoryTelemetryRepository CreateRepository( } return repository; } +#endif public static ulong DateTimeToUnixNanoseconds(DateTime dateTime) { From 6c69393c4001ccc6c8bd71c294f70035e6e0daba Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 22 Jul 2026 19:07:44 +0800 Subject: [PATCH 64/87] Fix metric aggregation across dimensions --- .../SqliteTelemetryRepository.Metrics.cs | 103 ++++++++++++--- .../TelemetryRepositoryTests/MetricsTests.cs | 125 ++++++++++++++++++ 2 files changed, 213 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs index c0d32e70708..fa69277442f 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs @@ -50,6 +50,32 @@ FROM ranked_metric_points ) """; + // Metric rows are value intervals for one dimension. A dimension that changes creates a new + // interval while unchanged dimensions extend their existing interval, so grouping only by the + // interval start omits unchanged dimensions. Convert each dimension's values into deltas; the + // query can then sum changes at each boundary and cumulatively reconstruct aggregate snapshots. + private const string MetricPointChangesCteSql = $""" + {EffectiveMetricPointsCteSql}, + metric_point_changes AS ( + SELECT + p.*, + p.integer_value - LAG(p.integer_value, 1, 0) OVER ( + PARTITION BY p.dimension_id, p.point_type + ORDER BY p.start_time_ticks, p.point_id) AS integer_delta, + p.double_value - LAG(p.double_value, 1, 0) OVER ( + PARTITION BY p.dimension_id, p.point_type + ORDER BY p.start_time_ticks, p.point_id) AS double_delta, + p.histogram_sum - LAG(p.histogram_sum, 1, 0) OVER ( + PARTITION BY p.dimension_id, p.point_type + ORDER BY p.start_time_ticks, p.point_id) AS histogram_sum_delta, + p.histogram_count - LAG(p.histogram_count, 1, 0) OVER ( + PARTITION BY p.dimension_id, p.point_type + ORDER BY p.start_time_ticks, p.point_id) AS histogram_count_delta, + MAX(p.end_time_ticks) OVER () AS latest_end_time_ticks + FROM effective_metric_points p + ) + """; + private readonly MetricIngestionState _metricIngestionState = new(); private void AddMetricsToDatabase(AddContext context, RepeatedField resourceMetrics) @@ -898,20 +924,48 @@ WHERE dimension_id IN @DimensionIds queryParameters.Add("StartTicks", startTime?.Ticks ?? 0); queryParameters.Add("EndTicks", endTime?.Ticks ?? 0); var points = connection.Query($""" - WITH {EffectiveMetricPointsCteSql}, - aggregated_metric_points AS ( + WITH {MetricPointChangesCteSql}, + aggregated_metric_point_changes AS ( SELECT p.start_time_ticks AS PointId, p.point_type AS PointType, p.start_time_ticks AS StartTimeTicks, - MAX(p.end_time_ticks) AS EndTimeTicks, + LEAD(p.start_time_ticks) OVER ( + PARTITION BY p.point_type + ORDER BY p.start_time_ticks) AS NextStartTimeTicks, + MAX(p.latest_end_time_ticks) AS LatestEndTimeTicks, MAX(p.repeat_count) AS RepeatCount, - SUM(p.integer_value) AS IntegerValue, - SUM(p.double_value) AS DoubleValue, - SUM(p.histogram_sum) AS HistogramSum, - SUM(p.histogram_count) AS HistogramCount - FROM effective_metric_points p + SUM(p.integer_delta) AS IntegerDelta, + SUM(p.double_delta) AS DoubleDelta, + SUM(p.histogram_sum_delta) AS HistogramSumDelta, + SUM(p.histogram_count_delta) AS HistogramCountDelta + FROM metric_point_changes p GROUP BY p.point_type, p.start_time_ticks + ), + aggregated_metric_points AS ( + SELECT + p.PointId, + p.PointType, + p.StartTimeTicks, + COALESCE(p.NextStartTimeTicks, p.LatestEndTimeTicks) AS EndTimeTicks, + p.RepeatCount, + SUM(p.IntegerDelta) OVER ( + PARTITION BY p.PointType + ORDER BY p.StartTimeTicks + ROWS UNBOUNDED PRECEDING) AS IntegerValue, + SUM(p.DoubleDelta) OVER ( + PARTITION BY p.PointType + ORDER BY p.StartTimeTicks + ROWS UNBOUNDED PRECEDING) AS DoubleValue, + SUM(p.HistogramSumDelta) OVER ( + PARTITION BY p.PointType + ORDER BY p.StartTimeTicks + ROWS UNBOUNDED PRECEDING) AS HistogramSum, + SUM(p.HistogramCountDelta) OVER ( + PARTITION BY p.PointType + ORDER BY p.StartTimeTicks + ROWS UNBOUNDED PRECEDING) AS HistogramCount + FROM aggregated_metric_point_changes p ) SELECT p.PointId, @@ -1008,14 +1062,33 @@ private static (ILookup BucketCounts, ILookup DynamicParameters queryParameters) { var bucketCounts = connection.Query($""" - WITH {EffectiveMetricPointsCteSql} + WITH {EffectiveMetricPointsCteSql}, + metric_histogram_bucket_changes AS ( + SELECT + p.start_time_ticks AS PointId, + b.ordinal AS Ordinal, + b.bucket_count - LAG(b.bucket_count, 1, 0) OVER ( + PARTITION BY p.dimension_id, b.ordinal + ORDER BY p.start_time_ticks, p.point_id) AS BucketDelta + FROM telemetry_metric_histogram_bucket_counts b + JOIN effective_metric_points p ON p.point_id = b.point_id + ), + aggregated_metric_histogram_bucket_changes AS ( + SELECT + p.PointId, + p.Ordinal, + SUM(p.BucketDelta) AS BucketDelta + FROM metric_histogram_bucket_changes p + GROUP BY p.PointId, p.Ordinal + ) SELECT - p.start_time_ticks AS PointId, - SUM(b.bucket_count) AS BucketCount - FROM telemetry_metric_histogram_bucket_counts b - JOIN effective_metric_points p ON p.point_id = b.point_id - GROUP BY p.start_time_ticks, b.ordinal - ORDER BY p.start_time_ticks, b.ordinal; + p.PointId, + SUM(p.BucketDelta) OVER ( + PARTITION BY p.Ordinal + ORDER BY p.PointId + ROWS UNBOUNDED PRECEDING) AS BucketCount + FROM aggregated_metric_histogram_bucket_changes p + ORDER BY p.PointId, p.Ordinal; """, queryParameters).ToLookup(record => record.PointId); var explicitBounds = connection.Query($""" WITH {EffectiveMetricPointsCteSql} diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index 534fe61f9a4..41c3cd9a3a0 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -1284,6 +1284,131 @@ public sealed class SqliteMetricsTests : MetricsTests protected override bool UseSqlite => true; + [Fact] + public void GetInstrument_StaggeredDimensionChanges_SumsCurrentValuesAtEachChange() + { + var repository = CreateRepository(); + var addContext = new AddContext(); + + for (var minute = 1; minute <= 3; minute++) + { + repository.AsWriter().AddMetrics(addContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(minute), value: 10, attributes: [KeyValuePair.Create("dimension", "stable")]), + CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(minute), value: 15 + (minute * 5), attributes: [KeyValuePair.Create("dimension", "changing")]) + } + } + } + } + }); + } + + Assert.Equal(0, addContext.FailureCount); + var resource = Assert.Single(repository.GetResources()); + var instrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = resource.ResourceKey, + MeterName = "test-meter", + InstrumentName = "test", + StartTime = s_queryTestTime, + EndTime = s_queryTestTime.AddMinutes(4) + }); + + Assert.NotNull(instrument); + var values = Assert.Single(instrument.Dimensions).Values.Cast>().ToArray(); + Assert.Collection( + values, + value => Assert.Equal(35, value.Value), + value => Assert.Equal(40, value.Value)); + } + + [Fact] + public void GetHistogram_StaggeredDimensionChanges_SumsCurrentValuesAtEachChange() + { + var repository = CreateRepository(); + var addContext = new AddContext(); + + for (var minute = 1; minute <= 3; minute++) + { + repository.AsWriter().AddMetrics(addContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateTestHistogramMetric(startTime: s_queryTestTime.AddMinutes(minute), value: 10, dimension: "stable"), + CreateTestHistogramMetric(startTime: s_queryTestTime.AddMinutes(minute), value: 15 + (minute * 5), dimension: "changing") + } + } + } + } + }); + } + + Assert.Equal(0, addContext.FailureCount); + var resource = Assert.Single(repository.GetResources()); + var instrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = resource.ResourceKey, + MeterName = "test-meter", + InstrumentName = "test", + StartTime = s_queryTestTime, + EndTime = s_queryTestTime.AddMinutes(4) + }); + + Assert.NotNull(instrument); + var values = Assert.Single(instrument.Dimensions).Values.Cast().ToArray(); + Assert.Collection( + values, + value => + { + Assert.Equal(30ul, value.Count); + Assert.Equal(30ul, value.Values[0]); + }, + value => + { + Assert.Equal(35ul, value.Count); + Assert.Equal(35ul, value.Values[0]); + }, + value => + { + Assert.Equal(40ul, value.Count); + Assert.Equal(40ul, value.Values[0]); + }); + + static Metric CreateTestHistogramMetric(DateTime startTime, int value, string dimension) + { + var metric = CreateHistogramMetric("test", startTime); + var point = Assert.Single(metric.Histogram.DataPoints); + point.Count = checked((ulong)value); + point.Sum = value; + point.BucketCounts.Clear(); + point.BucketCounts.AddRange([checked((ulong)value), 0, 0, 0]); + point.Attributes.Add(new KeyValue + { + Key = "dimension", + Value = new AnyValue { StringValue = dimension } + }); + return metric; + } + } + [Fact] public void AddMetrics_ReusesInstrumentAndDimensionLookupsWithinBatch() { From f67224e99ee7e9ac1862b3a8418d816d22e5a898 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Wed, 22 Jul 2026 22:27:16 +0800 Subject: [PATCH 65/87] Improve metric queries with incremental rollups --- .../TelemetryRepositoryMetricsBenchmarks.cs | 71 +- .../Controls/Chart/ChartContainer.razor.cs | 32 +- .../Chart/MetricInstrumentDataCache.cs | 98 ++ .../Otlp/Storage/GetInstrumentRequest.cs | 26 + ...SqliteTelemetryRepository.Metrics.Reads.cs | 464 +++++++++ ...qliteTelemetryRepository.Metrics.Writes.cs | 982 ++++++++++++++++++ .../SqliteTelemetryRepository.Metrics.cs | 338 +++--- .../Pages/MetricsTests.cs | 5 +- .../ChartDataCalculatorTests.cs | 183 ++++ .../TelemetryRepositoryTests/MetricsTests.cs | 358 ++++++- .../SqliteTelemetryPersistenceTests.cs | 2 +- .../Telemetry/InMemoryTelemetryRepository.cs | 43 +- 12 files changed, 2365 insertions(+), 237 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Controls/Chart/MetricInstrumentDataCache.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs create mode 100644 src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs index 9078fec8074..a609042289c 100644 --- a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs +++ b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs @@ -37,6 +37,7 @@ public class TelemetryRepositoryMetricsBenchmarks private string _temporaryDirectory = null!; private SqliteTelemetryRepository _queryRepository = null!; + private IReadOnlyList _incrementalCursors = null!; [Params(1, 10)] public int DimensionCount { get; set; } @@ -47,6 +48,18 @@ public class TelemetryRepositoryMetricsBenchmarks [GlobalSetup(Target = nameof(GetHistogramMetricsLongDuration))] public void SetupHistogramMetrics() => Setup(isHistogram: true); + [GlobalSetup(Target = nameof(GetMetricsLongDurationRollup))] + public void SetupMetricsRollup() => Setup(isHistogram: false); + + [GlobalSetup(Target = nameof(GetHistogramMetricsLongDurationRollup))] + public void SetupHistogramMetricsRollup() => Setup(isHistogram: true); + + [GlobalSetup(Target = nameof(GetMetricsIncrementalRollup))] + public void SetupMetricsIncrementalRollup() => SetupIncremental(isHistogram: false, MetricInstrumentName); + + [GlobalSetup(Target = nameof(GetHistogramMetricsIncrementalRollup))] + public void SetupHistogramMetricsIncrementalRollup() => SetupIncremental(isHistogram: true, HistogramMetricInstrumentName); + private void Setup(bool isHistogram) { _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-metrics-benchmark-").FullName; @@ -62,6 +75,21 @@ private void Setup(bool isHistogram) } } + private void SetupIncremental(bool isHistogram, string instrumentName) + { + Setup(isHistogram); + var instrument = GetLongDurationInstrument(instrumentName, TimeSpan.FromMinutes(1)); + _incrementalCursors = instrument.Dimensions.Select(dimension => + { + var latestValue = dimension.Values[^1]; + return new MetricDimensionCursor + { + Attributes = dimension.Attributes, + StartTime = latestValue.End.Subtract(TimeSpan.FromMinutes(1)) + }; + }).ToArray(); + } + [GlobalCleanup] public void Cleanup() { @@ -86,7 +114,44 @@ public int GetHistogramMetricsLongDuration() dimension.Values.Count + dimension.Values.Sum(value => value.Exemplars.Count)); } - private OtlpInstrumentData GetLongDurationInstrument(string instrumentName) + [Benchmark(Description = "TelemetryRepository: query 12h metrics with 1m rollup")] + public int GetMetricsLongDurationRollup() + { + var instrument = GetLongDurationInstrument(MetricInstrumentName, TimeSpan.FromMinutes(1)); + + return instrument.Dimensions.Sum(dimension => dimension.Values.Count); + } + + [Benchmark(Description = "TelemetryRepository: query 12h histogram metrics with 1m rollup")] + public int GetHistogramMetricsLongDurationRollup() + { + var instrument = GetLongDurationInstrument(HistogramMetricInstrumentName, TimeSpan.FromMinutes(1)); + + return instrument.Dimensions.Sum(dimension => + dimension.Values.Count + dimension.Values.Sum(value => value.Exemplars.Count)); + } + + [Benchmark(Description = "TelemetryRepository: query incremental metrics with 1m rollup")] + public int GetMetricsIncrementalRollup() + { + var instrument = GetLongDurationInstrument(MetricInstrumentName, TimeSpan.FromMinutes(1), _incrementalCursors); + + return instrument.Dimensions.Sum(dimension => dimension.Values.Count); + } + + [Benchmark(Description = "TelemetryRepository: query incremental histogram metrics with 1m rollup")] + public int GetHistogramMetricsIncrementalRollup() + { + var instrument = GetLongDurationInstrument(HistogramMetricInstrumentName, TimeSpan.FromMinutes(1), _incrementalCursors); + + return instrument.Dimensions.Sum(dimension => + dimension.Values.Count + dimension.Values.Sum(value => value.Exemplars.Count)); + } + + private OtlpInstrumentData GetLongDurationInstrument( + string instrumentName, + TimeSpan? dataPointInterval = null, + IReadOnlyList? dimensionCursors = null) { var endTime = _queryRepository.GetInstrumentLatestEndTime(s_metricResourceKey, MetricMeterName, instrumentName) ?? throw new InvalidOperationException($"Unable to find the benchmark metric '{instrumentName}' end time."); @@ -98,7 +163,9 @@ private OtlpInstrumentData GetLongDurationInstrument(string instrumentName) MeterName = MetricMeterName, InstrumentName = instrumentName, StartTime = endTime.Subtract(s_metricDisplayDuration + TimeSpan.FromSeconds(30)), - EndTime = endTime + EndTime = endTime, + DataPointInterval = dataPointInterval, + DimensionCursors = dimensionCursors ?? [] }) ?? throw new InvalidOperationException($"Unable to find the benchmark metric '{instrumentName}'."); } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs index 1a7a170eead..20b7244e3b5 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs @@ -105,7 +105,7 @@ private async Task UpdateDataAsync() { if (lastDataFetchTimestamp is null || Stopwatch.GetElapsedTime(lastDataFetchTimestamp.Value) >= s_dataFetchInterval) { - _instrument = GetInstrument(); + _instrument = GetInstrument(useIncrementalCache: true); lastDataFetchTimestamp = Stopwatch.GetTimestamp(); if (_instrument is not null && HaveDimensionFilterValuesChanged(_instrument)) @@ -129,7 +129,7 @@ await InvokeAsync(() => public async Task DimensionValuesChangedAsync(DimensionFilterViewModel dimensionViewModel) { - _instrument = GetInstrument(); + _instrument = GetInstrument(useIncrementalCache: false); if (_instrument is null) { return; @@ -160,7 +160,7 @@ private async Task ShowCountChangedAsync(bool showCount) protected override async Task OnParametersSetAsync() { - _instrument = GetInstrument(); + _instrument = GetInstrument(useIncrementalCache: false); if (_instrument == null) { @@ -176,7 +176,7 @@ protected override async Task OnParametersSetAsync() await UpdateInstrumentDataAsync(_instrument); } - private OtlpInstrumentData? GetInstrument() + private OtlpInstrumentData? GetInstrument(bool useIncrementalCache) { DateTime endDate; if (TelemetryRepository.IsReadOnly) @@ -191,17 +191,24 @@ protected override async Task OnParametersSetAsync() endDate = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : DateTime.UtcNow; } - // Get more data than is being displayed. Histogram graph uses some historical data to calculate bucket counts. - // It's ok to get more data than is needed here. An additional date filter is applied when building chart values. - var startDate = endDate.Subtract(Duration + TimeSpan.FromSeconds(30)); + var dataPointInterval = GetDataPointInterval(Duration); - var instrument = TelemetryRepository.GetInstrument(new GetInstrumentRequest + // Histogram graphs need one preceding rollup to calculate bucket count changes at the beginning of the chart. + var historyDuration = TimeSpan.FromTicks(Math.Max(TimeSpan.FromSeconds(30).Ticks, dataPointInterval.Ticks)); + var startDate = endDate.Subtract(Duration + historyDuration); + var cursors = useIncrementalCache && _instrument is not null + ? MetricInstrumentDataCache.CreateCursors(_instrument, historyDuration, dataPointInterval) + : []; + + var refreshedInstrument = TelemetryRepository.GetInstrument(new GetInstrumentRequest { ResourceKey = ResourceKey, MeterName = MeterName, InstrumentName = InstrumentName, StartTime = startDate, EndTime = endDate, + DataPointInterval = dataPointInterval, + DimensionCursors = cursors, DimensionFilters = DimensionFilters .Where(filter => filter.AreAllValuesSelected is not true) .ToDictionary( @@ -209,7 +216,7 @@ protected override async Task OnParametersSetAsync() filter => (IReadOnlyList)filter.SelectedValues.Select(value => value.Value).ToArray()) }); - if (instrument == null) + if (refreshedInstrument == null) { Logger.LogDebug( "Unable to find instrument. ResourceKey: {ResourceKey}, MeterName: {MeterName}, InstrumentName: {InstrumentName}", @@ -218,9 +225,14 @@ protected override async Task OnParametersSetAsync() InstrumentName); } - return instrument; + return refreshedInstrument is not null && _instrument is not null && cursors.Count > 0 + ? MetricInstrumentDataCache.Merge(_instrument, refreshedInstrument, cursors, startDate) + : refreshedInstrument; } + internal static TimeSpan GetDataPointInterval(TimeSpan duration) + => duration <= TimeSpan.FromMinutes(15) ? TimeSpan.FromSeconds(1) : TimeSpan.FromMinutes(1); + private void EnsureDataEndTime() { var key = (ResourceKey, MeterName, InstrumentName); diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/MetricInstrumentDataCache.cs b/src/Aspire.Dashboard/Components/Controls/Chart/MetricInstrumentDataCache.cs new file mode 100644 index 00000000000..d5a1db52445 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/Chart/MetricInstrumentDataCache.cs @@ -0,0 +1,98 @@ +// 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; +using Aspire.Dashboard.Otlp.Model.MetricValues; +using Aspire.Dashboard.Otlp.Storage; + +namespace Aspire.Dashboard.Components; + +internal static class MetricInstrumentDataCache +{ + public static List CreateCursors(OtlpInstrumentData data, TimeSpan refreshLookback, TimeSpan dataPointInterval) + { + ArgumentOutOfRangeException.ThrowIfLessThan(refreshLookback, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(dataPointInterval, TimeSpan.Zero); + + var cursors = new List(data.Dimensions.Count); + foreach (var dimension in data.Dimensions) + { + if (dimension.Values.Count == 0) + { + continue; + } + + var latestValue = dimension.Values[^1]; + // Refresh recent complete buckets because data can arrive behind the latest displayed value. Starting from + // the rolled value's start when it is earlier also retains source points that determine its representative. + var lookbackStartTicks = latestValue.End.Ticks - Math.Min(latestValue.End.Ticks, refreshLookback.Ticks); + var alignedLookbackStartTicks = lookbackStartTicks - (lookbackStartTicks % dataPointInterval.Ticks); + cursors.Add(new MetricDimensionCursor + { + Attributes = dimension.Attributes, + StartTime = new DateTime(Math.Min(latestValue.Start.Ticks, alignedLookbackStartTicks), DateTimeKind.Utc) + }); + } + return cursors; + } + + public static OtlpInstrumentData Merge( + OtlpInstrumentData cached, + OtlpInstrumentData refreshed, + IReadOnlyList cursors, + DateTime windowStart) + { + var dimensions = new List(refreshed.Dimensions.Count); + foreach (var refreshedDimension in refreshed.Dimensions) + { + var cachedDimension = cached.Dimensions.FirstOrDefault(dimension => AttributesEqual(dimension.Attributes, refreshedDimension.Attributes)); + var cursor = cursors.FirstOrDefault(cursor => AttributesEqual(cursor.Attributes, refreshedDimension.Attributes)); + if (cachedDimension is null || cursor is null) + { + dimensions.Add(CloneDimension(refreshedDimension, windowStart)); + continue; + } + + var mergedDimension = new DimensionScope(refreshedDimension.Capacity, refreshedDimension.Attributes); + foreach (var value in cachedDimension.Values) + { + if (value.End >= windowStart && value.End < cursor.StartTime) + { + mergedDimension.Values.Add(MetricValueBase.Clone(value)); + } + } + foreach (var value in refreshedDimension.Values) + { + if (value.End >= windowStart) + { + mergedDimension.Values.Add(MetricValueBase.Clone(value)); + } + } + dimensions.Add(mergedDimension); + } + + return new OtlpInstrumentData + { + Summary = refreshed.Summary, + Dimensions = dimensions, + KnownAttributeValues = refreshed.KnownAttributeValues, + HasOverflow = refreshed.HasOverflow + }; + } + + private static DimensionScope CloneDimension(DimensionScope dimension, DateTime windowStart) + { + var clone = new DimensionScope(dimension.Capacity, dimension.Attributes); + foreach (var value in dimension.Values) + { + if (value.End >= windowStart) + { + clone.Values.Add(MetricValueBase.Clone(value)); + } + } + return clone; + } + + private static bool AttributesEqual(KeyValuePair[] left, KeyValuePair[] right) + => left.AsSpan().SequenceEqual(right); +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs b/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs index a3a8984c06a..96832567a00 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs @@ -37,4 +37,30 @@ public sealed class GetInstrumentRequest /// Gets the dimension values to retrieve, keyed by dimension name. An empty dictionary retrieves all dimensions. ///
public IReadOnlyDictionary> DimensionFilters { get; init; } = new Dictionary>(); + + /// + /// Gets the per-dimension boundaries from which data should be refreshed. Dimensions without a cursor use . + /// + public IReadOnlyList DimensionCursors { get; init; } = []; + + /// + /// Gets the interval used to roll up data points within each dimension. A value returns full-fidelity data. + /// + public TimeSpan? DataPointInterval { get; init; } +} + +/// +/// Specifies the boundary from which one metric dimension should be refreshed. +/// +public sealed class MetricDimensionCursor +{ + /// + /// Gets the attributes that identify the dimension. + /// + public required KeyValuePair[] Attributes { get; init; } + + /// + /// Gets the inclusive beginning of the range to refresh. + /// + public required DateTime StartTime { get; init; } } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs new file mode 100644 index 00000000000..4d715569409 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs @@ -0,0 +1,464 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Model.MetricValues; +using Dapper; +using Microsoft.Data.Sqlite; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed partial class SqliteTelemetryRepository +{ + private const string MetricPointRangeFilterSql = "(@ApplyRange = 0 OR (p.start_time_ticks <= @EndTicks AND p.end_time_ticks >= r.start_time_ticks))"; + private const string SelectedMetricPointsCteSql = $""" + selected_metric_points AS ( + SELECT p.* + FROM telemetry_metric_points p + JOIN metric_dimension_query_ranges r ON r.dimension_id = p.dimension_id + WHERE {MetricPointRangeFilterSql} + ) + """; + private const string EffectiveMetricPointsCteSql = $""" + {SelectedMetricPointsCteSql}, + ranked_metric_points AS ( + SELECT + p.*, + ROW_NUMBER() OVER ( + PARTITION BY p.start_time_ticks, p.dimension_id + ORDER BY p.point_id DESC) AS point_rank + FROM selected_metric_points p + ), + effective_metric_points AS ( + SELECT * + FROM ranked_metric_points + WHERE point_rank = 1 + ) + """; + private static readonly string s_rolledUpMetricPointsCteSql = $""" + {EffectiveMetricPointsCteSql}, + bucketed_metric_points AS ( + SELECT + p.*, + CASE + WHEN @PointIntervalTicks = 0 THEN p.start_time_ticks + ELSE (p.start_time_ticks / @PointIntervalTicks) * @PointIntervalTicks + END AS rollup_start_time_ticks + FROM effective_metric_points p + ), + ranked_rollup_metric_points AS ( + SELECT + p.*, + MAX(p.end_time_ticks) OVER ( + PARTITION BY p.dimension_id, p.point_type, p.rollup_start_time_ticks) AS rollup_end_time_ticks, + SUM(p.repeat_count) OVER ( + PARTITION BY p.dimension_id, p.point_type, p.rollup_start_time_ticks) AS rollup_repeat_count, + ROW_NUMBER() OVER ( + PARTITION BY p.dimension_id, p.point_type, p.rollup_start_time_ticks + ORDER BY + CASE WHEN p.point_type = {HistogramPointType} THEN p.start_time_ticks END DESC, + p.integer_value DESC, + p.double_value DESC, + p.point_id DESC) AS rollup_rank + FROM bucketed_metric_points p + ), + rolled_up_metric_points AS ( + SELECT + p.point_id, + p.dimension_id, + p.point_type, + p.rollup_start_time_ticks AS start_time_ticks, + p.rollup_end_time_ticks AS end_time_ticks, + p.rollup_repeat_count AS repeat_count, + p.integer_value, + p.double_value, + p.histogram_sum, + p.histogram_count, + p.flags + FROM ranked_rollup_metric_points p + WHERE p.rollup_rank = 1 + ) + """; + private const string FullFidelityMetricPointsCteSql = $""" + {EffectiveMetricPointsCteSql}, + rolled_up_metric_points AS ( + SELECT * + FROM effective_metric_points + ) + """; + + private OtlpInstrumentData? GetInstrumentFromDatabase(GetInstrumentRequest request) + { + var instruments = GetCachedInstruments(request.ResourceKey, request.MeterName, request.InstrumentName); + if (instruments.Count == 0) + { + return null; + } + + using var connection = _database.OpenConnection(); + var knownAttributeValues = new Dictionary>(); + var dimensions = MaterializeMetricDimensions( + connection, + instruments.Select(instrument => instrument.InstrumentId).ToArray(), + instruments[0].Summary.Type == OtlpInstrumentType.Histogram, + request.StartTime, + request.EndTime, + request.DimensionFilters, + request.DimensionCursors, + request.DataPointInterval, + knownAttributeValues); + return new OtlpInstrumentData + { + Summary = instruments[0].Summary, + Dimensions = dimensions, + KnownAttributeValues = knownAttributeValues, + HasOverflow = instruments.Any(instrument => instrument.HasOverflow) + }; + } + + private DateTime? GetInstrumentLatestEndTimeFromDatabase(ResourceKey resourceKey, string meterName, string instrumentName) + { + using var connection = _database.OpenConnection(); + var endTimeTicks = connection.QuerySingleOrDefault(""" + SELECT MAX(p.end_time_ticks) + FROM telemetry_metric_points p + JOIN telemetry_metric_dimensions d ON d.dimension_id = p.dimension_id + JOIN telemetry_metric_instruments i ON i.instrument_id = d.instrument_id + JOIN telemetry_resources r ON r.resource_id = i.resource_id + JOIN telemetry_scopes s ON s.scope_id = i.scope_id + WHERE r.resource_name = @ResourceName COLLATE NOCASE + AND (@InstanceId IS NULL OR r.instance_id = @InstanceId COLLATE NOCASE) + AND s.scope_name = @MeterName + AND i.instrument_name = @InstrumentName; + """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId, MeterName = meterName, InstrumentName = instrumentName }); + return endTimeTicks is not null ? new DateTime(endTimeTicks.Value, DateTimeKind.Utc) : null; + } + + private OtlpInstrument? GetResourceInstrumentFromDatabase( + ResourceKey resourceKey, + string meterName, + string instrumentName, + DateTime? startTime, + DateTime? endTime) + { + var data = GetInstrumentFromDatabase(new GetInstrumentRequest + { + ResourceKey = resourceKey, + MeterName = meterName, + InstrumentName = instrumentName, + StartTime = startTime, + EndTime = endTime + }); + if (data is null) + { + return null; + } + + var instrument = new OtlpInstrument + { + Summary = data.Summary, + Context = _otlpContext, + HasOverflow = data.HasOverflow + }; + foreach (var (key, values) in data.KnownAttributeValues) + { + instrument.KnownAttributeValues.Add(key, values); + } + foreach (var dimension in data.Dimensions) + { + instrument.Dimensions.Add(dimension.Attributes, dimension); + } + return instrument; + } + + private List MaterializeMetricDimensions( + SqliteConnection connection, + IReadOnlyList instrumentIds, + bool isHistogram, + DateTime? startTime, + DateTime? endTime, + IReadOnlyDictionary> dimensionFilters, + IReadOnlyList dimensionCursors, + TimeSpan? dataPointInterval, + Dictionary> knownAttributeValues) + { + if (dataPointInterval is { } interval && interval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(dataPointInterval), interval, "The metric data point interval must be greater than zero."); + } + + var dimensionIds = connection.Query("SELECT dimension_id FROM telemetry_metric_dimensions WHERE instrument_id IN @InstrumentIds ORDER BY dimension_id;", new { InstrumentIds = instrumentIds }).AsList(); + var attributes = connection.Query(""" + SELECT dimension_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_metric_dimension_attributes + WHERE dimension_id IN @DimensionIds + ORDER BY dimension_id, ordinal; + """, new { DimensionIds = dimensionIds }).ToLookup(record => record.OwnerId); + PopulateKnownAttributeValues(dimensionIds, attributes, knownAttributeValues); + + var selectedDimensionIds = dimensionIds + .Where(dimensionId => MatchesDimensionFilters(attributes[dimensionId], dimensionFilters)) + .ToArray(); + if (selectedDimensionIds.Length == 0) + { + return []; + } + + var queryParameters = new DynamicParameters(); + queryParameters.Add("ApplyRange", startTime is not null && endTime is not null); + queryParameters.Add("EndTicks", endTime?.Ticks ?? 0); + queryParameters.Add("PointIntervalTicks", dataPointInterval?.Ticks ?? 0); + var dimensionQueryRangesCteSql = CreateMetricDimensionQueryRangesCte( + selectedDimensionIds, + attributes, + dimensionCursors, + startTime, + queryParameters); + var metricPointsCteSql = dataPointInterval is null ? FullFidelityMetricPointsCteSql : s_rolledUpMetricPointsCteSql; + var points = connection.Query($""" + WITH {dimensionQueryRangesCteSql}, + {metricPointsCteSql} + SELECT + p.point_id AS PointId, + p.dimension_id AS DimensionId, + p.point_type AS PointType, + p.start_time_ticks AS StartTimeTicks, + p.end_time_ticks AS EndTimeTicks, + p.repeat_count AS RepeatCount, + p.integer_value AS IntegerValue, + p.double_value AS DoubleValue, + p.histogram_sum AS HistogramSum, + p.histogram_count AS HistogramCount + FROM rolled_up_metric_points p + ORDER BY p.dimension_id, p.start_time_ticks, p.point_id; + """, queryParameters).ToLookup(record => record.DimensionId); + var (bucketCounts, explicitBounds) = isHistogram + ? MaterializeMetricHistogramData(connection, dimensionQueryRangesCteSql, metricPointsCteSql, queryParameters) + : (Array.Empty().ToLookup(record => record.PointId), + Array.Empty().ToLookup(record => record.PointId)); + var exemplars = MaterializeMetricExemplars(connection, dimensionQueryRangesCteSql, metricPointsCteSql, queryParameters); + + var results = new List(selectedDimensionIds.Length); + foreach (var dimensionId in selectedDimensionIds) + { + var dimension = new DimensionScope( + _otlpContext.Options.MaxMetricsCount, + attributes[dimensionId].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray()); + if (startTime is not null && endTime is not null) + { + foreach (var point in points[dimensionId]) + { + MetricValueBase value = point.PointType switch + { + LongPointType => new MetricValue(point.IntegerValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), + DoublePointType => new MetricValue(point.DoubleValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), + HistogramPointType => new HistogramValue( + bucketCounts[point.PointId].Select(bucket => checked((ulong)bucket.BucketCount)).ToArray(), + point.HistogramSum!.Value, + checked((ulong)point.HistogramCount!.Value), + new DateTime(point.StartTimeTicks, DateTimeKind.Utc), + new DateTime(point.EndTimeTicks, DateTimeKind.Utc), + explicitBounds[point.PointId].Select(bound => bound.ExplicitBound).ToArray()), + _ => throw new InvalidOperationException($"Unknown metric point type '{point.PointType}'.") + }; + if (point.PointType != HistogramPointType) + { + value.Count = checked((ulong)point.RepeatCount); + } + value.Exemplars.AddRange(exemplars[point.PointId]); + dimension.Values.Add(value); + } + } + results.Add(dimension); + } + return results; + } + + private static string CreateMetricDimensionQueryRangesCte( + IReadOnlyList selectedDimensionIds, + ILookup attributes, + IReadOnlyList dimensionCursors, + DateTime? defaultStartTime, + DynamicParameters queryParameters) + { + var sql = new StringBuilder("metric_dimension_query_ranges(dimension_id, start_time_ticks) AS (VALUES "); + for (var i = 0; i < selectedDimensionIds.Count; i++) + { + if (i > 0) + { + sql.Append(", "); + } + + var dimensionId = selectedDimensionIds[i]; + var dimensionAttributes = attributes[dimensionId]; + var cursor = dimensionCursors.FirstOrDefault(cursor => + cursor.Attributes.Length == dimensionAttributes.Count() && + cursor.Attributes.SequenceEqual(dimensionAttributes.Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)))); + queryParameters.Add($"DimensionId{i}", dimensionId); + queryParameters.Add($"DimensionStartTicks{i}", cursor?.StartTime.Ticks ?? defaultStartTime?.Ticks ?? 0); + sql.Append(CultureInfo.InvariantCulture, $"(@DimensionId{i}, @DimensionStartTicks{i})"); + } + sql.Append(')'); + return sql.ToString(); + } + + private static bool MatchesDimensionFilters(IEnumerable attributes, IReadOnlyDictionary> dimensionFilters) + { + foreach (var (key, values) in dimensionFilters) + { + var value = attributes.FirstOrDefault(attribute => attribute.AttributeKey == key)?.AttributeValue; + if (!values.Contains(value)) + { + return false; + } + } + return true; + } + + private static void PopulateKnownAttributeValues( + IReadOnlyList dimensionIds, + ILookup attributes, + Dictionary> knownAttributeValues) + { + for (var dimensionIndex = 0; dimensionIndex < dimensionIds.Count; dimensionIndex++) + { + var dimensionId = dimensionIds[dimensionIndex]; + foreach (var key in knownAttributeValues.Keys.Union(attributes[dimensionId].Select(attribute => attribute.AttributeKey)).Distinct().ToList()) + { + if (!knownAttributeValues.TryGetValue(key, out var values)) + { + values = []; + knownAttributeValues.Add(key, values); + if (dimensionIndex > 0) + { + values.Add(null); + } + } + var value = attributes[dimensionId].FirstOrDefault(attribute => attribute.AttributeKey == key)?.AttributeValue; + if (!values.Contains(value)) + { + values.Add(value); + } + } + } + } + + private static (ILookup BucketCounts, ILookup ExplicitBounds) MaterializeMetricHistogramData( + SqliteConnection connection, + string dimensionQueryRangesCteSql, + string metricPointsCteSql, + DynamicParameters queryParameters) + { + var bucketCounts = connection.Query($""" + WITH {dimensionQueryRangesCteSql}, + {metricPointsCteSql} + SELECT + p.point_id AS PointId, + b.bucket_count AS BucketCount + FROM telemetry_metric_histogram_bucket_counts b + JOIN rolled_up_metric_points p ON p.point_id = b.point_id + ORDER BY p.point_id, b.ordinal; + """, queryParameters).ToLookup(record => record.PointId); + var explicitBounds = connection.Query($""" + WITH {dimensionQueryRangesCteSql}, + {metricPointsCteSql} + SELECT + p.point_id AS PointId, + b.explicit_bound AS ExplicitBound + FROM telemetry_metric_histogram_explicit_bounds b + JOIN rolled_up_metric_points p ON p.point_id = b.point_id + ORDER BY p.point_id, b.ordinal; + """, queryParameters).ToLookup(record => record.PointId); + + return (bucketCounts, explicitBounds); + } + + private static ILookup MaterializeMetricExemplars( + SqliteConnection connection, + string dimensionQueryRangesCteSql, + string metricPointsCteSql, + DynamicParameters queryParameters) + { + var records = connection.Query($""" + WITH {dimensionQueryRangesCteSql}, + {metricPointsCteSql} + SELECT + e.exemplar_id AS ExemplarId, + p.point_id AS PointId, + e.start_time_ticks AS StartTimeTicks, + e.exemplar_value AS ExemplarValue, + e.span_id AS SpanId, + e.trace_id AS TraceId + FROM telemetry_metric_exemplars e + JOIN selected_metric_points source ON source.point_id = e.point_id + JOIN metric_dimension_query_ranges r ON r.dimension_id = source.dimension_id + JOIN rolled_up_metric_points p ON + p.dimension_id = source.dimension_id AND + p.point_type = source.point_type AND + p.start_time_ticks = CASE + WHEN @PointIntervalTicks = 0 THEN source.start_time_ticks + ELSE (source.start_time_ticks / @PointIntervalTicks) * @PointIntervalTicks + END + WHERE @ApplyRange = 0 OR (e.start_time_ticks >= r.start_time_ticks AND e.start_time_ticks <= @EndTicks) + ORDER BY p.point_id, e.exemplar_id; + """, queryParameters).AsList(); + var attributes = connection.Query($""" + WITH {dimensionQueryRangesCteSql}, + {metricPointsCteSql} + SELECT a.exemplar_id AS OwnerId, a.attribute_key AS AttributeKey, a.attribute_value AS AttributeValue + FROM telemetry_metric_exemplar_attributes a + JOIN telemetry_metric_exemplars e ON e.exemplar_id = a.exemplar_id + JOIN selected_metric_points source ON source.point_id = e.point_id + JOIN metric_dimension_query_ranges r ON r.dimension_id = source.dimension_id + JOIN rolled_up_metric_points p ON + p.dimension_id = source.dimension_id AND + p.point_type = source.point_type AND + p.start_time_ticks = CASE + WHEN @PointIntervalTicks = 0 THEN source.start_time_ticks + ELSE (source.start_time_ticks / @PointIntervalTicks) * @PointIntervalTicks + END + WHERE @ApplyRange = 0 OR (e.start_time_ticks >= r.start_time_ticks AND e.start_time_ticks <= @EndTicks) + ORDER BY a.exemplar_id, a.ordinal; + """, queryParameters).ToLookup(record => record.OwnerId); + return records.Select(record => new KeyValuePair(record.PointId, new MetricsExemplar + { + Start = new DateTime(record.StartTimeTicks, DateTimeKind.Utc), + Value = record.ExemplarValue, + SpanId = record.SpanId, + TraceId = record.TraceId, + Attributes = attributes[record.ExemplarId].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray() + })).ToLookup(pair => pair.Key, pair => pair.Value); + } + + private sealed class MetricPointDataRecord : MetricPointRecord + { + public required long DimensionId { get; init; } + public required long StartTimeTicks { get; init; } + public required long RepeatCount { get; init; } + public double? HistogramSum { get; init; } + } + + private sealed class MetricHistogramBucketRecord + { + public required long PointId { get; init; } + public required long BucketCount { get; init; } + } + + private sealed class MetricHistogramBoundRecord + { + public required long PointId { get; init; } + public required double ExplicitBound { get; init; } + } + + private sealed class MetricExemplarRecord + { + public required long ExemplarId { get; init; } + public required long PointId { get; init; } + public required long StartTimeTicks { get; init; } + public required double ExemplarValue { get; init; } + public required string SpanId { get; init; } + public required string TraceId { get; init; } + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs new file mode 100644 index 00000000000..7998641b169 --- /dev/null +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs @@ -0,0 +1,982 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Data; +using System.Globalization; +using System.IO.Hashing; +using System.Text; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Utils; +using Dapper; +using Google.Protobuf.Collections; +using Microsoft.Data.Sqlite; +using OpenTelemetry.Proto.Common.V1; +using OpenTelemetry.Proto.Metrics.V1; + +namespace Aspire.Dashboard.Otlp.Storage; + +public sealed partial class SqliteTelemetryRepository +{ + private const int LongPointType = 1; + private const int DoublePointType = 2; + private const int HistogramPointType = 3; + private const int MaxMetricPointBatchSize = 100; + + private readonly MetricIngestionState _metricIngestionState = new(); + + private void AddMetricsToDatabase(AddContext context, RepeatedField resourceMetrics) + { + lock (_writeLock) + { + _metricIngestionState.DimensionsToTrim.Clear(); + _metricIngestionState.PendingDimensions.Clear(); + _metricIngestionState.PendingDimensionAttributes.Clear(); + try + { + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + var pointBatch = new MetricPointBatch(); + foreach (var resourceMetricsItem in resourceMetrics) + { + CachedResource cachedResource; + try + { + cachedResource = GetOrAddCachedResource(connection, transaction, resourceMetricsItem.Resource.GetResourceKey()); + } + catch (Exception exception) + { + context.FailureCount += resourceMetricsItem.ScopeMetrics.Sum(scope => scope.Metrics.Sum(OtlpResource.GetMetricDataPointCount)); + _otlpContext.Logger.LogInformation(exception, "Error adding resource."); + continue; + } + + foreach (var scopeMetrics in resourceMetricsItem.ScopeMetrics) + { + CachedResourceScope cachedScope; + try + { + cachedScope = GetOrAddCachedScope(connection, transaction, cachedResource, scopeMetrics.Scope, CachedTelemetryType.Metrics); + } + catch (Exception exception) + { + context.FailureCount += scopeMetrics.Metrics.Sum(OtlpResource.GetMetricDataPointCount); + _otlpContext.Logger.LogInformation(exception, "Error adding metric scope."); + continue; + } + + EnsureCachedInstruments(connection, transaction, cachedResource, cachedScope, scopeMetrics.Metrics); + foreach (var metric in scopeMetrics.Metrics) + { + AddMetricToDatabase(connection, transaction, context, cachedResource, cachedScope, metric, _metricIngestionState, pointBatch); + } + } + + if (!cachedResource.Resource.HasMetrics) + { + connection.Execute( + "UPDATE telemetry_resources SET has_metrics = 1 WHERE resource_id = @ResourceId;", + new { cachedResource.ResourceId }, + transaction); + cachedResource.Resource.HasMetrics = true; + } + } + + InsertMetricDimensions(connection, transaction, _metricIngestionState.PendingDimensions); + InsertMetricDimensionAttributes(connection, transaction, _metricIngestionState.PendingDimensionAttributes); + ExecuteMetricPointBatch(connection, transaction, pointBatch); + + TrimMetricDimensions(connection, transaction, _metricIngestionState.DimensionsToTrim); + + transaction.Commit(); + _metricIngestionState.DimensionsToTrim.Clear(); + _metricIngestionState.PendingDimensions.Clear(); + _metricIngestionState.PendingDimensionAttributes.Clear(); + } + catch + { + // Cache entries can refer to changes that were rolled back with the transaction. + ClearIngestionCaches(); + throw; + } + } + } + + private void AddMetricToDatabase( + SqliteConnection connection, + IDbTransaction transaction, + AddContext context, + CachedResource cachedResource, + CachedResourceScope cachedScope, + Metric metric, + MetricIngestionState ingestionState, + MetricPointBatch pointBatch) + { + var pointCount = OtlpResource.GetMetricDataPointCount(metric); + if (metric.DataCase is Metric.DataOneofCase.Summary or Metric.DataOneofCase.ExponentialHistogram) + { + context.FailureCount += pointCount; + _otlpContext.Logger.LogInformation("Error adding {MetricType} metrics. {MetricType} is not supported.", metric.DataCase, metric.DataCase); + return; + } + if (metric.DataCase is Metric.DataOneofCase.None) + { + return; + } + + CachedInstrument cachedInstrument; + try + { + cachedInstrument = GetOrAddCachedInstrument(connection, transaction, cachedResource, cachedScope, metric); + } + catch (Exception exception) + { + context.FailureCount += pointCount; + _otlpContext.Logger.LogInformation(exception, "Error adding metric instrument {MetricName}.", metric.Name); + return; + } + + switch (metric.DataCase) + { + case Metric.DataOneofCase.Gauge: + foreach (var point in metric.Gauge.DataPoints) + { + AddNumberMetricPoint(connection, transaction, context, cachedInstrument.InstrumentId, cachedScope.Scope.Scope, point, ingestionState, pointBatch); + } + break; + case Metric.DataOneofCase.Sum: + foreach (var point in metric.Sum.DataPoints) + { + AddNumberMetricPoint(connection, transaction, context, cachedInstrument.InstrumentId, cachedScope.Scope.Scope, point, ingestionState, pointBatch); + } + break; + case Metric.DataOneofCase.Histogram: + foreach (var point in metric.Histogram.DataPoints) + { + AddHistogramMetricPoint(connection, transaction, context, cachedInstrument.InstrumentId, cachedScope.Scope.Scope, point, ingestionState, pointBatch); + } + break; + } + } + + private void AddNumberMetricPoint( + SqliteConnection connection, + IDbTransaction transaction, + AddContext context, + long instrumentId, + OtlpScope scope, + NumberDataPoint point, + MetricIngestionState ingestionState, + MetricPointBatch pointBatch) + { + try + { + var dimension = GetOrAddMetricDimension(connection, transaction, instrumentId, scope, point.Attributes, ingestionState); + var pointType = point.ValueCase switch + { + NumberDataPoint.ValueOneofCase.AsInt => LongPointType, + NumberDataPoint.ValueOneofCase.AsDouble => DoublePointType, + _ => throw new InvalidOperationException("Metric data point has no value.") + }; + var pendingLatest = dimension.PendingPoint; + var latest = dimension.LatestPoint; + var latestPointType = pendingLatest?.PointType ?? latest?.PointType; + var latestEndTimeTicks = pendingLatest?.EndTimeTicks ?? latest?.EndTimeTicks; + var sameValue = latestPointType == pointType && (pendingLatest is not null + ? pointType == LongPointType ? pendingLatest.IntegerValue == point.AsInt : pendingLatest.DoubleValue == point.AsDouble + : pointType == LongPointType ? latest?.IntegerValue == point.AsInt : latest?.DoubleValue == point.AsDouble); + var endTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks; + if (sameValue) + { + if (pendingLatest is not null) + { + pendingLatest.EndTimeTicks = endTimeTicks; + pendingLatest.RepeatCount++; + pendingLatest.SourcePointCount++; + pendingLatest.Exemplars.AddRange(point.Exemplars); + } + else + { + pointBatch.AddUpdate(latest!.PointId, endTimeTicks, incrementRepeatCount: true); + latest.EndTimeTicks = endTimeTicks; + QueueMetricExemplars(pointBatch, latest.PointId, point.Exemplars); + context.SuccessCount++; + } + } + else + { + var start = OtlpHelpers.UnixNanoSecondsToDateTime(point.StartTimeUnixNano); + if (latestPointType == pointType) + { + start = new DateTime(latestEndTimeTicks!.Value, DateTimeKind.Utc); + } + var pendingPoint = new PendingMetricPoint + { + Context = context, + Dimension = dimension, + PointType = pointType, + StartTimeTicks = start.Ticks, + EndTimeTicks = endTimeTicks, + RepeatCount = 1, + IntegerValue = pointType == LongPointType ? point.AsInt : (long?)null, + DoubleValue = pointType == DoublePointType ? point.AsDouble : (double?)null, + Flags = (long)point.Flags + }; + pendingPoint.Exemplars.AddRange(point.Exemplars); + pointBatch.Inserts.Add(pendingPoint); + dimension.PendingPoint = pendingPoint; + ingestionState.DimensionsToTrim.Add(dimension); + } + } + catch (Exception exception) + { + context.FailureCount++; + _otlpContext.Logger.LogInformation(exception, "Error adding metric."); + } + } + + private void AddHistogramMetricPoint( + SqliteConnection connection, + IDbTransaction transaction, + AddContext context, + long instrumentId, + OtlpScope scope, + HistogramDataPoint point, + MetricIngestionState ingestionState, + MetricPointBatch pointBatch) + { + try + { + if (point.BucketCounts.Count > 0 && point.ExplicitBounds.Count == 0) + { + throw new InvalidOperationException("Histogram data point has bucket counts without any explicit bounds."); + } + var dimension = GetOrAddMetricDimension(connection, transaction, instrumentId, scope, point.Attributes, ingestionState); + var pendingLatest = dimension.PendingPoint; + var latest = dimension.LatestPoint; + var latestPointType = pendingLatest?.PointType ?? latest?.PointType; + var latestEndTimeTicks = pendingLatest?.EndTimeTicks ?? latest?.EndTimeTicks; + var histogramCount = checked((long)point.Count); + var sameCount = latestPointType == HistogramPointType && + (pendingLatest?.HistogramCount ?? latest?.HistogramCount) == histogramCount; + var endTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks; + if (sameCount) + { + if (pendingLatest is not null) + { + pendingLatest.EndTimeTicks = endTimeTicks; + pendingLatest.SourcePointCount++; + pendingLatest.Exemplars.AddRange(point.Exemplars); + } + else + { + pointBatch.AddUpdate(latest!.PointId, endTimeTicks, incrementRepeatCount: false); + latest.EndTimeTicks = endTimeTicks; + QueueMetricExemplars(pointBatch, latest.PointId, point.Exemplars); + context.SuccessCount++; + } + } + else + { + var start = OtlpHelpers.UnixNanoSecondsToDateTime(point.StartTimeUnixNano); + if (latestPointType == HistogramPointType) + { + start = new DateTime(latestEndTimeTicks!.Value, DateTimeKind.Utc); + } + var pendingPoint = new PendingMetricPoint + { + Context = context, + Dimension = dimension, + PointType = HistogramPointType, + StartTimeTicks = start.Ticks, + EndTimeTicks = endTimeTicks, + RepeatCount = 1, + HistogramSum = point.Sum, + HistogramCount = histogramCount, + Flags = (long)point.Flags, + HistogramBucketCounts = point.BucketCounts.Select(count => checked((long)count)).ToArray(), + HistogramExplicitBounds = point.ExplicitBounds.ToArray() + }; + pendingPoint.Exemplars.AddRange(point.Exemplars); + pointBatch.Inserts.Add(pendingPoint); + dimension.PendingPoint = pendingPoint; + ingestionState.DimensionsToTrim.Add(dimension); + } + } + catch (Exception exception) + { + context.FailureCount++; + _otlpContext.Logger.LogInformation(exception, "Error adding metric."); + } + } + + private void ExecuteMetricPointBatch(SqliteConnection connection, IDbTransaction transaction, MetricPointBatch pointBatch) + { + foreach (var updates in pointBatch.Updates.Values.Chunk(MaxMetricPointBatchSize)) + { + var sql = new StringBuilder(""" + WITH updates(point_id, end_time_ticks, repeat_delta) AS ( + VALUES + """); + var parameters = new DynamicParameters(); + var index = 0; + foreach (var update in updates) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@PointId{index}, @EndTimeTicks{index}, @RepeatDelta{index})"); + parameters.Add($"PointId{index}", update.PointId); + parameters.Add($"EndTimeTicks{index}", update.EndTimeTicks); + parameters.Add($"RepeatDelta{index}", update.RepeatDelta); + index++; + } + sql.AppendLine(); + sql.Append(""" + ) + UPDATE telemetry_metric_points AS points + SET end_time_ticks = updates.end_time_ticks, + repeat_count = points.repeat_count + updates.repeat_delta + FROM updates + WHERE points.point_id = updates.point_id; + """); + connection.Execute(sql.ToString(), parameters, transaction); + } + + // Keep one INSERT statement and RETURNING result set per point inside a single command. This preserves + // deterministic point-to-ID mapping for histogram and exemplar rows without a round trip per point. + foreach (var points in pointBatch.Inserts.Chunk(MaxMetricPointBatchSize)) + { + var insertSql = new StringBuilder(); + var insertParameters = new DynamicParameters(); + for (var i = 0; i < points.Length; i++) + { + var point = points[i]; + insertSql.Append(CultureInfo.InvariantCulture, $$""" + INSERT INTO telemetry_metric_points ( + dimension_id, point_type, start_time_ticks, end_time_ticks, repeat_count, + integer_value, double_value, histogram_sum, histogram_count, flags) + VALUES ( + @DimensionId{{i}}, @PointType{{i}}, @StartTimeTicks{{i}}, @EndTimeTicks{{i}}, @RepeatCount{{i}}, + @IntegerValue{{i}}, @DoubleValue{{i}}, @HistogramSum{{i}}, @HistogramCount{{i}}, @Flags{{i}}) + RETURNING point_id; + """); + insertParameters.Add($"DimensionId{i}", point.Dimension.DimensionId); + insertParameters.Add($"PointType{i}", point.PointType); + insertParameters.Add($"StartTimeTicks{i}", point.StartTimeTicks); + insertParameters.Add($"EndTimeTicks{i}", point.EndTimeTicks); + insertParameters.Add($"RepeatCount{i}", point.RepeatCount); + insertParameters.Add($"IntegerValue{i}", point.IntegerValue); + insertParameters.Add($"DoubleValue{i}", point.DoubleValue); + insertParameters.Add($"HistogramSum{i}", point.HistogramSum); + insertParameters.Add($"HistogramCount{i}", point.HistogramCount); + insertParameters.Add($"Flags{i}", point.Flags); + } + + using var reader = connection.QueryMultiple(insertSql.ToString(), insertParameters, transaction); + foreach (var point in points) + { + point.PointId = reader.ReadSingle(); + } + } + + InsertHistogramBucketCounts(connection, transaction, pointBatch.Inserts); + InsertHistogramExplicitBounds(connection, transaction, pointBatch.Inserts); + foreach (var point in pointBatch.Inserts) + { + QueueMetricExemplars(pointBatch, point.PointId, point.Exemplars); + } + InsertMetricExemplars(connection, transaction, pointBatch.Exemplars); + + foreach (var point in pointBatch.Inserts) + { + point.Context.SuccessCount += point.SourcePointCount; + + if (ReferenceEquals(point.Dimension.PendingPoint, point)) + { + point.Dimension.LatestPoint = new MetricPointRecord + { + PointId = point.PointId, + PointType = point.PointType, + EndTimeTicks = point.EndTimeTicks, + IntegerValue = point.IntegerValue, + DoubleValue = point.DoubleValue, + HistogramCount = point.HistogramCount + }; + point.Dimension.PendingPoint = null; + } + } + } + + private static void InsertHistogramBucketCounts( + SqliteConnection connection, + IDbTransaction transaction, + List points) + { + var bucketCounts = points + .Where(point => point.HistogramBucketCounts is { Length: > 0 }) + .SelectMany(point => point.HistogramBucketCounts!.Select((bucketCount, ordinal) => new PendingHistogramBucketCount(point.PointId, ordinal, bucketCount))) + .ToArray(); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + bucketCounts, + MaxMetricPointBatchSize, + "telemetry_metric_histogram_bucket_counts", + ["point_id", "ordinal", "bucket_count"], + static (row, parameters) => + { + parameters[0].Value = row.PointId; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.BucketCount; + }); + } + + private static void InsertHistogramExplicitBounds( + SqliteConnection connection, + IDbTransaction transaction, + List points) + { + var explicitBounds = points + .Where(point => point.HistogramExplicitBounds is { Length: > 0 }) + .SelectMany(point => point.HistogramExplicitBounds!.Select((explicitBound, ordinal) => new PendingHistogramExplicitBound(point.PointId, ordinal, explicitBound))) + .ToArray(); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + explicitBounds, + MaxMetricPointBatchSize, + "telemetry_metric_histogram_explicit_bounds", + ["point_id", "ordinal", "explicit_bound"], + static (row, parameters) => + { + parameters[0].Value = row.PointId; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.ExplicitBound; + }); + } + + private MetricDimensionState GetOrAddMetricDimension( + SqliteConnection connection, + IDbTransaction transaction, + long instrumentId, + OtlpScope scope, + RepeatedField pointAttributes, + MetricIngestionState ingestionState) + { + KeyValuePair[]? temporaryAttributes = null; + OtlpHelpers.CopyKeyValuePairs(pointAttributes, scope.Attributes, _otlpContext, out var copyCount, ref temporaryAttributes); + Array.Sort(temporaryAttributes, 0, copyCount, MetricAttributeComparer.Instance); + var attributes = temporaryAttributes.AsSpan(0, copyCount).ToArray(); + var attributeHash = GetMetricDimensionAttributeHash(attributes); + var cacheKey = (instrumentId, attributeHash); + if (ingestionState.LoadedDimensionInstruments.Add(instrumentId)) + { + var dimensions = connection.Query(""" + SELECT + d.dimension_id AS DimensionId, + a.attribute_key AS AttributeKey, + a.attribute_value AS AttributeValue, + p.point_id AS PointId, + p.point_type AS PointType, + p.end_time_ticks AS EndTimeTicks, + p.integer_value AS IntegerValue, + p.double_value AS DoubleValue, + p.histogram_count AS HistogramCount + FROM telemetry_metric_dimensions d + LEFT JOIN telemetry_metric_dimension_attributes a ON a.dimension_id = d.dimension_id + LEFT JOIN telemetry_metric_points p ON p.point_id = ( + SELECT point_id + FROM telemetry_metric_points + WHERE dimension_id = d.dimension_id + ORDER BY point_id DESC + LIMIT 1 + ) + WHERE d.instrument_id = @InstrumentId + ORDER BY d.dimension_id, a.ordinal; + """, new { InstrumentId = instrumentId }, transaction) + .GroupBy(record => record.DimensionId) + .Select(group => + { + var first = group.First(); + return new MetricDimensionState + { + DimensionId = group.Key, + Attributes = group + .Where(record => record.AttributeKey is not null) + .Select(record => KeyValuePair.Create(record.AttributeKey!, record.AttributeValue!)) + .ToArray(), + LatestPoint = first.PointId is not null + ? new MetricPointRecord + { + PointId = first.PointId.Value, + PointType = first.PointType!.Value, + EndTimeTicks = first.EndTimeTicks!.Value, + IntegerValue = first.IntegerValue, + DoubleValue = first.DoubleValue, + HistogramCount = first.HistogramCount + } + : null + }; + }) + .ToList(); + foreach (var loadedDimension in dimensions) + { + var dimensionCacheKey = (instrumentId, GetMetricDimensionAttributeHash(loadedDimension.Attributes)); + if (!ingestionState.Dimensions.TryGetValue(dimensionCacheKey, out var dimensionCandidates)) + { + dimensionCandidates = []; + ingestionState.Dimensions.Add(dimensionCacheKey, dimensionCandidates); + } + dimensionCandidates.Add(loadedDimension); + } + ingestionState.DimensionCounts[instrumentId] = dimensions.Count; + } + + if (!ingestionState.Dimensions.TryGetValue(cacheKey, out var candidates)) + { + candidates = []; + ingestionState.Dimensions.Add(cacheKey, candidates); + } + + foreach (var candidate in candidates) + { + if (candidate.Attributes.SequenceEqual(attributes)) + { + return candidate; + } + } + + var dimensionCount = ingestionState.DimensionCounts[instrumentId]; + if (dimensionCount >= TelemetryRepositoryLimits.MaxDimensionCount) + { + throw new InvalidOperationException($"Dimension limit of {TelemetryRepositoryLimits.MaxDimensionCount} reached."); + } + var dimension = new MetricDimensionState { Attributes = attributes }; + ingestionState.PendingDimensions.Add(new PendingMetricDimension(instrumentId, attributeHash, dimension)); + ingestionState.PendingDimensionAttributes.AddRange(attributes.Select((attribute, ordinal) => new PendingMetricDimensionAttribute( + dimension, + ordinal, + attribute.Key, + attribute.Value))); + + if (pointAttributes.Count == 1 && pointAttributes[0].Key == "otel.metric.overflow" && pointAttributes[0].Value.GetString() == "true") + { + connection.Execute("UPDATE telemetry_metric_instruments SET has_overflow = 1 WHERE instrument_id = @InstrumentId;", new { InstrumentId = instrumentId }, transaction); + MarkCachedInstrumentHasOverflow(instrumentId); + } + candidates.Add(dimension); + ingestionState.DimensionCounts[instrumentId] = dimensionCount + 1; + return dimension; + } + + private static void InsertMetricDimensions( + SqliteConnection connection, + IDbTransaction transaction, + List dimensions) + { + foreach (var batch in dimensions.Chunk(MaxMetricPointBatchSize)) + { + var sql = new StringBuilder(""" + INSERT INTO telemetry_metric_dimensions (instrument_id, attribute_hash) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@InstrumentId{index}, @AttributeHash{index})"); + parameters.Add($"InstrumentId{index}", batch[index].InstrumentId); + parameters.Add($"AttributeHash{index}", batch[index].AttributeHash); + } + sql.Append(""" + + RETURNING dimension_id AS DimensionId, instrument_id AS InstrumentId, attribute_hash AS AttributeHash; + """); + + // Hash collisions can produce indistinguishable inserted rows. Their IDs are interchangeable here + // because attributes and points are associated only after an ID is assigned to each pending dimension. + var pendingDimensions = batch + .GroupBy(dimension => (dimension.InstrumentId, dimension.AttributeHash)) + .ToDictionary(group => group.Key, group => new Queue(group)); + foreach (var insertedDimension in connection.Query(sql.ToString(), parameters, transaction)) + { + pendingDimensions[(insertedDimension.InstrumentId, insertedDimension.AttributeHash)] + .Dequeue() + .Dimension.DimensionId = insertedDimension.DimensionId; + } + } + } + + private static void InsertMetricDimensionAttributes( + SqliteConnection connection, + IDbTransaction transaction, + List attributes) + { + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + attributes, + MaxMetricPointBatchSize, + "telemetry_metric_dimension_attributes", + ["dimension_id", "ordinal", "attribute_key", "attribute_value"], + static (row, parameters) => + { + parameters[0].Value = row.Dimension.DimensionId; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.Key; + parameters[3].Value = row.Value; + }); + } + + private static long GetMetricDimensionAttributeHash(ReadOnlySpan> attributes) + { + var hash = new XxHash3(); + foreach (var attribute in attributes) + { + AppendHashValue(hash, attribute.Key); + AppendHashValue(hash, attribute.Value); + } + + return BinaryPrimitives.ReadInt64LittleEndian(hash.GetCurrentHash()); + + static void AppendHashValue(XxHash3 hash, string value) + { + var valueBytes = Encoding.UTF8.GetBytes(value); + Span lengthBytes = stackalloc byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, valueBytes.Length); + hash.Append(lengthBytes); + hash.Append(valueBytes); + } + } + + private void QueueMetricExemplars(MetricPointBatch pointBatch, long pointId, IEnumerable exemplars) + { + foreach (var exemplar in exemplars) + { + if (exemplar.TraceId is null || exemplar.SpanId is null) + { + continue; + } + var startTicks = OtlpHelpers.UnixNanoSecondsToDateTime(exemplar.TimeUnixNano).Ticks; + var value = exemplar.HasAsDouble ? exemplar.AsDouble : exemplar.AsInt; + pointBatch.Exemplars.TryAdd( + new MetricExemplarKey(pointId, startTicks, value), + new PendingMetricExemplar + { + PointId = pointId, + StartTimeTicks = startTicks, + Value = value, + SpanId = exemplar.SpanId.ToHexString(), + TraceId = exemplar.TraceId.ToHexString(), + Attributes = exemplar.FilteredAttributes.ToKeyValuePairs(_otlpContext) + }); + } + } + + private static void InsertMetricExemplars( + SqliteConnection connection, + IDbTransaction transaction, + Dictionary exemplars) + { + foreach (var batch in exemplars.Values.Chunk(MaxMetricPointBatchSize)) + { + var sql = new StringBuilder(""" + INSERT OR IGNORE INTO telemetry_metric_exemplars ( + point_id, start_time_ticks, exemplar_value, span_id, trace_id) + VALUES + """); + var parameters = new DynamicParameters(); + for (var index = 0; index < batch.Length; index++) + { + if (index > 0) + { + sql.AppendLine(","); + } + sql.Append(CultureInfo.InvariantCulture, $" (@PointId{index}, @StartTimeTicks{index}, @Value{index}, @SpanId{index}, @TraceId{index})"); + parameters.Add($"PointId{index}", batch[index].PointId); + parameters.Add($"StartTimeTicks{index}", batch[index].StartTimeTicks); + parameters.Add($"Value{index}", batch[index].Value); + parameters.Add($"SpanId{index}", batch[index].SpanId); + parameters.Add($"TraceId{index}", batch[index].TraceId); + } + sql.Append(""" + RETURNING + exemplar_id AS ExemplarId, + point_id AS PointId, + start_time_ticks AS StartTimeTicks, + exemplar_value AS ExemplarValue; + """); + foreach (var inserted in connection.Query(sql.ToString(), parameters, transaction)) + { + exemplars[new MetricExemplarKey(inserted.PointId, inserted.StartTimeTicks, inserted.ExemplarValue)].ExemplarId = inserted.ExemplarId; + } + } + + var attributes = exemplars.Values + .Where(exemplar => exemplar.ExemplarId is not null) + .SelectMany(exemplar => exemplar.Attributes.Select((attribute, ordinal) => new PendingMetricExemplarAttribute( + exemplar.ExemplarId!.Value, + ordinal, + attribute.Key, + attribute.Value))) + .ToArray(); + SqliteBatchInsert.BatchInsertRows( + connection, + transaction, + attributes, + MaxMetricPointBatchSize, + "telemetry_metric_exemplar_attributes", + ["exemplar_id", "ordinal", "attribute_key", "attribute_value"], + static (row, parameters) => + { + parameters[0].Value = row.ExemplarId; + parameters[1].Value = row.Ordinal; + parameters[2].Value = row.Key; + parameters[3].Value = row.Value; + }); + } + + private void TrimMetricDimensions(SqliteConnection connection, IDbTransaction transaction, IEnumerable dimensions) + { + foreach (var batch in dimensions.Chunk(MaxMetricPointBatchSize)) + { + connection.Execute(""" + DELETE FROM telemetry_metric_points + WHERE point_id IN ( + SELECT point_id + FROM ( + SELECT + point_id, + ROW_NUMBER() OVER (PARTITION BY dimension_id ORDER BY point_id DESC) AS point_rank + FROM telemetry_metric_points + WHERE dimension_id IN @DimensionIds + ) + WHERE point_rank > @MaxMetricsCount + ); + """, new { DimensionIds = batch.Select(dimension => dimension.DimensionId).ToArray(), _otlpContext.Options.MaxMetricsCount }, transaction); + } + } + + private void ClearSelectedMetricsFromDatabase(Dictionary> selectedResources) + { + using var connection = _database.OpenConnection(); + foreach (var resource in connection.Query("SELECT resource_name AS ResourceName, instance_id AS InstanceId FROM telemetry_resources;")) + { + var key = new ResourceKey(resource.ResourceName, resource.InstanceId); + if (selectedResources.TryGetValue(key.GetCompositeName(), out var dataTypes) && dataTypes.Contains(AspireDataType.Metrics) && !dataTypes.Contains(AspireDataType.Resource)) + { + ClearMetricsFromDatabase(key); + } + } + } + + private void ClearMetricsFromDatabase(ResourceKey? resourceKey) + { + lock (_writeLock) + { + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + var parameters = new DynamicParameters(); + var where = string.Empty; + if (resourceKey is not null) + { + where = " WHERE resource_name = @ResourceName COLLATE NOCASE"; + parameters.Add("ResourceName", resourceKey.Value.Name); + if (resourceKey.Value.InstanceId is not null) + { + where += " AND instance_id = @InstanceId COLLATE NOCASE"; + parameters.Add("InstanceId", resourceKey.Value.InstanceId); + } + } + connection.Execute($""" + DELETE FROM telemetry_metric_instruments + WHERE resource_id IN (SELECT resource_id FROM telemetry_resources{where}); + + UPDATE telemetry_resources + SET has_metrics = EXISTS (SELECT 1 FROM telemetry_metric_instruments WHERE telemetry_metric_instruments.resource_id = telemetry_resources.resource_id); + """, parameters, transaction); + DeleteOrphanedScopes(connection, transaction); + transaction.Commit(); + ClearIngestionCaches(); + } + } + + private static OtlpScope CreateScope(string name, string version, KeyValuePair[] attributes) + { + return name == OtlpScope.Empty.Name && version.Length == 0 && attributes.Length == 0 + ? OtlpScope.Empty + : new OtlpScope(name, version, attributes); + } + + private static OtlpInstrumentType MapMetricType(Metric.DataOneofCase dataCase) + { + return dataCase switch + { + Metric.DataOneofCase.Gauge => OtlpInstrumentType.Gauge, + Metric.DataOneofCase.Sum => OtlpInstrumentType.Sum, + Metric.DataOneofCase.Histogram => OtlpInstrumentType.Histogram, + _ => OtlpInstrumentType.Unsupported + }; + } + + private static OtlpAggregationTemporality MapAggregationTemporality(Metric metric) + { + return metric.DataCase switch + { + Metric.DataOneofCase.Sum => (OtlpAggregationTemporality)metric.Sum.AggregationTemporality, + Metric.DataOneofCase.Histogram => (OtlpAggregationTemporality)metric.Histogram.AggregationTemporality, + Metric.DataOneofCase.ExponentialHistogram => (OtlpAggregationTemporality)metric.ExponentialHistogram.AggregationTemporality, + _ => OtlpAggregationTemporality.Unspecified + }; + } + + private sealed class MetricAttributeComparer : IComparer> + { + public static readonly MetricAttributeComparer Instance = new(); + + public int Compare(KeyValuePair x, KeyValuePair y) => string.Compare(x.Key, y.Key, StringComparison.Ordinal); + } + + private sealed class MetricIngestionState + { + public Dictionary<(long InstrumentId, long AttributeHash), List> Dimensions { get; } = []; + public Dictionary DimensionCounts { get; } = []; + public HashSet LoadedDimensionInstruments { get; } = []; + public HashSet DimensionsToTrim { get; } = []; + public List PendingDimensions { get; } = []; + public List PendingDimensionAttributes { get; } = []; + + public void Clear() + { + Dimensions.Clear(); + DimensionCounts.Clear(); + LoadedDimensionInstruments.Clear(); + DimensionsToTrim.Clear(); + PendingDimensions.Clear(); + PendingDimensionAttributes.Clear(); + } + } + + private sealed record PendingMetricDimension(long InstrumentId, long AttributeHash, MetricDimensionState Dimension); + + private sealed class InsertedMetricDimensionRecord + { + public required long DimensionId { get; init; } + public required long InstrumentId { get; init; } + public required long AttributeHash { get; init; } + } + + private sealed record PendingMetricDimensionAttribute(MetricDimensionState Dimension, int Ordinal, string Key, string Value); + + private sealed class MetricDimensionState + { + public long DimensionId { get; set; } + public required KeyValuePair[] Attributes { get; init; } + public MetricPointRecord? LatestPoint { get; set; } + public PendingMetricPoint? PendingPoint { get; set; } + } + + private sealed class MetricPointBatch + { + public Dictionary Updates { get; } = []; + public List Inserts { get; } = []; + public Dictionary Exemplars { get; } = []; + + public void AddUpdate(long pointId, long endTimeTicks, bool incrementRepeatCount) + { + if (!Updates.TryGetValue(pointId, out var update)) + { + update = new MetricPointUpdate { PointId = pointId }; + Updates.Add(pointId, update); + } + update.EndTimeTicks = endTimeTicks; + if (incrementRepeatCount) + { + update.RepeatDelta++; + } + } + } + + private readonly record struct MetricExemplarKey(long PointId, long StartTimeTicks, double Value); + + private sealed class PendingMetricExemplar + { + public required long PointId { get; init; } + public required long StartTimeTicks { get; init; } + public required double Value { get; init; } + public required string SpanId { get; init; } + public required string TraceId { get; init; } + public required KeyValuePair[] Attributes { get; init; } + public long? ExemplarId { get; set; } + } + + private sealed record PendingMetricExemplarAttribute(long ExemplarId, int Ordinal, string Key, string Value); + + private sealed record PendingHistogramBucketCount(long PointId, int Ordinal, long BucketCount); + + private sealed record PendingHistogramExplicitBound(long PointId, int Ordinal, double ExplicitBound); + + private sealed class InsertedMetricExemplarRecord + { + public required long ExemplarId { get; init; } + public required long PointId { get; init; } + public required long StartTimeTicks { get; init; } + public required double ExemplarValue { get; init; } + } + + private sealed class MetricPointUpdate + { + public required long PointId { get; init; } + public long EndTimeTicks { get; set; } + public long RepeatDelta { get; set; } + } + + private sealed class PendingMetricPoint + { + public required AddContext Context { get; init; } + public required MetricDimensionState Dimension { get; init; } + public required int PointType { get; init; } + public required long StartTimeTicks { get; init; } + public required long EndTimeTicks { get; set; } + public required long RepeatCount { get; set; } + public long? IntegerValue { get; init; } + public double? DoubleValue { get; init; } + public double? HistogramSum { get; init; } + public long? HistogramCount { get; init; } + public required long Flags { get; init; } + public long PointId { get; set; } + public int SourcePointCount { get; set; } = 1; + public long[]? HistogramBucketCounts { get; init; } + public double[]? HistogramExplicitBounds { get; init; } + public List Exemplars { get; } = []; + } + + private sealed class MetricDimensionStateRecord + { + public required long DimensionId { get; init; } + public string? AttributeKey { get; init; } + public string? AttributeValue { get; init; } + public long? PointId { get; init; } + public int? PointType { get; init; } + public long? EndTimeTicks { get; init; } + public long? IntegerValue { get; init; } + public double? DoubleValue { get; init; } + public long? HistogramCount { get; init; } + } + + private class MetricPointRecord + { + public required long PointId { get; init; } + public required int PointType { get; init; } + public required long EndTimeTicks { get; set; } + public long? IntegerValue { get; init; } + public double? DoubleValue { get; init; } + public long? HistogramCount { get; init; } + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs index fa69277442f..ae5232ecc8b 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs @@ -1,3 +1,4 @@ +#if false // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. @@ -24,13 +25,13 @@ public sealed partial class SqliteTelemetryRepository private const int DoublePointType = 2; private const int HistogramPointType = 3; private const int MaxMetricPointBatchSize = 100; - private const string MetricPointRangeFilterSql = "(@ApplyRange = 0 OR (p.start_time_ticks <= @EndTicks AND p.end_time_ticks >= @StartTicks) OR (p.start_time_ticks >= @StartTicks AND p.end_time_ticks <= @EndTicks))"; + private const string MetricPointRangeFilterSql = "(@ApplyRange = 0 OR (p.start_time_ticks <= @EndTicks AND p.end_time_ticks >= r.start_time_ticks))"; private const string SelectedMetricPointsCteSql = $""" selected_metric_points AS ( SELECT p.* FROM telemetry_metric_points p - WHERE p.dimension_id IN @DimensionIds - AND {MetricPointRangeFilterSql} + JOIN metric_dimension_query_ranges r ON r.dimension_id = p.dimension_id + WHERE {MetricPointRangeFilterSql} ) """; private const string EffectiveMetricPointsCteSql = $""" @@ -49,30 +50,55 @@ FROM ranked_metric_points WHERE point_rank = 1 ) """; - - // Metric rows are value intervals for one dimension. A dimension that changes creates a new - // interval while unchanged dimensions extend their existing interval, so grouping only by the - // interval start omits unchanged dimensions. Convert each dimension's values into deltas; the - // query can then sum changes at each boundary and cumulatively reconstruct aggregate snapshots. - private const string MetricPointChangesCteSql = $""" + private static readonly string s_rolledUpMetricPointsCteSql = $""" {EffectiveMetricPointsCteSql}, - metric_point_changes AS ( + bucketed_metric_points AS ( SELECT p.*, - p.integer_value - LAG(p.integer_value, 1, 0) OVER ( - PARTITION BY p.dimension_id, p.point_type - ORDER BY p.start_time_ticks, p.point_id) AS integer_delta, - p.double_value - LAG(p.double_value, 1, 0) OVER ( - PARTITION BY p.dimension_id, p.point_type - ORDER BY p.start_time_ticks, p.point_id) AS double_delta, - p.histogram_sum - LAG(p.histogram_sum, 1, 0) OVER ( - PARTITION BY p.dimension_id, p.point_type - ORDER BY p.start_time_ticks, p.point_id) AS histogram_sum_delta, - p.histogram_count - LAG(p.histogram_count, 1, 0) OVER ( - PARTITION BY p.dimension_id, p.point_type - ORDER BY p.start_time_ticks, p.point_id) AS histogram_count_delta, - MAX(p.end_time_ticks) OVER () AS latest_end_time_ticks + CASE + WHEN @PointIntervalTicks = 0 THEN p.start_time_ticks + ELSE (p.start_time_ticks / @PointIntervalTicks) * @PointIntervalTicks + END AS rollup_start_time_ticks FROM effective_metric_points p + ), + ranked_rollup_metric_points AS ( + SELECT + p.*, + MAX(p.end_time_ticks) OVER ( + PARTITION BY p.dimension_id, p.point_type, p.rollup_start_time_ticks) AS rollup_end_time_ticks, + SUM(p.repeat_count) OVER ( + PARTITION BY p.dimension_id, p.point_type, p.rollup_start_time_ticks) AS rollup_repeat_count, + ROW_NUMBER() OVER ( + PARTITION BY p.dimension_id, p.point_type, p.rollup_start_time_ticks + ORDER BY + CASE WHEN p.point_type = {HistogramPointType} THEN p.start_time_ticks END DESC, + p.integer_value DESC, + p.double_value DESC, + p.point_id DESC) AS rollup_rank + FROM bucketed_metric_points p + ), + rolled_up_metric_points AS ( + SELECT + p.point_id, + p.dimension_id, + p.point_type, + p.rollup_start_time_ticks AS start_time_ticks, + p.rollup_end_time_ticks AS end_time_ticks, + p.rollup_repeat_count AS repeat_count, + p.integer_value, + p.double_value, + p.histogram_sum, + p.histogram_count, + p.flags + FROM ranked_rollup_metric_points p + WHERE p.rollup_rank = 1 + ) + """; + private const string FullFidelityMetricPointsCteSql = $""" + {EffectiveMetricPointsCteSql}, + rolled_up_metric_points AS ( + SELECT * + FROM effective_metric_points ) """; @@ -825,18 +851,20 @@ WHERE point_rank > @MaxMetricsCount using var connection = _database.OpenConnection(); var knownAttributeValues = new Dictionary>(); - var dimension = MaterializeMetricDimension( + var dimensions = MaterializeMetricDimensions( connection, instruments.Select(instrument => instrument.InstrumentId).ToArray(), instruments[0].Summary.Type == OtlpInstrumentType.Histogram, request.StartTime, request.EndTime, request.DimensionFilters, + request.DimensionCursors, + request.DataPointInterval, knownAttributeValues); return new OtlpInstrumentData { Summary = instruments[0].Summary, - Dimensions = [dimension], + Dimensions = dimensions, KnownAttributeValues = knownAttributeValues, HasOverflow = instruments.Any(instrument => instrument.HasOverflow) }; @@ -897,15 +925,22 @@ FROM telemetry_metric_points p return instrument; } - private DimensionScope MaterializeMetricDimension( + private List MaterializeMetricDimensions( SqliteConnection connection, IReadOnlyList instrumentIds, bool isHistogram, DateTime? startTime, DateTime? endTime, IReadOnlyDictionary> dimensionFilters, + IReadOnlyList dimensionCursors, + TimeSpan? dataPointInterval, Dictionary> knownAttributeValues) { + if (dataPointInterval is { } interval && interval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(dataPointInterval), interval, "The metric data point interval must be greater than zero."); + } + var dimensionIds = connection.Query("SELECT dimension_id FROM telemetry_metric_dimensions WHERE instrument_id IN @InstrumentIds ORDER BY dimension_id;", new { InstrumentIds = instrumentIds }).AsList(); var attributes = connection.Query(""" SELECT dimension_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue @@ -918,102 +953,107 @@ WHERE dimension_id IN @DimensionIds var selectedDimensionIds = dimensionIds .Where(dimensionId => MatchesDimensionFilters(attributes[dimensionId], dimensionFilters)) .ToArray(); + if (selectedDimensionIds.Length == 0) + { + return []; + } + var queryParameters = new DynamicParameters(); - queryParameters.Add("DimensionIds", selectedDimensionIds); queryParameters.Add("ApplyRange", startTime is not null && endTime is not null); - queryParameters.Add("StartTicks", startTime?.Ticks ?? 0); queryParameters.Add("EndTicks", endTime?.Ticks ?? 0); + queryParameters.Add("PointIntervalTicks", dataPointInterval?.Ticks ?? 0); + var dimensionQueryRangesCteSql = CreateMetricDimensionQueryRangesCte( + selectedDimensionIds, + attributes, + dimensionCursors, + startTime, + queryParameters); + var metricPointsCteSql = dataPointInterval is null ? FullFidelityMetricPointsCteSql : s_rolledUpMetricPointsCteSql; var points = connection.Query($""" - WITH {MetricPointChangesCteSql}, - aggregated_metric_point_changes AS ( - SELECT - p.start_time_ticks AS PointId, - p.point_type AS PointType, - p.start_time_ticks AS StartTimeTicks, - LEAD(p.start_time_ticks) OVER ( - PARTITION BY p.point_type - ORDER BY p.start_time_ticks) AS NextStartTimeTicks, - MAX(p.latest_end_time_ticks) AS LatestEndTimeTicks, - MAX(p.repeat_count) AS RepeatCount, - SUM(p.integer_delta) AS IntegerDelta, - SUM(p.double_delta) AS DoubleDelta, - SUM(p.histogram_sum_delta) AS HistogramSumDelta, - SUM(p.histogram_count_delta) AS HistogramCountDelta - FROM metric_point_changes p - GROUP BY p.point_type, p.start_time_ticks - ), - aggregated_metric_points AS ( - SELECT - p.PointId, - p.PointType, - p.StartTimeTicks, - COALESCE(p.NextStartTimeTicks, p.LatestEndTimeTicks) AS EndTimeTicks, - p.RepeatCount, - SUM(p.IntegerDelta) OVER ( - PARTITION BY p.PointType - ORDER BY p.StartTimeTicks - ROWS UNBOUNDED PRECEDING) AS IntegerValue, - SUM(p.DoubleDelta) OVER ( - PARTITION BY p.PointType - ORDER BY p.StartTimeTicks - ROWS UNBOUNDED PRECEDING) AS DoubleValue, - SUM(p.HistogramSumDelta) OVER ( - PARTITION BY p.PointType - ORDER BY p.StartTimeTicks - ROWS UNBOUNDED PRECEDING) AS HistogramSum, - SUM(p.HistogramCountDelta) OVER ( - PARTITION BY p.PointType - ORDER BY p.StartTimeTicks - ROWS UNBOUNDED PRECEDING) AS HistogramCount - FROM aggregated_metric_point_changes p - ) + WITH {dimensionQueryRangesCteSql}, + {metricPointsCteSql} SELECT - p.PointId, - 0 AS DimensionId, - p.PointType, - p.StartTimeTicks, - p.EndTimeTicks, - p.RepeatCount, - p.IntegerValue, - p.DoubleValue, - p.HistogramSum, - p.HistogramCount - FROM aggregated_metric_points p - ORDER BY p.StartTimeTicks; - """, queryParameters).AsList(); + p.point_id AS PointId, + p.dimension_id AS DimensionId, + p.point_type AS PointType, + p.start_time_ticks AS StartTimeTicks, + p.end_time_ticks AS EndTimeTicks, + p.repeat_count AS RepeatCount, + p.integer_value AS IntegerValue, + p.double_value AS DoubleValue, + p.histogram_sum AS HistogramSum, + p.histogram_count AS HistogramCount + FROM rolled_up_metric_points p + ORDER BY p.dimension_id, p.start_time_ticks, p.point_id; + """, queryParameters).ToLookup(record => record.DimensionId); var (bucketCounts, explicitBounds) = isHistogram - ? MaterializeMetricHistogramData(connection, queryParameters) + ? MaterializeMetricHistogramData(connection, dimensionQueryRangesCteSql, metricPointsCteSql, queryParameters) : (Array.Empty().ToLookup(record => record.PointId), Array.Empty().ToLookup(record => record.PointId)); - var exemplars = MaterializeMetricExemplars(connection, queryParameters); + var exemplars = MaterializeMetricExemplars(connection, dimensionQueryRangesCteSql, metricPointsCteSql, queryParameters); - var result = new DimensionScope(_otlpContext.Options.MaxMetricsCount, []); - if (startTime is not null && endTime is not null) + var results = new List(selectedDimensionIds.Length); + foreach (var dimensionId in selectedDimensionIds) { - foreach (var point in points) + var dimension = new DimensionScope( + _otlpContext.Options.MaxMetricsCount, + attributes[dimensionId].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray()); + if (startTime is not null && endTime is not null) { - MetricValueBase value = point.PointType switch + foreach (var point in points[dimensionId]) { - LongPointType => new MetricValue(point.IntegerValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), - DoublePointType => new MetricValue(point.DoubleValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), - HistogramPointType => new HistogramValue( - bucketCounts[point.PointId].Select(bucket => checked((ulong)bucket.BucketCount)).ToArray(), - point.HistogramSum!.Value, - checked((ulong)point.HistogramCount!.Value), - new DateTime(point.StartTimeTicks, DateTimeKind.Utc), - new DateTime(point.EndTimeTicks, DateTimeKind.Utc), - explicitBounds[point.PointId].Select(bound => bound.ExplicitBound).ToArray()), - _ => throw new InvalidOperationException($"Unknown metric point type '{point.PointType}'.") - }; - if (point.PointType != HistogramPointType) - { - value.Count = checked((ulong)point.RepeatCount); + MetricValueBase value = point.PointType switch + { + LongPointType => new MetricValue(point.IntegerValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), + DoublePointType => new MetricValue(point.DoubleValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), + HistogramPointType => new HistogramValue( + bucketCounts[point.PointId].Select(bucket => checked((ulong)bucket.BucketCount)).ToArray(), + point.HistogramSum!.Value, + checked((ulong)point.HistogramCount!.Value), + new DateTime(point.StartTimeTicks, DateTimeKind.Utc), + new DateTime(point.EndTimeTicks, DateTimeKind.Utc), + explicitBounds[point.PointId].Select(bound => bound.ExplicitBound).ToArray()), + _ => throw new InvalidOperationException($"Unknown metric point type '{point.PointType}'.") + }; + if (point.PointType != HistogramPointType) + { + value.Count = checked((ulong)point.RepeatCount); + } + value.Exemplars.AddRange(exemplars[point.PointId]); + dimension.Values.Add(value); } - value.Exemplars.AddRange(exemplars[point.PointId]); - result.Values.Add(value); } + results.Add(dimension); + } + return results; + } + + private static string CreateMetricDimensionQueryRangesCte( + IReadOnlyList selectedDimensionIds, + ILookup attributes, + IReadOnlyList dimensionCursors, + DateTime? defaultStartTime, + DynamicParameters queryParameters) + { + var sql = new StringBuilder("metric_dimension_query_ranges(dimension_id, start_time_ticks) AS (VALUES "); + for (var i = 0; i < selectedDimensionIds.Count; i++) + { + if (i > 0) + { + sql.Append(", "); + } + + var dimensionId = selectedDimensionIds[i]; + var dimensionAttributes = attributes[dimensionId]; + var cursor = dimensionCursors.FirstOrDefault(cursor => + cursor.Attributes.Length == dimensionAttributes.Count() && + cursor.Attributes.SequenceEqual(dimensionAttributes.Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)))); + queryParameters.Add($"DimensionId{i}", dimensionId); + queryParameters.Add($"DimensionStartTicks{i}", cursor?.StartTime.Ticks ?? defaultStartTime?.Ticks ?? 0); + sql.Append(CultureInfo.InvariantCulture, $"(@DimensionId{i}, @DimensionStartTicks{i})"); } - return result; + sql.Append(')'); + return sql.ToString(); } private static bool MatchesDimensionFilters(IEnumerable attributes, IReadOnlyDictionary> dimensionFilters) @@ -1059,46 +1099,29 @@ private static void PopulateKnownAttributeValues( private static (ILookup BucketCounts, ILookup ExplicitBounds) MaterializeMetricHistogramData( SqliteConnection connection, + string dimensionQueryRangesCteSql, + string metricPointsCteSql, DynamicParameters queryParameters) { var bucketCounts = connection.Query($""" - WITH {EffectiveMetricPointsCteSql}, - metric_histogram_bucket_changes AS ( - SELECT - p.start_time_ticks AS PointId, - b.ordinal AS Ordinal, - b.bucket_count - LAG(b.bucket_count, 1, 0) OVER ( - PARTITION BY p.dimension_id, b.ordinal - ORDER BY p.start_time_ticks, p.point_id) AS BucketDelta - FROM telemetry_metric_histogram_bucket_counts b - JOIN effective_metric_points p ON p.point_id = b.point_id - ), - aggregated_metric_histogram_bucket_changes AS ( - SELECT - p.PointId, - p.Ordinal, - SUM(p.BucketDelta) AS BucketDelta - FROM metric_histogram_bucket_changes p - GROUP BY p.PointId, p.Ordinal - ) + WITH {dimensionQueryRangesCteSql}, + {metricPointsCteSql} SELECT - p.PointId, - SUM(p.BucketDelta) OVER ( - PARTITION BY p.Ordinal - ORDER BY p.PointId - ROWS UNBOUNDED PRECEDING) AS BucketCount - FROM aggregated_metric_histogram_bucket_changes p - ORDER BY p.PointId, p.Ordinal; + p.point_id AS PointId, + b.bucket_count AS BucketCount + FROM telemetry_metric_histogram_bucket_counts b + JOIN rolled_up_metric_points p ON p.point_id = b.point_id + ORDER BY p.point_id, b.ordinal; """, queryParameters).ToLookup(record => record.PointId); var explicitBounds = connection.Query($""" - WITH {EffectiveMetricPointsCteSql} + WITH {dimensionQueryRangesCteSql}, + {metricPointsCteSql} SELECT - p.start_time_ticks AS PointId, - MAX(b.explicit_bound) AS ExplicitBound + p.point_id AS PointId, + b.explicit_bound AS ExplicitBound FROM telemetry_metric_histogram_explicit_bounds b - JOIN effective_metric_points p ON p.point_id = b.point_id - GROUP BY p.start_time_ticks, b.ordinal - ORDER BY p.start_time_ticks, b.ordinal; + JOIN rolled_up_metric_points p ON p.point_id = b.point_id + ORDER BY p.point_id, b.ordinal; """, queryParameters).ToLookup(record => record.PointId); return (bucketCounts, explicitBounds); @@ -1106,27 +1129,49 @@ FROM telemetry_metric_histogram_explicit_bounds b private static ILookup MaterializeMetricExemplars( SqliteConnection connection, + string dimensionQueryRangesCteSql, + string metricPointsCteSql, DynamicParameters queryParameters) { var records = connection.Query($""" - WITH {SelectedMetricPointsCteSql} + WITH {dimensionQueryRangesCteSql}, + {metricPointsCteSql} SELECT e.exemplar_id AS ExemplarId, - p.start_time_ticks AS PointId, + p.point_id AS PointId, e.start_time_ticks AS StartTimeTicks, e.exemplar_value AS ExemplarValue, e.span_id AS SpanId, e.trace_id AS TraceId FROM telemetry_metric_exemplars e - JOIN selected_metric_points p ON p.point_id = e.point_id - ORDER BY p.start_time_ticks, e.exemplar_id; + JOIN selected_metric_points source ON source.point_id = e.point_id + JOIN metric_dimension_query_ranges r ON r.dimension_id = source.dimension_id + JOIN rolled_up_metric_points p ON + p.dimension_id = source.dimension_id AND + p.point_type = source.point_type AND + p.start_time_ticks = CASE + WHEN @PointIntervalTicks = 0 THEN source.start_time_ticks + ELSE (source.start_time_ticks / @PointIntervalTicks) * @PointIntervalTicks + END + WHERE @ApplyRange = 0 OR (e.start_time_ticks >= r.start_time_ticks AND e.start_time_ticks <= @EndTicks) + ORDER BY p.point_id, e.exemplar_id; """, queryParameters).AsList(); var attributes = connection.Query($""" - WITH {SelectedMetricPointsCteSql} + WITH {dimensionQueryRangesCteSql}, + {metricPointsCteSql} SELECT a.exemplar_id AS OwnerId, a.attribute_key AS AttributeKey, a.attribute_value AS AttributeValue FROM telemetry_metric_exemplar_attributes a JOIN telemetry_metric_exemplars e ON e.exemplar_id = a.exemplar_id - JOIN selected_metric_points p ON p.point_id = e.point_id + JOIN selected_metric_points source ON source.point_id = e.point_id + JOIN metric_dimension_query_ranges r ON r.dimension_id = source.dimension_id + JOIN rolled_up_metric_points p ON + p.dimension_id = source.dimension_id AND + p.point_type = source.point_type AND + p.start_time_ticks = CASE + WHEN @PointIntervalTicks = 0 THEN source.start_time_ticks + ELSE (source.start_time_ticks / @PointIntervalTicks) * @PointIntervalTicks + END + WHERE @ApplyRange = 0 OR (e.start_time_ticks >= r.start_time_ticks AND e.start_time_ticks <= @EndTicks) ORDER BY a.exemplar_id, a.ordinal; """, queryParameters).ToLookup(record => record.OwnerId); return records.Select(record => new KeyValuePair(record.PointId, new MetricsExemplar @@ -1385,4 +1430,5 @@ private sealed class MetricExemplarRecord public required string SpanId { get; init; } public required string TraceId { get; init; } } -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs index 3b918f2e41b..d34fe870777 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs @@ -130,8 +130,9 @@ public void ChartContainer_AllDimensionsSelected_IncludesNewDimensionInFirstFetc value => Assert.Equal("POST", value)); var chart = cut.FindComponent(); - var dimension = Assert.Single(chart.Instance.InstrumentViewModel.MatchedDimensions!); - Assert.Equal(2, Assert.IsType>(Assert.Single(dimension.Values)).Value); + var dimensions = chart.Instance.InstrumentViewModel.MatchedDimensions!; + Assert.Equal(2, dimensions.Count); + Assert.All(dimensions, dimension => Assert.Equal(1, Assert.IsType>(Assert.Single(dimension.Values)).Value)); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/ChartDataCalculatorTests.cs b/tests/Aspire.Dashboard.Tests/ChartDataCalculatorTests.cs index 655a5ad1a9e..1b00b6b28e7 100644 --- a/tests/Aspire.Dashboard.Tests/ChartDataCalculatorTests.cs +++ b/tests/Aspire.Dashboard.Tests/ChartDataCalculatorTests.cs @@ -2,9 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Components.Controls.Chart; +using Aspire.Dashboard.Components; using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.MetricValues; +using Aspire.Dashboard.Otlp.Storage; using Microsoft.Extensions.Logging.Abstractions; using OpenTelemetry.Proto.Metrics.V1; using Xunit; @@ -166,6 +168,40 @@ public void TryCalculatePoint_MultipleDimensions_SumsValues() Assert.Equal(30, pointValue); } + [Fact] + public void TryCalculatePoint_StaggeredDimensionChanges_SumsCurrentValues() + { + var context = CreateContext(); + var stableDimension = new DimensionScope(capacity: 100, []); + var changingDimension = new DimensionScope(capacity: 100, []); + var start = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero); + + for (var minute = 1; minute <= 3; minute++) + { + stableDimension.AddPointValue(new NumberDataPoint + { + AsInt = 10, + StartTimeUnixNano = ToNanos(start), + TimeUnixNano = ToNanos(start.AddMinutes(minute)) + }, context); + changingDimension.AddPointValue(new NumberDataPoint + { + AsInt = 15 + (minute * 5), + StartTimeUnixNano = ToNanos(start), + TimeUnixNano = ToNanos(start.AddMinutes(minute)) + }, context); + } + + var result = ChartDataCalculator.TryCalculatePoint( + [stableDimension, changingDimension], + start.AddMinutes(3).AddSeconds(-1), + start.AddMinutes(3), + out var pointValue); + + Assert.True(result); + Assert.Equal(40, pointValue); + } + [Fact] public void TryCalculatePoint_MultipleMetricsInDimension_TakesMax() { @@ -297,6 +333,60 @@ public void CalculateHistogramValues_ProducesThreePercentileTraces() Assert.All(data.Traces, t => Assert.Empty(t.Tooltips)); } + [Fact] + public void TryCalculateHistogramPoints_StaggeredDimensionChanges_CombinesObservationDeltas() + { + var context = CreateContext(); + var stableDimension = new DimensionScope(capacity: 100, []); + var changingDimension = new DimensionScope(capacity: 100, []); + var start = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero); + + for (var minute = 1; minute <= 3; minute++) + { + stableDimension.AddHistogramValue(CreateHistogramPoint(start, minute, 10, [10, 0, 0]), context); + changingDimension.AddHistogramValue(CreateHistogramPoint(start, minute, checked((ulong)(15 + (minute * 5))), [0, 0, checked((ulong)(15 + (minute * 5)))]), context); + } + + var traces = new Dictionary + { + [25] = new ChartTrace { Name = "P25", Percentile = 25 } + }; + var exemplars = new List(); + + var firstResult = ChartDataCalculator.TryCalculateHistogramPoints( + [stableDimension, changingDimension], + start, + start, + traces, + exemplars, + ToLocal); + var secondResult = ChartDataCalculator.TryCalculateHistogramPoints( + [stableDimension, changingDimension], + start.AddMinutes(1), + start.AddMinutes(1), + traces, + exemplars, + ToLocal); + + Assert.True(firstResult); + Assert.True(secondResult); + Assert.Equal([10, 100], traces[25].Values); + + static HistogramDataPoint CreateHistogramPoint(DateTimeOffset start, int minute, ulong count, ulong[] bucketCounts) + { + var point = new HistogramDataPoint + { + StartTimeUnixNano = ToNanos(start), + TimeUnixNano = ToNanos(start.AddMinutes(minute)), + Count = count, + Sum = count + }; + point.ExplicitBounds.AddRange([10, 100]); + point.BucketCounts.AddRange(bucketCounts); + return point; + } + } + [Fact] public void CalculateHistogramValues_XValuesInChronologicalOrder() { @@ -347,5 +437,98 @@ public void CalculateChartValues_ToLocalApplied() Assert.All(data.XValues, x => Assert.Equal(offset, x.Offset)); } + [Fact] + public void MetricInstrumentDataCache_Merge_ReplacesTailAndAddsDimension() + { + var start = s_startTime.UtcDateTime; + KeyValuePair[] firstAttributes = [KeyValuePair.Create("dimension", "first")]; + KeyValuePair[] secondAttributes = [KeyValuePair.Create("dimension", "second")]; + var cachedDimension = new DimensionScope(capacity: 100, firstAttributes); + cachedDimension.Values.Add(new MetricValue(1, start, start.AddSeconds(1))); + cachedDimension.Values.Add(new MetricValue(2, start.AddSeconds(1), start.AddSeconds(2))); + + var refreshedDimension = new DimensionScope(capacity: 100, firstAttributes); + refreshedDimension.Values.Add(new MetricValue(1, start, start.AddSeconds(1))); + refreshedDimension.Values.Add(new MetricValue(2, start.AddSeconds(1), start.AddSeconds(3))); + refreshedDimension.Values.Add(new MetricValue(3, start.AddSeconds(3), start.AddSeconds(4))); + var newDimension = new DimensionScope(capacity: 100, secondAttributes); + newDimension.Values.Add(new MetricValue(4, start.AddSeconds(3), start.AddSeconds(4))); + + var cached = CreateInstrumentData([cachedDimension]); + var refreshed = CreateInstrumentData([refreshedDimension, newDimension]); + var cursors = new List + { + new() { Attributes = firstAttributes, StartTime = start.AddSeconds(1) } + }; + + var merged = MetricInstrumentDataCache.Merge(cached, refreshed, cursors, start); + + Assert.Collection( + merged.Dimensions[0].Values.Cast>(), + value => Assert.Equal((1L, start.AddSeconds(1)), (value.Value, value.End)), + value => Assert.Equal((2L, start.AddSeconds(3)), (value.Value, value.End)), + value => Assert.Equal((3L, start.AddSeconds(4)), (value.Value, value.End))); + Assert.Equal(4, Assert.IsType>(Assert.Single(merged.Dimensions[1].Values)).Value); + } + + [Fact] + public void MetricInstrumentDataCache_Merge_ReplacesLateArrivingValue() + { + var start = s_startTime.UtcDateTime; + KeyValuePair[] attributes = [KeyValuePair.Create("dimension", "first")]; + var cachedDimension = new DimensionScope(capacity: 100, attributes); + cachedDimension.Values.Add(new MetricValue(1, start, start.AddSeconds(5))); + cachedDimension.Values.Add(new MetricValue(2, start.AddSeconds(14), start.AddSeconds(15))); + cachedDimension.Values.Add(new MetricValue(3, start.AddSeconds(29), start.AddSeconds(30))); + + var refreshedDimension = new DimensionScope(capacity: 100, attributes); + refreshedDimension.Values.Add(new MetricValue(20, start.AddSeconds(14), start.AddSeconds(15))); + refreshedDimension.Values.Add(new MetricValue(3, start.AddSeconds(29), start.AddSeconds(30))); + + var cached = CreateInstrumentData([cachedDimension]); + var refreshed = CreateInstrumentData([refreshedDimension]); + var cursors = MetricInstrumentDataCache.CreateCursors(cached, TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(1)); + + Assert.Equal(start.AddSeconds(10), Assert.Single(cursors).StartTime); + + var merged = MetricInstrumentDataCache.Merge(cached, refreshed, cursors, start); + + Assert.Collection( + Assert.Single(merged.Dimensions).Values.Cast>(), + value => Assert.Equal((1L, start.AddSeconds(5)), (value.Value, value.End)), + value => Assert.Equal((20L, start.AddSeconds(15)), (value.Value, value.End)), + value => Assert.Equal((3L, start.AddSeconds(30)), (value.Value, value.End))); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(5, 1)] + [InlineData(15, 1)] + [InlineData(30, 60)] + [InlineData(720, 60)] + public void GetDataPointInterval_ReturnsDurationResolution(int durationMinutes, int expectedSeconds) + { + Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), ChartContainer.GetDataPointInterval(TimeSpan.FromMinutes(durationMinutes))); + } + + private static OtlpInstrumentData CreateInstrumentData(List dimensions) + { + return new OtlpInstrumentData + { + Summary = new OtlpInstrumentSummary + { + Name = "test", + Description = "test", + Unit = "items", + Type = OtlpInstrumentType.Gauge, + AggregationTemporality = OtlpAggregationTemporality.Cumulative, + Parent = OtlpScope.Empty + }, + Dimensions = dimensions, + KnownAttributeValues = [], + HasOverflow = false + }; + } + private static ulong ToNanos(DateTimeOffset dt) => (ulong)(dt.ToUnixTimeMilliseconds() * 1_000_000); } diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index 41c3cd9a3a0..cc5518397d1 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -4,6 +4,7 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Text; +using Aspire.Dashboard.Components; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Model.MetricValues; using Aspire.Dashboard.Otlp.Storage; @@ -187,7 +188,12 @@ public void AddMetrics_MeterAttributeLimits_LimitsApplied() }); var dimensionAttributes = instrument.Dimensions.Single().Attributes; - Assert.Empty(dimensionAttributes); + Assert.Collection(dimensionAttributes, + p => Assert.Equal(KeyValuePair.Create("Meter_Key0", "01234"), p), + p => Assert.Equal(KeyValuePair.Create("Meter_Key1", "0123456789"), p), + p => Assert.Equal(KeyValuePair.Create("Meter_Key2", "012345678901234"), p), + p => Assert.Equal(KeyValuePair.Create("Meter_Key3", "0123456789012345"), p), + p => Assert.Equal(KeyValuePair.Create("Meter_Key4", "0123456789012345"), p)); Assert.Equal(5, instrument.KnownAttributeValues.Count); } @@ -258,7 +264,12 @@ public void AddMetrics_MetricAttributeLimits_LimitsApplied() }); var dimensionAttributes = instrument.Dimensions.Single().Attributes; - Assert.Empty(dimensionAttributes); + Assert.Collection(dimensionAttributes, + p => Assert.Equal(KeyValuePair.Create("Meter_Key0", "01234"), p), + p => Assert.Equal(KeyValuePair.Create("Metric_Key0", "01234"), p), + p => Assert.Equal(KeyValuePair.Create("Metric_Key1", "0123456789"), p), + p => Assert.Equal(KeyValuePair.Create("Metric_Key2", "012345678901234"), p), + p => Assert.Equal(KeyValuePair.Create("Metric_Key3", "0123456789012345"), p)); Assert.Equal(5, instrument.KnownAttributeValues.Count); } @@ -341,10 +352,10 @@ public void GetInstrument() Assert.Equal(new[] { null, "value1", "" }, e.Value); }); - var dimension = Assert.Single(instrumentData.Dimensions); - Assert.Empty(dimension.Attributes); - Assert.Equal(5, Assert.IsType>(Assert.Single(dimension.Values)).Value); - var exemplar = Assert.Single(dimension.Values.SelectMany(value => value.Exemplars)); + Assert.Equal(5, instrumentData.Dimensions.Count); + Assert.All(instrumentData.Dimensions, dimension => Assert.Equal(1, Assert.IsType>(Assert.Single(dimension.Values)).Value)); + var dimensionWithoutAttributes = Assert.Single(instrumentData.Dimensions, dimension => dimension.Attributes.Length == 0); + var exemplar = Assert.Single(dimensionWithoutAttributes.Values.SelectMany(value => value.Exemplars)); Assert.Equal("key1", exemplar.Attributes[0].Key); Assert.Equal("value1", exemplar.Attributes[0].Value); @@ -363,16 +374,18 @@ public void GetInstrument() }); Assert.NotNull(filteredInstrumentData); - var filteredDimension = Assert.Single(filteredInstrumentData.Dimensions); - Assert.Empty(filteredDimension.Attributes); - var filteredValue = Assert.IsType>(Assert.Single(filteredDimension.Values)); - Assert.Equal(3, filteredValue.Value); + Assert.Equal(3, filteredInstrumentData.Dimensions.Count); + Assert.All(filteredInstrumentData.Dimensions, dimension => + { + Assert.Contains(KeyValuePair.Create("key1", "value1"), dimension.Attributes); + Assert.Equal(1, Assert.IsType>(Assert.Single(dimension.Values)).Value); + }); Assert.Equal(instrumentData.KnownAttributeValues, filteredInstrumentData.KnownAttributeValues); } [Fact] - public void GetInstrument_StaggeredDimensionChanges_AggregatesCurrentValues() + public void GetInstrument_StaggeredDimensionChanges_ReturnsCurrentValues() { var repository = CreateRepository(); var addContext = new AddContext(); @@ -424,8 +437,9 @@ public void GetInstrument_StaggeredDimensionChanges_AggregatesCurrentValues() }); Assert.NotNull(instrument); - var values = Assert.Single(instrument.Dimensions).Values.Cast>().ToArray(); - Assert.Equal(35, Assert.Single(values).Value); + var dimensions = instrument.Dimensions.ToDictionary(dimension => Assert.Single(dimension.Attributes).Value); + Assert.Equal(10, Assert.IsType>(Assert.Single(dimensions["stable"].Values)).Value); + Assert.Equal(25, Assert.IsType>(dimensions["changing"].Values[^1]).Value); } [Fact] @@ -650,9 +664,11 @@ public void GetMetrics_MultipleInstances() Assert.NotNull(instrument); Assert.Equal("test1", instrument.Summary.Name); - var dimension = Assert.Single(instrument.Dimensions); - Assert.Empty(dimension.Attributes); - Assert.Equal(6, Assert.IsType>(Assert.Single(dimension.Values)).Value); + Assert.Collection( + instrument.Dimensions.OrderBy(dimension => Assert.Single(dimension.Attributes).Value), + dimension => Assert.Equal(1, Assert.IsType>(Assert.Single(dimension.Values)).Value), + dimension => Assert.Equal(2, Assert.IsType>(Assert.Single(dimension.Values)).Value), + dimension => Assert.Equal(3, Assert.IsType>(Assert.Single(dimension.Values)).Value)); var knownValues = Assert.Single(instrument.KnownAttributeValues); Assert.Equal("key-1", knownValues.Key); @@ -825,7 +841,7 @@ public void RemoveMetrics_SelectedResource() Assert.Equal("test1", resource1Test1Instrument.Summary.Name); var resource1Test1Dimensions = Assert.Single(resource1Test1Instrument.Dimensions); - Assert.Equal(2, Assert.IsType>(Assert.Single(resource1Test1Dimensions.Values)).Value); + Assert.Equal(2, Assert.IsType>(resource1Test1Dimensions.Values[^1]).Value); var resource1Test2Instrument = repository.GetInstrument(new GetInstrumentRequest { @@ -1285,7 +1301,7 @@ public sealed class SqliteMetricsTests : MetricsTests protected override bool UseSqlite => true; [Fact] - public void GetInstrument_StaggeredDimensionChanges_SumsCurrentValuesAtEachChange() + public void GetInstrument_StaggeredDimensionChanges_ReturnsDimensionTimelines() { var repository = CreateRepository(); var addContext = new AddContext(); @@ -1325,15 +1341,17 @@ public void GetInstrument_StaggeredDimensionChanges_SumsCurrentValuesAtEachChang }); Assert.NotNull(instrument); - var values = Assert.Single(instrument.Dimensions).Values.Cast>().ToArray(); + var dimensions = instrument.Dimensions.ToDictionary(dimension => Assert.Single(dimension.Attributes).Value); + var stableValue = Assert.IsType>(Assert.Single(dimensions["stable"].Values)); + Assert.Equal(10, stableValue.Value); Assert.Collection( - values, - value => Assert.Equal(35, value.Value), - value => Assert.Equal(40, value.Value)); + dimensions["changing"].Values.Cast>(), + value => Assert.Equal(25, value.Value), + value => Assert.Equal(30, value.Value)); } [Fact] - public void GetHistogram_StaggeredDimensionChanges_SumsCurrentValuesAtEachChange() + public void GetHistogram_StaggeredDimensionChanges_ReturnsDimensionTimelines() { var repository = CreateRepository(); var addContext = new AddContext(); @@ -1373,23 +1391,26 @@ public void GetHistogram_StaggeredDimensionChanges_SumsCurrentValuesAtEachChange }); Assert.NotNull(instrument); - var values = Assert.Single(instrument.Dimensions).Values.Cast().ToArray(); + var dimensions = instrument.Dimensions.ToDictionary(dimension => Assert.Single(dimension.Attributes).Value); + var stableValue = Assert.IsType(Assert.Single(dimensions["stable"].Values)); + Assert.Equal(10ul, stableValue.Count); + Assert.Equal(10ul, stableValue.Values[0]); Assert.Collection( - values, + dimensions["changing"].Values.Cast(), value => { - Assert.Equal(30ul, value.Count); - Assert.Equal(30ul, value.Values[0]); + Assert.Equal(20ul, value.Count); + Assert.Equal(20ul, value.Values[0]); }, value => { - Assert.Equal(35ul, value.Count); - Assert.Equal(35ul, value.Values[0]); + Assert.Equal(25ul, value.Count); + Assert.Equal(25ul, value.Values[0]); }, value => { - Assert.Equal(40ul, value.Count); - Assert.Equal(40ul, value.Values[0]); + Assert.Equal(30ul, value.Count); + Assert.Equal(30ul, value.Values[0]); }); static Metric CreateTestHistogramMetric(DateTime startTime, int value, string dimension) @@ -1409,6 +1430,266 @@ static Metric CreateTestHistogramMetric(DateTime startTime, int value, string di } } + [Fact] + public void GetInstrument_DimensionCursor_ReturnsExtendedLatestPoint() + { + var repository = CreateRepository(); + var addContext = new AddContext(); + repository.AsWriter().AddMetrics(addContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateSumMetric( + metricName: "test", + startTime: s_queryTestTime.AddMinutes(1), + value: 10, + attributes: [KeyValuePair.Create("dimension", "one")], + exemplars: [CreateExemplar(s_queryTestTime.AddMinutes(1), 10)]) + } + } + } + } + }); + + var resource = Assert.Single(repository.GetResources()); + var initialInstrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = resource.ResourceKey, + MeterName = "test-meter", + InstrumentName = "test", + StartTime = s_queryTestTime, + EndTime = s_queryTestTime.AddMinutes(1) + }); + var initialDimension = Assert.Single(initialInstrument!.Dimensions); + var initialValue = Assert.IsType>(Assert.Single(initialDimension.Values)); + + repository.AsWriter().AddMetrics(addContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateSumMetric( + metricName: "test", + startTime: s_queryTestTime.AddMinutes(2), + value: 10, + attributes: [KeyValuePair.Create("dimension", "one")], + exemplars: [CreateExemplar(s_queryTestTime.AddMinutes(2), 20)]) + } + } + } + } + }); + + var refreshedInstrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = resource.ResourceKey, + MeterName = "test-meter", + InstrumentName = "test", + StartTime = s_queryTestTime, + EndTime = s_queryTestTime.AddMinutes(2), + DimensionCursors = + [ + new MetricDimensionCursor + { + Attributes = initialDimension.Attributes, + StartTime = s_queryTestTime.AddMinutes(1.5) + } + ] + }); + + Assert.Equal(0, addContext.FailureCount); + var refreshedValue = Assert.IsType>(Assert.Single(Assert.Single(refreshedInstrument!.Dimensions).Values)); + Assert.Equal(initialValue.Start, refreshedValue.Start); + Assert.Equal(s_queryTestTime.AddMinutes(2), refreshedValue.End); + Assert.Equal(2ul, refreshedValue.Count); + Assert.Equal(20, Assert.Single(refreshedValue.Exemplars).Value); + } + + [Fact] + public void GetInstrument_DataPointInterval_RollsUpNumericValuesAndExemplars() + { + var repository = CreateRepository(); + var addContext = new AddContext(); + repository.AsWriter().AddMetrics(addContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateSumMetric( + metricName: "test", + startTime: s_queryTestTime.AddMilliseconds(100), + value: 3, + exemplars: [CreateExemplar(s_queryTestTime.AddMilliseconds(100), 3)]), + CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMilliseconds(200), value: 2), + CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMilliseconds(800), value: 1) + } + } + } + } + }); + + var resource = Assert.Single(repository.GetResources()); + var instrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = resource.ResourceKey, + MeterName = "test-meter", + InstrumentName = "test", + StartTime = s_queryTestTime, + EndTime = s_queryTestTime.AddSeconds(1), + DataPointInterval = TimeSpan.FromSeconds(1) + }); + + Assert.Equal(0, addContext.FailureCount); + var value = Assert.IsType>(Assert.Single(Assert.Single(instrument!.Dimensions).Values)); + Assert.Equal(2, value.Value); + Assert.Equal(s_queryTestTime, value.Start); + var exemplar = Assert.Single(value.Exemplars); + Assert.Equal(3, exemplar.Value); + } + + [Fact] + public void GetInstrument_IncrementalRollup_RecomputesCompleteLatestBucket() + { + var repository = CreateRepository(); + var addContext = new AddContext(); + AddMetric(s_queryTestTime.AddSeconds(1), 5); + AddMetric(s_queryTestTime.AddSeconds(5), 5); + AddMetric(s_queryTestTime.AddSeconds(10), 3); + AddMetric(s_queryTestTime.AddMinutes(2), 3); + + var resource = Assert.Single(repository.GetResources()); + var initialInstrument = GetInstrument([]); + var initialValue = Assert.IsType>(Assert.Single(Assert.Single(initialInstrument.Dimensions).Values)); + var cursors = MetricInstrumentDataCache.CreateCursors(initialInstrument, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); + var cursor = Assert.Single(cursors); + + Assert.Equal(0, addContext.FailureCount); + Assert.Equal(5, initialValue.Value); + Assert.Equal(s_queryTestTime, cursor.StartTime); + + var refreshedInstrument = GetInstrument(cursors); + var refreshedValue = Assert.IsType>(Assert.Single(Assert.Single(refreshedInstrument.Dimensions).Values)); + Assert.Equal(initialValue.Value, refreshedValue.Value); + Assert.Equal(initialValue.Count, refreshedValue.Count); + Assert.Equal(initialValue.Start, refreshedValue.Start); + Assert.Equal(initialValue.End, refreshedValue.End); + + void AddMetric(DateTime startTime, int value) + { + repository.AsWriter().AddMetrics(addContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = { CreateSumMetric(metricName: "test", startTime: startTime, value: value) } + } + } + } + }); + } + + OtlpInstrumentData GetInstrument(IReadOnlyList dimensionCursors) + { + return repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = resource.ResourceKey, + MeterName = "test-meter", + InstrumentName = "test", + StartTime = s_queryTestTime, + EndTime = s_queryTestTime.AddMinutes(2), + DataPointInterval = TimeSpan.FromMinutes(1), + DimensionCursors = dimensionCursors + })!; + } + } + + [Fact] + public void GetHistogram_DataPointInterval_ReturnsLatestCoherentSnapshot() + { + var repository = CreateRepository(); + var addContext = new AddContext(); + var metrics = new List(); + for (var pointIndex = 1; pointIndex <= 3; pointIndex++) + { + var metric = CreateHistogramMetric("test", s_queryTestTime.AddMilliseconds(pointIndex * 100)); + var point = Assert.Single(metric.Histogram.DataPoints); + point.Count = checked((ulong)pointIndex); + point.Sum = pointIndex * 10; + point.BucketCounts.Clear(); + point.BucketCounts.AddRange(pointIndex switch + { + 1 => [1, 0, 0, 0], + 2 => [1, 1, 0, 0], + _ => [1, 1, 1, 0] + }); + if (pointIndex == 2) + { + point.Exemplars.Add(CreateExemplar(s_queryTestTime.AddMilliseconds(200), 20)); + } + metrics.Add(metric); + } + repository.AsWriter().AddMetrics(addContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = { metrics } + } + } + } + }); + + var resource = Assert.Single(repository.GetResources()); + var instrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = resource.ResourceKey, + MeterName = "test-meter", + InstrumentName = "test", + StartTime = s_queryTestTime, + EndTime = s_queryTestTime.AddMinutes(1), + DataPointInterval = TimeSpan.FromMinutes(1) + }); + + Assert.Equal(0, addContext.FailureCount); + var value = Assert.IsType(Assert.Single(Assert.Single(instrument!.Dimensions).Values)); + Assert.Equal(3ul, value.Count); + Assert.Equal(30, value.Sum); + Assert.Equal([1ul, 1ul, 1ul, 0ul], value.Values); + Assert.Equal(20, Assert.Single(value.Exemplars).Value); + } + [Fact] public void AddMetrics_ReusesInstrumentAndDimensionLookupsWithinBatch() { @@ -1518,9 +1799,18 @@ public void AddMetrics_LargeHistogramAndDimensionAttributeBatchesRoundTrip() StartTime = DateTime.MinValue, EndTime = DateTime.MaxValue }); - var sumDimension = Assert.Single(sumInstrument!.Dimensions); - Assert.Empty(sumDimension.Attributes); - Assert.Equal(2, Assert.IsType>(Assert.Single(sumDimension.Values)).Value); + Assert.Collection( + sumInstrument!.Dimensions, + dimension => + { + Assert.Equal(firstDimensionAttributes.OrderBy(attribute => attribute.Key), dimension.Attributes); + Assert.Equal(1, Assert.IsType>(Assert.Single(dimension.Values)).Value); + }, + dimension => + { + Assert.Equal(secondDimensionAttributes.OrderBy(attribute => attribute.Key), dimension.Attributes); + Assert.Equal(1, Assert.IsType>(Assert.Single(dimension.Values)).Value); + }); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs index a75e78a4d04..67e2b2ad7ee 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs @@ -275,7 +275,7 @@ public void Metrics_ReopenFromNormalizedRows() EndTime = startTime.AddMinutes(1) }); var dimension = Assert.Single(instrument!.Dimensions); - Assert.Empty(dimension.Attributes); + Assert.Equal(KeyValuePair.Create("route", "/api"), Assert.Single(dimension.Attributes)); var routeValues = Assert.Single(instrument.KnownAttributeValues); Assert.Equal("route", routeValues.Key); Assert.Equal("/api", Assert.Single(routeValues.Value)); diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index 763bff75163..8c83fa237cd 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -2202,53 +2202,12 @@ public List GetInstrumentsSummaries(ResourceKey key) return new OtlpInstrumentData { Summary = instruments[0].Summary, - Dimensions = [AggregateDimensions(matchingDimensions)], + Dimensions = matchingDimensions, KnownAttributeValues = allKnownAttributes, HasOverflow = hasOverflow }; } - private DimensionScope AggregateDimensions(IEnumerable dimensions) - { - var dimensionsList = dimensions.ToList(); - var result = new DimensionScope(_otlpContext.Options.MaxMetricsCount, []); - foreach (var startTime in dimensionsList.SelectMany(dimension => dimension.Values).Select(value => value.Start).Distinct().Order()) - { - var effectiveValues = dimensionsList - .Select(dimension => dimension.Values.LastOrDefault(value => value.Start == startTime)) - .OfType() - .ToList(); - var first = effectiveValues[0]; - MetricValueBase aggregate = first switch - { - MetricValue => new MetricValue(effectiveValues.Cast>().Sum(value => value.Value), startTime, effectiveValues.Max(value => value.End)), - MetricValue => new MetricValue(effectiveValues.Cast>().Sum(value => value.Value), startTime, effectiveValues.Max(value => value.End)), - HistogramValue histogram => new HistogramValue( - effectiveValues.Cast().Select(value => value.Values).Aggregate( - new ulong[histogram.Values.Length], - (totals, values) => - { - for (var index = 0; index < totals.Length; index++) - { - totals[index] += values[index]; - } - return totals; - }), - effectiveValues.Cast().Sum(value => value.Sum), - effectiveValues.Cast().Aggregate(0ul, (count, value) => count + value.Count), - startTime, - effectiveValues.Max(value => value.End), - histogram.ExplicitBounds), - _ => throw new InvalidOperationException($"Unknown metric value type '{first.GetType()}'.") - }; - - aggregate.Count = effectiveValues.Max(value => value.Count); - aggregate.Exemplars.AddRange(dimensionsList.SelectMany(dimension => dimension.Values).Where(value => value.Start == startTime).SelectMany(value => value.Exemplars).Distinct()); - result.Values.Add(aggregate); - } - return result; - } - private static bool MatchesDimensionFilters( KeyValuePair[] attributes, IReadOnlyDictionary> dimensionFilters) From fa47ba26a9ea9031e43eb25a4f9b6531bc2fce22 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 23 Jul 2026 00:48:31 +0800 Subject: [PATCH 66/87] Optimize dashboard metric queries --- .../TelemetryRepositoryMetricsBenchmarks.cs | 44 +- .../Controls/Chart/ChartContainer.razor.cs | 6 +- .../Controls/Chart/MetricDataPointInterval.cs | 25 + .../Otlp/Storage/GetInstrumentRequest.cs | 5 + ...SqliteTelemetryRepository.Metrics.Reads.cs | 269 ++-- .../SqliteTelemetryRepository.Metrics.cs | 1434 ----------------- .../DatabaseSchema/005.Metrics.sql | 2 + .../ChartDataCalculatorTests.cs | 8 +- .../TelemetryRepositoryTests/MetricsTests.cs | 61 + 9 files changed, 283 insertions(+), 1571 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Controls/Chart/MetricDataPointInterval.cs delete mode 100644 src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs index a609042289c..2bfdd301ebe 100644 --- a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs +++ b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using Aspire.Dashboard.Components; using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; @@ -29,17 +30,19 @@ public class TelemetryRepositoryMetricsBenchmarks private const string MetricMeterName = "benchmark-meter"; private const string MetricInstrumentName = "benchmark.metric"; private const string HistogramMetricInstrumentName = "benchmark.histogram"; - private static readonly TimeSpan s_metricDataDuration = TimeSpan.FromHours(12); - private static readonly TimeSpan s_metricDisplayDuration = TimeSpan.FromHours(12); + private static readonly TimeSpan s_metricDataDuration = TimeSpan.FromHours(6); + private static readonly TimeSpan s_metricDisplayDuration = TimeSpan.FromHours(6); private static readonly TimeSpan s_metricInterval = TimeSpan.FromSeconds(2); private static readonly TimeSpan s_metricExemplarInterval = TimeSpan.FromSeconds(10); + private static readonly TimeSpan s_metricDataPointInterval = MetricDataPointInterval.Get(s_metricDisplayDuration); + private static readonly TimeSpan s_metricHistoryDuration = TimeSpan.FromTicks(Math.Max(TimeSpan.FromSeconds(30).Ticks, s_metricDataPointInterval.Ticks)); private static readonly ResourceKey s_metricResourceKey = new("benchmark-app", "benchmark-instance"); private string _temporaryDirectory = null!; private SqliteTelemetryRepository _queryRepository = null!; private IReadOnlyList _incrementalCursors = null!; - [Params(1, 10)] + [Params(1, 5)] public int DimensionCount { get; set; } [GlobalSetup(Target = nameof(GetMetricsLongDuration))] @@ -78,14 +81,14 @@ private void Setup(bool isHistogram) private void SetupIncremental(bool isHistogram, string instrumentName) { Setup(isHistogram); - var instrument = GetLongDurationInstrument(instrumentName, TimeSpan.FromMinutes(1)); + var instrument = GetLongDurationInstrument(instrumentName, s_metricDataPointInterval); _incrementalCursors = instrument.Dimensions.Select(dimension => { var latestValue = dimension.Values[^1]; return new MetricDimensionCursor { Attributes = dimension.Attributes, - StartTime = latestValue.End.Subtract(TimeSpan.FromMinutes(1)) + StartTime = latestValue.End.Subtract(s_metricDataPointInterval) }; }).ToArray(); } @@ -97,7 +100,7 @@ public void Cleanup() Directory.Delete(_temporaryDirectory, recursive: true); } - [Benchmark(Description = "TelemetryRepository: query 12h metrics display")] + [Benchmark(Description = "TelemetryRepository: query 6h metrics display")] public int GetMetricsLongDuration() { var instrument = GetLongDurationInstrument(MetricInstrumentName); @@ -105,7 +108,7 @@ public int GetMetricsLongDuration() return instrument.Dimensions.Sum(dimension => dimension.Values.Count); } - [Benchmark(Description = "TelemetryRepository: query 12h histogram metrics display")] + [Benchmark(Description = "TelemetryRepository: query 6h histogram metrics display")] public int GetHistogramMetricsLongDuration() { var instrument = GetLongDurationInstrument(HistogramMetricInstrumentName); @@ -114,35 +117,35 @@ public int GetHistogramMetricsLongDuration() dimension.Values.Count + dimension.Values.Sum(value => value.Exemplars.Count)); } - [Benchmark(Description = "TelemetryRepository: query 12h metrics with 1m rollup")] + [Benchmark(Description = "TelemetryRepository: query 6h metrics with dashboard rollup")] public int GetMetricsLongDurationRollup() { - var instrument = GetLongDurationInstrument(MetricInstrumentName, TimeSpan.FromMinutes(1)); + var instrument = GetLongDurationInstrument(MetricInstrumentName, s_metricDataPointInterval); return instrument.Dimensions.Sum(dimension => dimension.Values.Count); } - [Benchmark(Description = "TelemetryRepository: query 12h histogram metrics with 1m rollup")] + [Benchmark(Description = "TelemetryRepository: query 6h histogram metrics with dashboard rollup")] public int GetHistogramMetricsLongDurationRollup() { - var instrument = GetLongDurationInstrument(HistogramMetricInstrumentName, TimeSpan.FromMinutes(1)); + var instrument = GetLongDurationInstrument(HistogramMetricInstrumentName, s_metricDataPointInterval); return instrument.Dimensions.Sum(dimension => dimension.Values.Count + dimension.Values.Sum(value => value.Exemplars.Count)); } - [Benchmark(Description = "TelemetryRepository: query incremental metrics with 1m rollup")] + [Benchmark(Description = "TelemetryRepository: query incremental metrics with dashboard rollup")] public int GetMetricsIncrementalRollup() { - var instrument = GetLongDurationInstrument(MetricInstrumentName, TimeSpan.FromMinutes(1), _incrementalCursors); + var instrument = GetLongDurationInstrument(MetricInstrumentName, s_metricDataPointInterval, _incrementalCursors); return instrument.Dimensions.Sum(dimension => dimension.Values.Count); } - [Benchmark(Description = "TelemetryRepository: query incremental histogram metrics with 1m rollup")] + [Benchmark(Description = "TelemetryRepository: query incremental histogram metrics with dashboard rollup")] public int GetHistogramMetricsIncrementalRollup() { - var instrument = GetLongDurationInstrument(HistogramMetricInstrumentName, TimeSpan.FromMinutes(1), _incrementalCursors); + var instrument = GetLongDurationInstrument(HistogramMetricInstrumentName, s_metricDataPointInterval, _incrementalCursors); return instrument.Dimensions.Sum(dimension => dimension.Values.Count + dimension.Values.Sum(value => value.Exemplars.Count)); @@ -156,15 +159,16 @@ private OtlpInstrumentData GetLongDurationInstrument( var endTime = _queryRepository.GetInstrumentLatestEndTime(s_metricResourceKey, MetricMeterName, instrumentName) ?? throw new InvalidOperationException($"Unable to find the benchmark metric '{instrumentName}' end time."); - // Match the dashboard metrics display query, which includes an extra 30 seconds for histogram calculations. + // Match the dashboard metrics display query, which includes one preceding rollup for histogram calculations. return _queryRepository.GetInstrument(new GetInstrumentRequest { ResourceKey = s_metricResourceKey, MeterName = MetricMeterName, InstrumentName = instrumentName, - StartTime = endTime.Subtract(s_metricDisplayDuration + TimeSpan.FromSeconds(30)), + StartTime = endTime.Subtract(s_metricDisplayDuration + s_metricHistoryDuration), EndTime = endTime, DataPointInterval = dataPointInterval, + PopulateExemplarAttributes = false, DimensionCursors = dimensionCursors ?? [] }) ?? throw new InvalidOperationException($"Unable to find the benchmark metric '{instrumentName}'."); } @@ -376,11 +380,7 @@ private sealed class Config : ManualConfig { public Config() { - AddJob(Job.Default - .WithWarmupCount(1) - .WithIterationCount(1) - .WithInvocationCount(1) - .WithUnrollFactor(1)); + AddJob(Job.Dry); AddDiagnoser(MemoryDiagnoser.Default); } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs index 20b7244e3b5..2fc53f8ca4f 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs @@ -191,7 +191,7 @@ protected override async Task OnParametersSetAsync() endDate = PauseManager.AreMetricsPaused(out var pausedAt) ? pausedAt.Value : DateTime.UtcNow; } - var dataPointInterval = GetDataPointInterval(Duration); + var dataPointInterval = MetricDataPointInterval.Get(Duration); // Histogram graphs need one preceding rollup to calculate bucket count changes at the beginning of the chart. var historyDuration = TimeSpan.FromTicks(Math.Max(TimeSpan.FromSeconds(30).Ticks, dataPointInterval.Ticks)); @@ -208,6 +208,7 @@ protected override async Task OnParametersSetAsync() StartTime = startDate, EndTime = endDate, DataPointInterval = dataPointInterval, + PopulateExemplarAttributes = false, DimensionCursors = cursors, DimensionFilters = DimensionFilters .Where(filter => filter.AreAllValuesSelected is not true) @@ -230,9 +231,6 @@ protected override async Task OnParametersSetAsync() : refreshedInstrument; } - internal static TimeSpan GetDataPointInterval(TimeSpan duration) - => duration <= TimeSpan.FromMinutes(15) ? TimeSpan.FromSeconds(1) : TimeSpan.FromMinutes(1); - private void EnsureDataEndTime() { var key = (ResourceKey, MeterName, InstrumentName); diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/MetricDataPointInterval.cs b/src/Aspire.Dashboard/Components/Controls/Chart/MetricDataPointInterval.cs new file mode 100644 index 00000000000..b025507a70e --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/Chart/MetricDataPointInterval.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Components; + +/// +/// Selects the metric data point interval for a chart duration. +/// +internal static class MetricDataPointInterval +{ + /// + /// Gets the metric data point interval for the specified chart duration. + /// + public static TimeSpan Get(TimeSpan duration) + { + if (duration >= TimeSpan.FromHours(3)) + { + return TimeSpan.FromMinutes(5); + } + + return duration <= TimeSpan.FromMinutes(15) + ? TimeSpan.FromSeconds(1) + : TimeSpan.FromMinutes(1); + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs b/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs index 96832567a00..6007f7e5a78 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs @@ -47,6 +47,11 @@ public sealed class GetInstrumentRequest /// Gets the interval used to roll up data points within each dimension. A value returns full-fidelity data. /// public TimeSpan? DataPointInterval { get; init; } + + /// + /// Gets a value indicating whether exemplar attributes should be populated. + /// + public bool PopulateExemplarAttributes { get; init; } = true; } /// diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs index 4d715569409..f178596314e 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs @@ -12,10 +12,22 @@ namespace Aspire.Dashboard.Otlp.Storage; public sealed partial class SqliteTelemetryRepository { - private const string MetricPointRangeFilterSql = "(@ApplyRange = 0 OR (p.start_time_ticks <= @EndTicks AND p.end_time_ticks >= r.start_time_ticks))"; + private const int MaxMetricReadBatchSize = 500; + private const string MetricPointRangeFilterSql = "p.start_time_ticks <= @EndTicks AND p.end_time_ticks >= r.start_time_ticks"; + private const string MetricSourcePointRangeFilterSql = "source.start_time_ticks <= @EndTicks AND source.end_time_ticks >= r.start_time_ticks"; private const string SelectedMetricPointsCteSql = $""" selected_metric_points AS ( - SELECT p.* + SELECT + p.point_id, + p.dimension_id, + p.point_type, + p.start_time_ticks, + p.end_time_ticks, + p.repeat_count, + p.integer_value, + p.double_value, + p.histogram_sum, + p.histogram_count FROM telemetry_metric_points p JOIN metric_dimension_query_ranges r ON r.dimension_id = p.dimension_id WHERE {MetricPointRangeFilterSql} @@ -42,10 +54,7 @@ FROM ranked_metric_points bucketed_metric_points AS ( SELECT p.*, - CASE - WHEN @PointIntervalTicks = 0 THEN p.start_time_ticks - ELSE (p.start_time_ticks / @PointIntervalTicks) * @PointIntervalTicks - END AS rollup_start_time_ticks + (p.start_time_ticks / @PointIntervalTicks) * @PointIntervalTicks AS rollup_start_time_ticks FROM effective_metric_points p ), ranked_rollup_metric_points AS ( @@ -75,8 +84,7 @@ rolled_up_metric_points AS ( p.integer_value, p.double_value, p.histogram_sum, - p.histogram_count, - p.flags + p.histogram_count FROM ranked_rollup_metric_points p WHERE p.rollup_rank = 1 ) @@ -84,8 +92,18 @@ FROM ranked_rollup_metric_points p private const string FullFidelityMetricPointsCteSql = $""" {EffectiveMetricPointsCteSql}, rolled_up_metric_points AS ( - SELECT * - FROM effective_metric_points + SELECT + p.point_id, + p.dimension_id, + p.point_type, + p.start_time_ticks, + p.end_time_ticks, + p.repeat_count, + p.integer_value, + p.double_value, + p.histogram_sum, + p.histogram_count + FROM effective_metric_points p ) """; @@ -108,6 +126,7 @@ FROM effective_metric_points request.DimensionFilters, request.DimensionCursors, request.DataPointInterval, + request.PopulateExemplarAttributes, knownAttributeValues); return new OtlpInstrumentData { @@ -182,6 +201,7 @@ private List MaterializeMetricDimensions( IReadOnlyDictionary> dimensionFilters, IReadOnlyList dimensionCursors, TimeSpan? dataPointInterval, + bool populateExemplarAttributes, Dictionary> knownAttributeValues) { if (dataPointInterval is { } interval && interval <= TimeSpan.Zero) @@ -206,9 +226,18 @@ WHERE dimension_id IN @DimensionIds return []; } + var results = selectedDimensionIds + .Select(dimensionId => new DimensionScope( + _otlpContext.Options.MaxMetricsCount, + attributes[dimensionId].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray())) + .ToList(); + if (startTime is null || endTime is null) + { + return results; + } + var queryParameters = new DynamicParameters(); - queryParameters.Add("ApplyRange", startTime is not null && endTime is not null); - queryParameters.Add("EndTicks", endTime?.Ticks ?? 0); + queryParameters.Add("EndTicks", endTime.Value.Ticks); queryParameters.Add("PointIntervalTicks", dataPointInterval?.Ticks ?? 0); var dimensionQueryRangesCteSql = CreateMetricDimensionQueryRangesCte( selectedDimensionIds, @@ -217,7 +246,7 @@ WHERE dimension_id IN @DimensionIds startTime, queryParameters); var metricPointsCteSql = dataPointInterval is null ? FullFidelityMetricPointsCteSql : s_rolledUpMetricPointsCteSql; - var points = connection.Query($""" + var pointRecords = connection.Query($""" WITH {dimensionQueryRangesCteSql}, {metricPointsCteSql} SELECT @@ -233,45 +262,46 @@ WHERE dimension_id IN @DimensionIds p.histogram_count AS HistogramCount FROM rolled_up_metric_points p ORDER BY p.dimension_id, p.start_time_ticks, p.point_id; - """, queryParameters).ToLookup(record => record.DimensionId); + """, queryParameters).AsList(); + var points = pointRecords.ToLookup(record => record.DimensionId); var (bucketCounts, explicitBounds) = isHistogram - ? MaterializeMetricHistogramData(connection, dimensionQueryRangesCteSql, metricPointsCteSql, queryParameters) + ? MaterializeMetricHistogramData(connection, pointRecords.Select(point => point.PointId).ToArray()) : (Array.Empty().ToLookup(record => record.PointId), Array.Empty().ToLookup(record => record.PointId)); - var exemplars = MaterializeMetricExemplars(connection, dimensionQueryRangesCteSql, metricPointsCteSql, queryParameters); + var exemplars = MaterializeMetricExemplars( + connection, + dimensionQueryRangesCteSql, + queryParameters, + dataPointInterval, + pointRecords, + populateExemplarAttributes); - var results = new List(selectedDimensionIds.Length); - foreach (var dimensionId in selectedDimensionIds) + for (var dimensionIndex = 0; dimensionIndex < selectedDimensionIds.Length; dimensionIndex++) { - var dimension = new DimensionScope( - _otlpContext.Options.MaxMetricsCount, - attributes[dimensionId].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray()); - if (startTime is not null && endTime is not null) + var dimensionId = selectedDimensionIds[dimensionIndex]; + var dimension = results[dimensionIndex]; + foreach (var point in points[dimensionId]) { - foreach (var point in points[dimensionId]) + MetricValueBase value = point.PointType switch { - MetricValueBase value = point.PointType switch - { - LongPointType => new MetricValue(point.IntegerValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), - DoublePointType => new MetricValue(point.DoubleValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), - HistogramPointType => new HistogramValue( - bucketCounts[point.PointId].Select(bucket => checked((ulong)bucket.BucketCount)).ToArray(), - point.HistogramSum!.Value, - checked((ulong)point.HistogramCount!.Value), - new DateTime(point.StartTimeTicks, DateTimeKind.Utc), - new DateTime(point.EndTimeTicks, DateTimeKind.Utc), - explicitBounds[point.PointId].Select(bound => bound.ExplicitBound).ToArray()), - _ => throw new InvalidOperationException($"Unknown metric point type '{point.PointType}'.") - }; - if (point.PointType != HistogramPointType) - { - value.Count = checked((ulong)point.RepeatCount); - } - value.Exemplars.AddRange(exemplars[point.PointId]); - dimension.Values.Add(value); + LongPointType => new MetricValue(point.IntegerValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), + DoublePointType => new MetricValue(point.DoubleValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), + HistogramPointType => new HistogramValue( + bucketCounts[point.PointId].Select(bucket => checked((ulong)bucket.BucketCount)).ToArray(), + point.HistogramSum!.Value, + checked((ulong)point.HistogramCount!.Value), + new DateTime(point.StartTimeTicks, DateTimeKind.Utc), + new DateTime(point.EndTimeTicks, DateTimeKind.Utc), + explicitBounds[point.PointId].Select(bound => bound.ExplicitBound).ToArray()), + _ => throw new InvalidOperationException($"Unknown metric point type '{point.PointType}'.") + }; + if (point.PointType != HistogramPointType) + { + value.Count = checked((ulong)point.RepeatCount); } + value.Exemplars.AddRange(exemplars[point.PointId]); + dimension.Values.Add(value); } - results.Add(dimension); } return results; } @@ -347,89 +377,108 @@ private static void PopulateKnownAttributeValues( private static (ILookup BucketCounts, ILookup ExplicitBounds) MaterializeMetricHistogramData( SqliteConnection connection, - string dimensionQueryRangesCteSql, - string metricPointsCteSql, - DynamicParameters queryParameters) + IReadOnlyList pointIds) { - var bucketCounts = connection.Query($""" - WITH {dimensionQueryRangesCteSql}, - {metricPointsCteSql} - SELECT - p.point_id AS PointId, - b.bucket_count AS BucketCount - FROM telemetry_metric_histogram_bucket_counts b - JOIN rolled_up_metric_points p ON p.point_id = b.point_id - ORDER BY p.point_id, b.ordinal; - """, queryParameters).ToLookup(record => record.PointId); - var explicitBounds = connection.Query($""" - WITH {dimensionQueryRangesCteSql}, - {metricPointsCteSql} - SELECT - p.point_id AS PointId, - b.explicit_bound AS ExplicitBound - FROM telemetry_metric_histogram_explicit_bounds b - JOIN rolled_up_metric_points p ON p.point_id = b.point_id - ORDER BY p.point_id, b.ordinal; - """, queryParameters).ToLookup(record => record.PointId); + var bucketCounts = new List(); + var explicitBounds = new List(); + foreach (var pointIdBatch in pointIds.Chunk(MaxMetricReadBatchSize)) + { + using var reader = connection.QueryMultiple(""" + SELECT point_id AS PointId, bucket_count AS BucketCount + FROM telemetry_metric_histogram_bucket_counts + WHERE point_id IN @PointIds + ORDER BY point_id, ordinal; - return (bucketCounts, explicitBounds); + SELECT point_id AS PointId, explicit_bound AS ExplicitBound + FROM telemetry_metric_histogram_explicit_bounds + WHERE point_id IN @PointIds + ORDER BY point_id, ordinal; + """, new { PointIds = pointIdBatch }); + bucketCounts.AddRange(reader.Read()); + explicitBounds.AddRange(reader.Read()); + } + + return ( + bucketCounts.ToLookup(record => record.PointId), + explicitBounds.ToLookup(record => record.PointId)); } private static ILookup MaterializeMetricExemplars( SqliteConnection connection, string dimensionQueryRangesCteSql, - string metricPointsCteSql, - DynamicParameters queryParameters) + DynamicParameters queryParameters, + TimeSpan? dataPointInterval, + IReadOnlyList points, + bool populateExemplarAttributes) { var records = connection.Query($""" - WITH {dimensionQueryRangesCteSql}, - {metricPointsCteSql} + WITH {dimensionQueryRangesCteSql} SELECT e.exemplar_id AS ExemplarId, - p.point_id AS PointId, + source.dimension_id AS DimensionId, + source.point_type AS PointType, + source.start_time_ticks AS SourceStartTimeTicks, e.start_time_ticks AS StartTimeTicks, e.exemplar_value AS ExemplarValue, e.span_id AS SpanId, e.trace_id AS TraceId FROM telemetry_metric_exemplars e - JOIN selected_metric_points source ON source.point_id = e.point_id + JOIN telemetry_metric_points source ON source.point_id = e.point_id JOIN metric_dimension_query_ranges r ON r.dimension_id = source.dimension_id - JOIN rolled_up_metric_points p ON - p.dimension_id = source.dimension_id AND - p.point_type = source.point_type AND - p.start_time_ticks = CASE - WHEN @PointIntervalTicks = 0 THEN source.start_time_ticks - ELSE (source.start_time_ticks / @PointIntervalTicks) * @PointIntervalTicks - END - WHERE @ApplyRange = 0 OR (e.start_time_ticks >= r.start_time_ticks AND e.start_time_ticks <= @EndTicks) - ORDER BY p.point_id, e.exemplar_id; + WHERE {MetricSourcePointRangeFilterSql} + AND e.start_time_ticks >= r.start_time_ticks + AND e.start_time_ticks <= @EndTicks + ORDER BY source.dimension_id, source.start_time_ticks, e.exemplar_id; """, queryParameters).AsList(); - var attributes = connection.Query($""" - WITH {dimensionQueryRangesCteSql}, - {metricPointsCteSql} - SELECT a.exemplar_id AS OwnerId, a.attribute_key AS AttributeKey, a.attribute_value AS AttributeValue - FROM telemetry_metric_exemplar_attributes a - JOIN telemetry_metric_exemplars e ON e.exemplar_id = a.exemplar_id - JOIN selected_metric_points source ON source.point_id = e.point_id - JOIN metric_dimension_query_ranges r ON r.dimension_id = source.dimension_id - JOIN rolled_up_metric_points p ON - p.dimension_id = source.dimension_id AND - p.point_type = source.point_type AND - p.start_time_ticks = CASE - WHEN @PointIntervalTicks = 0 THEN source.start_time_ticks - ELSE (source.start_time_ticks / @PointIntervalTicks) * @PointIntervalTicks - END - WHERE @ApplyRange = 0 OR (e.start_time_ticks >= r.start_time_ticks AND e.start_time_ticks <= @EndTicks) - ORDER BY a.exemplar_id, a.ordinal; - """, queryParameters).ToLookup(record => record.OwnerId); - return records.Select(record => new KeyValuePair(record.PointId, new MetricsExemplar + var pointIds = points.ToDictionary( + point => new MetricPointKey(point.DimensionId, point.PointType, point.StartTimeTicks), + point => point.PointId); + var pointIntervalTicks = dataPointInterval?.Ticks; + var mappedRecords = new List<(long PointId, MetricExemplarRecord Record)>(); + foreach (var record in records) { - Start = new DateTime(record.StartTimeTicks, DateTimeKind.Utc), - Value = record.ExemplarValue, - SpanId = record.SpanId, - TraceId = record.TraceId, - Attributes = attributes[record.ExemplarId].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray() - })).ToLookup(pair => pair.Key, pair => pair.Value); + var rollupStartTimeTicks = pointIntervalTicks is { } intervalTicks + ? (record.SourceStartTimeTicks / intervalTicks) * intervalTicks + : record.SourceStartTimeTicks; + if (pointIds.TryGetValue(new MetricPointKey(record.DimensionId, record.PointType, rollupStartTimeTicks), out var pointId)) + { + mappedRecords.Add((pointId, record)); + } + } + var attributes = populateExemplarAttributes + ? MaterializeMetricExemplarAttributes(connection, mappedRecords.Select(item => item.Record.ExemplarId).Distinct().ToArray()) + : Array.Empty().ToLookup(record => record.OwnerId); + return mappedRecords + .OrderBy(item => item.PointId) + .ThenBy(item => item.Record.ExemplarId) + .Select(item => new KeyValuePair( + item.PointId, + new MetricsExemplar + { + Start = new DateTime(item.Record.StartTimeTicks, DateTimeKind.Utc), + Value = item.Record.ExemplarValue, + SpanId = item.Record.SpanId, + TraceId = item.Record.TraceId, + Attributes = attributes[item.Record.ExemplarId].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray() + })) + .ToLookup(pair => pair.Key, pair => pair.Value); + } + + private static ILookup MaterializeMetricExemplarAttributes( + SqliteConnection connection, + IReadOnlyList exemplarIds) + { + var attributes = new List(); + foreach (var exemplarIdBatch in exemplarIds.Chunk(MaxMetricReadBatchSize)) + { + attributes.AddRange(connection.Query(""" + SELECT exemplar_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue + FROM telemetry_metric_exemplar_attributes + WHERE exemplar_id IN @ExemplarIds + ORDER BY exemplar_id, ordinal; + """, new { ExemplarIds = exemplarIdBatch })); + } + return attributes.ToLookup(record => record.OwnerId); } private sealed class MetricPointDataRecord : MetricPointRecord @@ -455,10 +504,14 @@ private sealed class MetricHistogramBoundRecord private sealed class MetricExemplarRecord { public required long ExemplarId { get; init; } - public required long PointId { get; init; } + public required long DimensionId { get; init; } + public required int PointType { get; init; } + public required long SourceStartTimeTicks { get; init; } public required long StartTimeTicks { get; init; } public required double ExemplarValue { get; init; } public required string SpanId { get; init; } public required string TraceId { get; init; } } + + private readonly record struct MetricPointKey(long DimensionId, int PointType, long StartTimeTicks); } \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs deleted file mode 100644 index ae5232ecc8b..00000000000 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.cs +++ /dev/null @@ -1,1434 +0,0 @@ -#if false -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Buffers.Binary; -using System.Data; -using System.Globalization; -using System.IO.Hashing; -using System.Text; -using Aspire.Dashboard.Model; -using Aspire.Dashboard.Otlp.Model; -using Aspire.Dashboard.Otlp.Model.MetricValues; -using Aspire.Dashboard.Utils; -using Dapper; -using Google.Protobuf.Collections; -using Microsoft.Data.Sqlite; -using OpenTelemetry.Proto.Common.V1; -using OpenTelemetry.Proto.Metrics.V1; - -namespace Aspire.Dashboard.Otlp.Storage; - -public sealed partial class SqliteTelemetryRepository -{ - private const int LongPointType = 1; - private const int DoublePointType = 2; - private const int HistogramPointType = 3; - private const int MaxMetricPointBatchSize = 100; - private const string MetricPointRangeFilterSql = "(@ApplyRange = 0 OR (p.start_time_ticks <= @EndTicks AND p.end_time_ticks >= r.start_time_ticks))"; - private const string SelectedMetricPointsCteSql = $""" - selected_metric_points AS ( - SELECT p.* - FROM telemetry_metric_points p - JOIN metric_dimension_query_ranges r ON r.dimension_id = p.dimension_id - WHERE {MetricPointRangeFilterSql} - ) - """; - private const string EffectiveMetricPointsCteSql = $""" - {SelectedMetricPointsCteSql}, - ranked_metric_points AS ( - SELECT - p.*, - ROW_NUMBER() OVER ( - PARTITION BY p.start_time_ticks, p.dimension_id - ORDER BY p.point_id DESC) AS point_rank - FROM selected_metric_points p - ), - effective_metric_points AS ( - SELECT * - FROM ranked_metric_points - WHERE point_rank = 1 - ) - """; - private static readonly string s_rolledUpMetricPointsCteSql = $""" - {EffectiveMetricPointsCteSql}, - bucketed_metric_points AS ( - SELECT - p.*, - CASE - WHEN @PointIntervalTicks = 0 THEN p.start_time_ticks - ELSE (p.start_time_ticks / @PointIntervalTicks) * @PointIntervalTicks - END AS rollup_start_time_ticks - FROM effective_metric_points p - ), - ranked_rollup_metric_points AS ( - SELECT - p.*, - MAX(p.end_time_ticks) OVER ( - PARTITION BY p.dimension_id, p.point_type, p.rollup_start_time_ticks) AS rollup_end_time_ticks, - SUM(p.repeat_count) OVER ( - PARTITION BY p.dimension_id, p.point_type, p.rollup_start_time_ticks) AS rollup_repeat_count, - ROW_NUMBER() OVER ( - PARTITION BY p.dimension_id, p.point_type, p.rollup_start_time_ticks - ORDER BY - CASE WHEN p.point_type = {HistogramPointType} THEN p.start_time_ticks END DESC, - p.integer_value DESC, - p.double_value DESC, - p.point_id DESC) AS rollup_rank - FROM bucketed_metric_points p - ), - rolled_up_metric_points AS ( - SELECT - p.point_id, - p.dimension_id, - p.point_type, - p.rollup_start_time_ticks AS start_time_ticks, - p.rollup_end_time_ticks AS end_time_ticks, - p.rollup_repeat_count AS repeat_count, - p.integer_value, - p.double_value, - p.histogram_sum, - p.histogram_count, - p.flags - FROM ranked_rollup_metric_points p - WHERE p.rollup_rank = 1 - ) - """; - private const string FullFidelityMetricPointsCteSql = $""" - {EffectiveMetricPointsCteSql}, - rolled_up_metric_points AS ( - SELECT * - FROM effective_metric_points - ) - """; - - private readonly MetricIngestionState _metricIngestionState = new(); - - private void AddMetricsToDatabase(AddContext context, RepeatedField resourceMetrics) - { - lock (_writeLock) - { - _metricIngestionState.DimensionsToTrim.Clear(); - _metricIngestionState.PendingDimensions.Clear(); - _metricIngestionState.PendingDimensionAttributes.Clear(); - try - { - using var connection = _database.OpenConnection(); - using var transaction = connection.BeginTransaction(); - var pointBatch = new MetricPointBatch(); - foreach (var resourceMetricsItem in resourceMetrics) - { - CachedResource cachedResource; - try - { - cachedResource = GetOrAddCachedResource(connection, transaction, resourceMetricsItem.Resource.GetResourceKey()); - } - catch (Exception exception) - { - context.FailureCount += resourceMetricsItem.ScopeMetrics.Sum(scope => scope.Metrics.Sum(OtlpResource.GetMetricDataPointCount)); - _otlpContext.Logger.LogInformation(exception, "Error adding resource."); - continue; - } - - foreach (var scopeMetrics in resourceMetricsItem.ScopeMetrics) - { - CachedResourceScope cachedScope; - try - { - cachedScope = GetOrAddCachedScope(connection, transaction, cachedResource, scopeMetrics.Scope, CachedTelemetryType.Metrics); - } - catch (Exception exception) - { - context.FailureCount += scopeMetrics.Metrics.Sum(OtlpResource.GetMetricDataPointCount); - _otlpContext.Logger.LogInformation(exception, "Error adding metric scope."); - continue; - } - - EnsureCachedInstruments(connection, transaction, cachedResource, cachedScope, scopeMetrics.Metrics); - foreach (var metric in scopeMetrics.Metrics) - { - AddMetricToDatabase(connection, transaction, context, cachedResource, cachedScope, metric, _metricIngestionState, pointBatch); - } - } - - if (!cachedResource.Resource.HasMetrics) - { - connection.Execute( - "UPDATE telemetry_resources SET has_metrics = 1 WHERE resource_id = @ResourceId;", - new { cachedResource.ResourceId }, - transaction); - cachedResource.Resource.HasMetrics = true; - } - } - - InsertMetricDimensions(connection, transaction, _metricIngestionState.PendingDimensions); - InsertMetricDimensionAttributes(connection, transaction, _metricIngestionState.PendingDimensionAttributes); - ExecuteMetricPointBatch(connection, transaction, pointBatch); - - TrimMetricDimensions(connection, transaction, _metricIngestionState.DimensionsToTrim); - - transaction.Commit(); - _metricIngestionState.DimensionsToTrim.Clear(); - _metricIngestionState.PendingDimensions.Clear(); - _metricIngestionState.PendingDimensionAttributes.Clear(); - } - catch - { - // Cache entries can refer to changes that were rolled back with the transaction. - ClearIngestionCaches(); - throw; - } - } - } - - private void AddMetricToDatabase( - SqliteConnection connection, - IDbTransaction transaction, - AddContext context, - CachedResource cachedResource, - CachedResourceScope cachedScope, - Metric metric, - MetricIngestionState ingestionState, - MetricPointBatch pointBatch) - { - var pointCount = OtlpResource.GetMetricDataPointCount(metric); - if (metric.DataCase is Metric.DataOneofCase.Summary or Metric.DataOneofCase.ExponentialHistogram) - { - context.FailureCount += pointCount; - _otlpContext.Logger.LogInformation("Error adding {MetricType} metrics. {MetricType} is not supported.", metric.DataCase, metric.DataCase); - return; - } - if (metric.DataCase is Metric.DataOneofCase.None) - { - return; - } - - CachedInstrument cachedInstrument; - try - { - cachedInstrument = GetOrAddCachedInstrument(connection, transaction, cachedResource, cachedScope, metric); - } - catch (Exception exception) - { - context.FailureCount += pointCount; - _otlpContext.Logger.LogInformation(exception, "Error adding metric instrument {MetricName}.", metric.Name); - return; - } - - switch (metric.DataCase) - { - case Metric.DataOneofCase.Gauge: - foreach (var point in metric.Gauge.DataPoints) - { - AddNumberMetricPoint(connection, transaction, context, cachedInstrument.InstrumentId, cachedScope.Scope.Scope, point, ingestionState, pointBatch); - } - break; - case Metric.DataOneofCase.Sum: - foreach (var point in metric.Sum.DataPoints) - { - AddNumberMetricPoint(connection, transaction, context, cachedInstrument.InstrumentId, cachedScope.Scope.Scope, point, ingestionState, pointBatch); - } - break; - case Metric.DataOneofCase.Histogram: - foreach (var point in metric.Histogram.DataPoints) - { - AddHistogramMetricPoint(connection, transaction, context, cachedInstrument.InstrumentId, cachedScope.Scope.Scope, point, ingestionState, pointBatch); - } - break; - } - } - - private void AddNumberMetricPoint( - SqliteConnection connection, - IDbTransaction transaction, - AddContext context, - long instrumentId, - OtlpScope scope, - NumberDataPoint point, - MetricIngestionState ingestionState, - MetricPointBatch pointBatch) - { - try - { - var dimension = GetOrAddMetricDimension(connection, transaction, instrumentId, scope, point.Attributes, ingestionState); - var pointType = point.ValueCase switch - { - NumberDataPoint.ValueOneofCase.AsInt => LongPointType, - NumberDataPoint.ValueOneofCase.AsDouble => DoublePointType, - _ => throw new InvalidOperationException("Metric data point has no value.") - }; - var pendingLatest = dimension.PendingPoint; - var latest = dimension.LatestPoint; - var latestPointType = pendingLatest?.PointType ?? latest?.PointType; - var latestEndTimeTicks = pendingLatest?.EndTimeTicks ?? latest?.EndTimeTicks; - var sameValue = latestPointType == pointType && (pendingLatest is not null - ? pointType == LongPointType ? pendingLatest.IntegerValue == point.AsInt : pendingLatest.DoubleValue == point.AsDouble - : pointType == LongPointType ? latest?.IntegerValue == point.AsInt : latest?.DoubleValue == point.AsDouble); - var endTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks; - if (sameValue) - { - if (pendingLatest is not null) - { - pendingLatest.EndTimeTicks = endTimeTicks; - pendingLatest.RepeatCount++; - pendingLatest.SourcePointCount++; - pendingLatest.Exemplars.AddRange(point.Exemplars); - } - else - { - pointBatch.AddUpdate(latest!.PointId, endTimeTicks, incrementRepeatCount: true); - latest.EndTimeTicks = endTimeTicks; - QueueMetricExemplars(pointBatch, latest.PointId, point.Exemplars); - context.SuccessCount++; - } - } - else - { - var start = OtlpHelpers.UnixNanoSecondsToDateTime(point.StartTimeUnixNano); - if (latestPointType == pointType) - { - start = new DateTime(latestEndTimeTicks!.Value, DateTimeKind.Utc); - } - var pendingPoint = new PendingMetricPoint - { - Context = context, - Dimension = dimension, - PointType = pointType, - StartTimeTicks = start.Ticks, - EndTimeTicks = endTimeTicks, - RepeatCount = 1, - IntegerValue = pointType == LongPointType ? point.AsInt : (long?)null, - DoubleValue = pointType == DoublePointType ? point.AsDouble : (double?)null, - Flags = (long)point.Flags - }; - pendingPoint.Exemplars.AddRange(point.Exemplars); - pointBatch.Inserts.Add(pendingPoint); - dimension.PendingPoint = pendingPoint; - ingestionState.DimensionsToTrim.Add(dimension); - } - } - catch (Exception exception) - { - context.FailureCount++; - _otlpContext.Logger.LogInformation(exception, "Error adding metric."); - } - } - - private void AddHistogramMetricPoint( - SqliteConnection connection, - IDbTransaction transaction, - AddContext context, - long instrumentId, - OtlpScope scope, - HistogramDataPoint point, - MetricIngestionState ingestionState, - MetricPointBatch pointBatch) - { - try - { - if (point.BucketCounts.Count > 0 && point.ExplicitBounds.Count == 0) - { - throw new InvalidOperationException("Histogram data point has bucket counts without any explicit bounds."); - } - var dimension = GetOrAddMetricDimension(connection, transaction, instrumentId, scope, point.Attributes, ingestionState); - var pendingLatest = dimension.PendingPoint; - var latest = dimension.LatestPoint; - var latestPointType = pendingLatest?.PointType ?? latest?.PointType; - var latestEndTimeTicks = pendingLatest?.EndTimeTicks ?? latest?.EndTimeTicks; - var histogramCount = checked((long)point.Count); - var sameCount = latestPointType == HistogramPointType && - (pendingLatest?.HistogramCount ?? latest?.HistogramCount) == histogramCount; - var endTimeTicks = OtlpHelpers.UnixNanoSecondsToDateTime(point.TimeUnixNano).Ticks; - if (sameCount) - { - if (pendingLatest is not null) - { - pendingLatest.EndTimeTicks = endTimeTicks; - pendingLatest.SourcePointCount++; - pendingLatest.Exemplars.AddRange(point.Exemplars); - } - else - { - pointBatch.AddUpdate(latest!.PointId, endTimeTicks, incrementRepeatCount: false); - latest.EndTimeTicks = endTimeTicks; - QueueMetricExemplars(pointBatch, latest.PointId, point.Exemplars); - context.SuccessCount++; - } - } - else - { - var start = OtlpHelpers.UnixNanoSecondsToDateTime(point.StartTimeUnixNano); - if (latestPointType == HistogramPointType) - { - start = new DateTime(latestEndTimeTicks!.Value, DateTimeKind.Utc); - } - var pendingPoint = new PendingMetricPoint - { - Context = context, - Dimension = dimension, - PointType = HistogramPointType, - StartTimeTicks = start.Ticks, - EndTimeTicks = endTimeTicks, - RepeatCount = 1, - HistogramSum = point.Sum, - HistogramCount = histogramCount, - Flags = (long)point.Flags, - HistogramBucketCounts = point.BucketCounts.Select(count => checked((long)count)).ToArray(), - HistogramExplicitBounds = point.ExplicitBounds.ToArray() - }; - pendingPoint.Exemplars.AddRange(point.Exemplars); - pointBatch.Inserts.Add(pendingPoint); - dimension.PendingPoint = pendingPoint; - ingestionState.DimensionsToTrim.Add(dimension); - } - } - catch (Exception exception) - { - context.FailureCount++; - _otlpContext.Logger.LogInformation(exception, "Error adding metric."); - } - } - - private void ExecuteMetricPointBatch(SqliteConnection connection, IDbTransaction transaction, MetricPointBatch pointBatch) - { - foreach (var updates in pointBatch.Updates.Values.Chunk(MaxMetricPointBatchSize)) - { - var sql = new StringBuilder(""" - WITH updates(point_id, end_time_ticks, repeat_delta) AS ( - VALUES - """); - var parameters = new DynamicParameters(); - var index = 0; - foreach (var update in updates) - { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@PointId{index}, @EndTimeTicks{index}, @RepeatDelta{index})"); - parameters.Add($"PointId{index}", update.PointId); - parameters.Add($"EndTimeTicks{index}", update.EndTimeTicks); - parameters.Add($"RepeatDelta{index}", update.RepeatDelta); - index++; - } - sql.AppendLine(); - sql.Append(""" - ) - UPDATE telemetry_metric_points AS points - SET end_time_ticks = updates.end_time_ticks, - repeat_count = points.repeat_count + updates.repeat_delta - FROM updates - WHERE points.point_id = updates.point_id; - """); - connection.Execute(sql.ToString(), parameters, transaction); - } - - // Keep one INSERT statement and RETURNING result set per point inside a single command. This preserves - // deterministic point-to-ID mapping for histogram and exemplar rows without a round trip per point. - foreach (var points in pointBatch.Inserts.Chunk(MaxMetricPointBatchSize)) - { - var insertSql = new StringBuilder(); - var insertParameters = new DynamicParameters(); - for (var i = 0; i < points.Length; i++) - { - var point = points[i]; - insertSql.Append(CultureInfo.InvariantCulture, $$""" - INSERT INTO telemetry_metric_points ( - dimension_id, point_type, start_time_ticks, end_time_ticks, repeat_count, - integer_value, double_value, histogram_sum, histogram_count, flags) - VALUES ( - @DimensionId{{i}}, @PointType{{i}}, @StartTimeTicks{{i}}, @EndTimeTicks{{i}}, @RepeatCount{{i}}, - @IntegerValue{{i}}, @DoubleValue{{i}}, @HistogramSum{{i}}, @HistogramCount{{i}}, @Flags{{i}}) - RETURNING point_id; - """); - insertParameters.Add($"DimensionId{i}", point.Dimension.DimensionId); - insertParameters.Add($"PointType{i}", point.PointType); - insertParameters.Add($"StartTimeTicks{i}", point.StartTimeTicks); - insertParameters.Add($"EndTimeTicks{i}", point.EndTimeTicks); - insertParameters.Add($"RepeatCount{i}", point.RepeatCount); - insertParameters.Add($"IntegerValue{i}", point.IntegerValue); - insertParameters.Add($"DoubleValue{i}", point.DoubleValue); - insertParameters.Add($"HistogramSum{i}", point.HistogramSum); - insertParameters.Add($"HistogramCount{i}", point.HistogramCount); - insertParameters.Add($"Flags{i}", point.Flags); - } - - using var reader = connection.QueryMultiple(insertSql.ToString(), insertParameters, transaction); - foreach (var point in points) - { - point.PointId = reader.ReadSingle(); - } - } - - InsertHistogramBucketCounts(connection, transaction, pointBatch.Inserts); - InsertHistogramExplicitBounds(connection, transaction, pointBatch.Inserts); - foreach (var point in pointBatch.Inserts) - { - QueueMetricExemplars(pointBatch, point.PointId, point.Exemplars); - } - InsertMetricExemplars(connection, transaction, pointBatch.Exemplars); - - foreach (var point in pointBatch.Inserts) - { - point.Context.SuccessCount += point.SourcePointCount; - - if (ReferenceEquals(point.Dimension.PendingPoint, point)) - { - point.Dimension.LatestPoint = new MetricPointRecord - { - PointId = point.PointId, - PointType = point.PointType, - EndTimeTicks = point.EndTimeTicks, - IntegerValue = point.IntegerValue, - DoubleValue = point.DoubleValue, - HistogramCount = point.HistogramCount - }; - point.Dimension.PendingPoint = null; - } - } - } - - private static void InsertHistogramBucketCounts( - SqliteConnection connection, - IDbTransaction transaction, - List points) - { - var bucketCounts = points - .Where(point => point.HistogramBucketCounts is { Length: > 0 }) - .SelectMany(point => point.HistogramBucketCounts!.Select((bucketCount, ordinal) => new PendingHistogramBucketCount(point.PointId, ordinal, bucketCount))) - .ToArray(); - SqliteBatchInsert.BatchInsertRows( - connection, - transaction, - bucketCounts, - MaxMetricPointBatchSize, - "telemetry_metric_histogram_bucket_counts", - ["point_id", "ordinal", "bucket_count"], - static (row, parameters) => - { - parameters[0].Value = row.PointId; - parameters[1].Value = row.Ordinal; - parameters[2].Value = row.BucketCount; - }); - } - - private static void InsertHistogramExplicitBounds( - SqliteConnection connection, - IDbTransaction transaction, - List points) - { - var explicitBounds = points - .Where(point => point.HistogramExplicitBounds is { Length: > 0 }) - .SelectMany(point => point.HistogramExplicitBounds!.Select((explicitBound, ordinal) => new PendingHistogramExplicitBound(point.PointId, ordinal, explicitBound))) - .ToArray(); - SqliteBatchInsert.BatchInsertRows( - connection, - transaction, - explicitBounds, - MaxMetricPointBatchSize, - "telemetry_metric_histogram_explicit_bounds", - ["point_id", "ordinal", "explicit_bound"], - static (row, parameters) => - { - parameters[0].Value = row.PointId; - parameters[1].Value = row.Ordinal; - parameters[2].Value = row.ExplicitBound; - }); - } - - private MetricDimensionState GetOrAddMetricDimension( - SqliteConnection connection, - IDbTransaction transaction, - long instrumentId, - OtlpScope scope, - RepeatedField pointAttributes, - MetricIngestionState ingestionState) - { - KeyValuePair[]? temporaryAttributes = null; - OtlpHelpers.CopyKeyValuePairs(pointAttributes, scope.Attributes, _otlpContext, out var copyCount, ref temporaryAttributes); - Array.Sort(temporaryAttributes, 0, copyCount, MetricAttributeComparer.Instance); - var attributes = temporaryAttributes.AsSpan(0, copyCount).ToArray(); - var attributeHash = GetMetricDimensionAttributeHash(attributes); - var cacheKey = (instrumentId, attributeHash); - if (ingestionState.LoadedDimensionInstruments.Add(instrumentId)) - { - var dimensions = connection.Query(""" - SELECT - d.dimension_id AS DimensionId, - a.attribute_key AS AttributeKey, - a.attribute_value AS AttributeValue, - p.point_id AS PointId, - p.point_type AS PointType, - p.end_time_ticks AS EndTimeTicks, - p.integer_value AS IntegerValue, - p.double_value AS DoubleValue, - p.histogram_count AS HistogramCount - FROM telemetry_metric_dimensions d - LEFT JOIN telemetry_metric_dimension_attributes a ON a.dimension_id = d.dimension_id - LEFT JOIN telemetry_metric_points p ON p.point_id = ( - SELECT point_id - FROM telemetry_metric_points - WHERE dimension_id = d.dimension_id - ORDER BY point_id DESC - LIMIT 1 - ) - WHERE d.instrument_id = @InstrumentId - ORDER BY d.dimension_id, a.ordinal; - """, new { InstrumentId = instrumentId }, transaction) - .GroupBy(record => record.DimensionId) - .Select(group => - { - var first = group.First(); - return new MetricDimensionState - { - DimensionId = group.Key, - Attributes = group - .Where(record => record.AttributeKey is not null) - .Select(record => KeyValuePair.Create(record.AttributeKey!, record.AttributeValue!)) - .ToArray(), - LatestPoint = first.PointId is not null - ? new MetricPointRecord - { - PointId = first.PointId.Value, - PointType = first.PointType!.Value, - EndTimeTicks = first.EndTimeTicks!.Value, - IntegerValue = first.IntegerValue, - DoubleValue = first.DoubleValue, - HistogramCount = first.HistogramCount - } - : null - }; - }) - .ToList(); - foreach (var loadedDimension in dimensions) - { - var dimensionCacheKey = (instrumentId, GetMetricDimensionAttributeHash(loadedDimension.Attributes)); - if (!ingestionState.Dimensions.TryGetValue(dimensionCacheKey, out var dimensionCandidates)) - { - dimensionCandidates = []; - ingestionState.Dimensions.Add(dimensionCacheKey, dimensionCandidates); - } - dimensionCandidates.Add(loadedDimension); - } - ingestionState.DimensionCounts[instrumentId] = dimensions.Count; - } - - if (!ingestionState.Dimensions.TryGetValue(cacheKey, out var candidates)) - { - candidates = []; - ingestionState.Dimensions.Add(cacheKey, candidates); - } - - foreach (var candidate in candidates) - { - if (candidate.Attributes.SequenceEqual(attributes)) - { - return candidate; - } - } - - var dimensionCount = ingestionState.DimensionCounts[instrumentId]; - if (dimensionCount >= TelemetryRepositoryLimits.MaxDimensionCount) - { - throw new InvalidOperationException($"Dimension limit of {TelemetryRepositoryLimits.MaxDimensionCount} reached."); - } - var dimension = new MetricDimensionState { Attributes = attributes }; - ingestionState.PendingDimensions.Add(new PendingMetricDimension(instrumentId, attributeHash, dimension)); - ingestionState.PendingDimensionAttributes.AddRange(attributes.Select((attribute, ordinal) => new PendingMetricDimensionAttribute( - dimension, - ordinal, - attribute.Key, - attribute.Value))); - - if (pointAttributes.Count == 1 && pointAttributes[0].Key == "otel.metric.overflow" && pointAttributes[0].Value.GetString() == "true") - { - connection.Execute("UPDATE telemetry_metric_instruments SET has_overflow = 1 WHERE instrument_id = @InstrumentId;", new { InstrumentId = instrumentId }, transaction); - MarkCachedInstrumentHasOverflow(instrumentId); - } - candidates.Add(dimension); - ingestionState.DimensionCounts[instrumentId] = dimensionCount + 1; - return dimension; - } - - private static void InsertMetricDimensions( - SqliteConnection connection, - IDbTransaction transaction, - List dimensions) - { - foreach (var batch in dimensions.Chunk(MaxMetricPointBatchSize)) - { - var sql = new StringBuilder(""" - INSERT INTO telemetry_metric_dimensions (instrument_id, attribute_hash) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) - { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@InstrumentId{index}, @AttributeHash{index})"); - parameters.Add($"InstrumentId{index}", batch[index].InstrumentId); - parameters.Add($"AttributeHash{index}", batch[index].AttributeHash); - } - sql.Append(""" - - RETURNING dimension_id AS DimensionId, instrument_id AS InstrumentId, attribute_hash AS AttributeHash; - """); - - // Hash collisions can produce indistinguishable inserted rows. Their IDs are interchangeable here - // because attributes and points are associated only after an ID is assigned to each pending dimension. - var pendingDimensions = batch - .GroupBy(dimension => (dimension.InstrumentId, dimension.AttributeHash)) - .ToDictionary(group => group.Key, group => new Queue(group)); - foreach (var insertedDimension in connection.Query(sql.ToString(), parameters, transaction)) - { - pendingDimensions[(insertedDimension.InstrumentId, insertedDimension.AttributeHash)] - .Dequeue() - .Dimension.DimensionId = insertedDimension.DimensionId; - } - } - } - - private static void InsertMetricDimensionAttributes( - SqliteConnection connection, - IDbTransaction transaction, - List attributes) - { - SqliteBatchInsert.BatchInsertRows( - connection, - transaction, - attributes, - MaxMetricPointBatchSize, - "telemetry_metric_dimension_attributes", - ["dimension_id", "ordinal", "attribute_key", "attribute_value"], - static (row, parameters) => - { - parameters[0].Value = row.Dimension.DimensionId; - parameters[1].Value = row.Ordinal; - parameters[2].Value = row.Key; - parameters[3].Value = row.Value; - }); - } - - private static long GetMetricDimensionAttributeHash(ReadOnlySpan> attributes) - { - var hash = new XxHash3(); - foreach (var attribute in attributes) - { - AppendHashValue(hash, attribute.Key); - AppendHashValue(hash, attribute.Value); - } - - return BinaryPrimitives.ReadInt64LittleEndian(hash.GetCurrentHash()); - - static void AppendHashValue(XxHash3 hash, string value) - { - var valueBytes = Encoding.UTF8.GetBytes(value); - Span lengthBytes = stackalloc byte[sizeof(int)]; - BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, valueBytes.Length); - hash.Append(lengthBytes); - hash.Append(valueBytes); - } - } - - private void QueueMetricExemplars(MetricPointBatch pointBatch, long pointId, IEnumerable exemplars) - { - foreach (var exemplar in exemplars) - { - if (exemplar.TraceId is null || exemplar.SpanId is null) - { - continue; - } - var startTicks = OtlpHelpers.UnixNanoSecondsToDateTime(exemplar.TimeUnixNano).Ticks; - var value = exemplar.HasAsDouble ? exemplar.AsDouble : exemplar.AsInt; - pointBatch.Exemplars.TryAdd( - new MetricExemplarKey(pointId, startTicks, value), - new PendingMetricExemplar - { - PointId = pointId, - StartTimeTicks = startTicks, - Value = value, - SpanId = exemplar.SpanId.ToHexString(), - TraceId = exemplar.TraceId.ToHexString(), - Attributes = exemplar.FilteredAttributes.ToKeyValuePairs(_otlpContext) - }); - } - } - - private static void InsertMetricExemplars( - SqliteConnection connection, - IDbTransaction transaction, - Dictionary exemplars) - { - foreach (var batch in exemplars.Values.Chunk(MaxMetricPointBatchSize)) - { - var sql = new StringBuilder(""" - INSERT OR IGNORE INTO telemetry_metric_exemplars ( - point_id, start_time_ticks, exemplar_value, span_id, trace_id) - VALUES - """); - var parameters = new DynamicParameters(); - for (var index = 0; index < batch.Length; index++) - { - if (index > 0) - { - sql.AppendLine(","); - } - sql.Append(CultureInfo.InvariantCulture, $" (@PointId{index}, @StartTimeTicks{index}, @Value{index}, @SpanId{index}, @TraceId{index})"); - parameters.Add($"PointId{index}", batch[index].PointId); - parameters.Add($"StartTimeTicks{index}", batch[index].StartTimeTicks); - parameters.Add($"Value{index}", batch[index].Value); - parameters.Add($"SpanId{index}", batch[index].SpanId); - parameters.Add($"TraceId{index}", batch[index].TraceId); - } - sql.Append(""" - RETURNING - exemplar_id AS ExemplarId, - point_id AS PointId, - start_time_ticks AS StartTimeTicks, - exemplar_value AS ExemplarValue; - """); - foreach (var inserted in connection.Query(sql.ToString(), parameters, transaction)) - { - exemplars[new MetricExemplarKey(inserted.PointId, inserted.StartTimeTicks, inserted.ExemplarValue)].ExemplarId = inserted.ExemplarId; - } - } - - var attributes = exemplars.Values - .Where(exemplar => exemplar.ExemplarId is not null) - .SelectMany(exemplar => exemplar.Attributes.Select((attribute, ordinal) => new PendingMetricExemplarAttribute( - exemplar.ExemplarId!.Value, - ordinal, - attribute.Key, - attribute.Value))) - .ToArray(); - SqliteBatchInsert.BatchInsertRows( - connection, - transaction, - attributes, - MaxMetricPointBatchSize, - "telemetry_metric_exemplar_attributes", - ["exemplar_id", "ordinal", "attribute_key", "attribute_value"], - static (row, parameters) => - { - parameters[0].Value = row.ExemplarId; - parameters[1].Value = row.Ordinal; - parameters[2].Value = row.Key; - parameters[3].Value = row.Value; - }); - } - - private void TrimMetricDimensions(SqliteConnection connection, IDbTransaction transaction, IEnumerable dimensions) - { - foreach (var batch in dimensions.Chunk(MaxMetricPointBatchSize)) - { - connection.Execute(""" - DELETE FROM telemetry_metric_points - WHERE point_id IN ( - SELECT point_id - FROM ( - SELECT - point_id, - ROW_NUMBER() OVER (PARTITION BY dimension_id ORDER BY point_id DESC) AS point_rank - FROM telemetry_metric_points - WHERE dimension_id IN @DimensionIds - ) - WHERE point_rank > @MaxMetricsCount - ); - """, new { DimensionIds = batch.Select(dimension => dimension.DimensionId).ToArray(), _otlpContext.Options.MaxMetricsCount }, transaction); - } - } - - private OtlpInstrumentData? GetInstrumentFromDatabase(GetInstrumentRequest request) - { - var instruments = GetCachedInstruments(request.ResourceKey, request.MeterName, request.InstrumentName); - if (instruments.Count == 0) - { - return null; - } - - using var connection = _database.OpenConnection(); - var knownAttributeValues = new Dictionary>(); - var dimensions = MaterializeMetricDimensions( - connection, - instruments.Select(instrument => instrument.InstrumentId).ToArray(), - instruments[0].Summary.Type == OtlpInstrumentType.Histogram, - request.StartTime, - request.EndTime, - request.DimensionFilters, - request.DimensionCursors, - request.DataPointInterval, - knownAttributeValues); - return new OtlpInstrumentData - { - Summary = instruments[0].Summary, - Dimensions = dimensions, - KnownAttributeValues = knownAttributeValues, - HasOverflow = instruments.Any(instrument => instrument.HasOverflow) - }; - } - - private DateTime? GetInstrumentLatestEndTimeFromDatabase(ResourceKey resourceKey, string meterName, string instrumentName) - { - using var connection = _database.OpenConnection(); - var endTimeTicks = connection.QuerySingleOrDefault(""" - SELECT MAX(p.end_time_ticks) - FROM telemetry_metric_points p - JOIN telemetry_metric_dimensions d ON d.dimension_id = p.dimension_id - JOIN telemetry_metric_instruments i ON i.instrument_id = d.instrument_id - JOIN telemetry_resources r ON r.resource_id = i.resource_id - JOIN telemetry_scopes s ON s.scope_id = i.scope_id - WHERE r.resource_name = @ResourceName COLLATE NOCASE - AND (@InstanceId IS NULL OR r.instance_id = @InstanceId COLLATE NOCASE) - AND s.scope_name = @MeterName - AND i.instrument_name = @InstrumentName; - """, new { ResourceName = resourceKey.Name, resourceKey.InstanceId, MeterName = meterName, InstrumentName = instrumentName }); - return endTimeTicks is not null ? new DateTime(endTimeTicks.Value, DateTimeKind.Utc) : null; - } - - private OtlpInstrument? GetResourceInstrumentFromDatabase( - ResourceKey resourceKey, - string meterName, - string instrumentName, - DateTime? startTime, - DateTime? endTime) - { - var data = GetInstrumentFromDatabase(new GetInstrumentRequest - { - ResourceKey = resourceKey, - MeterName = meterName, - InstrumentName = instrumentName, - StartTime = startTime, - EndTime = endTime - }); - if (data is null) - { - return null; - } - - var instrument = new OtlpInstrument - { - Summary = data.Summary, - Context = _otlpContext, - HasOverflow = data.HasOverflow - }; - foreach (var (key, values) in data.KnownAttributeValues) - { - instrument.KnownAttributeValues.Add(key, values); - } - foreach (var dimension in data.Dimensions) - { - instrument.Dimensions.Add(dimension.Attributes, dimension); - } - return instrument; - } - - private List MaterializeMetricDimensions( - SqliteConnection connection, - IReadOnlyList instrumentIds, - bool isHistogram, - DateTime? startTime, - DateTime? endTime, - IReadOnlyDictionary> dimensionFilters, - IReadOnlyList dimensionCursors, - TimeSpan? dataPointInterval, - Dictionary> knownAttributeValues) - { - if (dataPointInterval is { } interval && interval <= TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException(nameof(dataPointInterval), interval, "The metric data point interval must be greater than zero."); - } - - var dimensionIds = connection.Query("SELECT dimension_id FROM telemetry_metric_dimensions WHERE instrument_id IN @InstrumentIds ORDER BY dimension_id;", new { InstrumentIds = instrumentIds }).AsList(); - var attributes = connection.Query(""" - SELECT dimension_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_metric_dimension_attributes - WHERE dimension_id IN @DimensionIds - ORDER BY dimension_id, ordinal; - """, new { DimensionIds = dimensionIds }).ToLookup(record => record.OwnerId); - PopulateKnownAttributeValues(dimensionIds, attributes, knownAttributeValues); - - var selectedDimensionIds = dimensionIds - .Where(dimensionId => MatchesDimensionFilters(attributes[dimensionId], dimensionFilters)) - .ToArray(); - if (selectedDimensionIds.Length == 0) - { - return []; - } - - var queryParameters = new DynamicParameters(); - queryParameters.Add("ApplyRange", startTime is not null && endTime is not null); - queryParameters.Add("EndTicks", endTime?.Ticks ?? 0); - queryParameters.Add("PointIntervalTicks", dataPointInterval?.Ticks ?? 0); - var dimensionQueryRangesCteSql = CreateMetricDimensionQueryRangesCte( - selectedDimensionIds, - attributes, - dimensionCursors, - startTime, - queryParameters); - var metricPointsCteSql = dataPointInterval is null ? FullFidelityMetricPointsCteSql : s_rolledUpMetricPointsCteSql; - var points = connection.Query($""" - WITH {dimensionQueryRangesCteSql}, - {metricPointsCteSql} - SELECT - p.point_id AS PointId, - p.dimension_id AS DimensionId, - p.point_type AS PointType, - p.start_time_ticks AS StartTimeTicks, - p.end_time_ticks AS EndTimeTicks, - p.repeat_count AS RepeatCount, - p.integer_value AS IntegerValue, - p.double_value AS DoubleValue, - p.histogram_sum AS HistogramSum, - p.histogram_count AS HistogramCount - FROM rolled_up_metric_points p - ORDER BY p.dimension_id, p.start_time_ticks, p.point_id; - """, queryParameters).ToLookup(record => record.DimensionId); - var (bucketCounts, explicitBounds) = isHistogram - ? MaterializeMetricHistogramData(connection, dimensionQueryRangesCteSql, metricPointsCteSql, queryParameters) - : (Array.Empty().ToLookup(record => record.PointId), - Array.Empty().ToLookup(record => record.PointId)); - var exemplars = MaterializeMetricExemplars(connection, dimensionQueryRangesCteSql, metricPointsCteSql, queryParameters); - - var results = new List(selectedDimensionIds.Length); - foreach (var dimensionId in selectedDimensionIds) - { - var dimension = new DimensionScope( - _otlpContext.Options.MaxMetricsCount, - attributes[dimensionId].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray()); - if (startTime is not null && endTime is not null) - { - foreach (var point in points[dimensionId]) - { - MetricValueBase value = point.PointType switch - { - LongPointType => new MetricValue(point.IntegerValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), - DoublePointType => new MetricValue(point.DoubleValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), - HistogramPointType => new HistogramValue( - bucketCounts[point.PointId].Select(bucket => checked((ulong)bucket.BucketCount)).ToArray(), - point.HistogramSum!.Value, - checked((ulong)point.HistogramCount!.Value), - new DateTime(point.StartTimeTicks, DateTimeKind.Utc), - new DateTime(point.EndTimeTicks, DateTimeKind.Utc), - explicitBounds[point.PointId].Select(bound => bound.ExplicitBound).ToArray()), - _ => throw new InvalidOperationException($"Unknown metric point type '{point.PointType}'.") - }; - if (point.PointType != HistogramPointType) - { - value.Count = checked((ulong)point.RepeatCount); - } - value.Exemplars.AddRange(exemplars[point.PointId]); - dimension.Values.Add(value); - } - } - results.Add(dimension); - } - return results; - } - - private static string CreateMetricDimensionQueryRangesCte( - IReadOnlyList selectedDimensionIds, - ILookup attributes, - IReadOnlyList dimensionCursors, - DateTime? defaultStartTime, - DynamicParameters queryParameters) - { - var sql = new StringBuilder("metric_dimension_query_ranges(dimension_id, start_time_ticks) AS (VALUES "); - for (var i = 0; i < selectedDimensionIds.Count; i++) - { - if (i > 0) - { - sql.Append(", "); - } - - var dimensionId = selectedDimensionIds[i]; - var dimensionAttributes = attributes[dimensionId]; - var cursor = dimensionCursors.FirstOrDefault(cursor => - cursor.Attributes.Length == dimensionAttributes.Count() && - cursor.Attributes.SequenceEqual(dimensionAttributes.Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)))); - queryParameters.Add($"DimensionId{i}", dimensionId); - queryParameters.Add($"DimensionStartTicks{i}", cursor?.StartTime.Ticks ?? defaultStartTime?.Ticks ?? 0); - sql.Append(CultureInfo.InvariantCulture, $"(@DimensionId{i}, @DimensionStartTicks{i})"); - } - sql.Append(')'); - return sql.ToString(); - } - - private static bool MatchesDimensionFilters(IEnumerable attributes, IReadOnlyDictionary> dimensionFilters) - { - foreach (var (key, values) in dimensionFilters) - { - var value = attributes.FirstOrDefault(attribute => attribute.AttributeKey == key)?.AttributeValue; - if (!values.Contains(value)) - { - return false; - } - } - return true; - } - - private static void PopulateKnownAttributeValues( - IReadOnlyList dimensionIds, - ILookup attributes, - Dictionary> knownAttributeValues) - { - for (var dimensionIndex = 0; dimensionIndex < dimensionIds.Count; dimensionIndex++) - { - var dimensionId = dimensionIds[dimensionIndex]; - foreach (var key in knownAttributeValues.Keys.Union(attributes[dimensionId].Select(attribute => attribute.AttributeKey)).Distinct().ToList()) - { - if (!knownAttributeValues.TryGetValue(key, out var values)) - { - values = []; - knownAttributeValues.Add(key, values); - if (dimensionIndex > 0) - { - values.Add(null); - } - } - var value = attributes[dimensionId].FirstOrDefault(attribute => attribute.AttributeKey == key)?.AttributeValue; - if (!values.Contains(value)) - { - values.Add(value); - } - } - } - } - - private static (ILookup BucketCounts, ILookup ExplicitBounds) MaterializeMetricHistogramData( - SqliteConnection connection, - string dimensionQueryRangesCteSql, - string metricPointsCteSql, - DynamicParameters queryParameters) - { - var bucketCounts = connection.Query($""" - WITH {dimensionQueryRangesCteSql}, - {metricPointsCteSql} - SELECT - p.point_id AS PointId, - b.bucket_count AS BucketCount - FROM telemetry_metric_histogram_bucket_counts b - JOIN rolled_up_metric_points p ON p.point_id = b.point_id - ORDER BY p.point_id, b.ordinal; - """, queryParameters).ToLookup(record => record.PointId); - var explicitBounds = connection.Query($""" - WITH {dimensionQueryRangesCteSql}, - {metricPointsCteSql} - SELECT - p.point_id AS PointId, - b.explicit_bound AS ExplicitBound - FROM telemetry_metric_histogram_explicit_bounds b - JOIN rolled_up_metric_points p ON p.point_id = b.point_id - ORDER BY p.point_id, b.ordinal; - """, queryParameters).ToLookup(record => record.PointId); - - return (bucketCounts, explicitBounds); - } - - private static ILookup MaterializeMetricExemplars( - SqliteConnection connection, - string dimensionQueryRangesCteSql, - string metricPointsCteSql, - DynamicParameters queryParameters) - { - var records = connection.Query($""" - WITH {dimensionQueryRangesCteSql}, - {metricPointsCteSql} - SELECT - e.exemplar_id AS ExemplarId, - p.point_id AS PointId, - e.start_time_ticks AS StartTimeTicks, - e.exemplar_value AS ExemplarValue, - e.span_id AS SpanId, - e.trace_id AS TraceId - FROM telemetry_metric_exemplars e - JOIN selected_metric_points source ON source.point_id = e.point_id - JOIN metric_dimension_query_ranges r ON r.dimension_id = source.dimension_id - JOIN rolled_up_metric_points p ON - p.dimension_id = source.dimension_id AND - p.point_type = source.point_type AND - p.start_time_ticks = CASE - WHEN @PointIntervalTicks = 0 THEN source.start_time_ticks - ELSE (source.start_time_ticks / @PointIntervalTicks) * @PointIntervalTicks - END - WHERE @ApplyRange = 0 OR (e.start_time_ticks >= r.start_time_ticks AND e.start_time_ticks <= @EndTicks) - ORDER BY p.point_id, e.exemplar_id; - """, queryParameters).AsList(); - var attributes = connection.Query($""" - WITH {dimensionQueryRangesCteSql}, - {metricPointsCteSql} - SELECT a.exemplar_id AS OwnerId, a.attribute_key AS AttributeKey, a.attribute_value AS AttributeValue - FROM telemetry_metric_exemplar_attributes a - JOIN telemetry_metric_exemplars e ON e.exemplar_id = a.exemplar_id - JOIN selected_metric_points source ON source.point_id = e.point_id - JOIN metric_dimension_query_ranges r ON r.dimension_id = source.dimension_id - JOIN rolled_up_metric_points p ON - p.dimension_id = source.dimension_id AND - p.point_type = source.point_type AND - p.start_time_ticks = CASE - WHEN @PointIntervalTicks = 0 THEN source.start_time_ticks - ELSE (source.start_time_ticks / @PointIntervalTicks) * @PointIntervalTicks - END - WHERE @ApplyRange = 0 OR (e.start_time_ticks >= r.start_time_ticks AND e.start_time_ticks <= @EndTicks) - ORDER BY a.exemplar_id, a.ordinal; - """, queryParameters).ToLookup(record => record.OwnerId); - return records.Select(record => new KeyValuePair(record.PointId, new MetricsExemplar - { - Start = new DateTime(record.StartTimeTicks, DateTimeKind.Utc), - Value = record.ExemplarValue, - SpanId = record.SpanId, - TraceId = record.TraceId, - Attributes = attributes[record.ExemplarId].Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)).ToArray() - })).ToLookup(pair => pair.Key, pair => pair.Value); - } - - private void ClearSelectedMetricsFromDatabase(Dictionary> selectedResources) - { - using var connection = _database.OpenConnection(); - foreach (var resource in connection.Query("SELECT resource_name AS ResourceName, instance_id AS InstanceId FROM telemetry_resources;")) - { - var key = new ResourceKey(resource.ResourceName, resource.InstanceId); - if (selectedResources.TryGetValue(key.GetCompositeName(), out var dataTypes) && dataTypes.Contains(AspireDataType.Metrics) && !dataTypes.Contains(AspireDataType.Resource)) - { - ClearMetricsFromDatabase(key); - } - } - } - - private void ClearMetricsFromDatabase(ResourceKey? resourceKey) - { - lock (_writeLock) - { - using var connection = _database.OpenConnection(); - using var transaction = connection.BeginTransaction(); - var parameters = new DynamicParameters(); - var where = string.Empty; - if (resourceKey is not null) - { - where = " WHERE resource_name = @ResourceName COLLATE NOCASE"; - parameters.Add("ResourceName", resourceKey.Value.Name); - if (resourceKey.Value.InstanceId is not null) - { - where += " AND instance_id = @InstanceId COLLATE NOCASE"; - parameters.Add("InstanceId", resourceKey.Value.InstanceId); - } - } - connection.Execute($""" - DELETE FROM telemetry_metric_instruments - WHERE resource_id IN (SELECT resource_id FROM telemetry_resources{where}); - - UPDATE telemetry_resources - SET has_metrics = EXISTS (SELECT 1 FROM telemetry_metric_instruments WHERE telemetry_metric_instruments.resource_id = telemetry_resources.resource_id); - """, parameters, transaction); - DeleteOrphanedScopes(connection, transaction); - transaction.Commit(); - ClearIngestionCaches(); - } - } - - private static OtlpScope CreateScope(string name, string version, KeyValuePair[] attributes) - { - return name == OtlpScope.Empty.Name && version.Length == 0 && attributes.Length == 0 - ? OtlpScope.Empty - : new OtlpScope(name, version, attributes); - } - - private static OtlpInstrumentType MapMetricType(Metric.DataOneofCase dataCase) - { - return dataCase switch - { - Metric.DataOneofCase.Gauge => OtlpInstrumentType.Gauge, - Metric.DataOneofCase.Sum => OtlpInstrumentType.Sum, - Metric.DataOneofCase.Histogram => OtlpInstrumentType.Histogram, - _ => OtlpInstrumentType.Unsupported - }; - } - - private static OtlpAggregationTemporality MapAggregationTemporality(Metric metric) - { - return metric.DataCase switch - { - Metric.DataOneofCase.Sum => (OtlpAggregationTemporality)metric.Sum.AggregationTemporality, - Metric.DataOneofCase.Histogram => (OtlpAggregationTemporality)metric.Histogram.AggregationTemporality, - Metric.DataOneofCase.ExponentialHistogram => (OtlpAggregationTemporality)metric.ExponentialHistogram.AggregationTemporality, - _ => OtlpAggregationTemporality.Unspecified - }; - } - - private sealed class MetricAttributeComparer : IComparer> - { - public static readonly MetricAttributeComparer Instance = new(); - - public int Compare(KeyValuePair x, KeyValuePair y) => string.Compare(x.Key, y.Key, StringComparison.Ordinal); - } - - private sealed class MetricIngestionState - { - public Dictionary<(long InstrumentId, long AttributeHash), List> Dimensions { get; } = []; - public Dictionary DimensionCounts { get; } = []; - public HashSet LoadedDimensionInstruments { get; } = []; - public HashSet DimensionsToTrim { get; } = []; - public List PendingDimensions { get; } = []; - public List PendingDimensionAttributes { get; } = []; - - public void Clear() - { - Dimensions.Clear(); - DimensionCounts.Clear(); - LoadedDimensionInstruments.Clear(); - DimensionsToTrim.Clear(); - PendingDimensions.Clear(); - PendingDimensionAttributes.Clear(); - } - } - - private sealed record PendingMetricDimension(long InstrumentId, long AttributeHash, MetricDimensionState Dimension); - - private sealed class InsertedMetricDimensionRecord - { - public required long DimensionId { get; init; } - public required long InstrumentId { get; init; } - public required long AttributeHash { get; init; } - } - - private sealed record PendingMetricDimensionAttribute(MetricDimensionState Dimension, int Ordinal, string Key, string Value); - - private sealed class MetricDimensionState - { - public long DimensionId { get; set; } - public required KeyValuePair[] Attributes { get; init; } - public MetricPointRecord? LatestPoint { get; set; } - public PendingMetricPoint? PendingPoint { get; set; } - } - - private sealed class MetricPointBatch - { - public Dictionary Updates { get; } = []; - public List Inserts { get; } = []; - public Dictionary Exemplars { get; } = []; - - public void AddUpdate(long pointId, long endTimeTicks, bool incrementRepeatCount) - { - if (!Updates.TryGetValue(pointId, out var update)) - { - update = new MetricPointUpdate { PointId = pointId }; - Updates.Add(pointId, update); - } - update.EndTimeTicks = endTimeTicks; - if (incrementRepeatCount) - { - update.RepeatDelta++; - } - } - } - - private readonly record struct MetricExemplarKey(long PointId, long StartTimeTicks, double Value); - - private sealed class PendingMetricExemplar - { - public required long PointId { get; init; } - public required long StartTimeTicks { get; init; } - public required double Value { get; init; } - public required string SpanId { get; init; } - public required string TraceId { get; init; } - public required KeyValuePair[] Attributes { get; init; } - public long? ExemplarId { get; set; } - } - - private sealed record PendingMetricExemplarAttribute(long ExemplarId, int Ordinal, string Key, string Value); - - private sealed record PendingHistogramBucketCount(long PointId, int Ordinal, long BucketCount); - - private sealed record PendingHistogramExplicitBound(long PointId, int Ordinal, double ExplicitBound); - - private sealed class InsertedMetricExemplarRecord - { - public required long ExemplarId { get; init; } - public required long PointId { get; init; } - public required long StartTimeTicks { get; init; } - public required double ExemplarValue { get; init; } - } - - private sealed class MetricPointUpdate - { - public required long PointId { get; init; } - public long EndTimeTicks { get; set; } - public long RepeatDelta { get; set; } - } - - private sealed class PendingMetricPoint - { - public required AddContext Context { get; init; } - public required MetricDimensionState Dimension { get; init; } - public required int PointType { get; init; } - public required long StartTimeTicks { get; init; } - public required long EndTimeTicks { get; set; } - public required long RepeatCount { get; set; } - public long? IntegerValue { get; init; } - public double? DoubleValue { get; init; } - public double? HistogramSum { get; init; } - public long? HistogramCount { get; init; } - public required long Flags { get; init; } - public long PointId { get; set; } - public int SourcePointCount { get; set; } = 1; - public long[]? HistogramBucketCounts { get; init; } - public double[]? HistogramExplicitBounds { get; init; } - public List Exemplars { get; } = []; - } - - private sealed class MetricDimensionStateRecord - { - public required long DimensionId { get; init; } - public string? AttributeKey { get; init; } - public string? AttributeValue { get; init; } - public long? PointId { get; init; } - public int? PointType { get; init; } - public long? EndTimeTicks { get; init; } - public long? IntegerValue { get; init; } - public double? DoubleValue { get; init; } - public long? HistogramCount { get; init; } - } - - private class MetricPointRecord - { - public required long PointId { get; init; } - public required int PointType { get; init; } - public required long EndTimeTicks { get; set; } - public long? IntegerValue { get; init; } - public double? DoubleValue { get; init; } - public long? HistogramCount { get; init; } - } - - private sealed class MetricPointDataRecord : MetricPointRecord - { - public required long DimensionId { get; init; } - public required long StartTimeTicks { get; init; } - public required long RepeatCount { get; init; } - public double? HistogramSum { get; init; } - } - - private sealed class MetricHistogramBucketRecord - { - public required long PointId { get; init; } - public required long BucketCount { get; init; } - } - - private sealed class MetricHistogramBoundRecord - { - public required long PointId { get; init; } - public required double ExplicitBound { get; init; } - } - - private sealed class MetricExemplarRecord - { - public required long ExemplarId { get; init; } - public required long PointId { get; init; } - public required long StartTimeTicks { get; init; } - public required double ExemplarValue { get; init; } - public required string SpanId { get; init; } - public required string TraceId { get; init; } - } -} -#endif \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql index 0a4e4786e29..740da65073c 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql @@ -84,6 +84,8 @@ CREATE INDEX IF NOT EXISTS ix_telemetry_metric_points_dimension_order ON telemetry_metric_points(dimension_id, point_id); CREATE INDEX IF NOT EXISTS ix_telemetry_metric_points_time ON telemetry_metric_points(dimension_id, start_time_ticks, end_time_ticks); +CREATE INDEX IF NOT EXISTS ix_telemetry_metric_points_end_time + ON telemetry_metric_points(dimension_id, end_time_ticks, start_time_ticks, point_id); -- Exemplar identity intentionally omits span/trace IDs and filtered attributes. Distinct exemplars that share a -- point, timestamp, and value can be collapsed, but that combination is unlikely to occur in real-world telemetry. CREATE UNIQUE INDEX IF NOT EXISTS ix_telemetry_metric_exemplars_identity diff --git a/tests/Aspire.Dashboard.Tests/ChartDataCalculatorTests.cs b/tests/Aspire.Dashboard.Tests/ChartDataCalculatorTests.cs index 1b00b6b28e7..98576199d87 100644 --- a/tests/Aspire.Dashboard.Tests/ChartDataCalculatorTests.cs +++ b/tests/Aspire.Dashboard.Tests/ChartDataCalculatorTests.cs @@ -505,10 +505,12 @@ public void MetricInstrumentDataCache_Merge_ReplacesLateArrivingValue() [InlineData(5, 1)] [InlineData(15, 1)] [InlineData(30, 60)] - [InlineData(720, 60)] - public void GetDataPointInterval_ReturnsDurationResolution(int durationMinutes, int expectedSeconds) + [InlineData(179, 60)] + [InlineData(180, 300)] + [InlineData(720, 300)] + public void MetricDataPointInterval_Get_ReturnsDurationResolution(int durationMinutes, int expectedSeconds) { - Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), ChartContainer.GetDataPointInterval(TimeSpan.FromMinutes(durationMinutes))); + Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), MetricDataPointInterval.Get(TimeSpan.FromMinutes(durationMinutes))); } private static OtlpInstrumentData CreateInstrumentData(List dimensions) diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index cc5518397d1..0f328fce8af 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -1300,6 +1300,67 @@ public sealed class SqliteMetricsTests : MetricsTests protected override bool UseSqlite => true; + [Fact] + public void GetInstrument_PopulateExemplarAttributesFalse_SkipsAttributes() + { + var repository = Assert.IsType(CreateRepository()); + repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField + { + CreateResourceMetrics(CreateSumMetric( + metricName: "test", + startTime: s_queryTestTime.AddMinutes(1), + value: 1, + exemplars: [CreateExemplar(s_queryTestTime.AddMinutes(1), 2, [KeyValuePair.Create("key", "value")])])) + }); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); + using var parent = new Activity("metric exemplar attributes test").Start(); + + var instrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = CreateResource().GetResourceKey(), + MeterName = "test-meter", + InstrumentName = "test", + StartTime = DateTime.MinValue, + EndTime = DateTime.MaxValue, + PopulateExemplarAttributes = false + }); + + var exemplar = Assert.Single(Assert.Single(Assert.Single(instrument!.Dimensions).Values).Exemplars); + Assert.Empty(exemplar.Attributes); + var queries = activities + .Where(activity => activity.ParentSpanId == parent.SpanId) + .Select(activity => (string)activity.GetTagItem("db.query.text")!); + Assert.DoesNotContain(queries, query => query.Contains("telemetry_metric_exemplar_attributes", StringComparison.Ordinal)); + Assert.Single(queries, query => query.Contains("ranked_metric_points", StringComparison.Ordinal)); + } + + [Fact] + public void GetInstrument_WithoutTimeRange_SkipsMetricPointQueries() + { + var repository = Assert.IsType(CreateRepository()); + repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField + { + CreateResourceMetrics(CreateSumMetric("test", s_queryTestTime.AddMinutes(1))) + }); + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); + using var parent = new Activity("metric metadata test").Start(); + + var instrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = CreateResource().GetResourceKey(), + MeterName = "test-meter", + InstrumentName = "test" + }); + + Assert.Empty(Assert.Single(instrument!.Dimensions).Values); + var queries = activities + .Where(activity => activity.ParentSpanId == parent.SpanId) + .Select(activity => (string)activity.GetTagItem("db.query.text")!); + Assert.DoesNotContain(queries, query => query.Contains("telemetry_metric_points", StringComparison.Ordinal)); + } + [Fact] public void GetInstrument_StaggeredDimensionChanges_ReturnsDimensionTimelines() { From c8311facdb59facbfb5e937241fa1152008105ef Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 23 Jul 2026 07:51:10 +0800 Subject: [PATCH 67/87] Refine dashboard metric rollup intervals --- .../Controls/Chart/MetricDataPointInterval.cs | 38 ++++++++++++++++--- .../ChartDataCalculatorTests.cs | 18 ++++++--- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/MetricDataPointInterval.cs b/src/Aspire.Dashboard/Components/Controls/Chart/MetricDataPointInterval.cs index b025507a70e..c7e7b88ff7e 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/MetricDataPointInterval.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/MetricDataPointInterval.cs @@ -13,13 +13,41 @@ internal static class MetricDataPointInterval /// public static TimeSpan Get(TimeSpan duration) { - if (duration >= TimeSpan.FromHours(3)) + if (duration <= TimeSpan.FromMinutes(5)) { - return TimeSpan.FromMinutes(5); + return TimeSpan.FromSeconds(1); } - return duration <= TimeSpan.FromMinutes(15) - ? TimeSpan.FromSeconds(1) - : TimeSpan.FromMinutes(1); + if (duration <= TimeSpan.FromMinutes(15)) + { + return TimeSpan.FromSeconds(2); + } + + if (duration <= TimeSpan.FromMinutes(30)) + { + return TimeSpan.FromSeconds(5); + } + + if (duration <= TimeSpan.FromHours(1)) + { + return TimeSpan.FromSeconds(10); + } + + if (duration <= TimeSpan.FromHours(3)) + { + return TimeSpan.FromSeconds(30); + } + + if (duration <= TimeSpan.FromHours(6)) + { + return TimeSpan.FromMinutes(1); + } + + if (duration <= TimeSpan.FromHours(12)) + { + return TimeSpan.FromMinutes(2); + } + + return TimeSpan.FromMinutes(5); } } \ No newline at end of file diff --git a/tests/Aspire.Dashboard.Tests/ChartDataCalculatorTests.cs b/tests/Aspire.Dashboard.Tests/ChartDataCalculatorTests.cs index 98576199d87..55b5f55e7a9 100644 --- a/tests/Aspire.Dashboard.Tests/ChartDataCalculatorTests.cs +++ b/tests/Aspire.Dashboard.Tests/ChartDataCalculatorTests.cs @@ -503,11 +503,19 @@ public void MetricInstrumentDataCache_Merge_ReplacesLateArrivingValue() [Theory] [InlineData(1, 1)] [InlineData(5, 1)] - [InlineData(15, 1)] - [InlineData(30, 60)] - [InlineData(179, 60)] - [InlineData(180, 300)] - [InlineData(720, 300)] + [InlineData(6, 2)] + [InlineData(15, 2)] + [InlineData(16, 5)] + [InlineData(30, 5)] + [InlineData(31, 10)] + [InlineData(60, 10)] + [InlineData(61, 30)] + [InlineData(180, 30)] + [InlineData(181, 60)] + [InlineData(360, 60)] + [InlineData(361, 120)] + [InlineData(720, 120)] + [InlineData(721, 300)] public void MetricDataPointInterval_Get_ReturnsDurationResolution(int durationMinutes, int expectedSeconds) { Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), MetricDataPointInterval.Get(TimeSpan.FromMinutes(durationMinutes))); From cee09512520e0cb61de6316cb1de9b864a6a71a8 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 23 Jul 2026 10:56:33 +0800 Subject: [PATCH 68/87] Move metric instrument storage to repositories --- .../Components/Pages/Metrics.razor.cs | 4 +- .../Model/TelemetryExportService.cs | 2 +- .../Otlp/Model/OtlpHelpers.cs | 14 + .../Otlp/Model/OtlpResource.cs | 255 +------------- .../Otlp/Storage/ITelemetryRepository.cs | 2 +- .../SqliteTelemetryRepository.Caching.cs | 15 - ...SqliteTelemetryRepository.Metrics.Reads.cs | 37 --- ...qliteTelemetryRepository.Metrics.Writes.cs | 6 +- .../Otlp/Storage/SqliteTelemetryRepository.cs | 2 +- .../Integration/OtlpHttpJsonTests.cs | 4 +- .../Model/TelemetryExportServiceTests.cs | 4 +- .../Model/TelemetryImportServiceTests.cs | 2 +- .../TelemetryRepositoryTests/MetricsTests.cs | 22 +- .../SqliteTelemetryPersistenceTests.cs | 6 +- .../TelemetryLimitTests.cs | 4 +- .../TelemetryRepositoryTests.cs | 12 +- .../Telemetry/InMemoryTelemetryRepository.cs | 312 +++++++++++++++--- 17 files changed, 311 insertions(+), 392 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs index a4fa94790f1..5889d6e8e2d 100644 --- a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs @@ -181,7 +181,7 @@ public Task UpdateViewModelFromQueryAsync(MetricsViewModel viewModel) private void UpdateInstruments(MetricsViewModel viewModel) { var selectedInstance = viewModel.SelectedResource.Id?.GetResourceKey(); - viewModel.Instruments = selectedInstance != null ? TelemetryRepository.GetInstrumentsSummaries(selectedInstance.Value) : null; + viewModel.Instruments = selectedInstance != null ? TelemetryRepository.GetInstrumentSummaries(selectedInstance.Value) : null; } private void UpdateResources() @@ -335,7 +335,7 @@ private void UpdateSubscription() if (selectedResourceKey != null) { // If there are more instruments than before then update the UI. - var instruments = TelemetryRepository.GetInstrumentsSummaries(selectedResourceKey.Value); + var instruments = TelemetryRepository.GetInstrumentSummaries(selectedResourceKey.Value); if (PageViewModel.Instruments is null || instruments.Count != PageViewModel.Instruments.Count) { diff --git a/src/Aspire.Dashboard/Model/TelemetryExportService.cs b/src/Aspire.Dashboard/Model/TelemetryExportService.cs index a2f4d087379..6ee6c5541a8 100644 --- a/src/Aspire.Dashboard/Model/TelemetryExportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryExportService.cs @@ -187,7 +187,7 @@ private void AddMetrics(ExportArchive exportArchive, List resource { foreach (var resource in resources) { - var instrumentSummaries = _dataSource.TelemetryRepository.GetInstrumentsSummaries(resource.ResourceKey); + var instrumentSummaries = _dataSource.TelemetryRepository.GetInstrumentSummaries(resource.ResourceKey); if (instrumentSummaries.Count == 0) { diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs index 38691a3a19e..462ea240da4 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs @@ -11,6 +11,7 @@ using Google.Protobuf; using Google.Protobuf.Collections; using OpenTelemetry.Proto.Common.V1; +using OpenTelemetry.Proto.Metrics.V1; using OpenTelemetry.Proto.Resource.V1; using static OpenTelemetry.Proto.Trace.V1.Span.Types; @@ -42,6 +43,19 @@ internal static OtlpSpanKind ConvertSpanKind(SpanKind? kind) }; } + internal static int GetMetricDataPointCount(Metric metric) + { + return metric.DataCase switch + { + Metric.DataOneofCase.Gauge => metric.Gauge.DataPoints.Count, + Metric.DataOneofCase.Sum => metric.Sum.DataPoints.Count, + Metric.DataOneofCase.Histogram => metric.Histogram.DataPoints.Count, + Metric.DataOneofCase.Summary => metric.Summary.DataPoints.Count, + Metric.DataOneofCase.ExponentialHistogram => metric.ExponentialHistogram.DataPoints.Count, + _ => 0, + }; + } + public static ResourceKey GetResourceKey(this Resource resource) { string? serviceName = null; diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs index e5a00887566..c8b793a2b8d 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpResource.cs @@ -7,7 +7,6 @@ using Aspire.Dashboard.Otlp.Storage; using Google.Protobuf.Collections; using OpenTelemetry.Proto.Common.V1; -using OpenTelemetry.Proto.Metrics.V1; namespace Aspire.Dashboard.Otlp.Model; @@ -43,14 +42,7 @@ public class OtlpResource : IOtlpResource public ResourceKey ResourceKey => new ResourceKey(ResourceName, InstanceId); - private readonly ReaderWriterLockSlim _metricsLock = new(); - // Bounded by TelemetryRepository.MaxScopeCount. Cleared when metrics are cleared. - private readonly Dictionary _meters = new(); - private readonly Dictionary _instruments = new(); private readonly ConcurrentDictionary[], OtlpResourceView> _resourceViews = new(ResourceViewKeyComparer.Instance); - private Func? _instrumentProvider; - private Func>? _instrumentSummariesProvider; - private Func>? _resourceViewsProvider; public OtlpResource(string name, string? instanceId, bool uninstrumentedPeer, OtlpContext context) { @@ -60,241 +52,6 @@ public OtlpResource(string name, string? instanceId, bool uninstrumentedPeer, Ot Context = context; } - public void AddMetrics(AddContext context, RepeatedField scopeMetrics) - { - _metricsLock.EnterWriteLock(); - - try - { - // Temporary attributes array to use when adding metrics to the instruments. - KeyValuePair[]? tempAttributes = null; - - foreach (var sm in scopeMetrics) - { - if (!OtlpHelpers.TryGetOrAddScope(_meters, sm.Scope, Context, TelemetryType.Metrics, out var scope)) - { - context.FailureCount += sm.Metrics.Sum(GetMetricDataPointCount); - continue; - } - - foreach (var metric in sm.Metrics) - { - OtlpInstrument instrument; - - try - { - if (string.IsNullOrEmpty(metric.Name)) - { - throw new InvalidOperationException("Instrument name is required."); - } - - var instrumentKey = new OtlpInstrumentKey(scope.Name, metric.Name); - if (_instruments.TryGetValue(instrumentKey, out var existingInstrument)) - { - instrument = existingInstrument; - } - else if (_instruments.Count < TelemetryRepositoryLimits.MaxInstrumentCount) - { - var newInstrument = new OtlpInstrument - { - Summary = new OtlpInstrumentSummary - { - Name = metric.Name, - Description = metric.Description, - Unit = metric.Unit, - Type = MapMetricType(metric.DataCase), - AggregationTemporality = MapAggregationTemporality(metric), - Parent = scope - }, - Context = Context - }; - - _instruments.Add(instrumentKey, newInstrument); - instrument = newInstrument; - - Context.Logger.LogTrace("Added metric instrument '{InstrumentName}' for scope '{ScopeName}'.", instrument.Summary.Name, scope.Name); - } - else - { - throw new InvalidOperationException($"Instrument limit of {TelemetryRepositoryLimits.MaxInstrumentCount} reached. Instrument '{metric.Name}' will not be added."); - } - } - catch (Exception ex) - { - // If we can't create the instrument then all data points for it are failures. - context.FailureCount += GetMetricDataPointCount(metric); - Context.Logger.LogInformation(ex, "Error adding metric instrument {MetricName}.", metric.Name); - continue; - } - - AddMetrics(instrument, metric, context, ref tempAttributes); - } - } - } - finally - { - _metricsLock.ExitWriteLock(); - } - } - - internal static int GetMetricDataPointCount(Metric metric) - { - return metric.DataCase switch - { - Metric.DataOneofCase.Gauge => metric.Gauge.DataPoints.Count, - Metric.DataOneofCase.Sum => metric.Sum.DataPoints.Count, - Metric.DataOneofCase.Histogram => metric.Histogram.DataPoints.Count, - Metric.DataOneofCase.Summary => metric.Summary.DataPoints.Count, - Metric.DataOneofCase.ExponentialHistogram => metric.ExponentialHistogram.DataPoints.Count, - _ => 0, - }; - } - - private void AddMetrics(OtlpInstrument instrument, Metric metric, AddContext context, ref KeyValuePair[]? tempAttributes) - { - switch (metric.DataCase) - { - case Metric.DataOneofCase.Gauge: - foreach (var d in metric.Gauge.DataPoints) - { - try - { - instrument.FindScope(d.Attributes, ref tempAttributes).AddPointValue(d, Context); - context.SuccessCount++; - } - catch (Exception ex) - { - context.FailureCount++; - Context.Logger.LogInformation(ex, "Error adding metric."); - } - } - break; - case Metric.DataOneofCase.Sum: - foreach (var d in metric.Sum.DataPoints) - { - try - { - instrument.FindScope(d.Attributes, ref tempAttributes).AddPointValue(d, Context); - context.SuccessCount++; - } - catch (Exception ex) - { - context.FailureCount++; - Context.Logger.LogInformation(ex, "Error adding metric."); - } - } - break; - case Metric.DataOneofCase.Histogram: - foreach (var d in metric.Histogram.DataPoints) - { - try - { - instrument.FindScope(d.Attributes, ref tempAttributes).AddHistogramValue(d, Context); - context.SuccessCount++; - } - catch (Exception ex) - { - context.FailureCount++; - Context.Logger.LogInformation(ex, "Error adding metric."); - } - } - break; - case Metric.DataOneofCase.Summary: - context.FailureCount += metric.Summary.DataPoints.Count; - Context.Logger.LogInformation("Error adding summary metrics. Summary is not supported."); - break; - case Metric.DataOneofCase.ExponentialHistogram: - context.FailureCount += metric.ExponentialHistogram.DataPoints.Count; - Context.Logger.LogInformation("Error adding exponential histogram metrics. Exponential histogram is not supported."); - break; - } - } - - public void ClearMetrics() - { - _metricsLock.EnterWriteLock(); - - try - { - _instruments.Clear(); - _meters.Clear(); - } - finally - { - _metricsLock.ExitWriteLock(); - } - } - - private static OtlpInstrumentType MapMetricType(Metric.DataOneofCase data) - { - return data switch - { - Metric.DataOneofCase.Gauge => OtlpInstrumentType.Gauge, - Metric.DataOneofCase.Sum => OtlpInstrumentType.Sum, - Metric.DataOneofCase.Histogram => OtlpInstrumentType.Histogram, - _ => OtlpInstrumentType.Unsupported - }; - } - - private static OtlpAggregationTemporality MapAggregationTemporality(Metric metric) - { - return metric.DataCase switch - { - Metric.DataOneofCase.Sum => (OtlpAggregationTemporality)metric.Sum.AggregationTemporality, - Metric.DataOneofCase.Histogram => (OtlpAggregationTemporality)metric.Histogram.AggregationTemporality, - Metric.DataOneofCase.ExponentialHistogram => (OtlpAggregationTemporality)metric.ExponentialHistogram.AggregationTemporality, - _ => OtlpAggregationTemporality.Unspecified - }; - } - - public OtlpInstrument? GetInstrument(string meterName, string instrumentName, DateTime? valuesStart, DateTime? valuesEnd) - { - if (_instrumentProvider is not null) - { - return _instrumentProvider(meterName, instrumentName, valuesStart, valuesEnd); - } - - _metricsLock.EnterReadLock(); - - try - { - if (!_instruments.TryGetValue(new OtlpInstrumentKey(meterName, instrumentName), out var instrument)) - { - return null; - } - - return OtlpInstrument.Clone(instrument, cloneData: true, valuesStart: valuesStart, valuesEnd: valuesEnd); - } - finally - { - _metricsLock.ExitReadLock(); - } - } - - public List GetInstrumentsSummary() - { - if (_instrumentSummariesProvider is not null) - { - return _instrumentSummariesProvider(); - } - - _metricsLock.EnterReadLock(); - - try - { - var instruments = new List(_instruments.Count); - foreach (var instrument in _instruments) - { - instruments.Add(instrument.Value.Summary); - } - return instruments; - } - finally - { - _metricsLock.ExitReadLock(); - } - } - public static Dictionary> GetReplicasByResourceName(IEnumerable allResources) { return allResources @@ -305,17 +62,7 @@ public static Dictionary> GetReplicasByResourceName(I public static string GetResourceName(OtlpResourceView resource, IReadOnlyList allResources) => OtlpHelpers.GetResourceName(resource.Resource, allResources); - internal List GetViews() => _resourceViewsProvider?.Invoke() ?? _resourceViews.Values.ToList(); - - internal void ConfigureDataProviders( - Func instrumentProvider, - Func> instrumentSummariesProvider, - Func> resourceViewsProvider) - { - _instrumentProvider = instrumentProvider; - _instrumentSummariesProvider = instrumentSummariesProvider; - _resourceViewsProvider = resourceViewsProvider; - } + internal List GetViews() => _resourceViews.Values.ToList(); internal OtlpResourceView GetView(RepeatedField attributes) { diff --git a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs index 54e97ead167..fab728553b2 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs @@ -50,7 +50,7 @@ public interface ITelemetryRepository : IDisposable OtlpTrace? GetTrace(string traceId); OtlpSpan? GetSpan(string traceId, string spanId); OtlpResource? GetPeerResource(OtlpSpan span); - List GetInstrumentsSummaries(ResourceKey key); + List GetInstrumentSummaries(ResourceKey key); OtlpInstrumentData? GetInstrument(GetInstrumentRequest request); DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName); diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs index 330c84caa5e..1d5c7274de1 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs @@ -593,10 +593,6 @@ private CachedResource GetOrAddCachedResource(CachedResourceRecord record) private CachedResource CreateCachedResource(long resourceId, ResourceKey key, bool uninstrumentedPeer) { var resource = new OtlpResource(key.Name, key.InstanceId, uninstrumentedPeer, _otlpContext); - resource.ConfigureDataProviders( - (meterName, instrumentName, startTime, endTime) => GetResourceInstrumentFromDatabase(key, meterName, instrumentName, startTime, endTime), - () => GetCachedInstrumentSummaries(key), - () => GetCachedResourceViews(key)); var cachedResource = new CachedResource(resourceId, resource); _cachedResourcesByKey.Add(key, cachedResource); _cachedResourcesById.Add(resourceId, cachedResource); @@ -664,17 +660,6 @@ private CachedInstrument AddCachedInstrument(CachedResourceScope resourceScope, return instrument; } - private List GetCachedResourceViews(ResourceKey key) - { - EnsureCachePopulated(); - lock (_cacheLock) - { - return _cachedResourcesByKey.TryGetValue(key, out var resource) - ? resource.ViewsById.Values.Select(view => view.View).ToList() - : []; - } - } - private List GetCachedInstrumentSummaries(ResourceKey key) { EnsureCachePopulated(); diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs index f178596314e..a06b05e26b4 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs @@ -155,43 +155,6 @@ FROM telemetry_metric_points p return endTimeTicks is not null ? new DateTime(endTimeTicks.Value, DateTimeKind.Utc) : null; } - private OtlpInstrument? GetResourceInstrumentFromDatabase( - ResourceKey resourceKey, - string meterName, - string instrumentName, - DateTime? startTime, - DateTime? endTime) - { - var data = GetInstrumentFromDatabase(new GetInstrumentRequest - { - ResourceKey = resourceKey, - MeterName = meterName, - InstrumentName = instrumentName, - StartTime = startTime, - EndTime = endTime - }); - if (data is null) - { - return null; - } - - var instrument = new OtlpInstrument - { - Summary = data.Summary, - Context = _otlpContext, - HasOverflow = data.HasOverflow - }; - foreach (var (key, values) in data.KnownAttributeValues) - { - instrument.KnownAttributeValues.Add(key, values); - } - foreach (var dimension in data.Dimensions) - { - instrument.Dimensions.Add(dimension.Attributes, dimension); - } - return instrument; - } - private List MaterializeMetricDimensions( SqliteConnection connection, IReadOnlyList instrumentIds, diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs index 7998641b169..f28e3318698 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs @@ -47,7 +47,7 @@ private void AddMetricsToDatabase(AddContext context, RepeatedField scope.Metrics.Sum(OtlpResource.GetMetricDataPointCount)); + context.FailureCount += resourceMetricsItem.ScopeMetrics.Sum(scope => scope.Metrics.Sum(OtlpHelpers.GetMetricDataPointCount)); _otlpContext.Logger.LogInformation(exception, "Error adding resource."); continue; } @@ -61,7 +61,7 @@ private void AddMetricsToDatabase(AddContext context, RepeatedField resourceS public OtlpTrace? GetTrace(string traceId) => GetTraceFromDatabase(traceId); public OtlpSpan? GetSpan(string traceId, string spanId) => GetSpanFromDatabase(traceId, spanId); public OtlpResource? GetPeerResource(OtlpSpan span) => span.UninstrumentedPeer; - public List GetInstrumentsSummaries(ResourceKey key) => GetCachedInstrumentSummaries(key); + public List GetInstrumentSummaries(ResourceKey key) => GetCachedInstrumentSummaries(key); public OtlpInstrumentData? GetInstrument(GetInstrumentRequest request) => GetInstrumentFromDatabase(request); public DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) => GetInstrumentLatestEndTimeFromDatabase(resourceKey, meterName, instrumentName); diff --git a/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs b/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs index f34ff02be34..af2d9947200 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpJsonTests.cs @@ -711,7 +711,7 @@ public async Task CallService_Metrics_JsonContentType_Success() var telemetryRepository = app.Services.GetRequiredService(); var resource = AssertMyServiceResource(telemetryRepository); - var instruments = resource.GetInstrumentsSummary(); + var instruments = telemetryRepository.GetInstrumentSummaries(resource.ResourceKey); Assert.Collection(instruments, counter => { @@ -762,7 +762,7 @@ public async Task CallService_Metrics_NumericBucketCounts_Success() var resources = telemetryRepository.GetResourcesByName("copilot-chat"); var resource = Assert.Single(resources); - var instruments = resource.GetInstrumentsSummary(); + var instruments = telemetryRepository.GetInstrumentSummaries(resource.ResourceKey); var summary = Assert.Single(instruments); Assert.Equal("copilot_chat.tool.call.duration", summary.Name); diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index fbcfa8ea1aa..0d1b54e4056 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs @@ -526,7 +526,7 @@ public void ConvertMetricsToOtlpJson_SingleInstrument_ReturnsCorrectStructure() var resources = repository.GetResources(); var resource = resources[0]; - var instrumentSummaries = repository.GetInstrumentsSummaries(resource.ResourceKey); + var instrumentSummaries = repository.GetInstrumentSummaries(resource.ResourceKey); // Get full instrument data with values var instrumentsData = new List(); @@ -612,7 +612,7 @@ public void ConvertMetricsToOtlpJson_MultipleInstruments_GroupsByScope() var resources = repository.GetResources(); var resource = resources[0]; - var instrumentSummaries = repository.GetInstrumentsSummaries(resource.ResourceKey); + var instrumentSummaries = repository.GetInstrumentSummaries(resource.ResourceKey); // Get full instrument data with values var instrumentsData = new List(); diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs index 8eb9675fc9e..9fd51676f62 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs @@ -131,7 +131,7 @@ public async Task ImportAsync_JsonFile_WithMetrics_ImportsSuccessfully() var resources = repository.GetResources(); Assert.Single(resources); - var instruments = resources[0].GetInstrumentsSummary(); + var instruments = repository.GetInstrumentSummaries(resources[0].ResourceKey); Assert.Single(instruments); Assert.Equal("test.metric", instruments[0].Name); } diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index 0f328fce8af..86bb2e90ef5 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -71,7 +71,7 @@ public void AddMetrics() Assert.Equal("TestId", resource.InstanceId); }); - var instruments = repository.GetInstrumentsSummaries(resources[0].ResourceKey); + var instruments = repository.GetInstrumentSummaries(resources[0].ResourceKey); Assert.Collection(instruments, instrument => { @@ -635,7 +635,7 @@ public void GetMetrics_MultipleInstances() Assert.Equal(0, addContext.FailureCount); var resourceKey = new ResourceKey("resource1", InstanceId: null); - var instruments = repository.GetInstrumentsSummaries(resourceKey); + var instruments = repository.GetInstrumentSummaries(resourceKey); Assert.Collection(instruments, instrument => { @@ -745,11 +745,11 @@ public void RemoveMetrics_All() Assert.Equal(0, addContext.FailureCount); var resource1Key = new ResourceKey("resource1", InstanceId: null); - var resource1Instruments = repository.GetInstrumentsSummaries(resource1Key); + var resource1Instruments = repository.GetInstrumentSummaries(resource1Key); Assert.Empty(resource1Instruments); var resource2Key = new ResourceKey("resource2", InstanceId: null); - var resource2Instruments = repository.GetInstrumentsSummaries(resource2Key); + var resource2Instruments = repository.GetInstrumentSummaries(resource2Key); Assert.Empty(resource2Instruments); } @@ -820,7 +820,7 @@ public void RemoveMetrics_SelectedResource() Assert.Equal(0, addContext.FailureCount); var resource1Key = new ResourceKey("resource1", InstanceId: null); - var resource1Instruments = repository.GetInstrumentsSummaries(resource1Key); + var resource1Instruments = repository.GetInstrumentSummaries(resource1Key); var resource1Instrument = Assert.Single(resource1Instruments); Assert.Equal("test1", resource1Instrument.Name); @@ -855,7 +855,7 @@ public void RemoveMetrics_SelectedResource() Assert.Null(resource1Test2Instrument); var resource2Key = new ResourceKey("resource2", InstanceId: null); - var resource2Instruments = repository.GetInstrumentsSummaries(resource2Key); + var resource2Instruments = repository.GetInstrumentSummaries(resource2Key); Assert.Collection(resource2Instruments, instrument => @@ -970,7 +970,7 @@ public void RemoveMetrics_MultipleSelectedResources() Assert.Equal(0, addContext.FailureCount); var resource1Key = new ResourceKey("resource1", InstanceId: null); - var resource1Instruments = repository.GetInstrumentsSummaries(resource1Key); + var resource1Instruments = repository.GetInstrumentSummaries(resource1Key); Assert.Empty(resource1Instruments); var resource1Test1Instrument = repository.GetInstrument(new GetInstrumentRequest @@ -996,7 +996,7 @@ public void RemoveMetrics_MultipleSelectedResources() Assert.Null(resource1Test2Instrument); var resource2Key = new ResourceKey("resource2", InstanceId: null); - var resource2Instruments = repository.GetInstrumentsSummaries(resource2Key); + var resource2Instruments = repository.GetInstrumentSummaries(resource2Key); Assert.Collection(resource2Instruments, instrument => { @@ -1077,7 +1077,7 @@ public void AddMetrics_InvalidInstrument() Assert.Equal(1, addContext.FailureCount); var resource1Key = new ResourceKey("resource1", InstanceId: null); - var resource1Instruments = repository.GetInstrumentsSummaries(resource1Key); + var resource1Instruments = repository.GetInstrumentSummaries(resource1Key); Assert.Collection(resource1Instruments, instrument => { @@ -1276,7 +1276,7 @@ public void AddMetrics_NoScope() Assert.Equal("TestId", resource.InstanceId); }); - var instruments = repository.GetInstrumentsSummaries(resources[0].ResourceKey); + var instruments = repository.GetInstrumentSummaries(resources[0].ResourceKey); Assert.Collection(instruments, instrument => { @@ -2009,7 +2009,7 @@ public void ClearMetrics_InvalidatesMetricIngestionCache() Assert.Equal(1, context.SuccessCount); Assert.Equal(0, context.FailureCount); - Assert.Single(repository.GetInstrumentsSummaries(CreateResource().GetResourceKey())); + Assert.Single(repository.GetInstrumentSummaries(CreateResource().GetResourceKey())); } private static ResourceMetrics CreateResourceMetrics(Metric metric) => new() diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs index 67e2b2ad7ee..df5de7fd67a 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs @@ -68,7 +68,7 @@ public void Cache_UsesCanonicalResourceViewAndScopeAcrossSignals() var cachedResource = Assert.Single(repository.GetResources()); var log = Assert.Single(repository.GetLogs(CreateLogsContext()).Items); var span = Assert.Single(Assert.Single(repository.GetTraces(GetTracesRequest.ForResourceKey(cachedResource.ResourceKey)).PagedResult.Items).Spans); - var instrument = Assert.Single(repository.GetInstrumentsSummaries(cachedResource.ResourceKey)); + var instrument = Assert.Single(repository.GetInstrumentSummaries(cachedResource.ResourceKey)); Assert.Same(cachedResource, log.ResourceView.Resource); Assert.Same(cachedResource, span.Source.Resource); @@ -111,7 +111,7 @@ public void Cache_HydratesPersistedMetadataOnce() activities.Clear(); var secondResource = Assert.Single(historicalRepository.GetResources()); - var summary = Assert.Single(historicalRepository.GetInstrumentsSummaries(firstResource.ResourceKey)); + var summary = Assert.Single(historicalRepository.GetInstrumentSummaries(firstResource.ResourceKey)); var view = Assert.Single(firstResource.GetViews()); Assert.Same(firstResource, secondResource); @@ -262,7 +262,7 @@ public void Metrics_ReopenFromNormalizedRows() using (var historicalRepository = CreateRepository(workspace.Path, readOnly: true)) { var resourceKey = new ResourceKey("TestService", "TestId"); - var summary = Assert.Single(historicalRepository.GetInstrumentsSummaries(resourceKey)); + var summary = Assert.Single(historicalRepository.GetInstrumentSummaries(resourceKey)); Assert.Equal("requests", summary.Name); Assert.Equal("TestMeter", summary.Parent.Name); diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs index f34e2173f26..3522cc32986 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs @@ -148,7 +148,7 @@ public void AddMetrics_ExceedsInstrumentLimit_ReportsFailure() Assert.Equal(0, addContext.FailureCount); var resources = repository.GetResources(); - var instruments = repository.GetInstrumentsSummaries(resources[0].ResourceKey); + var instruments = repository.GetInstrumentSummaries(resources[0].ResourceKey); Assert.Equal(InMemoryTelemetryRepository.MaxInstrumentCount, instruments.Count); // Adding one more instrument should fail. @@ -172,7 +172,7 @@ public void AddMetrics_ExceedsInstrumentLimit_ReportsFailure() Assert.Equal(1, failContext.FailureCount); Assert.Equal(0, failContext.SuccessCount); - instruments = repository.GetInstrumentsSummaries(resources[0].ResourceKey); + instruments = repository.GetInstrumentSummaries(resources[0].ResourceKey); Assert.Equal(InMemoryTelemetryRepository.MaxInstrumentCount, instruments.Count); } diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs index 4bc216ddc34..63aa24ae062 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs @@ -53,7 +53,7 @@ public void AddData_WhilePaused_IsDiscarded() Assert.Single(repository.GetLogs(new GetLogsContext { ResourceKeys = [resourceKey], Count = 100, Filters = [], StartIndex = 0 }).Items); var resource = repository.GetResource(resourceKey); Assert.NotNull(resource); - Assert.NotEmpty(resource.GetInstrumentsSummary()); + Assert.NotEmpty(repository.GetInstrumentSummaries(resource.ResourceKey)); Assert.Single(repository.GetTraces(new GetTracesRequest { ResourceKeys = [resourceKey], Count = 100, Filters = [], StartIndex = 0 }).PagedResult.Items); void AddLog() @@ -237,7 +237,7 @@ public void ClearSelectedSignals_ClearsSelectedDataTypes_ForSpecificResources() var traces = repository.GetTraces(new GetTracesRequest { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Equal(2, traces.PagedResult.TotalItemCount); - var resource1Metrics = repository.GetInstrumentsSummaries(new ResourceKey("resource1", "123")); + var resource1Metrics = repository.GetInstrumentSummaries(new ResourceKey("resource1", "123")); Assert.Single(resource1Metrics); // Assert - resource2 data is unaffected @@ -249,7 +249,7 @@ public void ClearSelectedSignals_ClearsSelectedDataTypes_ForSpecificResources() var resource2Traces = repository.GetTraces(new GetTracesRequest { ResourceKeys = [resource2Key], StartIndex = 0, Count = 10, Filters = [] }); Assert.Single(resource2Traces.PagedResult.Items); - var resource2Metrics = repository.GetInstrumentsSummaries(new ResourceKey("resource2", "456")); + var resource2Metrics = repository.GetInstrumentSummaries(new ResourceKey("resource2", "456")); Assert.Single(resource2Metrics); } @@ -280,10 +280,10 @@ public void ClearSelectedSignals_OtherResourcesRemainUnaffected() var traces = repository.GetTraces(new GetTracesRequest { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Equal(2, traces.PagedResult.TotalItemCount); - var resource1Metrics = repository.GetInstrumentsSummaries(new ResourceKey("resource1", "111")); + var resource1Metrics = repository.GetInstrumentSummaries(new ResourceKey("resource1", "111")); Assert.Single(resource1Metrics); - var resource3Metrics = repository.GetInstrumentsSummaries(new ResourceKey("resource3", "333")); + var resource3Metrics = repository.GetInstrumentSummaries(new ResourceKey("resource3", "333")); Assert.Single(resource3Metrics); // Assert - resource2 is removed from the repository since all data types were cleared @@ -352,7 +352,7 @@ public void ClearSelectedSignals_PartialClear_ResourceNotRemoved() var traces = repository.GetTraces(new GetTracesRequest { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); Assert.Empty(traces.PagedResult.Items); - var metrics = repository.GetInstrumentsSummaries(new ResourceKey("resource1", "123")); + var metrics = repository.GetInstrumentSummaries(new ResourceKey("resource1", "123")); Assert.Single(metrics); } diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index 8c83fa237cd..44e11158c23 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -52,7 +52,7 @@ public sealed partial class InMemoryTelemetryRepository : ITelemetryRepository, private List? _spanWatchers; private List? _logWatchers; - private readonly ConcurrentDictionary _resources = new(); + private readonly ConcurrentDictionary _resources = new(); private readonly ReaderWriterLockSlim _logsLock = new(); // Bounded by MaxScopeCount. Cleared when all logs are cleared. @@ -140,7 +140,7 @@ public List GetResourcesByName(string name, bool includeUninstrume private List GetResourcesCore(bool includeUninstrumentedPeers, string? name) { - IEnumerable results = _resources.Values; + IEnumerable results = _resources.Values.Select(entry => entry.Resource); if (!includeUninstrumentedPeers) { results = results.Where(a => !a.UninstrumentedPeer); @@ -160,7 +160,7 @@ private List GetResourcesCore(bool includeUninstrumentedPeers, str { if (kvp.Key.EqualsCompositeName(compositeName)) { - return kvp.Value; + return kvp.Value.Resource; } } @@ -174,8 +174,7 @@ private List GetResourcesCore(bool includeUninstrumentedPeers, str throw new InvalidOperationException($"{nameof(ResourceKey)} must have an instance ID."); } - _resources.TryGetValue(key, out var resource); - return resource; + return _resources.TryGetValue(key, out var entry) ? entry.Resource : null; } public List GetResources(ResourceKey key, bool includeUninstrumentedPeers = false) @@ -194,6 +193,20 @@ public List GetResources(ResourceKey key, bool includeUninstrument return [resource]; } + private List GetResourceEntries(ResourceKey key, bool includeUninstrumentedPeers = false) + { + IEnumerable entries = key.InstanceId is null + ? _resources.Values.Where(entry => string.Equals(entry.Resource.ResourceName, key.Name, StringComparisons.ResourceName)) + : _resources.TryGetValue(key, out var entry) ? [entry] : []; + + if (!includeUninstrumentedPeers) + { + entries = entries.Where(entry => !entry.Resource.UninstrumentedPeer); + } + + return entries.ToList(); + } + public Dictionary GetResourceUnviewedErrorLogsCount() { _logsLock.EnterReadLock(); @@ -240,28 +253,30 @@ public void MarkViewedErrorLogs(ResourceKey? key) } } - private OtlpResourceView GetOrAddResourceView(Resource resource) + private OtlpResourceView GetOrAddResourceView(Resource resource) => GetOrAddResourceView(resource, out _); + + private OtlpResourceView GetOrAddResourceView(Resource resource, out ResourceEntry resourceEntry) { ArgumentNullException.ThrowIfNull(resource); var key = resource.GetResourceKey(); - var (otlpResource, isNew) = GetOrAddResource(key, uninstrumentedPeer: false); + (resourceEntry, var isNew) = GetOrAddResourceEntry(key, uninstrumentedPeer: false); if (isNew) { RaiseSubscriptionChanged(_resourceSubscriptions); } - return otlpResource.GetView(resource.Attributes); + return resourceEntry.Resource.GetView(resource.Attributes); } - private (OtlpResource Resource, bool IsNew) GetOrAddResource(ResourceKey key, bool uninstrumentedPeer) + private (ResourceEntry Entry, bool IsNew) GetOrAddResourceEntry(ResourceKey key, bool uninstrumentedPeer) { // Fast path. - if (_resources.TryGetValue(key, out var resource)) + if (_resources.TryGetValue(key, out var entry)) { - resource.SetUninstrumentedPeer(uninstrumentedPeer); - return (Resource: resource, IsNew: false); + entry.Resource.SetUninstrumentedPeer(uninstrumentedPeer); + return (Entry: entry, IsNew: false); } // Check resource limit before adding a new resource. @@ -275,20 +290,20 @@ private OtlpResourceView GetOrAddResourceView(Resource resource) // Slower get or add path. // This GetOrAdd allocates a closure, so we avoid it if possible. var newResource = false; - resource = _resources.GetOrAdd(key, _ => + entry = _resources.GetOrAdd(key, _ => { newResource = true; - return new OtlpResource(key.Name, key.InstanceId, uninstrumentedPeer, _otlpContext); + return new ResourceEntry(new OtlpResource(key.Name, key.InstanceId, uninstrumentedPeer, _otlpContext)); }); if (!newResource) { - resource.SetUninstrumentedPeer(uninstrumentedPeer); + entry.Resource.SetUninstrumentedPeer(uninstrumentedPeer); } else { _logger.LogTrace("New resource added: {ResourceKey}", key); } - return (Resource: resource, IsNew: newResource); + return (Entry: entry, IsNew: newResource); } public Subscription OnNewResources(Func callback) @@ -1449,7 +1464,7 @@ public void ClearTraces(ResourceKey? resourceKey = null) foreach (var resource in _resources.Values) { - SetResourceHasTraces(resource, false); + SetResourceHasTraces(resource.Resource, false); } } else @@ -1513,7 +1528,7 @@ public void ClearStructuredLogs(ResourceKey? resourceKey = null) foreach (var resource in _resources.Values) { - SetResourceHasLogs(resource, false); + SetResourceHasLogs(resource.Resource, false); } _resourceUnviewedErrorLogs.Clear(); @@ -1558,20 +1573,29 @@ public void ClearMetrics(ResourceKey? resourceKey = null) { ThrowIfReadOnly(); - List resources; + List resources; if (resourceKey.HasValue) { - resources = GetResources(resourceKey.Value); + resources = GetResourceEntries(resourceKey.Value); } else { resources = _resources.Values.ToList(); } - foreach (var resource in resources) + foreach (var entry in resources) { - resource.ClearMetrics(); - SetResourceHasMetrics(resource, false); + entry.MetricsLock.EnterWriteLock(); + try + { + entry.Instruments.Clear(); + entry.Meters.Clear(); + } + finally + { + entry.MetricsLock.ExitWriteLock(); + } + SetResourceHasMetrics(entry.Resource, false); } RaiseSubscriptionChanged(_metricsSubscriptions); @@ -1728,24 +1752,182 @@ public void AddMetrics(AddContext context, RepeatedField resour foreach (var rm in resourceMetrics) { OtlpResourceView resourceView; + ResourceEntry resourceEntry; try { - resourceView = GetOrAddResourceView(rm.Resource); + resourceView = GetOrAddResourceView(rm.Resource, out resourceEntry); } catch (Exception ex) { - context.FailureCount += rm.ScopeMetrics.Sum(sm => sm.Metrics.Sum(OtlpResource.GetMetricDataPointCount)); + context.FailureCount += rm.ScopeMetrics.Sum(sm => sm.Metrics.Sum(OtlpHelpers.GetMetricDataPointCount)); _otlpContext.Logger.LogInformation(ex, "Error adding resource."); continue; } - resourceView.Resource.AddMetrics(context, rm.ScopeMetrics); + AddMetrics(resourceEntry, context, rm.ScopeMetrics); SetResourceHasMetrics(resourceView.Resource, true); } RaiseSubscriptionChanged(_metricsSubscriptions); } + private void AddMetrics(ResourceEntry resourceEntry, AddContext context, RepeatedField scopeMetrics) + { + resourceEntry.MetricsLock.EnterWriteLock(); + + try + { + // Temporary attributes array to use when adding metrics to the instruments. + KeyValuePair[]? tempAttributes = null; + + foreach (var scopeMetric in scopeMetrics) + { + if (!OtlpHelpers.TryGetOrAddScope(resourceEntry.Meters, scopeMetric.Scope, _otlpContext, TelemetryType.Metrics, out var scope)) + { + context.FailureCount += scopeMetric.Metrics.Sum(OtlpHelpers.GetMetricDataPointCount); + continue; + } + + foreach (var metric in scopeMetric.Metrics) + { + OtlpInstrument instrument; + + try + { + if (string.IsNullOrEmpty(metric.Name)) + { + throw new InvalidOperationException("Instrument name is required."); + } + + var instrumentKey = new OtlpInstrumentKey(scope.Name, metric.Name); + if (resourceEntry.Instruments.TryGetValue(instrumentKey, out var existingInstrument)) + { + instrument = existingInstrument; + } + else if (resourceEntry.Instruments.Count < TelemetryRepositoryLimits.MaxInstrumentCount) + { + instrument = new OtlpInstrument + { + Summary = new OtlpInstrumentSummary + { + Name = metric.Name, + Description = metric.Description, + Unit = metric.Unit, + Type = MapMetricType(metric.DataCase), + AggregationTemporality = MapAggregationTemporality(metric), + Parent = scope + }, + Context = _otlpContext + }; + + resourceEntry.Instruments.Add(instrumentKey, instrument); + _otlpContext.Logger.LogTrace("Added metric instrument '{InstrumentName}' for scope '{ScopeName}'.", instrument.Summary.Name, scope.Name); + } + else + { + throw new InvalidOperationException($"Instrument limit of {TelemetryRepositoryLimits.MaxInstrumentCount} reached. Instrument '{metric.Name}' will not be added."); + } + } + catch (Exception ex) + { + // If we can't create the instrument then all data points for it are failures. + context.FailureCount += OtlpHelpers.GetMetricDataPointCount(metric); + _otlpContext.Logger.LogInformation(ex, "Error adding metric instrument {MetricName}.", metric.Name); + continue; + } + + AddMetrics(instrument, metric, context, ref tempAttributes); + } + } + } + finally + { + resourceEntry.MetricsLock.ExitWriteLock(); + } + } + + private void AddMetrics(OtlpInstrument instrument, Metric metric, AddContext context, ref KeyValuePair[]? tempAttributes) + { + switch (metric.DataCase) + { + case Metric.DataOneofCase.Gauge: + foreach (var dataPoint in metric.Gauge.DataPoints) + { + try + { + instrument.FindScope(dataPoint.Attributes, ref tempAttributes).AddPointValue(dataPoint, _otlpContext); + context.SuccessCount++; + } + catch (Exception ex) + { + context.FailureCount++; + _otlpContext.Logger.LogInformation(ex, "Error adding metric."); + } + } + break; + case Metric.DataOneofCase.Sum: + foreach (var dataPoint in metric.Sum.DataPoints) + { + try + { + instrument.FindScope(dataPoint.Attributes, ref tempAttributes).AddPointValue(dataPoint, _otlpContext); + context.SuccessCount++; + } + catch (Exception ex) + { + context.FailureCount++; + _otlpContext.Logger.LogInformation(ex, "Error adding metric."); + } + } + break; + case Metric.DataOneofCase.Histogram: + foreach (var dataPoint in metric.Histogram.DataPoints) + { + try + { + instrument.FindScope(dataPoint.Attributes, ref tempAttributes).AddHistogramValue(dataPoint, _otlpContext); + context.SuccessCount++; + } + catch (Exception ex) + { + context.FailureCount++; + _otlpContext.Logger.LogInformation(ex, "Error adding metric."); + } + } + break; + case Metric.DataOneofCase.Summary: + context.FailureCount += metric.Summary.DataPoints.Count; + _otlpContext.Logger.LogInformation("Error adding summary metrics. Summary is not supported."); + break; + case Metric.DataOneofCase.ExponentialHistogram: + context.FailureCount += metric.ExponentialHistogram.DataPoints.Count; + _otlpContext.Logger.LogInformation("Error adding exponential histogram metrics. Exponential histogram is not supported."); + break; + } + } + + private static OtlpInstrumentType MapMetricType(Metric.DataOneofCase data) + { + return data switch + { + Metric.DataOneofCase.Gauge => OtlpInstrumentType.Gauge, + Metric.DataOneofCase.Sum => OtlpInstrumentType.Sum, + Metric.DataOneofCase.Histogram => OtlpInstrumentType.Histogram, + _ => OtlpInstrumentType.Unsupported + }; + } + + private static OtlpAggregationTemporality MapAggregationTemporality(Metric metric) + { + return metric.DataCase switch + { + Metric.DataOneofCase.Sum => (OtlpAggregationTemporality)metric.Sum.AggregationTemporality, + Metric.DataOneofCase.Histogram => (OtlpAggregationTemporality)metric.Histogram.AggregationTemporality, + Metric.DataOneofCase.ExponentialHistogram => (OtlpAggregationTemporality)metric.ExponentialHistogram.AggregationTemporality, + _ => OtlpAggregationTemporality.Unspecified + }; + } + public void AddTraces(AddContext context, RepeatedField resourceSpans) { ThrowIfReadOnly(); @@ -1997,8 +2179,8 @@ private void CalculateTraceUninstrumentedPeers(OtlpTrace trace) try { - var (resource, _) = GetOrAddResource(peerKey, uninstrumentedPeer: true); - trace.SetSpanUninstrumentedPeer(span, resource); + var (resource, _) = GetOrAddResourceEntry(peerKey, uninstrumentedPeer: true); + trace.SetSpanUninstrumentedPeer(span, resource.Resource); } catch (Exception ex) { @@ -2146,34 +2328,32 @@ private static OtlpSpan CreateSpan(OtlpResourceView resourceView, Span span, Otl return newSpan; } - public List GetInstrumentsSummaries(ResourceKey key) + public List GetInstrumentSummaries(ResourceKey key) { - var resources = GetResources(key); - if (resources.Count == 0) - { - return new List(); - } - else if (resources.Count == 1) - { - return resources[0].GetInstrumentsSummary(); - } - else + var resources = GetResourceEntries(key); + var summaries = new List(); + foreach (var resource in resources) { - var allResourceSummaries = resources - .SelectMany(a => a.GetInstrumentsSummary()) - .DistinctBy(s => s.GetKey()) - .ToList(); - - return allResourceSummaries; + resource.MetricsLock.EnterReadLock(); + try + { + summaries.AddRange(resource.Instruments.Values.Select(instrument => instrument.Summary)); + } + finally + { + resource.MetricsLock.ExitReadLock(); + } } + return resources.Count > 1 ? summaries.DistinctBy(summary => summary.GetKey()).ToList() : summaries; } public OtlpInstrumentData? GetInstrument(GetInstrumentRequest request) { - var resources = GetResources(request.ResourceKey); + var resources = GetResourceEntries(request.ResourceKey); + var instrumentKey = new OtlpInstrumentKey(request.MeterName, request.InstrumentName); var instruments = resources - .Select(a => a.GetInstrument(request.MeterName, request.InstrumentName, request.StartTime, request.EndTime)) + .Select(resource => CloneInstrument(resource, instrumentKey, request.StartTime, request.EndTime)) .OfType() .ToList(); @@ -2208,6 +2388,21 @@ public List GetInstrumentsSummaries(ResourceKey key) }; } + private static OtlpInstrument? CloneInstrument(ResourceEntry resource, OtlpInstrumentKey key, DateTime? valuesStart, DateTime? valuesEnd) + { + resource.MetricsLock.EnterReadLock(); + try + { + return resource.Instruments.TryGetValue(key, out var instrument) + ? OtlpInstrument.Clone(instrument, cloneData: true, valuesStart: valuesStart, valuesEnd: valuesEnd) + : null; + } + finally + { + resource.MetricsLock.ExitReadLock(); + } + } + private static bool MatchesDimensionFilters( KeyValuePair[] attributes, IReadOnlyDictionary> dimensionFilters) @@ -2224,10 +2419,16 @@ private static bool MatchesDimensionFilters( public DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) { - return GetResources(resourceKey) - .Select(resource => resource.GetInstrument(meterName, instrumentName, DateTime.MinValue, DateTime.MaxValue)) - .OfType() - .SelectMany(instrument => instrument.Dimensions.Values) + var instrument = GetInstrument(new GetInstrumentRequest + { + ResourceKey = resourceKey, + MeterName = meterName, + InstrumentName = instrumentName, + StartTime = DateTime.MinValue, + EndTime = DateTime.MaxValue + }); + + return instrument?.Dimensions .SelectMany(dimension => dimension.Values) .Select(value => (DateTime?)value.End) .Max(); @@ -2269,4 +2470,13 @@ public void Dispose() DisposeWatchers(); } + + private sealed record ResourceEntry(OtlpResource Resource) + { + public ReaderWriterLockSlim MetricsLock { get; } = new(); + // Bounded by MaxScopeCount. Cleared when metrics are cleared. + public Dictionary Meters { get; } = []; + // Bounded by MaxInstrumentCount. Cleared when metrics are cleared. + public Dictionary Instruments { get; } = []; + } } From c5f6ba2d4fcd20fd99a77007763e5b12d29ad11f Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 23 Jul 2026 11:07:46 +0800 Subject: [PATCH 69/87] Use telemetry repository read-only state --- .../Components/Controls/ClearSignalsButton.razor | 3 +-- .../Controls/ClearSignalsButton.razor.cs | 9 +++++++-- .../Controls/SignalsActionsDisplay.razor | 4 ++-- .../Controls/SignalsActionsDisplay.razor.cs | 4 +++- .../Components/Dialogs/ManageDataDialog.razor | 4 ++-- .../Dialogs/ManageDataDialogTests.cs | 8 +++++--- .../Pages/ConsoleLogsTests.cs | 6 +++--- .../Pages/MetricsTests.cs | 15 +++++++++++++++ 8 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor index 35f6b6434f5..9b2ed07e37f 100644 --- a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor @@ -2,7 +2,6 @@ @namespace Aspire.Dashboard.Components.Controls @inject IStringLocalizer Loc -@inject IDashboardClient DashboardClient diff --git a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs index 747720d5694..1e604c7bb5a 100644 --- a/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/ClearSignalsButton.razor.cs @@ -17,6 +17,11 @@ public partial class ClearSignalsButton : ComponentBase private static readonly Icon s_clearSelectedResourceIcon = new Icons.Regular.Size16.SelectAllOn(); private static readonly Icon s_clearAllResourcesIcon = new Icons.Regular.Size16.Stack(); + [Inject] + public required DashboardDataSource DataSource { get; init; } + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; + [Inject] public required IStringLocalizer ControlsStringsLoc { get; init; } @@ -37,7 +42,7 @@ protected override void OnParametersSet() Id = "clear-menu-all", Icon = s_clearAllResourcesIcon, OnClick = () => HandleClearSignal(null), - IsDisabled = DashboardClient.IsReadOnly, + IsDisabled = TelemetryRepository.IsReadOnly, Text = ControlsStringsLoc[name: nameof(ControlsStrings.ClearAllResources)], }); @@ -46,7 +51,7 @@ protected override void OnParametersSet() Id = "clear-menu-resource", Icon = s_clearSelectedResourceIcon, OnClick = () => HandleClearSignal(SelectedResource.Id?.GetResourceKey()), - IsDisabled = DashboardClient.IsReadOnly || SelectedResource.Id == null, + IsDisabled = TelemetryRepository.IsReadOnly || SelectedResource.Id == null, Text = SelectedResource.Id == null ? ControlsStringsLoc[nameof(ControlsStrings.ClearPendingSelectedResource)] : string.Format(CultureInfo.InvariantCulture, ControlsStringsLoc[name: nameof(ControlsStrings.ClearSelectedResource)], SelectedResource.Name), diff --git a/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor b/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor index fc66215078f..5d07f3c3bb3 100644 --- a/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor +++ b/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor @@ -3,7 +3,7 @@ @if (ViewportInformation.IsDesktop) { - + } @@ -12,7 +12,7 @@ else @ControlsStringsLoc[nameof(ControlsStrings.ResourceActions)] - + diff --git a/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor.cs b/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor.cs index a21961eddc0..ba102f828ef 100644 --- a/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/SignalsActionsDisplay.razor.cs @@ -10,7 +10,9 @@ namespace Aspire.Dashboard.Components.Controls; public partial class SignalsActionsDisplay { [Inject] - public required IDashboardClient DashboardClient { get; init; } + public required DashboardDataSource DataSource { get; init; } + + public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [CascadingParameter] public required ViewportInformation ViewportInformation { get; set; } diff --git a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor index 1e2db7d1712..1c80c985c55 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor @@ -140,13 +140,13 @@ @Loc[nameof(Dialogs.ManageDataRemoveButtonText)]
- @if (TelemetryImportService.IsImportEnabled && !DashboardClient.IsReadOnly) + @if (TelemetryImportService.IsImportEnabled && !TelemetryRepository.IsReadOnly) {
(); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs index b145aac257c..eda2be05c80 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs @@ -778,7 +778,7 @@ public void MenuButtons_SelectedResourceChanged_ButtonsUpdated() } [Fact] - public void ReadOnly_HighlightedCommandIsVisibleAndDisabled() + public void DashboardReadOnly_DisablesCommandButNotTelemetryActions() { var resource = ModelTestHelpers.CreateResource( resourceName: "test-resource", @@ -817,10 +817,10 @@ public void ReadOnly_HighlightedCommandIsVisibleAndDisabled() var clearButton = Assert.Single( cut.FindComponents(), button => string.Equals(button.Instance.Class, "clear-button", StringComparison.Ordinal)); - Assert.True(clearButton.Instance.Disabled); + Assert.False(clearButton.Instance.Disabled); var pauseButton = Assert.Single(cut.FindComponents()); - Assert.True(pauseButton.Instance.Disabled); + Assert.False(pauseButton.Instance.Disabled); }); } diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs index d34fe870777..144d58cb9aa 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs @@ -31,6 +31,21 @@ public partial class MetricsTests : DashboardTestContext { private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + public void SignalActions_UseTelemetryRepositoryReadOnlyState(bool telemetryRepositoryIsReadOnly, bool dashboardClientIsReadOnly) + { + MetricsSetupHelpers.SetupMetricsPage(this); + FluentUISetupHelpers.ConfigureTelemetryRepository(this, telemetryRepositoryIsReadOnly, _ => { }); + Services.AddSingleton(new TestDashboardClient(isReadOnly: dashboardClientIsReadOnly)); + + var cut = RenderComponent(builder => builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false))); + + Assert.Equal(telemetryRepositoryIsReadOnly, cut.FindComponent().Instance.Disabled); + Assert.Equal(telemetryRepositoryIsReadOnly, cut.FindComponent().FindComponent().Instance.Disabled); + } + [Fact] public void ChangeResource_MeterAndInstrumentOnNewResource_InstrumentSet() { From dd7fd539cb34e553f5e49c00171e7ba782a3a16a Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 23 Jul 2026 11:16:29 +0800 Subject: [PATCH 70/87] Move metric instrument state to in-memory repository --- .../Otlp/Model/OtlpInstrument.cs | 148 ----------------- .../Controls/PlotlyChartTests.cs | 20 +-- .../Telemetry/InMemoryTelemetryRepository.cs | 151 +++++++++++++++++- 3 files changed, 152 insertions(+), 167 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs index b7978ca3c1f..6e9c6a6293f 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpInstrument.cs @@ -2,12 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.InteropServices; using Aspire.Dashboard.Otlp.Model.MetricValues; -using Aspire.Dashboard.Otlp.Storage; -using Google.Protobuf.Collections; -using OpenTelemetry.Proto.Common.V1; namespace Aspire.Dashboard.Otlp.Model; @@ -31,146 +26,3 @@ public class OtlpInstrumentData public required Dictionary> KnownAttributeValues { get; init; } public required bool HasOverflow { get; init; } } - -[DebuggerDisplay("Name = {Summary.Name}, Unit = {Summary.Unit}, Type = {Summary.Type}")] -public class OtlpInstrument -{ - public required OtlpInstrumentSummary Summary { get; init; } - public required OtlpContext Context { get; init; } - - public Dictionary>, DimensionScope> Dimensions { get; } = new(ScopeAttributesComparer.Instance); - public Dictionary> KnownAttributeValues { get; } = new(); - public bool HasOverflow { get; set; } - - public DimensionScope FindScope(RepeatedField attributes, ref KeyValuePair[]? tempAttributes) - { - // See https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/sdk.md#overflow-attribute - // Inspect attributes before they're merged with parent attributes. "otel.metric.overflow" should be the only attribute. - if (!HasOverflow && attributes.Count == 1 && attributes[0].Key == "otel.metric.overflow" && attributes[0].Value.GetString() == "true") - { - HasOverflow = true; - } - - // We want to find the dimension scope that matches the attributes, but we don't want to allocate. - // Copy values to a temporary reusable array. - // - // A meter can have attributes. Merge these with the data point attributes when creating a dimension. - OtlpHelpers.CopyKeyValuePairs(attributes, Summary.Parent.Attributes, Context, out var copyCount, ref tempAttributes); - Array.Sort(tempAttributes, 0, copyCount, KeyValuePairComparer.Instance); - - var comparableAttributes = tempAttributes.AsMemory(0, copyCount); - - // Can't use CollectionsMarshal.GetValueRefOrAddDefault here because comparableAttributes is a view over mutable data. - // Need to add dimensions using durable attributes instance after scope is created. - if (!Dimensions.TryGetValue(comparableAttributes, out var dimension)) - { - if (Dimensions.Count >= TelemetryRepositoryLimits.MaxDimensionCount) - { - throw new InvalidOperationException($"Dimension limit of {TelemetryRepositoryLimits.MaxDimensionCount} reached for instrument '{Summary.Name}'."); - } - - dimension = CreateDimensionScope(comparableAttributes); - Dimensions.Add(dimension.Attributes, dimension); - } - return dimension; - } - - private DimensionScope CreateDimensionScope(Memory> comparableAttributes) - { - var isFirst = Dimensions.Count == 0; - var durableAttributes = comparableAttributes.ToArray(); - var dimension = new DimensionScope(Context.Options.MaxMetricsCount, durableAttributes); - - var keys = KnownAttributeValues.Keys.Union(durableAttributes.Select(a => a.Key)).Distinct(); - foreach (var key in keys) - { - ref var values = ref CollectionsMarshal.GetValueRefOrAddDefault(KnownAttributeValues, key, out var existed); - // Adds to dictionary if not present. - if (values == null) - { - if (!existed && KnownAttributeValues.Count > TelemetryRepositoryLimits.MaxKnownAttributeValueCount) - { - // Over limit. Remove the default entry that GetValueRefOrAddDefault added. - KnownAttributeValues.Remove(key); - continue; - } - - values = new List(); - - // If the key is new and there are already dimensions, add an empty value because there are dimensions without this key. - if (!isFirst) - { - TryAddValue(values, null, TelemetryRepositoryLimits.MaxKnownAttributeValuesPerKey); - } - } - - var currentDimensionValue = OtlpHelpers.GetValue(durableAttributes, key); - TryAddValue(values, currentDimensionValue, TelemetryRepositoryLimits.MaxKnownAttributeValuesPerKey); - } - - return dimension; - - static void TryAddValue(List values, string? value, int maxValues) - { - if (values.Count < maxValues && !values.Contains(value)) - { - values.Add(value); - } - } - } - - public static OtlpInstrument Clone(OtlpInstrument instrument, bool cloneData, DateTime? valuesStart, DateTime? valuesEnd) - { - var newInstrument = new OtlpInstrument - { - Summary = instrument.Summary, - Context = instrument.Context, - HasOverflow = instrument.HasOverflow - }; - - if (cloneData) - { - foreach (var item in instrument.KnownAttributeValues) - { - newInstrument.KnownAttributeValues.Add(item.Key, item.Value.ToList()); - } - foreach (var item in instrument.Dimensions) - { - newInstrument.Dimensions.Add(item.Key, DimensionScope.Clone(item.Value, valuesStart, valuesEnd)); - } - } - - return newInstrument; - } - - private sealed class ScopeAttributesComparer : IEqualityComparer>> - { - public static readonly ScopeAttributesComparer Instance = new(); - - public bool Equals(ReadOnlyMemory> x, ReadOnlyMemory> y) - { - return x.Span.SequenceEqual(y.Span); - } - - public int GetHashCode([DisallowNull] ReadOnlyMemory> obj) - { - var hashcode = new HashCode(); - foreach (KeyValuePair pair in obj.Span) - { - hashcode.Add(pair.Key); - hashcode.Add(pair.Value); - } - return hashcode.ToHashCode(); - } - } - - private sealed class KeyValuePairComparer : IComparer> - { - public static readonly KeyValuePairComparer Instance = new(); - - public int Compare(KeyValuePair x, KeyValuePair y) - { - return string.Compare(x.Key, y.Key, StringComparison.Ordinal); - } - } -} diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/PlotlyChartTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/PlotlyChartTests.cs index eea2f5eb699..48453a51b6e 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/PlotlyChartTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/PlotlyChartTests.cs @@ -56,18 +56,14 @@ public async Task Render_HasInstrument_InitializeChartInvocation() var options = new TelemetryLimitOptions(); var logger = NullLogger.Instance; var context = new OtlpContext { Options = options, Logger = logger }; - var instrument = new OtlpInstrument + var instrumentSummary = new OtlpInstrumentSummary { - Summary = new OtlpInstrumentSummary - { - Name = "Name-Bold", - Unit = "Unit-Bold", - Description = "Description-Bold", - Parent = new OtlpScope("Parent-Name-Bold", string.Empty, []), - Type = OtlpInstrumentType.Sum, - AggregationTemporality = OtlpAggregationTemporality.Cumulative - }, - Context = context + Name = "Name-Bold", + Unit = "Unit-Bold", + Description = "Description-Bold", + Parent = new OtlpScope("Parent-Name-Bold", string.Empty, []), + Type = OtlpInstrumentType.Sum, + AggregationTemporality = OtlpAggregationTemporality.Cumulative }; var model = new InstrumentViewModel(); @@ -79,7 +75,7 @@ public async Task Render_HasInstrument_InitializeChartInvocation() TimeUnixNano = long.MaxValue }, context); - await model.UpdateDataAsync(instrument.Summary, [dimension]); + await model.UpdateDataAsync(instrumentSummary, [dimension]); // Act var cut = RenderComponent(builder => diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index 44e11158c23..e6fc2f00dc0 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -17,6 +17,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.FluentUI.AspNetCore.Components; +using OpenTelemetry.Proto.Common.V1; using OpenTelemetry.Proto.Logs.V1; using OpenTelemetry.Proto.Metrics.V1; using OpenTelemetry.Proto.Resource.V1; @@ -1790,7 +1791,7 @@ private void AddMetrics(ResourceEntry resourceEntry, AddContext context, Repeate foreach (var metric in scopeMetric.Metrics) { - OtlpInstrument instrument; + InMemoryInstrument instrument; try { @@ -1806,7 +1807,7 @@ private void AddMetrics(ResourceEntry resourceEntry, AddContext context, Repeate } else if (resourceEntry.Instruments.Count < TelemetryRepositoryLimits.MaxInstrumentCount) { - instrument = new OtlpInstrument + instrument = new InMemoryInstrument { Summary = new OtlpInstrumentSummary { @@ -1846,7 +1847,7 @@ private void AddMetrics(ResourceEntry resourceEntry, AddContext context, Repeate } } - private void AddMetrics(OtlpInstrument instrument, Metric metric, AddContext context, ref KeyValuePair[]? tempAttributes) + private void AddMetrics(InMemoryInstrument instrument, Metric metric, AddContext context, ref KeyValuePair[]? tempAttributes) { switch (metric.DataCase) { @@ -2354,7 +2355,7 @@ public List GetInstrumentSummaries(ResourceKey key) var instrumentKey = new OtlpInstrumentKey(request.MeterName, request.InstrumentName); var instruments = resources .Select(resource => CloneInstrument(resource, instrumentKey, request.StartTime, request.EndTime)) - .OfType() + .OfType() .ToList(); if (instruments.Count == 0) @@ -2388,13 +2389,13 @@ public List GetInstrumentSummaries(ResourceKey key) }; } - private static OtlpInstrument? CloneInstrument(ResourceEntry resource, OtlpInstrumentKey key, DateTime? valuesStart, DateTime? valuesEnd) + private static InMemoryInstrument? CloneInstrument(ResourceEntry resource, OtlpInstrumentKey key, DateTime? valuesStart, DateTime? valuesEnd) { resource.MetricsLock.EnterReadLock(); try { return resource.Instruments.TryGetValue(key, out var instrument) - ? OtlpInstrument.Clone(instrument, cloneData: true, valuesStart: valuesStart, valuesEnd: valuesEnd) + ? InMemoryInstrument.Clone(instrument, valuesStart, valuesEnd) : null; } finally @@ -2471,12 +2472,148 @@ public void Dispose() DisposeWatchers(); } + [DebuggerDisplay("Name = {Summary.Name}, Unit = {Summary.Unit}, Type = {Summary.Type}")] + private sealed class InMemoryInstrument + { + public required OtlpInstrumentSummary Summary { get; init; } + public required OtlpContext Context { get; init; } + + public Dictionary>, DimensionScope> Dimensions { get; } = new(ScopeAttributesComparer.Instance); + public Dictionary> KnownAttributeValues { get; } = []; + public bool HasOverflow { get; set; } + + public DimensionScope FindScope(RepeatedField attributes, ref KeyValuePair[]? tempAttributes) + { + // See https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/sdk.md#overflow-attribute + // Inspect attributes before they're merged with parent attributes. "otel.metric.overflow" should be the only attribute. + if (!HasOverflow && attributes.Count == 1 && attributes[0].Key == "otel.metric.overflow" && attributes[0].Value.GetString() == "true") + { + HasOverflow = true; + } + + // We want to find the dimension scope that matches the attributes, but we don't want to allocate. + // Copy values to a temporary reusable array. + // + // A meter can have attributes. Merge these with the data point attributes when creating a dimension. + OtlpHelpers.CopyKeyValuePairs(attributes, Summary.Parent.Attributes, Context, out var copyCount, ref tempAttributes); + Array.Sort(tempAttributes, 0, copyCount, KeyValuePairComparer.Instance); + + var comparableAttributes = tempAttributes.AsMemory(0, copyCount); + + // Can't use CollectionsMarshal.GetValueRefOrAddDefault here because comparableAttributes is a view over mutable data. + // Need to add dimensions using durable attributes instance after scope is created. + if (!Dimensions.TryGetValue(comparableAttributes, out var dimension)) + { + if (Dimensions.Count >= TelemetryRepositoryLimits.MaxDimensionCount) + { + throw new InvalidOperationException($"Dimension limit of {TelemetryRepositoryLimits.MaxDimensionCount} reached for instrument '{Summary.Name}'."); + } + + dimension = CreateDimensionScope(comparableAttributes); + Dimensions.Add(dimension.Attributes, dimension); + } + return dimension; + } + + private DimensionScope CreateDimensionScope(Memory> comparableAttributes) + { + var isFirst = Dimensions.Count == 0; + var durableAttributes = comparableAttributes.ToArray(); + var dimension = new DimensionScope(Context.Options.MaxMetricsCount, durableAttributes); + + var keys = KnownAttributeValues.Keys.Union(durableAttributes.Select(attribute => attribute.Key)).Distinct(); + foreach (var key in keys) + { + ref var values = ref CollectionsMarshal.GetValueRefOrAddDefault(KnownAttributeValues, key, out var existed); + // Adds to dictionary if not present. + if (values is null) + { + if (!existed && KnownAttributeValues.Count > TelemetryRepositoryLimits.MaxKnownAttributeValueCount) + { + // Over limit. Remove the default entry that GetValueRefOrAddDefault added. + KnownAttributeValues.Remove(key); + continue; + } + + values = []; + + // If the key is new and there are already dimensions, add an empty value because there are dimensions without this key. + if (!isFirst) + { + TryAddValue(values, null, TelemetryRepositoryLimits.MaxKnownAttributeValuesPerKey); + } + } + + var currentDimensionValue = OtlpHelpers.GetValue(durableAttributes, key); + TryAddValue(values, currentDimensionValue, TelemetryRepositoryLimits.MaxKnownAttributeValuesPerKey); + } + + return dimension; + + static void TryAddValue(List values, string? value, int maxValues) + { + if (values.Count < maxValues && !values.Contains(value)) + { + values.Add(value); + } + } + } + + public static InMemoryInstrument Clone(InMemoryInstrument instrument, DateTime? valuesStart, DateTime? valuesEnd) + { + var newInstrument = new InMemoryInstrument + { + Summary = instrument.Summary, + Context = instrument.Context, + HasOverflow = instrument.HasOverflow + }; + + foreach (var item in instrument.KnownAttributeValues) + { + newInstrument.KnownAttributeValues.Add(item.Key, item.Value.ToList()); + } + foreach (var item in instrument.Dimensions) + { + newInstrument.Dimensions.Add(item.Key, DimensionScope.Clone(item.Value, valuesStart, valuesEnd)); + } + + return newInstrument; + } + + private sealed class ScopeAttributesComparer : IEqualityComparer>> + { + public static readonly ScopeAttributesComparer Instance = new(); + + public bool Equals(ReadOnlyMemory> x, ReadOnlyMemory> y) => + x.Span.SequenceEqual(y.Span); + + public int GetHashCode([DisallowNull] ReadOnlyMemory> obj) + { + var hashcode = new HashCode(); + foreach (var pair in obj.Span) + { + hashcode.Add(pair.Key); + hashcode.Add(pair.Value); + } + return hashcode.ToHashCode(); + } + } + + private sealed class KeyValuePairComparer : IComparer> + { + public static readonly KeyValuePairComparer Instance = new(); + + public int Compare(KeyValuePair x, KeyValuePair y) => + string.Compare(x.Key, y.Key, StringComparison.Ordinal); + } + } + private sealed record ResourceEntry(OtlpResource Resource) { public ReaderWriterLockSlim MetricsLock { get; } = new(); // Bounded by MaxScopeCount. Cleared when metrics are cleared. public Dictionary Meters { get; } = []; // Bounded by MaxInstrumentCount. Cleared when metrics are cleared. - public Dictionary Instruments { get; } = []; + public Dictionary Instruments { get; } = []; } } From 69bc5f00383e8aa69ab3787d45d9a7a22525286c Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 23 Jul 2026 13:21:29 +0800 Subject: [PATCH 71/87] Improve dashboard metric chart performance --- .../Components/Controls/Chart/ChartBase.cs | 1 + .../Controls/Chart/ChartContainer.razor | 3 + .../Controls/Chart/ChartContainer.razor.cs | 53 +++++++++++----- .../Otlp/Storage/GetInstrumentRequest.cs | 5 ++ .../Otlp/Storage/ITelemetryRepository.cs | 10 +++ ...SqliteTelemetryRepository.Metrics.Reads.cs | 52 +++++++++++----- .../Otlp/Storage/SqliteTelemetryRepository.cs | 2 + .../Pages/MetricsTests.cs | 62 ++++++++++++++++++- .../Shared/FluentUISetupHelpers.cs | 1 + .../TelemetryRepositoryTests/MetricsTests.cs | 5 ++ .../Telemetry/InMemoryTelemetryRepository.cs | 22 +++++++ 11 files changed, 187 insertions(+), 29 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs index 408613be958..d6daa66214a 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs @@ -272,6 +272,7 @@ private string GetDisplayedUnit(OtlpInstrumentSummary instrument) protected virtual ValueTask DisposeAsync(bool disposing) { + InstrumentViewModel.DataUpdateSubscriptions.Remove(OnInstrumentDataUpdate); _cts.Cancel(); _cts.Dispose(); return ValueTask.CompletedTask; diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor index 977d58e51b0..3d7e683b6c1 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor @@ -30,11 +30,13 @@ else
}
+ @* Avoid rendering the inactive graph or table because both subscribe to and process metric data updates. *@
@@ -44,6 +46,7 @@ else
diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs index 2fc53f8ca4f..5e838153cd9 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs @@ -24,7 +24,9 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable private IDisposable? _themeChangedSubscription; private readonly InstrumentViewModel _instrumentViewModel = new InstrumentViewModel(); private (ResourceKey ResourceKey, string MeterName, string InstrumentName)? _dataEndTimeKey; + private (ResourceKey ResourceKey, string MeterName, string InstrumentName, TimeSpan Duration)? _instrumentRequestKey; private DateTimeOffset? _dataEndTime; + private long _lastDataFetchTimestamp; [Parameter, EditorRequired] public required ResourceKey ResourceKey { get; set; } @@ -64,6 +66,9 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable [Inject] public required PauseManager PauseManager { get; init; } + [Inject] + public required DashboardActivitySource DashboardActivitySource { get; init; } + public ImmutableList DimensionFilters { get; set; } = []; public string? PreviousMeterName { get; set; } public string? PreviousInstrumentName { get; set; } @@ -76,7 +81,10 @@ protected override async Task OnInitializedAsync() { // Update the graph every 200ms. This displays the latest data and moves time forward. _tickTimer = new PeriodicTimer(s_chartUpdateInterval); - _tickTask = Task.Run(UpdateDataAsync); + using (ExecutionContext.SuppressFlow()) + { + _tickTask = Task.Run(UpdateDataAsync); + } } _themeChangedSubscription = ThemeManager.OnThemeChanged(async () => { @@ -100,13 +108,14 @@ public async ValueTask DisposeAsync() private async Task UpdateDataAsync() { var timer = _tickTimer; - long? lastDataFetchTimestamp = null; while (await timer!.WaitForNextTickAsync()) { - if (lastDataFetchTimestamp is null || Stopwatch.GetElapsedTime(lastDataFetchTimestamp.Value) >= s_dataFetchInterval) + var lastDataFetchTimestamp = Volatile.Read(ref _lastDataFetchTimestamp); + if (lastDataFetchTimestamp == 0 || Stopwatch.GetElapsedTime(lastDataFetchTimestamp) >= s_dataFetchInterval) { + using var activity = DashboardActivitySource.ActivitySource.StartActivity("Update metric chart data from tick"); + _instrument = GetInstrument(useIncrementalCache: true); - lastDataFetchTimestamp = Stopwatch.GetTimestamp(); if (_instrument is not null && HaveDimensionFilterValuesChanged(_instrument)) { @@ -159,6 +168,16 @@ private async Task ShowCountChangedAsync(bool showCount) } protected override async Task OnParametersSetAsync() + { + var requestKey = (ResourceKey, MeterName, InstrumentName, Duration); + if (_instrumentRequestKey != requestKey) + { + _instrumentRequestKey = requestKey; + await RefreshChartAsync(); + } + } + + private async Task RefreshChartAsync() { _instrument = GetInstrument(useIncrementalCache: false); @@ -178,6 +197,17 @@ protected override async Task OnParametersSetAsync() private OtlpInstrumentData? GetInstrument(bool useIncrementalCache) { + var instrumentSummary = TelemetryRepository.GetInstrumentSummary(ResourceKey, MeterName, InstrumentName); + if (instrumentSummary is null) + { + Logger.LogDebug( + "Unable to find instrument. ResourceKey: {ResourceKey}, MeterName: {MeterName}, InstrumentName: {InstrumentName}", + ResourceKey, + MeterName, + InstrumentName); + return null; + } + DateTime endDate; if (TelemetryRepository.IsReadOnly) { @@ -192,6 +222,7 @@ protected override async Task OnParametersSetAsync() } var dataPointInterval = MetricDataPointInterval.Get(Duration); + var includeExemplars = instrumentSummary.Type == OtlpInstrumentType.Histogram; // Histogram graphs need one preceding rollup to calculate bucket count changes at the beginning of the chart. var historyDuration = TimeSpan.FromTicks(Math.Max(TimeSpan.FromSeconds(30).Ticks, dataPointInterval.Ticks)); @@ -208,6 +239,7 @@ protected override async Task OnParametersSetAsync() StartTime = startDate, EndTime = endDate, DataPointInterval = dataPointInterval, + IncludeExemplars = includeExemplars, PopulateExemplarAttributes = false, DimensionCursors = cursors, DimensionFilters = DimensionFilters @@ -216,17 +248,10 @@ protected override async Task OnParametersSetAsync() filter => filter.Name, filter => (IReadOnlyList)filter.SelectedValues.Select(value => value.Value).ToArray()) }); + Debug.Assert(refreshedInstrument is not null); + Volatile.Write(ref _lastDataFetchTimestamp, Stopwatch.GetTimestamp()); - if (refreshedInstrument == null) - { - Logger.LogDebug( - "Unable to find instrument. ResourceKey: {ResourceKey}, MeterName: {MeterName}, InstrumentName: {InstrumentName}", - ResourceKey, - MeterName, - InstrumentName); - } - - return refreshedInstrument is not null && _instrument is not null && cursors.Count > 0 + return _instrument is not null && cursors.Count > 0 ? MetricInstrumentDataCache.Merge(_instrument, refreshedInstrument, cursors, startDate) : refreshedInstrument; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs b/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs index 6007f7e5a78..96d730ec3e6 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/GetInstrumentRequest.cs @@ -48,6 +48,11 @@ public sealed class GetInstrumentRequest /// public TimeSpan? DataPointInterval { get; init; } + /// + /// Gets a value indicating whether exemplars should be retrieved. + /// + public bool IncludeExemplars { get; init; } = true; + /// /// Gets a value indicating whether exemplar attributes should be populated. /// diff --git a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs index fab728553b2..b243ba515f6 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepository.cs @@ -51,6 +51,16 @@ public interface ITelemetryRepository : IDisposable OtlpSpan? GetSpan(string traceId, string spanId); OtlpResource? GetPeerResource(OtlpSpan span); List GetInstrumentSummaries(ResourceKey key); + + /// + /// Gets the summary for an instrument emitted by a resource. + /// + /// The resource that emitted the instrument. + /// The name of the meter that contains the instrument. + /// The name of the instrument. + /// The instrument summary, or when the instrument is not found. + OtlpInstrumentSummary? GetInstrumentSummary(ResourceKey resourceKey, string meterName, string instrumentName); + OtlpInstrumentData? GetInstrument(GetInstrumentRequest request); DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName); diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs index a06b05e26b4..bb3552b46a6 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Reads.cs @@ -126,6 +126,7 @@ FROM effective_metric_points p request.DimensionFilters, request.DimensionCursors, request.DataPointInterval, + request.IncludeExemplars, request.PopulateExemplarAttributes, knownAttributeValues); return new OtlpInstrumentData @@ -164,6 +165,7 @@ private List MaterializeMetricDimensions( IReadOnlyDictionary> dimensionFilters, IReadOnlyList dimensionCursors, TimeSpan? dataPointInterval, + bool includeExemplars, bool populateExemplarAttributes, Dictionary> knownAttributeValues) { @@ -172,13 +174,26 @@ private List MaterializeMetricDimensions( throw new ArgumentOutOfRangeException(nameof(dataPointInterval), interval, "The metric data point interval must be greater than zero."); } - var dimensionIds = connection.Query("SELECT dimension_id FROM telemetry_metric_dimensions WHERE instrument_id IN @InstrumentIds ORDER BY dimension_id;", new { InstrumentIds = instrumentIds }).AsList(); - var attributes = connection.Query(""" - SELECT dimension_id AS OwnerId, attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_metric_dimension_attributes - WHERE dimension_id IN @DimensionIds - ORDER BY dimension_id, ordinal; - """, new { DimensionIds = dimensionIds }).ToLookup(record => record.OwnerId); + var dimensionRecords = connection.Query(""" + SELECT + d.dimension_id AS DimensionId, + a.attribute_key AS AttributeKey, + a.attribute_value AS AttributeValue + FROM telemetry_metric_dimensions d + LEFT JOIN telemetry_metric_dimension_attributes a ON a.dimension_id = d.dimension_id + WHERE d.instrument_id IN @InstrumentIds + ORDER BY d.dimension_id, a.ordinal; + """, new { InstrumentIds = instrumentIds }).AsList(); + var dimensionIds = dimensionRecords.Select(record => record.DimensionId).Distinct().ToArray(); + var attributes = dimensionRecords + .Where(record => record.AttributeKey is not null) + .Select(record => new OwnedAttributeRecord + { + OwnerId = record.DimensionId, + AttributeKey = record.AttributeKey!, + AttributeValue = record.AttributeValue! + }) + .ToLookup(record => record.OwnerId); PopulateKnownAttributeValues(dimensionIds, attributes, knownAttributeValues); var selectedDimensionIds = dimensionIds @@ -231,13 +246,15 @@ FROM rolled_up_metric_points p ? MaterializeMetricHistogramData(connection, pointRecords.Select(point => point.PointId).ToArray()) : (Array.Empty().ToLookup(record => record.PointId), Array.Empty().ToLookup(record => record.PointId)); - var exemplars = MaterializeMetricExemplars( - connection, - dimensionQueryRangesCteSql, - queryParameters, - dataPointInterval, - pointRecords, - populateExemplarAttributes); + var exemplars = includeExemplars + ? MaterializeMetricExemplars( + connection, + dimensionQueryRangesCteSql, + queryParameters, + dataPointInterval, + pointRecords, + populateExemplarAttributes) + : Array.Empty>().ToLookup(pair => pair.Key, pair => pair.Value); for (var dimensionIndex = 0; dimensionIndex < selectedDimensionIds.Length; dimensionIndex++) { @@ -452,6 +469,13 @@ private sealed class MetricPointDataRecord : MetricPointRecord public double? HistogramSum { get; init; } } + private sealed class MetricDimensionAttributeRecord + { + public required long DimensionId { get; init; } + public string? AttributeKey { get; init; } + public string? AttributeValue { get; init; } + } + private sealed class MetricHistogramBucketRecord { public required long PointId { get; init; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index ea49699aa4f..30c3f4b3b90 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -167,6 +167,8 @@ public void AddTraces(AddContext context, RepeatedField resourceS public OtlpSpan? GetSpan(string traceId, string spanId) => GetSpanFromDatabase(traceId, spanId); public OtlpResource? GetPeerResource(OtlpSpan span) => span.UninstrumentedPeer; public List GetInstrumentSummaries(ResourceKey key) => GetCachedInstrumentSummaries(key); + public OtlpInstrumentSummary? GetInstrumentSummary(ResourceKey resourceKey, string meterName, string instrumentName) => + GetCachedInstruments(resourceKey, meterName, instrumentName).FirstOrDefault()?.Summary; public OtlpInstrumentData? GetInstrument(GetInstrumentRequest request) => GetInstrumentFromDatabase(request); public DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) => GetInstrumentLatestEndTimeFromDatabase(resourceKey, meterName, instrumentName); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs index 144d58cb9aa..b631a19308b 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Concurrent; +using System.Diagnostics; using System.Web; using Aspire.Dashboard.Components.Controls; using Aspire.Dashboard.Components.Pages; @@ -20,6 +22,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.FluentUI.AspNetCore.Components; using OpenTelemetry.Proto.Metrics.V1; +using Aspire.Tests; using Xunit; using static Aspire.Dashboard.Components.Pages.Metrics; using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers; @@ -67,7 +70,7 @@ public void ChangeResource_MeterAndInstrumentNotOnNewResources_InstrumentCleared } [Fact] - public void ChartContainer_AllDimensionsSelected_IncludesNewDimensionInFirstFetch() + public void ChartContainer_ParametersAndActiveView_OnlyRefreshAndRenderWhenChanged() { JSInterop.Mode = JSRuntimeMode.Loose; MetricsSetupHelpers.SetupMetricsPage(this); @@ -112,6 +115,23 @@ public void ChartContainer_AllDimensionsSelected_IncludesNewDimensionInFirstFetc var dimensionFilters = cut.Instance.DimensionFilters; var dimensionFilter = Assert.Single(dimensionFilters); Assert.Equal("GET", Assert.Single(dimensionFilter.SelectedValues).Value); + Assert.Single(cut.FindComponents()); + Assert.Empty(cut.FindComponents()); + + var activities = new ConcurrentQueue(); + using var listener = ActivityListenerHelper.Create(telemetryRepository.SqlActivitySource, onActivityStopped: activities.Enqueue); + + cut.SetParametersAndRender(builder => builder.Add(component => component.ActiveView, MetricViewKind.Table)); + + Assert.Empty(cut.FindComponents()); + Assert.Single(cut.FindComponents()); + Assert.Empty(activities); + + cut.SetParametersAndRender(builder => builder.Add(component => component.ActiveView, MetricViewKind.Graph)); + + Assert.Single(cut.FindComponents()); + Assert.Empty(cut.FindComponents()); + Assert.Empty(activities); telemetryRepository.AddMetrics(new AddContext(), new RepeatedField { @@ -134,9 +154,14 @@ public void ChartContainer_AllDimensionsSelected_IncludesNewDimensionInFirstFetc } } }); + activities.Clear(); cut.SetParametersAndRender(builder => builder.Add(component => component.Duration, TimeSpan.FromMinutes(5))); + Assert.Empty(activities); + + cut.SetParametersAndRender(builder => builder.Add(component => component.Duration, TimeSpan.FromMinutes(1))); + var updatedFilter = Assert.Single(cut.Instance.DimensionFilters); Assert.NotSame(dimensionFilters, cut.Instance.DimensionFilters); Assert.Collection( @@ -148,6 +173,41 @@ public void ChartContainer_AllDimensionsSelected_IncludesNewDimensionInFirstFetc var dimensions = chart.Instance.InstrumentViewModel.MatchedDimensions!; Assert.Equal(2, dimensions.Count); Assert.All(dimensions, dimension => Assert.Equal(1, Assert.IsType>(Assert.Single(dimension.Values)).Value)); + Assert.Contains(activities, activity => ((string?)activity.GetTagItem("db.query.text"))?.Contains("telemetry_metric_points", StringComparison.Ordinal) == true); + } + + [Fact] + public async Task ChartContainer_TickUpdate_CreatesActivity() + { + JSInterop.Mode = JSRuntimeMode.Loose; + MetricsSetupHelpers.SetupMetricsPage(this); + + var activitySource = Services.GetRequiredService(); + var activityStopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var listener = ActivityListenerHelper.Create(activitySource.ActivitySource, onActivityStopped: activity => + { + if (activity.OperationName == "Update metric chart data from tick") + { + activityStopped.TrySetResult(activity); + } + }); + + var cut = RenderComponent(builder => + { + builder.Add(component => component.ResourceKey, new ResourceKey("TestApp", null)); + builder.Add(component => component.MeterName, "test-meter"); + builder.Add(component => component.InstrumentName, "test-instrument"); + builder.Add(component => component.Duration, TimeSpan.FromMinutes(5)); + builder.Add(component => component.ActiveView, MetricViewKind.Graph); + builder.Add(component => component.OnViewChangedAsync, _ => Task.CompletedTask); + builder.Add(component => component.Resources, []); + builder.Add(component => component.PauseText, null); + }); + + var activity = await activityStopped.Task.WaitAsync(DefaultWaitTimeout); + + Assert.Equal(ActivityKind.Internal, activity.Kind); + Assert.Null(activity.ParentId); } [Fact] diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index afdedc70c2c..6020090a769 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -208,6 +208,7 @@ public static void AddCommonDashboardServices( context.Services.AddSingleton(); context.Services.AddSingleton(messageService ?? new MessageService()); context.Services.AddSingleton(); + context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(); diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index 86bb2e90ef5..26c70663d9e 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -101,6 +101,11 @@ public void AddMetrics() Assert.Equal("widget", instrument.Unit); Assert.Equal("test-meter2", instrument.Parent.Name); }); + + var instrumentSummary = repository.GetInstrumentSummary(resources[0].ResourceKey, "test-meter2", "test2"); + Assert.NotNull(instrumentSummary); + Assert.Equal(OtlpInstrumentType.Histogram, instrumentSummary.Type); + Assert.Null(repository.GetInstrumentSummary(resources[0].ResourceKey, "test-meter2", "missing")); } [Fact] diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index e6fc2f00dc0..7960ae72575 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -2349,6 +2349,28 @@ public List GetInstrumentSummaries(ResourceKey key) return resources.Count > 1 ? summaries.DistinctBy(summary => summary.GetKey()).ToList() : summaries; } + public OtlpInstrumentSummary? GetInstrumentSummary(ResourceKey resourceKey, string meterName, string instrumentName) + { + var instrumentKey = new OtlpInstrumentKey(meterName, instrumentName); + foreach (var resource in GetResourceEntries(resourceKey)) + { + resource.MetricsLock.EnterReadLock(); + try + { + if (resource.Instruments.TryGetValue(instrumentKey, out var instrument)) + { + return instrument.Summary; + } + } + finally + { + resource.MetricsLock.ExitReadLock(); + } + } + + return null; + } + public OtlpInstrumentData? GetInstrument(GetInstrumentRequest request) { var resources = GetResourceEntries(request.ResourceKey); From 3ade89e9c151eda7a22badcbec565dba65cdc703 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 23 Jul 2026 13:46:33 +0800 Subject: [PATCH 72/87] Update dashboard metric refresh and injections --- .../Controls/Chart/ChartContainer.razor.cs | 4 +- .../Controls/DashboardRunSelect.razor.cs | 2 +- .../ResourceNameButtonValue.razor.cs | 2 +- .../PropertyValues/SpanIdButtonValue.razor.cs | 2 +- .../Components/Controls/SpanDetails.razor.cs | 2 +- .../Controls/TreeMetricSelector.razor.cs | 2 +- .../Dialogs/ExemplarsDialog.razor.cs | 2 +- .../Components/Dialogs/FilterDialog.razor.cs | 2 +- .../Dialogs/GenAIVisualizerDialog.razor.cs | 2 +- .../Dialogs/ManageDataDialog.razor.cs | 2 +- .../Components/Layout/MainLayout.razor.cs | 2 +- .../Components/Pages/ConsoleLogs.razor.cs | 2 +- .../Components/Pages/Metrics.razor.cs | 2 +- .../Components/Pages/Resources.razor.cs | 2 +- .../Components/Pages/StructuredLogs.razor.cs | 2 +- .../Components/Pages/TraceDetail.razor.cs | 2 +- .../Components/Pages/Traces.razor.cs | 2 +- .../UnreadLogErrorsBadge.razor.cs | 2 +- .../Pages/MetricsTests.cs | 52 ++++++++++++++++--- 19 files changed, 63 insertions(+), 27 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs index 5e838153cd9..09193625398 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor.cs @@ -16,7 +16,7 @@ namespace Aspire.Dashboard.Components; public partial class ChartContainer : ComponentBase, IAsyncDisposable { private static readonly TimeSpan s_chartUpdateInterval = TimeSpan.FromSeconds(0.2); - private static readonly TimeSpan s_dataFetchInterval = TimeSpan.FromSeconds(10); + private static readonly TimeSpan s_dataFetchInterval = TimeSpan.FromSeconds(1); private OtlpInstrumentData? _instrument; private PeriodicTimer? _tickTimer; @@ -53,7 +53,7 @@ public partial class ChartContainer : ComponentBase, IAsyncDisposable public required string? PauseText { get; set; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs index 88d9bcd78d0..9ceebf2fe0b 100644 --- a/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/DashboardRunSelect.razor.cs @@ -37,7 +37,7 @@ public partial class DashboardRunSelect : ComponentBase public required BrowserTimeProvider TimeProvider { get; init; } [Inject] - internal IDashboardRunStore RunStore { get; init; } = null!; + public required IDashboardRunStore RunStore { get; init; } private void LoadRuns() { diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceNameButtonValue.razor.cs b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceNameButtonValue.razor.cs index d4d574e4073..59ad01c2f08 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceNameButtonValue.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/ResourceNameButtonValue.razor.cs @@ -28,7 +28,7 @@ public partial class ResourceNameButtonValue public required IDashboardClient DashboardClient { get; init; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } [Inject] public required IconResolver IconResolver { get; init; } diff --git a/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs b/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs index 2ea456e88e6..52ab3ca72a2 100644 --- a/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/PropertyValues/SpanIdButtonValue.razor.cs @@ -24,7 +24,7 @@ public partial class SpanIdButtonValue public required DashboardDialogService DialogService { get; init; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs index 7f8891eea85..57df5495725 100644 --- a/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/SpanDetails.razor.cs @@ -34,7 +34,7 @@ public partial class SpanDetails : IDisposable public required NavigationManager NavigationManager { get; init; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs b/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs index 12a1f988dc9..e0b77afb73d 100644 --- a/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TreeMetricSelector.razor.cs @@ -19,7 +19,7 @@ public partial class TreeMetricSelector public bool IncludeLabel { get; set; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs index e69a423c284..267d48557c2 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/ExemplarsDialog.razor.cs @@ -27,7 +27,7 @@ public partial class ExemplarsDialog : IDisposable public required NavigationManager NavigationManager { get; init; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs index ac56584d9af..a9c49786593 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/FilterDialog.razor.cs @@ -30,7 +30,7 @@ private SelectViewModel CreateFilterSelectViewModel(FilterCondi public FilterDialogViewModel Content { get; set; } = default!; [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs index acb0a68eb21..b42e7db08c2 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/GenAIVisualizerDialog.razor.cs @@ -47,7 +47,7 @@ public partial class GenAIVisualizerDialog : ComponentBase, IComponentWithTeleme public required BrowserTimeProvider TimeProvider { get; init; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs index 570a18cbfd0..23cffb166fd 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs @@ -30,7 +30,7 @@ public partial class ManageDataDialog : IDialogContentComponent, IAsyncDisposabl public required NavigationManager NavigationManager { get; init; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 8931910ba6b..f7ae8e807f6 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -78,7 +78,7 @@ public partial class MainLayout : IGlobalKeydownListener, IAsyncDisposable public required ISessionStorage SessionStorage { get; init; } [Inject] - internal IDashboardRunStore RunStore { get; init; } = null!; + public required IDashboardRunStore RunStore { get; init; } [Inject] internal IDashboardRunSelection RunSelection { get; init; } = null!; diff --git a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs index 585251619aa..fe3226d724c 100644 --- a/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs @@ -81,7 +81,7 @@ public void Cancel() public required ISessionStorage SessionStorage { get; init; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs index 5889d6e8e2d..812c7d17c79 100644 --- a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs @@ -60,7 +60,7 @@ public partial class Metrics : IDisposable, IComponentWithTelemetry, IPageWithSe public required ISessionStorage SessionStorage { get; init; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs index 558577d4f27..0495cc13dc1 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs @@ -45,7 +45,7 @@ public partial class Resources : ComponentBase, IComponentWithTelemetry, IAsyncD [Inject] public required IDashboardClient DashboardClient { get; init; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; [Inject] diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index 8909be7083a..fa26f025996 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -58,7 +58,7 @@ public partial class StructuredLogs : IComponentWithTelemetry, IPageWithSessionA public StructuredLogsPageViewModel PageViewModel { get; set; } = null!; [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs index 6b0ab3c299d..b14513af552 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs @@ -63,7 +63,7 @@ public partial class TraceDetail : ComponentBase, IComponentWithTelemetry, IDisp public required ITelemetryErrorRecorder ErrorRecorder { get; init; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs index c9435ee8eca..547d2fe6403 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs @@ -56,7 +56,7 @@ public partial class Traces : IComponentWithTelemetry, IPageWithSessionAndUrlSta public string? ResourceName { get; set; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs index 357a3bc4033..d6772e00459 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UnreadLogErrorsBadge.razor.cs @@ -20,7 +20,7 @@ public partial class UnreadLogErrorsBadge public required Dictionary? UnviewedErrorCounts { get; set; } [Inject] - private DashboardDataSource DataSource { get; set; } = null!; + public required DashboardDataSource DataSource { get; init; } public ITelemetryRepository TelemetryRepository => DataSource.TelemetryRepository; diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs index b631a19308b..c838fbc8607 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs @@ -177,35 +177,71 @@ public void ChartContainer_ParametersAndActiveView_OnlyRefreshAndRenderWhenChang } [Fact] - public async Task ChartContainer_TickUpdate_CreatesActivity() + public async Task ChartContainer_TickUpdate_FetchesDataAfterInterval() { JSInterop.Mode = JSRuntimeMode.Loose; MetricsSetupHelpers.SetupMetricsPage(this); - var activitySource = Services.GetRequiredService(); - var activityStopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var listener = ActivityListenerHelper.Create(activitySource.ActivitySource, onActivityStopped: activity => + var telemetryRepository = Services.GetRequiredService(); + telemetryRepository.AddMetrics(new AddContext(), new RepeatedField { - if (activity.OperationName == "Update metric chart data from tick") + new ResourceMetrics { - activityStopped.TrySetResult(activity); + Resource = CreateResource(name: "TestApp"), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = + { + CreateSumMetric(metricName: "test-instrument", startTime: DateTime.UtcNow.AddMinutes(-1)) + } + } + } } }); + var resource = telemetryRepository.GetResources().Single(); + var activitySource = Services.GetRequiredService(); + var activityStarted = new TaskCompletionSource<(Activity Activity, long Timestamp)>(TaskCreationOptions.RunContinuationsAsynchronously); + var activityStopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var listener = ActivityListenerHelper.Create( + activitySource.ActivitySource, + onActivityStarted: activity => + { + if (activity.OperationName == "Update metric chart data from tick") + { + activityStarted.TrySetResult((activity, Stopwatch.GetTimestamp())); + } + }, + onActivityStopped: activity => + { + if (activity.OperationName == "Update metric chart data from tick") + { + activityStopped.TrySetResult(activity); + } + }); + + var renderStartedTimestamp = Stopwatch.GetTimestamp(); var cut = RenderComponent(builder => { - builder.Add(component => component.ResourceKey, new ResourceKey("TestApp", null)); + builder.Add(component => component.ResourceKey, resource.ResourceKey); builder.Add(component => component.MeterName, "test-meter"); builder.Add(component => component.InstrumentName, "test-instrument"); builder.Add(component => component.Duration, TimeSpan.FromMinutes(5)); builder.Add(component => component.ActiveView, MetricViewKind.Graph); builder.Add(component => component.OnViewChangedAsync, _ => Task.CompletedTask); - builder.Add(component => component.Resources, []); + builder.Add(component => component.Resources, [resource]); builder.Add(component => component.PauseText, null); }); + var started = await activityStarted.Task.WaitAsync(DefaultWaitTimeout); var activity = await activityStopped.Task.WaitAsync(DefaultWaitTimeout); + var elapsed = Stopwatch.GetElapsedTime(renderStartedTimestamp, started.Timestamp); + Assert.Same(started.Activity, activity); + Assert.InRange(elapsed, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5)); Assert.Equal(ActivityKind.Internal, activity.Kind); Assert.Null(activity.ParentId); } From 30ce412df6f84faef1fff81497acaef93455d834 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 23 Jul 2026 14:26:04 +0800 Subject: [PATCH 73/87] Combine scope and attribute lookup --- .../SqliteTelemetryRepository.Caching.cs | 36 ++++++++++++------ .../Storage/SqliteTelemetryRepository.Logs.cs | 7 ---- .../SqliteTelemetryPersistenceTests.cs | 38 +++++++++++++++++++ 3 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs index 1d5c7274de1..52e181e8b1b 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs @@ -194,20 +194,23 @@ private CachedResourceScope GetOrAddCachedScope( if (!_cachedScopesByName.TryGetValue(incomingScope.Name, out var cachedScope)) { - var existing = connection.QuerySingleOrDefault(""" - SELECT scope_id AS ScopeId, scope_name AS ScopeName, scope_version AS ScopeVersion - FROM telemetry_scopes - WHERE scope_name = @ScopeName; + var existingRecords = connection.Query(""" + SELECT + s.scope_id AS ScopeId, + s.scope_name AS ScopeName, + s.scope_version AS ScopeVersion, + a.attribute_key AS AttributeKey, + a.attribute_value AS AttributeValue + FROM telemetry_scopes s + LEFT JOIN telemetry_scope_attributes a ON a.scope_id = s.scope_id + WHERE s.scope_name = @ScopeName + ORDER BY a.ordinal; """, new { ScopeName = incomingScope.Name }, transaction); - if (existing is not null) + if (existingRecords.FirstOrDefault() is { } existing) { - var attributes = connection.Query(""" - SELECT attribute_key AS AttributeKey, attribute_value AS AttributeValue - FROM telemetry_scope_attributes - WHERE scope_id = @ScopeId - ORDER BY ordinal; - """, new { existing.ScopeId }, transaction) - .Select(attribute => KeyValuePair.Create(attribute.AttributeKey, attribute.AttributeValue)) + var attributes = existingRecords + .Where(record => record.AttributeKey is not null) + .Select(record => KeyValuePair.Create(record.AttributeKey!, record.AttributeValue!)) .ToArray(); cachedScope = GetOrAddCachedScope(existing.ScopeId, existing.ScopeName, existing.ScopeVersion, attributes); } @@ -809,6 +812,15 @@ private sealed class CachedScopeUsageRecord public required string ScopeVersion { get; init; } } + private sealed class CachedScopeRecord + { + public required long ScopeId { get; init; } + public required string ScopeName { get; init; } + public required string ScopeVersion { get; init; } + public string? AttributeKey { get; init; } + public string? AttributeValue { get; init; } + } + private sealed class CachedScopeAttributeRecord { public required long ScopeId { get; init; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs index 82653ecb031..fa7a9d1c18b 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs @@ -801,13 +801,6 @@ private sealed record LogQuery(string FromAndWhere, DynamicParameters Parameters private sealed record PendingLog(long ResourceId, long ResourceViewId, long ScopeId, OtlpLogEntry Log); - private sealed class ScopeRecord - { - public required long ScopeId { get; init; } - public required string ScopeName { get; init; } - public required string ScopeVersion { get; init; } - } - private class AttributeRecord { public required string AttributeKey { get; init; } diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs index df5de7fd67a..3401ffb9217 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs @@ -425,6 +425,44 @@ FROM sqlite_schema Assert.Equal(0L, command.ExecuteScalar()); } + [Fact] + public void Scopes_ReopenAndReusePersistedScopesWithAndWithoutAttributes() + { + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); + var attributedScope = CreateScope(name: "AttributedScope", attributes: [KeyValuePair.Create("scope-key", "scope-value")]); + var emptyScope = CreateScope(name: "EmptyScope"); + + for (var iteration = 0; iteration < 2; iteration++) + { + using var repository = CreateRepository(workspace.Path); + var addContext = new AddContext(); + repository.AddLogs(addContext, new RepeatedField + { + new ResourceLogs + { + Resource = CreateResource(), + ScopeLogs = + { + new ScopeLogs { Scope = attributedScope, LogRecords = { CreateLogRecord() } }, + new ScopeLogs { Scope = emptyScope, LogRecords = { CreateLogRecord() } } + } + } + }); + Assert.Equal(0, addContext.FailureCount); + } + + using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM telemetry_scopes;"; + Assert.Equal(2L, command.ExecuteScalar()); + command.CommandText = "SELECT COUNT(*) FROM telemetry_scope_attributes;"; + Assert.Equal(1L, command.ExecuteScalar()); + command.CommandText = "SELECT COUNT(*) FROM telemetry_logs;"; + Assert.Equal(4L, command.ExecuteScalar()); + } + [Fact] public void ResourceViews_EquivalentAttributesShareNormalizedRows() { From 67ab96ea6d693c532b3f477ab9bf2ff9a91eac6b Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 24 Jul 2026 11:02:53 +0800 Subject: [PATCH 74/87] Improve dashboard reliability under telemetry load --- .../LargeTelemetryGenerator.cs | 430 ++++++++++++++++++ .../Stress/Stress.ApiService/Program.cs | 9 + .../Stress.ApiService.csproj | 12 + playground/Stress/Stress.AppHost/AppHost.cs | 13 + .../Stress/Stress.AppHost/CommandResources.cs | 7 + .../Properties/launchSettings.json | 27 ++ .../Dialogs/ManageDataDialog.razor.cs | 2 +- .../Components/Pages/Metrics.razor.cs | 3 +- .../Components/Pages/StructuredLogs.razor.cs | 3 +- .../Components/Pages/Traces.razor.cs | 3 +- .../Markdown/HighlightedCodeBlockRenderer.cs | 6 +- .../Model/TelemetryImportService.cs | 18 +- .../Otlp/Grpc/OtlpGrpcLogsService.cs | 2 +- .../Otlp/Grpc/OtlpGrpcMetricsService.cs | 2 +- .../Otlp/Grpc/OtlpGrpcTraceService.cs | 2 +- .../Otlp/Http/OtlpHttpEndpointsBuilder.cs | 12 +- .../Otlp/Model/MetricValues/DimensionScope.cs | 10 +- .../Otlp/Model/OtlpHelpers.cs | 8 + src/Aspire.Dashboard/Otlp/OtlpLogsService.cs | 4 +- .../Otlp/OtlpMetricsService.cs | 4 +- src/Aspire.Dashboard/Otlp/OtlpTraceService.cs | 4 +- .../Storage/ITelemetryRepositoryWriter.cs | 35 +- .../SqliteTelemetryRepository.Caching.cs | 12 +- .../Storage/SqliteTelemetryRepository.Logs.cs | 14 +- ...SqliteTelemetryRepository.Metrics.Reads.cs | 67 +-- ...qliteTelemetryRepository.Metrics.Writes.cs | 145 +++--- .../SqliteTelemetryRepository.Resources.cs | 4 +- ...SqliteTelemetryRepository.Traces.Writes.cs | 16 +- .../Otlp/Storage/SqliteTelemetryRepository.cs | 42 +- .../ServiceClient/DashboardClient.cs | 29 +- .../ServiceClient/DashboardSqliteDatabase.cs | 11 +- .../DatabaseSchema/005.Metrics.sql | 16 +- .../IResourceRepositoryWriter.cs | 8 +- .../ServiceClient/SqliteResourceRepository.cs | 237 ++++++---- src/Aspire.Dashboard/Utils/AsyncLock.cs | 43 ++ .../Dashboard/ResourcePublisher.cs | 2 +- .../Controls/GenAIVisualizerDialogTests.cs | 10 +- .../Dialogs/ManageDataDialogTests.cs | 8 +- .../Pages/MetricsTests.cs | 40 +- .../Pages/ResourcesTests.cs | 8 +- .../Pages/StructuredLogsTests.cs | 8 +- .../Pages/TraceDetailsTests.cs | 42 +- .../Pages/TracesTests.cs | 4 +- .../Shared/FluentUISetupHelpers.cs | 22 +- .../Markdown/MarkdownProcessorTests.cs | 13 +- .../Model/DashboardClientTests.cs | 12 +- .../Model/DashboardDataSourceTests.cs | 4 +- .../Model/DashboardSqliteDatabaseTests.cs | 41 +- .../GenAIVisualizerDialogViewModelTests.cs | 130 +++--- .../Model/ResourceMenuBuilderTests.cs | 8 +- .../Model/SqliteResourceRepositoryTests.cs | 68 +-- .../Model/TelemetryExportServiceTests.cs | 88 ++-- .../Model/TelemetryImportServiceTests.cs | 6 +- .../TelemetryApiServiceTests.cs | 186 ++++---- .../TelemetryRepositoryTests/LogTests.cs | 124 ++--- .../TelemetryRepositoryTests/MetricsTests.cs | 205 ++++++--- .../TelemetryRepositoryTests/ResourceTests.cs | 38 +- .../SqliteTelemetryPersistenceTests.cs | 135 ++++-- .../TelemetryLimitTests.cs | 54 +-- .../TelemetryRepositoryTests.cs | 126 ++--- .../TelemetryRepositoryTests/TraceTests.cs | 304 ++++++------- .../Telemetry/InMemoryTelemetryRepository.cs | 43 +- 62 files changed, 1886 insertions(+), 1103 deletions(-) create mode 100644 playground/Stress/Stress.ApiService/LargeTelemetryGenerator.cs create mode 100644 src/Aspire.Dashboard/Utils/AsyncLock.cs diff --git a/playground/Stress/Stress.ApiService/LargeTelemetryGenerator.cs b/playground/Stress/Stress.ApiService/LargeTelemetryGenerator.cs new file mode 100644 index 00000000000..1958c897ed8 --- /dev/null +++ b/playground/Stress/Stress.ApiService/LargeTelemetryGenerator.cs @@ -0,0 +1,430 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Globalization; +using Google.Protobuf; +using Grpc.Core; +using Grpc.Net.Client; +using OpenTelemetry.Proto.Collector.Logs.V1; +using OpenTelemetry.Proto.Collector.Metrics.V1; +using OpenTelemetry.Proto.Collector.Trace.V1; +using OpenTelemetry.Proto.Common.V1; +using OpenTelemetry.Proto.Logs.V1; +using OpenTelemetry.Proto.Metrics.V1; +using OpenTelemetry.Proto.Resource.V1; +using OpenTelemetry.Proto.Trace.V1; +using OtlpSpan = OpenTelemetry.Proto.Trace.V1.Span; + +namespace Stress.ApiService; + +public sealed class LargeTelemetryGenerator(ILogger logger, IConfiguration configuration) +{ + public const int TraceCount = 50_000; + public const int SpansPerTrace = 6; + public const int LargeTraceSpanCount = 50_000; + public const int StructuredLogCount = 100_000; + public const int ConsoleLogCount = 100_000; + public const int MetricDurationSeconds = 24 * 60 * 60; + public const int MetricDimensionCount = 5; + + private const int TraceBatchSize = 1_000; + private const int LargeTraceBatchSize = 1_000; + private const int LogBatchSize = 1_000; + private const int MetricSecondsPerBatch = 100; + private static readonly double[] s_histogramValues = [5, 25, 75, 150]; + private readonly SemaphoreSlim _runLock = new(1, 1); + + public async Task TryGenerateAsync(CancellationToken cancellationToken) + { + if (!await _runLock.WaitAsync(0, cancellationToken)) + { + return false; + } + + try + { + var endpoint = configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] + ?? throw new InvalidOperationException("OTEL_EXPORTER_OTLP_ENDPOINT is required."); + using var channel = GrpcChannel.ForAddress(endpoint); + var metadata = CreateMetadata(configuration["OTEL_EXPORTER_OTLP_HEADERS"]); + var traceClient = new TraceService.TraceServiceClient(channel); + var logsClient = new LogsService.LogsServiceClient(channel); + var metricsClient = new MetricsService.MetricsServiceClient(channel); + + logger.LogInformation("Generating {TraceCount} traces with {SpansPerTrace} spans each.", TraceCount, SpansPerTrace); + await ExportTracesAsync(traceClient, metadata, cancellationToken); + + logger.LogInformation("Generating one trace with {SpanCount} spans.", LargeTraceSpanCount); + await ExportLargeTraceAsync(traceClient, metadata, cancellationToken); + + logger.LogInformation("Generating {LogCount} structured logs.", StructuredLogCount); + await ExportStructuredLogsAsync(logsClient, metadata, cancellationToken); + + logger.LogInformation("Writing {LogCount} console logs through ILogger.", ConsoleLogCount); + for (var logIndex = 0; logIndex < ConsoleLogCount; logIndex++) + { + logger.LogInformation("Large console log {LogIndex}.", logIndex + 1); + if ((logIndex + 1) % LogBatchSize == 0) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + } + } + + logger.LogInformation( + "Generating {DurationSeconds} seconds of counter and histogram data across {DimensionCount} dimensions.", + MetricDurationSeconds, + MetricDimensionCount); + await ExportMetricsAsync(metricsClient, metadata, cancellationToken); + + logger.LogInformation("Large telemetry generation completed."); + return true; + } + finally + { + _runLock.Release(); + } + } + + private static async Task ExportTracesAsync( + TraceService.TraceServiceClient client, + Metadata metadata, + CancellationToken cancellationToken) + { + var finalTraceTime = DateTime.UtcNow; + for (var firstTraceIndex = 0; firstTraceIndex < TraceCount; firstTraceIndex += TraceBatchSize) + { + var scopeSpans = CreateScopeSpans("LargeTelemetry.ManyTraces"); + var traceCount = Math.Min(TraceBatchSize, TraceCount - firstTraceIndex); + for (var traceOffset = 0; traceOffset < traceCount; traceOffset++) + { + var traceIndex = firstTraceIndex + traceOffset; + var traceId = CreateTraceId(discriminator: 1, traceIndex + 1); + var traceStart = finalTraceTime.AddMilliseconds(traceIndex - TraceCount); + for (var spanIndex = 0; spanIndex < SpansPerTrace; spanIndex++) + { + var isRoot = spanIndex == 0; + scopeSpans.Spans.Add(CreateSpan( + traceId, + CreateSpanId(spanIndex + 1), + isRoot ? ByteString.Empty : CreateSpanId(1), + $"GET /stress/page/{spanIndex + 1}", + traceStart.AddMilliseconds(spanIndex), + isRoot ? traceStart.AddMilliseconds(SpansPerTrace) : traceStart.AddMilliseconds(spanIndex + 1))); + } + } + + await ExportTraceBatchAsync(client, metadata, scopeSpans, cancellationToken); + } + } + + private static async Task ExportLargeTraceAsync( + TraceService.TraceServiceClient client, + Metadata metadata, + CancellationToken cancellationToken) + { + var traceId = CreateTraceId(discriminator: 2, value: 1); + var traceStart = DateTime.UtcNow.AddMinutes(-1); + for (var firstSpanIndex = 0; firstSpanIndex < LargeTraceSpanCount; firstSpanIndex += LargeTraceBatchSize) + { + var scopeSpans = CreateScopeSpans("LargeTelemetry.LargeTrace"); + var spanCount = Math.Min(LargeTraceBatchSize, LargeTraceSpanCount - firstSpanIndex); + for (var spanOffset = 0; spanOffset < spanCount; spanOffset++) + { + var spanIndex = firstSpanIndex + spanOffset; + var isRoot = spanIndex == 0; + scopeSpans.Spans.Add(CreateSpan( + traceId, + CreateSpanId(spanIndex + 1), + isRoot ? ByteString.Empty : CreateSpanId(1), + $"large-trace-span-{spanIndex + 1}", + traceStart.AddTicks(spanIndex * 10L), + isRoot ? traceStart.AddTicks(LargeTraceSpanCount * 10L) : traceStart.AddTicks((spanIndex + 1L) * 10L))); + } + + await ExportTraceBatchAsync(client, metadata, scopeSpans, cancellationToken); + } + } + + private static async Task ExportTraceBatchAsync( + TraceService.TraceServiceClient client, + Metadata metadata, + ScopeSpans scopeSpans, + CancellationToken cancellationToken) + { + // Export requests use the OTLP shape ResourceSpans -> ScopeSpans -> Span. Keeping each request + // below 6,000 spans avoids large gRPC messages while allowing one trace to cross request boundaries. + var request = new ExportTraceServiceRequest + { + ResourceSpans = + { + new ResourceSpans + { + Resource = CreateResource("large-telemetry-traces"), + ScopeSpans = { scopeSpans } + } + } + }; + var response = await client.ExportAsync(request, headers: metadata, cancellationToken: cancellationToken); + if (response.PartialSuccess is { RejectedSpans: > 0 } partialSuccess) + { + throw new InvalidOperationException($"Dashboard rejected {partialSuccess.RejectedSpans} spans: {partialSuccess.ErrorMessage}"); + } + } + + private static async Task ExportStructuredLogsAsync( + LogsService.LogsServiceClient client, + Metadata metadata, + CancellationToken cancellationToken) + { + var finalLogTime = DateTime.UtcNow; + for (var firstLogIndex = 0; firstLogIndex < StructuredLogCount; firstLogIndex += LogBatchSize) + { + var scopeLogs = new ScopeLogs + { + Scope = new InstrumentationScope { Name = "LargeTelemetry.StructuredLogs" } + }; + var logCount = Math.Min(LogBatchSize, StructuredLogCount - firstLogIndex); + for (var logOffset = 0; logOffset < logCount; logOffset++) + { + var logIndex = firstLogIndex + logOffset; + var timestamp = DateTimeToUnixNanoseconds(finalLogTime.AddMilliseconds(logIndex - StructuredLogCount)); + scopeLogs.LogRecords.Add(new LogRecord + { + TimeUnixNano = timestamp, + ObservedTimeUnixNano = timestamp, + SeverityNumber = SeverityNumber.Info, + SeverityText = "Information", + Body = new AnyValue { StringValue = $"Large structured log {logIndex + 1}." }, + Attributes = + { + new KeyValue { Key = "{OriginalFormat}", Value = new AnyValue { StringValue = "Large structured log {LogIndex}." } }, + new KeyValue { Key = "LogIndex", Value = new AnyValue { IntValue = logIndex + 1 } } + } + }); + } + + // Log batches use the OTLP shape ResourceLogs -> ScopeLogs -> LogRecord. + var request = new ExportLogsServiceRequest + { + ResourceLogs = + { + new ResourceLogs + { + Resource = CreateResource("large-telemetry-structured-logs"), + ScopeLogs = { scopeLogs } + } + } + }; + var response = await client.ExportAsync(request, headers: metadata, cancellationToken: cancellationToken); + if (response.PartialSuccess is { RejectedLogRecords: > 0 } partialSuccess) + { + throw new InvalidOperationException($"Dashboard rejected {partialSuccess.RejectedLogRecords} logs: {partialSuccess.ErrorMessage}"); + } + } + } + + private static async Task ExportMetricsAsync( + MetricsService.MetricsServiceClient client, + Metadata metadata, + CancellationToken cancellationToken) + { + var startTime = DateTime.UtcNow.AddDays(-1); + for (var firstSecond = 0; firstSecond < MetricDurationSeconds; firstSecond += MetricSecondsPerBatch) + { + var counter = new Metric + { + Name = "large.telemetry.counter", + Description = "One day of one-second counter data.", + Unit = "requests", + Sum = new Sum + { + AggregationTemporality = AggregationTemporality.Cumulative, + IsMonotonic = true + } + }; + var histogram = new Metric + { + Name = "large.telemetry.histogram", + Description = "One day of one-second histogram data with exemplars.", + Unit = "ms", + Histogram = new Histogram + { + AggregationTemporality = AggregationTemporality.Cumulative + } + }; + + var secondCount = Math.Min(MetricSecondsPerBatch, MetricDurationSeconds - firstSecond); + for (var secondOffset = 0; secondOffset < secondCount; secondOffset++) + { + var secondIndex = firstSecond + secondOffset; + var pointTime = startTime.AddSeconds(secondIndex + 1L); + var timestamp = DateTimeToUnixNanoseconds(pointTime); + for (var dimensionIndex = 0; dimensionIndex < MetricDimensionCount; dimensionIndex++) + { + var dimension = new KeyValue + { + Key = "stress.dimension", + Value = new AnyValue { StringValue = dimensionIndex.ToString(CultureInfo.InvariantCulture) } + }; + counter.Sum.DataPoints.Add(new NumberDataPoint + { + StartTimeUnixNano = DateTimeToUnixNanoseconds(startTime), + TimeUnixNano = timestamp, + AsInt = (long)(secondIndex + 1) * (dimensionIndex + 1), + Attributes = { dimension } + }); + + var observationIndex = secondIndex % s_histogramValues.Length; + var histogramPoint = new HistogramDataPoint + { + StartTimeUnixNano = DateTimeToUnixNanoseconds(startTime), + TimeUnixNano = timestamp, + Count = (ulong)secondIndex + 1, + Sum = CalculateHistogramSum(secondIndex + 1), + ExplicitBounds = { 10, 50, 100 }, + Attributes = { dimension }, + Exemplars = + { + new Exemplar + { + TimeUnixNano = timestamp, + AsDouble = s_histogramValues[observationIndex], + TraceId = CreateTraceId(discriminator: 1, (secondIndex % TraceCount) + 1), + SpanId = CreateSpanId((dimensionIndex % SpansPerTrace) + 1) + } + } + }; + histogramPoint.BucketCounts.Add(CalculateBucketCount(secondIndex + 1, 0)); + histogramPoint.BucketCounts.Add(CalculateBucketCount(secondIndex + 1, 1)); + histogramPoint.BucketCounts.Add(CalculateBucketCount(secondIndex + 1, 2)); + histogramPoint.BucketCounts.Add(CalculateBucketCount(secondIndex + 1, 3)); + histogram.Histogram.DataPoints.Add(histogramPoint); + } + } + + // Metric batches use ResourceMetrics -> ScopeMetrics -> Metric and contain 100 seconds at a + // time. At five dimensions and two instruments this caps each request at 1,000 data points. + var request = new ExportMetricsServiceRequest + { + ResourceMetrics = + { + new ResourceMetrics + { + Resource = CreateResource("large-telemetry-metrics"), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = new InstrumentationScope { Name = "LargeTelemetry.Metrics" }, + Metrics = { counter, histogram } + } + } + } + } + }; + var response = await client.ExportAsync(request, headers: metadata, cancellationToken: cancellationToken); + if (response.PartialSuccess is { RejectedDataPoints: > 0 } partialSuccess) + { + throw new InvalidOperationException($"Dashboard rejected {partialSuccess.RejectedDataPoints} metric points: {partialSuccess.ErrorMessage}"); + } + } + } + + private static ScopeSpans CreateScopeSpans(string name) => new() + { + Scope = new InstrumentationScope { Name = name } + }; + + private static OtlpSpan CreateSpan( + ByteString traceId, + ByteString spanId, + ByteString parentSpanId, + string name, + DateTime startTime, + DateTime endTime) => new() + { + TraceId = traceId, + SpanId = spanId, + ParentSpanId = parentSpanId, + Name = name, + Kind = OtlpSpan.Types.SpanKind.Server, + StartTimeUnixNano = DateTimeToUnixNanoseconds(startTime), + EndTimeUnixNano = DateTimeToUnixNanoseconds(endTime), + Status = new OpenTelemetry.Proto.Trace.V1.Status + { + Code = OpenTelemetry.Proto.Trace.V1.Status.Types.StatusCode.Ok + } + }; + + private static Resource CreateResource(string serviceName) => new() + { + Attributes = + { + new KeyValue { Key = "service.name", Value = new AnyValue { StringValue = serviceName } }, + new KeyValue { Key = "service.instance.id", Value = new AnyValue { StringValue = "large-telemetry-1" } } + } + }; + + private static Metadata CreateMetadata(string? headers) + { + var metadata = new Metadata(); + if (string.IsNullOrWhiteSpace(headers)) + { + return metadata; + } + + // OTEL_EXPORTER_OTLP_HEADERS uses comma-separated key=value pairs. Values may contain '=' and + // may be URL encoded, so split each pair only at its first '=' delimiter. + foreach (var pair in headers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var separatorIndex = pair.IndexOf('='); + if (separatorIndex <= 0) + { + continue; + } + + metadata.Add( + pair[..separatorIndex].Trim().ToLowerInvariant(), + Uri.UnescapeDataString(pair[(separatorIndex + 1)..].Trim())); + } + return metadata; + } + + private static ByteString CreateTraceId(byte discriminator, int value) + { + var id = new byte[16]; + id[0] = discriminator; + BinaryPrimitives.WriteInt32BigEndian(id.AsSpan(12), value); + return ByteString.CopyFrom(id); + } + + private static ByteString CreateSpanId(int value) + { + var id = new byte[8]; + BinaryPrimitives.WriteInt32BigEndian(id.AsSpan(4), value); + return ByteString.CopyFrom(id); + } + + private static ulong CalculateBucketCount(int sampleCount, int observationIndex) + { + var completeCycles = sampleCount / s_histogramValues.Length; + var remainder = sampleCount % s_histogramValues.Length; + return (ulong)(completeCycles + (observationIndex < remainder ? 1 : 0)); + } + + private static double CalculateHistogramSum(int sampleCount) + { + var completeCycles = sampleCount / s_histogramValues.Length; + var remainder = sampleCount % s_histogramValues.Length; + var cycleSum = s_histogramValues.Sum(); + return completeCycles * cycleSum + s_histogramValues.Take(remainder).Sum(); + } + + private static ulong DateTimeToUnixNanoseconds(DateTime dateTime) + { + var timeSinceEpoch = dateTime.ToUniversalTime() - DateTime.UnixEpoch; + return (ulong)timeSinceEpoch.Ticks * 100; + } +} \ No newline at end of file diff --git a/playground/Stress/Stress.ApiService/Program.cs b/playground/Stress/Stress.ApiService/Program.cs index 9806fdae36f..4decb2f9cad 100644 --- a/playground/Stress/Stress.ApiService/Program.cs +++ b/playground/Stress/Stress.ApiService/Program.cs @@ -31,6 +31,7 @@ }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); @@ -43,6 +44,14 @@ app.MapGet("/", () => "Hello world"); +app.MapPost("/large-telemetry", async (LargeTelemetryGenerator generator, CancellationToken cancellationToken) => +{ + var generated = await generator.TryGenerateAsync(cancellationToken); + return generated + ? Results.Ok() + : Results.Conflict("Large telemetry generation is already running."); +}); + app.MapGet("/write-console", () => { for (var i = 0; i < 5000; i++) diff --git a/playground/Stress/Stress.ApiService/Stress.ApiService.csproj b/playground/Stress/Stress.ApiService/Stress.ApiService.csproj index a1e9c07d5ba..36e2a968076 100644 --- a/playground/Stress/Stress.ApiService/Stress.ApiService.csproj +++ b/playground/Stress/Stress.ApiService/Stress.ApiService.csproj @@ -16,4 +16,16 @@ + + + + + + + + + + diff --git a/playground/Stress/Stress.AppHost/AppHost.cs b/playground/Stress/Stress.AppHost/AppHost.cs index 148e9710bd9..8436443c55c 100644 --- a/playground/Stress/Stress.AppHost/AppHost.cs +++ b/playground/Stress/Stress.AppHost/AppHost.cs @@ -9,6 +9,7 @@ var builder = DistributedApplication.CreateBuilder(args); builder.Services.AddHttpClient(); +builder.Services.AddHttpClient("large-telemetry", client => client.Timeout = Timeout.InfiniteTimeSpan); builder.Services.AddHealthChecks().AddAsyncCheck("health-test", async (ct) => { await Task.Delay(5_000, ct); @@ -100,6 +101,18 @@ // the dashboard binary (defaults to the Aspire.Dashboard bin output in the // artifacts dir). var dashboardBuilder = builder.AddProject(KnownResourceNames.AspireDashboard); +if (string.Equals(builder.Configuration["STRESS_REMOVE_DASHBOARD_LIMITS"], bool.TrueString, StringComparison.OrdinalIgnoreCase)) +{ + var unlimited = int.MaxValue.ToString(CultureInfo.InvariantCulture); + dashboardBuilder + .WithEnvironment("Dashboard__TelemetryLimits__MaxLogCount", unlimited) + .WithEnvironment("Dashboard__TelemetryLimits__MaxTraceCount", unlimited) + .WithEnvironment("Dashboard__TelemetryLimits__MaxMetricsCount", unlimited) + .WithEnvironment("Dashboard__TelemetryLimits__MaxAttributeCount", unlimited) + .WithEnvironment("Dashboard__TelemetryLimits__MaxAttributeLength", unlimited) + .WithEnvironment("Dashboard__TelemetryLimits__MaxSpanEventCount", unlimited) + .WithEnvironment("Dashboard__TelemetryLimits__MaxResourceCount", unlimited); +} if (builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] is { Length: > 0 } dashboardOtlpEndpoint) { // The AppHost normally points every project at its own dashboard. Preserve an explicitly configured diff --git a/playground/Stress/Stress.AppHost/CommandResources.cs b/playground/Stress/Stress.AppHost/CommandResources.cs index ec45e503b62..6e7c19dde16 100644 --- a/playground/Stress/Stress.AppHost/CommandResources.cs +++ b/playground/Stress/Stress.AppHost/CommandResources.cs @@ -909,6 +909,13 @@ private static void AddServiceCommands(IDistributedApplicationBuilder builder, I serviceBuilder.WithHttpCommand("/genai-evaluations", "Gen AI evaluations", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/log-formatting", "Log formatting", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); serviceBuilder.WithHttpCommand("/big-nested-trace", "Big nested trace", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" }); + serviceBuilder.WithHttpCommand("/large-telemetry", "Generate large telemetry data", commandOptions: new() + { + Method = HttpMethod.Post, + HttpClientName = "large-telemetry", + IconName = "DatabaseLightning", + ConfirmationMessage = "Generate a large telemetry workload. This operation can take several minutes and consume significant disk space." + }); } private static void AddTelemetryCommands(IDistributedApplicationBuilder builder, IResourceBuilder telemetryBuilder) diff --git a/playground/Stress/Stress.AppHost/Properties/launchSettings.json b/playground/Stress/Stress.AppHost/Properties/launchSettings.json index 6b730759796..6b348fa860c 100644 --- a/playground/Stress/Stress.AppHost/Properties/launchSettings.json +++ b/playground/Stress/Stress.AppHost/Properties/launchSettings.json @@ -12,6 +12,33 @@ "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" } }, + "https-unlimited-dashboard": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://Stress.dev.localhost:16309;http://Stress.dev.localhost:16310", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "STRESS_REMOVE_DASHBOARD_LIMITS": "true" + } + }, + "https-unlimited-dashboard (profiling)": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://Stress.dev.localhost:16309;http://Stress.dev.localhost:16310", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "STRESS_REMOVE_DASHBOARD_LIMITS": "true", + "AppHost__DefaultLaunchProfileName": "https", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc" + } + }, "http": { "commandName": "Project", "dotnetRunMessages": true, diff --git a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs index 23cffb166fd..328f59fbc85 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs +++ b/src/Aspire.Dashboard/Components/Dialogs/ManageDataDialog.razor.cs @@ -557,7 +557,7 @@ private async Task RemoveSelectedAsync() var selectedResources = GetSelectedResourcesAndDataTypes(); // Clear telemetry signals via repository - TelemetryRepositoryWriter.ClearSelectedSignals(selectedResources); + await TelemetryRepositoryWriter.ClearSelectedSignalsAsync(selectedResources); // Handle console logs filtering separately (not stored in TelemetryRepository) // Console logs are only available when the dashboard client is enabled diff --git a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs index 812c7d17c79..c37f8f40a6b 100644 --- a/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Metrics.razor.cs @@ -241,8 +241,7 @@ private bool ShouldClearSelectedMetrics(List instruments) private Task ClearMetrics(ResourceKey? key) { - TelemetryRepositoryWriter.ClearMetrics(key); - return Task.CompletedTask; + return TelemetryRepositoryWriter.ClearMetricsAsync(key); } private Task HandleSelectedDurationChangedAsync() diff --git a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs index fa26f025996..32367a9e978 100644 --- a/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor.cs @@ -567,8 +567,7 @@ await GenAIVisualizerDialog.OpenDialogAsync( private Task ClearStructureLogs(ResourceKey? key) { - TelemetryRepositoryWriter.ClearStructuredLogs(key); - return Task.CompletedTask; + return TelemetryRepositoryWriter.ClearStructuredLogsAsync(key); } public class StructuredLogsPageViewModel diff --git a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs index 547d2fe6403..87eb76b2bab 100644 --- a/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Traces.razor.cs @@ -395,8 +395,7 @@ private async Task HandleFilterDialog(DialogResult result) private Task ClearTraces(ResourceKey? key) { - TelemetryRepositoryWriter.ClearTraces(key); - return Task.CompletedTask; + return TelemetryRepositoryWriter.ClearTracesAsync(key); } private List GetFilterMenuItems() diff --git a/src/Aspire.Dashboard/Model/Markdown/HighlightedCodeBlockRenderer.cs b/src/Aspire.Dashboard/Model/Markdown/HighlightedCodeBlockRenderer.cs index 548a0297551..282912f2402 100644 --- a/src/Aspire.Dashboard/Model/Markdown/HighlightedCodeBlockRenderer.cs +++ b/src/Aspire.Dashboard/Model/Markdown/HighlightedCodeBlockRenderer.cs @@ -78,12 +78,10 @@ protected override void Write(HtmlRenderer renderer, CodeBlock obj) // Add copy attributes to the copy button. var rawCode = GetRawCodeText(obj); - var copyToClipboard = _loc[nameof(ControlsStrings.GridValueCopyToClipboard)].Value; - var attributes = FluentUIExtensions.GetClipboardCopyAdditionalAttributes(rawCode, copyToClipboard, _loc[nameof(ControlsStrings.GridValueCopied)]); + var attributes = FluentUIExtensions.GetClipboardCopyAdditionalAttributes(rawCode, _loc[nameof(ControlsStrings.GridValueCopyToClipboard)], _loc[nameof(ControlsStrings.GridValueCopied)]); var copyButtonAttributes = new HtmlAttributes(); copyButtonAttributes.AddClass("code-copy-button"); copyButtonAttributes.AddProperty("id", $"code-copy-button-{obj.Span.Start}"); - copyButtonAttributes.AddProperty("aria-label", copyToClipboard); foreach (var item in attributes) { copyButtonAttributes.AddProperty(item.Key, item.Value.ToString()!); @@ -95,7 +93,7 @@ protected override void Write(HtmlRenderer renderer, CodeBlock obj) renderer.Writer.Write(@"
"); renderer.Writer.Write(@"
"); - renderer.Writer.Write(title); + renderer.WriteEscape(title); renderer.Writer.Write("
"); renderer.Writer.Write(@"
"); diff --git a/src/Aspire.Dashboard/Model/TelemetryImportService.cs b/src/Aspire.Dashboard/Model/TelemetryImportService.cs index 8a3bf906222..14d15c53f85 100644 --- a/src/Aspire.Dashboard/Model/TelemetryImportService.cs +++ b/src/Aspire.Dashboard/Model/TelemetryImportService.cs @@ -144,21 +144,21 @@ private async Task ImportJsonAsync(string fileName, Stream stream, CancellationT if (telemetryData.ResourceLogs is { Length: > 0 }) { - ImportLogs(telemetryData.ResourceLogs); + await ImportLogsAsync(telemetryData.ResourceLogs).ConfigureAwait(false); _logger.LogDebug("Imported logs from {FileName}", fileName); imported = true; } if (telemetryData.ResourceSpans is { Length: > 0 }) { - ImportTraces(telemetryData.ResourceSpans); + await ImportTracesAsync(telemetryData.ResourceSpans).ConfigureAwait(false); _logger.LogDebug("Imported traces from {FileName}", fileName); imported = true; } if (telemetryData.ResourceMetrics is { Length: > 0 }) { - ImportMetrics(telemetryData.ResourceMetrics); + await ImportMetricsAsync(telemetryData.ResourceMetrics).ConfigureAwait(false); _logger.LogDebug("Imported metrics from {FileName}", fileName); imported = true; } @@ -169,35 +169,35 @@ private async Task ImportJsonAsync(string fileName, Stream stream, CancellationT } } - private void ImportLogs(OtlpResourceLogsJson[] resourceLogs) + private async Task ImportLogsAsync(OtlpResourceLogsJson[] resourceLogs) { var exportRequest = new OtlpExportLogsServiceRequestJson { ResourceLogs = resourceLogs }; var protobufRequest = OtlpJsonToProtobufConverter.ToProtobuf(exportRequest); var addContext = new AddContext(); - _telemetryRepositoryWriter.AddLogs(addContext, protobufRequest.ResourceLogs); + await _telemetryRepositoryWriter.AddLogsAsync(addContext, protobufRequest.ResourceLogs).ConfigureAwait(false); _logger.LogDebug("Imported logs: {SuccessCount} succeeded, {FailureCount} failed", addContext.SuccessCount, addContext.FailureCount); } - private void ImportTraces(OtlpResourceSpansJson[] resourceSpans) + private async Task ImportTracesAsync(OtlpResourceSpansJson[] resourceSpans) { var exportRequest = new OtlpExportTraceServiceRequestJson { ResourceSpans = resourceSpans }; var protobufRequest = OtlpJsonToProtobufConverter.ToProtobuf(exportRequest); var addContext = new AddContext(); - _telemetryRepositoryWriter.AddTraces(addContext, protobufRequest.ResourceSpans); + await _telemetryRepositoryWriter.AddTracesAsync(addContext, protobufRequest.ResourceSpans).ConfigureAwait(false); _logger.LogDebug("Imported traces: {SuccessCount} succeeded, {FailureCount} failed", addContext.SuccessCount, addContext.FailureCount); } - private void ImportMetrics(OtlpResourceMetricsJson[] resourceMetrics) + private async Task ImportMetricsAsync(OtlpResourceMetricsJson[] resourceMetrics) { var exportRequest = new OtlpExportMetricsServiceRequestJson { ResourceMetrics = resourceMetrics }; var protobufRequest = OtlpJsonToProtobufConverter.ToProtobuf(exportRequest); var addContext = new AddContext(); - _telemetryRepositoryWriter.AddMetrics(addContext, protobufRequest.ResourceMetrics); + await _telemetryRepositoryWriter.AddMetricsAsync(addContext, protobufRequest.ResourceMetrics).ConfigureAwait(false); _logger.LogDebug("Imported metrics: {SuccessCount} succeeded, {FailureCount} failed", addContext.SuccessCount, addContext.FailureCount); } diff --git a/src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcLogsService.cs b/src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcLogsService.cs index 477215fa59c..1a409573a9f 100644 --- a/src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcLogsService.cs +++ b/src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcLogsService.cs @@ -22,6 +22,6 @@ public OtlpGrpcLogsService(OtlpLogsService logsService) public override Task Export(ExportLogsServiceRequest request, ServerCallContext context) { - return Task.FromResult(_logsService.Export(request)); + return _logsService.ExportAsync(request); } } diff --git a/src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcMetricsService.cs b/src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcMetricsService.cs index 85023989f6c..fb533e71c7b 100644 --- a/src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcMetricsService.cs +++ b/src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcMetricsService.cs @@ -22,6 +22,6 @@ public OtlpGrpcMetricsService(OtlpMetricsService metricsService) public override Task Export(ExportMetricsServiceRequest request, ServerCallContext context) { - return Task.FromResult(_metricsService.Export(request)); + return _metricsService.ExportAsync(request); } } diff --git a/src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcTraceService.cs b/src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcTraceService.cs index 5bbb39cd845..5abf6e72998 100644 --- a/src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcTraceService.cs +++ b/src/Aspire.Dashboard/Otlp/Grpc/OtlpGrpcTraceService.cs @@ -22,6 +22,6 @@ public OtlpGrpcTraceService(OtlpTraceService traceService) public override Task Export(ExportTraceServiceRequest request, ServerCallContext context) { - return Task.FromResult(_traceService.Export(request)); + return _traceService.ExportAsync(request); } } diff --git a/src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs b/src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs index 14723769ef8..2f002854e65 100644 --- a/src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs +++ b/src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs @@ -48,29 +48,29 @@ public static void MapHttpOtlpApi(this IEndpointRouteBuilder endpoints, OtlpOpti group = group.RequireCors(CorsPolicyName); } - group.MapPost("logs", static (MessageBindable request, OtlpLogsService service) => + group.MapPost("logs", static async (MessageBindable request, OtlpLogsService service) => { if (request.Message is null) { return Results.Empty; } - return OtlpResult.Response(service.Export(request.Message), request.RequestContentType); + return OtlpResult.Response(await service.ExportAsync(request.Message).ConfigureAwait(false), request.RequestContentType); }); - group.MapPost("traces", static (MessageBindable request, OtlpTraceService service) => + group.MapPost("traces", static async (MessageBindable request, OtlpTraceService service) => { if (request.Message is null) { return Results.Empty; } - return OtlpResult.Response(service.Export(request.Message), request.RequestContentType); + return OtlpResult.Response(await service.ExportAsync(request.Message).ConfigureAwait(false), request.RequestContentType); }); - group.MapPost("metrics", (MessageBindable request, OtlpMetricsService service) => + group.MapPost("metrics", static async (MessageBindable request, OtlpMetricsService service) => { if (request.Message is null) { return Results.Empty; } - return OtlpResult.Response(service.Export(request.Message), request.RequestContentType); + return OtlpResult.Response(await service.ExportAsync(request.Message).ConfigureAwait(false), request.RequestContentType); }); } diff --git a/src/Aspire.Dashboard/Otlp/Model/MetricValues/DimensionScope.cs b/src/Aspire.Dashboard/Otlp/Model/MetricValues/DimensionScope.cs index ddfddaca5ec..779daf09421 100644 --- a/src/Aspire.Dashboard/Otlp/Model/MetricValues/DimensionScope.cs +++ b/src/Aspire.Dashboard/Otlp/Model/MetricValues/DimensionScope.cs @@ -81,8 +81,13 @@ public void AddHistogramValue(HistogramDataPoint h, OtlpContext context) { var start = OtlpHelpers.UnixNanoSecondsToDateTime(h.StartTimeUnixNano); var end = OtlpHelpers.UnixNanoSecondsToDateTime(h.TimeUnixNano); + OtlpHelpers.ValidateHistogramDataPoint(h); var lastHistogramValue = _lastValue as HistogramValue; + if (lastHistogramValue is not null && lastHistogramValue.Values.Length != h.BucketCounts.Count) + { + throw new InvalidOperationException("Histogram data point bucket count length changed."); + } if (lastHistogramValue is not null && lastHistogramValue.Count == h.Count) { lastHistogramValue.End = end; @@ -105,11 +110,6 @@ public void AddHistogramValue(HistogramDataPoint h, OtlpContext context) } var bucketCounts = h.BucketCounts.ToArray(); - if (bucketCounts.Length > 0 && explicitBounds.Length == 0) - { - throw new InvalidOperationException("Histogram data point has bucket counts without any explicit bounds."); - } - _lastValue = new HistogramValue(bucketCounts, h.Sum, h.Count, start, end, explicitBounds); AddExemplars(_lastValue, h.Exemplars, context); _values.Add(_lastValue); diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs index 462ea240da4..18e5d3985bc 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpHelpers.cs @@ -56,6 +56,14 @@ internal static int GetMetricDataPointCount(Metric metric) }; } + internal static void ValidateHistogramDataPoint(HistogramDataPoint point) + { + if (point.BucketCounts.Count > 0 && point.ExplicitBounds.Count == 0) + { + throw new InvalidOperationException("Histogram data point has bucket counts without any explicit bounds."); + } + } + public static ResourceKey GetResourceKey(this Resource resource) { string? serviceName = null; diff --git a/src/Aspire.Dashboard/Otlp/OtlpLogsService.cs b/src/Aspire.Dashboard/Otlp/OtlpLogsService.cs index f5c52957206..6a9a7d40b8d 100644 --- a/src/Aspire.Dashboard/Otlp/OtlpLogsService.cs +++ b/src/Aspire.Dashboard/Otlp/OtlpLogsService.cs @@ -20,10 +20,10 @@ public OtlpLogsService(ILogger logger, ITelemetryRepositoryWrit _telemetryRepositoryWriter = telemetryRepositoryWriter; } - public ExportLogsServiceResponse Export(ExportLogsServiceRequest request) + public async Task ExportAsync(ExportLogsServiceRequest request) { var addContext = new AddContext(); - _telemetryRepositoryWriter.AddLogs(addContext, request.ResourceLogs); + await _telemetryRepositoryWriter.AddLogsAsync(addContext, request.ResourceLogs).ConfigureAwait(false); _logger.LogDebug("Processed logs export. Success count: {SuccessCount}, failure count: {FailureCount}", addContext.SuccessCount, addContext.FailureCount); diff --git a/src/Aspire.Dashboard/Otlp/OtlpMetricsService.cs b/src/Aspire.Dashboard/Otlp/OtlpMetricsService.cs index c1501855570..a0a779f83e7 100644 --- a/src/Aspire.Dashboard/Otlp/OtlpMetricsService.cs +++ b/src/Aspire.Dashboard/Otlp/OtlpMetricsService.cs @@ -20,10 +20,10 @@ public OtlpMetricsService(ILogger logger, ITelemetryReposito _telemetryRepositoryWriter = telemetryRepositoryWriter; } - public ExportMetricsServiceResponse Export(ExportMetricsServiceRequest request) + public async Task ExportAsync(ExportMetricsServiceRequest request) { var addContext = new AddContext(); - _telemetryRepositoryWriter.AddMetrics(addContext, request.ResourceMetrics); + await _telemetryRepositoryWriter.AddMetricsAsync(addContext, request.ResourceMetrics).ConfigureAwait(false); _logger.LogDebug("Processed metrics export. Success count: {SuccessCount}, failure count: {FailureCount}", addContext.SuccessCount, addContext.FailureCount); diff --git a/src/Aspire.Dashboard/Otlp/OtlpTraceService.cs b/src/Aspire.Dashboard/Otlp/OtlpTraceService.cs index a7766f3af21..5289cd273ac 100644 --- a/src/Aspire.Dashboard/Otlp/OtlpTraceService.cs +++ b/src/Aspire.Dashboard/Otlp/OtlpTraceService.cs @@ -20,10 +20,10 @@ public OtlpTraceService(ILogger logger, ITelemetryRepositoryWr _telemetryRepositoryWriter = telemetryRepositoryWriter; } - public ExportTraceServiceResponse Export(ExportTraceServiceRequest request) + public async Task ExportAsync(ExportTraceServiceRequest request) { var addContext = new AddContext(); - _telemetryRepositoryWriter.AddTraces(addContext, request.ResourceSpans); + await _telemetryRepositoryWriter.AddTracesAsync(addContext, request.ResourceSpans).ConfigureAwait(false); _logger.LogDebug("Processed trace export. Success count: {SuccessCount}, failure count: {FailureCount}", addContext.SuccessCount, addContext.FailureCount); diff --git a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepositoryWriter.cs b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepositoryWriter.cs index 6b5c34a904c..9bcbac17867 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepositoryWriter.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/ITelemetryRepositoryWriter.cs @@ -16,47 +16,54 @@ namespace Aspire.Dashboard.Otlp.Storage; public interface ITelemetryRepositoryWriter { /// - /// Adds log records to the telemetry store. + /// Asynchronously adds log records to the telemetry store. /// /// The context that records the result of adding telemetry. /// The resource log records to add. - void AddLogs(AddContext context, RepeatedField resourceLogs); + /// A task that represents the asynchronous operation. + Task AddLogsAsync(AddContext context, RepeatedField resourceLogs); /// - /// Adds metric data to the telemetry store. + /// Asynchronously adds metric data to the telemetry store. /// /// The context that records the result of adding telemetry. /// The resource metric data to add. - void AddMetrics(AddContext context, RepeatedField resourceMetrics); + /// A task that represents the asynchronous operation. + Task AddMetricsAsync(AddContext context, RepeatedField resourceMetrics); /// - /// Adds spans to the telemetry store. + /// Asynchronously adds spans to the telemetry store. /// /// The context that records the result of adding telemetry. /// The resource spans to add. - void AddTraces(AddContext context, RepeatedField resourceSpans); + /// A task that represents the asynchronous operation. + Task AddTracesAsync(AddContext context, RepeatedField resourceSpans); /// - /// Removes the selected telemetry signals for each resource. + /// Asynchronously removes the selected telemetry signals for each resource. /// /// The telemetry signal types selected for removal, keyed by resource name. - void ClearSelectedSignals(Dictionary> selectedResources); + /// A task that represents the asynchronous operation. + Task ClearSelectedSignalsAsync(Dictionary> selectedResources); /// - /// Removes traces, optionally limited to a resource. + /// Asynchronously removes traces, optionally limited to a resource. /// /// The resource to clear, or to clear all resources. - void ClearTraces(ResourceKey? resourceKey = null); + /// A task that represents the asynchronous operation. + Task ClearTracesAsync(ResourceKey? resourceKey = null); /// - /// Removes structured logs, optionally limited to a resource. + /// Asynchronously removes structured logs, optionally limited to a resource. /// /// The resource to clear, or to clear all resources. - void ClearStructuredLogs(ResourceKey? resourceKey = null); + /// A task that represents the asynchronous operation. + Task ClearStructuredLogsAsync(ResourceKey? resourceKey = null); /// - /// Removes metrics, optionally limited to a resource. + /// Asynchronously removes metrics, optionally limited to a resource. /// /// The resource to clear, or to clear all resources. - void ClearMetrics(ResourceKey? resourceKey = null); + /// A task that represents the asynchronous operation. + Task ClearMetricsAsync(ResourceKey? resourceKey = null); } \ No newline at end of file diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs index 52e181e8b1b..1d01823e6fd 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Caching.cs @@ -450,7 +450,17 @@ private void ClearIngestionCaches() private void EnsureCachePopulated() { - lock (_writeLock) + lock (_cacheLock) + { + if (_cachePopulated) + { + return; + } + } + + // Writers update the database and metadata caches as one operation. Wait for any active write to + // finish so the cache is populated from committed data instead of a partially updated state. + using (_database.WriteLock.Lock()) { lock (_cacheLock) { diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs index fa7a9d1c18b..0d4bba4e7ec 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Logs.cs @@ -20,10 +20,10 @@ public sealed partial class SqliteTelemetryRepository private const int MaxLogBatchSize = 50; private const int MaxLogAttributeBatchSize = 200; - private List AddLogsToDatabase(AddContext context, RepeatedField resourceLogs) + private async Task> AddLogsToDatabaseAsync(AddContext context, RepeatedField resourceLogs) { var addedLogs = new List(); - lock (_writeLock) + using (await _database.WriteLock.LockAsync().ConfigureAwait(false)) { using var connection = _database.OpenConnection(); using var transaction = connection.BeginTransaction(); @@ -730,7 +730,7 @@ static KeyValuePair[] ToPairs(IEnumerable } } - private void ClearSelectedLogsFromDatabase(Dictionary> selectedResources) + private async Task ClearSelectedLogsFromDatabaseAsync(Dictionary> selectedResources) { EnsureWritable(); using var connection = _database.OpenConnection(); @@ -748,18 +748,18 @@ private void ClearSelectedLogsFromDatabase(Dictionary instrument.InstrumentId).ToArray(), - instruments[0].Summary.Type == OtlpInstrumentType.Histogram, request.StartTime, request.EndTime, request.DimensionFilters, @@ -159,7 +158,6 @@ FROM telemetry_metric_points p private List MaterializeMetricDimensions( SqliteConnection connection, IReadOnlyList instrumentIds, - bool isHistogram, DateTime? startTime, DateTime? endTime, IReadOnlyDictionary> dimensionFilters, @@ -237,15 +235,14 @@ WHERE d.instrument_id IN @InstrumentIds p.integer_value AS IntegerValue, p.double_value AS DoubleValue, p.histogram_sum AS HistogramSum, - p.histogram_count AS HistogramCount + p.histogram_count AS HistogramCount, + stored.bucket_counts AS BucketCounts, + stored.explicit_bounds AS ExplicitBounds FROM rolled_up_metric_points p + JOIN telemetry_metric_points stored ON stored.point_id = p.point_id ORDER BY p.dimension_id, p.start_time_ticks, p.point_id; """, queryParameters).AsList(); var points = pointRecords.ToLookup(record => record.DimensionId); - var (bucketCounts, explicitBounds) = isHistogram - ? MaterializeMetricHistogramData(connection, pointRecords.Select(point => point.PointId).ToArray()) - : (Array.Empty().ToLookup(record => record.PointId), - Array.Empty().ToLookup(record => record.PointId)); var exemplars = includeExemplars ? MaterializeMetricExemplars( connection, @@ -266,13 +263,7 @@ FROM rolled_up_metric_points p { LongPointType => new MetricValue(point.IntegerValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), DoublePointType => new MetricValue(point.DoubleValue!.Value, new DateTime(point.StartTimeTicks, DateTimeKind.Utc), new DateTime(point.EndTimeTicks, DateTimeKind.Utc)), - HistogramPointType => new HistogramValue( - bucketCounts[point.PointId].Select(bucket => checked((ulong)bucket.BucketCount)).ToArray(), - point.HistogramSum!.Value, - checked((ulong)point.HistogramCount!.Value), - new DateTime(point.StartTimeTicks, DateTimeKind.Utc), - new DateTime(point.EndTimeTicks, DateTimeKind.Utc), - explicitBounds[point.PointId].Select(bound => bound.ExplicitBound).ToArray()), + HistogramPointType => CreateHistogramValue(point), _ => throw new InvalidOperationException($"Unknown metric point type '{point.PointType}'.") }; if (point.PointType != HistogramPointType) @@ -355,33 +346,13 @@ private static void PopulateKnownAttributeValues( } } - private static (ILookup BucketCounts, ILookup ExplicitBounds) MaterializeMetricHistogramData( - SqliteConnection connection, - IReadOnlyList pointIds) - { - var bucketCounts = new List(); - var explicitBounds = new List(); - foreach (var pointIdBatch in pointIds.Chunk(MaxMetricReadBatchSize)) - { - using var reader = connection.QueryMultiple(""" - SELECT point_id AS PointId, bucket_count AS BucketCount - FROM telemetry_metric_histogram_bucket_counts - WHERE point_id IN @PointIds - ORDER BY point_id, ordinal; - - SELECT point_id AS PointId, explicit_bound AS ExplicitBound - FROM telemetry_metric_histogram_explicit_bounds - WHERE point_id IN @PointIds - ORDER BY point_id, ordinal; - """, new { PointIds = pointIdBatch }); - bucketCounts.AddRange(reader.Read()); - explicitBounds.AddRange(reader.Read()); - } - - return ( - bucketCounts.ToLookup(record => record.PointId), - explicitBounds.ToLookup(record => record.PointId)); - } + private static HistogramValue CreateHistogramValue(MetricPointDataRecord point) => new( + UnpackUInt64Values(point.BucketCounts!), + point.HistogramSum!.Value, + checked((ulong)point.HistogramCount!.Value), + new DateTime(point.StartTimeTicks, DateTimeKind.Utc), + new DateTime(point.EndTimeTicks, DateTimeKind.Utc), + UnpackDoubleValues(point.ExplicitBounds!)); private static ILookup MaterializeMetricExemplars( SqliteConnection connection, @@ -467,6 +438,8 @@ private sealed class MetricPointDataRecord : MetricPointRecord public required long StartTimeTicks { get; init; } public required long RepeatCount { get; init; } public double? HistogramSum { get; init; } + public byte[]? BucketCounts { get; init; } + public byte[]? ExplicitBounds { get; init; } } private sealed class MetricDimensionAttributeRecord @@ -476,18 +449,6 @@ private sealed class MetricDimensionAttributeRecord public string? AttributeValue { get; init; } } - private sealed class MetricHistogramBucketRecord - { - public required long PointId { get; init; } - public required long BucketCount { get; init; } - } - - private sealed class MetricHistogramBoundRecord - { - public required long PointId { get; init; } - public required double ExplicitBound { get; init; } - } - private sealed class MetricExemplarRecord { public required long ExemplarId { get; init; } diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs index f28e3318698..c8504cbc1d8 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Metrics.Writes.cs @@ -26,9 +26,9 @@ public sealed partial class SqliteTelemetryRepository private readonly MetricIngestionState _metricIngestionState = new(); - private void AddMetricsToDatabase(AddContext context, RepeatedField resourceMetrics) + private async Task AddMetricsToDatabaseAsync(AddContext context, RepeatedField resourceMetrics) { - lock (_writeLock) + using (await _database.WriteLock.LockAsync().ConfigureAwait(false)) { _metricIngestionState.DimensionsToTrim.Clear(); _metricIngestionState.PendingDimensions.Clear(); @@ -248,15 +248,17 @@ private void AddHistogramMetricPoint( { try { - if (point.BucketCounts.Count > 0 && point.ExplicitBounds.Count == 0) - { - throw new InvalidOperationException("Histogram data point has bucket counts without any explicit bounds."); - } + OtlpHelpers.ValidateHistogramDataPoint(point); var dimension = GetOrAddMetricDimension(connection, transaction, instrumentId, scope, point.Attributes, ingestionState); var pendingLatest = dimension.PendingPoint; var latest = dimension.LatestPoint; var latestPointType = pendingLatest?.PointType ?? latest?.PointType; var latestEndTimeTicks = pendingLatest?.EndTimeTicks ?? latest?.EndTimeTicks; + var latestBucketCountLength = pendingLatest?.HistogramBucketCounts?.Length ?? latest?.HistogramBucketCountLength; + if (latestPointType == HistogramPointType && latestBucketCountLength != point.BucketCounts.Count) + { + throw new InvalidOperationException("Histogram data point bucket count length changed."); + } var histogramCount = checked((long)point.Count); var sameCount = latestPointType == HistogramPointType && (pendingLatest?.HistogramCount ?? latest?.HistogramCount) == histogramCount; @@ -346,7 +348,7 @@ FROM updates } // Keep one INSERT statement and RETURNING result set per point inside a single command. This preserves - // deterministic point-to-ID mapping for histogram and exemplar rows without a round trip per point. + // deterministic point-to-ID mapping for exemplar rows without a round trip per point. foreach (var points in pointBatch.Inserts.Chunk(MaxMetricPointBatchSize)) { var insertSql = new StringBuilder(); @@ -357,10 +359,10 @@ FROM updates insertSql.Append(CultureInfo.InvariantCulture, $$""" INSERT INTO telemetry_metric_points ( dimension_id, point_type, start_time_ticks, end_time_ticks, repeat_count, - integer_value, double_value, histogram_sum, histogram_count, flags) + integer_value, double_value, histogram_sum, histogram_count, bucket_counts, explicit_bounds, flags) VALUES ( @DimensionId{{i}}, @PointType{{i}}, @StartTimeTicks{{i}}, @EndTimeTicks{{i}}, @RepeatCount{{i}}, - @IntegerValue{{i}}, @DoubleValue{{i}}, @HistogramSum{{i}}, @HistogramCount{{i}}, @Flags{{i}}) + @IntegerValue{{i}}, @DoubleValue{{i}}, @HistogramSum{{i}}, @HistogramCount{{i}}, @BucketCounts{{i}}, @ExplicitBounds{{i}}, @Flags{{i}}) RETURNING point_id; """); insertParameters.Add($"DimensionId{i}", point.Dimension.DimensionId); @@ -372,6 +374,8 @@ INSERT INTO telemetry_metric_points ( insertParameters.Add($"DoubleValue{i}", point.DoubleValue); insertParameters.Add($"HistogramSum{i}", point.HistogramSum); insertParameters.Add($"HistogramCount{i}", point.HistogramCount); + insertParameters.Add($"BucketCounts{i}", point.HistogramBucketCounts is not null ? PackInt64Values(point.HistogramBucketCounts) : null); + insertParameters.Add($"ExplicitBounds{i}", point.HistogramExplicitBounds is not null ? PackDoubleValues(point.HistogramExplicitBounds) : null); insertParameters.Add($"Flags{i}", point.Flags); } @@ -382,8 +386,6 @@ INSERT INTO telemetry_metric_points ( } } - InsertHistogramBucketCounts(connection, transaction, pointBatch.Inserts); - InsertHistogramExplicitBounds(connection, transaction, pointBatch.Inserts); foreach (var point in pointBatch.Inserts) { QueueMetricExemplars(pointBatch, point.PointId, point.Exemplars); @@ -403,61 +405,14 @@ INSERT INTO telemetry_metric_points ( EndTimeTicks = point.EndTimeTicks, IntegerValue = point.IntegerValue, DoubleValue = point.DoubleValue, - HistogramCount = point.HistogramCount + HistogramCount = point.HistogramCount, + HistogramBucketCountLength = point.HistogramBucketCounts?.Length }; point.Dimension.PendingPoint = null; } } } - private static void InsertHistogramBucketCounts( - SqliteConnection connection, - IDbTransaction transaction, - List points) - { - var bucketCounts = points - .Where(point => point.HistogramBucketCounts is { Length: > 0 }) - .SelectMany(point => point.HistogramBucketCounts!.Select((bucketCount, ordinal) => new PendingHistogramBucketCount(point.PointId, ordinal, bucketCount))) - .ToArray(); - SqliteBatchInsert.BatchInsertRows( - connection, - transaction, - bucketCounts, - MaxMetricPointBatchSize, - "telemetry_metric_histogram_bucket_counts", - ["point_id", "ordinal", "bucket_count"], - static (row, parameters) => - { - parameters[0].Value = row.PointId; - parameters[1].Value = row.Ordinal; - parameters[2].Value = row.BucketCount; - }); - } - - private static void InsertHistogramExplicitBounds( - SqliteConnection connection, - IDbTransaction transaction, - List points) - { - var explicitBounds = points - .Where(point => point.HistogramExplicitBounds is { Length: > 0 }) - .SelectMany(point => point.HistogramExplicitBounds!.Select((explicitBound, ordinal) => new PendingHistogramExplicitBound(point.PointId, ordinal, explicitBound))) - .ToArray(); - SqliteBatchInsert.BatchInsertRows( - connection, - transaction, - explicitBounds, - MaxMetricPointBatchSize, - "telemetry_metric_histogram_explicit_bounds", - ["point_id", "ordinal", "explicit_bound"], - static (row, parameters) => - { - parameters[0].Value = row.PointId; - parameters[1].Value = row.Ordinal; - parameters[2].Value = row.ExplicitBound; - }); - } - private MetricDimensionState GetOrAddMetricDimension( SqliteConnection connection, IDbTransaction transaction, @@ -484,7 +439,8 @@ private MetricDimensionState GetOrAddMetricDimension( p.end_time_ticks AS EndTimeTicks, p.integer_value AS IntegerValue, p.double_value AS DoubleValue, - p.histogram_count AS HistogramCount + p.histogram_count AS HistogramCount, + p.bucket_counts AS HistogramBucketCounts FROM telemetry_metric_dimensions d LEFT JOIN telemetry_metric_dimension_attributes a ON a.dimension_id = d.dimension_id LEFT JOIN telemetry_metric_points p ON p.point_id = ( @@ -516,7 +472,8 @@ LIMIT 1 EndTimeTicks = first.EndTimeTicks!.Value, IntegerValue = first.IntegerValue, DoubleValue = first.DoubleValue, - HistogramCount = first.HistogramCount + HistogramCount = first.HistogramCount, + HistogramBucketCountLength = first.HistogramBucketCounts?.Length / sizeof(long) } : null }; @@ -655,6 +612,56 @@ static void AppendHashValue(XxHash3 hash, string value) } } + private static byte[] PackInt64Values(ReadOnlySpan values) + { + var bytes = new byte[checked(values.Length * sizeof(long))]; + for (var i = 0; i < values.Length; i++) + { + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(i * sizeof(long)), values[i]); + } + return bytes; + } + + private static byte[] PackDoubleValues(ReadOnlySpan values) + { + var bytes = new byte[checked(values.Length * sizeof(double))]; + for (var i = 0; i < values.Length; i++) + { + BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(i * sizeof(double)), BitConverter.DoubleToInt64Bits(values[i])); + } + return bytes; + } + + private static ulong[] UnpackUInt64Values(ReadOnlySpan bytes) + { + ValidatePackedValueLength(bytes); + var values = new ulong[bytes.Length / sizeof(long)]; + for (var i = 0; i < values.Length; i++) + { + values[i] = checked((ulong)BinaryPrimitives.ReadInt64LittleEndian(bytes[(i * sizeof(long))..])); + } + return values; + } + + private static double[] UnpackDoubleValues(ReadOnlySpan bytes) + { + ValidatePackedValueLength(bytes); + var values = new double[bytes.Length / sizeof(double)]; + for (var i = 0; i < values.Length; i++) + { + values[i] = BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64LittleEndian(bytes[(i * sizeof(double))..])); + } + return values; + } + + private static void ValidatePackedValueLength(ReadOnlySpan bytes) + { + if (bytes.Length % sizeof(long) != 0) + { + throw new InvalidOperationException("Packed histogram data length must be a multiple of 8 bytes."); + } + } + private void QueueMetricExemplars(MetricPointBatch pointBatch, long pointId, IEnumerable exemplars) { foreach (var exemplar in exemplars) @@ -763,7 +770,7 @@ WHERE point_rank > @MaxMetricsCount } } - private void ClearSelectedMetricsFromDatabase(Dictionary> selectedResources) + private async Task ClearSelectedMetricsFromDatabaseAsync(Dictionary> selectedResources) { using var connection = _database.OpenConnection(); foreach (var resource in connection.Query("SELECT resource_name AS ResourceName, instance_id AS InstanceId FROM telemetry_resources;")) @@ -771,14 +778,14 @@ private void ClearSelectedMetricsFromDatabase(Dictionary GetResources(ResourceKey key, bool includeUninstrument : GetResources(includeUninstrumentedPeers).Where(resource => resource.ResourceKey == key).ToList(); } - private void DeleteTelemetryResourceFromDatabase(ResourceKey resourceKey) + private async Task DeleteTelemetryResourceFromDatabaseAsync(ResourceKey resourceKey) { - lock (_writeLock) + using (await _database.WriteLock.LockAsync().ConfigureAwait(false)) { using var connection = _database.OpenConnection(); using var transaction = connection.BeginTransaction(); diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs index a1f7a386ecd..1b039d62946 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.Traces.Writes.cs @@ -22,10 +22,10 @@ public sealed partial class SqliteTelemetryRepository private const int MaxSpanBatchSize = 25; private const int MaxSpanDetailBatchSize = 50; - private List AddTracesToDatabase(AddContext context, RepeatedField resourceSpans) + private async Task> AddTracesToDatabaseAsync(AddContext context, RepeatedField resourceSpans) { var addedSpans = new List(); - lock (_writeLock) + using (await _database.WriteLock.LockAsync().ConfigureAwait(false)) { using var connection = _database.OpenConnection(); using var transaction = connection.BeginTransaction(); @@ -1052,7 +1052,7 @@ UPDATE telemetry_resources """, new { _otlpContext.Options.MaxTraceCount }, transaction); } - private void ClearSelectedTracesFromDatabase(Dictionary> selectedResources) + private async Task ClearSelectedTracesFromDatabaseAsync(Dictionary> selectedResources) { using var connection = _database.OpenConnection(); var resources = connection.Query(""" @@ -1066,14 +1066,14 @@ private void ClearSelectedTracesFromDatabase(Dictionary[] attributes return false; } - private void ClearTracesFromDatabase(ResourceKey? resourceKey) + private async Task ClearTracesFromDatabaseAsync(ResourceKey? resourceKey) { - lock (_writeLock) + using (await _database.WriteLock.LockAsync().ConfigureAwait(false)) { using var connection = _database.OpenConnection(); using var transaction = connection.BeginTransaction(); diff --git a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs index 30c3f4b3b90..44092971e65 100644 --- a/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs +++ b/src/Aspire.Dashboard/Otlp/Storage/SqliteTelemetryRepository.cs @@ -24,7 +24,6 @@ public sealed partial class SqliteTelemetryRepository : ITelemetryRepository, IT private readonly IReadOnlyList _outgoingPeerResolvers; private readonly List _outgoingPeerSubscriptions = []; private readonly bool _ownsDatabase; - private readonly object _writeLock = new(); private int _disposed; private static string CreateContainsLikePattern(string value) => $"%{EscapeLikePattern(value)}%"; @@ -76,18 +75,17 @@ internal SqliteTelemetryRepository( database.InitializeSchema(); foreach (var resolver in _outgoingPeerResolvers) { - _outgoingPeerSubscriptions.Add(resolver.OnPeerChanges(() => + _outgoingPeerSubscriptions.Add(resolver.OnPeerChanges(async () => { - RecalculateUninstrumentedPeers(); + await RecalculateUninstrumentedPeersAsync().ConfigureAwait(false); NotifyPeersChanged(); - return Task.CompletedTask; })); } } } - public void AddLogs(AddContext context, RepeatedField resourceLogs) + public async Task AddLogsAsync(AddContext context, RepeatedField resourceLogs) { if (_pauseManager.AreStructuredLogsPaused(out _)) { @@ -98,11 +96,11 @@ public void AddLogs(AddContext context, RepeatedField resourceLogs EnsureWritable(); try { - NotifyLogsAdded(AddLogsToDatabase(context, resourceLogs)); + NotifyLogsAdded(await AddLogsToDatabaseAsync(context, resourceLogs).ConfigureAwait(false)); } catch { - lock (_writeLock) + using (await _database.WriteLock.LockAsync().ConfigureAwait(false)) { ClearIngestionCaches(); } @@ -110,7 +108,7 @@ public void AddLogs(AddContext context, RepeatedField resourceLogs } } - public void AddMetrics(AddContext context, RepeatedField resourceMetrics) + public async Task AddMetricsAsync(AddContext context, RepeatedField resourceMetrics) { if (_pauseManager.AreMetricsPaused(out _)) { @@ -120,14 +118,14 @@ public void AddMetrics(AddContext context, RepeatedField resour EnsureWritable(); var successCount = context.SuccessCount; - AddMetricsToDatabase(context, resourceMetrics); + await AddMetricsToDatabaseAsync(context, resourceMetrics).ConfigureAwait(false); if (context.SuccessCount > successCount) { NotifyMetricsAdded(); } } - public void AddTraces(AddContext context, RepeatedField resourceSpans) + public async Task AddTracesAsync(AddContext context, RepeatedField resourceSpans) { if (_pauseManager.AreTracesPaused(out _)) { @@ -138,11 +136,11 @@ public void AddTraces(AddContext context, RepeatedField resourceS EnsureWritable(); try { - NotifySpansAdded(AddTracesToDatabase(context, resourceSpans)); + NotifySpansAdded(await AddTracesToDatabaseAsync(context, resourceSpans).ConfigureAwait(false)); } catch { - lock (_writeLock) + using (await _database.WriteLock.LockAsync().ConfigureAwait(false)) { ClearIngestionCaches(); } @@ -172,12 +170,12 @@ public void AddTraces(AddContext context, RepeatedField resourceS public OtlpInstrumentData? GetInstrument(GetInstrumentRequest request) => GetInstrumentFromDatabase(request); public DateTime? GetInstrumentLatestEndTime(ResourceKey resourceKey, string meterName, string instrumentName) => GetInstrumentLatestEndTimeFromDatabase(resourceKey, meterName, instrumentName); - public void ClearSelectedSignals(Dictionary> selectedResources) + public async Task ClearSelectedSignalsAsync(Dictionary> selectedResources) { EnsureWritable(); - ClearSelectedLogsFromDatabase(selectedResources); - ClearSelectedTracesFromDatabase(selectedResources); - ClearSelectedMetricsFromDatabase(selectedResources); + await ClearSelectedLogsFromDatabaseAsync(selectedResources).ConfigureAwait(false); + await ClearSelectedTracesFromDatabaseAsync(selectedResources).ConfigureAwait(false); + await ClearSelectedMetricsFromDatabaseAsync(selectedResources).ConfigureAwait(false); ClearUnviewedErrorCounts(selectedResources); RaiseSubscriptionChanged(_logSubscriptions); RaiseSubscriptionChanged(_tracesSubscriptions); @@ -185,27 +183,27 @@ public void ClearSelectedSignals(Dictionary> sel RaiseSubscriptionChanged(_resourceSubscriptions); } - public void ClearTraces(ResourceKey? resourceKey = null) + public async Task ClearTracesAsync(ResourceKey? resourceKey = null) { EnsureWritable(); - ClearTracesFromDatabase(resourceKey); + await ClearTracesFromDatabaseAsync(resourceKey).ConfigureAwait(false); RaiseSubscriptionChanged(_tracesSubscriptions); RaiseSubscriptionChanged(_resourceSubscriptions); } - public void ClearStructuredLogs(ResourceKey? resourceKey = null) + public async Task ClearStructuredLogsAsync(ResourceKey? resourceKey = null) { EnsureWritable(); - ClearStructuredLogsFromDatabase(resourceKey); + await ClearStructuredLogsFromDatabaseAsync(resourceKey).ConfigureAwait(false); ClearUnviewedErrorCounts(resourceKey); RaiseSubscriptionChanged(_logSubscriptions); RaiseSubscriptionChanged(_resourceSubscriptions); } - public void ClearMetrics(ResourceKey? resourceKey = null) + public async Task ClearMetricsAsync(ResourceKey? resourceKey = null) { EnsureWritable(); - ClearMetricsFromDatabase(resourceKey); + await ClearMetricsFromDatabaseAsync(resourceKey).ConfigureAwait(false); RaiseSubscriptionChanged(_metricsSubscriptions); RaiseSubscriptionChanged(_resourceSubscriptions); } diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index d70beb1174d..968a90c3c0a 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -654,13 +654,13 @@ private async Task WatchResourcesAsync(RetryContext retryContext, C } } - if (response.KindCase == WatchResourcesUpdate.KindOneofCase.InitialData) + if (_resourceRepositoryWriter is not null && response.KindCase == WatchResourcesUpdate.KindOneofCase.InitialData) { - _resourceRepositoryWriter?.ReplaceResources(response.InitialData.Resources); + await _resourceRepositoryWriter.ReplaceResourcesAsync(response.InitialData.Resources).ConfigureAwait(false); } - else if (response.KindCase == WatchResourcesUpdate.KindOneofCase.Changes) + else if (_resourceRepositoryWriter is not null && response.KindCase == WatchResourcesUpdate.KindOneofCase.Changes) { - _resourceRepositoryWriter?.ApplyChanges(response.Changes.Value); + await _resourceRepositoryWriter.ApplyChangesAsync(response.Changes.Value).ConfigureAwait(false); } // Update connection state outside the lock to avoid potential deadlocks @@ -931,7 +931,7 @@ public async IAsyncEnumerable> SubscribeConsoleLo // historical runs can omit logs for resources that were never viewed or exported. The historical // Console Logs page checks this capture state and displays a notice when logs aren't available. // See https://github.com/microsoft/aspire/issues/18823. - MarkConsoleLogsLoaded(resourceName); + await MarkConsoleLogsLoadedAsync(resourceName).ConfigureAwait(false); // It's ok to dispose CTS with using because this method exits after it is finished being used. using var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); @@ -952,7 +952,10 @@ public async IAsyncEnumerable> SubscribeConsoleLo { await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken: combinedTokens.Token).ConfigureAwait(false)) { - _resourceRepositoryWriter?.AddConsoleLogs(resourceName, response.LogLines); + if (_resourceRepositoryWriter is not null) + { + await _resourceRepositoryWriter.AddConsoleLogsAsync(resourceName, response.LogLines).ConfigureAwait(false); + } // Channel is unbound so TryWrite always succeeds. channel.Writer.TryWrite(CreateLogLines(response.LogLines)); } @@ -975,7 +978,7 @@ public async IAsyncEnumerable> GetConsoleLogs(str { EnsureInitialized(); - MarkConsoleLogsLoaded(resourceName); + await MarkConsoleLogsLoadedAsync(resourceName).ConfigureAwait(false); using var combinedTokens = CancellationTokenSource.CreateLinkedTokenSource(_clientCancellationToken, cancellationToken); @@ -986,12 +989,15 @@ public async IAsyncEnumerable> GetConsoleLogs(str await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken: combinedTokens.Token).ConfigureAwait(false)) { - _resourceRepositoryWriter?.AddConsoleLogs(resourceName, response.LogLines); + if (_resourceRepositoryWriter is not null) + { + await _resourceRepositoryWriter.AddConsoleLogsAsync(resourceName, response.LogLines).ConfigureAwait(false); + } yield return CreateLogLines(response.LogLines); } } - private void MarkConsoleLogsLoaded(string resourceName) + private async Task MarkConsoleLogsLoadedAsync(string resourceName) { lock (_lock) { @@ -1001,7 +1007,10 @@ private void MarkConsoleLogsLoaded(string resourceName) } } - _resourceRepositoryWriter?.MarkConsoleLogsLoaded(resourceName); + if (_resourceRepositoryWriter is not null) + { + await _resourceRepositoryWriter.MarkConsoleLogsLoadedAsync(resourceName).ConfigureAwait(false); + } } private static ResourceLogLine[] CreateLogLines(IList logLines) diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs index cf274655846..dd2f3408eaf 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardSqliteDatabase.cs @@ -4,6 +4,7 @@ using Dapper; using System.Data; using System.Diagnostics; +using Aspire.Dashboard.Utils; using Microsoft.Data.Sqlite; namespace Aspire.Dashboard.ServiceClient; @@ -15,13 +16,12 @@ public sealed class DashboardSqliteDatabase : IDisposable { private const string SchemaResourcePrefix = "Aspire.Dashboard.ServiceClient.DatabaseSchema."; - internal const int SchemaVersion = 13; + internal const int SchemaVersion = 15; private static readonly Lazy> s_schemaScripts = new(LoadSchemaScripts); private readonly string _connectionString; private readonly ActivitySource _activitySource = new(TracingSqliteConnection.ActivitySourceName); - private readonly object _schemaLock = new(); private bool _schemaInitialized; /// @@ -63,6 +63,11 @@ public DashboardSqliteDatabase(string databasePath, bool readOnly = false, bool internal ActivitySource ActivitySource => _activitySource; + /// + /// Gets the lock that serializes writes to this database. + /// + internal AsyncLock WriteLock { get; } = new(); + /// /// Determines whether a dashboard database uses the current schema version. /// @@ -120,7 +125,7 @@ public void InitializeSchema() { EnsureWritable("Historical dashboard data is read-only."); - lock (_schemaLock) + using (WriteLock.Lock()) { if (_schemaInitialized) { diff --git a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql index 740da65073c..ac50db57292 100644 --- a/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql +++ b/src/Aspire.Dashboard/ServiceClient/DatabaseSchema/005.Metrics.sql @@ -40,23 +40,11 @@ CREATE TABLE IF NOT EXISTS telemetry_metric_points ( double_value REAL NULL, histogram_sum REAL NULL, histogram_count INTEGER NULL, + bucket_counts BLOB NULL, + explicit_bounds BLOB NULL, flags INTEGER NOT NULL ) STRICT; -CREATE TABLE IF NOT EXISTS telemetry_metric_histogram_bucket_counts ( - point_id INTEGER NOT NULL REFERENCES telemetry_metric_points(point_id) ON DELETE CASCADE, - ordinal INTEGER NOT NULL, - bucket_count INTEGER NOT NULL, - PRIMARY KEY (point_id, ordinal) -) STRICT; - -CREATE TABLE IF NOT EXISTS telemetry_metric_histogram_explicit_bounds ( - point_id INTEGER NOT NULL REFERENCES telemetry_metric_points(point_id) ON DELETE CASCADE, - ordinal INTEGER NOT NULL, - explicit_bound REAL NOT NULL, - PRIMARY KEY (point_id, ordinal) -) STRICT; - CREATE TABLE IF NOT EXISTS telemetry_metric_exemplars ( exemplar_id INTEGER PRIMARY KEY AUTOINCREMENT, point_id INTEGER NOT NULL REFERENCES telemetry_metric_points(point_id) ON DELETE CASCADE, diff --git a/src/Aspire.Dashboard/ServiceClient/IResourceRepositoryWriter.cs b/src/Aspire.Dashboard/ServiceClient/IResourceRepositoryWriter.cs index 1f575bd3725..95c0b65c4d5 100644 --- a/src/Aspire.Dashboard/ServiceClient/IResourceRepositoryWriter.cs +++ b/src/Aspire.Dashboard/ServiceClient/IResourceRepositoryWriter.cs @@ -7,8 +7,8 @@ namespace Aspire.Dashboard.ServiceClient; internal interface IResourceRepositoryWriter { - void ReplaceResources(IReadOnlyList resources); - void ApplyChanges(IReadOnlyList changes); - void MarkConsoleLogsLoaded(string resourceName); - void AddConsoleLogs(string resourceName, IReadOnlyList logLines); + Task ReplaceResourcesAsync(IReadOnlyList resources); + Task ApplyChangesAsync(IReadOnlyList changes); + Task MarkConsoleLogsLoadedAsync(string resourceName); + Task AddConsoleLogsAsync(string resourceName, IReadOnlyList logLines); } \ No newline at end of file diff --git a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs index f5f0dac6d52..fb2069c22ff 100644 --- a/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs +++ b/src/Aspire.Dashboard/ServiceClient/SqliteResourceRepository.cs @@ -19,12 +19,12 @@ public sealed partial class SqliteResourceRepository : IResourceRepository, IRes private readonly IKnownPropertyLookup _knownPropertyLookup; private readonly ILogger _logger; private readonly bool _ownsDatabase; - private readonly object _lock = new(); + private readonly object _stateLock = new(); private readonly Dictionary _resources = new(StringComparers.ResourceName); private ImmutableHashSet>> _resourceChannels = []; private readonly Dictionary>>> _consoleChannels = new(StringComparers.ResourceName); private readonly Dictionary _lastConsoleLogLineNumbers = new(StringComparers.ResourceName); - private bool _disposed; + private int _disposed; public SqliteResourceRepository( string databasePath, @@ -55,39 +55,41 @@ internal SqliteResourceRepository( public ResourceViewModel? GetResource(string resourceName) { - lock (_lock) + ThrowIfDisposed(); + lock (_stateLock) { - ThrowIfDisposed(); return _resources.GetValueOrDefault(resourceName); } } public IReadOnlyList GetResources() { - lock (_lock) + ThrowIfDisposed(); + lock (_stateLock) { - ThrowIfDisposed(); return _resources.Values.ToList(); } } public Task SubscribeResourcesAsync(CancellationToken cancellationToken) { - lock (_lock) + ThrowIfDisposed(); + var channel = Channel.CreateUnbounded>(new UnboundedChannelOptions + { + AllowSynchronousContinuations = false, + SingleReader = true, + SingleWriter = false + }); + ImmutableArray initialState; + lock (_stateLock) { - ThrowIfDisposed(); - var channel = Channel.CreateUnbounded>(new UnboundedChannelOptions - { - AllowSynchronousContinuations = false, - SingleReader = true, - SingleWriter = false - }); _resourceChannels = _resourceChannels.Add(channel); - - return Task.FromResult(new ResourceViewModelSubscription( - _resources.Values.ToImmutableArray(), - ReadResourceUpdatesAsync(channel, cancellationToken))); + initialState = _resources.Values.ToImmutableArray(); } + + return Task.FromResult(new ResourceViewModelSubscription( + initialState, + ReadResourceUpdatesAsync(channel, cancellationToken))); } public async IAsyncEnumerable> GetConsoleLogs( @@ -95,19 +97,16 @@ public async IAsyncEnumerable> GetConsoleLogs( [EnumeratorCancellation] CancellationToken cancellationToken) { ResourceLogLine[] lines; - lock (_lock) - { - ThrowIfDisposed(); - using var connection = _database.OpenConnection(); - lines = connection.Query(""" - SELECT line_number AS LineNumber, content AS Content, is_stderr AS IsStdErr - FROM console_logs - WHERE resource_name = @ResourceName - ORDER BY console_log_id; - """, new { ResourceName = resourceName }) - .Select(line => new ResourceLogLine(line.LineNumber, line.Content, line.IsStdErr)) - .ToArray(); - } + ThrowIfDisposed(); + using var connection = _database.OpenConnection(); + lines = connection.Query(""" + SELECT line_number AS LineNumber, content AS Content, is_stderr AS IsStdErr + FROM console_logs + WHERE resource_name = @ResourceName + ORDER BY console_log_id; + """, new { ResourceName = resourceName }) + .Select(line => new ResourceLogLine(line.LineNumber, line.Content, line.IsStdErr)) + .ToArray(); cancellationToken.ThrowIfCancellationRequested(); if (lines.Length > 0) @@ -122,9 +121,11 @@ public async IAsyncEnumerable> SubscribeConsoleLo { ResourceLogLine[] initialLines; Channel> channel; - lock (_lock) + ThrowIfDisposed(); + // Keep the initial query and channel registration atomic with console-log writes. Otherwise, a write + // could commit after the query but before registration, causing the subscriber to miss those lines. + using (await _database.WriteLock.LockAsync(cancellationToken).ConfigureAwait(false)) { - ThrowIfDisposed(); using var connection = _database.OpenConnection(); initialLines = connection.Query(""" SELECT line_number AS LineNumber, content AS Content, is_stderr AS IsStdErr @@ -141,7 +142,10 @@ FROM console_logs SingleReader = true, SingleWriter = false }); - _consoleChannels[resourceName] = (_consoleChannels.GetValueOrDefault(resourceName) ?? []).Add(channel); + lock (_stateLock) + { + _consoleChannels[resourceName] = (_consoleChannels.GetValueOrDefault(resourceName) ?? []).Add(channel); + } } try @@ -158,7 +162,7 @@ FROM console_logs } finally { - lock (_lock) + lock (_stateLock) { if (_consoleChannels.TryGetValue(resourceName, out var channels)) { @@ -176,59 +180,71 @@ FROM console_logs } } - void IResourceRepositoryWriter.ReplaceResources(IReadOnlyList resources) + async Task IResourceRepositoryWriter.ReplaceResourcesAsync(IReadOnlyList resources) { EnsureWritable(); + ThrowIfDisposed(); List changes = []; - lock (_lock) + using (await _database.WriteLock.LockAsync().ConfigureAwait(false)) { - ThrowIfDisposed(); - using var connection = _database.OpenConnection(); - using var transaction = connection.BeginTransaction(); + Dictionary currentResources; + lock (_stateLock) + { + currentResources = new Dictionary(_resources, StringComparers.ResourceName); + } + + var replacementResources = new Dictionary(StringComparers.ResourceName); + var resourcesToSave = new List(resources.Count); var replacementResourceNames = resources.Select(resource => resource.Name).ToHashSet(StringComparers.ResourceName); - foreach (var (resourceName, viewModel) in _resources) + foreach (var (resourceName, viewModel) in currentResources) { if (!replacementResourceNames.Contains(resourceName)) { changes.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Delete, viewModel)); } } - var resourcesWithLoadedConsoleLogs = _resources.Values + var resourcesWithLoadedConsoleLogs = currentResources.Values .Where(resource => resource.ConsoleLogsLoaded) .Select(resource => resource.Name) .ToHashSet(StringComparers.ResourceName); - connection.Execute("DELETE FROM dashboard_resources;", transaction: transaction); - _resources.Clear(); - var resourcesToSave = new List(resources.Count); - foreach (var resource in resources) { - var viewModel = CreateViewModel(resource); + var viewModel = CreateViewModel(resource, replacementResources); viewModel.ConsoleLogsLoaded = resourcesWithLoadedConsoleLogs.Contains(resource.Name); resourcesToSave.Add(new(resource, viewModel.ReplicaIndex, viewModel.ConsoleLogsLoaded)); - _resources[resource.Name] = viewModel; + replacementResources[resource.Name] = viewModel; changes.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, viewModel)); } + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + connection.Execute("DELETE FROM dashboard_resources;", transaction: transaction); InsertResources(connection, transaction, resourcesToSave); transaction.Commit(); + + lock (_stateLock) + { + _resources.Clear(); + foreach (var (resourceName, viewModel) in replacementResources) + { + _resources.Add(resourceName, viewModel); + } + } } PublishResourceChanges(changes); } - void IResourceRepositoryWriter.ApplyChanges(IReadOnlyList changes) + async Task IResourceRepositoryWriter.ApplyChangesAsync(IReadOnlyList changes) { EnsureWritable(); + ThrowIfDisposed(); List viewModelChanges = []; - lock (_lock) + using (await _database.WriteLock.LockAsync().ConfigureAwait(false)) { - ThrowIfDisposed(); - using var connection = _database.OpenConnection(); - using var transaction = connection.BeginTransaction(); var affectedResourceNames = changes .Select(change => change.KindCase switch { @@ -240,54 +256,74 @@ void IResourceRepositoryWriter.ApplyChanges(IReadOnlyList .Distinct(StringComparers.ResourceName) .ToArray(); var resourcesToSave = new Dictionary(StringComparers.ResourceName); + Dictionary updatedResources; + lock (_stateLock) + { + updatedResources = new Dictionary(_resources, StringComparers.ResourceName); + } foreach (var change in changes) { if (change.KindCase == WatchResourcesChange.KindOneofCase.Upsert) { var resource = change.Upsert; - var viewModel = CreateViewModel(resource); + var viewModel = CreateViewModel(resource, updatedResources); resourcesToSave[resource.Name] = new(resource, viewModel.ReplicaIndex, viewModel.ConsoleLogsLoaded); - _resources[resource.Name] = viewModel; + updatedResources[resource.Name] = viewModel; viewModelChanges.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, viewModel)); } - else if (change.KindCase == WatchResourcesChange.KindOneofCase.Delete && _resources.Remove(change.Delete.ResourceName, out var removed)) + else if (change.KindCase == WatchResourcesChange.KindOneofCase.Delete && updatedResources.Remove(change.Delete.ResourceName, out var removed)) { resourcesToSave.Remove(change.Delete.ResourceName); viewModelChanges.Add(new ResourceViewModelChange(ResourceViewModelChangeType.Delete, removed)); } } + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); connection.Execute("DELETE FROM dashboard_resources WHERE resource_name IN @ResourceNames;", new { ResourceNames = affectedResourceNames }, transaction); InsertResources(connection, transaction, resourcesToSave.Values.ToArray()); transaction.Commit(); + + lock (_stateLock) + { + _resources.Clear(); + foreach (var (resourceName, viewModel) in updatedResources) + { + _resources.Add(resourceName, viewModel); + } + } } PublishResourceChanges(viewModelChanges); } - void IResourceRepositoryWriter.MarkConsoleLogsLoaded(string resourceName) + async Task IResourceRepositoryWriter.MarkConsoleLogsLoadedAsync(string resourceName) { EnsureWritable(); - lock (_lock) + ThrowIfDisposed(); + using (await _database.WriteLock.LockAsync().ConfigureAwait(false)) { - ThrowIfDisposed(); using var connection = _database.OpenConnection(); connection.Execute(""" UPDATE dashboard_resources SET console_logs_loaded = 1 WHERE resource_name = @ResourceName; """, new { ResourceName = resourceName }); - if (_resources.TryGetValue(resourceName, out var resource)) + lock (_stateLock) { - resource.ConsoleLogsLoaded = true; + if (_resources.TryGetValue(resourceName, out var resource)) + { + resource.ConsoleLogsLoaded = true; + } } } } - void IResourceRepositoryWriter.AddConsoleLogs(string resourceName, IReadOnlyList logLines) + async Task IResourceRepositoryWriter.AddConsoleLogsAsync(string resourceName, IReadOnlyList logLines) { EnsureWritable(); + ThrowIfDisposed(); if (logLines.Count == 0) { return; @@ -295,17 +331,14 @@ void IResourceRepositoryWriter.AddConsoleLogs(string resourceName, IReadOnlyList var viewModelLines = logLines.Select(line => new ResourceLogLine(line.LineNumber, line.Text, line.IsStdErr)).ToArray(); Channel>[] channels; - lock (_lock) + using (await _database.WriteLock.LockAsync().ConfigureAwait(false)) { - ThrowIfDisposed(); - using var connection = _database.OpenConnection(); - using var transaction = connection.BeginTransaction(); - connection.Execute(""" - UPDATE dashboard_resources - SET console_logs_loaded = 1 - WHERE resource_name = @ResourceName; - """, new { ResourceName = resourceName }, transaction); - var lastLineNumber = _lastConsoleLogLineNumbers.GetValueOrDefault(resourceName, int.MinValue); + int lastLineNumber; + lock (_stateLock) + { + lastLineNumber = _lastConsoleLogLineNumbers.GetValueOrDefault(resourceName, int.MinValue); + } + // A response can overlap a previously persisted response or repeat a line number within the // same batch. Persist only new line numbers and keep the latest content for an in-batch repeat. var consoleLogsToInsert = new List(logLines.Count); @@ -327,17 +360,29 @@ UPDATE dashboard_resources consoleLogsToInsert.Add(new(line.LineNumber, line.Text, line.IsStdErr)); } } + + using var connection = _database.OpenConnection(); + using var transaction = connection.BeginTransaction(); + connection.Execute(""" + UPDATE dashboard_resources + SET console_logs_loaded = 1 + WHERE resource_name = @ResourceName; + """, new { ResourceName = resourceName }, transaction); InsertConsoleLogs(connection, transaction, resourceName, consoleLogsToInsert); transaction.Commit(); - if (consoleLogsToInsert.Count > 0) - { - _lastConsoleLogLineNumbers[resourceName] = consoleLogsToInsert.Max(line => line.LineNumber); - } - if (_resources.TryGetValue(resourceName, out var resource)) + + lock (_stateLock) { - resource.ConsoleLogsLoaded = true; + if (consoleLogsToInsert.Count > 0) + { + _lastConsoleLogLineNumbers[resourceName] = consoleLogsToInsert.Max(line => line.LineNumber); + } + if (_resources.TryGetValue(resourceName, out var resource)) + { + resource.ConsoleLogsLoaded = true; + } + channels = (_consoleChannels.GetValueOrDefault(resourceName) ?? []).ToArray(); } - channels = (_consoleChannels.GetValueOrDefault(resourceName) ?? []).ToArray(); } foreach (var channel in channels) @@ -346,16 +391,16 @@ UPDATE dashboard_resources } } - private ResourceViewModel CreateViewModel(Resource resource) + private ResourceViewModel CreateViewModel(Resource resource, IReadOnlyDictionary resources) { - if (_resources.TryGetValue(resource.Name, out var existingResource)) + if (resources.TryGetValue(resource.Name, out var existingResource)) { var viewModel = resource.ToViewModel(existingResource.ReplicaIndex, _knownPropertyLookup, _logger); viewModel.ConsoleLogsLoaded = existingResource.ConsoleLogsLoaded; return viewModel; } - var replicaIndex = _resources.Values.Count(r => string.Equals(r.DisplayName, resource.DisplayName, StringComparisons.ResourceName)) + 1; + var replicaIndex = resources.Values.Count(r => string.Equals(r.DisplayName, resource.DisplayName, StringComparisons.ResourceName)) + 1; return resource.ToViewModel(replicaIndex, _knownPropertyLookup, _logger); } @@ -383,7 +428,7 @@ private async IAsyncEnumerable> ReadResou } finally { - lock (_lock) + lock (_stateLock) { _resourceChannels = _resourceChannels.Remove(channel); } @@ -398,7 +443,7 @@ private void PublishResourceChanges(IReadOnlyList chang } Channel>[] channels; - lock (_lock) + lock (_stateLock) { channels = _resourceChannels.ToArray(); } @@ -413,28 +458,32 @@ private void EnsureWritable() _database.EnsureWritable("Historical dashboard resources are read-only."); } - private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); public void Dispose() { - lock (_lock) + using (_database.WriteLock.Lock()) { - if (_disposed) + if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; } - _disposed = true; - foreach (var channel in _resourceChannels) + Channel>[] resourceChannels; + Channel>[] consoleChannels; + lock (_stateLock) + { + resourceChannels = _resourceChannels.ToArray(); + consoleChannels = _consoleChannels.Values.SelectMany(channels => channels).ToArray(); + } + + foreach (var channel in resourceChannels) { channel.Writer.TryComplete(); } - foreach (var channels in _consoleChannels.Values) + foreach (var channel in consoleChannels) { - foreach (var channel in channels) - { - channel.Writer.TryComplete(); - } + channel.Writer.TryComplete(); } _database.ClearPool(); diff --git a/src/Aspire.Dashboard/Utils/AsyncLock.cs b/src/Aspire.Dashboard/Utils/AsyncLock.cs new file mode 100644 index 00000000000..79189cdc7c8 --- /dev/null +++ b/src/Aspire.Dashboard/Utils/AsyncLock.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Dashboard.Utils; + +/// +/// Provides mutual exclusion with asynchronous lock acquisition. +/// +internal sealed class AsyncLock +{ + private readonly SemaphoreSlim _semaphore = new(1, 1); + + /// + /// Acquires the lock synchronously. + /// + /// A handle that releases the lock when disposed. + public IDisposable Lock() + { + _semaphore.Wait(); + return new Releaser(_semaphore); + } + + /// + /// Acquires the lock asynchronously. + /// + /// The token to monitor for cancellation requests. + /// A handle that releases the lock when disposed. + public async ValueTask LockAsync(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + return new Releaser(_semaphore); + } + + private sealed class Releaser(SemaphoreSlim semaphore) : IDisposable + { + private SemaphoreSlim? _semaphore = semaphore; + + public void Dispose() + { + Interlocked.Exchange(ref _semaphore, null)?.Release(); + } + } +} \ No newline at end of file diff --git a/src/Aspire.Hosting/Dashboard/ResourcePublisher.cs b/src/Aspire.Hosting/Dashboard/ResourcePublisher.cs index 86aeeca91e8..64f2373c143 100644 --- a/src/Aspire.Hosting/Dashboard/ResourcePublisher.cs +++ b/src/Aspire.Hosting/Dashboard/ResourcePublisher.cs @@ -61,7 +61,7 @@ async IAsyncEnumerable> StreamUpdates([Enu try { - await foreach (var batch in channel.GetBatchesAsync(cancellationToken: linked.Token).ConfigureAwait(false)) + await foreach (var batch in channel.GetBatchesAsync(cancellationToken: linked.Token, minReadInterval: TimeSpan.FromMilliseconds(100)).ConfigureAwait(false)) { yield return batch; } diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs index b9b07600ea9..c9d9340eb76 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs @@ -130,7 +130,7 @@ public async Task Render_HasGenAIMessages_CopyButtonHasAccessibleName() var cut = SetUpDialog(out var dialogService); var repository = Services.GetRequiredService(); - repository.AddLogs(new AddContext(), new RepeatedField + await repository.AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -181,7 +181,7 @@ public async Task UpdateTelemetry_DifferentTrace_ContentInstanceUnchanged() // Add initial trace to repository for the dialog to display var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField + await repository.AddTracesAsync(addContext, new RepeatedField { new ResourceSpans { @@ -228,7 +228,7 @@ await GenAIVisualizerDialog.OpenDialogAsync( var originalContent = instance.Content; // Act - Add a DIFFERENT trace to the repository - repository.AddTraces(addContext, new RepeatedField + await repository.AddTracesAsync(addContext, new RepeatedField { new ResourceSpans { @@ -264,7 +264,7 @@ public async Task UpdateTelemetry_SameTrace_ContentInstanceChanged() // Add initial trace to repository for the dialog to display var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField + await repository.AddTracesAsync(addContext, new RepeatedField { new ResourceSpans { @@ -325,7 +325,7 @@ await GenAIVisualizerDialog.OpenDialogAsync( var originalContent = instance.Content; // Act - Add a new span to the SAME trace that the dialog is displaying - repository.AddTraces(addContext, new RepeatedField + await repository.AddTracesAsync(addContext, new RepeatedField { new ResourceSpans { diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs index d4270d3954e..77844c652f6 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs @@ -162,7 +162,7 @@ public async Task Render_ClearedSignals_PrunesSelectionsAndSupportsRemovingEmpty var repository = Services.GetRequiredService(); var resourceKey = new ResourceKey("orphan", "instance"); - repository.AddLogs(new AddContext(), new RepeatedField + await repository.AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -178,7 +178,7 @@ public async Task Render_ClearedSignals_PrunesSelectionsAndSupportsRemovingEmpty } }); var timestamp = DateTime.UnixEpoch; - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -207,7 +207,7 @@ public async Task Render_ClearedSignals_PrunesSelectionsAndSupportsRemovingEmpty cut.WaitForAssertion(() => AssertSelectionCheckbox(cut, "Structured logs for orphan", "true")); await ClickSelectionCheckboxAsync(cut, "Structured logs for orphan", "true"); - repository.ClearTraces(resourceKey); + await repository.ClearTracesAsync(resourceKey); cut.WaitForAssertion(() => { @@ -217,7 +217,7 @@ public async Task Render_ClearedSignals_PrunesSelectionsAndSupportsRemovingEmpty AssertButtonDisabled(cut, "Remove selected", expectedDisabled: true); }); - repository.ClearStructuredLogs(resourceKey); + await repository.ClearStructuredLogsAsync(resourceKey); cut.WaitForAssertion(() => { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs index c838fbc8607..2d1628b64ea 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs @@ -37,11 +37,11 @@ public partial class MetricsTests : DashboardTestContext [Theory] [InlineData(false, true)] [InlineData(true, false)] - public void SignalActions_UseTelemetryRepositoryReadOnlyState(bool telemetryRepositoryIsReadOnly, bool dashboardClientIsReadOnly) + public async Task SignalActions_UseTelemetryRepositoryReadOnlyState(bool telemetryRepositoryIsReadOnly, bool dashboardClientIsReadOnly) { MetricsSetupHelpers.SetupMetricsPage(this); - FluentUISetupHelpers.ConfigureTelemetryRepository(this, telemetryRepositoryIsReadOnly, _ => { }); Services.AddSingleton(new TestDashboardClient(isReadOnly: dashboardClientIsReadOnly)); + await FluentUISetupHelpers.ConfigureTelemetryRepository(this, telemetryRepositoryIsReadOnly, _ => Task.CompletedTask); var cut = RenderComponent(builder => builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false))); @@ -50,9 +50,9 @@ public void SignalActions_UseTelemetryRepositoryReadOnlyState(bool telemetryRepo } [Fact] - public void ChangeResource_MeterAndInstrumentOnNewResource_InstrumentSet() + public async Task ChangeResource_MeterAndInstrumentOnNewResource_InstrumentSet() { - ChangeResourceAndAssertInstrument( + await ChangeResourceAndAssertInstrument( app1InstrumentName: "test1", app2InstrumentName: "test1", expectedMeterNameAfterChange: "test-meter", @@ -60,9 +60,9 @@ public void ChangeResource_MeterAndInstrumentOnNewResource_InstrumentSet() } [Fact] - public void ChangeResource_MeterAndInstrumentNotOnNewResources_InstrumentCleared() + public async Task ChangeResource_MeterAndInstrumentNotOnNewResources_InstrumentCleared() { - ChangeResourceAndAssertInstrument( + await ChangeResourceAndAssertInstrument( app1InstrumentName: "test1", app2InstrumentName: "test2", expectedMeterNameAfterChange: null, @@ -70,14 +70,14 @@ public void ChangeResource_MeterAndInstrumentNotOnNewResources_InstrumentCleared } [Fact] - public void ChartContainer_ParametersAndActiveView_OnlyRefreshAndRenderWhenChanged() + public async Task ChartContainer_ParametersAndActiveView_OnlyRefreshAndRenderWhenChanged() { JSInterop.Mode = JSRuntimeMode.Loose; MetricsSetupHelpers.SetupMetricsPage(this); var telemetryRepository = Services.GetRequiredService(); var metricTime = DateTime.UtcNow.AddMinutes(-1); - telemetryRepository.AddMetrics(new AddContext(), new RepeatedField + await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -133,7 +133,7 @@ public void ChartContainer_ParametersAndActiveView_OnlyRefreshAndRenderWhenChang Assert.Empty(cut.FindComponents()); Assert.Empty(activities); - telemetryRepository.AddMetrics(new AddContext(), new RepeatedField + await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -183,7 +183,7 @@ public async Task ChartContainer_TickUpdate_FetchesDataAfterInterval() MetricsSetupHelpers.SetupMetricsPage(this); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddMetrics(new AddContext(), new RepeatedField + await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -262,7 +262,7 @@ public async Task InitialLoad_SingleResource_RedirectToResource() }); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddMetrics(new AddContext(), new RepeatedField + await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -293,7 +293,7 @@ public async Task InitialLoad_SingleResource_RedirectToResource() } [Fact] - public void InitialLoad_HasSessionState_RedirectUsingState() + public async Task InitialLoad_HasSessionState_RedirectUsingState() { // Arrange var testSessionStorage = new TestSessionStorage @@ -331,7 +331,7 @@ public void InitialLoad_HasSessionState_RedirectUsingState() }; var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddMetrics(new AddContext(), new RepeatedField + await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -383,13 +383,13 @@ public void InitialLoad_HasSessionState_RedirectUsingState() } [Fact] - public void MetricsTree_MetricsAdded_TreeUpdated() + public async Task MetricsTree_MetricsAdded_TreeUpdated() { // Arrange MetricsSetupHelpers.SetupMetricsPage(this); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddMetrics(new AddContext(), new RepeatedField + await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -434,7 +434,7 @@ public void MetricsTree_MetricsAdded_TreeUpdated() // Act 2 // New instruments added - telemetryRepository.AddMetrics(new AddContext(), new RepeatedField + await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -479,11 +479,11 @@ public void MetricsTree_MetricsAdded_TreeUpdated() } [Fact] - public void ReadOnly_ChartEndsAtLatestMetricTime() + public async Task ReadOnly_ChartEndsAtLatestMetricTime() { MetricsSetupHelpers.SetupMetricsPage(this); - FluentUISetupHelpers.ConfigureTelemetryRepository(this, readOnly: true, telemetryRepository => telemetryRepository.AddMetrics(new AddContext(), new RepeatedField + await FluentUISetupHelpers.ConfigureTelemetryRepository(this, readOnly: true, telemetryRepository => telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -522,7 +522,7 @@ public void ReadOnly_ChartEndsAtLatestMetricTime() }); } - private void ChangeResourceAndAssertInstrument(string app1InstrumentName, string app2InstrumentName, string? expectedMeterNameAfterChange, string? expectedInstrumentNameAfterChange) + private async Task ChangeResourceAndAssertInstrument(string app1InstrumentName, string app2InstrumentName, string? expectedMeterNameAfterChange, string? expectedInstrumentNameAfterChange) { // Arrange MetricsSetupHelpers.SetupMetricsPage(this); @@ -531,7 +531,7 @@ private void ChangeResourceAndAssertInstrument(string app1InstrumentName, string navigationManager.NavigateTo(DashboardUrls.MetricsUrl(resource: "TestApp", meter: "test-meter", instrument: app1InstrumentName, duration: 720, view: MetricViewKind.Table.ToString())); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddMetrics(new AddContext(), new RepeatedField + await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs index d612ef66931..c56b4efcf1e 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs @@ -513,14 +513,14 @@ public void ResourcesShouldRemainUnchangedWhenFilterDoesNotMatchUpdatedResource( } [Fact] - public void UnreadLogErrorsBadge_StopsKeyboardPropagation() + public async Task UnreadLogErrorsBadge_StopsKeyboardPropagation() { FluentUISetupHelpers.AddCommonDashboardServices(this); FluentUISetupHelpers.SetupFluentUIComponents(this); FluentUISetupHelpers.SetupFluentAnchor(this); var telemetryRepository = Services.GetRequiredService(); - AddErrorLog(telemetryRepository, resourceName: "Resource1"); + await AddErrorLog(telemetryRepository, resourceName: "Resource1"); var unviewedErrorCounts = telemetryRepository.GetResourceUnviewedErrorLogsCount(); var resourceKey = Assert.Single(unviewedErrorCounts.Keys); var resource = CreateResource(resourceKey.GetCompositeName(), "Type1", "Running", null); @@ -571,7 +571,7 @@ private static ResourceViewModel CreateResource( }; } - private static void AddErrorLog(SqliteTelemetryRepository repository, string resourceName) + private static async Task AddErrorLog(SqliteTelemetryRepository repository, string resourceName) { var addContext = new AddContext(); var logs = new RepeatedField(); @@ -594,7 +594,7 @@ private static void AddErrorLog(SqliteTelemetryRepository repository, string res } }); - repository.AddLogs(addContext, logs); + await repository.AddLogsAsync(addContext, logs); Assert.Equal(0, addContext.FailureCount); } diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs index 65aa59ab8a7..98663558e57 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs @@ -31,13 +31,13 @@ namespace Aspire.Dashboard.Components.Tests.Pages; public partial class StructuredLogsTests : DashboardTestContext { [Fact] - public void Render_ResourceInstanceHasDashes_AppKeyResolvedCorrectly() + public async Task Render_ResourceInstanceHasDashes_AppKeyResolvedCorrectly() { // Arrange SetupStructureLogsServices(); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddLogs(new AddContext(), new RepeatedField + await telemetryRepository.AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -258,7 +258,7 @@ public void PauseIncomingData_DisplaysPauseWarningImmediately() [Theory] [InlineData(false, 1)] [InlineData(true, 0)] - public void Render_AtLogLimit_LimitMessageOnlyDisplayedForLiveRun(bool isReadOnly, int expectedMessageCount) + public async Task Render_AtLogLimit_LimitMessageOnlyDisplayedForLiveRun(bool isReadOnly, int expectedMessageCount) { var messageCount = 0; var messageService = new TestMessageService(_ => @@ -274,7 +274,7 @@ public void Render_AtLogLimit_LimitMessageOnlyDisplayedForLiveRun(bool isReadOnl TelemetryLimits = { MaxLogCount = 1 } })); - FluentUISetupHelpers.ConfigureTelemetryRepository(this, isReadOnly, telemetryRepository => telemetryRepository.AddLogs(new AddContext(), new RepeatedField + await FluentUISetupHelpers.ConfigureTelemetryRepository(this, isReadOnly, telemetryRepository => telemetryRepository.AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs index 2da20e17ee7..0a08d8ac571 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs @@ -36,7 +36,7 @@ public TraceDetailsTests(ITestOutputHelper testOutputHelper) } [Fact] - public void Render_HasTrace_SubscriptionRemovedOnDispose() + public async Task Render_HasTrace_SubscriptionRemovedOnDispose() { // Arrange SetupTraceDetailsServices(); @@ -47,7 +47,7 @@ public void Render_HasTrace_SubscriptionRemovedOnDispose() dimensionManager.InvokeOnViewportInformationChanged(viewport); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddTraces(new AddContext(), new RepeatedField + await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -84,7 +84,7 @@ public void Render_HasTrace_SubscriptionRemovedOnDispose() } [Fact] - public void Render_FocusesAccessibleScrollContainerOnInitialRender() + public async Task Render_FocusesAccessibleScrollContainerOnInitialRender() { SetupTraceDetailsServices(); @@ -94,7 +94,7 @@ public void Render_FocusesAccessibleScrollContainerOnInitialRender() dimensionManager.InvokeOnViewportInformationChanged(viewport); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddTraces(new AddContext(), new RepeatedField + await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -152,7 +152,7 @@ public async Task Render_ChangeTrace_RowsRendered() dimensionManager.InvokeOnViewportInformationChanged(viewport); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddTraces(new AddContext(), new RepeatedField + await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -220,7 +220,7 @@ public async Task Render_TraceUpdateWithNewSpans_RowsRendered() dimensionManager.InvokeOnViewportInformationChanged(viewport); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddTraces(new AddContext(), new RepeatedField + await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -258,7 +258,7 @@ await AsyncTestHelpers.AssertIsTrueRetryAsync(() => return rows.Count == 3; }, "Expected rows to be rendered.", logger); - telemetryRepository.AddTraces(new AddContext(), new RepeatedField + await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -302,7 +302,7 @@ public async Task Render_UpdateDifferentTrace_TraceNotUpdated() dimensionManager.InvokeOnViewportInformationChanged(viewport); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddTraces(new AddContext(), new RepeatedField + await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -341,7 +341,7 @@ await AsyncTestHelpers.AssertIsTrueRetryAsync(() => }, "Expected rows to be rendered.", logger); logger.LogInformation($"Adding span for difference trace"); - telemetryRepository.AddTraces(new AddContext(), new RepeatedField + await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -379,7 +379,7 @@ public async Task Render_SpansOrderedByStartTime_RowsRenderedInCorrectOrder() dimensionManager.InvokeOnViewportInformationChanged(viewport); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddTraces(new AddContext(), + await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans @@ -447,7 +447,7 @@ public async Task Render_DurationFilter_FiltersShortSpans() dimensionManager.InvokeOnViewportInformationChanged(viewport); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddTraces(new AddContext(), + await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans @@ -572,7 +572,7 @@ public async Task Render_DurationFilter_LongRoot_DoesNotExposeShortChildren() dimensionManager.InvokeOnViewportInformationChanged(viewport); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddTraces(new AddContext(), + await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans @@ -645,7 +645,7 @@ public async Task Render_DurationFilter_LongRoot_DoesNotExposeShortChildren() } [Fact] - public void ToggleCollapse_SpanStateChanges() + public async Task ToggleCollapse_SpanStateChanges() { // Arrange SetupTraceDetailsServices(); @@ -655,7 +655,7 @@ public void ToggleCollapse_SpanStateChanges() dimensionManager.InvokeOnViewportInformationChanged(viewport); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddTraces(new AddContext(), + await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans @@ -728,7 +728,7 @@ public void ToggleCollapse_SpanStateChanges() } [Fact] - public void CollapseAllSpans_CollapsesAllSpans() + public async Task CollapseAllSpans_CollapsesAllSpans() { // Arrange SetupTraceDetailsServices(); @@ -738,7 +738,7 @@ public void CollapseAllSpans_CollapsesAllSpans() dimensionManager.InvokeOnViewportInformationChanged(viewport); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddTraces(new AddContext(), + await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans @@ -779,7 +779,7 @@ public void CollapseAllSpans_CollapsesAllSpans() var menuButton = cut.FindComponent(); var collapseAllMenuItem = menuButton.Instance.Items.FirstOrDefault(item => item.Text == "Collapse all"); // Locate by text since ID was removed Assert.NotNull(collapseAllMenuItem); - cut.InvokeAsync(() => collapseAllMenuItem!.OnClick?.Invoke() ?? Task.CompletedTask); + await cut.InvokeAsync(() => collapseAllMenuItem!.OnClick?.Invoke() ?? Task.CompletedTask); // Assert cut.WaitForAssertion(() => @@ -803,7 +803,7 @@ public void CollapseAllSpans_CollapsesAllSpans() } [Fact] - public void ExpandAllSpans_ExpandsAllSpans() + public async Task ExpandAllSpans_ExpandsAllSpans() { // Arrange SetupTraceDetailsServices(); @@ -813,7 +813,7 @@ public void ExpandAllSpans_ExpandsAllSpans() dimensionManager.InvokeOnViewportInformationChanged(viewport); var telemetryRepository = Services.GetRequiredService(); - telemetryRepository.AddTraces(new AddContext(), + await telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans @@ -854,7 +854,7 @@ public void ExpandAllSpans_ExpandsAllSpans() var menuButton = cut.FindComponent(); var collapseAllMenuItem = menuButton.Instance.Items.FirstOrDefault(item => item.Text == "Collapse all"); // Locate by text since ID was removed Assert.NotNull(collapseAllMenuItem); - cut.InvokeAsync(() => collapseAllMenuItem!.OnClick?.Invoke() ?? Task.CompletedTask); + await cut.InvokeAsync(() => collapseAllMenuItem!.OnClick?.Invoke() ?? Task.CompletedTask); // Wait for spans to collapse cut.WaitForAssertion(() => @@ -867,7 +867,7 @@ public void ExpandAllSpans_ExpandsAllSpans() // Act - Click "Expand All" var expandAllMenuItem = menuButton.Instance.Items.FirstOrDefault(item => item.Text == "Expand all"); // Locate by text since ID was removed Assert.NotNull(expandAllMenuItem); - cut.InvokeAsync(() => expandAllMenuItem!.OnClick?.Invoke() ?? Task.CompletedTask); + await cut.InvokeAsync(() => expandAllMenuItem!.OnClick?.Invoke() ?? Task.CompletedTask); // Assert cut.WaitForAssertion(() => diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs index 8c0224d5e22..0f5309401a6 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs @@ -58,7 +58,7 @@ public void Render_FocusesAccessibleScrollContainerOnInitialRender() [Theory] [InlineData(false, 1)] [InlineData(true, 0)] - public void Render_AtTraceLimit_LimitMessageOnlyDisplayedForLiveRun(bool isReadOnly, int expectedMessageCount) + public async Task Render_AtTraceLimit_LimitMessageOnlyDisplayedForLiveRun(bool isReadOnly, int expectedMessageCount) { var messageCount = 0; var messageService = new TestMessageService(_ => @@ -75,7 +75,7 @@ public void Render_AtTraceLimit_LimitMessageOnlyDisplayedForLiveRun(bool isReadO })); var timestamp = DateTime.UnixEpoch; - FluentUISetupHelpers.ConfigureTelemetryRepository(this, isReadOnly, telemetryRepository => telemetryRepository.AddTraces(new AddContext(), new RepeatedField + await FluentUISetupHelpers.ConfigureTelemetryRepository(this, isReadOnly, telemetryRepository => telemetryRepository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index 6020090a769..6d8b543ba93 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -147,12 +147,20 @@ public static void SetupFluentCombobox(TestContext context) comboboxModule.SetupVoid("setControlAttribute", _ => true); } - public static void ConfigureTelemetryRepository( + public static async Task ConfigureTelemetryRepository( TestContext context, bool readOnly, - Action seed) + Func seed) { - context.Services.AddSingleton(new TelemetryRepositoryConfiguration(readOnly, seed)); + context.Services.AddSingleton(new TelemetryRepositoryConfiguration(readOnly)); + + var databasePath = Path.Combine(context.Services.GetRequiredService().Path, "dashboard.db"); + var loggerFactory = context.Services.GetRequiredService(); + var options = context.Services.GetRequiredService>(); + var outgoingPeerResolvers = context.Services.GetServices(); + + using var writer = new SqliteTelemetryRepository(databasePath, loggerFactory, options, new PauseManager(), outgoingPeerResolvers); + await seed(writer); } public static void AddCommonDashboardServices( @@ -177,12 +185,6 @@ public static void AddCommonDashboardServices( var outgoingPeerResolvers = services.GetServices(); var configuration = services.GetService(); - if (configuration is not null) - { - using var writer = new SqliteTelemetryRepository(databasePath, loggerFactory, options, new PauseManager(), outgoingPeerResolvers); - configuration.Seed(writer); - } - return new SqliteTelemetryRepository( databasePath, loggerFactory, @@ -307,5 +309,5 @@ public static IRenderedFragment RenderDialogProvider(TestContext context) }); } - private sealed record TelemetryRepositoryConfiguration(bool ReadOnly, Action Seed); + private sealed record TelemetryRepositoryConfiguration(bool ReadOnly); } diff --git a/tests/Aspire.Dashboard.Tests/Markdown/MarkdownProcessorTests.cs b/tests/Aspire.Dashboard.Tests/Markdown/MarkdownProcessorTests.cs index 23c74bb0413..36c1e65c3e8 100644 --- a/tests/Aspire.Dashboard.Tests/Markdown/MarkdownProcessorTests.cs +++ b/tests/Aspire.Dashboard.Tests/Markdown/MarkdownProcessorTests.cs @@ -151,21 +151,24 @@ In code block. } [Fact] - public void ToHtml_FencedCodeBlock_CopyButtonHasLocalizedAccessibleName() + public void ToHtml_FencedCodeBlockLanguageWithHtml_HtmlEncoded() { + // Arrange var processor = CreateMarkdownProcessor(); var markdown = """ - ```csharp - Console.WriteLine("Hello"); + ```
+ In code block. ``` """; + // Act var html = processor.ToHtml(markdown, inCompleteDocument: true); - var copyButton = Regex.Match(html, """]* (?aria-label="[^"]+")[^>]*>"""); - Assert.Equal("aria-label=\"Localized:GridValueCopyToClipboard\"", copyButton.Groups["accessibleName"].Value); + // Assert + var title = Regex.Match(html, "
(.*?)
").Groups[1].Value; + Assert.Equal("</div><svg/onload=alert(1)>", title); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index 48f5f67171d..0194b638681 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -750,22 +750,26 @@ private sealed class RecordingResourceRepositoryWriter : IResourceRepositoryWrit public List<(string ResourceName, IReadOnlyList LogLines)> ConsoleLogs { get; } = []; public List LoadedConsoleLogs { get; } = []; - public void ReplaceResources(IReadOnlyList resources) + public Task ReplaceResourcesAsync(IReadOnlyList resources) { + return Task.CompletedTask; } - public void ApplyChanges(IReadOnlyList changes) + public Task ApplyChangesAsync(IReadOnlyList changes) { + return Task.CompletedTask; } - public void MarkConsoleLogsLoaded(string resourceName) + public Task MarkConsoleLogsLoadedAsync(string resourceName) { LoadedConsoleLogs.Add(resourceName); + return Task.CompletedTask; } - public void AddConsoleLogs(string resourceName, IReadOnlyList logLines) + public Task AddConsoleLogsAsync(string resourceName, IReadOnlyList logLines) { ConsoleLogs.Add((resourceName, logLines)); + return Task.CompletedTask; } } diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index f147cc23bd5..36542a7a9db 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -590,7 +590,7 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() { historicalRunId = historicalRunStore.RunId; using var telemetryRepository = CreateTelemetryRepository(historicalRunStore.DatabasePath, options); - telemetryRepository.AddLogs(new AddContext(), new RepeatedField + await telemetryRepository.AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -606,7 +606,7 @@ public async Task SelectedHistoricalRun_ReplaysDataAndRejectsMutation() } }); using var resourceRepository = CreateResourceRepository(historicalRunStore.DatabasePath); - ((IResourceRepositoryWriter)resourceRepository).ReplaceResources([new Resource + await ((IResourceRepositoryWriter)resourceRepository).ReplaceResourcesAsync([new Resource { Name = "api", DisplayName = "API", diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs index 1be1ed44f7b..00f9bde5a69 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardSqliteDatabaseTests.cs @@ -4,10 +4,15 @@ using System.Collections.Concurrent; using System.Data.Common; using System.Diagnostics; +using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Model; using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Otlp.Storage; using Aspire.Tests; using Dapper; using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Xunit; namespace Aspire.Dashboard.Tests.Model; @@ -17,17 +22,47 @@ public sealed class DashboardSqliteDatabaseTests(ITestOutputHelper testOutputHel private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(testOutputHelper); [Fact] - public void InitializeSchema_HistogramCountsUseIntegerStorage() + public void InitializeSchema_HistogramValuesUseBlobStorage() { using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false); database.InitializeSchema(); using var connection = database.OpenConnection(); var histogramCountColumnType = connection.QuerySingle("SELECT type FROM pragma_table_info('telemetry_metric_points') WHERE name = 'histogram_count';"); - var bucketCountColumnType = connection.QuerySingle("SELECT type FROM pragma_table_info('telemetry_metric_histogram_bucket_counts') WHERE name = 'bucket_count';"); + var histogramColumnTypes = connection.Query<(string Name, string Type)>("SELECT name, type FROM pragma_table_info('telemetry_metric_points') WHERE name IN ('bucket_counts', 'explicit_bounds') ORDER BY name;"); Assert.Equal("INTEGER", histogramCountColumnType); - Assert.Equal("INTEGER", bucketCountColumnType); + Assert.Collection( + histogramColumnTypes, + column => Assert.Equal(("bucket_counts", "BLOB"), column), + column => Assert.Equal(("explicit_bounds", "BLOB"), column)); + Assert.Equal(0, connection.QuerySingle("SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name = 'telemetry_metric_histograms';")); + } + + [Fact] + public async Task RepositoryWrites_ShareDatabaseWriteLock() + { + using var database = new DashboardSqliteDatabase(Path.Combine(_workspace.Path, "dashboard.db"), pooling: false); + using var telemetryRepository = new SqliteTelemetryRepository( + database, + NullLoggerFactory.Instance, + Options.Create(new DashboardOptions()), + new PauseManager(), + []); + using var resourceRepository = new SqliteResourceRepository(database, new MockKnownPropertyLookup(), NullLoggerFactory.Instance); + + Task telemetryWriteTask; + Task resourceWriteTask; + using (await database.WriteLock.LockAsync()) + { + telemetryWriteTask = ((ITelemetryRepositoryWriter)telemetryRepository).ClearMetricsAsync(); + resourceWriteTask = ((IResourceRepositoryWriter)resourceRepository).ReplaceResourcesAsync([]); + + Assert.False(telemetryWriteTask.IsCompleted); + Assert.False(resourceWriteTask.IsCompleted); + } + + await Task.WhenAll(telemetryWriteTask, resourceWriteTask); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Model/GenAIVisualizerDialogViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/GenAIVisualizerDialogViewModelTests.cs index 0305f7f65e0..3acaaf37869 100644 --- a/tests/Aspire.Dashboard.Tests/Model/GenAIVisualizerDialogViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/GenAIVisualizerDialogViewModelTests.cs @@ -21,13 +21,13 @@ public sealed class GenAIVisualizerDialogViewModelTests private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); [Fact] - public void Create_NoGenAIAttributes_NoMessages() + public async Task Create_NoGenAIAttributes_NoMessages() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -61,7 +61,7 @@ public void Create_NoGenAIAttributes_NoMessages() } [Fact] - public void Create_SpanError_HasErrorItem() + public async Task Create_SpanError_HasErrorItem() { // Arrange var repository = CreateRepository(); @@ -77,7 +77,7 @@ public void Create_SpanError_HasErrorItem() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -119,13 +119,13 @@ public void Create_SpanError_HasErrorItem() } [Fact] - public void Create_GenAILogEntries_HasMessages() + public async Task Create_GenAILogEntries_HasMessages() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -144,7 +144,7 @@ public void Create_GenAILogEntries_HasMessages() } }); Assert.Equal(0, addContext.FailureCount); - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -216,13 +216,13 @@ public void Create_GenAILogEntries_HasMessages() } [Fact] - public void Create_GenAILogEntries_EmptyMessage_NoMessagesCreated() + public async Task Create_GenAILogEntries_EmptyMessage_NoMessagesCreated() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -241,7 +241,7 @@ public void Create_GenAILogEntries_EmptyMessage_NoMessagesCreated() } }); Assert.Equal(0, addContext.FailureCount); - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -284,7 +284,7 @@ public void Create_GenAILogEntries_EmptyMessage_NoMessagesCreated() } [Fact] - public void Create_GenAISpanEvents_HasMessages() + public async Task Create_GenAISpanEvents_HasMessages() { // Arrange var repository = CreateRepository(); @@ -321,7 +321,7 @@ public void Create_GenAISpanEvents_HasMessages() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -376,7 +376,7 @@ public void Create_GenAISpanEvents_HasMessages() } [Fact] - public void Create_GenAISpanEvents_EmptyContent_NoMessagesCreated() + public async Task Create_GenAISpanEvents_EmptyContent_NoMessagesCreated() { // Arrange var repository = CreateRepository(); @@ -399,7 +399,7 @@ public void Create_GenAISpanEvents_EmptyContent_NoMessagesCreated() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -431,7 +431,7 @@ public void Create_GenAISpanEvents_EmptyContent_NoMessagesCreated() } [Fact] - public void Create_GenAISpanEvents_MissingContent_NoMessagesCreated() + public async Task Create_GenAISpanEvents_MissingContent_NoMessagesCreated() { // Arrange var repository = CreateRepository(); @@ -448,7 +448,7 @@ public void Create_GenAISpanEvents_MissingContent_NoMessagesCreated() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -480,7 +480,7 @@ public void Create_GenAISpanEvents_MissingContent_NoMessagesCreated() } [Fact] - public void Create_GenAISpanAttributes_HasMessages() + public async Task Create_GenAISpanAttributes_HasMessages() { // Arrange var repository = CreateRepository(); @@ -528,7 +528,7 @@ public void Create_GenAISpanAttributes_HasMessages() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -603,7 +603,7 @@ public void Create_GenAISpanAttributes_HasMessages() } [Fact] - public void Create_GenAISpanAttributes_InvalidJson_DisplayErrorMessage() + public async Task Create_GenAISpanAttributes_InvalidJson_DisplayErrorMessage() { // Arrange var repository = CreateRepository(); @@ -644,7 +644,7 @@ public void Create_GenAISpanAttributes_InvalidJson_DisplayErrorMessage() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -676,7 +676,7 @@ public void Create_GenAISpanAttributes_InvalidJson_DisplayErrorMessage() } [Fact] - public void Create_GenAISpanAttributesWithoutContent_HasNoMessageContent() + public async Task Create_GenAISpanAttributesWithoutContent_HasNoMessageContent() { // Arrange var repository = CreateRepository(); @@ -724,7 +724,7 @@ public void Create_GenAISpanAttributesWithoutContent_HasNoMessageContent() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -796,7 +796,7 @@ public void Create_GenAISpanAttributesWithoutContent_HasNoMessageContent() } [Fact] - public void Create_NoMessages_HasNoMessageContent() + public async Task Create_NoMessages_HasNoMessageContent() { // Arrange var repository = CreateRepository(); @@ -808,7 +808,7 @@ public void Create_NoMessages_HasNoMessageContent() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -840,7 +840,7 @@ public void Create_NoMessages_HasNoMessageContent() } [Fact] - public void Create_LangSmithFormat_HasMessages() + public async Task Create_LangSmithFormat_HasMessages() { // Arrange var repository = CreateRepository(); @@ -861,7 +861,7 @@ public void Create_LangSmithFormat_HasMessages() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -916,7 +916,7 @@ public void Create_LangSmithFormat_HasMessages() } [Fact] - public void Create_LangSmithFormat_MessageRoleContentFallback_HasMessages() + public async Task Create_LangSmithFormat_MessageRoleContentFallback_HasMessages() { // Arrange var repository = CreateRepository(); @@ -937,7 +937,7 @@ public void Create_LangSmithFormat_MessageRoleContentFallback_HasMessages() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -992,7 +992,7 @@ public void Create_LangSmithFormat_MessageRoleContentFallback_HasMessages() } [Fact] - public void Create_LangSmithFormat_WithGapsInIndices_HasMessages() + public async Task Create_LangSmithFormat_WithGapsInIndices_HasMessages() { // Arrange var repository = CreateRepository(); @@ -1020,7 +1020,7 @@ public void Create_LangSmithFormat_WithGapsInIndices_HasMessages() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1090,7 +1090,7 @@ public void Create_LangSmithFormat_WithGapsInIndices_HasMessages() } [Fact] - public void Create_GenAIToolDefinitions_ParsesToolDefinitions() + public async Task Create_GenAIToolDefinitions_ParsesToolDefinitions() { // Arrange var repository = CreateRepository(); @@ -1127,7 +1127,7 @@ public void Create_GenAIToolDefinitions_ParsesToolDefinitions() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1186,7 +1186,7 @@ public void Create_GenAIToolDefinitions_ParsesToolDefinitions() } [Fact] - public void Create_GenAIToolDefinitions_InvalidJson_EmptyToolDefinitions() + public async Task Create_GenAIToolDefinitions_InvalidJson_EmptyToolDefinitions() { // Arrange var repository = CreateRepository(); @@ -1198,7 +1198,7 @@ public void Create_GenAIToolDefinitions_InvalidJson_EmptyToolDefinitions() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1229,7 +1229,7 @@ public void Create_GenAIToolDefinitions_InvalidJson_EmptyToolDefinitions() } [Fact] - public void Create_NoToolDefinitions_EmptyToolDefinitions() + public async Task Create_NoToolDefinitions_EmptyToolDefinitions() { // Arrange var repository = CreateRepository(); @@ -1240,7 +1240,7 @@ public void Create_NoToolDefinitions_EmptyToolDefinitions() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1271,7 +1271,7 @@ public void Create_NoToolDefinitions_EmptyToolDefinitions() } [Fact] - public void Create_GenAISpanAttributes_JsonWithCommentsAndTrailingCommas_HasMessages() + public async Task Create_GenAISpanAttributes_JsonWithCommentsAndTrailingCommas_HasMessages() { // Arrange var repository = CreateRepository(); @@ -1327,7 +1327,7 @@ public void Create_GenAISpanAttributes_JsonWithCommentsAndTrailingCommas_HasMess }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1377,7 +1377,7 @@ public void Create_GenAISpanAttributes_JsonWithCommentsAndTrailingCommas_HasMess } [Fact] - public void Create_GenAIToolDefinitions_JsonWithCommentsAndTrailingCommas_HasToolDefinitions() + public async Task Create_GenAIToolDefinitions_JsonWithCommentsAndTrailingCommas_HasToolDefinitions() { // Arrange var repository = CreateRepository(); @@ -1416,7 +1416,7 @@ public void Create_GenAIToolDefinitions_JsonWithCommentsAndTrailingCommas_HasToo }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1449,7 +1449,7 @@ public void Create_GenAIToolDefinitions_JsonWithCommentsAndTrailingCommas_HasToo } [Fact] - public void Create_GenAIToolDefinitions_TypeAsArray_ParsesToolDefinitions() + public async Task Create_GenAIToolDefinitions_TypeAsArray_ParsesToolDefinitions() { // Arrange var repository = CreateRepository(); @@ -1489,7 +1489,7 @@ public void Create_GenAIToolDefinitions_TypeAsArray_ParsesToolDefinitions() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1550,7 +1550,7 @@ public void Create_GenAIToolDefinitions_TypeAsArray_ParsesToolDefinitions() } [Fact] - public void Create_GenAIToolDefinitions_WithArrayItems_ParsesAndFormatsCorrectly() + public async Task Create_GenAIToolDefinitions_WithArrayItems_ParsesAndFormatsCorrectly() { // Arrange var repository = CreateRepository(); @@ -1597,7 +1597,7 @@ public void Create_GenAIToolDefinitions_WithArrayItems_ParsesAndFormatsCorrectly }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1669,13 +1669,13 @@ private static GenAIVisualizerDialogViewModel Create( } [Fact] - public void Create_NoEvaluationResults_EmptyEvaluationsList() + public async Task Create_NoEvaluationResults_EmptyEvaluationsList() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1706,13 +1706,13 @@ public void Create_NoEvaluationResults_EmptyEvaluationsList() } [Fact] - public void Create_EvaluationResultsInLogEntries_ParsedCorrectly() + public async Task Create_EvaluationResultsInLogEntries_ParsedCorrectly() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1731,7 +1731,7 @@ public void Create_EvaluationResultsInLogEntries_ParsedCorrectly() } }); - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1804,7 +1804,7 @@ public void Create_EvaluationResultsInLogEntries_ParsedCorrectly() } [Fact] - public void Create_EvaluationResultsInSpanEvents_ParsedCorrectly() + public async Task Create_EvaluationResultsInSpanEvents_ParsedCorrectly() { // Arrange var repository = CreateRepository(); @@ -1833,7 +1833,7 @@ public void Create_EvaluationResultsInSpanEvents_ParsedCorrectly() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1887,13 +1887,13 @@ public void Create_EvaluationResultsInSpanEvents_ParsedCorrectly() } [Fact] - public void Create_EvaluationResultsMinimalData_ParsedCorrectly() + public async Task Create_EvaluationResultsMinimalData_ParsedCorrectly() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1912,7 +1912,7 @@ public void Create_EvaluationResultsMinimalData_ParsedCorrectly() } }); - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1957,7 +1957,7 @@ public void Create_EvaluationResultsMinimalData_ParsedCorrectly() } [Fact] - public void Create_EvaluationResultsFromBothLogEntriesAndSpanEvents_AllParsed() + public async Task Create_EvaluationResultsFromBothLogEntriesAndSpanEvents_AllParsed() { // Arrange var repository = CreateRepository(); @@ -1975,7 +1975,7 @@ public void Create_EvaluationResultsFromBothLogEntriesAndSpanEvents_AllParsed() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1999,7 +1999,7 @@ public void Create_EvaluationResultsFromBothLogEntriesAndSpanEvents_AllParsed() } }); - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -2041,7 +2041,7 @@ public void Create_EvaluationResultsFromBothLogEntriesAndSpanEvents_AllParsed() } [Fact] - public void Create_GenAIToolDefinitions_UnexpectedTypeObject_DoesNotThrow() + public async Task Create_GenAIToolDefinitions_UnexpectedTypeObject_DoesNotThrow() { // Arrange var repository = CreateRepository(); @@ -2080,7 +2080,7 @@ public void Create_GenAIToolDefinitions_UnexpectedTypeObject_DoesNotThrow() }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -2131,7 +2131,7 @@ public void Create_GenAIToolDefinitions_UnexpectedTypeObject_DoesNotThrow() } [Fact] - public void Create_GenAISpanAttributes_TruncatedInputMessages_DisplaysAvailableMessages() + public async Task Create_GenAISpanAttributes_TruncatedInputMessages_DisplaysAvailableMessages() { // Arrange var repository = CreateRepository(); @@ -2166,7 +2166,7 @@ public void Create_GenAISpanAttributes_TruncatedInputMessages_DisplaysAvailableM }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -2216,7 +2216,7 @@ public void Create_GenAISpanAttributes_TruncatedInputMessages_DisplaysAvailableM } [Fact] - public void Create_GenAISpanAttributes_TruncatedSystemInstructions_DisplaysPartialContent() + public async Task Create_GenAISpanAttributes_TruncatedSystemInstructions_DisplaysPartialContent() { // Arrange var repository = CreateRepository(); @@ -2238,7 +2238,7 @@ public void Create_GenAISpanAttributes_TruncatedSystemInstructions_DisplaysParti }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -2277,7 +2277,7 @@ public void Create_GenAISpanAttributes_TruncatedSystemInstructions_DisplaysParti } [Fact] - public void Create_GenAISpanAttributes_TruncatedOutputMessages_DisplaysAvailableMessages() + public async Task Create_GenAISpanAttributes_TruncatedOutputMessages_DisplaysAvailableMessages() { // Arrange var repository = CreateRepository(); @@ -2307,7 +2307,7 @@ public void Create_GenAISpanAttributes_TruncatedOutputMessages_DisplaysAvailable }; var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { diff --git a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs index 2345a4c485e..b319756131a 100644 --- a/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/ResourceMenuBuilderTests.cs @@ -80,14 +80,14 @@ public void AddMenuItems_NoTelemetry_NoTelemetryItems() } [Fact] - public void AddMenuItems_UninstrumentedPeer_TraceItem() + public async Task AddMenuItems_UninstrumentedPeer_TraceItem() { // Arrange var resource = ModelTestHelpers.CreateResource(resourceName: "test-abc"); var outgoingPeerResolver = new TestOutgoingPeerResolver(onResolve: attributes => (resource.Name, resource)); var repository = TelemetryTestHelpers.CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -132,13 +132,13 @@ public void AddMenuItems_UninstrumentedPeer_TraceItem() } [Fact] - public void AddMenuItems_HasTelemetry_TelemetryItems() + public async Task AddMenuItems_HasTelemetry_TelemetryItems() { // Arrange var resource = ModelTestHelpers.CreateResource(resourceName: "test-abc"); var repository = TelemetryTestHelpers.CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { diff --git a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs index a0ee7144cf8..a19bf1ce639 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SqliteResourceRepositoryTests.cs @@ -14,7 +14,7 @@ namespace Aspire.Dashboard.Tests.Model; public sealed class SqliteResourceRepositoryTests(ITestOutputHelper testOutputHelper) { [Fact] - public void Resources_PersistAndReplayWithEquivalentValues() + public async Task Resources_PersistAndReplayWithEquivalentValues() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var resource = CreateResource("api-123", "api"); @@ -22,13 +22,13 @@ public void Resources_PersistAndReplayWithEquivalentValues() using (var repository = CreateRepository(workspace.Path)) { var writer = (IResourceRepositoryWriter)repository; - writer.ReplaceResources([resource]); + await writer.ReplaceResourcesAsync([resource]); AssertResource(Assert.Single(repository.GetResources()), resource, replicaIndex: 1); var updated = resource.Clone(); updated.State = "Running"; - writer.ApplyChanges([new WatchResourcesChange { Upsert = updated }]); + await writer.ApplyChangesAsync([new WatchResourcesChange { Upsert = updated }]); Assert.Equal("Running", repository.GetResource(resource.Name)!.State); } @@ -49,11 +49,11 @@ public async Task ResourceSubscription_ReceivesUpsertAndDelete() await using var enumerator = subscription.Subscription.GetAsyncEnumerator(cts.Token); var resource = CreateResource("worker", "worker"); - writer.ApplyChanges([new WatchResourcesChange { Upsert = resource }]); + await writer.ApplyChangesAsync([new WatchResourcesChange { Upsert = resource }]); Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); Assert.Equal(ResourceViewModelChangeType.Upsert, Assert.Single(enumerator.Current).ChangeType); - writer.ApplyChanges([new WatchResourcesChange { Delete = new ResourceDeletion { ResourceName = resource.Name } }]); + await writer.ApplyChangesAsync([new WatchResourcesChange { Delete = new ResourceDeletion { ResourceName = resource.Name } }]); Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); Assert.Equal(ResourceViewModelChangeType.Delete, Assert.Single(enumerator.Current).ChangeType); Assert.Empty(repository.GetResources()); @@ -65,14 +65,14 @@ public async Task ResourceSubscription_ReplaceResourcesDeletesOmittedResources() using var workspace = TemporaryWorkspace.Create(testOutputHelper); using var repository = CreateRepository(workspace.Path); var writer = (IResourceRepositoryWriter)repository; - writer.ReplaceResources([CreateResource("api", "api"), CreateResource("worker", "worker")]); + await writer.ReplaceResourcesAsync([CreateResource("api", "api"), CreateResource("worker", "worker")]); var subscription = await repository.SubscribeResourcesAsync(CancellationToken.None); Assert.Equal(2, subscription.InitialState.Length); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await using var enumerator = subscription.Subscription.GetAsyncEnumerator(cts.Token); - writer.ReplaceResources([CreateResource("api", "api")]); + await writer.ReplaceResourcesAsync([CreateResource("api", "api")]); Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout()); Assert.Collection( @@ -96,11 +96,11 @@ public async Task ConsoleLogs_UseInsertionOrderAndAllowLineNumbersToRestart() using (var repository = CreateRepository(workspace.Path)) { var writer = (IResourceRepositoryWriter)repository; - writer.AddConsoleLogs("api", [ + await writer.AddConsoleLogsAsync("api", [ new ConsoleLogLine { LineNumber = 2, Text = "second", IsStdErr = true }, new ConsoleLogLine { LineNumber = 1, Text = "first" } ]); - writer.AddConsoleLogs("api", [ + await writer.AddConsoleLogsAsync("api", [ new ConsoleLogLine { LineNumber = 2, Text = "second-updated", IsStdErr = true }, new ConsoleLogLine { LineNumber = 3, Text = "third" } ]); @@ -108,7 +108,7 @@ public async Task ConsoleLogs_UseInsertionOrderAndAllowLineNumbersToRestart() using (var restartedRepository = CreateRepository(workspace.Path)) { - ((IResourceRepositoryWriter)restartedRepository).AddConsoleLogs( + await ((IResourceRepositoryWriter)restartedRepository).AddConsoleLogsAsync( "api", [new ConsoleLogLine { LineNumber = 1, Text = "first-after-restart" }]); } @@ -137,7 +137,7 @@ public async Task ConsoleLogs_LargeBatchRoundTrips() using (var repository = CreateRepository(workspace.Path)) { - ((IResourceRepositoryWriter)repository).AddConsoleLogs("api", logLines); + await ((IResourceRepositoryWriter)repository).AddConsoleLogsAsync("api", logLines); } using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); @@ -152,7 +152,7 @@ public async Task ConsoleLogs_LargeBatchRoundTrips() } [Fact] - public void Resources_LargeBatchRoundTrips() + public async Task Resources_LargeBatchRoundTrips() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var resources = Enumerable.Range(1, 201) @@ -161,7 +161,7 @@ public void Resources_LargeBatchRoundTrips() using (var repository = CreateRepository(workspace.Path)) { - ((IResourceRepositoryWriter)repository).ReplaceResources(resources); + await ((IResourceRepositoryWriter)repository).ReplaceResourcesAsync(resources); } using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); @@ -175,25 +175,29 @@ public void Resources_LargeBatchRoundTrips() } [Fact] - public void ConsoleLogsLoaded_PersistsWithoutLogLines() + public async Task ConsoleLogsLoaded_PersistsWithoutLogLines() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); using (var repository = CreateRepository(workspace.Path)) { var writer = (IResourceRepositoryWriter)repository; - writer.ReplaceResources([CreateResource("api", "api"), CreateResource("worker", "worker")]); + await writer.ReplaceResourcesAsync([CreateResource("api", "api"), CreateResource("worker", "worker")]); Assert.False(repository.GetResource("api")!.ConsoleLogsLoaded); - writer.MarkConsoleLogsLoaded("api"); + await writer.MarkConsoleLogsLoadedAsync("api"); - var readQueries = CaptureSqlQueries(() => Assert.True(repository.GetResource("api")!.ConsoleLogsLoaded)); + var readQueries = await CaptureSqlQueriesAsync(() => + { + Assert.True(repository.GetResource("api")!.ConsoleLogsLoaded); + return Task.CompletedTask; + }); Assert.Empty(readQueries); Assert.False(repository.GetResource("worker")!.ConsoleLogsLoaded); - writer.ApplyChanges([new WatchResourcesChange { Upsert = CreateResource("api", "api") }]); + await writer.ApplyChangesAsync([new WatchResourcesChange { Upsert = CreateResource("api", "api") }]); Assert.True(repository.GetResource("api")!.ConsoleLogsLoaded); - writer.ReplaceResources([CreateResource("api", "api"), CreateResource("worker", "worker")]); + await writer.ReplaceResourcesAsync([CreateResource("api", "api"), CreateResource("worker", "worker")]); Assert.True(repository.GetResource("api")!.ConsoleLogsLoaded); } @@ -203,7 +207,7 @@ public void ConsoleLogsLoaded_PersistsWithoutLogLines() } [Fact] - public void Resources_AllFieldsAndRecursiveValuesRoundTrip() + public async Task Resources_AllFieldsAndRecursiveValuesRoundTrip() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var nestedValue = new Value @@ -312,7 +316,7 @@ public void Resources_AllFieldsAndRecursiveValuesRoundTrip() using (var repository = CreateRepository(workspace.Path)) { - ((IResourceRepositoryWriter)repository).ReplaceResources([resource]); + await ((IResourceRepositoryWriter)repository).ReplaceResourcesAsync([resource]); } using (var connection = new SqliteConnection($"Data Source={GetDatabasePath(workspace.Path)};Mode=ReadOnly;Pooling=False")) @@ -371,7 +375,7 @@ FROM dashboard_resource_commands } [Fact] - public void Resources_DuplicateEndpointUrlsRoundTrip() + public async Task Resources_DuplicateEndpointUrlsRoundTrip() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var resource = CreateResource("frontend-cqgvshvm", "frontend"); @@ -386,7 +390,7 @@ public void Resources_DuplicateEndpointUrlsRoundTrip() using (var repository = CreateRepository(workspace.Path)) { - ((IResourceRepositoryWriter)repository).ReplaceResources([resource]); + await ((IResourceRepositoryWriter)repository).ReplaceResourcesAsync([resource]); } using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); @@ -408,7 +412,7 @@ static void AssertUrl(global::Aspire.Dashboard.Model.UrlViewModel actual, string } [Fact] - public void Resources_BulkLoadKeepsChildRecordsIsolated() + public async Task Resources_BulkLoadKeepsChildRecordsIsolated() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var resources = new[] @@ -419,7 +423,7 @@ public void Resources_BulkLoadKeepsChildRecordsIsolated() using (var repository = CreateRepository(workspace.Path)) { - ((IResourceRepositoryWriter)repository).ReplaceResources(resources); + await ((IResourceRepositoryWriter)repository).ReplaceResourcesAsync(resources); } using var historicalRepository = CreateRepository(workspace.Path, readOnly: true); @@ -430,19 +434,19 @@ public void Resources_BulkLoadKeepsChildRecordsIsolated() } [Fact] - public void Resources_MultipleResourcesArePersistedWithBatchedQueries() + public async Task Resources_MultipleResourcesArePersistedWithBatchedQueries() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); using var repository = CreateRepository(workspace.Path); var writer = (IResourceRepositoryWriter)repository; - var replaceQueries = CaptureSqlQueries(() => writer.ReplaceResources([ + var replaceQueries = await CaptureSqlQueriesAsync(() => writer.ReplaceResourcesAsync([ CreateResourceWithChildren("api", "API", "api-value"), CreateResourceWithChildren("worker", "Worker", "worker-value") ])); AssertBatchedResourceQueries(replaceQueries); - var applyQueries = CaptureSqlQueries(() => writer.ApplyChanges([ + var applyQueries = await CaptureSqlQueriesAsync(() => writer.ApplyChangesAsync([ new WatchResourcesChange { Upsert = CreateResourceWithChildren("api", "API", "api-updated") }, new WatchResourcesChange { Upsert = CreateResourceWithChildren("worker", "Worker", "worker-updated") } ])); @@ -482,7 +486,7 @@ FROM pragma_table_info('dashboard_resources') } [Fact] - public void Values_AreStoredOnOwnerRowsAsValidatedJson() + public async Task Values_AreStoredOnOwnerRowsAsValidatedJson() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var databasePath = GetDatabasePath(workspace.Path); @@ -516,7 +520,7 @@ public void Values_AreStoredOnOwnerRowsAsValidatedJson() using (var repository = CreateRepository(workspace.Path)) { - ((IResourceRepositoryWriter)repository).ReplaceResources([resource]); + await ((IResourceRepositoryWriter)repository).ReplaceResourcesAsync([resource]); } using var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False"); @@ -801,7 +805,7 @@ private static Resource CreateResourceWithChildren(string name, string displayNa return resource; } - private static IReadOnlyList CaptureSqlQueries(Action action) + private static async Task> CaptureSqlQueriesAsync(Func action) { var queries = new List(); using var operation = new Activity("Capture resource persistence queries").Start(); @@ -819,7 +823,7 @@ private static IReadOnlyList CaptureSqlQueries(Action action) }; ActivitySource.AddActivityListener(listener); - action(); + await action(); return queries; } diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs index 0d1b54e4056..e8dfd2ff345 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryExportServiceTests.cs @@ -28,12 +28,12 @@ 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() + public async Task ConvertLogsToOtlpJson_SingleLog_ReturnsCorrectStructure() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -95,12 +95,12 @@ public void ConvertLogsToOtlpJson_SingleLog_ReturnsCorrectStructure() } [Fact] - public void ConvertLogsToOtlpJson_AddsAspireLogIdAttribute() + public async Task ConvertLogsToOtlpJson_AddsAspireLogIdAttribute() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -133,12 +133,12 @@ public void ConvertLogsToOtlpJson_AddsAspireLogIdAttribute() } [Fact] - public void ConvertLogsToOtlpJson_MultipleLogs_GroupsByScope() + public async Task ConvertLogsToOtlpJson_MultipleLogs_GroupsByScope() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -214,12 +214,12 @@ public void ConvertLogsToOtlpJson_MultipleLogs_GroupsByScope() [InlineData(SeverityNumber.Fatal2, "Critical")] [InlineData(SeverityNumber.Fatal3, "Critical")] [InlineData(SeverityNumber.Fatal4, "Critical")] - public void ConvertLogsToOtlpJson_RoundTripsSeverityNumber(SeverityNumber inputSeverity, string expectedSeverityText) + public async Task ConvertLogsToOtlpJson_RoundTripsSeverityNumber(SeverityNumber inputSeverity, string expectedSeverityText) { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -251,12 +251,12 @@ public void ConvertLogsToOtlpJson_RoundTripsSeverityNumber(SeverityNumber inputS } [Fact] - public void ConvertTracesToOtlpJson_SingleTrace_ReturnsCorrectStructure() + public async Task ConvertTracesToOtlpJson_SingleTrace_ReturnsCorrectStructure() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -329,12 +329,12 @@ public void ConvertTracesToOtlpJson_SingleTrace_ReturnsCorrectStructure() } [Fact] - public void ConvertTracesToOtlpJson_SpanWithParent_IncludesParentSpanId() + public async Task ConvertTracesToOtlpJson_SpanWithParent_IncludesParentSpanId() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -372,12 +372,12 @@ public void ConvertTracesToOtlpJson_SpanWithParent_IncludesParentSpanId() } [Fact] - public void ConvertTracesToOtlpJson_WithPersistedPeer_AddsDestinationNameAttribute() + public async Task ConvertTracesToOtlpJson_WithPersistedPeer_AddsDestinationNameAttribute() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -421,12 +421,12 @@ public void ConvertTracesToOtlpJson_WithPersistedPeer_AddsDestinationNameAttribu } [Fact] - public void ConvertTracesToOtlpJson_WithoutPersistedPeer_AddsPeerAddressAttribute() + public async Task ConvertTracesToOtlpJson_WithoutPersistedPeer_AddsPeerAddressAttribute() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -503,12 +503,12 @@ public void ConvertTracesToOtlpJson_WithInstrumentedChild_AddsDestinationResourc } [Fact] - public void ConvertMetricsToOtlpJson_SingleInstrument_ReturnsCorrectStructure() + public async Task ConvertMetricsToOtlpJson_SingleInstrument_ReturnsCorrectStructure() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + await repository.AddMetricsAsync(addContext, new RepeatedField() { new OpenTelemetry.Proto.Metrics.V1.ResourceMetrics { @@ -580,12 +580,12 @@ public void ConvertMetricsToOtlpJson_SingleInstrument_ReturnsCorrectStructure() } [Fact] - public void ConvertMetricsToOtlpJson_MultipleInstruments_GroupsByScope() + public async Task ConvertMetricsToOtlpJson_MultipleInstruments_GroupsByScope() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddMetrics(addContext, new RepeatedField() + await repository.AddMetricsAsync(addContext, new RepeatedField() { new OpenTelemetry.Proto.Metrics.V1.ResourceMetrics { @@ -668,10 +668,10 @@ public async Task ExportSelectedAsync_ExportsOnlySelectedDataTypesForSpecificRes var exportService = await CreateExportServiceAsync(repository); // Add test data for three resources - AddTestData(repository, "resource1", "111"); - AddTestData(repository, "resource2", "222"); - AddTestData(repository, "resource3", "333"); - AddTestData(repository, "resource4", "444"); + await AddTestData(repository, "resource1", "111"); + await AddTestData(repository, "resource2", "222"); + await AddTestData(repository, "resource3", "333"); + await AddTestData(repository, "resource4", "444"); // Act - Export only structured logs for resource1, only traces for resource2, all types for resource3 var selectedResources = new Dictionary> @@ -729,7 +729,7 @@ public async Task ExportAllAsync_WhenDashboardClientDisabled_ExportsOnlyTelemetr var addContext = new AddContext(); // Add logs - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -771,7 +771,7 @@ public async Task ExportSelectedAsync_SkipsEmptyResources() var addContext = new AddContext(); // Add logs for only one resource - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -788,7 +788,7 @@ public async Task ExportSelectedAsync_SkipsEmptyResources() }); // Add traces for a different resource - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -833,7 +833,7 @@ public async Task ExportSelectedAsync_JapaneseCharactersInLogs_PreservesContent( const string japaneseAttributeValue = "日本語の属性値"; // "Japanese attribute value" const string japaneseEventName = "テストイベント"; // "Test event" - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -906,12 +906,12 @@ public async Task ExportSelectedAsync_JapaneseCharactersInLogs_PreservesContent( } [Fact] - public void ConvertSpanToJson_ReturnsValidOtlpTelemetryDataJson() + public async Task ConvertSpanToJson_ReturnsValidOtlpTelemetryDataJson() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -943,12 +943,12 @@ public void ConvertSpanToJson_ReturnsValidOtlpTelemetryDataJson() } [Fact] - public void ConvertSpanToJson_WithLogs_IncludesLogsInOutput() + public async Task ConvertSpanToJson_WithLogs_IncludesLogsInOutput() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -963,7 +963,7 @@ public void ConvertSpanToJson_WithLogs_IncludesLogsInOutput() } } }); - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -995,12 +995,12 @@ public void ConvertSpanToJson_WithLogs_IncludesLogsInOutput() } [Fact] - public void ConvertTraceToJson_WithLogs_IncludesLogsInOutput() + public async Task ConvertTraceToJson_WithLogs_IncludesLogsInOutput() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1019,7 +1019,7 @@ public void ConvertTraceToJson_WithLogs_IncludesLogsInOutput() } } }); - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1055,12 +1055,12 @@ public void ConvertTraceToJson_WithLogs_IncludesLogsInOutput() } [Fact] - public void ConvertTraceToJson_ReturnsValidOtlpTelemetryDataJson() + public async Task ConvertTraceToJson_ReturnsValidOtlpTelemetryDataJson() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddTraces(addContext, new RepeatedField() + await repository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1095,12 +1095,12 @@ public void ConvertTraceToJson_ReturnsValidOtlpTelemetryDataJson() } [Fact] - public void ConvertLogEntryToJson_ReturnsValidOtlpTelemetryDataJson() + public async Task ConvertLogEntryToJson_ReturnsValidOtlpTelemetryDataJson() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField() + await repository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1149,11 +1149,11 @@ private static Dictionary> BuildAllResourcesSele _ => new HashSet([AspireDataType.ConsoleLogs, AspireDataType.StructuredLogs, AspireDataType.Traces, AspireDataType.Metrics])); } - private static void AddTestData(InMemoryTelemetryRepository repository, string resourceName, string instanceId) + private static async Task AddTestData(InMemoryTelemetryRepository repository, string resourceName, string instanceId) { var compositeName = $"{resourceName}-{instanceId}"; - repository.AddLogs(new AddContext(), new RepeatedField() + await repository.AddLogsAsync(new AddContext(), new RepeatedField() { new ResourceLogs { @@ -1169,7 +1169,7 @@ private static void AddTestData(InMemoryTelemetryRepository repository, string r } }); - repository.AddTraces(new AddContext(), new RepeatedField() + await repository.AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -1188,7 +1188,7 @@ private static void AddTestData(InMemoryTelemetryRepository repository, string r } }); - repository.AddMetrics(new AddContext(), new RepeatedField() + await repository.AddMetricsAsync(new AddContext(), new RepeatedField() { new OpenTelemetry.Proto.Metrics.V1.ResourceMetrics { diff --git a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs index 9fd51676f62..acd03dce242 100644 --- a/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/TelemetryImportServiceTests.cs @@ -289,7 +289,7 @@ public async Task ImportAsync_RoundTrip_LogsExportAndImport_PreservesData() var sourceRepository = CreateRepository(); var addContext = new AddContext(); - sourceRepository.AddLogs(addContext, new RepeatedField() + await sourceRepository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -340,7 +340,7 @@ public async Task ImportAsync_RoundTrip_LogsWithAspireLogId_FiltersOutAttribute( var sourceRepository = CreateRepository(); var addContext = new AddContext(); - sourceRepository.AddLogs(addContext, new RepeatedField() + await sourceRepository.AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -401,7 +401,7 @@ public async Task ImportAsync_RoundTrip_TracesExportAndImport_PreservesData() var sourceRepository = CreateRepository(); var addContext = new AddContext(); - sourceRepository.AddTraces(addContext, new RepeatedField() + await sourceRepository.AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs index 2daaa940931..63040f45c89 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryApiServiceTests.cs @@ -23,7 +23,7 @@ public class TelemetryApiServiceTests public async Task FollowSpansAsync_StreamsAllSpans() { var repository = CreateRepository(subscriptionMinExecuteInterval: TimeSpan.Zero); - AddSpans(repository, count: 5); + await AddSpans(repository, count: 5); var service = CreateService(repository); @@ -44,7 +44,7 @@ public async Task FollowSpansAsync_StreamsAllSpans() public async Task FollowLogsAsync_StreamsAllLogs() { var repository = CreateRepository(subscriptionMinExecuteInterval: TimeSpan.Zero); - AddLogs(repository, ["log1", "log2", "log3", "log4", "log5"]); + await AddLogs(repository, ["log1", "log2", "log3", "log4", "log5"]); var service = CreateService(repository); @@ -65,10 +65,10 @@ public async Task FollowLogsAsync_StreamsAllLogs() [InlineData(false, 1)] [InlineData(true, 1)] [InlineData(null, 2)] - public void GetTraces_HasErrorFilter_ReturnsExpectedTraces(bool? hasError, int expectedCount) + public async Task GetTraces_HasErrorFilter_ReturnsExpectedTraces(bool? hasError, int expectedCount) { var repository = CreateRepository(); - AddTracesWithStatus(repository); + await AddTracesWithStatus(repository); var service = CreateService(repository); @@ -82,7 +82,7 @@ public void GetTraces_HasErrorFilter_ReturnsExpectedTraces(bool? hasError, int e public async Task FollowSpansAsync_WithInvalidResourceName_ReturnsNoSpans() { var repository = CreateRepository(); - AddSpans(repository, count: 1); + await AddSpans(repository, count: 1); var service = CreateService(repository); @@ -105,7 +105,7 @@ public async Task FollowSpansAsync_WithInvalidResourceName_ReturnsNoSpans() public async Task FollowLogsAsync_WithInvalidResourceName_ReturnsNoLogs() { var repository = CreateRepository(); - AddLogs(repository, ["log1"]); + await AddLogs(repository, ["log1"]); var service = CreateService(repository); @@ -129,12 +129,12 @@ public async Task FollowLogsAsync_WithInvalidResourceName_ReturnsNoLogs() [InlineData("7472616", true)] // shortened (7 char) prefix [InlineData("747261", false)] // too short [InlineData("nonexistent", false)] - public void GetTrace_VariousTraceIds_ReturnsExpectedResult(string lookupId, bool expectFound) + public async Task GetTrace_VariousTraceIds_ReturnsExpectedResult(string lookupId, bool expectFound) { var repository = CreateRepository(); var traceId = Encoding.UTF8.GetString(Convert.FromHexString("747261636531")); - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: traceId, spanId: "span1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)) ]); @@ -159,7 +159,7 @@ public async Task FollowSpansAsync_WithTraceIdFilter_MatchesShortenedIds() var repository = CreateRepository(); var traceId = Encoding.UTF8.GetString(Convert.FromHexString("747261636531")); - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: traceId, spanId: "matching-span", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)), CreateSpan(traceId: "other-trace", spanId: "other-span", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3)) ]); @@ -179,12 +179,12 @@ public async Task FollowSpansAsync_WithTraceIdFilter_MatchesShortenedIds() } [Fact] - public void GetTrace_ReturnsAllSpansForTrace() + public async Task GetTrace_ReturnsAllSpansForTrace() { var repository = CreateRepository(); var traceId = Encoding.UTF8.GetString(Convert.FromHexString("747261636531")); - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: traceId, spanId: "short-span", startTime: s_testTime, endTime: s_testTime.AddMilliseconds(49)), CreateSpan(traceId: traceId, spanId: "long-span", startTime: s_testTime.AddSeconds(1), endTime: s_testTime.AddSeconds(1).AddMilliseconds(50)) ]); @@ -199,10 +199,10 @@ public void GetTrace_ReturnsAllSpansForTrace() } [Fact] - public void GetTraces_WithLimit_ReturnsMostRecentTraces() + public async Task GetTraces_WithLimit_ReturnsMostRecentTraces() { var repository = CreateRepository(); - AddSpans(repository, count: 3, startMinuteSpacing: 10); + await AddSpans(repository, count: 3, startMinuteSpacing: 10); var service = CreateService(repository); @@ -219,10 +219,10 @@ public void GetTraces_WithLimit_ReturnsMostRecentTraces() } [Fact] - public void GetTraces_WithLimitAndDurationSearchFilter_ReturnsMostRecentMatchingTraces() + public async Task GetTraces_WithLimitAndDurationSearchFilter_ReturnsMostRecentMatchingTraces() { var repository = CreateRepository(); - AddSpans(repository, count: 3, startMinuteSpacing: 10); + await AddSpans(repository, count: 3, startMinuteSpacing: 10); var service = CreateService(repository); @@ -240,13 +240,13 @@ public void GetTraces_WithLimitAndDurationSearchFilter_ReturnsMostRecentMatching } [Fact] - public void GetTraces_WithDurationSearchFilter_FiltersShortSpans() + public async Task GetTraces_WithDurationSearchFilter_FiltersShortSpans() { var repository = CreateRepository(); - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: "short-trace", spanId: "short-trace-span", startTime: s_testTime, endTime: s_testTime.AddMilliseconds(49)) ]); - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: "mixed-trace", spanId: "mixed-short-span", startTime: s_testTime.AddSeconds(1), endTime: s_testTime.AddSeconds(1).AddMilliseconds(49)), CreateSpan(traceId: "mixed-trace", spanId: "mixed-long-span", startTime: s_testTime.AddSeconds(2), endTime: s_testTime.AddSeconds(2).AddMilliseconds(50)) ]); @@ -268,10 +268,10 @@ public void GetTraces_WithDurationSearchFilter_FiltersShortSpans() } [Fact] - public void GetTraces_WithHasErrorAndDurationSearchFilter_ReturnsAllSpansFromMatchingTraces() + public async Task GetTraces_WithHasErrorAndDurationSearchFilter_ReturnsAllSpansFromMatchingTraces() { var repository = CreateRepository(); - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan( traceId: "mixed-trace", spanId: "short-error-span", @@ -304,10 +304,10 @@ public void GetTraces_WithHasErrorAndDurationSearchFilter_ReturnsAllSpansFromMat } [Fact] - public void GetLogs_WithLimit_ReturnsMostRecentLogs() + public async Task GetLogs_WithLimit_ReturnsMostRecentLogs() { var repository = CreateRepository(); - AddLogs(repository, ["old-log", "mid-log", "new-log"]); + await AddLogs(repository, ["old-log", "mid-log", "new-log"]); var service = CreateService(repository); @@ -324,7 +324,7 @@ public void GetLogs_WithLimit_ReturnsMostRecentLogs() } [Fact] - public void GetLogs_LargeLimit_ReturnsAllLogs() + public async Task GetLogs_LargeLimit_ReturnsAllLogs() { const int totalLogs = 20_000; var repository = CreateRepository(maxLogCount: totalLogs); @@ -335,7 +335,7 @@ public void GetLogs_LargeLimit_ReturnsAllLogs() logRecords.Add(CreateLogRecord(time: s_testTime.AddMilliseconds(i), message: $"log{i}", severity: SeverityNumber.Info)); } - AddLogsToRepository(repository, logRecords); + await AddLogsToRepository(repository, logRecords); var service = CreateService(repository); @@ -349,10 +349,10 @@ public void GetLogs_LargeLimit_ReturnsAllLogs() [Theory] [InlineData("Connection", 2)] [InlineData("nonexistent", 0)] - public void GetLogs_WithSearch_FiltersLogsByMessage(string search, int expectedCount) + public async Task GetLogs_WithSearch_FiltersLogsByMessage(string search, int expectedCount) { var repository = CreateRepository(); - AddLogs(repository, ["Connection established", "Request received", "Connection closed"]); + await AddLogs(repository, ["Connection established", "Request received", "Connection closed"]); var service = CreateService(repository); @@ -363,11 +363,11 @@ public void GetLogs_WithSearch_FiltersLogsByMessage(string search, int expectedC } [Fact] - public void GetLogs_WithSearch_IsCaseInsensitive() + public async Task GetLogs_WithSearch_IsCaseInsensitive() { var repository = CreateRepository(); - AddLogs(repository, ["UPPERCASE warning detected"]); - AddLogs(repository, ["Normal log"]); + await AddLogs(repository, ["UPPERCASE warning detected"]); + await AddLogs(repository, ["Normal log"]); var service = CreateService(repository); @@ -378,10 +378,10 @@ public void GetLogs_WithSearch_IsCaseInsensitive() } [Fact] - public void GetLogs_WithSearch_MatchesAttributes() + public async Task GetLogs_WithSearch_MatchesAttributes() { var repository = CreateRepository(); - AddLogsToRepository(repository, [ + await AddLogsToRepository(repository, [ CreateLogRecord(time: s_testTime, message: "log1", severity: SeverityNumber.Info, attributes: [new KeyValuePair("http.url", "/api/products")]), CreateLogRecord(time: s_testTime.AddMinutes(1), message: "log2", severity: SeverityNumber.Info, @@ -399,15 +399,15 @@ public void GetLogs_WithSearch_MatchesAttributes() [Theory] [InlineData("span1", 1)] [InlineData("nonexistent-xyz", 0)] - public void GetTraces_WithSearch_FiltersTraces(string search, int expectedCount) + public async Task GetTraces_WithSearch_FiltersTraces(string search, int expectedCount) { var repository = CreateRepository(); // Each trace needs a separate AddTraces call to get distinct trace IDs in the repository - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: "trace1", spanId: "span1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)) ]); - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: "trace2", spanId: "span2", startTime: s_testTime.AddMinutes(10), endTime: s_testTime.AddMinutes(11)) ]); @@ -427,10 +427,10 @@ public void GetTraces_WithSearch_FiltersTraces(string search, int expectedCount) } [Fact] - public void GetSpans_WithAttributeFilter_FiltersSpans() + public async Task GetSpans_WithAttributeFilter_FiltersSpans() { var repository = CreateRepository(); - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: "trace1", spanId: "span1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1), attributes: [new KeyValuePair("http.method", "GET")]), CreateSpan(traceId: "trace1", spanId: "span2", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3), @@ -450,14 +450,14 @@ public void GetSpans_WithAttributeFilter_FiltersSpans() } [Fact] - public void GetTraces_WithAttributeFilter_FiltersTraces() + public async Task GetTraces_WithAttributeFilter_FiltersTraces() { var repository = CreateRepository(); - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: "trace1", spanId: "span1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1), attributes: [new KeyValuePair("http.method", "GET")]) ]); - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: "trace2", spanId: "span2", startTime: s_testTime.AddMinutes(10), endTime: s_testTime.AddMinutes(11), attributes: [new KeyValuePair("http.method", "POST")]) ]); @@ -475,10 +475,10 @@ public void GetTraces_WithAttributeFilter_FiltersTraces() } [Fact] - public void GetLogs_WithAttributeFilter_FiltersLogs() + public async Task GetLogs_WithAttributeFilter_FiltersLogs() { var repository = CreateRepository(); - AddLogsToRepository(repository, [ + await AddLogsToRepository(repository, [ CreateLogRecord(time: s_testTime, message: "log1", severity: SeverityNumber.Info, attributes: [new KeyValuePair("http.method", "GET")]), CreateLogRecord(time: s_testTime.AddMinutes(1), message: "log2", severity: SeverityNumber.Info, @@ -494,10 +494,10 @@ public void GetLogs_WithAttributeFilter_FiltersLogs() } [Fact] - public void GetSpans_WithDurationRangeFilter_ReturnsSpansInRange() + public async Task GetSpans_WithDurationRangeFilter_ReturnsSpansInRange() { var repository = CreateRepository(); - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: "trace1", spanId: "short-span", startTime: s_testTime, endTime: s_testTime.AddMilliseconds(30)), CreateSpan(traceId: "trace1", spanId: "mid-span", startTime: s_testTime.AddSeconds(1), endTime: s_testTime.AddSeconds(1).AddMilliseconds(75)), CreateSpan(traceId: "trace1", spanId: "long-span", startTime: s_testTime.AddSeconds(2), endTime: s_testTime.AddSeconds(2).AddMilliseconds(200)) @@ -517,10 +517,10 @@ public void GetSpans_WithDurationRangeFilter_ReturnsSpansInRange() } [Fact] - public void GetLogs_WithUrlSearch_MatchesExactScheme() + public async Task GetLogs_WithUrlSearch_MatchesExactScheme() { var repository = CreateRepository(); - AddLogs(repository, [ + await AddLogs(repository, [ "Request to http://www.contoso.com/api completed", "Request to https://www.contoso.com/api completed", "No URL in this message" @@ -536,11 +536,11 @@ public void GetLogs_WithUrlSearch_MatchesExactScheme() } [Fact] - public void GetSpans_WithTimestampGreaterThan_FiltersCorrectly() + public async Task GetSpans_WithTimestampGreaterThan_FiltersCorrectly() { var repository = CreateRepository(); // Spans at s_testTime+1min, +2min, +3min - AddSpans(repository, count: 3); + await AddSpans(repository, count: 3); var service = CreateService(repository); @@ -553,11 +553,11 @@ public void GetSpans_WithTimestampGreaterThan_FiltersCorrectly() } [Fact] - public void GetSpans_WithTimestampLessThan_FiltersCorrectly() + public async Task GetSpans_WithTimestampLessThan_FiltersCorrectly() { var repository = CreateRepository(); // Spans at s_testTime+1min, +2min, +3min - AddSpans(repository, count: 3); + await AddSpans(repository, count: 3); var service = CreateService(repository); @@ -570,11 +570,11 @@ public void GetSpans_WithTimestampLessThan_FiltersCorrectly() } [Fact] - public void GetSpans_WithTimestampGreaterThanOrEqual_FiltersCorrectly() + public async Task GetSpans_WithTimestampGreaterThanOrEqual_FiltersCorrectly() { var repository = CreateRepository(); // Spans at s_testTime+1min, +2min, +3min - AddSpans(repository, count: 3); + await AddSpans(repository, count: 3); var service = CreateService(repository); @@ -587,11 +587,11 @@ public void GetSpans_WithTimestampGreaterThanOrEqual_FiltersCorrectly() } [Fact] - public void GetLogs_WithTimestampGreaterThan_FiltersCorrectly() + public async Task GetLogs_WithTimestampGreaterThan_FiltersCorrectly() { var repository = CreateRepository(); // Logs at s_testTime, +1min, +2min - AddLogs(repository, ["log1", "log2", "log3"]); + await AddLogs(repository, ["log1", "log2", "log3"]); var service = CreateService(repository); @@ -604,10 +604,10 @@ public void GetLogs_WithTimestampGreaterThan_FiltersCorrectly() } [Fact] - public void GetSpans_WithTimestampInvalidDate_ReturnsNoResults() + public async Task GetSpans_WithTimestampInvalidDate_ReturnsNoResults() { var repository = CreateRepository(); - AddSpans(repository, count: 3); + await AddSpans(repository, count: 3); var service = CreateService(repository); @@ -619,11 +619,11 @@ public void GetSpans_WithTimestampInvalidDate_ReturnsNoResults() } [Fact] - public void GetSpans_WithTimestampUtcSuffix_TreatedAsUtc() + public async Task GetSpans_WithTimestampUtcSuffix_TreatedAsUtc() { var repository = CreateRepository(); // Spans at s_testTime+1min, +2min, +3min (s_testTime is 1970-01-01T00:00:00Z) - AddSpans(repository, count: 3); + await AddSpans(repository, count: 3); var service = CreateService(repository); @@ -636,11 +636,11 @@ public void GetSpans_WithTimestampUtcSuffix_TreatedAsUtc() } [Fact] - public void GetSpans_WithTimestampNoTimezone_TreatedAsLocalTime() + public async Task GetSpans_WithTimestampNoTimezone_TreatedAsLocalTime() { var repository = CreateRepository(); // Spans at s_testTime+1min, +2min, +3min (s_testTime is 1970-01-01T00:00:00Z) - AddSpans(repository, count: 3); + await AddSpans(repository, count: 3); var service = CreateService(repository); @@ -657,11 +657,11 @@ public void GetSpans_WithTimestampNoTimezone_TreatedAsLocalTime() } [Fact] - public void GetSpans_WithTimestampOffset_AdjustedToUtc() + public async Task GetSpans_WithTimestampOffset_AdjustedToUtc() { var repository = CreateRepository(); // Spans at s_testTime+1min, +2min, +3min (s_testTime is 1970-01-01T00:00:00Z) - AddSpans(repository, count: 3); + await AddSpans(repository, count: 3); var service = CreateService(repository); @@ -674,14 +674,14 @@ public void GetSpans_WithTimestampOffset_AdjustedToUtc() } [Fact] - public void GetSpans_WithTimestampDateOnly_FiltersCorrectly() + public async Task GetSpans_WithTimestampDateOnly_FiltersCorrectly() { var repository = CreateRepository(); // Create spans on two different days: 1970-01-01 and 1970-01-02 - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: "trace1", spanId: "span1", startTime: new DateTime(1970, 1, 1, 12, 0, 0, DateTimeKind.Utc), endTime: new DateTime(1970, 1, 1, 12, 1, 0, DateTimeKind.Utc)) ]); - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: "trace2", spanId: "span2", startTime: new DateTime(1970, 1, 2, 12, 0, 0, DateTimeKind.Utc), endTime: new DateTime(1970, 1, 2, 12, 1, 0, DateTimeKind.Utc)) ]); @@ -699,11 +699,11 @@ public void GetSpans_WithTimestampDateOnly_FiltersCorrectly() /// Adds spans with sequential trace/span IDs to the repository. Each span is added in a separate /// AddTraces call so that it gets its own trace entry. /// - private static void AddSpans(InMemoryTelemetryRepository repository, int count, int startMinuteSpacing = 1) + private static async Task AddSpans(InMemoryTelemetryRepository repository, int count, int startMinuteSpacing = 1) { for (var i = 1; i <= count; i++) { - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: $"trace{i}", spanId: $"span{i}", startTime: s_testTime.AddMinutes(i * startMinuteSpacing), endTime: s_testTime.AddMinutes(i * startMinuteSpacing + 1)) ]); } @@ -712,9 +712,9 @@ private static void AddSpans(InMemoryTelemetryRepository repository, int count, /// /// Adds a batch of spans (as raw Span objects) to the repository under a single resource. /// - private static void AddSpansToRepository(InMemoryTelemetryRepository repository, IEnumerable spans) + private static async Task AddSpansToRepository(InMemoryTelemetryRepository repository, IEnumerable spans) { - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -734,12 +734,12 @@ private static void AddSpansToRepository(InMemoryTelemetryRepository repository, /// /// Adds two traces (separate trace IDs) with OK and Error status for hasError filter tests. /// - private static void AddTracesWithStatus(InMemoryTelemetryRepository repository) + private static async Task AddTracesWithStatus(InMemoryTelemetryRepository repository) { - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: "ok-trace", spanId: "span1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1), status: new Status { Code = Status.Types.StatusCode.Ok }) ]); - AddSpansToRepository(repository, [ + await AddSpansToRepository(repository, [ CreateSpan(traceId: "error-trace", spanId: "span2", startTime: s_testTime.AddMinutes(2), endTime: s_testTime.AddMinutes(3), status: new Status { Code = Status.Types.StatusCode.Error }) ]); } @@ -747,7 +747,7 @@ private static void AddTracesWithStatus(InMemoryTelemetryRepository repository) /// /// Adds log entries with the specified messages to the repository. /// - private static void AddLogs(InMemoryTelemetryRepository repository, string[] messages, SeverityNumber severity = SeverityNumber.Info) + private static async Task AddLogs(InMemoryTelemetryRepository repository, string[] messages, SeverityNumber severity = SeverityNumber.Info) { var logRecords = new RepeatedField(); for (var i = 0; i < messages.Length; i++) @@ -755,15 +755,15 @@ private static void AddLogs(InMemoryTelemetryRepository repository, string[] mes logRecords.Add(CreateLogRecord(time: s_testTime.AddMinutes(i), message: messages[i], severity: severity)); } - AddLogsToRepository(repository, logRecords); + await AddLogsToRepository(repository, logRecords); } /// /// Adds a batch of raw LogRecord objects to the repository under a single resource. /// - private static void AddLogsToRepository(InMemoryTelemetryRepository repository, RepeatedField logRecords) + private static async Task AddLogsToRepository(InMemoryTelemetryRepository repository, RepeatedField logRecords) { - repository.AddLogs(new AddContext(), new RepeatedField + await repository.AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -810,7 +810,7 @@ public async Task FollowSpansAsync_WaitsForResourceToAppear_ThenStreams() Assert.False(moveNextTask.IsCompleted); // Now add spans for the resource - this should unblock the stream. - AddSpans(repository, count: 1); + await AddSpans(repository, count: 1); Assert.True(await moveNextTask.DefaultTimeout()); Assert.NotNull(enumerator.Current); @@ -832,7 +832,7 @@ public async Task FollowLogsAsync_WaitsForResourceToAppear_ThenStreams() Assert.False(moveNextTask.IsCompleted); // Now add logs for the resource - this should unblock the stream. - AddLogs(repository, ["hello"]); + await AddLogs(repository, ["hello"]); Assert.True(await moveNextTask.DefaultTimeout()); Assert.NotNull(enumerator.Current); @@ -852,7 +852,7 @@ private static string DecodeSpanId(string? hexSpanId) } [Fact] - public void GetSpans_WithReplicatedResourceName_DoesNotThrow() + public async Task GetSpans_WithReplicatedResourceName_DoesNotThrow() { // When multiple replicas share the same base ResourceName, the resource resolver // must not throw InvalidOperationException from SingleOrDefault. It should treat @@ -860,7 +860,7 @@ public void GetSpans_WithReplicatedResourceName_DoesNotThrow() var repository = CreateRepository(); // Add two replicas of the same service with different instance IDs. - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -868,7 +868,7 @@ public void GetSpans_WithReplicatedResourceName_DoesNotThrow() ScopeSpans = { new ScopeSpans { Scope = CreateScope(), Spans = { CreateSpan(traceId: "t1", spanId: "s1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)) } } } } }); - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -885,13 +885,13 @@ public void GetSpans_WithReplicatedResourceName_DoesNotThrow() } [Fact] - public void GetSpans_WithCompositeResourceKey_ResolvesReplica() + public async Task GetSpans_WithCompositeResourceKey_ResolvesReplica() { // When the caller uses the composite ResourceKey string (e.g. "myapp-replica-1"), // the resolver should find the exact replica. var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -899,7 +899,7 @@ public void GetSpans_WithCompositeResourceKey_ResolvesReplica() ScopeSpans = { new ScopeSpans { Scope = CreateScope(), Spans = { CreateSpan(traceId: "t1", spanId: "s1", startTime: s_testTime, endTime: s_testTime.AddMinutes(1)) } } } } }); - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -921,11 +921,11 @@ public void GetSpans_WithCompositeResourceKey_ResolvesReplica() } [Fact] - public void GetSpans_WithResourceNameMatchingCompositeResourceKey_ReturnsNull() + public async Task GetSpans_WithResourceNameMatchingCompositeResourceKey_ReturnsNull() { var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -947,11 +947,11 @@ public void GetSpans_WithResourceNameMatchingCompositeResourceKey_ReturnsNull() } [Fact] - public void GetSpans_WithAmbiguousCompositeResourceKey_ReturnsNull() + public async Task GetSpans_WithAmbiguousCompositeResourceKey_ReturnsNull() { var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -973,11 +973,11 @@ public void GetSpans_WithAmbiguousCompositeResourceKey_ReturnsNull() } [Fact] - public void GetSpans_WithBaseResourceNameAndMixedInstanceIds_ReturnsNull() + public async Task GetSpans_WithBaseResourceNameAndMixedInstanceIds_ReturnsNull() { var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -999,12 +999,12 @@ public void GetSpans_WithBaseResourceNameAndMixedInstanceIds_ReturnsNull() } [Fact] - public void GetSpans_WithUniqueResourceName_ResolvesDirectly() + public async Task GetSpans_WithUniqueResourceName_ResolvesDirectly() { // When only one resource matches the base name, it should resolve directly. var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -1022,12 +1022,12 @@ public void GetSpans_WithUniqueResourceName_ResolvesDirectly() } [Fact] - public void GetSpans_WithDifferentCaseResourceName_ResolvesCaseInsensitively() + public async Task GetSpans_WithDifferentCaseResourceName_ResolvesCaseInsensitively() { // Resource names are case-insensitive throughout the dashboard. var repository = CreateRepository(); - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs index a58444963c0..a86bcfbf692 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/LogTests.cs @@ -30,14 +30,14 @@ public LogTests(ITestOutputHelper testOutputHelper) } [Fact] - public void AddLogs() + public async Task AddLogs() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -93,11 +93,11 @@ public void AddLogs() } [Fact] - public void GetLogSummaries_ReturnsPageData() + public async Task GetLogSummaries_ReturnsPageData() { var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField { new ResourceSpans { @@ -120,7 +120,7 @@ public void GetLogSummaries_ReturnsPageData() } } }); - repository.AsWriter().AddLogs(addContext, new RepeatedField + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField { new ResourceLogs { @@ -244,11 +244,11 @@ public void GetLogSummaries_ReturnsPageData() } [Fact] - public void GetLogsFieldValues_AllFieldsMatchMaterializedLogs() + public async Task GetLogsFieldValues_AllFieldsMatchMaterializedLogs() { var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField { new ResourceLogs { @@ -300,14 +300,14 @@ public void GetLogsFieldValues_AllFieldsMatchMaterializedLogs() } [Fact] - public void AddLogs_NoBody_EmptyMessage() + public async Task AddLogs_NoBody_EmptyMessage() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -341,14 +341,14 @@ public void AddLogs_NoBody_EmptyMessage() } [Fact] - public void AddLogs_MultipleOutOfOrder() + public async Task AddLogs_MultipleOutOfOrder() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -403,14 +403,14 @@ public void AddLogs_MultipleOutOfOrder() } [Fact] - public void AddLogs_Error_UnviewedCount() + public async Task AddLogs_Error_UnviewedCount() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -478,7 +478,7 @@ public void AddLogs_Error_UnviewedCount() } [Fact] - public void AddLogs_Error_UnviewedCount_WithReadSubscriptionAll() + public async Task AddLogs_Error_UnviewedCount_WithReadSubscriptionAll() { // Arrange var repository = CreateRepository(); @@ -486,7 +486,7 @@ public void AddLogs_Error_UnviewedCount_WithReadSubscriptionAll() // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -530,7 +530,7 @@ public void AddLogs_Error_UnviewedCount_WithReadSubscriptionAll() } [Fact] - public void AddLogs_Error_UnviewedCount_WithReadSubscriptionOneApp() + public async Task AddLogs_Error_UnviewedCount_WithReadSubscriptionOneApp() { // Arrange var repository = CreateRepository(); @@ -538,7 +538,7 @@ public void AddLogs_Error_UnviewedCount_WithReadSubscriptionOneApp() // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -583,7 +583,7 @@ public void AddLogs_Error_UnviewedCount_WithReadSubscriptionOneApp() } [Fact] - public void AddLogs_Error_UnviewedCount_WithNonReadSubscription() + public async Task AddLogs_Error_UnviewedCount_WithNonReadSubscription() { // Arrange var repository = CreateRepository(); @@ -591,7 +591,7 @@ public void AddLogs_Error_UnviewedCount_WithNonReadSubscription() // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -666,7 +666,7 @@ public async Task Subscriptions_AddLog() // Act 1 var addContext1 = new AddContext(); - repository.AsWriter().AddLogs(addContext1, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext1, new RepeatedField() { new ResourceLogs { @@ -703,7 +703,7 @@ public async Task Subscriptions_AddLog() }); var addContext2 = new AddContext(); - repository.AsWriter().AddLogs(addContext2, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext2, new RepeatedField() { new ResourceLogs { @@ -736,7 +736,7 @@ public async Task Subscriptions_AddLog() } [Fact] - public void Unsubscribe() + public async Task Unsubscribe() { // Arrange var repository = CreateRepository(); @@ -751,7 +751,7 @@ public void Unsubscribe() // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -792,10 +792,10 @@ public async Task Subscription_RaisedFromDifferentContext_InitialContextPreserve Task task; using (ExecutionContext.SuppressFlow()) { - task = Task.Run(() => + task = Task.Run(async () => { var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -821,7 +821,7 @@ public async Task Subscription_RaisedFromDifferentContext_InitialContextPreserve } [Fact] - public void AddLogs_AttributeLimits_LimitsApplied() + public async Task AddLogs_AttributeLimits_LimitsApplied() { // Arrange var repository = CreateRepository(maxAttributeCount: 5, maxAttributeLength: 16); @@ -839,7 +839,7 @@ public void AddLogs_AttributeLimits_LimitsApplied() } var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -937,7 +937,7 @@ public async Task Subscription_MultipleUpdates_MinExecuteIntervalApplied() // Act var addContext = new AddContext(); logger.LogInformation("Writing log 1"); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -959,7 +959,7 @@ public async Task Subscription_MultipleUpdates_MinExecuteIntervalApplied() logger.LogInformation("Received log 1 callback"); logger.LogInformation("Writing log 2"); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -985,14 +985,14 @@ public async Task Subscription_MultipleUpdates_MinExecuteIntervalApplied() } [Fact] - public void FilterLogs_With_Message_Returns_CorrectLog() + public async Task FilterLogs_With_Message_Returns_CorrectLog() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1035,11 +1035,11 @@ public void FilterLogs_With_Message_Returns_CorrectLog() [InlineData("%")] [InlineData("_")] [InlineData("!")] - public void FilterLogs_WithLikeMetacharacter_TreatsValueAsLiteral(string fragment) + public async Task FilterLogs_WithLikeMetacharacter_TreatsValueAsLiteral(string fragment) { var repository = CreateRepository(); var expectedMessage = $"matches-{fragment}-literal"; - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField + await repository.AsWriter().AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1072,14 +1072,14 @@ public void FilterLogs_WithLikeMetacharacter_TreatsValueAsLiteral(string fragmen } [Fact] - public void FilterLogs_With_EventName_Returns_CorrectLog() + public async Task FilterLogs_With_EventName_Returns_CorrectLog() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1119,14 +1119,14 @@ public void FilterLogs_With_EventName_Returns_CorrectLog() } [Fact] - public void AddLogs_MultipleResources_SameInstanceId_CreateMultipleResources() + public async Task AddLogs_MultipleResources_SameInstanceId_CreateMultipleResources() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1218,14 +1218,14 @@ public void AddLogs_MultipleResources_SameInstanceId_CreateMultipleResources() } [Fact] - public void GetLogs_MultipleInstances() + public async Task GetLogs_MultipleInstances() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1307,13 +1307,13 @@ public void GetLogs_MultipleInstances() } [Fact] - public void RemoveLogs_All() + public async Task RemoveLogs_All() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1354,7 +1354,7 @@ public void RemoveLogs_All() }); // Act - repository.AsWriter().ClearStructuredLogs(); + await repository.AsWriter().ClearStructuredLogsAsync(); // Assert Assert.Equal(0, addContext.FailureCount); @@ -1372,13 +1372,13 @@ public void RemoveLogs_All() } [Fact] - public void RemoveLogs_SelectedResource() + public async Task RemoveLogs_SelectedResource() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1419,7 +1419,7 @@ public void RemoveLogs_SelectedResource() }); // Act - repository.AsWriter().ClearStructuredLogs(new ResourceKey("resource1", "123")); + await repository.AsWriter().ClearStructuredLogsAsync(new ResourceKey("resource1", "123")); // Assert Assert.Equal(0, addContext.FailureCount); @@ -1446,13 +1446,13 @@ public void RemoveLogs_SelectedResource() } [Fact] - public void RemoveLogs_MultipleSelectedResources() + public async Task RemoveLogs_MultipleSelectedResources() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1493,7 +1493,7 @@ public void RemoveLogs_MultipleSelectedResources() }); // Act - repository.AsWriter().ClearStructuredLogs(new ResourceKey("resource1", null)); + await repository.AsWriter().ClearStructuredLogsAsync(new ResourceKey("resource1", null)); // Assert Assert.Equal(0, addContext.FailureCount); @@ -1512,14 +1512,14 @@ public void RemoveLogs_MultipleSelectedResources() } [Fact] - public void AddLogs_ObservedUnixTimeNanos() + public async Task AddLogs_ObservedUnixTimeNanos() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1553,14 +1553,14 @@ public void AddLogs_ObservedUnixTimeNanos() } [Fact] - public void AddLogs_EventName_FromLogRecordField() + public async Task AddLogs_EventName_FromLogRecordField() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1594,14 +1594,14 @@ public void AddLogs_EventName_FromLogRecordField() } [Fact] - public void AddLogs_EventName_FromLegacyAttribute() + public async Task AddLogs_EventName_FromLegacyAttribute() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1637,14 +1637,14 @@ public void AddLogs_EventName_FromLegacyAttribute() } [Fact] - public void AddLogs_EventName_FieldTakesPrecedenceOverAttribute() + public async Task AddLogs_EventName_FieldTakesPrecedenceOverAttribute() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1679,14 +1679,14 @@ public void AddLogs_EventName_FieldTakesPrecedenceOverAttribute() } [Fact] - public void AddLogs_EventName_NullWhenNotSet() + public async Task AddLogs_EventName_NullWhenNotSet() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -1720,11 +1720,11 @@ public void AddLogs_EventName_NullWhenNotSet() } [Fact] - public void GetLogs_DisabledFiltersAreIgnored() + public async Task GetLogs_DisabledFiltersAreIgnored() { var repository = CreateRepository(); - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField + await repository.AsWriter().AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1787,14 +1787,14 @@ public sealed class SqliteLogTests(ITestOutputHelper testOutputHelper) : LogTest protected override bool UseSqlite => true; [Fact] - public void AddLogs_LargeAttributeBatchesRoundTripAcrossResources() + public async Task AddLogs_LargeAttributeBatchesRoundTripAcrossResources() { var repository = Assert.IsType(CreateRepository()); var context = new AddContext(); var attributes = Enumerable.Range(0, 128) .Select(index => KeyValuePair.Create($"key-{index}", $"value-{index}")) .ToArray(); - repository.AsWriter().AddLogs(context, new RepeatedField + await repository.AsWriter().AddLogsAsync(context, new RepeatedField { new ResourceLogs { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs index 26c70663d9e..8f34fe23024 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/MetricsTests.cs @@ -23,14 +23,14 @@ public abstract class MetricsTests : TelemetryRepositoryTestBase private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); [Fact] - public void AddMetrics() + public async Task AddMetrics() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -109,7 +109,7 @@ public void AddMetrics() } [Fact] - public void AddMetrics_MeterAttributeLimits_LimitsApplied() + public async Task AddMetrics_MeterAttributeLimits_LimitsApplied() { // Arrange var repository = CreateRepository(maxAttributeCount: 5, maxAttributeLength: 16); @@ -126,7 +126,7 @@ public void AddMetrics_MeterAttributeLimits_LimitsApplied() // Act var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -203,7 +203,7 @@ public void AddMetrics_MeterAttributeLimits_LimitsApplied() } [Fact] - public void AddMetrics_MetricAttributeLimits_LimitsApplied() + public async Task AddMetrics_MetricAttributeLimits_LimitsApplied() { // Arrange var repository = CreateRepository(maxAttributeCount: 5, maxAttributeLength: 16); @@ -222,7 +222,7 @@ public void AddMetrics_MetricAttributeLimits_LimitsApplied() // Act var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -288,14 +288,14 @@ public void RoundtripSeconds() } [Fact] - public void GetInstrument() + public async Task GetInstrument() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -390,11 +390,11 @@ public void GetInstrument() } [Fact] - public void GetInstrument_StaggeredDimensionChanges_ReturnsCurrentValues() + public async Task GetInstrument_StaggeredDimensionChanges_ReturnsCurrentValues() { var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField { new ResourceMetrics { @@ -448,10 +448,10 @@ public void GetInstrument_StaggeredDimensionChanges_ReturnsCurrentValues() } [Fact] - public void GetInstrumentLatestEndTime() + public async Task GetInstrumentLatestEndTime() { var repository = CreateRepository(); - repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField + await repository.AsWriter().AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -498,14 +498,14 @@ protected static Exemplar CreateExemplar(DateTime startTime, double value, IEnum } [Fact] - public void AddMetrics_Capacity_ValuesRemoved() + public async Task AddMetrics_Capacity_ValuesRemoved() { // Arrange var repository = CreateRepository(maxMetricsCount: 3); // Act var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -577,14 +577,14 @@ public void AddMetrics_Capacity_ValuesRemoved() } [Fact] - public void GetMetrics_MultipleInstances() + public async Task GetMetrics_MultipleInstances() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -685,13 +685,13 @@ public void GetMetrics_MultipleInstances() } [Fact] - public void RemoveMetrics_All() + public async Task RemoveMetrics_All() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -744,7 +744,7 @@ public void RemoveMetrics_All() }); // Act - repository.AsWriter().ClearMetrics(); + await repository.AsWriter().ClearMetricsAsync(); // Assert Assert.Equal(0, addContext.FailureCount); @@ -760,13 +760,13 @@ public void RemoveMetrics_All() } [Fact] - public void RemoveMetrics_SelectedResource() + public async Task RemoveMetrics_SelectedResource() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -819,7 +819,7 @@ public void RemoveMetrics_SelectedResource() }); // Act - repository.AsWriter().ClearMetrics(new ResourceKey("resource1", "456")); + await repository.AsWriter().ClearMetricsAsync(new ResourceKey("resource1", "456")); // Assert Assert.Equal(0, addContext.FailureCount); @@ -910,13 +910,13 @@ public void RemoveMetrics_SelectedResource() } [Fact] - public void RemoveMetrics_MultipleSelectedResources() + public async Task RemoveMetrics_MultipleSelectedResources() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -969,7 +969,7 @@ public void RemoveMetrics_MultipleSelectedResources() }); // Act - repository.AsWriter().ClearMetrics(new ResourceKey("resource1", null)); + await repository.AsWriter().ClearMetricsAsync(new ResourceKey("resource1", null)); // Assert Assert.Equal(0, addContext.FailureCount); @@ -1050,7 +1050,7 @@ public void RemoveMetrics_MultipleSelectedResources() } [Fact] - public void AddMetrics_InvalidInstrument() + public async Task AddMetrics_InvalidInstrument() { // Arrange var repository = CreateRepository(); @@ -1058,7 +1058,7 @@ public void AddMetrics_InvalidInstrument() var addContext = new AddContext(); // Act - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -1094,7 +1094,7 @@ public void AddMetrics_InvalidInstrument() } [Fact] - public void AddMetrics_InvalidHistogramDataPoints() + public async Task AddMetrics_InvalidHistogramDataPoints() { // Arrange var repository = CreateRepository(); @@ -1140,7 +1140,7 @@ public void AddMetrics_InvalidHistogramDataPoints() } }; - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -1181,14 +1181,81 @@ public void AddMetrics_InvalidHistogramDataPoints() } [Fact] - public void AddMetrics_OverflowDimension() + public async Task AddMetrics_HistogramBucketCountLengthChanges_DataPointRejected() + { + var repository = CreateRepository(); + var addContext = new AddContext(); + var histogramMetric = new Metric + { + Name = "test", + Histogram = new Histogram + { + AggregationTemporality = AggregationTemporality.Cumulative, + DataPoints = + { + new HistogramDataPoint + { + Count = 6, + ExplicitBounds = { 1, 2 }, + BucketCounts = { 1, 2, 3 }, + TimeUnixNano = DateTimeToUnixNanoseconds(s_testTime.AddMinutes(1)) + }, + new HistogramDataPoint + { + Count = 10, + ExplicitBounds = { 1, 2, 3 }, + BucketCounts = { 1, 2, 3, 4 }, + TimeUnixNano = DateTimeToUnixNanoseconds(s_testTime.AddMinutes(2)) + } + } + } + }; + + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = + { + new ScopeMetrics + { + Scope = CreateScope(name: "test-meter"), + Metrics = { histogramMetric } + } + } + } + }); + + Assert.Equal(1, addContext.SuccessCount); + Assert.Equal(1, addContext.FailureCount); + + var instrument = repository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = new ResourceKey("TestService", "TestId"), + MeterName = "test-meter", + InstrumentName = "test", + StartTime = DateTime.MinValue, + EndTime = DateTime.MaxValue + }); + + Assert.NotNull(instrument); + var dimension = Assert.Single(instrument.Dimensions); + var histogramValue = Assert.IsType(Assert.Single(dimension.Values)); + Assert.Equal([1UL, 2UL, 3UL], histogramValue.Values); + Assert.Equal([1d, 2d], histogramValue.ExplicitBounds); + Assert.Equal(6UL, histogramValue.Count); + } + + [Fact] + public async Task AddMetrics_OverflowDimension() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -1244,14 +1311,14 @@ public void AddMetrics_OverflowDimension() } [Fact] - public void AddMetrics_NoScope() + public async Task AddMetrics_NoScope() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -1306,10 +1373,10 @@ public sealed class SqliteMetricsTests : MetricsTests protected override bool UseSqlite => true; [Fact] - public void GetInstrument_PopulateExemplarAttributesFalse_SkipsAttributes() + public async Task GetInstrument_PopulateExemplarAttributesFalse_SkipsAttributes() { var repository = Assert.IsType(CreateRepository()); - repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField + await repository.AsWriter().AddMetricsAsync(new AddContext(), new RepeatedField { CreateResourceMetrics(CreateSumMetric( metricName: "test", @@ -1341,10 +1408,10 @@ public void GetInstrument_PopulateExemplarAttributesFalse_SkipsAttributes() } [Fact] - public void GetInstrument_WithoutTimeRange_SkipsMetricPointQueries() + public async Task GetInstrument_WithoutTimeRange_SkipsMetricPointQueries() { var repository = Assert.IsType(CreateRepository()); - repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField + await repository.AsWriter().AddMetricsAsync(new AddContext(), new RepeatedField { CreateResourceMetrics(CreateSumMetric("test", s_queryTestTime.AddMinutes(1))) }); @@ -1367,14 +1434,14 @@ public void GetInstrument_WithoutTimeRange_SkipsMetricPointQueries() } [Fact] - public void GetInstrument_StaggeredDimensionChanges_ReturnsDimensionTimelines() + public async Task GetInstrument_StaggeredDimensionChanges_ReturnsDimensionTimelines() { var repository = CreateRepository(); var addContext = new AddContext(); for (var minute = 1; minute <= 3; minute++) { - repository.AsWriter().AddMetrics(addContext, new RepeatedField + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField { new ResourceMetrics { @@ -1417,14 +1484,14 @@ public void GetInstrument_StaggeredDimensionChanges_ReturnsDimensionTimelines() } [Fact] - public void GetHistogram_StaggeredDimensionChanges_ReturnsDimensionTimelines() + public async Task GetHistogram_StaggeredDimensionChanges_ReturnsDimensionTimelines() { var repository = CreateRepository(); var addContext = new AddContext(); for (var minute = 1; minute <= 3; minute++) { - repository.AsWriter().AddMetrics(addContext, new RepeatedField + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField { new ResourceMetrics { @@ -1497,11 +1564,11 @@ static Metric CreateTestHistogramMetric(DateTime startTime, int value, string di } [Fact] - public void GetInstrument_DimensionCursor_ReturnsExtendedLatestPoint() + public async Task GetInstrument_DimensionCursor_ReturnsExtendedLatestPoint() { var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField { new ResourceMetrics { @@ -1537,7 +1604,7 @@ public void GetInstrument_DimensionCursor_ReturnsExtendedLatestPoint() var initialDimension = Assert.Single(initialInstrument!.Dimensions); var initialValue = Assert.IsType>(Assert.Single(initialDimension.Values)); - repository.AsWriter().AddMetrics(addContext, new RepeatedField + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField { new ResourceMetrics { @@ -1587,11 +1654,11 @@ public void GetInstrument_DimensionCursor_ReturnsExtendedLatestPoint() } [Fact] - public void GetInstrument_DataPointInterval_RollsUpNumericValuesAndExemplars() + public async Task GetInstrument_DataPointInterval_RollsUpNumericValuesAndExemplars() { var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField { new ResourceMetrics { @@ -1636,14 +1703,14 @@ public void GetInstrument_DataPointInterval_RollsUpNumericValuesAndExemplars() } [Fact] - public void GetInstrument_IncrementalRollup_RecomputesCompleteLatestBucket() + public async Task GetInstrument_IncrementalRollup_RecomputesCompleteLatestBucket() { var repository = CreateRepository(); var addContext = new AddContext(); - AddMetric(s_queryTestTime.AddSeconds(1), 5); - AddMetric(s_queryTestTime.AddSeconds(5), 5); - AddMetric(s_queryTestTime.AddSeconds(10), 3); - AddMetric(s_queryTestTime.AddMinutes(2), 3); + await AddMetric(s_queryTestTime.AddSeconds(1), 5); + await AddMetric(s_queryTestTime.AddSeconds(5), 5); + await AddMetric(s_queryTestTime.AddSeconds(10), 3); + await AddMetric(s_queryTestTime.AddMinutes(2), 3); var resource = Assert.Single(repository.GetResources()); var initialInstrument = GetInstrument([]); @@ -1662,9 +1729,9 @@ public void GetInstrument_IncrementalRollup_RecomputesCompleteLatestBucket() Assert.Equal(initialValue.Start, refreshedValue.Start); Assert.Equal(initialValue.End, refreshedValue.End); - void AddMetric(DateTime startTime, int value) + async Task AddMetric(DateTime startTime, int value) { - repository.AsWriter().AddMetrics(addContext, new RepeatedField + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField { new ResourceMetrics { @@ -1697,7 +1764,7 @@ OtlpInstrumentData GetInstrument(IReadOnlyList dimensionC } [Fact] - public void GetHistogram_DataPointInterval_ReturnsLatestCoherentSnapshot() + public async Task GetHistogram_DataPointInterval_ReturnsLatestCoherentSnapshot() { var repository = CreateRepository(); var addContext = new AddContext(); @@ -1721,7 +1788,7 @@ public void GetHistogram_DataPointInterval_ReturnsLatestCoherentSnapshot() } metrics.Add(metric); } - repository.AsWriter().AddMetrics(addContext, new RepeatedField + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField { new ResourceMetrics { @@ -1757,7 +1824,7 @@ public void GetHistogram_DataPointInterval_ReturnsLatestCoherentSnapshot() } [Fact] - public void AddMetrics_ReusesInstrumentAndDimensionLookupsWithinBatch() + public async Task AddMetrics_ReusesInstrumentAndDimensionLookupsWithinBatch() { var repository = Assert.IsType(CreateRepository()); var activities = new ConcurrentQueue(); @@ -1765,7 +1832,7 @@ public void AddMetrics_ReusesInstrumentAndDimensionLookupsWithinBatch() using var parent = new Activity("metric ingestion test").Start(); var context = new AddContext(); - repository.AsWriter().AddMetrics(context, new RepeatedField + await repository.AsWriter().AddMetricsAsync(context, new RepeatedField { new ResourceMetrics { @@ -1799,7 +1866,7 @@ public void AddMetrics_ReusesInstrumentAndDimensionLookupsWithinBatch() } [Fact] - public void AddMetrics_LargeHistogramAndDimensionAttributeBatchesRoundTrip() + public async Task AddMetrics_LargeHistogramAndDimensionAttributeBatchesRoundTrip() { var repository = Assert.IsType(CreateRepository()); var histogram = CreateHistogramMetric(metricName: "histogram", startTime: s_queryTestTime.AddMinutes(1)); @@ -1820,7 +1887,7 @@ public void AddMetrics_LargeHistogramAndDimensionAttributeBatchesRoundTrip() .Select(index => KeyValuePair.Create($"key-{index}", $"second-{index}")) .ToArray(); var context = new AddContext(); - repository.AsWriter().AddMetrics(context, new RepeatedField + await repository.AsWriter().AddMetricsAsync(context, new RepeatedField { new ResourceMetrics { @@ -1880,10 +1947,10 @@ public void AddMetrics_LargeHistogramAndDimensionAttributeBatchesRoundTrip() } [Fact] - public void AddMetrics_BatchesAndDeduplicatesExemplars() + public async Task AddMetrics_BatchesAndDeduplicatesExemplars() { var repository = Assert.IsType(CreateRepository()); - repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField + await repository.AsWriter().AddMetricsAsync(new AddContext(), new RepeatedField { CreateResourceMetrics(CreateSumMetric( metricName: "test", @@ -1896,7 +1963,7 @@ public void AddMetrics_BatchesAndDeduplicatesExemplars() using var parent = new Activity("metric exemplar test").Start(); var context = new AddContext(); - repository.AsWriter().AddMetrics(context, new RepeatedField + await repository.AsWriter().AddMetricsAsync(context, new RepeatedField { CreateResourceMetrics(CreateSumMetric( metricName: "test", @@ -1935,10 +2002,10 @@ public void AddMetrics_BatchesAndDeduplicatesExemplars() } [Fact] - public void AddMetrics_UpdateOnlyBatch_DoesNotTrimMetricPoints() + public async Task AddMetrics_UpdateOnlyBatch_DoesNotTrimMetricPoints() { var repository = Assert.IsType(CreateRepository()); - repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField + await repository.AsWriter().AddMetricsAsync(new AddContext(), new RepeatedField { CreateResourceMetrics(CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(1), value: 1)) }); @@ -1946,7 +2013,7 @@ public void AddMetrics_UpdateOnlyBatch_DoesNotTrimMetricPoints() using var listener = ActivityListenerHelper.Create(repository.SqlActivitySource, onActivityStopped: activities.Enqueue); using var parent = new Activity("metric update test").Start(); - repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField + await repository.AsWriter().AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -1996,18 +2063,18 @@ public void AddMetrics_UpdateOnlyBatch_DoesNotTrimMetricPoints() } [Fact] - public void ClearMetrics_InvalidatesMetricIngestionCache() + public async Task ClearMetrics_InvalidatesMetricIngestionCache() { var repository = CreateRepository(); - repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField + await repository.AsWriter().AddMetricsAsync(new AddContext(), new RepeatedField { CreateResourceMetrics(CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(1), value: 1)) }); - repository.AsWriter().ClearMetrics(); + await repository.AsWriter().ClearMetricsAsync(); var context = new AddContext(); - repository.AsWriter().AddMetrics(context, new RepeatedField + await repository.AsWriter().AddMetricsAsync(context, new RepeatedField { CreateResourceMetrics(CreateSumMetric(metricName: "test", startTime: s_queryTestTime.AddMinutes(2), value: 2)) }); diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs index 31a6f0c9339..1b4ca544784 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/ResourceTests.cs @@ -13,13 +13,13 @@ namespace Aspire.Dashboard.Tests.TelemetryRepositoryTests; public abstract class ResourceTests : TelemetryRepositoryTestBase { [Fact] - public void GetResourceByCompositeName() + public async Task GetResourceByCompositeName() { // Arrange var repository = CreateRepository(); - AddResource(repository, "app2"); - AddResource(repository, "app1"); + await AddResource(repository, "app2"); + await AddResource(repository, "app1"); // Act 1 var resources = repository.GetResources(); @@ -55,14 +55,14 @@ public void GetResourceByCompositeName() } [Fact] - public void GetResources_WithNameAndNoKey() + public async Task GetResources_WithNameAndNoKey() { // Arrange var repository = CreateRepository(); - AddResource(repository, "app2"); - AddResource(repository, "app1", instanceId: "123"); - AddResource(repository, "app1", instanceId: "456"); + await AddResource(repository, "app2"); + await AddResource(repository, "app1", instanceId: "123"); + await AddResource(repository, "app1", instanceId: "456"); // Act 1 var resources1 = repository.GetResources(new ResourceKey("app1", InstanceId: null)); @@ -93,14 +93,14 @@ public void GetResources_WithNameAndNoKey() } [Fact] - public void GetResources_Order() + public async Task GetResources_Order() { // Arrange var repository = CreateRepository(); - AddResource(repository, "app2"); - AddResource(repository, "app1", instanceId: "def"); - AddResource(repository, "app1", instanceId: "abc"); + await AddResource(repository, "app2"); + await AddResource(repository, "app1", instanceId: "def"); + await AddResource(repository, "app1", instanceId: "abc"); // Act var resources = repository.GetResources(); @@ -125,15 +125,15 @@ public void GetResources_Order() } [Fact] - public void GetResourceName_GuidInstanceId_Shorten() + public async Task GetResourceName_GuidInstanceId_Shorten() { // Arrange var repository = CreateRepository(); var guid1 = "19572b19-d1c0-4a51-98b4-fcc2658f73d3"; var guid2 = "f66e2b1e-f420-4a22-a067-8dd2f6fcda86"; - AddResource(repository, "app1", guid1); - AddResource(repository, "app1", guid2); + await AddResource(repository, "app1", guid1); + await AddResource(repository, "app1", guid2); // Act var resources = repository.GetResources(); @@ -147,7 +147,7 @@ public void GetResourceName_GuidInstanceId_Shorten() } [Fact] - public void GetResourceName_Version7GuidInstanceId_ShortenedNamesDiffer() + public async Task GetResourceName_Version7GuidInstanceId_ShortenedNamesDiffer() { // Arrange var repository = CreateRepository(); @@ -156,8 +156,8 @@ public void GetResourceName_Version7GuidInstanceId_ShortenedNamesDiffer() var guid1 = "01890a5d-ac96-774b-bcce-b302099a8057"; var guid2 = "01890a5d-ac96-7768-a3e2-34c4a0e9f6ad"; - AddResource(repository, "app1", guid1); - AddResource(repository, "app1", guid2); + await AddResource(repository, "app1", guid1); + await AddResource(repository, "app1", guid2); // Act var resources = repository.GetResources(); @@ -173,10 +173,10 @@ public void GetResourceName_Version7GuidInstanceId_ShortenedNamesDiffer() Assert.Equal("app1-a0e9f6ad", instance2Name); } - private static void AddResource(ITelemetryRepository repository, string name, string? instanceId = null) + private static async Task AddResource(ITelemetryRepository repository, string name, string? instanceId = null) { var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs index 3401ffb9217..3a294e4cbf1 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/SqliteTelemetryPersistenceTests.cs @@ -25,7 +25,7 @@ namespace Aspire.Dashboard.Tests.TelemetryRepositoryTests; public sealed class SqliteTelemetryPersistenceTests(ITestOutputHelper testOutputHelper) { [Fact] - public void Cache_UsesCanonicalResourceViewAndScopeAcrossSignals() + public async Task Cache_UsesCanonicalResourceViewAndScopeAcrossSignals() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var startTime = new DateTime(2025, 4, 5, 6, 7, 8, DateTimeKind.Utc); @@ -33,7 +33,7 @@ public void Cache_UsesCanonicalResourceViewAndScopeAcrossSignals() var scope = CreateScope(name: "SharedScope", attributes: [KeyValuePair.Create("scope-key", "scope-value")]); using var repository = CreateRepository(workspace.Path); - repository.AddLogs(new AddContext(), new RepeatedField + await repository.AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -41,7 +41,7 @@ public void Cache_UsesCanonicalResourceViewAndScopeAcrossSignals() ScopeLogs = { new ScopeLogs { Scope = scope, LogRecords = { CreateLogRecord() } } } } }); - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -56,7 +56,7 @@ public void Cache_UsesCanonicalResourceViewAndScopeAcrossSignals() } } }); - repository.AddMetrics(new AddContext(), new RepeatedField + await repository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -78,13 +78,13 @@ public void Cache_UsesCanonicalResourceViewAndScopeAcrossSignals() } [Fact] - public void Cache_HydratesPersistedMetadataOnce() + public async Task Cache_HydratesPersistedMetadataOnce() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var startTime = new DateTime(2025, 4, 5, 6, 7, 8, DateTimeKind.Utc); using (var repository = CreateRepository(workspace.Path)) { - repository.AddLogs(new AddContext(), new RepeatedField + await repository.AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -92,7 +92,7 @@ public void Cache_HydratesPersistedMetadataOnce() ScopeLogs = { new ScopeLogs { Scope = CreateScope("TestScope"), LogRecords = { CreateLogRecord() } } } } }); - repository.AddMetrics(new AddContext(), new RepeatedField + await repository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -121,14 +121,14 @@ public void Cache_HydratesPersistedMetadataOnce() } [Fact] - public void Logs_ReopenFromNormalizedRowsWithStableIds() + public async Task Logs_ReopenFromNormalizedRowsWithStableIds() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var databasePath = GetDatabasePath(workspace.Path); long logId; using (var repository = CreateRepository(workspace.Path)) { - repository.AddLogs(new AddContext(), new RepeatedField + await repository.AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -167,7 +167,7 @@ public void Logs_ReopenFromNormalizedRowsWithStableIds() } [Fact] - public void Traces_ReopenFromNormalizedRowsWithStableEventIds() + public async Task Traces_ReopenFromNormalizedRowsWithStableEventIds() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var databasePath = GetDatabasePath(workspace.Path); @@ -181,7 +181,7 @@ public void Traces_ReopenFromNormalizedRowsWithStableEventIds() Guid eventId; using (var repository = CreateRepository(workspace.Path)) { - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -235,14 +235,14 @@ public void Traces_ReopenFromNormalizedRowsWithStableEventIds() } [Fact] - public void Metrics_ReopenFromNormalizedRows() + public async Task Metrics_ReopenFromNormalizedRows() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var databasePath = GetDatabasePath(workspace.Path); var startTime = new DateTime(2025, 2, 3, 4, 5, 6, DateTimeKind.Utc); using (var repository = CreateRepository(workspace.Path)) { - repository.AddMetrics(new AddContext(), new RepeatedField + await repository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -290,7 +290,76 @@ public void Metrics_ReopenFromNormalizedRows() } [Fact] - public void Metrics_EquivalentAttributesShareIndexedDimension() + public async Task Metrics_HistogramPackedStorage_ReopensAndRejectsChangedBucketCountLength() + { + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var databasePath = GetDatabasePath(workspace.Path); + var startTime = new DateTime(2025, 2, 3, 4, 5, 6, DateTimeKind.Utc); + using (var repository = CreateRepository(workspace.Path)) + { + var histogram = CreateHistogramMetric("histogram", startTime); + histogram.Histogram.DataPoints[0].ExplicitBounds.Clear(); + histogram.Histogram.DataPoints[0].ExplicitBounds.Add([1, 2]); + var addContext = new AddContext(); + await repository.AddMetricsAsync(addContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = { new ScopeMetrics { Scope = CreateScope("TestMeter"), Metrics = { histogram } } } + } + }); + Assert.Equal(1, addContext.SuccessCount); + Assert.Equal(0, addContext.FailureCount); + } + + using (var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False")) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT length(bucket_counts) FROM telemetry_metric_points;"; + Assert.Equal(24L, command.ExecuteScalar()); + command.CommandText = "SELECT length(explicit_bounds) FROM telemetry_metric_points;"; + Assert.Equal(16L, command.ExecuteScalar()); + command.CommandText = """ + SELECT COUNT(*) + FROM sqlite_schema + WHERE type = 'table' + AND name IN ('telemetry_metric_histograms', 'telemetry_metric_histogram_bucket_counts', 'telemetry_metric_histogram_explicit_bounds'); + """; + Assert.Equal(0L, command.ExecuteScalar()); + } + + using var reopenedRepository = CreateRepository(workspace.Path); + var changedHistogram = CreateHistogramMetric("histogram", startTime.AddMinutes(1)); + changedHistogram.Histogram.DataPoints[0].BucketCounts.Add(4); + var changedContext = new AddContext(); + await reopenedRepository.AddMetricsAsync(changedContext, new RepeatedField + { + new ResourceMetrics + { + Resource = CreateResource(), + ScopeMetrics = { new ScopeMetrics { Scope = CreateScope("TestMeter"), Metrics = { changedHistogram } } } + } + }); + + Assert.Equal(0, changedContext.SuccessCount); + Assert.Equal(1, changedContext.FailureCount); + var instrument = reopenedRepository.GetInstrument(new GetInstrumentRequest + { + ResourceKey = new ResourceKey("TestService", "TestId"), + MeterName = "TestMeter", + InstrumentName = "histogram", + StartTime = DateTime.MinValue, + EndTime = DateTime.MaxValue + }); + var value = Assert.IsType(Assert.Single(Assert.Single(instrument!.Dimensions).Values)); + Assert.Equal([1UL, 2UL, 3UL], value.Values); + Assert.Equal([1d, 2d], value.ExplicitBounds); + } + + [Fact] + public async Task Metrics_EquivalentAttributesShareIndexedDimension() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var databasePath = GetDatabasePath(workspace.Path); @@ -305,7 +374,7 @@ public void Metrics_EquivalentAttributesShareIndexedDimension() new[] { KeyValuePair.Create("first", "different") } }) { - repository.AddMetrics(addContext, new RepeatedField + await repository.AddMetricsAsync(addContext, new RepeatedField { new ResourceMetrics { @@ -334,7 +403,7 @@ public void Metrics_EquivalentAttributesShareIndexedDimension() } [Fact] - public void Scopes_AreSharedAcrossLogsTracesAndMetrics() + public async Task Scopes_AreSharedAcrossLogsTracesAndMetrics() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var databasePath = GetDatabasePath(workspace.Path); @@ -342,7 +411,7 @@ public void Scopes_AreSharedAcrossLogsTracesAndMetrics() var scope = CreateScope(name: "SharedScope", attributes: [KeyValuePair.Create("scope-key", "scope-value")]); using (var repository = CreateRepository(workspace.Path)) { - repository.AddLogs(new AddContext(), new RepeatedField + await repository.AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -357,7 +426,7 @@ public void Scopes_AreSharedAcrossLogsTracesAndMetrics() } } }); - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -372,7 +441,7 @@ public void Scopes_AreSharedAcrossLogsTracesAndMetrics() } } }); - repository.AddMetrics(new AddContext(), new RepeatedField + await repository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -426,7 +495,7 @@ FROM sqlite_schema } [Fact] - public void Scopes_ReopenAndReusePersistedScopesWithAndWithoutAttributes() + public async Task Scopes_ReopenAndReusePersistedScopesWithAndWithoutAttributes() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var databasePath = GetDatabasePath(workspace.Path); @@ -437,7 +506,7 @@ public void Scopes_ReopenAndReusePersistedScopesWithAndWithoutAttributes() { using var repository = CreateRepository(workspace.Path); var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField + await repository.AddLogsAsync(addContext, new RepeatedField { new ResourceLogs { @@ -464,14 +533,14 @@ public void Scopes_ReopenAndReusePersistedScopesWithAndWithoutAttributes() } [Fact] - public void ResourceViews_EquivalentAttributesShareNormalizedRows() + public async Task ResourceViews_EquivalentAttributesShareNormalizedRows() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var databasePath = GetDatabasePath(workspace.Path); using (var repository = CreateRepository(workspace.Path)) { var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField + await repository.AddLogsAsync(addContext, new RepeatedField { new ResourceLogs { @@ -511,13 +580,13 @@ public void ResourceViews_EquivalentAttributesShareNormalizedRows() } [Fact] - public void ResourceViews_LimitRejectsNewNormalizedRow() + public async Task ResourceViews_LimitRejectsNewNormalizedRow() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var databasePath = GetDatabasePath(workspace.Path); using var repository = CreateRepository(workspace.Path); var addContext = new AddContext(); - repository.AddLogs(addContext, new RepeatedField + await repository.AddLogsAsync(addContext, new RepeatedField { new ResourceLogs { @@ -554,7 +623,7 @@ FROM telemetry_resources Assert.Equal((long)TelemetryRepositoryLimits.MaxResourceViewCount, command.ExecuteScalar()); } - repository.AddLogs(addContext, new RepeatedField + await repository.AddLogsAsync(addContext, new RepeatedField { new ResourceLogs { @@ -574,14 +643,14 @@ FROM telemetry_resources } [Fact] - public void Scopes_AreDeletedAfterTheirFinalOwnerIsCleared() + public async Task Scopes_AreDeletedAfterTheirFinalOwnerIsCleared() { using var workspace = TemporaryWorkspace.Create(testOutputHelper); var databasePath = GetDatabasePath(workspace.Path); var startTime = new DateTime(2025, 3, 4, 5, 6, 7, DateTimeKind.Utc); var scope = CreateScope("SharedScope"); using var repository = CreateRepository(workspace.Path); - repository.AddLogs(new AddContext(), new RepeatedField + await repository.AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -589,7 +658,7 @@ public void Scopes_AreDeletedAfterTheirFinalOwnerIsCleared() ScopeLogs = { new ScopeLogs { Scope = scope, LogRecords = { CreateLogRecord() } } } } }); - repository.AddTraces(new AddContext(), new RepeatedField + await repository.AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -604,7 +673,7 @@ public void Scopes_AreDeletedAfterTheirFinalOwnerIsCleared() } } }); - repository.AddMetrics(new AddContext(), new RepeatedField + await repository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics { @@ -620,11 +689,11 @@ public void Scopes_AreDeletedAfterTheirFinalOwnerIsCleared() } }); - repository.ClearStructuredLogs(); + await repository.ClearStructuredLogsAsync(); Assert.Equal(1L, GetScopeCount(databasePath)); - repository.ClearTraces(); + await repository.ClearTracesAsync(); Assert.Equal(1L, GetScopeCount(databasePath)); - repository.ClearMetrics(); + await repository.ClearMetricsAsync(); Assert.Equal(0L, GetScopeCount(databasePath)); } diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs index 3522cc32986..ae7c57fe448 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryLimitTests.cs @@ -17,14 +17,14 @@ public abstract class TelemetryLimitTests : TelemetryRepositoryTestBase private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); [Fact] - public void AddTraces_ExceedsResourceLimit_ReportsFailure() + public async Task AddTraces_ExceedsResourceLimit_ReportsFailure() { var repository = CreateRepository(maxResourceCount: 3); for (var i = 0; i < 3; i++) { var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField { new ResourceSpans { @@ -46,7 +46,7 @@ public void AddTraces_ExceedsResourceLimit_ReportsFailure() // Adding a 4th resource should fail. var failContext = new AddContext(); - repository.AsWriter().AddTraces(failContext, new RepeatedField + await repository.AsWriter().AddTracesAsync(failContext, new RepeatedField { new ResourceSpans { @@ -68,7 +68,7 @@ public void AddTraces_ExceedsResourceLimit_ReportsFailure() } [Fact] - public void AddTraces_ExistingResourceAfterLimitReached_Succeeds() + public async Task AddTraces_ExistingResourceAfterLimitReached_Succeeds() { var repository = CreateRepository(maxResourceCount: 2); @@ -76,7 +76,7 @@ public void AddTraces_ExistingResourceAfterLimitReached_Succeeds() for (var i = 0; i < 2; i++) { var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField { new ResourceSpans { @@ -96,7 +96,7 @@ public void AddTraces_ExistingResourceAfterLimitReached_Succeeds() // Adding data for an existing resource should still succeed. var successContext = new AddContext(); - repository.AsWriter().AddTraces(successContext, new RepeatedField + await repository.AsWriter().AddTracesAsync(successContext, new RepeatedField { new ResourceSpans { @@ -117,7 +117,7 @@ public void AddTraces_ExistingResourceAfterLimitReached_Succeeds() } [Fact] - public void AddMetrics_ExceedsInstrumentLimit_ReportsFailure() + public async Task AddMetrics_ExceedsInstrumentLimit_ReportsFailure() { var repository = CreateRepository(); @@ -129,7 +129,7 @@ public void AddMetrics_ExceedsInstrumentLimit_ReportsFailure() } var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField { new ResourceMetrics { @@ -153,7 +153,7 @@ public void AddMetrics_ExceedsInstrumentLimit_ReportsFailure() // Adding one more instrument should fail. var failContext = new AddContext(); - repository.AsWriter().AddMetrics(failContext, new RepeatedField + await repository.AsWriter().AddMetricsAsync(failContext, new RepeatedField { new ResourceMetrics { @@ -177,13 +177,13 @@ public void AddMetrics_ExceedsInstrumentLimit_ReportsFailure() } [Fact] - public void AddLogs_ExceedsResourceLimit_FailureCountIsLogRecordCount() + public async Task AddLogs_ExceedsResourceLimit_FailureCountIsLogRecordCount() { var repository = CreateRepository(maxResourceCount: 1); // Fill the single resource slot. var setupContext = new AddContext(); - repository.AsWriter().AddLogs(setupContext, new RepeatedField + await repository.AsWriter().AddLogsAsync(setupContext, new RepeatedField { new ResourceLogs { @@ -203,7 +203,7 @@ public void AddLogs_ExceedsResourceLimit_FailureCountIsLogRecordCount() // Attempt to add logs for a new resource with multiple scopes and records. // FailureCount must equal total log records, not number of scopes. var failContext = new AddContext(); - repository.AsWriter().AddLogs(failContext, new RepeatedField + await repository.AsWriter().AddLogsAsync(failContext, new RepeatedField { new ResourceLogs { @@ -238,13 +238,13 @@ public void AddLogs_ExceedsResourceLimit_FailureCountIsLogRecordCount() } [Fact] - public void AddMetrics_ExceedsResourceLimit_FailureCountIsDataPointCount() + public async Task AddMetrics_ExceedsResourceLimit_FailureCountIsDataPointCount() { var repository = CreateRepository(maxResourceCount: 1); // Fill the single resource slot. var setupContext = new AddContext(); - repository.AsWriter().AddMetrics(setupContext, new RepeatedField + await repository.AsWriter().AddMetricsAsync(setupContext, new RepeatedField { new ResourceMetrics { @@ -264,7 +264,7 @@ public void AddMetrics_ExceedsResourceLimit_FailureCountIsDataPointCount() // Attempt to add metrics for a new resource with multiple scopes and metrics. // FailureCount must equal total data points, not number of metrics. var failContext = new AddContext(); - repository.AsWriter().AddMetrics(failContext, new RepeatedField + await repository.AsWriter().AddMetricsAsync(failContext, new RepeatedField { new ResourceMetrics { @@ -300,13 +300,13 @@ public void AddMetrics_ExceedsResourceLimit_FailureCountIsDataPointCount() } [Fact] - public void AddTraces_ExceedsResourceLimit_FailureCountIsSpanCount() + public async Task AddTraces_ExceedsResourceLimit_FailureCountIsSpanCount() { var repository = CreateRepository(maxResourceCount: 1); // Fill the single resource slot. var setupContext = new AddContext(); - repository.AsWriter().AddTraces(setupContext, new RepeatedField + await repository.AsWriter().AddTracesAsync(setupContext, new RepeatedField { new ResourceSpans { @@ -326,7 +326,7 @@ public void AddTraces_ExceedsResourceLimit_FailureCountIsSpanCount() // Attempt to add traces for a new resource with multiple scopes and spans. // FailureCount must equal total spans, not number of scopes. var failContext = new AddContext(); - repository.AsWriter().AddTraces(failContext, new RepeatedField + await repository.AsWriter().AddTracesAsync(failContext, new RepeatedField { new ResourceSpans { @@ -359,7 +359,7 @@ public void AddTraces_ExceedsResourceLimit_FailureCountIsSpanCount() } [Fact] - public void AddLogs_ExceedsScopeLimit_ReportsFailure() + public async Task AddLogs_ExceedsScopeLimit_ReportsFailure() { var repository = CreateRepository(); @@ -377,12 +377,12 @@ public void AddLogs_ExceedsScopeLimit_ReportsFailure() scopeLogs.Add(rl); var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, scopeLogs); + await repository.AsWriter().AddLogsAsync(addContext, scopeLogs); Assert.Equal(0, addContext.FailureCount); // Adding one more scope should fail. var failContext = new AddContext(); - repository.AsWriter().AddLogs(failContext, new RepeatedField + await repository.AsWriter().AddLogsAsync(failContext, new RepeatedField { new ResourceLogs { @@ -408,7 +408,7 @@ public void AddLogs_ExceedsScopeLimit_ReportsFailure() } [Fact] - public void AddTraces_ExceedsScopeLimit_ReportsFailure() + public async Task AddTraces_ExceedsScopeLimit_ReportsFailure() { var repository = CreateRepository(); @@ -424,12 +424,12 @@ public void AddTraces_ExceedsScopeLimit_ReportsFailure() } var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField { rs }); + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField { rs }); Assert.Equal(0, addContext.FailureCount); // Adding one more scope should fail. var failContext = new AddContext(); - repository.AsWriter().AddTraces(failContext, new RepeatedField + await repository.AsWriter().AddTracesAsync(failContext, new RepeatedField { new ResourceSpans { @@ -454,7 +454,7 @@ public void AddTraces_ExceedsScopeLimit_ReportsFailure() } [Fact] - public void AddMetrics_ExceedsScopeLimit_ReportsFailure() + public async Task AddMetrics_ExceedsScopeLimit_ReportsFailure() { var repository = CreateRepository(); @@ -470,12 +470,12 @@ public void AddMetrics_ExceedsScopeLimit_ReportsFailure() } var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField { rm }); + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField { rm }); Assert.Equal(0, addContext.FailureCount); // Adding one more scope should fail. Each metric has 1 data point. var failContext = new AddContext(); - repository.AsWriter().AddMetrics(failContext, new RepeatedField + await repository.AsWriter().AddMetricsAsync(failContext, new RepeatedField { new ResourceMetrics { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs index 63aa24ae062..89bc9fa6ccd 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TelemetryRepositoryTests.cs @@ -23,7 +23,7 @@ public abstract class TelemetryRepositoryTests : TelemetryRepositoryTestBase private static readonly DateTime s_testTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); [Fact] - public void AddData_WhilePaused_IsDiscarded() + public async Task AddData_WhilePaused_IsDiscarded() { // Arrange var pauseManager = new PauseManager(); @@ -34,9 +34,9 @@ public void AddData_WhilePaused_IsDiscarded() pauseManager.SetStructuredLogsPaused(true); pauseManager.SetMetricsPaused(true); pauseManager.SetTracesPaused(true); - AddLog(); - AddMetric(); - AddTrace(); + await AddLog(); + await AddMetric(); + await AddTrace(); var resourceKey = new ResourceKey("resource", "resource"); Assert.Empty(repository.GetLogs(new GetLogsContext { ResourceKeys = [resourceKey], Count = 100, Filters = [], StartIndex = 0 }).Items); @@ -47,19 +47,19 @@ public void AddData_WhilePaused_IsDiscarded() pauseManager.SetMetricsPaused(false); pauseManager.SetTracesPaused(false); - AddLog(); - AddMetric(); - AddTrace(); + await AddLog(); + await AddMetric(); + await AddTrace(); Assert.Single(repository.GetLogs(new GetLogsContext { ResourceKeys = [resourceKey], Count = 100, Filters = [], StartIndex = 0 }).Items); var resource = repository.GetResource(resourceKey); Assert.NotNull(resource); Assert.NotEmpty(repository.GetInstrumentSummaries(resource.ResourceKey)); Assert.Single(repository.GetTraces(new GetTracesRequest { ResourceKeys = [resourceKey], Count = 100, Filters = [], StartIndex = 0 }).PagedResult.Items); - void AddLog() + async Task AddLog() { var addContext = new AddContext(); - repository.AsWriter().AddLogs(addContext, new RepeatedField() + await repository.AsWriter().AddLogsAsync(addContext, new RepeatedField() { new ResourceLogs { @@ -79,10 +79,10 @@ void AddLog() }); } - void AddMetric() + async Task AddMetric() { var addContext = new AddContext(); - repository.AsWriter().AddMetrics(addContext, new RepeatedField() + await repository.AsWriter().AddMetricsAsync(addContext, new RepeatedField() { new ResourceMetrics { @@ -113,10 +113,10 @@ void AddMetric() }); } - void AddTrace() + async Task AddTrace() { var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -201,13 +201,13 @@ public async Task Subscription_ExecuteAfterDispose_LogWithNoExecute() } [Fact] - public void ClearSelectedSignals_ClearsSelectedDataTypes_ForSpecificResources() + public async Task ClearSelectedSignals_ClearsSelectedDataTypes_ForSpecificResources() { // Arrange var repository = CreateRepository(); - AddTestData(repository, "resource1", "123"); - AddTestData(repository, "resource2", "456"); + await AddTestData(repository, "resource1", "123"); + await AddTestData(repository, "resource2", "456"); // Verify unviewed error logs exist before clearing var unviewedBefore = repository.GetResourceUnviewedErrorLogsCount(); @@ -221,7 +221,7 @@ public void ClearSelectedSignals_ClearsSelectedDataTypes_ForSpecificResources() { ["resource1-123"] = [AspireDataType.StructuredLogs] }; - repository.AsWriter().ClearSelectedSignals(selectedResources); + await repository.AsWriter().ClearSelectedSignalsAsync(selectedResources); // Assert - resource1 unviewed error logs cleared var unviewedAfter = repository.GetResourceUnviewedErrorLogsCount(); @@ -254,21 +254,21 @@ public void ClearSelectedSignals_ClearsSelectedDataTypes_ForSpecificResources() } [Fact] - public void ClearSelectedSignals_OtherResourcesRemainUnaffected() + public async Task ClearSelectedSignals_OtherResourcesRemainUnaffected() { // Arrange var repository = CreateRepository(); - AddTestData(repository, "resource1", "111"); - AddTestData(repository, "resource2", "222"); - AddTestData(repository, "resource3", "333"); + await AddTestData(repository, "resource1", "111"); + await AddTestData(repository, "resource2", "222"); + await AddTestData(repository, "resource3", "333"); // Act - Clear all data types for resource2 only var selectedResources = new Dictionary> { ["resource2-222"] = [AspireDataType.StructuredLogs, AspireDataType.Traces, AspireDataType.Metrics, AspireDataType.Resource] }; - repository.AsWriter().ClearSelectedSignals(selectedResources); + await repository.AsWriter().ClearSelectedSignalsAsync(selectedResources); // Assert - resource1 and resource3 data is unaffected var logs = repository.GetLogs(new GetLogsContext { ResourceKeys = [], StartIndex = 0, Count = 10, Filters = [] }); @@ -292,12 +292,12 @@ public void ClearSelectedSignals_OtherResourcesRemainUnaffected() } [Fact] - public void ClearSelectedSignals_ResourceRemovedWhenAllDataTypesCleared() + public async Task ClearSelectedSignals_ResourceRemovedWhenAllDataTypesCleared() { // Arrange var repository = CreateRepository(); - AddTestData(repository, "resource1", "123"); + await AddTestData(repository, "resource1", "123"); // Verify resource exists before clearing var resourceBefore = repository.GetResource(new ResourceKey("resource1", "123")); @@ -308,7 +308,7 @@ public void ClearSelectedSignals_ResourceRemovedWhenAllDataTypesCleared() { ["resource1-123"] = [AspireDataType.StructuredLogs, AspireDataType.Traces, AspireDataType.Metrics, AspireDataType.Resource] }; - repository.AsWriter().ClearSelectedSignals(selectedResources); + await repository.AsWriter().ClearSelectedSignalsAsync(selectedResources); // Assert - Resource is removed from the repository var resourceAfter = repository.GetResource(new ResourceKey("resource1", "123")); @@ -327,19 +327,19 @@ public void ClearSelectedSignals_ResourceRemovedWhenAllDataTypesCleared() } [Fact] - public void ClearSelectedSignals_PartialClear_ResourceNotRemoved() + public async Task ClearSelectedSignals_PartialClear_ResourceNotRemoved() { // Arrange var repository = CreateRepository(); - AddTestData(repository, "resource1", "123"); + await AddTestData(repository, "resource1", "123"); // Act - Clear only logs and traces for resource1 (not metrics) var selectedResources = new Dictionary> { ["resource1-123"] = [AspireDataType.StructuredLogs, AspireDataType.Traces] }; - repository.AsWriter().ClearSelectedSignals(selectedResources); + await repository.AsWriter().ClearSelectedSignalsAsync(selectedResources); // Assert - Resource still exists because not all data types were cleared var resourceAfter = repository.GetResource(new ResourceKey("resource1", "123")); @@ -365,7 +365,7 @@ public async Task WatchSpansAsync_ReturnsExistingSpans_ThenNewSpans() var repository = CreateRepository(); // Add initial span - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -409,7 +409,7 @@ public async Task WatchSpansAsync_ReturnsExistingSpans_ThenNewSpans() await firstSpanReceived.Task; // Add another span while watching - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -483,7 +483,7 @@ public async Task WatchLogsAsync_ReturnsExistingLogs_ThenNewLogs() var repository = CreateRepository(); // Add initial log - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField + await repository.AsWriter().AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -527,7 +527,7 @@ public async Task WatchLogsAsync_ReturnsExistingLogs_ThenNewLogs() await firstLogReceived.Task; // Add another log while watching - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField + await repository.AsWriter().AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -600,7 +600,7 @@ public async Task WatchSpansAsync_ReturnsExistingSpans_OrderedByStartTime() var repository = CreateRepository(); // Add spans with non-chronological start times across different traces - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -694,7 +694,7 @@ public async Task WatchSpansAsync_ReturnsExistingSpans_OrderedByStartTime_Across // Without explicit sorting, iterating trace-by-trace would yield: // T=1 (trace1), T=8 (trace1), then T=3 (trace2), T=5 (trace2) // Correct chronological order is: T=1, T=3, T=5, T=8 - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -769,7 +769,7 @@ public async Task WatchSpansAsync_FiltersById_WhenResourceKeyProvided() var repository = CreateRepository(); // Add spans for two different resources - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -825,13 +825,13 @@ public async Task WatchSpansAsync_FiltersById_WhenResourceKeyProvided() } [Fact] - public void GetTraces_MultipleResourceKeys_ReturnsMatchingTracesOnly() + public async Task GetTraces_MultipleResourceKeys_ReturnsMatchingTracesOnly() { var repository = CreateRepository(); - AddTestData(repository, "resource1", "inst1"); - AddTestData(repository, "resource2", "inst2"); - AddTestData(repository, "resource3", "inst3"); + await AddTestData(repository, "resource1", "inst1"); + await AddTestData(repository, "resource2", "inst2"); + await AddTestData(repository, "resource3", "inst3"); var key1 = new ResourceKey("resource1", "inst1"); var key2 = new ResourceKey("resource2", "inst2"); @@ -846,13 +846,13 @@ public void GetTraces_MultipleResourceKeys_ReturnsMatchingTracesOnly() } [Fact] - public void GetSpans_MultipleResourceKeys_ReturnsMatchingSpansOnly() + public async Task GetSpans_MultipleResourceKeys_ReturnsMatchingSpansOnly() { var repository = CreateRepository(); - AddTestData(repository, "service1", "inst1"); - AddTestData(repository, "service2", "inst2"); - AddTestData(repository, "service3", "inst3"); + await AddTestData(repository, "service1", "inst1"); + await AddTestData(repository, "service2", "inst2"); + await AddTestData(repository, "service3", "inst3"); // Act - query spans for service1 and service2 only var result = repository.GetSpans(new GetSpansRequest @@ -874,9 +874,9 @@ public async Task WatchSpansAsync_MultipleResourceKeys_FiltersCorrectly() { var repository = CreateRepository(); - AddTestData(repository, "service1", "inst1"); - AddTestData(repository, "service2", "inst2"); - AddTestData(repository, "service3", "inst3"); + await AddTestData(repository, "service1", "inst1"); + await AddTestData(repository, "service2", "inst2"); + await AddTestData(repository, "service3", "inst3"); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); var receivedSpans = new List(); @@ -905,9 +905,9 @@ public async Task WatchLogsAsync_MultipleResourceKeys_FiltersCorrectly() { var repository = CreateRepository(); - AddTestData(repository, "service1", "inst1"); - AddTestData(repository, "service2", "inst2"); - AddTestData(repository, "service3", "inst3"); + await AddTestData(repository, "service1", "inst1"); + await AddTestData(repository, "service2", "inst2"); + await AddTestData(repository, "service3", "inst3"); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); var receivedLogs = new List(); @@ -949,7 +949,7 @@ public async Task WatchLogsAsync_FiltersAppliedWhenPushing() }; // Add an initial matching log so we know when watcher is ready - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField + await repository.AsWriter().AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -994,7 +994,7 @@ public async Task WatchLogsAsync_FiltersAppliedWhenPushing() await firstLogReceived.Task; // Add more logs - one matches filter, one doesn't - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField + await repository.AsWriter().AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1040,7 +1040,7 @@ public async Task WatchLogsAsync_SeverityFilterApplied() }; // Add an initial error log so we know when watcher is ready - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField + await repository.AsWriter().AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1085,7 +1085,7 @@ public async Task WatchLogsAsync_SeverityFilterApplied() await firstLogReceived.Task; // Add logs with different severity levels - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField + await repository.AsWriter().AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1122,7 +1122,7 @@ public async Task WatchLogsAsync_TextFragmentsFilterApplied() var repository = CreateRepository(); // Add initial logs — one matches text fragments, one doesn't - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField + await repository.AsWriter().AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1172,7 +1172,7 @@ public async Task WatchLogsAsync_TextFragmentsFilterApplied() await firstLogReceived.Task; // Add more logs — one matches both fragments, one matches only one - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField + await repository.AsWriter().AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1225,7 +1225,7 @@ public async Task WatchLogsAsync_DisabledFiltersAreIgnored() }; // Add a matching log - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField + await repository.AsWriter().AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1268,7 +1268,7 @@ public async Task WatchLogsAsync_DisabledFiltersAreIgnored() await firstLogReceived.Task; // Push a new matching log - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField + await repository.AsWriter().AddLogsAsync(new AddContext(), new RepeatedField { new ResourceLogs { @@ -1322,7 +1322,7 @@ public async Task WatchSpansAsync_DisabledFiltersAreIgnored() }; // Add spans — one whose name contains "span1", one that doesn't - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -1365,7 +1365,7 @@ public async Task WatchSpansAsync_DisabledFiltersAreIgnored() await firstSpanReceived.Task; // Push a new span that matches the enabled filter - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -1395,11 +1395,11 @@ public async Task WatchSpansAsync_DisabledFiltersAreIgnored() #endregion - private static void AddTestData(ITelemetryRepository repository, string resourceName, string instanceId) + private static async Task AddTestData(ITelemetryRepository repository, string resourceName, string instanceId) { var compositeName = $"{resourceName}-{instanceId}"; - repository.AsWriter().AddLogs(new AddContext(), new RepeatedField() + await repository.AsWriter().AddLogsAsync(new AddContext(), new RepeatedField() { new ResourceLogs { @@ -1415,7 +1415,7 @@ private static void AddTestData(ITelemetryRepository repository, string resource } }); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -1434,7 +1434,7 @@ private static void AddTestData(ITelemetryRepository repository, string resource } }); - repository.AsWriter().AddMetrics(new AddContext(), new RepeatedField() + await repository.AsWriter().AddMetricsAsync(new AddContext(), new RepeatedField() { new ResourceMetrics { diff --git a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs index ca9094c03c2..11b9519337c 100644 --- a/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs +++ b/tests/Aspire.Dashboard.Tests/TelemetryRepositoryTests/TraceTests.cs @@ -41,14 +41,14 @@ public void ConvertSpanKind(OtlpSpanKind expected, Span.Types.SpanKind value) } [Fact] - public void AddTraces() + public async Task AddTraces() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -98,11 +98,11 @@ public void AddTraces() } [Fact] - public void GetTraceSummaries_ReturnsPageData() + public async Task GetTraceSummaries_ReturnsPageData() { var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField { new ResourceSpans { @@ -217,10 +217,10 @@ public void GetTraceSummaries_ReturnsPageData() } [Fact] - public void GetTraceSummaries_LateParent_PreservesResourceOrder() + public async Task GetTraceSummaries_LateParent_PreservesResourceOrder() { var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), + await repository.AsWriter().AddTracesAsync(new AddContext(), [ new ResourceSpans { @@ -243,7 +243,7 @@ public void GetTraceSummaries_LateParent_PreservesResourceOrder() } } ]); - repository.AsWriter().AddTraces(new AddContext(), + await repository.AsWriter().AddTracesAsync(new AddContext(), [ new ResourceSpans { @@ -284,11 +284,11 @@ public void GetTraceSummaries_LateParent_PreservesResourceOrder() } [Fact] - public void GetTraceSummaries_SameOrderTime_UninstrumentedPeerAfterInstrumentedResource() + public async Task GetTraceSummaries_SameOrderTime_UninstrumentedPeerAfterInstrumentedResource() { var outgoingPeerResolver = new TestOutgoingPeerResolver(onResolve: _ => ("dashboard.db", null)); var repository = CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); - repository.AsWriter().AddTraces(new AddContext(), + await repository.AsWriter().AddTracesAsync(new AddContext(), [ new ResourceSpans { @@ -335,10 +335,10 @@ public void GetTraceSummaries_SameOrderTime_UninstrumentedPeerAfterInstrumentedR } [Fact] - public void GetTraceSummaries_IncrementalAppend_UpdatesSummaryValues() + public async Task GetTraceSummaries_IncrementalAppend_UpdatesSummaryValues() { var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), + await repository.AsWriter().AddTracesAsync(new AddContext(), [ new ResourceSpans { @@ -360,7 +360,7 @@ public void GetTraceSummaries_IncrementalAppend_UpdatesSummaryValues() } } ]); - repository.AsWriter().AddTraces(new AddContext(), + await repository.AsWriter().AddTracesAsync(new AddContext(), [ new ResourceSpans { @@ -412,7 +412,7 @@ public void GetTraceSummaries_IncrementalAppend_UpdatesSummaryValues() } [Fact] - public void AddTraces_SelfParent_Reject() + public async Task AddTraces_SelfParent_Reject() { // Arrange var testSink = new TestSink(); @@ -422,7 +422,7 @@ public void AddTraces_SelfParent_Reject() // Act var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -467,14 +467,14 @@ public void AddTraces_SelfParent_Reject() } [Fact] - public void AddTraces_MultipleSpansLoop_Reject() + public async Task AddTraces_MultipleSpansLoop_Reject() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -521,10 +521,10 @@ public void AddTraces_MultipleSpansLoop_Reject() } [Fact] - public void AddTraces_CircularReferenceAcrossIngestionCalls_Reject() + public async Task AddTraces_CircularReferenceAcrossIngestionCalls_Reject() { var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -545,7 +545,7 @@ public void AddTraces_CircularReferenceAcrossIngestionCalls_Reject() }); var context = new AddContext(); - repository.AsWriter().AddTraces(context, new RepeatedField + await repository.AsWriter().AddTracesAsync(context, new RepeatedField { new ResourceSpans { @@ -578,14 +578,14 @@ public void AddTraces_CircularReferenceAcrossIngestionCalls_Reject() } [Fact] - public void AddTraces_DuplicateTraceIds_Reject() + public async Task AddTraces_DuplicateTraceIds_Reject() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -632,14 +632,14 @@ public void AddTraces_DuplicateTraceIds_Reject() } [Fact] - public void AddTraces_Scope_Multiple() + public async Task AddTraces_Scope_Multiple() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -657,7 +657,7 @@ public void AddTraces_Scope_Multiple() } } }); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -709,14 +709,14 @@ public void AddTraces_Scope_Multiple() } [Fact] - public void AddTraces_Traces_MultipleOutOrOrder() + public async Task AddTraces_Traces_MultipleOutOrOrder() { // Arrange var repository = CreateRepository(); // Act var addContext1 = new AddContext(); - repository.AsWriter().AddTraces(addContext1, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext1, new RepeatedField() { new ResourceSpans { @@ -736,7 +736,7 @@ public void AddTraces_Traces_MultipleOutOrOrder() Assert.Equal(0, addContext1.FailureCount); var addContext2 = new AddContext(); - repository.AsWriter().AddTraces(addContext2, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext2, new RepeatedField() { new ResourceSpans { @@ -785,7 +785,7 @@ public void AddTraces_Traces_MultipleOutOrOrder() }); var addContext3 = new AddContext(); - repository.AsWriter().AddTraces(addContext3, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext3, new RepeatedField() { new ResourceSpans { @@ -829,13 +829,13 @@ public void AddTraces_Traces_MultipleOutOrOrder() } [Fact] - public void AddTraces_Spans_MultipleOutOrOrder() + public async Task AddTraces_Spans_MultipleOutOrOrder() { // Arrange var repository = CreateRepository(); // Act - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -881,13 +881,13 @@ public void AddTraces_Spans_MultipleOutOrOrder() } [Fact] - public void AddTraces_SpanEvents_ReturnData() + public async Task AddTraces_SpanEvents_ReturnData() { // Arrange var repository = CreateRepository(); // Act - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -957,13 +957,13 @@ public void AddTraces_SpanEvents_ReturnData() } [Fact] - public void AddTraces_SpanLinks_ReturnData() + public async Task AddTraces_SpanLinks_ReturnData() { // Arrange var repository = CreateRepository(); // Act - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -1042,14 +1042,14 @@ public void AddTraces_SpanLinks_ReturnData() } [Fact] - public void GetTraces_ReturnCopies() + public async Task GetTraces_ReturnCopies() { // Arrange var repository = CreateRepository(); // Act var addContext1 = new AddContext(); - repository.AsWriter().AddTraces(addContext1, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext1, new RepeatedField() { new ResourceSpans { @@ -1100,7 +1100,7 @@ public void GetTraces_ReturnCopies() } [Fact] - public void AddTraces_AttributeAndEventLimits_LimitsApplied() + public async Task AddTraces_AttributeAndEventLimits_LimitsApplied() { // Arrange var repository = CreateRepository(maxAttributeCount: 5, maxAttributeLength: 16, maxSpanEventCount: 5); @@ -1120,7 +1120,7 @@ public void AddTraces_AttributeAndEventLimits_LimitsApplied() // Act var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1194,13 +1194,13 @@ public void AddTraces_AttributeAndEventLimits_LimitsApplied() } [Fact] - public void AddTraces_Links_BacklinksPopulated() + public async Task AddTraces_Links_BacklinksPopulated() { // Arrange var repository = CreateRepository(); // Act - AddTrace(repository, "1", s_testTime); + await AddTrace(repository, "1", s_testTime); var traces = repository.GetTraces(new GetTracesRequest { ResourceKeys = [], @@ -1236,7 +1236,7 @@ public void AddTraces_Links_BacklinksPopulated() } [Fact] - public void AddTraces_ExceedLimit_OrderedByTimestampAndTraceId() + public async Task AddTraces_ExceedLimit_OrderedByTimestampAndTraceId() { // Arrange const int MaxTraceCount = 10; @@ -1257,7 +1257,7 @@ public void AddTraces_ExceedLimit_OrderedByTimestampAndTraceId() try { - AddTrace(repository, traceId, startTime); + await AddTrace(repository, traceId, startTime); } catch (Exception ex) { @@ -1294,7 +1294,7 @@ public void AddTraces_ExceedLimit_OrderedByTimestampAndTraceId() Assert.Equal(MaxTraceCount * 2, traces.PagedResult.Items.SelectMany(t => t.Spans).SelectMany(s => s.Links).Count()); } - private static void AddTrace(ITelemetryRepository repository, string traceId, DateTime startTime) + private static async Task AddTrace(ITelemetryRepository repository, string traceId, DateTime startTime) { var addContext = new AddContext(); @@ -1317,7 +1317,7 @@ private static void AddTrace(ITelemetryRepository repository, string traceId, Da } }; - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1347,14 +1347,14 @@ private static void AddTrace(ITelemetryRepository repository, string traceId, Da } [Fact] - public void AddTraces_MultipleRootSpans_RootSpanIsEarliestWithoutParent() + public async Task AddTraces_MultipleRootSpans_RootSpanIsEarliestWithoutParent() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1396,14 +1396,14 @@ public void AddTraces_MultipleRootSpans_RootSpanIsEarliestWithoutParent() } [Fact] - public void GetTraces_MultipleInstances() + public async Task GetTraces_MultipleInstances() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1471,14 +1471,14 @@ public void GetTraces_MultipleInstances() } [Fact] - public void AddTraces_MissingAndEmptyInstanceIdsAreDistinct() + public async Task AddTraces_MissingAndEmptyInstanceIdsAreDistinct() { var repository = CreateRepository(); var missingInstanceIdResource = CreateResource(name: "resource", instanceId: "placeholder"); missingInstanceIdResource.Attributes.Remove(missingInstanceIdResource.Attributes.Single(attribute => attribute.Key == OtlpResource.SERVICE_INSTANCE_ID)); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField { new ResourceSpans { @@ -1514,11 +1514,11 @@ public void AddTraces_MissingAndEmptyInstanceIdsAreDistinct() } [Fact] - public void GetTraceFieldValues_AllFieldsMatchMaterializedTraces() + public async Task GetTraceFieldValues_AllFieldsMatchMaterializedTraces() { var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField { new ResourceSpans { @@ -1564,13 +1564,13 @@ public void GetTraceFieldValues_AllFieldsMatchMaterializedTraces() } [Fact] - public void GetTraces_AttributeFilters() + public async Task GetTraces_AttributeFilters() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1663,14 +1663,14 @@ public void GetTraces_AttributeFilters() [InlineData(KnownResourceFields.ServiceNameField, "TestPeer")] [InlineData(KnownSourceFields.NameField, "TestScope")] [InlineData(KnownTraceFields.DurationField, "540000")] - public void GetTraces_KnownFilters(string name, string value) + public async Task GetTraces_KnownFilters(string name, string value) { // Arrange var outgoingPeerResolver = new TestOutgoingPeerResolver(); var repository = CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1726,12 +1726,12 @@ public void GetTraces_KnownFilters(string name, string value) } [Fact] - public void GetTraces_FiltersPagingAndMaxDuration_ComputedFromAllMatchingTraces() + public async Task GetTraces_FiltersPagingAndMaxDuration_ComputedFromAllMatchingTraces() { var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1783,7 +1783,7 @@ public void GetTraces_FiltersPagingAndMaxDuration_ComputedFromAllMatchingTraces( } [Fact] - public void GetTraces_DurationFilter_AppliesTraceLevelDuration() + public async Task GetTraces_DurationFilter_AppliesTraceLevelDuration() { // Verifies that the duration filter uses the trace's overall duration (first span // start to latest span end), not individual span durations. A trace with a 100ms @@ -1792,7 +1792,7 @@ public void GetTraces_DurationFilter_AppliesTraceLevelDuration() var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1843,13 +1843,13 @@ public void GetTraces_DurationFilter_AppliesTraceLevelDuration() } [Fact] - public void GetTraces_NotEqualFilter_NonMatchingValue_ReturnsTrace() + public async Task GetTraces_NotEqualFilter_NonMatchingValue_ReturnsTrace() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1887,7 +1887,7 @@ public void GetTraces_NotEqualFilter_NonMatchingValue_ReturnsTrace() } [Fact] - public void AddTraces_OutOfOrder_FullName() + public async Task AddTraces_OutOfOrder_FullName() { // Arrange var repository = CreateRepository(); @@ -1901,7 +1901,7 @@ public void AddTraces_OutOfOrder_FullName() // Act 1 var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1926,7 +1926,7 @@ public void AddTraces_OutOfOrder_FullName() Assert.Equal("TestService: Test span. Id: 1-3", trace.FullName); // Act 2 - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1951,7 +1951,7 @@ public void AddTraces_OutOfOrder_FullName() Assert.Equal("TestService: Test span. Id: 1-2", trace.FullName); // Act 3 - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -1976,7 +1976,7 @@ public void AddTraces_OutOfOrder_FullName() Assert.Equal("TestService: Test span. Id: 1-1", trace.FullName); // Act 4 - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -2002,14 +2002,14 @@ public void AddTraces_OutOfOrder_FullName() } [Fact] - public void AddTraces_SameResourceDifferentProperties_MultipleResourceViews() + public async Task AddTraces_SameResourceDifferentProperties_MultipleResourceViews() { // Arrange var repository = CreateRepository(); // Act var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -2146,13 +2146,13 @@ public void AddTraces_SameResourceDifferentProperties_MultipleResourceViews() } [Fact] - public void RemoveTraces_All() + public async Task RemoveTraces_All() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -2205,7 +2205,7 @@ public void RemoveTraces_All() }); // Act - repository.AsWriter().ClearTraces(); + await repository.AsWriter().ClearTracesAsync(); // Assert Assert.Equal(0, addContext.FailureCount); @@ -2224,13 +2224,13 @@ public void RemoveTraces_All() } [Fact] - public void RemoveTraces_SelectedResource() + public async Task RemoveTraces_SelectedResource() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -2283,7 +2283,7 @@ public void RemoveTraces_SelectedResource() }); // Act - repository.AsWriter().ClearTraces(new ResourceKey("resource1", "123")); + await repository.AsWriter().ClearTracesAsync(new ResourceKey("resource1", "123")); // Assert Assert.Equal(0, addContext.FailureCount); @@ -2329,13 +2329,13 @@ public void RemoveTraces_SelectedResource() } [Fact] - public void RemoveTraces_MultipleSelectedResources() + public async Task RemoveTraces_MultipleSelectedResources() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -2388,7 +2388,7 @@ public void RemoveTraces_MultipleSelectedResources() }); // Act - repository.AsWriter().ClearTraces(new ResourceKey("resource1", null)); + await repository.AsWriter().ClearTracesAsync(new ResourceKey("resource1", null)); // Assert Assert.Equal(0, addContext.FailureCount); @@ -2417,13 +2417,13 @@ public void RemoveTraces_MultipleSelectedResources() } [Fact] - public void RemoveTraces_SelectedResource_SpansFromDifferentTrace() + public async Task RemoveTraces_SelectedResource_SpansFromDifferentTrace() { // Arrange var repository = CreateRepository(); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -2479,7 +2479,7 @@ public void RemoveTraces_SelectedResource_SpansFromDifferentTrace() }); // Act - repository.AsWriter().ClearTraces(new ResourceKey("resource1", null)); + await repository.AsWriter().ClearTracesAsync(new ResourceKey("resource1", null)); // Assert Assert.Equal(0, addContext.FailureCount); @@ -2508,7 +2508,7 @@ public void RemoveTraces_SelectedResource_SpansFromDifferentTrace() } [Fact] - public void AddTraces_HaveUninstrumentedPeers() + public async Task AddTraces_HaveUninstrumentedPeers() { // Arrange var outgoingPeerResolver = new TestOutgoingPeerResolver(); @@ -2516,7 +2516,7 @@ public void AddTraces_HaveUninstrumentedPeers() // Act var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -2606,7 +2606,7 @@ public async Task AddTraces_OnPeerUpdated_HaveUninstrumentedPeers() // Act var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -2703,13 +2703,13 @@ public async Task AddTraces_OnPeerUpdated_HaveUninstrumentedPeers() } [Fact] - public void AddTraces_NameOnlyPeerResolver_PersistsUninstrumentedPeer() + public async Task AddTraces_NameOnlyPeerResolver_PersistsUninstrumentedPeer() { var outgoingPeerResolver = new TestOutgoingPeerResolver(onResolve: _ => ("Browser Link", null)); var repository = CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField { new ResourceSpans { @@ -2752,7 +2752,7 @@ public async Task AddTraces_NameOnlyPeerResolverChange_PersistsUninstrumentedPee var repository = CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField { new ResourceSpans { @@ -2790,14 +2790,14 @@ public async Task AddTraces_NameOnlyPeerResolverChange_PersistsUninstrumentedPee } [Fact] - public void AddTraces_UninstrumentedPeer_InstanceIdDashes_AppKeyResolvedCorrectly() + public async Task AddTraces_UninstrumentedPeer_InstanceIdDashes_AppKeyResolvedCorrectly() { // Arrange var resource = ModelTestHelpers.CreateResource(resourceName: "test-abc-def", displayName: "test"); var outgoingPeerResolver = new TestOutgoingPeerResolver(onResolve: attributes => (resource.Name, resource)); var repository = CreateRepository(outgoingPeerResolvers: [outgoingPeerResolver]); var addContext = new AddContext(); - repository.AsWriter().AddTraces(addContext, new RepeatedField() + await repository.AsWriter().AddTracesAsync(addContext, new RepeatedField() { new ResourceSpans { @@ -2834,12 +2834,12 @@ public void AddTraces_UninstrumentedPeer_InstanceIdDashes_AppKeyResolvedCorrectl } [Fact] - public void GetSpans_ReturnsAllSpans() + public async Task GetSpans_ReturnsAllSpans() { // Arrange var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -2875,12 +2875,12 @@ public void GetSpans_ReturnsAllSpans() } [Fact] - public void GetSpans_FilterByTraceId_ReturnsMatchingSpans() + public async Task GetSpans_FilterByTraceId_ReturnsMatchingSpans() { // Arrange var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -2917,12 +2917,12 @@ public void GetSpans_FilterByTraceId_ReturnsMatchingSpans() } [Fact] - public void GetSpans_FilterByHasError_ReturnsErrorSpansOnly() + public async Task GetSpans_FilterByHasError_ReturnsErrorSpansOnly() { // Arrange var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -2958,12 +2958,12 @@ public void GetSpans_FilterByHasError_ReturnsErrorSpansOnly() } [Fact] - public void GetSpans_FilterByHasErrorFalse_ReturnsNonErrorSpansOnly() + public async Task GetSpans_FilterByHasErrorFalse_ReturnsNonErrorSpansOnly() { // Arrange var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -3001,11 +3001,11 @@ public void GetSpans_FilterByHasErrorFalse_ReturnsNonErrorSpansOnly() [Theory] [InlineData(FilterCondition.Equals, "1", "3")] [InlineData(FilterCondition.NotEqual, "2")] - public void GetTraces_StatusFilter_ReturnsMatchingTraces(FilterCondition condition, params string[] expectedTraceIds) + public async Task GetTraces_StatusFilter_ReturnsMatchingTraces(FilterCondition condition, params string[] expectedTraceIds) { var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -3052,12 +3052,12 @@ public void GetTraces_StatusFilter_ReturnsMatchingTraces(FilterCondition conditi } [Fact] - public void GetSpans_FilterByResource_ReturnsMatchingSpans() + public async Task GetSpans_FilterByResource_ReturnsMatchingSpans() { // Arrange var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -3109,12 +3109,12 @@ public void GetSpans_FilterByResource_ReturnsMatchingSpans() } [Fact] - public void GetSpans_FilterByDuration_ReturnsMatchingSpans() + public async Task GetSpans_FilterByDuration_ReturnsMatchingSpans() { // Arrange var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -3159,12 +3159,12 @@ public void GetSpans_FilterByDuration_ReturnsMatchingSpans() } [Fact] - public void GetSpans_FilterByTextFragments_ReturnsMatchingSpans() + public async Task GetSpans_FilterByTextFragments_ReturnsMatchingSpans() { // Arrange var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -3202,10 +3202,10 @@ public void GetSpans_FilterByTextFragments_ReturnsMatchingSpans() [Theory] [InlineData(KnownTraceFields.KindField, "Client")] [InlineData(KnownTraceFields.StatusField, "Error")] - public void GetSpans_FilterByKindOrStatusText_ReturnsMatchingSpans(string field, string value) + public async Task GetSpans_FilterByKindOrStatusText_ReturnsMatchingSpans(string field, string value) { var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), + await repository.AsWriter().AddTracesAsync(new AddContext(), [ new ResourceSpans { @@ -3269,12 +3269,12 @@ public void GetSpans_FilterByKindOrStatusText_ReturnsMatchingSpans(string field, } [Fact] - public void GetSpans_Pagination_ReturnsCorrectPage() + public async Task GetSpans_Pagination_ReturnsCorrectPage() { // Arrange var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -3311,12 +3311,12 @@ public void GetSpans_Pagination_ReturnsCorrectPage() } [Fact] - public void GetSpans_CombinedFilters_ReturnsMatchingSpans() + public async Task GetSpans_CombinedFilters_ReturnsMatchingSpans() { // Arrange var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -3374,12 +3374,12 @@ public void GetSpans_EmptyRepository_ReturnsEmpty() } [Fact] - public void GetSpans_UnknownResource_ReturnsEmpty() + public async Task GetSpans_UnknownResource_ReturnsEmpty() { // Arrange var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -3416,7 +3416,7 @@ public void GetSpans_UnknownResource_ReturnsEmpty() [InlineData("747261636531", 1)] // full hex trace ID — prefix match [InlineData("7472616", 1)] // 7 chars — meets ShortenedIdLength, prefix match [InlineData("747261", 0)] // 6 chars — below ShortenedIdLength, requires exact match - public void GetSpans_TraceIdPrefixLength_MatchesShortenedIds(string traceIdFilter, int expectedCount) + public async Task GetSpans_TraceIdPrefixLength_MatchesShortenedIds(string traceIdFilter, int expectedCount) { // Arrange var repository = CreateRepository(); @@ -3424,7 +3424,7 @@ public void GetSpans_TraceIdPrefixLength_MatchesShortenedIds(string traceIdFilte // Use a trace ID whose hex representation is "747261636531" (UTF-8 bytes of "trace1") var traceId = Encoding.UTF8.GetString(Convert.FromHexString("747261636531")); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField() + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField() { new ResourceSpans { @@ -3459,11 +3459,11 @@ public void GetSpans_TraceIdPrefixLength_MatchesShortenedIds(string traceIdFilte } [Fact] - public void GetSpans_DisabledFiltersAreIgnored() + public async Task GetSpans_DisabledFiltersAreIgnored() { var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3514,14 +3514,14 @@ public void GetSpans_DisabledFiltersAreIgnored() } [Fact] - public void GetTraces_NotContainsFilter_ExcludesTraceWhenAnySpanMatches() + public async Task GetTraces_NotContainsFilter_ExcludesTraceWhenAnySpanMatches() { // Verifies that a "not contains" filter on the trace Name field excludes the trace // when ANY span's name contains the filtered text, even if other spans in the same // trace do not contain it. This is the fix for https://github.com/microsoft/aspire/issues/18684. var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3567,13 +3567,13 @@ public void GetTraces_NotContainsFilter_ExcludesTraceWhenAnySpanMatches() } [Fact] - public void GetTraces_NotContainsFilter_IncludesTraceWhenNoSpanMatches() + public async Task GetTraces_NotContainsFilter_IncludesTraceWhenNoSpanMatches() { // Verifies that a "not contains" filter includes the trace when none of its spans' // names contain the filtered text. var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3618,13 +3618,13 @@ public void GetTraces_NotContainsFilter_IncludesTraceWhenNoSpanMatches() } [Fact] - public void GetTraces_NotEqualFilter_ExcludesTraceWhenAnySpanMatches() + public async Task GetTraces_NotEqualFilter_ExcludesTraceWhenAnySpanMatches() { // Verifies that a "not equal" filter excludes the trace when ANY span's field value // equals the filtered text. var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3668,14 +3668,14 @@ public void GetTraces_NotEqualFilter_ExcludesTraceWhenAnySpanMatches() } [Fact] - public void GetTraces_NotContainsWithPositiveFilter_CombinesCorrectly() + public async Task GetTraces_NotContainsWithPositiveFilter_CombinesCorrectly() { // Verifies that combining a positive filter with a negative filter works correctly: // the trace must have at least one span matching the positive filter AND all spans // must satisfy the negative filter. var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3754,14 +3754,14 @@ public void GetTraces_NotContainsWithPositiveFilter_CombinesCorrectly() } [Fact] - public void GetTraces_NotContainsFilter_AbsentAttributeDoesNotExcludeTrace() + public async Task GetTraces_NotContainsFilter_AbsentAttributeDoesNotExcludeTrace() { // Verifies that a negative filter on an attribute field does NOT exclude a trace // just because some spans lack the attribute. A span without the field trivially // satisfies "not contains X" — it cannot contain the value. var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3810,13 +3810,13 @@ public void GetTraces_NotContainsFilter_AbsentAttributeDoesNotExcludeTrace() } [Fact] - public void GetTraces_NotContainsFilter_AbsentAttributeWithViolatingSpanExcludes() + public async Task GetTraces_NotContainsFilter_AbsentAttributeWithViolatingSpanExcludes() { // Verifies that a trace is excluded when one span has the attribute and violates // the negative condition, even though another span lacks the attribute entirely. var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3862,13 +3862,13 @@ public void GetTraces_NotContainsFilter_AbsentAttributeWithViolatingSpanExcludes } [Fact] - public void GetSpans_NotContainsFilter_AbsentAttributeIncludesSpan() + public async Task GetSpans_NotContainsFilter_AbsentAttributeIncludesSpan() { // Verifies that span-level negative filtering correctly includes spans that lack the // filtered attribute. A span without the field trivially satisfies "not contains X". var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -3919,14 +3919,14 @@ public void GetSpans_NotContainsFilter_AbsentAttributeIncludesSpan() } [Fact] - public void GetTraces_NotEqualTimestampFilter_ExcludesTraceViaUnoptimizedPath() + public async Task GetTraces_NotEqualTimestampFilter_ExcludesTraceViaUnoptimizedPath() { // Verifies that the non-optimized MatchesFilters path (used for date/numeric fields) // correctly applies ALL-span semantics for negative filters. A timestamp NotEqual // filter excludes a trace when any span's timestamp matches the filter value. var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { new ResourceSpans { @@ -4002,11 +4002,11 @@ public sealed class SqliteTraceTests : TraceTests protected override bool UseSqlite => true; [Fact] - public void AddTraces_CircularReferenceAcrossPersistedAndIncomingSpans_Reject() + public async Task AddTraces_CircularReferenceAcrossPersistedAndIncomingSpans_Reject() { var testTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), + await repository.AsWriter().AddTracesAsync(new AddContext(), [ new ResourceSpans { @@ -4031,7 +4031,7 @@ public void AddTraces_CircularReferenceAcrossPersistedAndIncomingSpans_Reject() ]); var context = new AddContext(); - repository.AsWriter().AddTraces(context, + await repository.AsWriter().AddTracesAsync(context, [ new ResourceSpans { @@ -4070,11 +4070,11 @@ public void AddTraces_CircularReferenceAcrossPersistedAndIncomingSpans_Reject() } [Fact] - public void GetTraceSummaries_EqualStartTime_MatchesMaterializedTrace() + public async Task GetTraceSummaries_EqualStartTime_MatchesMaterializedTrace() { var testTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), + await repository.AsWriter().AddTracesAsync(new AddContext(), [ new ResourceSpans { @@ -4092,7 +4092,7 @@ public void GetTraceSummaries_EqualStartTime_MatchesMaterializedTrace() } } ]); - repository.AsWriter().AddTraces(new AddContext(), + await repository.AsWriter().AddTracesAsync(new AddContext(), [ new ResourceSpans { @@ -4125,12 +4125,12 @@ public void GetTraceSummaries_EqualStartTime_MatchesMaterializedTrace() } [Fact] - public void AddTraces_LargeAppend_DoesNotExceedSqliteParameterLimit() + public async Task AddTraces_LargeAppend_DoesNotExceedSqliteParameterLimit() { const int appendedSpanCount = 8_192; var testTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), + await repository.AsWriter().AddTracesAsync(new AddContext(), [ new ResourceSpans { @@ -4170,7 +4170,7 @@ public void AddTraces_LargeAppend_DoesNotExceedSqliteParameterLimit() } var context = new AddContext(); - repository.AsWriter().AddTraces(context, [resourceSpans]); + await repository.AsWriter().AddTracesAsync(context, [resourceSpans]); Assert.Equal(appendedSpanCount, context.SuccessCount); Assert.Equal(0, context.FailureCount); @@ -4185,11 +4185,11 @@ public void AddTraces_LargeAppend_DoesNotExceedSqliteParameterLimit() } [Fact] - public void GetTraceSummaries_AfterResourceDeletion_DeletesAffectedTraces() + public async Task GetTraceSummaries_AfterResourceDeletion_DeletesAffectedTraces() { var testTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); var repository = CreateRepository(); - repository.AsWriter().AddTraces(new AddContext(), + await repository.AsWriter().AddTracesAsync(new AddContext(), [ new ResourceSpans { @@ -4239,7 +4239,7 @@ public void GetTraceSummaries_AfterResourceDeletion_DeletesAffectedTraces() } ]); - repository.AsWriter().ClearSelectedSignals(new Dictionary> + await repository.AsWriter().ClearSelectedSignalsAsync(new Dictionary> { [new ResourceKey("frontend", "TestId").GetCompositeName()] = [AspireDataType.Resource] }); @@ -4269,10 +4269,10 @@ [new ResourceKey("frontend", "TestId").GetCompositeName()] = [AspireDataType.Res } [Fact] - public void AddTraces_BatchesSpansAndDetailsAcrossResources() + public async Task AddTraces_BatchesSpansAndDetailsAcrossResources() { var repository = Assert.IsType(CreateRepository()); - repository.AsWriter().AddTraces(new AddContext(), new RepeatedField + await repository.AsWriter().AddTracesAsync(new AddContext(), new RepeatedField { CreateResourceSpans("app-one", "trace-one", "warm-one-span"), CreateResourceSpans("app-two", "trace-two", "warm-two-span") @@ -4282,7 +4282,7 @@ public void AddTraces_BatchesSpansAndDetailsAcrossResources() using var parent = new Activity("trace ingestion test").Start(); var context = new AddContext(); - repository.AsWriter().AddTraces(context, new RepeatedField + await repository.AsWriter().AddTracesAsync(context, new RepeatedField { CreateResourceSpans("app-one", "trace-one", "span-one"), CreateResourceSpans("app-two", "trace-two", "span-two") @@ -4363,7 +4363,7 @@ static ResourceSpans CreateResourceSpans(string resourceName, string traceId, st } [Fact] - public void AddTraces_LargeSpanAndAttributeBatchesRoundTrip() + public async Task AddTraces_LargeSpanAndAttributeBatchesRoundTrip() { const int spanCount = 101; var repository = Assert.IsType(CreateRepository()); @@ -4387,7 +4387,7 @@ public void AddTraces_LargeSpanAndAttributeBatchesRoundTrip() } var context = new AddContext(); - repository.AsWriter().AddTraces(context, [resourceSpans]); + await repository.AsWriter().AddTracesAsync(context, [resourceSpans]); Assert.Equal(spanCount, context.SuccessCount); Assert.Equal(0, context.FailureCount); @@ -4403,11 +4403,11 @@ public void AddTraces_LargeSpanAndAttributeBatchesRoundTrip() } [Fact] - public void GetTraceSummaries_UsesPersistedResourceSummaries() + public async Task GetTraceSummaries_UsesPersistedResourceSummaries() { var repository = Assert.IsType(CreateRepository()); var testTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - repository.AsWriter().AddTraces(new AddContext(), + await repository.AsWriter().AddTracesAsync(new AddContext(), [ new ResourceSpans { diff --git a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs index 7960ae72575..9fab99944e2 100644 --- a/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs +++ b/tests/Shared/Telemetry/InMemoryTelemetryRepository.cs @@ -357,14 +357,14 @@ private void RaiseSubscriptionChanged(List subscriptions) } } - public void AddLogs(AddContext context, RepeatedField resourceLogs) + public Task AddLogsAsync(AddContext context, RepeatedField resourceLogs) { ThrowIfReadOnly(); if (_pauseManager.AreStructuredLogsPaused(out _)) { _logger.LogTrace("{Count} incoming structured log(s) ignored because of an active pause.", resourceLogs.Count); - return; + return Task.CompletedTask; } foreach (var rl in resourceLogs) @@ -386,6 +386,7 @@ public void AddLogs(AddContext context, RepeatedField resourceLogs } RaiseSubscriptionChanged(_logSubscriptions); + return Task.CompletedTask; } public void AddLogsCore(AddContext context, OtlpResourceView resourceView, RepeatedField scopeLogs) @@ -1394,7 +1395,7 @@ private void SetResourceHasMetrics(OtlpResource resource, bool value) /// Clears selected telemetry signals for specified resources. /// /// Dictionary mapping resource names to the data types to clear. - public void ClearSelectedSignals(Dictionary> selectedResources) + public Task ClearSelectedSignalsAsync(Dictionary> selectedResources) { ThrowIfReadOnly(); @@ -1440,9 +1441,17 @@ static bool IsDataTypeSelected(HashSet dataTypes, AspireDataType // Always remove everything if the resource is being removed. return dataTypes.Contains(dataType) || dataTypes.Contains(AspireDataType.Resource); } + + return Task.CompletedTask; } - public void ClearTraces(ResourceKey? resourceKey = null) + public Task ClearTracesAsync(ResourceKey? resourceKey = null) + { + ClearTraces(resourceKey); + return Task.CompletedTask; + } + + private void ClearTraces(ResourceKey? resourceKey) { ThrowIfReadOnly(); @@ -1506,7 +1515,13 @@ public void ClearTraces(ResourceKey? resourceKey = null) RaiseSubscriptionChanged(_tracesSubscriptions); } - public void ClearStructuredLogs(ResourceKey? resourceKey = null) + public Task ClearStructuredLogsAsync(ResourceKey? resourceKey = null) + { + ClearStructuredLogs(resourceKey); + return Task.CompletedTask; + } + + private void ClearStructuredLogs(ResourceKey? resourceKey) { ThrowIfReadOnly(); @@ -1570,7 +1585,13 @@ private void ClearResource(ResourceKey resourceKey) } } - public void ClearMetrics(ResourceKey? resourceKey = null) + public Task ClearMetricsAsync(ResourceKey? resourceKey = null) + { + ClearMetrics(resourceKey); + return Task.CompletedTask; + } + + private void ClearMetrics(ResourceKey? resourceKey) { ThrowIfReadOnly(); @@ -1740,14 +1761,14 @@ public bool HasUpdatedTrace(OtlpTrace trace) } } - public void AddMetrics(AddContext context, RepeatedField resourceMetrics) + public Task AddMetricsAsync(AddContext context, RepeatedField resourceMetrics) { ThrowIfReadOnly(); if (_pauseManager.AreMetricsPaused(out _)) { _logger.LogTrace("{Count} incoming metric(s) ignored because of an active pause.", resourceMetrics.Count); - return; + return Task.CompletedTask; } foreach (var rm in resourceMetrics) @@ -1770,6 +1791,7 @@ public void AddMetrics(AddContext context, RepeatedField resour } RaiseSubscriptionChanged(_metricsSubscriptions); + return Task.CompletedTask; } private void AddMetrics(ResourceEntry resourceEntry, AddContext context, RepeatedField scopeMetrics) @@ -1929,14 +1951,14 @@ private static OtlpAggregationTemporality MapAggregationTemporality(Metric metri }; } - public void AddTraces(AddContext context, RepeatedField resourceSpans) + public Task AddTracesAsync(AddContext context, RepeatedField resourceSpans) { ThrowIfReadOnly(); if (_pauseManager.AreTracesPaused(out _)) { _logger.LogTrace("{Count} incoming trace(s) ignored because of an active pause.", resourceSpans.Count); - return; + return Task.CompletedTask; } foreach (var rs in resourceSpans) @@ -1958,6 +1980,7 @@ public void AddTraces(AddContext context, RepeatedField resourceS } RaiseSubscriptionChanged(_tracesSubscriptions); + return Task.CompletedTask; } private static OtlpSpanStatusCode ConvertStatus(Status? status) From 6dae9ccdca8764fd593cc4c0a79b516f73946ebc Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Fri, 24 Jul 2026 13:35:53 +0800 Subject: [PATCH 75/87] Improve dashboard telemetry storage scalability --- .../Components/Pages/TraceDetail.razor | 6 +- .../Components/Pages/TraceDetail.razor.cs | 27 ++- .../Model/Otlp/SpanLogEntryViewModel.cs | 4 +- .../Model/Otlp/SpanWaterfallViewModel.cs | 5 +- .../Model/TelemetryExportService.cs | 38 ++-- .../Otlp/Storage/LogSummary.cs | 2 + .../SqliteTelemetryRepository.Caching.cs | 79 ++++---- .../Storage/SqliteTelemetryRepository.Logs.cs | 165 ++++++++-------- ...SqliteTelemetryRepository.Traces.Writes.cs | 11 -- .../Otlp/Storage/SqliteTelemetryRepository.cs | 24 ++- .../Model/SpanWaterfallViewModelTests.cs | 20 +- .../Model/TelemetryExportServiceTests.cs | 180 +++++++++++++++--- .../Model/TraceDetailPageViewModelTests.cs | 17 +- .../TelemetryRepositoryTests/LogTests.cs | 75 ++++++++ .../TelemetryRepositoryTests/MetricsTests.cs | 2 + .../SqliteTelemetryPersistenceTests.cs | 146 +++++++++++++- .../TelemetryRepositoryTests/TraceTests.cs | 7 +- .../Telemetry/InMemoryTelemetryRepository.cs | 2 + 18 files changed, 614 insertions(+), 196 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor index 28c8bdc0bae..26a6665c0ac 100644 --- a/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor +++ b/src/Aspire.Dashboard/Components/Pages/TraceDetail.razor @@ -288,13 +288,13 @@ @foreach (var item in context.SpanLogs) { var buttonId = $"{context.Span.SpanId}-{item.LogEntry.InternalId}"; - var eventName = StructureLogsDetailsViewModel.GetEventName(item.LogEntry, StructuredLogsLoc); + var eventName = item.LogEntry.EventName ?? StructuredLogsLoc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsEntryDetails)]; var isSelected = SelectedData?.LogEntryViewModel?.LogEntry.InternalId == item.LogEntry.InternalId; var htmlTooltip = item.Index < 500; var buttonColor = item.LogEntry.IsError ? "var(--error)" : spanColor;