Skip to content

Commit 85757e1

Browse files
feat: Add optional DebugProbe Server ingestion
Adds optional centralized ingestion from DebugProbe.AspNetCore to DebugProbe.Server.
2 parents 95ec998 + 80aac6f commit 85757e1

9 files changed

Lines changed: 280 additions & 8 deletions

File tree

DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ public void Defaults_work_correctly()
1818
Assert.False(options.AllowUiInProduction);
1919
Assert.Null(options.AuthorizationPolicy);
2020
Assert.True(options.CaptureOutgoingHttpClientRequests);
21+
Assert.Null(options.ServerUrl);
22+
Assert.Null(options.ApplicationId);
23+
Assert.Null(options.ApplicationName);
24+
Assert.Null(options.InstanceId);
2125
Assert.Empty(options.IgnorePaths);
2226
Assert.Equal(["Authorization", "Cookie", "Set-Cookie"], options.RedactedHeaders);
2327
Assert.Empty(options.RedactedQueryParameters);
@@ -38,6 +42,10 @@ public void Custom_options_are_registered_and_used()
3842
options.AuthorizationPolicy = "DebugProbePolicy";
3943
options.IgnorePaths = ["/health"];
4044
options.CaptureOutgoingHttpClientRequests = false;
45+
options.ServerUrl = "https://debugprobe.example";
46+
options.ApplicationId = "sample-api";
47+
options.ApplicationName = "Sample API";
48+
options.InstanceId = "local-dev";
4149
options.RedactedHeaders = ["X-Api-Key"];
4250
options.RedactedQueryParameters = ["token"];
4351
options.RedactedJsonFields = ["password"];
@@ -54,6 +62,10 @@ public void Custom_options_are_registered_and_used()
5462
Assert.Equal("DebugProbePolicy", options.AuthorizationPolicy);
5563
Assert.Equal(["/health"], options.IgnorePaths);
5664
Assert.False(options.CaptureOutgoingHttpClientRequests);
65+
Assert.Equal("https://debugprobe.example", options.ServerUrl);
66+
Assert.Equal("sample-api", options.ApplicationId);
67+
Assert.Equal("Sample API", options.ApplicationName);
68+
Assert.Equal("local-dev", options.InstanceId);
5769
Assert.Equal(["X-Api-Key"], options.RedactedHeaders);
5870
Assert.Equal(["token"], options.RedactedQueryParameters);
5971
Assert.Equal(["password"], options.RedactedJsonFields);
@@ -88,6 +100,20 @@ public void MaxEntries_negative_throws_InvalidOperationException()
88100
Assert.Contains("MaxEntries", exception.Message);
89101
}
90102

