diff --git a/bulker/ingest/destination_types.go b/bulker/ingest/destination_types.go index 34252eb6a..1d362a846 100644 --- a/bulker/ingest/destination_types.go +++ b/bulker/ingest/destination_types.go @@ -17,4 +17,12 @@ var DeviceOptions = map[string]map[string]any{ "type": "internal-plugin", "name": "gtm", }, + "clarity": { + "type": "internal-plugin", + "name": "clarity", + }, + "hotjar": { + "type": "internal-plugin", + "name": "hotjar", + }, } diff --git a/libs/destination-functions/src/index.ts b/libs/destination-functions/src/index.ts index f0727e53a..3082f5c1c 100644 --- a/libs/destination-functions/src/index.ts +++ b/libs/destination-functions/src/index.ts @@ -47,6 +47,8 @@ const builtinDestinations: Record "builtin.destination.gtm": () => undefined, "builtin.destination.logrocket": () => undefined, "builtin.destination.ga4-tag": () => undefined, + "builtin.destination.clarity": () => undefined, + "builtin.destination.hotjar": () => undefined, } as const; const builtinTransformations: Record = { diff --git a/libs/jitsu-js/src/destination-plugins/clarity.ts b/libs/jitsu-js/src/destination-plugins/clarity.ts new file mode 100644 index 000000000..845d61500 --- /dev/null +++ b/libs/jitsu-js/src/destination-plugins/clarity.ts @@ -0,0 +1,191 @@ +import { loadScript } from "../script-loader"; +import { AnalyticsClientEvent } from "@jitsu/protocols/analytics"; +import { applyFilters, CommonDestinationCredentials, InternalPlugin } from "./index"; + +export type ClarityDestinationCredentials = { + // Clarity Project ID. Doubles as the API key - no separate key needed. + projectId: string; + // When true, Jitsu calls clarity('consent') right after the tag loads. + cookieConsent?: boolean; + // When false, Jitsu does not inject the Clarity tag - the page is expected to load it itself + // (e.g. via its own snippet or a tag manager). Jitsu still forwards events to the existing + // window.clarity. The projectId is only used when Jitsu loads the tag. Default true. + loadClarity?: boolean; + // What to do with `track` event properties. Clarity events carry only a name, so the only + // way to attach properties is as custom tags via clarity('set', ...). "tags" (default) does + // that, "ignore" drops them. + trackProperties?: "tags" | "ignore"; +} & CommonDestinationCredentials; + +// "passive" = a loadClarity===false destination installed the stub and forwards events, but no real +// SDK has been loaded by us yet; a later load-enabled destination may still transition to "loading". +type ClarityState = "fresh" | "passive" | "loading" | "loaded" | "failed"; + +// Upper bound on the offline stub queue. The real Clarity script drains window.clarity.q within +// seconds, so this is only ever hit when the tag never loads (e.g. loadClarity===false with a +// missing/blocked snippet); the cap keeps window.clarity.q from growing unbounded over a long +// session. Generous enough that a normal slow load never drops events. +const STUB_QUEUE_CAP = 1000; + +function getClarityState(): ClarityState { + return window["__jitsuClarityState"] || "fresh"; +} + +function setClarityState(s: ClarityState) { + window["__jitsuClarityState"] = s; +} + +// The projectId of the destination that actually loaded the tag. window.clarity is a vendor-owned +// singleton, so only one project can be active per page; we track it to warn on misconfiguration. +function getClarityProjectId(): string | undefined { + return window["__jitsuClarityProjectId"]; +} + +function setClarityProjectId(id: string) { + window["__jitsuClarityProjectId"] = id; +} + +// clarity('set', key, value) only accepts a string or an array of strings. Coerce anything else, +// dropping values that can't be sensibly represented as a tag. +function coerceTagValue(value: any): string | string[] | undefined { + if (value === null || value === undefined) { + return undefined; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) { + const arr = value.filter(v => v !== null && v !== undefined).map(v => String(v)); + return arr.length > 0 ? arr : undefined; + } + return undefined; +} + +function setTags(clarity: (...args: any[]) => void, obj: Record | undefined) { + if (!obj) { + return; + } + for (const [key, rawValue] of Object.entries(obj)) { + const value = coerceTagValue(rawValue); + if (value !== undefined) { + clarity("set", key, value); + } + } +} + +export const clarityPlugin: InternalPlugin = { + id: "clarity", + async handle(config, payload: AnalyticsClientEvent) { + if (!applyFilters(payload, config)) { + return; + } + initClarityIfNeeded(config); + + // Tag load failed: the stub has been swapped for a no-op and its queue dropped. Short-circuit so + // we don't keep handing work to a dead instance. + if (getClarityState() === "failed") { + return; + } + + // The vendor snippet installs a self-queuing stub, so calls made before the script finishes + // loading are queued by Clarity itself - no need for our own flush queue. + const clarity = window["clarity"]; + if (typeof clarity !== "function") { + return; + } + + // traits could be in both nodes, context.traits takes precedence + const traits = { + ...(payload.traits || {}), + ...(payload.context?.traits || {}), + }; + + switch (payload.type) { + case "identify": { + if (payload.userId) { + const friendlyName = traits.name || traits.email; + clarity("identify", payload.userId, undefined, undefined, friendlyName); + } + setTags(clarity, traits); + break; + } + case "track": { + if (payload.event) { + clarity("event", payload.event); + } + if ((config.trackProperties ?? "tags") === "tags") { + setTags(clarity, payload.properties); + } + break; + } + // `page` is intentionally a no-op. Clarity has no stateChange/page-view API (its whole + // client API is identify/set/event/consent/upgrade), and it already auto-tracks navigation + // - including SPA route changes - exposing URL as a native filter. So there is nothing + // useful to call here, unlike Hotjar where `page` -> hj('stateChange') fills a real gap. + } + }, +}; + +function initClarityIfNeeded(config: ClarityDestinationCredentials) { + const state = getClarityState(); + + // Once a load-enabled destination has taken ownership of the tag, no other destination can change + // that - window.clarity is a singleton. Warn on a projectId mismatch so the misconfiguration is + // visible; the destination that loaded the tag wins. + if (state === "loading" || state === "loaded" || state === "failed") { + const activeProjectId = getClarityProjectId(); + if (config.loadClarity !== false && activeProjectId && activeProjectId !== config.projectId) { + console.warn( + `Clarity: a destination for projectId=${activeProjectId} is already active on this page; ` + + `ignoring projectId=${config.projectId}. Multiple Clarity destinations on one page are not supported.` + ); + } + return; + } + + // state is "fresh" or "passive". Install the self-queuing stub (mirrors Clarity's official + // snippet) once - idempotent via `|| function(){}`. Even when Jitsu does not load the tag, this + // ensures events fired before the page's own Clarity script runs are queued and drained once it + // loads. + window["clarity"] = + window["clarity"] || + function () { + const q = (window["clarity"].q = window["clarity"].q || []); + if (q.length < STUB_QUEUE_CAP) { + q.push(arguments); + } + }; + + if (config.loadClarity !== false) { + // This destination loads the tag - take ownership even if a passive destination ran first, so a + // "load it yourself" destination processed earlier never strands us with only the stub. + setClarityState("loading"); + setClarityProjectId(config.projectId); + loadScript(`clarity.ms/tag/${config.projectId}`, { www: true }) + .then(() => { + setClarityState("loaded"); + }) + .catch(e => { + console.warn(`Clarity (projectId=${config.projectId}) init failed: ${e.message}`, e); + setClarityState("failed"); + // Init failed: replace the queuing stub with a no-op and drop its queue so events fired for + // the rest of the page lifetime don't accumulate in window.clarity.q. + window["clarity"] = function () {}; + }); + if (config.cookieConsent) { + window["clarity"]("consent"); + } + } else if (state === "fresh") { + // The page loads the Clarity tag itself; we only forward events to it. Stay in a transitional + // "passive" state (not "loaded") so a later load-enabled destination can still load the real + // SDK instead of being stranded at the guard above. Guard on "fresh" so we don't re-run the + // one-time consent call on every event. + setClarityState("passive"); + if (config.cookieConsent) { + window["clarity"]("consent"); + } + } +} diff --git a/libs/jitsu-js/src/destination-plugins/hotjar.ts b/libs/jitsu-js/src/destination-plugins/hotjar.ts new file mode 100644 index 000000000..ddddb09e5 --- /dev/null +++ b/libs/jitsu-js/src/destination-plugins/hotjar.ts @@ -0,0 +1,168 @@ +import { loadScript } from "../script-loader"; +import { AnalyticsClientEvent } from "@jitsu/protocols/analytics"; +import { applyFilters, CommonDestinationCredentials, InternalPlugin } from "./index"; + +export type HotjarDestinationCredentials = { + // Hotjar Site ID (a.k.a. hjid). + siteId: string; + // When true, Jitsu `page` events emit a Hotjar virtual page view via hj('stateChange', path). + // Off by default to avoid double-counting Hotjar's built-in page detection. + spaPageViews?: boolean; + // What to do with `track` event properties. Hotjar has no per-event property API - the only way + // to attach arbitrary data is hj('identify', userId, attributes). "attributes" merges the + // properties into the identified user's record (requires a userId); "ignore" (default) drops them. + trackProperties?: "ignore" | "attributes"; + // When false, Jitsu does not inject the Hotjar tag - the page is expected to load it itself + // (e.g. via its own snippet or a tag manager). Jitsu still forwards events to the existing + // window.hj. The siteId is only used when Jitsu loads the tag. Default true. + loadHotjar?: boolean; +} & CommonDestinationCredentials; + +// Hotjar protocol version. The tag URL and _hjSettings must agree on this. +const HOTJAR_VERSION = 6; + +// "passive" = a loadHotjar===false destination installed the stub and forwards events, but no real +// SDK has been loaded by us yet; a later load-enabled destination may still transition to "loading". +type HotjarState = "fresh" | "passive" | "loading" | "loaded" | "failed"; + +// Upper bound on the offline stub queue. The real Hotjar script drains window.hj.q within seconds, +// so this is only ever hit when the tag never loads (e.g. loadHotjar===false with a missing/blocked +// snippet); the cap keeps window.hj.q from growing unbounded over a long session. Generous enough +// that a normal slow load never drops events. +const STUB_QUEUE_CAP = 1000; + +function getHotjarState(): HotjarState { + return window["__jitsuHotjarState"] || "fresh"; +} + +function setHotjarState(s: HotjarState) { + window["__jitsuHotjarState"] = s; +} + +// The siteId of the destination that actually loaded the tag. window.hj is a vendor-owned singleton, +// so only one site can be active per page; we track it to warn on misconfiguration. +function getHotjarSiteId(): string | undefined { + return window["__jitsuHotjarSiteId"]; +} + +function setHotjarSiteId(id: string) { + window["__jitsuHotjarSiteId"] = id; +} + +// Hotjar event names must be <=250 chars and use no spaces. +// https://help.hotjar.com/hc/en-us/articles/4405109971095 +function sanitizeEventName(event: string): string { + return event.substring(0, 250).replace(/ /g, "_"); +} + +export const hotjarPlugin: InternalPlugin = { + id: "hotjar", + async handle(config, payload: AnalyticsClientEvent) { + if (!applyFilters(payload, config)) { + return; + } + initHotjarIfNeeded(config); + + // Tag load failed: the stub has been swapped for a no-op and its queue dropped. Short-circuit so + // we don't keep handing work to a dead instance. + if (getHotjarState() === "failed") { + return; + } + + // The vendor snippet installs a self-queuing stub, so calls made before the script finishes + // loading are queued by Hotjar itself - no need for our own flush queue. + const hj = window["hj"]; + if (typeof hj !== "function") { + return; + } + + // traits could be in both nodes, context.traits takes precedence + const traits = { + ...(payload.traits || {}), + ...(payload.context?.traits || {}), + }; + + switch (payload.type) { + case "identify": { + // Hotjar accepts a null userId to attach anonymous attributes. + hj("identify", payload.userId ?? null, traits); + break; + } + case "track": { + if (payload.event) { + hj("event", sanitizeEventName(payload.event)); + } + if (config.trackProperties === "attributes" && payload.userId && payload.properties) { + hj("identify", payload.userId, payload.properties); + } + break; + } + case "page": { + if (config.spaPageViews) { + const path = payload.properties?.path || payload.properties?.url || payload.context?.page?.path; + if (path) { + hj("stateChange", path); + } + } + break; + } + } + }, +}; + +function initHotjarIfNeeded(config: HotjarDestinationCredentials) { + const state = getHotjarState(); + + // Once a load-enabled destination has taken ownership of the tag, no other destination can change + // that - window.hj is a singleton. Warn on a siteId mismatch so the misconfiguration is visible; + // the destination that loaded the tag wins. + if (state === "loading" || state === "loaded" || state === "failed") { + const activeSiteId = getHotjarSiteId(); + if (config.loadHotjar !== false && activeSiteId && activeSiteId !== config.siteId) { + console.warn( + `Hotjar: a destination for siteId=${activeSiteId} is already active on this page; ` + + `ignoring siteId=${config.siteId}. Multiple Hotjar destinations on one page are not supported.` + ); + } + return; + } + + // state is "fresh" or "passive". Install the self-queuing stub (mirrors Hotjar's official snippet) + // once - idempotent via `|| function(){}`. Even when Jitsu does not load the tag, this ensures + // events fired before the page's own Hotjar script runs are queued and drained once it loads. + window["hj"] = + window["hj"] || + function () { + const q = (window["hj"].q = window["hj"].q || []); + if (q.length < STUB_QUEUE_CAP) { + q.push(arguments); + } + }; + + if (config.loadHotjar !== false) { + // This destination loads the tag - take ownership even if a passive destination ran first, so a + // "load it yourself" destination processed earlier never strands us with only the stub. + setHotjarState("loading"); + setHotjarSiteId(config.siteId); + // Only set _hjSettings when we load the tag ourselves, so we never repoint a Hotjar tag the + // page already configured. + window["_hjSettings"] = { hjid: config.siteId, hjsv: HOTJAR_VERSION }; + + loadScript(`static.hotjar.com/c/hotjar-${config.siteId}.js`, { query: `sv=${HOTJAR_VERSION}` }) + .then(() => { + setHotjarState("loaded"); + }) + .catch(e => { + console.warn(`Hotjar (siteId=${config.siteId}) init failed: ${e.message}`, e); + setHotjarState("failed"); + // Init failed: replace the queuing stub with a no-op and drop its queue so events fired for + // the rest of the page lifetime don't accumulate in window.hj.q. + window["hj"] = function () {}; + }); + } else if (state === "fresh") { + // The page loads the Hotjar tag itself; we only forward events to it. Stay in a transitional + // "passive" state (not "loaded") so a later load-enabled destination can still load the real + // SDK instead of being stranded at the guard above. + setHotjarState("passive"); + } +} diff --git a/libs/jitsu-js/src/destination-plugins/index.ts b/libs/jitsu-js/src/destination-plugins/index.ts index 08e19fa8e..c8b02cf86 100644 --- a/libs/jitsu-js/src/destination-plugins/index.ts +++ b/libs/jitsu-js/src/destination-plugins/index.ts @@ -3,6 +3,8 @@ import { tagPlugin } from "./tag"; import { logrocketPlugin } from "./logrocket"; import { gtmPlugin } from "./gtm"; import { ga4Plugin } from "./ga4"; +import { clarityPlugin } from "./clarity"; +import { hotjarPlugin } from "./hotjar"; export type InternalPlugin = { id: string; @@ -58,4 +60,6 @@ export const internalDestinationPlugins: Record> = { [gtmPlugin.id]: gtmPlugin, [ga4Plugin.id]: ga4Plugin, [logrocketPlugin.id]: logrocketPlugin, + [clarityPlugin.id]: clarityPlugin, + [hotjarPlugin.id]: hotjarPlugin, }; diff --git a/webapps/console/lib/schema/destinations.tsx b/webapps/console/lib/schema/destinations.tsx index ac24ee877..83337df80 100644 --- a/webapps/console/lib/schema/destinations.tsx +++ b/webapps/console/lib/schema/destinations.tsx @@ -29,6 +29,8 @@ import s3Icon from "./icons/s3"; import tagIcon from "./icons/tag"; import snowflakeIcon from "./icons/snowflake"; import logRocketIcon from "./icons/logrocket"; +import clarityIcon from "./icons/clarity"; +import hotjarIcon from "./icons/hotjar"; import intercomIcon from "./icons/intercom"; import webhookIcon from "./icons/webhook"; import { branding } from "../branding"; @@ -319,6 +321,86 @@ const logRocketDestination = { connectionOptions: DeviceDestinationsConnectionOptions, }; +const clarityDestination = { + id: "clarity", + isSynchronous: true, + icon: clarityIcon, + title: "Microsoft Clarity", + tags: "Device Destinations", + description: + "Microsoft Clarity is a free heatmap and session-recording tool. Jitsu attaches user identity, tags and custom events to Clarity sessions with a client-side snippet.", + credentials: z.object({ + projectId: z + .string() + .describe( + "Project ID::Your Clarity Project ID. Open your Clarity project » Settings » Overview to find it. It also serves as the API key. Only used when Jitsu loads the Clarity tag." + ), + loadClarity: z + .boolean() + .default(true) + .describe( + "Load Clarity::Whether Jitsu should load the Microsoft Clarity tag. Disable this if you already load Clarity yourself (e.g. via your own snippet or a tag manager) — Jitsu will only forward events to the existing Clarity instance and the Project ID is ignored." + ), + cookieConsent: z + .boolean() + .default(false) + .describe( + "Cookie consent::Call clarity('consent') after the tag loads. Enable this if you rely on Clarity's cookie consent gate." + ), + trackProperties: z + .enum(["tags", "ignore"]) + .default("tags") + .describe( + "Track event properties::Clarity custom events carry only a name. Choose tags to also forward track properties as filterable Clarity custom tags, or ignore to send the event name only." + ), + }), + deviceOptions: { + type: "internal-plugin", + name: "clarity", + } as DeviceOptions, + connectionOptions: DeviceDestinationsConnectionOptions, +}; + +const hotjarDestination = { + id: "hotjar", + isSynchronous: true, + icon: hotjarIcon, + title: "Hotjar", + tags: "Device Destinations", + description: + "Hotjar is a heatmap, session-recording and survey tool. Jitsu attaches user attributes and custom events to Hotjar with a client-side snippet.", + credentials: z.object({ + siteId: z + .string() + .describe( + "Site ID::Your Hotjar Site ID (the numeric hjid). Find it in Hotjar » Settings » Sites & Organizations, or in your tracking-code snippet. Only used when Jitsu loads the Hotjar tag." + ), + loadHotjar: z + .boolean() + .default(true) + .describe( + "Load Hotjar::Whether Jitsu should load the Hotjar tag. Disable this if you already load Hotjar yourself (e.g. via your own snippet or a tag manager) — Jitsu will only forward events to the existing Hotjar instance and the Site ID is ignored." + ), + spaPageViews: z + .boolean() + .default(false) + .describe( + "SPA page views::Emit a Hotjar virtual page view (hj('stateChange', path)) on every Jitsu page event. Enable this for single-page apps. Leave off to rely on Hotjar's built-in page detection and avoid double-counting." + ), + trackProperties: z + .enum(["ignore", "attributes"]) + .default("ignore") + .describe( + "Track event properties::Hotjar events carry only a name and have no per-event property API. Choose attributes to merge track properties into the identified user's record (requires a known user), or ignore to send the event name only." + ), + }), + deviceOptions: { + type: "internal-plugin", + name: "hotjar", + } as DeviceOptions, + connectionOptions: DeviceDestinationsConnectionOptions, +}; + const tagDestination = { id: "tag", isSynchronous: true, @@ -411,6 +493,8 @@ export const coreDestinations: DestinationType[] = [ gaDeviceDestination, gtmDeviceDestination, logRocketDestination, + clarityDestination, + hotjarDestination, { id: "clickhouse", usesBulker: true, diff --git a/webapps/console/lib/schema/icons/clarity.tsx b/webapps/console/lib/schema/icons/clarity.tsx new file mode 100644 index 000000000..f9a340874 --- /dev/null +++ b/webapps/console/lib/schema/icons/clarity.tsx @@ -0,0 +1,47 @@ +export default ( + + + + + + + + + + + + + + + + + + + + +); diff --git a/webapps/console/lib/schema/icons/hotjar.tsx b/webapps/console/lib/schema/icons/hotjar.tsx new file mode 100644 index 000000000..680b93b0c --- /dev/null +++ b/webapps/console/lib/schema/icons/hotjar.tsx @@ -0,0 +1,5 @@ +export default ( + + + +);