Skip to content

Commit d2cb08d

Browse files
committed
Implementing backend tracking infra
1 parent 8adec59 commit d2cb08d

15 files changed

Lines changed: 1239 additions & 8 deletions

src/BE/web/Controllers/Admin/GlobalConfigs/GlobalConfigController.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
using Chats.DB;
22
using Chats.BE.Controllers.Admin.Common;
33
using Chats.BE.Controllers.Admin.GlobalConfigs.Dtos;
4+
using Chats.BE.Services.Configs;
45
using Microsoft.AspNetCore.Mvc;
56
using Microsoft.EntityFrameworkCore;
67
using System.Text.Json;
78

89
namespace Chats.BE.Controllers.Admin.GlobalConfigs;
910

1011
[Route("api/admin/global-configs"), AuthorizeAdmin]
11-
public class GlobalConfigController(ChatsDB db) : ControllerBase
12+
public class GlobalConfigController(ChatsDB db, IRequestTraceConfigProvider requestTraceConfigProvider) : ControllerBase
1213
{
1314
[HttpGet]
1415
public async Task<GlobalConfigDto[]> GetGlobalConfigs(CancellationToken cancellationToken)
@@ -52,6 +53,10 @@ public async Task<ActionResult> UpdateGlobalConfig([FromBody] GlobalConfigDto re
5253
if (db.ChangeTracker.HasChanges())
5354
{
5455
await db.SaveChangesAsync(cancellationToken);
56+
if (req.Key == DBConfigKey.InboundRequestTrace || req.Key == DBConfigKey.OutboundRequestTrace)
57+
{
58+
await requestTraceConfigProvider.ForceRefreshAsync(cancellationToken);
59+
}
5560
}
5661
return NoContent();
5762
}
@@ -66,6 +71,10 @@ public async Task<ActionResult> DeleteGlobalConfig([FromQuery] string id, Cancel
6671
}
6772
db.Configs.Remove(config);
6873
await db.SaveChangesAsync(cancellationToken);
74+
if (id == DBConfigKey.InboundRequestTrace || id == DBConfigKey.OutboundRequestTrace)
75+
{
76+
await requestTraceConfigProvider.ForceRefreshAsync(cancellationToken);
77+
}
6978
return NoContent();
7079
}
7180
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
using Chats.BE.Services.Configs;
2+
using Chats.BE.Services.RequestTracing;
3+
using System.Diagnostics;
4+
using System.Security.Claims;
5+
6+
namespace Chats.BE.Infrastructure;
7+
8+
public sealed class RequestTraceMiddleware(
9+
RequestDelegate next,
10+
IRequestTraceConfigProvider configProvider,
11+
IRequestTraceQueue queue,
12+
ILogger<RequestTraceMiddleware> logger)
13+
{
14+
public async Task Invoke(HttpContext context)
15+
{
16+
RequestTraceConfig config = configProvider.GetInboundConfig();
17+
if (!RequestTraceHelper.IsEnabledAndSampled(config))
18+
{
19+
await next(context);
20+
return;
21+
}
22+
23+
DateTime startedAt = DateTime.UtcNow;
24+
long startTick = Stopwatch.GetTimestamp();
25+
string method = context.Request.Method;
26+
string url = context.Request.Path + context.Request.QueryString;
27+
string? source = context.Connection.RemoteIpAddress?.ToString();
28+
string? traceId = context.TraceIdentifier;
29+
int? userId = TryGetUserId(context.User);
30+
31+
if (!RequestTraceHelper.MatchRequestStageFilters(config.Filters, source, method, url))
32+
{
33+
await next(context);
34+
return;
35+
}
36+
37+
byte[]? requestBytes = null;
38+
bool requestBodyTruncated = false;
39+
40+
bool captureRequestBody = config.Body.CaptureRequestBody || config.Body.CaptureRawRequestBody;
41+
if (captureRequestBody)
42+
{
43+
(requestBytes, requestBodyTruncated) = await ReadRequestBody(context.Request, config.Body.MaxTextCharsForTruncate, context.RequestAborted);
44+
}
45+
46+
string requestHeaders = RequestTraceHelper.FormatHeaders(
47+
context.Request.Headers.Select(x => new KeyValuePair<string, IEnumerable<string>>(x.Key, x.Value.Select(v => v ?? string.Empty))),
48+
config.Headers.IncludeRequestHeaders,
49+
config.Headers.RedactRequestHeaders);
50+
51+
(string? requestText, bool requestTextTruncated) = config.Body.CaptureRequestBody
52+
? RequestTraceHelper.DecodeTextBody(
53+
requestBytes,
54+
config.Body.MaxTextCharsForTruncate,
55+
context.Request.Headers.ContentEncoding.ToString(),
56+
config.Body.AllowedContentTypes,
57+
context.Request.ContentType)
58+
: (null, false);
59+
60+
RequestTraceRequestWriteModel requestModel = new()
61+
{
62+
StartedAt = startedAt,
63+
Direction = RequestTraceDirection.Inbound,
64+
Source = source,
65+
UserId = userId,
66+
TraceId = traceId,
67+
Method = method,
68+
Url = url,
69+
RequestContentType = context.Request.ContentType,
70+
RawRequestBodyBytes = requestBytes?.Length ?? 0,
71+
IsRequestBodyTruncated = requestBodyTruncated || requestTextTruncated,
72+
RequestHeaders = requestHeaders,
73+
RequestBody = requestText,
74+
RequestBodyRaw = config.Body.CaptureRawRequestBody ? requestBytes : null,
75+
};
76+
77+
if (!queue.TryEnqueueRequest(requestModel))
78+
{
79+
logger.LogDebug("Request trace queue dropped an inbound request event. dropped={dropped}", queue.DroppedCount);
80+
}
81+
82+
Stream originalResponseBody = context.Response.Body;
83+
TeeCaptureStream? tee = null;
84+
if (config.Body.CaptureResponseBody || config.Body.CaptureRawResponseBody)
85+
{
86+
tee = new TeeCaptureStream(originalResponseBody, config.Body.MaxTextCharsForTruncate);
87+
context.Response.Body = tee;
88+
}
89+
90+
Exception? exception = null;
91+
try
92+
{
93+
await next(context);
94+
}
95+
catch (Exception ex)
96+
{
97+
exception = ex;
98+
throw;
99+
}
100+
finally
101+
{
102+
try
103+
{
104+
if (tee != null)
105+
{
106+
context.Response.Body = originalResponseBody;
107+
}
108+
109+
int durationMs = (int)Stopwatch.GetElapsedTime(startTick, Stopwatch.GetTimestamp()).TotalMilliseconds;
110+
short? statusCode = (short?)context.Response.StatusCode;
111+
112+
bool shouldPersist = RequestTraceHelper.MatchResponseStageFilters(config.Filters, source, method, url, statusCode, durationMs);
113+
if (shouldPersist)
114+
{
115+
string? responseHeaders = RequestTraceHelper.FormatHeaders(
116+
context.Response.Headers.Select(x => new KeyValuePair<string, IEnumerable<string>>(x.Key, x.Value.Select(v => v ?? string.Empty))),
117+
config.Headers.IncludeResponseHeaders,
118+
config.Headers.RedactResponseHeaders);
119+
120+
byte[]? responseBytes = tee?.CapturedBytes;
121+
bool responseBodyTruncated = tee?.IsTruncated == true;
122+
123+
(string? responseText, bool responseTextTruncated) = config.Body.CaptureResponseBody
124+
? RequestTraceHelper.DecodeTextBody(
125+
responseBytes,
126+
config.Body.MaxTextCharsForTruncate,
127+
context.Response.Headers.ContentEncoding.ToString(),
128+
config.Body.AllowedContentTypes,
129+
context.Response.ContentType)
130+
: (null, false);
131+
132+
RequestTraceResponseWriteModel responseModel = new()
133+
{
134+
StartedAt = startedAt,
135+
DurationMs = durationMs,
136+
Direction = RequestTraceDirection.Inbound,
137+
Source = source,
138+
UserId = userId,
139+
TraceId = traceId,
140+
Method = method,
141+
Url = url,
142+
ResponseContentType = context.Response.ContentType,
143+
StatusCode = statusCode,
144+
ErrorType = exception?.GetType().Name,
145+
ErrorMessage = exception?.ToString(),
146+
RawResponseBodyBytes = responseBytes?.Length,
147+
IsResponseBodyTruncated = responseBodyTruncated || responseTextTruncated,
148+
ResponseHeaders = responseHeaders,
149+
ResponseBody = responseText,
150+
ResponseBodyRaw = config.Body.CaptureRawResponseBody ? responseBytes : null,
151+
};
152+
153+
if (!queue.TryEnqueueResponse(responseModel))
154+
{
155+
logger.LogDebug("Request trace queue dropped an inbound response event. dropped={dropped}", queue.DroppedCount);
156+
}
157+
}
158+
}
159+
catch (Exception traceEx)
160+
{
161+
logger.LogWarning(traceEx, "Inbound request trace post-processing failed and is ignored.");
162+
}
163+
}
164+
}
165+
166+
private static int? TryGetUserId(ClaimsPrincipal user)
167+
{
168+
string? raw = user.FindFirstValue("UserId");
169+
if (int.TryParse(raw, out int value)) return value;
170+
return null;
171+
}
172+
173+
private static async Task<(byte[]? bytes, bool truncated)> ReadRequestBody(HttpRequest request, int maxBytes, CancellationToken cancellationToken)
174+
{
175+
if (request.Body == Stream.Null || !request.Body.CanRead)
176+
{
177+
return (null, false);
178+
}
179+
180+
request.EnableBuffering();
181+
int cap = Math.Max(0, maxBytes);
182+
if (cap == 0)
183+
{
184+
request.Body.Position = 0;
185+
return (null, false);
186+
}
187+
188+
using MemoryStream output = new();
189+
byte[] buffer = new byte[8192];
190+
bool truncated = false;
191+
while (true)
192+
{
193+
int read = await request.Body.ReadAsync(buffer, cancellationToken);
194+
if (read == 0) break;
195+
196+
int remain = cap - (int)output.Length;
197+
if (remain <= 0)
198+
{
199+
truncated = true;
200+
continue;
201+
}
202+
203+
int write = Math.Min(remain, read);
204+
await output.WriteAsync(buffer.AsMemory(0, write), cancellationToken);
205+
if (write < read)
206+
{
207+
truncated = true;
208+
}
209+
}
210+
211+
request.Body.Position = 0;
212+
return (output.ToArray(), truncated);
213+
}
214+
}

