import posthog from "posthog-js"; import { BotData } from "~/types/bot"; import { WidgetType } from "./session-storage"; import { devLog } from "./sentry"; import { fetchRenderingEventsConfig } from "./api"; // PostHog singleton instance let isInitialized = false; // Session-level guards to prevent duplicate tracking let widgetPresentTracked = false; let widgetVisibleTracked: Record = {}; // Store bot data for use in event properties (anonymous mode) let currentBotData: BotData | null = null; /** * Check if user is a WordPress admin (seller) * Sellers get 100% session recording for testing their widget */ function isWordPressAdmin(): boolean { if (typeof window === "undefined") return false; // Check for WordPress admin indicators const isAdminPage = document.body.classList.contains("wp-admin") || window.location.href.includes("/wp-admin/"); const hasAdminCookie = document.cookie.includes("wordpress_logged_in"); return isAdminPage || hasAdminCookie; } /** * Initialize PostHog analytics in anonymous mode * No identify() or setPersonProperties() calls - events are ~4x cheaper * Bot data is passed as event properties instead of person properties */ export function initializePostHog( botData: BotData, widgetType: WidgetType ): void { if (isInitialized) { devLog.request( "initializePostHog", "PostHog already initialized", "SKIP", {} ); return; } const apiKey = "phc_W4WGCkuBDFaLvVhQiaIOyHclPPpkjEECUamKMNrtOpL"; const apiHost = "https://us.i.posthog.com"; if (!apiKey || !apiHost) { console.warn("[Mori Widget] PostHog credentials not configured"); return; } try { // Store bot data for use in event properties currentBotData = botData; posthog.init(apiKey, { api_host: apiHost, defaults: "2025-11-30", // Anonymous mode: never create person profiles (4x cheaper events) person_profiles: "identified_only", capture_pageview: false, // We manually track widget_opened capture_pageleave: false, // Not relevant for embedded widget autocapture: false, // Manual events only for cleaner data disable_session_recording: false, // Controlled by dashboard settings capture_performance: false, // Disable $web_vitals to reduce event volume session_recording: { recordCrossOriginIframes: false, // Not needed in Shadow DOM }, loaded: () => { // Anonymous mode: NO identify(), setPersonProperties(), or group() calls // This keeps all events anonymous and ~4x cheaper // Bot data is passed as event properties in each capture() call instead // Get plugin version from WordPress script data (try window.chatbotData first, then data attribute) const getPluginVersion = (): string | undefined => { if (typeof window === "undefined") return undefined; // Try window.chatbotData first const fromWindow = (window as any).chatbotData?.pluginVersion; if (fromWindow) return fromWindow; // Fallback to script data attribute const widgetScriptTag = document.querySelector( "script#mori-react-widget" ) as HTMLScriptElement | null; return widgetScriptTag?.getAttribute("data-plugin-version") || undefined; }; // Register plugin_version as a super property (included in all events) const registerPluginVersion = (version: string) => { posthog.register({ plugin_version: version }); }; let pluginVersion = getPluginVersion(); if (pluginVersion) { registerPluginVersion(pluginVersion); } else { // Poll for plugin version if not immediately available (async loading) let attempts = 0; const maxAttempts = 20; // 2 seconds max const pollInterval = setInterval(() => { attempts++; pluginVersion = getPluginVersion(); if (pluginVersion) { clearInterval(pollInterval); registerPluginVersion(pluginVersion); } else if (attempts >= maxAttempts) { clearInterval(pollInterval); } }, 100); } devLog.success(`PostHog initialized (anonymous mode) for ${widgetType}`, 0, 0, 200); }, }); isInitialized = true; } catch (error) { console.error("[Mori Widget] Failed to initialize PostHog:", error); } } /** * Track widget_opened event and start session recording * This also handles lazy initialization of PostHog on first widget open */ export function trackWidgetOpened( widgetType: WidgetType, triggerMethod: "fab_click" | "auto_expand" | "manual" = "fab_click", botData?: BotData | null ): void { // Lazy initialization: only initialize PostHog when widget is actually opened if (!isInitialized && botData) { initializePostHog(botData, widgetType); } if (!isInitialized) { // PostHog not available (missing credentials or initialization failed) // Fail gracefully - widget continues to work normally return; } try { posthog.capture("widget_opened", { widget_type: widgetType, trigger_method: triggerMethod, // Bot properties for filtering (anonymous mode) bot_id: currentBotData?.sharing_id, bot_name: currentBotData?.bot_name, timestamp: new Date().toISOString(), }); // Start session recording when widget opens // Override sampling for WordPress admins (sellers) if (isWordPressAdmin()) { // Force session recording for WordPress admins (sellers) posthog.startSessionRecording(); } else { // For regular users, respect dashboard sampling settings posthog.startSessionRecording(); } devLog.request("trackWidgetOpened", "widget_opened event", "POST", { widget_type: widgetType, trigger_method: triggerMethod, }); } catch (error) { console.error("[Mori Widget] Failed to track widget_opened:", error); } } /** * Track message_sent event */ export function trackMessageSent( threadId: string | null, messageLength: number, hasImage: boolean = false, isRetry: boolean = false ): void { if (!isInitialized) return; try { posthog.capture("message_sent", { thread_id: threadId, message_length: messageLength, has_image: hasImage, is_retry: isRetry, // Bot properties for filtering (anonymous mode) bot_id: currentBotData?.sharing_id, bot_name: currentBotData?.bot_name, timestamp: new Date().toISOString(), }); devLog.request("trackMessageSent", "message_sent event", "POST", { thread_id: threadId, message_length: messageLength, }); } catch (error) { console.error("[Mori Widget] Failed to track message_sent:", error); } } /** * Track product_clicked event (revenue attribution) */ export function trackProductClicked( productId: string, threadId: string | null, productUrl?: string, toolCallId?: string ): void { if (!isInitialized) return; try { posthog.capture("product_clicked", { product_id: productId, thread_id: threadId, product_url: productUrl, tool_call_id: toolCallId, // Bot properties for filtering (anonymous mode) bot_id: currentBotData?.sharing_id, bot_name: currentBotData?.bot_name, timestamp: new Date().toISOString(), }); devLog.request("trackProductClicked", "product_clicked event", "POST", { product_id: productId, thread_id: threadId, }); } catch (error) { console.error("[Mori Widget] Failed to track product_clicked:", error); } } /** * Track widget_error event */ export function trackWidgetError( errorType: string, errorMessage: string, operation: string, threadId?: string | null ): void { if (!isInitialized) return; try { posthog.capture("widget_error", { error_type: errorType, error_message: errorMessage, operation, thread_id: threadId, // Bot properties for filtering (anonymous mode) bot_id: currentBotData?.sharing_id, bot_name: currentBotData?.bot_name, timestamp: new Date().toISOString(), }); } catch (error) { console.error("[Mori Widget] Failed to track widget_error:", error); } } /** * Track widget_closed event */ export function trackWidgetClosed( sessionDuration: number, messagesSentCount: number ): void { if (!isInitialized) return; try { posthog.capture("widget_closed", { session_duration: sessionDuration, messages_sent_count: messagesSentCount, // Bot properties for filtering (anonymous mode) bot_id: currentBotData?.sharing_id, bot_name: currentBotData?.bot_name, timestamp: new Date().toISOString(), }); devLog.request("trackWidgetClosed", "widget_closed event", "POST", { session_duration: sessionDuration, messages_sent_count: messagesSentCount, }); } catch (error) { console.error("[Mori Widget] Failed to track widget_closed:", error); } } /** * Track widget_present event - fired once when widget initializes * This indicates the widget script loaded and rendered successfully * Checks backend API to see if rendering events are enabled before sending */ export async function trackWidgetPresent( widgetType: WidgetType, botData?: BotData | null ): Promise { // Check if rendering events are enabled via API try { const config = await fetchRenderingEventsConfig(); if (!config.enabled) { devLog.request( "trackWidgetPresent", "Skipped - rendering events disabled", "SKIP", { widget_type: widgetType } ); return; } } catch (error) { // If API check fails, skip sending to avoid hitting PostHog limits devLog.error(error as Error, "trackWidgetPresent", { context: "api_check_failed", widget_type: widgetType, }); return; } // Lazy initialization if needed if (!isInitialized && botData) { initializePostHog(botData, widgetType); } if (!isInitialized || widgetPresentTracked) return; try { posthog.capture("widget_present", { widget_type: widgetType, page_url: window.location.href, // Bot properties for filtering (anonymous mode) bot_id: currentBotData?.sharing_id, bot_name: currentBotData?.bot_name, timestamp: new Date().toISOString(), }); widgetPresentTracked = true; devLog.request("trackWidgetPresent", "widget_present event", "POST", { widget_type: widgetType, }); } catch (error) { console.error("[Mori Widget] Failed to track widget_present:", error); } } /** * Track widget_visible event - fired once when widget is ≥50% visible in viewport * Uses IntersectionObserver threshold of 0.5 * Checks backend API to see if rendering events are enabled before sending */ export async function trackWidgetVisible(widgetType: WidgetType): Promise { // Check if rendering events are enabled via API try { const config = await fetchRenderingEventsConfig(); if (!config.enabled) { devLog.request( "trackWidgetVisible", "Skipped - rendering events disabled", "SKIP", { widget_type: widgetType } ); return; } } catch (error) { // If API check fails, skip sending to avoid hitting PostHog limits devLog.error(error as Error, "trackWidgetVisible", { context: "api_check_failed", widget_type: widgetType, }); return; } if (!isInitialized || widgetVisibleTracked[widgetType]) return; try { posthog.capture("widget_visible", { widget_type: widgetType, // Bot properties for filtering (anonymous mode) bot_id: currentBotData?.sharing_id, bot_name: currentBotData?.bot_name, timestamp: new Date().toISOString(), }); widgetVisibleTracked[widgetType] = true; devLog.request("trackWidgetVisible", "widget_visible event", "POST", { widget_type: widgetType, }); } catch (error) { console.error("[Mori Widget] Failed to track widget_visible:", error); } } /** * Reset PostHog state (useful for testing) */ export function resetPostHog(): void { if (isInitialized) { posthog.reset(); isInitialized = false; widgetPresentTracked = false; widgetVisibleTracked = {}; currentBotData = null; } } /** * Update the stored bot data for use in event properties. * Call this whenever botData becomes available or changes. * This ensures all events have correct bot_id even if PostHog * was initialized in a previous session. */ export function updateBotData(botData: BotData | null): void { if (botData) { currentBotData = botData; } } // Export the posthog instance for advanced usage export { posthog };