Repository: App-vNext/Polly

Issue:
Track execution time for retries and hedging attempts
We need to extend `OnHedgingArguments` and `OnRetryArguments` with the `ExecutionTime` property that allows tracking how long each attempt took. 

Additionally, we should also introduce `ExecutionAttemptArguments` that tracks the execution info for both hedging and retries.
We can then hook and additional telemetry for these events.


Hints:

Patch:
diff --git a/bench/BenchmarkDotNet.Artifacts/results/Polly.Core.Benchmarks.MultipleStrategiesBenchmark-report-github.md b/bench/BenchmarkDotNet.Artifacts/results/Polly.Core.Benchmarks.MultipleStrategiesBenchmark-report-github.md
index 56839c5e0a1..f4743763425 100644
--- a/bench/BenchmarkDotNet.Artifacts/results/Polly.Core.Benchmarks.MultipleStrategiesBenchmark-report-github.md
+++ b/bench/BenchmarkDotNet.Artifacts/results/Polly.Core.Benchmarks.MultipleStrategiesBenchmark-report-github.md
@@ -9,8 +9,8 @@ Job=MediumRun  Toolchain=InProcessEmitToolchain  IterationCount=15
 LaunchCount=2  WarmupCount=10  
 
 ```
-|                               Method |     Mean |     Error |    StdDev | Ratio |   Gen0 | Allocated | Alloc Ratio |
-|------------------------------------- |---------:|----------:|----------:|------:|-------:|----------:|------------:|
-|           ExecuteStrategyPipeline_V7 | 2.269 μs | 0.0136 μs | 0.0204 μs |  1.00 | 0.1106 |    2824 B |        1.00 |
-|           ExecuteStrategyPipeline_V8 | 1.861 μs | 0.0111 μs | 0.0155 μs |  0.82 |      - |      40 B |        0.01 |
-| ExecuteStrategyPipeline_Telemetry_V8 | 2.402 μs | 0.0104 μs | 0.0156 μs |  1.06 |      - |      40 B |        0.01 |
+|                               Method |     Mean |     Error |    StdDev | Ratio | RatioSD |   Gen0 | Allocated | Alloc Ratio |
+|------------------------------------- |---------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
+|           ExecuteStrategyPipeline_V7 | 2.220 μs | 0.0164 μs | 0.0236 μs |  1.00 |    0.00 | 0.1106 |    2824 B |        1.00 |
+|           ExecuteStrategyPipeline_V8 | 1.901 μs | 0.0089 μs | 0.0127 μs |  0.86 |    0.01 |      - |      40 B |        0.01 |
+| ExecuteStrategyPipeline_Telemetry_V8 | 2.947 μs | 0.0077 μs | 0.0115 μs |  1.33 |    0.02 |      - |      40 B |        0.01 |
diff --git a/src/Polly.Core/Hedging/Controller/HedgingController.cs b/src/Polly.Core/Hedging/Controller/HedgingController.cs
index 1138248d351..1cd7cc4e14e 100644
--- a/src/Polly.Core/Hedging/Controller/HedgingController.cs
+++ b/src/Polly.Core/Hedging/Controller/HedgingController.cs
@@ -1,4 +1,5 @@
 using Polly.Hedging.Controller;
+using Polly.Telemetry;
 
 namespace Polly.Hedging.Utils;
 
@@ -10,6 +11,7 @@ internal sealed class HedgingController<T>
     private int _rentedExecutions;
 
     public HedgingController(
+        ResilienceStrategyTelemetry telemetry,
         TimeProvider provider,
         HedgingHandler<T> handler,
         int maxAttempts)
@@ -20,7 +22,7 @@ public HedgingController(
         _executionPool = new ObjectPool<TaskExecution<T>>(() =>
         {
             Interlocked.Increment(ref _rentedExecutions);
-            return new TaskExecution<T>(handler, pool);
+            return new TaskExecution<T>(handler, pool, provider, telemetry);
         },
         _ =>
         {
diff --git a/src/Polly.Core/Hedging/Controller/TaskExecution.cs b/src/Polly.Core/Hedging/Controller/TaskExecution.cs
index a75a5201fd3..4aa7eaca3a8 100644
--- a/src/Polly.Core/Hedging/Controller/TaskExecution.cs
+++ b/src/Polly.Core/Hedging/Controller/TaskExecution.cs
@@ -1,4 +1,6 @@
+using System;
 using Polly.Hedging.Utils;
+using Polly.Telemetry;
 
 namespace Polly.Hedging.Controller;
 
@@ -21,15 +23,21 @@ internal sealed class TaskExecution<T>
 {
     private readonly ResilienceContext _cachedContext = ResilienceContext.Get();
     private readonly CancellationTokenSourcePool _cancellationTokenSourcePool;
+    private readonly TimeProvider _timeProvider;
+    private readonly ResilienceStrategyTelemetry _telemetry;
     private readonly HedgingHandler<T> _handler;
     private CancellationTokenSource? _cancellationSource;
     private CancellationTokenRegistration? _cancellationRegistration;
     private ResilienceContext? _activeContext;
+    private long _startExecutionTimestamp;
+    private long _stopExecutionTimestamp;
 
-    public TaskExecution(HedgingHandler<T> handler, CancellationTokenSourcePool cancellationTokenSourcePool)
+    public TaskExecution(HedgingHandler<T> handler, CancellationTokenSourcePool cancellationTokenSourcePool, TimeProvider timeProvider, ResilienceStrategyTelemetry telemetry)
     {
         _handler = handler;
         _cancellationTokenSourcePool = cancellationTokenSourcePool;
+        _timeProvider = timeProvider;
+        _telemetry = telemetry;
     }
 
     /// <summary>
@@ -56,6 +64,10 @@ public TaskExecution(HedgingHandler<T> handler, CancellationTokenSourcePool canc
 
     public Action<TaskExecution<T>>? OnReset { get; set; }
 
+    public TimeSpan ExecutionTime => _timeProvider.GetElapsedTime(_startExecutionTimestamp, _stopExecutionTimestamp);
+
+    public int Attempt { get; private set; }
+
     public void AcceptOutcome()
     {
         if (ExecutionTaskSafe?.IsCompleted == true)
@@ -83,8 +95,10 @@ public async ValueTask<bool> InitializeAsync<TResult, TState>(
         TState state,
         int attempt)
     {
+        Attempt = attempt;
         Type = type;
         _cancellationSource = _cancellationTokenSourcePool.Get(System.Threading.Timeout.InfiniteTimeSpan);
+        _startExecutionTimestamp = _timeProvider.GetTimestamp();
         Properties.Replace(snapshot.OriginalProperties);
 
         if (snapshot.OriginalCancellationToken.CanBeCanceled)
@@ -109,6 +123,7 @@ public async ValueTask<bool> InitializeAsync<TResult, TState>(
             }
             catch (Exception e)
             {
+                _stopExecutionTimestamp = _timeProvider.GetTimestamp();
                 ExecutionTaskSafe = ExecuteCreateActionException<TResult>(e);
                 return true;
             }
@@ -164,9 +179,12 @@ public async ValueTask ResetAsync()
         IsHandled = false;
         Properties.Clear();
         OnReset = null;
+        Attempt = 0;
         _activeContext = null;
         _cachedContext.Reset();
         _cancellationSource = null!;
+        _startExecutionTimestamp = 0;
+        _stopExecutionTimestamp = 0;
     }
 
     private async Task ExecuteSecondaryActionAsync<TResult>(Func<ValueTask<Outcome<TResult>>> action)
@@ -182,6 +200,7 @@ private async Task ExecuteSecondaryActionAsync<TResult>(Func<ValueTask<Outcome<T
             outcome = new Outcome<TResult>(e);
         }
 
+        _stopExecutionTimestamp = _timeProvider.GetTimestamp();
         await UpdateOutcomeAsync(outcome).ConfigureAwait(Context.ContinueOnCapturedContext);
     }
 
@@ -203,6 +222,7 @@ private async Task ExecutePrimaryActionAsync<TResult, TState>(Func<ResilienceCon
             outcome = new Outcome<TResult>(e);
         }
 
+        _stopExecutionTimestamp = _timeProvider.GetTimestamp();
         await UpdateOutcomeAsync(outcome).ConfigureAwait(Context.ContinueOnCapturedContext);
     }
 
@@ -211,6 +231,7 @@ private async Task UpdateOutcomeAsync<TResult>(Outcome<TResult> outcome)
         var args = new OutcomeArguments<TResult, HedgingPredicateArguments>(Context, outcome, new HedgingPredicateArguments());
         Outcome = outcome.AsOutcome();
         IsHandled = await _handler.ShouldHandle.HandleAsync(args).ConfigureAwait(Context.ContinueOnCapturedContext);
+        TelemetryUtil.ReportExecutionAttempt(_telemetry, Context, outcome, Attempt, ExecutionTime, IsHandled);
     }
 
     private void PrepareContext(ref ContextSnapshot snapshot)
diff --git a/src/Polly.Core/Hedging/HedgingResilienceStrategy.cs b/src/Polly.Core/Hedging/HedgingResilienceStrategy.cs
index 88f8d763b0c..9a9e6dd6e41 100644
--- a/src/Polly.Core/Hedging/HedgingResilienceStrategy.cs
+++ b/src/Polly.Core/Hedging/HedgingResilienceStrategy.cs
@@ -7,6 +7,7 @@ namespace Polly.Hedging;
 
 internal sealed class HedgingResilienceStrategy<T> : ResilienceStrategy
 {
+    private readonly TimeProvider _timeProvider;
     private readonly ResilienceStrategyTelemetry _telemetry;
     private readonly HedgingController<T> _controller;
 
@@ -22,11 +23,12 @@ public HedgingResilienceStrategy(
         HedgingDelay = hedgingDelay;
         MaxHedgedAttempts = maxHedgedAttempts;
         HedgingDelayGenerator = hedgingDelayGenerator;
+        _timeProvider = timeProvider;
         HedgingHandler = hedgingHandler;
         OnHedging = onHedging;
 
         _telemetry = telemetry;
-        _controller = new HedgingController<T>(timeProvider, HedgingHandler, maxHedgedAttempts);
+        _controller = new HedgingController<T>(telemetry, timeProvider, HedgingHandler, maxHedgedAttempts);
     }
 
     public TimeSpan HedgingDelay { get; }
@@ -75,7 +77,7 @@ private async ValueTask<Outcome<TResult>> ExecuteCoreAsync<TResult, TState>(
             attempt++;
             var continueOnCapturedContext = context.ContinueOnCapturedContext;
             var cancellationToken = context.CancellationToken;
-
+            var start = _timeProvider.GetTimestamp();
             if (cancellationToken.IsCancellationRequested)
             {
                 return new Outcome<TResult>(new OperationCanceledException(cancellationToken).TrySetStackTrace());
@@ -97,7 +99,7 @@ private async ValueTask<Outcome<TResult>> ExecuteCoreAsync<TResult, TState>(
                 await HandleOnHedgingAsync(
                     context,
                     new Outcome<TResult>(default(TResult)),
-                    new OnHedgingArguments(attempt, HasOutcome: false)).ConfigureAwait(context.ContinueOnCapturedContext);
+                    new OnHedgingArguments(attempt, HasOutcome: false, ExecutionTime: delay)).ConfigureAwait(context.ContinueOnCapturedContext);
                 continue;
             }
 
@@ -109,10 +111,11 @@ await HandleOnHedgingAsync(
                 return outcome;
             }
 
+            var executionTime = _timeProvider.GetElapsedTime(start);
             await HandleOnHedgingAsync(
                 context,
                 outcome,
-                new OnHedgingArguments(attempt, HasOutcome: true)).ConfigureAwait(context.ContinueOnCapturedContext);
+                new OnHedgingArguments(attempt, HasOutcome: true, executionTime)).ConfigureAwait(context.ContinueOnCapturedContext);
         }
     }
 
diff --git a/src/Polly.Core/Hedging/OnHedgingArguments.cs b/src/Polly.Core/Hedging/OnHedgingArguments.cs
index b1930cced0b..4c963bd4985 100644
--- a/src/Polly.Core/Hedging/OnHedgingArguments.cs
+++ b/src/Polly.Core/Hedging/OnHedgingArguments.cs
@@ -8,4 +8,8 @@ namespace Polly.Hedging;
 /// Determines whether the outcome is available before loading the next hedged task.
 /// No outcome indicates that the previous action did not finish within the hedging delay.
 /// </param>
-public record OnHedgingArguments(int Attempt, bool HasOutcome);
+/// <param name="ExecutionTime">
+/// The execution time of hedging attempt or the hedging delay
+/// in case the attempt was not finished in time.
+/// </param>
+public record OnHedgingArguments(int Attempt, bool HasOutcome, TimeSpan ExecutionTime);
diff --git a/src/Polly.Core/Retry/OnRetryArguments.cs b/src/Polly.Core/Retry/OnRetryArguments.cs
index 578106d0885..70e5e1926c9 100644
--- a/src/Polly.Core/Retry/OnRetryArguments.cs
+++ b/src/Polly.Core/Retry/OnRetryArguments.cs
@@ -5,4 +5,5 @@ namespace Polly.Retry;
 /// </summary>
 /// <param name="Attempt">The zero-based attempt number. The first attempt is 0, the second attempt is 1, and so on.</param>
 /// <param name="RetryDelay">The delay before the next retry.</param>
-public record OnRetryArguments(int Attempt, TimeSpan RetryDelay);
+/// <param name="ExecutionTime">The execution time of this attempt.</param>
+public record OnRetryArguments(int Attempt, TimeSpan RetryDelay, TimeSpan ExecutionTime);
diff --git a/src/Polly.Core/Retry/RetryResilienceStrategy.cs b/src/Polly.Core/Retry/RetryResilienceStrategy.cs
index d653a83f3de..418c1b73020 100644
--- a/src/Polly.Core/Retry/RetryResilienceStrategy.cs
+++ b/src/Polly.Core/Retry/RetryResilienceStrategy.cs
@@ -57,10 +57,15 @@ protected internal override async ValueTask<Outcome<TResult>> ExecuteCoreAsync<T
 
         while (true)
         {
+            var startTimestamp = _timeProvider.GetTimestamp();
             var outcome = await ExecuteCallbackSafeAsync(callback, context, state).ConfigureAwait(context.ContinueOnCapturedContext);
             var shouldRetryArgs = new OutcomeArguments<TResult, RetryPredicateArguments>(context, outcome, new RetryPredicateArguments(attempt));
+            var handle = await ShouldRetry.HandleAsync(shouldRetryArgs).ConfigureAwait(context.ContinueOnCapturedContext);
+            var executionTime = _timeProvider.GetElapsedTime(startTimestamp);
 
-            if (context.CancellationToken.IsCancellationRequested || IsLastAttempt(attempt) || !await ShouldRetry.HandleAsync(shouldRetryArgs).ConfigureAwait(context.ContinueOnCapturedContext))
+            TelemetryUtil.ReportExecutionAttempt(_telemetry, context, outcome, attempt, executionTime, handle);
+
+            if (context.CancellationToken.IsCancellationRequested || IsLastAttempt(attempt) || !handle)
             {
                 return outcome;
             }
@@ -76,7 +81,7 @@ protected internal override async ValueTask<Outcome<TResult>> ExecuteCoreAsync<T
                 }
             }
 
-            var onRetryArgs = new OutcomeArguments<TResult, OnRetryArguments>(context, outcome, new OnRetryArguments(attempt, delay));
+            var onRetryArgs = new OutcomeArguments<TResult, OnRetryArguments>(context, outcome, new OnRetryArguments(attempt, delay, executionTime));
             _telemetry.Report(RetryConstants.OnRetryEvent, onRetryArgs);
 
             if (OnRetry is not null)
diff --git a/src/Polly.Core/Telemetry/ExecutionAttemptArguments.Pool.cs b/src/Polly.Core/Telemetry/ExecutionAttemptArguments.Pool.cs
new file mode 100644
index 00000000000..38cee68d3df
--- /dev/null
+++ b/src/Polly.Core/Telemetry/ExecutionAttemptArguments.Pool.cs
@@ -0,0 +1,22 @@
+﻿namespace Polly.Telemetry;
+
+public partial class ExecutionAttemptArguments
+{
+    private static readonly ObjectPool<ExecutionAttemptArguments> Pool = new(() => new ExecutionAttemptArguments(), args =>
+    {
+        args.ExecutionTime = TimeSpan.Zero;
+        args.Attempt = 0;
+        args.Handled = false;
+    });
+
+    internal static ExecutionAttemptArguments Get(int attempt, TimeSpan executionTime, bool handled)
+    {
+        var args = Pool.Get();
+        args.Attempt = attempt;
+        args.ExecutionTime = executionTime;
+        args.Handled = handled;
+        return args;
+    }
+
+    internal static void Return(ExecutionAttemptArguments args) => Pool.Return(args);
+}
diff --git a/src/Polly.Core/Telemetry/ExecutionAttemptArguments.cs b/src/Polly.Core/Telemetry/ExecutionAttemptArguments.cs
new file mode 100644
index 00000000000..df3dc859f58
--- /dev/null
+++ b/src/Polly.Core/Telemetry/ExecutionAttemptArguments.cs
@@ -0,0 +1,39 @@
+﻿namespace Polly.Telemetry;
+
+/// <summary>
+/// Arguments that encapsulate the execution attempt for retries or hedging.
+/// </summary>
+public partial class ExecutionAttemptArguments
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="ExecutionAttemptArguments"/> class.
+    /// </summary>
+    /// <param name="attempt">The execution attempt.</param>
+    /// <param name="executionTime">The execution time.</param>
+    /// <param name="handled">Determines whether the attempt was handled by the strategy.</param>
+    public ExecutionAttemptArguments(int attempt, TimeSpan executionTime, bool handled)
+    {
+        Attempt = attempt;
+        ExecutionTime = executionTime;
+        Handled = handled;
+    }
+
+    private ExecutionAttemptArguments()
+    {
+    }
+
+    /// <summary>
+    /// Gets the attempt number.
+    /// </summary>
+    public int Attempt { get; private set; }
+
+    /// <summary>
+    /// Gets the execution time of the attempt.
+    /// </summary>
+    public TimeSpan ExecutionTime { get; private set; }
+
+    /// <summary>
+    /// Gets a value indicating whether the outcome was handled by retry or hedging strategy.
+    /// </summary>
+    public bool Handled { get; private set; }
+}
diff --git a/src/Polly.Core/Telemetry/ResilienceStrategyTelemetry.cs b/src/Polly.Core/Telemetry/ResilienceStrategyTelemetry.cs
index ff3f7984ac0..100fc9073a2 100644
--- a/src/Polly.Core/Telemetry/ResilienceStrategyTelemetry.cs
+++ b/src/Polly.Core/Telemetry/ResilienceStrategyTelemetry.cs
@@ -18,6 +18,11 @@ internal ResilienceStrategyTelemetry(ResilienceTelemetrySource source, Diagnosti
 
     internal ResilienceTelemetrySource TelemetrySource { get; }
 
+    /// <summary>
+    /// Gets a value indicating whether telemetry is enabled.
+    /// </summary>
+    public bool IsEnabled => DiagnosticSource is not null;
+
     /// <summary>
     /// Reports an event that occurred in a resilience strategy.
     /// </summary>
@@ -31,7 +36,7 @@ public void Report<TArgs>(string eventName, ResilienceContext context, TArgs arg
         Guard.NotNull(eventName);
         Guard.NotNull(context);
 
-        context.AddResilienceEvent(new ResilienceEvent(eventName));
+        AddResilienceEvent(eventName, context, args);
 
         if (DiagnosticSource is null || !DiagnosticSource.IsEnabled(eventName))
         {
@@ -57,7 +62,7 @@ public void Report<TArgs, TResult>(string eventName, OutcomeArguments<TResult, T
     {
         Guard.NotNull(eventName);
 
-        args.Context.AddResilienceEvent(new ResilienceEvent(eventName));
+        AddResilienceEvent(eventName, args.Context, args.Arguments);
 
         if (DiagnosticSource is null || !DiagnosticSource.IsEnabled(eventName))
         {
@@ -70,5 +75,17 @@ public void Report<TArgs, TResult>(string eventName, OutcomeArguments<TResult, T
 
         TelemetryEventArguments.Return(telemetryArgs);
     }
+
+    private static void AddResilienceEvent<TArgs>(string eventName, ResilienceContext context, TArgs args)
+    {
+        // ExecutionAttemptArguments is not reported as resilience event because that information is already contained
+        // in OnHedgingArguments and OnRetryArguments
+        if (args is ExecutionAttemptArguments attempt)
+        {
+            return;
+        }
+
+        context.AddResilienceEvent(new ResilienceEvent(eventName));
+    }
 }
 
diff --git a/src/Polly.Core/Telemetry/TelemetryUtil.cs b/src/Polly.Core/Telemetry/TelemetryUtil.cs
index 567706215c9..7486edd855f 100644
--- a/src/Polly.Core/Telemetry/TelemetryUtil.cs
+++ b/src/Polly.Core/Telemetry/TelemetryUtil.cs
@@ -4,6 +4,8 @@ internal static class TelemetryUtil
 {
     internal const string PollyDiagnosticSource = "Polly";
 
+    internal const string ExecutionAttempt = "ExecutionAttempt";
+
     internal static readonly ResiliencePropertyKey<string> StrategyKey = new("Polly.StrategyKey");
 
     public static ResilienceStrategyTelemetry CreateTelemetry(
@@ -17,4 +19,22 @@ public static ResilienceStrategyTelemetry CreateTelemetry(
 
         return new ResilienceStrategyTelemetry(telemetrySource, diagnosticSource);
     }
+
+    public static void ReportExecutionAttempt<TResult>(
+        ResilienceStrategyTelemetry telemetry,
+        ResilienceContext context,
+        Outcome<TResult> outcome,
+        int attempt,
+        TimeSpan executionTime,
+        bool handled)
+    {
+        if (!telemetry.IsEnabled)
+        {
+            return;
+        }
+
+        var attemptArgs = ExecutionAttemptArguments.Get(attempt, executionTime, handled);
+        telemetry.Report<ExecutionAttemptArguments, TResult>(ExecutionAttempt, new(context, outcome, attemptArgs));
+        ExecutionAttemptArguments.Return(attemptArgs);
+    }
 }
diff --git a/src/Polly.Core/Utils/TimeProvider.cs b/src/Polly.Core/Utils/TimeProvider.cs
index d8b76d2453b..400d5ff93e3 100644
--- a/src/Polly.Core/Utils/TimeProvider.cs
+++ b/src/Polly.Core/Utils/TimeProvider.cs
@@ -1,3 +1,5 @@
+using System.Diagnostics.CodeAnalysis;
+
 namespace Polly.Utils;
 
 #pragma warning disable S3872 // Parameter names should not duplicate the names of their methods
@@ -6,6 +8,7 @@ namespace Polly.Utils;
 /// TEMPORARY ONLY, to be replaced with System.TimeProvider - https://github.com/dotnet/runtime/issues/36617 later.
 /// </summary>
 /// <remarks>We trimmed some of the API that's not relevant for us too.</remarks>
+[ExcludeFromCodeCoverage]
 internal abstract class TimeProvider
 {
     private readonly double _tickFrequency;
@@ -22,7 +25,7 @@ protected TimeProvider(long timestampFrequency)
 
     public long TimestampFrequency { get; }
 
-    public abstract long GetTimestamp();
+    public virtual long GetTimestamp() => Environment.TickCount;
 
     public TimeSpan GetElapsedTime(long startingTimestamp, long endingTimestamp) => new((long)((endingTimestamp - startingTimestamp) * _tickFrequency));
 
diff --git a/src/Polly.Extensions/Telemetry/ResilienceTelemetryDiagnosticSource.cs b/src/Polly.Extensions/Telemetry/ResilienceTelemetryDiagnosticSource.cs
index 0f4a58bb38a..b0cfc2e0ab5 100644
--- a/src/Polly.Extensions/Telemetry/ResilienceTelemetryDiagnosticSource.cs
+++ b/src/Polly.Extensions/Telemetry/ResilienceTelemetryDiagnosticSource.cs
@@ -1,6 +1,5 @@
 using System.Diagnostics.Metrics;
 using Microsoft.Extensions.Logging;
-using Polly.Extensions.Utils;
 using Polly.Telemetry;
 
 namespace Polly.Extensions.Telemetry;
@@ -22,10 +21,17 @@ public ResilienceTelemetryDiagnosticSource(TelemetryOptions options)
         Counter = Meter.CreateCounter<int>(
             "resilience-events",
             description: "Tracks the number of resilience events that occurred in resilience strategies.");
+
+        AttemptDuration = Meter.CreateHistogram<double>(
+            "execution-attempt-duration",
+            unit: "ms",
+            description: "Tracks the duration of execution attempts.");
     }
 
     public Counter<int> Counter { get; }
 
+    public Histogram<double> AttemptDuration { get; }
+
     public override bool IsEnabled(string name) => true;
 
     public override void Write(string name, object? value)
@@ -39,16 +45,8 @@ public override void Write(string name, object? value)
         MeterEvent(args);
     }
 
-    private void MeterEvent(TelemetryEventArguments args)
+    private static void AddCommonTags(TelemetryEventArguments args, ResilienceTelemetrySource source, EnrichmentContext enrichmentContext)
     {
-        if (!Counter.Enabled)
-        {
-            return;
-        }
-
-        var source = args.Source;
-
-        var enrichmentContext = EnrichmentContext.Get(args.Context, args.Arguments, args.Outcome);
         enrichmentContext.Tags.Add(new(ResilienceTelemetryTags.EventName, args.EventName));
         enrichmentContext.Tags.Add(new(ResilienceTelemetryTags.BuilderName, source.BuilderName));
         enrichmentContext.Tags.Add(new(ResilienceTelemetryTags.StrategyName, source.StrategyName));
@@ -56,11 +54,35 @@ private void MeterEvent(TelemetryEventArguments args)
         enrichmentContext.Tags.Add(new(ResilienceTelemetryTags.StrategyKey, source.BuilderProperties.GetValue(TelemetryUtil.StrategyKey, null!)));
         enrichmentContext.Tags.Add(new(ResilienceTelemetryTags.ResultType, args.Context.GetResultType()));
         enrichmentContext.Tags.Add(new(ResilienceTelemetryTags.ExceptionName, args.Outcome?.Exception?.GetType().FullName));
-        EnrichmentUtil.Enrich(enrichmentContext, _enrichers);
+    }
 
-        Counter.Add(1, enrichmentContext.TagsSpan);
+    private void MeterEvent(TelemetryEventArguments args)
+    {
+        var source = args.Source;
+
+        if (args.Arguments is ExecutionAttemptArguments executionAttempt)
+        {
+            if (!AttemptDuration.Enabled)
+            {
+                return;
+            }
 
-        EnrichmentContext.Return(enrichmentContext);
+            var enrichmentContext = EnrichmentContext.Get(args.Context, args.Arguments, args.Outcome);
+            AddCommonTags(args, source, enrichmentContext);
+            enrichmentContext.Tags.Add(new(ResilienceTelemetryTags.AttemptNumber, executionAttempt.Attempt.AsBoxedInt()));
+            enrichmentContext.Tags.Add(new(ResilienceTelemetryTags.AttemptHandled, executionAttempt.Handled.AsBoxedBool()));
+            EnrichmentUtil.Enrich(enrichmentContext, _enrichers);
+            AttemptDuration.Record(executionAttempt.ExecutionTime.TotalMilliseconds, enrichmentContext.TagsSpan);
+            EnrichmentContext.Return(enrichmentContext);
+        }
+        else if (Counter.Enabled)
+        {
+            var enrichmentContext = EnrichmentContext.Get(args.Context, args.Arguments, args.Outcome);
+            AddCommonTags(args, source, enrichmentContext);
+            EnrichmentUtil.Enrich(enrichmentContext, _enrichers);
+            Counter.Add(1, enrichmentContext.TagsSpan);
+            EnrichmentContext.Return(enrichmentContext);
+        }
     }
 
     private void LogEvent(TelemetryEventArguments args)
diff --git a/src/Polly.Extensions/Telemetry/ResilienceTelemetryTags.cs b/src/Polly.Extensions/Telemetry/ResilienceTelemetryTags.cs
index 0fe4708cb0e..bbfbae10b50 100644
--- a/src/Polly.Extensions/Telemetry/ResilienceTelemetryTags.cs
+++ b/src/Polly.Extensions/Telemetry/ResilienceTelemetryTags.cs
@@ -17,4 +17,8 @@ internal class ResilienceTelemetryTags
     public const string ExceptionName = "exception-name";
 
     public const string ExecutionHealth = "execution-health";
+
+    public const string AttemptNumber = "attempt-number";
+
+    public const string AttemptHandled = "attempt-handled";
 }
diff --git a/src/Polly.Extensions/Telemetry/TelemetryResilienceStrategy.cs b/src/Polly.Extensions/Telemetry/TelemetryResilienceStrategy.cs
index b9c275ff246..738fb6b803d 100644
--- a/src/Polly.Extensions/Telemetry/TelemetryResilienceStrategy.cs
+++ b/src/Polly.Extensions/Telemetry/TelemetryResilienceStrategy.cs
@@ -1,7 +1,5 @@
 using System.Diagnostics.Metrics;
 using Microsoft.Extensions.Logging;
-using Polly.Extensions.Telemetry;
-using Polly.Extensions.Utils;
 using Polly.Utils;
 
 namespace Polly.Extensions.Telemetry;
diff --git a/src/Polly.Extensions/Telemetry/TelemetryResilienceStrategyBuilderExtensions.cs b/src/Polly.Extensions/Telemetry/TelemetryResilienceStrategyBuilderExtensions.cs
index b79940b6342..ad822a48292 100644
--- a/src/Polly.Extensions/Telemetry/TelemetryResilienceStrategyBuilderExtensions.cs
+++ b/src/Polly.Extensions/Telemetry/TelemetryResilienceStrategyBuilderExtensions.cs
@@ -1,6 +1,5 @@
 using Microsoft.Extensions.Logging;
 using Polly.Extensions.Telemetry;
-using Polly.Extensions.Utils;
 using Polly.Utils;
 
 namespace Polly;
diff --git a/src/Polly.Extensions/Telemetry/TelemetryUtil.cs b/src/Polly.Extensions/Telemetry/TelemetryUtil.cs
new file mode 100644
index 00000000000..bf07a06b0f6
--- /dev/null
+++ b/src/Polly.Extensions/Telemetry/TelemetryUtil.cs
@@ -0,0 +1,28 @@
+namespace Polly.Extensions.Telemetry;
+
+internal static class TelemetryUtil
+{
+    private const int MaxIntegers = 100;
+
+    private static readonly object[] Integers = Enumerable.Range(0, MaxIntegers).Select(v => (object)v).ToArray();
+
+    private static readonly object True = true;
+
+    private static readonly object False = false;
+
+    internal const string PollyDiagnosticSource = "Polly";
+
+    internal static readonly ResiliencePropertyKey<string> StrategyKey = new("Polly.StrategyKey");
+
+    public static object AsBoxedBool(this bool value) => value switch
+    {
+        true => True,
+        false => False,
+    };
+
+    public static object AsBoxedInt(this int value) => value switch
+    {
+        >= 0 and < MaxIntegers => Integers[value],
+        _ => value,
+    };
+}
diff --git a/src/Polly.Extensions/Utils/TelemetryUtil.cs b/src/Polly.Extensions/Utils/TelemetryUtil.cs
deleted file mode 100644
index 0b854951882..00000000000
--- a/src/Polly.Extensions/Utils/TelemetryUtil.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace Polly.Extensions.Utils;
-
-internal static class TelemetryUtil
-{
-    internal const string PollyDiagnosticSource = "Polly";
-
-    internal static readonly ResiliencePropertyKey<string> StrategyKey = new("Polly.StrategyKey");
-}


Test patch:
diff --git a/test/Polly.Core.Tests/Hedging/Controller/HedgingControllerTests.cs b/test/Polly.Core.Tests/Hedging/Controller/HedgingControllerTests.cs
index 77455c3b6e1..0fca74985fe 100644
--- a/test/Polly.Core.Tests/Hedging/Controller/HedgingControllerTests.cs
+++ b/test/Polly.Core.Tests/Hedging/Controller/HedgingControllerTests.cs
@@ -1,4 +1,5 @@
 using Polly.Hedging.Utils;
+using Polly.Telemetry;
 
 namespace Polly.Core.Tests.Hedging.Controller;
 
@@ -7,7 +8,8 @@ public class HedgingControllerTests
     [Fact]
     public async Task Pooling_Ok()
     {
-        var controller = new HedgingController<int>(new HedgingTimeProvider(), HedgingHelper.CreateHandler<int>(_ => false, args => null), 3);
+        var telemetry = TestUtilities.CreateResilienceTelemetry(_ => { });
+        var controller = new HedgingController<int>(telemetry, new HedgingTimeProvider(), HedgingHelper.CreateHandler<int>(_ => false, args => null), 3);
 
         var context1 = controller.GetContext(ResilienceContext.Get());
         await PrepareAsync(context1);
diff --git a/test/Polly.Core.Tests/Hedging/Controller/HedgingExecutionContextTests.cs b/test/Polly.Core.Tests/Hedging/Controller/HedgingExecutionContextTests.cs
index ea78a2a7aa3..c279c17d35f 100644
--- a/test/Polly.Core.Tests/Hedging/Controller/HedgingExecutionContextTests.cs
+++ b/test/Polly.Core.Tests/Hedging/Controller/HedgingExecutionContextTests.cs
@@ -477,7 +477,8 @@ private HedgingExecutionContext<DisposableResult> Create()
         var pool = new ObjectPool<TaskExecution<DisposableResult>>(
             () =>
             {
-                var execution = new TaskExecution<DisposableResult>(_hedgingHandler, CancellationTokenSourcePool.Create(_timeProvider));
+                var telemetry = TestUtilities.CreateResilienceTelemetry(_ => { });
+                var execution = new TaskExecution<DisposableResult>(_hedgingHandler, CancellationTokenSourcePool.Create(_timeProvider), _timeProvider, telemetry);
                 _createdExecutions.Add(execution);
                 return execution;
             },
diff --git a/test/Polly.Core.Tests/Hedging/Controller/TaskExecutionTests.cs b/test/Polly.Core.Tests/Hedging/Controller/TaskExecutionTests.cs
index 63d005a329d..a0ddc532e15 100644
--- a/test/Polly.Core.Tests/Hedging/Controller/TaskExecutionTests.cs
+++ b/test/Polly.Core.Tests/Hedging/Controller/TaskExecutionTests.cs
@@ -1,6 +1,7 @@
 using Polly.Hedging;
 using Polly.Hedging.Controller;
 using Polly.Hedging.Utils;
+using Polly.Telemetry;
 using Polly.Utils;
 
 namespace Polly.Core.Tests.Hedging.Controller;
@@ -12,10 +13,20 @@ public class TaskExecutionTests : IDisposable
     private readonly HedgingHandler<DisposableResult> _hedgingHandler;
     private readonly CancellationTokenSource _cts;
     private readonly HedgingTimeProvider _timeProvider;
+    private readonly ResilienceStrategyTelemetry _telemetry;
+    private readonly List<ExecutionAttemptArguments> _args = new();
     private ContextSnapshot _snapshot;
 
     public TaskExecutionTests()
     {
+        _telemetry = TestUtilities.CreateResilienceTelemetry(args =>
+        {
+            if (args.Arguments is ExecutionAttemptArguments attempt)
+            {
+                _args.Add(ExecutionAttemptArguments.Get(attempt.Attempt, attempt.ExecutionTime, attempt.Handled));
+            }
+        });
+
         _timeProvider = new HedgingTimeProvider();
         _cts = new CancellationTokenSource();
         _hedgingHandler = HedgingHelper.CreateHandler<DisposableResult>(outcome => outcome switch
@@ -44,12 +55,16 @@ await execution.InitializeAsync(HedgedTaskType.Primary, _snapshot,
                 return new Outcome<DisposableResult>(new DisposableResult { Name = value }).AsValueTask();
             },
             "dummy-state",
-            1);
+            99);
 
         await execution.ExecutionTaskSafe!;
         ((DisposableResult)execution.Outcome.Result!).Name.Should().Be(value);
         execution.IsHandled.Should().Be(handled);
         AssertPrimaryContext(execution.Context, execution);
+
+        _args.Should().HaveCount(1);
+        _args[0].Handled.Should().Be(handled);
+        _args[0].Attempt.Should().Be(99);
     }
 
     [Fact]
@@ -284,5 +299,5 @@ private void CreateSnapshot(CancellationToken? token = null)
         return () => new DisposableResult { Name = Handled }.AsOutcomeAsync();
     };
 
-    private TaskExecution<DisposableResult> Create() => new(_hedgingHandler, CancellationTokenSourcePool.Create(TimeProvider.System));
+    private TaskExecution<DisposableResult> Create() => new(_hedgingHandler, CancellationTokenSourcePool.Create(TimeProvider.System), _timeProvider, _telemetry);
 }
diff --git a/test/Polly.Core.Tests/Hedging/HedgingResilienceStrategyTests.cs b/test/Polly.Core.Tests/Hedging/HedgingResilienceStrategyTests.cs
index 7801b062c4a..f88df19454a 100644
--- a/test/Polly.Core.Tests/Hedging/HedgingResilienceStrategyTests.cs
+++ b/test/Polly.Core.Tests/Hedging/HedgingResilienceStrategyTests.cs
@@ -80,6 +80,32 @@ public async Task Execute_CancellationRequested_Throws()
         outcome.Exception!.StackTrace.Should().Contain("Execute_CancellationRequested_Throws");
     }
 
+    [Fact]
+    public void ExecutePrimaryAndSecondary_EnsureAttemptReported()
+    {
+        int timeStamp = 0;
+        _timeProvider.TimeStampProvider = () =>
+        {
+            timeStamp += 1000;
+            return timeStamp;
+        };
+        _options.MaxHedgedAttempts = 2;
+        ConfigureHedging(_ => true, args => () => "any".AsOutcomeAsync());
+        var strategy = Create();
+
+        strategy.Execute(_ => "dummy");
+
+        var attempts = _events.Select(v => v.Arguments).OfType<ExecutionAttemptArguments>().ToArray();
+
+        attempts[0].Handled.Should().BeTrue();
+        attempts[0].ExecutionTime.Should().Be(_timeProvider.GetElapsedTime(0, 1000));
+        attempts[0].Attempt.Should().Be(0);
+
+        attempts[1].Handled.Should().BeTrue();
+        attempts[1].ExecutionTime.Should().Be(_timeProvider.GetElapsedTime(0, 1000));
+        attempts[1].Attempt.Should().Be(1);
+    }
+
     [InlineData(-1)]
     [InlineData(-1000)]
     [InlineData(0)]
diff --git a/test/Polly.Core.Tests/Hedging/HedgingTimeProvider.cs b/test/Polly.Core.Tests/Hedging/HedgingTimeProvider.cs
index 3a008e42cdf..d1f04d52a07 100644
--- a/test/Polly.Core.Tests/Hedging/HedgingTimeProvider.cs
+++ b/test/Polly.Core.Tests/Hedging/HedgingTimeProvider.cs
@@ -21,10 +21,14 @@ public void Advance(TimeSpan diff)
         }
     }
 
+    public Func<int> TimeStampProvider { get; set; } = () => 0;
+
     public List<DelayEntry> DelayEntries { get; } = new List<DelayEntry>();
 
     public override DateTimeOffset UtcNow => _utcNow;
 
+    public override long GetTimestamp() => TimeStampProvider();
+
     public override void CancelAfter(CancellationTokenSource source, TimeSpan delay)
     {
         throw new NotSupportedException();
@@ -41,8 +45,6 @@ public override Task Delay(TimeSpan delayValue, CancellationToken cancellationTo
         return entry.Source.Task;
     }
 
-    public override long GetTimestamp() => throw new NotSupportedException();
-
     public record DelayEntry(TimeSpan Delay, TaskCompletionSource<bool> Source, DateTimeOffset TimeStamp)
     {
         public void Complete()
diff --git a/test/Polly.Core.Tests/Helpers/FakeTimeProvider.cs b/test/Polly.Core.Tests/Helpers/FakeTimeProvider.cs
index 969d6992555..d8a57bbcd46 100644
--- a/test/Polly.Core.Tests/Helpers/FakeTimeProvider.cs
+++ b/test/Polly.Core.Tests/Helpers/FakeTimeProvider.cs
@@ -41,6 +41,12 @@ public FakeTimeProvider SetupAnyDelay(CancellationToken cancellationToken = defa
         return this;
     }
 
+    public FakeTimeProvider SetupGetTimestamp()
+    {
+        Setup(x => x.GetTimestamp()).Returns(0);
+        return this;
+    }
+
     public FakeTimeProvider SetupDelay(TimeSpan delay, CancellationToken cancellationToken = default)
     {
         Setup(x => x.Delay(delay, cancellationToken)).Returns(Task.CompletedTask);
diff --git a/test/Polly.Core.Tests/Issues/IssuesTests.cs b/test/Polly.Core.Tests/Issues/IssuesTests.cs
index c94cc022449..0bb15eef3b0 100644
--- a/test/Polly.Core.Tests/Issues/IssuesTests.cs
+++ b/test/Polly.Core.Tests/Issues/IssuesTests.cs
@@ -2,5 +2,5 @@ namespace Polly.Core.Tests.Issues;
 
 public partial class IssuesTests
 {
-    private FakeTimeProvider TimeProvider { get; } = new FakeTimeProvider().SetupUtcNow().SetupAnyDelay();
+    private FakeTimeProvider TimeProvider { get; } = new FakeTimeProvider().SetupUtcNow().SetupAnyDelay().SetupGetTimestamp();
 }
diff --git a/test/Polly.Core.Tests/Retry/OnRetryArgumentsTests.cs b/test/Polly.Core.Tests/Retry/OnRetryArgumentsTests.cs
index 1364e578802..3237ee134ed 100644
--- a/test/Polly.Core.Tests/Retry/OnRetryArgumentsTests.cs
+++ b/test/Polly.Core.Tests/Retry/OnRetryArgumentsTests.cs
@@ -7,9 +7,10 @@ public class OnRetryArgumentsTests
     [Fact]
     public void Ctor_Ok()
     {
-        var args = new OnRetryArguments(2, TimeSpan.FromSeconds(3));
+        var args = new OnRetryArguments(2, TimeSpan.FromSeconds(3), TimeSpan.MaxValue);
 
         args.Attempt.Should().Be(2);
         args.RetryDelay.Should().Be(TimeSpan.FromSeconds(3));
+        args.ExecutionTime.Should().Be(TimeSpan.MaxValue);
     }
 }
diff --git a/test/Polly.Core.Tests/Retry/RetryResilienceStrategyTests.cs b/test/Polly.Core.Tests/Retry/RetryResilienceStrategyTests.cs
index 18e14b9dbec..009616d0b77 100644
--- a/test/Polly.Core.Tests/Retry/RetryResilienceStrategyTests.cs
+++ b/test/Polly.Core.Tests/Retry/RetryResilienceStrategyTests.cs
@@ -9,13 +9,15 @@ public class RetryResilienceStrategyTests
 {
     private readonly RetryStrategyOptions _options = new();
     private readonly FakeTimeProvider _timeProvider = new();
-    private readonly ResilienceStrategyTelemetry _telemetry;
     private readonly Mock<DiagnosticSource> _diagnosticSource = new();
+    private ResilienceStrategyTelemetry _telemetry;
 
     public RetryResilienceStrategyTests()
     {
         _telemetry = TestUtilities.CreateResilienceTelemetry(_diagnosticSource.Object);
         _options.ShouldHandle = _ => new ValueTask<bool>(false);
+
+        _timeProvider.SetupSequence(v => v.GetTimestamp()).Returns(0).Returns(100);
     }
 
     [Fact]
@@ -207,6 +209,53 @@ public void OnRetry_EnsureCorrectArguments()
         delays[2].Should().Be(TimeSpan.FromSeconds(6));
     }
 
+    [Fact]
+    public void OnRetry_EnsureExecutionTime()
+    {
+        _options.OnRetry = args =>
+        {
+            args.Arguments.ExecutionTime.Should().Be(_timeProvider.Object.GetElapsedTime(100, 1000));
+
+            return default;
+        };
+
+        _options.ShouldHandle = _ => PredicateResult.True;
+        _options.RetryCount = 1;
+        _options.BackoffType = RetryBackoffType.Linear;
+        _timeProvider.SetupAnyDelay();
+        _timeProvider
+            .SetupSequence(v => v.GetTimestamp())
+            .Returns(100)
+            .Returns(1000)
+            .Returns(100)
+            .Returns(1000);
+
+        var sut = CreateSut();
+
+        sut.Execute(() => 0);
+    }
+
+    [Fact]
+    public void Execute_EnsureAttemptReported()
+    {
+        var called = false;
+        _timeProvider.SetupSequence(v => v.GetTimestamp()).Returns(100).Returns(1000);
+        _telemetry = TestUtilities.CreateResilienceTelemetry(args =>
+        {
+            var attempt = args.Arguments.Should().BeOfType<ExecutionAttemptArguments>().Subject;
+
+            attempt.Handled.Should().BeFalse();
+            attempt.Attempt.Should().Be(0);
+            attempt.ExecutionTime.Should().Be(_timeProvider.Object.GetElapsedTime(100, 1000));
+            called = true;
+        });
+
+        var sut = CreateSut();
+
+        sut.Execute(() => 0);
+        called.Should().BeTrue();
+    }
+
     [Fact]
     public void OnRetry_EnsureTelemetry()
     {
diff --git a/test/Polly.Core.Tests/Telemetry/ExecutionAttemptArgumentsTests.cs b/test/Polly.Core.Tests/Telemetry/ExecutionAttemptArgumentsTests.cs
new file mode 100644
index 00000000000..80947ff0843
--- /dev/null
+++ b/test/Polly.Core.Tests/Telemetry/ExecutionAttemptArgumentsTests.cs
@@ -0,0 +1,39 @@
+﻿using System;
+using Polly.Telemetry;
+
+namespace Polly.Extensions.Tests.Telemetry;
+
+public class ExecutionAttemptArgumentsTests
+{
+    [Fact]
+    public void Ctor_Ok()
+    {
+        var args = new ExecutionAttemptArguments(99, TimeSpan.MaxValue, true);
+        Assert.NotNull(args);
+        args.Attempt.Should().Be(99);
+        args.ExecutionTime.Should().Be(TimeSpan.MaxValue);
+        args.Handled.Should().BeTrue();
+    }
+
+    [Fact]
+    public void Get_Ok()
+    {
+        var args = ExecutionAttemptArguments.Get(99, TimeSpan.MaxValue, true);
+        Assert.NotNull(args);
+        args.Attempt.Should().Be(99);
+        args.ExecutionTime.Should().Be(TimeSpan.MaxValue);
+        args.Handled.Should().BeTrue();
+    }
+
+    [Fact]
+    public void Return_EnsurePropertiesCleared()
+    {
+        var args = ExecutionAttemptArguments.Get(99, TimeSpan.MaxValue, true);
+
+        ExecutionAttemptArguments.Return(args);
+
+        args.Attempt.Should().Be(0);
+        args.ExecutionTime.Should().Be(TimeSpan.Zero);
+        args.Handled.Should().BeFalse();
+    }
+}
diff --git a/test/Polly.Core.Tests/Telemetry/ResilienceStrategyTelemetryTests.cs b/test/Polly.Core.Tests/Telemetry/ResilienceStrategyTelemetryTests.cs
index 034683fc82b..bce36341bb0 100644
--- a/test/Polly.Core.Tests/Telemetry/ResilienceStrategyTelemetryTests.cs
+++ b/test/Polly.Core.Tests/Telemetry/ResilienceStrategyTelemetryTests.cs
@@ -38,6 +38,16 @@ public void Report_NoOutcome_OK()
         _diagnosticSource.VerifyAll();
     }
 
+    [Fact]
+    public void Report_Attempt_EnsureNotRecordedOnResilienceContext()
+    {
+        var context = ResilienceContext.Get();
+        _diagnosticSource.Setup(o => o.IsEnabled("dummy-event")).Returns(false);
+        _sut.Report("dummy-event", context, ExecutionAttemptArguments.Get(0, TimeSpan.Zero, true));
+
+        context.ResilienceEvents.Should().BeEmpty();
+    }
+
     [Fact]
     public void Report_NoOutcomeWhenNotSubscribed_None()
     {
diff --git a/test/Polly.Extensions.Tests/Telemetry/ResilienceTelemetryDiagnosticSourceTests.cs b/test/Polly.Extensions.Tests/Telemetry/ResilienceTelemetryDiagnosticSourceTests.cs
index 2ebce931327..4fe0e37cc14 100644
--- a/test/Polly.Extensions.Tests/Telemetry/ResilienceTelemetryDiagnosticSourceTests.cs
+++ b/test/Polly.Extensions.Tests/Telemetry/ResilienceTelemetryDiagnosticSourceTests.cs
@@ -1,6 +1,7 @@
 using System.Net.Http;
 using Microsoft.Extensions.Logging;
 using Polly.Extensions.Telemetry;
+using Polly.Telemetry;
 
 namespace Polly.Extensions.Tests.Telemetry;
 
@@ -31,8 +32,11 @@ public void Meter_Ok()
     {
         ResilienceTelemetryDiagnosticSource.Meter.Name.Should().Be("Polly");
         ResilienceTelemetryDiagnosticSource.Meter.Version.Should().Be("1.0");
-        new ResilienceTelemetryDiagnosticSource(new TelemetryOptions())
-            .Counter.Description.Should().Be("Tracks the number of resilience events that occurred in resilience strategies.");
+        var source = new ResilienceTelemetryDiagnosticSource(new TelemetryOptions());
+
+        source.Counter.Description.Should().Be("Tracks the number of resilience events that occurred in resilience strategies.");
+        source.AttemptDuration.Description.Should().Be("Tracks the duration of execution attempts.");
+        source.AttemptDuration.Unit.Should().Be("ms");
     }
 
     [Fact]
@@ -151,6 +155,49 @@ public void WriteEvent_MeteringWithoutEnrichers_Ok(bool noOutcome, bool exceptio
         }
     }
 
+    [InlineData(true, false)]
+    [InlineData(false, false)]
+    [InlineData(true, true)]
+    [InlineData(false, true)]
+    [Theory]
+    public void WriteExecutionAttemptEvent_Metering_Ok(bool noOutcome, bool exception)
+    {
+        var telemetry = Create();
+        var attemptArg = new ExecutionAttemptArguments(5, TimeSpan.FromSeconds(50), true);
+        Outcome<object>? outcome = noOutcome switch
+        {
+            false => null,
+            true when exception => new Outcome<object>(new InvalidOperationException("Dummy message.")),
+            _ => new Outcome<object>(true)
+        };
+        ReportEvent(telemetry, outcome, context: ResilienceContext.Get().WithResultType<bool>(), arg: attemptArg);
+
+        var events = GetEvents("execution-attempt-duration");
+        events.Should().HaveCount(1);
+        var ev = events[0];
+
+        ev.Count.Should().Be(9);
+        ev["event-name"].Should().Be("my-event");
+        ev["strategy-type"].Should().Be("my-strategy-type");
+        ev["strategy-name"].Should().Be("my-strategy");
+        ev["strategy-key"].Should().Be("my-strategy-key");
+        ev["builder-name"].Should().Be("my-builder");
+        ev["result-type"].Should().Be("Boolean");
+        ev["attempt-number"].Should().Be(5);
+        ev["attempt-handled"].Should().Be(true);
+
+        if (outcome?.Exception is not null)
+        {
+            ev["exception-name"].Should().Be("System.InvalidOperationException");
+        }
+        else
+        {
+            ev["exception-name"].Should().Be(null);
+        }
+
+        _events.Single(v => v.Name == "execution-attempt-duration").Measurement.Should().Be(50000);
+    }
+
     [InlineData(1)]
     [InlineData(100)]
     [Theory]
@@ -208,7 +255,12 @@ private ResilienceTelemetryDiagnosticSource Create(Action<ICollection<Action<Enr
         return new(options);
     }
 
-    private static void ReportEvent(ResilienceTelemetryDiagnosticSource telemetry, Outcome<object>? outcome, string? strategyKey = "my-strategy-key", ResilienceContext? context = null)
+    private static void ReportEvent(
+        ResilienceTelemetryDiagnosticSource telemetry,
+        Outcome<object>? outcome,
+        string? strategyKey = "my-strategy-key",
+        ResilienceContext? context = null,
+        object? arg = null)
     {
         context ??= ResilienceContext.Get();
         var props = new ResilienceProperties();
@@ -225,6 +277,6 @@ private static void ReportEvent(ResilienceTelemetryDiagnosticSource telemetry, O
             "my-strategy-type",
             context,
             outcome,
-            new TestArguments());
+            arg ?? new TestArguments());
     }
 }
diff --git a/test/Polly.Extensions.Tests/Utils/TelemetryUtilTests.cs b/test/Polly.Extensions.Tests/Utils/TelemetryUtilTests.cs
new file mode 100644
index 00000000000..b2efdeec2f6
--- /dev/null
+++ b/test/Polly.Extensions.Tests/Utils/TelemetryUtilTests.cs
@@ -0,0 +1,29 @@
+﻿using FluentAssertions;
+using Polly.Extensions.Telemetry;
+
+namespace Polly.Extensions.Tests.Utils;
+
+public class TelemetryUtilTests
+{
+    [Fact]
+    public void AsBoxedPool_Ok()
+    {
+        TelemetryUtil.AsBoxedBool(true).Should().Be(true);
+        TelemetryUtil.AsBoxedBool(false).Should().Be(false);
+    }
+
+    [Fact]
+    public void AsBoxedInt_Ok()
+    {
+        var hash = new HashSet<object>();
+
+        for (int i = 0; i < 100; i++)
+        {
+            i.AsBoxedInt().Should().BeSameAs(i.AsBoxedInt());
+            i.AsBoxedInt().Should().Be(i);
+        }
+
+        (-1).AsBoxedInt().Should().NotBeSameAs((-1).AsBoxedInt());
+        100.AsBoxedInt().Should().NotBeSameAs(100.AsBoxedInt());
+    }
+}
diff --git a/test/Polly.TestUtils/MeteringEvent.cs b/test/Polly.TestUtils/MeteringEvent.cs
index f422877d73c..ffe79df6d16 100644
--- a/test/Polly.TestUtils/MeteringEvent.cs
+++ b/test/Polly.TestUtils/MeteringEvent.cs
@@ -1,3 +1,3 @@
 namespace Polly.TestUtils;
 
-public record MeteringEvent(string Name, Dictionary<string, object?> Tags);
+public record MeteringEvent(object Measurement, string Name, Dictionary<string, object?> Tags);
diff --git a/test/Polly.TestUtils/TestUtilities.cs b/test/Polly.TestUtils/TestUtilities.cs
index 540e83d4374..f4d8f5cbefc 100644
--- a/test/Polly.TestUtils/TestUtilities.cs
+++ b/test/Polly.TestUtils/TestUtilities.cs
@@ -70,7 +70,7 @@ public static IDisposable EnablePollyMetering(ICollection<MeteringEvent> events)
         meterListener.Start();
 
         void OnMeasurementRecorded<T>(Instrument instrument, T measurement, ReadOnlySpan<KeyValuePair<string, object?>> tags, object? state)
-            => events.Add(new MeteringEvent(instrument.Name, tags.ToArray().ToDictionary(v => v.Key, v => v.Value)));
+            => events.Add(new MeteringEvent(measurement!, instrument.Name, tags.ToArray().ToDictionary(v => v.Key, v => v.Value)));
 
         return meterListener;
     }
@@ -113,9 +113,15 @@ private sealed class CallbackDiagnosticSource : DiagnosticSource
         public override void Write(string name, object? value)
         {
             var args = (TelemetryEventArguments)value!;
+            var arguments = args.Arguments;
+
+            if (arguments is ExecutionAttemptArguments attempt)
+            {
+                arguments = ExecutionAttemptArguments.Get(attempt.Attempt, attempt.ExecutionTime, attempt.Handled);
+            }
 
             // copy the args because these are pooled and in tests we want to preserve them
-            args = TelemetryEventArguments.Get(args.Source, args.EventName, args.Context, args.Outcome, args.Arguments);
+            args = TelemetryEventArguments.Get(args.Source, args.EventName, args.Context, args.Outcome, arguments);
             _callback(args);
         }
     }