Skip to content
This repository was archived by the owner on Feb 20, 2025. It is now read-only.

Commit f07d0b7

Browse files
committed
Add query logging
1 parent 53bbe36 commit f07d0b7

File tree

10 files changed

+232
-62
lines changed

10 files changed

+232
-62
lines changed

src/Shiny.Extensions.EntityFramework/Auditing/AuditSaveChangesInterceptor.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ namespace Shiny.Auditing;
55

66
// TODO: I need the entity ID after insert
77
// TODO: catch ExecuteUpdate & ExecuteDelete - how? ExecuteDelete isn't something I believe in with audited tables anyhow - So only ExecuteUpdate
8-
public class AuditSaveChangesInterceptor(IAuditInfoProvider provider) : SaveChangesInterceptor
8+
public class AuditSaveChangesInterceptor(IContextInfoProvider provider) : SaveChangesInterceptor
99
{
1010
AuditScope? auditScope;
1111

src/Shiny.Extensions.EntityFramework/Auditing/AuditScope.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public class AuditScope
1919
}
2020

2121

22-
public static AuditScope Create(IAuditInfoProvider provider, DbContextEventData eventData)
22+
public static AuditScope Create(IContextInfoProvider provider, DbContextEventData eventData)
2323
{
2424
var state = AuditScope.BuildState(provider, eventData);
2525
var scope = new AuditScope(eventData.Context!, state);
@@ -86,7 +86,7 @@ static DbOperation ToOperation(EntityState state)
8686
}
8787

8888

