Skip to content

Commit 3d5c302

Browse files
authored
Merge pull request #63 from PandaTechAM/development
Logging significant performance upgrade
2 parents fbbf80b + 3ca7a2c commit 3d5c302

17 files changed

+579
-281
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using Microsoft.Data.Sqlite;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace SharedKernel.Demo.Context;
5+
6+
public class InMemoryContext(DbContextOptions<InMemoryContext> options) : DbContext(options)
7+
{
8+
public DbSet<OutboxMessage> OutboxMessages { get; set; }
9+
10+
protected override void OnModelCreating(ModelBuilder b) =>
11+
b.Entity<OutboxMessage>()
12+
.ToTable("outbox_messages")
13+
.HasKey(x => x.Id);
14+
}
15+
16+
public class OutboxMessage
17+
{
18+
public long Id { get; set; }
19+
}
20+
21+
public static class SqlLiteInMemoryConfigurationHelper
22+
{
23+
public static WebApplicationBuilder UseSqlLiteInMemory(this WebApplicationBuilder builder)
24+
{
25+
builder.Services.AddSingleton(_ =>
26+
{
27+
// Keep the in-memory DB alive for the app lifetime
28+
var conn = new SqliteConnection("Data Source=:memory:;Cache=Shared");
29+
conn.Open();
30+
return conn;
31+
});
32+
33+
builder.Services.AddDbContext<InMemoryContext>((sp, opt) =>
34+
{
35+
var conn = sp.GetRequiredService<SqliteConnection>();
36+
opt.UseSqlite(conn); // <- in-memory SQLite
37+
opt.EnableSensitiveDataLogging(); // for parameters in logs (dev)
38+
opt.EnableDetailedErrors();
39+
});
40+
41+
return builder;
42+
}
43+
44+
public static WebApplication CreateInMemoryDb(this WebApplication app)
45+
{
46+
using var scope = app.Services.CreateScope();
47+
var context = scope.ServiceProvider.GetRequiredService<InMemoryContext>();
48+
context.Database.EnsureCreated();
49+
return app;
50+
}
51+
}

SharedKernel.Demo/Program.cs

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
using FluentValidation;
44
using MediatR;
55
using Microsoft.AspNetCore.Mvc;
6-
using SharedKernel.Demo2;
6+
using Microsoft.EntityFrameworkCore;
77
using ResponseCrafter.Enums;
88
using ResponseCrafter.Extensions;
99
using SharedKernel.Demo;
10+
using SharedKernel.Demo.Context;
1011
using SharedKernel.Extensions;
1112
using SharedKernel.Helpers;
1213
using SharedKernel.Logging;
14+
using SharedKernel.Logging.Middleware;
1315
using SharedKernel.OpenApi;
1416
using SharedKernel.Resilience;
1517
using SharedKernel.ValidatorAndMediatR;
@@ -21,7 +23,7 @@
2123

2224
builder
2325
// .ConfigureWithPandaVault()
24-
.AddSerilog(LogBackend.Loki)
26+
.AddSerilog(LogBackend.ElasticSearch)
2527
.AddResponseCrafter(NamingConvention.ToSnakeCase)
2628
.AddOpenApi()
2729
.AddOpenTelemetry()
@@ -57,6 +59,7 @@
5759
})
5860
.AddOutboundLoggingHandler();
5961

62+
builder.UseSqlLiteInMemory();
6063

6164
var app = builder.Build();
6265

@@ -73,11 +76,25 @@
7376
.UseOpenApi()
7477
.MapControllers();
7578

76-
app.MapPost("user", async ([FromBody] UserCommand user, ISender sender) =>
77-
{
78-
await sender.Send(user);
79-
return Results.Ok();
80-
});
79+
app.CreateInMemoryDb();
80+
81+
82+
app.MapGet("/outbox-count",
83+
async (InMemoryContext db) =>
84+
{
85+
var cnt = await db.OutboxMessages.CountAsync();
86+
return TypedResults.Ok(new
87+
{
88+
count = cnt
89+
});
90+
});
91+
92+
app.MapPost("user",
93+
async ([FromBody] UserCommand user, ISender sender) =>
94+
{
95+
await sender.Send(user);
96+
return Results.Ok();
97+
});
8198

8299
app.MapPost("/receive-file", ([FromForm] IFormFile file) => TypedResults.Ok())
83100
.DisableAntiforgery();
@@ -133,7 +150,7 @@
133150
app.LogStartSuccess();
134151
app.Run();
135152

