Skip to content

TriggerMatcher Internals

The TriggerMatcher is responsible for efficiently matching incoming events against registered rule triggers. This document explains the O(1) lookup strategy and background trigger management.

TriggerMatcher Class

Location: /lib/ruleEngine/triggers/TriggerMatcher.ts

Lines: 203 total

Purpose: Provide constant-time lookup of rules that should evaluate for a given event type.

Data Structures

typescript
export class TriggerMatcher {
  // Index mapping event types to rule IDs that should trigger
  private triggerIndex: Map<string, Set<string>> = new Map();

  // Set of rule IDs that have wildcard triggers (match all events)
  private wildcardRules: Set<string> = new Set();

  // Map of rule ID to its triggers for reverse lookup
  private ruleTriggers: Map<string, Trigger[]> = new Map();
}

Design rationale:

  1. triggerIndex: O(1) lookup by event type
  2. wildcardRules: Separate set for rules that match everything (avoids polluting index)
  3. ruleTriggers: Enables unregistration and statistics

O(1) Lookup Indexing Strategy

The Problem

Naive approach: Iterate through all rules for every event

typescript
// O(n) where n = total number of rules
for (const rule of allRules) {
  if (rule.triggers.some(t => t.matches(event))) {
    // evaluate rule
  }
}

Problem at scale:

  • 100 rules = 100 iterations per event
  • 1000 rules = 1000 iterations per event
  • Performance degrades linearly with rule count

The Solution: Map-Based Indexing

Build index during registration:

typescript
// lib/ruleEngine/triggers/TriggerMatcher.ts:41-61
public registerTrigger(ruleId: string, trigger: Trigger): void {
  // Store trigger for reverse lookup
  if (!this.ruleTriggers.has(ruleId)) {
    this.ruleTriggers.set(ruleId, []);
  }
  this.ruleTriggers.get(ruleId)!.push(trigger);

  // Only index OneTrack event triggers
  if (trigger.type !== "oneTrackEvent") {
    return;
  }

  const config = trigger.config as EventTriggerConfig;
  const eventTypes = config.eventType;

  if (typeof eventTypes === "string") {
    this.indexEventType(ruleId, eventTypes);
  } else if (Array.isArray(eventTypes)) {
    eventTypes.forEach(type => this.indexEventType(ruleId, type));
  }
}

Index structure example:

typescript
// After registering rules:
triggerIndex = Map {
  "pageView" => Set {"rule-1", "rule-5", "rule-12"},
  "lead" => Set {"rule-2", "rule-7"},
  "purchase" => Set {"rule-3", "rule-7", "rule-9"}
}

wildcardRules = Set {"rule-4", "rule-10"}

Lookup during event processing:

typescript
// lib/ruleEngine/triggers/TriggerMatcher.ts:69-83
public findMatchingRuleIds(event: ClientEvent): Set<string> {
  const matchingRuleIds = new Set<string>();

  // Add all wildcard rules (always match)
  this.wildcardRules.forEach(ruleId => matchingRuleIds.add(ruleId));

  // Add rules that match the specific event type
  const eventType = event.event;
  const typeMatches = this.triggerIndex.get(eventType);
  if (typeMatches) {
    typeMatches.forEach(ruleId => matchingRuleIds.add(ruleId));
  }

  return matchingRuleIds;
}

Time complexity:

  • Map lookup: O(1)
  • Set iteration for wildcards: O(w) where w = number of wildcard rules
  • Set iteration for type matches: O(m) where m = rules matching this event type
  • Total: O(w + m) which is significantly better than O(n) where n = total rules

Space complexity: O(n * t) where n = number of rules, t = average triggers per rule

Wildcard Handling

Why Separate Set?

Option 1: Add wildcards to every event type in index

typescript
// BAD: Adds wildcard rules to every event type
triggerIndex = Map {
  "pageView" => Set {"rule-1", "rule-4", "rule-10", ...},
  "lead" => Set {"rule-2", "rule-4", "rule-10", ...},
  "purchase" => Set {"rule-3", "rule-4", "rule-10", ...},
  // ... duplicated across all event types
}

Problems:

  • Memory waste: Wildcard rules duplicated across all entries
  • Maintenance: Adding new event type requires updating wildcard entries

Option 2: Separate wildcard set (chosen approach)

typescript
// GOOD: Wildcard rules in dedicated set
wildcardRules = Set {"rule-4", "rule-10"}

// Only specific matches in index
triggerIndex = Map {
  "pageView" => Set {"rule-1", "rule-5"},
  "lead" => Set {"rule-2", "rule-7"},
  "purchase" => Set {"rule-3", "rule-9"}
}

