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
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:
- triggerIndex: O(1) lookup by event type
- wildcardRules: Separate set for rules that match everything (avoids polluting index)
- ruleTriggers: Enables unregistration and statistics
O(1) Lookup Indexing Strategy
The Problem
Naive approach: Iterate through all rules for every event
// 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:
// 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:
// 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:
// 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
// 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)
// 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
// 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 Type | Monitor Class | What It Tracks |
|---|---|---|
scrollDepth | ScrollDepthMonitor | Scroll percentage/pixels |
timeOnPage | TimeOnPageMonitor | Time since page load |
postMessage | PostMessageMonitor | window.postMessage events |
domReady | DOMEventMonitor | DOMContentLoaded |
windowLoad | DOMEventMonitor | window.load |
Lazy Initialization Design
Problem: Don't want to start monitors if no rules use them.
Solution: Initialize monitors only when rules require them.
// 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.
// 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)
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:
// 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)
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:
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:
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:
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(fordomReadytriggers)load(forwindowLoadtriggers)
Implementation:
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
// 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():
// 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
// 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
// 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
// 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
// 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
| Monitor | Performance Impact |
|---|---|
| ScrollDepthMonitor | Passive scroll listener, ~0.1ms per scroll |
| TimeOnPageMonitor | 1s interval timer, negligible |
| PostMessageMonitor | Event listener, only fires on postMessage |
| DOMEventMonitor | One-time listeners, zero ongoing cost |
Key optimization: Lazy initialization means zero cost if monitors not used.
Key Takeaways for Developers
- O(1) lookup is achieved through Map indexing - Not O(n) iteration
- Wildcards are stored separately - Avoids duplication across all event types
- Background monitors start lazily - Only when rules require them
- Monitors can start/stop dynamically - Based on rule registration
- Unregistration cleans up empty sets - Prevents memory leaks
- Passive scroll listeners - Don't block scroll performance
- Index statistics available - For debugging and monitoring
- Type guards separate OneTrack vs background triggers - Different matching logic