136-
namespace SharedKernel.Demo2
153+
namespace SharedKernel.Demo
137154
{
138155
public class TestTypes
139156
{
@@ -148,38 +165,38 @@ public enum AnimalType
148165
Cat,
149166
Fish
150167
}
151-
}
152168

153-
public record UserCommand(string Name, string Email) : ICommand<string>;
169+
public record UserCommand(string Name, string Email) : ICommand<string>;
154170

155-
public class UserCommandHandler : ICommandHandler<UserCommand, string>
156-
{
157-
public Task<string> Handle(UserCommand request, CancellationToken cancellationToken)
171+
public class UserCommandHandler : ICommandHandler<UserCommand, string>
158172
{
159-
return Task.FromResult($"User {request.Name} with email {request.Email} created successfully.");
173+
public Task<string> Handle(UserCommand request, CancellationToken cancellationToken)
174+
{
175+
return Task.FromResult($"User {request.Name} with email {request.Email} created successfully.");
176+
}
160177
}
161-
}
162178

163-
public class User
164-
{
165-
public string Name { get; set; } = string.Empty;
166-
public string Email { get; set; } = string.Empty;
167-
}
179+
public class User
180+
{
181+
public string Name { get; set; } = string.Empty;
182+
public string Email { get; set; } = string.Empty;
183+
}
168184

169-
public class UserValidator : AbstractValidator<UserCommand>
170-
{
171-
public UserValidator()
185+
public class UserValidator : AbstractValidator<UserCommand>
172186
{
173-
RuleFor(x => x.Name)
174-
.NotEmpty()
175-
.WithMessage("Name is required.")
176-
.MaximumLength(100)
177-
.WithMessage("Name cannot exceed 100 characters.");
178-
179-
RuleFor(x => x.Email)
180-
.NotEmpty()
181-
.WithMessage("Email is required.")
182-
.EmailAddress()
183-
.WithMessage("Invalid email format.");
187+
public UserValidator()
188+
{
189+
RuleFor(x => x.Name)
190+
.NotEmpty()
191+
.WithMessage("Name is required.")
192+
.MaximumLength(100)
193+
.WithMessage("Name cannot exceed 100 characters.");
194+
195+
RuleFor(x => x.Email)
196+
.NotEmpty()
197+
.WithMessage("Email is required.")
198+
.EmailAddress()
199+
.WithMessage("Invalid email format.");
200+
}
184201
}
185202
}

SharedKernel.Demo/SharedKernel.Demo.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<PackageReference Include="AspNetCore.HealthChecks.Rabbitmq" Version="9.0.0" />
1111
<PackageReference Include="MassTransit.RabbitMQ" Version="8.5.2" />
1212
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8" />
13+
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" />
1314
</ItemGroup>
1415

1516
<ItemGroup>

src/SharedKernel/Extensions/SignalRExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Serilog;
66
using Serilog.Events;
77
using SharedKernel.Logging;
8+
using SharedKernel.Logging.Middleware;
89
using StackExchange.Redis;
910

1011
namespace SharedKernel.Extensions;

src/SharedKernel/Logging/Helpers/HttpLogHelper.cs

