UNPKG

@ninetailed/experience.js-plugin-analytics

Version:
200 lines (191 loc) 7.91 kB
import { isEqual } from 'radash'; import { allowVariableTypeSchema, logger, template } from '@ninetailed/experience.js-shared'; import { z } from 'zod'; // Base schema with shared properties const BaseSeenPayloadSchema = z.object({ variant: z.object({ id: z.string() }).catchall(z.unknown()), variantIndex: z.number() }); // Element specific schema const ElementSeenPayloadSchema = BaseSeenPayloadSchema.extend({ element: z.any(), experience: z.object({ id: z.string(), type: z.union([z.literal('nt_experiment'), z.literal('nt_personalization')]), name: z.string().optional(), description: z.string().optional(), sticky: z.boolean().optional().default(false) }).optional().nullable(), audience: z.object({ id: z.string(), name: z.string().optional(), description: z.string().optional() }).optional().nullable().default({ id: 'ALL_VISITORS', name: 'All Visitors', description: 'This is the default all visitors audience as no audience was set.' }), componentType: z.literal('Entry').default('Entry'), seenFor: z.number().optional().default(0) }); // Variable specific schema const VariableSeenPayloadSchema = BaseSeenPayloadSchema.extend({ componentType: z.literal('Variable').default('Variable'), variable: allowVariableTypeSchema, experienceId: z.string().optional() }); const TrackComponentPropertiesSchema = z.object({ variant: z.object({ id: z.string() }), audience: z.object({ id: z.string() }), isPersonalized: z.boolean() }); const HAS_SEEN_COMPONENT = 'has_seen_component'; const HAS_SEEN_ELEMENT_START = 'has_seen_elementStart'; const HAS_SEEN_ELEMENT = 'has_seen_element'; const HAS_SEEN_VARIABLE = 'has_seen_variable'; class NinetailedPlugin { constructor() { this.componentViewTrackingThreshold = 0; this[HAS_SEEN_ELEMENT] = event => { if (event.payload.seenFor !== this.getComponentViewTrackingThreshold()) { return; } this.onHasSeenElement(event); }; this[HAS_SEEN_VARIABLE] = event => { this.onHasSeenVariable(event); }; // eslint-disable-next-line @typescript-eslint/no-empty-function this.onHasSeenElement = () => {}; // eslint-disable-next-line @typescript-eslint/no-empty-function this.onHasSeenVariable = () => {}; this.setComponentViewTrackingThreshold = threshold => { this.componentViewTrackingThreshold = threshold; }; this.getComponentViewTrackingThreshold = () => this.componentViewTrackingThreshold; } } const TEMPLATE_OPTIONS = { interpolate: /{{([\s\S]+?)}}/g }; class NinetailedAnalyticsPlugin extends NinetailedPlugin { constructor(hasSeenExperienceEventTemplate = {}) { super(); this.seenElements = new WeakMap(); this.seenVariables = new Map(); this.getHasSeenExperienceEventPayload = data => { const event = Object.entries(this.hasSeenExperienceEventTemplate).reduce((acc, [keyTemplate, valueTemplate]) => { const key = () => { try { return template(keyTemplate, data, TEMPLATE_OPTIONS.interpolate); } catch (error) { logger.error(`Your Ninetailed Analytics Plugin's template is invalid. They key template ${keyTemplate} could not find the path in the specified experience.`); return 'undefined'; } }; const value = () => { try { return template(valueTemplate, data, TEMPLATE_OPTIONS.interpolate); } catch (error) { logger.error(`Your Ninetailed Analytics Plugin's template is invalid. They value template ${valueTemplate} could not find the path in the specified experience.`); return 'undefined'; } }; return Object.assign({}, acc, { [key()]: value() }); }, {}); return event; }; this.onHasSeenElement = ({ payload }) => { const sanitizedPayload = ElementSeenPayloadSchema.safeParse(payload); if (!sanitizedPayload.success) { logger.error('Invalid payload for has_seen_element event', sanitizedPayload.error.format()); return; } if (!sanitizedPayload.data.experience || !sanitizedPayload.data.audience) { return; } const elementPayloads = this.seenElements.get(payload.element) || []; const selectedVariantSelector = sanitizedPayload.data.variantIndex === 0 ? 'control' : `variant ${sanitizedPayload.data.variantIndex}`; const sanitizedTrackExperienceProperties = { experience: sanitizedPayload.data.experience, audience: sanitizedPayload.data.audience, selectedVariant: sanitizedPayload.data.variant, selectedVariantIndex: sanitizedPayload.data.variantIndex, selectedVariantSelector }; const isElementAlreadySeenWithPayload = elementPayloads.some(elementPayload => { return isEqual(elementPayload, sanitizedTrackExperienceProperties); }); if (isElementAlreadySeenWithPayload) { return; } const insightsPayload = Object.assign({}, sanitizedTrackExperienceProperties, { componentType: 'Entry' }); this.seenElements.set(payload.element, [...elementPayloads, insightsPayload]); this.onTrackExperience(insightsPayload, this.getHasSeenExperienceEventPayload(insightsPayload)); }; this.onHasSeenVariable = ({ payload }) => { const sanitizedPayload = VariableSeenPayloadSchema.safeParse(payload); if (!sanitizedPayload.success) { logger.error('Invalid payload for has_seen_variable event', sanitizedPayload.error.format()); return; } const componentId = sanitizedPayload.data.variant.id; if (typeof componentId === 'undefined') { logger.error('Component ID is undefined in has_seen_variable event payload'); return; } const variableKey = componentId; const variablePayloads = this.seenVariables.get(variableKey) || []; const selectedVariantSelector = sanitizedPayload.data.variantIndex === 0 ? 'control' : `variant ${sanitizedPayload.data.variantIndex}`; const sanitizedTrackVariableProperties = { componentId, selectedVariant: sanitizedPayload.data.variant, selectedVariantIndex: sanitizedPayload.data.variantIndex, selectedVariantSelector }; // Add type only for Insights API payload const insightsPayload = Object.assign({}, sanitizedTrackVariableProperties, { componentType: 'Variable' }); const isVariableAlreadySeenWithPayload = variablePayloads.some(variablePayload => { return isEqual(variablePayload, insightsPayload); }); if (isVariableAlreadySeenWithPayload) { return; } this.seenVariables.set(variableKey, [...variablePayloads, insightsPayload]); }; /** * @deprecated */ this[HAS_SEEN_COMPONENT] = ({ payload }) => { const sanitizedPayload = TrackComponentPropertiesSchema.safeParse(payload); if (!sanitizedPayload.success) { logger.error('Invalid payload for has_seen_component event', sanitizedPayload.error.format()); return; } this.onTrackComponent(sanitizedPayload.data); }; this.hasSeenExperienceEventTemplate = hasSeenExperienceEventTemplate; } } const hasComponentViewTrackingThreshold = arg => { return typeof arg === 'object' && arg !== null && 'getComponentViewTrackingThreshold' in arg && typeof arg['getComponentViewTrackingThreshold'] === 'function' && 'setComponentViewTrackingThreshold' in arg && typeof arg['setComponentViewTrackingThreshold'] === 'function'; }; export { ElementSeenPayloadSchema, HAS_SEEN_COMPONENT, HAS_SEEN_ELEMENT, HAS_SEEN_ELEMENT_START, HAS_SEEN_VARIABLE, NinetailedAnalyticsPlugin, NinetailedPlugin, TrackComponentPropertiesSchema, VariableSeenPayloadSchema, hasComponentViewTrackingThreshold };