Skip to content

RuleManager

Ahmed Abbas edited this page Apr 23, 2025 · 1 revision

Chapter 7: RuleManager

Welcome back! In Chapter 6: BucketingManager, we learned how the SDK uses a "Sorting Hat" approach (hashing) to consistently assign visitors to different variations based on traffic percentages, after they've already qualified for an experiment.

But how does the SDK decide if a visitor qualifies in the first place? What if you only want to run your headline A/B test for visitors from Canada who are using the Chrome browser? How does the SDK check these conditions?

The Problem: Are You on the List?

Imagine you're running a special promotion on your website, but only for visitors who meet specific criteria:

  • They must be visiting from Canada.
  • They must be using a desktop device.
  • They must have visited your pricing page before (maybe indicated by a cookie).

When a visitor arrives, the SDK needs a way to check if they satisfy all these conditions before even considering showing them the promotion (or bucketing them into an experiment variation). How does it perform this check against potentially complex sets of rules?

What is RuleManager? The Club Bouncer

Think of the RuleManager as the bouncer at an exclusive club. Before anyone gets in (i.e., qualifies for an experiment or feature), the bouncer checks their credentials against the entry rules.

  • The Rules: The bouncer has a list of requirements (the targeting rules you set up in your Convert project, like "Location: Canada", "Device: Desktop", "Cookie 'visited_pricing': exists").
  • The Visitor's ID: The bouncer looks at the visitor's details (the visitor attributes and location properties provided to the SDK, like { country: 'Canada', device: 'desktop', cookies: { visited_pricing: 'true' } }).
  • The Check: The bouncer compares the visitor's details against the rules one by one. Do they match?
  • The Decision: The bouncer decides: "Yes, this visitor meets all the criteria, let them in!" or "No, they don't meet the criteria, they can't enter."

The RuleManager does exactly this: it takes visitor data and compares it against a set of predefined rules (often called Audience conditions or Segment rules) using various comparison methods (like "equals", "contains", "less than"). It then returns whether the visitor is a "match" or "no match".

How it's Used (The Bouncer Reports to the Manager)

Just like the bouncer doesn't decide the club's overall admission policy but just enforces the rules given, the RuleManager doesn't act alone. You, as the SDK user, rarely interact with it directly.

Instead, the RuleManager is called by the DataManager. Remember the DataManager.getBucketing flow?

  1. DataManager checks its cache/store for a prior decision.
  2. If none exists, it retrieves the experiment/feature details, including the targeting rules.
  3. Crucially, it asks the RuleManager: "Hey, does this visitor (with these properties) match these targeting rules?" by calling ruleManager.isRuleMatched(visitorData, targetingRules).
  4. The RuleManager (the bouncer) performs the checks and reports back: true (match) or false (no match).
  5. If the RuleManager reports true, then the DataManager proceeds to ask the BucketingManager to assign a variation. If false, the process stops, and the visitor doesn't see the experiment.

So, RuleManager is the specialist that DataManager consults to verify visitor eligibility based on targeting rules.

Under the Hood: How the Rules are Checked

How does the "bouncer" actually read the rules and check the visitor's ID?

1. The Rule Structure (JSON)

The targeting rules you define in Convert are represented inside the SDK as a JSON object. This object has a specific structure using OR and AND logic, allowing for complex conditions.

Imagine rules like: "(Country is Canada AND Device is Desktop) OR (User is logged in)".

A simplified JSON representation might look like this (from packages/types/src/Rule.ts):

{
  "OR": [ // The top level is usually OR
    { // First main condition block (Country=Canada AND Device=Desktop)
      "AND": [
        { // Conditions inside this AND must ALL be true
          "OR_WHEN": [ // Conditions inside OR_WHEN: ANY can be true
            {
              "rule_type": "location", // Check visitor location data
              "key": "country",        // Specifically the 'country' property
              "matching": {
                "match_type": "equals", // Must be exactly equal to
                "negated": false        // (Not negated)
              },
              "value": "Canada"         // The value to compare against
            }
          ]
        },
        {
          "OR_WHEN": [
            {
              "rule_type": "visitor", // Check visitor properties data
              "key": "device",
              "matching": { "match_type": "equals", "negated": false },
              "value": "desktop"
            }
          ]
        }
      ]
    },
    { // Second main condition block (User is logged in)
      "AND": [
        {
          "OR_WHEN": [
            {
              "rule_type": "visitor",
              "key": "isLoggedIn",
              "matching": { "match_type": "equals", "negated": false },
              "value": true
            }
          ]
        }
      ]
    }
  ]
}
  • OR: An array of conditions where at least one AND block must be true.
  • AND: An array of conditions where all OR_WHEN blocks must be true.
  • OR_WHEN: An array of individual rule checks where at least one must be true. (Often contains just one rule).
  • RuleElement (the innermost object): Defines a single check (rule_type, key, matching method, value).

