Table of Contents

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.

Next

Host and reload rules.