src/BE/web/Program.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
using Chats.DockerInterface;
2121
using Microsoft.Extensions.Options;
2222
using Chats.BE.Services.Keycloak;
23+
using Chats.BE.Services.RequestTracing;
2324

2425
namespace Chats.BE;
2526

@@ -49,7 +50,7 @@ public static async Task Main(string[] args)
4950
builder.Services.AddHttpClient(string.Empty, httpClient =>
5051
{
5152
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd($"Sdcb-Chats/{CurrentVersion}");
52-
});
53+
}).AddHttpMessageHandler<OutboundRequestTraceHandler>();
5354

5455
builder.Services.AddSingleton<KeycloakOAuthClient>();
5556
builder.Services.AddSingleton<InitService>();
@@ -93,6 +94,9 @@ public static async Task Main(string[] args)
9394
builder.Services.AddSingleton<AsyncClientInfoManager>();
9495
builder.Services.AddSingleton<AsyncCacheUsageManager>();
9596
builder.Services.AddSingleton<GitHubReleaseChecker>();
97+
builder.Services.AddSingleton<IRequestTraceConfigProvider, RequestTraceConfigProvider>();
98+
builder.Services.AddSingleton<IRequestTraceQueue, RequestTraceQueue>();
99+
builder.Services.AddTransient<OutboundRequestTraceHandler>();
96100