2. The Evaluation Process (isRuleMatched)

When DataManager calls ruleManager.isRuleMatched(visitorData, rules), the RuleManager recursively navigates this JSON structure:

  1. It starts at the top OR level. It processes each AND block within the OR array.
  2. For an AND block, it processes each OR_WHEN block inside. All of these must evaluate to true for the AND block to be true. If any OR_WHEN block is false, the RuleManager stops processing this AND block and moves to the next one in the parent OR array.
  3. For an OR_WHEN block, it processes each individual RuleElement inside. At least one of these must evaluate to true for the OR_WHEN block to be true. If it finds one true rule, it stops processing this OR_WHEN block and considers it true.
  4. For a single RuleElement:
    • It identifies the rule_type (e.g., 'location', 'visitor', 'cookie').
    • It looks up the corresponding value from the visitorData provided (e.g., visitorData.locationProperties.country or visitorData.visitorProperties.device).
    • It finds the comparison method specified in matching.match_type (e.g., 'equals', 'contains', 'less').
    • It calls that comparison method, passing the visitor's value and the rule's value.
    • It considers matching.negated (e.g., "does not equal").
    • It returns true or false for this single rule check.

If the RuleManager finds any top-level AND block that evaluates to true (meaning all its conditions passed), the entire isRuleMatched call returns true. If it goes through all the AND blocks and none evaluate to true, it returns false.

3. The Comparison Methods (Comparisons utility)

The actual checking ("equals", "contains", "less than", etc.) happens using helper functions, typically found in a utility class like Comparisons (from packages/utils/src/comparisons.ts).

These methods take the visitor's data value and the rule's target value and perform the check:

// Simplified example from packages/utils/src/comparisons.ts
class Comparisons {
  // Checks if the value contains the testAgainst string (case-insensitive)
  static contains(
    value: string | number,
    testAgainst: string | number,
    negation?: boolean // Is this a "does NOT contain" check?
  ): boolean {
    let strValue = String(value).toLowerCase();
    let strTestAgainst = String(testAgainst).toLowerCase();

    let result = strValue.indexOf(strTestAgainst) !== -1; // Does it contain?

    // Apply negation if needed
    return negation ? !result : result;
  }

  // Checks if the value is exactly equal to testAgainst (case-insensitive)
  static equals(
    value: any, // Can be string, number, boolean, array...
    testAgainst: string | number | boolean,
    negation?: boolean
  ): boolean {
     // ... logic to handle different types and compare ...
     let result = /* comparison logic */;
     return negation ? !result : result;
  }

  // Other methods like less, lessEqual, startsWith, endsWith, regexMatches...
}

The RuleManager uses these comparison methods when processing each individual RuleElement.

Simplified Sequence Diagram:

sequenceDiagram
    participant DM as DataManager
    participant RM as RuleManager
    participant Comp as Comparisons Util

    DM->>+RM: isRuleMatched(visitorData, rules)
    Note over RM: Start processing rules JSON...
    RM->>RM: Process OR[0].AND[0].OR_WHEN[0]... (Check Country)
    RM->>Comp: equals(visitorData.country, 'Canada', false)
    Comp-->>RM: true
    RM->>RM: Process OR[0].AND[1].OR_WHEN[0]... (Check Device)
    RM->>Comp: equals(visitorData.device, 'desktop', false)
    Comp-->>RM: true
    Note over RM: AND block is true. OR block is true.
    RM-->>-DM: true (Visitor matches)
Loading

Code Dive: RuleManager Implementation

Let's look at simplified snippets from packages/rules/src/rule-manager.ts.

1. The Constructor

Sets up the comparison methods to use and configuration options.

// File: packages/rules/src/rule-manager.ts (Simplified)

import { Comparisons as DEFAULT_COMPARISON_PROCESSOR } from '@convertcom/js-sdk-utils';
import { Config, RuleObject, RuleElement, RuleAnd, RuleOrWhen } from '@convertcom/js-sdk-types';
import { LogManagerInterface } from '@convertcom/js-sdk-logger';
import { ERROR_MESSAGES, MESSAGES, RuleError } from '@convertcom/js-sdk-enums';

