Audit Settings

AuditSettings decides two things: how a record gets into the database, and what a record holds. Every setting has a default that suits most applications, so you set only what you want otherwise. Pass the settings to the recorder's constructor:

var audit = new AuditRecorder(dbProvider, new AuditSettings
{
    JournalFolder = "/var/lib/myapp/audit-journal",
    OnProblem = problem => logger.LogWarning(problem.Message)
});

The recorder reads its settings once, when it is created: changing them afterwards changes nothing, except Enabled and OnProblem, which are read every time they are used. Numbers it cannot work with are refused when the recorder is created, with CE20008; an empty journal folder with the journal on is refused with CE20001.

All Settings

Setting Default What it decides
Mode Asynchronous Whether an evaluation waits for its record to be in the database.
Resilience Journal In asynchronous mode, how much of what is waiting survives the process going away.
ThrowOnStoreFailure false In synchronous mode, whether an evaluation whose record could not be written fails with an AuditException.
JournalFolder codeeffects-audit in the temp folder Where the journal files are written.
JournalCapMegabytes 1024 How large the journal may grow while the database is out of reach.
JournalSegmentMegabytes 64 The size at which the journal starts a new file.
JournalFlush ToOperatingSystem How far each journal write goes before the evaluation returns.
QueueCapacity 100,000 How many records the in-memory queue holds (Volatile and Drain).
WhenFull Drop What an evaluation does when the queue or the journal is full.
BlockTimeoutMilliseconds 100 How long an evaluation waits for room with BlockWithTimeout.
BatchSize 500 The most records written to the database in one transaction.
DrainTimeoutSeconds 30 How long Dispose(), Flush() and a Drain process exit wait for records to reach the database.
RetryDelayMilliseconds 1000 How long to wait before trying a refused batch again; the wait doubles on each failure.
MaxRetryDelaySeconds 60 The longest wait between tries.
RecordValues Read Which of the evaluated object's values a record holds.
ExcludedMembers empty Members never recorded.
MaxCollectionItems 100 With AllFields, how many items of each collection are recorded.
AnalyticsFields empty Members kept as analytics values (see Analytics Fields).
Enabled true Whether anything is recorded.
OnProblem null How the recorder tells you something went wrong (see Problems and Monitoring).

How Records Reach the Database

The choice is between the cost on each evaluation and what can be lost when something fails. Four combinations of Mode and Resilience are possible:

On the evaluating thread A process crash or kill loses A power loss loses
Asynchronous + Journal (the default) The record is built and appended to a local journal file Nothing The last moment of writes, as with any file; nothing with JournalFlush = ToDisk
Asynchronous + Drain The record is put on an in-memory queue What was queued, except on a normal exit or an unhandled exception, when the queue is drained for up to DrainTimeoutSeconds What was queued
Asynchronous + Volatile As with Drain What was queued, unless you called Flush() or Dispose() first What was queued
Synchronous The record is written to the database before the evaluation returns Nothing: nothing is held Nothing

Asynchronous with a Journal (the Default)

Every record is appended to a file in JournalFolder before the evaluation returns, and a background thread moves the records into the database in batches. This costs some tens of microseconds per evaluation and survives anything that happens to the process afterwards. If the database is down, records wait in the journal and are moved when it is back; the background thread retries with a growing delay (RetryDelayMilliseconds, doubling up to MaxRetryDelaySeconds) and reports each failure as a DrainFailed problem.

