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.<host>/api),
VITE_OPENPANEL_CLIENT_ID, VITE_OPENPANEL_SCRIPT_URL (e.g. https://stats.<host>/op1.js).
The client *secret* is deliberately not a frontend arg — it belongs to the API only.
105 lines
3.6 KiB
TypeScript
105 lines
3.6 KiB
TypeScript
// ============================================================
|
|
// Product analytics + error capture (Phase 2)
|
|
//
|
|
// - OpenPanel (@openpanel/web): funnels/usage — initialized only when
|
|
// VITE_OPENPANEL_URL and VITE_OPENPANEL_CLIENT_ID are set (build args),
|
|
// so local dev and air-gapped environments stay silent.
|
|
// - GlitchTip (@sentry/react): frontend exception capture, only when
|
|
// VITE_SENTRY_DSN is set.
|
|
//
|
|
// Both are no-ops without configuration; every helper is safe to call
|
|
// unconditionally from anywhere in the app.
|
|
// ============================================================
|
|
import * as Sentry from "@sentry/react";
|
|
|
|
type Props = Record<string, unknown>;
|
|
|
|
declare global {
|
|
interface Window {
|
|
op?: (...args: unknown[]) => void;
|
|
}
|
|
}
|
|
|
|
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({
|
|
dsn: SENTRY_DSN,
|
|
environment: (import.meta.env?.MODE as string | undefined) ?? "production",
|
|
// Frontend traces are expensive and rarely needed — errors only.
|
|
tracesSampleRate: 0,
|
|
});
|
|
}
|
|
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,
|
|
trackScreenViews: true, // pageview → which pages users visit
|
|
});
|
|
} catch {
|
|
/* analytics must never break the app */
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Attach the ERP user identity to analytics (after login / app load). */
|
|
export function identifyUser(profile: {
|
|
id?: string;
|
|
name?: string;
|
|
role?: string | null;
|
|
}): void {
|
|
if (!profile.id) return;
|
|
try {
|
|
window.op?.("identify", {
|
|
profileId: profile.id,
|
|
firstName: profile.name || undefined,
|
|
data: { role: profile.role ?? undefined },
|
|
});
|
|
track("user_identified", { role: profile.role ?? undefined });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
export function track(event: string, properties?: Props): void {
|
|
try {
|
|
window.op?.("track", event, properties ?? {});
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
export function captureError(err: unknown): void {
|
|
if (SENTRY_DSN) Sentry.captureException(err);
|
|
}
|
|
|
|
export const analyticsEnabled = Boolean(OPENPANEL_URL && OPENPANEL_CLIENT_ID);
|