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