Core Processing Deep Dive
This document provides a detailed technical analysis of the RuleEngine class, the central processing unit for all rule-based event transformation.
RuleEngine Singleton Class
Location: /lib/ruleEngine/core/ruleEngine.ts
Lines: 1349 total, with core processing in lines 277-576
Class Structure
export class RuleEngine {
private static instance: RuleEngine;
// Rule storage per workspace
private workspaceRules: Map<string, Rule[]> = new Map();
private workspaceVariables: Map<string, Variable[]> = new Map();
private globalVariables: Variable[] = [];
// Processing subsystems
private triggerMatcher: TriggerMatcher;
private conditionEvaluator: ConditionEvaluator;
private actionExecutor: ActionExecutor;
private variableResolver: VariableResolver;
private debugBroadcaster: RuleEngineDebugBroadcaster;
// State tracking
private isInitialized: boolean = false;
private debugMode: boolean = false;
private ruleExecutionTracker: Map<string, number> = new Map();
// Infinite loop prevention
private eventProcessingDepth: number = 0;
private readonly MAX_RECURSION_DEPTH = 10;
}Design decision: Singleton pattern ensures:
- Single source of truth for execution counts
- Centralized debug mode management
- Shared trigger matcher indices across all workspaces
- Single debug broadcaster instance
Workspace Isolation Design
Why Events Are Cloned Per Workspace
Problem: Multiple workspaces must process the same event independently.
Example scenario:
- Workspace A has a rule that changes
event.data.pricefrom"$99.99"to99.99(string to number) - Workspace B needs to see the original
"$99.99"string value - Without cloning, Workspace A's modification would affect Workspace B's processing
Solution: Deep clone event once per workspace (line 329):
for (const workspaceId of allWorkspaceIds) {
const workspaceEventClone = deepClone(event); // CRITICAL: Use a clone for each workspace
const rules = this.workspaceRules.get(workspaceId) || [];
// ... process rules for this workspace
}Performance consideration:
- Cloning has O(m) cost where m = event object size
- Trade-off: Correctness over performance
- Optimization: Only clone once per workspace, not per rule
- Typical event size: 1-5KB, clone time: <1ms
Deep Clone Implementation
Uses structured clone algorithm via deepClone() utility:
Location: /lib/ruleEngine/utils/objectUtils.ts
Handles:
- Nested objects
- Arrays
- Date objects
- Null/undefined
- Circular references (breaks them)
Avoids issues with:
JSON.parse(JSON.stringify())- loses Date objects, functions- Shallow spread
{...obj}- doesn't clone nested objects
Event Routing with suppressWorkspaces Array
The Routing Contract
interface ProcessedEvent extends ClientEvent {
routing?: {
workspaceId?: string[]; // Explicit targets (empty = all)
suppressWorkspaces?: string[]; // Exclusions
externalProviders?: string[]; // Third-party targets
}
}Routing Semantics
Empty workspaceId array:
routing: {
workspaceId: [], // "All workspaces..."
suppressWorkspaces: ["ws-2", "ws-3"] // "...except ws-2 and ws-3"
}Populated workspaceId array:
routing: {
workspaceId: ["ws-1"], // "Only ws-1" (suppressWorkspaces ignored)
}suppressWorkspaces Accumulation
Location: Lines 515-536
// Accumulate suppressions from all workspaces
const combinedSuppressedWorkspaces = [...new Set([
...suppressedWorkspaces, // From suppressEvent actions
...modifyingWorkspaces // From modifyEvent actions (implicit suppression)
])];
// Create original event with routing
const originalEventWithRouting: ProcessedEvent = {
...event, // Pristine original
routing: {
...event.routing,
workspaceId: [] // Empty = all workspaces
}
};
// Add suppressWorkspaces if any
if (combinedSuppressedWorkspaces.length > 0) {
originalEventWithRouting.routing!.suppressWorkspaces = combinedSuppressedWorkspaces;
}
finalResults.push(originalEventWithRouting);Key insight: modifyEvent actions implicitly suppress the original event from that workspace, because the workspace receives the modified version instead.
Rule Registration and Priority Sorting
Registration Flow
Location: Lines 156-186
public registerRules(rules: Rule[], workspaceId: string): void {
// 1. Filter and sort
const enabledRules = rules
.filter(rule => rule.enabled)
.sort((a, b) => (a.priority || 999) - (b.priority || 999));
// 2. Store in workspace map
this.workspaceRules.set(workspaceId, enabledRules);
// 3. Index triggers for fast matching
enabledRules.forEach(rule => {
rule.triggers.forEach(trigger => {
this.triggerMatcher.registerTrigger(rule.id, trigger);
// 4. Start background monitors if needed
if (trigger.type !== "oneTrackEvent" && this.isInitialized) {
startMonitor(trigger.type, this);
}
});
});
// 5. Broadcast configuration to debug console
const configInfo: RuleConfigurationDebugInfo = {
workspaceId,
rules: enabledRules,
timestamp: Date.now(),
source: "registerRules"
};
this.debugBroadcaster.broadcastConfiguration(configInfo);
}Priority Sorting Behavior
Default priority: 999 (lowest priority)
Sorting:
.sort((a, b) => (a.priority || 999) - (b.priority || 999))Examples:
- Rule with
priority: 1executes beforepriority: 10 - Rule with
priority: 50executes before rule with no priority (999) - Two rules with same priority maintain insertion order (stable sort)
Why it matters:
- Sequential modifications: Earlier rules' changes visible to later rules
- Suppression: First matching suppress rule prevents all subsequent rules
- Execution limits: Priority determines which rule "wins" if both hit limits
processEvent() Flow
Method Signature
public processEvent(event: ClientEvent): ProcessedEvent[]Input: Single ClientEvent (never modified) Output: Array of ProcessedEvent (0 to N events)
Detailed Flow Breakdown
Phase 1: Validation (Lines 278-293)
const startTime = performance.now();
const eventId = event.clientSideId || ErrorContextBuilder.generateEventId();
// Infinite loop prevention
this.eventProcessingDepth++;
if (this.eventProcessingDepth > this.MAX_RECURSION_DEPTH) {
console.error(`Maximum recursion depth exceeded`);
this.eventProcessingDepth--;
return [event]; // Safe fallback
}Purpose: Prevent infinite loops where rules fire events that trigger themselves.
Max depth: 10 levels
Behavior on overflow: Return original event, log error, decrement counter.
Phase 2: Initialization (Lines 303-313)
const allWorkspaceIds = Array.from(this.workspaceRules.keys());
// Fast path: No rules registered
if (allWorkspaceIds.length === 0) {
return [{
...event,
routing: {
...event.routing,
workspaceId: [] // Route to all by default
}
}];
}Optimization: Skip processing if no rules exist.
Phase 3: State Tracking Setup (Lines 315-325)
const newlyFiredEvents: ProcessedEvent[] = []; // From fireEvent actions
const modifiedEvents: ProcessedEvent[] = []; // From modifyEvent actions
const suppressedWorkspaces: string[] = []; // Suppression accumulator
const modifyingWorkspaces: string[] = []; // Modification accumulator
let eventSuppressedOrModifiedInAllWorkspaces = true; // Track original event fatePhase 4: Workspace Processing Loop (Lines 328-510)
for (const workspaceId of allWorkspaceIds) {
// 1. Clone event for workspace isolation
const workspaceEventClone = deepClone(event);
const rules = this.workspaceRules.get(workspaceId) || [];
// 2. Create execution context
const context = this.createExecutionContext(workspaceEventClone, workspaceId);
// 3. Check execution limits BEFORE matching
let ruleHitExecutionLimit = false;
for (const rule of rules) {
const shouldSuppress = rule.executionLimit?.suppressOnLimitReached !== false;
if (rule.executionLimit && shouldSuppress) {
if (!this.canRuleExecute(rule) && this.wouldRuleMatch(rule, context)) {
console.log(`Rule hit execution limit - suppressing event`);
ruleHitExecutionLimit = true;
suppressedWorkspaces.push(workspaceId);
break;
}
}
}
if (ruleHitExecutionLimit) continue;
// 4. Find matching rules
const matchingRules = this.findMatchingRules(rules, context)
.filter(rule => this.canRuleExecute(rule));
if (matchingRules.length === 0) {
eventSuppressedOrModifiedInAllWorkspaces = false;
continue;
}
// 5. Execute matching rules
let hasModification = false;
let hasSuppression = false;
for (const rule of matchingRules) {
// Resolve rule-scoped variables
if (rule.variables) {
this.resolveRuleVariables(rule.variables, context);
}
// Execute actions
const actionResult = this.actionExecutor.execute(rule.actions, context);
// Track suppression
if (actionResult.suppressOriginal) {
hasSuppression = true;
if (!suppressedWorkspaces.includes(workspaceId)) {
suppressedWorkspaces.push(workspaceId);
}
}
// Collect new events
if (actionResult.newEvents && actionResult.newEvents.length > 0) {
const routedNewEvents = actionResult.newEvents.map(e => {
if (!e.routing || (!e.routing.workspaceId && !e.routing.externalProviders)) {
return { ...e, routing: { workspaceId: [workspaceId] } };
}
return e;
});
newlyFiredEvents.push(...routedNewEvents);
}
// Update context with modification
if (actionResult.modifiedEvent) {
context.event = actionResult.modifiedEvent;
hasModification = true;
}
// Record execution for limit tracking
this.recordRuleExecution(rule);
}
// 6. Track original event fate
if (!hasSuppression && !hasModification) {
eventSuppressedOrModifiedInAllWorkspaces = false;
}
// 7. Collect final modified event for this workspace
if (hasModification && !hasSuppression) {
const finalModifiedEvent = context.event;
const routingConfig = finalModifiedEvent.routing || { workspaceId: [workspaceId] };
modifiedEvents.push({ ...finalModifiedEvent, routing: routingConfig });
if (!modifyingWorkspaces.includes(workspaceId)) {
modifyingWorkspaces.push(workspaceId);
}
}
}Key design points:
- Execution limit check happens BEFORE rule matching - optimization to skip expensive matching when limit reached
- Context.event is mutable within workspace - allows sequential modifications by multiple rules
- Each workspace accumulates modifications - final version after all rules includes all changes
Phase 5: Consolidation (Lines 512-542)
const finalResults: ProcessedEvent[] = [];
// 1. Original event with suppressWorkspaces
const combinedSuppressedWorkspaces = [...new Set([
...suppressedWorkspaces,
...modifyingWorkspaces
])];
if (combinedSuppressedWorkspaces.length > 0 || !eventSuppressedOrModifiedInAllWorkspaces) {
const originalEventWithRouting: ProcessedEvent = {
...event, // Pristine original
routing: {
...event.routing,
workspaceId: []
}
};
if (combinedSuppressedWorkspaces.length > 0) {
originalEventWithRouting.routing!.suppressWorkspaces = combinedSuppressedWorkspaces;
}
finalResults.push(originalEventWithRouting);
}
// 2. All modified events
finalResults.push(...modifiedEvents);
// 3. All newly fired events
finalResults.push(...newlyFiredEvents);
this.eventProcessingDepth--;
return finalResults;Result scenarios:
| Scenario | Result |
|---|---|
| No matching rules | Original event, workspaceId: [] |
| All workspaces suppress | Empty suppressWorkspaces array would exclude all, so original event not included |
| Workspace A suppresses | Original event with suppressWorkspaces: ["A"] |
| Workspace A modifies | Original event with suppressWorkspaces: ["A"] + modified event for A |
| Workspace A fires new event | Original event + new event routed to A |
| Mixed behavior | Original event + all modifications + all new events |
Execution Limits and Tracking
Data Structure
// Maps ruleId → execution count (page scope only)
private ruleExecutionTracker: Map<string, number> = new Map();Can Execute Check (Lines 1227-1237)
private canRuleExecute(rule: Rule): boolean {
if (!rule.executionLimit) return true;
const { maxExecutions } = rule.executionLimit;
if (maxExecutions === undefined) return true;
const execCount = this.ruleExecutionTracker.get(rule.id) || 0;
return execCount < maxExecutions;
}Recording Execution (Lines 1242-1250)
private recordRuleExecution(rule: Rule): void {
if (!rule.executionLimit) return;
const execCount = this.ruleExecutionTracker.get(rule.id) || 0;
this.ruleExecutionTracker.set(rule.id, execCount + 1);
const { maxExecutions = Infinity } = rule.executionLimit;
console.log(`Recorded execution for rule "${rule.name}" (${execCount + 1}/${maxExecutions})`);
}Would Rule Match (Lines 1254-1275)
Checks if a rule would match without executing it - used for execution limit suppression:
private wouldRuleMatch(rule: Rule, context: ExecutionContext): boolean {
// Check triggers
for (const trigger of rule.triggers) {
const triggerMatches = this.evaluateSingleTrigger(trigger, context.event);
if (triggerMatches) {
// Migrate to conditionGroups format
const migratedRule = migrateRuleConditions(rule);
// Evaluate conditions
const conditionsPass = this.conditionEvaluator.evaluateConditionGroups(
migratedRule.conditionGroups || [],
context
);
return conditionsPass;
}
}
return false;
}Why separate from normal matching: Execution limit check happens BEFORE creating execution context, so needs standalone evaluation.
Execution Context Creation
Location: Lines 1040-1067
private createExecutionContext(
event: ClientEvent,
workspaceId: string
): ExecutionContext {
const context: ExecutionContext = {
event,
workspaceId,
variables: new Map(),
metrics: { startTime: performance.now() }
};
// Initialize system variables (event, timestamp, page, device, browser)
this.initializeSystemVariables(context);
// Load workspace and global variables
const workspaceVars = this.workspaceVariables.get(workspaceId) || [];
const allVariables = [...this.globalVariables, ...workspaceVars];
// Resolve all variables into context
for (const variable of allVariables) {
const value = this.variableResolver.resolve(variable, context);
context.variables.set(variable.name, value);
}
return context;
}Variable resolution order:
- System variables (always present)
- Global variables (shared across all workspaces)
- Workspace variables (workspace-specific)
- Rule variables (resolved during rule execution)
Variable precedence: Later variables override earlier ones with same name.
Error Handling Strategy
Isolation Boundaries
Each rule execution is wrapped in try-catch:
for (const rule of matchingRules) {
try {
// Variable resolution
if (rule.variables) {
this.resolveRuleVariables(rule.variables, context);
}
// Action execution
const actionResult = this.actionExecutor.execute(rule.actions, context);
// ... process results
} catch (error) {
// Broadcast error but continue processing
const errorContext = ErrorContextBuilder.buildErrorContext(
error as Error,
"action",
{ rule, event: workspaceEventClone, workspaceId, executionContext: context }
);
this.debugBroadcaster.broadcastError(errorContext);
}
}Philosophy: One failing rule should not break the entire pipeline.
Top-Level Error Handler
try {
// ... entire processEvent logic
} catch (error) {
console.error("[RuleEngine] Error processing event:", error);
this.eventProcessingDepth--;
// Broadcast critical error
this.debugBroadcaster.broadcastError(errorContext);
// Return original event unchanged as safer default
return [event];
}Fallback: Always return at least the original event on catastrophic failure.
Performance Monitoring
Built-in timing for every event:
const startTime = performance.now();
// ... processing
const endTime = performance.now();
this.debug(`Processed event in ${(endTime - startTime).toFixed(2)}ms`);Typical processing times:
- No matching rules: <0.1ms
- Simple rule (1 condition): <0.5ms
- Complex rule (10 conditions, variables): 1-3ms
- Multiple workspaces (3 workspaces): 2-5ms
Debug Mode Integration
Debug logging throughout processEvent:
this.debug(`Processed event in ${(endTime - startTime).toFixed(2)}ms`);
this.debug(`Event suppressed in workspaces: ${suppressedWorkspaces.join(', ') || 'none'}`);
this.debug(`Event modified in workspaces: ${modifyingWorkspaces.join(', ') || 'none'}`);
this.debug(`Generated ${modifiedEvents.length} modified events.`);
this.debug(`Generated ${newlyFiredEvents.length} new events.`);No performance impact when disabled: debug() method checks this.debugMode first.
Key Takeaways for Developers
- Never modify input event - Always work with clones
- Workspace isolation is sacred - Each workspace must process independently
- Execution order matters - Priority determines which modifications win
- Errors are isolated - One bad rule doesn't break the pipeline
- Recursion depth is limited - Max 10 levels prevents infinite loops
- Execution limits are per-rule - Tracked in Map, page-scoped only
- suppressWorkspaces accumulates - Both suppress and modify actions add to it
- Empty workspaceId means all - Dispatch layer interprets routing