From 9b75d6070d3d7a32f5eab876bbaaaecefcc8e164 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Wed, 16 Apr 2025 14:42:02 +0200 Subject: [PATCH 1/4] initial --- .../Microsoft.AspNetCore.Components.csproj | 1 + .../Components/src/RenderTree/Renderer.cs | 8 ++ .../src/Rendering/RenderingMetrics.cs | 91 +++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 src/Components/Components/src/Rendering/RenderingMetrics.cs 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..6e012efd5012 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; @@ -24,6 +25,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree; // dispatching events to them, and notifying when the user interface is being updated. public abstract partial class Renderer : IDisposable, IAsyncDisposable { + private readonly RenderingMetrics? _renderingMetrics; private readonly object _lockObject = new(); private readonly IServiceProvider _serviceProvider; private readonly Dictionary _componentStateById = new Dictionary(); @@ -90,6 +92,9 @@ public Renderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory, _logger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Components.RenderTree.Renderer"); _componentFactory = new ComponentFactory(componentActivator, this); + var meterFactory = serviceProvider.GetService(); + _renderingMetrics = meterFactory != null ? new RenderingMetrics(meterFactory) : null; + ServiceProviderCascadingValueSuppliers = serviceProvider.GetService() is null ? Array.Empty() : serviceProvider.GetServices().ToArray(); @@ -926,12 +931,15 @@ private void RenderInExistingBatch(RenderQueueEntry renderQueueEntry) { var componentState = renderQueueEntry.ComponentState; Log.RenderingComponent(_logger, componentState); + var startTime = _renderingMetrics != null ? 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..990dc3011ed2 --- /dev/null +++ b/src/Components/Components/src/Rendering/RenderingMetrics.cs @@ -0,0 +1,91 @@ +// 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 _renderCounter; + private readonly Histogram _renderDuration; + + public RenderingMetrics(IMeterFactory meterFactory) + { + _meter = meterFactory.Create(MeterName); + + _renderCounter = _meter.CreateCounter( + "aspnetcore.components.rendering.count", + unit: "{render}", + 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); + + _renderCounter.Add(1, tags); + } + + public void RenderEnd(string componentType, Exception? exception, long startTimestamp, long currentTimestamp) + { + var tags = new TagList(); + tags = InitializeRequestTags(componentType, tags); + + // Tags must match request start. + if (_renderCounter.Enabled) + { + _renderCounter.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 IsEnabled() => _renderCounter.Enabled || _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; + } +} From 51c936dc33f34824792a89c364f72ddef22dfbc6 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Wed, 16 Apr 2025 23:31:38 +0200 Subject: [PATCH 2/4] more --- .../Components/src/RenderTree/Renderer.cs | 5 +- .../src/Rendering/RenderingMetrics.cs | 29 +++-- .../Server/src/Circuits/CircuitFactory.cs | 6 + .../Server/src/Circuits/CircuitHost.cs | 11 ++ .../Server/src/Circuits/CircuitMetrics.cs | 108 ++++++++++++++++++ .../ComponentServiceCollectionExtensions.cs | 7 ++ ...rosoft.AspNetCore.Components.Server.csproj | 3 +- src/Shared/Metrics/MetricsConstants.cs | 3 + 8 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 src/Components/Server/src/Circuits/CircuitMetrics.cs diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 6e012efd5012..80446a5bf476 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -25,7 +25,6 @@ namespace Microsoft.AspNetCore.Components.RenderTree; // dispatching events to them, and notifying when the user interface is being updated. public abstract partial class Renderer : IDisposable, IAsyncDisposable { - private readonly RenderingMetrics? _renderingMetrics; private readonly object _lockObject = new(); private readonly IServiceProvider _serviceProvider; private readonly Dictionary _componentStateById = new Dictionary(); @@ -35,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; @@ -92,6 +92,7 @@ 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; @@ -931,7 +932,7 @@ private void RenderInExistingBatch(RenderQueueEntry renderQueueEntry) { var componentState = renderQueueEntry.ComponentState; Log.RenderingComponent(_logger, componentState); - var startTime = _renderingMetrics != null ? Stopwatch.GetTimestamp() : 0; + var startTime = ((bool)_renderingMetrics?.IsDurationEnabled()) ? Stopwatch.GetTimestamp() : 0; _renderingMetrics?.RenderStart(componentState.Component.GetType().FullName); componentState.RenderIntoBatch(_batchBuilder, renderQueueEntry.RenderFragment, out var renderFragmentException); if (renderFragmentException != null) diff --git a/src/Components/Components/src/Rendering/RenderingMetrics.cs b/src/Components/Components/src/Rendering/RenderingMetrics.cs index 990dc3011ed2..cfd272910910 100644 --- a/src/Components/Components/src/Rendering/RenderingMetrics.cs +++ b/src/Components/Components/src/Rendering/RenderingMetrics.cs @@ -12,16 +12,22 @@ internal sealed class RenderingMetrics : IDisposable public const string MeterName = "Microsoft.AspNetCore.Components.Rendering"; private readonly Meter _meter; - private readonly Counter _renderCounter; + private readonly Counter _renderTotalCounter; + private readonly UpDownCounter _renderActiveCounter; private readonly Histogram _renderDuration; public RenderingMetrics(IMeterFactory meterFactory) { _meter = meterFactory.Create(MeterName); - _renderCounter = _meter.CreateCounter( + _renderTotalCounter = _meter.CreateCounter( "aspnetcore.components.rendering.count", - unit: "{render}", + 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( @@ -36,18 +42,25 @@ public void RenderStart(string componentType) var tags = new TagList(); tags = InitializeRequestTags(componentType, tags); - _renderCounter.Add(1, 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); - // Tags must match request start. - if (_renderCounter.Enabled) + if (_renderActiveCounter.Enabled) { - _renderCounter.Add(-1, tags); + _renderActiveCounter.Add(-1, tags); } if (_renderDuration.Enabled) @@ -68,7 +81,7 @@ private static TagList InitializeRequestTags(string componentType, TagList tags) return tags; } - public bool IsEnabled() => _renderCounter.Enabled || _renderDuration.Enabled; + public bool IsDurationEnabled() => _renderDuration.Enabled; public void Dispose() { diff --git a/src/Components/Server/src/Circuits/CircuitFactory.cs b/src/Components/Server/src/Circuits/CircuitFactory.cs index 28ba43aada35..011379316c78 100644 --- a/src/Components/Server/src/Circuits/CircuitFactory.cs +++ b/src/Components/Server/src/Circuits/CircuitFactory.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.Metrics; using System.Linq; using System.Security.Claims; using Microsoft.AspNetCore.Components.Infrastructure; @@ -20,11 +21,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 +35,8 @@ public CircuitFactory( _circuitIdFactory = circuitIdFactory; _options = options.Value; _logger = _loggerFactory.CreateLogger(); + + _circuitMetrics = circuitMetrics; } public async ValueTask CreateCircuitHostAsync( @@ -104,6 +109,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..086f81cb94cc 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.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; +using System.Diagnostics.Metrics; using System.Globalization; using System.Linq; using System.Security.Claims; @@ -23,11 +25,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 +51,7 @@ public CircuitHost( RemoteJSRuntime jsRuntime, RemoteNavigationManager navigationManager, CircuitHandler[] circuitHandlers, + CircuitMetrics circuitMetrics, ILogger logger) { CircuitId = circuitId; @@ -64,6 +69,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 +236,8 @@ await Renderer.Dispatcher.InvokeAsync(async () => private async Task OnCircuitOpenedAsync(CancellationToken cancellationToken) { Log.CircuitOpened(_logger, CircuitId); + _startTime = ((bool)_circuitMetrics?.IsDurationEnabled()) ? Stopwatch.GetTimestamp() : 0; + _circuitMetrics?.OnCircuitOpened(); Renderer.Dispatcher.AssertAccess(); @@ -259,6 +267,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 +297,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 +327,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..c0e4568f8e16 --- /dev/null +++ b/src/Components/Server/src/Circuits/CircuitMetrics.cs @@ -0,0 +1,108 @@ +// 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) + { + _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/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]; } From bab2dafca2d9648c4f756d5faff82f29fd59751c Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Thu, 17 Apr 2025 00:19:40 +0200 Subject: [PATCH 3/4] argh --- src/Components/Server/src/Circuits/CircuitFactory.cs | 1 - src/Components/Server/src/Circuits/CircuitHost.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Components/Server/src/Circuits/CircuitFactory.cs b/src/Components/Server/src/Circuits/CircuitFactory.cs index 011379316c78..cb8573bd81b1 100644 --- a/src/Components/Server/src/Circuits/CircuitFactory.cs +++ b/src/Components/Server/src/Circuits/CircuitFactory.cs @@ -1,7 +1,6 @@ // 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 System.Linq; using System.Security.Claims; using Microsoft.AspNetCore.Components.Infrastructure; diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index 086f81cb94cc..a78e98c1323a 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Diagnostics.Metrics; using System.Globalization; using System.Linq; using System.Security.Claims; From 8ac9034e0f8e63b6f2ca9c0e0aad303c5d035f60 Mon Sep 17 00:00:00 2001 From: pavelsavara Date: Thu, 17 Apr 2025 10:40:05 +0200 Subject: [PATCH 4/4] tests --- .../Components/src/RenderTree/Renderer.cs | 2 +- .../src/Rendering/RenderingMetrics.cs | 2 + ...crosoft.AspNetCore.Components.Tests.csproj | 2 + .../test/Rendering/RenderingMetricsTest.cs | 238 ++++++++++++++++++ .../Server/src/Circuits/CircuitHost.cs | 4 +- .../Server/src/Circuits/CircuitMetrics.cs | 2 + .../test/Circuits/CircuitMetricsTest.cs | 204 +++++++++++++++ .../Server/test/Circuits/TestCircuitHost.cs | 8 +- ....AspNetCore.Components.Server.Tests.csproj | 3 + 9 files changed, 460 insertions(+), 5 deletions(-) create mode 100644 src/Components/Components/test/Rendering/RenderingMetricsTest.cs create mode 100644 src/Components/Server/test/Circuits/CircuitMetricsTest.cs diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 80446a5bf476..62357b9ac34e 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -932,7 +932,7 @@ private void RenderInExistingBatch(RenderQueueEntry renderQueueEntry) { var componentState = renderQueueEntry.ComponentState; Log.RenderingComponent(_logger, componentState); - var startTime = ((bool)_renderingMetrics?.IsDurationEnabled()) ? Stopwatch.GetTimestamp() : 0; + 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) diff --git a/src/Components/Components/src/Rendering/RenderingMetrics.cs b/src/Components/Components/src/Rendering/RenderingMetrics.cs index cfd272910910..54b32a793cc7 100644 --- a/src/Components/Components/src/Rendering/RenderingMetrics.cs +++ b/src/Components/Components/src/Rendering/RenderingMetrics.cs @@ -18,6 +18,8 @@ internal sealed class RenderingMetrics : IDisposable public RenderingMetrics(IMeterFactory meterFactory) { + Debug.Assert(meterFactory != null); + _meter = meterFactory.Create(MeterName); _renderTotalCounter = _meter.CreateCounter( 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/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index a78e98c1323a..b8bc2b05e158 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -50,7 +50,7 @@ public CircuitHost( RemoteJSRuntime jsRuntime, RemoteNavigationManager navigationManager, CircuitHandler[] circuitHandlers, - CircuitMetrics circuitMetrics, + CircuitMetrics? circuitMetrics, ILogger logger) { CircuitId = circuitId; @@ -235,7 +235,7 @@ await Renderer.Dispatcher.InvokeAsync(async () => private async Task OnCircuitOpenedAsync(CancellationToken cancellationToken) { Log.CircuitOpened(_logger, CircuitId); - _startTime = ((bool)_circuitMetrics?.IsDurationEnabled()) ? Stopwatch.GetTimestamp() : 0; + _startTime = (_circuitMetrics != null && _circuitMetrics.IsDurationEnabled()) ? Stopwatch.GetTimestamp() : 0; _circuitMetrics?.OnCircuitOpened(); Renderer.Dispatcher.AssertAccess(); diff --git a/src/Components/Server/src/Circuits/CircuitMetrics.cs b/src/Components/Server/src/Circuits/CircuitMetrics.cs index c0e4568f8e16..fb772c119f51 100644 --- a/src/Components/Server/src/Circuits/CircuitMetrics.cs +++ b/src/Components/Server/src/Circuits/CircuitMetrics.cs @@ -19,6 +19,8 @@ internal sealed class CircuitMetrics : IDisposable public CircuitMetrics(IMeterFactory meterFactory) { + Debug.Assert(meterFactory != null); + _meter = meterFactory.Create(MeterName); _circuitTotalCounter = _meter.CreateCounter( 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 @@ + +