const DEFAULT_KEYS_CASE_SENSITIVE = true;

export class RuleManager implements RuleManagerInterface {
  // Holds the comparison functions (equals, contains, etc.)
  private _comparisonProcessor: Record<string, any> = DEFAULT_COMPARISON_PROCESSOR;
  private _loggerManager: LogManagerInterface | null;
  private _keys_case_sensitive: boolean;

  constructor(
    config?: Config,
    { loggerManager }: { loggerManager?: LogManagerInterface } = {}
  ) {
    this._loggerManager = loggerManager;
    // Use comparison processor from config, or the default one
    this._comparisonProcessor = config?.rules?.comparisonProcessor || DEFAULT_COMPARISON_PROCESSOR;
    // Should rule keys ('country', 'device') be matched case-sensitively?
    this._keys_case_sensitive = config?.rules?.keys_case_sensitive || DEFAULT_KEYS_CASE_SENSITIVE;
    // ... logging ...
  }
  // ... methods ...
}
  • It stores a reference to the comparisonProcessor (which contains methods like equals, contains). This allows customizing the comparison logic if needed.
  • It also stores configuration like keys_case_sensitive.

2. The Main Entry Point (isRuleMatched)

This method starts the recursive processing of the rule JSON.

// File: packages/rules/src/rule-manager.ts (Simplified)

// Inside RuleManager class:
  isRuleMatched(
    data: Record<string, any>, // Visitor data (properties, location, cookies)
    ruleSet: RuleObject,      // The rule JSON object
    logEntry?: string         // Optional label for logging
  ): boolean | RuleError {    // Returns true, false, or an error
    // ... logging ...

    // Top OR level check
    if (ruleSet?.OR && Array.isArray(ruleSet.OR)) {
      // Loop through each AND block inside the top OR
      for (const andBlock of ruleSet.OR) {
        // === Process the AND block ===
        const match = this._processAND(data, andBlock as RuleAnd);

        // If this AND block is true, the whole rule set is true!
        if (match === true) {
          // ... logging ...
          return true; // Short-circuit: Found a match!
        }
        // Handle potential errors during processing
        if (Object.values(RuleError).includes(match as RuleError)) {
           // ... logging ...
        }
        // ... logging for no match in this AND block ...
      }
      // If we finished the loop and didn't return true, no AND block matched
    } else {
      this._loggerManager?.warn?.(/* Rule structure invalid */);
    }
    // If no matching OR condition was found
    return false;
  }
  • It expects the top level of the ruleSet to be an OR array.
  • It iterates through the items in the OR array (which should be AND blocks).
  • It calls _processAND for each AND block.
  • If any call to _processAND returns true, this method immediately returns true.
  • If the loop finishes without finding a match, it returns false.

3. Processing AND Blocks (_processAND)

This helper checks if all conditions within an AND block are met.

// File: packages/rules/src/rule-manager.ts (Simplified)

// Inside RuleManager class:
  private _processAND(
    data: Record<string, any>,
    rulesSubset: RuleAnd // An object like { "AND": [...] }
  ): boolean | RuleError {
    if (rulesSubset?.AND && Array.isArray(rulesSubset.AND)) {
      // Loop through each OR_WHEN block inside this AND
      for (const orWhenBlock of rulesSubset.AND) {
        // === Process the OR_WHEN block ===
        const match = this._processORWHEN(data, orWhenBlock);

        // If ANY OR_WHEN block is false, this AND block is false
        if (match === false) {
          return false; // Short-circuit: AND condition failed
        }
        // Propagate errors up
        if (match !== true) return match;
      }
      // If loop finished, all OR_WHEN blocks were true
      // ... logging ...
      return true;
    } else {
      this._loggerManager?.warn?.(/* Rule structure invalid */);
    }
    return false;
  }
  • It expects an AND array containing OR_WHEN blocks.
  • It iterates through the OR_WHEN blocks, calling _processORWHEN for each.
  • If any call to _processORWHEN returns false, this method immediately returns false.
  • If the loop completes (meaning all OR_WHEN blocks were true), it returns true.

4. Processing OR_WHEN Blocks (_processORWHEN)

This helper checks if at least one condition within an OR_WHEN block is met.

// File: packages/rules/src/rule-manager.ts (Simplified)