97101
builder.Services.AddScoped<CurrentUser>();
98102
builder.Services.AddScoped<CurrentApiKey>();
@@ -108,6 +112,7 @@ public static async Task Main(string[] args)
108112
builder.Services.AddScoped<LoginRateLimiter>();
109113

110114
builder.Services.Configure<CodePodConfig>(builder.Configuration.GetSection("CodePod"));
115+
builder.Services.Configure<RequestTraceSyncOptions>(builder.Configuration.GetSection("RequestTraceSync"));
111116
builder.Services.AddSingleton<IDockerService>(sp =>
112117
new DockerService(
113118
sp.GetRequiredService<IOptions<CodePodConfig>>().Value,
@@ -119,6 +124,8 @@ public static async Task Main(string[] args)
119124
.ValidateOnStart();
120125
builder.Services.AddScoped<CodeInterpreterExecutor>();
121126
builder.Services.AddHostedService<ChatDockerSessionCleanupService>();
127+
builder.Services.AddHostedService<RequestTraceConfigRefreshService>();
128+
builder.Services.AddHostedService<RequestTracePersistService>();
122129
builder.Services.Configure<ChatOptions>(builder.Configuration.GetSection("Chat"));
123130

124131
builder.Services.AddUrlEncryption();
@@ -144,6 +151,7 @@ public static async Task Main(string[] args)
144151

145152
app.UseAuthentication();
146153
app.UseAuthorization();
154+
app.UseMiddleware<RequestTraceMiddleware>();
147155
app.MapControllers();
148156
app.UseMiddleware<FrontendMiddleware>();
149157
app.UseStaticFiles();

src/BE/web/Services/Configs/GlobalDBConfig.cs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,6 @@ public class GlobalDBConfig(ChatsDB db, ILogger<GlobalDBConfig> logger)
2323

2424
public Task<SiteInfo?> GetFillingInfo(CancellationToken cancellationToken) => GetConfigByKey<SiteInfo>(DBConfigKey.SiteInfo, cancellationToken);
2525

26-
public Task<RequestTraceConfig?> GetInboundRequestTraceConfig(CancellationToken cancellationToken) =>
27-
GetConfigByKey<RequestTraceConfig>(DBConfigKey.InboundRequestTrace, cancellationToken);
28-
29-
public Task<RequestTraceConfig?> GetOutboundRequestTraceConfig(CancellationToken cancellationToken) =>
30-
GetConfigByKey<RequestTraceConfig>(DBConfigKey.OutboundRequestTrace, cancellationToken);
31-
3226
private async Task<T?> GetConfigByKey<T>(string key, CancellationToken cancellationToken) where T : class
3327
{
3428
string? configText = await db.Configs
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace Chats.BE.Services.Configs;
2+
3+
public interface IRequestTraceConfigProvider
4+
{
5+
RequestTraceConfig GetInboundConfig();
6+
7+
RequestTraceConfig GetOutboundConfig();
8+
9+
DateTime LastRefreshAtUtc { get; }
10+
11+
Task RefreshAsync(CancellationToken cancellationToken);
12+
13+
Task ForceRefreshAsync(CancellationToken cancellationToken);
14+
}

0 commit comments

Comments
 (0)