From cff35e3660989d950f62e9ca3a13864d47605201 Mon Sep 17 00:00:00 2001 From: ZCode Date: Mon, 21 Sep 2026 09:13:27 +0000 Subject: [PATCH] fix(analytics): actually load the OpenPanel browser script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit analytics.ts called window.op?.(…) but nothing ever defined that global — no script tag in index.html and @openpanel/web was never imported — so every call was a silent no-op and product analytics would have stayed empty no matter how the build args were set. Now, when VITE_OPENPANEL_SCRIPT_URL is configured, the loader installs OpenPanel's documented queueing shim (window.op becomes a queue) and appends op1.js async; the existing op("init"/"identify"/"track") calls are queued and drained when it loads, so ordering cannot break them. Without the URL the behaviour is unchanged (no-op), which keeps local dev and air-gapped builds silent. Build args: VITE_OPENPANEL_URL (API base, e.g. https://stats./api), VITE_OPENPANEL_CLIENT_ID, VITE_OPENPANEL_SCRIPT_URL (e.g. https://stats./op1.js). The client *secret* is deliberately not a frontend arg — it belongs to the API only. --- frontend/src/lib/analytics.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/frontend/src/lib/analytics.ts b/frontend/src/lib/analytics.ts index 2362fc1..8269b65 100644 --- a/frontend/src/lib/analytics.ts +++ b/frontend/src/lib/analytics.ts @@ -22,8 +22,31 @@ declare global { const OPENPANEL_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim(); const OPENPANEL_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim(); +const OPENPANEL_SCRIPT_URL = (import.meta.env?.VITE_OPENPANEL_SCRIPT_URL as string | undefined)?.trim(); const SENTRY_DSN = (import.meta.env?.VITE_SENTRY_DSN as string | undefined)?.trim(); +/** + * Load the OpenPanel browser script (op1.js) and its queueing shim. + * + * The shim is OpenPanel's documented snippet: `window.op` becomes a queue while the + * script is in flight, so `op("init", …)` can be called immediately and the real + * script drains the queue on load. Without it, every `window.op?.()` call here is a + * silent no-op — which is what happened before this loader existed. + */ +function loadOpenPanelScript(src: string): void { + if (document.querySelector("script[data-openpanel]")) return; + const shim = document.createElement("script"); + shim.textContent = + 'window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}},has:function(t,r){return"q"===r}}) }();'; + document.head.appendChild(shim); + const script = document.createElement("script"); + script.src = src; + script.async = true; + script.defer = true; + script.dataset.openpanel = "1"; + document.head.appendChild(script); +} + export function initAnalytics(): void { if (SENTRY_DSN) { Sentry.init({ @@ -35,6 +58,7 @@ export function initAnalytics(): void { } if (OPENPANEL_URL && OPENPANEL_CLIENT_ID) { try { + if (OPENPANEL_SCRIPT_URL) loadOpenPanelScript(OPENPANEL_SCRIPT_URL); window.op?.("init", { apiUrl: OPENPANEL_URL, clientId: OPENPANEL_CLIENT_ID,