@base44-preview/sdk 0.8.48-pr.280.b5c6133 → 0.8.48-pr.280.b620aa9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.js +1 -0
- package/dist/modules/analytics-queue.d.ts +12 -0
- package/dist/modules/analytics-queue.js +128 -0
- package/dist/modules/analytics.d.ts +36 -5
- package/dist/modules/analytics.js +99 -155
- package/dist/modules/auth.js +2 -2
- package/dist/modules/experiment-exposures.d.ts +1 -1
- package/dist/modules/experiment-exposures.js +19 -54
- package/dist/modules/experiments.types.d.ts +25 -7
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -230,6 +230,7 @@ export function createClient(config) {
|
|
|
230
230
|
userAuthModule,
|
|
231
231
|
enabled: (_f = analytics === null || analytics === void 0 ? void 0 : analytics.enabled) !== null && _f !== void 0 ? _f : true,
|
|
232
232
|
getVisitorId: experiments.visitorId,
|
|
233
|
+
experimentsContext,
|
|
233
234
|
}),
|
|
234
235
|
actors: actorsModule.module,
|
|
235
236
|
cleanup: () => {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AxiosInstance } from "axios";
|
|
2
|
+
import type { AnalyticsApiRequestData, AnalyticsModuleOptions } from "./analytics.types.js";
|
|
3
|
+
type Event = AnalyticsApiRequestData & {
|
|
4
|
+
event_id?: string;
|
|
5
|
+
};
|
|
6
|
+
/** @internal One transport queue per client, shared by goals and exposures. */
|
|
7
|
+
export declare function getAnalyticsQueue(axiosClient: AxiosInstance, appId: string, config: AnalyticsModuleOptions): {
|
|
8
|
+
enqueue(event: Event | Promise<Event>, authorization: string | null, userId: string | null | Promise<string | null>): void;
|
|
9
|
+
flush(): Promise<void>;
|
|
10
|
+
cleanup(): void;
|
|
11
|
+
};
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const DELIVERY_BUDGET_MS = 5000;
|
|
2
|
+
const queues = new WeakMap();
|
|
3
|
+
/** @internal One transport queue per client, shared by goals and exposures. */
|
|
4
|
+
export function getAnalyticsQueue(axiosClient, appId, config) {
|
|
5
|
+
let queue = queues.get(axiosClient);
|
|
6
|
+
if (!queue) {
|
|
7
|
+
queue = createAnalyticsQueue(axiosClient, appId, config);
|
|
8
|
+
queues.set(axiosClient, queue);
|
|
9
|
+
}
|
|
10
|
+
return queue;
|
|
11
|
+
}
|
|
12
|
+
function createAnalyticsQueue(axiosClient, appId, config) {
|
|
13
|
+
const entries = [];
|
|
14
|
+
const pending = new Set();
|
|
15
|
+
let timer;
|
|
16
|
+
function deliver(batch) {
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const deadlineAt = Date.now() + DELIVERY_BUDGET_MS;
|
|
19
|
+
const deadline = new Promise((resolve) => {
|
|
20
|
+
controller.signal.addEventListener("abort", () => resolve(), { once: true });
|
|
21
|
+
});
|
|
22
|
+
const timeout = setTimeout(() => controller.abort(), DELIVERY_BUDGET_MS);
|
|
23
|
+
function send(prepared) {
|
|
24
|
+
var _a;
|
|
25
|
+
const groups = new Map();
|
|
26
|
+
for (const entry of prepared) {
|
|
27
|
+
const key = JSON.stringify([entry.authorization, entry.userId, entry.event.session_id]);
|
|
28
|
+
const group = (_a = groups.get(key)) !== null && _a !== void 0 ? _a : [];
|
|
29
|
+
group.push(entry);
|
|
30
|
+
groups.set(key, group);
|
|
31
|
+
}
|
|
32
|
+
return Promise.all([...groups.values()].map(async (group) => {
|
|
33
|
+
var _a, _b;
|
|
34
|
+
const events = group.map(({ event }) => event);
|
|
35
|
+
const exposures = events.filter((event) => event.event_name === "__experiment_exposure__");
|
|
36
|
+
const attempts = exposures.length ? 3 : 1;
|
|
37
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
38
|
+
try {
|
|
39
|
+
if (controller.signal.aborted)
|
|
40
|
+
return;
|
|
41
|
+
await axiosClient.request({
|
|
42
|
+
method: "POST", url: `/apps/${appId}/analytics/track/batch`,
|
|
43
|
+
headers: { Authorization: group[0].authorization },
|
|
44
|
+
// Ordinary goals have no backend deduplication and remain single-attempt.
|
|
45
|
+
data: { events: attempt === 0 ? events : exposures },
|
|
46
|
+
timeout: Math.max(1, deadlineAt - Date.now()), signal: controller.signal,
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const status = (_b = (_a = error.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : error.status;
|
|
52
|
+
if (controller.signal.aborted || attempt === attempts - 1 ||
|
|
53
|
+
(status !== undefined && (status < 500 || status >= 600)))
|
|
54
|
+
return;
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 100 : 500));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
const delivery = (async () => {
|
|
61
|
+
const ready = [];
|
|
62
|
+
const requests = [];
|
|
63
|
+
let wake = () => { };
|
|
64
|
+
let preparedAll = false;
|
|
65
|
+
const preparation = Promise.all(batch.map(async (entry) => {
|
|
66
|
+
const [event, userId] = await Promise.all([entry.event, entry.userId]);
|
|
67
|
+
if (event && !controller.signal.aborted)
|
|
68
|
+
ready.push({ event, userId, authorization: entry.authorization });
|
|
69
|
+
wake();
|
|
70
|
+
})).then(() => { preparedAll = true; wake(); });
|
|
71
|
+
while (!preparedAll || ready.length) {
|
|
72
|
+
if (!ready.length && !preparedAll)
|
|
73
|
+
await Promise.race([
|
|
74
|
+
new Promise((resolve) => { wake = resolve; }), deadline,
|
|
75
|
+
]);
|
|
76
|
+
if (controller.signal.aborted)
|
|
77
|
+
return;
|
|
78
|
+
// Coalesce this turn's resolved identities without waiting for unrelated auth I/O.
|
|
79
|
+
let turnTimer;
|
|
80
|
+
await Promise.race([preparation, new Promise((resolve) => { turnTimer = setTimeout(resolve, 0); })]);
|
|
81
|
+
clearTimeout(turnTimer);
|
|
82
|
+
if (ready.length)
|
|
83
|
+
requests.push(send(ready.splice(0)));
|
|
84
|
+
}
|
|
85
|
+
await Promise.all(requests);
|
|
86
|
+
})();
|
|
87
|
+
// Identity lookup and transports that ignore cancellation must also be bounded.
|
|
88
|
+
const settlement = Promise.race([delivery, deadline]).catch(() => { }).finally(() => {
|
|
89
|
+
clearTimeout(timeout);
|
|
90
|
+
controller.abort();
|
|
91
|
+
pending.delete(settlement);
|
|
92
|
+
});
|
|
93
|
+
pending.add(settlement);
|
|
94
|
+
}
|
|
95
|
+
function schedule() {
|
|
96
|
+
var _a;
|
|
97
|
+
if (timer || entries.length === 0)
|
|
98
|
+
return;
|
|
99
|
+
timer = setTimeout(() => {
|
|
100
|
+
var _a;
|
|
101
|
+
timer = undefined;
|
|
102
|
+
deliver(entries.splice(0, (_a = config.batchSize) !== null && _a !== void 0 ? _a : 30));
|
|
103
|
+
schedule();
|
|
104
|
+
}, (_a = config.throttleTime) !== null && _a !== void 0 ? _a : 1000);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
enqueue(event, authorization, userId) {
|
|
108
|
+
var _a;
|
|
109
|
+
if (entries.length >= ((_a = config.maxQueueSize) !== null && _a !== void 0 ? _a : 1000))
|
|
110
|
+
return;
|
|
111
|
+
entries.push({ event: Promise.resolve(event).catch(() => undefined), authorization,
|
|
112
|
+
userId: Promise.resolve(userId).catch(() => null) });
|
|
113
|
+
schedule();
|
|
114
|
+
},
|
|
115
|
+
async flush() {
|
|
116
|
+
var _a;
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
timer = undefined;
|
|
119
|
+
while (entries.length)
|
|
120
|
+
deliver(entries.splice(0, (_a = config.batchSize) !== null && _a !== void 0 ? _a : 30));
|
|
121
|
+
await Promise.all([...pending]);
|
|
122
|
+
},
|
|
123
|
+
cleanup() {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
timer = undefined;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { AxiosInstance } from "axios";
|
|
2
|
-
import { TrackEventParams, AnalyticsModuleOptions } from "./analytics.types";
|
|
2
|
+
import { TrackEventParams, AnalyticsModuleOptions, SessionContext } from "./analytics.types";
|
|
3
3
|
import type { InternalAuthModule } from "./auth.types";
|
|
4
|
+
import type { ExperimentsContext } from "./experiments-config.types.js";
|
|
4
5
|
export declare const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__";
|
|
5
6
|
export declare const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__";
|
|
6
7
|
export declare const ANALYTICS_SESSION_DURATION_EVENT_NAME = "__session_duration_event__";
|
|
7
8
|
export declare const ANALYTICS_CONFIG_ENABLE_URL_PARAM_KEY = "analytics-enable";
|
|
8
9
|
export declare const ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY = "base44_analytics_session_id";
|
|
10
|
+
declare function createAnalyticsState(): {
|
|
11
|
+
isHeartBeatProcessing: boolean;
|
|
12
|
+
wasInitializationTracked: boolean;
|
|
13
|
+
sessionContext: SessionContext | null;
|
|
14
|
+
sessionContextPromise: Promise<SessionContext> | null;
|
|
15
|
+
sessionStartTime: string | null;
|
|
16
|
+
fallbackSessionId: string | null;
|
|
17
|
+
config: Required<AnalyticsModuleOptions>;
|
|
18
|
+
};
|
|
19
|
+
type AnalyticsState = ReturnType<typeof createAnalyticsState>;
|
|
20
|
+
/** @internal */
|
|
21
|
+
export declare function getAnalyticsState(axiosClient: AxiosInstance): AnalyticsState;
|
|
9
22
|
export interface AnalyticsModuleArgs {
|
|
10
23
|
axiosClient: AxiosInstance;
|
|
11
24
|
serverUrl: string;
|
|
@@ -13,10 +26,19 @@ export interface AnalyticsModuleArgs {
|
|
|
13
26
|
userAuthModule: InternalAuthModule;
|
|
14
27
|
enabled: boolean;
|
|
15
28
|
getVisitorId?: () => string | undefined;
|
|
29
|
+
experimentsContext?: ExperimentsContext;
|
|
16
30
|
}
|
|
17
31
|
/** @internal */
|
|
18
|
-
export declare function isAnalyticsEnabled(enabled: boolean
|
|
19
|
-
|
|
32
|
+
export declare function isAnalyticsEnabled(enabled: boolean, state?: {
|
|
33
|
+
isHeartBeatProcessing: boolean;
|
|
34
|
+
wasInitializationTracked: boolean;
|
|
35
|
+
sessionContext: SessionContext | null;
|
|
36
|
+
sessionContextPromise: Promise<SessionContext> | null;
|
|
37
|
+
sessionStartTime: string | null;
|
|
38
|
+
fallbackSessionId: string | null;
|
|
39
|
+
config: Required<AnalyticsModuleOptions>;
|
|
40
|
+
}): boolean;
|
|
41
|
+
export declare const createAnalyticsModule: ({ axiosClient, appId, userAuthModule, enabled, getVisitorId, experimentsContext, }: AnalyticsModuleArgs) => {
|
|
20
42
|
track: (params: TrackEventParams) => void;
|
|
21
43
|
cleanup: () => void;
|
|
22
44
|
};
|
|
@@ -30,6 +52,15 @@ export declare const createAnalyticsModule: ({ axiosClient, serverUrl, appId, us
|
|
|
30
52
|
*
|
|
31
53
|
* @internal
|
|
32
54
|
*/
|
|
33
|
-
export declare function resetAnalyticsSessionContext(): void;
|
|
55
|
+
export declare function resetAnalyticsSessionContext(axiosClient?: AxiosInstance): void;
|
|
34
56
|
export declare function getAnalyticsConfigFromUrlParams(): AnalyticsModuleOptions | undefined;
|
|
35
|
-
export declare function getAnalyticsSessionId(
|
|
57
|
+
export declare function getAnalyticsSessionId(state?: {
|
|
58
|
+
isHeartBeatProcessing: boolean;
|
|
59
|
+
wasInitializationTracked: boolean;
|
|
60
|
+
sessionContext: SessionContext | null;
|
|
61
|
+
sessionContextPromise: Promise<SessionContext> | null;
|
|
62
|
+
sessionStartTime: string | null;
|
|
63
|
+
fallbackSessionId: string | null;
|
|
64
|
+
config: Required<AnalyticsModuleOptions>;
|
|
65
|
+
}): string;
|
|
66
|
+
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getSharedInstance } from "../utils/sharedInstance.js";
|
|
2
2
|
import { generateUuid, isReactNative } from "../utils/common.js";
|
|
3
3
|
import { getExperimentsRuntime } from "./experiments-runtime.types.js";
|
|
4
|
+
import { getAnalyticsQueue } from "./analytics-queue.js";
|
|
4
5
|
export const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__";
|
|
5
6
|
export const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__";
|
|
6
7
|
export const ANALYTICS_SESSION_DURATION_EVENT_NAME = "__session_duration_event__";
|
|
@@ -14,112 +15,85 @@ const defaultConfiguration = {
|
|
|
14
15
|
batchSize: 30,
|
|
15
16
|
heartBeatInterval: 60 * 1000,
|
|
16
17
|
};
|
|
17
|
-
///////////////////////////////////////////////
|
|
18
|
-
//// shared queue for analytics events ////
|
|
19
|
-
///////////////////////////////////////////////
|
|
20
18
|
const ANALYTICS_SHARED_STATE_NAME = "analytics";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
}
|
|
19
|
+
function createAnalyticsState() {
|
|
20
|
+
return {
|
|
21
|
+
isHeartBeatProcessing: false,
|
|
22
|
+
wasInitializationTracked: false,
|
|
23
|
+
sessionContext: null,
|
|
24
|
+
sessionContextPromise: null,
|
|
25
|
+
sessionStartTime: null,
|
|
26
|
+
// Memoized session id for when `localStorage` can't persist one — see
|
|
27
|
+
// getAnalyticsSessionId.
|
|
28
|
+
fallbackSessionId: null,
|
|
29
|
+
config: {
|
|
30
|
+
...defaultConfiguration,
|
|
31
|
+
...getAnalyticsConfigFromUrlParams(),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const analyticsSharedState = getSharedInstance(ANALYTICS_SHARED_STATE_NAME, createAnalyticsState);
|
|
36
|
+
const clientAnalyticsStates = new WeakMap();
|
|
37
37
|
/** @internal */
|
|
38
|
-
export function
|
|
39
|
-
|
|
38
|
+
export function getAnalyticsState(axiosClient) {
|
|
39
|
+
let state = clientAnalyticsStates.get(axiosClient);
|
|
40
|
+
if (!state) {
|
|
41
|
+
state = createAnalyticsState();
|
|
42
|
+
if (typeof window !== "undefined") {
|
|
43
|
+
state.config = analyticsSharedState.config;
|
|
44
|
+
}
|
|
45
|
+
clientAnalyticsStates.set(axiosClient, state);
|
|
46
|
+
}
|
|
47
|
+
return state;
|
|
40
48
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
/** @internal */
|
|
50
|
+
export function isAnalyticsEnabled(enabled, state = analyticsSharedState) {
|
|
51
|
+
return enabled && state.config.enabled && !isReactNative;
|
|
52
|
+
}
|
|
53
|
+
export const createAnalyticsModule = ({ axiosClient, appId, userAuthModule, enabled, getVisitorId, experimentsContext, }) => {
|
|
54
|
+
const state = getAnalyticsState(axiosClient);
|
|
55
|
+
const automaticState = typeof window === "undefined" ? state : analyticsSharedState;
|
|
56
|
+
const queue = getAnalyticsQueue(axiosClient, appId, state.config);
|
|
44
57
|
// Disable analytics on React Native. It defines `window` but not `document`,
|
|
45
58
|
// so the per-callsite `typeof window` guards below aren't enough to keep it
|
|
46
59
|
// from touching `document` (e.g. `document.referrer` on init). Node/SSR is
|
|
47
60
|
// still handled by those `window` guards, so this doesn't affect it.
|
|
48
|
-
if (!isAnalyticsEnabled(enabled)) {
|
|
61
|
+
if (!isAnalyticsEnabled(enabled, state)) {
|
|
49
62
|
return {
|
|
50
63
|
track: () => { },
|
|
51
64
|
cleanup: () => { },
|
|
52
65
|
};
|
|
53
66
|
}
|
|
54
67
|
let clearHeartBeatProcessor = undefined;
|
|
55
|
-
const trackBatchUrl = `${serverUrl}/api/apps/${appId}/analytics/track/batch`;
|
|
56
|
-
const batchRequestFallback = async (events) => {
|
|
57
|
-
await axiosClient.request({
|
|
58
|
-
method: "POST",
|
|
59
|
-
url: `/apps/${appId}/analytics/track/batch`,
|
|
60
|
-
data: { events },
|
|
61
|
-
});
|
|
62
|
-
};
|
|
63
|
-
// currently disabled, until fully tested //
|
|
64
|
-
const beaconRequest = (events) => {
|
|
65
|
-
try {
|
|
66
|
-
const beaconPayload = JSON.stringify({ events });
|
|
67
|
-
const blob = new Blob([beaconPayload], { type: "application/json" });
|
|
68
|
-
return (typeof navigator === "undefined" ||
|
|
69
|
-
beaconPayload.length > 60000 ||
|
|
70
|
-
!navigator.sendBeacon(trackBatchUrl, blob));
|
|
71
|
-
}
|
|
72
|
-
catch (_a) {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
const flush = async (eventsData, options = {}) => {
|
|
77
|
-
var _a;
|
|
78
|
-
if (eventsData.length === 0)
|
|
79
|
-
return;
|
|
80
|
-
const sessionContext_ = await getSessionContext(userAuthModule);
|
|
81
|
-
const events = eventsData.map(transformEventDataToApiRequestData({ ...sessionContext_, session_id: (_a = getVisitorId === null || getVisitorId === void 0 ? void 0 : getVisitorId()) !== null && _a !== void 0 ? _a : sessionContext_.session_id }));
|
|
82
|
-
try {
|
|
83
|
-
if (!options.isBeacon || !beaconRequest(events)) {
|
|
84
|
-
await batchRequestFallback(events);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
catch (_b) {
|
|
88
|
-
// do nothing
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
const startProcessing = () => {
|
|
92
|
-
startAnalyticsProcessor(flush, {
|
|
93
|
-
throttleTime,
|
|
94
|
-
batchSize,
|
|
95
|
-
});
|
|
96
|
-
};
|
|
97
68
|
const track = (params) => {
|
|
98
|
-
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
69
|
+
var _a, _b;
|
|
101
70
|
const intrinsicData = getEventIntrinsicData();
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
});
|
|
106
|
-
|
|
71
|
+
const visitorId = (_a = getVisitorId === null || getVisitorId === void 0 ? void 0 : getVisitorId()) !== null && _a !== void 0 ? _a : getAnalyticsSessionId(state);
|
|
72
|
+
const authorization = userAuthModule.hasToken() ? axiosClient.defaults.headers.common.Authorization : null;
|
|
73
|
+
const context = getSessionContext(userAuthModule, state);
|
|
74
|
+
const preview = Object.fromEntries(Object.entries((_b = experimentsContext === null || experimentsContext === void 0 ? void 0 : experimentsContext.preview) !== null && _b !== void 0 ? _b : {}).filter(([, value]) => typeof value === "boolean"));
|
|
75
|
+
const properties = { ...params.properties };
|
|
76
|
+
delete properties.__b44_experiment_preview;
|
|
77
|
+
if (Object.keys(preview).length) {
|
|
78
|
+
// Capture now: a queued event must retain its occurrence-time preview.
|
|
79
|
+
properties.__b44_experiment_preview = JSON.stringify(preview);
|
|
80
|
+
}
|
|
81
|
+
const event = {
|
|
82
|
+
event_name: params.eventName,
|
|
83
|
+
timestamp: intrinsicData.timestamp,
|
|
84
|
+
page_url: intrinsicData.pageUrl,
|
|
85
|
+
properties: params.properties || Object.keys(properties).length ? properties : undefined,
|
|
86
|
+
};
|
|
87
|
+
queue.enqueue(context.then((identity) => ({ ...event, ...identity, session_id: visitorId })), typeof authorization === "string" ? authorization : null, context.then((identity) => { var _a; return (_a = identity.user_id) !== null && _a !== void 0 ? _a : null; }));
|
|
107
88
|
};
|
|
108
89
|
const onDocVisible = () => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
batchSize,
|
|
112
|
-
});
|
|
113
|
-
clearHeartBeatProcessor = startHeartBeatProcessor(track);
|
|
114
|
-
setSessionDurationTimerStart();
|
|
90
|
+
clearHeartBeatProcessor = startHeartBeatProcessor(track, automaticState);
|
|
91
|
+
setSessionDurationTimerStart(automaticState);
|
|
115
92
|
};
|
|
116
93
|
const onDocHidden = () => {
|
|
117
|
-
stopAnalyticsProcessor();
|
|
118
94
|
clearHeartBeatProcessor === null || clearHeartBeatProcessor === void 0 ? void 0 : clearHeartBeatProcessor();
|
|
119
|
-
trackSessionDurationEvent(track);
|
|
120
|
-
|
|
121
|
-
const eventsData = analyticsSharedState.requestsQueue.splice(0);
|
|
122
|
-
flush(eventsData, { isBeacon: true });
|
|
95
|
+
trackSessionDurationEvent(track, automaticState);
|
|
96
|
+
void queue.flush();
|
|
123
97
|
};
|
|
124
98
|
const onVisibilityChange = () => {
|
|
125
99
|
if (typeof window === "undefined")
|
|
@@ -132,18 +106,16 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
|
|
|
132
106
|
}
|
|
133
107
|
};
|
|
134
108
|
const cleanup = () => {
|
|
135
|
-
|
|
109
|
+
queue.cleanup();
|
|
136
110
|
clearHeartBeatProcessor === null || clearHeartBeatProcessor === void 0 ? void 0 : clearHeartBeatProcessor();
|
|
137
111
|
if (typeof window !== "undefined") {
|
|
138
112
|
window.removeEventListener("visibilitychange", onVisibilityChange);
|
|
139
113
|
}
|
|
140
114
|
};
|
|
141
|
-
// start the flusing process ///
|
|
142
|
-
startProcessing();
|
|
143
115
|
// start the heart beat processor //
|
|
144
|
-
clearHeartBeatProcessor = startHeartBeatProcessor(track);
|
|
116
|
+
clearHeartBeatProcessor = startHeartBeatProcessor(track, automaticState);
|
|
145
117
|
// track the referrer event //
|
|
146
|
-
trackInitializationEvent(track);
|
|
118
|
+
trackInitializationEvent(track, automaticState);
|
|
147
119
|
// start the visibility change listener //
|
|
148
120
|
if (typeof window !== "undefined") {
|
|
149
121
|
window.addEventListener("visibilitychange", onVisibilityChange);
|
|
@@ -153,50 +125,32 @@ export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthM
|
|
|
153
125
|
cleanup,
|
|
154
126
|
};
|
|
155
127
|
};
|
|
156
|
-
function
|
|
157
|
-
analyticsSharedState.isProcessing = false;
|
|
158
|
-
}
|
|
159
|
-
async function startAnalyticsProcessor(handleTrack, options) {
|
|
160
|
-
if (analyticsSharedState.isProcessing) {
|
|
161
|
-
// only one instance of the analytics processor can be running at a time //
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
analyticsSharedState.isProcessing = true;
|
|
165
|
-
const { throttleTime = 1000, batchSize = 30 } = options !== null && options !== void 0 ? options : {};
|
|
166
|
-
while (analyticsSharedState.isProcessing &&
|
|
167
|
-
analyticsSharedState.requestsQueue.length > 0) {
|
|
168
|
-
const requests = analyticsSharedState.requestsQueue.splice(0, batchSize);
|
|
169
|
-
requests.length && (await handleTrack(requests));
|
|
170
|
-
await new Promise((resolve) => setTimeout(resolve, throttleTime));
|
|
171
|
-
}
|
|
172
|
-
analyticsSharedState.isProcessing = false;
|
|
173
|
-
}
|
|
174
|
-
function startHeartBeatProcessor(track) {
|
|
128
|
+
function startHeartBeatProcessor(track, state) {
|
|
175
129
|
var _a;
|
|
176
130
|
// Browser-only, like the other automatic events here (initialization, session
|
|
177
131
|
// duration, visibility). Outside a browser this timer fired a `me()` every
|
|
178
132
|
// interval for the lifetime of a long-lived server-side client, and kept the
|
|
179
133
|
// Node event loop alive. Explicit `analytics.track()` calls still work.
|
|
180
134
|
if (typeof window === "undefined" ||
|
|
181
|
-
|
|
182
|
-
((_a =
|
|
135
|
+
state.isHeartBeatProcessing ||
|
|
136
|
+
((_a = state.config.heartBeatInterval) !== null && _a !== void 0 ? _a : 0) < 10) {
|
|
183
137
|
return () => { };
|
|
184
138
|
}
|
|
185
|
-
|
|
139
|
+
state.isHeartBeatProcessing = true;
|
|
186
140
|
const interval = setInterval(() => {
|
|
187
141
|
track({ eventName: USER_HEARTBEAT_EVENT_NAME });
|
|
188
|
-
},
|
|
142
|
+
}, state.config.heartBeatInterval);
|
|
189
143
|
return () => {
|
|
190
144
|
clearInterval(interval);
|
|
191
|
-
|
|
145
|
+
state.isHeartBeatProcessing = false;
|
|
192
146
|
};
|
|
193
147
|
}
|
|
194
|
-
function trackInitializationEvent(track) {
|
|
148
|
+
function trackInitializationEvent(track, state) {
|
|
195
149
|
if (typeof window === "undefined" ||
|
|
196
|
-
|
|
150
|
+
state.wasInitializationTracked) {
|
|
197
151
|
return;
|
|
198
152
|
}
|
|
199
|
-
|
|
153
|
+
state.wasInitializationTracked = true;
|
|
200
154
|
track({
|
|
201
155
|
eventName: ANALYTICS_INITIALIZATION_EVENT_NAME,
|
|
202
156
|
properties: {
|
|
@@ -204,20 +158,20 @@ function trackInitializationEvent(track) {
|
|
|
204
158
|
},
|
|
205
159
|
});
|
|
206
160
|
}
|
|
207
|
-
function setSessionDurationTimerStart() {
|
|
161
|
+
function setSessionDurationTimerStart(state) {
|
|
208
162
|
if (typeof window === "undefined" ||
|
|
209
|
-
|
|
163
|
+
state.sessionStartTime !== null) {
|
|
210
164
|
return;
|
|
211
165
|
}
|
|
212
|
-
|
|
166
|
+
state.sessionStartTime = new Date().toISOString();
|
|
213
167
|
}
|
|
214
|
-
function trackSessionDurationEvent(track) {
|
|
168
|
+
function trackSessionDurationEvent(track, state) {
|
|
215
169
|
if (typeof window === "undefined" ||
|
|
216
|
-
|
|
170
|
+
state.sessionStartTime === null)
|
|
217
171
|
return;
|
|
218
172
|
const sessionDuration = new Date().getTime() -
|
|
219
|
-
new Date(
|
|
220
|
-
|
|
173
|
+
new Date(state.sessionStartTime).getTime();
|
|
174
|
+
state.sessionStartTime = null;
|
|
221
175
|
track({
|
|
222
176
|
eventName: ANALYTICS_SESSION_DURATION_EVENT_NAME,
|
|
223
177
|
properties: { sessionDuration },
|
|
@@ -231,16 +185,6 @@ function getEventIntrinsicData() {
|
|
|
231
185
|
pageUrl: typeof window !== "undefined" ? (_b = (_a = window.location) === null || _a === void 0 ? void 0 : _a.pathname) !== null && _b !== void 0 ? _b : null : null,
|
|
232
186
|
};
|
|
233
187
|
}
|
|
234
|
-
function transformEventDataToApiRequestData(sessionContext) {
|
|
235
|
-
return (eventData) => ({
|
|
236
|
-
event_name: eventData.eventName,
|
|
237
|
-
properties: eventData.properties,
|
|
238
|
-
timestamp: eventData.timestamp,
|
|
239
|
-
page_url: eventData.pageUrl,
|
|
240
|
-
...sessionContext,
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
let sessionContextPromise = null;
|
|
244
188
|
/**
|
|
245
189
|
* Clears the memoized analytics session context.
|
|
246
190
|
*
|
|
@@ -251,22 +195,23 @@ let sessionContextPromise = null;
|
|
|
251
195
|
*
|
|
252
196
|
* @internal
|
|
253
197
|
*/
|
|
254
|
-
export function resetAnalyticsSessionContext() {
|
|
255
|
-
|
|
256
|
-
|
|
198
|
+
export function resetAnalyticsSessionContext(axiosClient) {
|
|
199
|
+
const state = axiosClient ? getAnalyticsState(axiosClient) : analyticsSharedState;
|
|
200
|
+
state.sessionContext = null;
|
|
201
|
+
state.sessionContextPromise = null;
|
|
257
202
|
}
|
|
258
|
-
async function getSessionContext(userAuthModule) {
|
|
259
|
-
if (!
|
|
203
|
+
async function getSessionContext(userAuthModule, state) {
|
|
204
|
+
if (!state.sessionContext) {
|
|
260
205
|
// With no token there is no identity to resolve: `me()` can only answer 401,
|
|
261
206
|
// which the browser logs to the console before any handler here sees it. On
|
|
262
207
|
// a public page that request is the sole reason an error appears, so skip
|
|
263
208
|
// it. This is not memoized — a visitor who logs in later must still resolve.
|
|
264
209
|
if (!userAuthModule.hasToken()) {
|
|
265
|
-
return { user_id: null, session_id: getAnalyticsSessionId() };
|
|
210
|
+
return { user_id: null, session_id: getAnalyticsSessionId(state) };
|
|
266
211
|
}
|
|
267
|
-
if (!sessionContextPromise) {
|
|
268
|
-
const sessionId = getAnalyticsSessionId();
|
|
269
|
-
sessionContextPromise = userAuthModule
|
|
212
|
+
if (!state.sessionContextPromise) {
|
|
213
|
+
const sessionId = getAnalyticsSessionId(state);
|
|
214
|
+
state.sessionContextPromise = userAuthModule
|
|
270
215
|
.me()
|
|
271
216
|
.then((user) => ({
|
|
272
217
|
user_id: user.id,
|
|
@@ -277,7 +222,7 @@ async function getSessionContext(userAuthModule) {
|
|
|
277
222
|
session_id: sessionId,
|
|
278
223
|
}));
|
|
279
224
|
}
|
|
280
|
-
const pending = sessionContextPromise;
|
|
225
|
+
const pending = state.sessionContextPromise;
|
|
281
226
|
const context = await pending;
|
|
282
227
|
// Publish only if this lookup is still the current one. A reset that lands
|
|
283
228
|
// while the request is in flight nulls `sessionContextPromise`, and an
|
|
@@ -285,12 +230,12 @@ async function getSessionContext(userAuthModule) {
|
|
|
285
230
|
// for the rest of the session. The awaited value is still returned: these
|
|
286
231
|
// events were queued before the identity changed, so that is who they
|
|
287
232
|
// belong to.
|
|
288
|
-
if (sessionContextPromise === pending) {
|
|
289
|
-
|
|
233
|
+
if (state.sessionContextPromise === pending) {
|
|
234
|
+
state.sessionContext = context;
|
|
290
235
|
}
|
|
291
236
|
return context;
|
|
292
237
|
}
|
|
293
|
-
return
|
|
238
|
+
return state.sessionContext;
|
|
294
239
|
}
|
|
295
240
|
export function getAnalyticsConfigFromUrlParams() {
|
|
296
241
|
// `window.location` is absent on React Native. This runs at module load (via
|
|
@@ -312,19 +257,18 @@ export function getAnalyticsConfigFromUrlParams() {
|
|
|
312
257
|
// return the config object //
|
|
313
258
|
return { enabled: analyticsEnable === "true" };
|
|
314
259
|
}
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
function getFallbackSessionId() {
|
|
260
|
+
// Without persistent storage, keep the id stable within this analytics state.
|
|
261
|
+
function getFallbackSessionId(state) {
|
|
318
262
|
var _a;
|
|
319
|
-
return ((_a =
|
|
263
|
+
return ((_a = state.fallbackSessionId) !== null && _a !== void 0 ? _a : (state.fallbackSessionId = generateUuid()));
|
|
320
264
|
}
|
|
321
|
-
export function getAnalyticsSessionId() {
|
|
265
|
+
export function getAnalyticsSessionId(state = analyticsSharedState) {
|
|
322
266
|
var _a;
|
|
323
267
|
const visitorId = (_a = getExperimentsRuntime()) === null || _a === void 0 ? void 0 : _a.visitorId;
|
|
324
268
|
if (visitorId && visitorId !== "anon")
|
|
325
269
|
return visitorId;
|
|
326
270
|
if (typeof window === "undefined") {
|
|
327
|
-
return getFallbackSessionId();
|
|
271
|
+
return getFallbackSessionId(state);
|
|
328
272
|
}
|
|
329
273
|
try {
|
|
330
274
|
const sessionId = localStorage.getItem(ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY);
|
|
@@ -336,6 +280,6 @@ export function getAnalyticsSessionId() {
|
|
|
336
280
|
return sessionId;
|
|
337
281
|
}
|
|
338
282
|
catch (_b) {
|
|
339
|
-
return getFallbackSessionId();
|
|
283
|
+
return getFallbackSessionId(state);
|
|
340
284
|
}
|
|
341
285
|
}
|
package/dist/modules/auth.js
CHANGED
|
@@ -177,7 +177,7 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
|
|
|
177
177
|
// Drop identity resolved under the previous session: a `me()` already in
|
|
178
178
|
// flight would otherwise resolve into callers that run after the logout.
|
|
179
179
|
clearPendingMe();
|
|
180
|
-
resetAnalyticsSessionContext();
|
|
180
|
+
resetAnalyticsSessionContext(axios);
|
|
181
181
|
hasAccessToken = false;
|
|
182
182
|
notifyAuthState({ status: "anonymous" });
|
|
183
183
|
// Only do the rest if in a browser environment
|
|
@@ -207,7 +207,7 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
|
|
|
207
207
|
// Same reasoning as in `logout`: the identity changes here, so anything
|
|
208
208
|
// resolved for the previous one must not be handed to later callers.
|
|
209
209
|
clearPendingMe();
|
|
210
|
-
resetAnalyticsSessionContext();
|
|
210
|
+
resetAnalyticsSessionContext(axios);
|
|
211
211
|
hasAccessToken = true;
|
|
212
212
|
// handle token change for axios clients
|
|
213
213
|
axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
|
|
@@ -1,65 +1,30 @@
|
|
|
1
1
|
import { v4 as uuid } from "uuid";
|
|
2
|
-
import { isAnalyticsEnabled } from "./analytics.js";
|
|
2
|
+
import { getAnalyticsState, isAnalyticsEnabled } from "./analytics.js";
|
|
3
|
+
import { getAnalyticsQueue } from "./analytics-queue.js";
|
|
3
4
|
/** @internal */
|
|
4
5
|
export function createExposureTracker({ axiosClient, appId, enabled, source = "browser", pageUrl, }) {
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return entry.pending;
|
|
9
|
-
const pending = (async () => {
|
|
10
|
-
for (let attempt = 0;; attempt++) {
|
|
11
|
-
try {
|
|
12
|
-
const response = await axiosClient.request({
|
|
13
|
-
method: "POST",
|
|
14
|
-
url: `/apps/${appId}/analytics/track/batch`,
|
|
15
|
-
headers: { Authorization: entry.authorization },
|
|
16
|
-
data: entry.data,
|
|
17
|
-
});
|
|
18
|
-
if (response.accepted !== 1)
|
|
19
|
-
throw new Error("Experiment exposure was not accepted");
|
|
20
|
-
entry.acknowledged = true;
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
catch (error) {
|
|
24
|
-
if (attempt === 2)
|
|
25
|
-
throw error;
|
|
26
|
-
await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 100 : 500));
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
})().finally(() => { entry.pending = undefined; });
|
|
30
|
-
entry.pending = pending;
|
|
31
|
-
// Reads stay synchronous; flush() lets request handlers observe delivery failures.
|
|
32
|
-
void pending.catch(() => { });
|
|
33
|
-
return pending;
|
|
34
|
-
}
|
|
6
|
+
const state = getAnalyticsState(axiosClient);
|
|
7
|
+
const queue = getAnalyticsQueue(axiosClient, appId, state.config);
|
|
8
|
+
const tracked = new Set();
|
|
35
9
|
return {
|
|
36
10
|
track(assignment, identity) {
|
|
37
|
-
if ((source === "browser" && typeof window === "undefined") || !isAnalyticsEnabled(enabled))
|
|
11
|
+
if ((source === "browser" && typeof window === "undefined") || !isAnalyticsEnabled(enabled, state))
|
|
38
12
|
return;
|
|
39
13
|
const { experiment_id, run_version, variant_key } = assignment;
|
|
40
14
|
const key = JSON.stringify([experiment_id, run_version, variant_key, identity.userId, identity.visitorId]);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
properties: { experiment_id, run_version, variant_key, source },
|
|
54
|
-
}] },
|
|
55
|
-
};
|
|
56
|
-
entries.set(key, entry);
|
|
57
|
-
}
|
|
58
|
-
if (!entry.acknowledged)
|
|
59
|
-
void send(entry);
|
|
60
|
-
},
|
|
61
|
-
async flush() {
|
|
62
|
-
await Promise.all([...entries.values()].filter((entry) => !entry.acknowledged).map(send));
|
|
15
|
+
if (tracked.has(key))
|
|
16
|
+
return;
|
|
17
|
+
tracked.add(key);
|
|
18
|
+
const authorization = identity.userId ? axiosClient.defaults.headers.common.Authorization : null;
|
|
19
|
+
queue.enqueue({
|
|
20
|
+
event_id: uuid(),
|
|
21
|
+
event_name: "__experiment_exposure__",
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
session_id: identity.visitorId,
|
|
24
|
+
page_url: pageUrl !== null && pageUrl !== void 0 ? pageUrl : (typeof window === "undefined" ? "/" : window.location.pathname),
|
|
25
|
+
properties: { experiment_id, run_version, variant_key, source },
|
|
26
|
+
}, typeof authorization === "string" ? authorization : null, identity.userId);
|
|
63
27
|
},
|
|
28
|
+
flush: queue.flush,
|
|
64
29
|
};
|
|
65
30
|
}
|
|
@@ -25,7 +25,7 @@ export interface ExperimentsSnapshot {
|
|
|
25
25
|
*/
|
|
26
26
|
export interface ExperimentsModule {
|
|
27
27
|
/**
|
|
28
|
-
* Reads a flag and queues
|
|
28
|
+
* Reads a flag and queues a best-effort exposure for its current assignment.
|
|
29
29
|
*
|
|
30
30
|
* Never starts an authentication request. Reads return the fallback while the
|
|
31
31
|
* app's normal auth initialization is pending or failed. Supply trusted bootstrap
|
|
@@ -34,8 +34,13 @@ export interface ExperimentsModule {
|
|
|
34
34
|
* Call only where the feature is used: a read counts as exposure, not proof of
|
|
35
35
|
* visibility. Preview overrides and flags without an assignment are not tracked.
|
|
36
36
|
* Exposures respect the client's analytics setting, are deduplicated per client,
|
|
37
|
-
* experiment run, variant and identity.
|
|
38
|
-
*
|
|
37
|
+
* experiment run, variant and identity. Network and server failures retry up to
|
|
38
|
+
* three attempts within five seconds of batch delivery, preserving the event ID,
|
|
39
|
+
* timestamp and credentials. HTTP successes (including rejected measurements) and client errors
|
|
40
|
+
* are terminal. Exposures share the Analytics batch with compatible ordinary
|
|
41
|
+
* events; credentials and user/visitor identities are captured when tracking.
|
|
42
|
+
* Only exposures are retried; ordinary goals retain single-attempt delivery.
|
|
43
|
+
* On servers, use the runtime's background lifetime mechanism.
|
|
39
44
|
*
|
|
40
45
|
* @param flagKey - Feature flag key defined in your app.
|
|
41
46
|
* @param fallback - Value for an unavailable flag or unresolved identity. Defaults to `false`.
|
|
@@ -97,10 +102,23 @@ export interface ExperimentsModule {
|
|
|
97
102
|
*/
|
|
98
103
|
ready(): Promise<ExperimentsSnapshot>;
|
|
99
104
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
105
|
+
* Flushes this client's queued Analytics goals and exposures without rejecting.
|
|
106
|
+
* Each delivery has a five-second total budget; exhausted or rejected events are
|
|
107
|
+
* dropped and are not retried by later reads or flushes. Settlement is not proof
|
|
108
|
+
* of ingestion, and raw storage is not exactly-once. No new exposures are created.
|
|
109
|
+
* Worker handlers should use `ctx.waitUntil(client.experiments.flush())` instead
|
|
110
|
+
* of awaiting Analytics on the application response path. Other runtimes must use
|
|
111
|
+
* their supported background lifetime mechanism; fire-and-forget alone may be cut off.
|
|
112
|
+
* Base44's legacy Cloudflare runtime exposes `globalThis.Base44.waitUntil(...)`;
|
|
113
|
+
* the newer runtime exports `waitUntil` from `base44:runtime`. Use the API provided
|
|
114
|
+
* by your deployed runtime. Deno without a background lifetime API must await flush.
|
|
115
|
+
*
|
|
116
|
+
* @returns A promise that resolves when the current batch deliveries settle.
|
|
117
|
+
* @example
|
|
118
|
+
* ```typescript
|
|
119
|
+
* // In a Worker handler with an execution context:
|
|
120
|
+
* ctx.waitUntil(base44.experiments.flush());
|
|
121
|
+
* ```
|
|
104
122
|
*/
|
|
105
123
|
flush(): Promise<void>;
|
|
106
124
|
}
|