Trace why a rule fired
"Why did this customer get the discount?" and "why didn't they?" are the questions a rule engine gets asked most. Turn on tracing for an evaluation and the result records every rule, every condition node's outcome, and every node that was never evaluated.
The rules
{
"name": "Traced checkout",
"rules": [
{
"id": "vip-discount",
"priority": 20,
"condition": {
"type": "group",
"operator": "AND",
"rules": [
{ "field": "Customer.Age", "operator": "GreaterThanOrEqual", "value": 18 },
{
"type": "group",
"operator": "OR",
"rules": [
{ "field": "Customer.IsVip", "operator": "Equals", "value": true },
{ "field": "Order.Total", "operator": "GreaterThanOrEqual", "value": 500 }
]
}
]
},
"actions": [ { "type": "setOutput", "target": "Discount", "value": 10 } ]
},
{
"id": "restricted-lines",
"priority": 10,
"condition": {
"field": "Order.Lines",
"operator": "Any",
"condition": { "field": "Category", "operator": "In", "value": ["alcohol", "tobacco"] }
},
"actions": [
{ "type": "setOutput", "target": "AgeCheck", "value": true }
]
},
{
"id": "retired",
"enabled": false,
"condition": { "field": "Order.Coupon", "operator": "IsNotNull" },
"actions": [ { "type": "setOutput", "target": "Legacy", "value": true } ]
}
]
}
A traced evaluation
using System.Text.RegularExpressions;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;
using RuleWright.Serialization;
LoadedRuleSet rules = engine.LoadRuleSet(File.ReadAllText("traced.json"));
RuleEvaluationResult result = engine.Evaluate(rules, Facts.Vip(), new EvaluationOptions { EnableTrace = true });
foreach (RuleTrace rule in result.Trace!.Rules)
{
if (rule.Skipped)
{
Console.WriteLine($"{rule.RuleId}: skipped ({rule.SkipReason})");
continue;
}
Console.WriteLine($"{rule.RuleId}: {(rule.Fired ? "FIRED" : "no match")}");
Print(rule.Condition!, indent: 1);
}
static void Print(ConditionTraceNode node, int indent)
{
string mark = node.Passed switch { true => "PASS", false => "FAIL", null => " - " };
Console.WriteLine($"{new string(' ', indent * 2)}{mark} {node.Description}");
foreach (ConditionTraceNode child in node.Children) // empty for a leaf
{
Print(child, indent + 1);
}
}
Output
vip-discount: FIRED
PASS AND
PASS Customer.Age GreaterThanOrEqual 18
PASS OR
PASS Customer.IsVip Equals true
- Order.Total GreaterThanOrEqual 500
restricted-lines: FIRED
PASS Order.Lines Any (Category In ["alcohol", "tobacco"])
retired: skipped (Disabled)
Reading a trace
result.Trace is null unless you asked for it. When you did, Trace.Rules has one RuleTrace per rule, in evaluation order:
| Member | Meaning |
|---|---|
RuleId |
The rule. |
Fired |
Whether its condition held. |
Skipped, SkipReason |
Disabled for a rule with enabled: false; StoppedAfterMatch for a rule after the match that stopped evaluation. |
Condition |
The root ConditionTraceNode, or null for a skipped rule. |
And each ConditionTraceNode has:
| Member | Meaning |
|---|---|
Description |
The node in words: AND, Customer.Age GreaterThanOrEqual 18, or a quantifier with its element condition. |
Passed |
true, false, or null when the node was never evaluated because its group had already decided. |
Children |
The children of a group; empty for a leaf. |
Order.Total GreaterThanOrEqual 500 shows - above: the OR had already passed on IsVip, so it was never evaluated.
Short circuits and stops
using System.Text.RegularExpressions;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Json.SystemText;
using RuleWright.Serialization;
// Ben is 17: the AND fails on its first child, so the OR is never evaluated.
// And with StopOnFirstMatch, a rule after the first match is never reached.
var options = new EvaluationOptions { EnableTrace = true, StopOnFirstMatch = true };
foreach (Checkout checkout in new[] { Facts.Newcomer(), Facts.Vip() })
{
RuleEvaluationResult result = engine.Evaluate(rules, checkout, options);
Console.WriteLine($"{checkout.Customer.Name}:");
foreach (RuleTrace rule in result.Trace!.Rules)
{
string state = rule.Skipped ? $"skipped ({rule.SkipReason})" : rule.Fired ? "FIRED" : "no match";
Console.WriteLine($" {rule.RuleId,-17} {state}");
if (rule.Condition is { Children.Count: > 0 } group)
{
foreach (ConditionTraceNode child in group.Children)
{
Console.WriteLine($" {child.Passed?.ToString() ?? "not evaluated",-14} {child.Description}");
}
}
}
}
Output
Ben:
vip-discount no match
False Customer.Age GreaterThanOrEqual 18
not evaluated OR
restricted-lines no match
retired skipped (Disabled)
Aroha:
vip-discount FIRED
True Customer.Age GreaterThanOrEqual 18
True OR
restricted-lines skipped (StoppedAfterMatch)
retired skipped (Disabled)
Ben fails the age check, so the OR is never evaluated. For Aroha, StopOnFirstMatch ends the evaluation after vip-discount, so restricted-lines is skipped with StoppedAfterMatch, which is different from being disabled.
What tracing costs
Nothing when it's off. Each rule compiles to two delegates: a plain one, and a traced one that also records each node's outcome. EnableTrace picks which to call, so an untraced evaluation never checks a flag. A traced evaluation is roughly three times slower and allocates the trace, so turn it on per request (for a support tool, a "why?" button, or a sample of traffic), not for everything.
Tracing in production
- Log the decision, not the fact. A trace describes rules and outcomes; it never contains your fact's values. Log it alongside an id you can join to the data.
- A quantifier is one node. The element condition appears in its description, not as one result per element.
- Decision tables trace as rules. Each row is a rule
<table id>-<row>, so a trace tells you which row matched. See the fraud scoring tutorial.