@reservamos/browser-analytics 1.1.8-alpha.2 → 1.1.8-alpha.3
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/CHANGELOG.md +4 -0
- package/README.md +7 -1
- package/dist/browser-analytics.cjs +2 -2
- package/dist/browser-analytics.cjs.map +1 -1
- package/dist/browser-analytics.d.ts +8 -8
- package/dist/browser-analytics.esm.js +308 -118
- package/dist/browser-analytics.esm.js.map +1 -1
- package/dist/browser-analytics.iife.js +2 -2
- package/dist/browser-analytics.iife.js.map +1 -1
- package/package.json +1 -1
- package/src/events/identify/identify.recovery.test.ts +367 -0
- package/src/events/identify/identify.ts +5 -18
- package/src/init.ts +10 -1
- package/src/profiles/createAnonymousProfile/createAnonymousProfile.ts +18 -2
- package/src/services/mixpanel.batch.test.ts +108 -0
- package/src/services/mixpanel.ts +22 -5
- package/src/services/profileEnrichment.ts +258 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { IdentifyReporter } from '@/util/identifyAttempt';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import {
|
|
4
|
+
createAnonymousProfileForIdentify,
|
|
5
|
+
prepareAnonymousProfileForIdentify,
|
|
6
|
+
requestPreparedAnonymousProfile,
|
|
7
|
+
} from '@/profiles/createAnonymousProfile/createAnonymousProfile';
|
|
8
|
+
import mixpanelService from '@/services/mixpanel';
|
|
9
|
+
import trackingLifecycle from '@/services/trackingLifecycle';
|
|
10
|
+
import {
|
|
11
|
+
createIdentifyReporter,
|
|
12
|
+
waitForIdentifyStage,
|
|
13
|
+
} from '@/util/identifyAttempt';
|
|
14
|
+
|
|
15
|
+
const STORAGE_KEY = 'reservamos_pending_profile_v1';
|
|
16
|
+
const LIFETIME_MS = 10 * 60 * 1000;
|
|
17
|
+
const MAX_ATTEMPTS = 3;
|
|
18
|
+
const MAX_BYTES = 4096;
|
|
19
|
+
const profileIdSchema = z.union([
|
|
20
|
+
z.string().min(1),
|
|
21
|
+
z.number().int().positive(),
|
|
22
|
+
]);
|
|
23
|
+
const requestSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
identifier_key: z.enum(['email', 'phone']),
|
|
26
|
+
identifier_value: z.string().min(1),
|
|
27
|
+
identifiers: z.array(
|
|
28
|
+
z.object({ key: z.string().min(1), value: z.string().min(1) }).strict(),
|
|
29
|
+
),
|
|
30
|
+
details: z.record(z.string()),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
const pendingSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
version: z.literal(1),
|
|
36
|
+
id: z.string().min(1),
|
|
37
|
+
userId: z.string().min(1),
|
|
38
|
+
scope: z.string().min(1),
|
|
39
|
+
expiresAt: z.number().int(),
|
|
40
|
+
attempts: z.number().int().min(1).max(MAX_ATTEMPTS),
|
|
41
|
+
request: requestSchema.optional(),
|
|
42
|
+
profileId: profileIdSchema.optional(),
|
|
43
|
+
})
|
|
44
|
+
.strict()
|
|
45
|
+
.refine((entry) => entry.request || entry.profileId);
|
|
46
|
+
type PendingProfile = z.infer<typeof pendingSchema>;
|
|
47
|
+
interface ProfileLink {
|
|
48
|
+
id: z.infer<typeof profileIdSchema>;
|
|
49
|
+
}
|
|
50
|
+
interface RecoveryScope {
|
|
51
|
+
project: string;
|
|
52
|
+
proxy?: string;
|
|
53
|
+
apiBaseUrl: string;
|
|
54
|
+
appName?: string;
|
|
55
|
+
}
|
|
56
|
+
let scope: string | undefined;
|
|
57
|
+
let generation = 0;
|
|
58
|
+
|
|
59
|
+
function remove(report: IdentifyReporter, id?: string): void {
|
|
60
|
+
try {
|
|
61
|
+
if (id) {
|
|
62
|
+
const stored = JSON.parse(
|
|
63
|
+
sessionStorage.getItem(STORAGE_KEY) || 'null',
|
|
64
|
+
) as PendingProfile | null;
|
|
65
|
+
if (stored?.id !== id) return;
|
|
66
|
+
}
|
|
67
|
+
sessionStorage.removeItem(STORAGE_KEY);
|
|
68
|
+
} catch {
|
|
69
|
+
report('cache', 'unavailable');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function save(entry: PendingProfile, report: IdentifyReporter): boolean {
|
|
74
|
+
try {
|
|
75
|
+
const serialized = JSON.stringify(entry);
|
|
76
|
+
if (new Blob([serialized]).size > MAX_BYTES) {
|
|
77
|
+
remove(report);
|
|
78
|
+
report('cache', 'unavailable');
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
sessionStorage.setItem(STORAGE_KEY, serialized);
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
remove(report);
|
|
85
|
+
report('cache', 'unavailable');
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function read(report: IdentifyReporter): PendingProfile | undefined {
|
|
91
|
+
try {
|
|
92
|
+
const serialized = sessionStorage.getItem(STORAGE_KEY);
|
|
93
|
+
if (!serialized) return undefined;
|
|
94
|
+
if (new Blob([serialized]).size > MAX_BYTES) throw new Error();
|
|
95
|
+
const entry = pendingSchema.parse(JSON.parse(serialized));
|
|
96
|
+
if (
|
|
97
|
+
entry.expiresAt <= Date.now() ||
|
|
98
|
+
entry.expiresAt > Date.now() + LIFETIME_MS
|
|
99
|
+
) {
|
|
100
|
+
remove(report);
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
return entry;
|
|
104
|
+
} catch {
|
|
105
|
+
remove(report);
|
|
106
|
+
report('cache', 'unavailable');
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function currentUserIs(userId: string): boolean {
|
|
112
|
+
return (
|
|
113
|
+
mixpanelService.isReady() &&
|
|
114
|
+
mixpanelService.getMixpanelDistinctId() === userId
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function resolveProfile(
|
|
119
|
+
userId: string,
|
|
120
|
+
properties: Record<string, string | number | boolean | undefined>,
|
|
121
|
+
isCurrent: () => boolean,
|
|
122
|
+
report: IdentifyReporter,
|
|
123
|
+
entry?: PendingProfile,
|
|
124
|
+
recovering = false,
|
|
125
|
+
): Promise<ProfileLink | undefined> {
|
|
126
|
+
if (!isCurrent()) return undefined;
|
|
127
|
+
const profile =
|
|
128
|
+
entry?.profileId !== undefined
|
|
129
|
+
? { id: entry.profileId }
|
|
130
|
+
: await waitForIdentifyStage(
|
|
131
|
+
'profile',
|
|
132
|
+
3000,
|
|
133
|
+
(context) =>
|
|
134
|
+
isCurrent()
|
|
135
|
+
? entry?.request
|
|
136
|
+
? requestPreparedAnonymousProfile(entry.request)
|
|
137
|
+
: createAnonymousProfileForIdentify(
|
|
138
|
+
{ ...properties },
|
|
139
|
+
context.report,
|
|
140
|
+
)
|
|
141
|
+
: Promise.resolve(undefined),
|
|
142
|
+
report,
|
|
143
|
+
);
|
|
144
|
+
if (!isCurrent()) return undefined;
|
|
145
|
+
if (!profile) {
|
|
146
|
+
if (entry && entry.attempts >= MAX_ATTEMPTS) remove(report, entry.id);
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const parsedId = profileIdSchema.safeParse(profile.id);
|
|
150
|
+
if (!parsedId.success) {
|
|
151
|
+
report('profile', 'failed');
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
const profileId = parsedId.data;
|
|
155
|
+
if (entry) save({ ...entry, request: undefined, profileId }, report);
|
|
156
|
+
const accepted = () => {
|
|
157
|
+
if (entry && isCurrent()) remove(report, entry.id);
|
|
158
|
+
};
|
|
159
|
+
try {
|
|
160
|
+
const properties = { reservamos_one_id: profileId };
|
|
161
|
+
if (recovering) {
|
|
162
|
+
mixpanelService.restorePeopleProperties(
|
|
163
|
+
userId,
|
|
164
|
+
properties,
|
|
165
|
+
report,
|
|
166
|
+
accepted,
|
|
167
|
+
);
|
|
168
|
+
} else if (entry) {
|
|
169
|
+
mixpanelService.setPeopleProperties(properties, report, accepted);
|
|
170
|
+
} else {
|
|
171
|
+
mixpanelService.setPeopleProperties(properties, report);
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
report('profile', 'failed');
|
|
175
|
+
}
|
|
176
|
+
return { id: profileId };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Records the original normalized request while preserving the ordinary in-memory enrichment. */
|
|
180
|
+
function enrich(
|
|
181
|
+
userId: string,
|
|
182
|
+
properties: Record<string, string | number | boolean | undefined>,
|
|
183
|
+
isActive: () => boolean,
|
|
184
|
+
report: IdentifyReporter,
|
|
185
|
+
): Promise<ProfileLink | undefined> {
|
|
186
|
+
if (!isActive()) return Promise.resolve(undefined);
|
|
187
|
+
const attempt = ++generation;
|
|
188
|
+
let request: z.infer<typeof requestSchema> | undefined;
|
|
189
|
+
if (scope) {
|
|
190
|
+
try {
|
|
191
|
+
const prepared = requestSchema.safeParse(
|
|
192
|
+
prepareAnonymousProfileForIdentify({ ...properties }, report),
|
|
193
|
+
);
|
|
194
|
+
if (prepared.success) request = prepared.data;
|
|
195
|
+
} catch {
|
|
196
|
+
// The ordinary bounded request below retains its existing validation handling.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const entry: PendingProfile | undefined =
|
|
200
|
+
scope && request
|
|
201
|
+
? {
|
|
202
|
+
version: 1,
|
|
203
|
+
id: `${Date.now()}-${Math.random()}`,
|
|
204
|
+
userId,
|
|
205
|
+
scope,
|
|
206
|
+
expiresAt: Date.now() + LIFETIME_MS,
|
|
207
|
+
attempts: 1,
|
|
208
|
+
request,
|
|
209
|
+
}
|
|
210
|
+
: undefined;
|
|
211
|
+
if (entry) save(entry, report);
|
|
212
|
+
else if (scope) remove(report);
|
|
213
|
+
const isCurrent = () =>
|
|
214
|
+
attempt === generation &&
|
|
215
|
+
isActive() &&
|
|
216
|
+
(!entry || Date.now() < entry.expiresAt) &&
|
|
217
|
+
(!scope || currentUserIs(userId));
|
|
218
|
+
return resolveProfile(userId, properties, isCurrent, report, entry);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Resumes only matching tab-local work after Mixpanel and Core configuration are ready. */
|
|
222
|
+
function initialize(configuration: RecoveryScope): void {
|
|
223
|
+
scope = JSON.stringify([
|
|
224
|
+
configuration.project,
|
|
225
|
+
configuration.proxy ?? '',
|
|
226
|
+
configuration.apiBaseUrl,
|
|
227
|
+
configuration.appName ?? '',
|
|
228
|
+
]);
|
|
229
|
+
const attempt = ++generation;
|
|
230
|
+
const report = createIdentifyReporter();
|
|
231
|
+
const entry = read(report);
|
|
232
|
+
if (!entry) return;
|
|
233
|
+
if (
|
|
234
|
+
entry.scope !== scope ||
|
|
235
|
+
!currentUserIs(entry.userId) ||
|
|
236
|
+
(entry.profileId === undefined && entry.attempts >= MAX_ATTEMPTS)
|
|
237
|
+
) {
|
|
238
|
+
remove(report, entry.id);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const lifecycle = trackingLifecycle.capture();
|
|
242
|
+
const isCurrent = () =>
|
|
243
|
+
attempt === generation &&
|
|
244
|
+
lifecycle() &&
|
|
245
|
+
Date.now() < entry.expiresAt &&
|
|
246
|
+
currentUserIs(entry.userId);
|
|
247
|
+
const resumed = {
|
|
248
|
+
...entry,
|
|
249
|
+
attempts: entry.attempts + (entry.profileId === undefined ? 1 : 0),
|
|
250
|
+
};
|
|
251
|
+
if (entry.profileId === undefined && !save(resumed, report)) return;
|
|
252
|
+
const scopedReport: IdentifyReporter = (...args) => {
|
|
253
|
+
if (isCurrent()) report(...args);
|
|
254
|
+
};
|
|
255
|
+
void resolveProfile(entry.userId, {}, isCurrent, scopedReport, resumed, true);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export default { enrich, initialize };
|