@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 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): boolean;
19
- export declare const createAnalyticsModule: ({ axiosClient, serverUrl, appId, userAuthModule, enabled, getVisitorId, }: AnalyticsModuleArgs) => {
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(): string;
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
- // shared state//
22
- const analyticsSharedState = getSharedInstance(ANALYTICS_SHARED_STATE_NAME, () => ({
23
- requestsQueue: [],
24
- isProcessing: false,
25
- isHeartBeatProcessing: false,
26
- wasInitializationTracked: false,
27
- sessionContext: null,
28
- sessionStartTime: null,
29
- // Memoized session id for when `localStorage` can't persist one — see
30
- // getAnalyticsSessionId.
31
- fallbackSessionId: null,
32
- config: {
33
- ...defaultConfiguration,
34
- ...getAnalyticsConfigFromUrlParams(),
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 isAnalyticsEnabled(enabled) {
39
- return enabled && analyticsSharedState.config.enabled && !isReactNative;
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
- export const createAnalyticsModule = ({ axiosClient, serverUrl, appId, userAuthModule, enabled, getVisitorId, }) => {
42
- // prevent overflow of events //
43
- const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config;
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
- if (analyticsSharedState.requestsQueue.length >= maxQueueSize) {
99
- return;
100
- }
69
+ var _a, _b;
101
70
  const intrinsicData = getEventIntrinsicData();
102
- analyticsSharedState.requestsQueue.push({
103
- ...params,
104
- ...intrinsicData,
105
- });
106
- startProcessing();
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
- startAnalyticsProcessor(flush, {
110
- throttleTime,
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
- // flush entire queue on visibility change and hope for the best //
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
- stopAnalyticsProcessor();
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 stopAnalyticsProcessor() {
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
- analyticsSharedState.isHeartBeatProcessing ||
182
- ((_a = analyticsSharedState.config.heartBeatInterval) !== null && _a !== void 0 ? _a : 0) < 10) {
135
+ state.isHeartBeatProcessing ||
136
+ ((_a = state.config.heartBeatInterval) !== null && _a !== void 0 ? _a : 0) < 10) {
183
137
  return () => { };
184
138
  }
185
- analyticsSharedState.isHeartBeatProcessing = true;
139
+ state.isHeartBeatProcessing = true;
186
140
  const interval = setInterval(() => {
187
141
  track({ eventName: USER_HEARTBEAT_EVENT_NAME });
188
- }, analyticsSharedState.config.heartBeatInterval);
142
+ }, state.config.heartBeatInterval);
189
143
  return () => {
190
144
  clearInterval(interval);
191
- analyticsSharedState.isHeartBeatProcessing = false;
145
+ state.isHeartBeatProcessing = false;
192
146
  };
193
147
  }
194
- function trackInitializationEvent(track) {
148
+ function trackInitializationEvent(track, state) {
195
149
  if (typeof window === "undefined" ||
196
- analyticsSharedState.wasInitializationTracked) {
150
+ state.wasInitializationTracked) {
197
151
  return;
198
152
  }
199
- analyticsSharedState.wasInitializationTracked = true;
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
- analyticsSharedState.sessionStartTime !== null) {
163
+ state.sessionStartTime !== null) {
210
164
  return;
211
165
  }
212
- analyticsSharedState.sessionStartTime = new Date().toISOString();
166
+ state.sessionStartTime = new Date().toISOString();
213
167
  }
214
- function trackSessionDurationEvent(track) {
168
+ function trackSessionDurationEvent(track, state) {
215
169
  if (typeof window === "undefined" ||
216
- analyticsSharedState.sessionStartTime === null)
170
+ state.sessionStartTime === null)
217
171
  return;
218
172
  const sessionDuration = new Date().getTime() -
219
- new Date(analyticsSharedState.sessionStartTime).getTime();
220
- analyticsSharedState.sessionStartTime = null;
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
- analyticsSharedState.sessionContext = null;
256
- sessionContextPromise = null;
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 (!analyticsSharedState.sessionContext) {
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
- analyticsSharedState.sessionContext = context;
233
+ if (state.sessionContextPromise === pending) {
234
+ state.sessionContext = context;
290
235
  }
291
236
  return context;
292
237
  }
293
- return analyticsSharedState.sessionContext;
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
- // When the id can't be persisted (React Native has no `localStorage`), keep
316
- // it stable for the process instead of minting a fresh one per call.
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 = analyticsSharedState.fallbackSessionId) !== null && _a !== void 0 ? _a : (analyticsSharedState.fallbackSessionId = generateUuid()));
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
  }
@@ -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}`;
@@ -15,5 +15,5 @@ export declare function createExposureTracker({ axiosClient, appId, enabled, sou
15
15
  visitorId: string;
16
16
  userId: string | null;
17
17
  }): void;
18
- flush(): Promise<void>;
18
+ flush: () => Promise<void>;
19
19
  };
@@ -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 entries = new Map();
6
- function send(entry) {
7
- if (entry.pending)
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
- let entry = entries.get(key);
42
- if (!entry) {
43
- const authorization = identity.userId ? axiosClient.defaults.headers.common.Authorization : null;
44
- entry = {
45
- acknowledged: false,
46
- authorization: typeof authorization === "string" ? authorization : null,
47
- data: { events: [{
48
- event_id: uuid(),
49
- event_name: "__experiment_exposure__",
50
- timestamp: new Date().toISOString(),
51
- session_id: identity.visitorId,
52
- page_url: pageUrl !== null && pageUrl !== void 0 ? pageUrl : (typeof window === "undefined" ? "/" : window.location.pathname),
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 an acknowledged exposure for its current assignment.
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. Failed sends retry up to three attempts
38
- * with the same event ID, timestamp and credentials. Await flush() on servers.
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
- * Waits until queued exposures are acknowledged; rejects after bounded retries.
101
- * Server/Worker handlers must await this before ending the request (or use waitUntil).
102
- * Retries preserve event IDs but raw storage is not exactly-once. Calling again
103
- * retries unacknowledged events with the same IDs. No new exposures are created.
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44-preview/sdk",
3
- "version": "0.8.48-pr.280.b5c6133",
3
+ "version": "0.8.48-pr.280.b620aa9",
4
4
  "description": "JavaScript SDK for Base44 API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",