import { errorToString } from '@threekit/diagnostics'; import { AddToCartEvent, Analytics2, AREvent, ARStage, type AssetOption, ChatPromptEvent, ChatResponseEvent, ConfidenceMessagePromptEvent, Configuration, type Context, CustomEvent, LeadEvent, LoadEvent, OptionInteractionEvent, OptionInteractionType, OptionsShowEvent, OptionsType, PlayerType, PurchaseEvent, type QuizOption, QuoteEvent, RephrasePromptEvent, type SessionEvent, SessionEventType, ShareEvent, ShareType, StageEvent, type ThreekitAuthProps, type ValueOption, type VariantConfiguration, type VariantOption, VisualInteractionEvent, VisualInteractionType } from '@threekit/rest-api'; import * as uuid from 'uuid'; import { roundTo } from '../math/roundTo.js'; export type SessionProps = { auth: ThreekitAuthProps; sessionId?: string; userId?: string; customUserId?: string; experienceId?: string; experienceName?: string; experienceVersion?: string; }; type Vector3 = { x: number; y: number; z: number; }; const backupStorage: Record = {}; const setState = ( storage: Storage | undefined, name: string, value: string | undefined ) => { // required because localStorage and sessionStorage can be undefined in some environments if (storage === undefined) { backupStorage[name] = value ?? ''; return; } if (value === undefined) { storage.removeItem(`tkAnalytics.${name}`); } else { storage.setItem(`tkAnalytics.${name}`, value); } }; const getState = ( storage: Storage | undefined, name: string, defaultValue: string | undefined = undefined ) => { // required because localStorage and sessionStorage can be undefined in some environments if (storage === undefined) { return backupStorage[name] ?? defaultValue; } return storage.getItem(`tkAnalytics.${name}`) ?? defaultValue; }; export class Session { public auth: ThreekitAuthProps; private analytics: Analytics2; private dwellStartTime: Record = {}; private performance: Performance; private pageLoadTime: number | undefined = undefined; // // IMPORTANT: All analytics internal state is saved/loaded from session/local storage // this is to ensure that the state is maintained across page refreshes, and also // if there are multiple instances of the analytics object in the same page // private static get orgId(): string | undefined { return getState(sessionStorage, 'orgId'); } private static set orgId(value: string | undefined) { setState(sessionStorage, 'orgId', value); } private static get userId(): string | undefined { return getState(localStorage, 'userId'); } private static set userId(value: string | undefined) { setState(localStorage, 'userId', value); } private static get customUserId(): string | undefined { return getState(localStorage, 'customUserId'); } private static set customUserId(value: string | undefined) { setState(localStorage, 'customUserId', value); } public static get sessionId(): string | undefined { return getState(sessionStorage, 'sessionId'); } public static set sessionId(value: string | undefined) { setState(sessionStorage, 'sessionId', value); } private static get stageName(): string | undefined { return getState(sessionStorage, 'stageName'); } private static set stageName(value: string | undefined) { setState(sessionStorage, 'stageName', value); } private static get experienceId(): string | undefined { return getState(sessionStorage, 'experienceId'); } private static set experienceId(value: string | undefined) { setState(sessionStorage, 'experienceId', value); } private static get experienceName(): string | undefined { return getState(sessionStorage, 'experienceName'); } private static set experienceName(value: string | undefined) { setState(sessionStorage, 'experienceName', value); } private static get experienceVersion(): string | undefined { return getState(sessionStorage, 'experienceVersion'); } private static set experienceVersion(value: string | undefined) { setState(sessionStorage, 'experienceVersion', value); } static get log(): boolean { return getState(sessionStorage, 'log') === 'true'; } static set log(value: boolean) { setState(sessionStorage, 'log', value ? 'true' : undefined); } private debounceTimeout = 500; private eventDebounceTimers: Map> = new Map(); private eventsToDebounce = new Set([ SessionEventType.OptionInteraction, SessionEventType.Change, SessionEventType.ParametricValue, SessionEventType.PersonalizeText, SessionEventType.Error, SessionEventType.Stage, SessionEventType.VisualInteraction ]); constructor({ auth, sessionId = Session.sessionId ?? uuid.v4(), userId = Session.userId ?? uuid.v4(), experienceId = Session.experienceId, experienceName = Session.experienceName, experienceVersion = Session.experienceVersion, customUserId = Session.customUserId }: SessionProps) { this.auth = auth; Session.orgId = auth.orgId; Session.sessionId = sessionId; Session.userId = userId; Session.customUserId = customUserId; Session.experienceId = experienceId; Session.experienceName = experienceName; Session.experienceVersion = experienceVersion; console.log('Session state', { orgId: Session.orgId, sessionId: Session.sessionId, userId: Session.userId, experienceId: Session.experienceId, experienceName: Session.experienceName, experienceVersion: Session.experienceVersion, customUserId: Session.customUserId }); this.analytics = new Analytics2(auth); this.performance = globalThis.performance; const navigationEntries = this.performance.getEntriesByType( 'navigation' ) as PerformanceNavigationTiming[]; if (navigationEntries.length > 0) { this.pageLoadTime = navigationEntries[0].startTime / 1000; } } // eslint-disable-next-line class-methods-use-this public get sessionId(): string | undefined { return Session.sessionId; } private tryCatchBlock = (fn: () => void, reportErrors = true) => { try { fn(); } catch (e) { console.error(errorToString(e)); if (reportErrors) { // report error if (e instanceof Error) { this.error({ error: e }); } } } }; private getCommonEventProperties({ assetId, configuration, componentId }: { assetId?: string; configuration?: Configuration; componentId?: string; }) { return { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion orgId: Session.orgId!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion userId: Session.userId!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion sessionId: Session.sessionId!, customUserId: Session.customUserId, clientTime: new Date().toISOString(), experienceId: Session.experienceId, experienceName: Session.experienceName, experienceVersion: Session.experienceVersion, assetId, configuration, componentId, pageLoadOffset: this.pageLoadTime !== undefined && this.performance !== undefined ? this.performance.now() / 1000 - this.pageLoadTime : undefined, stageName: Session.stageName, pageUrl: typeof window !== 'undefined' ? window.location.href : undefined }; } static logEvent(event: SessionEvent) { if (Session.log) { const params: Record = { ...event }; const commonProperties = [ 'eventType', 'orgId', 'userId', 'sessionId', 'customUserId', 'clientTime', 'assetId', 'configuration', 'componentId', 'stageName', 'pageUrl', 'platformHost', 'experienceId', 'experienceName', 'experienceVersion', 'pageLoadOffset' ]; const auxiliaryParams: Record = {}; commonProperties.forEach((value) => { if (value in params) { auxiliaryParams[value] = params[value]; delete params[value]; } }); delete auxiliaryParams.eventType; console.log( `%c Threekit Analytics: ${event.eventType}`, 'background: #222; color: #bada55', params, auxiliaryParams ); } } errorLoopProtection = false; // never await on this function! let it run in the background private sendEvent(event: SessionEvent) { // ensure we don't get in a recursive loop of errors if (this.errorLoopProtection) { return; } this.errorLoopProtection = true; this.tryCatchBlock(() => { event.platformHost = this.auth.host; Session.logEvent(event); this.analytics.reportSessionEvent(event); }); this.errorLoopProtection = false; } errorCount = 0; maxErrorCount = 10; private reportEvent(event: SessionEvent) { if (!this.eventsToDebounce.has(event.eventType)) { return this.sendEvent(event); } // otherwise debounce event, based on ChatGPT strategy here: https://chatgpt.com/share/e/de565b81-b8b0-4380-ab65-611454b0525d if (this.eventDebounceTimers.has(event.eventType)) { clearTimeout(this.eventDebounceTimers.get(event.eventType)); } const timer = setTimeout(() => { this.sendEvent(event); this.eventDebounceTimers.delete(event.eventType); // Clean up after sending }, this.debounceTimeout); this.eventDebounceTimers.set(event.eventType, timer); } error({ error }: { error: Error }) { // ensure we don't spam the service with repeated errors when things are in a bad state this.errorCount++; if (this.errorCount > this.maxErrorCount) { return; } this.tryCatchBlock(() => { const event: SessionEvent = { ...this.getCommonEventProperties({}), eventType: SessionEventType.Error, errorType: error.constructor.name, errorMessage: error.message, errorStack: error.stack, errorDetails: errorToString(error) }; this.reportEvent(event); }, false); } query({ queryName, query }: { queryName: string; query: Record; }) { // ensure we don't spam the service with repeated errors when things are in a bad state this.errorCount++; if (this.errorCount > this.maxErrorCount) { return; } this.tryCatchBlock(() => { const event: SessionEvent = { ...this.getCommonEventProperties({}), eventType: SessionEventType.Query, queryName, query }; this.reportEvent(event); }); } change({ assetId, configuration, changeDuration, downloadSize }: { assetId?: string; configuration?: Configuration; changeDuration?: number; downloadSize?: number; }) { this.tryCatchBlock(() => { const event: SessionEvent = { ...this.getCommonEventProperties({ assetId, configuration }), eventType: SessionEventType.Change, changeDuration: changeDuration !== undefined ? roundTo(changeDuration, 3) : undefined, downloadSize }; this.reportEvent(event); }); } imageUpload({ assetId, configuration, imageUploadId, imageUploadFileId }: { assetId?: string; configuration?: Configuration; imageUploadId: string; imageUploadFileId: string; }) { this.tryCatchBlock(() => { const event: SessionEvent = { ...this.getCommonEventProperties({}), eventType: SessionEventType.ImageUpload, assetId, configuration, imageUploadId, imageUploadFileId }; this.reportEvent(event); }); } personalizeText({ assetId, configuration, personalizeId, personalizedText }: { assetId?: string; configuration?: Configuration; personalizeId: string; personalizedText: string; }) { this.tryCatchBlock(() => { const event: SessionEvent = { ...this.getCommonEventProperties({}), eventType: SessionEventType.PersonalizeText, assetId, configuration, personalizeId, personalizedText }; this.reportEvent(event); }); } parametricValue({ assetId, configuration, parametricId, parametricValue }: { assetId?: string; configuration?: Configuration; parametricId: string; parametricValue: number; }) { this.tryCatchBlock(() => { const event: SessionEvent = { ...this.getCommonEventProperties({}), eventType: SessionEventType.ParametricValue, assetId, configuration, parametricId, parametricValue }; this.reportEvent(event); }); } load({ playerType, loadDuration, downloadDuration, parseDuration, prefetch, loadOptions, downloadSize, assetId, configuration, componentId, partialLoad }: { playerType: PlayerType; loadDuration: number; downloadDuration?: number; parseDuration?: number; prefetch?: boolean; loadOptions?: string; downloadSize?: number; partialLoad?: boolean; assetId?: string; configuration?: Configuration; componentId?: string; }) { this.tryCatchBlock(() => { const event: LoadEvent = { ...this.getCommonEventProperties({ assetId, configuration, componentId }), eventType: SessionEventType.Load, playerType, loadDuration: roundTo(loadDuration, 3), downloadDuration: downloadDuration !== undefined ? roundTo(downloadDuration, 3) : undefined, parseDuration: parseDuration !== undefined ? roundTo(parseDuration, 3) : undefined, prefetch: prefetch || false, loadOptions, partialLoad, downloadSize }; this.reportEvent(event); }); } optionsShow({ optionsSetId, optionsSetName, options, optionsType, assetId, attributePath, configuration, componentId, context }: { optionsSetId: string; optionsSetName?: string; options: Array; optionsType: OptionsType; assetId?: string; configuration?: Configuration; attributePath?: string; componentId?: string; context?: Context; }) { this.tryCatchBlock(() => { const event: OptionsShowEvent = { ...this.getCommonEventProperties({ assetId, configuration, componentId }), eventType: SessionEventType.OptionsShow, optionsSetId, optionsSetName, options, optionsType, attributePath, context }; this.dwellStartTime[`$optionSetId$ ${optionsSetId}`] = new Date(); this.reportEvent(event); }); } optionInteraction({ optionsSetId, optionId, interactionType, assetId, configuration, componentId, context }: { optionsSetId: string; optionId: string; interactionType: OptionInteractionType; assetId?: string; configuration?: Configuration; componentId?: string; context?: Context; }) { this.tryCatchBlock(() => { const dwellStartTime = this.dwellStartTime[`$optionSetId$ ${optionsSetId}`]; const optionsSetDuration = dwellStartTime ? roundTo(new Date().getTime() - dwellStartTime.getTime() / 1000, 3) : undefined; const event: OptionInteractionEvent = { ...this.getCommonEventProperties({ assetId, configuration, componentId }), eventType: SessionEventType.OptionInteraction, optionsSetId, optionId, interactionType, optionsSetDuration, context }; this.reportEvent(event); }); } custom({ customName, customParameters, assetId, configuration, componentId }: { customName: string; customParameters?: Record; assetId?: string; configuration?: Configuration; componentId?: string; }) { this.tryCatchBlock(() => { const event: CustomEvent = { ...this.getCommonEventProperties({ assetId, configuration, componentId }), eventType: SessionEventType.Custom, customName, customParameters }; this.reportEvent(event); }); } stage({ stageName, assetId, configuration }: { stageName: string; assetId?: string; configuration?: Configuration; }) { this.tryCatchBlock(() => { // skip duplicate stage events if (Session.stageName === stageName) { return; } const previousStageName = Session.stageName; Session.stageName = stageName; const dwellStartTime = previousStageName ? this.dwellStartTime[`$stage$ ${previousStageName}`] : undefined; const previousStageDuration2 = dwellStartTime ? roundTo((new Date().getTime() - dwellStartTime.getTime()) / 1000, 3) : undefined; const event: StageEvent = { ...this.getCommonEventProperties({ assetId, configuration }), previousStageName, previousStageDuration2, eventType: SessionEventType.Stage }; this.dwellStartTime[`$stage$ ${stageName}`] = new Date(); this.reportEvent(event); }); } visualInteraction({ visualInteractionType, zoomFactor, targetAssetId, targetName, targetRotation, targetPosition, cameraPosition, cameraTarget, assetId, configuration, componentId }: { visualInteractionType: VisualInteractionType; zoomFactor?: number; targetAssetId?: string; targetName?: string; targetRotation?: Vector3; targetPosition?: Vector3; cameraPosition?: Vector3; cameraTarget?: Vector3; assetId?: string; configuration?: Configuration; componentId?: string; }) { this.tryCatchBlock(() => { const event: VisualInteractionEvent = { ...this.getCommonEventProperties({ assetId, configuration, componentId }), eventType: SessionEventType.VisualInteraction, visualInteractionType, zoomFactor, targetAssetId, targetName, targetRotation, targetPosition, cameraPosition, cameraTarget }; this.reportEvent(event); }); } chatPrompt({ promptId, promptText, assetId, configuration, componentId }: { promptId: string; promptText: string; assetId?: string; configuration?: Configuration; componentId?: string; }) { this.tryCatchBlock(() => { const event: ChatPromptEvent = { ...this.getCommonEventProperties({ assetId, configuration, componentId }), eventType: SessionEventType.ChatPrompt, promptId, promptText }; this.reportEvent(event); }); } chatResponse({ promptId, promptResponseText, assetId, configuration, componentId }: { promptId: string; promptResponseText: string; assetId?: string; configuration?: Configuration; componentId?: string; }) { this.tryCatchBlock(() => { const event: ChatResponseEvent = { ...this.getCommonEventProperties({ assetId, configuration, componentId }), eventType: SessionEventType.ChatResponse, promptId, promptResponseText }; this.reportEvent(event); }); } rephrasePrompt({ promptId, promptText, promptVersion, userPrompt, rephrasedPrompt, componentId }: { promptId: string; promptText: string; promptVersion: number; userPrompt: string; rephrasedPrompt: string; componentId?: string; }) { this.tryCatchBlock(() => { const event: RephrasePromptEvent = { ...this.getCommonEventProperties({ componentId }), eventType: SessionEventType.RephrasePrompt, promptId, promptText, promptVersion, userPrompt, rephrasedPrompt }; this.reportEvent(event); }); } confidenceMessage({ promptId, promptText, promptVersion, confidenceMessage, vectorizedString, metadata, componentId }: { promptId: string; promptText: string; promptVersion: number; confidenceMessage: string; vectorizedString: string; metadata: any; componentId?: string; }) { this.tryCatchBlock(() => { const event: ConfidenceMessagePromptEvent = { ...this.getCommonEventProperties({ componentId }), eventType: SessionEventType.ConfidenceMessagePrompt, promptId, promptText, promptVersion, confidenceMessage, vectorizedString, metadata }; this.reportEvent(event); }); } share({ shareLink, shareType, configurationId, orderId, assetId, configuration, componentId }: { shareLink: string; shareType: ShareType; orderId?: string; configurationId?: string; assetId?: string; configuration?: Configuration; componentId?: string; }) { this.tryCatchBlock(() => { const event: ShareEvent = { ...this.getCommonEventProperties({ assetId, configuration, componentId }), eventType: SessionEventType.Share, shareType, shareLink, orderId, configurationId }; this.reportEvent(event); }); } addToCart({ configurationId, cartCustomId, assetId, configuration, itemPrice, itemCount = 1, itemName, itemCustomId, orderId, orderCustomId, orderDetails, orderPrice, customerId, customerCustomId, customerDetails, itemId, variantId, variantConfiguration, callToAction }: { configurationId?: string; cartCustomId?: string; assetId?: string; configuration?: Configuration; itemName?: string; itemCustomId?: string; itemPrice?: number; itemCount?: number; orderId?: string; orderCustomId?: string; orderDetails?: any; orderPrice?: number; customerId?: string; customerCustomId?: string; customerDetails?: any; itemId?: string; variantId?: string; variantConfiguration?: VariantConfiguration; callToAction?: boolean; }) { this.tryCatchBlock(() => { const event: AddToCartEvent = { ...this.getCommonEventProperties({ assetId, configuration }), eventType: SessionEventType.AddToCart, itemName, itemCustomId, itemPrice, itemCount, configurationId, cartCustomId, orderId, orderCustomId, orderDetails, orderPrice, customerId, customerCustomId, customerDetails, itemId, variantId, variantConfiguration, callToAction }; this.reportEvent(event); }); } purchase({ configurationId, orderId, orderCustomId, orderDetails, orderPrice, cart, purchaseCustomId, assetId, configuration, customerId, customerCustomId, customerDetails, callToAction }: { configurationId?: string; orderId?: string; orderCustomId?: string; orderDetails?: any; orderPrice?: number; cart?: Array<{ assetId?: string; configuration?: Configuration; itemName?: string; itemCustomId?: string; itemPrice?: number; itemCount: number; itemId?: string; variantId?: string; variantConfiguration?: VariantConfiguration; }>; purchaseCustomId?: string; assetId?: string; configuration?: Configuration; customerId?: string; customerCustomId?: string; customerDetails?: any; callToAction?: boolean; }) { this.tryCatchBlock(() => { const event: PurchaseEvent = { ...this.getCommonEventProperties({ assetId, configuration }), eventType: SessionEventType.Purchase, configurationId, orderId, orderCustomId, orderDetails, orderPrice, cart, purchaseCustomId, customerId, customerCustomId, customerDetails, callToAction }; this.reportEvent(event); }); } quote({ configurationId, orderId, orderCustomId, orderDetails, orderPrice, cart, quoteCustomId, assetId, configuration, customerId, customerCustomId, customerDetails, callToAction }: { configurationId?: string; orderId?: string; orderCustomId?: string; orderDetails?: any; orderPrice?: number; cart?: Array<{ assetId?: string; configuration?: Configuration; itemName?: string; itemCustomId?: string; itemPrice?: number; itemCount: number; itemId?: string; variantId?: string; variantConfiguration?: VariantConfiguration; }>; quoteCustomId?: string; assetId?: string; configuration?: Configuration; customerId?: string; customerCustomId?: string; customerDetails?: any; callToAction?: boolean; }) { this.tryCatchBlock(() => { const event: QuoteEvent = { ...this.getCommonEventProperties({ assetId, configuration }), eventType: SessionEventType.Quote, configurationId, orderId, orderCustomId, orderDetails, orderPrice, cart, quoteCustomId, customerId, customerCustomId, customerDetails, callToAction }; this.reportEvent(event); }); } lead({ configurationId, orderId, orderCustomId, orderDetails, orderPrice, leadCustomId, assetId, configuration, customerId, customerCustomId, customerDetails, itemId, variantId, variantConfiguration, callToAction }: { configurationId?: string; orderId?: string; orderCustomId?: string; orderDetails?: any; orderPrice?: number; leadCustomId?: string; assetId?: string; configuration?: Configuration; customerId?: string; customerCustomId?: string; customerDetails?: any; itemId?: string; variantId?: string; variantConfiguration?: VariantConfiguration; callToAction?: boolean; }) { this.tryCatchBlock(() => { const event: LeadEvent = { ...this.getCommonEventProperties({ assetId, configuration }), eventType: SessionEventType.Lead, configurationId, orderId, orderCustomId, orderDetails, orderPrice, leadCustomId, customerId, customerCustomId, customerDetails, itemId, variantId, variantConfiguration, callToAction }; this.reportEvent(event); }); } ar({ arStage, arHandoffId, assetId, configuration, componentId }: { arStage: ARStage; arHandoffId?: string; assetId?: string; configuration?: Configuration; componentId?: string; }) { this.tryCatchBlock(() => { const event: AREvent = { ...this.getCommonEventProperties({ assetId, configuration, componentId }), eventType: SessionEventType.AR, arStage, arHandoffId }; this.reportEvent(event); }); } } let _session: Session | undefined; export const getSession = ({ auth, sessionId = undefined, userId = undefined, customUserId = undefined, experienceId = undefined, experienceName = undefined, experienceVersion = undefined }: SessionProps) => { if (_session === undefined) { _session = new Session({ auth, sessionId, userId, customUserId, experienceId, experienceName, experienceVersion }); } return _session; };