Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Core/src/Diagnostics/DiagnosticsManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public DiagnosticsManager(IEnumerable<IDiagnosticMetrics> metrics, IEnumerable<I

public ActivitySource ActivitySource { get; }

public bool HasActivityListeners => ActivitySource.HasListeners();

public Meter? Meter { get; }

public void GetTags(object source, out TagList tagList)
Expand Down
2 changes: 2 additions & 0 deletions src/Core/src/Diagnostics/IDiagnosticsManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ internal interface IDiagnosticsManager
{
ActivitySource ActivitySource { get; }

bool HasActivityListeners { get; }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] API surface — HasActivityListeners duplicates ActivitySource.HasListeners() — Since the interface already exposes ActivitySource, callers can write diagnostics.ActivitySource.HasListeners() directly and skip widening the interface. Two reasons to keep the new member anyway, both valid: (a) tighter intent at the gate site, (b) easier mocking in unit tests. Suggest adding a one-line XML doc clarifying it's a hot-path gate so future readers don't treat it as redundant API.


Meter? Meter { get; }

void GetTags(object source, out TagList tagList);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,52 @@ internal static class DiagnosticInstrumentation
/// </summary>
/// <param name="view">The view to instrument.</param>
/// <returns>Returns an instance of <see cref="LayoutMeasureInstrumentation"/> if instrumentation is supported; otherwise, null.</returns>
public static LayoutMeasureInstrumentation? StartLayoutMeasure(IView view) =>
RuntimeFeature.IsMeterSupported
? new LayoutMeasureInstrumentation(view)
: null;
public static LayoutMeasureInstrumentation? StartLayoutMeasure(IView view)
{
if (!RuntimeFeature.IsMeterSupported)
{
return null;
}

var diagnostics = view.GetMauiDiagnostics();
if (diagnostics is null)
{
return null;
}

var metrics = diagnostics.GetMetrics<LayoutDiagnosticMetrics>();
if (!diagnostics.HasActivityListeners && metrics?.IsMeasureEnabled != true)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] AOT/trim & hot-path DI lookup — Every measure/arrange on every view pays a view.Handler?.MauiContext?.Services?.GetService<IDiagnosticsManager>() call (via GetMauiDiagnostics()). MS.DI's generic GetService<T> is fast (~tens of ns) but it's still a dictionary lookup on every layout pass, scaling with the visual tree. The benchmark (16 ns) suggests it's tolerable, but consider caching the resolved IDiagnosticsManager on the handler (or on MauiContext) as a follow-up — a single ref field read would be ~1 ns and remove the DI call from the per-view hot path entirely. Not blocking for this PR.

{
return null;
}

return new LayoutMeasureInstrumentation(view, diagnostics, metrics);
}

