react-native-avo-inspector
Version:
[](https://badge.fury.io/js/react-native-avo-inspector)
688 lines (687 loc) • 27.6 kB
JavaScript
/**
* EventValidator - Client-side validation of tracking events against the Avo Tracking Plan.
*
* This module validates property values against constraints:
* - Pinned values (exact match required)
* - Allowed values (must be in list)
* - Regex patterns (must match pattern)
* - Min/max ranges (numeric values must be in range)
*
* No schema validation (types/required) is performed - only value constraints.
* Validation runs against ALL events/variants in the response.
*/
import safe from 'safe-regex2';
// =============================================================================
// HELPER FUNCTIONS FOR NESTED PROPERTIES
// =============================================================================
/**
* Deep copies a constraint mapping (pinnedValues, allowedValues, etc.),
* including the arrays inside to avoid shared references.
*/
function deepCopyConstraintMapping(mapping) {
const result = {};
for (const [key, arr] of Object.entries(mapping)) {
result[key] = [...arr];
}
return result;
}
/**
* Deep copies children constraints recursively.
*/
function deepCopyChildren(children) {
const result = {};
for (const [propName, constraints] of Object.entries(children)) {
result[propName] = {
type: constraints.type,
required: constraints.required,
pinnedValues: constraints.pinnedValues
? deepCopyConstraintMapping(constraints.pinnedValues)
: undefined,
allowedValues: constraints.allowedValues
? deepCopyConstraintMapping(constraints.allowedValues)
: undefined,
regexPatterns: constraints.regexPatterns
? deepCopyConstraintMapping(constraints.regexPatterns)
: undefined,
minMaxRanges: constraints.minMaxRanges
? deepCopyConstraintMapping(constraints.minMaxRanges)
: undefined,
children: constraints.children
? deepCopyChildren(constraints.children)
: undefined
};
}
return result;
}
/**
* Merges children constraints from source into target recursively.
*/
function mergeChildren(target, source) {
for (const [propName, sourceConstraints] of Object.entries(source)) {
if (!target[propName]) {
// New child property - deep copy it
target[propName] = {
type: sourceConstraints.type,
required: sourceConstraints.required,
pinnedValues: sourceConstraints.pinnedValues
? deepCopyConstraintMapping(sourceConstraints.pinnedValues)
: undefined,
allowedValues: sourceConstraints.allowedValues
? deepCopyConstraintMapping(sourceConstraints.allowedValues)
: undefined,
regexPatterns: sourceConstraints.regexPatterns
? deepCopyConstraintMapping(sourceConstraints.regexPatterns)
: undefined,
minMaxRanges: sourceConstraints.minMaxRanges
? deepCopyConstraintMapping(sourceConstraints.minMaxRanges)
: undefined,
children: sourceConstraints.children
? deepCopyChildren(sourceConstraints.children)
: undefined
};
}
else {
// Merge into existing child property
const targetConstraints = target[propName];
mergeConstraintMappings(targetConstraints, sourceConstraints);
// Recursively merge nested children
if (sourceConstraints.children) {
if (!targetConstraints.children) {
targetConstraints.children = deepCopyChildren(sourceConstraints.children);
}
else {
mergeChildren(targetConstraints.children, sourceConstraints.children);
}
}
}
}
}
/**
* Merges constraint mappings (pinnedValues, allowedValues, etc.) from source into target.
*/
function mergeConstraintMappings(target, source) {
if (source.pinnedValues) {
if (!target.pinnedValues) {
target.pinnedValues = {};
}
for (const key of Object.keys(source.pinnedValues)) {
if (target.pinnedValues[key]) {
const merged = new Set(target.pinnedValues[key]);
for (const id of source.pinnedValues[key]) {
merged.add(id);
}
target.pinnedValues[key] = Array.from(merged);
}
else {
target.pinnedValues[key] = [...source.pinnedValues[key]];
}
}
}
if (source.allowedValues) {
if (!target.allowedValues) {
target.allowedValues = {};
}
for (const key of Object.keys(source.allowedValues)) {
if (target.allowedValues[key]) {
const merged = new Set(target.allowedValues[key]);
for (const id of source.allowedValues[key]) {
merged.add(id);
}
target.allowedValues[key] = Array.from(merged);
}
else {
target.allowedValues[key] = [...source.allowedValues[key]];
}
}
}
if (source.regexPatterns) {
if (!target.regexPatterns) {
target.regexPatterns = {};
}
for (const key of Object.keys(source.regexPatterns)) {
if (target.regexPatterns[key]) {
const merged = new Set(target.regexPatterns[key]);
for (const id of source.regexPatterns[key]) {
merged.add(id);
}
target.regexPatterns[key] = Array.from(merged);
}
else {
target.regexPatterns[key] = [...source.regexPatterns[key]];
}
}
}
if (source.minMaxRanges) {
if (!target.minMaxRanges) {
target.minMaxRanges = {};
}
for (const key of Object.keys(source.minMaxRanges)) {
if (target.minMaxRanges[key]) {
const merged = new Set(target.minMaxRanges[key]);
for (const id of source.minMaxRanges[key]) {
merged.add(id);
}
target.minMaxRanges[key] = Array.from(merged);
}
else {
target.minMaxRanges[key] = [...source.minMaxRanges[key]];
}
}
}
}
// =============================================================================
// CACHES
// =============================================================================
/**
* Cache for compiled regex objects to avoid recompilation on every event.
* Patterns are expected to be stable per session.
* null values indicate patterns that were rejected (unsafe or invalid).
*/
const regexCache = new Map();
/**
* Gets a compiled regex from cache or compiles and caches it.
* Validates patterns with safe-regex2 before compilation to prevent ReDoS.
* Returns null if the pattern is unsafe or invalid.
* Null results are cached to avoid retrying bad patterns.
*/
function getOrCompileRegex(pattern) {
if (regexCache.has(pattern)) {
return regexCache.get(pattern);
}
if (!safe(pattern)) {
console.warn(`[Avo Inspector] Potentially unsafe regex pattern rejected, skipping constraint: ${pattern}`);
regexCache.set(pattern, null);
return null;
}
try {
const regex = new RegExp(pattern);
regexCache.set(pattern, regex);
return regex;
}
catch (e) {
console.warn(`[Avo Inspector] Invalid regex pattern, skipping constraint: ${pattern}`);
regexCache.set(pattern, null);
return null;
}
}
/**
* Cache for parsed allowed values JSON.
* Key: JSON string, Value: Set of allowed values for O(1) lookup.
*/
const allowedValuesCache = new Map();
/**
* Parses allowed values JSON string and returns a Set for O(1) lookup.
* Results are cached to avoid repeated JSON.parse calls.
* @returns Set of allowed values, or null if JSON is invalid
*/
function getOrParseAllowedValues(jsonString) {
let allowedSet = allowedValuesCache.get(jsonString);
if (!allowedSet) {
try {
const allowedArray = JSON.parse(jsonString);
allowedSet = new Set(allowedArray);
allowedValuesCache.set(jsonString, allowedSet);
}
catch (e) {
// Invalid JSON - return null
return null;
}
}
return allowedSet;
}
// =============================================================================
// MAIN VALIDATION FUNCTION
// =============================================================================
/**
* Validates runtime properties against all events in the EventSpecResponse.
*
* For each property:
* - If property not in spec: no validation needed (empty result)
* - If property in spec: check constraints and collect failed/passed eventIds
* - Return whichever list is smaller for bandwidth optimization
*
* @param properties - The properties observed at runtime
* @param specResponse - The EventSpecResponse from the backend
* @returns ValidationResult with baseEventId, metadata, and per-property results
*/
export function validateEvent(properties, specResponse) {
// Collect all eventIds from all events
const allEventIds = collectAllEventIds(specResponse.events);
// Build lookup table: propertyName -> constraints (with embedded eventId mappings)
const constraintsByProperty = collectConstraintsByPropertyName(specResponse.events);
// Validate each runtime property against its constraints
const propertyResults = {};
for (const propName of Object.keys(properties)) {
const value = properties[propName];
const constraints = constraintsByProperty[propName];
if (!constraints) {
// Property not in spec - no constraints to fail
propertyResults[propName] = {};
}
else {
const result = validatePropertyConstraints(value, constraints, allEventIds);
propertyResults[propName] = result;
}
}
return {
metadata: specResponse.metadata,
propertyResults
};
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
/**
* Collects all eventIds (baseEventId + variantIds) from all events.
*/
function collectAllEventIds(events) {
const ids = [];
for (const event of events) {
ids.push(event.baseEventId);
for (let j = 0; j < event.variantIds.length; j++) {
ids.push(event.variantIds[j]);
}
}
return ids;
}
/**
* Collects all property constraints from all events into a single lookup table.
*
* This is purely for lookup efficiency - each constraint already contains
* its applicable eventIds, so no conflict resolution is needed.
*
* Example:
* Event1.props.method = { pinnedValues: { "email": ["evt_1"] } }
* Event2.props.method = { pinnedValues: { "phone": ["evt_2"] } }
*
* Result: { method: { pinnedValues: { "email": ["evt_1"], "phone": ["evt_2"] } } }
*/
function collectConstraintsByPropertyName(events) {
// Fast path: no events
if (events.length === 0) {
return {};
}
// Fast path: single event, return props directly (no aggregation needed)
if (events.length === 1) {
return events[0].props;
}
// Multiple events: aggregate constraints from all events
const result = {};
for (const event of events) {
for (const [propName, constraints] of Object.entries(event.props)) {
if (!result[propName]) {
// First time seeing this property - initialize with copies
result[propName] = {
type: constraints.type,
required: constraints.required,
pinnedValues: constraints.pinnedValues
? { ...constraints.pinnedValues }
: undefined,
allowedValues: constraints.allowedValues
? { ...constraints.allowedValues }
: undefined,
regexPatterns: constraints.regexPatterns
? { ...constraints.regexPatterns }
: undefined,
minMaxRanges: constraints.minMaxRanges
? { ...constraints.minMaxRanges }
: undefined,
children: constraints.children
? deepCopyChildren(constraints.children)
: undefined
};
}
else {
// Aggregate constraint mappings from additional events
const existing = result[propName];
mergeConstraintMappings(existing, constraints);
// Recursively merge nested children
if (constraints.children) {
if (!existing.children) {
existing.children = deepCopyChildren(constraints.children);
}
else {
mergeChildren(existing.children, constraints.children);
}
}
}
}
}
return result;
}
/**
* Maximum nesting depth for recursive value validation.
* We validate prop (depth 0), prop.child1 (depth 1), but NOT prop.child1.child2 (depth 2+).
* At depth 2+, we know there's an object but don't dive into its children for value validation.
* This matches the behavior of schema validation.
*/
const MAX_CHILD_DEPTH = 2;
/**
* Validates a property value against its constraints.
* Returns the validation result with either failedEventIds or passedEventIds
* (whichever is smaller for bandwidth optimization).
*
* For object properties with children:
* - Skip value-level validation (pinned/allowed/regex/minmax)
* - Recursively validate child properties (up to MAX_CHILD_DEPTH)
*
* For list properties (isList=true):
* - If list of objects with children: validate each array item's children
* - If list of primitives: validate each array item against constraints
*
* Depth limiting: We validate prop (depth 0), prop.child1 (depth 1), but stop
* at prop.child1.child2 (depth 2+). This matches schema validation behavior.
*
* @param depth - Current recursion depth (internal use)
*/
function validatePropertyConstraints(value, constraints, allEventIds, depth = 0) {
const result = {};
// Stop recursion at depth 2+ - we don't validate child2 and deeper
// This matches schema validation which shows prop.child1.child2: object without diving in
if (depth >= MAX_CHILD_DEPTH) {
return result;
}
// Handle list types (isList=true)
if (constraints.isList) {
return validateListProperty(value, constraints, allEventIds, depth);
}
// Handle nested object properties with children (single object, not list)
if (constraints.children) {
return validateObjectProperty(value, constraints, allEventIds, depth);
}
// For primitive properties only: skip validation for null/undefined on non-required properties.
// For optional properties, null/undefined means "not sent" which is valid.
// Only required properties should fail validation when value is null/undefined.
// Note: We check this AFTER checking for children, because object/list properties
// need to validate their children even when the parent value is null/undefined.
if ((value === null || value === undefined) && !constraints.required) {
return result;
}
// Validate value constraints for primitive properties
return validatePrimitiveProperty(value, constraints, allEventIds);
}
/**
* Validates a list property (array of items).
* For list of objects: validates each item's children.
* For list of primitives: validates each item against constraints.
* Any item failing causes the eventId to fail for that constraint.
*/
function validateListProperty(value, constraints, allEventIds, depth) {
const result = {};
// If value is not an array, we can't validate list items
if (!Array.isArray(value)) {
// Non-array value for a list property - return empty result (type mismatch not validated here)
return result;
}
// List of objects with children
if (constraints.children) {
const childrenResults = {};
// For each child property, collect failures across ALL array items
for (const [childName, childConstraints] of Object.entries(constraints.children)) {
const aggregatedFailedIds = new Set();
// Validate this child property in each array item
for (const item of value) {
const itemObj = (typeof item === "object" && item !== null && !Array.isArray(item))
? item
: {};
const childValue = itemObj[childName];
const childResult = validatePropertyConstraints(childValue, childConstraints, allEventIds, depth + 1);
// Collect failed IDs from this item
if (childResult.failedEventIds) {
for (const id of childResult.failedEventIds) {
aggregatedFailedIds.add(id);
}
}
// If passedEventIds is returned, failed = allEventIds - passedEventIds
// Use Set for O(1) lookup instead of O(n) .includes()
if (childResult.passedEventIds) {
const passedSet = new Set(childResult.passedEventIds);
for (const id of allEventIds) {
if (!passedSet.has(id)) {
aggregatedFailedIds.add(id);
}
}
}
}
// Build result for this child property
if (aggregatedFailedIds.size > 0) {
const failedArray = Array.from(aggregatedFailedIds);
const passedIds = allEventIds.filter((id) => !aggregatedFailedIds.has(id));
if (passedIds.length < failedArray.length && passedIds.length > 0) {
childrenResults[childName] = { passedEventIds: passedIds };
}
else {
childrenResults[childName] = { failedEventIds: failedArray };
}
}
}
if (Object.keys(childrenResults).length > 0) {
result.children = childrenResults;
}
return result;
}
// List of primitives - validate each item against constraints
const failedIds = new Set();
for (const item of value) {
// Check pinned values for this item
if (constraints.pinnedValues) {
checkPinnedValues(item, constraints.pinnedValues, failedIds);
}
// Check allowed values for this item
if (constraints.allowedValues) {
checkAllowedValues(item, constraints.allowedValues, failedIds);
}
// Check regex patterns for this item
if (constraints.regexPatterns) {
checkRegexPatterns(item, constraints.regexPatterns, failedIds);
}
// Check min/max ranges for this item
if (constraints.minMaxRanges) {
checkMinMaxRanges(item, constraints.minMaxRanges, failedIds);
}
}
return buildValidationResult(failedIds, allEventIds);
}
/**
* Validates an object property (single object with children).
*/
function validateObjectProperty(value, constraints, allEventIds, depth) {
const result = {};
const childrenResults = {};
const valueObj = (typeof value === "object" && value !== null && !Array.isArray(value))
? value
: {};
for (const [childName, childConstraints] of Object.entries(constraints.children)) {
const childValue = valueObj[childName];
const childResult = validatePropertyConstraints(childValue, childConstraints, allEventIds, depth + 1);
// Only include non-empty results
if (childResult.failedEventIds || childResult.passedEventIds || childResult.children) {
childrenResults[childName] = childResult;
}
}
if (Object.keys(childrenResults).length > 0) {
result.children = childrenResults;
}
return result;
}
/**
* Validates a primitive property (not list, not object with children).
*/
function validatePrimitiveProperty(value, constraints, allEventIds) {
const failedIds = new Set();
// Check pinned values
if (constraints.pinnedValues) {
checkPinnedValues(value, constraints.pinnedValues, failedIds);
}
// Check allowed values
if (constraints.allowedValues) {
checkAllowedValues(value, constraints.allowedValues, failedIds);
}
// Check regex patterns
if (constraints.regexPatterns) {
checkRegexPatterns(value, constraints.regexPatterns, failedIds);
}
// Check min/max ranges
if (constraints.minMaxRanges) {
checkMinMaxRanges(value, constraints.minMaxRanges, failedIds);
}
return buildValidationResult(failedIds, allEventIds);
}
/**
* Builds the validation result from failed IDs, returning whichever list is smaller.
*/
function buildValidationResult(failedIds, allEventIds) {
const passedIds = allEventIds.filter((id) => !failedIds.has(id));
const failedArray = Array.from(failedIds);
// Return whichever list is smaller for bandwidth optimization
// If both are empty, return empty object (no constraints)
if (failedArray.length === 0 && passedIds.length === 0) {
return {};
}
// Prefer passedEventIds only when strictly smaller than failedEventIds
// When equal, prefer failedEventIds (more intuitive to see what failed)
if (passedIds.length < failedArray.length && passedIds.length > 0) {
return { passedEventIds: passedIds };
}
else if (failedArray.length > 0) {
return { failedEventIds: failedArray };
}
else {
return {};
}
}
// =============================================================================
// CONSTRAINT VALIDATION FUNCTIONS
// =============================================================================
/**
* Adds all IDs from the array to the set.
* Helper to reduce code duplication in constraint check functions.
*/
function addIdsToSet(ids, set) {
for (const id of ids) {
set.add(id);
}
}
/**
* Converts runtime value to string for comparison.
* - Primitives (null, undefined, boolean, number, string) -> String(value)
* - Objects/Arrays -> JSON.stringify(value)
*/
function convertValueToString(value) {
if (value === null ||
value === undefined ||
typeof value === "boolean" ||
typeof value === "number" ||
typeof value === "string") {
return String(value);
}
if (Array.isArray(value) || typeof value === "object") {
try {
return JSON.stringify(value);
}
catch (e) {
// Circular reference or other serialization error
console.warn(`[Avo Inspector] Failed to stringify value: ${e}`);
return String(value);
}
}
return String(value);
}
/**
* Checks pinned values constraint.
* For each pinnedValue -> eventIds entry, if runtime value !== pinnedValue, those eventIds FAIL.
*/
function checkPinnedValues(value, pinnedValues, failedIds) {
const stringValue = convertValueToString(value);
for (const [pinnedValue, eventIds] of Object.entries(pinnedValues)) {
if (stringValue !== pinnedValue) {
// Value doesn't match this pinned value, so these eventIds fail
addIdsToSet(eventIds, failedIds);
}
}
}
/**
* Checks allowed values constraint.
* For each "[...array]" -> eventIds entry, if runtime value NOT in array, those eventIds FAIL.
* Uses cached Set for O(1) lookup instead of O(n) .includes().
*/
function checkAllowedValues(value, allowedValues, failedIds) {
const stringValue = convertValueToString(value);
for (const [allowedArrayJson, eventIds] of Object.entries(allowedValues)) {
const allowedSet = getOrParseAllowedValues(allowedArrayJson);
if (allowedSet === null) {
// Invalid JSON - skip this constraint
console.warn(`[Avo Inspector] Invalid allowed values JSON: ${allowedArrayJson}`);
continue;
}
if (!allowedSet.has(stringValue)) {
// Value not in allowed list, so these eventIds fail
addIdsToSet(eventIds, failedIds);
}
}
}
/**
* Checks regex pattern constraint.
* For each pattern -> eventIds entry, if runtime value doesn't match pattern, those eventIds FAIL.
*/
function checkRegexPatterns(value, regexPatterns, failedIds) {
// Only check regex for string values
if (typeof value !== "string") {
// Non-string values fail all regex constraints
for (const eventIds of Object.values(regexPatterns)) {
addIdsToSet(eventIds, failedIds);
}
return;
}
for (const [pattern, eventIds] of Object.entries(regexPatterns)) {
const regex = getOrCompileRegex(pattern);
if (regex === null) {
// Unsafe or invalid pattern - skip constraint (fail-open)
continue;
}
if (!regex.test(value)) {
// Value doesn't match pattern, so these eventIds fail
addIdsToSet(eventIds, failedIds);
}
}
}
/**
* Checks min/max range constraint.
* For each "min,max" -> eventIds entry, if runtime value < min OR > max, those eventIds FAIL.
* Empty bounds are supported: "0," means min=0 with no max, ",100" means no min with max=100.
*/
function checkMinMaxRanges(value, minMaxRanges, failedIds) {
// Only check min/max for numeric values
if (typeof value !== "number") {
// Non-numeric values fail all min/max constraints
for (const eventIds of Object.values(minMaxRanges)) {
addIdsToSet(eventIds, failedIds);
}
return;
}
// NaN values fail all min/max constraints (comparisons with NaN are always false)
if (Number.isNaN(value)) {
console.warn(`[Avo Inspector] NaN value fails min/max constraint`);
for (const eventIds of Object.values(minMaxRanges)) {
addIdsToSet(eventIds, failedIds);
}
return;
}
for (const [rangeStr, eventIds] of Object.entries(minMaxRanges)) {
const [minStr, maxStr] = rangeStr.split(",");
// Handle empty bounds: empty string means no constraint on that side
const hasMin = minStr !== "" && minStr !== undefined;
const hasMax = maxStr !== "" && maxStr !== undefined;
const min = hasMin ? parseFloat(minStr) : -Infinity;
const max = hasMax ? parseFloat(maxStr) : Infinity;
// Only check for invalid format if a bound was specified but couldn't be parsed
if ((hasMin && isNaN(min)) || (hasMax && isNaN(max))) {
console.warn(`[Avo Inspector] Invalid min/max range: ${rangeStr}`);
continue;
}
if (value < min || value > max) {
// Value out of range, so these eventIds fail
addIdsToSet(eventIds, failedIds);
}
}
}