ActivityKit.EntityFrameworkCore 1.1.0

dotnet add package ActivityKit.EntityFrameworkCore --version 1.1.0
                    
NuGet\Install-Package ActivityKit.EntityFrameworkCore -Version 1.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ActivityKit.EntityFrameworkCore" Version="1.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ActivityKit.EntityFrameworkCore" Version="1.1.0" />
                    
Directory.Packages.props
<PackageReference Include="ActivityKit.EntityFrameworkCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add ActivityKit.EntityFrameworkCore --version 1.1.0
                    
#r "nuget: ActivityKit.EntityFrameworkCore, 1.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package ActivityKit.EntityFrameworkCore@1.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=ActivityKit.EntityFrameworkCore&version=1.1.0
                    
Install as a Cake Addin
#tool nuget:?package=ActivityKit.EntityFrameworkCore&version=1.1.0
                    
Install as a Cake Tool

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

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 — uses object to support any ID type (Guid, Ulid, int). Stored as string via .ToString().
  • static abstract string ActivityEntityType — entity type name stored in SubjectType. Must be lowercase, singular.
  • Explicit interface implementationobject ILogsActivity<ActivityEntry>.Id => Id; converts your native ID type to object (same as object 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:

  1. Before save: Scans ChangeTracker for ILogsActivity<TEntry> entities, captures changes
  2. 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:

  1. ClaimTypes.NameIdentifierCauserId
  2. "sub" claim → CauserId (fallback)
  3. "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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.1.0 248 3/25/2026
1.0.1 105 3/24/2026
1.0.0 99 3/24/2026