Skip to content

Commit 3b04a65

Browse files
TylerReidncipollinaCopilot
authored
Add metrics (#29)
* Add metrics for lock operations using System.Diagnostics.Metrics * add View documentation to the readme for filtering out unwanted metrics * fix issues found by copilot review * remove source generated metrics class, and instead implement a metrics interface * remove unused reference * add second constructors to DynamoDbDistributedLock and ExponentialBackoffRetryPolicy that don't take the lock interface to preserve backwards compatibility * Update src/DynamoDb.DistributedLock/Retry/ExponentialBackoffRetryPolicy.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/DynamoDb.DistributedLock.Tests/Metrics/TestMetricAggregator.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Nick Cipollina <ncipollina@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent a6525ee commit 3b04a65

17 files changed

Lines changed: 554 additions & 34 deletions

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<VersionPrefix>1.1.2</VersionPrefix>
3+
<VersionPrefix>1.2.0</VersionPrefix>
44
<PackageLicenseExpression>MIT</PackageLicenseExpression>
55

66
<!-- Other useful metadata -->

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,35 @@ However, the partition and sort key attribute names are fully configurable via `
225225
226226
---
227227

228+
## 📈 Telemetry
229+
230+
This library uses [System.Diagnostics.Metrics](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/metrics) `Meter`s to collect metrics. These metrics can be opted in to be exported to your preferred telemetry system (e.g., OpenTelemetry, console output) using standard dotnet telemetry exporters.
231+
232+
A full list of metric names can be found in the [MetricNames](src/DynamoDb.DistributedLock/Metrics/MetricNames.cs) class.
233+
234+
By default no metrics are exported to your collector, but you can enable them by configuring the `Meter` in your application:
235+
236+
[OpenTelemetry](https://opentelemetry.io/docs/languages/dotnet/)
237+
```csharp
238+
services.AddOpenTelemetry()
239+
.WithMetrics(metrics =>
240+
metrics
241+
// There is only one meter used by this library
242+
// and this constant value refers to its name
243+
// this causes the telemetry system to collect metrics emitted during lock operations
244+
.AddMeter(DynamoDb.DistributedLock.Metrics.MetricNames.MeterName)
245+
// Views can be used to filter out any metrics you do not want to collect
246+
// while still collecting all metrics from the Meter
247+
.AddView(DynamoDb.DistributedLock.Metrics.MetricNames.LockReleaseTimer, MetricStreamConfiguration.Drop)
248+
// Configure your preferred exporter, e.g., OpenTelemetry Protocol (OTLP)
249+
.AddOtlpExporter(options => options.Endpoint = otlpEndpoint)
250+
);
251+
```
252+
253+
Other options for collection of metrics are available, including local development options such as [dotnet-counters](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-counters) or the [.Net Aspire standalone dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone)
254+
255+
---
256+
228257
## 🧪 Unit Testing
229258

230259
Unit tests are written with:
@@ -241,7 +270,6 @@ The library provides `DynamoDbDistributedLockAutoData` to support streamlined te
241270

242271
- ⏱ Lock renewal support
243272
- 🔁 Auto-release logic for expired locks
244-
- 📈 Metrics and diagnostics support
245273
- 🎯 Health check integration
246274

247275
---

src/DynamoDb.DistributedLock/DynamoDbDistributedLock.cs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Diagnostics.Metrics;
34
using System.Threading;
45
using System.Threading.Tasks;
56
using Amazon.DynamoDBv2;
67
using Amazon.DynamoDBv2.Model;
8+
using DynamoDb.DistributedLock.Metrics;
79
using DynamoDb.DistributedLock.Retry;
810
using Microsoft.Extensions.Options;
911

@@ -17,18 +19,33 @@ public class DynamoDbDistributedLock : IDynamoDbDistributedLock
1719
private readonly IAmazonDynamoDB _client;
1820
private readonly DynamoDbLockOptions _options;
1921
private readonly Lazy<IRetryPolicy> _retryPolicy;
22+
private readonly ILockMetrics _lockMetrics;
2023

2124
/// <summary>
2225
/// Initializes a new instance of the <see cref="DynamoDbDistributedLock"/> class.
26+
/// Uses the default <see cref="ILockMetrics"/> instance
2327
/// </summary>
2428
/// <param name="client">The DynamoDB client.</param>
2529
/// <param name="options">Configuration options for the lock.</param>
2630
public DynamoDbDistributedLock(IAmazonDynamoDB client,
27-
IOptions<DynamoDbLockOptions> options)
31+
IOptions<DynamoDbLockOptions> options) : this(client, options, LockMetrics.Default)
32+
{
33+
}
34+
35+
/// <summary>
36+
/// Initializes a new instance of the <see cref="DynamoDbDistributedLock"/> class.
37+
/// </summary>
38+
/// <param name="client">The DynamoDB client.</param>
39+
/// <param name="options">Configuration options for the lock.</param>
40+
/// <param name="lockMetrics">Collects telemetry based on lock operations</param>
41+
public DynamoDbDistributedLock(IAmazonDynamoDB client,
42+
IOptions<DynamoDbLockOptions> options,
43+
ILockMetrics lockMetrics)
2844
{
2945
_client = client ?? throw new ArgumentNullException(nameof(client));
3046
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
31-
_retryPolicy = new Lazy<IRetryPolicy>(() => new ExponentialBackoffRetryPolicy(_options.Retry));
47+
_lockMetrics = lockMetrics ?? throw new ArgumentNullException(nameof(lockMetrics));
48+
_retryPolicy = new Lazy<IRetryPolicy>(() => new ExponentialBackoffRetryPolicy(_options.Retry, lockMetrics));
3249
}
3350

3451
/// <summary>
@@ -40,6 +57,7 @@ public DynamoDbDistributedLock(IAmazonDynamoDB client,
4057
/// <returns><c>true</c> if the lock was acquired; otherwise, <c>false</c>.</returns>
4158
public async Task<bool> AcquireLockAsync(string resourceId, string ownerId, CancellationToken cancellationToken = default)
4259
{
60+
using var _ = _lockMetrics.TrackLockAcquire();
4361
var result = await TryAcquireLockInternalAsync(resourceId, ownerId, cancellationToken);
4462
return result.IsSuccess;
4563
}
@@ -53,6 +71,7 @@ public async Task<bool> AcquireLockAsync(string resourceId, string ownerId, Canc
5371
/// <returns><c>true</c> if the lock was released; <c>false</c> if the lock was not owned by the caller.</returns>
5472
public async Task<bool> ReleaseLockAsync(string resourceId, string ownerId, CancellationToken cancellationToken = default)
5573
{
74+
using var _ = _lockMetrics.TrackLockRelease();
5675
var request = new DeleteItemRequest
5776
{
5877
TableName = _options.TableName,
@@ -71,12 +90,19 @@ public async Task<bool> ReleaseLockAsync(string resourceId, string ownerId, Canc
7190
try
7291
{
7392
await _client.DeleteItemAsync(request, cancellationToken);
93+
_lockMetrics.LockReleased();
7494
return true; // Lock released
7595
}
7696
catch (ConditionalCheckFailedException)
7797
{
98+
_lockMetrics.LockReleaseFailed("not_owned");
7899
return false; // Lock was held by another process
79100
}
101+
catch
102+
{
103+
_lockMetrics.LockReleaseFailed("unexpected_exception");
104+
throw;
105+
}
80106
}
81107

82108
/// <summary>
@@ -88,6 +114,7 @@ public async Task<bool> ReleaseLockAsync(string resourceId, string ownerId, Canc
88114
/// <returns>An <see cref="IDistributedLockHandle"/> if the lock was successfully acquired; otherwise, <c>null</c>.</returns>
89115
public async Task<IDistributedLockHandle?> AcquireLockHandleAsync(string resourceId, string ownerId, CancellationToken cancellationToken = default)
90116
{
117+
using var _ = _lockMetrics.TrackLockAcquire();
91118
var result = await TryAcquireLockInternalAsync(resourceId, ownerId, cancellationToken);
92119
return result.IsSuccess ? new DistributedLockHandle(this, resourceId, ownerId, result.ExpiresAt) : null;
93120
}
@@ -116,16 +143,23 @@ private async Task<LockAcquisitionResult> TryAcquireLockInternalAsync(string res
116143
private async Task<LockAcquisitionResult> TryAcquireLockOnceAsync(string resourceId, string ownerId, bool suppressExceptions, CancellationToken cancellationToken)
117144
{
118145
var (request, expiresAt) = CreatePutItemRequest(resourceId, ownerId);
119-
146+
120147
try
121148
{
122149
await _client.PutItemAsync(request, cancellationToken);
150+
_lockMetrics.LockAcquired();
123151
return new LockAcquisitionResult(true, expiresAt);
124152
}
125153
catch (ConditionalCheckFailedException) when (suppressExceptions)
126154
{
155+
_lockMetrics.LockAcquireFailed("condition_check_failed");
127156
return new LockAcquisitionResult(false, default);
128157
}
158+
catch
159+
{
160+
_lockMetrics.LockAcquireFailed("exception_taking_lock");
161+
throw;
162+
}
129163
}
130164

131165
private (PutItemRequest Request, DateTimeOffset ExpiresAt) CreatePutItemRequest(string resourceId, string ownerId)

src/DynamoDb.DistributedLock/Extensions/ServiceCollectionExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using System;
2+
using System.Diagnostics.Metrics;
23
using Amazon.DynamoDBv2;
34
using Amazon.Extensions.NETCore.Setup;
5+
using DynamoDb.DistributedLock.Metrics;
46
using Microsoft.Extensions.Configuration;
57
using Microsoft.Extensions.DependencyInjection;
68

@@ -42,6 +44,7 @@ public static IServiceCollection AddDynamoDbDistributedLock(this IServiceCollect
4244
services.Configure(configure);
4345
services.AddAWSService<IAmazonDynamoDB>(awsOptions);
4446
services.AddSingleton<IDynamoDbDistributedLock, DynamoDbDistributedLock>();
47+
services.AddSingleton<ILockMetrics, LockMetrics>(_ => LockMetrics.Default);
4548
return services;
4649
}
4750
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System;
2+
3+
namespace DynamoDb.DistributedLock.Metrics;
4+
5+
/// <summary>
6+
/// Defines methods for tracking metrics related to distributed lock operations.
7+
/// </summary>
8+
public interface ILockMetrics
9+
{
10+
// Timers (use with 'using' to record elapsed milliseconds)
11+
/// <summary>
12+
/// Creates a timer to track the duration of a lock acquisition attempt.
13+
/// </summary>
14+
/// <returns>IDisposable that tracks start time based on time created, and publishes end time based on disposal time</returns>
15+
IDisposable TrackLockAcquire();
16+
/// <summary>
17+
/// Creates a timer to track the duration of a lock release attempt.
18+
/// </summary>
19+
/// <returns>IDisposable that tracks start time based on time created, and publishes end time based on disposal time</returns>
20+
IDisposable TrackLockRelease();
21+
22+
// Counters
23+
/// <summary>
24+
/// A lock was successfully acquired.
25+
/// </summary>
26+
void LockAcquired();
27+
/// <summary>
28+
/// A lock was successfully released.
29+
/// </summary>
30+
void LockReleased();
31+
/// <summary>
32+
/// A lock acquisition attempt failed.
33+
/// </summary>
34+
/// <param name="reason">tag value for the reason the failure occured</param>
35+
void LockAcquireFailed(string reason); // e.g., "not_owned", "timeout", "unexpected_exception"
36+
/// <summary>
37+
/// A lock release attempt failed.
38+
/// </summary>
39+
/// <param name="reason">tag value for the reason the failure occured</param>
40+
void LockReleaseFailed(string reason); // e.g., "not_owned", "unexpected_exception"
41+
/// <summary>
42+
/// A retry attempt was made during lock acquisition.
43+
/// </summary>
44+
void RetryAttempt();
45+
/// <summary>
46+
/// All retry attempts were exhausted without acquiring the lock.
47+
/// </summary>
48+
void RetriesExhausted();
49+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using System;
2+
using System.Diagnostics;
3+
using System.Diagnostics.Metrics;
4+
5+
namespace DynamoDb.DistributedLock.Metrics;
6+
7+
/// <inheritdoc cref="ILockMetrics" />
8+
public sealed class LockMetrics : ILockMetrics, IDisposable
9+
{
10+
private readonly Meter _meter;
11+
12+
private readonly Counter<int> _lockAcquire;
13+
private readonly Counter<int> _lockRelease;
14+
private readonly Counter<int> _lockAcquireFailed;
15+
private readonly Counter<int> _lockReleaseFailed;
16+
private readonly Counter<int> _retriesExhausted;
17+
private readonly Counter<int> _retryAttempt;
18+
private readonly Histogram<double> _lockAcquireTimer;
19+
private readonly Histogram<double> _lockReleaseTimer;
20+
21+
/// <summary>
22+
/// Initializes a new instance of the <see cref="LockMetrics"/> class with the specified <see cref="Meter"/>.
23+
/// </summary>
24+
/// <param name="meter"></param>
25+
public LockMetrics(Meter meter)
26+
{
27+
ArgumentNullException.ThrowIfNull(meter);
28+
_meter = meter;
29+
30+
_lockAcquire = _meter.CreateCounter<int>(MetricNames.LockAcquire, unit: "count");
31+
_lockRelease = _meter.CreateCounter<int>(MetricNames.LockRelease, unit: "count");
32+
_lockAcquireFailed = _meter.CreateCounter<int>(MetricNames.LockAcquireFailed, unit: "count");
33+
_lockReleaseFailed = _meter.CreateCounter<int>(MetricNames.LockReleaseFailed, unit: "count");
34+
_retriesExhausted = _meter.CreateCounter<int>(MetricNames.RetriesExhausted, unit: "count");
35+
_retryAttempt = _meter.CreateCounter<int>(MetricNames.RetryAttempt, unit: "count");
36+
_lockAcquireTimer = _meter.CreateHistogram<double>(MetricNames.LockAcquireTimer, unit: "ms");
37+
_lockReleaseTimer = _meter.CreateHistogram<double>(MetricNames.LockReleaseTimer, unit: "ms");
38+
}
39+
40+
/// <summary>
41+
/// A default instance of <see cref="LockMetrics"/> using a meter with the name defined by <see cref="MetricNames.MeterName"/>.
42+
/// </summary>
43+
public static LockMetrics Default { get; } = new(new Meter(MetricNames.MeterName));
44+
45+
/// <inheritdoc />
46+
public IDisposable TrackLockAcquire() => new TimerScope(_lockAcquireTimer);
47+
48+
/// <inheritdoc />
49+
public IDisposable TrackLockRelease() => new TimerScope(_lockReleaseTimer);
50+
51+
/// <inheritdoc />
52+
public void LockAcquired() => _lockAcquire.Add(1);
53+
54+
/// <inheritdoc />
55+
public void LockReleased() => _lockRelease.Add(1);
56+
57+
/// <inheritdoc />
58+
public void LockAcquireFailed(string reason)
59+
{
60+
var tags = new TagList { { "reason", reason } };
61+
_lockAcquireFailed.Add(1, tags);
62+
}
63+
64+
/// <inheritdoc />
65+
public void LockReleaseFailed(string reason)
66+
{
67+
var tags = new TagList { { "reason", reason } };
68+
_lockReleaseFailed.Add(1, tags);
69+
}
70+
71+
/// <inheritdoc />
72+
public void RetryAttempt() => _retryAttempt.Add(1);
73+
74+
/// <inheritdoc />
75+
public void RetriesExhausted() => _retriesExhausted.Add(1);
76+
77+
/// <inheritdoc />
78+
public void Dispose() => _meter.Dispose();
79+
80+
private readonly struct TimerScope(Histogram<double> hist) : IDisposable
81+
{
82+
private readonly long _start = Stopwatch.GetTimestamp();
83+
84+
public void Dispose()
85+
{
86+
var ms = (Stopwatch.GetTimestamp() - _start) * 1000.0 / Stopwatch.Frequency;
87+
hist.Record(ms);
88+
}
89+
}
90+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
namespace DynamoDb.DistributedLock.Metrics;
2+
3+
/// <summary>
4+
/// Names of metrics that are published by DynamoDb.DistributedLock
5+
/// </summary>
6+
public static class MetricNames
7+
{
8+
/// <summary>
9+
/// Name of the Meter used for publishing metrics.
10+
/// </summary>
11+
public const string MeterName = "DynamoDb.DistributedLock";
12+
13+
/// <summary>
14+
/// A lock is successfully released.
15+
/// </summary>
16+
public const string LockRelease = "dynamodb.distributedlock.lock_release";
17+
/// <summary>
18+
/// A lock release operation failed.
19+
/// </summary>
20+
public const string LockReleaseFailed = "dynamodb.distributedlock.lock_release.failed";
21+
/// <summary>
22+
/// A lock is successfully acquired.
23+
/// </summary>
24+
public const string LockAcquire = "dynamodb.distributedlock.lock_acquire";
25+
/// <summary>
26+
/// A lock acquisition operation failed.
27+
/// </summary>
28+
public const string LockAcquireFailed = "dynamodb.distributedlock.lock_acquire.failed";
29+
/// <summary>
30+
/// Retries where attempted, but the maximum number of retries was reached without success.
31+
/// </summary>
32+
public const string RetriesExhausted = "dynamodb.distributedlock.retries_exhausted";
33+
/// <summary>
34+
/// A lock acquisition retry was attempted after a failure. When retrying an operation, the first attempt is not counted.
35+
/// </summary>
36+
public const string RetryAttempt = "dynamodb.distributedlock.retry_attempt";
37+
/// <summary>
38+
/// Measures the time taken to acquire a lock.
39+
/// </summary>
40+
public const string LockAcquireTimer = "dynamodb.distributedlock.lock_acquire.timer";
41+
/// <summary>
42+
/// Measures the time taken to release a lock.
43+
/// </summary>
44+
public const string LockReleaseTimer = "dynamodb.distributedlock.lock_release.timer";
45+
}

0 commit comments

Comments
 (0)