diff --git a/playground/Stress/Stress.ApiService/Program.cs b/playground/Stress/Stress.ApiService/Program.cs index 12321930113..f06f072f813 100644 --- a/playground/Stress/Stress.ApiService/Program.cs +++ b/playground/Stress/Stress.ApiService/Program.cs @@ -20,6 +20,11 @@ app.Lifetime.ApplicationStarted.Register(ConsoleStresser.Stress); +app.Lifetime.ApplicationStarted.Register(() => +{ + _ = app.Services.GetRequiredService(); +}); + app.MapGet("/", () => "Hello world"); app.MapGet("/write-console", () => diff --git a/playground/Stress/Stress.ApiService/TestMetrics.cs b/playground/Stress/Stress.ApiService/TestMetrics.cs index 5ab0b3681c3..798362a468d 100644 --- a/playground/Stress/Stress.ApiService/TestMetrics.cs +++ b/playground/Stress/Stress.ApiService/TestMetrics.cs @@ -15,15 +15,42 @@ public class TestMetrics : IDisposable public TestMetrics() { - _meter = new Meter(MeterName, "1.0.0", new[] - { + _meter = new Meter(MeterName, "1.0.0", + [ new KeyValuePair("meter-tag", Guid.NewGuid().ToString()) - }); + ]); - _counter = _meter.CreateCounter("test-counter", unit: null, description: null, tags: new[] - { + _counter = _meter.CreateCounter("test-counter", unit: null, description: null, tags: + [ new KeyValuePair("instrument-tag", Guid.NewGuid().ToString()) + ]); + + var uploadSpeed = new List(); + + Task.Run(async () => + { + while (true) + { + lock (uploadSpeed) + { + uploadSpeed.Add(Random.Shared.Next(5, 10)); + } + await Task.Delay(1000); + } }); + + _meter.CreateObservableGauge("observable-gauge", () => + { + lock (uploadSpeed) + { + var sum = 0d; + for (var i = 0; i < uploadSpeed.Count; i++) + { + sum += uploadSpeed[i]; + } + return new Measurement(sum / uploadSpeed.Count); + } + }, unit: "By/s"); } public void IncrementCounter(int value, in TagList tags) diff --git a/src/Aspire.Dashboard/Model/DefaultInstrumentUnitResolver.cs b/src/Aspire.Dashboard/Model/DefaultInstrumentUnitResolver.cs index 27b86ce9d61..60dd5e25796 100644 --- a/src/Aspire.Dashboard/Model/DefaultInstrumentUnitResolver.cs +++ b/src/Aspire.Dashboard/Model/DefaultInstrumentUnitResolver.cs @@ -15,15 +15,19 @@ public string ResolveDisplayedUnit(OtlpInstrumentSummary instrument, bool titleC { if (!string.IsNullOrEmpty(instrument.Unit)) { - var unit = OtlpUnits.GetUnit(instrument.Unit.TrimStart('{').TrimEnd('}')); - if (pluralize) + var (unit, isRateUnit) = OtlpUnits.GetUnit(instrument.Unit.TrimStart('{').TrimEnd('}')); + + // Don't pluralize rate units, e.g. We want "Bytes per second", not "Bytes per seconds". + if (pluralize && !isRateUnit) { unit = unit.Pluralize(); } + if (titleCase) { unit = unit.Titleize(); } + return unit; } diff --git a/src/Aspire.Dashboard/Otlp/Model/OtlpUnits.cs b/src/Aspire.Dashboard/Otlp/Model/OtlpUnits.cs index 7b16e6550af..fb41e08f410 100644 --- a/src/Aspire.Dashboard/Otlp/Model/OtlpUnits.cs +++ b/src/Aspire.Dashboard/Otlp/Model/OtlpUnits.cs @@ -8,17 +8,19 @@ namespace Aspire.Dashboard.Otlp.Model; public static class OtlpUnits { - public static string GetUnit(string unit) + public static (string Unit, bool IsRateUnit) GetUnit(string unit) { // Dropping the portions of the Unit within brackets (e.g. {packet}). Brackets MUST NOT be included in the resulting unit. A "count of foo" is considered unitless in Prometheus. // https://github.com/open-telemetry/opentelemetry-specification/blob/b2f923fb1650dde1f061507908b834035506a796/specification/compatibility/prometheus_and_openmetrics.md#L238 var updatedUnit = RemoveAnnotations(unit); + var isRateUnit = false; // Converting "foo/bar" to "foo_per_bar". // https://github.com/open-telemetry/opentelemetry-specification/blob/b2f923fb1650dde1f061507908b834035506a796/specification/compatibility/prometheus_and_openmetrics.md#L240C3-L240C41 if (TryProcessRateUnits(updatedUnit, out var updatedPerUnit)) { updatedUnit = updatedPerUnit; + isRateUnit = true; } else { @@ -27,7 +29,7 @@ public static string GetUnit(string unit) updatedUnit = MapUnit(updatedUnit.AsSpan()); } - return updatedUnit; + return (updatedUnit, isRateUnit); } private static bool TryProcessRateUnits(string updatedUnit, [NotNullWhen(true)] out string? updatedPerUnit) @@ -44,7 +46,7 @@ private static bool TryProcessRateUnits(string updatedUnit, [NotNullWhen(true)] return false; } - updatedPerUnit = MapUnit(updatedUnit.AsSpan(0, i)) + " per" + MapPerUnit(updatedUnit.AsSpan(i + 1, updatedUnit.Length - i - 1)); + updatedPerUnit = MapUnit(updatedUnit.AsSpan(0, i)) + " per " + MapPerUnit(updatedUnit.AsSpan(i + 1, updatedUnit.Length - i - 1)); return true; } } diff --git a/tests/Aspire.Dashboard.Tests/Model/DefaultInstrumentUnitResolverTests.cs b/tests/Aspire.Dashboard.Tests/Model/DefaultInstrumentUnitResolverTests.cs new file mode 100644 index 00000000000..69424442ca9 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Model/DefaultInstrumentUnitResolverTests.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Model; +using Aspire.Dashboard.Otlp.Model; +using Aspire.Dashboard.Resources; +using Microsoft.Extensions.Localization; +using OpenTelemetry.Proto.Common.V1; +using Xunit; + +namespace Aspire.Dashboard.Tests.Model; + +public sealed class DefaultInstrumentUnitResolverTests +{ + [Theory] + [InlineData("By/s", "instrument_name", "Bytes Per Second")] + [InlineData("connection", "instrument_name", "Connections")] + [InlineData("{connection}", "instrument_name", "Connections")] + [InlineData("", "instrument_name", "Localized:PlotlyChartValue")] + [InlineData("", "instrument_name.count", "Localized:PlotlyChartCount")] + [InlineData("", "instrument_name.length", "Localized:PlotlyChartLength")] + public void ResolveDisplayedUnit(string unit, string name, string expected) + { + // Arrange + var localizer = new TestStringLocalizer(); + var resolver = new DefaultInstrumentUnitResolver(localizer); + + var otlpInstrumentSummary = new OtlpInstrumentSummary + { + Description = "Description!", + Name = name, + Parent = new OtlpMeter(new InstrumentationScope { Name = "meter_name" }, new TelemetryLimitOptions()), + Type = OtlpInstrumentType.Gauge, + Unit = unit + }; + + // Act + var result = resolver.ResolveDisplayedUnit(otlpInstrumentSummary, titleCase: true, pluralize: true); + + // Assert + Assert.Equal(expected, result); + } + + private sealed class TestStringLocalizer : IStringLocalizer + { + public LocalizedString this[string name] => new LocalizedString(name, $"Localized:{name}"); + public LocalizedString this[string name, params object[] arguments] => new LocalizedString(name, $"Localized:{name}"); + + public IEnumerable GetAllStrings(bool includeParentCultures) => []; + } +}