Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
13 changes: 13 additions & 0 deletions .github/instructions/dashboard.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,16 @@ applyTo: "src/Aspire.Dashboard/**/*.{cs,razor,js}"
- For bounded channels feeding one consumer, prefer `BoundedChannelFullMode.DropOldest` and set `SingleReader = true`.
- Use `FormatHelpers` for culture-aware date/time/number display. Reserve invariant formatting for intentionally fixed diagnostic formats.
- Localize user-visible dashboard text with resource-backed localizers. Prefer typed localizers and `nameof` keys when practical, but existing model/helpers also generate localized UI text.

## Blazor components

- Avoid `@code` blocks and substantial C# logic in `.razor` files. Keep `.razor` files focused on markup, directives, and simple binding or event expressions; put component state, lifecycle methods, event handlers, and other logic in the matching `.razor.cs` code-behind partial class for better IDE and compiler support.
- Declare injected component dependencies as public, required, init-only properties:

```csharp
[Inject]
public required IDashboardClient DashboardClient { get; init; }
```

- `public` keeps dependencies visible to component and test infrastructure, `required` expresses that the component cannot operate without the service, and `init` prevents reassignment after component activation.
- Do not use non-public injected properties, mutable `set` accessors, or null-forgiving initializers such as `= null!;`. These weaken compile-time validation and hide missing dependencies when components are constructed in tests.
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
<PackageVersion Include="Microsoft.Agents.AI.OpenAI" Version="1.5.0" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.0.1" />
<PackageVersion Include="Microsoft.Data.SqlClient.Extensions.Azure" Version="1.0.0" />
<PackageVersion Include="Microsoft.Data.Sqlite.Core" Version="10.0.10" />
<PackageVersion Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.14.4" />
<PackageVersion Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.14.4" />
<PackageVersion Include="Milvus.Client" Version="2.3.0-preview.1" /> <!-- No stable release available -->
Expand All @@ -141,6 +142,7 @@
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageVersion Include="Qdrant.Client" Version="1.18.1" />
<PackageVersion Include="RabbitMQ.Client" Version="7.2.1" />
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
<PackageVersion Include="Spectre.Console" Version="0.57.2" />
<PackageVersion Include="StackExchange.Redis" Version="2.13.1" />
<PackageVersion Include="System.IO.Hashing" Version="10.0.8" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" />
<PackageReference Include="Microsoft.Data.Sqlite.Core" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" />
</ItemGroup>

<ItemGroup>
Expand Down
217 changes: 217 additions & 0 deletions benchmarks/Aspire.Dashboard.Benchmarks/SqliteTraceBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// 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 Aspire.Dashboard.ServiceClient;
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 static readonly DateTime s_generatedTraceStartTime = DateTime.UnixEpoch;

private string _temporaryDirectory = null!;
private DashboardSqliteDatabase _database = null!;
private SqliteTelemetryRepository _repository = null!;
private RepeatedField<ResourceSpans> _appendResourceSpans = null!;
private long _appendIndex;

[GlobalSetup]
public async Task Setup()
{
_temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-trace-benchmark-").FullName;
_database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "dashboard.db"));
_repository = CreateRepository(_database);

var resourceSpans = LoadResourceSpans();
var context = new AddContext();
await _repository.AddTracesAsync(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();
_database.ClearPool();
_database.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 async Task<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();
await _repository.AddTracesAsync(context, _appendResourceSpans);
return context.SuccessCount;
}

private static RepeatedField<ResourceSpans> LoadResourceSpans()
{
var traceFile = Environment.GetEnvironmentVariable(TraceFileEnvironmentVariable);
if (string.IsNullOrWhiteSpace(traceFile))
{
return CreateGeneratedResourceSpans();
}

var request = JsonParser.Default.Parse<ExportTraceServiceRequest>(File.ReadAllText(traceFile));
return request.ResourceSpans;
}

