Skip to content

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

typescript
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

typescript
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:

typescript
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

typescript
// 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

typescript
// 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:

typescript
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

typescript
// 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

typescript
// 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)

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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)

  • 1 is not true
  • 0 is not false
  • Empty string is not false

Existence Operators

exists / notExists

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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):

typescript
// 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):

typescript
{
  field: "data.users",
  operator: "arrayObjectContains",
  value: {
    age: {
      value: 21,
      operator: "greaterThanOrEqual"
    },
    name: {
      value: "john",
      operator: "contains",
      caseSensitive: false
    }
  }
}

Implementation:

typescript
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

typescript
// 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

typescript
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

typescript
// 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.orderTotal

Array Notation Support

Pattern: fieldName[index]

Regex: /^(.+)\[(\d+)\]$/

Examples:

  • items[0]items array, index 0
  • data.users[2].name → Navigate to data.users, get index 2, then .name
  • Invalid: items[] (no index), items[a] (non-numeric)

Value Transformation Pipeline

Transformation Types

typescript
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:

typescript
{
  field: "data.email",
  fieldTransform: "toLowerCase",
  operator: "equals",
  value: "user@example.com"
}

Chained transformations (applied in order):

typescript
{
  field: "data.input",
  fieldTransform: ["trim", "toLowerCase"],  // Trim first, then lowercase
  operator: "contains",
  value: "search term"
}

Implementation

typescript
// 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:

typescript
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

typescript
// 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:

typescript
// BAD: Creates new RegExp every evaluation
new RegExp(pattern, flags).test(str)  // ~100μs per creation

For 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

typescript
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:

  1. Key includes flags: "pattern::i" vs "pattern::"
  2. Caches failures: Invalid patterns stored as null (avoid re-parsing)
  3. Size limit: Max 100 patterns (prevents memory leak)
  4. LRU-like eviction: Deletes oldest when full

Usage in Conditions

typescript
// 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

typescript
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)

typescript
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:

typescript
// 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:

typescript
{
  field: "page.url",
  operator: "contains",
  value: "checkout",
  negate: true  // NOT contains
}

Equivalent to: notContains operator, but more flexible

Complete Operator Reference

CategoryOperators
Stringequals, notEquals, contains, notContains, startsWith, endsWith, matches, notMatches
NumericgreaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual, between, notBetween
BooleanisTrue, isFalse
Existenceexists, notExists, isEmpty, isNotEmpty
ArrayarrayContains, arrayNotContains, arrayContainsAny, arrayContainsAll, arrayObjectContains

Total: 25 operators

Key Takeaways for Developers

  1. AND/OR logic uses .some() and .every() - Native JS methods for short-circuit evaluation
  2. Regex patterns are cached - 100x speedup for repeated patterns
  3. Transformations apply before comparison - Order matters for chained transforms
  4. Array access supported in paths - data.items[0].name works
  5. Variable references use - resolves from context
  6. Negation applies after comparison - Can negate any operator
  7. Empty groups always match - No conditions = always true
  8. Validation only in debug mode - Zero production overhead
  9. Type coercion is explicit - Use transforms to convert types
  10. Case sensitivity is operator-specific - String operators support caseSensitive flag