Skip to content

Commit d75d52b

Browse files
committed
feat: add exception tracker
1 parent 0fd19c0 commit d75d52b

2 files changed

Lines changed: 162 additions & 0 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
using Microsoft.Extensions.Hosting;
2+
using Microsoft.Extensions.Logging;
3+
using System.Collections.Concurrent;
4+
using System.Diagnostics.Metrics;
5+
using System.Runtime.ExceptionServices;
6+
using System.Runtime.InteropServices;
7+
8+
namespace BookLibrary.Infrastructure.OpenTelemetry;
9+
10+
internal sealed partial class ExceptionTracker : IDisposable
11+
{
12+
private readonly Meter _meter;
13+
private readonly ILogger<ExceptionTracker> _logger;
14+
15+
private readonly Counter<long> _exceptionsCounter;
16+
17+
private static readonly ConcurrentDictionary<Type, string> _trimmedTypeNames = new();
18+
19+
private const int LABEL_MAX_LENGTH = 127;
20+
private const int LOG_EVERY_N_EXCEPTIONS = 100;
21+
22+
[ThreadStatic]
23+
private static bool _handlingFirstChanceException;
24+
25+
[ThreadStatic]
26+
private static Dictionary<Type, long>? _sampler;
27+
28+
private static bool _started;
29+
30+
public ExceptionTracker(
31+
IMeterFactory meterFactory,
32+
ILogger<ExceptionTracker> logger
33+
)
34+
{
35+
_logger = logger;
36+
37+
_meter = meterFactory.Create(GetType().FullName!);
38+
_exceptionsCounter = _meter.CreateCounter<long>(name: "dotnet.exceptions", description: "Count of throwed exceptions.");
39+
}
40+
41+
public void Dispose()
42+
{
43+
_meter.Dispose();
44+
45+
Stop();
46+
}
47+
48+
public void Start()
49+
{
50+
if (_started)
51+
{
52+
return;
53+
}
54+
55+
_started = true;
56+
AppDomain.CurrentDomain.FirstChanceException += HandleFirstChanceException;
57+
}
58+
59+
public void Stop()
60+
{
61+
if (!_started)
62+
{
63+
return;
64+
}
65+
66+
AppDomain.CurrentDomain.FirstChanceException -= HandleFirstChanceException;
67+
}
68+
69+
private void HandleFirstChanceException(object? sender, FirstChanceExceptionEventArgs e)
70+
{
71+
if (_handlingFirstChanceException)
72+
{
73+
return;
74+
}
75+
76+
if (e.Exception is TaskCanceledException or OperationCanceledException)
77+
{
78+
return;
79+
}
80+
81+
_handlingFirstChanceException = true;
82+
83+
var exceptionType = e.Exception.GetType();
84+
85+
_sampler ??= new Dictionary<Type, long>();
86+
87+
ref var counter = ref CollectionsMarshal.GetValueRefOrAddDefault(_sampler, exceptionType, out var exists);
88+
89+
var needLog = !exists || Interlocked.Increment(ref counter) % LOG_EVERY_N_EXCEPTIONS == 0;
90+
91+
if (needLog)
92+
{
93+
LogExceptionSample(e.Exception, exceptionType.FullName);
94+
}
95+
96+
_exceptionsCounter.Add(1, new KeyValuePair<string, object?>("error_type", Trim(exceptionType)));
97+
98+
_handlingFirstChanceException = false;
99+
}
100+
101+
private static string Trim(Type type)
102+
{
103+
return _trimmedTypeNames.GetOrAdd(type, static type =>
104+
{
105+
var typeFullName = type.FullName!;
106+
107+
Span<char> typeLabel = stackalloc char[Math.Min(LABEL_MAX_LENGTH, typeFullName.Length)];
108+
109+
typeFullName.AsSpan(0, typeLabel.Length).CopyTo(typeLabel);
110+
111+
var writePos = 0;
112+
for (var readPos = 0; readPos < typeLabel.Length; readPos++)
113+
{
114+
var c = typeLabel[readPos];
115+
116+
if (char.IsAscii(c))
117+
{
118+
typeLabel[writePos++] = c;
119+
}
120+
}
121+
122+
typeLabel = typeLabel[..writePos];
123+
124+
return typeLabel.ToString();
125+
});
126+
}
127+
128+
[LoggerMessage(
129+
eventId: 0,
130+
level: LogLevel.Information,
131+
message: "Sample of throwed exception {ExceptionType}"
132+
)]
133+
private partial void LogExceptionSample(Exception ex, string? exceptionType);
134+
}
135+
136+
internal sealed class ExceptionTrackerLifecycle : IHostedService
137+
{
138+
private readonly ExceptionTracker _exceptionTracker;
139+
140+
public ExceptionTrackerLifecycle(ExceptionTracker exceptionTracker)
141+
{
142+
_exceptionTracker = exceptionTracker;
143+
}
144+
145+
public Task StartAsync(CancellationToken cancellationToken)
146+
{
147+
_exceptionTracker.Start();
148+
149+
return Task.CompletedTask;
150+
}
151+
152+
public Task StopAsync(CancellationToken cancellationToken)
153+
{
154+
_exceptionTracker.Stop();
155+
156+
return Task.CompletedTask;
157+
}
158+
}

BookLibrary.Infrastructure/ServiceCollectionExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
using BookLibrary.Application.Infrastructure;
22
using BookLibrary.Infrastructure.Books;
3+
using BookLibrary.Infrastructure.OpenTelemetry;
34
using BookLibrary.Infrastructure.ValueConverters;
45
using EntityFramework.Exceptions.PostgreSQL;
6+
using Microsoft.AspNetCore.Hosting;
57
using Microsoft.EntityFrameworkCore;
68
using Microsoft.EntityFrameworkCore.Diagnostics;
79
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -28,6 +30,8 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
2830
{
2931
services.AddSingleton<IUuidGenerator, UuidGenerator>();
3032
services.AddSingleton(TimeProvider.System);
33+
services.AddSingleton<ExceptionTracker>();
34+
services.AddHostedService<ExceptionTrackerLifecycle>();
3135

3236
services.AddOutboxItem<ApplicationContext, BookStatChange>(o =>
3337
{

0 commit comments

Comments
 (0)