Skip to content
Merged
8 changes: 8 additions & 0 deletions bulker/ingest/destination_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
}
2 changes: 2 additions & 0 deletions libs/destination-functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const builtinDestinations: Record<BuiltinDestinationFunctionName, JitsuFunction>
"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<BuiltinTransformationFunctionName, JitsuFunction> = {
Expand Down
191 changes: 191 additions & 0 deletions libs/jitsu-js/src/destination-plugins/clarity.ts
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);
Comment thread
vklimontovich marked this conversation as resolved.
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");
}
}
}
168 changes: 168 additions & 0 deletions libs/jitsu-js/src/destination-plugins/hotjar.ts
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);
Comment thread
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");
}
}
4 changes: 4 additions & 0 deletions libs/jitsu-js/src/destination-plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = {
id: string;
Expand Down Expand Up @@ -58,4 +60,6 @@ export const internalDestinationPlugins: Record<string, InternalPlugin<any>> = {
[gtmPlugin.id]: gtmPlugin,
[ga4Plugin.id]: ga4Plugin,
[logrocketPlugin.id]: logrocketPlugin,
[clarityPlugin.id]: clarityPlugin,
[hotjarPlugin.id]: hotjarPlugin,
};
Loading
Loading