From 19d95a014a0a2aeacd2af0d57044ffbdf6eac1f8 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Wed, 6 Aug 2025 14:30:05 -0500 Subject: [PATCH 1/3] Add diagnostics metrics tracking to Maui application --- .../samples/Controls.Sample/MauiProgram.cs | 43 +++++++-- .../src/Core/Diagnostics/MetricsTracker.cs | 70 +++++++++++++++ .../src/Core/VisualElement/VisualElement.cs | 7 +- src/Core/src/Diagnostics/MauiDiagnostics.cs | 89 +++++++++++++++++++ src/Core/src/Hosting/MauiAppBuilder.cs | 2 + src/Core/src/RuntimeFeature.cs | 11 +++ 6 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 src/Controls/src/Core/Diagnostics/MetricsTracker.cs create mode 100644 src/Core/src/Diagnostics/MauiDiagnostics.cs diff --git a/src/Controls/samples/Controls.Sample/MauiProgram.cs b/src/Controls/samples/Controls.Sample/MauiProgram.cs index 2fa788c24c15..0cf9a744c73f 100644 --- a/src/Controls/samples/Controls.Sample/MauiProgram.cs +++ b/src/Controls/samples/Controls.Sample/MauiProgram.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Runtime.CompilerServices; using Maui.Controls.Sample.Controls; using Maui.Controls.Sample.Pages; using Maui.Controls.Sample.Services; @@ -69,6 +71,33 @@ public static MauiApp CreateMauiApp() #endif } + + appBuilder.Services.AddMetrics(); + ActivitySource.AddActivityListener(new ActivityListener + { + ShouldListenTo = source => true, + Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStarted = activity => Console.WriteLine("Started: {0,-15} {1,-60}", activity.OperationName, activity.Id), + ActivityStopped = activity => Console.WriteLine("Stopped: {0,-15} {1,-60} {2,-15}", activity.OperationName, activity.Id, activity.Duration) + }); + + MeterListener meterListener = new(); + meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name is "Microsoft.Maui.Diagnostics") + { + listener.EnableMeasurementEvents(instrument); + } + }; + + meterListener.SetMeasurementEventCallback((Instrument instrument, int measurement, ReadOnlySpan> tags, object? state) => + { + Console.WriteLine($"{instrument.Name} recorded measurement {measurement}"); + }); + // Start the meterListener, enabling InstrumentPublished callbacks. + meterListener.Start(); + + if (UseMauiGraphicsSkia) { /* @@ -76,13 +105,13 @@ public static MauiApp CreateMauiApp() { handlers.AddHandler(); handlers.AddHandler(); - handlers.AddHandler(); - handlers.AddHandler(); - handlers.AddHandler(); - handlers.AddHandler(); - handlers.AddHandler(); - handlers.AddHandler(); - handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(); }); */ } diff --git a/src/Controls/src/Core/Diagnostics/MetricsTracker.cs b/src/Controls/src/Core/Diagnostics/MetricsTracker.cs new file mode 100644 index 000000000000..196998773d04 --- /dev/null +++ b/src/Controls/src/Core/Diagnostics/MetricsTracker.cs @@ -0,0 +1,70 @@ +using System; +using System.Diagnostics; +using Microsoft.Maui.Diagnostics; + +namespace Microsoft.Maui.Controls.Diagnostics; + +enum DiagnosticsMeasuring +{ + None, + Measure, + Arrange +} + +readonly struct MetricsTracker : IDisposable +{ + readonly Activity? _activity; + readonly IView _view; + readonly DiagnosticsMeasuring _activityName; + + public static MetricsTracker? Create(IView view, DiagnosticsMeasuring diagnosticsMeasuring) + { + if (RuntimeFeature.IsMeterSupported) + return new MetricsTracker(view, diagnosticsMeasuring); + + return null; + } + + MetricsTracker(IView view, DiagnosticsMeasuring diagnosticsMeasuring) + { + _view = view; + _activity = view.StartActivity($"{diagnosticsMeasuring}"); + _activityName = diagnosticsMeasuring; + + if (_activity is null) + return; + + if (view is Element element) + { + _activity.SetTag("element.id", element.Id); + _activity.SetTag("element.automation_id", element.AutomationId); + _activity.SetTag("element.class_id", element.ClassId); + _activity.SetTag("element.style_id", element.StyleId); + } + + if (view is VisualElement ve) + { + _activity.SetTag("element.class", ve.@class); + _activity.SetTag("element.frame", ve.Frame); + } + } + + public void Dispose() + { + _activity?.Stop(); + + switch (_activityName) + { + case DiagnosticsMeasuring.Arrange: + _view.RecordArrange(_activity?.Duration); + break; + case DiagnosticsMeasuring.Measure: + _view.RecordMeasure(_activity?.Duration); + break; + default: + break; + } + + _activity?.Dispose(); + } +} \ No newline at end of file diff --git a/src/Controls/src/Core/VisualElement/VisualElement.cs b/src/Controls/src/Core/VisualElement/VisualElement.cs index 772d4ba27689..854291fc7bdb 100644 --- a/src/Controls/src/Core/VisualElement/VisualElement.cs +++ b/src/Controls/src/Core/VisualElement/VisualElement.cs @@ -4,9 +4,10 @@ using System.ComponentModel; using System.Diagnostics; using System.Globalization; +using Microsoft.Maui.Controls.Diagnostics; using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Controls.Shapes; - +using Microsoft.Maui.Diagnostics; using Microsoft.Maui.Graphics; using Microsoft.Maui.Layouts; using Geometry = Microsoft.Maui.Controls.Shapes.Geometry; @@ -1888,11 +1889,12 @@ public int ZIndex public void Arrange(Rect bounds) { ArrangeOverride(bounds); - } + } /// Size IView.Arrange(Rect bounds) { + using var activity = MetricsTracker.Create(this, DiagnosticsMeasuring.Arrange); return ArrangeOverride(bounds); } @@ -1947,6 +1949,7 @@ void IView.InvalidateArrange() /// Size IView.Measure(double widthConstraint, double heightConstraint) { + using var activity = MetricsTracker.Create(this, DiagnosticsMeasuring.Measure); DesiredSize = MeasureOverride(widthConstraint, heightConstraint); return DesiredSize; } diff --git a/src/Core/src/Diagnostics/MauiDiagnostics.cs b/src/Core/src/Diagnostics/MauiDiagnostics.cs new file mode 100644 index 000000000000..ec89af013802 --- /dev/null +++ b/src/Core/src/Diagnostics/MauiDiagnostics.cs @@ -0,0 +1,89 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.Hosting; + +namespace Microsoft.Maui.Diagnostics; + +internal class MauiDiagnostics +{ + public MauiDiagnostics(IMeterFactory? meterFactory = null) + { + ActivitySource = new ActivitySource("Microsoft.Maui.Diagnostics", "1.0.0"); + + Meters = meterFactory?.Create("Microsoft.Maui.Diagnostics", "1.0.0"); + + MeasureCounter = Meters?.CreateCounter("maui.layout.measure_count", "{times}", "The number of times a measure happened."); + ArrangeCounter = Meters?.CreateCounter("maui.layout.arrange_count", "{times}", "The number of times an arrange happened."); + + MeasureHistogram = Meters?.CreateHistogram("maui.layout.measure_duration", "ns"); + ArrangeHistogram = Meters?.CreateHistogram("maui.layout.arrange_duration", "ns"); + } + + public ActivitySource ActivitySource { get; } + public Meter? Meters { get; } + public Counter? MeasureCounter { get; } + public Counter? ArrangeCounter { get; } + public Histogram? MeasureHistogram { get; } + public Histogram? ArrangeHistogram { get; } +} + +internal static class MauiDiagnosticsExtensions +{ + public static MauiAppBuilder ConfigureMauiDiagnostics(this MauiAppBuilder builder) + { + if (RuntimeFeature.IsMeterSupported) + { + builder.Services.AddSingleton(serviceProvider => new MauiDiagnostics(serviceProvider.GetService())); + } + + return builder; + } + + static MauiDiagnostics? GetMauiDiagnostics(this IView view) + { + if (!RuntimeFeature.IsMeterSupported) + return null; + + return view.Handler?.MauiContext?.Services.GetService(); + } + + public static Activity? StartActivity(this IView view, string name) + { + if (!RuntimeFeature.IsMeterSupported) + return null; + + var elementName = view.GetType().Name; + + var activity = view.GetMauiDiagnostics()?.ActivitySource.StartActivity($"{name} {elementName}"); + + activity?.SetTag("element.type", view.GetType().FullName); + + return activity; + } + + public static void RecordMeasure(this IView view, TimeSpan? duration) + { + if (!RuntimeFeature.IsMeterSupported) + return; + + var diag = view.GetMauiDiagnostics(); + diag?.MeasureCounter?.Add(1); + + if (duration is not null) + diag?.MeasureHistogram?.Record((int)duration.Value.TotalNanoseconds); + } + + public static void RecordArrange(this IView view, TimeSpan? duration) + { + if (!RuntimeFeature.IsMeterSupported) + return; + + var diag = view.GetMauiDiagnostics(); + diag?.ArrangeCounter?.Add(1); + + if (duration is not null) + diag?.ArrangeHistogram?.Record((int)duration.Value.TotalNanoseconds); + } +} \ No newline at end of file diff --git a/src/Core/src/Hosting/MauiAppBuilder.cs b/src/Core/src/Hosting/MauiAppBuilder.cs index dbcc5bb0a1b6..dc9ff4c936e9 100644 --- a/src/Core/src/Hosting/MauiAppBuilder.cs +++ b/src/Core/src/Hosting/MauiAppBuilder.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Maui.Diagnostics; using Microsoft.Maui.LifecycleEvents; namespace Microsoft.Maui.Hosting @@ -53,6 +54,7 @@ internal MauiAppBuilder(bool useDefaults) this.ConfigureWindowEvents(); this.ConfigureDispatching(); this.ConfigureEnvironmentVariables(); + this.ConfigureMauiDiagnostics(); this.UseEssentials(); diff --git a/src/Core/src/RuntimeFeature.cs b/src/Core/src/RuntimeFeature.cs index f9641a261143..5bf7983e821b 100644 --- a/src/Core/src/RuntimeFeature.cs +++ b/src/Core/src/RuntimeFeature.cs @@ -25,6 +25,7 @@ static class RuntimeFeature const bool IsHybridWebViewSupportedByDefault = true; const bool SupportNamescopesByDefault = true; const bool EnableDiagnosticsByDefault = false; + const bool IsMeterSupportedByDefault = true; #pragma warning disable IL4000 // Return value does not match FeatureGuardAttribute 'System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute'. #if NET9_0_OR_GREATER @@ -102,6 +103,16 @@ public static bool AreNamescopesSupported } + // https://github.com/dotnet/runtime/blob/8c7de742a77ed3919a3f3fe8c4475fce689f5e83/src/libraries/System.Private.CoreLib/src/System/Diagnostics/Tracing/EventSource.cs#L291-L295 +#if NET9_0_OR_GREATER + [FeatureSwitchDefinition($"System.Diagnostics.Metrics.Meter.IsSupported")] +#endif + public static bool IsMeterSupported => AppContext.TryGetSwitch($"System.Diagnostics.Metrics.Meter.IsSupported", out bool isEnabled) + ? isEnabled + : IsMeterSupportedByDefault; + + + #if NET9_0_OR_GREATER [FeatureSwitchDefinition($"{FeatureSwitchPrefix}.{nameof(EnableDiagnostics)}")] #endif From 493df194df33e45c1bf51279d565aa82e18c815f Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Wed, 6 Aug 2025 14:35:34 -0500 Subject: [PATCH 2/3] - change this to just init once --- src/Core/src/RuntimeFeature.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Core/src/RuntimeFeature.cs b/src/Core/src/RuntimeFeature.cs index 5bf7983e821b..cfe10873787f 100644 --- a/src/Core/src/RuntimeFeature.cs +++ b/src/Core/src/RuntimeFeature.cs @@ -107,11 +107,10 @@ public static bool AreNamescopesSupported #if NET9_0_OR_GREATER [FeatureSwitchDefinition($"System.Diagnostics.Metrics.Meter.IsSupported")] #endif - public static bool IsMeterSupported => AppContext.TryGetSwitch($"System.Diagnostics.Metrics.Meter.IsSupported", out bool isEnabled) - ? isEnabled - : IsMeterSupportedByDefault; - + internal static bool IsMeterSupported { get; } = InitializeIsMeterSupported(); + private static bool InitializeIsMeterSupported() => + AppContext.TryGetSwitch("System.Diagnostics.Metrics.Meter.IsSupported", out bool isSupported) ? isSupported : IsMeterSupportedByDefault; #if NET9_0_OR_GREATER [FeatureSwitchDefinition($"{FeatureSwitchPrefix}.{nameof(EnableDiagnostics)}")] From 258c83ee4bc7e8ef302c4823ef6f01f42a4d293e Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Wed, 6 Aug 2025 14:55:06 -0500 Subject: [PATCH 3/3] fix for netstandard --- src/Core/src/Diagnostics/MauiDiagnostics.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Core/src/Diagnostics/MauiDiagnostics.cs b/src/Core/src/Diagnostics/MauiDiagnostics.cs index ec89af013802..621f0249e443 100644 --- a/src/Core/src/Diagnostics/MauiDiagnostics.cs +++ b/src/Core/src/Diagnostics/MauiDiagnostics.cs @@ -72,7 +72,13 @@ public static void RecordMeasure(this IView view, TimeSpan? duration) diag?.MeasureCounter?.Add(1); if (duration is not null) - diag?.MeasureHistogram?.Record((int)duration.Value.TotalNanoseconds); + { +#if NET9_0_OR_GREATER + diag?.MeasureHistogram?.Record((int)duration.Value.TotalNanoseconds); +#else + diag?.MeasureHistogram?.Record((int)(duration.Value.TotalMilliseconds * 1_000_000)); +#endif + } } public static void RecordArrange(this IView view, TimeSpan? duration) @@ -82,8 +88,14 @@ public static void RecordArrange(this IView view, TimeSpan? duration) var diag = view.GetMauiDiagnostics(); diag?.ArrangeCounter?.Add(1); - - if (duration is not null) + + if (duration is not null) + { +#if NET9_0_OR_GREATER diag?.ArrangeHistogram?.Record((int)duration.Value.TotalNanoseconds); +#else + diag?.ArrangeHistogram?.Record((int)(duration.Value.TotalMilliseconds * 1_000_000)); +#endif + } } } \ No newline at end of file