diff --git a/src/Components/Components/src/ComponentsActivitySource.cs b/src/Components/Components/src/ComponentsActivitySource.cs new file mode 100644 index 000000000000..592d3bee9164 --- /dev/null +++ b/src/Components/Components/src/ComponentsActivitySource.cs @@ -0,0 +1,176 @@ +// 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; + +namespace Microsoft.AspNetCore.Components; + +/// +/// This is instance scoped per renderer +/// +internal class ComponentsActivitySource +{ + internal const string Name = "Microsoft.AspNetCore.Components"; + internal const string OnEventName = $"{Name}.OnEvent"; + internal const string OnRouteName = $"{Name}.OnRoute"; + + private ActivityContext _httpContext; + private ActivityContext _circuitContext; + private string? _circuitId; + private ActivityContext _routeContext; + + private ActivitySource ActivitySource { get; } = new ActivitySource(Name); + + public static ActivityContext CaptureHttpContext() + { + var parentActivity = Activity.Current; + if (parentActivity is not null && parentActivity.OperationName == "Microsoft.AspNetCore.Hosting.HttpRequestIn" && parentActivity.Recorded) + { + return parentActivity.Context; + } + return default; + } + + public Activity? StartCircuitActivity(string circuitId, ActivityContext httpContext) + { + _circuitId = circuitId; + + var activity = ActivitySource.CreateActivity(OnRouteName, ActivityKind.Server, parentId: null, null, null); + if (activity is not null) + { + if (activity.IsAllDataRequested) + { + if (_circuitId != null) + { + activity.SetTag("circuit.id", _circuitId); + } + if (httpContext != default) + { + activity.AddLink(new ActivityLink(httpContext)); + } + } + activity.DisplayName = $"CIRCUIT {circuitId ?? ""}"; + activity.Start(); + _circuitContext = activity.Context; + } + return activity; + } + + public void FailCircuitActivity(Activity activity, Exception ex) + { + _circuitContext = default; + if (!activity.IsStopped) + { + activity.SetTag("error.type", ex.GetType().FullName); + activity.SetStatus(ActivityStatusCode.Error); + activity.Stop(); + } + } + + public Activity? StartRouteActivity(string componentType, string route) + { + if (_httpContext == default) + { + _httpContext = CaptureHttpContext(); + } + + var activity = ActivitySource.CreateActivity(OnRouteName, ActivityKind.Server, parentId: null, null, null); + if (activity is not null) + { + if (activity.IsAllDataRequested) + { + if (_circuitId != null) + { + activity.SetTag("circuit.id", _circuitId); + } + if (componentType != null) + { + activity.SetTag("component.type", componentType); + } + if (route != null) + { + activity.SetTag("route", route); + } + if (_httpContext != default) + { + activity.AddLink(new ActivityLink(_httpContext)); + } + if (_circuitContext != default) + { + activity.AddLink(new ActivityLink(_circuitContext)); + } + } + + activity.DisplayName = $"ROUTE {route ?? "unknown"} -> {componentType ?? "unknown"}"; + activity.Start(); + _routeContext = activity.Context; + } + return activity; + } + + public Activity? StartEventActivity(string? componentType, string? methodName, string? attributeName) + { + var activity = ActivitySource.CreateActivity(OnEventName, ActivityKind.Server, parentId: null, null, null); + if (activity is not null) + { + if (activity.IsAllDataRequested) + { + if (_circuitId != null) + { + activity.SetTag("circuit.id", _circuitId); + } + if (componentType != null) + { + activity.SetTag("component.type", componentType); + } + if (methodName != null) + { + activity.SetTag("component.method", methodName); + } + if (attributeName != null) + { + activity.SetTag("attribute.name", attributeName); + } + if (_httpContext != default) + { + activity.AddLink(new ActivityLink(_httpContext)); + } + if (_circuitContext != default) + { + activity.AddLink(new ActivityLink(_circuitContext)); + } + if (_routeContext != default) + { + activity.AddLink(new ActivityLink(_routeContext)); + } + } + + activity.DisplayName = $"EVENT {attributeName ?? "unknown"} -> {componentType ?? "unknown"}.{methodName ?? "unknown"}"; + activity.Start(); + } + return activity; + } + + public static void FailEventActivity(Activity activity, Exception ex) + { + if (!activity.IsStopped) + { + activity.SetTag("error.type", ex.GetType().FullName); + activity.SetStatus(ActivityStatusCode.Error); + activity.Stop(); + } + } + + public static async Task CaptureEventStopAsync(Task task, Activity activity) + { + try + { + await task; + activity.Stop(); + } + catch (Exception ex) + { + FailEventActivity(activity, ex); + } + } +} diff --git a/src/Components/Components/src/ComponentsMetrics.cs b/src/Components/Components/src/ComponentsMetrics.cs new file mode 100644 index 000000000000..609707e7f1ed --- /dev/null +++ b/src/Components/Components/src/ComponentsMetrics.cs @@ -0,0 +1,235 @@ +// 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.Components; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Components; + +internal sealed class ComponentsMetrics : IDisposable +{ + public const string MeterName = "Microsoft.AspNetCore.Components"; + private readonly Meter _meter; + + private readonly Counter _navigationCount; + + private readonly Histogram _eventDuration; + private readonly Counter _eventException; + + private readonly Histogram _parametersDuration; + private readonly Counter _parametersException; + + private readonly Histogram _batchDuration; + private readonly Counter _batchException; + + public bool IsNavigationEnabled => _navigationCount.Enabled; + + public bool IsEventDurationEnabled => _eventDuration.Enabled; + public bool IsEventExceptionEnabled => _eventException.Enabled; + + public bool IsParametersDurationEnabled => _parametersDuration.Enabled; + public bool IsParametersExceptionEnabled => _parametersException.Enabled; + + public bool IsBatchDurationEnabled => _batchDuration.Enabled; + public bool IsBatchExceptionEnabled => _batchException.Enabled; + + public ComponentsMetrics(IMeterFactory meterFactory) + { + Debug.Assert(meterFactory != null); + + _meter = meterFactory.Create(MeterName); + + _navigationCount = _meter.CreateCounter( + "aspnetcore.components.navigation.count", + unit: "{exceptions}", + description: "Total number of route changes."); + + _eventDuration = _meter.CreateHistogram( + "aspnetcore.components.event.duration", + unit: "s", + description: "Duration of processing browser event.", + advice: new InstrumentAdvice { HistogramBucketBoundaries = MetricsConstants.ShortSecondsBucketBoundaries }); + + _eventException = _meter.CreateCounter( + "aspnetcore.components.event.exception", + unit: "{exceptions}", + description: "Total number of exceptions during browser event processing."); + + _parametersDuration = _meter.CreateHistogram( + "aspnetcore.components.parameters.duration", + unit: "s", + description: "Duration of processing component parameters.", + advice: new InstrumentAdvice { HistogramBucketBoundaries = MetricsConstants.ShortSecondsBucketBoundaries }); + + _parametersException = _meter.CreateCounter( + "aspnetcore.components.parameters.exception", + unit: "{exceptions}", + description: "Total number of exceptions during processing component parameters."); + + _batchDuration = _meter.CreateHistogram( + "aspnetcore.components.rendering.batch.duration", + unit: "s", + description: "Duration of rendering batch.", + advice: new InstrumentAdvice { HistogramBucketBoundaries = MetricsConstants.ShortSecondsBucketBoundaries }); + + _batchException = _meter.CreateCounter( + "aspnetcore.components.rendering.batch.exception", + unit: "{exceptions}", + description: "Total number of exceptions during batch rendering."); + } + + public void Navigation(string componentType, string route) + { + var tags = new TagList + { + { "component.type", componentType ?? "unknown" }, + { "route", route ?? "unknown" }, + }; + + _navigationCount.Add(1, tags); + } + + public async Task CaptureEventDurationAsync(Task task, long startTimestamp, string? componentType, string? methodName, string? attributeName) + { + try + { + await task; + + var tags = new TagList + { + { "component.type", componentType ?? "unknown" }, + { "component.method", methodName ?? "unknown" }, + { "attribute.name", attributeName ?? "unknown" } + }; + + var duration = Stopwatch.GetElapsedTime(startTimestamp); + _eventDuration.Record(duration.TotalSeconds, tags); + } + catch + { + // none + } + } + + public async Task CaptureParametersDurationAsync(Task task, long startTimestamp, string? componentType) + { + try + { + await task; + + var tags = new TagList + { + { "component.type", componentType ?? "unknown" }, + }; + + var duration = Stopwatch.GetElapsedTime(startTimestamp); + _parametersDuration.Record(duration.TotalSeconds, tags); + } + catch + { + // none + } + } + + public void BatchDuration(long startTimestamp, int diffLength) + { + var tags = new TagList + { + { "diff.length.bucket", BucketEditLength(diffLength) } + }; + + var duration = Stopwatch.GetElapsedTime(startTimestamp); + _batchDuration.Record(duration.TotalSeconds, tags); + } + + public void EventFailed(string? exceptionType, EventCallback callback, string? attributeName) + { + var receiverName = (callback.Receiver?.GetType() ?? callback.Delegate?.Target?.GetType())?.FullName; + var tags = new TagList + { + { "component.type", receiverName ?? "unknown" }, + { "attribute.name", attributeName ?? "unknown"}, + { "error.type", exceptionType ?? "unknown"} + }; + _eventException.Add(1, tags); + } + + public async Task CaptureEventFailedAsync(Task task, EventCallback callback, string? attributeName) + { + try + { + await task; + } + catch (Exception ex) + { + EventFailed(ex.GetType().Name, callback, attributeName); + } + } + + public void PropertiesFailed(string? exceptionType, string? componentType) + { + var tags = new TagList + { + { "component.type", componentType ?? "unknown" }, + { "error.type", exceptionType ?? "unknown"} + }; + _parametersException.Add(1, tags); + } + + public async Task CapturePropertiesFailedAsync(Task task, string? componentType) + { + try + { + await task; + } + catch (Exception ex) + { + PropertiesFailed(ex.GetType().Name, componentType); + } + } + + public void BatchFailed(string? exceptionType) + { + var tags = new TagList + { + { "error.type", exceptionType ?? "unknown"} + }; + _batchException.Add(1, tags); + } + + public async Task CaptureBatchFailedAsync(Task task) + { + try + { + await task; + } + catch (Exception ex) + { + BatchFailed(ex.GetType().Name); + } + } + + private static int BucketEditLength(int batchLength) + { + return batchLength switch + { + <= 1 => 1, + <= 2 => 2, + <= 5 => 5, + <= 10 => 10, + <= 50 => 50, + <= 100 => 100, + <= 500 => 500, + <= 1000 => 1000, + <= 10000 => 10000, + _ => 10001, + }; + } + + public void Dispose() + { + _meter.Dispose(); + } +} diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index ca3286f8b6c2..07fcc360fd7e 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -79,6 +79,7 @@ + diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index b99e9fd7f216..ce563e15a7cb 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,7 +1,7 @@ #nullable enable - Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.get -> System.Type! Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.set -> void +Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions Microsoft.AspNetCore.Components.NavigationManager.OnNotFound -> System.EventHandler! Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void Microsoft.AspNetCore.Components.Routing.IHostEnvironmentNavigationManager.Initialize(string! baseUri, string! uri, System.Func! onNavigateTo) -> void @@ -14,5 +14,7 @@ Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttri Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute.SupplyParameterFromPersistentComponentStateAttribute() -> void Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.AspNetCore.Components.Infrastructure.ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.SignalRendererToFinishRendering() -> void \ No newline at end of file diff --git a/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs b/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs new file mode 100644 index 000000000000..e1e224e5f71b --- /dev/null +++ b/src/Components/Components/src/RegisterRenderingMetricsServiceCollectionExtensions.cs @@ -0,0 +1,57 @@ +// 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.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.AspNetCore.Components.Infrastructure; + +/// +/// Infrastructure APIs for registering diagnostic metrics. +/// +public static class ComponentsMetricsServiceCollectionExtensions +{ + /// + /// Registers component rendering metrics + /// + /// The . + /// The . + public static IServiceCollection AddComponentsMetrics( + IServiceCollection services) + { + // do not register IConfigureOptions multiple times + if (!IsMeterFactoryRegistered(services)) + { + services.AddMetrics(); + } + services.TryAddSingleton(); + + return services; + } + + /// + /// Registers component rendering traces + /// + /// The . + /// The . + public static IServiceCollection AddComponentsTracing( + IServiceCollection services) + { + services.TryAddScoped(); + + return services; + } + + private static bool IsMeterFactoryRegistered(IServiceCollection services) + { + foreach (var service in services) + { + if (service.ServiceType == typeof(IMeterFactory)) + { + return true; + } + } + return false; + } +} diff --git a/src/Components/Components/src/RenderHandle.cs b/src/Components/Components/src/RenderHandle.cs index 6ac2b7b3cdec..edcb644bddfb 100644 --- a/src/Components/Components/src/RenderHandle.cs +++ b/src/Components/Components/src/RenderHandle.cs @@ -21,6 +21,9 @@ internal RenderHandle(Renderer renderer, int componentId) _componentId = componentId; } + internal ComponentsMetrics? ComponentMetrics => _renderer?.ComponentMetrics; + internal ComponentsActivitySource? ComponentActivitySource => _renderer?.ComponentActivitySource; + /// /// Gets the associated with the component. /// diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 5ba977930a46..d68640ea8b7b 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -5,7 +5,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Diagnostics.Metrics; using System.Linq; using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.Reflection; @@ -25,16 +24,20 @@ 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 { + internal static readonly Task CanceledRenderTask = Task.FromCanceled(new CancellationToken(canceled: true)); + private readonly object _lockObject = new(); private readonly IServiceProvider _serviceProvider; private readonly Dictionary _componentStateById = new Dictionary(); private readonly Dictionary _componentStateByComponent = new Dictionary(); private readonly RenderBatchBuilder _batchBuilder = new RenderBatchBuilder(); - private readonly Dictionary _eventBindings = new(); + private readonly Dictionary _eventBindings = new(); private readonly Dictionary _eventHandlerIdReplacements = new Dictionary(); private readonly ILogger _logger; private readonly ComponentFactory _componentFactory; - private readonly RenderingMetrics? _renderingMetrics; + private readonly ComponentsMetrics? _componentsMetrics; + private readonly ComponentsActivitySource? _componentsActivitySource; + private Dictionary? _rootComponentsLatestParameters; private Task? _ongoingQuiescenceTask; @@ -92,16 +95,17 @@ public Renderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory, // logger name in here as a string literal. _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; + _componentsMetrics = serviceProvider.GetService(); + _componentsActivitySource = serviceProvider.GetService(); ServiceProviderCascadingValueSuppliers = serviceProvider.GetService() is null ? Array.Empty() : serviceProvider.GetServices().ToArray(); } + internal ComponentsMetrics? ComponentMetrics => _componentsMetrics; + internal ComponentsActivitySource? ComponentActivitySource => _componentsActivitySource; + internal ICascadingValueSupplier[] ServiceProviderCascadingValueSuppliers { get; } internal HotReloadManager HotReloadManager { get; set; } = HotReloadManager.Default; @@ -442,7 +446,18 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie _pendingTasks ??= new(); } - var (renderedByComponentId, callback) = GetRequiredEventBindingEntry(eventHandlerId); + var (renderedByComponentId, callback, attributeName) = GetRequiredEventBindingEntry(eventHandlerId); + + // collect trace + Activity? activity = null; + if (ComponentActivitySource != null) + { + var receiverName = (callback.Receiver?.GetType() ?? callback.Delegate.Target?.GetType())?.FullName; + var methodName = callback.Delegate.Method?.Name; + activity = ComponentActivitySource.StartEventActivity(receiverName, methodName, attributeName); + } + + var eventStartTimestamp = ComponentMetrics != null && ComponentMetrics.IsEventDurationEnabled ? Stopwatch.GetTimestamp() : 0; // If this event attribute was rendered by a component that's since been disposed, don't dispatch the event at all. // This can occur because event handler disposal is deferred, so event handler IDs can outlive their components. @@ -484,9 +499,37 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie _isBatchInProgress = true; task = callback.InvokeAsync(eventArgs); + + // collect metrics + if (ComponentMetrics != null && ComponentMetrics.IsEventDurationEnabled) + { + var receiverName = (callback.Receiver?.GetType() ?? callback.Delegate.Target?.GetType())?.FullName; + var methodName = callback.Delegate.Method?.Name; + _ = ComponentMetrics.CaptureEventDurationAsync(task, eventStartTimestamp, receiverName, methodName, attributeName); + } + if (ComponentMetrics != null && ComponentMetrics.IsEventExceptionEnabled) + { + _ = ComponentMetrics.CaptureEventFailedAsync(task, callback, attributeName); + } + + // stop activity/trace + if (ComponentActivitySource != null && activity != null) + { + _ = ComponentsActivitySource.CaptureEventStopAsync(task, activity); + } } catch (Exception e) { + if (ComponentMetrics != null && ComponentMetrics.IsEventExceptionEnabled) + { + ComponentMetrics.EventFailed(e.GetType().FullName, callback, attributeName); + } + + if (ComponentActivitySource != null && activity != null) + { + ComponentsActivitySource.FailEventActivity(activity, e); + } + HandleExceptionViaErrorBoundary(e, receiverComponentState); return Task.CompletedTask; } @@ -638,7 +681,7 @@ internal void AssignEventHandlerId(int renderedByComponentId, ref RenderTreeFram // // When that happens we intentionally box the EventCallback because we need to hold on to // the receiver. - _eventBindings.Add(id, (renderedByComponentId, callback)); + _eventBindings.Add(id, (renderedByComponentId, callback, frame.AttributeName)); } else if (frame.AttributeValueField is MulticastDelegate @delegate) { @@ -646,7 +689,7 @@ internal void AssignEventHandlerId(int renderedByComponentId, ref RenderTreeFram // is the same as delegate.Target. In this case since the receiver is implicit we can // avoid boxing the EventCallback object and just re-hydrate it on the other side of the // render tree. - _eventBindings.Add(id, (renderedByComponentId, new EventCallback(@delegate.Target as IHandleEvent, @delegate))); + _eventBindings.Add(id, (renderedByComponentId, new EventCallback(@delegate.Target as IHandleEvent, @delegate), frame.AttributeName)); } // NOTE: we do not to handle EventCallback here. EventCallback is only used when passing @@ -696,7 +739,7 @@ internal void TrackReplacedEventHandlerId(ulong oldEventHandlerId, ulong newEven _eventHandlerIdReplacements.Add(oldEventHandlerId, newEventHandlerId); } - private (int RenderedByComponentId, EventCallback Callback) GetRequiredEventBindingEntry(ulong eventHandlerId) + private (int RenderedByComponentId, EventCallback Callback, string? attributeName) GetRequiredEventBindingEntry(ulong eventHandlerId) { if (!_eventBindings.TryGetValue(eventHandlerId, out var entry)) { @@ -770,6 +813,7 @@ private void ProcessRenderQueue() _isBatchInProgress = true; var updateDisplayTask = Task.CompletedTask; + var batchStartTimestamp = ComponentMetrics != null && ComponentMetrics.IsBatchDurationEnabled ? Stopwatch.GetTimestamp() : 0; try { @@ -801,9 +845,23 @@ private void ProcessRenderQueue() // Fire off the execution of OnAfterRenderAsync, but don't wait for it // if there is async work to be done. _ = InvokeRenderCompletedCalls(batch.UpdatedComponents, updateDisplayTask); + + if (ComponentMetrics != null && ComponentMetrics.IsBatchDurationEnabled) + { + ComponentMetrics.BatchDuration(batchStartTimestamp, batch.UpdatedComponents.Count); + } + if (ComponentMetrics != null && ComponentMetrics.IsBatchExceptionEnabled) + { + _ = ComponentMetrics.CaptureBatchFailedAsync(updateDisplayTask); + } } catch (Exception e) { + if (ComponentMetrics != null && ComponentMetrics.IsBatchExceptionEnabled) + { + ComponentMetrics.BatchFailed(e.GetType().Name); + } + // Ensure we catch errors while running the render functions of the components. HandleException(e); return; @@ -947,15 +1005,13 @@ 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/ComponentState.cs b/src/Components/Components/src/Rendering/ComponentState.cs index c7be2643edd9..a4a91ae9e0d2 100644 --- a/src/Components/Components/src/Rendering/ComponentState.cs +++ b/src/Components/Components/src/Rendering/ComponentState.cs @@ -23,6 +23,7 @@ public class ComponentState : IAsyncDisposable private RenderTreeBuilder _nextRenderTree; private ArrayBuilder? _latestDirectParametersSnapshot; // Lazily instantiated private bool _componentWasDisposed; + private readonly string? _componentTypeName; /// /// Constructs an instance of . @@ -51,6 +52,11 @@ public ComponentState(Renderer renderer, int componentId, IComponent component, _hasCascadingParameters = true; _hasAnyCascadingParameterSubscriptions = AddCascadingParameterSubscriptions(); } + + if (_renderer.ComponentMetrics != null && (_renderer.ComponentMetrics.IsParametersDurationEnabled || _renderer.ComponentMetrics.IsParametersExceptionEnabled)) + { + _componentTypeName = component.GetType().FullName; + } } private static ComponentState? GetSectionOutletLogicalParent(Renderer renderer, SectionOutlet sectionOutlet) @@ -231,14 +237,31 @@ internal void NotifyCascadingValueChanged(in ParameterViewLifetime lifetime) // a consistent set to the recipient. private void SupplyCombinedParameters(ParameterView directAndCascadingParameters) { - // Normalise sync and async exceptions into a Task + // Normalize sync and async exceptions into a Task Task setParametersAsyncTask; try { + var stateStartTimestamp = _renderer.ComponentMetrics != null && _renderer.ComponentMetrics.IsParametersDurationEnabled ? Stopwatch.GetTimestamp() : 0; + setParametersAsyncTask = Component.SetParametersAsync(directAndCascadingParameters); + + // collect metrics + if (_renderer.ComponentMetrics != null && _renderer.ComponentMetrics.IsParametersDurationEnabled) + { + _ = _renderer.ComponentMetrics.CaptureParametersDurationAsync(setParametersAsyncTask, stateStartTimestamp, _componentTypeName); + } + if (_renderer.ComponentMetrics != null && _renderer.ComponentMetrics.IsParametersExceptionEnabled) + { + _ = _renderer.ComponentMetrics.CapturePropertiesFailedAsync(setParametersAsyncTask, _componentTypeName); + } } catch (Exception ex) { + if (_renderer.ComponentMetrics != null && _renderer.ComponentMetrics.IsParametersExceptionEnabled) + { + _renderer.ComponentMetrics.PropertiesFailed(ex.GetType().FullName, _componentTypeName); + } + setParametersAsyncTask = Task.FromException(ex); } diff --git a/src/Components/Components/src/Rendering/RenderingMetrics.cs b/src/Components/Components/src/Rendering/RenderingMetrics.cs deleted file mode 100644 index 54b32a793cc7..000000000000 --- a/src/Components/Components/src/Rendering/RenderingMetrics.cs +++ /dev/null @@ -1,106 +0,0 @@ -// 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/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs index d562b94bb639..2d2afadc3d39 100644 --- a/src/Components/Components/src/Routing/Router.cs +++ b/src/Components/Components/src/Routing/Router.cs @@ -3,6 +3,7 @@ #nullable disable warnings +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Reflection.Metadata; @@ -10,8 +11,8 @@ using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Internal; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Components.Routing; @@ -222,10 +223,13 @@ internal virtual void Refresh(bool isNavigationIntercepted) var relativePath = NavigationManager.ToBaseRelativePath(_locationAbsolute.AsSpan()); var locationPathSpan = TrimQueryOrHash(relativePath); var locationPath = $"/{locationPathSpan}"; + Activity? activity = null; // In order to avoid routing twice we check for RouteData if (RoutingStateProvider?.RouteData is { } endpointRouteData) { + activity = RecordDiagnostics(endpointRouteData.PageType.FullName, endpointRouteData.Template); + // Other routers shouldn't provide RouteData, this is specific to our router component // and must abide by our syntax and behaviors. // Other routers must create their own abstractions to flow data from their SSR routing @@ -236,6 +240,8 @@ internal virtual void Refresh(bool isNavigationIntercepted) // - Convert constrained parameters with (int, double, etc) to the target type. endpointRouteData = RouteTable.ProcessParameters(endpointRouteData); _renderHandle.Render(Found(endpointRouteData)); + + activity?.Stop(); return; } @@ -252,6 +258,8 @@ internal virtual void Refresh(bool isNavigationIntercepted) $"does not implement {typeof(IComponent).FullName}."); } + activity = RecordDiagnostics(context.Handler.FullName, context.Entry.RoutePattern.RawText); + Log.NavigatingToComponent(_logger, context.Handler, locationPath, _baseUri); var routeData = new RouteData( @@ -271,6 +279,8 @@ internal virtual void Refresh(bool isNavigationIntercepted) { if (!isNavigationIntercepted) { + activity = RecordDiagnostics("NotFound", "NotFound"); + Log.DisplayingNotFound(_logger, locationPath, _baseUri); // We did not find a Component that matches the route. @@ -280,10 +290,30 @@ internal virtual void Refresh(bool isNavigationIntercepted) } else { + activity = RecordDiagnostics("External", "External"); + Log.NavigatingToExternalUri(_logger, _locationAbsolute, locationPath, _baseUri); NavigationManager.NavigateTo(_locationAbsolute, forceLoad: true); } } + activity?.Stop(); + + } + + private Activity? RecordDiagnostics(string componentType, string template) + { + Activity? activity = null; + if (_renderHandle.ComponentActivitySource != null) + { + activity = _renderHandle.ComponentActivitySource.StartRouteActivity(componentType, template); + } + + if (_renderHandle.ComponentMetrics != null && _renderHandle.ComponentMetrics.IsNavigationEnabled) + { + _renderHandle.ComponentMetrics.Navigation(componentType, template); + } + + return activity; } private static void DefaultNotFoundContent(RenderTreeBuilder builder) diff --git a/src/Components/Components/test/Rendering/ComponentsMetricsTest.cs b/src/Components/Components/test/Rendering/ComponentsMetricsTest.cs new file mode 100644 index 000000000000..15a468cc819d --- /dev/null +++ b/src/Components/Components/test/Rendering/ComponentsMetricsTest.cs @@ -0,0 +1,271 @@ +// 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 ComponentsMetricsTest +{ + private readonly TestMeterFactory _meterFactory; + + public ComponentsMetricsTest() + { + _meterFactory = new TestMeterFactory(); + } + + [Fact] + public void Constructor_CreatesMetersCorrectly() + { + // Arrange & Act + var componentsMetrics = new ComponentsMetrics(_meterFactory); + + // Assert + Assert.Single(_meterFactory.Meters); + Assert.Equal(ComponentsMetrics.MeterName, _meterFactory.Meters[0].Name); + } + + [Fact] + public async Task CaptureEventDurationAsync_RecordsDuration() + { + // Arrange + var componentsMetrics = new ComponentsMetrics(_meterFactory); + using var eventAsyncDurationCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.event.duration"); + + // Act + var startTime = Stopwatch.GetTimestamp(); + var task = Task.Delay(10); // Create a delay task + await componentsMetrics.CaptureEventDurationAsync(task, startTime, "TestComponent", "MyMethod", "OnClickAsync"); + + // Assert + var measurements = eventAsyncDurationCollector.GetMeasurementSnapshot(); + + Assert.Single(measurements); + Assert.True(measurements[0].Value > 0); + Assert.Equal("TestComponent", measurements[0].Tags["component.type"]); + Assert.Equal("OnClickAsync", measurements[0].Tags["attribute.name"]); + Assert.Equal("MyMethod", measurements[0].Tags["component.method"]); + } + + [Fact] + public async Task CaptureParametersDurationAsync_RecordsDuration() + { + // Arrange + var componentsMetrics = new ComponentsMetrics(_meterFactory); + using var parametersAsyncDurationCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.parameters.duration"); + + // Act + var startTime = Stopwatch.GetTimestamp(); + var task = Task.Delay(10); // Create a delay task + await componentsMetrics.CaptureParametersDurationAsync(task, startTime, "TestComponent"); + + // Assert + var measurements = parametersAsyncDurationCollector.GetMeasurementSnapshot(); + + Assert.Single(measurements); + Assert.True(measurements[0].Value > 0); + Assert.Equal("TestComponent", measurements[0].Tags["component.type"]); + } + + [Fact] + public void BatchDuration_RecordsDuration() + { + // Arrange + var componentsMetrics = new ComponentsMetrics(_meterFactory); + using var batchDurationCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.rendering.batch.duration"); + + // Act + var startTime = Stopwatch.GetTimestamp(); + Thread.Sleep(10); // Add a small delay to ensure a measurable duration + componentsMetrics.BatchDuration(startTime, 50); + + // Assert + var measurements = batchDurationCollector.GetMeasurementSnapshot(); + + Assert.Single(measurements); + Assert.True(measurements[0].Value > 0); + Assert.Equal(50, measurements[0].Tags["diff.length.bucket"]); + } + + [Fact] + public void EventFailed_RecordsException() + { + // Arrange + var componentsMetrics = new ComponentsMetrics(_meterFactory); + using var eventExceptionCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.event.exception"); + + // Create a mock EventCallback + var callback = new EventCallback(new TestComponent(), (Action)(() => { })); + + // Act + componentsMetrics.EventFailed("ArgumentException", callback, "OnClick"); + + // Assert + var measurements = eventExceptionCollector.GetMeasurementSnapshot(); + + Assert.Single(measurements); + Assert.Equal(1, measurements[0].Value); + Assert.Equal("ArgumentException", measurements[0].Tags["error.type"]); + Assert.Equal("OnClick", measurements[0].Tags["attribute.name"]); + Assert.Contains("Microsoft.AspNetCore.Components.Rendering.ComponentsMetricsTest+TestComponent", (string)measurements[0].Tags["component.type"]); + } + + [Fact] + public async Task CaptureEventFailedAsync_RecordsException() + { + // Arrange + var componentsMetrics = new ComponentsMetrics(_meterFactory); + using var eventExceptionCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.event.exception"); + + // Create a mock EventCallback + var callback = new EventCallback(new TestComponent(), (Action)(() => { })); + + // Create a task that throws an exception + var task = Task.FromException(new InvalidOperationException()); + + // Act + await componentsMetrics.CaptureEventFailedAsync(task, callback, "OnClickAsync"); + + // Assert + var measurements = eventExceptionCollector.GetMeasurementSnapshot(); + + Assert.Single(measurements); + Assert.Equal(1, measurements[0].Value); + Assert.Equal("InvalidOperationException", measurements[0].Tags["error.type"]); + Assert.Equal("OnClickAsync", measurements[0].Tags["attribute.name"]); + Assert.Contains("Microsoft.AspNetCore.Components.Rendering.ComponentsMetricsTest+TestComponent", (string)measurements[0].Tags["component.type"]); + } + + [Fact] + public void PropertiesFailed_RecordsException() + { + // Arrange + var componentsMetrics = new ComponentsMetrics(_meterFactory); + using var parametersExceptionCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.parameters.exception"); + + // Act + componentsMetrics.PropertiesFailed("ArgumentException", "TestComponent"); + + // Assert + var measurements = parametersExceptionCollector.GetMeasurementSnapshot(); + + Assert.Single(measurements); + Assert.Equal(1, measurements[0].Value); + Assert.Equal("ArgumentException", measurements[0].Tags["error.type"]); + Assert.Equal("TestComponent", measurements[0].Tags["component.type"]); + } + + [Fact] + public async Task CapturePropertiesFailedAsync_RecordsException() + { + // Arrange + var componentsMetrics = new ComponentsMetrics(_meterFactory); + using var parametersExceptionCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.parameters.exception"); + + // Create a task that throws an exception + var task = Task.FromException(new InvalidOperationException()); + + // Act + await componentsMetrics.CapturePropertiesFailedAsync(task, "TestComponent"); + + // Assert + var measurements = parametersExceptionCollector.GetMeasurementSnapshot(); + + Assert.Single(measurements); + Assert.Equal(1, measurements[0].Value); + Assert.Equal("InvalidOperationException", measurements[0].Tags["error.type"]); + Assert.Equal("TestComponent", measurements[0].Tags["component.type"]); + } + + [Fact] + public void BatchFailed_RecordsException() + { + // Arrange + var componentsMetrics = new ComponentsMetrics(_meterFactory); + using var batchExceptionCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.rendering.batch.exception"); + + // Act + componentsMetrics.BatchFailed("ArgumentException"); + + // Assert + var measurements = batchExceptionCollector.GetMeasurementSnapshot(); + + Assert.Single(measurements); + Assert.Equal(1, measurements[0].Value); + Assert.Equal("ArgumentException", measurements[0].Tags["error.type"]); + } + + [Fact] + public async Task CaptureBatchFailedAsync_RecordsException() + { + // Arrange + var componentsMetrics = new ComponentsMetrics(_meterFactory); + using var batchExceptionCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.rendering.batch.exception"); + + // Create a task that throws an exception + var task = Task.FromException(new InvalidOperationException()); + + // Act + await componentsMetrics.CaptureBatchFailedAsync(task); + + // Assert + var measurements = batchExceptionCollector.GetMeasurementSnapshot(); + + Assert.Single(measurements); + Assert.Equal(1, measurements[0].Value); + Assert.Equal("InvalidOperationException", measurements[0].Tags["error.type"]); + } + + [Fact] + public void EnabledProperties_ReflectMeterState() + { + // Arrange + var componentsMetrics = new ComponentsMetrics(_meterFactory); + + // Create collectors to ensure the meters are enabled + using var eventAsyncDurationCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.event.duration"); + using var eventExceptionCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.event.exception"); + using var parametersAsyncDurationCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.parameters.duration"); + using var parametersExceptionCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.parameters.exception"); + using var batchDurationCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.rendering.batch.duration"); + using var batchExceptionCollector = new MetricCollector(_meterFactory, + ComponentsMetrics.MeterName, "aspnetcore.components.rendering.batch.exception"); + + // Assert + Assert.True(componentsMetrics.IsEventDurationEnabled); + Assert.True(componentsMetrics.IsEventExceptionEnabled); + Assert.True(componentsMetrics.IsParametersDurationEnabled); + Assert.True(componentsMetrics.IsParametersExceptionEnabled); + Assert.True(componentsMetrics.IsBatchDurationEnabled); + Assert.True(componentsMetrics.IsBatchExceptionEnabled); + } + + // Helper class for mock components + public class TestComponent : IComponent, IHandleEvent + { + public void Attach(RenderHandle renderHandle) { } + public Task HandleEventAsync(EventCallbackWorkItem item, object arg) => Task.CompletedTask; + public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask; + } +} diff --git a/src/Components/Components/test/Rendering/RenderingMetricsTest.cs b/src/Components/Components/test/Rendering/RenderingMetricsTest.cs deleted file mode 100644 index 7339ebbf5dec..000000000000 --- a/src/Components/Components/test/Rendering/RenderingMetricsTest.cs +++ /dev/null @@ -1,238 +0,0 @@ -// 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/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index 302dec7dcb16..de2e1f4707e0 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -76,6 +76,9 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.TryAddScoped(); + ComponentsMetricsServiceCollectionExtensions.AddComponentsMetrics(services); + ComponentsMetricsServiceCollectionExtensions.AddComponentsTracing(services); + // Form handling services.AddSupplyValueFromFormProvider(); services.TryAddScoped(); diff --git a/src/Components/Server/src/Circuits/CircuitFactory.cs b/src/Components/Server/src/Circuits/CircuitFactory.cs index cb8573bd81b1..6683c2e20d75 100644 --- a/src/Components/Server/src/Circuits/CircuitFactory.cs +++ b/src/Components/Server/src/Circuits/CircuitFactory.cs @@ -66,6 +66,7 @@ public async ValueTask CreateCircuitHostAsync( { navigationManager.Initialize(baseUri, uri); } + var componentsActivitySource = scope.ServiceProvider.GetService(); if (components.Count > 0) { @@ -109,6 +110,7 @@ public async ValueTask CreateCircuitHostAsync( navigationManager, circuitHandlers, _circuitMetrics, + componentsActivitySource, _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 b8bc2b05e158..38b50461ce3e 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -25,6 +25,7 @@ internal partial class CircuitHost : IAsyncDisposable private readonly RemoteNavigationManager _navigationManager; private readonly ILogger _logger; private readonly CircuitMetrics? _circuitMetrics; + private readonly ComponentsActivitySource? _componentsActivitySource; private Func, Task> _dispatchInboundActivity; private CircuitHandler[] _circuitHandlers; private bool _initialized; @@ -51,6 +52,7 @@ public CircuitHost( RemoteNavigationManager navigationManager, CircuitHandler[] circuitHandlers, CircuitMetrics? circuitMetrics, + ComponentsActivitySource? componentsActivitySource, ILogger logger) { CircuitId = circuitId; @@ -69,6 +71,7 @@ public CircuitHost( _navigationManager = navigationManager ?? throw new ArgumentNullException(nameof(navigationManager)); _circuitHandlers = circuitHandlers ?? throw new ArgumentNullException(nameof(circuitHandlers)); _circuitMetrics = circuitMetrics; + _componentsActivitySource = componentsActivitySource; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); Services = scope.ServiceProvider; @@ -105,7 +108,7 @@ public CircuitHost( // InitializeAsync is used in a fire-and-forget context, so it's responsible for its own // error handling. - public Task InitializeAsync(ProtectedPrerenderComponentApplicationStore store, CancellationToken cancellationToken) + public Task InitializeAsync(ProtectedPrerenderComponentApplicationStore store, ActivityContext httpContext, CancellationToken cancellationToken) { Log.InitializationStarted(_logger); @@ -115,13 +118,17 @@ public Task InitializeAsync(ProtectedPrerenderComponentApplicationStore store, C { throw new InvalidOperationException("The circuit host is already initialized."); } + Activity? activity = null; try { _initialized = true; // We're ready to accept incoming JSInterop calls from here on + activity = _componentsActivitySource?.StartCircuitActivity(CircuitId.Id, httpContext); + _startTime = (_circuitMetrics != null && _circuitMetrics.IsDurationEnabled()) ? Stopwatch.GetTimestamp() : 0; + // We only run the handlers in case we are in a Blazor Server scenario, which renders - // the components inmediately during start. + // the components immediately during start. // On Blazor Web scenarios we delay running these handlers until the first UpdateRootComponents call // We do this so that the handlers can have access to the restored application state. if (Descriptors.Count > 0) @@ -162,9 +169,13 @@ public Task InitializeAsync(ProtectedPrerenderComponentApplicationStore store, C _isFirstUpdate = Descriptors.Count == 0; Log.InitializationSucceeded(_logger); + + activity?.Stop(); } catch (Exception ex) { + _componentsActivitySource?.FailCircuitActivity(activity, ex); + // Report errors asynchronously. InitializeAsync is designed not to throw. Log.InitializationFailed(_logger, ex); UnhandledException?.Invoke(this, new UnhandledExceptionEventArgs(ex, isTerminating: false)); @@ -235,7 +246,6 @@ 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(); diff --git a/src/Components/Server/src/Circuits/CircuitMetrics.cs b/src/Components/Server/src/Circuits/CircuitMetrics.cs index fb772c119f51..da7b8e9d297b 100644 --- a/src/Components/Server/src/Circuits/CircuitMetrics.cs +++ b/src/Components/Server/src/Circuits/CircuitMetrics.cs @@ -26,7 +26,7 @@ public CircuitMetrics(IMeterFactory meterFactory) _circuitTotalCounter = _meter.CreateCounter( "aspnetcore.components.circuits.count", unit: "{circuits}", - description: "Number of active circuits."); + description: "Total number of circuits."); _circuitActiveCounter = _meter.CreateUpDownCounter( "aspnetcore.components.circuits.active_circuits", @@ -47,57 +47,48 @@ public CircuitMetrics(IMeterFactory meterFactory) public void OnCircuitOpened() { - var tags = new TagList(); - if (_circuitActiveCounter.Enabled) { - _circuitActiveCounter.Add(1, tags); + _circuitActiveCounter.Add(1); } if (_circuitTotalCounter.Enabled) { - _circuitTotalCounter.Add(1, tags); + _circuitTotalCounter.Add(1); } } public void OnConnectionUp() { - var tags = new TagList(); - if (_circuitConnectedCounter.Enabled) { - _circuitConnectedCounter.Add(1, tags); + _circuitConnectedCounter.Add(1); } } public void OnConnectionDown() { - var tags = new TagList(); - if (_circuitConnectedCounter.Enabled) { - _circuitConnectedCounter.Add(-1, tags); + _circuitConnectedCounter.Add(-1); } } public void OnCircuitDown(long startTimestamp, long currentTimestamp) { - // Tags must match request start. - var tags = new TagList(); - if (_circuitActiveCounter.Enabled) { - _circuitActiveCounter.Add(-1, tags); + _circuitActiveCounter.Add(-1); } if (_circuitConnectedCounter.Enabled) { - _circuitConnectedCounter.Add(-1, tags); + _circuitConnectedCounter.Add(-1); } if (_circuitDuration.Enabled) { var duration = Stopwatch.GetElapsedTime(startTimestamp, currentTimestamp); - _circuitDuration.Record(duration.TotalSeconds, tags); + _circuitDuration.Record(duration.TotalSeconds); } } diff --git a/src/Components/Server/src/Circuits/RemoteRenderer.cs b/src/Components/Server/src/Circuits/RemoteRenderer.cs index 31b29206212b..7f2345bff74a 100644 --- a/src/Components/Server/src/Circuits/RemoteRenderer.cs +++ b/src/Components/Server/src/Circuits/RemoteRenderer.cs @@ -60,11 +60,11 @@ public RemoteRenderer( public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault(); - protected override ResourceAssetCollection Assets => _resourceCollection ?? base.Assets; + protected internal override ResourceAssetCollection Assets => _resourceCollection ?? base.Assets; - protected override RendererInfo RendererInfo => _componentPlatform; + protected internal override RendererInfo RendererInfo => _componentPlatform; - protected override IComponentRenderMode? GetComponentRenderMode(IComponent component) => RenderMode.InteractiveServer; + protected internal override IComponentRenderMode? GetComponentRenderMode(IComponent component) => RenderMode.InteractiveServer; public Task AddComponentAsync(Type componentType, ParameterView parameters, string domElementSelector) { @@ -306,7 +306,7 @@ public Task OnRenderCompletedAsync(long incomingBatchId, string? errorMessageOrN } } - protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode) + protected internal override IComponent ResolveComponentForRenderMode([DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode) => renderMode switch { InteractiveServerRenderMode or InteractiveAutoRenderMode => componentActivator.CreateInstance(componentType), @@ -369,7 +369,7 @@ private async Task CaptureAsyncExceptions(Task task) } } - private static partial class Log + private static new partial class Log { [LoggerMessage(100, LogLevel.Warning, "Unhandled exception rendering component: {Message}", EventName = "ExceptionRenderingComponent")] private static partial void UnhandledExceptionRenderingComponent(ILogger logger, string message, Exception exception); diff --git a/src/Components/Server/src/ComponentHub.cs b/src/Components/Server/src/ComponentHub.cs index b3308f17bfd7..84561349ee48 100644 --- a/src/Components/Server/src/ComponentHub.cs +++ b/src/Components/Server/src/ComponentHub.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers; +using System.Diagnostics; using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.AspNetCore.DataProtection; @@ -43,6 +44,7 @@ internal sealed partial class ComponentHub : Hub private readonly CircuitRegistry _circuitRegistry; private readonly ICircuitHandleRegistry _circuitHandleRegistry; private readonly ILogger _logger; + private readonly ActivityContext _httpContext; public ComponentHub( IServerComponentDeserializer serializer, @@ -60,6 +62,7 @@ public ComponentHub( _circuitRegistry = circuitRegistry; _circuitHandleRegistry = circuitHandleRegistry; _logger = logger; + _httpContext = ComponentsActivitySource.CaptureHttpContext(); } /// @@ -137,7 +140,7 @@ public async ValueTask StartCircuit(string baseUri, string uri, string s // SignalR message loop (we'd get a deadlock if any of the initialization // logic relied on receiving a subsequent message from SignalR), and it will // take care of its own errors anyway. - _ = circuitHost.InitializeAsync(store, Context.ConnectionAborted); + _ = circuitHost.InitializeAsync(store, _httpContext, Context.ConnectionAborted); // It's safe to *publish* the circuit now because nothing will be able // to run inside it until after InitializeAsync completes. diff --git a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs index 08195b0218c7..4c6eb34d27f6 100644 --- a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs +++ b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs @@ -2,7 +2,6 @@ // 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; @@ -62,11 +61,7 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti // 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(); services.TryAddSingleton(); diff --git a/src/Components/Server/test/Circuits/CircuitHostTest.cs b/src/Components/Server/test/Circuits/CircuitHostTest.cs index 47d7e7cf5a76..b68bdf8286fa 100644 --- a/src/Components/Server/test/Circuits/CircuitHostTest.cs +++ b/src/Components/Server/test/Circuits/CircuitHostTest.cs @@ -193,7 +193,7 @@ public async Task InitializeAsync_InvokesHandlers() var circuitHost = TestCircuitHost.Create(handlers: new[] { handler1.Object, handler2.Object }); // Act - await circuitHost.InitializeAsync(new ProtectedPrerenderComponentApplicationStore(Mock.Of()), cancellationToken); + await circuitHost.InitializeAsync(new ProtectedPrerenderComponentApplicationStore(Mock.Of()), default, cancellationToken); // Assert handler1.VerifyAll(); @@ -236,7 +236,7 @@ public async Task InitializeAsync_RendersRootComponentsInParallel() // Act object initializeException = null; circuitHost.UnhandledException += (sender, eventArgs) => initializeException = eventArgs.ExceptionObject; - var initializeTask = circuitHost.InitializeAsync(new ProtectedPrerenderComponentApplicationStore(Mock.Of()), cancellationToken); + var initializeTask = circuitHost.InitializeAsync(new ProtectedPrerenderComponentApplicationStore(Mock.Of()), default, cancellationToken); await initializeTask.WaitAsync(initializeTimeout); // Assert: This was not reached only because an exception was thrown in InitializeAsync() @@ -266,7 +266,7 @@ public async Task InitializeAsync_ReportsOwnAsyncExceptions() }; // Act - var initializeAsyncTask = circuitHost.InitializeAsync(new ProtectedPrerenderComponentApplicationStore(Mock.Of()), new CancellationToken()); + var initializeAsyncTask = circuitHost.InitializeAsync(new ProtectedPrerenderComponentApplicationStore(Mock.Of()), default, new CancellationToken()); // Assert: No synchronous exceptions handler.VerifyAll(); diff --git a/src/Components/Server/test/Circuits/CircuitMetricsTest.cs b/src/Components/Server/test/Circuits/CircuitMetricsTest.cs index 770125996634..9124d5d64294 100644 --- a/src/Components/Server/test/Circuits/CircuitMetricsTest.cs +++ b/src/Components/Server/test/Circuits/CircuitMetricsTest.cs @@ -95,7 +95,7 @@ public void OnConnectionDown_DecreasesConnectedCounter() } [Fact] - public void OnCircuitDown_UpdatesCountersAndRecordsDuration() + public async Task OnCircuitDown_UpdatesCountersAndRecordsDuration() { // Arrange var circuitMetrics = new CircuitMetrics(_meterFactory); @@ -108,7 +108,7 @@ public void OnCircuitDown_UpdatesCountersAndRecordsDuration() // Act var startTime = Stopwatch.GetTimestamp(); - Thread.Sleep(10); // Add a small delay to ensure a measurable duration + await Task.Delay(10); // Add a small delay to ensure a measurable duration var endTime = Stopwatch.GetTimestamp(); circuitMetrics.OnCircuitDown(startTime, endTime); diff --git a/src/Components/Server/test/Circuits/TestCircuitHost.cs b/src/Components/Server/test/Circuits/TestCircuitHost.cs index eeb86ad3a639..11e9f7ed4d65 100644 --- a/src/Components/Server/test/Circuits/TestCircuitHost.cs +++ b/src/Components/Server/test/Circuits/TestCircuitHost.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.Diagnostics.Metrics; using Microsoft.AspNetCore.InternalTesting; using Microsoft.AspNetCore.SignalR; @@ -15,8 +16,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, CircuitMetrics circuitMetrics, ILogger logger) - : base(circuitId, scope, options, client, renderer, descriptors, jsRuntime, navigationManager, circuitHandlers, circuitMetrics, logger) + private TestCircuitHost(CircuitId circuitId, AsyncServiceScope scope, CircuitOptions options, CircuitClientProxy client, RemoteRenderer renderer, IReadOnlyList descriptors, RemoteJSRuntime jsRuntime, RemoteNavigationManager navigationManager, CircuitHandler[] circuitHandlers, CircuitMetrics circuitMetrics, ComponentsActivitySource componentsActivitySource, ILogger logger) + : base(circuitId, scope, options, client, renderer, descriptors, jsRuntime, navigationManager, circuitHandlers, circuitMetrics, componentsActivitySource, logger) { } @@ -38,6 +39,7 @@ public static CircuitHost Create( .Returns(jsRuntime); var serverComponentDeserializer = Mock.Of(); var circuitMetrics = new CircuitMetrics(new TestMeterFactory()); + var componentsActivitySource = new ComponentsActivitySource(); if (remoteRenderer == null) { @@ -64,6 +66,7 @@ public static CircuitHost Create( navigationManager, handlers, circuitMetrics, + componentsActivitySource, NullLogger.Instance); } } diff --git a/src/Components/Web/src/HtmlRendering/StaticHtmlRenderer.cs b/src/Components/Web/src/HtmlRendering/StaticHtmlRenderer.cs index 104dcb930d66..a77b3e98c4ec 100644 --- a/src/Components/Web/src/HtmlRendering/StaticHtmlRenderer.cs +++ b/src/Components/Web/src/HtmlRendering/StaticHtmlRenderer.cs @@ -21,7 +21,6 @@ public partial class StaticHtmlRenderer : Renderer { private static readonly RendererInfo _componentPlatform = new RendererInfo("Static", isInteractive: false); - private static readonly Task CanceledRenderTask = Task.FromCanceled(new CancellationToken(canceled: true)); private readonly NavigationManager? _navigationManager; /// diff --git a/src/Shared/Metrics/MetricsConstants.cs b/src/Shared/Metrics/MetricsConstants.cs index ff64c6fefcad..cdb338f1d7a0 100644 --- a/src/Shared/Metrics/MetricsConstants.cs +++ b/src/Shared/Metrics/MetricsConstants.cs @@ -11,6 +11,6 @@ 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]; + // For blazor circuit sessions, which can last a long time. + public static readonly IReadOnlyList VeryLongSecondsBucketBoundaries = [1, 10, 30, 1 * 60, 2 * 60, 3 * 60, 4 * 60, 5 * 60, 6 * 60, 7 * 60, 8 * 60, 9 * 60, 10 * 60, 1 * 60 * 60, 2 * 60 * 60, 3 * 60 * 60, 6 * 60 * 60, 12 * 60 * 60, 24 * 60 * 60]; }