89-
static List<EntityAuditContext> BuildState(IAuditInfoProvider provider, DbContextEventData eventData)
89+
static List<EntityAuditContext> BuildState(IContextInfoProvider provider, DbContextEventData eventData)
9090
{
9191
var entries = new List<EntityAuditContext>();
9292
var changeTracker = eventData.Context!.ChangeTracker;

src/Shiny.Extensions.EntityFramework/EntityFrameworkExtensions.cs

+68-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,61 @@
11
using System.Linq;
22
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.Extensions.DependencyInjection;
34
using Shiny.Auditing;
5+
using Shiny.QueryLog;
46

57
namespace Shiny;
68

79

810
public static class EntityFrameworkExtensions
911
{
12+
public static IServiceCollection AddDbContextAuditing<T>(this IServiceCollection services) where T : class, IContextInfoProvider
13+
{
14+
services.AddScoped<T>();
15+
services.AddScoped<AuditSaveChangesInterceptor>();
16+
return services;
17+
}
18+
19+
20+
public static IServiceCollection AddDbContextAuditing(
21+
this IServiceCollection services,
22+
IContextInfoProvider contextProvider
23+
) => services.AddScoped(_ => new AuditSaveChangesInterceptor(contextProvider));
24+
25+
26+
public static DbContextOptionsBuilder UseAuditing(this DbContextOptionsBuilder builder, IServiceProvider scope)
27+
{
28+
var interceptor = scope.GetRequiredService<AuditSaveChangesInterceptor>();
29+
return builder.AddInterceptors(interceptor);
30+
}
31+
32+
33+
public static IServiceCollection AddDbContextQueryLogging<T>(this IServiceCollection services, TimeSpan minLogDuration) where T : class, IContextInfoProvider
34+
{
35+
services.AddScoped<T>();
36+
services.AddScoped(sp =>
37+
{
38+
var contextInfo = sp.GetRequiredService<T>();
39+
return new QueryLogDbCommandInterceptor(contextInfo, minLogDuration);
40+
});
41+
return services;
42+
}
43+
44+
45+
public static IServiceCollection AddDbContextQueryLogging(
46+
this IServiceCollection services,
47+
IContextInfoProvider contextProvider,
48+
TimeSpan minLogDuration
49+
) => services.AddScoped(_ => new QueryLogDbCommandInterceptor(contextProvider, minLogDuration));
50+
51+
52+
public static void UseQueryLogging(this DbContextOptionsBuilder builder, IServiceProvider scope)
53+
{
54+
var interceptor = scope.GetRequiredService<QueryLogDbCommandInterceptor>();
55+
builder.AddInterceptors(interceptor);
56+
}
57+
58+
1059
public static ModelConfigurationBuilder SetDefaultStringLength(this ModelConfigurationBuilder configurationBuilder, int length = 50, bool unicode = true)
1160
{
1261
configurationBuilder
@@ -46,7 +95,25 @@ public static ModelBuilder MapAuditing(this ModelBuilder modelBuilder)
4695
map.Property(x => x.AppLocation).HasMaxLength(1024);
4796

4897
return modelBuilder;
49-
}
98+
}
99+
100+
101+
public static ModelBuilder MapSlowQueryAuditing(this ModelBuilder modelBuilder)
102+
{
103+
var map = modelBuilder.Entity<QueryLogEntry>();
104+
105+
map.HasKey(x => x.Id);
106+
map.Property(x => x.Id).ValueGeneratedOnAdd();
107+
map.Property(x => x.Query).HasMaxLength(5000);
108+
map.Property(x => x.Duration);
109+
map.Property(x => x.Timestamp);
110+
111+
map.Property(x => x.UserIdentifier).HasMaxLength(50);
112+
map.Property(x => x.UserIpAddress).HasMaxLength(39);
113+
map.Property(x => x.AppLocation).HasMaxLength(1024);
114+
115+
return modelBuilder;
116+
}
50117

51118

52119
public static ModelBuilder MapEasyPropertyIds(this ModelBuilder modelBuilder)

src/Shiny.Extensions.EntityFramework/Auditing/IAuditInfoProvider.cs src/Shiny.Extensions.EntityFramework/IContextInfoProvider.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
namespace Shiny.Auditing;
1+
namespace Shiny;
22

3-
public interface IAuditInfoProvider
3+
public interface IContextInfoProvider
44
{
55
/// <summary>
66
/// Can be a URL or anything else if available
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System.Data.Common;
2+
using Microsoft.EntityFrameworkCore.Diagnostics;
3+
4+
namespace Shiny.QueryLog;
5+
6+
7+
public class QueryLogDbCommandInterceptor(IContextInfoProvider infoProvider, TimeSpan minLogDuration) : IDbCommandInterceptor
8+
{
9+
public int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result)
10+
{
11+
this.TryLog(command, eventData);
12+
return result;
13+
}
14+
15+
16+
public DbDataReader ReaderExecuted(DbCommand command, CommandExecutedEventData eventData, DbDataReader result)
17+
{
18+
this.TryLog(command, eventData);
19+
return result;
20+
}
21+
22+
23+
public object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result)
24+
{
25+
this.TryLog(command, eventData);
26+
return result;
27+
}
28+
29+
30+
protected void TryLog(DbCommand command, CommandExecutedEventData eventData)
31+
{
32+
if (minLogDuration < eventData.Duration)
33+
{
34+
var log = new QueryLogEntry
35+
{
36+
UserIdentifier = infoProvider.UserIdentifier,
37+
UserIpAddress = infoProvider.UserIpAddress,
38+
AppLocation = infoProvider.AppLocation,
39+
40+
Query = eventData.Command.CommandText,
41+
Duration = eventData.Duration,
42+
Timestamp = DateTimeOffset.UtcNow
43+
};
44+
try
45+
{
46+
// TODO: cannot audit seeding/migrations data
47+
eventData.Context!.Add(log);
48+
eventData.Context.SaveChanges();
49+
}
50+
catch (Exception ex)
51+
{
52+
Console.WriteLine(ex.ToString());
53+
}
54+
}
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Shiny.QueryLog;
2+
3+
public class QueryLogEntry
4+
{
5+
public int Id { get; set; }
6+
public string Query { get; set; }
7+
public TimeSpan Duration { get; set; }
8+
public DateTimeOffset Timestamp { get; set; }
9+
10+
public string? UserIdentifier { get; set; }
11+
public string? AppLocation { get; set; }
12+
public string? UserIpAddress { get; set; }
13+
}

tests/Shiny.Extensions.EntityFramework.Tests/AuditTests.cs tests/Shiny.Extensions.EntityFramework.Tests/EfTests.Auditing.cs

+10-56
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,14 @@
11
using FluentAssertions;
22
using Microsoft.EntityFrameworkCore;
3-
using Microsoft.Extensions.DependencyInjection;
4-
using Npgsql;
53
using Shiny.Auditing;
64

75
namespace Shiny.Extensions.EntityFramework.Tests;
86

97

108
// TODO: check entityId & entityType
11-
public class AuditTests : IDisposable
9+
public partial class EfTests
1210
{
13-
readonly TestAuditInfoProvider auditProvider;
14-
readonly IServiceProvider serviceProvider;
1511

16-
public AuditTests()
17-
{
18-
this.auditProvider = new();
19-
20-
var services = new ServiceCollection();
21-
services.AddSingleton<IAuditInfoProvider>(this.auditProvider);
22-
services.AddScoped<AuditSaveChangesInterceptor>();
23-
services.AddDbContext<TestDbContext>((sp, opts) =>
24-
{
25-
// .UseSqlite("Data Source=test.db")
26-
opts.UseNpgsql(new NpgsqlDataSourceBuilder("User ID=sa;Password=Blargh911!;Host=localhost;Port=5432;Database=AuditUnitTests;Pooling=true;Connection Lifetime=30;").Build());
27-
var interceptor = sp.GetRequiredService<AuditSaveChangesInterceptor>();
28-
opts.AddInterceptors(interceptor);
29-
});
30-
this.serviceProvider = services.BuildServiceProvider();
31-
32-
using var scope = this.serviceProvider.CreateScope();
33-
var data = scope.ServiceProvider.GetRequiredService<TestDbContext>();
34-
data.Database.EnsureDeleted();
35-
data.Database.EnsureCreated();
36-
37-
// TODO: seed
38-
}
39-
40-
async Task DoDb(Func<TestDbContext, Task> task)
41-
{
42-
using var scope = this.serviceProvider.CreateScope();
43-
var data = scope.ServiceProvider.GetRequiredService<TestDbContext>();
44-
await task(data);
45-
}
46-
47-
4812
[Fact]
4913
public async Task AddAudits()
5014
{
@@ -106,6 +70,15 @@ await this.DoDb(async data =>
10670
}
10771

10872

73+
void AssertAudit(AuditEntry audit, DbOperation op)
74+
{
75+
audit.Operation.Should().Be(op, "Invalid Operation");
76+
audit.UserIdentifier.Should().Be("Test User");
77+
audit.UserIpAddress.Should().Be("0.0.0.0");
78+
audit.AppLocation.Should().Be("UNIT TESTS");
79+
}
80+
81+
10982
Task Seed() => this.DoDb(data =>
11083
{
11184
var manu = new Manufacturer { Name = "Cadillac" };
@@ -119,23 +92,4 @@ Task Seed() => this.DoDb(data =>
11992
data.Add(model);
12093
return data.SaveChangesAsync();
12194
});
122-
123-
124-
void AssertAudit(AuditEntry audit, DbOperation op)
125-
{
126-
audit.Operation.Should().Be(op, "Invalid Operation");
127-
audit.UserIdentifier.Should().Be("Test User");
128-
audit.UserIpAddress.Should().Be("0.0.0.0");
129-
audit.AppLocation.Should().Be("UNIT TESTS");
130-
}
131-
132-
public void Dispose() => (this.serviceProvider as IDisposable)?.Dispose();
133-
}
134-
135-
public class TestAuditInfoProvider : IAuditInfoProvider
136-
{
137-
public string? AppLocation { get; set; } = "UNIT TESTS";
138-
public string? Tenant { get; set; } = "Test Tenant";
139-
public string? UserIdentifier { get; set; } = "Test User";
140-
public string? UserIpAddress { get; set; } = "0.0.0.0";
14195
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using FluentAssertions;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace Shiny.Extensions.EntityFramework.Tests;
5+
6+
public partial class EfTests
7+
{
8+
9+
[Fact]
10+
public async Task DidLog()
11+
{
12+
await this.DoDb(async data =>
13+
{
14+
await data.Manufacturers.ToListAsync();
15+
});
16+
17+
await this.DoDb(async data =>
18+
{
19+
var logs = await data.QueryLogs.ToListAsync();
20+
logs.Count.Should().BeGreaterOrEqualTo(1, "No query logs");
21+
});
22+
}
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using Npgsql;
4+
5+
namespace Shiny.Extensions.EntityFramework.Tests;
6+
7+
8+
public partial class EfTests : IDisposable
9+
{
10+
readonly TestContextInfoProvider contextProvider;
11+
readonly IServiceProvider serviceProvider;
12+
13+
public EfTests()
14+
{
15+
this.contextProvider = new();
16+
17+
var services = new ServiceCollection();
18+
services.AddDbContextAuditing(this.contextProvider);
19+
services.AddDbContextQueryLogging(this.contextProvider, TimeSpan.Zero);
20+
21+
services.AddDbContext<TestDbContext>((sp, opts) => opts
22+
.UseNpgsql(new NpgsqlDataSourceBuilder("User ID=sa;Password=Blargh911!;Host=localhost;Port=5432;Database=AuditUnitTests;Pooling=true;Connection Lifetime=30;").Build())
23+
.UseAuditing(sp)
24+
.UseQueryLogging(sp)
25+
// .UseSqlite("Data Source=test.db")
26+
);
27+
this.serviceProvider = services.BuildServiceProvider();
28+
29+
using var scope = this.serviceProvider.CreateScope();
30+
var data = scope.ServiceProvider.GetRequiredService<TestDbContext>();
31+
data.Database.EnsureDeleted();
32+
data.Database.EnsureCreated();
33+
34+
// TODO: seed
35+
}
36+
37+
async Task DoDb(Func<TestDbContext, Task> task)
38+
{
39+
using var scope = this.serviceProvider.CreateScope();
40+
var data = scope.ServiceProvider.GetRequiredService<TestDbContext>();
41+
await task(data);
42+
}
43+
44+
45+
public void Dispose() => (this.serviceProvider as IDisposable)?.Dispose();
46+
}
47+
48+
public class TestContextInfoProvider : IContextInfoProvider
49+
{
50+
public string? AppLocation { get; set; } = "UNIT TESTS";
51+
public string? Tenant { get; set; } = "Test Tenant";
52+
public string? UserIdentifier { get; set; } = "Test User";
53+
public string? UserIpAddress { get; set; } = "0.0.0.0";
54+
}

tests/Shiny.Extensions.EntityFramework.Tests/TestDbContext.cs

+3
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
using System.Text.Json;
22
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.EntityFrameworkCore.Infrastructure;
34
using Shiny.Auditing;
5+
using Shiny.QueryLog;
46

57
namespace Shiny.Extensions.EntityFramework.Tests;
68

79
public class TestDbContext(DbContextOptions<TestDbContext> options) : DbContext(options)
810
{
911
public DbSet<AuditEntry> AuditEntries => this.Set<AuditEntry>();
12+
public DbSet<QueryLogEntry> QueryLogs => this.Set<QueryLogEntry>();
1013
public DbSet<Manufacturer> Manufacturers => this.Set<Manufacturer>();
1114
public DbSet<Model> Models => this.Set<Model>();
1215

0 commit comments

Comments
 (0)