Skip to content

Commit 766fd9a

Browse files
committed
feat: add otel instrumentation
1 parent 2d73633 commit 766fd9a

File tree

6 files changed

+172
-42
lines changed

6 files changed

+172
-42
lines changed

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,64 @@ Alternatively, you may configure `IntelliTect.AspNetCore.SignalR.SqlServer.SqlSe
7575
services.Configure<SqlServerOptions>(Configuration.GetSection("SignalR:SqlServer"));
7676
```
7777

78+
## OpenTelemetry Support
79+
80+
This library includes OpenTelemetry instrumentation that wraps background database queries in activities, making them more easily identified and grouped in your collected telemetry.
81+
82+
### Setup
83+
84+
To enable OpenTelemetry collection of these trace spans, add the source to your tracing configuration:
85+
86+
``` cs
87+
builder.Services.AddOpenTelemetry()
88+
.WithTracing(tracing => tracing
89+
.AddSource("IntelliTect.AspNetCore.SignalR.SqlServer")
90+
// ... other instrumentation
91+
);
92+
```
93+
94+
### Activity Names
95+
96+
The library creates activities for the following operations:
97+
- `SignalR.SqlServer.Install` - Database schema installation/setup
98+
- `SignalR.SqlServer.Start` - Receiver startup operations
99+
- `SignalR.SqlServer.Listen` - Service Broker listening operations (database reads)
100+
- `SignalR.SqlServer.Poll` - Polling operations (database reads, when Service Broker is not used)
101+
- `SignalR.SqlServer.Publish` - Message publishing operations (database writes)
102+
103+
### Filtering Noise
104+
105+
Since the SQL Server backplane performs frequent polling operations, you may want to filter out successful, fast queries to reduce trace noise.
106+
107+
The following example assumes using package `OpenTelemetry.Instrumentation.SqlClient` for SqlClient instrumentation. There are currently [4 different packages for SqlClient instrumentation](https://github.com/dotnet/aspire/issues/2427#issuecomment-3259572206), so your method of collecting or filtering the command details may vary if you're using Aspire's instrumentation or Azure Monitor's instrumentation. Be sure to update the CommandText filter if you customize the schema name:
108+
109+
``` cs
110+
builder.Services.AddOpenTelemetry()
111+
.WithTracing(tracing => tracing
112+
.AddSqlClientInstrumentation(options =>
113+
{
114+
options.Enrich = (activity, _, cmd) => activity.SetCustomProperty("sqlCommand", cmd);
115+
})
116+
.AddSource("IntelliTect.AspNetCore.SignalR.SqlServer")
117+
.AddProcessor<SignalRTelemetryNoiseFilter>()
118+
);
119+
120+
internal sealed class SignalRTelemetryNoiseFilter : BaseProcessor<Activity>
121+
{
122+
public override void OnEnd(Activity activity)
123+
{
124+
if (activity.Status != ActivityStatusCode.Error &&
125+
activity.Duration.TotalMilliseconds < 100 &&
126+
activity.GetCustomProperty("sqlCommand") is DbCommand command &&
127+
command.CommandText.StartsWith("SELECT [PayloadId], [Payload], [InsertedOn] FROM [SignalR]") == true)
128+
{
129+
// Sample out successful and fast SignalR queries
130+
activity.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded;
131+
}
132+
}
133+
}
134+
```
135+
78136
## Caveats
79137

80138
As mentioned above, if SQL Server Service Broker is not available, messages will not always be transmitted immediately since a fallback of periodic querying must be used.

demo/DemoServer/Program.cs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
using System.Data.Common;
2+
using System.Diagnostics;
13
using DemoServer;
24
using IntelliTect.AspNetCore.SignalR.SqlServer;
3-
using IntelliTect.AspNetCore.SignalR.SqlServer.OpenTelemetry;
45
using Microsoft.AspNetCore.SignalR;
56
using OpenTelemetry;
67
using OpenTelemetry.Metrics;
@@ -36,11 +37,11 @@
3637
)
3738
.WithTracing(tracing => tracing
3839
.AddSource(builder.Environment.ApplicationName)
40+
.AddSource("IntelliTect.AspNetCore.SignalR.SqlServer")
41+
.AddProcessor<SignalRMessagesNoiseFilterProcessor>()
3942
.AddAspNetCoreInstrumentation()
40-
// .AddSignalRSqlServerInstrumentation()
4143
.AddSqlClientInstrumentation(tracing =>
4244
{
43-
tracing.SetDbStatementForText = true;
4445
tracing.Enrich = (activity, _, cmd) => activity.SetCustomProperty("sqlCommand", cmd);
4546
})
4647
);
@@ -99,3 +100,19 @@
99100
app.Run();
100101

