Skip to content

Commit fac84e0

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

2 files changed

Lines changed: 171 additions & 0 deletions

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
try
84+
{
85+
var exceptionType = e.Exception.GetType();
86+
87+
_sampler ??= new Dictionary<Type, long>();
88+
89+
ref var counter = ref CollectionsMarshal.GetValueRefOrAddDefault(_sampler, exceptionType, out var exists);
90+
91+
var needLog = !exists || Interlocked.Increment(ref counter) % LOG_EVERY_N_EXCEPTIONS == 0;
92+
93+
if (needLog)
94+
{
95+
LogExceptionSample(e.Exception, exceptionType.FullName);
96+
}
97+
98+
_exceptionsCounter.Add(1, new KeyValuePair<string, object?>("error_type", Trim(exceptionType)));
99+
}
100+
catch (Exception)
101+
{
102+
// skip
103+
}
104+
finally
105+
{
106+
_handlingFirstChanceException = false;
107+
}
108+
}
109+
110+
private static string Trim(Type type)
111+
{
112+
return _trimmedTypeNames.GetOrAdd(type, static type =>
113+
{
114+
var typeFullName = type.FullName!;
115+
116+
Span<char> typeLabel = stackalloc char[Math.Min(LABEL_MAX_LENGTH, typeFullName.Length)];
117+
118+
typeFullName.AsSpan(0, typeLabel.Length).CopyTo(typeLabel);
119+
120+
var writePos = 0;
121+
for (var readPos = 0; readPos < typeLabel.Length; readPos++)
122+
{
123+
var c = typeLabel[readPos];
124+
125+
if (char.IsAscii(c))
126+
{
127+
typeLabel[writePos++] = c;
128+
}
129+
}
130+
131+
typeLabel = typeLabel[..writePos];
132+
133+
return typeLabel.ToString();
134+
});
135+
}
136+
137+
[LoggerMessage(
138+
eventId: 0,
139+
level: LogLevel.Information,
140+
message: "Sample of throwed exception {ExceptionType}"
141+
)]
142+
private partial void LogExceptionSample(Exception ex, string? exceptionType);
143+
}
144+
145+
internal sealed class ExceptionTrackerLifecycle : IHostedService
146+
{
147+
private readonly ExceptionTracker _exceptionTracker;
148+
149+
public ExceptionTrackerLifecycle(ExceptionTracker exceptionTracker)
150+
{
151+
_exceptionTracker = exceptionTracker;
152+
}
153+
154+
public Task StartAsync(CancellationToken cancellationToken)
155+
{
156+
_exceptionTracker.Start();
157+
158+
return Task.CompletedTask;
159+
}
160+
161+
public Task StopAsync(CancellationToken cancellationToken)
162+
{
163+
_exceptionTracker.Stop();
164+
165+
return Task.CompletedTask;
166+
}
167+
}

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)