ConditionEvaluator Internals
The ConditionEvaluator implements Google Tag Manager-style condition logic with support for 25+ operators, value transformations, and deep object path access.
ConditionEvaluator Class
Location: /lib/ruleEngine/conditions/conditionEvaluator.ts
Lines: 535 total
Purpose: Evaluate rule conditions against event data and variables to determine if a rule should execute.
Class Structure
export class ConditionEvaluator {
private regexCache: RegexCache;
constructor() {
this.regexCache = getDefaultRegexCache();
}
// Public methods
public evaluateAll(conditions: Condition[], context: ExecutionContext): boolean
public evaluateConditionGroups(conditionGroups: ConditionGroup[], context: ExecutionContext): boolean
public evaluate(condition: Condition, context: ExecutionContext): boolean
public validateCondition(condition: Condition, context: ExecutionContext): { valid: boolean; errors: string[] }
// Private comparison methods (25+ operators)
private compare(actual: any, expected: any, operator: ComparisonOperator, caseSensitive?: boolean): boolean
private equals(a: any, b: any, caseSensitive?: boolean): boolean
private contains(str: any, substr: any, caseSensitive?: boolean): boolean
// ... many more
}AND/OR Logic Implementation
Condition Groups Structure
interface ConditionGroup {
conditions: Condition[];
}
interface Rule {
// NEW: Groups are OR'd together
conditionGroups?: ConditionGroup[];
// DEPRECATED: Legacy format (auto-migrated)
conditions?: Condition[];
}GTM-Style AND/OR Semantics
Google Tag Manager logic:
- Conditions within a group = AND (all must pass)
- Multiple groups = OR (any group passing = rule matches)
- Empty groups = always match
Example:
conditionGroups: [
{
// Group 1: (page.url contains "checkout" AND event.type = "pageView")
conditions: [
{ field: "page.url", operator: "contains", value: "checkout" },
{ field: "event.type", operator: "equals", value: "pageView" }
]
},
{
// Group 2: OR (page.url contains "purchase" AND event.type = "lead")
conditions: [
{ field: "page.url", operator: "contains", value: "purchase" },
{ field: "event.type", operator: "equals", value: "lead" }
]
}
]
// Rule matches if (Group 1 passes) OR (Group 2 passes)Implementation
// lib/ruleEngine/conditions/conditionEvaluator.ts:58-74
public evaluateConditionGroups(
conditionGroups: ConditionGroup[],
context: ExecutionContext
): boolean {
// Empty groups = always match (no conditions to fail)
if (!conditionGroups || conditionGroups.length === 0) {
return true;
}
// OR between groups - any group matching = true
return conditionGroups.some(group => {
// Empty conditions in a group = always match that group
if (!group.conditions || group.conditions.length === 0) {
return true;
}
// AND within group - all conditions must match
return this.evaluateAll(group.conditions, context);
});
}Key insight: Uses .some() for OR logic (short-circuits on first match).
Early Exit Optimization
// lib/ruleEngine/conditions/conditionEvaluator.ts:27-29
public evaluateAll(conditions: Condition[], context: ExecutionContext): boolean {
return conditions.every(condition => this.evaluate(condition, context));
}Performance: .every() short-circuits on first failure (doesn't evaluate remaining conditions).
Example:
conditions: [
{ field: "page.url", operator: "contains", value: "checkout" }, // ✓ PASS
{ field: "event.type", operator: "equals", value: "lead" }, // ✗ FAIL - stops here
{ field: "data.price", operator: "greaterThan", value: 100 } // Not evaluated
]Operator Handlers
String Operators
equals
// Lines 339-344
private equals(a: any, b: any, caseSensitive?: boolean): boolean {
if (typeof a === "string" && typeof b === "string" && caseSensitive === false) {
return a.toLowerCase() === b.toLowerCase();
}
return a === b;
}Supports: Case-insensitive comparison when caseSensitive: false
contains
// Lines 346-352
private contains(str: any, substr: any, caseSensitive?: boolean): boolean {
if (typeof str !== "string" || typeof substr !== "string") return false;
if (caseSensitive === false) {
return str.toLowerCase().includes(substr.toLowerCase());
}
return str.includes(substr);
}Type safety: Returns false if operands aren't strings (graceful degradation)
matches (Regex)
// Lines 370-379
private matches(str: any, pattern: any, caseSensitive?: boolean): boolean {
if (typeof str !== "string" || typeof pattern !== "string") return false;
const flags = caseSensitive === false ? 'i' : '';
const regex = this.regexCache.getOrCreate(pattern, flags);
if (!regex) {
return false; // Invalid pattern - already logged by cache
}
return regex.test(str);
}Performance optimization: Uses cached compiled regex patterns (see Regex Caching section)
Numeric Operators
toNumber Helper
// Lines 382-391
private toNumber(value: any): number {
const num = Number(value);
if (isNaN(num)) {
if (typeof window !== "undefined" && (window as any).__OneTrack?.debug) {
console.warn(`Could not convert value to number:`, value);
}
return NaN;
}
return num;
}Handles: String numbers ("42" → 42), non-numeric values (returns NaN)
between
// Lines 393-423
private between(value: any, range: any): boolean {
const num = this.toNumber(value);
if (isNaN(num)) return false;
if (Array.isArray(range) && range.length === 2) {
let [min, max] = range.map(v => this.toNumber(v));
if (isNaN(min) || isNaN(max)) {
if (debug) console.warn(`between: min or max is NaN`);
return false;
}
// Auto-swap if min > max
if (min > max) {
if (debug) console.warn(`between: min > max, auto-swapping`);
[min, max] = [max, min];
}
return num >= min && num <= max;
}
return false;
}Features:
- Auto-swaps min/max if reversed
- Inclusive range (
>=and<=) - Validation for NaN values
Boolean Operators
// Lines 425-432
private isTrue(value: any): boolean {
return value === true; // Strict equality
}
private isFalse(value: any): boolean {
return value === false; // Strict equality
}Design: Uses strict equality (not truthy/falsy)
1is nottrue0is notfalse- Empty string is not
false
Existence Operators
exists / notExists
// Lines 293-314
case "exists":
const exists = actual !== undefined && actual !== null;
if (expected === false) {
console.warn(`DEPRECATED: Use "notExists" operator instead.`);
return !exists;
}
return exists;
case "notExists":
const notExists = actual === undefined || actual === null;
if (expected === false) {
console.warn(`DEPRECATED: Use "exists" operator instead.`);
return !notExists;
}
return notExists;Null vs Undefined: Both treated as non-existent
Deprecation: Using value: false with exists/notExists is deprecated (use opposite operator)
isEmpty
// Lines 435-441
private isEmpty(value: any): boolean {
if (value === null || value === undefined) return true;
if (typeof value === "string") return value.trim() === "";
if (Array.isArray(value)) return value.length === 0;
if (typeof value === "object") return Object.keys(value).length === 0;
return false;
}Empty semantics:
- Null/undefined → empty
- String: trimmed length = 0
- Array: length = 0
- Object: no keys
- Numbers/booleans → not empty
Array Operators
arrayContains
// Lines 444-447
private arrayContains(arr: any, value: any): boolean {
if (!Array.isArray(arr)) return false;
return arr.includes(value);
}Simple equality check: Uses Array.includes() (strict equality)
arrayContainsAny
// Lines 449-452
private arrayContainsAny(arr: any, values: any): boolean {
if (!Array.isArray(arr) || !Array.isArray(values)) return false;
return values.some(value => arr.includes(value));
}Logic: Returns true if arr contains ANY value from values array
arrayContainsAll
// Lines 454-466
private arrayContainsAll(arr: any, values: any): boolean {
if (!Array.isArray(arr) || !Array.isArray(values)) return false;
if (values.length === 0) {
if (debug) console.warn(`arrayContainsAll: values array is empty`);
return false;
}
return values.every(value => arr.includes(value));
}Logic: Returns true only if arr contains ALL values from values array
Edge case: Empty values array returns false (can't contain "all" of nothing)
arrayObjectContains (Advanced)
Location: Lines 475-534
Purpose: Search array of objects for object matching criteria
Supports two modes:
1. Simple mode (backward compatible):
// Check if array contains object with status="active" AND type="admin"
{
field: "data.users",
operator: "arrayObjectContains",
value: {
status: "active",
type: "admin"
}
}2. Advanced mode (with operators):
{
field: "data.users",
operator: "arrayObjectContains",
value: {
age: {
value: 21,
operator: "greaterThanOrEqual"
},
name: {
value: "john",
operator: "contains",
caseSensitive: false
}
}
}Implementation:
private arrayObjectContains(arr: any, criteria: any): boolean {
if (!Array.isArray(arr)) return false;
if (typeof criteria !== 'object' || criteria === null) return false;
// Check if any object in array matches ALL criteria
return arr.some(item => {
if (!item || typeof item !== 'object') return false;
// Check each criterion
for (const [key, criterion] of Object.entries(criteria)) {
const itemValue = item[key];
// Check if advanced criterion with operator
if (criterion && typeof criterion === 'object' && 'value' in criterion) {
const { value, operator = 'equals', caseSensitive, negate = false } = criterion;
// Use existing compare method
let result = this.compare(itemValue, value, operator, caseSensitive);
if (negate) result = !result;
if (!result) return false; // Criterion failed
} else {
// Simple value - strict equality
if (itemValue !== criterion) return false;
}
}
return true; // All criteria matched
});
}Performance: Short-circuits on first matching object (.some())
Field Path Resolution
Resolution Flow
// Lines 168-177
private resolveFieldValue(field: string, context: ExecutionContext): any {
// Check if it's a variable reference
if (field.startsWith("{{") && field.endsWith("}}")) {
const varName = field.slice(2, -2).trim();
return resolvePath(varName, context);
}
// Direct path access using centralized resolver
return resolvePath(field, context);
}Path Resolver Implementation
Location: /lib/ruleEngine/utils/pathResolver.ts
export function resolvePath(path: string, context: ExecutionContext): any {
// Check variables first
if (context.variables.has(path)) {
return context.variables.get(path);
}
// Handle special prefixes
if (path.startsWith("event.")) {
return getValueFromPath(path.substring(6), context.event);
}
if (path.startsWith("page.")) {
const pageVar = context.variables.get("page");
if (pageVar) {
return getValueFromPath(path.substring(5), pageVar);
}
}
// Try direct path on event
return getValueFromPath(path, context.event);
}
function getValueFromPath(path: string, obj: any): any {
const parts = path.split(".");
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
// Handle array access like items[0]
const arrayMatch = part.match(/^(.+)\[(\d+)\]$/);
if (arrayMatch) {
current = current[arrayMatch[1]];
if (Array.isArray(current)) {
current = current[parseInt(arrayMatch[2], 10)];
}
} else {
current = current[part];
}
}
return current;
}Path Resolution Examples
// Variable reference
"{{myVariable}}" → context.variables.get("myVariable")
// Event path
"event.data.price" → context.event.data.price
// Page path (special prefix)
"page.url" → context.variables.get("page").url
// Array access
"data.items[0].name" → context.event.data.items[0].name
// Nested array
"data.users[1].orders[0].total" → context.event.data.users[1].orders[0].total
// Direct event property
"data.orderTotal" → context.event.data.orderTotalArray Notation Support
Pattern: fieldName[index]
Regex: /^(.+)\[(\d+)\]$/
Examples:
items[0]→itemsarray, index0data.users[2].name→ Navigate todata.users, get index2, then.name- Invalid:
items[](no index),items[a](non-numeric)
Value Transformation Pipeline
Transformation Types
export type ValueTransform =
| "toLowerCase"
| "toUpperCase"
| "trim"
| "toString"
| "toNumber"
| "toInteger"
| "toFloat"
| "toBoolean"
| "toPrice"; // Parses price strings like "$99.99", "43,39€"Single vs Chained Transformations
Single transformation:
{
field: "data.email",
fieldTransform: "toLowerCase",
operator: "equals",
value: "user@example.com"
}Chained transformations (applied in order):
{
field: "data.input",
fieldTransform: ["trim", "toLowerCase"], // Trim first, then lowercase
operator: "contains",
value: "search term"
}Implementation
// Lines 205-215
private applyTransform(value: any, transform: ValueTransform | ValueTransform[]): any {
// Handle array of transforms - apply in order
if (Array.isArray(transform)) {
return transform.reduce((currentValue, singleTransform) => {
return this.applySingleTransform(currentValue, singleTransform);
}, value);
}
// Single transform
return this.applySingleTransform(value, transform);
}Array.reduce pattern: Threads value through each transformation sequentially
Example:
value = " HELLO WORLD "
transforms = ["trim", "toLowerCase"]
Step 1: trim(" HELLO WORLD ") → "HELLO WORLD"
Step 2: toLowerCase("HELLO WORLD") → "hello world"
Result: "hello world"Individual Transform Handlers
// Lines 220-243
private applySingleTransform(value: any, transform: ValueTransform): any {
switch (transform) {
case "toLowerCase":
return typeof value === "string" ? value.toLowerCase() : value;
case "toUpperCase":
return typeof value === "string" ? value.toUpperCase() : value;
case "trim":
return typeof value === "string" ? value.trim() : value;
case "toString":
return String(value);
case "toNumber":
return Number(value);
case "toInteger":
return parseInt(String(value), 10);
case "toFloat":
return parseFloat(String(value));
case "toBoolean":
return Boolean(value);
case "toPrice":
return parsePrice(value); // From sharedUtilities
default:
return value;
}
}Type safety: String transforms only apply if value is string (graceful handling)
Price Parsing
Location: /lib/ruleEngine/utils/sharedUtilities.ts
Handles:
"$99.99"→99.99"43,39€"→43.39(European format)"€1,234.56"→1234.56"1.234,56"→1234.56(European thousands separator)
Use case: Comparing prices extracted from DOM elements
Regex Caching for Performance
The Problem
Creating regex objects is expensive:
// BAD: Creates new RegExp every evaluation
new RegExp(pattern, flags).test(str) // ~100μs per creationFor a rule evaluated 1000 times with same pattern:
- Total time: ~100ms wasted on regex compilation
- Solution: Cache compiled patterns
RegexCache Implementation
Location: /lib/ruleEngine/utils/regexCache.ts
export class RegexCache {
private cache: Map<string, RegExp | null> = new Map();
private maxSize: number = 100;
public getOrCreate(pattern: string, flags: string = ""): RegExp | null {
const key = `${pattern}::${flags}`;
// Return cached if exists
if (this.cache.has(key)) {
return this.cache.get(key)!;
}
// Create and cache
try {
const regex = new RegExp(pattern, flags);
// Evict oldest if cache full
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, regex);
return regex;
} catch (error) {
console.error(`Invalid regex pattern: ${pattern}`, error);
this.cache.set(key, null); // Cache failure
return null;
}
}
}Features:
- Key includes flags:
"pattern::i"vs"pattern::" - Caches failures: Invalid patterns stored as
null(avoid re-parsing) - Size limit: Max 100 patterns (prevents memory leak)
- LRU-like eviction: Deletes oldest when full
Usage in Conditions
// lib/ruleEngine/conditions/conditionEvaluator.ts:22-23
private regexCache: RegexCache;
constructor() {
this.regexCache = getDefaultRegexCache();
}
// Lines 370-379
private matches(str: any, pattern: any, caseSensitive?: boolean): boolean {
const flags = caseSensitive === false ? 'i' : '';
const regex = this.regexCache.getOrCreate(pattern, flags);
if (!regex) {
return false; // Invalid pattern
}
return regex.test(str);
}Performance impact:
- First match: ~100μs (compile + test)
- Subsequent matches: ~1μs (cache hit + test)
- 100x speedup for repeated patterns
Condition Validation (Debug Mode Only)
Validation Rules
Location: Lines 121-163
public validateCondition(
condition: Condition,
context: ExecutionContext
): { valid: boolean; errors: string[] } {
const errors: string[] = [];
// Check if field resolves to a value (unless existence operator)
if (!['exists', 'notExists'].includes(condition.operator)) {
const actualValue = this.resolveFieldValue(condition.field, context);
if (actualValue === undefined) {
errors.push(`Field "${condition.field}" does not exist in context`);
}
}
// Operator-specific validation
switch (condition.operator) {
case 'between':
case 'notBetween':
if (!Array.isArray(condition.value) || condition.value.length !== 2) {
errors.push(`Operator requires [min, max] array, got: ${JSON.stringify(condition.value)}`);
}
break;
case 'matches':
case 'notMatches':
if (typeof condition.value === 'string') {
try {
new RegExp(condition.value);
} catch {
errors.push(`Invalid regex pattern: ${condition.value}`);
}
}
break;
case 'arrayContains':
case 'arrayContainsAny':
const fieldValue = this.resolveFieldValue(condition.field, context);
if (fieldValue !== undefined && !Array.isArray(fieldValue)) {
errors.push(`Field should be array, got: ${typeof fieldValue}`);
}
break;
}
return { valid: errors.length === 0, errors };
}When it runs: Only in debug mode (lines 81-86)
if (typeof window !== "undefined" && (window as any).__OneTrack?.debug) {
const validation = this.validateCondition(condition, context);
if (!validation.valid) {
console.warn(`Validation warnings:`, validation.errors, condition);
}
}Performance: Zero cost in production (check short-circuits)
Negation Support
Applied AFTER comparison:
// Lines 79-114
public evaluate(condition: Condition, context: ExecutionContext): boolean {
let actualValue = this.resolveFieldValue(condition.field, context);
let expectedValue = this.resolveValue(condition.value, context);
// Apply transformations
if (condition.fieldTransform) {
actualValue = this.applyTransform(actualValue, condition.fieldTransform);
}
if (condition.valueTransform) {
expectedValue = this.applyTransform(expectedValue, condition.valueTransform);
}
// Compare
let result = this.compare(actualValue, expectedValue, condition.operator, condition.caseSensitive);
// Apply negation
if (condition.negate) {
result = !result;
}
return result;
}Example:
{
field: "page.url",
operator: "contains",
value: "checkout",
negate: true // NOT contains
}Equivalent to: notContains operator, but more flexible
Complete Operator Reference
| Category | Operators |
|---|---|
| String | equals, notEquals, contains, notContains, startsWith, endsWith, matches, notMatches |
| Numeric | greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual, between, notBetween |
| Boolean | isTrue, isFalse |
| Existence | exists, notExists, isEmpty, isNotEmpty |
| Array | arrayContains, arrayNotContains, arrayContainsAny, arrayContainsAll, arrayObjectContains |
Total: 25 operators
Key Takeaways for Developers
- AND/OR logic uses .some() and .every() - Native JS methods for short-circuit evaluation
- Regex patterns are cached - 100x speedup for repeated patterns
- Transformations apply before comparison - Order matters for chained transforms
- Array access supported in paths -
data.items[0].nameworks - Variable references use -
resolves from context - Negation applies after comparison - Can negate any operator
- Empty groups always match - No conditions = always true
- Validation only in debug mode - Zero production overhead
- Type coercion is explicit - Use transforms to convert types
- Case sensitivity is operator-specific - String operators support caseSensitive flag