/// <summary>
/// Starts layout arrange instrumentation for the specified view.
/// </summary>
/// <param name="view">The view to instrument.</param>
/// <returns>Returns an instance of <see cref="LayoutArrangeInstrumentation"/> if instrumentation is supported; otherwise, null.</returns>
public static LayoutArrangeInstrumentation? StartLayoutArrange(IView view) =>
RuntimeFeature.IsMeterSupported
? new LayoutArrangeInstrumentation(view)
: null;
public static LayoutArrangeInstrumentation? StartLayoutArrange(IView view)
{
if (!RuntimeFeature.IsMeterSupported)
{
return null;
}

var diagnostics = view.GetMauiDiagnostics();
if (diagnostics is null)
{
return null;
}

var metrics = diagnostics.GetMetrics<LayoutDiagnosticMetrics>();
if (!diagnostics.HasActivityListeners && metrics?.IsArrangeEnabled != true)
{
return null;
}

return new LayoutArrangeInstrumentation(view, diagnostics, metrics);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,59 @@ namespace Microsoft.Maui.Diagnostics;
/// <summary>
/// Instrumentation for the layout arrange phase of a view.
/// </summary>
readonly struct LayoutArrangeInstrumentation(IView view) : IDiagnosticInstrumentation
readonly struct LayoutArrangeInstrumentation : System.IDisposable
{
readonly Activity? _activity = view.StartDiagnosticActivity("Arrange");
readonly IView _view;
readonly IDiagnosticsManager _diagnostics;
readonly LayoutDiagnosticMetrics? _metrics;
readonly Activity? _activity;
readonly bool _metricsDurationStarted;
readonly long _metricsStartTimestamp;

public LayoutArrangeInstrumentation(IView view, IDiagnosticsManager diagnostics, LayoutDiagnosticMetrics? metrics)
{
_view = view;
_diagnostics = diagnostics;
_metrics = metrics;

if (diagnostics.HasActivityListeners)
{
diagnostics.GetTags(view, out var tagList);
_activity = diagnostics.ActivitySource.StartActivity(
ActivityKind.Internal,
name: $"Arrange {view.GetType().Name}",
tags: tagList);
}
else
{
_activity = null;
}

_metricsDurationStarted = metrics?.IsArrangeDurationEnabled == true;
_metricsStartTimestamp = _metricsDurationStarted
? Stopwatch.GetTimestamp()
: 0;
}

/// <summary>
/// Disposes the instrumentation and stops the diagnostic activity.
/// </summary>
public void Dispose() =>
view.StopDiagnostics(_activity, this);
public void Dispose()
{
var metrics = _metrics;
var recordDuration = _metricsDurationStarted && metrics?.IsArrangeDurationEnabled == true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Logic — recordDuration re-check is asymmetric with IsArrangeEnabled recovery — If IsArrangeDurationEnabled flips from falsetrue between ctor and Dispose, _metricsDurationStarted is false so we won't have a timestamp, and recordDuration is forced false (correct). However if IsArrangeEnabled flips falsetrue mid-call, the counter is recorded with duration=0 and the histogram is silently skipped — a one-sided race that produces a counter sample with no matching histogram sample. The window is tiny and the consequence is benign (slightly inconsistent metric snapshot), but worth a one-line comment so a future reader doesn't assume the symmetric check is intentional. Same applies to LayoutMeasureInstrumentation.Dispose line 48.

var duration = recordDuration
? LayoutDiagnosticMetrics.GetElapsedNanoseconds(_metricsStartTimestamp)
: 0;

/// <summary>
/// Records the stopping of the instrumentation and publishes various metrics.
/// </summary>
/// <param name="diagnostics">The <see cref="IDiagnosticsManager"/> instance.</param>
/// <param name="tagList">The tags associated with the instrumentation.</param>
public void Stopped(IDiagnosticsManager diagnostics, in TagList tagList) =>
diagnostics.GetMetrics<LayoutDiagnosticMetrics>()?.RecordArrange(_activity?.Duration, in tagList);
_activity?.Stop();

if (metrics?.IsArrangeEnabled == true)
{
_diagnostics.GetTags(_view, out var tagList);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Performance — duplicated tagger walk — When both HasActivityListeners and IsArrangeEnabled are true, _diagnostics.GetTags(_view, ...) runs once in the constructor (for the Activity) and again here (for the metric). TagList is a stack struct but the tagger iteration still re-reads _taggers[] and re-invokes AddTags for every tagger. Consider caching the TagList as a field on the struct when the activity branch already produced it (cheap because the struct is on the stack and TagList copies in the tagger contract are already by-ref). Skip if you'd rather keep the struct slim — current cost only hits the dual-listener path. Same nit applies to LayoutMeasureInstrumentation.Dispose line 57.

metrics.RecordArrange(duration, recordDuration, in tagList);
}

_activity?.Dispose();
}
}
63 changes: 44 additions & 19 deletions src/Core/src/Diagnostics/Instrumentation/LayoutDiagnosticMetrics.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System;
using System.Diagnostics;
using System.Diagnostics.Metrics;

Expand Down Expand Up @@ -29,6 +28,18 @@ internal class LayoutDiagnosticMetrics : IDiagnosticMetrics
/// </summary>
internal Histogram<int>? ArrangeHistogram { get; private set; }

internal bool IsMeasureEnabled =>
MeasureCounter?.Enabled == true ||
MeasureHistogram?.Enabled == true;

internal bool IsMeasureDurationEnabled => MeasureHistogram?.Enabled == true;

internal bool IsArrangeEnabled =>
ArrangeCounter?.Enabled == true ||
ArrangeHistogram?.Enabled == true;

internal bool IsArrangeDurationEnabled => ArrangeHistogram?.Enabled == true;

/// <inheritdoc/>
public void Create(Meter meter)
{
Expand All @@ -42,38 +53,52 @@ public void Create(Meter meter)
/// <summary>
/// Records a measure operation with an optional duration and associated tags.
/// </summary>
/// <param name="duration">The duration of the measure operation.</param>
/// <param name="duration">The duration of the measure operation in nanoseconds.</param>
/// <param name="recordDuration">Whether a duration should be recorded.</param>
/// <param name="tagList">The tags associated with the measure operation.</param>
public void RecordMeasure(TimeSpan? duration, in TagList tagList)
public void RecordMeasure(int duration, bool recordDuration, in TagList tagList)
{
MeasureCounter?.Add(1, tagList);
if (MeasureCounter?.Enabled == true)
{
MeasureCounter.Add(1, tagList);
}

if (duration is not null)
if (recordDuration && MeasureHistogram?.Enabled == true)
{
#if NET9_0_OR_GREATER
MeasureHistogram?.Record((int)duration.Value.TotalNanoseconds, tagList);
#else
MeasureHistogram?.Record((int)(duration.Value.TotalMilliseconds * 1_000_000), tagList);
#endif
MeasureHistogram.Record(duration, tagList);
}
}

/// <summary>
/// Records an arrange operation with an optional duration and associated tags.
/// </summary>
/// <param name="duration">The duration of the arrange operation.</param>
/// <param name="duration">The duration of the arrange operation in nanoseconds.</param>
/// <param name="recordDuration">Whether a duration should be recorded.</param>
/// <param name="tagList">The tags associated with the arrange operation.</param>
public void RecordArrange(TimeSpan? duration, in TagList tagList)
public void RecordArrange(int duration, bool recordDuration, in TagList tagList)
{
ArrangeCounter?.Add(1, tagList);
if (ArrangeCounter?.Enabled == true)
{
ArrangeCounter.Add(1, tagList);
}

if (duration is not null)
if (recordDuration && ArrangeHistogram?.Enabled == true)
{
#if NET9_0_OR_GREATER
ArrangeHistogram?.Record((int)duration.Value.TotalNanoseconds, tagList);
#else
ArrangeHistogram?.Record((int)(duration.Value.TotalMilliseconds * 1_000_000), tagList);
#endif
ArrangeHistogram.Record(duration, tagList);
}
}

internal static int GetElapsedNanoseconds(long startTimestamp)
{
var elapsedTimestamp = Stopwatch.GetTimestamp() - startTimestamp;
if (elapsedTimestamp <= 0)
{
return 0;
}

var elapsedNanoseconds = elapsedTimestamp * (1_000_000_000.0 / Stopwatch.Frequency);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Performance — recomputed scale factor1_000_000_000.0 / Stopwatch.Frequency is recomputed on every call. Stopwatch.Frequency is a runtime constant per process, but the JIT cannot fold the division because Frequency is a static property, not a const. Hoist into a private static readonly double s_tickToNanoseconds = 1_000_000_000.0 / Stopwatch.Frequency; to save a div per measure/arrange when durations are recorded. Pure win on the histogram-enabled hot path.

return elapsedNanoseconds >= int.MaxValue
? int.MaxValue
: (int)elapsedNanoseconds;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,59 @@ namespace Microsoft.Maui.Diagnostics;
/// <summary>
/// Instrumentation for measuring layout operations in a view.
/// </summary>
readonly struct LayoutMeasureInstrumentation(IView view) : IDiagnosticInstrumentation
readonly struct LayoutMeasureInstrumentation : System.IDisposable
{
readonly Activity? _activity = view.StartDiagnosticActivity("Measure");
readonly IView _view;
readonly IDiagnosticsManager _diagnostics;
readonly LayoutDiagnosticMetrics? _metrics;
readonly Activity? _activity;
readonly bool _metricsDurationStarted;
readonly long _metricsStartTimestamp;

public LayoutMeasureInstrumentation(IView view, IDiagnosticsManager diagnostics, LayoutDiagnosticMetrics? metrics)
{
_view = view;
_diagnostics = diagnostics;
_metrics = metrics;

if (diagnostics.HasActivityListeners)
{
diagnostics.GetTags(view, out var tagList);
_activity = diagnostics.ActivitySource.StartActivity(
ActivityKind.Internal,
name: $"Measure {view.GetType().Name}",
tags: tagList);
}
else
{
_activity = null;
}

_metricsDurationStarted = metrics?.IsMeasureDurationEnabled == true;
_metricsStartTimestamp = _metricsDurationStarted
? Stopwatch.GetTimestamp()
: 0;
}

/// <summary>
/// Disposes the instrumentation and stops the diagnostic activity.
/// </summary>
public void Dispose() =>
view.StopDiagnostics(_activity, this);
public void Dispose()
{
var metrics = _metrics;
var recordDuration = _metricsDurationStarted && metrics?.IsMeasureDurationEnabled == true;
var duration = recordDuration
? LayoutDiagnosticMetrics.GetElapsedNanoseconds(_metricsStartTimestamp)
: 0;

/// <summary>
/// Records the stopping of the instrumentation and publishes various metrics.
/// </summary>
/// <param name="diagnostics">The <see cref="IDiagnosticsManager"/> instance.</param>
/// <param name="tagList">The tags associated with the instrumentation.</param>
public void Stopped(IDiagnosticsManager diagnostics, in TagList tagList) =>
diagnostics.GetMetrics<LayoutDiagnosticMetrics>()?.RecordMeasure(_activity?.Duration, in tagList);
_activity?.Stop();

if (metrics?.IsMeasureEnabled == true)
{
_diagnostics.GetTags(_view, out var tagList);
metrics.RecordMeasure(duration, recordDuration, in tagList);
}

_activity?.Dispose();
}
}
Loading
Loading