diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index 0f6a86511ac0..ca3286f8b6c2 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index e6c3c240c538..62357b9ac34e 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; using System.Linq; using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.Reflection; @@ -33,6 +34,7 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable private readonly Dictionary _eventHandlerIdReplacements = new Dictionary(); private readonly ILogger _logger; private readonly ComponentFactory _componentFactory; + private readonly RenderingMetrics? _renderingMetrics; private Dictionary? _rootComponentsLatestParameters; private Task? _ongoingQuiescenceTask; @@ -90,6 +92,10 @@ public Renderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory, _logger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Components.RenderTree.Renderer"); _componentFactory = new ComponentFactory(componentActivator, this); + // TODO register RenderingMetrics as singleton in DI + var meterFactory = serviceProvider.GetService(); + _renderingMetrics = meterFactory != null ? new RenderingMetrics(meterFactory) : null; + ServiceProviderCascadingValueSuppliers = serviceProvider.GetService() is null ? Array.Empty() : serviceProvider.GetServices().ToArray(); @@ -926,12 +932,15 @@ private void RenderInExistingBatch(RenderQueueEntry renderQueueEntry) { var componentState = renderQueueEntry.ComponentState; Log.RenderingComponent(_logger, componentState); + var startTime = (_renderingMetrics != null && _renderingMetrics.IsDurationEnabled()) ? Stopwatch.GetTimestamp() : 0; + _renderingMetrics?.RenderStart(componentState.Component.GetType().FullName); componentState.RenderIntoBatch(_batchBuilder, renderQueueEntry.RenderFragment, out var renderFragmentException); if (renderFragmentException != null) { // If this returns, the error was handled by an error boundary. Otherwise it throws. HandleExceptionViaErrorBoundary(renderFragmentException, componentState); } + _renderingMetrics?.RenderEnd(componentState.Component.GetType().FullName, renderFragmentException, startTime, Stopwatch.GetTimestamp()); // Process disposal queue now in case it causes further component renders to be enqueued ProcessDisposalQueueInExistingBatch(); diff --git a/src/Components/Components/src/Rendering/RenderingMetrics.cs b/src/Components/Components/src/Rendering/RenderingMetrics.cs new file mode 100644 index 000000000000..54b32a793cc7 --- /dev/null +++ b/src/Components/Components/src/Rendering/RenderingMetrics.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Components.Rendering; + +internal sealed class RenderingMetrics : IDisposable +{ + public const string MeterName = "Microsoft.AspNetCore.Components.Rendering"; + + private readonly Meter _meter; + private readonly Counter _renderTotalCounter; + private readonly UpDownCounter _renderActiveCounter; + private readonly Histogram _renderDuration; + + public RenderingMetrics(IMeterFactory meterFactory) + { + Debug.Assert(meterFactory != null); + + _meter = meterFactory.Create(MeterName); + + _renderTotalCounter = _meter.CreateCounter( + "aspnetcore.components.rendering.count", + unit: "{renders}", + description: "Number of component renders performed."); + + _renderActiveCounter = _meter.CreateUpDownCounter( + "aspnetcore.components.rendering.active_renders", + unit: "{renders}", + description: "Number of component renders performed."); + + _renderDuration = _meter.CreateHistogram( + "aspnetcore.components.rendering.duration", + unit: "ms", + description: "Duration of component rendering operations per component.", + advice: new InstrumentAdvice { HistogramBucketBoundaries = MetricsConstants.ShortSecondsBucketBoundaries }); + } + + public void RenderStart(string componentType) + { + var tags = new TagList(); + tags = InitializeRequestTags(componentType, tags); + + if (_renderActiveCounter.Enabled) + { + _renderActiveCounter.Add(1, tags); + } + if (_renderTotalCounter.Enabled) + { + _renderTotalCounter.Add(1, tags); + } + } + + public void RenderEnd(string componentType, Exception? exception, long startTimestamp, long currentTimestamp) + { + // Tags must match request start. + var tags = new TagList(); + tags = InitializeRequestTags(componentType, tags); + + if (_renderActiveCounter.Enabled) + { + _renderActiveCounter.Add(-1, tags); + } + + if (_renderDuration.Enabled) + { + if (exception != null) + { + TryAddTag(ref tags, "error.type", exception.GetType().FullName); + } + + var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp); + _renderDuration.Record(duration.TotalMilliseconds, tags); + } + } + + private static TagList InitializeRequestTags(string componentType, TagList tags) + { + tags.Add("component.type", componentType); + return tags; + } + + public bool IsDurationEnabled() => _renderDuration.Enabled; + + public void Dispose() + { + _meter.Dispose(); + } + + private static bool TryAddTag(ref TagList tags, string name, object? value) + { + for (var i = 0; i < tags.Count; i++) + { + if (tags[i].Key == name) + { + return false; + } + } + + tags.Add(new KeyValuePair(name, value)); + return true; + } +} diff --git a/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj b/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj index 8f414c52f80c..732ebbb65892 100644 --- a/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj +++ b/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj @@ -8,9 +8,11 @@ + + diff --git a/src/Components/Components/test/Rendering/RenderingMetricsTest.cs b/src/Components/Components/test/Rendering/RenderingMetricsTest.cs new file mode 100644 index 000000000000..7339ebbf5dec --- /dev/null +++ b/src/Components/Components/test/Rendering/RenderingMetricsTest.cs @@ -0,0 +1,238 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.Extensions.Diagnostics.Metrics.Testing; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; +using Moq; + +namespace Microsoft.AspNetCore.Components.Rendering; + +public class RenderingMetricsTest +{ + private readonly TestMeterFactory _meterFactory; + + public RenderingMetricsTest() + { + _meterFactory = new TestMeterFactory(); + } + + [Fact] + public void Constructor_CreatesMetersCorrectly() + { + // Arrange & Act + var renderingMetrics = new RenderingMetrics(_meterFactory); + + // Assert + Assert.Single(_meterFactory.Meters); + Assert.Equal(RenderingMetrics.MeterName, _meterFactory.Meters[0].Name); + } + + [Fact] + public void RenderStart_IncreasesCounters() + { + // Arrange + var renderingMetrics = new RenderingMetrics(_meterFactory); + using var totalCounter = new MetricCollector(_meterFactory, + RenderingMetrics.MeterName, "aspnetcore.components.rendering.count"); + using var activeCounter = new MetricCollector(_meterFactory, + RenderingMetrics.MeterName, "aspnetcore.components.rendering.active_renders"); + + var componentType = "TestComponent"; + + // Act + renderingMetrics.RenderStart(componentType); + + // Assert + var totalMeasurements = totalCounter.GetMeasurementSnapshot(); + var activeMeasurements = activeCounter.GetMeasurementSnapshot(); + + Assert.Single(totalMeasurements); + Assert.Equal(1, totalMeasurements[0].Value); + Assert.Equal(componentType, totalMeasurements[0].Tags["component.type"]); + + Assert.Single(activeMeasurements); + Assert.Equal(1, activeMeasurements[0].Value); + Assert.Equal(componentType, activeMeasurements[0].Tags["component.type"]); + } + + [Fact] + public void RenderEnd_DecreasesActiveCounterAndRecordsDuration() + { + // Arrange + var renderingMetrics = new RenderingMetrics(_meterFactory); + using var activeCounter = new MetricCollector(_meterFactory, + RenderingMetrics.MeterName, "aspnetcore.components.rendering.active_renders"); + using var durationCollector = new MetricCollector(_meterFactory, + RenderingMetrics.MeterName, "aspnetcore.components.rendering.duration"); + + var componentType = "TestComponent"; + + // Act + var startTime = Stopwatch.GetTimestamp(); + Thread.Sleep(10); // Add a small delay to ensure a measurable duration + var endTime = Stopwatch.GetTimestamp(); + renderingMetrics.RenderEnd(componentType, null, startTime, endTime); + + // Assert + var activeMeasurements = activeCounter.GetMeasurementSnapshot(); + var durationMeasurements = durationCollector.GetMeasurementSnapshot(); + + Assert.Single(activeMeasurements); + Assert.Equal(-1, activeMeasurements[0].Value); + Assert.Equal(componentType, activeMeasurements[0].Tags["component.type"]); + + Assert.Single(durationMeasurements); + Assert.True(durationMeasurements[0].Value > 0); + Assert.Equal(componentType, durationMeasurements[0].Tags["component.type"]); + } + + [Fact] + public void RenderEnd_AddsErrorTypeTag_WhenExceptionIsProvided() + { + // Arrange + var renderingMetrics = new RenderingMetrics(_meterFactory); + using var durationCollector = new MetricCollector(_meterFactory, + RenderingMetrics.MeterName, "aspnetcore.components.rendering.duration"); + + var componentType = "TestComponent"; + var exception = new InvalidOperationException("Test exception"); + + // Act + var startTime = Stopwatch.GetTimestamp(); + Thread.Sleep(10); + var endTime = Stopwatch.GetTimestamp(); + renderingMetrics.RenderEnd(componentType, exception, startTime, endTime); + + // Assert + var durationMeasurements = durationCollector.GetMeasurementSnapshot(); + + Assert.Single(durationMeasurements); + Assert.True(durationMeasurements[0].Value > 0); + Assert.Equal(componentType, durationMeasurements[0].Tags["component.type"]); + Assert.Equal(exception.GetType().FullName, durationMeasurements[0].Tags["error.type"]); + } + + [Fact] + public void IsDurationEnabled_ReturnsMeterEnabledState() + { + // Arrange + var renderingMetrics = new RenderingMetrics(_meterFactory); + + // Create a collector to ensure the meter is enabled + using var durationCollector = new MetricCollector(_meterFactory, + RenderingMetrics.MeterName, "aspnetcore.components.rendering.duration"); + + // Act & Assert + Assert.True(renderingMetrics.IsDurationEnabled()); + } + + [Fact] + public void FullRenderingLifecycle_RecordsAllMetricsCorrectly() + { + // Arrange + var renderingMetrics = new RenderingMetrics(_meterFactory); + using var totalCounter = new MetricCollector(_meterFactory, + RenderingMetrics.MeterName, "aspnetcore.components.rendering.count"); + using var activeCounter = new MetricCollector(_meterFactory, + RenderingMetrics.MeterName, "aspnetcore.components.rendering.active_renders"); + using var durationCollector = new MetricCollector(_meterFactory, + RenderingMetrics.MeterName, "aspnetcore.components.rendering.duration"); + + var componentType = "TestComponent"; + + // Act - Simulating a full rendering lifecycle + var startTime = Stopwatch.GetTimestamp(); + + // 1. Component render starts + renderingMetrics.RenderStart(componentType); + + // 2. Component render ends + Thread.Sleep(10); // Add a small delay to ensure a measurable duration + var endTime = Stopwatch.GetTimestamp(); + renderingMetrics.RenderEnd(componentType, null, startTime, endTime); + + // Assert + var totalMeasurements = totalCounter.GetMeasurementSnapshot(); + var activeMeasurements = activeCounter.GetMeasurementSnapshot(); + var durationMeasurements = durationCollector.GetMeasurementSnapshot(); + + // Total render count should have 1 measurement with value 1 + Assert.Single(totalMeasurements); + Assert.Equal(1, totalMeasurements[0].Value); + Assert.Equal(componentType, totalMeasurements[0].Tags["component.type"]); + + // Active render count should have 2 measurements (1 for start, -1 for end) + Assert.Equal(2, activeMeasurements.Count); + Assert.Equal(1, activeMeasurements[0].Value); + Assert.Equal(-1, activeMeasurements[1].Value); + Assert.Equal(componentType, activeMeasurements[0].Tags["component.type"]); + Assert.Equal(componentType, activeMeasurements[1].Tags["component.type"]); + + // Duration should have 1 measurement with a positive value + Assert.Single(durationMeasurements); + Assert.True(durationMeasurements[0].Value > 0); + Assert.Equal(componentType, durationMeasurements[0].Tags["component.type"]); + } + + [Fact] + public void MultipleRenders_TracksMetricsIndependently() + { + // Arrange + var renderingMetrics = new RenderingMetrics(_meterFactory); + using var totalCounter = new MetricCollector(_meterFactory, + RenderingMetrics.MeterName, "aspnetcore.components.rendering.count"); + using var activeCounter = new MetricCollector(_meterFactory, + RenderingMetrics.MeterName, "aspnetcore.components.rendering.active_renders"); + using var durationCollector = new MetricCollector(_meterFactory, + RenderingMetrics.MeterName, "aspnetcore.components.rendering.duration"); + + var componentType1 = "TestComponent1"; + var componentType2 = "TestComponent2"; + + // Act + // First component render + var startTime1 = Stopwatch.GetTimestamp(); + renderingMetrics.RenderStart(componentType1); + + // Second component render starts while first is still rendering + var startTime2 = Stopwatch.GetTimestamp(); + renderingMetrics.RenderStart(componentType2); + + // First component render ends + Thread.Sleep(5); + var endTime1 = Stopwatch.GetTimestamp(); + renderingMetrics.RenderEnd(componentType1, null, startTime1, endTime1); + + // Second component render ends + Thread.Sleep(5); + var endTime2 = Stopwatch.GetTimestamp(); + renderingMetrics.RenderEnd(componentType2, null, startTime2, endTime2); + + // Assert + var totalMeasurements = totalCounter.GetMeasurementSnapshot(); + var activeMeasurements = activeCounter.GetMeasurementSnapshot(); + var durationMeasurements = durationCollector.GetMeasurementSnapshot(); + + // Should have 2 total render counts (one for each component) + Assert.Equal(2, totalMeasurements.Count); + Assert.Contains(totalMeasurements, m => m.Value == 1 && m.Tags["component.type"] as string == componentType1); + Assert.Contains(totalMeasurements, m => m.Value == 1 && m.Tags["component.type"] as string == componentType2); + + // Should have 4 active render counts (start and end for each component) + Assert.Equal(4, activeMeasurements.Count); + Assert.Contains(activeMeasurements, m => m.Value == 1 && m.Tags["component.type"] as string == componentType1); + Assert.Contains(activeMeasurements, m => m.Value == 1 && m.Tags["component.type"] as string == componentType2); + Assert.Contains(activeMeasurements, m => m.Value == -1 && m.Tags["component.type"] as string == componentType1); + Assert.Contains(activeMeasurements, m => m.Value == -1 && m.Tags["component.type"] as string == componentType2); + + // Should have 2 duration measurements (one for each component) + Assert.Equal(2, durationMeasurements.Count); + Assert.Contains(durationMeasurements, m => m.Value > 0 && m.Tags["component.type"] as string == componentType1); + Assert.Contains(durationMeasurements, m => m.Value > 0 && m.Tags["component.type"] as string == componentType2); + } +} diff --git a/src/Components/Server/src/Circuits/CircuitFactory.cs b/src/Components/Server/src/Circuits/CircuitFactory.cs index 28ba43aada35..cb8573bd81b1 100644 --- a/src/Components/Server/src/Circuits/CircuitFactory.cs +++ b/src/Components/Server/src/Circuits/CircuitFactory.cs @@ -20,11 +20,13 @@ internal sealed partial class CircuitFactory : ICircuitFactory private readonly CircuitIdFactory _circuitIdFactory; private readonly CircuitOptions _options; private readonly ILogger _logger; + private readonly CircuitMetrics? _circuitMetrics; public CircuitFactory( IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory, CircuitIdFactory circuitIdFactory, + CircuitMetrics? circuitMetrics, IOptions options) { _scopeFactory = scopeFactory; @@ -32,6 +34,8 @@ public CircuitFactory( _circuitIdFactory = circuitIdFactory; _options = options.Value; _logger = _loggerFactory.CreateLogger(); + + _circuitMetrics = circuitMetrics; } public async ValueTask CreateCircuitHostAsync( @@ -104,6 +108,7 @@ public async ValueTask CreateCircuitHostAsync( jsRuntime, navigationManager, circuitHandlers, + _circuitMetrics, _loggerFactory.CreateLogger()); Log.CreatedCircuit(_logger, circuitHost); diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index 3a9bea6bd82b..b8bc2b05e158 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Globalization; using System.Linq; using System.Security.Claims; @@ -23,11 +24,13 @@ internal partial class CircuitHost : IAsyncDisposable private readonly CircuitOptions _options; private readonly RemoteNavigationManager _navigationManager; private readonly ILogger _logger; + private readonly CircuitMetrics? _circuitMetrics; private Func, Task> _dispatchInboundActivity; private CircuitHandler[] _circuitHandlers; private bool _initialized; private bool _isFirstUpdate = true; private bool _disposed; + private long _startTime; // This event is fired when there's an unrecoverable exception coming from the circuit, and // it need so be torn down. The registry listens to this even so that the circuit can @@ -47,6 +50,7 @@ public CircuitHost( RemoteJSRuntime jsRuntime, RemoteNavigationManager navigationManager, CircuitHandler[] circuitHandlers, + CircuitMetrics? circuitMetrics, ILogger logger) { CircuitId = circuitId; @@ -64,6 +68,7 @@ public CircuitHost( JSRuntime = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime)); _navigationManager = navigationManager ?? throw new ArgumentNullException(nameof(navigationManager)); _circuitHandlers = circuitHandlers ?? throw new ArgumentNullException(nameof(circuitHandlers)); + _circuitMetrics = circuitMetrics; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); Services = scope.ServiceProvider; @@ -230,6 +235,8 @@ await Renderer.Dispatcher.InvokeAsync(async () => private async Task OnCircuitOpenedAsync(CancellationToken cancellationToken) { Log.CircuitOpened(_logger, CircuitId); + _startTime = (_circuitMetrics != null && _circuitMetrics.IsDurationEnabled()) ? Stopwatch.GetTimestamp() : 0; + _circuitMetrics?.OnCircuitOpened(); Renderer.Dispatcher.AssertAccess(); @@ -259,6 +266,7 @@ private async Task OnCircuitOpenedAsync(CancellationToken cancellationToken) public async Task OnConnectionUpAsync(CancellationToken cancellationToken) { Log.ConnectionUp(_logger, CircuitId, Client.ConnectionId); + _circuitMetrics?.OnConnectionUp(); Renderer.Dispatcher.AssertAccess(); @@ -288,6 +296,7 @@ public async Task OnConnectionUpAsync(CancellationToken cancellationToken) public async Task OnConnectionDownAsync(CancellationToken cancellationToken) { Log.ConnectionDown(_logger, CircuitId, Client.ConnectionId); + _circuitMetrics?.OnConnectionDown(); Renderer.Dispatcher.AssertAccess(); @@ -317,6 +326,7 @@ public async Task OnConnectionDownAsync(CancellationToken cancellationToken) private async Task OnCircuitDownAsync(CancellationToken cancellationToken) { Log.CircuitClosed(_logger, CircuitId); + _circuitMetrics?.OnCircuitDown(_startTime, Stopwatch.GetTimestamp()); List exceptions = null; diff --git a/src/Components/Server/src/Circuits/CircuitMetrics.cs b/src/Components/Server/src/Circuits/CircuitMetrics.cs new file mode 100644 index 000000000000..fb772c119f51 --- /dev/null +++ b/src/Components/Server/src/Circuits/CircuitMetrics.cs @@ -0,0 +1,110 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Components.Server.Circuits; + +internal sealed class CircuitMetrics : IDisposable +{ + public const string MeterName = "Microsoft.AspNetCore.Components.Server.Circuits"; + + private readonly Meter _meter; + private readonly Counter _circuitTotalCounter; + private readonly UpDownCounter _circuitActiveCounter; + private readonly UpDownCounter _circuitConnectedCounter; + private readonly Histogram _circuitDuration; + + public CircuitMetrics(IMeterFactory meterFactory) + { + Debug.Assert(meterFactory != null); + + _meter = meterFactory.Create(MeterName); + + _circuitTotalCounter = _meter.CreateCounter( + "aspnetcore.components.circuits.count", + unit: "{circuits}", + description: "Number of active circuits."); + + _circuitActiveCounter = _meter.CreateUpDownCounter( + "aspnetcore.components.circuits.active_circuits", + unit: "{circuits}", + description: "Number of active circuits."); + + _circuitConnectedCounter = _meter.CreateUpDownCounter( + "aspnetcore.components.circuits.connected_circuits", + unit: "{circuits}", + description: "Number of disconnected circuits."); + + _circuitDuration = _meter.CreateHistogram( + "aspnetcore.components.circuits.duration", + unit: "s", + description: "Duration of circuit.", + advice: new InstrumentAdvice { HistogramBucketBoundaries = MetricsConstants.VeryLongSecondsBucketBoundaries }); + } + + public void OnCircuitOpened() + { + var tags = new TagList(); + + if (_circuitActiveCounter.Enabled) + { + _circuitActiveCounter.Add(1, tags); + } + if (_circuitTotalCounter.Enabled) + { + _circuitTotalCounter.Add(1, tags); + } + } + + public void OnConnectionUp() + { + var tags = new TagList(); + + if (_circuitConnectedCounter.Enabled) + { + _circuitConnectedCounter.Add(1, tags); + } + } + + public void OnConnectionDown() + { + var tags = new TagList(); + + if (_circuitConnectedCounter.Enabled) + { + _circuitConnectedCounter.Add(-1, tags); + } + } + + public void OnCircuitDown(long startTimestamp, long currentTimestamp) + { + // Tags must match request start. + var tags = new TagList(); + + if (_circuitActiveCounter.Enabled) + { + _circuitActiveCounter.Add(-1, tags); + } + + if (_circuitConnectedCounter.Enabled) + { + _circuitConnectedCounter.Add(-1, tags); + } + + if (_circuitDuration.Enabled) + { + var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp); + _circuitDuration.Record(duration.TotalSeconds, tags); + } + } + + public bool IsDurationEnabled() => _circuitDuration.Enabled; + + public void Dispose() + { + _meter.Dispose(); + } +} diff --git a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs index 1718c955e7e6..08195b0218c7 100644 --- a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs +++ b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Metrics; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Forms; @@ -60,6 +61,12 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti // Here we add a bunch of services that don't vary in any way based on the // user's configuration. So even if the user has multiple independent server-side // Components entrypoints, this lot is the same and repeated registrations are a no-op. + + services.TryAddSingleton(s => + { + var meterFactory = s.GetService(); + return meterFactory != null ? new CircuitMetrics(meterFactory) : null; + }); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj index 2978116fc5c7..ed27d422aca6 100644 --- a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj +++ b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj @@ -26,7 +26,8 @@ - + + diff --git a/src/Components/Server/test/Circuits/CircuitMetricsTest.cs b/src/Components/Server/test/Circuits/CircuitMetricsTest.cs new file mode 100644 index 000000000000..770125996634 --- /dev/null +++ b/src/Components/Server/test/Circuits/CircuitMetricsTest.cs @@ -0,0 +1,204 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.Extensions.Diagnostics.Metrics.Testing; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.JSInterop; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.InternalTesting; +using Moq; + +namespace Microsoft.AspNetCore.Components.Server.Circuits; + +public class CircuitMetricsTest +{ + private readonly TestMeterFactory _meterFactory; + + public CircuitMetricsTest() + { + _meterFactory = new TestMeterFactory(); + } + + [Fact] + public void Constructor_CreatesMetersCorrectly() + { + // Arrange & Act + var circuitMetrics = new CircuitMetrics(_meterFactory); + + // Assert + Assert.Single(_meterFactory.Meters); + Assert.Equal(CircuitMetrics.MeterName, _meterFactory.Meters[0].Name); + } + + [Fact] + public void OnCircuitOpened_IncreasesCounters() + { + // Arrange + var circuitMetrics = new CircuitMetrics(_meterFactory); + using var activeTotalCounter = new MetricCollector(_meterFactory, + CircuitMetrics.MeterName, "aspnetcore.components.circuits.count"); + using var activeCircuitCounter = new MetricCollector(_meterFactory, + CircuitMetrics.MeterName, "aspnetcore.components.circuits.active_circuits"); + + // Act + circuitMetrics.OnCircuitOpened(); + + // Assert + var totalMeasurements = activeTotalCounter.GetMeasurementSnapshot(); + var activeMeasurements = activeCircuitCounter.GetMeasurementSnapshot(); + + Assert.Single(totalMeasurements); + Assert.Equal(1, totalMeasurements[0].Value); + + Assert.Single(activeMeasurements); + Assert.Equal(1, activeMeasurements[0].Value); + } + + [Fact] + public void OnConnectionUp_IncreasesConnectedCounter() + { + // Arrange + var circuitMetrics = new CircuitMetrics(_meterFactory); + using var connectedCircuitCounter = new MetricCollector(_meterFactory, + CircuitMetrics.MeterName, "aspnetcore.components.circuits.connected_circuits"); + + // Act + circuitMetrics.OnConnectionUp(); + + // Assert + var measurements = connectedCircuitCounter.GetMeasurementSnapshot(); + + Assert.Single(measurements); + Assert.Equal(1, measurements[0].Value); + } + + [Fact] + public void OnConnectionDown_DecreasesConnectedCounter() + { + // Arrange + var circuitMetrics = new CircuitMetrics(_meterFactory); + using var connectedCircuitCounter = new MetricCollector(_meterFactory, + CircuitMetrics.MeterName, "aspnetcore.components.circuits.connected_circuits"); + + // Act + circuitMetrics.OnConnectionDown(); + + // Assert + var measurements = connectedCircuitCounter.GetMeasurementSnapshot(); + + Assert.Single(measurements); + Assert.Equal(-1, measurements[0].Value); + } + + [Fact] + public void OnCircuitDown_UpdatesCountersAndRecordsDuration() + { + // Arrange + var circuitMetrics = new CircuitMetrics(_meterFactory); + using var activeCircuitCounter = new MetricCollector(_meterFactory, + CircuitMetrics.MeterName, "aspnetcore.components.circuits.active_circuits"); + using var connectedCircuitCounter = new MetricCollector(_meterFactory, + CircuitMetrics.MeterName, "aspnetcore.components.circuits.connected_circuits"); + using var circuitDurationCollector = new MetricCollector(_meterFactory, + CircuitMetrics.MeterName, "aspnetcore.components.circuits.duration"); + + // Act + var startTime = Stopwatch.GetTimestamp(); + Thread.Sleep(10); // Add a small delay to ensure a measurable duration + var endTime = Stopwatch.GetTimestamp(); + circuitMetrics.OnCircuitDown(startTime, endTime); + + // Assert + var activeMeasurements = activeCircuitCounter.GetMeasurementSnapshot(); + var connectedMeasurements = connectedCircuitCounter.GetMeasurementSnapshot(); + var durationMeasurements = circuitDurationCollector.GetMeasurementSnapshot(); + + Assert.Single(activeMeasurements); + Assert.Equal(-1, activeMeasurements[0].Value); + + Assert.Single(connectedMeasurements); + Assert.Equal(-1, connectedMeasurements[0].Value); + + Assert.Single(durationMeasurements); + Assert.True(durationMeasurements[0].Value > 0); + } + + [Fact] + public void IsDurationEnabled_ReturnsMeterEnabledState() + { + // Arrange + var circuitMetrics = new CircuitMetrics(_meterFactory); + + // Create a collector to ensure the meter is enabled + using var circuitDurationCollector = new MetricCollector(_meterFactory, + CircuitMetrics.MeterName, "aspnetcore.components.circuits.duration"); + + // Act & Assert + Assert.True(circuitMetrics.IsDurationEnabled()); + } + + [Fact] + public void FullCircuitLifecycle_RecordsAllMetricsCorrectly() + { + // Arrange + var circuitMetrics = new CircuitMetrics(_meterFactory); + using var totalCounter = new MetricCollector(_meterFactory, + CircuitMetrics.MeterName, "aspnetcore.components.circuits.count"); + using var activeCircuitCounter = new MetricCollector(_meterFactory, + CircuitMetrics.MeterName, "aspnetcore.components.circuits.active_circuits"); + using var connectedCircuitCounter = new MetricCollector(_meterFactory, + CircuitMetrics.MeterName, "aspnetcore.components.circuits.connected_circuits"); + using var circuitDurationCollector = new MetricCollector(_meterFactory, + CircuitMetrics.MeterName, "aspnetcore.components.circuits.duration"); + + // Act - Simulating a full circuit lifecycle + var startTime = Stopwatch.GetTimestamp(); + + // 1. Circuit opens + circuitMetrics.OnCircuitOpened(); + + // 2. Connection established + circuitMetrics.OnConnectionUp(); + + // 3. Connection drops + circuitMetrics.OnConnectionDown(); + + // 4. Connection re-established + circuitMetrics.OnConnectionUp(); + + // 5. Circuit closes + Thread.Sleep(10); // Add a small delay to ensure a measurable duration + var endTime = Stopwatch.GetTimestamp(); + circuitMetrics.OnCircuitDown(startTime, endTime); + + // Assert + var totalMeasurements = totalCounter.GetMeasurementSnapshot(); + var activeMeasurements = activeCircuitCounter.GetMeasurementSnapshot(); + var connectedMeasurements = connectedCircuitCounter.GetMeasurementSnapshot(); + var durationMeasurements = circuitDurationCollector.GetMeasurementSnapshot(); + + // Total circuit count should have 1 measurement with value 1 + Assert.Single(totalMeasurements); + Assert.Equal(1, totalMeasurements[0].Value); + + // Active circuit count should have 2 measurements (1 for open, -1 for close) + Assert.Equal(2, activeMeasurements.Count); + Assert.Equal(1, activeMeasurements[0].Value); + Assert.Equal(-1, activeMeasurements[1].Value); + + // Connected circuit count should have 4 measurements (connecting, disconnecting, reconnecting, closing) + Assert.Equal(4, connectedMeasurements.Count); + Assert.Equal(1, connectedMeasurements[0].Value); + Assert.Equal(-1, connectedMeasurements[1].Value); + Assert.Equal(1, connectedMeasurements[2].Value); + Assert.Equal(-1, connectedMeasurements[3].Value); + + // Duration should have 1 measurement with a positive value + Assert.Single(durationMeasurements); + Assert.True(durationMeasurements[0].Value > 0); + } +} diff --git a/src/Components/Server/test/Circuits/TestCircuitHost.cs b/src/Components/Server/test/Circuits/TestCircuitHost.cs index c0b09cb45189..eeb86ad3a639 100644 --- a/src/Components/Server/test/Circuits/TestCircuitHost.cs +++ b/src/Components/Server/test/Circuits/TestCircuitHost.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.Diagnostics.Metrics; +using Microsoft.AspNetCore.InternalTesting; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -13,8 +15,8 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits; internal class TestCircuitHost : CircuitHost { - private TestCircuitHost(CircuitId circuitId, AsyncServiceScope scope, CircuitOptions options, CircuitClientProxy client, RemoteRenderer renderer, IReadOnlyList descriptors, RemoteJSRuntime jsRuntime, RemoteNavigationManager navigationManager, CircuitHandler[] circuitHandlers, ILogger logger) - : base(circuitId, scope, options, client, renderer, descriptors, jsRuntime, navigationManager, circuitHandlers, logger) + private TestCircuitHost(CircuitId circuitId, AsyncServiceScope scope, CircuitOptions options, CircuitClientProxy client, RemoteRenderer renderer, IReadOnlyList descriptors, RemoteJSRuntime jsRuntime, RemoteNavigationManager navigationManager, CircuitHandler[] circuitHandlers, CircuitMetrics circuitMetrics, ILogger logger) + : base(circuitId, scope, options, client, renderer, descriptors, jsRuntime, navigationManager, circuitHandlers, circuitMetrics, logger) { } @@ -35,6 +37,7 @@ public static CircuitHost Create( .Setup(services => services.GetService(typeof(IJSRuntime))) .Returns(jsRuntime); var serverComponentDeserializer = Mock.Of(); + var circuitMetrics = new CircuitMetrics(new TestMeterFactory()); if (remoteRenderer == null) { @@ -60,6 +63,7 @@ public static CircuitHost Create( jsRuntime, navigationManager, handlers, + circuitMetrics, NullLogger.Instance); } } diff --git a/src/Components/Server/test/Microsoft.AspNetCore.Components.Server.Tests.csproj b/src/Components/Server/test/Microsoft.AspNetCore.Components.Server.Tests.csproj index 487cc73b8990..db46700aed1b 100644 --- a/src/Components/Server/test/Microsoft.AspNetCore.Components.Server.Tests.csproj +++ b/src/Components/Server/test/Microsoft.AspNetCore.Components.Server.Tests.csproj @@ -7,6 +7,7 @@ + @@ -14,6 +15,8 @@ + + diff --git a/src/Shared/Metrics/MetricsConstants.cs b/src/Shared/Metrics/MetricsConstants.cs index 6cd103eb3d35..ff64c6fefcad 100644 --- a/src/Shared/Metrics/MetricsConstants.cs +++ b/src/Shared/Metrics/MetricsConstants.cs @@ -10,4 +10,7 @@ internal static class MetricsConstants // Not based on a standard. Larger bucket sizes for longer lasting operations, e.g. HTTP connection duration. See https://github.com/open-telemetry/semantic-conventions/issues/336 public static readonly IReadOnlyList LongSecondsBucketBoundaries = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300]; + + // For Blazor/signalR sessions, which can last a long time. + public static readonly IReadOnlyList VeryLongSecondsBucketBoundaries = [0.5, 1, 2, 5, 10, 30, 60, 120, 300, 600, 1500, 60*60, 2 * 60 * 60, 4 * 60 * 60]; }