import { ZywaveBaseElement } from "@zywave/zywave-base"; import { configureHistory } from "@zywave/zywave-base/dist/configure-history-api.js"; import { html } from "lit"; import { property } from "lit/decorators.js"; import { style } from "./zywave-analytics-css.js"; import { configureAppcues, configureHeap } from "./analytics-configuration.js"; import { AnalyticsTracker } from "./analytics-tracker.js"; import type { ThirdPartyTracker, UserProperties } from "./analytics-configuration"; import type { PropertyValues } from "lit"; import type { ActivityTracker } from "./activity-tracker"; let DEFAULT_CDN_HOST = new URL(import.meta.url).origin; if (DEFAULT_CDN_HOST.includes("unpkg.com")) { DEFAULT_CDN_HOST = "https://cdn.zywave.com"; // eslint-disable-next-line no-console console.warn( "Loading the Zywave API Toolkit from unpkg.com is deprecated. Please use cdn.zywave.com instead. Your application is likely to break.", ); } function getLanguages() { return navigator.languages.map((l) => `[${l.toLowerCase()}]`).join(";"); } /** * `ZywaveAnalyticsElement` defines a configurable way to communicate with our centralized analytics tracking. Note: it's highly preferred to use `ZywaveShellElement` to do this for you; this should only be used if you cannot use `ZywaveShellElement`. * @element zywave-analytics * * @event load - Fired when analytics scripts have finished loading * * @attr {string | null} [api-base-url=null] - Provide the base URL to the Zywave APIs e.g., https://api.zywave.com/ (Note: the trailing slash is critical, especially if the base URL includes a path.) * @attr {string | null} [bearer-token=null] - (optional) Provide a Zywave bearer token for authorization * @attr {string | null} [profile-token=null] - (optional) Provide the explicit profile token that your application understands this user to be accessing * * @prop {string | null} [apiBaseUrl=null] - Provide the base URL to the Zywave APIs e.g., https://api.zywave.com/ (Note: the trailing slash is critical, especially if the base URL includes a path.) * @prop {string | null} [bearerToken=null] - (optional) Provide a Zywave bearer token for authorization * @prop {string | null} [profileToken=null] - (optional) Provide the explicit profile token that your application understands this user to be accessing */ export class ZywaveAnalyticsElement extends ZywaveBaseElement { static get styles() { return [style]; } /** * The app id to use when communicating with Heap. * If not specified, the `/shell/v2.0/analyticsinfo` API will be used. */ @property({ type: String, attribute: "heap-app-id" }) heapAppId: string | null = null; /** * The account id to use when communicating with Appcues. * If not specified, the `/shell/v2.0/analyticsinfo` API will be used. */ @property({ type: String, attribute: "appcues-account-id" }) appcuesAccountId: string | null = null; /** * A uniquely identifying string for the authenticated user. For most apps, this should be in the form `${profileTypeCode}~${profileId}`. * If not specified, the `/shell/v2.0/analyticsinfo` API will be used. */ @property({ type: String }) identity: string | null = null; /** * An optional property to directly add `userProperties` to analytics utilities. * `givenName`, `familyName`, and `email` are common properties to be used across all platforms; you can provide more properties to this object where applicable. */ @property({ type: Object, attribute: "user-properties" }) userProperties: UserProperties | null = null; /** * If specified, will prevent scripts from loading and analytics being configured until after the document has been parsed */ @property({ type: Boolean }) defer = false; /** * If provided, will set the CDN host for all external script loading. * @default new URL(import.meta.url).origin */ @property({ type: String, attribute: "cdn-host" }) cdnHost: string | null = DEFAULT_CDN_HOST; /** * UNSTABLE: DO NOT USE * @ignore */ @property({ type: String, attribute: "context-path" }) contextPath: string | null = null; #activityTrackingIntervalId?: number; get #isConfigBased() { return !!(this.heapAppId || this.appcuesAccountId || this.identity); } // this property is not a JS private field to enable testing private get _userProperties() { const analyticsUserProperties = this.#analyticsData?.userProperties; const userProperties = typeof this.userProperties === "object" ? this.userProperties : undefined; const result = { languages: getLanguages(), ...analyticsUserProperties, ...userProperties, }; // API is source of truth if (this.#userData) { result.email = this.#userData.email || result.email; result.givenName = this.#userData.given_name || result.givenName; result.familyName = this.#userData.family_name || result.familyName; } return result; } #analyticsData?: AnalyticsInfo; #userData?: UserInfo; #analyticsDataLoaded = false; connectedCallback() { super.connectedCallback(); configureHistory(); } disconnectedCallback() { super.disconnectedCallback(); this.#activityTrackingIntervalId && clearInterval(this.#activityTrackingIntervalId); this.#activityTrackingIntervalId = undefined; } async firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); if (!this.#isConfigBased) { await this._apiClientReady; } await Promise.allSettled([this.#loadAnalyticsInfo(), this.#loadUserInfo()]); if (this.#analyticsDataLoaded) { if (this.defer && document.readyState !== "complete") { document.addEventListener("readystatechange", (event) => { if ((event.target as Document).readyState === "complete") { this.#configureAnalytics(); } }); } else { this.#configureAnalytics(); } } } render() { return html``; } #configureAnalytics() { this.#configureActivityTracker(); const { heapAppId, appcuesAccountId, identity, eventProperties } = this.#analyticsData ?? {}; const cdnHost = this.cdnHost ?? "https://cdn.zywave.com"; let heapPromise: Promise | undefined; let appcuesPromise: Promise | undefined; if (heapAppId) { heapPromise = configureHeap(heapAppId, identity, this._userProperties, eventProperties, cdnHost); } if (appcuesAccountId) { appcuesPromise = configureAppcues(appcuesAccountId, identity, this._userProperties, eventProperties, cdnHost); } AnalyticsTracker.registerTracker(heapPromise); AnalyticsTracker.registerTracker(appcuesPromise); Promise.allSettled([heapPromise, appcuesPromise]).then(() => { this.dispatchEvent(new CustomEvent("load")); }); } /** * Method used to track custom events. * @param eventName Name of the event to track * @param payload Optional payload to pass to the event */ track(eventName: string, payload?: Record) { AnalyticsTracker.track(eventName, payload); } async #loadAnalyticsInfo() { if (this.#isConfigBased) { this.#analyticsData = { heapAppId: this.heapAppId || "", appcuesAccountId: this.appcuesAccountId || "", identity: this.identity || "", contextPath: this.contextPath || "", }; this.#analyticsDataLoaded = true; } else if (this._authorized && this._apiClient) { const url = new URL("shell/v2.0/analyticsinfo", this.apiUrl); url.searchParams.set("host", window.location.hostname); const resp = await this._apiClient.get(url); if (resp instanceof Response && resp.ok) { const data = (await resp.json()) as AnalyticsInfo; this.#analyticsDataLoaded = true; this.#analyticsData = data; this.#analyticsData.contextPath ??= this.contextPath; } } else { this.#analyticsDataLoaded = true; } } async #loadUserInfo() { if (this._authorized && this._apiClient) { const url = new URL("userinfo", this.apiUrl); const resp = await this._apiClient.fetch(url); if (resp instanceof Response && resp.ok) { const data = await resp.json(); this.#userData = data as UserInfo; } } } async #configureActivityTracker() { if (!this.#analyticsData?.contextPath) { return; } const { ActivityTracker } = await import("./activity-tracker.js"); await ActivityTracker.connect({ isImpersonated: this.#analyticsData.isImpersonating ?? false, cookieDomain: this.#analyticsData.cookieDomain, }); this.#activityTrackingIntervalId = window.setInterval(async () => { await this.#recordActivities(ActivityTracker); }, /* 5 seconds */ this.#analyticsData?.activityTrackingInterval ?? 2_000); window.addEventListener("visibilitychange", async () => { if (document.visibilityState === "hidden") { await this.#recordActivities(ActivityTracker); } }); } async #recordActivities(tracker: typeof ActivityTracker) { const activity = await tracker.retrieveActivity(); if (!(this.#analyticsData?.contextPath && activity.length)) { return; } // /a/e = /analytics/event, to cicumvent naive ad blockers const url = new URL(`v3/shell/v1.0${this.#analyticsData.contextPath}/a/e`, this.apiUrl); this._apiClient?.fetch(url, { method: "POST", body: JSON.stringify(activity), headers: { "Content-Type": "application/json", }, keepalive: true, }); } } window.customElements.define("zywave-analytics", ZywaveAnalyticsElement); declare global { interface HTMLElementTagNameMap { "zywave-analytics": ZywaveAnalyticsElement; } }