From 1784ba71804b3846bf612f4aa57a96c55f20a35d Mon Sep 17 00:00:00 2001 From: Vladimir Klimontovich Date: Sun, 26 Jul 2026 12:09:11 -0400 Subject: [PATCH 1/7] JITSU-135 feat(console): add Microsoft Clarity and Hotjar device destinations Both are client-side session-analytics tools, so they run in the browser via the @jitsu/js SDK as internal device plugins, following the logrocket/ga4-tag/gtm pattern. - clarity: identify -> clarity('identify') + traits as custom tags; track -> clarity('event') + optional properties as tags; cookie-consent option. - hotjar: identify -> hj('identify', userId, attrs); track -> hj('event') (name sanitized); opt-in page -> hj('stateChange') for SPAs. Wires the id across the console catalog, jitsu-js plugin registry, and the ingest DeviceOptions map (which classifies the destination as client-side and tells the SDK which plugin to run). --- bulker/ingest/destination_types.go | 8 ++ .../src/destination-plugins/clarity.ts | 129 ++++++++++++++++++ .../src/destination-plugins/hotjar.ts | 107 +++++++++++++++ .../jitsu-js/src/destination-plugins/index.ts | 4 + webapps/console/lib/schema/destinations.tsx | 72 ++++++++++ webapps/console/lib/schema/icons/clarity.tsx | 13 ++ webapps/console/lib/schema/icons/hotjar.tsx | 5 + 7 files changed, 338 insertions(+) create mode 100644 libs/jitsu-js/src/destination-plugins/clarity.ts create mode 100644 libs/jitsu-js/src/destination-plugins/hotjar.ts create mode 100644 webapps/console/lib/schema/icons/clarity.tsx create mode 100644 webapps/console/lib/schema/icons/hotjar.tsx 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/jitsu-js/src/destination-plugins/clarity.ts b/libs/jitsu-js/src/destination-plugins/clarity.ts new file mode 100644 index 000000000..dbac1c5e6 --- /dev/null +++ b/libs/jitsu-js/src/destination-plugins/clarity.ts @@ -0,0 +1,129 @@ +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; + // 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; + +type ClarityState = "fresh" | "loading" | "loaded" | "failed"; + +function getClarityState(): ClarityState { + return window["__jitsuClarityState"] || "fresh"; +} + +function setClarityState(s: ClarityState) { + window["__jitsuClarityState"] = s; +} + +// 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); + + // 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) { + if (getClarityState() !== "fresh") { + return; + } + setClarityState("loading"); + + // Install the self-queuing stub (mirrors Clarity's official snippet). + window["clarity"] = + window["clarity"] || + function () { + (window["clarity"].q = window["clarity"].q || []).push(arguments); + }; + + 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"); + }); + + 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..67d38e283 --- /dev/null +++ b/libs/jitsu-js/src/destination-plugins/hotjar.ts @@ -0,0 +1,107 @@ +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"; +} & CommonDestinationCredentials; + +// Hotjar protocol version. The tag URL and _hjSettings must agree on this. +const HOTJAR_VERSION = 6; + +type HotjarState = "fresh" | "loading" | "loaded" | "failed"; + +function getHotjarState(): HotjarState { + return window["__jitsuHotjarState"] || "fresh"; +} + +function setHotjarState(s: HotjarState) { + window["__jitsuHotjarState"] = s; +} + +// 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); + + // 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) { + if (getHotjarState() !== "fresh") { + return; + } + setHotjarState("loading"); + + // Install the self-queuing stub (mirrors Hotjar's official snippet). + window["hj"] = + window["hj"] || + function () { + (window["hj"].q = window["hj"].q || []).push(arguments); + }; + 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"); + }); +} 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..67ac4e64d 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,74 @@ 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." + ), + 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." + ), + 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 +481,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..d2ca232b2 --- /dev/null +++ b/webapps/console/lib/schema/icons/clarity.tsx @@ -0,0 +1,13 @@ +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 ( + + + +); From 00be5a13739ec8f87803fbaf362c712194a6e21d Mon Sep 17 00:00:00 2001 From: Vladimir Klimontovich Date: Tue, 28 Jul 2026 09:01:12 -0400 Subject: [PATCH 2/7] JITSU-135 feat(console): add loadClarity/loadHotjar toggle Mirrors GTM's loadGtm. When off, Jitsu skips injecting the vendor tag (and, for Hotjar, skips setting _hjSettings) and only forwards events to the already-loaded window.clarity/hj. The self-queuing stub is still installed so early events aren't lost. Avoids double-loading Clarity/Hotjar when the page already includes them. The projectId/siteId is only used when Jitsu loads the tag. --- .../src/destination-plugins/clarity.ts | 29 +++++++++++----- .../src/destination-plugins/hotjar.ts | 34 +++++++++++++------ webapps/console/lib/schema/destinations.tsx | 16 +++++++-- 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/libs/jitsu-js/src/destination-plugins/clarity.ts b/libs/jitsu-js/src/destination-plugins/clarity.ts index dbac1c5e6..2e85969e9 100644 --- a/libs/jitsu-js/src/destination-plugins/clarity.ts +++ b/libs/jitsu-js/src/destination-plugins/clarity.ts @@ -7,6 +7,10 @@ export type ClarityDestinationCredentials = { 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. @@ -107,21 +111,28 @@ function initClarityIfNeeded(config: ClarityDestinationCredentials) { } setClarityState("loading"); - // Install the self-queuing stub (mirrors Clarity's official snippet). + // Install the self-queuing stub (mirrors Clarity's official snippet). Even when Jitsu does not + // load the tag, this ensures events fired before the page's own Clarity script runs are queued + // and drained by it once it loads. window["clarity"] = window["clarity"] || function () { (window["clarity"].q = window["clarity"].q || []).push(arguments); }; - 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"); - }); + if (config.loadClarity !== false) { + 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"); + }); + } else { + // The page loads the Clarity tag itself; we only forward events to it. + setClarityState("loaded"); + } 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 index 67d38e283..a7e7eed62 100644 --- a/libs/jitsu-js/src/destination-plugins/hotjar.ts +++ b/libs/jitsu-js/src/destination-plugins/hotjar.ts @@ -12,6 +12,10 @@ export type HotjarDestinationCredentials = { // 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. @@ -88,20 +92,30 @@ function initHotjarIfNeeded(config: HotjarDestinationCredentials) { } setHotjarState("loading"); - // Install the self-queuing stub (mirrors Hotjar's official snippet). + // Install the self-queuing stub (mirrors Hotjar's official snippet). Even when Jitsu does not + // load the tag, this ensures events fired before the page's own Hotjar script runs are queued + // and drained by it once it loads. window["hj"] = window["hj"] || function () { (window["hj"].q = window["hj"].q || []).push(arguments); }; - 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"); - }); + if (config.loadHotjar !== false) { + // 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"); + }); + } else { + // The page loads the Hotjar tag itself; we only forward events to it. + setHotjarState("loaded"); + } } diff --git a/webapps/console/lib/schema/destinations.tsx b/webapps/console/lib/schema/destinations.tsx index 67ac4e64d..83337df80 100644 --- a/webapps/console/lib/schema/destinations.tsx +++ b/webapps/console/lib/schema/destinations.tsx @@ -333,7 +333,13 @@ const clarityDestination = { 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." + "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() @@ -367,7 +373,13 @@ const hotjarDestination = { 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." + "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() From ebda1868867666e3dd34c02b066313ddeeaac6c5 Mon Sep 17 00:00:00 2001 From: Vladimir Klimontovich Date: Tue, 28 Jul 2026 09:08:34 -0400 Subject: [PATCH 3/7] JITSU-135 feat(console): use official Microsoft Clarity logo Replaces the hand-drawn placeholder with Clarity's official mark, sourced from Clarity's own static assets (claritystatic.azureedge.net/images/logo.svg). --- webapps/console/lib/schema/icons/clarity.tsx | 48 +++++++++++++++++--- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/webapps/console/lib/schema/icons/clarity.tsx b/webapps/console/lib/schema/icons/clarity.tsx index d2ca232b2..f9a340874 100644 --- a/webapps/console/lib/schema/icons/clarity.tsx +++ b/webapps/console/lib/schema/icons/clarity.tsx @@ -1,13 +1,47 @@ export default ( - + + + + + - - - + + + + + + + + + + + - - - ); From ea0dcdf8ffcfcae8f4a200d5bb87bc33385b9d8d Mon Sep 17 00:00:00 2001 From: vklimontovich Date: Tue, 28 Jul 2026 09:37:12 -0400 Subject: [PATCH 4/7] fix: register clarity/hotjar builtin destinations in rotor Device destinations must also be registered as no-op builtin functions so rotor's function-chain builder resolves them; without the entry getBuiltinFunction returns undefined and rotor throws "no functions assigned to it's destination type" for clarity/hotjar connections. Mirrors the existing tag/gtm/logrocket/ga4-tag no-op entries. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01LVmXNhA3FRyKRc4cdK1k4i --- libs/destination-functions/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) 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 = { From 5dbb6fa3f556b53c384dbab2d0a31a3db0fa589b Mon Sep 17 00:00:00 2001 From: vklimontovich Date: Tue, 28 Jul 2026 10:11:04 -0400 Subject: [PATCH 5/7] fix(jitsu-js): harden clarity/hotjar singleton state and failed-load queue Addresses code-review findings: - window.clarity / window.hj are vendor-owned singletons, so a second destination with a different projectId/siteId can't get its own instance. Track the active id and warn on mismatch instead of silently routing events to the wrong project/site (first destination to load the tag wins). - On script-load failure, replace the queuing stub with a no-op (dropping its queue) and short-circuit handle() when state is "failed", so events no longer accumulate unbounded in window.clarity.q / window.hj.q for the rest of the page lifetime. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01LVmXNhA3FRyKRc4cdK1k4i --- .../src/destination-plugins/clarity.ts | 30 +++++++++++++++++++ .../src/destination-plugins/hotjar.ts | 30 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/libs/jitsu-js/src/destination-plugins/clarity.ts b/libs/jitsu-js/src/destination-plugins/clarity.ts index 2e85969e9..079209c46 100644 --- a/libs/jitsu-js/src/destination-plugins/clarity.ts +++ b/libs/jitsu-js/src/destination-plugins/clarity.ts @@ -27,6 +27,16 @@ 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 { @@ -66,6 +76,12 @@ export const clarityPlugin: InternalPlugin = { } 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"]; @@ -107,6 +123,16 @@ export const clarityPlugin: InternalPlugin = { function initClarityIfNeeded(config: ClarityDestinationCredentials) { if (getClarityState() !== "fresh") { + // window.clarity is a singleton owned by the vendor script; a second destination pointing at a + // different projectId can't get its own instance. Warn rather than silently routing events to + // the wrong project - the first destination that loads the tag wins. + 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; } setClarityState("loading"); @@ -121,6 +147,7 @@ function initClarityIfNeeded(config: ClarityDestinationCredentials) { }; if (config.loadClarity !== false) { + setClarityProjectId(config.projectId); loadScript(`clarity.ms/tag/${config.projectId}`, { www: true }) .then(() => { setClarityState("loaded"); @@ -128,6 +155,9 @@ function initClarityIfNeeded(config: ClarityDestinationCredentials) { .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 () {}; }); } else { // The page loads the Clarity tag itself; we only forward events to it. diff --git a/libs/jitsu-js/src/destination-plugins/hotjar.ts b/libs/jitsu-js/src/destination-plugins/hotjar.ts index a7e7eed62..b9ae9a497 100644 --- a/libs/jitsu-js/src/destination-plugins/hotjar.ts +++ b/libs/jitsu-js/src/destination-plugins/hotjar.ts @@ -31,6 +31,16 @@ 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 { @@ -45,6 +55,12 @@ export const hotjarPlugin: InternalPlugin = { } 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"]; @@ -88,6 +104,16 @@ export const hotjarPlugin: InternalPlugin = { function initHotjarIfNeeded(config: HotjarDestinationCredentials) { if (getHotjarState() !== "fresh") { + // window.hj is a singleton owned by the vendor script; a second destination pointing at a + // different siteId can't get its own instance. Warn rather than silently routing events to the + // wrong site - the first destination that loads the tag wins. + 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; } setHotjarState("loading"); @@ -102,6 +128,7 @@ function initHotjarIfNeeded(config: HotjarDestinationCredentials) { }; if (config.loadHotjar !== false) { + 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 }; @@ -113,6 +140,9 @@ function initHotjarIfNeeded(config: HotjarDestinationCredentials) { .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 { // The page loads the Hotjar tag itself; we only forward events to it. From ce4b34c5946c485fecfa32df0467b2b3ab80e42a Mon Sep 17 00:00:00 2001 From: vklimontovich Date: Tue, 28 Jul 2026 10:18:32 -0400 Subject: [PATCH 6/7] fix(jitsu-js): let a load-enabled clarity/hotjar destination init after a passive one Follow-up code-review finding: when loadClarity/loadHotjar===false ran first it set global state to "loaded", so a later load-enabled destination early-returned at the guard and never loaded the real SDK - stranding events in window.clarity.q / window.hj.q forever. Introduce a transitional "passive" state for the load-it-yourself path. A subsequent load-enabled destination transitions passive -> loading and takes ownership of loading the tag. Only loading/loaded/failed are terminal for the singleton-ownership guard. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01LVmXNhA3FRyKRc4cdK1k4i --- .../src/destination-plugins/clarity.ts | 44 ++++++++++++------- .../src/destination-plugins/hotjar.ts | 32 +++++++++----- 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/libs/jitsu-js/src/destination-plugins/clarity.ts b/libs/jitsu-js/src/destination-plugins/clarity.ts index 079209c46..be22e33db 100644 --- a/libs/jitsu-js/src/destination-plugins/clarity.ts +++ b/libs/jitsu-js/src/destination-plugins/clarity.ts @@ -17,7 +17,9 @@ export type ClarityDestinationCredentials = { trackProperties?: "tags" | "ignore"; } & CommonDestinationCredentials; -type ClarityState = "fresh" | "loading" | "loaded" | "failed"; +// "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"; function getClarityState(): ClarityState { return window["__jitsuClarityState"] || "fresh"; @@ -122,10 +124,12 @@ export const clarityPlugin: InternalPlugin = { }; function initClarityIfNeeded(config: ClarityDestinationCredentials) { - if (getClarityState() !== "fresh") { - // window.clarity is a singleton owned by the vendor script; a second destination pointing at a - // different projectId can't get its own instance. Warn rather than silently routing events to - // the wrong project - the first destination that loads the tag wins. + 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( @@ -135,11 +139,11 @@ function initClarityIfNeeded(config: ClarityDestinationCredentials) { } return; } - setClarityState("loading"); - // Install the self-queuing stub (mirrors Clarity's official snippet). Even when Jitsu does not - // load the tag, this ensures events fired before the page's own Clarity script runs are queued - // and drained by it once it loads. + // 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 () { @@ -147,6 +151,9 @@ function initClarityIfNeeded(config: ClarityDestinationCredentials) { }; 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(() => { @@ -159,12 +166,17 @@ function initClarityIfNeeded(config: ClarityDestinationCredentials) { // the rest of the page lifetime don't accumulate in window.clarity.q. window["clarity"] = function () {}; }); - } else { - // The page loads the Clarity tag itself; we only forward events to it. - setClarityState("loaded"); - } - - if (config.cookieConsent) { - window["clarity"]("consent"); + 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 index b9ae9a497..a78b811b6 100644 --- a/libs/jitsu-js/src/destination-plugins/hotjar.ts +++ b/libs/jitsu-js/src/destination-plugins/hotjar.ts @@ -21,7 +21,9 @@ export type HotjarDestinationCredentials = { // Hotjar protocol version. The tag URL and _hjSettings must agree on this. const HOTJAR_VERSION = 6; -type HotjarState = "fresh" | "loading" | "loaded" | "failed"; +// "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"; function getHotjarState(): HotjarState { return window["__jitsuHotjarState"] || "fresh"; @@ -103,10 +105,12 @@ export const hotjarPlugin: InternalPlugin = { }; function initHotjarIfNeeded(config: HotjarDestinationCredentials) { - if (getHotjarState() !== "fresh") { - // window.hj is a singleton owned by the vendor script; a second destination pointing at a - // different siteId can't get its own instance. Warn rather than silently routing events to the - // wrong site - the first destination that loads the tag wins. + 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( @@ -116,11 +120,10 @@ function initHotjarIfNeeded(config: HotjarDestinationCredentials) { } return; } - setHotjarState("loading"); - // Install the self-queuing stub (mirrors Hotjar's official snippet). Even when Jitsu does not - // load the tag, this ensures events fired before the page's own Hotjar script runs are queued - // and drained by it once it loads. + // 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 () { @@ -128,6 +131,9 @@ function initHotjarIfNeeded(config: HotjarDestinationCredentials) { }; 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. @@ -144,8 +150,10 @@ function initHotjarIfNeeded(config: HotjarDestinationCredentials) { // the rest of the page lifetime don't accumulate in window.hj.q. window["hj"] = function () {}; }); - } else { - // The page loads the Hotjar tag itself; we only forward events to it. - setHotjarState("loaded"); + } 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"); } } From d871271911c7bcb376ecc6af8a70de3c8ffd657a Mon Sep 17 00:00:00 2001 From: vklimontovich Date: Tue, 28 Jul 2026 10:24:40 -0400 Subject: [PATCH 7/7] fix(jitsu-js): cap the clarity/hotjar offline stub queue Follow-up code-review question: in loadClarity/loadHotjar===false mode, if the page never loads the vendor tag (misconfig, blocked snippet), the self-queuing stub would grow window.clarity.q / window.hj.q unbounded for the whole session. Cap the stub queue at 1000 entries. The real vendor script drains the queue within seconds, so the cap is only reached when the tag never loads; it's generous enough that a normal slow load never drops events. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01LVmXNhA3FRyKRc4cdK1k4i --- libs/jitsu-js/src/destination-plugins/clarity.ts | 11 ++++++++++- libs/jitsu-js/src/destination-plugins/hotjar.ts | 11 ++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/libs/jitsu-js/src/destination-plugins/clarity.ts b/libs/jitsu-js/src/destination-plugins/clarity.ts index be22e33db..845d61500 100644 --- a/libs/jitsu-js/src/destination-plugins/clarity.ts +++ b/libs/jitsu-js/src/destination-plugins/clarity.ts @@ -21,6 +21,12 @@ export type ClarityDestinationCredentials = { // 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"; } @@ -147,7 +153,10 @@ function initClarityIfNeeded(config: ClarityDestinationCredentials) { window["clarity"] = window["clarity"] || function () { - (window["clarity"].q = window["clarity"].q || []).push(arguments); + const q = (window["clarity"].q = window["clarity"].q || []); + if (q.length < STUB_QUEUE_CAP) { + q.push(arguments); + } }; if (config.loadClarity !== false) { diff --git a/libs/jitsu-js/src/destination-plugins/hotjar.ts b/libs/jitsu-js/src/destination-plugins/hotjar.ts index a78b811b6..ddddb09e5 100644 --- a/libs/jitsu-js/src/destination-plugins/hotjar.ts +++ b/libs/jitsu-js/src/destination-plugins/hotjar.ts @@ -25,6 +25,12 @@ const HOTJAR_VERSION = 6; // 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"; } @@ -127,7 +133,10 @@ function initHotjarIfNeeded(config: HotjarDestinationCredentials) { window["hj"] = window["hj"] || function () { - (window["hj"].q = window["hj"].q || []).push(arguments); + const q = (window["hj"].q = window["hj"].q || []); + if (q.length < STUB_QUEUE_CAP) { + q.push(arguments); + } }; if (config.loadHotjar !== false) {