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];
}