Skip to content

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 ​

typescript
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:

  1. Single source of truth for execution counts
  2. Centralized debug mode management
  3. Shared trigger matcher indices across all workspaces
  4. 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.price from "$99.99" to 99.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):

typescript
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 ​

typescript
interface ProcessedEvent extends ClientEvent {
  routing?: {
    workspaceId?: string[];         // Explicit targets (empty = all)
    suppressWorkspaces?: string[];  // Exclusions
    externalProviders?: string[];   // Third-party targets
  }
}

Routing Semantics ​

Empty workspaceId array:

typescript
routing: {
  workspaceId: [],  // "All workspaces..."
  suppressWorkspaces: ["ws-2", "ws-3"]  // "...except ws-2 and ws-3"
}

Populated workspaceId array:

typescript
routing: {
  workspaceId: ["ws-1"],  // "Only ws-1" (suppressWorkspaces ignored)
}

suppressWorkspaces Accumulation ​

Location: Lines 515-536

typescript
// 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

typescript
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:

typescript
.sort((a, b) => (a.priority || 999) - (b.priority || 999))

Examples:

  • Rule with priority: 1 executes before priority: 10
  • Rule with priority: 50 executes 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 ​

typescript
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) ​

typescript
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) ​

typescript
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) ​

typescript
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 fate

Phase 4: Workspace Processing Loop (Lines 328-510) ​

typescript
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:

  1. Execution limit check happens BEFORE rule matching - optimization to skip expensive matching when limit reached
  2. Context.event is mutable within workspace - allows sequential modifications by multiple rules
  3. Each workspace accumulates modifications - final version after all rules includes all changes

Phase 5: Consolidation (Lines 512-542) ​

typescript
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:

ScenarioResult
No matching rulesOriginal event, workspaceId: []
All workspaces suppressEmpty suppressWorkspaces array would exclude all, so original event not included
Workspace A suppressesOriginal event with suppressWorkspaces: ["A"]
Workspace A modifiesOriginal event with suppressWorkspaces: ["A"] + modified event for A
Workspace A fires new eventOriginal event + new event routed to A
Mixed behaviorOriginal event + all modifications + all new events

Execution Limits and Tracking ​

Data Structure ​

typescript
// Maps ruleId → execution count (page scope only)
private ruleExecutionTracker: Map<string, number> = new Map();

Can Execute Check (Lines 1227-1237) ​

typescript
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) ​

typescript
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:

typescript
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

typescript
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:

  1. System variables (always present)
  2. Global variables (shared across all workspaces)
  3. Workspace variables (workspace-specific)
  4. 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:

typescript
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 ​

typescript
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:

typescript
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:

typescript
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 ​

  1. Never modify input event - Always work with clones
  2. Workspace isolation is sacred - Each workspace must process independently
  3. Execution order matters - Priority determines which modifications win
  4. Errors are isolated - One bad rule doesn't break the pipeline
  5. Recursion depth is limited - Max 10 levels prevents infinite loops
  6. Execution limits are per-rule - Tracked in Map, page-scoped only
  7. suppressWorkspaces accumulates - Both suppress and modify actions add to it
  8. Empty workspaceId means all - Dispatch layer interprets routing