Lines changed: 0 additions & 56 deletions
This file was deleted.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
using System.Text;
2+
using Microsoft.AspNetCore.Http;
3+
4+
namespace SharedKernel.Logging.Middleware;
5+
6+
internal static class HttpLogHelper
7+
{
8+
// defaults; can be overridden via Configure(options)
9+
10+
public static async Task<(string Headers, string Body)> CaptureAsync(Stream bodyStream,
11+
IHeaderDictionary headers,
12+
string? mediaType)
13+
{
14+
var hdrs = RedactionHelper.RedactHeaders(headers);
15+
16+
if (!IsTextLike(mediaType))
17+
{
18+
long? len = null;
19+
if (headers.TryGetValue("Content-Length", out var clVal) &&
20+
long.TryParse(clVal.ToString(), out var cl))
21+
len = cl;
22+
23+
return (LogFormatting.Json(hdrs),
24+
LogFormatting.Json(BuildOmittedBodyMessage("non-text",
25+
len,
26+
mediaType,
27+
LoggingOptions.RequestResponseBodyMaxBytes)));
28+
}
29+
30+
var (raw, truncated) = await ReadLimitedAsync(bodyStream, LoggingOptions.RequestResponseBodyMaxBytes);
31+
32+
if (truncated)
33+
return (LogFormatting.Json(hdrs),
34+
LogFormatting.Json($"[OMITTED: body exceeds {LoggingOptions.RequestResponseBodyMaxBytes / 1024}KB]"));
35+
36+
var body = RedactionHelper.RedactBody(mediaType, raw);
37+
return (LogFormatting.Json(hdrs), LogFormatting.Json(body));
38+
}
39+
40+
public static async Task<(string Headers, string Body)> CaptureAsync(Dictionary<string, IEnumerable<string>> headers,
41+
Func<Task<string>> rawReader,
42+
string? mediaType)
43+
{
44+
var hdrs = RedactionHelper.RedactHeaders(headers);
45+
46+
if (!IsTextLike(mediaType))
47+
return (LogFormatting.Json(hdrs), LogFormatting.Json(string.Empty));
48+
49+
var raw = await rawReader();
50+
if (Utf8ByteCount(raw) > LoggingOptions.RequestResponseBodyMaxBytes)
51+
{
52+
return (LogFormatting.Json(hdrs),
53+
LogFormatting.Json($"[OMITTED: body exceeds {LoggingOptions.RequestResponseBodyMaxBytes / 1024}KB]"));
54+
}
55+
56+
var body = RedactionHelper.RedactBody(mediaType, raw);
57+
return (LogFormatting.Json(hdrs), LogFormatting.Json(body));
58+
}
59+
60+
public static Dictionary<string, IEnumerable<string>> CreateHeadersDictionary(HttpRequestMessage req)
61+
{
62+
var dict = new Dictionary<string, IEnumerable<string>>(StringComparer.OrdinalIgnoreCase);
63+
foreach (var h in req.Headers) dict[h.Key] = h.Value;
64+
65+
var contentHeaders = req.Content?.Headers;
66+
if (contentHeaders != null)
67+
foreach (var h in contentHeaders)
68+
dict[h.Key] = h.Value;
69+
70+
return dict;
71+
}
72+
73+
public static Dictionary<string, IEnumerable<string>> CreateHeadersDictionary(HttpResponseMessage res)
74+
{
75+
var dict = new Dictionary<string, IEnumerable<string>>(StringComparer.OrdinalIgnoreCase);
76+
foreach (var h in res.Headers) dict[h.Key] = h.Value;
77+
foreach (var h in res.Content.Headers) dict[h.Key] = h.Value;
78+
return dict;
79+
}
80+
81+
internal static string BuildOmittedBodyMessage(string reason,
82+
long? lengthBytes,
83+
string? mediaType,
84+
int thresholdBytes) =>
85+
LogFormatting.Omitted(reason, lengthBytes, mediaType, thresholdBytes);
86+
87+
internal static bool IsTextLike(string? mediaType)
88+
{
89+
if (string.IsNullOrWhiteSpace(mediaType)) return false;
90+
return LoggingOptions.TextLikeMediaPrefixes.Any(m => mediaType.StartsWith(m, StringComparison.OrdinalIgnoreCase))
91+
|| mediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase);
92+
}
93+
94+
private static async Task<(string text, bool truncated)> ReadLimitedAsync(Stream s, int maxBytes)
95+
{
96+
s.Seek(0, SeekOrigin.Begin);
97+
98+
using var ms = new MemoryStream(capacity: maxBytes);
99+
var buf = new byte[Math.Min(8192, maxBytes)];
100+
var total = 0;
101+
102+
while (total < maxBytes)
103+
{
104+
var toRead = Math.Min(buf.Length, maxBytes - total);
105+
var read = await s.ReadAsync(buf.AsMemory(0, toRead));
106+
if (read == 0) break;
107+
await ms.WriteAsync(buf.AsMemory(0, read));
108+
total += read;
109+
}
110+
111+
var truncated = false;
112+
if (total == maxBytes)
113+
{
114+
var probe = new byte[1];
115+
var read = await s.ReadAsync(probe.AsMemory(0, 1));
116+
if (read > 0)
117+
{
118+
truncated = true;
119+
if (s.CanSeek) s.Seek(-read, SeekOrigin.Current);
120+
}
121+
}
122+
123+
s.Seek(0, SeekOrigin.Begin);
124+
return (Encoding.UTF8.GetString(ms.ToArray()), truncated);
125+
}
126+
127+
private static int Utf8ByteCount(string s) => Encoding.UTF8.GetByteCount(s);
128+
}

0 commit comments

Comments
 (0)