Lightweight, developer-friendly rule engine for .NET
RuleFlow provides a lightweight, optional observability layer that gives you runtime insights into rule execution without impacting performance or adding complexity.
Observability in RuleFlow enables you to:
Important: Observability is completely optional and has zero overhead when disabled. No observer calls, no Stopwatch allocations, no extra objects are created unless you opt-in.
The simplest way to get started is to enable observability with the built-in observer:
var options = new RuleExecutionOptions<Order>
{
EnableObservability = true,
EnableDetailedTiming = true // Optional: capture per-execution timing
};
var result = engine.Evaluate(order, rules, options);
// Metrics are now available
if (result.Metrics != null)
{
Console.WriteLine($"Evaluated: {result.Metrics.TotalRulesEvaluated} rules");
Console.WriteLine($"Matched: {result.Metrics.RulesMatched} rules");
Console.WriteLine($"Executed: {result.Metrics.ActionsExecuted} actions");
Console.WriteLine($"Traversed: {result.Metrics.GroupsTraversed} groups");
if (result.Metrics.TotalElapsedMilliseconds.HasValue)
{
Console.WriteLine($"Duration: {result.Metrics.TotalElapsedMilliseconds}ms");
}
}
| Metric | Description |
|---|---|
TotalRulesEvaluated |
Total number of rules evaluated (including filtered/skipped) |
RulesMatched |
Number of rules where the condition evaluated to true |
ActionsExecuted |
Total number of action steps executed across all matched rules |
GroupsTraversed |
Number of rule groups traversed during evaluation |
ExecutionStopped |
Whether execution stopped early (StopProcessing or StopOnFirstMatch) |
TotalElapsedMilliseconds |
Total execution time in milliseconds (only if EnableDetailedTiming is true) |
For more control, implement the IRuleObserver<T> interface to receive real-time callbacks during rule execution:
public class LoggingObserver : IRuleObserver<Order>
{
public void OnRuleEvaluating(RuleEvaluationContext<Order> context)
{
Console.WriteLine($"Evaluating rule: {context.RuleName}");
if (context.GroupPath != null)
{
Console.WriteLine($" Group: {context.GroupPath}");
}
}
public void OnRuleMatched(RuleMatchContext<Order> context)
{
Console.WriteLine($"âś“ Rule matched: {context.RuleName}");
if (context.Reason != null)
{
Console.WriteLine($" Reason: {context.Reason}");
}
}
public void OnRuleExecuted(RuleExecutionContext<Order> context)
{
Console.WriteLine($"Executed: {context.RuleName}");
Console.WriteLine($" Actions executed: {context.ActionsExecutedCount}");
if (context.TotalDuration.HasValue)
{
Console.WriteLine($" Duration: {context.TotalDuration.Value.TotalMilliseconds}ms");
}
}
public void OnExecutionCompleted(RuleExecutionSummary summary)
{
Console.WriteLine($"Execution complete:");
Console.WriteLine($" Rules matched: {summary.RulesMatched}/{summary.TotalRulesEvaluated}");
Console.WriteLine($" Actions executed: {summary.ActionsExecuted}");
Console.WriteLine($" Groups traversed: {summary.GroupsTraversed}");
}
}
// Use your custom observer
var observer = new LoggingObserver();
var options = new RuleExecutionOptions<Order>
{
EnableObservability = true,
Observer = observer
};
var result = engine.Evaluate(order, rules, options);
Observer callbacks are always invoked in a predictable sequence:
Rule 1 → Evaluating → (not matched)
Rule 2 → Evaluating → Matched → Executed
Rule 3 → Evaluating → (not matched)
...
OnExecutionCompleted (final summary)
Each observer callback receives a lightweight, immutable context:
public class RuleEvaluationContext<T>
{
public string RuleName { get; init; } // Name of the rule
public T Input { get; init; } // Input object being evaluated
public string? GroupPath { get; init; } // e.g., "Parent/Child" or null if root
public DateTime? StartTime { get; init; } // Evaluation start time (if detailed timing enabled)
}
public class RuleMatchContext<T>
{
public string RuleName { get; init; } // Name of the matched rule
public T Input { get; init; } // Input object
public string? GroupPath { get; init; } // Hierarchical group path
public string? Reason { get; init; } // Rule's reason (from Because())
public TimeSpan? DurationFromEvaluation { get; init; } // Time from evaluation start to match
}
public class RuleExecutionContext<T>
{
public string RuleName { get; init; } // Name of the executed rule
public T Input { get; init; } // Input object
public string? GroupPath { get; init; } // Hierarchical group path
public bool Executed { get; init; } // Whether actions executed successfully
public int ActionsExecutedCount { get; init; } // Number of action steps executed
public TimeSpan? TotalDuration { get; init; } // Total time for evaluation + execution
}
public class RuleExecutionSummary
{
public int TotalRulesEvaluated { get; set; }
public int RulesMatched { get; set; }
public int ActionsExecuted { get; set; }
public int GroupsTraversed { get; set; }
public bool ExecutionStopped { get; set; }
public TimeSpan? TotalExecutionTime { get; set; }
public RuleExecutionMetrics Metrics { get; init; }
}
Observability and explainability are separate concepts:
| Feature | Purpose | Overhead | When to Use |
|---|---|---|---|
| Observability | Real-time runtime insights, metrics, custom monitoring | Zero when disabled | Logging, monitoring, analytics, performance analysis |
| Explainability | Detailed hierarchical audit trail of rule execution | Always active by default | Understanding why rules matched, debugging |
You can use both together for comprehensive visibility into rule execution.
When EnableObservability is false (the default):
When EnableDetailedTiming is enabled:
If you need detailed per-rule timing, capture it in your custom observer’s callbacks.
Observer callbacks are wrapped in exception-safe guards. If an observer throws:
This ensures observability failures never break rule evaluation.
Observability works seamlessly with nested rule groups:
var rules = RuleSet.For<Order>("Main")
.Add(rootRule)
.AddGroup("Approval", g => g
.Add(approvalRule)
.AddGroup("Escalation", sub => sub
.Add(escalationRule)));
var options = new RuleExecutionOptions<Order>
{
EnableObservability = true
};
var result = engine.Evaluate(order, rules, options);
// Metrics show both rules and groups were traversed
Console.WriteLine($"Groups traversed: {result.Metrics!.GroupsTraversed}"); // 2
In observer callbacks, GroupPath shows the full hierarchical path:
GroupPath = nullGroupPath = "Approval"GroupPath = "Approval/Escalation"When observability is enabled, result.Metrics is populated and flows directly into the DebugMetrics property of RuleExecutionDebugView. This means a single call captures both runtime metrics and a structured execution snapshot:
var options = new RuleExecutionOptions<Order>
{
EnableObservability = true,
EnableDetailedTiming = true
};
var result = engine.Evaluate(order, rules, options);
// Human-readable tree (for logs / console)
Console.WriteLine(result.ToDebugString());
// Structured JSON (for dashboards / APIs / storage)
Console.WriteLine(result.ToDebugJson());
The JSON output includes a "metrics" section only when observability is enabled; it is omitted entirely (null → omitted via WhenWritingNull) otherwise.
See Explainability — Debug DTO and JSON for the full DTO shape reference.
When a rule has StopIfMatched() or StopOnFirstMatch is enabled, observability tracks this:
var options = new RuleExecutionOptions<Order>
{
EnableObservability = true
};
var result = engine.Evaluate(order, rules, options);
if (result.Metrics!.ExecutionStopped)
{
Console.WriteLine("Execution stopped early due to stop-processing rule");
}
Here’s a practical example of building a monitoring dashboard with observability:
public class MonitoringObserver : IRuleObserver<Order>
{
private readonly List<RuleExecutionRecord> _executions = new();
public void OnRuleEvaluating(RuleEvaluationContext<Order> context)
{
// Track rule evaluation start
}
public void OnRuleMatched(RuleMatchContext<Order> context)
{
// Log matched rules
Console.WriteLine($"📌 {context.RuleName} matched");
}
public void OnRuleExecuted(RuleExecutionContext<Order> context)
{
_executions.Add(new RuleExecutionRecord
{
RuleName = context.RuleName,
Executed = context.Executed,
Duration = context.TotalDuration,
ActionsCount = context.ActionsExecutedCount
});
}
public void OnExecutionCompleted(RuleExecutionSummary summary)
{
// Generate report
Console.WriteLine($"\n{'=',60}");
Console.WriteLine($"Execution Summary");
Console.WriteLine($"{'=',60}");
Console.WriteLine($"Total evaluated: {summary.TotalRulesEvaluated}");
Console.WriteLine($"Matched: {summary.RulesMatched}");
Console.WriteLine($"Duration: {summary.TotalExecutionTime?.TotalMilliseconds}ms");
foreach (var exec in _executions.Where(e => e.Executed))
{
Console.WriteLine($" âś“ {exec.RuleName} ({exec.Duration?.TotalMilliseconds}ms)");
}
}
private record RuleExecutionRecord(string RuleName, bool Executed, TimeSpan? Duration, int ActionsCount);
}
// Usage
var observer = new MonitoringObserver();
var options = new RuleExecutionOptions<Order>
{
EnableObservability = true,
EnableDetailedTiming = true,
Observer = observer
};
engine.Evaluate(order, rules, options);