103+
[Fact]
104+
public void Invalid_ServerUrl_throws_InvalidOperationException()
105+
{
106+
var services = new ServiceCollection();
107+
108+
var exception = Assert.Throws<InvalidOperationException>(() =>
109+
services.AddDebugProbe(options =>
110+
{
111+
options.ServerUrl = "localhost:5000";
112+
}));
113+
114+
Assert.Contains("ServerUrl", exception.Message);
115+
}
116+
91117
[Fact]
92118
public void MaxBodyCaptureSizeKb_negative_throws_ArgumentOutOfRangeException()
93119
{

DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Net;
2+
using DebugProbe.AspNetCore.Ingestion;
23
using DebugProbe.AspNetCore.Middleware;
34
using DebugProbe.AspNetCore.Options;
45
using DebugProbe.AspNetCore.Storage;
@@ -90,10 +91,12 @@ public async Task Response_stream_is_restored_after_successful_request()
9091
{
9192
var originalBody = new MemoryStream();
9293
var context = CreateHttpContext(originalBody);
93-
var store = new DebugEntryStore(new DebugProbeOptions());
94+
var options = new DebugProbeOptions();
95+
var store = new DebugEntryStore(options);
9496
var middleware = new DebugProbeMiddleware(
9597
async httpContext => await httpContext.Response.WriteAsync("ok"),
96-
new DebugProbeOptions());
98+
options,
99+
new DebugProbeServerClient(options));
97100

98101
await middleware.Invoke(context, store);
99102

@@ -106,10 +109,12 @@ public async Task Response_stream_is_restored_after_exception()
106109
{
107110
var originalBody = new MemoryStream();
108111
var context = CreateHttpContext(originalBody);
109-
var store = new DebugEntryStore(new DebugProbeOptions());
112+
var options = new DebugProbeOptions();
113+
var store = new DebugEntryStore(options);
110114
var middleware = new DebugProbeMiddleware(
111115
_ => throw new InvalidOperationException("broken"),
112-
new DebugProbeOptions());
116+
options,
117+
new DebugProbeServerClient(options));
113118

114119
await Assert.ThrowsAsync<InvalidOperationException>(() => middleware.Invoke(context, store));
115120

DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Net.Http.Json;
22
using DebugProbe.AspNetCore.Handlers;
3+
using DebugProbe.AspNetCore.Ingestion;
34
using DebugProbe.AspNetCore.Internal.Compare;
45
using DebugProbe.AspNetCore.Internal.Rendering;
56
using DebugProbe.AspNetCore.Internal.Resources;
@@ -48,6 +49,7 @@ public static IServiceCollection AddDebugProbe(this IServiceCollection services,
4849
services.AddHttpContextAccessor();
4950

5051
services.AddHttpClient();
52+
services.AddSingleton<DebugProbeServerClient>();
5153

5254
if (options.CaptureOutgoingHttpClientRequests)
5355
{
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
using System.Net.Http.Json;
2+
using System.Reflection;
3+
using DebugProbe.AspNetCore.Models;
4+
using DebugProbe.AspNetCore.Options;
5+
6+
namespace DebugProbe.AspNetCore.Ingestion;
7+
8+
public sealed class DebugProbeServerClient
9+
{
10+
private const string BodyTooLargeMessage = "[Body too large]";
11+
private const string BinaryBodyMessage = "[Body not captured: non-text content]";
12+
13+
private static readonly HttpClient Http = new()
14+
{
15+
Timeout = TimeSpan.FromSeconds(2)
16+
};
17+
18+
private readonly DebugProbeOptions _options;
19+
20+
public DebugProbeServerClient(DebugProbeOptions options)
21+
{
22+
_options = options;
23+
}
24+
25+
public async Task SendRequestAsync(DebugEntry entry, DebugEnvironment environment)
26+
{
27+
if (!TryGetEndpoint(out var endpoint))
28+
{
29+
return;
30+
}
31+
32+
try
33+
{
34+
await Http.PostAsJsonAsync(endpoint, MapRequest(entry, environment));
35+
}
36+
catch
37+
{
38+
// Central ingestion is optional and must never break the local application.
39+
}
40+
}
41+
42+
private bool TryGetEndpoint(out Uri endpoint)
43+
{
44+
endpoint = default!;
45+
46+
if (string.IsNullOrWhiteSpace(_options.ServerUrl) ||
47+
!Uri.TryCreate(_options.ServerUrl, UriKind.Absolute, out var serverUri))
48+
{
49+
return false;
50+
}
51+
52+
endpoint = new Uri(EnsureTrailingSlash(serverUri), "api/ingestion/requests");
53+
return true;
54+
}
55+
56+
private RequestData MapRequest(DebugEntry entry, DebugEnvironment environment)
57+
{
58+
return new RequestData
59+
{
60+
Application = MapApplication(environment),
61+
TimestampUtc = entry.RequestTimeUtc.ToUniversalTime(),
62+
RequestId = entry.Id,
63+
Method = entry.Method,
64+
Path = entry.Path,
65+
Query = entry.Query,
66+
Url = entry.RequestUrl,
67+
DurationMs = entry.DurationMs,
68+
StatusCode = entry.StatusCode,
69+
RequestBody = MapBody(entry.RequestBody, entry.RequestSize),
70+
ResponseBody = MapBody(entry.ResponseBody, entry.ResponseSize),
71+
RequestHeaders = entry.RequestHeaders,
72+
ResponseHeaders = entry.ResponseHeaders,
73+
OutgoingRequests = entry.OutgoingRequests.Select(MapOutgoingRequest).ToList()
74+
};
75+
}
76+
77+
private ApplicationData MapApplication(DebugEnvironment environment)
78+
{
79+
var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "Application";
80+
81+
return new ApplicationData
82+
{
83+
ApplicationId = string.IsNullOrWhiteSpace(_options.ApplicationId) ? assemblyName : _options.ApplicationId,
84+
ApplicationName = string.IsNullOrWhiteSpace(_options.ApplicationName) ? assemblyName : _options.ApplicationName,
85+
Environment = environment.Environment,
86+
InstanceId = _options.InstanceId,
87+
MachineName = environment.MachineName,
88+
AssemblyVersion = environment.AssemblyVersion,
89+
Culture = environment.Culture,
90+
UiCulture = environment.UiCulture,
91+
TimeZone = environment.TimeZone
92+
};
93+
}
94+
95+
private static OutgoingRequestData MapOutgoingRequest(DebugOutgoingRequest outgoing)
96+
{
97+
return new OutgoingRequestData
98+
{
99+
Method = outgoing.Method,
100+
Url = outgoing.Url,
101+
StatusCode = outgoing.StatusCode,
102+
DurationMs = outgoing.DurationMs,
103+
TimestampUtc = new DateTimeOffset(DateTime.SpecifyKind(outgoing.TimestampUtc, DateTimeKind.Utc)),
104+
IsSuccessStatusCode = outgoing.IsSuccessStatusCode,
105+
RequestBody = MapBody(outgoing.RequestBody, null),
106+
ResponseBody = MapBody(outgoing.ResponseBody, null),
107+
Exception = outgoing.Exception,
108+
RequestHeaders = outgoing.RequestHeaders,
109+
ResponseHeaders = outgoing.ResponseHeaders
110+
};
111+
}
112+
113+
private static BodyData? MapBody(string? content, long? sizeBytes)
114+
{
115+
if (content is null)
116+
{
117+
return null;
118+
}
119+
120+
return new BodyData
121+
{
122+
SizeBytes = sizeBytes,
123+
Captured = content.Length > 0 && content != BodyTooLargeMessage && content != BinaryBodyMessage,
124+
Truncated = content == BodyTooLargeMessage || content.EndsWith("[truncated]", StringComparison.Ordinal),
125+
Content = content
126+
};
127+
}
128+
129+
private static Uri EnsureTrailingSlash(Uri uri)
130+
{
131+
var value = uri.ToString();
132+
return value.EndsWith("/", StringComparison.Ordinal) ? uri : new Uri(value + "/");
133+
}
134+
135+
private sealed class RequestData
136+
{
137+
public int SchemaVersion { get; set; } = 1;
138+
public ApplicationData Application { get; set; } = new();
139+
public DateTimeOffset TimestampUtc { get; set; }
140+
public string? RequestId { get; set; }
141+
public string Method { get; set; } = string.Empty;
142+
public string Path { get; set; } = string.Empty;
143+
public string? Query { get; set; }
144+
public string? Url { get; set; }
145+
public long DurationMs { get; set; }
146+
public int StatusCode { get; set; }
147+
public BodyData? RequestBody { get; set; }
148+
public BodyData? ResponseBody { get; set; }
149+
public Dictionary<string, string> RequestHeaders { get; set; } = [];
150+
public Dictionary<string, string> ResponseHeaders { get; set; } = [];
151+
public List<OutgoingRequestData> OutgoingRequests { get; set; } = [];
152+
}
153+
154+
private sealed class ApplicationData
155+
{
156+
public string ApplicationId { get; set; } = string.Empty;
157+
public string ApplicationName { get; set; } = string.Empty;
158+
public string? Environment { get; set; }
159+
public string? InstanceId { get; set; }
160+
public string? MachineName { get; set; }
161+
public string? AssemblyVersion { get; set; }
162+
public string? Culture { get; set; }
163+
public string? UiCulture { get; set; }
164+
public string? TimeZone { get; set; }
165+
}
166+
167+
private sealed class BodyData
168+
{
169+
public long? SizeBytes { get; set; }
170+
public bool Captured { get; set; }
171+
public bool Truncated { get; set; }
172+
public string? Content { get; set; }
173+
}
174+
175+
private sealed class OutgoingRequestData
176+
{
177+
public string Method { get; set; } = string.Empty;
178+
public string Url { get; set; } = string.Empty;
179+
public int? StatusCode { get; set; }
180+
public long DurationMs { get; set; }
181+
public DateTimeOffset? TimestampUtc { get; set; }
182+
public bool? IsSuccessStatusCode { get; set; }
183+
public BodyData? RequestBody { get; set; }
184+
public BodyData? ResponseBody { get; set; }
185+
public string? Exception { get; set; }
186+
public Dictionary<string, string> RequestHeaders { get; set; } = [];
187+
public Dictionary<string, string> ResponseHeaders { get; set; } = [];
188+
}
189+
}

DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Diagnostics;
22
using System.Text;
3+
using DebugProbe.AspNetCore.Ingestion;
34
using DebugProbe.AspNetCore.Internal.Streams;
45
using DebugProbe.AspNetCore.Internal.Utils;
56
using DebugProbe.AspNetCore.Models;
@@ -41,14 +42,16 @@ public class DebugProbeMiddleware
4142

4243
private readonly RequestDelegate _next;
4344
private readonly DebugProbeOptions _options;
45+
private readonly DebugProbeServerClient _serverClient;
4446

4547
/// <summary>
4648
/// Initializes a new instance of the middleware.
4749
/// </summary>
48-
public DebugProbeMiddleware(RequestDelegate next, DebugProbeOptions options)
50+
public DebugProbeMiddleware(RequestDelegate next, DebugProbeOptions options, DebugProbeServerClient serverClient)
4951
{
5052
_next = next;
5153
_options = options;
54+
_serverClient = serverClient;
5255
}
5356

5457
/// <summary>
@@ -151,6 +154,8 @@ public async Task Invoke(HttpContext context, DebugEntryStore store)
151154
x => RedactionUtils.RedactHeader(x.Key, x.Value.ToString(), _options));
152155

153156
store.Add(entry);
157+
158+
_ = _serverClient.SendRequestAsync(entry, store.Environment);
154159
}
155160
}
156161

