Appouse.Observer
1.2.2
dotnet add package Appouse.Observer --version 1.2.2
NuGet\Install-Package Appouse.Observer -Version 1.2.2
<PackageReference Include="Appouse.Observer" Version="1.2.2" />
<PackageVersion Include="Appouse.Observer" Version="1.2.2" />
<PackageReference Include="Appouse.Observer" />
paket add Appouse.Observer --version 1.2.2
#r "nuget: Appouse.Observer, 1.2.2"
#:package Appouse.Observer@1.2.2
#addin nuget:?package=Appouse.Observer&version=1.2.2
#tool nuget:?package=Appouse.Observer&version=1.2.2
Appouse.Observer
Automatic, Detailed Audit Trail for .NET Applications
Appouse.Observer is a comprehensive audit trail library for .NET that automatically tracks every change to your database entities — both from your application code and from direct database operations. No marker interfaces required.
Supported Frameworks
| Target | EF Core | Status |
|---|---|---|
.NET 9 |
8.x | ✅ Full support |
.NET 8 |
8.x | ✅ Full support |
.NET 6 |
6.x | ✅ Full support |
.NET 5 |
5.x | ✅ Full support |
.NET Standard 2.0 |
3.1.x | ✅ Full support (via ObserverDbContextBase) |
✨ Features
- Hybrid Auditing — Tracks changes via EF Core interceptors or SaveChanges override (application-level) AND database triggers (DB-level)
- Multi-Framework — Supports .NET Standard 2.0, .NET 5, .NET 6, .NET 8, .NET 9
- Multi-Database — PostgreSQL, SQL Server, Oracle, MySQL
- Column-Level Tracking — Records old and new values for every changed column
- Rich Metadata — Captures who, when, what, and how:
- Application User, DB User
- Method/Endpoint name
- Activity ID, Trace Identifier, Correlation ID
- Client IP, User Agent
- Attribute-Based Configuration —
[AuditInclude],[AuditIgnore],[AuditOverride],[AuditMask] - Opt-In / Opt-Out Strategy — Choose to audit all entities by default or only selected ones
- Custom Fields — Inject
IAuditContextAccessorand set arbitrary fields, tags, and comments per scope - Sensitive Data Masking —
[AuditMask]automatically masks values (e.g.,****1234) - Immutable Audit Log — Append-only tables with DELETE/UPDATE protection at both EF Core and DB trigger level
- Retention Policies — Automatic cleanup of old records via background service (global + per-table)
- Zero-Code Audit — OptOut mode audits all entities by default — no attributes needed
- Auto-Setup — Audit tables and triggers are automatically created at startup
- Message Queue Publishing — Publish audit events to RabbitMQ, Kafka, Azure Service Bus via MassTransit
- Transactional Outbox — Reliable message delivery with MassTransit's EF Core Outbox pattern
- Flexible Delivery — Database only, event bus only, or both — configure per environment
- Polly Resilience — Built-in retry (exponential backoff + jitter) and circuit breaker policies
- Idempotency — Each audit entry has a unique
IdempotencyKeypreventing duplicates across retries and redelivery
📦 Packages
🚀 Quick Start
1. Install NuGet Packages
# Core + your database provider
dotnet add package Appouse.Observer
dotnet add package Appouse.Observer.PostgreSql # or SqlServer, Oracle, MySql
# Optional: message queue publishing
dotnet add package Appouse.Observer.MassTransit
# Optional: transactional outbox for reliable delivery
dotnet add package Appouse.Observer.MassTransit.Outbox
2. Define Your Entities (No Interface Required!)
using Appouse.Observer.Abstractions;
// In OptOut mode (default), ALL entities are audited automatically.
// No marker interface needed!
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
[AuditIgnore] // ← This property will NOT appear in audit logs
public decimal InternalCost { get; set; }
[AuditMask(ShowLast = 4)] // ← Stored as "****5678" in audit logs
public string? SupplierCode { get; set; }
[AuditOverride(DisplayName = "Birim Fiyat", OutputFormat = "N2")]
public decimal UnitPrice { get; set; }
}
// Exclude an entire entity from audit
[AuditIgnore]
public class AppSetting
{
public int Id { get; set; }
public string Key { get; set; }
public string Value { get; set; }
}
3. Configure Services
Observer supports two integration approaches. Choose the one that fits your target framework:
Option A: Interceptor Approach (.NET 5+)
Uses SaveChangesInterceptor (EF Core 5.0+). Register the interceptor on your DbContextOptionsBuilder:
// Program.cs
using Appouse.Observer.Extensions;
using Appouse.Observer.PostgreSql.Extensions;
var builder = WebApplication.CreateBuilder(args);
// Register Observer core
builder.Services.AddObserver(options =>
{
options.AuditSchema = "observer";
options.AutoCreateTables = true;
options.AutoCreateTriggers = true;
options.EnableRetention = true;
options.RetentionPeriod = TimeSpan.FromDays(730); // 2 years
options.ProtectAuditTables = true;
});
// Register database provider
builder.Services.AddObserverPostgreSql(
builder.Configuration.GetConnectionString("DefaultConnection")!);
// Register your DbContext WITH Observer interceptors
builder.Services.AddDbContext<MyDbContext>((sp, options) =>
{
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")!);
options.AddObserverInterceptors(sp); // ← THIS enables EF Core interception
});
Option B: Base Class Approach (All Frameworks — including .NET Standard 2.0)
Uses ObserverDbContextBase<T> which overrides SaveChanges/SaveChangesAsync. Works on all EF Core versions, including 3.x:
using Appouse.Observer.Extensions;
// 1. Inherit your DbContext from ObserverDbContextBase
public class MyDbContext : ObserverDbContextBase<MyDbContext>
{
public MyDbContext(
DbContextOptions<MyDbContext> options,
IServiceProvider serviceProvider)
: base(options, serviceProvider) { }
public DbSet<Product> Products => Set<Product>();
public DbSet<Order> Orders => Set<Order>();
}
// 2. Register as usual — no AddObserverInterceptors needed
builder.Services.AddObserver(options =>
{
options.AuditSchema = "observer";
options.AutoCreateTables = true;
options.AutoCreateTriggers = true;
});
builder.Services.AddObserverPostgreSql(connectionString);
builder.Services.AddDbContext<MyDbContext>((sp, options) =>
{
options.UseNpgsql(connectionString);
// No AddObserverInterceptors call — base class handles it
});
Note: Both approaches use the same
AuditChangeCollectorengine internally and produce identical audit entries. On .NET 5+ you can use either; on .NET Standard 2.0 only the base class approach is available.
Integration Approach Comparison
Interceptor (AddObserverInterceptors) |
Base Class (ObserverDbContextBase<T>) |
|
|---|---|---|
| Minimum Framework | .NET 5 (EF Core 5.0+) | .NET Standard 2.0 (EF Core 3.1+) |
| DbContext Changes | None — interceptor hooks externally | Inherit from ObserverDbContextBase<T> |
| DI Registration | options.AddObserverInterceptors(sp) |
Pass IServiceProvider to constructor |
| Multiple DbContexts | Each needs AddObserverInterceptors |
Each inherits from base class |
| Audit Logic | Identical | Identical |
4. That's It! 🎉
Observer will automatically:
- Create audit tables (
observer.audit_logandobserver.audit_detail) - Create database triggers on all auditable entity tables
- Track every INSERT, UPDATE, DELETE with full metadata
- Assign a unique
IdempotencyKey(Guid) to each audit entry - Protect audit records from tampering
🏷️ Audit Attributes
Attribute Summary
| Attribute | Class | Property | Description |
|---|---|---|---|
[AuditInclude] |
✅ | ✅ | Explicitly include entity/property in audit |
[AuditIgnore] |
✅ | ✅ | Exclude entity/property from audit |
[AuditOverride] |
❌ | ✅ | Override column name, display name, or output format |
[AuditMask] |
❌ | ✅ | Mask sensitive values (e.g., ****1234) |
Audit Mode: OptOut vs OptIn
| Mode | Default Behavior | Attribute Usage |
|---|---|---|
| OptOut (default) | All entities audited | Use [AuditIgnore] to exclude |
| OptIn | No entities audited | Use [AuditInclude] to include |
builder.Services.AddObserver(options =>
{
options.AuditMode = AuditMode.OptIn; // Only [AuditInclude] entities
// options.AuditMode = AuditMode.OptOut; // Everything (default)
});
[AuditIgnore] — Exclude from Audit
using Appouse.Observer.Abstractions;
// Class-level: entire entity excluded
[AuditIgnore]
public class TempCache
{
public int Id { get; set; }
public string Data { get; set; }
}
// Property-level: specific properties excluded
public class User
{
public int Id { get; set; }
public string Email { get; set; } // ✅ Audited
public string FullName { get; set; } // ✅ Audited
public string Role { get; set; } // ✅ Audited
[AuditIgnore] // ❌ NOT audited
public string PasswordHash { get; set; }
[AuditIgnore] // ❌ NOT audited
public string? SecurityStamp { get; set; }
}
You can also exclude columns globally via ObserverOptions.ExcludedColumns (useful for cross-cutting exclusions like CreditCardNumber that apply to all entities):
options.ExcludedColumns.Add("CreditCardNumber");
options.ExcludedColumns.Add("SSN");
Note: Both methods work together. A property is excluded if either
[AuditIgnore]is present or the column name is inExcludedColumns.
[AuditInclude] — Explicitly Include in Audit
// OptIn mode: only this entity is audited
[AuditInclude]
public class Payment
{
public int Id { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
}
// Mixed: entity excluded but specific property re-included
[AuditIgnore]
public class UserProfile
{
public int Id { get; set; }
[AuditInclude] // ← This property IS audited despite class [AuditIgnore]
public string Email { get; set; }
public string InternalData { get; set; } // ← NOT audited
}
[AuditOverride] — Customize Audit Output
Override column names, display names, or value formatting in audit logs:
public class Order
{
public int Id { get; set; }
[AuditOverride(DisplayName = "Sipariş Tutarı", OutputFormat = "N2")]
public decimal TotalAmount { get; set; } // Stored as "1,234.56"
[AuditOverride(ColumnName = "order_date", OutputFormat = "yyyy-MM-dd")]
public DateTime OrderDate { get; set; } // Stored as "2026-04-10"
}
| Property | Description |
|---|---|
ColumnName |
Overrides the column name in audit detail records |
DisplayName |
Human-readable label for UI/reports |
OutputFormat |
Format string applied via ToString(format) |
[AuditMask] — Mask Sensitive Data
Automatically masks property values before storing in audit logs:
public class Payment
{
public int Id { get; set; }
[AuditMask(ShowLast = 4)]
public string CreditCardNumber { get; set; } // "****5678"
[AuditMask(ShowFirst = 2, ShowLast = 2, MaskCharacter = '#')]
public string PhoneNumber { get; set; } // "05########90"
[AuditMask(PreserveLength = false)]
public string SecretKey { get; set; } // "****"
}
| Property | Default | Description |
|---|---|---|
MaskCharacter |
'*' |
Character used for masking |
ShowFirst |
0 |
Visible characters at start |
ShowLast |
4 |
Visible characters at end |
PreserveLength |
true |
Keep original length or use fixed 4-char mask |
Note: Masking is applied to both
OldValueandNewValuein audit details.
🎛️ Custom Fields (AuditContext)
Inject IAuditContextAccessor anywhere in your code to attach custom metadata to audit entries:
using Appouse.Observer.Abstractions;
public class OrderService
{
private readonly IAuditContextAccessor _auditCtx;
private readonly AppDbContext _db;
public OrderService(IAuditContextAccessor auditCtx, AppDbContext db)
{
_auditCtx = auditCtx;
_db = db;
}
public async Task PlaceOrder(OrderDto dto)
{
// Set custom fields — fluent API, set once per scope
_auditCtx.Context
.SetField("Department", "Finance")
.SetField("Region", "EU-West")
.SetComment("Quarterly price adjustment per management approval")
.SetTag("batch-import")
.SetTag("high-priority");
_db.Orders.Add(new Order { /* ... */ });
await _db.SaveChangesAsync();
// → Audit entry will include:
// CustomFields = {"Department":"Finance","Region":"EU-West",
// "_comment":"Quarterly price adjustment...",
// "_tags":"batch-import;high-priority"}
}
}
AuditContext API
| Method | Description |
|---|---|
SetField(key, value) |
Set a custom key-value field |
GetField(key) |
Get a field value by key |
RemoveField(key) |
Remove a field |
SetTag(tag) |
Add a categorization tag |
SetComment(text) |
Set a human-readable reason (stored as _comment field) |
GetAllFields() |
Get a snapshot of all fields + tags |
Clear() |
Clear all fields and tags |
Note:
AuditContextis scoped — one instance per DI scope (one per HTTP request in web apps). AllSaveChangescalls within the same scope will include the custom fields.
📊 What Gets Tracked
Audit Log Entry (observer.audit_log)
| Field | Description |
|---|---|
id |
Auto-increment primary key |
idempotency_key |
Unique Guid for deduplication (unique index) |
timestamp_utc |
When the change occurred (UTC) |
local_timestamp |
When the change occurred (server time) |
operation |
INSERT / UPDATE / DELETE |
source |
Application (EF Core) or Database (trigger) |
schema_name |
Database schema |
table_name |
Table name |
row_id |
Primary key of affected row |
entity_type |
.NET CLR type name |
transaction_id |
DB transaction ID (for grouping) |
application_user |
Who made the change (from auth context) |
db_user |
SQL user that executed the query |
method_name |
API endpoint / method name |
activity_id |
Distributed tracing Activity ID |
trace_identifier |
HTTP request trace ID |
correlation_id |
Correlation ID for request chaining |
client_ip |
Client IP address |
user_agent |
Client User-Agent |
additional_data |
Custom metadata from IAuditMetadataProvider (JSON) |
custom_fields |
Custom fields from AuditContext (JSON) |
Audit Detail (observer.audit_detail)
| Field | Description |
|---|---|
column_name |
Name of the changed column (or [AuditOverride] override) |
display_name |
Human-readable name from [AuditOverride] |
old_value |
Previous value (null for INSERT, masked if [AuditMask]) |
new_value |
New value (null for DELETE, masked if [AuditMask]) |
data_type |
Column data type |
🔑 Idempotency
Every audit log entry is assigned a unique IdempotencyKey (Guid) when it is created by the interceptor. This key is used to prevent duplicate entries across:
- Polly retries — If a DB write fails and is retried, the key ensures no duplicate rows are inserted.
- Message redelivery — If a message broker redelivers an event, consumers can check the key for deduplication.
- Outbox reprocessing — If the outbox delivery service reprocesses a message, the key prevents duplicates.
The key is enforced with a unique index on the observer.audit_log table.
// The IdempotencyKey is automatically generated — no configuration needed.
// It flows through the entire pipeline: Interceptor → IAuditStore → IAuditEventPublisher
// On the consumer side, use it for deduplication:
public class AuditEventConsumer : IConsumer<AuditLogCreatedEvent>
{
public async Task Consume(ConsumeContext<AuditLogCreatedEvent> context)
{
var key = context.Message.IdempotencyKey;
// Check if already processed
if (await _repository.ExistsAsync(key))
return; // Skip duplicate
// Process the audit event...
await _repository.SaveAsync(context.Message);
}
}
🔒 Immutability & Protection
Audit records are append-only. Protection is enforced at two levels:
- EF Core Level:
AuditProtectionInterceptorblocks DELETE/UPDATE on audit entities - Database Level: Protection triggers reject DELETE/UPDATE on audit tables
Only the retention service (with a special session variable) can clean up old records.
⚙️ Configuration Options
builder.Services.AddObserver(options =>
{
// Audit mode
options.AuditMode = AuditMode.OptOut; // Default: OptOut (all entities audited)
// options.AuditMode = AuditMode.OptIn; // Only [AuditInclude] entities
// Schema & table names
options.AuditSchema = "observer"; // Default: "observer"
options.AuditLogTableName = "audit_log"; // Default: "audit_log"
options.AuditDetailTableName = "audit_detail"; // Default: "audit_detail"
// Delivery mode
options.DeliveryMode = AuditDeliveryMode.DatabaseOnly; // Default
// options.DeliveryMode = AuditDeliveryMode.EventBusOnly;
// options.DeliveryMode = AuditDeliveryMode.Both;
// Auto-setup
options.AutoCreateTables = true; // Default: true
options.AutoCreateTriggers = true; // Default: true
// Retention (global default)
options.EnableRetention = false; // Default: false
options.RetentionPeriod = TimeSpan.FromDays(365);
options.RetentionCheckInterval = TimeSpan.FromHours(24);
// Retention per table (overrides global)
options.TableRetentionOverrides["public.orders"] = TimeSpan.FromDays(1825); // 5 years
options.TableRetentionOverrides["public.logs"] = TimeSpan.FromDays(90); // 90 days
// Protection
options.ProtectAuditTables = true; // Default: true
// Metadata
options.IncludeClientIp = true; // Default: true
options.IncludeUserAgent = false; // Default: false
// Resilience (Polly)
options.Resilience.Enabled = true; // Default: false
options.Resilience.MaxRetryAttempts = 3; // Default: 3
options.Resilience.BaseDelay = TimeSpan.FromMilliseconds(200); // Default: 200ms
options.Resilience.EnableCircuitBreaker = true; // Default: false
// Exclude sensitive columns globally
options.ExcludedColumns.Add("CreditCardNumber");
options.ExcludedColumns.Add("SSN");
});
🗄️ Database Providers
PostgreSQL
builder.Services.AddObserverPostgreSql("Host=localhost;Database=mydb;...");
Uses SET LOCAL for session context and PL/pgSQL functions for triggers.
SQL Server
builder.Services.AddObserverSqlServer("Server=localhost;Database=mydb;...");
Uses sp_set_session_context / SESSION_CONTEXT() and T-SQL triggers.
Oracle
builder.Services.AddObserverOracle("Data Source=...;User Id=...;Password=...;");
Uses DBMS_SESSION.SET_CONTEXT / SYS_CONTEXT() and PL/SQL triggers.
MySQL
builder.Services.AddObserverMySql("Server=localhost;Database=mydb;...");
Uses @user_defined_variables and separate INSERT/UPDATE/DELETE triggers.
🛡️ Resilience (Polly)
Observer includes built-in Polly v8 resilience support for audit operations. When enabled, all audit store operations (DB writes and/or event publishing) are automatically wrapped with:
- Retry with exponential backoff + jitter
- Circuit Breaker (optional) to prevent cascading failures
builder.Services.AddObserver(options =>
{
options.Resilience.Enabled = true;
// Retry configuration
options.Resilience.MaxRetryAttempts = 3; // Default: 3
options.Resilience.BaseDelay = TimeSpan.FromMilliseconds(200); // Default: 200ms
options.Resilience.MaxDelay = TimeSpan.FromSeconds(5); // Default: 5s
// Circuit breaker (optional)
options.Resilience.EnableCircuitBreaker = true; // Default: false
options.Resilience.CircuitBreakerFailureThreshold = 0.5; // 50% failure rate
options.Resilience.CircuitBreakerSamplingDuration = TimeSpan.FromSeconds(30);
options.Resilience.CircuitBreakerBreakDuration = TimeSpan.FromSeconds(15);
options.Resilience.CircuitBreakerMinimumThroughput = 10;
});
The resilience pipeline is applied as a decorator around whatever IAuditStore is active (EfAuditStore, CompositeAuditStore), so it works with all delivery modes. Combined with IdempotencyKey, retries are fully safe — duplicate entries are automatically filtered out.
📡 Message Queue Integration (MassTransit)
Observer can publish audit events to message brokers like RabbitMQ, Kafka, or Azure Service Bus using MassTransit. You can choose to write to the database only, publish events only, or do both.
Install Packages
# Core + DB provider + MassTransit
dotnet add package Appouse.Observer
dotnet add package Appouse.Observer.PostgreSql # or SqlServer, Oracle, MySql
dotnet add package Appouse.Observer.MassTransit
# Optional: Transactional Outbox for reliable delivery
dotnet add package Appouse.Observer.MassTransit.Outbox
Delivery Modes
| Mode | Database | Event Bus | Use Case |
|---|---|---|---|
DatabaseOnly |
✅ | ❌ | Default. Local audit trail only |
EventBusOnly |
❌ | ✅ | Centralized audit via message broker |
Both |
✅ | ✅ | Local trail + real-time event streaming |
Example: DB + RabbitMQ
using Appouse.Observer.Extensions;
using Appouse.Observer.PostgreSql.Extensions;
using Appouse.Observer.MassTransit.Extensions;
using Appouse.Observer.Models;
var builder = WebApplication.CreateBuilder(args);
// 1. Observer core — write to both DB and event bus
builder.Services.AddObserver(options =>
{
options.DeliveryMode = AuditDeliveryMode.Both;
options.AutoCreateTables = true;
options.AutoCreateTriggers = true;
options.Resilience.Enabled = true; // Retry on failures
});
builder.Services.AddObserverPostgreSql(
builder.Configuration.GetConnectionString("DefaultConnection")!);
// 2. MassTransit with RabbitMQ
builder.Services.AddMassTransit(x =>
{
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("rabbitmq://localhost");
cfg.ConfigureEndpoints(context);
});
});
// 3. Connect Observer to MassTransit
builder.Services.AddObserverMassTransit();
Example: RabbitMQ with Transactional Outbox
For guaranteed delivery, use the Outbox pattern. Messages are stored in the database first and delivered by a background service:
using Appouse.Observer.MassTransit.Outbox.Extensions;
// 1. Observer core
builder.Services.AddObserver(options =>
{
options.DeliveryMode = AuditDeliveryMode.Both;
options.Resilience.Enabled = true;
});
builder.Services.AddObserverPostgreSql(connectionString);
// 2. MassTransit with RabbitMQ + EF Core Outbox
builder.Services.AddMassTransit(x =>
{
x.AddEntityFrameworkOutbox<AuditDbContext>(o =>
{
o.UsePostgres(); // or UseSqlServer(), UseMySql()
o.UseBusOutbox(); // Enable Bus Outbox for publish scope
});
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("rabbitmq://localhost");
cfg.ConfigureEndpoints(context);
});
});
// 3. Observer MassTransit + Outbox
builder.Services.AddObserverMassTransit();
builder.Services.AddObserverMassTransitOutbox();
Example: Event Bus Only (No DB)
builder.Services.AddObserver(options =>
{
options.DeliveryMode = AuditDeliveryMode.EventBusOnly;
});
builder.Services.AddMassTransit(x =>
{
x.UsingRabbitMq((context, cfg) => cfg.Host("rabbitmq://localhost"));
});
builder.Services.AddObserverMassTransit();
Example: Kafka
builder.Services.AddMassTransit(x =>
{
x.UsingInMemory();
x.AddRider(rider =>
{
rider.AddProducer<AuditLogCreatedEvent>("audit-events-topic");
rider.UsingKafka((ctx, k) => k.Host("localhost:9092"));
});
});
Consuming Audit Events
Any service can consume the published events:
public class AuditEventConsumer : IConsumer<AuditLogCreatedEvent>
{
public async Task Consume(ConsumeContext<AuditLogCreatedEvent> context)
{
var audit = context.Message;
// Use IdempotencyKey for deduplication
if (await _repo.ExistsAsync(audit.IdempotencyKey))
return;
Console.WriteLine($"[{audit.Operation}] {audit.SchemaName}.{audit.TableName} " +
$"by {audit.ApplicationUser} at {audit.TimestampUtc}");
foreach (var detail in audit.Details)
{
Console.WriteLine($" {detail.ColumnName}: {detail.OldValue} → {detail.NewValue}");
}
await _repo.SaveAsync(audit);
}
}
🔧 Custom Metadata Provider
Override the default metadata provider for custom scenarios:
public class CustomMetadataProvider : IAuditMetadataProvider
{
public string? GetApplicationUser() => "custom-user";
public string? GetMethodName() => "custom-method";
public string? GetActivityId() => Activity.Current?.Id;
public string? GetTraceIdentifier() => null;
public string? GetCorrelationId() => "custom-correlation";
public string? GetClientIp() => null;
public string? GetUserAgent() => null;
public IDictionary<string, string>? GetAdditionalData() =>
new Dictionary<string, string> { ["TenantId"] = "tenant-1" };
}
// Register before AddObserver
builder.Services.AddSingleton<IAuditMetadataProvider, CustomMetadataProvider>();
🏗️ Architecture
┌──────────────────────────────────────┐
│ AuditChangeCollector │ ← shared audit engine
│ CollectAuditEntries() / FlushAsync() │
└──────────┬───────────────┬────────────┘
│ │
┌────────────────▼───┐ ┌───────▼──────────────────────┐
│ SaveChanges │ │ ObserverDbContextBase<T> │
│ Interceptor │ │ (all EF Core versions) │
│ (.NET 5+ only) │ │ SaveChanges() override │
└────────────────────┘ └──────────────────────────────┘
│ │
▼ ▼
ResilientAuditStore (Polly)
│
CompositeAuditStore
│
┌──────────┴──────────┐
▼ ▼
EfAuditStore IAuditEventPublisher
(DB + Idempotency) (MassTransit Publish)
│
┌──────────────┴──────────────┐
▼ ▼
Direct Publish Outbox Pattern
(fire & forget) (transactional)
│ │
▼ ▼
┌─────────┐ ┌──────────────┐
│RabbitMQ │ │ DB Outbox │
│Kafka │ │ Table │
│Azure SB │ │ │ │
│InMemory │ │ ▼ │
└─────────┘ │ Delivery │
│ Service → MQ │
└──────────────┘
Direct SQL / Other Apps ──→ Database ──→ DB Triggers ──→ Audit Tables
- AuditChangeCollector: Shared engine that scans
ChangeTracker, builds audit entries, flushes to store. Used by both integration approaches. - SaveChangesInterceptor (.NET 5+): Hooks into EF Core's interceptor pipeline — no code changes to your DbContext.
- ObserverDbContextBase (all frameworks): Base class that overrides
SaveChanges/SaveChangesAsync— the only option on .NET Standard 2.0 / EF Core 3.x. - DB Triggers: Captures ALL changes (including raw SQL), reads session context set by the application layer.
- AuditContext: Scoped service for attaching custom fields, tags, and comments per request.
- CompositeAuditStore: Routes audit entries to DB, event bus, or both based on
DeliveryMode. - ResilientAuditStore: Polly decorator providing retry + circuit breaker around any store.
- IdempotencyKey: Guid assigned once, enforced with unique DB index, carried through events.
⬆️ Migrating from IAuditableEntity
If you previously used IAuditableEntity, it still works — the interface is treated as an implicit [AuditInclude]. However, it is now [Obsolete] and will be removed in a future major version.
// ❌ Old approach (still works but deprecated)
public class Product : IAuditableEntity
{
public int Id { get; set; }
public string Name { get; set; }
}
// ✅ New approach — OptOut mode (default): nothing needed
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
}
// ✅ New approach — OptIn mode: use [AuditInclude]
[AuditInclude]
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
}
📦 Build & Pack
# Build
dotnet build Appouse.Observer.sln -c Release
# Run tests
dotnet test Appouse.Observer.sln -c Release
# Pack all packages (or use pack.bat)
dotnet pack Appouse.Observer.sln -c Release -o ./artifacts/packages
# Or simply:
.\pack.bat
# Push to local feed
dotnet nuget push ./artifacts/packages/*.nupkg -s C:\LocalNuGetFeed
# Push to NuGet.org
dotnet nuget push ./artifacts/packages/*.nupkg -s https://api.nuget.org/v3/index.json -k YOUR_API_KEY
🎯 Framework Compatibility Matrix
| Feature | .NET Standard 2.0 | .NET 5 | .NET 6 | .NET 8 | .NET 9 |
|---|---|---|---|---|---|
| Core audit engine | ✅ | ✅ | ✅ | ✅ | ✅ |
ObserverDbContextBase<T> |
✅ | ✅ | ✅ | ✅ | ✅ |
SaveChangesInterceptor |
❌ | ✅ | ✅ | ✅ | ✅ |
AuditProtectionInterceptor |
❌ | ✅ | ✅ | ✅ | ✅ |
SqlQueryRaw<T> (table check) |
❌¹ | ❌¹ | ❌¹ | ✅ | ✅ |
| DB Triggers | ✅ | ✅ | ✅ | ✅ | ✅ |
| MassTransit integration | ✅ | ✅ | ✅ | ✅ | ✅ |
| MassTransit Outbox | ❌ | ❌ | ❌ | ✅ | ✅ |
| Polly resilience | ✅ | ✅ | ✅ | ✅ | ✅ |
| Retention service | ✅ | ✅ | ✅ | ✅ | ✅ |
¹ Falls back to ADO.NET DbCommand.ExecuteScalarAsync automatically.
EF Core & Provider Versions
| TFM | EF Core | Npgsql | SqlServer | Pomelo (MySQL) | Oracle |
|---|---|---|---|---|---|
netstandard2.0 |
3.1.32 | 3.1.18 | 3.1.32 | 3.2.7 | 3.19.180 |
net5.0 |
5.0.17 | 5.0.10 | 5.0.17 | 5.0.4 | 5.21.90 |
net6.0 |
6.0.36 | 6.0.29 | 6.0.36 | 6.0.3 | 6.21.90 |
net8.0 / net9.0 |
8.0.11 | 8.0.11 | 8.0.11 | 8.0.2 | 8.23.60 |
📁 Project Structure
Appouse.Observer/
├── src/
│ ├── Appouse.Observer/ # Core library (attributes, interceptor, models, Polly)
│ │ ├── Abstractions/ # Interfaces, attributes, contracts
│ │ ├── Compatibility/ # Polyfills for older frameworks
│ │ ├── Configuration/ # ObserverOptions, resilience options
│ │ ├── Extensions/ # DI extensions, ObserverDbContextBase
│ │ ├── Interceptors/ # AuditChangeCollector, SaveChangesInterceptor
│ │ ├── Models/ # AuditLogEntry, AuditLogDetail, enums
│ │ ├── Protection/ # AuditProtectionInterceptor
│ │ ├── Providers/ # DefaultAuditMetadataProvider
│ │ ├── Retention/ # RetentionHostedService
│ │ └── Store/ # AuditDbContext, EfAuditStore, ResilientAuditStore
│ ├── Appouse.Observer.PostgreSql/ # PostgreSQL provider
│ ├── Appouse.Observer.SqlServer/ # SQL Server provider
│ ├── Appouse.Observer.Oracle/ # Oracle provider
│ ├── Appouse.Observer.MySql/ # MySQL provider
│ ├── Appouse.Observer.MassTransit/ # MassTransit messaging
│ └── Appouse.Observer.MassTransit.Outbox/ # Transactional Outbox (net8.0+ only)
├── tests/
│ ├── Appouse.Observer.UnitTests/
│ └── Appouse.Observer.IntegrationTests/
├── samples/
│ └── Appouse.Observer.Sample/
├── docs/
│ └── ARCHITECTURE.md
└── pack.bat # NuGet package build script
📄 License
This project is licensed under the MIT License - see the LICENSE file for details.
🤝 Contributing
Contributions are welcome! Please open an issue or submit a pull request.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 is compatible. net5.0-windows was computed. net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. 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 was computed. 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. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- Microsoft.AspNetCore.Http.Abstractions (>= 2.2.0)
- Microsoft.EntityFrameworkCore (>= 3.1.32)
- Microsoft.EntityFrameworkCore.Relational (>= 3.1.32)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 3.1.32)
- Microsoft.Extensions.Hosting.Abstractions (>= 3.1.32)
- Microsoft.Extensions.Logging.Abstractions (>= 3.1.32)
- Microsoft.Extensions.Options (>= 3.1.32)
- Polly.Core (>= 8.6.6)
- System.Text.Json (>= 8.0.5)
-
net5.0
- Microsoft.AspNetCore.Http.Abstractions (>= 2.2.0)
- Microsoft.EntityFrameworkCore (>= 5.0.17)
- Microsoft.EntityFrameworkCore.Relational (>= 5.0.17)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 5.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 5.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 5.0.0)
- Microsoft.Extensions.Options (>= 5.0.0)
- Polly.Core (>= 8.6.6)
- System.Text.Json (>= 8.0.5)
-
net6.0
- Microsoft.AspNetCore.Http.Abstractions (>= 2.2.0)
- Microsoft.EntityFrameworkCore (>= 6.0.36)
- Microsoft.EntityFrameworkCore.Relational (>= 6.0.36)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 6.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 6.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 6.0.4)
- Microsoft.Extensions.Options (>= 6.0.1)
- Polly.Core (>= 8.6.6)
- System.Text.Json (>= 8.0.5)
-
net8.0
- Microsoft.AspNetCore.Http.Abstractions (>= 2.2.0)
- Microsoft.EntityFrameworkCore (>= 8.0.11)
- Microsoft.EntityFrameworkCore.Relational (>= 8.0.11)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Options (>= 8.0.2)
- Polly.Core (>= 8.6.6)
- System.Text.Json (>= 8.0.5)
-
net9.0
- Microsoft.AspNetCore.Http.Abstractions (>= 2.2.0)
- Microsoft.EntityFrameworkCore (>= 8.0.11)
- Microsoft.EntityFrameworkCore.Relational (>= 8.0.11)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Options (>= 8.0.2)
- Polly.Core (>= 8.6.6)
- System.Text.Json (>= 8.0.5)
NuGet packages (5)
Showing the top 5 NuGet packages that depend on Appouse.Observer:
| Package | Downloads |
|---|---|
|
Appouse.Observer.MassTransit
MassTransit integration for Appouse.Observer audit trail library. Publishes audit events to message brokers (RabbitMQ, Kafka, Azure Service Bus) with optional Transactional Outbox support. |
|
|
Appouse.Observer.MySql
MySQL provider for Appouse.Observer audit trail library. Provides automatic trigger creation and management for MySQL databases. |
|
|
Appouse.Observer.Oracle
Oracle provider for Appouse.Observer audit trail library. Provides automatic trigger creation and management for Oracle databases. |
|
|
Appouse.Observer.SqlServer
SQL Server provider for Appouse.Observer audit trail library. Provides automatic trigger creation and management for SQL Server databases. |
|
|
Appouse.Observer.PostgreSql
PostgreSQL provider for Appouse.Observer audit trail library. Provides automatic trigger creation and management for PostgreSQL databases. |
GitHub repositories
This package is not used by any popular GitHub repositories.