ActivityKit.EntityFrameworkCore
1.1.0
dotnet add package ActivityKit.EntityFrameworkCore --version 1.1.0
NuGet\Install-Package ActivityKit.EntityFrameworkCore -Version 1.1.0
<PackageReference Include="ActivityKit.EntityFrameworkCore" Version="1.1.0" />
<PackageVersion Include="ActivityKit.EntityFrameworkCore" Version="1.1.0" />
<PackageReference Include="ActivityKit.EntityFrameworkCore" />
paket add ActivityKit.EntityFrameworkCore --version 1.1.0
#r "nuget: ActivityKit.EntityFrameworkCore, 1.1.0"
#:package ActivityKit.EntityFrameworkCore@1.1.0
#addin nuget:?package=ActivityKit.EntityFrameworkCore&version=1.1.0
#tool nuget:?package=ActivityKit.EntityFrameworkCore&version=1.1.0
ActivityKit
Polymorphic audit/activity logging for .NET — automatic change detection via EF Core interceptors, fluent manual logging, and Dapper query support.
ActivityKit gives you production-grade activity logging for any .NET API. Track entity changes automatically via EF Core interceptors, log custom activities with a fluent builder, and query the activity log with Dapper — all without manual logging calls in your services. It fills the Spatie Activity Log gap for the .NET ecosystem.
Table of Contents
- Packages
- Installation
- Quick Start
- Core Concepts
- Configuration
- Attributes
- Extending ActivityEntry
- EF Core Integration
- Dapper Integration
- ASP.NET Core Integration
- Querying Activities
- Database Schema
- License
Packages
| Package | Description | Target |
|---|---|---|
| ActivityKit | Core types — activity entry entity, interfaces, attributes, fluent builder, query API. Zero ASP.NET dependency. | net8.0, net9.0, net10.0 |
| ActivityKit.AspNetCore | ASP.NET Core integration — HttpContext causer resolution from JWT claims. | net8.0, net10.0 |
| ActivityKit.EntityFrameworkCore | EF Core integration — SaveChanges interceptor for automatic change detection, entity configuration. | net8.0, net9.0, net10.0 |
| ActivityKit.Dapper | Dapper integration — SQL query builder, write store, JSON type handlers. | net8.0, net9.0, net10.0 |
Installation
# Core (required)
dotnet add package ActivityKit
# ASP.NET Core integration (causer resolution from JWT claims)
dotnet add package ActivityKit.AspNetCore
# EF Core integration (automatic change detection)
dotnet add package ActivityKit.EntityFrameworkCore
# Dapper integration (query and write store)
dotnet add package ActivityKit.Dapper
Quick Start
using ActivityKit.DependencyInjection;
using ActivityKit.EntityFrameworkCore.DependencyInjection;
using ActivityKit.EntityFrameworkCore.Extensions;
using ActivityKit.AspNetCore.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
// Register ActivityKit with EF Core interceptor and HttpContext causer
builder.Services.AddActivityKit(options =>
{
options.UseTable("activity_log");
options.StoreOldValues = true;
options.StoreNewValues = true;
})
.UseEntityFrameworkCore<AppDbContext>()
.UseHttpContextCauser();
var app = builder.Build();
// Manual logging example
app.MapPost("/api/tickets/{id}/tags", async (
string id, AddTagRequest request,
IActivityLogger<ActivityEntry> activityLogger) =>
{
// ... add the tag ...
await activityLogger.Log("tag_added")
.On("ticket", id)
.WithProperties(new { TagId = request.TagId, TagName = request.TagName })
.SaveAsync();
return Results.Ok();
});
app.Run();
Core Concepts
Activity Entry
The ActivityEntry class is the base entity for all activity log records. It uses a polymorphic design (SubjectType + SubjectId) to track changes on any entity without foreign keys:
public class ActivityEntry
{
public string Id { get; set; } // Auto-generated GUID (overridable)
public string SubjectType { get; set; } // "ticket", "license", "project_member"
public string SubjectId { get; set; } // Polymorphic subject ID (any ID type → string)
public string? ScopeId { get; set; } // Tenant/project scoping
public string? CauserType { get; set; } // "user", "system", "api_key"
public string? CauserId { get; set; } // Who performed the action
public string ActivityType { get; set; } // "created", "updated", "deleted", "tag_added"
public string? Description { get; set; } // Human-readable description
public JsonDocument? OldValues { get; set; } // Previous values (JSONB)
public JsonDocument? NewValues { get; set; } // New values (JSONB)
public JsonDocument? Properties { get; set; } // Additional context (JSONB)
public DateTimeOffset CreatedAt { get; set; }
}
All ID columns are string — the same polymorphic approach as MediaKit's Media entity. This works with Guid, Ulid, int, or any ID type via .ToString().
Polymorphic Subjects
Any entity can be tracked by implementing ILogsActivity<TEntry>:
using ActivityKit.Abstractions;
using ActivityKit.Attributes;
using ActivityKit.Entities;
public class Ticket : ILogsActivity<ActivityEntry>
{
// ILogsActivity implementation
public static string ActivityEntityType => "ticket";
object ILogsActivity<ActivityEntry>.Id => Id;
public Guid Id { get; set; }
[LogChanges]
public string Status { get; set; } = "open";
[LogChanges]
public string Priority { get; set; } = "medium";
public string Title { get; set; } = "";
[IgnoreChange]
public DateTime UpdatedAt { get; set; }
}
The interface follows the same pattern as MediaKit's IHasMedia<TMedia>:
object Id— usesobjectto support any ID type (Guid, Ulid, int). Stored asstringvia.ToString().static abstract string ActivityEntityType— entity type name stored inSubjectType. Must be lowercase, singular.- Explicit interface implementation —
object ILogsActivity<ActivityEntry>.Id => Id;converts your native ID type toobject(same asobject IHasMedia<Media>.Id => Id;in MediaKit).
Automatic Change Detection
When you register the EF Core interceptor, ActivityKit automatically detects changes on ILogsActivity entities during SaveChangesAsync:
| Entity State | Activity Type | What's Captured |
|---|---|---|
Added |
"created" |
New values for [LogChanges] properties |
Modified |
"updated" |
Old and new values for changed [LogChanges] properties |
Deleted |
"deleted" |
Old values for [LogChanges] properties |
Only properties marked with [LogChanges] (or included via [LogOnly]) are captured. Properties marked with [IgnoreChange] are always excluded. If no tracked property actually changed on a Modified entity, no activity entry is created.
// This automatically creates an activity entry with:
// - SubjectType: "ticket"
// - SubjectId: ticket.Id.ToString()
// - ActivityType: "updated"
// - OldValues: { "Status": "open" }
// - NewValues: { "Status": "closed" }
ticket.Status = "closed";
await dbContext.SaveChangesAsync();
Manual Activity Logging
For actions that aren't simple property changes (tag operations, replies, custom events), use the fluent builder:
// Inject IActivityLogger<TEntry>
public class TicketService(IActivityLogger<ActivityEntry> activityLogger)
{
public async Task AddTag(Ticket ticket, Tag tag)
{
// ... add the tag ...
await activityLogger.Log("tag_added")
.On(ticket) // Sets SubjectType + SubjectId from ILogsActivity
.WithDescription($"Added tag '{tag.Name}'")
.WithProperties(new { TagId = tag.Id, TagName = tag.Name })
.SaveAsync();
}
public async Task ChangeStatus(Ticket ticket, string oldStatus, string newStatus)
{
// ... change the status ...
await activityLogger.Log("status_changed")
.On(ticket)
.WithOldValues(new { Status = oldStatus })
.WithNewValues(new { Status = newStatus })
.SaveAsync();
}
}
The fluent builder supports:
| Method | Description |
|---|---|
.On(entity) |
Sets subject from an ILogsActivity entity (extracts type + ID) |
.On("type", id) |
Sets subject using explicit type and ID (object — any type) |
.By("user", userId) |
Sets the causer explicitly |
.By(causer) |
Sets the causer from a Causer record |
.InScope(scopeId) |
Sets the tenant/project scope (object — any type) |
.WithDescription(text) |
Sets a human-readable description |
.WithOldValues(obj) |
Captures previous values (serialized to JSON) |
.WithNewValues(obj) |
Captures new values (serialized to JSON) |
.WithProperties(obj) |
Attaches additional context (serialized to JSON) |
.SaveAsync() |
Persists the entry (auto-resolves causer if not set) |
.Build() |
Returns the entry without persisting (for testing or batching) |
Causer Resolution
The causer (who performed the action) is resolved automatically via ICauserResolver:
// ASP.NET Core: resolves from HttpContext.User JWT claims
services.AddActivityKit(options => { ... })
.UseHttpContextCauser();
// Custom: implement ICauserResolver
public class ApiKeyCauserResolver(IApiKeyContext context) : ICauserResolver
{
public Task<Causer?> ResolveAsync(CancellationToken ct = default)
{
if (context.ApiKey is null) return Task.FromResult<Causer?>(null);
return Task.FromResult<Causer?>(new Causer("api_key", context.ApiKey.Id));
}
}
// Register custom resolver
services.AddActivityKit(options =>
{
options.ResolveUserFrom<ApiKeyCauserResolver>();
});
When using the fluent builder, .By() takes precedence over the auto-resolved causer. If no causer resolver is registered and .By() is not called, the causer fields are null.
Scope (Multi-Tenancy)
For multi-tenant applications, entities can implement IHasScope to automatically populate the ScopeId field:
using ActivityKit.Abstractions;
public class Ticket : ILogsActivity<ActivityEntry>, IHasScope
{
public static string ActivityEntityType => "ticket";
object ILogsActivity<ActivityEntry>.Id => Id;
public object ScopeId => ProjectId; // Auto-extracted by interceptor
public Guid Id { get; set; }
public Guid ProjectId { get; set; }
[LogChanges]
public string Status { get; set; } = "open";
}
For manual logging, use .InScope():
await activityLogger.Log("tag_added")
.On(ticket)
.InScope(projectId)
.SaveAsync();
When .On(entity) is used and the entity implements IHasScope, the scope is extracted automatically.
Configuration
ActivityKit Options
services.AddActivityKit(options =>
{
// Database table name (default: "activity_log")
options.UseTable("activity_log");
// Capture old values on modified/deleted entities (default: true)
options.StoreOldValues = true;
// Capture new values on created/modified entities (default: true)
options.StoreNewValues = true;
// Enable EF Core interceptor auto-detection (default: true)
options.AutoDetectChanges = true;
// Register causer resolver
options.ResolveUserFrom<HttpContextCauserResolver>();
});
Builder Pattern
AddActivityKit() returns an IActivityKitBuilder for fluent chaining:
services.AddActivityKit(options =>
{
options.StoreOldValues = true;
options.StoreNewValues = true;
})
.UseEntityFrameworkCore<AppDbContext>() // EF Core interceptor + store
.UseDapper() // Dapper query + write store
.UseHttpContextCauser(); // ASP.NET Core causer resolver
Attributes
[LogChanges]
Applied to a property: only that property is tracked.
Applied to a class: all public settable properties are tracked (except [IgnoreChange]).
// Property-level: only Status and Priority are tracked
public class Ticket : ILogsActivity<ActivityEntry>
{
[LogChanges] public string Status { get; set; }
[LogChanges] public string Priority { get; set; }
public string Title { get; set; } // NOT tracked
}
// Class-level: all properties tracked except ignored ones
[LogChanges]
public class Ticket : ILogsActivity<ActivityEntry>
{
public string Status { get; set; } // Tracked
public string Priority { get; set; } // Tracked
public string Title { get; set; } // Tracked
[IgnoreChange] public DateTime UpdatedAt { get; set; } // NOT tracked
}
[IgnoreChange]
Excludes a property from tracking. Takes precedence over [LogChanges] on the class.
[LogChanges]
public class Ticket : ILogsActivity<ActivityEntry>
{
public string Status { get; set; } // Tracked
[IgnoreChange] public DateTime UpdatedAt { get; set; } // Excluded
}
[LogOnly]
Whitelist mode — only the listed properties are tracked. Applied to the class.
[LogOnly("Status", "Priority")]
public class Ticket : ILogsActivity<ActivityEntry>
{
public string Status { get; set; } // Tracked
public string Priority { get; set; } // Tracked
public string Title { get; set; } // NOT tracked
public string Description { get; set; } // NOT tracked
}
Extending ActivityEntry
Like MediaKit's AppMedia : Media pattern, you can extend ActivityEntry to add app-specific columns.
Custom Entry Type
// Override ID generation (e.g., use Ulid instead of Guid)
public class AppActivity : ActivityEntry
{
public AppActivity() => Id = Ulid.NewUlid().ToString();
}
// Add app-specific columns
public class AuditEntry : ActivityEntry
{
public AuditEntry() => Id = Ulid.NewUlid().ToString();
public string? IpAddress { get; set; }
public string? ActorEmail { get; set; }
}
Custom EF Configuration
Override ActivityEntryEntityConfiguration<TEntry> to add custom columns:
using ActivityKit.EntityFrameworkCore.EntityConfiguration;
public class AuditEntryConfiguration : ActivityEntryEntityConfiguration<AuditEntry>
{
public AuditEntryConfiguration() : base("audit_log") { }
public override void Configure(EntityTypeBuilder<AuditEntry> builder)
{
base.Configure(builder);
// App-specific columns
builder.Property(e => e.IpAddress).HasColumnName("ip_address").HasMaxLength(45);
builder.Property(e => e.ActorEmail).HasColumnName("actor_email").HasMaxLength(255);
// PostgreSQL jsonb override
builder.Property(e => e.OldValues).HasColumnType("jsonb");
builder.Property(e => e.NewValues).HasColumnType("jsonb");
builder.Property(e => e.Properties).HasColumnType("jsonb");
}
}
Registration with Custom Type
// Entities reference the custom entry type
public class License : ILogsActivity<AuditEntry>
{
public static string ActivityEntityType => "license";
object ILogsActivity<AuditEntry>.Id => Id;
public Ulid Id { get; set; }
[LogChanges] public string Status { get; set; }
}
// Register with custom type
services.AddActivityKit<AuditEntry>(options =>
{
options.UseTable("audit_log");
})
.UseEntityFrameworkCore<AppDbContext, AuditEntry>();
EF Core Integration
Entity Configuration
Apply the default configuration in your DbContext:
using ActivityKit.EntityFrameworkCore.Extensions;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Default ActivityEntry
modelBuilder.ApplyActivityKitConfiguration("activity_log");
// Or with custom type
modelBuilder.ApplyActivityKitConfiguration<AuditEntry>("audit_log");
}
This configures:
- Table name (configurable)
- Primary key on
Id(max 64 chars) - All columns with snake_case names and appropriate max lengths
- JSONB column type for
OldValues,NewValues,Properties - Indexes on
(SubjectType, SubjectId),ScopeId,(CauserType, CauserId),CreatedAt DESC
SaveChanges Interceptor
The interceptor hooks into SavingChangesAsync and SavedChangesAsync:
- Before save: Scans
ChangeTrackerforILogsActivity<TEntry>entities, captures changes - After save: Persists the collected activity entries (so entity IDs are available for new entities)
Interceptor Registration
Add the interceptor to your DbContext:
// Option 1: Via DI (recommended)
services.AddActivityKit(options => { ... })
.UseEntityFrameworkCore<AppDbContext>();
// Then in DbContext:
public class AppDbContext(
DbContextOptions<AppDbContext> options,
ActivitySaveChangesInterceptor<ActivityEntry> activityInterceptor)
: DbContext(options)
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(activityInterceptor);
}
}
// Option 2: Via DbContextOptionsBuilder
services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseNpgsql(connectionString);
options.AddInterceptors(sp.GetRequiredService<ActivitySaveChangesInterceptor<ActivityEntry>>());
});
Recursion Prevention
The interceptor uses AsyncLocal<bool> to prevent infinite recursion when saving activity entries. When the interceptor saves its own ActivityEntry records, it sets a flag that causes the interceptor to skip change detection for that save operation.
Dapper Integration
Query Store
DapperActivityQueryStore<TEntry> provides efficient SQL-based querying:
services.AddActivityKit(options => { ... })
.UseDapper();
The query store translates the fluent ActivityQuery into parameterized SQL with proper column aliasing for Dapper property mapping.
Write Store
DapperActivityStore<TEntry> provides INSERT operations for consumers not using EF Core:
// Registered automatically with .UseDapper()
// Used by the fluent builder's .SaveAsync() when EF Core is not registered
Type Handlers
ActivityKit.Dapper registers custom Dapper type handlers for:
JsonDocument— serializes/deserializes to/from JSON strings (for JSONB columns)DateTimeOffset— handles ISO 8601 string round-trips (for SQLite compatibility)
Handlers are registered automatically when .UseDapper() is called.
ASP.NET Core Integration
HttpContext Causer Resolver
Resolves the acting user from HttpContext.User JWT claims:
services.AddActivityKit(options => { ... })
.UseHttpContextCauser();
Claim resolution order:
ClaimTypes.NameIdentifier→CauserId"sub"claim →CauserId(fallback)"causer_type"claim →CauserType(default:"user")
Returns null if the user is not authenticated.
Querying Activities
Fluent Query API
// Via IActivityLogger
var result = await activityLogger.Query()
.ForSubject<Ticket>(ticketId) // Filter by entity type + ID
.ByType("status_changed") // Filter by activity type
.InScope(projectId) // Filter by scope
.Since(DateTimeOffset.UtcNow.AddDays(-30)) // Date range start
.Until(DateTimeOffset.UtcNow) // Date range end
.ByCauser("user", userId) // Filter by causer
.Page(1, 25) // Pagination
.ToListAsync();
// Result
result.Items // IReadOnlyList<ActivityEntry>
result.TotalCount // Total matching entries
result.PageNumber // Current page
result.PageSize // Items per page
result.HasNextPage // bool
result.HasPreviousPage // bool
Available filter methods:
| Method | Description |
|---|---|
.ForSubject<T>(id) |
Filter by entity type (from ActivityEntityType) and ID |
.ForSubject("type", id) |
Filter by explicit type and ID |
.ForSubjectType("type") |
Filter by entity type only |
.InScope(scopeId) |
Filter by scope/tenant |
.ByType("activity_type") |
Filter by activity type |
.ByCauser("type", id) |
Filter by causer |
.Since(date) |
Entries created on or after date |
.Until(date) |
Entries created on or before date |
.Page(number, size) |
Pagination (default: page 1, 25 items) |
Pagination
Results include pagination metadata for building paginated API responses:
var result = await activityLogger.Query()
.ForSubject("ticket", ticketId)
.Page(2, 10)
.ToListAsync();
// result.TotalCount = 47
// result.PageNumber = 2
// result.PageSize = 10
// result.HasNextPage = true
// result.HasPreviousPage = true
Database Schema
The default schema (PostgreSQL):
CREATE TABLE activity_log (
id varchar(64) PRIMARY KEY,
subject_type varchar(100) NOT NULL,
subject_id varchar(64) NOT NULL,
scope_id varchar(64),
causer_type varchar(100),
causer_id varchar(64),
activity_type varchar(100) NOT NULL,
description text,
old_values jsonb,
new_values jsonb,
properties jsonb,
created_at timestamptz NOT NULL
);
CREATE INDEX ix_activity_log_subject ON activity_log (subject_type, subject_id);
CREATE INDEX ix_activity_log_scope ON activity_log (scope_id);
CREATE INDEX ix_activity_log_causer ON activity_log (causer_type, causer_id);
CREATE INDEX ix_activity_log_created_at ON activity_log (created_at DESC);
License
MIT — Copyright (c) 2026 ActivityKit Contributors
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- ActivityKit (>= 1.1.0)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.3)
-
net8.0
- ActivityKit (>= 1.1.0)
- Microsoft.EntityFrameworkCore.Relational (>= 9.0.3)
-
net9.0
- ActivityKit (>= 1.1.0)
- Microsoft.EntityFrameworkCore.Relational (>= 9.0.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.