101102
public record BroadcastRequest(string Message);
103+
104+
105+
internal sealed class SignalRMessagesNoiseFilterProcessor : BaseProcessor<Activity>
106+
{
107+
public override void OnEnd(Activity activity)
108+
{
109+
if (activity.Status != ActivityStatusCode.Error &&
110+
activity.Duration.TotalMilliseconds < 100 &&
111+
activity.GetCustomProperty("sqlCommand") is DbCommand command &&
112+
command.CommandText.StartsWith("SELECT [PayloadId], [Payload], [InsertedOn] FROM [SignalR") == true)
113+
{
114+
// Sample out successful and fast SignalR queries
115+
activity.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded;
116+
}
117+
}
118+
}

src/IntelliTect.AspNetCore.SignalR.SqlServer/Internal/SqlServer/SqlInstaller.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public async Task Install()
2222
return;
2323
}
2424

25+
using var activity = SqlServerOptions.ActivitySource.StartActivity("SignalR.SqlServer.Install");
26+
activity?.SetTag("signalr.hub", tracePrefix);
27+
2528
await options.InstallLock.WaitAsync();
2629
logger.LogInformation("{HubName}: Start installing SignalR SQL objects", tracePrefix);
2730
try
@@ -55,12 +58,13 @@ public async Task Install()
5558
command.CommandText = script;
5659
await command.ExecuteNonQueryAsync();
5760

58-
logger.LogInformation("{HubName}: SignalR SQL objects installed", messagesTableNamePrefix);
61+
logger.LogInformation("{HubName}: SignalR SQL objects installed", tracePrefix);
5962
}
6063
}
6164
catch (Exception ex)
6265
{
63-
logger.LogError(ex, "{HubName}: Unable to install SignalR SQL objects", messagesTableNamePrefix);
66+
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
67+
logger.LogError(ex, "{HubName}: Unable to install SignalR SQL objects", tracePrefix);
6468
throw;
6569
}
6670
finally

src/IntelliTect.AspNetCore.SignalR.SqlServer/Internal/SqlServer/SqlReceiver.cs

Lines changed: 77 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ internal class SqlReceiver : IDisposable
4242
private bool _disposed;
4343
private readonly string _maxIdSql = "SELECT [PayloadId] FROM [{0}].[{1}_Id]";
4444
private readonly string _selectSql = "SELECT [PayloadId], [Payload], [InsertedOn] FROM [{0}].[{1}] WHERE [PayloadId] > @PayloadId";
45+
private readonly TimeSpan _activityMaxDuration = TimeSpan.FromMinutes(10);
46+
4547