Benefits:

  • Memory efficient: Wildcards stored once
  • Simple lookup: Union of wildcard set + specific matches
  • Easy maintenance: New event types don't affect wildcard storage

Wildcard Indexing

typescript
// lib/ruleEngine/triggers/TriggerMatcher.ts:193-202
private indexEventType(ruleId: string, eventType: string): void {
  if (eventType === "*") {
    this.wildcardRules.add(ruleId);
  } else {
    if (!this.triggerIndex.has(eventType)) {
      this.triggerIndex.set(eventType, new Set());
    }
    this.triggerIndex.get(eventType)!.add(ruleId);
  }
}

Handles:

  • Single wildcard: eventType: "*"
  • Array with wildcard: eventType: ["pageView", "*"]
  • Multiple event types: eventType: ["pageView", "lead"]

Background Trigger Management

Background Trigger Types

Location: /lib/ruleEngine/triggers/backgroundTriggerManager.ts

Background triggers fire independently of OneTrack events:

Trigger TypeMonitor ClassWhat It Tracks
scrollDepthScrollDepthMonitorScroll percentage/pixels
timeOnPageTimeOnPageMonitorTime since page load
postMessagePostMessageMonitorwindow.postMessage events
domReadyDOMEventMonitorDOMContentLoaded
windowLoadDOMEventMonitorwindow.load

Lazy Initialization Design

Problem: Don't want to start monitors if no rules use them.

Solution: Initialize monitors only when rules require them.

typescript
// lib/ruleEngine/triggers/backgroundTriggerManager.ts:31-71
export function initializeBackgroundTriggers(ruleEngine: RuleEngine): void {
  if (isInitialized || typeof window === "undefined") {
    return;
  }

  // Get required trigger types from registered rules
  const requiredTriggers = ruleEngine.getRequiredBackgroundTriggers();

  // Initialize monitors only if needed
  if (requiredTriggers.has("scrollDepth")) {
    scrollDepthMonitor = new ScrollDepthMonitor(ruleEngine);
    scrollDepthMonitor.start();
    activeMonitors.add("scrollDepth");
  }

  if (requiredTriggers.has("timeOnPage")) {
    timeOnPageMonitor = new TimeOnPageMonitor(ruleEngine);
    timeOnPageMonitor.start();
    activeMonitors.add("timeOnPage");
  }

  // ... similar for other monitors

  isInitialized = true;
}

Benefits:

  • No performance overhead if rules don't use background triggers
  • Scroll listener only added if needed
  • Interval timers only created when necessary

Dynamic Start/Stop Without Re-initialization

Scenario: Rules are registered dynamically after initialization.

Solution: Start specific monitors on-demand.

typescript
// lib/ruleEngine/triggers/backgroundTriggerManager.ts:76-127
export function startMonitor(triggerType: string, ruleEngine: RuleEngine): void {
  if (typeof window === "undefined") return;

  switch (triggerType) {
    case "scrollDepth":
      if (!activeMonitors.has("scrollDepth")) {
        if (!scrollDepthMonitor) {
          scrollDepthMonitor = new ScrollDepthMonitor(ruleEngine);
        }
        scrollDepthMonitor.start();
        activeMonitors.add("scrollDepth");
        console.log("[BackgroundTriggerManager] Started scrollDepth monitor dynamically");
      }
      break;
    // ... similar for other monitors
  }
}

Called from: Rule registration (line 170 in ruleEngine.ts)

typescript
enabledRules.forEach(rule => {
  rule.triggers.forEach(trigger => {
    this.triggerMatcher.registerTrigger(rule.id, trigger);

    // Start background monitors dynamically if needed
    if (trigger.type !== "oneTrackEvent" && this.isInitialized) {
      startMonitor(trigger.type, this);
    }
  });
});

Monitor Lifecycle Management

Stop monitor when no longer needed:

typescript
// lib/ruleEngine/triggers/backgroundTriggerManager.ts:132-175
export function stopMonitor(triggerType: string, ruleEngine: RuleEngine): void {
  // First check if any rules still require this trigger
  if (ruleEngine.isBackgroundTriggerRequired(triggerType)) {
    return; // Don't stop if still needed
  }

  switch (triggerType) {
    case "scrollDepth":
      if (activeMonitors.has("scrollDepth")) {
        scrollDepthMonitor?.stop();
        activeMonitors.delete("scrollDepth");
        console.log("[BackgroundTriggerManager] Stopped scrollDepth monitor");
      }
      break;
    // ... similar for other monitors
  }
}

