Skip to content

Commit 906486a

Browse files
committed
live users count tracking with heartbeat
1 parent 2092b43 commit 906486a

3 files changed

Lines changed: 97 additions & 62 deletions

File tree

src/modules/analytics.ts

Lines changed: 94 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@ import {
1111
import { getSharedInstance } from "../utils/sharedInstance";
1212
import type { AuthModule } from "./auth.types";
1313

14+
export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__";
15+
1416
const defaultConfiguration: AnalyticsModuleOptions = {
1517
enabled: true,
1618
maxQueueSize: 1000,
1719
throttleTime: 1000,
1820
batchSize: 30,
21+
heartBeatInterval: 60 * 1000,
1922
};
2023

2124
///////////////////////////////////////////////
@@ -29,10 +32,11 @@ const analyticsSharedState = getSharedInstance(
2932
() => ({
3033
requestsQueue: [] as TrackEventData[],
3134
isProcessing: false,
35+
isHeartBeatProcessing: false,
3236
sessionContext: null as SessionContext | null,
3337
config: {
3438
...defaultConfiguration,
35-
...getAnalyticsModuleOptionsFromUrlParams(),
39+
...getAnalyticsModuleOptionsFromLocalStorage(),
3640
} as Required<AnalyticsModuleOptions>,
3741
})
3842
);
@@ -55,19 +59,9 @@ export const createAnalyticsModule = ({
5559
// prevent overflow of events //
5660
const { enabled, maxQueueSize, throttleTime, batchSize } =
5761
analyticsSharedState.config;
58-
62+
let clearHeartBeatProcessor: (() => void) | undefined = undefined;
5963
const trackBatchUrl = `${serverUrl}/api/apps/${appId}/analytics/track/batch`;
6064

61-
const getSessionContext = async () => {
62-
if (!analyticsSharedState.sessionContext) {
63-
const user = await userAuthModule.me();
64-
analyticsSharedState.sessionContext = {
65-
user_id: user.id,
66-
};
67-
}
68-
return analyticsSharedState.sessionContext;
69-
};
70-
7165
const batchRequestFallback = async (events: AnalyticsApiRequestData[]) => {
7266
await axiosClient.request({
7367
method: "POST",
@@ -77,49 +71,25 @@ export const createAnalyticsModule = ({
7771
};
7872

7973
const flush = async (eventsData: TrackEventData[]) => {
80-
const sessionContext_ = await getSessionContext();
74+
const sessionContext_ = await getSessionContext(userAuthModule);
8175
const events = eventsData.map(
8276
transformEventDataToApiRequestData(sessionContext_)
8377
);
8478
const beaconPayload = JSON.stringify({ events });
85-
86-
if (
87-
typeof navigator === "undefined" ||
88-
beaconPayload.length > 60000 ||
89-
!navigator.sendBeacon(trackBatchUrl, beaconPayload)
90-
) {
91-
// beacon didn't work, fallback to axios
92-
await batchRequestFallback(events);
93-
}
94-
};
95-
96-
const onDocHidden = () => {
97-
stopAnalyticsProcessor();
98-
// flush entire queue on visibility change and hope for the best //
99-
const eventsData = analyticsSharedState.requestsQueue.splice(0);
100-
flush(eventsData);
101-
};
102-
103-
const onDocVisible = () => {
104-
startAnalyticsProcessor(flush, {
105-
throttleTime,
106-
batchSize,
107-
});
108-
};
109-
110-
const onVisibilityChange = () => {
111-
if (typeof window === "undefined") return;
112-
if (document.visibilityState === "hidden") {
113-
onDocHidden();
114-
} else if (document.visibilityState === "visible") {
115-
onDocVisible();
79+
try {
80+
if (
81+
typeof navigator === "undefined" ||
82+
beaconPayload.length > 60000 ||
83+
!navigator.sendBeacon(trackBatchUrl, beaconPayload)
84+
) {
85+
// beacon didn't work, fallback to axios
86+
await batchRequestFallback(events);
87+
}
88+
} catch {
89+
// TODO: think about retries if needed
11690
}
11791
};
11892

119-
if (typeof window !== "undefined" && enabled) {
120-
window.addEventListener("visibilitychange", onVisibilityChange);
121-
}
122-
12393
const startProcessing = () => {
12494
if (!enabled) return;
12595
startAnalyticsProcessor(flush, {
@@ -140,14 +110,47 @@ export const createAnalyticsModule = ({
140110
startProcessing();
141111
};
142112

143-
const cleanup = () => {
113+
const onDocVisible = () => {
114+
startAnalyticsProcessor(flush, {
115+
throttleTime,
116+
batchSize,
117+
});
118+
clearHeartBeatProcessor = startHeartBeatProcessor(track);
119+
};
120+
121+
const onDocHidden = () => {
122+
stopAnalyticsProcessor();
123+
// flush entire queue on visibility change and hope for the best //
124+
const eventsData = analyticsSharedState.requestsQueue.splice(0);
125+
flush(eventsData);
126+
clearHeartBeatProcessor?.();
127+
};
128+
129+
const onVisibilityChange = () => {
144130
if (typeof window === "undefined") return;
145-
window.removeEventListener("visibilitychange", onVisibilityChange);
131+
if (document.visibilityState === "hidden") {
132+
onDocHidden();
133+
} else if (document.visibilityState === "visible") {
134+
onDocVisible();
135+
}
136+
};
137+
138+
const cleanup = () => {
146139
stopAnalyticsProcessor();
140+
clearHeartBeatProcessor?.();
141+
if (typeof window !== "undefined") {
142+
window.removeEventListener("visibilitychange", onVisibilityChange);
143+
}
147144
};
148145

149146
// start the flusing process ///
150147
startProcessing();
148+
// start the heart beat processor //
149+
clearHeartBeatProcessor = startHeartBeatProcessor(track);
150+
// start the visibility change listener //
151+
if (typeof window !== "undefined" && enabled) {
152+
window.addEventListener("visibilitychange", onVisibilityChange);
153+
}
151154

152155
return {
153156
track,
@@ -178,19 +181,31 @@ async function startAnalyticsProcessor(
178181
analyticsSharedState.requestsQueue.length > 0
179182
) {
180183
const requests = analyticsSharedState.requestsQueue.splice(0, batchSize);
181-
if (requests.length > 0) {
182-
try {
183-
await handleTrack(requests);
184-
} catch (error) {
185-
// TODO: think about retries if needed
186-
console.error("Error processing analytics request:", error);
187-
}
188-
}
184+
requests.length && (await handleTrack(requests));
189185
await new Promise((resolve) => setTimeout(resolve, throttleTime));
190186
}
191187
analyticsSharedState.isProcessing = false;
192188
}
193189

190+
function startHeartBeatProcessor(track: (params: TrackEventParams) => void) {
191+
if (
192+
analyticsSharedState.isHeartBeatProcessing ||
193+
(analyticsSharedState.config.heartBeatInterval ?? 0) < 10
194+
) {
195+
return () => {};
196+
}
197+
198+
analyticsSharedState.isHeartBeatProcessing = true;
199+
const interval = setInterval(() => {
200+
track({ eventName: USER_HEARTBEAT_EVENT_NAME });
201+
}, analyticsSharedState.config.heartBeatInterval);
202+
203+
return () => {
204+
clearInterval(interval);
205+
analyticsSharedState.isHeartBeatProcessing = false;
206+
};
207+
}
208+
194209
function getEventIntrinsicData(): TrackEventIntrinsicData {
195210
return {
196211
timestamp: new Date().toISOString(),
@@ -208,14 +223,31 @@ function transformEventDataToApiRequestData(sessionContext: SessionContext) {
208223
});
209224
}
210225

211-
export function getAnalyticsModuleOptionsFromUrlParams():
226+
let sessionContextPromise: Promise<SessionContext> | null = null;
227+
async function getSessionContext(userAuthModule: AuthModule) {
228+
if (!analyticsSharedState.sessionContext) {
229+
if (!sessionContextPromise) {
230+
sessionContextPromise = userAuthModule
231+
.me()
232+
.then((user) => ({
233+
user_id: user.id,
234+
}))
235+
.catch(() => ({
236+
user_id: "unknown: error getting session context",
237+
}));
238+
}
239+
analyticsSharedState.sessionContext = await sessionContextPromise;
240+
}
241+
return analyticsSharedState.sessionContext;
242+
}
243+
244+
export function getAnalyticsModuleOptionsFromLocalStorage():
212245
| AnalyticsModuleOptions
213246
| undefined {
214247
if (typeof window === "undefined") return undefined;
215-
const urlParams = new URLSearchParams(window.location.search);
216-
const jsonString = urlParams.get("analytics");
217-
if (!jsonString) return undefined;
218248
try {
249+
const jsonString = localStorage.getItem("base44_analytics_config");
250+
if (!jsonString) return undefined;
219251
return JSON.parse(jsonString);
220252
} catch {
221253
return undefined;

src/modules/analytics.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export type AnalyticsModuleOptions = {
4141
maxQueueSize?: number;
4242
throttleTime?: number;
4343
batchSize?: number;
44+
// used for live users count tracking
45+
heartBeatInterval?: number;
4446
};
4547

4648
export type AnalyticsModule = {

tests/unit/analytics.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ describe("Analytics Module", () => {
5050
maxQueueSize: 1000,
5151
throttleTime: 1000,
5252
batchSize: 2,
53+
heartBeatInterval: undefined,
5354
};
5455

5556
base44 = createClient({

0 commit comments

Comments
 (0)