private static RepeatedField<ResourceSpans> 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"));
for (var spanIndex = 0; spanIndex < GeneratedSpanCount; spanIndex++)
{
var spanStartTime = s_generatedTraceStartTime.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<ResourceSpans> CreateAppendResourceSpans(RepeatedField<ResourceSpans> 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(DashboardSqliteDatabase database)
{
return new SqliteTelemetryRepository(
database,
NullLoggerFactory.Instance,
Options.Create(new DashboardOptions
{
TelemetryLimits = new TelemetryLimitOptions { MaxTraceCount = 1_000 }
}),
new PauseManager(),
TimeProvider.System,
[]);
}

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.ServiceClient;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Diagnosers;
Expand Down Expand Up @@ -63,39 +64,53 @@ public class TelemetryRepositoryBenchmarks
];

private RepeatedField<ResourceSpans> _resourceSpans = [];
private TelemetryRepository _queryRepository = null!;
private string _temporaryDirectory = null!;
private DashboardSqliteDatabase _queryDatabase = null!;
private SqliteTelemetryRepository _queryRepository = null!;

[GlobalSetup]
public void Setup()
public async Task Setup()
{
_resourceSpans = CreateResourceSpans(TraceCount, SpansPerTrace);
_queryRepository = CreateRepository();
_queryRepository.AddTraces(new AddContext(), _resourceSpans);
_temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-telemetry-benchmark-").FullName;
_queryDatabase = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "query.db"));
_queryRepository = CreateRepository(_queryDatabase);
await _queryRepository.AddTracesAsync(new AddContext(), _resourceSpans);
}

[GlobalCleanup]
public void Cleanup()
{
_queryRepository.Dispose();
_queryDatabase.ClearPool();
_queryDatabase.Dispose();
Directory.Delete(_temporaryDirectory, recursive: true);
}

[Benchmark(Description = "TelemetryRepository: add 10k spans")]
public int AddTracesLargeBatch()
public async Task<int> AddTracesLargeBatch()
{
using var repository = CreateRepository();
var context = new AddContext();
repository.AddTraces(context, _resourceSpans);
var temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-telemetry-add-benchmark-");
using var database = new DashboardSqliteDatabase(Path.Combine(temporaryDirectory.FullName, "add.db"));
int successCount;
using (var repository = CreateRepository(database))
{
var context = new AddContext();
await repository.AddTracesAsync(context, _resourceSpans);
successCount = context.SuccessCount;
}
database.ClearPool();
Directory.Delete(temporaryDirectory.FullName, recursive: true);

return context.SuccessCount;
return successCount;
}

[Benchmark(Description = "TelemetryRepository: query no filters")]
public int GetTracesNoFilters()
{
var result = _queryRepository.GetTraces(new GetTracesRequest
{
ResourceKey = null,
FilterText = string.Empty,
ResourceKeys = [],
Filters = [],
StartIndex = 0,
Count = 100
Expand All @@ -109,8 +124,7 @@ public int GetTracesDurationFilter()
{
var result = _queryRepository.GetTraces(new GetTracesRequest
{
ResourceKey = null,
FilterText = string.Empty,
ResourceKeys = [],
Filters = _durationFilters,
StartIndex = 0,
Count = 100
Expand All @@ -124,8 +138,7 @@ public int GetTracesNoMatchDurationFilter()
{
var result = _queryRepository.GetTraces(new GetTracesRequest
{
ResourceKey = null,
FilterText = string.Empty,
ResourceKeys = [],
Filters = _noMatchDurationFilters,
StartIndex = 0,
Count = 100
Expand All @@ -139,8 +152,7 @@ public int GetTracesNoMatchAttributeFilter()
{
var result = _queryRepository.GetTraces(new GetTracesRequest
{
ResourceKey = null,
FilterText = string.Empty,
ResourceKeys = [],
Filters = _noMatchAttributeFilters,
StartIndex = 0,
Count = 100
Expand All @@ -149,9 +161,10 @@ public int GetTracesNoMatchAttributeFilter()
return result.PagedResult.Items.Count;
}

private static TelemetryRepository CreateRepository()
private static SqliteTelemetryRepository CreateRepository(DashboardSqliteDatabase database)
{
return new TelemetryRepository(
return new SqliteTelemetryRepository(
database,
NullLoggerFactory.Instance,
Options.Create(new DashboardOptions
{
Expand All @@ -161,6 +174,7 @@ private static TelemetryRepository CreateRepository()
}
}),
new PauseManager(),
TimeProvider.System,
[]);
}

Expand Down
Loading
Loading