What you need to know about the journal folder:

  • It must be writable by the account your application runs as.
  • It must survive restarts. On a container's throwaway disk, mount a volume for it, or records not yet moved are lost with the container.
  • Several processes may share it (the instances of a web farm on one machine, an application pool's overlapping recycle). Each process writes its own files, and one process at a time moves them. A process that ends leaves its files for the others or for the next process on the folder.
  • Each Rule Repository database needs its own folder. A folder is moved into the database of the recorder moving it.
  • JournalSegmentMegabytes is the size of each file; a file is deleted once every record in it is in the database.
  • JournalCapMegabytes limits the folder while the database is unreachable for a long time. When it is reached, WhenFull applies and a JournalFull problem is reported.
  • JournalFlush: ToOperatingSystem (the default) hands each record to the operating system, which survives a process crash; ToDisk writes it through to the disk, which survives a power loss as well, at the cost of a disk flush (usually a millisecond or more) on every evaluation.
  • Every record is written once. Each record gets its ID when it is journaled, and the database takes an ID only once, so a batch sent again after a crash creates no duplicates.

Asynchronous with an In-Memory Queue (Drain or Volatile)

Records wait in memory and are built by the background thread rather than the evaluating one, which makes this the cheapest option on the evaluating thread. Use it where a few records lost in a crash are acceptable. Drain moves what is queued when the process exits normally or on an unhandled exception; Volatile does not, so call Flush() or Dispose() before the process ends. A process that is killed or loses power loses what was queued either way. QueueCapacity limits the queue.

Synchronous

The record, with the production counters, is written to the database on the evaluating thread before Evaluate(..) returns. Nothing is held anywhere, and every evaluation costs a database round trip. If the write fails, the problem is reported as StoreFailed and the evaluation returns its result anyway, unless ThrowOnStoreFailure is true: then the evaluation fails with an AuditException, whose inner exception is the database's own. Use that for decisions that must not be acted on unless they are recorded. A rule that throws always throws its own exception, never an AuditException.

// A strict audit: no decision is acted on unless it is recorded
var strict = new AuditSettings
{
    Mode = AuditMode.Synchronous,
    ThrowOnStoreFailure = true
};

Resilience, the journal and queue settings and WhenFull are ignored in synchronous mode.

When the Database Falls Behind

The queue and the journal fill up only while the database is slower than your evaluations or out of reach. WhenFull decides what an evaluation does then:

  • Drop (the default): the record is dropped and the evaluation goes on at once. The problem is reported once (QueueFull or JournalFull) until there is room again, and the drops are counted in Statistics.Dropped.
  • Block: the evaluation waits for room, however long it takes. Nothing is lost, and evaluations run at the database's pace.
  • BlockWithTimeout: the evaluation waits up to BlockTimeoutMilliseconds, then the record is dropped.

BatchSize is the most records written in one transaction. The background thread takes whatever is waiting, up to that many, so batches grow by themselves when the database is the slower side.

What a Record Holds

The Values Read, or Every Field

RecordValues decides which values of the evaluated object a record keeps:

  • AuditValues.Read (the default): only the values the engine read, taken as it read them. That is everything an auditor needs to see why the decision was made, and everything a replay needs to take the same path. Recording reads nothing the engine did not.
  • AuditValues.AllFields: also every field the source version declares (fields, collections and their items' fields, never methods), taken before the rule runs. The record then holds the whole case, the Evaluation dialog shows the fields the rule did not read marked as such, and a test saved from the evaluation can run any rule. Audit calls getters the rule did not, on the evaluating thread: do not use it for sources whose properties load data lazily (such as Entity Framework navigation properties) or are expensive to read. A getter that throws is recorded as that member's error; the evaluation goes on. Each collection is cut at MaxCollectionItems items, and its real size is recorded too, so the record shows the cut.

What the branch that ran set through its setters is recorded in both cases.

Members Never Recorded

ExcludedMembers lists members, by their path from the evaluated object, that no record may keep: personal data, secrets, anything your data-protection policy keeps out of logs.

var settings = new AuditSettings
{
    RecordValues = AuditValues.AllFields,
    MaxCollectionItems = 50,
    ExcludedMembers = { "Customer.TaxId", "Lines.Price" }
};
  • "Customer.TaxId" excludes one field; "Lines.Price" excludes the field in every item of the collection; "Customer" excludes the whole object and everything in it.
  • Excluded members are kept out of the values read, the other fields and the setters' values alike. An action's argument that reads one is recorded as excluded.
  • A method's return value is recorded whatever it was computed from. If a method reads an excluded member and returns it, the return value is recorded.
  • A replay reads an excluded member as unset, so the Evaluation dialog's replay may take another path than production did. The record lists the excluded paths, so AI agents reading it say "not recorded" rather than "empty".

Turning Recording Off

Enabled = false stops recording: evaluations run as without Audit. It is read on every evaluation, so you can switch it at run time through the recorder's Settings:

audit.Settings.Enabled = false;   // evaluations run as without Audit

Examples

// A container: the journal on a mounted volume, written through to the disk
var container = new AuditSettings
{
    JournalFolder = "/var/lib/myapp/audit-journal",
    JournalFlush = AuditJournalFlush.ToDisk,
    JournalCapMegabytes = 4096
};

// High volume, where a few records lost in a crash are acceptable
var highVolume = new AuditSettings
{
    Resilience = AuditResilience.Drain,
    QueueCapacity = 250000,
    WhenFull = AuditWhenFull.BlockWithTimeout,
    BlockTimeoutMilliseconds = 50,
    BatchSize = 1000
};

Ask on Stack Overflow - Yes, it's still active
Ask in GitHub Discussions

p102

l097 --

l102

p101

×