4648
public SqlReceiver(SqlServerOptions options, ILogger logger, string tableName, string tracePrefix)
4749
{
@@ -73,14 +75,26 @@ private async Task StartLoop()
7375
{
7476
if (_cts.IsCancellationRequested) return;
7577

76-
if (!_lastPayloadId.HasValue)
78+
bool useBroker = _options.Mode.HasFlag(SqlServerMessageMode.ServiceBroker);
79+
80+
using (var activity = SqlServerOptions.ActivitySource.StartActivity("SignalR.SqlServer.Start"))
7781
{
78-
_lastPayloadId = await GetLastPayloadId();
82+
activity?.SetTag("signalr.hub", _tracePrefix);
83+
activity?.SetTag("signalr.sql.mode", _options.Mode.ToString());
84+
85+
if (!_lastPayloadId.HasValue)
86+
{
87+
_lastPayloadId = await GetLastPayloadId();
88+
}
89+
if (useBroker)
90+
{
91+
useBroker = StartSqlDependencyListener();
92+
}
7993
}
8094

8195
if (_cts.IsCancellationRequested) return;
8296

83-
if (_options.Mode.HasFlag(SqlServerMessageMode.ServiceBroker) && StartSqlDependencyListener())
97+
if (useBroker)
8498
{
8599
await NotificationLoop(_cts.Token);
86100
}
@@ -103,6 +117,9 @@ private async Task NotificationLoop(CancellationToken cancellationToken)
103117
{
104118
if (cancellationToken.IsCancellationRequested) return;
105119

120+
using var activity = SqlServerOptions.ActivitySource.StartActivity("SignalR.SqlServer.Listen");
121+
activity?.SetTag("signalr.hub", _tracePrefix);
122+
106123
try
107124
{
108125
_logger.LogDebug("{HubStream}: Setting up SQL notification", _tracePrefix);
@@ -170,6 +187,9 @@ private async Task NotificationLoop(CancellationToken cancellationToken)
170187
else
171188
{
172189
// Unknown subscription error, let's stop using query notifications
190+
activity?.SetStatus(ActivityStatusCode.Error);
191+
// Dispose so this activity doesn't become the parent of the next loop.
192+
activity?.Dispose();
173193
_notificationsDisabled = true;
174194
await StartLoop();
175195
return;
@@ -180,6 +200,7 @@ private async Task NotificationLoop(CancellationToken cancellationToken)
180200
catch (TaskCanceledException) { return; }
181201
catch (Exception ex)
182202
{
203+
activity?.SetStatus(ActivityStatusCode.Error);
183204
_logger.LogError(ex, "{HubStream}: Error in SQL notification loop", _tracePrefix);
184205

185206
await Task.Delay(1000, cancellationToken);
@@ -196,56 +217,75 @@ private async Task NotificationLoop(CancellationToken cancellationToken)
196217
/// </summary>
197218
private async Task PollingLoop(CancellationToken cancellationToken)
198219
{
199-
var delays = _updateLoopRetryDelays;
200-
for (var retrySetIndex = 0; retrySetIndex < delays.Length; retrySetIndex++)
220+
var activity = SqlServerOptions.ActivitySource.StartActivity("SignalR.SqlServer.Poll");
221+
activity?.SetTag("signalr.hub", _tracePrefix);
222+
223+
try
201224
{
202-
Tuple<int, int> retry = delays[retrySetIndex];
203-
var retryDelay = retry.Item1;
204-
var numRetries = retry.Item2;
205225

206-
for (var retryIndex = 0; retryIndex < numRetries; retryIndex++)
226+
var delays = _updateLoopRetryDelays;
227+
for (var retrySetIndex = 0; retrySetIndex < delays.Length; retrySetIndex++)
207228
{
208-
if (cancellationToken.IsCancellationRequested) return;
229+
Tuple<int, int> retry = delays[retrySetIndex];
230+
var retryDelay = retry.Item1;
231+
var numRetries = retry.Item2;
209232

210-
var recordCount = 0;
211-
try
233+
for (var retryIndex = 0; retryIndex < numRetries; retryIndex++)
212234
{
213-
if (retryDelay > 0)
214-
{
215-
_logger.LogTrace("{HubStream}: Waiting {1}ms before checking for messages again", _tracePrefix, retryDelay);
235+
if (cancellationToken.IsCancellationRequested) return;
216236

217-
await Task.Delay(retryDelay, cancellationToken);
237+
// Restart activity every 10 minutes to prevent long-running activities
238+
if (activity != null && DateTime.UtcNow - activity.StartTimeUtc > _activityMaxDuration)
239+
{
240+
activity?.Dispose();
241+
activity = SqlServerOptions.ActivitySource.StartActivity("SignalR.SqlServer.Poll");
242+
activity?.SetTag("signalr.hub", _tracePrefix);
218243
}
219244

220-
recordCount = await ReadRows(null);
221-
}
222-
catch (TaskCanceledException) { return; }
223-
catch (Exception ex)
224-
{
225-
_logger.LogError(ex, "{HubStream}: Error in SQL polling loop", _tracePrefix);
226-
}
245+
var recordCount = 0;
246+
try
247+
{
248+
if (retryDelay > 0)
249+
{
250+
_logger.LogTrace("{HubStream}: Waiting {Delay}ms before checking for messages again", _tracePrefix, retryDelay);
227251

228-
if (recordCount > 0)
229-
{
230-
_logger.LogDebug("{HubStream}: {RecordCount} records received", _tracePrefix, recordCount);
252+
await Task.Delay(retryDelay, cancellationToken);
253+
}
231254

232-
// We got records so start the retry loop again
233-
// at the lowest delay.
234-
retrySetIndex = -1;
235-
break;
236-
}
255+
recordCount = await ReadRows(null);
256+
}
257+
catch (TaskCanceledException) { return; }
258+
catch (Exception ex)
259+
{
260+
_logger.LogError(ex, "{HubStream}: Error in SQL polling loop", _tracePrefix);
261+
}
237262

238-
_logger.LogTrace("{HubStream}: No records received", _tracePrefix);
263+
if (recordCount > 0)
264+
{
265+
_logger.LogDebug("{HubStream}: {RecordCount} records received", _tracePrefix, recordCount);
239266

240-
var isLastRetry = retrySetIndex == delays.Length - 1 && retryIndex == numRetries - 1;
267+
// We got records so start the retry loop again
268+
// at the lowest delay.
269+
retrySetIndex = -1;
270+
break;
271+
}
241272

242-
if (isLastRetry)
243-
{
244-
// Last retry loop so just stay looping on the last retry delay
245-
retryIndex--;
273+
_logger.LogTrace("{HubStream}: No records received", _tracePrefix);
274+
275+
var isLastRetry = retrySetIndex == delays.Length - 1 && retryIndex == numRetries - 1;
276+
277+
if (isLastRetry)
278+
{
279+
// Last retry loop so just stay looping on the last retry delay
280+
retryIndex--;
281+
}
246282
}
247283
}
248284
}
285+
finally
286+
{
287+
activity?.Dispose();
288+
}
249289

250290
_logger.LogDebug("{HubStream}: SQL polling loop fell out", _tracePrefix);
251291
await StartLoop();

src/IntelliTect.AspNetCore.SignalR.SqlServer/SqlServerHubLifetimeManager.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,14 @@ public override Task SendUserAsync(string userId, string methodName, object?[]?
297297

298298
private async Task PublishAsync(MessageType type, byte[] payload)
299299
{
300+
using var activity = SqlServerOptions.ActivitySource.StartActivity("SignalR.SqlServer.Publish");
301+
if (activity != null)
302+
{
303+
activity.SetTag("signalr.hub", typeof(THub).FullName!);
304+
activity.SetTag("signalr.sql.message_type", type.ToString());
305+
activity.SetTag("signalr.sql.message_size_bytes", payload.Length);
306+
}
307+
300308
await EnsureSqlServerConnection();
301309
_logger.Published(type.ToString());
302310

src/IntelliTect.AspNetCore.SignalR.SqlServer/SqlServerOptions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.ComponentModel.DataAnnotations;
6+
using System.Diagnostics;
67
using System.IO;
78
using System.Net;
89
using System.Threading.Tasks;
@@ -14,6 +15,8 @@ namespace IntelliTect.AspNetCore.SignalR.SqlServer
1415
/// </summary>
1516
public class SqlServerOptions
1617
{
18+
internal static readonly ActivitySource ActivitySource = new("IntelliTect.AspNetCore.SignalR.SqlServer");
19+
1720
/// <summary>
1821
/// Shared lock to prevent multiple concurrent installs against the same DB.
1922
/// This prevents auto-enable of service broker from deadlocking

0 commit comments

Comments
 (0)