Observer.Auditing.Contract
1.0.1
dotnet add package Observer.Auditing.Contract --version 1.0.1
NuGet\Install-Package Observer.Auditing.Contract -Version 1.0.1
<PackageReference Include="Observer.Auditing.Contract" Version="1.0.1" />
<PackageVersion Include="Observer.Auditing.Contract" Version="1.0.1" />
<PackageReference Include="Observer.Auditing.Contract" />
paket add Observer.Auditing.Contract --version 1.0.1
#r "nuget: Observer.Auditing.Contract, 1.0.1"
#:package Observer.Auditing.Contract@1.0.1
#addin nuget:?package=Observer.Auditing.Contract&version=1.0.1
#tool nuget:?package=Observer.Auditing.Contract&version=1.0.1
Observer.Auditing.Contract
Drop-in change auditing for any .NET service. Captures every insert/update/delete you opt into, stores it locally in the same transaction as the business change, and ships it to the Audit service over gRPC in the background.
No message broker. No added request latency. No lost records if the audit service is down.
handler saves → interceptor writes an outbox row in the SAME transaction
(request returns here — audit cost ≈ one local INSERT)
background dispatcher → gRPC RecordAuditBatch → audit service stores it
background registrar → gRPC RegisterAuditableEntities → registry rows created for you
Two packages:
| Package | Reference from | Contains |
|---|---|---|
ObserverTech.Auditing.Abstractions |
Domain / Shared / Application | IAuditableEntity, IAuditScope, [NoAudit], the enums. Zero dependencies. |
Observer.Auditing.Contract |
Infrastructure | Interceptor, outbox, dispatcher, gRPC transport |
Setup — five steps
1. Mark your entity base
public abstract class Entity : IAuditableEntity
{
public Guid ObjectKey { get; set; } = Guid.NewGuid(); // must be CLR-generated
public bool IsDeleted { get; set; }
}
2. Map and migrate the outbox
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.AddAuditOutbox(); // creates Audit.AuditOutbox
}
dotnet ef migrations add AddAuditOutbox
If you apply a global query filter by looping over every mapped type, skip this one — it has no
IsDeletedcolumn.
3. Register, and declare what you audit — in code
services.AddAuditing<ApplicationDbContext>(configuration, audit =>
{
audit.Audit<Area>();
audit.Audit<DeviceOwner>().Ignoring(owner => owner.NationalId);
audit.Audit<Device>();
});
services.AddDbContext<ApplicationDbContext>((serviceProvider, options) => options
.UseNpgsql(connectionString)
.AddAuditCapture(serviceProvider)); // no-op when disabled
AddAuditing must come first. A type argument can't be misspelled and survives renames — a config
string can't do either.
4. Tell it who the user is
internal sealed class AuditActorProvider(IUserContext user) : IAuditActorProvider
{
public Guid UserId => Guid.TryParse(user.UserId, out Guid id) ? id : Guid.Empty;
public string? DisplayName => user.DisplayName;
}
services.AddScoped<IAuditActorProvider, AuditActorProvider>(); // before AddAuditing
Skip this and every change is attributed to the system actor. Optionally add
IAuditCorrelationProvider to carry your request correlation id.
5. Configure
"AuditService": {
"Enabled": true,
"GrpcAddress": "http://127.0.0.1:5327", // the audit service's h2c port, NOT its REST port
"SourceService": "AMS.AVL.Core" // must match your registry rows
}
That's it. On startup the service announces its entities and the registry rows are created automatically — no SQL.
Declaring business intent
Only when a column diff can't express it:
_auditScope.Set(AuditBusinessAction.Deactivate);
await _unitOfWork.SaveChangesAsync();
Create, Update and Delete are inferred. A soft delete (IsDeleted false → true) is detected as
Delete automatically.
Redacting — two levels
| Value stored | Change recorded | Use for | |
|---|---|---|---|
.Ignoring(x => x.NationalId) |
❌ | ✅ name only | Personal data you must prove was altered |
[NoAudit] on the property |
❌ | ❌ | Secrets — tokens, credentials, SIM numbers |
A save that only touches an Ignoring property is recorded, naming it. A save that only touches a
[NoAudit] property records nothing — the entry would betray the change by existing.
Configuration reference
| Key | Default | What it does |
|---|---|---|
Enabled |
false |
Kill switch. Nothing is registered when off. |
GrpcAddress |
— | Audit service h2c endpoint. Required. |
SourceService |
— | Stamped on every record. Required. |
PollInterval |
5s |
How often the outbox drains |
BatchSize |
200 |
Rows per batch (server max 500) |
SendTimeout |
30s |
Per-call gRPC deadline |
MaxValueLength |
4000 |
Per snapshot value, then truncated |
RunDispatcher |
true |
Set false on all but one replica |
OutboxRetention |
7d |
Prune delivered rows after this. null = keep forever |
RetentionSweepInterval |
1h |
How often pruning runs |
SelfRegister |
true |
Announce entities at startup |
RegistryRefreshInterval |
5m |
Re-announce + refresh policy |
Aggregates |
[] |
Legacy string allowlist. Prefer the typed declaration. |
DisabledAggregates |
[] |
Mute an entity in this environment. Narrowing only. |
Outbox retention
The outbox is a queue, not a second copy of the trail. Delivered rows are pruned after
OutboxRetention. Pending and dead-lettered rows are never pruned — they're changes with no
compliance record yet.
Scaling out
Rows drain in Id order, which preserves per-aggregate order only while one dispatcher runs. On
multiple replicas set RunDispatcher: false on all but one. Two dispatchers racing is safe, just
wasteful.
Monitoring
| Signal | Query | Alert when |
|---|---|---|
| Backlog | count(*) WHERE "SentAt" IS NULL |
> 10k, or oldest > 15 min |
| Poison rows | count(*) WHERE "LastError" IS NOT NULL |
any — capture produced an unstorable payload |
| Coverage drift | in AuditDB: WHERE "IsRegistered" = false |
any — a registry row is missing |
Troubleshooting
| Symptom | Cause |
|---|---|
Outbox grows, LastError null |
Can't reach the audit service. Check GrpcAddress is the h2c port. |
Audit service logs 307 for gRPC calls |
UseHttpsRedirection on the gRPC port. Remove it — gRPC clients don't follow redirects. |
HTTP_1_1_REQUIRED |
gRPC pointed at an HTTP/1.1 port. h2c needs its own port. |
| Startup warns "aggregates that do not exist" | Typo in Aggregates, or a renamed class. |
| Everything recorded as changed, snapshots identical | The repository calls DbSet.Update on a tracked entity, discarding EF change detection. |
ChangedByName is the empty GUID |
No IAuditActorProvider registered, or the call was unauthenticated. |
Known gaps
- No service-account credential. The dispatcher runs with no
HttpContext; it needs client-credentials or mTLS before any non-local deployment. - Protos are vendored, not packaged.
Scripts/verify-proto-parity.ps1fails on drift.
Build
dotnet build Observer.Auditing.Contract.slnx
dotnet pack Observer.Auditing.Contract.slnx -c Release -o nupkg
pwsh Observer.Auditing.Contract/Scripts/verify-proto-parity.ps1
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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
- Google.Protobuf (>= 3.35.1)
- Grpc.Net.ClientFactory (>= 2.80.0)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.9)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.9)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.