-
Notifications
You must be signed in to change notification settings - Fork 360
JITSU-135 feat(console): add Microsoft Clarity and Hotjar device destinations #1424
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
vklimontovich
merged 8 commits into
newjitsu
from
jitsu-135-clarity-hotjar-destinations
Jul 28, 2026
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
1784ba7
JITSU-135 feat(console): add Microsoft Clarity and Hotjar device dest…
vklimontovich 00be5a1
JITSU-135 feat(console): add loadClarity/loadHotjar toggle
vklimontovich ebda186
JITSU-135 feat(console): use official Microsoft Clarity logo
vklimontovich ea0dcdf
fix: register clarity/hotjar builtin destinations in rotor
vklimontovich 4b4a87f
Merge remote-tracking branch 'origin/newjitsu' into jitsu-135-clarity…
vklimontovich 5dbb6fa
fix(jitsu-js): harden clarity/hotjar singleton state and failed-load …
vklimontovich ce4b34c
fix(jitsu-js): let a load-enabled clarity/hotjar destination init aft…
vklimontovich d871271
fix(jitsu-js): cap the clarity/hotjar offline stub queue
vklimontovich File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, any> | 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<ClarityDestinationCredentials> = { | ||
| 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"); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HotjarDestinationCredentials> = { | ||
| 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); | ||
|
vklimontovich marked this conversation as resolved.
|
||
| 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"); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.