diff --git a/src/client.ts b/src/client.ts index 34afc83..6b9448f 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 }; @@ -127,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, @@ -144,7 +152,14 @@ export function createClient(config: CreateClientConfig): Base44Client { }), appLogs: createAppLogsModule(axiosClient, appId), users: createUsersModule(axiosClient, appId), + analytics: createAnalyticsModule({ + axiosClient, + serverUrl, + appId, + 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 new file mode 100644 index 0000000..95b0a9c --- /dev/null +++ b/src/modules/analytics.ts @@ -0,0 +1,286 @@ +import { AxiosInstance } from "axios"; +import { + TrackEventParams, + TrackEventData, + AnalyticsApiRequestData, + AnalyticsApiBatchRequest, + TrackEventIntrinsicData, + AnalyticsModuleOptions, + SessionContext, +} 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_WINDOW_KEY = "base44_analytics_config"; +export const ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY = + "base44_analytics_session_id"; + +const defaultConfiguration: AnalyticsModuleOptions = { + enabled: true, + maxQueueSize: 1000, + throttleTime: 1000, + batchSize: 30, + heartBeatInterval: 60 * 1000, +}; + +/////////////////////////////////////////////// +//// shared queue for analytics events //// +/////////////////////////////////////////////// + +const ANALYTICS_SHARED_STATE_NAME = "analytics"; +// shared state// +const analyticsSharedState = getSharedInstance( + ANALYTICS_SHARED_STATE_NAME, + () => ({ + requestsQueue: [] as TrackEventData[], + isProcessing: false, + isHeartBeatProcessing: false, + sessionContext: null as SessionContext | null, + config: { + ...defaultConfiguration, + ...getAnalyticsModuleOptionsFromWindow(), + } as Required, + }) +); + +/////////////////////////////////////////////// + +export interface AnalyticsModuleArgs { + axiosClient: AxiosInstance; + serverUrl: string; + appId: string; + userAuthModule: AuthModule; +} + +export const createAnalyticsModule = ({ + axiosClient, + serverUrl, + appId, + userAuthModule, +}: AnalyticsModuleArgs) => { + // prevent overflow of events // + 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`; + + const batchRequestFallback = async (events: AnalyticsApiRequestData[]) => { + await axiosClient.request({ + method: "POST", + url: `/apps/${appId}/analytics/track/batch`, + data: { events }, + } as AnalyticsApiBatchRequest); + }; + + const flush = async (eventsData: TrackEventData[]) => { + const sessionContext_ = await getSessionContext(userAuthModule); + const events = eventsData.map( + transformEventDataToApiRequestData(sessionContext_) + ); + const beaconPayload = JSON.stringify({ events }); + 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 + } + }; + + const startProcessing = () => { + startAnalyticsProcessor(flush, { + throttleTime, + batchSize, + }); + }; + + const track = (params: TrackEventParams) => { + if (analyticsSharedState.requestsQueue.length >= maxQueueSize) { + return; + } + const intrinsicData = getEventIntrinsicData(); + analyticsSharedState.requestsQueue.push({ + ...params, + ...intrinsicData, + }); + startProcessing(); + }; + + 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; + 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") { + window.addEventListener("visibilitychange", onVisibilityChange); + } + + return { + track, + cleanup, + }; +}; + +function stopAnalyticsProcessor() { + analyticsSharedState.isProcessing = false; +} + +async function startAnalyticsProcessor( + handleTrack: (eventsData: TrackEventData[]) => Promise, + options?: { + throttleTime: number; + batchSize: number; + } +) { + 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 && + analyticsSharedState.requestsQueue.length > 0 + ) { + const requests = analyticsSharedState.requestsQueue.splice(0, batchSize); + 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(), + pageUrl: typeof window !== "undefined" ? window.location.pathname : null, + }; +} + +function transformEventDataToApiRequestData(sessionContext: SessionContext) { + return (eventData: TrackEventData): AnalyticsApiRequestData => ({ + event_name: eventData.eventName, + properties: eventData.properties, + timestamp: eventData.timestamp, + page_url: eventData.pageUrl, + ...sessionContext, + }); +} + +let sessionContextPromise: Promise | null = null; +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: null, + session_id: sessionId, + })); + } + analyticsSharedState.sessionContext = await sessionContextPromise; + } + return analyticsSharedState.sessionContext; +} + +export function getAnalyticsModuleOptionsFromWindow(): + | AnalyticsModuleOptions + | undefined { + if (typeof window === "undefined") return undefined; + return (window as any)[ANALYTICS_CONFIG_WINDOW_KEY]; +} + +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 new file mode 100644 index 0000000..8172cb6 --- /dev/null +++ b/src/modules/analytics.types.ts @@ -0,0 +1,51 @@ +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 SessionContext = { + user_id?: string | null; + session_id?: string | null; +}; + +export type AnalyticsApiRequestData = { + event_name: string; + properties?: TrackEventProperties; + timestamp?: string; + page_url?: string | null; +} & SessionContext; + +export type AnalyticsApiBatchRequest = { + method: "POST"; + url: `/apps/${string}/analytics/track/batch`; + data: { + events: AnalyticsApiRequestData[]; + }; +}; + +export type AnalyticsModuleOptions = { + enabled?: boolean; + maxQueueSize?: number; + throttleTime?: number; + batchSize?: number; + // used for live users count tracking + heartBeatInterval?: number; +}; + +export type AnalyticsModule = { + track: (params: TrackEventParams) => void; +}; 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/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) + ); +}; diff --git a/src/utils/sharedInstance.ts b/src/utils/sharedInstance.ts new file mode 100644 index 0000000..2922dbd --- /dev/null +++ b/src/utils/sharedInstance.ts @@ -0,0 +1,21 @@ +const windowObj: { + base44SharedInstances?: { + [key: string]: { instance: any }; + }; +} = + typeof window !== "undefined" + ? (window as any) + : { base44SharedInstances: {} }; + +// Singleton (shared between sdk instances)// +export function getSharedInstance(name: string, factory: () => T): T { + if (!windowObj.base44SharedInstances) { + windowObj.base44SharedInstances = {}; + } + if (!windowObj.base44SharedInstances[name]) { + windowObj.base44SharedInstances[name] = { + instance: factory(), + }; + } + return windowObj.base44SharedInstances[name].instance; +} diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts new file mode 100644 index 0000000..4c0f647 --- /dev/null +++ b/tests/unit/analytics.test.ts @@ -0,0 +1,106 @@ +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"; +import { User } from "../../src/modules/auth.types.ts"; +import { AxiosInstance } from "axios"; + +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(() => { + 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: {}, + config: {}, + })); + sharedState.isProcessing = false; + sharedState.requestsQueue = []; + sharedState.sessionContext = { + user_id: "test-user-id", + }; + sharedState.config = { + enabled: true, + maxQueueSize: 1000, + throttleTime: 1000, + batchSize: 2, + heartBeatInterval: undefined, + }; + + base44 = createClient({ + serverUrl, + appId, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + 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(false); + }); + + test("should track an event", () => { + 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); + }); +}); 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');