Called from: Rule unregistration (line 1328 in ruleEngine.ts)

typescript
public unregisterRules(workspaceId: string): void {
  const removedRules = this.workspaceRules.get(workspaceId) || [];

  // Collect trigger types from removed rules
  const removedTriggerTypes = new Set<string>();
  removedRules.forEach(rule => {
    rule.triggers.forEach(trigger => {
      if (trigger.type !== "oneTrackEvent") {
        removedTriggerTypes.add(trigger.type);
      }
    });
  });

  // Remove the workspace rules
  this.workspaceRules.delete(workspaceId);

  // Stop monitors that are no longer needed
  removedTriggerTypes.forEach(triggerType => {
    stopMonitor(triggerType, this);
  });
}

Why check all rules? Multiple workspaces might use the same trigger type. Only stop when no workspace needs it.

Monitor Implementations

ScrollDepthMonitor

Location: /lib/ruleEngine/triggers/monitors/scrollDepthMonitor.ts

Tracks:

  • Percentage scrolled: (scrollTop + windowHeight) / documentHeight * 100
  • Pixels scrolled: scrollTop
  • Direction: up vs down

Implementation:

typescript
private handleScroll = (): void => {
  const currentDepth = this.calculateScrollDepth();

  // Fire triggers for all thresholds that have been crossed
  this.checkThresholds(currentDepth);
};

window.addEventListener('scroll', this.handleScroll, { passive: true });

Performance: Uses passive: true for scroll listener (doesn't block scrolling)

TimeOnPageMonitor

Location: /lib/ruleEngine/triggers/monitors/timeOnPageMonitor.ts

Tracks: Seconds since monitor started

Implementation:

typescript
public start(): void {
  this.startTime = Date.now();
  this.interval = window.setInterval(() => {
    const secondsOnPage = Math.floor((Date.now() - this.startTime) / 1000);
    this.checkThresholds(secondsOnPage);
  }, 1000); // Check every second
}

Handles:

  • Non-recurring: Fire once when threshold reached
  • Recurring: Fire every interval after threshold

PostMessageMonitor

Location: /lib/ruleEngine/triggers/monitors/postMessageMonitor.ts

Listens to: window.postMessage events

Implementation:

typescript
private handleMessage = (event: MessageEvent): void => {
  // Check origin if specified in trigger config
  // Check event name if specified
  // Fire matching triggers
};

window.addEventListener('message', this.handleMessage);

Use case: Integration with third-party iframes or parent windows

DOMEventMonitor

Location: /lib/ruleEngine/triggers/monitors/domEventMonitor.ts

Listens to:

  • DOMContentLoaded (for domReady triggers)
  • load (for windowLoad triggers)

Implementation:

typescript
public start(): void {
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', this.handleDOMReady);
  } else {
    // DOM already ready
    this.fireDOMReadyTriggers();
  }

  if (document.readyState === 'complete') {
    // Window already loaded
    this.fireWindowLoadTriggers();
  } else {
    window.addEventListener('load', this.handleWindowLoad);
  }
}

Handles: Late initialization (DOM/window already loaded)

Trigger Matching Logic

OneTrack Event Triggers

typescript
// lib/ruleEngine/triggers/TriggerMatcher.ts:108-125
public matches(trigger: Trigger, event: ClientEvent): boolean {
  if (trigger.type !== "oneTrackEvent") {
    return false;
  }

  const config = trigger.config as EventTriggerConfig;
  const eventType = event.event;

  if (typeof config.eventType === "string") {
    return config.eventType === "*" || config.eventType === eventType;
  } else if (Array.isArray(config.eventType)) {
    return (config.eventType as any[]).includes("*") ||
           (config.eventType as any[]).includes(eventType);
  }

  return false;
}

Handles:

  • Single event type: "pageView"
  • Multiple event types: ["pageView", "lead"]
  • Wildcard: "*"
  • Array with wildcard: ["pageView", "*"]

Background Trigger Matching

Background triggers use more complex matching logic in RuleEngine.triggerMatches():

