Skip to content

Commit a996026

Browse files
authored
Custom-Events | Analytics module (#57)
* Custom-Events | Analytics module * switch to beacon * processing state * bot comments * move config and sessionContext to sharedState * cleanup and tests * processor management * live users count tracking with heartbeat * add session_id * get config from window
1 parent 6f08dfe commit a996026

File tree

9 files changed

+496
-5
lines changed

9 files changed

+496
-5
lines changed

src/client.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
CreateClientConfig,
1616
CreateClientOptions,
1717
} from "./client.types.js";
18+
import { createAnalyticsModule } from "./modules/analytics.js";
1819

1920
// Re-export client types
2021
export type { Base44Client, CreateClientConfig, CreateClientOptions };
@@ -127,13 +128,20 @@ export function createClient(config: CreateClientConfig): Base44Client {
127128
interceptResponses: false,
128129
});
129130

131+
const userAuthModule = createAuthModule(
132+
axiosClient,
133+
functionsAxiosClient,
134+
appId,
135+
{
136+
appBaseUrl,
137+
serverUrl,
138+
}
139+
);
140+
130141
const userModules = {
131142
entities: createEntitiesModule(axiosClient, appId),
132143
integrations: createIntegrationsModule(axiosClient, appId),
133-
auth: createAuthModule(axiosClient, functionsAxiosClient, appId, {
134-
appBaseUrl,
135-
serverUrl,
136-
}),
144+
auth: userAuthModule,
137145
functions: createFunctionsModule(functionsAxiosClient, appId),
138146
agents: createAgentsModule({
139147
axios: axiosClient,
@@ -144,7 +152,14 @@ export function createClient(config: CreateClientConfig): Base44Client {
144152
}),
145153
appLogs: createAppLogsModule(axiosClient, appId),
146154
users: createUsersModule(axiosClient, appId),
155+
analytics: createAnalyticsModule({
156+
axiosClient,
157+
serverUrl,
158+
appId,
159+
userAuthModule,
160+
}),
147161
cleanup: () => {
162+
userModules.analytics.cleanup();
148163
if (socket) {
149164
socket.disconnect();
150165
}

src/client.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ConnectorsModule } from "./modules/connectors.types.js";
66
import type { FunctionsModule } from "./modules/functions.types.js";
77
import type { AgentsModule } from "./modules/agents.types.js";
88
import type { AppLogsModule } from "./modules/app-logs.types.js";
9+
import type { AnalyticsModule } from "./modules/analytics.types.js";
910

1011
/**
1112
* Options for creating a Base44 client.
@@ -85,6 +86,8 @@ export interface Base44Client {
8586
agents: AgentsModule;
8687
/** {@link AppLogsModule | App logs module} for tracking app usage. */
8788
appLogs: AppLogsModule;
89+
/** {@link AnalyticsModule | Analytics module} for tracking app usage. */
90+
analytics: AnalyticsModule;
8891
/** Cleanup function to disconnect WebSocket connections. Call when you're done with the client. */
8992
cleanup: () => void;
9093

src/modules/analytics.ts

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { AxiosInstance } from "axios";
2+
import {
3+
TrackEventParams,
4+
TrackEventData,
5+
AnalyticsApiRequestData,
6+
AnalyticsApiBatchRequest,
7+
TrackEventIntrinsicData,
8+
AnalyticsModuleOptions,
9+
SessionContext,
10+
} from "./analytics.types";
11+
import { getSharedInstance } from "../utils/sharedInstance";
12+
import type { AuthModule } from "./auth.types";
13+
import { generateUuid } from "../utils/common";
14+
15+
export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__";
16+
export const ANALYTICS_CONFIG_WINDOW_KEY = "base44_analytics_config";
17+
export const ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY =
18+
"base44_analytics_session_id";
19+
20+
const defaultConfiguration: AnalyticsModuleOptions = {
21+
enabled: true,
22+
maxQueueSize: 1000,
23+
throttleTime: 1000,
24+
batchSize: 30,
25+
heartBeatInterval: 60 * 1000,
26+
};
27+
28+
///////////////////////////////////////////////
29+
//// shared queue for analytics events ////
30+
///////////////////////////////////////////////
31+
32+
const ANALYTICS_SHARED_STATE_NAME = "analytics";
33+
// shared state//
34+
const analyticsSharedState = getSharedInstance(
35+
ANALYTICS_SHARED_STATE_NAME,
36+
() => ({
37+
requestsQueue: [] as TrackEventData[],
38+
isProcessing: false,
39+
isHeartBeatProcessing: false,
40+
sessionContext: null as SessionContext | null,
41+
config: {
42+
...defaultConfiguration,
43+
...getAnalyticsModuleOptionsFromWindow(),
44+
} as Required<AnalyticsModuleOptions>,
45+
})
46+
);
47+
48+
///////////////////////////////////////////////
49+
50+
export interface AnalyticsModuleArgs {
51+
axiosClient: AxiosInstance;
52+
serverUrl: string;
53+
appId: string;
54+
userAuthModule: AuthModule;
55+
}
56+
57+
export const createAnalyticsModule = ({
58+
axiosClient,
59+
serverUrl,
60+
appId,
61+
userAuthModule,
62+
}: AnalyticsModuleArgs) => {
63+
// prevent overflow of events //
64+
const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config;
65+
66+
if (!analyticsSharedState.config?.enabled) {
67+
return {
68+
track: () => {},
69+
cleanup: () => {},
70+
};
71+
}
72+
73+
let clearHeartBeatProcessor: (() => void) | undefined = undefined;
74+
const trackBatchUrl = `${serverUrl}/api/apps/${appId}/analytics/track/batch`;
75+
76+
const batchRequestFallback = async (events: AnalyticsApiRequestData[]) => {
77+
await axiosClient.request({
78+
method: "POST",
79+
url: `/apps/${appId}/analytics/track/batch`,
80+
data: { events },
81+
} as AnalyticsApiBatchRequest);
82+
};
83+
84+
const flush = async (eventsData: TrackEventData[]) => {
85+
const sessionContext_ = await getSessionContext(userAuthModule);
86+
const events = eventsData.map(
87+
transformEventDataToApiRequestData(sessionContext_)
88+
);
89+
const beaconPayload = JSON.stringify({ events });
90+
try {
91+
if (
92+
typeof navigator === "undefined" ||
93+
beaconPayload.length > 60000 ||
94+
!navigator.sendBeacon(trackBatchUrl, beaconPayload)
95+
) {
96+
// beacon didn't work, fallback to axios
97+
await batchRequestFallback(events);
98+
}
99+
} catch {
100+
// TODO: think about retries if needed
101+
}
102+
};
103+
104+
const startProcessing = () => {
105+
startAnalyticsProcessor(flush, {
106+
throttleTime,
107+
batchSize,
108+
});
109+
};
110+
111+
const track = (params: TrackEventParams) => {
112+
if (analyticsSharedState.requestsQueue.length >= maxQueueSize) {
113+
return;
114+
}
115+
const intrinsicData = getEventIntrinsicData();
116+
analyticsSharedState.requestsQueue.push({
117+
...params,
118+
...intrinsicData,
119+
});
120+
startProcessing();
121+
};
122+
123+
const onDocVisible = () => {
124+
startAnalyticsProcessor(flush, {
125+
throttleTime,
126+
batchSize,
127+
});
128+
clearHeartBeatProcessor = startHeartBeatProcessor(track);
129+
};
130+
131+
const onDocHidden = () => {
132+
stopAnalyticsProcessor();
133+
// flush entire queue on visibility change and hope for the best //
134+
const eventsData = analyticsSharedState.requestsQueue.splice(0);
135+
flush(eventsData);
136+
clearHeartBeatProcessor?.();
137+
};
138+
139+
const onVisibilityChange = () => {
140+
if (typeof window === "undefined") return;
141+
if (document.visibilityState === "hidden") {
142+
onDocHidden();
143+
} else if (document.visibilityState === "visible") {
144+
onDocVisible();
145+
}
146+
};
147+
148+
const cleanup = () => {
149+
stopAnalyticsProcessor();
150+
clearHeartBeatProcessor?.();
151+
if (typeof window !== "undefined") {
152+
window.removeEventListener("visibilitychange", onVisibilityChange);
153+
}
154+
};
155+
156+
// start the flusing process ///
157+
startProcessing();
158+
// start the heart beat processor //
159+
clearHeartBeatProcessor = startHeartBeatProcessor(track);
160+
// start the visibility change listener //
161+
if (typeof window !== "undefined") {
162+
window.addEventListener("visibilitychange", onVisibilityChange);
163+
}
164+
165+
return {
166+
track,
167+
cleanup,
168+
};
169+
};
170+
171+
function stopAnalyticsProcessor() {
172+
analyticsSharedState.isProcessing = false;
173+
}
174+
175+
async function startAnalyticsProcessor(
176+
handleTrack: (eventsData: TrackEventData[]) => Promise<void>,
177+
options?: {
178+
throttleTime: number;
179+
batchSize: number;
180+
}
181+
) {
182+
if (analyticsSharedState.isProcessing) {
183+
// only one instance of the analytics processor can be running at a time //
184+
return;
185+
}
186+
analyticsSharedState.isProcessing = true;
187+
188+
const { throttleTime = 1000, batchSize = 30 } = options ?? {};
189+
while (
190+
analyticsSharedState.isProcessing &&
191+
analyticsSharedState.requestsQueue.length > 0
192+
) {
193+
const requests = analyticsSharedState.requestsQueue.splice(0, batchSize);
194+
requests.length && (await handleTrack(requests));
195+
await new Promise((resolve) => setTimeout(resolve, throttleTime));
196+
}
197+
analyticsSharedState.isProcessing = false;
198+
}
199+
200+
function startHeartBeatProcessor(track: (params: TrackEventParams) => void) {
201+
if (
202+
analyticsSharedState.isHeartBeatProcessing ||
203+
(analyticsSharedState.config.heartBeatInterval ?? 0) < 10
204+
) {
205+
return () => {};
206+
}
207+
208+
analyticsSharedState.isHeartBeatProcessing = true;
209+
const interval = setInterval(() => {
210+
track({ eventName: USER_HEARTBEAT_EVENT_NAME });
211+
}, analyticsSharedState.config.heartBeatInterval);
212+
213+
return () => {
214+
clearInterval(interval);
215+
analyticsSharedState.isHeartBeatProcessing = false;
216+
};
217+
}
218+
219+
function getEventIntrinsicData(): TrackEventIntrinsicData {
220+
return {
221+
timestamp: new Date().toISOString(),
222+
pageUrl: typeof window !== "undefined" ? window.location.pathname : null,
223+
};
224+
}
225+
226+
function transformEventDataToApiRequestData(sessionContext: SessionContext) {
227+
return (eventData: TrackEventData): AnalyticsApiRequestData => ({
228+
event_name: eventData.eventName,
229+
properties: eventData.properties,
230+
timestamp: eventData.timestamp,
231+
page_url: eventData.pageUrl,
232+
...sessionContext,
233+
});
234+
}
235+
236+
let sessionContextPromise: Promise<SessionContext> | null = null;
237+
async function getSessionContext(
238+
userAuthModule: AuthModule
239+
): Promise<SessionContext> {
240+
if (!analyticsSharedState.sessionContext) {
241+
if (!sessionContextPromise) {
242+
const sessionId = getAnalyticsSessionId();
243+
sessionContextPromise = userAuthModule
244+
.me()
245+
.then((user) => ({
246+
user_id: user.id,
247+
session_id: sessionId,
248+
}))
249+
.catch(() => ({
250+
user_id: null,
251+
session_id: sessionId,
252+
}));
253+
}
254+
analyticsSharedState.sessionContext = await sessionContextPromise;
255+
}
256+
return analyticsSharedState.sessionContext;
257+
}
258+
259+
export function getAnalyticsModuleOptionsFromWindow():
260+
| AnalyticsModuleOptions
261+
| undefined {
262+
if (typeof window === "undefined") return undefined;
263+
return (window as any)[ANALYTICS_CONFIG_WINDOW_KEY];
264+
}
265+
266+
export function getAnalyticsSessionId(): string {
267+
if (typeof window === "undefined") {
268+
return generateUuid();
269+
}
270+
try {
271+
const sessionId = localStorage.getItem(
272+
ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY
273+
);
274+
if (!sessionId) {
275+
const newSessionId = generateUuid();
276+
localStorage.setItem(
277+
ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY,
278+
newSessionId
279+
);
280+
return newSessionId;
281+
}
282+
return sessionId;
283+
} catch {
284+
return generateUuid();
285+
}
286+
}

0 commit comments

Comments
 (0)