diff --git a/.github/instructions/dashboard.instructions.md b/.github/instructions/dashboard.instructions.md
index 1b91436274d..682cf19d3ec 100644
--- a/.github/instructions/dashboard.instructions.md
+++ b/.github/instructions/dashboard.instructions.md
@@ -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.
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/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..57681d2faf1
--- /dev/null
+++ b/benchmarks/Aspire.Dashboard.Benchmarks/SqliteTraceBenchmarks.cs
@@ -0,0 +1,216 @@
+// 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 string _temporaryDirectory = null!;
+ private DashboardSqliteDatabase _database = null!;
+ private SqliteTelemetryRepository _repository = null!;
+ private RepeatedField _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 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 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(DashboardSqliteDatabase database)
+ {
+ return new SqliteTelemetryRepository(
+ database,
+ 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..c3746e4dc01 100644
--- a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryBenchmarks.cs
+++ b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryBenchmarks.cs
@@ -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;
@@ -63,30 +64,45 @@ public class TelemetryRepositoryBenchmarks
];
private RepeatedField _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 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")]
@@ -94,8 +110,7 @@ public int GetTracesNoFilters()
{
var result = _queryRepository.GetTraces(new GetTracesRequest
{
- ResourceKey = null,
- FilterText = string.Empty,
+ ResourceKeys = [],
Filters = [],
StartIndex = 0,
Count = 100
@@ -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
@@ -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
@@ -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
@@ -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
{
diff --git a/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs
new file mode 100644
index 00000000000..fd2f469fef7
--- /dev/null
+++ b/benchmarks/Aspire.Dashboard.Benchmarks/TelemetryRepositoryMetricsBenchmarks.cs
@@ -0,0 +1,393 @@
+// 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;
+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 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(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 DashboardSqliteDatabase _database = null!;
+ private SqliteTelemetryRepository _queryRepository = null!;
+ private IReadOnlyList _incrementalCursors = null!;
+
+ [Params(1, 5)]
+ public int DimensionCount { get; set; }
+
+ [GlobalSetup(Target = nameof(GetMetricsLongDuration))]
+ public Task SetupMetrics() => SetupAsync(isHistogram: false);
+
+ [GlobalSetup(Target = nameof(GetHistogramMetricsLongDuration))]
+ public Task SetupHistogramMetrics() => SetupAsync(isHistogram: true);
+
+ [GlobalSetup(Target = nameof(GetMetricsLongDurationRollup))]
+ public Task SetupMetricsRollup() => SetupAsync(isHistogram: false);
+
+ [GlobalSetup(Target = nameof(GetHistogramMetricsLongDurationRollup))]
+ public Task SetupHistogramMetricsRollup() => SetupAsync(isHistogram: true);
+
+ [GlobalSetup(Target = nameof(GetMetricsIncrementalRollup))]
+ public Task SetupMetricsIncrementalRollup() => SetupIncrementalAsync(isHistogram: false, MetricInstrumentName);
+
+ [GlobalSetup(Target = nameof(GetHistogramMetricsIncrementalRollup))]
+ public Task SetupHistogramMetricsIncrementalRollup() => SetupIncrementalAsync(isHistogram: true, HistogramMetricInstrumentName);
+
+ private async Task SetupAsync(bool isHistogram)
+ {
+ _temporaryDirectory = Directory.CreateTempSubdirectory("aspire-dashboard-metrics-benchmark-").FullName;
+ _database = new DashboardSqliteDatabase(Path.Combine(_temporaryDirectory, "query.db"));
+ _queryRepository = CreateRepository(_database);
+ var addContext = new AddContext();
+ foreach (var batch in CreateLongDurationMetricBatches(DimensionCount, isHistogram))
+ {
+ await _queryRepository.AddMetricsAsync(addContext, batch);
+ }
+ if (addContext.FailureCount > 0)
+ {
+ throw new InvalidOperationException($"Failed to add {addContext.FailureCount} benchmark metric points.");
+ }
+ }
+
+ private async Task SetupIncrementalAsync(bool isHistogram, string instrumentName)
+ {
+ await SetupAsync(isHistogram);
+ 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(s_metricDataPointInterval)
+ };
+ }).ToArray();
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ _queryRepository.Dispose();
+ _database.ClearPool();
+ _database.Dispose();
+ Directory.Delete(_temporaryDirectory, recursive: true);
+ }
+
+ [Benchmark(Description = "TelemetryRepository: query 6h metrics display")]
+ public int GetMetricsLongDuration()
+ {
+ var instrument = GetLongDurationInstrument(MetricInstrumentName);
+
+ return instrument.Dimensions.Sum(dimension => dimension.Values.Count);
+ }
+
+ [Benchmark(Description = "TelemetryRepository: query 6h 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));
+ }
+
+ [Benchmark(Description = "TelemetryRepository: query 6h metrics with dashboard rollup")]
+ public int GetMetricsLongDurationRollup()
+ {
+ var instrument = GetLongDurationInstrument(MetricInstrumentName, s_metricDataPointInterval);
+
+ return instrument.Dimensions.Sum(dimension => dimension.Values.Count);
+ }
+
+ [Benchmark(Description = "TelemetryRepository: query 6h histogram metrics with dashboard rollup")]
+ public int GetHistogramMetricsLongDurationRollup()
+ {
+ 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 dashboard rollup")]
+ public int GetMetricsIncrementalRollup()
+ {
+ var instrument = GetLongDurationInstrument(MetricInstrumentName, s_metricDataPointInterval, _incrementalCursors);
+
+ return instrument.Dimensions.Sum(dimension => dimension.Values.Count);
+ }
+
+ [Benchmark(Description = "TelemetryRepository: query incremental histogram metrics with dashboard rollup")]
+ public int GetHistogramMetricsIncrementalRollup()
+ {
+ var instrument = GetLongDurationInstrument(HistogramMetricInstrumentName, s_metricDataPointInterval, _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.");
+
+ // 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 + s_metricHistoryDuration),
+ EndTime = endTime,
+ DataPointInterval = dataPointInterval,
+ PopulateExemplarAttributes = false,
+ DimensionCursors = dimensionCursors ?? []
+ }) ?? throw new InvalidOperationException($"Unable to find the benchmark metric '{instrumentName}'.");
+ }
+
+ private static SqliteTelemetryRepository CreateRepository(DashboardSqliteDatabase database)
+ {
+ return new SqliteTelemetryRepository(
+ database,
+ 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.Dry);
+
+ AddDiagnoser(MemoryDiagnoser.Default);
+ }
+ }
+}
\ No newline at end of file
diff --git a/playground/Stress/Stress.ApiService/LargeTelemetryGenerationOptions.cs b/playground/Stress/Stress.ApiService/LargeTelemetryGenerationOptions.cs
new file mode 100644
index 00000000000..5a45762ab07
--- /dev/null
+++ b/playground/Stress/Stress.ApiService/LargeTelemetryGenerationOptions.cs
@@ -0,0 +1,58 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Stress.ApiService;
+
+public sealed class LargeTelemetryGenerationOptions
+{
+ public bool GenerateTraces { get; init; } = true;
+ public int TraceCount { get; init; } = LargeTelemetryGenerator.TraceCount;
+ public int SpansPerTrace { get; init; } = LargeTelemetryGenerator.SpansPerTrace;
+ public bool GenerateLargeTrace { get; init; } = true;
+ public int LargeTraceSpanCount { get; init; } = LargeTelemetryGenerator.LargeTraceSpanCount;
+ public bool GenerateStructuredLogs { get; init; } = true;
+ public int StructuredLogCount { get; init; } = LargeTelemetryGenerator.StructuredLogCount;
+ public bool GenerateConsoleLogs { get; init; } = true;
+ public int ConsoleLogCount { get; init; } = LargeTelemetryGenerator.ConsoleLogCount;
+ public bool GenerateMetrics { get; init; } = true;
+ public int MetricDurationHours { get; init; } = LargeTelemetryGenerator.MetricDurationHours;
+ public int MetricDimensionCount { get; init; } = LargeTelemetryGenerator.MetricDimensionCount;
+
+ public string? GetValidationError()
+ {
+ if (!GenerateTraces && !GenerateLargeTrace && !GenerateStructuredLogs && !GenerateConsoleLogs && !GenerateMetrics)
+ {
+ return "At least one telemetry kind must be enabled.";
+ }
+ if (GenerateTraces && TraceCount <= 0)
+ {
+ return "TraceCount must be positive when trace generation is enabled.";
+ }
+ if (GenerateTraces && SpansPerTrace <= 0)
+ {
+ return "SpansPerTrace must be positive when trace generation is enabled.";
+ }
+ if (GenerateLargeTrace && LargeTraceSpanCount <= 0)
+ {
+ return "LargeTraceSpanCount must be positive when large trace generation is enabled.";
+ }
+ if (GenerateStructuredLogs && StructuredLogCount <= 0)
+ {
+ return "StructuredLogCount must be positive when structured log generation is enabled.";
+ }
+ if (GenerateConsoleLogs && ConsoleLogCount <= 0)
+ {
+ return "ConsoleLogCount must be positive when console log generation is enabled.";
+ }
+ if (GenerateMetrics && MetricDurationHours <= 0)
+ {
+ return "MetricDurationHours must be positive when metric generation is enabled.";
+ }
+ if (GenerateMetrics && MetricDimensionCount <= 0)
+ {
+ return "MetricDimensionCount must be positive when metric generation is enabled.";
+ }
+
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/playground/Stress/Stress.ApiService/LargeTelemetryGenerator.cs b/playground/Stress/Stress.ApiService/LargeTelemetryGenerator.cs
new file mode 100644
index 00000000000..aeb45206161
--- /dev/null
+++ b/playground/Stress/Stress.ApiService/LargeTelemetryGenerator.cs
@@ -0,0 +1,468 @@
+// 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 MetricDurationHours = 24;
+ 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(LargeTelemetryGenerationOptions options, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ if (options.GetValidationError() is { } validationError)
+ {
+ throw new ArgumentException(validationError, nameof(options));
+ }
+
+ 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"]);
+
+ if (options.GenerateTraces)
+ {
+ logger.LogInformation("Generating {TraceCount} traces with {SpansPerTrace} spans each.", options.TraceCount, options.SpansPerTrace);
+ var traceClient = new TraceService.TraceServiceClient(channel);
+ await ExportTracesAsync(traceClient, metadata, options.TraceCount, options.SpansPerTrace, cancellationToken);
+ }
+
+ if (options.GenerateLargeTrace)
+ {
+ logger.LogInformation("Generating one trace with {SpanCount} spans.", options.LargeTraceSpanCount);
+ var traceClient = new TraceService.TraceServiceClient(channel);
+ await ExportLargeTraceAsync(traceClient, metadata, options.LargeTraceSpanCount, cancellationToken);
+ }
+
+ if (options.GenerateStructuredLogs)
+ {
+ logger.LogInformation("Generating {LogCount} structured logs.", options.StructuredLogCount);
+ var logsClient = new LogsService.LogsServiceClient(channel);
+ await ExportStructuredLogsAsync(logsClient, metadata, options.StructuredLogCount, cancellationToken);
+ }
+
+ if (options.GenerateConsoleLogs)
+ {
+ logger.LogInformation("Writing {LogCount} console logs through ILogger.", options.ConsoleLogCount);
+ for (var logIndex = 0; logIndex < options.ConsoleLogCount; logIndex++)
+ {
+ logger.LogInformation("Large console log {LogIndex}.", logIndex + 1);
+ if ((logIndex + 1) % LogBatchSize == 0)
+ {
+ await Task.Yield();
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ }
+ }
+
+ if (options.GenerateMetrics)
+ {
+ logger.LogInformation(
+ "Generating {DurationHours} hours of counter and histogram data across {DimensionCount} dimensions.",
+ options.MetricDurationHours,
+ options.MetricDimensionCount);
+ var metricsClient = new MetricsService.MetricsServiceClient(channel);
+ await ExportMetricsAsync(
+ metricsClient,
+ metadata,
+ options.MetricDurationHours,
+ options.MetricDimensionCount,
+ Math.Max(options.TraceCount, 1),
+ Math.Max(options.SpansPerTrace, 1),
+ cancellationToken);
+ }
+
+ logger.LogInformation("Large telemetry generation completed.");
+ return true;
+ }
+ finally
+ {
+ _runLock.Release();
+ }
+ }
+
+ private static async Task ExportTracesAsync(
+ TraceService.TraceServiceClient client,
+ Metadata metadata,
+ int totalTraceCount,
+ int spansPerTrace,
+ CancellationToken cancellationToken)
+ {
+ var finalTraceTime = DateTime.UtcNow;
+ for (var firstTraceIndex = 0; firstTraceIndex < totalTraceCount; firstTraceIndex += TraceBatchSize)
+ {
+ var scopeSpans = CreateScopeSpans("LargeTelemetry.ManyTraces");
+ var traceCount = Math.Min(TraceBatchSize, totalTraceCount - firstTraceIndex);
+ for (var traceOffset = 0; traceOffset < traceCount; traceOffset++)
+ {
+ var traceIndex = firstTraceIndex + traceOffset;
+ var traceId = CreateTraceId(discriminator: 1, traceIndex + 1);
+ var traceStart = finalTraceTime.AddMilliseconds(traceIndex - totalTraceCount);
+ 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,
+ int totalSpanCount,
+ CancellationToken cancellationToken)
+ {
+ var traceId = CreateTraceId(discriminator: 2, value: 1);
+ var traceStart = DateTime.UtcNow.AddMinutes(-1);
+ for (var firstSpanIndex = 0; firstSpanIndex < totalSpanCount; firstSpanIndex += LargeTraceBatchSize)
+ {
+ var scopeSpans = CreateScopeSpans("LargeTelemetry.LargeTrace");
+ var spanCount = Math.Min(LargeTraceBatchSize, totalSpanCount - 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(totalSpanCount * 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,
+ int totalLogCount,
+ CancellationToken cancellationToken)
+ {
+ var finalLogTime = DateTime.UtcNow;
+ for (var firstLogIndex = 0; firstLogIndex < totalLogCount; firstLogIndex += LogBatchSize)
+ {
+ var scopeLogs = new ScopeLogs
+ {
+ Scope = new InstrumentationScope { Name = "LargeTelemetry.StructuredLogs" }
+ };
+ var logCount = Math.Min(LogBatchSize, totalLogCount - firstLogIndex);
+ for (var logOffset = 0; logOffset < logCount; logOffset++)
+ {
+ var logIndex = firstLogIndex + logOffset;
+ var timestamp = DateTimeToUnixNanoseconds(finalLogTime.AddMilliseconds(logIndex - totalLogCount));
+ 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,
+ int durationHours,
+ int dimensionCount,
+ int exemplarTraceCount,
+ int exemplarSpansPerTrace,
+ CancellationToken cancellationToken)
+ {
+ var durationSeconds = checked(durationHours * 60 * 60);
+ var startTime = DateTime.UtcNow.AddSeconds(-durationSeconds);
+ for (var firstSecond = 0; firstSecond < durationSeconds; firstSecond += MetricSecondsPerBatch)
+ {
+ var counter = new Metric
+ {
+ Name = "large.telemetry.counter",
+ Description = "One-second counter data.",
+ Unit = "requests",
+ Sum = new Sum
+ {
+ AggregationTemporality = AggregationTemporality.Cumulative,
+ IsMonotonic = true
+ }
+ };
+ var histogram = new Metric
+ {
+ Name = "large.telemetry.histogram",
+ Description = "One-second histogram data with exemplars.",
+ Unit = "ms",
+ Histogram = new Histogram
+ {
+ AggregationTemporality = AggregationTemporality.Cumulative
+ }
+ };
+
+ var secondCount = Math.Min(MetricSecondsPerBatch, durationSeconds - 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 < dimensionCount; 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 % exemplarTraceCount) + 1),
+ SpanId = CreateSpanId((dimensionIndex % exemplarSpansPerTrace) + 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..e04ed1efab0 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,19 @@
app.MapGet("/", () => "Hello world");
+app.MapPost("/large-telemetry", async (LargeTelemetryGenerationOptions options, LargeTelemetryGenerator generator, CancellationToken cancellationToken) =>
+{
+ if (options.GetValidationError() is { } validationError)
+ {
+ return Results.BadRequest(validationError);
+ }
+
+ var generated = await generator.TryGenerateAsync(options, 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 d64551bf923..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);
@@ -99,7 +100,28 @@
// 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 (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
+ // 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/Stress/Stress.AppHost/CommandResources.cs b/playground/Stress/Stress.AppHost/CommandResources.cs
index ec45e503b62..08e56ca3a4e 100644
--- a/playground/Stress/Stress.AppHost/CommandResources.cs
+++ b/playground/Stress/Stress.AppHost/CommandResources.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.Globalization;
+using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
@@ -8,6 +10,16 @@
internal static class CommandResources
{
+ // AppHost project references identify orchestrated projects without exposing their runtime assemblies.
+ // Keep these command defaults aligned with LargeTelemetryGenerator in Stress.ApiService.
+ private const int DefaultTraceCount = 50_000;
+ private const int DefaultSpansPerTrace = 6;
+ private const int DefaultLargeTraceSpanCount = 50_000;
+ private const int DefaultStructuredLogCount = 100_000;
+ private const int DefaultConsoleLogCount = 100_000;
+ private const int DefaultMetricDurationHours = 24;
+ private const int DefaultMetricDimensionCount = 5;
+
private static string NodeExecutablePath { get; } = Environment.GetEnvironmentVariable("NODE") ?? "node";
private static string DotnetExecutablePath { get; } = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet";
@@ -889,13 +901,9 @@ private static void AddServiceCommands(IDistributedApplicationBuilder builder, I
});
serviceBuilder.WithHttpCommand("/write-console", "Write to console", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
- serviceBuilder.WithHttpCommand("/write-console-large", "Write to console large", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
serviceBuilder.WithHttpCommand("/increment-counter", "Increment counter", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
serviceBuilder.WithHttpCommand("/big-trace", "Big trace", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
- serviceBuilder.WithHttpCommand("/trace-limit", "Trace limit", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
serviceBuilder.WithHttpCommand("/log-message", "Log message", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
- serviceBuilder.WithHttpCommand("/log-message-limit", "Log message limit", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
- serviceBuilder.WithHttpCommand("/log-message-limit-large", "Log message limit large", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
serviceBuilder.WithHttpCommand("/http-command-auto-result", "HTTP command auto result", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning", ResultMode = HttpCommandResultMode.Auto, Description = "Run an HTTP command and infer the result format from the response content type" });
serviceBuilder.WithHttpCommand("/http-command-json-result", "HTTP command JSON result", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning", ResultMode = HttpCommandResultMode.Json, Description = "Run an HTTP command and flow the JSON response back to the caller" });
serviceBuilder.WithHttpCommand("/http-command-text-result", "HTTP command text result", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning", ResultMode = HttpCommandResultMode.Text, Description = "Run an HTTP command and flow the plain-text response back to the caller" });
@@ -908,7 +916,175 @@ private static void AddServiceCommands(IDistributedApplicationBuilder builder, I
serviceBuilder.WithHttpCommand("/genai-trace-display-error", "Gen AI trace display error", commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
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" });
+
+ var largeTelemetryCommands = builder.AddHttpCommandGroup("large-telemetry-commands", serviceBuilder.Resource);
+ largeTelemetryCommands.WithHttpCommand(
+ "/write-console-large",
+ "Write to console large",
+ endpointSelector: () => serviceBuilder.GetEndpoint("http"),
+ commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
+ largeTelemetryCommands.WithHttpCommand(
+ "/trace-limit",
+ "Trace limit",
+ endpointSelector: () => serviceBuilder.GetEndpoint("http"),
+ commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
+ largeTelemetryCommands.WithHttpCommand(
+ "/log-message-limit",
+ "Log message limit",
+ endpointSelector: () => serviceBuilder.GetEndpoint("http"),
+ commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
+ largeTelemetryCommands.WithHttpCommand(
+ "/log-message-limit-large",
+ "Log message limit large",
+ endpointSelector: () => serviceBuilder.GetEndpoint("http"),
+ commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
+ largeTelemetryCommands.WithHttpCommand(
+ "/big-nested-trace",
+ "Big nested trace",
+ endpointSelector: () => serviceBuilder.GetEndpoint("http"),
+ commandOptions: new() { Method = HttpMethod.Get, IconName = "ContentViewGalleryLightning" });
+ largeTelemetryCommands.WithHttpCommand(
+ "/large-telemetry",
+ "Generate large telemetry data",
+ endpointSelector: () => serviceBuilder.GetEndpoint("http"),
+ 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.",
+ ValidateArguments = context =>
+ {
+ var options = ReadLargeTelemetryGenerationOptions(context.Inputs);
+ if (!options.GenerateTraces && !options.GenerateLargeTrace && !options.GenerateStructuredLogs && !options.GenerateConsoleLogs && !options.GenerateMetrics)
+ {
+ context.AddValidationError("generateTraces", "At least one telemetry kind must be enabled.");
+ }
+
+ AddPositiveCountValidation(context, options.GenerateTraces, "traceCount");
+ AddPositiveCountValidation(context, options.GenerateTraces, "spansPerTrace");
+ AddPositiveCountValidation(context, options.GenerateLargeTrace, "largeTraceSpanCount");
+ AddPositiveCountValidation(context, options.GenerateStructuredLogs, "structuredLogCount");
+ AddPositiveCountValidation(context, options.GenerateConsoleLogs, "consoleLogCount");
+ AddPositiveCountValidation(context, options.GenerateMetrics, "metricDurationHours");
+ AddPositiveCountValidation(context, options.GenerateMetrics, "metricDimensionCount");
+ return Task.CompletedTask;
+ },
+ PrepareRequest = context =>
+ {
+ context.Request.Content = JsonContent.Create(ReadLargeTelemetryGenerationOptions(context.Arguments));
+ return Task.CompletedTask;
+ },
+ Arguments =
+ [
+ new InteractionInput
+ {
+ Name = "generateTraces",
+ Label = "Generate traces",
+ Description = "Generate many traces with a configurable number of spans.",
+ InputType = InputType.Boolean,
+ Required = true,
+ Value = "true"
+ },
+ new InteractionInput
+ {
+ Name = "traceCount",
+ Label = "Trace count",
+ InputType = InputType.Number,
+ Required = true,
+ Value = DefaultTraceCount.ToString(CultureInfo.InvariantCulture),
+ DynamicLoading = CreateEnabledWhenTrue("generateTraces")
+ },
+ new InteractionInput
+ {
+ Name = "spansPerTrace",
+ Label = "Spans per trace",
+ InputType = InputType.Number,
+ Required = true,
+ Value = DefaultSpansPerTrace.ToString(CultureInfo.InvariantCulture),
+ DynamicLoading = CreateEnabledWhenTrue("generateTraces")
+ },
+ new InteractionInput
+ {
+ Name = "generateLargeTrace",
+ Label = "Generate large trace",
+ Description = "Generate one trace containing many spans.",
+ InputType = InputType.Boolean,
+ Required = true,
+ Value = "true"
+ },
+ new InteractionInput
+ {
+ Name = "largeTraceSpanCount",
+ Label = "Large trace span count",
+ InputType = InputType.Number,
+ Required = true,
+ Value = DefaultLargeTraceSpanCount.ToString(CultureInfo.InvariantCulture),
+ DynamicLoading = CreateEnabledWhenTrue("generateLargeTrace")
+ },
+ new InteractionInput
+ {
+ Name = "generateStructuredLogs",
+ Label = "Generate structured logs",
+ InputType = InputType.Boolean,
+ Required = true,
+ Value = "true"
+ },
+ new InteractionInput
+ {
+ Name = "structuredLogCount",
+ Label = "Structured log count",
+ InputType = InputType.Number,
+ Required = true,
+ Value = DefaultStructuredLogCount.ToString(CultureInfo.InvariantCulture),
+ DynamicLoading = CreateEnabledWhenTrue("generateStructuredLogs")
+ },
+ new InteractionInput
+ {
+ Name = "generateConsoleLogs",
+ Label = "Generate console logs",
+ InputType = InputType.Boolean,
+ Required = true,
+ Value = "true"
+ },
+ new InteractionInput
+ {
+ Name = "consoleLogCount",
+ Label = "Console log count",
+ InputType = InputType.Number,
+ Required = true,
+ Value = DefaultConsoleLogCount.ToString(CultureInfo.InvariantCulture),
+ DynamicLoading = CreateEnabledWhenTrue("generateConsoleLogs")
+ },
+ new InteractionInput
+ {
+ Name = "generateMetrics",
+ Label = "Generate metrics",
+ Description = "Generate counter and histogram data points.",
+ InputType = InputType.Boolean,
+ Required = true,
+ Value = "true"
+ },
+ new InteractionInput
+ {
+ Name = "metricDurationHours",
+ Label = "Metric duration (hours)",
+ InputType = InputType.Number,
+ Required = true,
+ Value = DefaultMetricDurationHours.ToString(CultureInfo.InvariantCulture),
+ DynamicLoading = CreateEnabledWhenTrue("generateMetrics")
+ },
+ new InteractionInput
+ {
+ Name = "metricDimensionCount",
+ Label = "Metric dimension count",
+ InputType = InputType.Number,
+ Required = true,
+ Value = DefaultMetricDimensionCount.ToString(CultureInfo.InvariantCulture),
+ DynamicLoading = CreateEnabledWhenTrue("generateMetrics")
+ }
+ ]
+ });
}
private static void AddTelemetryCommands(IDistributedApplicationBuilder builder, IResourceBuilder telemetryBuilder)
@@ -1083,6 +1259,46 @@ private static ValidateCommandArguments ReadValidateCommandArguments(Interaction
};
}
+ private static LargeTelemetryCommandOptions ReadLargeTelemetryGenerationOptions(InteractionInputCollection arguments)
+ {
+ return new LargeTelemetryCommandOptions
+ {
+ GenerateTraces = arguments.GetBoolean("generateTraces"),
+ TraceCount = arguments.GetInt32("traceCount"),
+ SpansPerTrace = arguments.GetInt32("spansPerTrace"),
+ GenerateLargeTrace = arguments.GetBoolean("generateLargeTrace"),
+ LargeTraceSpanCount = arguments.GetInt32("largeTraceSpanCount"),
+ GenerateStructuredLogs = arguments.GetBoolean("generateStructuredLogs"),
+ StructuredLogCount = arguments.GetInt32("structuredLogCount"),
+ GenerateConsoleLogs = arguments.GetBoolean("generateConsoleLogs"),
+ ConsoleLogCount = arguments.GetInt32("consoleLogCount"),
+ GenerateMetrics = arguments.GetBoolean("generateMetrics"),
+ MetricDurationHours = arguments.GetInt32("metricDurationHours"),
+ MetricDimensionCount = arguments.GetInt32("metricDimensionCount")
+ };
+ }
+
+ private static void AddPositiveCountValidation(InputsDialogValidationContext context, bool enabled, string inputName)
+ {
+ if (enabled && context.Inputs.GetInt32(inputName) <= 0)
+ {
+ context.AddValidationError(inputName, "The value must be positive when this telemetry kind is enabled.");
+ }
+ }
+
+ private static InputLoadOptions CreateEnabledWhenTrue(string inputName)
+ {
+ return new InputLoadOptions
+ {
+ DependsOnInputs = [inputName],
+ LoadCallback = context =>
+ {
+ context.Input.Disabled = !bool.TryParse(context.AllInputs.GetString(inputName), out var enabled) || !enabled;
+ return Task.CompletedTask;
+ }
+ };
+ }
+
private static IReadOnlyList CreateArgumentStressInputs(int fieldCount)
{
var inputs = new List
@@ -1224,6 +1440,22 @@ private sealed class ValidateCommandArguments
public bool RequireHealthy { get; set; } = true;
}
+
+ private sealed class LargeTelemetryCommandOptions
+ {
+ public bool GenerateTraces { get; init; }
+ public int TraceCount { get; init; }
+ public int SpansPerTrace { get; init; }
+ public bool GenerateLargeTrace { get; init; }
+ public int LargeTraceSpanCount { get; init; }
+ public bool GenerateStructuredLogs { get; init; }
+ public int StructuredLogCount { get; init; }
+ public bool GenerateConsoleLogs { get; init; }
+ public int ConsoleLogCount { get; init; }
+ public bool GenerateMetrics { get; init; }
+ public int MetricDurationHours { get; init; }
+ public int MetricDimensionCount { get; init; }
+ }
}
#pragma warning restore ASPIREPROCESSCOMMAND001
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/playground/Stress/Stress.AppHost/TestResource.cs b/playground/Stress/Stress.AppHost/TestResource.cs
index f737d1c5de6..18b0561e495 100644
--- a/playground/Stress/Stress.AppHost/TestResource.cs
+++ b/playground/Stress/Stress.AppHost/TestResource.cs
@@ -65,6 +65,23 @@ public static IResourceBuilder AddCommandGroup(this IDistr
return rb;
}
+ [AspireExportIgnore(Reason = "Stress playground helper; not part of the supported ATS surface.")]
+ public static IResourceBuilder AddHttpCommandGroup(this IDistributedApplicationBuilder builder, string name, IResource parent)
+ {
+ var rb = builder.AddResource(new HttpCommandGroupResource(name, parent))
+ .WithInitialState(new()
+ {
+ ResourceType = "Command Group",
+ State = "Running",
+ Properties = [
+ new(KnownProperties.Resource.ParentName, parent.Name)
+ ]
+ })
+ .ExcludeFromManifest();
+
+ return rb;
+ }
+
[AspireExportIgnore(Reason = "Stress playground helper; not part of the supported ATS surface.")]
public static IResourceBuilder AddNoStatusResource(this IDistributedApplicationBuilder builder, string name)
{
@@ -201,6 +218,11 @@ sealed class CommandGroupResource(string name, IResource parent) : Resource(name
public IResource Parent { get; } = parent;
}
+sealed class HttpCommandGroupResource(string name, IResource parent) : Resource(name), IResourceWithParent, IResourceWithEndpoints
+{
+ public IResource Parent { get; } = parent;
+}
+
sealed class NoStatusResource(string name) : Resource(name)
{
}
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/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json b/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json
index 880a3578bc0..888be547e88 100644
--- a/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json
+++ b/playground/TestShop/TestShop.AppHost/Properties/launchSettings.json
@@ -10,6 +10,19 @@
"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",
+ "AppHost__DefaultLaunchProfileName": "https",
+ "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317",
+ "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc"
+ }
+ },
"http": {
"commandName": "Project",
"launchBrowser": true,
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/Api/TelemetryApiService.cs b/src/Aspire.Dashboard/Api/TelemetryApiService.cs
index d365c608d38..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(
- TelemetryRepository 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
{
@@ -96,6 +92,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 +112,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)
@@ -132,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
{
@@ -156,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
{
@@ -275,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/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj
index 2897628bb1e..63d555a69f7 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,14 @@
+
+
+
+
+
@@ -264,6 +272,7 @@
+
diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor
index b81cab8bfaa..743de923f42 100644
--- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor
+++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor
@@ -11,7 +11,12 @@
};
}
-