UNPKG

@ninetailed/experience.js-plugin-insights

Version:

Ninetailed SDK plugin for Ninetailed Insights

465 lines (453 loc) 16.9 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var experience_js = require('@ninetailed/experience.js'); var experience_jsShared = require('@ninetailed/experience.js-shared'); var retry = require('async-retry'); var experience_jsPluginAnalytics = require('@ninetailed/experience.js-plugin-analytics'); /****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ /* global Reflect, Promise */ function __rest(s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; } function __awaiter(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, [])).next()); }); } const BASE_URL = 'https://ingest.insights.ninetailed.co'; const DEFAULT_ENVIRONMENT = 'main'; class HttpError extends Error { constructor(message, status = 500) { super(message); this.status = status; Object.setPrototypeOf(this, HttpError.prototype); } } class NinetailedInsightsApiClient { constructor({ clientId, environment = DEFAULT_ENVIRONMENT, url = BASE_URL }) { this.clientId = clientId; this.environment = environment; this.url = url; } logRequestError(error, { requestName }) { if (error instanceof Error) { if (error.name === 'AbortError') { experience_jsShared.logger.warn(`${requestName} request aborted due to network issues. This request is not retryable.`); } else { experience_jsShared.logger.error(`${requestName} request failed with error: [${error.name}] ${error.message}`); } } } makeRequest(url, payload, name, options) { const { useBeacon = false, timeout = 3000, retries = 1, minRetryTimeout = 0 } = options; const requestUrl = this.constructUrl(url); if (useBeacon) { const blobData = new Blob([JSON.stringify(payload)], { type: 'text/plain' }); navigator.sendBeacon(requestUrl, blobData); return; } return retry(bail => __awaiter(this, void 0, void 0, function* () { try { const response = yield experience_jsShared.fetchTimeout(requestUrl, { method: 'POST', headers: this.constructHeaders(), body: JSON.stringify(payload), timeout }); if (response.status === 503) { throw new HttpError(`${name} request failed with status: "[${response.status}] ${response.statusText}".`, 503); } if (!response.ok) { bail(new Error(`${name} request failed with status: "[${response.status}] ${response.statusText} - traceparent: ${response.headers.get('traceparent')}". This request is not retryable`)); return null; } experience_jsShared.logger.debug(`${name} response: `, response); return response; } catch (error) { if (error instanceof HttpError && error.status === 503) { throw error; } if (error instanceof Error) { bail(error); } else { bail(new Error(`${name} request failed with an unknown error. This request is not retryable.`)); } return null; } }), { retries: retries, minTimeout: minRetryTimeout, onRetry: (error, attempt) => experience_jsShared.logger.error(`${error.message} Retrying (attempt ${attempt}).`) }); } sendEventBatches(batches, options = {}) { return __awaiter(this, void 0, void 0, function* () { const requestName = 'Send component event batches'; experience_jsShared.logger.info(`Sending ${requestName} request.`); experience_jsShared.logger.debug(`${requestName} request Body: `, batches); try { yield this.makeRequest(`/v1/organizations/${this.clientId}/environments/${this.environment}/events`, batches, requestName, options); experience_jsShared.logger.debug(`${requestName} request succesfully completed.`); } catch (error) { this.logRequestError(error, { requestName }); // Abort errors caused by timeouts should not bubble up and be reported by third-party tools (e.g. Sentry) if (!(error instanceof Error) || error.name !== 'AbortError') { throw error; } } }); } constructUrl(path) { const baseUrl = new URL(this.url); // Remove trailing slash from base pathname and leading slash from path before concatenating const basePathname = baseUrl.pathname.replace(/\/$/, ''); const endpointPath = path.replace(/^\//, ''); baseUrl.pathname = `${basePathname}/${endpointPath}`; return baseUrl.toString(); } constructHeaders() { const headers = new Map(); headers.set('Content-Type', 'application/json'); return Object.fromEntries(headers); } } var _a, _b, _c, _d, _e; class NinetailedInsightsPlugin extends experience_jsPluginAnalytics.NinetailedPlugin { constructor({ url } = {}) { super(); this.name = 'ninetailed:insights'; this.seenElements = new WeakMap(); this.seenVariables = new Map(); this.events = []; this.eventsQueue = []; this.initialize = ({ instance }) => __awaiter(this, void 0, void 0, function* () { this.instance = instance; }); this.onHasSeenElement = ({ payload }) => { var _f; const sanitizedPayload = experience_jsPluginAnalytics.ElementSeenPayloadSchema.safeParse(payload); if (!sanitizedPayload.success) { experience_jsShared.logger.error('Insights Plugin: Invalid payload for has_seen_element event', sanitizedPayload.error.format()); return; } const { element, componentType, experience, variant, variantIndex, viewDurationMs, viewId } = sanitizedPayload.data; const componentId = variant.id; if (typeof componentId === 'undefined') { return; } const dedupeKey = viewId !== null && viewId !== void 0 ? viewId : 'no-view-id'; const latestSeenDurationsByViewId = this.seenElements.get(element) || new Map(); const latestSeenDurationMs = latestSeenDurationsByViewId.get(dedupeKey); const currentSeenDurationMs = viewDurationMs !== null && viewDurationMs !== void 0 ? viewDurationMs : 0; if (typeof latestSeenDurationMs === 'number' && currentSeenDurationMs <= latestSeenDurationMs) { return; } latestSeenDurationsByViewId.set(dedupeKey, currentSeenDurationMs); this.seenElements.set(element, latestSeenDurationsByViewId); /** * Intentionally sending a COMPONENT_START event instead of COMPONENT. * The NinetailedPrivacyPlugin, when used, will listen to COMPONENT_START and abort it if no consent is given. * If COMPONENT_START is aborted, the COMPONENT event will not be sent. * If NinetailedPrivacyPlugin is not used, the COMPONENT_START event will trigger the COMPONENT event. * * This behavior of the analytics library can be seen in the source code here: * https://github.com/DavidWells/analytics/blob/ba02d13d8b9d092cf24835b65f4f90af18f2740b/packages/analytics-core/src/index.js#L577 */ (_f = this.instance) === null || _f === void 0 ? void 0 : _f.dispatch({ type: experience_js.COMPONENT_START, componentId, componentType, variantIndex, experienceId: experience === null || experience === void 0 ? void 0 : experience.id, viewDurationMs, viewId }); }; this[_a] = ({ payload }) => { if (!this.eventBuilder) { experience_jsShared.logger.error('EventBuilder is not injected. Cannot build event. Skipping.'); return; } const { componentId, experienceId, variantIndex, componentType, viewDurationMs, viewId } = payload; const event = this.eventBuilder.component(componentId, componentType, experienceId, variantIndex, viewDurationMs, viewId); this.events.push(event); if (this.shouldFlushEventsQueue()) { if (this.profile) { this.createEventsBatch(this.profile); } this.flushEventsQueue(); } }; this.onHasClickedElement = ({ payload }) => { var _f; const sanitizedPayload = experience_jsPluginAnalytics.ElementClickedPayloadSchema.safeParse(payload); if (!sanitizedPayload.success) { experience_jsShared.logger.error('Insights Plugin: Invalid payload for has_clicked_element event', sanitizedPayload.error.format()); return; } const { componentType, experience, variant, variantIndex } = sanitizedPayload.data; const componentId = variant.id; if (typeof componentId === 'undefined') { return; } (_f = this.instance) === null || _f === void 0 ? void 0 : _f.dispatch({ type: experience_js.COMPONENT_CLICK_START, componentId, componentType, variantIndex, experienceId: experience === null || experience === void 0 ? void 0 : experience.id }); }; this[_b] = ({ payload }) => { if (!this.eventBuilder) { experience_jsShared.logger.error('EventBuilder is not injected. Cannot build event. Skipping.'); return; } const { componentId, experienceId, variantIndex, componentType } = payload; const event = this.eventBuilder.componentClick(componentId, componentType, experienceId, variantIndex); this.events.push(event); if (this.shouldFlushEventsQueue()) { if (this.profile) { this.createEventsBatch(this.profile); } this.flushEventsQueue(); } }; this.onHasHoveredElement = ({ payload }) => { var _f; const sanitizedPayload = experience_jsPluginAnalytics.ElementHoveredPayloadSchema.safeParse(payload); if (!sanitizedPayload.success) { experience_jsShared.logger.error('Insights Plugin: Invalid payload for has_hovered_element event', sanitizedPayload.error.format()); return; } const { componentType, experience, variant, variantIndex, hoverDurationMs, hoverId } = sanitizedPayload.data; const componentId = variant.id; if (typeof componentId === 'undefined') { return; } (_f = this.instance) === null || _f === void 0 ? void 0 : _f.dispatch({ type: experience_js.COMPONENT_HOVER_START, componentId, componentType, variantIndex, experienceId: experience === null || experience === void 0 ? void 0 : experience.id, hoverDurationMs, hoverId }); }; this[_c] = ({ payload }) => { if (!this.eventBuilder) { experience_jsShared.logger.error('EventBuilder is not injected. Cannot build event. Skipping.'); return; } const { componentId, experienceId, variantIndex, componentType, hoverDurationMs, hoverId } = payload; const event = this.eventBuilder.componentHover(componentId, componentType, hoverDurationMs, hoverId, experienceId, variantIndex); this.events.push(event); if (this.shouldFlushEventsQueue()) { if (this.profile) { this.createEventsBatch(this.profile); } this.flushEventsQueue(); } }; this.onHasSeenVariable = ({ payload }) => { var _f; const { variant, variantIndex, experienceId } = payload; // eslint-disable-next-line @typescript-eslint/no-unused-vars const { variable: _ } = payload, variablePayloadWithoutVariable = __rest(payload, ["variable"]); const componentId = variant.id; if (typeof componentId === 'undefined') { return; } const variablePayloads = this.seenVariables.get(componentId) || []; const isVariableAlreadySeenWithPayload = variablePayloads.some(variablePayload => { return experience_jsShared.isPlainDeepEqual(variablePayload, variablePayloadWithoutVariable); }); if (isVariableAlreadySeenWithPayload) { return; } this.seenVariables.set(componentId, [...variablePayloads, variablePayloadWithoutVariable]); /** * Intentionally sending a COMPONENT_START event instead of COMPONENT. * The NinetailedPrivacyPlugin, when used, will listen to COMPONENT_START and abort it if no consent is given. * If COMPONENT_START is aborted, the COMPONENT event will not be sent. * If NinetailedPrivacyPlugin is not used, the COMPONENT_START event will trigger the COMPONENT event. * * This behavior of the analytics library can be seen in the source code here: * https://github.com/DavidWells/analytics/blob/ba02d13d8b9d092cf24835b65f4f90af18f2740b/packages/analytics-core/src/index.js#L577 */ (_f = this.instance) === null || _f === void 0 ? void 0 : _f.dispatch({ type: experience_js.COMPONENT_START, componentId, componentType: 'Variable', variantIndex, experienceId }); }; this[_d] = ({ payload }) => { var _f; const { profile } = payload; const previousProfile = (_f = this.profile) !== null && _f !== void 0 ? _f : profile; if (previousProfile) { this.createEventsBatch(previousProfile); this.flushEventsQueue(); } this.profile = profile !== null && profile !== void 0 ? profile : undefined; this.seenElements = new WeakMap(); }; this[_e] = () => { if (this.profile) { this.createEventsBatch(this.profile); } this.flushEventsQueue(true); }; this.insightsApiClientUrl = url; } createEventsBatch(previousProfile) { if (this.events.length === 0) { return; } const profileBatch = { profile: previousProfile, events: this.events }; this.eventsQueue.push(profileBatch); this.events = []; } shouldFlushEventsQueue() { return this.eventsQueue.map(({ events }) => events).flat().length + this.events.length >= NinetailedInsightsPlugin.MAX_EVENTS; } flushEventsQueue(useBeacon = false) { var _f; if (this.eventsQueue.length === 0) { return; } (_f = this.insightsApiClient) === null || _f === void 0 ? void 0 : _f.sendEventBatches(this.eventsQueue, { useBeacon }); this.eventsQueue = []; } setCredentials(credentials) { this.insightsApiClient = new NinetailedInsightsApiClient({ url: this.insightsApiClientUrl, clientId: credentials.clientId, environment: credentials.environment }); } setEventBuilder(eventBuilder) { this.eventBuilder = eventBuilder; } } _a = experience_js.COMPONENT, _b = experience_js.COMPONENT_CLICK, _c = experience_js.COMPONENT_HOVER, _d = experience_js.PROFILE_CHANGE, _e = experience_js.PAGE_HIDDEN; NinetailedInsightsPlugin.MAX_EVENTS = 25; exports.NinetailedInsightsApiClient = NinetailedInsightsApiClient; exports.NinetailedInsightsPlugin = NinetailedInsightsPlugin; exports.default = NinetailedInsightsPlugin;