ActivityKit 1.1.0

dotnet add package ActivityKit --version 1.1.0
                    
NuGet\Install-Package ActivityKit -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" Version="1.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ActivityKit" Version="1.1.0" />
                    
Directory.Packages.props
<PackageReference Include="ActivityKit" />
                    
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 --version 1.1.0
                    
#r "nuget: ActivityKit, 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@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&version=1.1.0
                    
Install as a Cake Addin
#tool nuget:?package=ActivityKit&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 (3)

Showing the top 3 NuGet packages that depend on ActivityKit:

Package Downloads
ActivityKit.EntityFrameworkCore

Entity Framework Core integration for ActivityKit — SaveChanges interceptor for automatic change detection and activity logging.

ActivityKit.AspNetCore

ASP.NET Core integration for ActivityKit — HttpContext-based causer resolution from JWT claims.

ActivityKit.Dapper

Dapper integration for ActivityKit — SQL query builder and store for reading/writing activity log entries.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.1.0 389 3/25/2026
1.0.1 147 3/24/2026
1.0.0 152 3/24/2026