// Inside RuleManager class:
  private _processORWHEN(
    data: Record<string, any>,
    rulesSubset: RuleOrWhen // An object like { "OR_WHEN": [...] }
  ): boolean | RuleError {
    if (rulesSubset?.OR_WHEN && Array.isArray(rulesSubset.OR_WHEN)) {
      // Loop through each individual RuleElement inside this OR_WHEN
      for (const ruleItem of rulesSubset.OR_WHEN) {
         // === Process the single rule ===
         const match = this._processRuleItem(data, ruleItem);

         // If ANY rule item is true, this OR_WHEN block is true
         if (match === true) {
           return true; // Short-circuit: OR condition met!
         }
         // Propagate errors up
         if (match !== false) return match;
      }
      // If loop finished, no rule items were true
    } else {
      this._loggerManager?.warn?.(/* Rule structure invalid */);
    }
    return false;
  }
  • It expects an OR_WHEN array containing individual RuleElement objects.
  • It iterates through the RuleElements, calling _processRuleItem for each.
  • If any call to _processRuleItem returns true, this method immediately returns true.
  • If the loop completes (meaning all rules were false), it returns false.

5. Processing a Single Rule (_processRuleItem)

This is where the actual comparison happens using the visitor data.

// File: packages/rules/src/rule-manager.ts (Simplified)

// Inside RuleManager class:
  private _processRuleItem(
    data: Record<string, any>, // Visitor data
    rule: RuleElement         // Single rule {rule_type, key, matching, value}
  ): boolean | RuleError {
    if (this.isValidRule(rule)) { // Basic check if rule object looks okay
      try {
        const matching = rule.matching.match_type; // e.g., 'equals', 'contains'
        const negation = rule.matching.negated || false; // e.g., false
        const ruleKey = rule.key; // e.g., 'country', 'device'
        const ruleValue = rule.value; // e.g., 'Canada', 'desktop'

        // Check if the required comparison method exists
        if (this._comparisonProcessor[matching]) {
          // === Get the Visitor's Value ===
          // (Simplified logic - actual code determines which part of 'data'
          // to use based on rule.rule_type: visitorProperties, locationProperties, etc.)
          const visitorValue = data?.[ruleKey]; // e.g., data['country'] -> 'Canada'

          if (visitorValue !== undefined) {
            // === Perform the Comparison ===
            return this._comparisonProcessor[matching](
              visitorValue, // e.g., 'Canada'
              ruleValue,    // e.g., 'Canada'
              negation      // e.g., false
            ); // Returns true or false
          } else {
             // Visitor data did not contain the required key
             return false;
          }
        } else {
           this._loggerManager?.warn?.(/* Match type not supported */);
        }
      } catch (error) { /* ... logging ... */ }
    } else { /* Rule not valid log */ }
    // Default to false if anything goes wrong
    return false;
  }
  • It extracts the details from the rule object (match_type, key, value, negated).
  • It retrieves the corresponding value from the visitor data based on the rule.key.
  • It calls the appropriate method from the _comparisonProcessor (e.g., _comparisonProcessor['equals'](...)).
  • It returns the boolean result of the comparison.

Conclusion

The RuleManager is the SDK's engine for evaluating complex targeting rules. Like a diligent bouncer, it checks if a visitor's attributes (location, device, cookies, custom properties) match the conditions defined for an experiment, audience, or feature flag.

You've learned:

  1. Why rule evaluation is necessary for targeted experiences.
  2. The "bouncer" analogy for RuleManager.
  3. How rules are structured using OR/AND/OR_WHEN logic in JSON.
  4. How RuleManager processes these rules recursively using isRuleMatched.
  5. How it uses comparison methods (like equals, contains) from a utility class to perform individual checks.
  6. That it's primarily used internally by the DataManager to determine visitor eligibility before bucketing.

Now we understand how the SDK gets configuration data (Chapter 1: ConvertSDK / Core), manages individual visitor sessions (Chapter 2: Context), handles experiences (Chapter 3: ExperienceManager) and features (Chapter 4: FeatureManager), stores data and orchestrates decisions (Chapter 5: DataManager), allocates traffic fairly (Chapter 6: BucketingManager), and evaluates targeting rules (Chapter 7: RuleManager).

But how does the SDK actually get the project configuration data (including those rules) from the Convert servers in the first place? And how does it send tracking data back? That's the job of the communicator module.

Let's explore how the SDK interacts with external APIs in the next chapter: Chapter 8: ApiManager!

Clone this wiki locally