DebugProbe.AspNetCore/Options/DebugProbeOptions.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,29 @@ public int MaxBodyCaptureSizeKb
5252
/// </summary>
5353
public bool CaptureOutgoingHttpClientRequests { get; set; } = true;
5454

55+
/// <summary>
56+
/// Optional DebugProbe Server base URL used to send captured traces to a central location.
57+
/// When not configured, DebugProbe stores traces locally only.
58+
/// </summary>
59+
public string? ServerUrl { get; set; }
60+
61+
/// <summary>
62+
/// Optional stable application id sent to DebugProbe Server.
63+
/// Defaults to the entry assembly name when not configured.
64+
/// </summary>
65+
public string? ApplicationId { get; set; }
66+
67+
/// <summary>
68+
/// Optional friendly application name sent to DebugProbe Server.
69+
/// Defaults to the entry assembly name when not configured.
70+
/// </summary>
71+
public string? ApplicationName { get; set; }
72+
73+
/// <summary>
74+
/// Optional application instance id sent to DebugProbe Server.
75+
/// </summary>
76+
public string? InstanceId { get; set; }
77+
5578
/// <summary>
5679
/// Additional request paths to ignore.
5780
/// </summary>

DebugProbe.AspNetCore/Options/DebugProbeOptionsValidator.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using Microsoft.Extensions.Options;
1+
using Microsoft.Extensions.Options;
22

33
namespace DebugProbe.AspNetCore.Options;
44

@@ -17,6 +17,15 @@ public ValidateOptionsResult Validate(
1717
$"Provided value: {options.MaxEntries}.");
1818
}
1919

20+
if (!string.IsNullOrWhiteSpace(options.ServerUrl) &&
21+
(!Uri.TryCreate(options.ServerUrl, UriKind.Absolute, out var serverUri) ||
22+
(serverUri.Scheme != Uri.UriSchemeHttp && serverUri.Scheme != Uri.UriSchemeHttps)))
23+
{
24+
return ValidateOptionsResult.Fail(
25+
"DebugProbe configuration is invalid. " +
26+
"ServerUrl must be an absolute HTTP or HTTPS URL.");
27+
}
28+
2029
return ValidateOptionsResult.Success;
2130
}
2231
}

0 commit comments

Comments
 (0)