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