typescript
// lib/ruleEngine/core/ruleEngine.ts:1143-1221
private triggerMatches(ruleTrigger: Trigger, trigger: Trigger): boolean {
  // Type must match
  if (ruleTrigger.type !== trigger.type) {
    return false;
  }

  switch (trigger.type) {
    case "scrollDepth":
      const ruleConfig = ruleTrigger.config as ScrollDepthTriggerConfig;
      const triggerConfig = trigger.config as ScrollDepthTriggerConfig;

      // Check threshold and unit
      if (ruleConfig.threshold && triggerConfig.threshold) {
        if (ruleConfig.unit !== triggerConfig.unit) return false;
        // Rule fires when actual depth >= configured threshold
        if (triggerConfig.threshold < ruleConfig.threshold) return false;
      }

      // Check direction if specified
      if (ruleConfig.direction && ruleConfig.direction !== triggerConfig.direction) {
        return false;
      }

      return true;

    case "timeOnPage":
      // Similar logic for time thresholds
      // Handles recurring vs non-recurring

    case "postMessage":
      // Matches event name and origin

    default:
      return false;
  }
}

Unregistration and Cleanup

Unregister Single Rule

typescript
// lib/ruleEngine/triggers/TriggerMatcher.ts:141-161
public unregisterRule(ruleId: string): void {
  // Remove from wildcard rules
  this.wildcardRules.delete(ruleId);

  // Remove from event type index
  this.triggerIndex.forEach(ruleIds => {
    ruleIds.delete(ruleId);
  });

  // Clean up empty sets
  const emptyTypes: string[] = [];
  this.triggerIndex.forEach((ruleIds, eventType) => {
    if (ruleIds.size === 0) {
      emptyTypes.push(eventType);
    }
  });
  emptyTypes.forEach(type => this.triggerIndex.delete(type));

  // Remove from rule triggers
  this.ruleTriggers.delete(ruleId);
}

Cleanup strategy: Remove empty sets to avoid memory leaks.

Destroy All Background Triggers

typescript
// lib/ruleEngine/triggers/backgroundTriggerManager.ts:180-208
export function destroyBackgroundTriggers(): void {
  if (!isInitialized) return;

  // Stop all monitors
  if (scrollDepthMonitor) {
    scrollDepthMonitor.stop();
    scrollDepthMonitor = null;
  }
  // ... stop all other monitors

  activeMonitors.clear();
  isInitialized = false;
}

Called from: RuleEngine.destroy()

Statistics and Debugging

Trigger Index Statistics

typescript
// lib/ruleEngine/triggers/TriggerMatcher.ts:166-188
public getStats(): {
  totalRules: number;
  wildcardRules: number;
  indexedEventTypes: number;
  averageRulesPerType: number;
} {
  let totalRulesInIndex = 0;
  this.triggerIndex.forEach(ruleIds => {
    totalRulesInIndex += ruleIds.size;
  });

  const indexedEventTypes = this.triggerIndex.size;
  const averageRulesPerType = indexedEventTypes > 0
    ? totalRulesInIndex / indexedEventTypes
    : 0;

  return {
    totalRules: this.ruleTriggers.size,
    wildcardRules: this.wildcardRules.size,
    indexedEventTypes,
    averageRulesPerType
  };
}

Useful for:

  • Monitoring index size
  • Identifying over-indexing (too many rules per event type)
  • Detecting wildcard overuse

Active Monitors Query

typescript
// lib/ruleEngine/triggers/backgroundTriggerManager.ts:213-215
export function getActiveMonitors(): string[] {
  return Array.from(activeMonitors);
}

Useful for: Debugging monitor lifecycle issues.

Performance Considerations

Indexing Trade-offs

Benefits:

  • O(1) lookup for specific event types
  • Scales well with large rule sets
  • Minimal overhead for wildcard rules

Costs:

  • Memory: O(n * t) storage for indices
  • Registration: O(t) time to index triggers
  • Typical values: n = 10-100 rules, t = 1-3 triggers per rule
  • Total index size: ~100-300 entries (negligible)

Background Monitor Impact

MonitorPerformance Impact
ScrollDepthMonitorPassive scroll listener, ~0.1ms per scroll
TimeOnPageMonitor1s interval timer, negligible
PostMessageMonitorEvent listener, only fires on postMessage
DOMEventMonitorOne-time listeners, zero ongoing cost

Key optimization: Lazy initialization means zero cost if monitors not used.

Key Takeaways for Developers

  1. O(1) lookup is achieved through Map indexing - Not O(n) iteration
  2. Wildcards are stored separately - Avoids duplication across all event types
  3. Background monitors start lazily - Only when rules require them
  4. Monitors can start/stop dynamically - Based on rule registration
  5. Unregistration cleans up empty sets - Prevents memory leaks
  6. Passive scroll listeners - Don't block scroll performance
  7. Index statistics available - For debugging and monitoring
  8. Type guards separate OneTrack vs background triggers - Different matching logic