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