@ninetailed/experience.js-plugin-analytics
Version:
Ninetailed SDK plugin for analytics
200 lines (191 loc) • 7.91 kB
JavaScript
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 };