From 1c48ac1243016bae2eb0351afe9c32c8304ed19e Mon Sep 17 00:00:00 2001 From: Omer Katzir Date: Thu, 25 Dec 2025 16:44:49 +0200 Subject: [PATCH 01/10] Custom-Events | Analytics module --- src/client.ts | 6 ++ src/client.types.ts | 2 + src/modules/analytics.ts | 121 +++++++++++++++++++++++++++++++++ src/modules/analytics.types.ts | 41 +++++++++++ src/modules/types.ts | 3 +- src/utils/singleton.ts | 24 +++++++ 6 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 src/modules/analytics.ts create mode 100644 src/modules/analytics.types.ts create mode 100644 src/utils/singleton.ts diff --git a/src/client.ts b/src/client.ts index 34afc83..24eedab 100644 --- a/src/client.ts +++ b/src/client.ts @@ -15,6 +15,7 @@ import type { CreateClientConfig, CreateClientOptions, } from "./client.types.js"; +import { createAnalyticsModule } from "./modules/analytics.js"; // Re-export client types export type { Base44Client, CreateClientConfig, CreateClientOptions }; @@ -144,6 +145,11 @@ export function createClient(config: CreateClientConfig): Base44Client { }), appLogs: createAppLogsModule(axiosClient, appId), users: createUsersModule(axiosClient, appId), + analytics: createAnalyticsModule({ + axiosClient, + appId, + options: options?.analytics, + }), cleanup: () => { if (socket) { socket.disconnect(); diff --git a/src/client.types.ts b/src/client.types.ts index 40e3c87..239eadb 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -6,6 +6,7 @@ import type { ConnectorsModule } from "./modules/connectors.types.js"; import type { FunctionsModule } from "./modules/functions.types.js"; import type { AgentsModule } from "./modules/agents.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; +import type { AnalyticsModuleOptions } from "./modules/analytics.types.js"; /** * Options for creating a Base44 client. @@ -15,6 +16,7 @@ export interface CreateClientOptions { * Optional error handler that will be called whenever an API error occurs. */ onError?: (error: Error) => void; + analytics?: AnalyticsModuleOptions; } /** diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts new file mode 100644 index 0000000..24824b4 --- /dev/null +++ b/src/modules/analytics.ts @@ -0,0 +1,121 @@ +import { AxiosInstance } from "axios"; +import { + TrackEventParams, + TrackEventData, + AnalyticsApiRequestData, + AnalyticsApiBatchRequest, + TrackEventIntrinsicData, + AnalyticsModuleOptions, +} from "./analytics.types"; +import { + getSharedInstance, + getSharedInstanceRefCount, +} from "../utils/singleton"; + +/////////////////////////////////////////////// +//// shared queue for analytics events //// +/////////////////////////////////////////////// +type AnalyticsSharedState = { + requestsQueue: TrackEventData[]; +}; + +const ANALYTICS_SHARED_STATE_NAME = "analytics"; +// shared state// +const analyticsSharedState = getSharedInstance( + ANALYTICS_SHARED_STATE_NAME, + () => ({ + requestsQueue: [], + }) +); +/////////////////////////////////////////////// + +export interface AnalyticsModuleArgs { + axiosClient: AxiosInstance; + appId: string; + options?: AnalyticsModuleOptions; +} + +export const createAnalyticsModule = ({ + axiosClient, + appId, + options, +}: AnalyticsModuleArgs) => { + // prevent overflow of events // + const MAX_QUEUE_SIZE = 1000; + const isEnabled = options?.enabled !== false; + + const track = (params: TrackEventParams) => { + if ( + !isEnabled || + analyticsSharedState.requestsQueue.length >= MAX_QUEUE_SIZE + ) { + return; + } + const intrinsicData = getEventIntrinsicData(); + analyticsSharedState.requestsQueue.push({ + ...params, + ...intrinsicData, + }); + }; + + const flush = async (eventsData: TrackEventData[]) => { + const apiEvents = eventsData.map(transformEventDataToApiRequestData); + await axiosClient.request({ + method: "POST", + url: `/apps/${appId}/analytics/track/batch`, + data: { events: apiEvents }, + } as AnalyticsApiBatchRequest); + }; + + // start analytics processor only if it's the first instance and analytics is enabled // + if ( + getSharedInstanceRefCount(ANALYTICS_SHARED_STATE_NAME) <= 1 && + isEnabled + ) { + startAnalyticsProcessor(flush, options?.trackService); + } + + return { + track, + }; +}; + +async function startAnalyticsProcessor( + handleTrack: (trackRequest: TrackEventData[]) => Promise, + options?: { + throttleTime: number; + batchSize: number; + } +) { + const { throttleTime = 1000, batchSize = 30 } = options ?? {}; + while (true) { + await new Promise((resolve) => setTimeout(resolve, throttleTime)); + const requests = analyticsSharedState.requestsQueue.splice(0, batchSize); + if (requests.length > 0) { + try { + await handleTrack(requests); + } catch (error) { + // TODO: think about retries if needed + console.error("Error processing analytics request:", error); + } + } + } +} + +function getEventIntrinsicData(): TrackEventIntrinsicData { + return { + timestamp: new Date().toISOString(), + pageUrl: typeof window !== "undefined" ? window.location.href : null, + }; +} + +function transformEventDataToApiRequestData( + eventData: TrackEventData +): AnalyticsApiRequestData { + return { + event_name: eventData.eventName, + properties: eventData.properties, + timestamp: eventData.timestamp, + page_url: eventData.pageUrl, + }; +} diff --git a/src/modules/analytics.types.ts b/src/modules/analytics.types.ts new file mode 100644 index 0000000..853cc79 --- /dev/null +++ b/src/modules/analytics.types.ts @@ -0,0 +1,41 @@ +export type TrackEventProperties = { + [key: string]: string | number | boolean | null | undefined; +}; + +export type TrackEventParams = { + eventName: string; + properties?: TrackEventProperties; +}; + +export type TrackEventIntrinsicData = { + timestamp: string; + pageUrl?: string | null; +}; + +export type TrackEventData = { + properties?: TrackEventProperties; + eventName: string; +} & TrackEventIntrinsicData; + +export type AnalyticsApiRequestData = { + event_name: string; + properties?: TrackEventProperties; + timestamp?: string; + page_url?: string | null; +}; + +export type AnalyticsApiBatchRequest = { + method: "POST"; + url: `/apps/${string}/analytics/track/batch`; + data: { + events: AnalyticsApiRequestData[]; + }; +}; + +export type AnalyticsModuleOptions = { + enabled?: boolean; + trackService?: { + throttleTime: number; + batchSize: number; + }; +}; diff --git a/src/modules/types.ts b/src/modules/types.ts index cc37d8e..c7423e6 100644 --- a/src/modules/types.ts +++ b/src/modules/types.ts @@ -1,3 +1,4 @@ export * from "./app.types.js"; export * from "./agents.types.js"; -export * from "./connectors.types.js"; \ No newline at end of file +export * from "./connectors.types.js"; +export * from "./analytics.types.js"; \ No newline at end of file diff --git a/src/utils/singleton.ts b/src/utils/singleton.ts new file mode 100644 index 0000000..2fc43fc --- /dev/null +++ b/src/utils/singleton.ts @@ -0,0 +1,24 @@ + + + +// Singleton (shared between sdk instances)// +export function getSharedInstance(name: string, factory: () => T): T { + const windowObj: Window & { + base44?: { [key: string]: { instance: T; _refCount: number } }; + } = typeof window !== "undefined" ? (window as any) : { base44: {} }; + if (!windowObj.base44) { + windowObj.base44 = {}; + } + if (!windowObj.base44[name]) { + windowObj.base44[name] = { instance: factory(), _refCount: 1 }; + } + return windowObj.base44[name].instance; +} + +export function getSharedInstanceRefCount(name: string): number { + const windowObj: Window & { + base44?: { [key: string]: { instance: T; _refCount: number } }; + } = typeof window !== "undefined" ? (window as any) : { base44: {} }; + + return windowObj.base44?.[name]?._refCount ?? 0; +} From 00c583d9d18927e3c50b87bca100e7e47954cc3c Mon Sep 17 00:00:00 2001 From: Omer Katzir Date: Thu, 25 Dec 2025 18:41:25 +0200 Subject: [PATCH 02/10] switch to beacon --- src/client.ts | 18 +++++-- src/client.types.ts | 2 - src/modules/analytics.ts | 95 ++++++++++++++++++++++++++-------- src/modules/analytics.types.ts | 13 +++-- 4 files changed, 95 insertions(+), 33 deletions(-) diff --git a/src/client.ts b/src/client.ts index 24eedab..949a0cc 100644 --- a/src/client.ts +++ b/src/client.ts @@ -128,13 +128,20 @@ export function createClient(config: CreateClientConfig): Base44Client { interceptResponses: false, }); + const userAuthModule = createAuthModule( + axiosClient, + functionsAxiosClient, + appId, + { + appBaseUrl, + serverUrl, + } + ); + const userModules = { entities: createEntitiesModule(axiosClient, appId), integrations: createIntegrationsModule(axiosClient, appId), - auth: createAuthModule(axiosClient, functionsAxiosClient, appId, { - appBaseUrl, - serverUrl, - }), + auth: userAuthModule, functions: createFunctionsModule(functionsAxiosClient, appId), agents: createAgentsModule({ axios: axiosClient, @@ -147,8 +154,9 @@ export function createClient(config: CreateClientConfig): Base44Client { users: createUsersModule(axiosClient, appId), analytics: createAnalyticsModule({ axiosClient, + serverUrl, appId, - options: options?.analytics, + userAuthModule, }), cleanup: () => { if (socket) { diff --git a/src/client.types.ts b/src/client.types.ts index 239eadb..40e3c87 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -6,7 +6,6 @@ import type { ConnectorsModule } from "./modules/connectors.types.js"; import type { FunctionsModule } from "./modules/functions.types.js"; import type { AgentsModule } from "./modules/agents.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; -import type { AnalyticsModuleOptions } from "./modules/analytics.types.js"; /** * Options for creating a Base44 client. @@ -16,7 +15,6 @@ export interface CreateClientOptions { * Optional error handler that will be called whenever an API error occurs. */ onError?: (error: Error) => void; - analytics?: AnalyticsModuleOptions; } /** diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index 24824b4..661c414 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -6,11 +6,13 @@ import { AnalyticsApiBatchRequest, TrackEventIntrinsicData, AnalyticsModuleOptions, + SessionContext, } from "./analytics.types"; import { getSharedInstance, getSharedInstanceRefCount, } from "../utils/singleton"; +import type { AuthModule } from "./auth.types"; /////////////////////////////////////////////// //// shared queue for analytics events //// @@ -31,24 +33,39 @@ const analyticsSharedState = getSharedInstance( export interface AnalyticsModuleArgs { axiosClient: AxiosInstance; + serverUrl: string; appId: string; - options?: AnalyticsModuleOptions; + userAuthModule: AuthModule; } export const createAnalyticsModule = ({ axiosClient, + serverUrl, appId, - options, + userAuthModule, }: AnalyticsModuleArgs) => { // prevent overflow of events // - const MAX_QUEUE_SIZE = 1000; - const isEnabled = options?.enabled !== false; + const { + enabled = true, + maxQueueSize = 1000, + throttleTime = 1000, + batchSize = 30, + } = getAnalyticsModuleOptionsFromUrlParams() ?? {}; + + const trackBatchUrl = `${serverUrl}/api/apps/${appId}/analytics/track/batch`; + let sessionContext: SessionContext | null = null; + + const getSessionContext = async () => { + if (sessionContext) return sessionContext; + const user = await userAuthModule.me(); + sessionContext = { + user_id: user.id, + }; + return sessionContext; + }; const track = (params: TrackEventParams) => { - if ( - !isEnabled || - analyticsSharedState.requestsQueue.length >= MAX_QUEUE_SIZE - ) { + if (!enabled || analyticsSharedState.requestsQueue.length >= maxQueueSize) { return; } const intrinsicData = getEventIntrinsicData(); @@ -58,21 +75,45 @@ export const createAnalyticsModule = ({ }); }; - const flush = async (eventsData: TrackEventData[]) => { - const apiEvents = eventsData.map(transformEventDataToApiRequestData); + const batchRequestFallback = async (events: AnalyticsApiRequestData[]) => { await axiosClient.request({ method: "POST", url: `/apps/${appId}/analytics/track/batch`, - data: { events: apiEvents }, + data: { events }, } as AnalyticsApiBatchRequest); }; + const flush = async (eventsData: TrackEventData[]) => { + const sessionContext_ = sessionContext ?? (await getSessionContext()); + const events = eventsData.map( + transformEventDataToApiRequestData(sessionContext_) + ); + const beaconPayload = JSON.stringify({ events }); + + if ( + typeof navigator === "undefined" || + beaconPayload.length > 60000 || + !navigator.sendBeacon(trackBatchUrl, beaconPayload) + ) { + // beacon didn't work, fallback to axios + await batchRequestFallback(events); + } + }; + + if (typeof window !== "undefined") { + window.addEventListener("visibilitychange", () => { + // flush entire queue on visibility change and hope for the best // + const eventsData = analyticsSharedState.requestsQueue.splice(0); + flush(eventsData); + }); + } + // start analytics processor only if it's the first instance and analytics is enabled // - if ( - getSharedInstanceRefCount(ANALYTICS_SHARED_STATE_NAME) <= 1 && - isEnabled - ) { - startAnalyticsProcessor(flush, options?.trackService); + if (getSharedInstanceRefCount(ANALYTICS_SHARED_STATE_NAME) <= 1 && enabled) { + startAnalyticsProcessor(flush, { + throttleTime, + batchSize, + }); } return { @@ -109,13 +150,25 @@ function getEventIntrinsicData(): TrackEventIntrinsicData { }; } -function transformEventDataToApiRequestData( - eventData: TrackEventData -): AnalyticsApiRequestData { - return { +function transformEventDataToApiRequestData(sessionContext: SessionContext) { + return (eventData: TrackEventData): AnalyticsApiRequestData => ({ event_name: eventData.eventName, properties: eventData.properties, timestamp: eventData.timestamp, page_url: eventData.pageUrl, - }; + ...sessionContext, + }); +} + +export function getAnalyticsModuleOptionsFromUrlParams(): + | AnalyticsModuleOptions + | undefined { + const urlParams = new URLSearchParams(window.location.search); + const jsonString = urlParams.get("analytics"); + if (!jsonString) return undefined; + try { + return JSON.parse(jsonString); + } catch { + return undefined; + } } diff --git a/src/modules/analytics.types.ts b/src/modules/analytics.types.ts index 853cc79..0d92171 100644 --- a/src/modules/analytics.types.ts +++ b/src/modules/analytics.types.ts @@ -17,12 +17,16 @@ export type TrackEventData = { eventName: string; } & TrackEventIntrinsicData; +export type SessionContext = { + user_id?: string | null; +}; + export type AnalyticsApiRequestData = { event_name: string; properties?: TrackEventProperties; timestamp?: string; page_url?: string | null; -}; +} & SessionContext; export type AnalyticsApiBatchRequest = { method: "POST"; @@ -34,8 +38,7 @@ export type AnalyticsApiBatchRequest = { export type AnalyticsModuleOptions = { enabled?: boolean; - trackService?: { - throttleTime: number; - batchSize: number; - }; + maxQueueSize?: number; + throttleTime?: number; + batchSize?: number; }; From 05bee93c05d50ff037f56cd2443860507707285c Mon Sep 17 00:00:00 2001 From: Omer Katzir Date: Fri, 26 Dec 2025 16:33:58 +0200 Subject: [PATCH 03/10] processing state --- src/modules/analytics.ts | 39 +++++++++++++++++++++++++-------------- src/utils/singleton.ts | 4 +++- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index 661c414..d0440f8 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -17,18 +17,17 @@ import type { AuthModule } from "./auth.types"; /////////////////////////////////////////////// //// shared queue for analytics events //// /////////////////////////////////////////////// -type AnalyticsSharedState = { - requestsQueue: TrackEventData[]; -}; const ANALYTICS_SHARED_STATE_NAME = "analytics"; // shared state// -const analyticsSharedState = getSharedInstance( +const analyticsSharedState = getSharedInstance( ANALYTICS_SHARED_STATE_NAME, () => ({ - requestsQueue: [], + requestsQueue: [] as TrackEventData[], + isProcessing: false, }) ); + /////////////////////////////////////////////// export interface AnalyticsModuleArgs { @@ -100,16 +99,24 @@ export const createAnalyticsModule = ({ } }; - if (typeof window !== "undefined") { + if (typeof window !== "undefined" && enabled) { window.addEventListener("visibilitychange", () => { - // flush entire queue on visibility change and hope for the best // - const eventsData = analyticsSharedState.requestsQueue.splice(0); - flush(eventsData); + if (document.visibilityState === "hidden") { + analyticsSharedState.isProcessing = false; + // flush entire queue on visibility change and hope for the best // + const eventsData = analyticsSharedState.requestsQueue.splice(0); + flush(eventsData); + } else if (document.visibilityState === "visible") { + startAnalyticsProcessor(flush, { + throttleTime, + batchSize, + }); + } }); } // start analytics processor only if it's the first instance and analytics is enabled // - if (getSharedInstanceRefCount(ANALYTICS_SHARED_STATE_NAME) <= 1 && enabled) { + if (enabled) { startAnalyticsProcessor(flush, { throttleTime, batchSize, @@ -122,15 +129,18 @@ export const createAnalyticsModule = ({ }; async function startAnalyticsProcessor( - handleTrack: (trackRequest: TrackEventData[]) => Promise, + handleTrack: (eventsData: TrackEventData[]) => Promise, options?: { throttleTime: number; batchSize: number; } ) { + if (analyticsSharedState.isProcessing) { + return; + } + analyticsSharedState.isProcessing = true; const { throttleTime = 1000, batchSize = 30 } = options ?? {}; - while (true) { - await new Promise((resolve) => setTimeout(resolve, throttleTime)); + while (analyticsSharedState.isProcessing) { const requests = analyticsSharedState.requestsQueue.splice(0, batchSize); if (requests.length > 0) { try { @@ -140,13 +150,14 @@ async function startAnalyticsProcessor( console.error("Error processing analytics request:", error); } } + await new Promise((resolve) => setTimeout(resolve, throttleTime)); } } function getEventIntrinsicData(): TrackEventIntrinsicData { return { timestamp: new Date().toISOString(), - pageUrl: typeof window !== "undefined" ? window.location.href : null, + pageUrl: typeof window !== "undefined" ? window.location.pathname : null, }; } diff --git a/src/utils/singleton.ts b/src/utils/singleton.ts index 2fc43fc..93dbdf7 100644 --- a/src/utils/singleton.ts +++ b/src/utils/singleton.ts @@ -6,12 +6,14 @@ export function getSharedInstance(name: string, factory: () => T): T { const windowObj: Window & { base44?: { [key: string]: { instance: T; _refCount: number } }; } = typeof window !== "undefined" ? (window as any) : { base44: {} }; + if (!windowObj.base44) { windowObj.base44 = {}; } if (!windowObj.base44[name]) { - windowObj.base44[name] = { instance: factory(), _refCount: 1 }; + windowObj.base44[name] = { instance: factory(), _refCount: 0 }; } + windowObj.base44[name]._refCount++; return windowObj.base44[name].instance; } From fc48301dc635ceb1eb7e51c02db114aa4acbd49a Mon Sep 17 00:00:00 2001 From: Omer Katzir Date: Fri, 26 Dec 2025 17:20:57 +0200 Subject: [PATCH 04/10] bot comments --- src/modules/analytics.ts | 38 +++++++++++++++++++++++++------------- src/utils/singleton.ts | 35 ++++++++++++++++++----------------- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index d0440f8..e7ab4f4 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -99,20 +99,31 @@ export const createAnalyticsModule = ({ } }; - if (typeof window !== "undefined" && enabled) { - window.addEventListener("visibilitychange", () => { - if (document.visibilityState === "hidden") { - analyticsSharedState.isProcessing = false; - // flush entire queue on visibility change and hope for the best // - const eventsData = analyticsSharedState.requestsQueue.splice(0); - flush(eventsData); - } else if (document.visibilityState === "visible") { - startAnalyticsProcessor(flush, { - throttleTime, - batchSize, - }); - } + const onDocHidden = () => { + analyticsSharedState.isProcessing = false; + // flush entire queue on visibility change and hope for the best // + const eventsData = analyticsSharedState.requestsQueue.splice(0); + flush(eventsData); + }; + + const onDocVisible = () => { + startAnalyticsProcessor(flush, { + throttleTime, + batchSize, }); + }; + + const onVisibilityChange = () => { + if (typeof window === "undefined") return; + if (document.visibilityState === "hidden") { + onDocHidden(); + } else if (document.visibilityState === "visible") { + onDocVisible(); + } + }; + + if (typeof window !== "undefined" && enabled) { + window.addEventListener("visibilitychange", onVisibilityChange); } // start analytics processor only if it's the first instance and analytics is enabled // @@ -174,6 +185,7 @@ function transformEventDataToApiRequestData(sessionContext: SessionContext) { export function getAnalyticsModuleOptionsFromUrlParams(): | AnalyticsModuleOptions | undefined { + if (typeof window === "undefined") return undefined; const urlParams = new URLSearchParams(window.location.search); const jsonString = urlParams.get("analytics"); if (!jsonString) return undefined; diff --git a/src/utils/singleton.ts b/src/utils/singleton.ts index 93dbdf7..9b3884b 100644 --- a/src/utils/singleton.ts +++ b/src/utils/singleton.ts @@ -1,26 +1,27 @@ - - +const windowObj: { + base44SharedInstances?: { + [key: string]: { instance: any; _refCount: number }; + }; +} = + typeof window !== "undefined" + ? (window as any) + : { base44SharedInstances: {} }; // Singleton (shared between sdk instances)// export function getSharedInstance(name: string, factory: () => T): T { - const windowObj: Window & { - base44?: { [key: string]: { instance: T; _refCount: number } }; - } = typeof window !== "undefined" ? (window as any) : { base44: {} }; - - if (!windowObj.base44) { - windowObj.base44 = {}; + if (!windowObj.base44SharedInstances) { + windowObj.base44SharedInstances = {}; } - if (!windowObj.base44[name]) { - windowObj.base44[name] = { instance: factory(), _refCount: 0 }; + if (!windowObj.base44SharedInstances[name]) { + windowObj.base44SharedInstances[name] = { + instance: factory(), + _refCount: 0, + }; } - windowObj.base44[name]._refCount++; - return windowObj.base44[name].instance; + windowObj.base44SharedInstances[name]._refCount++; + return windowObj.base44SharedInstances[name].instance; } export function getSharedInstanceRefCount(name: string): number { - const windowObj: Window & { - base44?: { [key: string]: { instance: T; _refCount: number } }; - } = typeof window !== "undefined" ? (window as any) : { base44: {} }; - - return windowObj.base44?.[name]?._refCount ?? 0; + return windowObj.base44SharedInstances?.[name]?._refCount ?? 0; } From a51c69aa4c28d0b046b4743cd63313c10f03b7ab Mon Sep 17 00:00:00 2001 From: Omer Katzir Date: Fri, 26 Dec 2025 17:35:07 +0200 Subject: [PATCH 05/10] move config and sessionContext to sharedState --- src/modules/analytics.ts | 41 +++++++++++-------- src/modules/analytics.types.ts | 4 ++ src/utils/{singleton.ts => sharedInstance.ts} | 8 +--- 3 files changed, 28 insertions(+), 25 deletions(-) rename src/utils/{singleton.ts => sharedInstance.ts} (67%) diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index e7ab4f4..33135c0 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -8,12 +8,16 @@ import { AnalyticsModuleOptions, SessionContext, } from "./analytics.types"; -import { - getSharedInstance, - getSharedInstanceRefCount, -} from "../utils/singleton"; +import { getSharedInstance } from "../utils/sharedInstance"; import type { AuthModule } from "./auth.types"; +const defaultConfiguration: AnalyticsModuleOptions = { + enabled: true, + maxQueueSize: 1000, + throttleTime: 1000, + batchSize: 30, +}; + /////////////////////////////////////////////// //// shared queue for analytics events //// /////////////////////////////////////////////// @@ -25,6 +29,11 @@ const analyticsSharedState = getSharedInstance( () => ({ requestsQueue: [] as TrackEventData[], isProcessing: false, + sessionContext: null as SessionContext | null, + config: { + ...defaultConfiguration, + ...getAnalyticsModuleOptionsFromUrlParams(), + } as Required, }) ); @@ -44,23 +53,19 @@ export const createAnalyticsModule = ({ userAuthModule, }: AnalyticsModuleArgs) => { // prevent overflow of events // - const { - enabled = true, - maxQueueSize = 1000, - throttleTime = 1000, - batchSize = 30, - } = getAnalyticsModuleOptionsFromUrlParams() ?? {}; + const { enabled, maxQueueSize, throttleTime, batchSize } = + analyticsSharedState.config; const trackBatchUrl = `${serverUrl}/api/apps/${appId}/analytics/track/batch`; - let sessionContext: SessionContext | null = null; const getSessionContext = async () => { - if (sessionContext) return sessionContext; - const user = await userAuthModule.me(); - sessionContext = { - user_id: user.id, - }; - return sessionContext; + if (!analyticsSharedState.sessionContext) { + const user = await userAuthModule.me(); + analyticsSharedState.sessionContext = { + user_id: user.id, + }; + } + return analyticsSharedState.sessionContext; }; const track = (params: TrackEventParams) => { @@ -83,7 +88,7 @@ export const createAnalyticsModule = ({ }; const flush = async (eventsData: TrackEventData[]) => { - const sessionContext_ = sessionContext ?? (await getSessionContext()); + const sessionContext_ = await getSessionContext(); const events = eventsData.map( transformEventDataToApiRequestData(sessionContext_) ); diff --git a/src/modules/analytics.types.ts b/src/modules/analytics.types.ts index 0d92171..764b519 100644 --- a/src/modules/analytics.types.ts +++ b/src/modules/analytics.types.ts @@ -42,3 +42,7 @@ export type AnalyticsModuleOptions = { throttleTime?: number; batchSize?: number; }; + +export type AnalyticsModule = { + track: (params: TrackEventParams) => void; +}; diff --git a/src/utils/singleton.ts b/src/utils/sharedInstance.ts similarity index 67% rename from src/utils/singleton.ts rename to src/utils/sharedInstance.ts index 9b3884b..2922dbd 100644 --- a/src/utils/singleton.ts +++ b/src/utils/sharedInstance.ts @@ -1,6 +1,6 @@ const windowObj: { base44SharedInstances?: { - [key: string]: { instance: any; _refCount: number }; + [key: string]: { instance: any }; }; } = typeof window !== "undefined" @@ -15,13 +15,7 @@ export function getSharedInstance(name: string, factory: () => T): T { if (!windowObj.base44SharedInstances[name]) { windowObj.base44SharedInstances[name] = { instance: factory(), - _refCount: 0, }; } - windowObj.base44SharedInstances[name]._refCount++; return windowObj.base44SharedInstances[name].instance; } - -export function getSharedInstanceRefCount(name: string): number { - return windowObj.base44SharedInstances?.[name]?._refCount ?? 0; -} From 1faced462a6a6bdbcbcd22dcf2103a038666eeb4 Mon Sep 17 00:00:00 2001 From: Omer Katzir Date: Fri, 26 Dec 2025 17:55:59 +0200 Subject: [PATCH 06/10] cleanup and tests --- src/client.ts | 1 + src/client.types.ts | 3 ++ src/modules/analytics.ts | 13 +++++++- tests/unit/analytics.test.ts | 63 ++++++++++++++++++++++++++++++++++++ tests/unit/client.test.js | 1 + 5 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/unit/analytics.test.ts diff --git a/src/client.ts b/src/client.ts index 949a0cc..6b9448f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -159,6 +159,7 @@ export function createClient(config: CreateClientConfig): Base44Client { userAuthModule, }), cleanup: () => { + userModules.analytics.cleanup(); if (socket) { socket.disconnect(); } diff --git a/src/client.types.ts b/src/client.types.ts index 40e3c87..a86af41 100644 --- a/src/client.types.ts +++ b/src/client.types.ts @@ -6,6 +6,7 @@ import type { ConnectorsModule } from "./modules/connectors.types.js"; import type { FunctionsModule } from "./modules/functions.types.js"; import type { AgentsModule } from "./modules/agents.types.js"; import type { AppLogsModule } from "./modules/app-logs.types.js"; +import type { AnalyticsModule } from "./modules/analytics.types.js"; /** * Options for creating a Base44 client. @@ -85,6 +86,8 @@ export interface Base44Client { agents: AgentsModule; /** {@link AppLogsModule | App logs module} for tracking app usage. */ appLogs: AppLogsModule; + /** {@link AnalyticsModule | Analytics module} for tracking app usage. */ + analytics: AnalyticsModule; /** Cleanup function to disconnect WebSocket connections. Call when you're done with the client. */ cleanup: () => void; diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index 33135c0..c4e2279 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -105,7 +105,7 @@ export const createAnalyticsModule = ({ }; const onDocHidden = () => { - analyticsSharedState.isProcessing = false; + stopAnalyticsProcessor(); // flush entire queue on visibility change and hope for the best // const eventsData = analyticsSharedState.requestsQueue.splice(0); flush(eventsData); @@ -139,11 +139,22 @@ export const createAnalyticsModule = ({ }); } + const cleanup = () => { + if (typeof window === "undefined") return; + window.removeEventListener("visibilitychange", onVisibilityChange); + stopAnalyticsProcessor(); + }; + return { track, + cleanup, }; }; +function stopAnalyticsProcessor() { + analyticsSharedState.isProcessing = false; +} + async function startAnalyticsProcessor( handleTrack: (eventsData: TrackEventData[]) => Promise, options?: { diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts new file mode 100644 index 0000000..ea4d038 --- /dev/null +++ b/tests/unit/analytics.test.ts @@ -0,0 +1,63 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { + AnalyticsModuleOptions, + createClient, + SessionContext, + TrackEventData, +} from "../../src/index.ts"; +import { getSharedInstance } from "../../src/utils/sharedInstance.ts"; + +describe("Analytics Module", () => { + let base44: ReturnType; + let sharedState: null | { + requestsQueue: TrackEventData[]; + isProcessing: boolean; + sessionContext: SessionContext; + config: AnalyticsModuleOptions; + }; + const appId = "test-app-id"; + const serverUrl = "https://api.base44.com"; + + beforeEach(() => { + base44 = createClient({ + serverUrl, + appId, + }); + sharedState = getSharedInstance("analytics", () => ({ + requestsQueue: [], + isProcessing: false, + sessionContext: { + user_id: "test-user-id", + }, + config: { + enabled: true, + maxQueueSize: 1000, + throttleTime: 1000, + batchSize: 30, + }, + })); + }); + + afterEach(() => { + base44.cleanup(); + sharedState = null; + }); + + test("should create analytics module with shared state", () => { + expect(base44.analytics).toBeDefined(); + expect(sharedState).toBeDefined(); + expect(sharedState?.requestsQueue).toBeDefined(); + expect(sharedState?.isProcessing).toBe(true); + }); + + test("should track an event", () => { + vi.spyOn(base44.analytics, "track").mockImplementation(() => { + console.log("track called"); + }); + + base44.analytics.track({ eventName: "test-event" }); + expect(base44.analytics.track).toHaveBeenCalledWith({ + eventName: "test-event", + }); + }); +}); diff --git a/tests/unit/client.test.js b/tests/unit/client.test.js index 5f8c265..74c97aa 100644 --- a/tests/unit/client.test.js +++ b/tests/unit/client.test.js @@ -12,6 +12,7 @@ describe('Client Creation', () => { expect(client.entities).toBeDefined(); expect(client.integrations).toBeDefined(); expect(client.auth).toBeDefined(); + expect(client.analytics).toBeDefined(); const config = client.getConfig(); expect(config.appId).toBe('test-app-id'); From 2092b4380936741ea52c8545ce691d85fb9a48c5 Mon Sep 17 00:00:00 2001 From: Omer Katzir Date: Mon, 29 Dec 2025 13:05:13 +0200 Subject: [PATCH 07/10] processor management --- src/modules/analytics.ts | 40 ++++++++++++------- tests/unit/analytics.test.ts | 76 ++++++++++++++++++++++++++++-------- 2 files changed, 84 insertions(+), 32 deletions(-) diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index c4e2279..46e6a9d 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -68,17 +68,6 @@ export const createAnalyticsModule = ({ return analyticsSharedState.sessionContext; }; - const track = (params: TrackEventParams) => { - if (!enabled || analyticsSharedState.requestsQueue.length >= maxQueueSize) { - return; - } - const intrinsicData = getEventIntrinsicData(); - analyticsSharedState.requestsQueue.push({ - ...params, - ...intrinsicData, - }); - }; - const batchRequestFallback = async (events: AnalyticsApiRequestData[]) => { await axiosClient.request({ method: "POST", @@ -131,13 +120,25 @@ export const createAnalyticsModule = ({ window.addEventListener("visibilitychange", onVisibilityChange); } - // start analytics processor only if it's the first instance and analytics is enabled // - if (enabled) { + const startProcessing = () => { + if (!enabled) return; startAnalyticsProcessor(flush, { throttleTime, batchSize, }); - } + }; + + const track = (params: TrackEventParams) => { + if (!enabled || analyticsSharedState.requestsQueue.length >= maxQueueSize) { + return; + } + const intrinsicData = getEventIntrinsicData(); + analyticsSharedState.requestsQueue.push({ + ...params, + ...intrinsicData, + }); + startProcessing(); + }; const cleanup = () => { if (typeof window === "undefined") return; @@ -145,6 +146,9 @@ export const createAnalyticsModule = ({ stopAnalyticsProcessor(); }; + // start the flusing process /// + startProcessing(); + return { track, cleanup, @@ -163,11 +167,16 @@ async function startAnalyticsProcessor( } ) { if (analyticsSharedState.isProcessing) { + // only one instance of the analytics processor can be running at a time // return; } analyticsSharedState.isProcessing = true; + const { throttleTime = 1000, batchSize = 30 } = options ?? {}; - while (analyticsSharedState.isProcessing) { + while ( + analyticsSharedState.isProcessing && + analyticsSharedState.requestsQueue.length > 0 + ) { const requests = analyticsSharedState.requestsQueue.splice(0, batchSize); if (requests.length > 0) { try { @@ -179,6 +188,7 @@ async function startAnalyticsProcessor( } await new Promise((resolve) => setTimeout(resolve, throttleTime)); } + analyticsSharedState.isProcessing = false; } function getEventIntrinsicData(): TrackEventIntrinsicData { diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index ea4d038..8dca786 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -6,6 +6,8 @@ import { TrackEventData, } from "../../src/index.ts"; import { getSharedInstance } from "../../src/utils/sharedInstance.ts"; +import { User } from "../../src/modules/auth.types.ts"; +import { AxiosInstance } from "axios"; describe("Analytics Module", () => { let base44: ReturnType; @@ -19,26 +21,45 @@ describe("Analytics Module", () => { const serverUrl = "https://api.base44.com"; beforeEach(() => { - base44 = createClient({ - serverUrl, - appId, - }); + vi.mock("../../src/utils/axios-client.ts", () => ({ + createAxiosClient: vi.fn().mockImplementation( + () => + ({ + request: vi.fn().mockResolvedValue({ + status: 200, + data: { + message: "success", + }, + }), + } as unknown as AxiosInstance) + ), + })); sharedState = getSharedInstance("analytics", () => ({ requestsQueue: [], isProcessing: false, - sessionContext: { - user_id: "test-user-id", - }, - config: { - enabled: true, - maxQueueSize: 1000, - throttleTime: 1000, - batchSize: 30, - }, + sessionContext: {}, + config: {}, })); + sharedState.isProcessing = false; + sharedState.requestsQueue = []; + sharedState.sessionContext = { + user_id: "test-user-id", + }; + sharedState.config = { + enabled: true, + maxQueueSize: 1000, + throttleTime: 1000, + batchSize: 2, + }; + + base44 = createClient({ + serverUrl, + appId, + }); }); afterEach(() => { + vi.clearAllMocks(); base44.cleanup(); sharedState = null; }); @@ -47,17 +68,38 @@ describe("Analytics Module", () => { expect(base44.analytics).toBeDefined(); expect(sharedState).toBeDefined(); expect(sharedState?.requestsQueue).toBeDefined(); - expect(sharedState?.isProcessing).toBe(true); + expect(sharedState?.isProcessing).toBe(false); }); test("should track an event", () => { - vi.spyOn(base44.analytics, "track").mockImplementation(() => { - console.log("track called"); - }); + vi.spyOn(base44.analytics, "track"); base44.analytics.track({ eventName: "test-event" }); + expect(sharedState?.isProcessing).toBe(true); expect(base44.analytics.track).toHaveBeenCalledWith({ eventName: "test-event", }); }); + + test("should track multiple events", async () => { + vi.useFakeTimers(); + + for (let i = 0; i < 5; i++) { + base44.analytics.track({ eventName: `test-event ${i}` }); + } + + expect(sharedState?.isProcessing).toBe(true); + expect(sharedState?.requestsQueue.length).toBe(4); + await vi.advanceTimersByTimeAsync(1000); + expect(sharedState?.requestsQueue.length).toBe(2); + // add another event while processing to mix things up + base44.analytics.track({ eventName: `test-event 5` }); + + await vi.advanceTimersByTimeAsync(1000); + expect(sharedState?.requestsQueue.length).toBe(1); + await vi.advanceTimersByTimeAsync(1000); + expect(sharedState?.requestsQueue.length).toBe(0); + await vi.advanceTimersByTimeAsync(1000); + expect(sharedState?.isProcessing).toBe(false); + }); }); From 906486afd0167d8fc2ad1049c34ff07311a911d2 Mon Sep 17 00:00:00 2001 From: Omer Katzir Date: Mon, 29 Dec 2025 14:35:30 +0200 Subject: [PATCH 08/10] live users count tracking with heartbeat --- src/modules/analytics.ts | 156 ++++++++++++++++++++------------- src/modules/analytics.types.ts | 2 + tests/unit/analytics.test.ts | 1 + 3 files changed, 97 insertions(+), 62 deletions(-) diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index 46e6a9d..03e1647 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -11,11 +11,14 @@ import { import { getSharedInstance } from "../utils/sharedInstance"; import type { AuthModule } from "./auth.types"; +export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__"; + const defaultConfiguration: AnalyticsModuleOptions = { enabled: true, maxQueueSize: 1000, throttleTime: 1000, batchSize: 30, + heartBeatInterval: 60 * 1000, }; /////////////////////////////////////////////// @@ -29,10 +32,11 @@ const analyticsSharedState = getSharedInstance( () => ({ requestsQueue: [] as TrackEventData[], isProcessing: false, + isHeartBeatProcessing: false, sessionContext: null as SessionContext | null, config: { ...defaultConfiguration, - ...getAnalyticsModuleOptionsFromUrlParams(), + ...getAnalyticsModuleOptionsFromLocalStorage(), } as Required, }) ); @@ -55,19 +59,9 @@ export const createAnalyticsModule = ({ // prevent overflow of events // const { enabled, maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config; - + let clearHeartBeatProcessor: (() => void) | undefined = undefined; const trackBatchUrl = `${serverUrl}/api/apps/${appId}/analytics/track/batch`; - const getSessionContext = async () => { - if (!analyticsSharedState.sessionContext) { - const user = await userAuthModule.me(); - analyticsSharedState.sessionContext = { - user_id: user.id, - }; - } - return analyticsSharedState.sessionContext; - }; - const batchRequestFallback = async (events: AnalyticsApiRequestData[]) => { await axiosClient.request({ method: "POST", @@ -77,49 +71,25 @@ export const createAnalyticsModule = ({ }; const flush = async (eventsData: TrackEventData[]) => { - const sessionContext_ = await getSessionContext(); + const sessionContext_ = await getSessionContext(userAuthModule); const events = eventsData.map( transformEventDataToApiRequestData(sessionContext_) ); const beaconPayload = JSON.stringify({ events }); - - if ( - typeof navigator === "undefined" || - beaconPayload.length > 60000 || - !navigator.sendBeacon(trackBatchUrl, beaconPayload) - ) { - // beacon didn't work, fallback to axios - await batchRequestFallback(events); - } - }; - - const onDocHidden = () => { - stopAnalyticsProcessor(); - // flush entire queue on visibility change and hope for the best // - const eventsData = analyticsSharedState.requestsQueue.splice(0); - flush(eventsData); - }; - - const onDocVisible = () => { - startAnalyticsProcessor(flush, { - throttleTime, - batchSize, - }); - }; - - const onVisibilityChange = () => { - if (typeof window === "undefined") return; - if (document.visibilityState === "hidden") { - onDocHidden(); - } else if (document.visibilityState === "visible") { - onDocVisible(); + try { + if ( + typeof navigator === "undefined" || + beaconPayload.length > 60000 || + !navigator.sendBeacon(trackBatchUrl, beaconPayload) + ) { + // beacon didn't work, fallback to axios + await batchRequestFallback(events); + } + } catch { + // TODO: think about retries if needed } }; - if (typeof window !== "undefined" && enabled) { - window.addEventListener("visibilitychange", onVisibilityChange); - } - const startProcessing = () => { if (!enabled) return; startAnalyticsProcessor(flush, { @@ -140,14 +110,47 @@ export const createAnalyticsModule = ({ startProcessing(); }; - const cleanup = () => { + const onDocVisible = () => { + startAnalyticsProcessor(flush, { + throttleTime, + batchSize, + }); + clearHeartBeatProcessor = startHeartBeatProcessor(track); + }; + + const onDocHidden = () => { + stopAnalyticsProcessor(); + // flush entire queue on visibility change and hope for the best // + const eventsData = analyticsSharedState.requestsQueue.splice(0); + flush(eventsData); + clearHeartBeatProcessor?.(); + }; + + const onVisibilityChange = () => { if (typeof window === "undefined") return; - window.removeEventListener("visibilitychange", onVisibilityChange); + if (document.visibilityState === "hidden") { + onDocHidden(); + } else if (document.visibilityState === "visible") { + onDocVisible(); + } + }; + + const cleanup = () => { stopAnalyticsProcessor(); + clearHeartBeatProcessor?.(); + if (typeof window !== "undefined") { + window.removeEventListener("visibilitychange", onVisibilityChange); + } }; // start the flusing process /// startProcessing(); + // start the heart beat processor // + clearHeartBeatProcessor = startHeartBeatProcessor(track); + // start the visibility change listener // + if (typeof window !== "undefined" && enabled) { + window.addEventListener("visibilitychange", onVisibilityChange); + } return { track, @@ -178,19 +181,31 @@ async function startAnalyticsProcessor( analyticsSharedState.requestsQueue.length > 0 ) { const requests = analyticsSharedState.requestsQueue.splice(0, batchSize); - if (requests.length > 0) { - try { - await handleTrack(requests); - } catch (error) { - // TODO: think about retries if needed - console.error("Error processing analytics request:", error); - } - } + requests.length && (await handleTrack(requests)); await new Promise((resolve) => setTimeout(resolve, throttleTime)); } analyticsSharedState.isProcessing = false; } +function startHeartBeatProcessor(track: (params: TrackEventParams) => void) { + if ( + analyticsSharedState.isHeartBeatProcessing || + (analyticsSharedState.config.heartBeatInterval ?? 0) < 10 + ) { + return () => {}; + } + + analyticsSharedState.isHeartBeatProcessing = true; + const interval = setInterval(() => { + track({ eventName: USER_HEARTBEAT_EVENT_NAME }); + }, analyticsSharedState.config.heartBeatInterval); + + return () => { + clearInterval(interval); + analyticsSharedState.isHeartBeatProcessing = false; + }; +} + function getEventIntrinsicData(): TrackEventIntrinsicData { return { timestamp: new Date().toISOString(), @@ -208,14 +223,31 @@ function transformEventDataToApiRequestData(sessionContext: SessionContext) { }); } -export function getAnalyticsModuleOptionsFromUrlParams(): +let sessionContextPromise: Promise | null = null; +async function getSessionContext(userAuthModule: AuthModule) { + if (!analyticsSharedState.sessionContext) { + if (!sessionContextPromise) { + sessionContextPromise = userAuthModule + .me() + .then((user) => ({ + user_id: user.id, + })) + .catch(() => ({ + user_id: "unknown: error getting session context", + })); + } + analyticsSharedState.sessionContext = await sessionContextPromise; + } + return analyticsSharedState.sessionContext; +} + +export function getAnalyticsModuleOptionsFromLocalStorage(): | AnalyticsModuleOptions | undefined { if (typeof window === "undefined") return undefined; - const urlParams = new URLSearchParams(window.location.search); - const jsonString = urlParams.get("analytics"); - if (!jsonString) return undefined; try { + const jsonString = localStorage.getItem("base44_analytics_config"); + if (!jsonString) return undefined; return JSON.parse(jsonString); } catch { return undefined; diff --git a/src/modules/analytics.types.ts b/src/modules/analytics.types.ts index 764b519..2ef59d4 100644 --- a/src/modules/analytics.types.ts +++ b/src/modules/analytics.types.ts @@ -41,6 +41,8 @@ export type AnalyticsModuleOptions = { maxQueueSize?: number; throttleTime?: number; batchSize?: number; + // used for live users count tracking + heartBeatInterval?: number; }; export type AnalyticsModule = { diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts index 8dca786..4c0f647 100644 --- a/tests/unit/analytics.test.ts +++ b/tests/unit/analytics.test.ts @@ -50,6 +50,7 @@ describe("Analytics Module", () => { maxQueueSize: 1000, throttleTime: 1000, batchSize: 2, + heartBeatInterval: undefined, }; base44 = createClient({ From 461682777cbe5f3b4cb36157eecccbd769aac46b Mon Sep 17 00:00:00 2001 From: Omer Katzir Date: Tue, 30 Dec 2025 18:42:28 +0200 Subject: [PATCH 09/10] add session_id --- src/modules/analytics.ts | 53 +++++++++++++++++++++++++++++----- src/modules/analytics.types.ts | 1 + src/utils/common.ts | 7 +++++ 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index 03e1647..200348f 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -10,8 +10,12 @@ import { } from "./analytics.types"; import { getSharedInstance } from "../utils/sharedInstance"; import type { AuthModule } from "./auth.types"; +import { generateUuid } from "../utils/common"; export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__"; +export const ANALYTICS_CONFIG_LOCAL_STORAGE_KEY = "base44_analytics_config"; +export const ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY = + "base44_analytics_session_id"; const defaultConfiguration: AnalyticsModuleOptions = { enabled: true, @@ -57,8 +61,15 @@ export const createAnalyticsModule = ({ userAuthModule, }: AnalyticsModuleArgs) => { // prevent overflow of events // - const { enabled, maxQueueSize, throttleTime, batchSize } = - analyticsSharedState.config; + const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config; + + if (!analyticsSharedState.config?.enabled) { + return { + track: () => {}, + cleanup: () => {}, + }; + } + let clearHeartBeatProcessor: (() => void) | undefined = undefined; const trackBatchUrl = `${serverUrl}/api/apps/${appId}/analytics/track/batch`; @@ -91,7 +102,6 @@ export const createAnalyticsModule = ({ }; const startProcessing = () => { - if (!enabled) return; startAnalyticsProcessor(flush, { throttleTime, batchSize, @@ -99,7 +109,7 @@ export const createAnalyticsModule = ({ }; const track = (params: TrackEventParams) => { - if (!enabled || analyticsSharedState.requestsQueue.length >= maxQueueSize) { + if (analyticsSharedState.requestsQueue.length >= maxQueueSize) { return; } const intrinsicData = getEventIntrinsicData(); @@ -148,7 +158,7 @@ export const createAnalyticsModule = ({ // start the heart beat processor // clearHeartBeatProcessor = startHeartBeatProcessor(track); // start the visibility change listener // - if (typeof window !== "undefined" && enabled) { + if (typeof window !== "undefined") { window.addEventListener("visibilitychange", onVisibilityChange); } @@ -224,16 +234,21 @@ function transformEventDataToApiRequestData(sessionContext: SessionContext) { } let sessionContextPromise: Promise | null = null; -async function getSessionContext(userAuthModule: AuthModule) { +async function getSessionContext( + userAuthModule: AuthModule +): Promise { if (!analyticsSharedState.sessionContext) { if (!sessionContextPromise) { + const sessionId = getAnalyticsSessionId(); sessionContextPromise = userAuthModule .me() .then((user) => ({ user_id: user.id, + session_id: sessionId, })) .catch(() => ({ - user_id: "unknown: error getting session context", + user_id: null, + session_id: sessionId, })); } analyticsSharedState.sessionContext = await sessionContextPromise; @@ -246,10 +261,32 @@ export function getAnalyticsModuleOptionsFromLocalStorage(): | undefined { if (typeof window === "undefined") return undefined; try { - const jsonString = localStorage.getItem("base44_analytics_config"); + const jsonString = localStorage.getItem(ANALYTICS_CONFIG_LOCAL_STORAGE_KEY); if (!jsonString) return undefined; return JSON.parse(jsonString); } catch { return undefined; } } + +export function getAnalyticsSessionId(): string { + if (typeof window === "undefined") { + return generateUuid(); + } + try { + const sessionId = localStorage.getItem( + ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY + ); + if (!sessionId) { + const newSessionId = generateUuid(); + localStorage.setItem( + ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY, + newSessionId + ); + return newSessionId; + } + return sessionId; + } catch { + return generateUuid(); + } +} diff --git a/src/modules/analytics.types.ts b/src/modules/analytics.types.ts index 2ef59d4..8172cb6 100644 --- a/src/modules/analytics.types.ts +++ b/src/modules/analytics.types.ts @@ -19,6 +19,7 @@ export type TrackEventData = { export type SessionContext = { user_id?: string | null; + session_id?: string | null; }; export type AnalyticsApiRequestData = { diff --git a/src/utils/common.ts b/src/utils/common.ts index b3895fb..61d8d17 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,2 +1,9 @@ export const isNode = typeof window === "undefined"; export const isInIFrame = !isNode && window.self !== window.top; + +export const generateUuid = () => { + return ( + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15) + ); +}; From c4ee9763b279b5a7526d381df5edfcd82b12a88d Mon Sep 17 00:00:00 2001 From: Omer Katzir Date: Tue, 30 Dec 2025 19:34:28 +0200 Subject: [PATCH 10/10] get config from window --- src/modules/analytics.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/modules/analytics.ts b/src/modules/analytics.ts index 200348f..95b0a9c 100644 --- a/src/modules/analytics.ts +++ b/src/modules/analytics.ts @@ -13,7 +13,7 @@ import type { AuthModule } from "./auth.types"; import { generateUuid } from "../utils/common"; export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__"; -export const ANALYTICS_CONFIG_LOCAL_STORAGE_KEY = "base44_analytics_config"; +export const ANALYTICS_CONFIG_WINDOW_KEY = "base44_analytics_config"; export const ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY = "base44_analytics_session_id"; @@ -40,7 +40,7 @@ const analyticsSharedState = getSharedInstance( sessionContext: null as SessionContext | null, config: { ...defaultConfiguration, - ...getAnalyticsModuleOptionsFromLocalStorage(), + ...getAnalyticsModuleOptionsFromWindow(), } as Required, }) ); @@ -256,17 +256,11 @@ async function getSessionContext( return analyticsSharedState.sessionContext; } -export function getAnalyticsModuleOptionsFromLocalStorage(): +export function getAnalyticsModuleOptionsFromWindow(): | AnalyticsModuleOptions | undefined { if (typeof window === "undefined") return undefined; - try { - const jsonString = localStorage.getItem(ANALYTICS_CONFIG_LOCAL_STORAGE_KEY); - if (!jsonString) return undefined; - return JSON.parse(jsonString); - } catch { - return undefined; - } + return (window as any)[ANALYTICS_CONFIG_WINDOW_KEY]; } export function getAnalyticsSessionId(): string {