@iann29/rastro 0.1.0-alpha.0 → 0.1.0-alpha.2
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/README.md +98 -18
- package/dist/component/_generated/api.d.ts +7 -1
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/constants.d.ts +11 -6
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +11 -6
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +2 -2
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/convex.config.js +4 -1
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/eventStore.d.ts +102 -0
- package/dist/component/eventStore.d.ts.map +1 -0
- package/dist/component/eventStore.js +463 -0
- package/dist/component/eventStore.js.map +1 -0
- package/dist/component/goals.d.ts.map +1 -1
- package/dist/component/goals.js +17 -4
- package/dist/component/goals.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +39 -7
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +520 -207
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts +8 -0
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +72 -8
- package/dist/component/live.js.map +1 -1
- package/dist/component/migrations.d.ts +17 -0
- package/dist/component/migrations.d.ts.map +1 -0
- package/dist/component/migrations.js +44 -0
- package/dist/component/migrations.js.map +1 -0
- package/dist/component/reports.d.ts +16 -16
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +228 -65
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +15 -10
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +11 -3
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +132 -9
- package/dist/component/schema.js +32 -7
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +27 -18
- package/dist/component/sites.js.map +1 -1
- package/dist/component/validators.d.ts +96 -0
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +12 -0
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +4 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +4 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.js +33 -22
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/benchmarks/2026-08-20-realistic.md +171 -0
- package/package.json +15 -5
- package/scripts/benchmark-ingest.mjs +499 -0
- package/src/component/_generated/api.ts +7 -1
- package/src/component/constants.ts +11 -6
- package/src/component/convex.config.ts +5 -1
- package/src/component/eventStore.ts +684 -0
- package/src/component/goals.ts +25 -4
- package/src/component/http.ts +49 -6
- package/src/component/ingest.ts +671 -215
- package/src/component/live.ts +83 -8
- package/src/component/migrations.ts +52 -0
- package/src/component/reports.ts +295 -86
- package/src/component/retention.ts +15 -12
- package/src/component/sanitize.ts +10 -3
- package/src/component/schema.ts +35 -7
- package/src/component/sites.ts +31 -18
- package/src/component/validators.ts +16 -0
- package/src/test.ts +2 -0
package/src/component/ingest.ts
CHANGED
|
@@ -5,10 +5,13 @@ import { mutation } from "./_generated/server.js";
|
|
|
5
5
|
import { internal } from "./_generated/api.js";
|
|
6
6
|
import {
|
|
7
7
|
AGGREGATE_SHARDS,
|
|
8
|
+
MAX_BATCH_EVENT_GROUPS,
|
|
8
9
|
DAY_MS,
|
|
10
|
+
HOURLY_AGGREGATE_SHARDS,
|
|
9
11
|
DIMENSION_SLOTS,
|
|
10
12
|
HOUR_MS,
|
|
11
13
|
LIVE_SESSION_TTL_MS,
|
|
14
|
+
LIVE_SWEEP_INTERVAL_MS,
|
|
12
15
|
MAX_BATCH_EVENTS,
|
|
13
16
|
MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
14
17
|
MAX_SITE_EVENTS_PER_SHARD_WINDOW,
|
|
@@ -17,9 +20,20 @@ import {
|
|
|
17
20
|
MAX_FUNNELS_PER_SITE,
|
|
18
21
|
MAX_GOALS_PER_SITE,
|
|
19
22
|
RATE_LIMIT_WINDOW_MS,
|
|
23
|
+
SECONDARY_AGGREGATE_SHARDS,
|
|
20
24
|
SITE_INGEST_SHARDS,
|
|
21
25
|
} from "./constants.js";
|
|
22
26
|
import { fail } from "./errors.js";
|
|
27
|
+
import {
|
|
28
|
+
bufferEventBatch,
|
|
29
|
+
eventBatchKey,
|
|
30
|
+
eventBucketStart,
|
|
31
|
+
type EventBatchBuffer,
|
|
32
|
+
findTelemetry,
|
|
33
|
+
flushEventBatches,
|
|
34
|
+
insertEventBatch,
|
|
35
|
+
inspectTelemetryMinute,
|
|
36
|
+
} from "./eventStore.js";
|
|
23
37
|
import {
|
|
24
38
|
cleanString,
|
|
25
39
|
ensureBatchBytes,
|
|
@@ -93,6 +107,35 @@ type SessionState = {
|
|
|
93
107
|
affiliateExpiresAt?: number;
|
|
94
108
|
};
|
|
95
109
|
|
|
110
|
+
type SessionRateWindowState = {
|
|
111
|
+
siteId: Id<"sites">;
|
|
112
|
+
sessionId: string;
|
|
113
|
+
window: Doc<"ingestWindows"> | null;
|
|
114
|
+
eventCount: number;
|
|
115
|
+
byteCount: number;
|
|
116
|
+
dirty: boolean;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
type CachedRow<T> = {
|
|
120
|
+
current: T | null;
|
|
121
|
+
dirty: boolean;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
type CachedDocument<TableName extends keyof import("./_generated/dataModel.js").DataModel> =
|
|
125
|
+
Omit<Doc<TableName>, "_creationTime"> & { _creationTime?: number };
|
|
126
|
+
|
|
127
|
+
type BatchState = {
|
|
128
|
+
sessions: Map<string, CachedRow<CachedDocument<"sessions">>>;
|
|
129
|
+
liveSessions: Map<string, CachedRow<CachedDocument<"liveSessions">>>;
|
|
130
|
+
visitorAttributions: Map<
|
|
131
|
+
string,
|
|
132
|
+
CachedRow<CachedDocument<"visitorAttributions">>
|
|
133
|
+
>;
|
|
134
|
+
funnelProgress: Map<string, CachedRow<CachedDocument<"funnelProgress">>>;
|
|
135
|
+
eventBatches: EventBatchBuffer;
|
|
136
|
+
liveSweepSites: Set<string>;
|
|
137
|
+
};
|
|
138
|
+
|
|
96
139
|
export const ingestBatch = mutation({
|
|
97
140
|
args: {
|
|
98
141
|
siteId: v.id("sites"),
|
|
@@ -129,27 +172,101 @@ export const ingestBatch = mutation({
|
|
|
129
172
|
);
|
|
130
173
|
const context = sanitizeContext(args.context);
|
|
131
174
|
const aggregateDeltas: AggregateDeltas = new Map();
|
|
175
|
+
const sessionRateWindows = new Map<string, SessionRateWindowState>();
|
|
176
|
+
const batchState: BatchState = {
|
|
177
|
+
sessions: new Map(),
|
|
178
|
+
liveSessions: new Map(),
|
|
179
|
+
visitorAttributions: new Map(),
|
|
180
|
+
funnelProgress: new Map(),
|
|
181
|
+
eventBatches: new Map(),
|
|
182
|
+
liveSweepSites: new Set(),
|
|
183
|
+
};
|
|
132
184
|
let goals: Doc<"goals">[] | null = null;
|
|
133
185
|
let funnels: Doc<"funnels">[] | null = null;
|
|
134
186
|
let accepted = 0;
|
|
135
187
|
let duplicates = 0;
|
|
136
188
|
let rejected = 0;
|
|
137
|
-
|
|
138
|
-
const input = args.events[eventIndex];
|
|
139
|
-
let event: TrackerEvent;
|
|
189
|
+
const events = args.events.map((input) => {
|
|
140
190
|
try {
|
|
141
|
-
|
|
191
|
+
return sanitizeEvent(input, now);
|
|
142
192
|
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
const duplicateInBatch = new Set<number>();
|
|
197
|
+
const eventIdsByGroup = new Map<string, Set<string>>();
|
|
198
|
+
const requestGroups = new Set<string>();
|
|
199
|
+
const dedupeGroups = new Map<
|
|
200
|
+
string,
|
|
201
|
+
{ sessionId: string; bucketStart: number; eventIds: Set<string> }
|
|
202
|
+
>();
|
|
203
|
+
for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
|
|
204
|
+
const event = events[eventIndex];
|
|
205
|
+
if (!event) continue;
|
|
206
|
+
const bucketStart = eventBucketStart(event.timestamp);
|
|
207
|
+
const groupKey = eventBatchKey(event.sessionId, bucketStart);
|
|
208
|
+
requestGroups.add(groupKey);
|
|
209
|
+
if (event.type === "heartbeat") continue;
|
|
210
|
+
const eventIds = eventIdsByGroup.get(groupKey) ?? new Set<string>();
|
|
211
|
+
if (eventIds.has(event.eventId)) {
|
|
212
|
+
duplicateInBatch.add(eventIndex);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
eventIds.add(event.eventId);
|
|
216
|
+
eventIdsByGroup.set(groupKey, eventIds);
|
|
217
|
+
const group = dedupeGroups.get(groupKey) ?? {
|
|
218
|
+
sessionId: event.sessionId,
|
|
219
|
+
bucketStart,
|
|
220
|
+
eventIds: new Set<string>(),
|
|
221
|
+
};
|
|
222
|
+
group.eventIds.add(event.eventId);
|
|
223
|
+
dedupeGroups.set(groupKey, group);
|
|
224
|
+
}
|
|
225
|
+
if (requestGroups.size > MAX_BATCH_EVENT_GROUPS) {
|
|
226
|
+
fail(
|
|
227
|
+
"BATCH_TOO_LARGE",
|
|
228
|
+
`events may span at most ${MAX_BATCH_EVENT_GROUPS} session-minute groups`,
|
|
229
|
+
{ limit: MAX_BATCH_EVENT_GROUPS },
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const persistedEventIds = new Map<string, Set<string>>();
|
|
233
|
+
await Promise.all(
|
|
234
|
+
[...dedupeGroups].map(async ([key, group]) => {
|
|
235
|
+
const { eventIds, persistedCount } = await inspectTelemetryMinute(ctx, {
|
|
236
|
+
siteId: args.siteId,
|
|
237
|
+
sessionId: group.sessionId,
|
|
238
|
+
bucketStart: group.bucketStart,
|
|
239
|
+
candidateEventIds: group.eventIds,
|
|
240
|
+
});
|
|
241
|
+
const newCount = [...group.eventIds].filter((eventId) =>
|
|
242
|
+
!eventIds.has(eventId)
|
|
243
|
+
).length;
|
|
244
|
+
if (
|
|
245
|
+
newCount > 0 &&
|
|
246
|
+
persistedCount + newCount > MAX_EVENTS_PER_SESSION_WINDOW
|
|
247
|
+
) {
|
|
248
|
+
fail("RATE_LIMITED", "session event minute limit exceeded", {
|
|
249
|
+
limit: MAX_EVENTS_PER_SESSION_WINDOW,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
persistedEventIds.set(key, eventIds);
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
|
|
257
|
+
const event = events[eventIndex];
|
|
258
|
+
if (!event) {
|
|
143
259
|
rejected += 1;
|
|
144
260
|
continue;
|
|
145
261
|
}
|
|
146
262
|
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
263
|
+
const sessionRow = await loadSession(
|
|
264
|
+
ctx,
|
|
265
|
+
args.siteId,
|
|
266
|
+
event.sessionId,
|
|
267
|
+
batchState,
|
|
268
|
+
);
|
|
269
|
+
const existingSession = sessionRow.current;
|
|
153
270
|
if (existingSession && existingSession.visitorId !== event.visitorId) {
|
|
154
271
|
rejected += 1;
|
|
155
272
|
continue;
|
|
@@ -160,13 +277,19 @@ export const ingestBatch = mutation({
|
|
|
160
277
|
rejected += 1;
|
|
161
278
|
continue;
|
|
162
279
|
}
|
|
163
|
-
const liveSession =
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
280
|
+
const liveSession = (
|
|
281
|
+
await loadLiveSession(
|
|
282
|
+
ctx,
|
|
283
|
+
args.siteId,
|
|
284
|
+
event.sessionId,
|
|
285
|
+
batchState,
|
|
167
286
|
)
|
|
168
|
-
|
|
169
|
-
if (
|
|
287
|
+
).current;
|
|
288
|
+
if (
|
|
289
|
+
liveSession &&
|
|
290
|
+
(liveSession.lastHeartbeatEventId === event.eventId ||
|
|
291
|
+
liveSession.recentHeartbeatEventIds?.includes(event.eventId))
|
|
292
|
+
) {
|
|
170
293
|
duplicates += 1;
|
|
171
294
|
continue;
|
|
172
295
|
}
|
|
@@ -177,6 +300,7 @@ export const ingestBatch = mutation({
|
|
|
177
300
|
event.sessionId,
|
|
178
301
|
eventByteCounts[eventIndex],
|
|
179
302
|
now,
|
|
303
|
+
sessionRateWindows,
|
|
180
304
|
))
|
|
181
305
|
) {
|
|
182
306
|
rejected += 1;
|
|
@@ -189,18 +313,18 @@ export const ingestBatch = mutation({
|
|
|
189
313
|
event,
|
|
190
314
|
context,
|
|
191
315
|
now,
|
|
316
|
+
batchState,
|
|
192
317
|
);
|
|
193
318
|
accepted += 1;
|
|
194
319
|
continue;
|
|
195
320
|
}
|
|
196
321
|
|
|
197
|
-
|
|
198
|
-
.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (duplicate) {
|
|
322
|
+
if (
|
|
323
|
+
duplicateInBatch.has(eventIndex) ||
|
|
324
|
+
persistedEventIds
|
|
325
|
+
.get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
|
|
326
|
+
?.has(event.eventId)
|
|
327
|
+
) {
|
|
204
328
|
duplicates += 1;
|
|
205
329
|
continue;
|
|
206
330
|
}
|
|
@@ -212,6 +336,7 @@ export const ingestBatch = mutation({
|
|
|
212
336
|
event.sessionId,
|
|
213
337
|
eventByteCounts[eventIndex],
|
|
214
338
|
now,
|
|
339
|
+
sessionRateWindows,
|
|
215
340
|
))
|
|
216
341
|
) {
|
|
217
342
|
rejected += 1;
|
|
@@ -243,9 +368,13 @@ export const ingestBatch = mutation({
|
|
|
243
368
|
financialConversion: false,
|
|
244
369
|
trustedGoalKey: undefined,
|
|
245
370
|
now,
|
|
371
|
+
batchState,
|
|
246
372
|
});
|
|
373
|
+
bufferEventBatch(args.siteId, event, batchState.eventBatches);
|
|
247
374
|
accepted += 1;
|
|
248
375
|
}
|
|
376
|
+
await flushBatchState(ctx, batchState);
|
|
377
|
+
await flushSessionRateLimits(ctx, sessionRateWindows, now);
|
|
249
378
|
await flushAggregates(ctx, aggregateDeltas);
|
|
250
379
|
return { accepted, duplicates, rejected };
|
|
251
380
|
},
|
|
@@ -261,14 +390,18 @@ export const trackConversion = mutation({
|
|
|
261
390
|
fail("INVALID_ARGUMENT", "revenueCents must be an integer between 0 and 1000000000");
|
|
262
391
|
}
|
|
263
392
|
let event: TrackerEvent;
|
|
393
|
+
let trustedGoalKey: string | undefined;
|
|
264
394
|
try {
|
|
395
|
+
trustedGoalKey = args.goalKey
|
|
396
|
+
? sanitizeSlug(args.goalKey, "goalKey")
|
|
397
|
+
: undefined;
|
|
265
398
|
event = sanitizeEvent(
|
|
266
399
|
{
|
|
267
400
|
eventId: sanitizeOpaqueId(args.eventId, "eventId"),
|
|
268
401
|
sessionId: sanitizeOpaqueId(args.sessionId, "sessionId"),
|
|
269
402
|
visitorId: sanitizeOpaqueId(args.visitorId, "visitorId"),
|
|
270
403
|
type: "conversion",
|
|
271
|
-
name:
|
|
404
|
+
name: trustedGoalKey ?? "conversion",
|
|
272
405
|
path: sanitizePath(args.path ?? "/conversion"),
|
|
273
406
|
timestamp: args.timestamp,
|
|
274
407
|
sequence: 0,
|
|
@@ -283,12 +416,6 @@ export const trackConversion = mutation({
|
|
|
283
416
|
}
|
|
284
417
|
|
|
285
418
|
const siteCurrency = site.currency ?? "USD";
|
|
286
|
-
if (event.currency !== siteCurrency) {
|
|
287
|
-
fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
|
|
288
|
-
siteCurrency,
|
|
289
|
-
conversionCurrency: event.currency ?? "USD",
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
419
|
const priorConversion = await ctx.db
|
|
293
420
|
.query("conversions")
|
|
294
421
|
.withIndex("by_siteId_and_eventId", (range) =>
|
|
@@ -296,15 +423,23 @@ export const trackConversion = mutation({
|
|
|
296
423
|
)
|
|
297
424
|
.unique();
|
|
298
425
|
if (priorConversion) {
|
|
426
|
+
if (!sameTrustedConversion(priorConversion, event, trustedGoalKey)) {
|
|
427
|
+
fail(
|
|
428
|
+
"CONFLICT",
|
|
429
|
+
"conversion eventId is already associated with a different payload",
|
|
430
|
+
);
|
|
431
|
+
}
|
|
299
432
|
return { accepted: 0, duplicates: 1, rejected: 0 };
|
|
300
433
|
}
|
|
434
|
+
if (event.currency !== siteCurrency) {
|
|
435
|
+
fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
|
|
436
|
+
siteCurrency,
|
|
437
|
+
conversionCurrency: event.currency ?? "USD",
|
|
438
|
+
});
|
|
439
|
+
}
|
|
301
440
|
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
.withIndex("by_siteId_and_eventId", (range) =>
|
|
305
|
-
range.eq("siteId", args.siteId).eq("eventId", event.eventId),
|
|
306
|
-
)
|
|
307
|
-
.unique();
|
|
441
|
+
const telemetry = await findTelemetry(ctx, args.siteId, event);
|
|
442
|
+
const existingTelemetry = telemetry.match;
|
|
308
443
|
if (
|
|
309
444
|
existingTelemetry &&
|
|
310
445
|
(existingTelemetry.sessionId !== event.sessionId ||
|
|
@@ -321,17 +456,15 @@ export const trackConversion = mutation({
|
|
|
321
456
|
if (existingSession && existingSession.visitorId !== event.visitorId) {
|
|
322
457
|
fail("CONFLICT", "session belongs to a different visitor");
|
|
323
458
|
}
|
|
324
|
-
const conversionContext: IngestContext =
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
: sanitizeContext(undefined);
|
|
459
|
+
const conversionContext: IngestContext = {
|
|
460
|
+
country: existingTelemetry?.country ?? existingSession?.country,
|
|
461
|
+
city: existingTelemetry?.city ?? existingSession?.city,
|
|
462
|
+
latitude: existingTelemetry?.latitude ?? existingSession?.latitude,
|
|
463
|
+
longitude: existingTelemetry?.longitude ?? existingSession?.longitude,
|
|
464
|
+
browser: existingTelemetry?.browser ?? existingSession?.browser,
|
|
465
|
+
os: existingTelemetry?.os ?? existingSession?.os,
|
|
466
|
+
device: existingTelemetry?.device ?? existingSession?.device,
|
|
467
|
+
};
|
|
335
468
|
|
|
336
469
|
const aggregateDeltas: AggregateDeltas = new Map();
|
|
337
470
|
await accountEvent(ctx, {
|
|
@@ -342,25 +475,134 @@ export const trackConversion = mutation({
|
|
|
342
475
|
goals: [],
|
|
343
476
|
funnels: [],
|
|
344
477
|
aggregateDeltas,
|
|
345
|
-
recordTelemetry: existingTelemetry === null,
|
|
478
|
+
recordTelemetry: existingTelemetry === null && !telemetry.saturated,
|
|
346
479
|
financialConversion: true,
|
|
347
|
-
trustedGoalKey
|
|
348
|
-
? sanitizeSlug(args.goalKey, "goalKey")
|
|
349
|
-
: undefined,
|
|
480
|
+
trustedGoalKey,
|
|
350
481
|
now: Date.now(),
|
|
351
482
|
});
|
|
483
|
+
if (!existingTelemetry && !telemetry.saturated) {
|
|
484
|
+
await insertEventBatch(ctx, args.siteId, event);
|
|
485
|
+
}
|
|
352
486
|
await flushAggregates(ctx, aggregateDeltas);
|
|
353
487
|
return { accepted: 1, duplicates: 0, rejected: 0 };
|
|
354
488
|
},
|
|
355
489
|
});
|
|
356
490
|
|
|
491
|
+
function sameTrustedConversion(
|
|
492
|
+
existing: Doc<"conversions">,
|
|
493
|
+
event: TrackerEvent,
|
|
494
|
+
goalKey: string | undefined,
|
|
495
|
+
): boolean {
|
|
496
|
+
return (
|
|
497
|
+
existing.source === "trusted" &&
|
|
498
|
+
existing.sessionId === event.sessionId &&
|
|
499
|
+
existing.visitorId === event.visitorId &&
|
|
500
|
+
existing.timestamp === event.timestamp &&
|
|
501
|
+
existing.path === event.path &&
|
|
502
|
+
existing.revenueCents === (event.revenueCents ?? 0) &&
|
|
503
|
+
existing.currency === (event.currency ?? "USD") &&
|
|
504
|
+
existing.goalKey === goalKey
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function loadSession(
|
|
509
|
+
ctx: MutationCtx,
|
|
510
|
+
siteId: Id<"sites">,
|
|
511
|
+
sessionId: string,
|
|
512
|
+
batchState: BatchState,
|
|
513
|
+
): Promise<CachedRow<CachedDocument<"sessions">>> {
|
|
514
|
+
const cached = batchState.sessions.get(sessionId);
|
|
515
|
+
if (cached) return cached;
|
|
516
|
+
const current = await ctx.db
|
|
517
|
+
.query("sessions")
|
|
518
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
519
|
+
range.eq("siteId", siteId).eq("sessionId", sessionId),
|
|
520
|
+
)
|
|
521
|
+
.unique();
|
|
522
|
+
const loaded = { current, dirty: false };
|
|
523
|
+
batchState.sessions.set(sessionId, loaded);
|
|
524
|
+
return loaded;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function loadLiveSession(
|
|
528
|
+
ctx: MutationCtx,
|
|
529
|
+
siteId: Id<"sites">,
|
|
530
|
+
sessionId: string,
|
|
531
|
+
batchState: BatchState,
|
|
532
|
+
): Promise<CachedRow<CachedDocument<"liveSessions">>> {
|
|
533
|
+
const cached = batchState.liveSessions.get(sessionId);
|
|
534
|
+
if (cached) return cached;
|
|
535
|
+
const current = await ctx.db
|
|
536
|
+
.query("liveSessions")
|
|
537
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
538
|
+
range.eq("siteId", siteId).eq("sessionId", sessionId),
|
|
539
|
+
)
|
|
540
|
+
.unique();
|
|
541
|
+
const loaded = { current, dirty: false };
|
|
542
|
+
batchState.liveSessions.set(sessionId, loaded);
|
|
543
|
+
return loaded;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function flushBatchState(ctx: MutationCtx, batchState: BatchState) {
|
|
547
|
+
for (const state of batchState.sessions.values()) {
|
|
548
|
+
if (state.dirty && state.current) {
|
|
549
|
+
await ctx.db.replace(
|
|
550
|
+
"sessions",
|
|
551
|
+
state.current._id,
|
|
552
|
+
withoutSystemFields(state.current),
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
for (const state of batchState.liveSessions.values()) {
|
|
557
|
+
if (state.dirty && state.current) {
|
|
558
|
+
await ctx.db.replace(
|
|
559
|
+
"liveSessions",
|
|
560
|
+
state.current._id,
|
|
561
|
+
withoutSystemFields(state.current),
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
for (const state of batchState.visitorAttributions.values()) {
|
|
566
|
+
if (state.dirty && state.current) {
|
|
567
|
+
await ctx.db.replace(
|
|
568
|
+
"visitorAttributions",
|
|
569
|
+
state.current._id,
|
|
570
|
+
withoutSystemFields(state.current),
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
for (const state of batchState.funnelProgress.values()) {
|
|
575
|
+
if (state.dirty && state.current) {
|
|
576
|
+
await ctx.db.replace(
|
|
577
|
+
"funnelProgress",
|
|
578
|
+
state.current._id,
|
|
579
|
+
withoutSystemFields(state.current),
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
await flushEventBatches(ctx, batchState.eventBatches);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function withoutSystemFields<
|
|
587
|
+
T extends { _id: unknown; _creationTime?: number },
|
|
588
|
+
>(document: T): Omit<T, "_id" | "_creationTime"> {
|
|
589
|
+
const {
|
|
590
|
+
_id,
|
|
591
|
+
_creationTime,
|
|
592
|
+
...fields
|
|
593
|
+
} = document;
|
|
594
|
+
void _id;
|
|
595
|
+
void _creationTime;
|
|
596
|
+
return fields;
|
|
597
|
+
}
|
|
598
|
+
|
|
357
599
|
async function accountEvent(
|
|
358
600
|
ctx: MutationCtx,
|
|
359
601
|
input: {
|
|
360
602
|
siteId: Id<"sites">;
|
|
361
603
|
event: TrackerEvent;
|
|
362
604
|
context: IngestContext;
|
|
363
|
-
existingSession:
|
|
605
|
+
existingSession: CachedDocument<"sessions"> | null;
|
|
364
606
|
goals: Doc<"goals">[];
|
|
365
607
|
funnels: Doc<"funnels">[];
|
|
366
608
|
aggregateDeltas: AggregateDeltas;
|
|
@@ -368,6 +610,7 @@ async function accountEvent(
|
|
|
368
610
|
financialConversion: boolean;
|
|
369
611
|
trustedGoalKey: string | undefined;
|
|
370
612
|
now: number;
|
|
613
|
+
batchState?: BatchState;
|
|
371
614
|
},
|
|
372
615
|
) {
|
|
373
616
|
const { event } = input;
|
|
@@ -379,61 +622,57 @@ async function accountEvent(
|
|
|
379
622
|
input.context,
|
|
380
623
|
input.existingSession,
|
|
381
624
|
input.financialConversion,
|
|
625
|
+
input.batchState,
|
|
382
626
|
)
|
|
383
627
|
: await accountTrustedSession(ctx, input.existingSession, event);
|
|
384
628
|
|
|
385
|
-
if (input.recordTelemetry) {
|
|
386
|
-
await ctx.db.insert("events", {
|
|
387
|
-
siteId: input.siteId,
|
|
388
|
-
eventId: event.eventId,
|
|
389
|
-
sessionId: event.sessionId,
|
|
390
|
-
visitorId: event.visitorId,
|
|
391
|
-
type: event.type,
|
|
392
|
-
...(event.name ? { name: event.name } : {}),
|
|
393
|
-
path: event.path,
|
|
394
|
-
...(event.referrer ? { referrer: event.referrer } : {}),
|
|
395
|
-
timestamp: event.timestamp,
|
|
396
|
-
sequence: event.sequence,
|
|
397
|
-
...(event.target ? { target: event.target } : {}),
|
|
398
|
-
...(event.href ? { href: event.href } : {}),
|
|
399
|
-
...(event.properties ? { properties: event.properties } : {}),
|
|
400
|
-
...(event.revenueCents !== undefined
|
|
401
|
-
? { revenueCents: event.revenueCents }
|
|
402
|
-
: {}),
|
|
403
|
-
...(event.currency ? { currency: event.currency } : {}),
|
|
404
|
-
...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
|
|
405
|
-
...(input.context.country ? { country: input.context.country } : {}),
|
|
406
|
-
...(input.context.city ? { city: input.context.city } : {}),
|
|
407
|
-
...(input.context.latitude !== undefined
|
|
408
|
-
? { latitude: input.context.latitude }
|
|
409
|
-
: {}),
|
|
410
|
-
...(input.context.longitude !== undefined
|
|
411
|
-
? { longitude: input.context.longitude }
|
|
412
|
-
: {}),
|
|
413
|
-
browser: input.context.browser ?? "Other",
|
|
414
|
-
os: input.context.os ?? "Other",
|
|
415
|
-
device: input.context.device ?? "unknown",
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
|
|
419
629
|
const matchedGoalKeys: string[] = [];
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
630
|
+
const matchingGoals = input.goals.filter((goal) =>
|
|
631
|
+
goal.active && goalMatches(goal, event)
|
|
632
|
+
);
|
|
633
|
+
if (matchingGoals.length > 1) {
|
|
634
|
+
fail("LIMIT_EXCEEDED", "active goals must use unique matchers");
|
|
635
|
+
}
|
|
636
|
+
for (const goal of matchingGoals) {
|
|
637
|
+
const completionEventId = JSON.stringify([
|
|
638
|
+
event.sessionId,
|
|
639
|
+
eventBucketStart(event.timestamp),
|
|
640
|
+
event.eventId,
|
|
641
|
+
]);
|
|
642
|
+
const scopedPrior = (await ctx.db
|
|
423
643
|
.query("goalCompletions")
|
|
424
644
|
.withIndex("by_siteId_and_goalId_and_eventId", (range) =>
|
|
425
645
|
range
|
|
426
646
|
.eq("siteId", input.siteId)
|
|
427
647
|
.eq("goalId", goal._id)
|
|
428
|
-
.eq("eventId",
|
|
648
|
+
.eq("eventId", completionEventId),
|
|
429
649
|
)
|
|
430
|
-
.
|
|
431
|
-
|
|
650
|
+
.take(2))
|
|
651
|
+
.find((completion) => completion.idempotencyVersion === 2);
|
|
652
|
+
const legacyPrior = scopedPrior
|
|
653
|
+
? undefined
|
|
654
|
+
: (await ctx.db
|
|
655
|
+
.query("goalCompletions")
|
|
656
|
+
.withIndex("by_siteId_and_goalId_and_eventId", (range) =>
|
|
657
|
+
range
|
|
658
|
+
.eq("siteId", input.siteId)
|
|
659
|
+
.eq("goalId", goal._id)
|
|
660
|
+
.eq("eventId", event.eventId),
|
|
661
|
+
)
|
|
662
|
+
.take(2))
|
|
663
|
+
.find((completion) =>
|
|
664
|
+
completion.idempotencyVersion === undefined &&
|
|
665
|
+
completion.sessionId === event.sessionId &&
|
|
666
|
+
eventBucketStart(completion.timestamp) ===
|
|
667
|
+
eventBucketStart(event.timestamp)
|
|
668
|
+
);
|
|
669
|
+
if (scopedPrior || legacyPrior) continue;
|
|
432
670
|
await ctx.db.insert("goalCompletions", {
|
|
433
671
|
siteId: input.siteId,
|
|
434
672
|
goalId: goal._id,
|
|
435
673
|
goalKey: goal.key,
|
|
436
|
-
eventId:
|
|
674
|
+
eventId: completionEventId,
|
|
675
|
+
idempotencyVersion: 2,
|
|
437
676
|
sessionId: event.sessionId,
|
|
438
677
|
visitorId: event.visitorId,
|
|
439
678
|
timestamp: event.timestamp,
|
|
@@ -444,7 +683,7 @@ async function accountEvent(
|
|
|
444
683
|
|
|
445
684
|
for (const funnel of input.funnels) {
|
|
446
685
|
if (funnel.active) {
|
|
447
|
-
await advanceFunnel(ctx, input.siteId, funnel, event);
|
|
686
|
+
await advanceFunnel(ctx, input.siteId, funnel, event, input.batchState);
|
|
448
687
|
}
|
|
449
688
|
}
|
|
450
689
|
|
|
@@ -491,13 +730,14 @@ async function accountEvent(
|
|
|
491
730
|
input.context,
|
|
492
731
|
session,
|
|
493
732
|
input.now,
|
|
733
|
+
input.batchState,
|
|
494
734
|
);
|
|
495
735
|
}
|
|
496
736
|
}
|
|
497
737
|
|
|
498
738
|
async function accountTrustedSession(
|
|
499
739
|
ctx: MutationCtx,
|
|
500
|
-
existing:
|
|
740
|
+
existing: CachedDocument<"sessions"> | null,
|
|
501
741
|
event: TrackerEvent,
|
|
502
742
|
): Promise<SessionState> {
|
|
503
743
|
if (!existing) {
|
|
@@ -529,18 +769,25 @@ async function upsertSession(
|
|
|
529
769
|
siteId: Id<"sites">,
|
|
530
770
|
event: TrackerEvent,
|
|
531
771
|
context: IngestContext,
|
|
532
|
-
existing:
|
|
772
|
+
existing: CachedDocument<"sessions"> | null,
|
|
533
773
|
financialConversion: boolean,
|
|
774
|
+
batchState?: BatchState,
|
|
534
775
|
): Promise<SessionState> {
|
|
535
776
|
const source = sourceFromReferrer(event.referrer);
|
|
536
777
|
const utmSource = stringProperty(event.properties, "utm_source");
|
|
537
778
|
const utmMedium = stringProperty(event.properties, "utm_medium");
|
|
538
779
|
const utmCampaign = stringProperty(event.properties, "utm_campaign");
|
|
539
|
-
const attribution = await attributionForEvent(
|
|
780
|
+
const attribution = await attributionForEvent(
|
|
781
|
+
ctx,
|
|
782
|
+
siteId,
|
|
783
|
+
event,
|
|
784
|
+
existing,
|
|
785
|
+
batchState,
|
|
786
|
+
);
|
|
540
787
|
if (existing) {
|
|
541
788
|
const startedAt = Math.min(existing.startedAt, event.timestamp);
|
|
542
789
|
const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
|
|
543
|
-
|
|
790
|
+
const updates = {
|
|
544
791
|
startedAt,
|
|
545
792
|
lastSeenAt,
|
|
546
793
|
entryPath: event.timestamp < existing.startedAt ? event.path : existing.entryPath,
|
|
@@ -566,7 +813,15 @@ async function upsertSession(
|
|
|
566
813
|
browser: context.browser ?? existing.browser,
|
|
567
814
|
os: context.os ?? existing.os,
|
|
568
815
|
device: context.device ?? existing.device,
|
|
569
|
-
}
|
|
816
|
+
};
|
|
817
|
+
if (batchState) {
|
|
818
|
+
const state = batchState.sessions.get(event.sessionId);
|
|
819
|
+
if (!state) throw new Error("session cache was not initialized");
|
|
820
|
+
state.current = { ...existing, ...updates };
|
|
821
|
+
state.dirty = true;
|
|
822
|
+
} else {
|
|
823
|
+
await ctx.db.patch("sessions", existing._id, updates);
|
|
824
|
+
}
|
|
570
825
|
return {
|
|
571
826
|
isNewSession: false,
|
|
572
827
|
isNewVisitorHour:
|
|
@@ -584,14 +839,27 @@ async function upsertSession(
|
|
|
584
839
|
};
|
|
585
840
|
}
|
|
586
841
|
|
|
587
|
-
const
|
|
842
|
+
const priorVisits = await ctx.db
|
|
588
843
|
.query("sessions")
|
|
589
844
|
.withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) =>
|
|
590
845
|
range.eq("siteId", siteId).eq("visitorId", event.visitorId),
|
|
591
846
|
)
|
|
592
847
|
.order("desc")
|
|
593
848
|
.take(1);
|
|
594
|
-
|
|
849
|
+
let priorVisit: CachedDocument<"sessions"> | null = priorVisits[0] ?? null;
|
|
850
|
+
if (batchState) {
|
|
851
|
+
for (const state of batchState.sessions.values()) {
|
|
852
|
+
const candidate = state.current;
|
|
853
|
+
if (
|
|
854
|
+
candidate?.visitorId === event.visitorId &&
|
|
855
|
+
candidate.sessionId !== event.sessionId &&
|
|
856
|
+
(!priorVisit || candidate.lastSeenAt > priorVisit.lastSeenAt)
|
|
857
|
+
) {
|
|
858
|
+
priorVisit = candidate;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
|
|
595
863
|
siteId,
|
|
596
864
|
sessionId: event.sessionId,
|
|
597
865
|
visitorId: event.visitorId,
|
|
@@ -620,16 +888,25 @@ async function upsertSession(
|
|
|
620
888
|
? { currency: event.currency }
|
|
621
889
|
: {}),
|
|
622
890
|
...(attribution ?? {}),
|
|
623
|
-
}
|
|
891
|
+
};
|
|
892
|
+
const sessionId = await ctx.db.insert("sessions", fields);
|
|
893
|
+
if (batchState) {
|
|
894
|
+
const state = batchState.sessions.get(event.sessionId);
|
|
895
|
+
if (!state) throw new Error("session cache was not initialized");
|
|
896
|
+
state.current = {
|
|
897
|
+
...fields,
|
|
898
|
+
_id: sessionId,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
624
901
|
return {
|
|
625
902
|
isNewSession: true,
|
|
626
903
|
isNewVisitorHour:
|
|
627
|
-
priorVisit
|
|
628
|
-
Math.floor(priorVisit
|
|
904
|
+
priorVisit === null ||
|
|
905
|
+
Math.floor(priorVisit.lastSeenAt / HOUR_MS) <
|
|
629
906
|
Math.floor(event.timestamp / HOUR_MS),
|
|
630
907
|
isNewVisitorDay:
|
|
631
|
-
priorVisit
|
|
632
|
-
Math.floor(priorVisit
|
|
908
|
+
priorVisit === null ||
|
|
909
|
+
Math.floor(priorVisit.lastSeenAt / DAY_MS) <
|
|
633
910
|
Math.floor(event.timestamp / DAY_MS),
|
|
634
911
|
startedAt: event.timestamp,
|
|
635
912
|
source,
|
|
@@ -680,7 +957,8 @@ async function attributionForEvent(
|
|
|
680
957
|
ctx: MutationCtx,
|
|
681
958
|
siteId: Id<"sites">,
|
|
682
959
|
event: TrackerEvent,
|
|
683
|
-
existingSession:
|
|
960
|
+
existingSession: CachedDocument<"sessions"> | null,
|
|
961
|
+
batchState?: BatchState,
|
|
684
962
|
): Promise<AttributionSnapshot | undefined> {
|
|
685
963
|
const sessionAttribution = existingSession
|
|
686
964
|
? attributionSnapshot(existingSession)
|
|
@@ -696,12 +974,25 @@ async function attributionForEvent(
|
|
|
696
974
|
return sessionAttribution;
|
|
697
975
|
}
|
|
698
976
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
977
|
+
let attributionState = batchState?.visitorAttributions.get(event.visitorId);
|
|
978
|
+
if (batchState && !attributionState) {
|
|
979
|
+
const current = await ctx.db
|
|
980
|
+
.query("visitorAttributions")
|
|
981
|
+
.withIndex("by_siteId_and_visitorId", (range) =>
|
|
982
|
+
range.eq("siteId", siteId).eq("visitorId", event.visitorId),
|
|
983
|
+
)
|
|
984
|
+
.unique();
|
|
985
|
+
attributionState = { current, dirty: false };
|
|
986
|
+
batchState.visitorAttributions.set(event.visitorId, attributionState);
|
|
987
|
+
}
|
|
988
|
+
const current = attributionState
|
|
989
|
+
? attributionState.current
|
|
990
|
+
: await ctx.db
|
|
991
|
+
.query("visitorAttributions")
|
|
992
|
+
.withIndex("by_siteId_and_visitorId", (range) =>
|
|
993
|
+
range.eq("siteId", siteId).eq("visitorId", event.visitorId),
|
|
994
|
+
)
|
|
995
|
+
.unique();
|
|
705
996
|
if (current) {
|
|
706
997
|
const snapshot: AttributionSnapshot = {
|
|
707
998
|
affiliateId: current.affiliateId,
|
|
@@ -733,22 +1024,30 @@ async function attributionForEvent(
|
|
|
733
1024
|
|
|
734
1025
|
const attributedAt = event.timestamp;
|
|
735
1026
|
const expiresAt = attributedAt + affiliate.attributionWindowDays * DAY_MS;
|
|
1027
|
+
const fields = {
|
|
1028
|
+
siteId,
|
|
1029
|
+
visitorId: event.visitorId,
|
|
1030
|
+
affiliateId: affiliate._id,
|
|
1031
|
+
affiliateSlug: affiliate.slug,
|
|
1032
|
+
attributedAt,
|
|
1033
|
+
expiresAt,
|
|
1034
|
+
};
|
|
736
1035
|
if (current) {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
}
|
|
1036
|
+
if (attributionState) {
|
|
1037
|
+
attributionState.current = { ...current, ...fields };
|
|
1038
|
+
attributionState.dirty = true;
|
|
1039
|
+
} else {
|
|
1040
|
+
await ctx.db.patch("visitorAttributions", current._id, fields);
|
|
1041
|
+
}
|
|
743
1042
|
} else {
|
|
744
|
-
await ctx.db.insert("visitorAttributions",
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
}
|
|
1043
|
+
const attributionId = await ctx.db.insert("visitorAttributions", fields);
|
|
1044
|
+
if (attributionState) {
|
|
1045
|
+
attributionState.current = {
|
|
1046
|
+
...fields,
|
|
1047
|
+
_id: attributionId,
|
|
1048
|
+
_creationTime: event.timestamp,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
752
1051
|
}
|
|
753
1052
|
return {
|
|
754
1053
|
affiliateId: affiliate._id,
|
|
@@ -761,14 +1060,15 @@ async function attributionForEvent(
|
|
|
761
1060
|
async function accountHeartbeat(
|
|
762
1061
|
ctx: MutationCtx,
|
|
763
1062
|
siteId: Id<"sites">,
|
|
764
|
-
session:
|
|
1063
|
+
session: CachedDocument<"sessions">,
|
|
765
1064
|
event: TrackerEvent,
|
|
766
1065
|
context: IngestContext,
|
|
767
1066
|
now: number,
|
|
1067
|
+
batchState?: BatchState,
|
|
768
1068
|
) {
|
|
769
1069
|
const startedAt = Math.min(session.startedAt, event.timestamp);
|
|
770
1070
|
const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
|
|
771
|
-
|
|
1071
|
+
const updates = {
|
|
772
1072
|
startedAt,
|
|
773
1073
|
lastSeenAt,
|
|
774
1074
|
exitPath: event.timestamp >= session.lastSeenAt
|
|
@@ -782,7 +1082,15 @@ async function accountHeartbeat(
|
|
|
782
1082
|
browser: context.browser ?? session.browser,
|
|
783
1083
|
os: context.os ?? session.os,
|
|
784
1084
|
device: context.device ?? session.device,
|
|
785
|
-
}
|
|
1085
|
+
};
|
|
1086
|
+
if (batchState) {
|
|
1087
|
+
const state = batchState.sessions.get(event.sessionId);
|
|
1088
|
+
if (!state) throw new Error("session cache was not initialized");
|
|
1089
|
+
state.current = { ...session, ...updates };
|
|
1090
|
+
state.dirty = true;
|
|
1091
|
+
} else {
|
|
1092
|
+
await ctx.db.patch("sessions", session._id, updates);
|
|
1093
|
+
}
|
|
786
1094
|
await updateLiveSession(
|
|
787
1095
|
ctx,
|
|
788
1096
|
siteId,
|
|
@@ -799,6 +1107,7 @@ async function accountHeartbeat(
|
|
|
799
1107
|
affiliateSlug: session.affiliateSlug,
|
|
800
1108
|
},
|
|
801
1109
|
now,
|
|
1110
|
+
batchState,
|
|
802
1111
|
);
|
|
803
1112
|
}
|
|
804
1113
|
|
|
@@ -830,15 +1139,17 @@ async function consumeSiteRateLimit(
|
|
|
830
1139
|
window: Doc<"siteIngestWindows"> | null;
|
|
831
1140
|
}> = [];
|
|
832
1141
|
for (const [shard, increment] of increments) {
|
|
833
|
-
const
|
|
1142
|
+
const rows = await ctx.db
|
|
834
1143
|
.query("siteIngestWindows")
|
|
835
1144
|
.withIndex("by_siteId_and_shard_and_windowStart", (range) =>
|
|
836
|
-
range
|
|
837
|
-
.eq("siteId", siteId)
|
|
838
|
-
.eq("shard", shard)
|
|
839
|
-
.eq("windowStart", windowStart),
|
|
1145
|
+
range.eq("siteId", siteId).eq("shard", shard),
|
|
840
1146
|
)
|
|
841
|
-
.
|
|
1147
|
+
.order("desc")
|
|
1148
|
+
.take(1);
|
|
1149
|
+
const storedWindow = rows[0] ?? null;
|
|
1150
|
+
const window = storedWindow?.windowStart === windowStart
|
|
1151
|
+
? storedWindow
|
|
1152
|
+
: null;
|
|
842
1153
|
if (
|
|
843
1154
|
(window?.eventCount ?? 0) + increment.eventCount >
|
|
844
1155
|
MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
|
|
@@ -856,14 +1167,21 @@ async function consumeSiteRateLimit(
|
|
|
856
1167
|
SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
857
1168
|
});
|
|
858
1169
|
}
|
|
859
|
-
windows.push({ shard, increment, window });
|
|
1170
|
+
windows.push({ shard, increment, window: storedWindow });
|
|
860
1171
|
}
|
|
861
1172
|
|
|
862
1173
|
for (const { shard, increment, window } of windows) {
|
|
863
1174
|
if (window) {
|
|
864
1175
|
await ctx.db.patch("siteIngestWindows", window._id, {
|
|
865
|
-
|
|
866
|
-
|
|
1176
|
+
windowStart,
|
|
1177
|
+
eventCount:
|
|
1178
|
+
window.windowStart === windowStart
|
|
1179
|
+
? window.eventCount + increment.eventCount
|
|
1180
|
+
: increment.eventCount,
|
|
1181
|
+
byteCount:
|
|
1182
|
+
window.windowStart === windowStart
|
|
1183
|
+
? window.byteCount + increment.byteCount
|
|
1184
|
+
: increment.byteCount,
|
|
867
1185
|
updatedAt: now,
|
|
868
1186
|
});
|
|
869
1187
|
} else {
|
|
@@ -886,43 +1204,69 @@ async function consumeRateLimit(
|
|
|
886
1204
|
sessionId: string,
|
|
887
1205
|
byteCount: number,
|
|
888
1206
|
now: number,
|
|
1207
|
+
states: Map<string, SessionRateWindowState>,
|
|
889
1208
|
): Promise<boolean> {
|
|
890
1209
|
const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
.eq("sessionId", sessionId)
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1210
|
+
let state = states.get(sessionId);
|
|
1211
|
+
if (!state) {
|
|
1212
|
+
const rows = await ctx.db
|
|
1213
|
+
.query("ingestWindows")
|
|
1214
|
+
.withIndex("by_siteId_and_sessionId_and_windowStart", (range) =>
|
|
1215
|
+
range.eq("siteId", siteId).eq("sessionId", sessionId),
|
|
1216
|
+
)
|
|
1217
|
+
.order("desc")
|
|
1218
|
+
.take(1);
|
|
1219
|
+
const window = rows[0] ?? null;
|
|
1220
|
+
state = {
|
|
1221
|
+
siteId,
|
|
1222
|
+
sessionId,
|
|
1223
|
+
window,
|
|
1224
|
+
eventCount: window?.windowStart === windowStart ? window.eventCount : 0,
|
|
1225
|
+
byteCount: window?.windowStart === windowStart ? window.byteCount : 0,
|
|
1226
|
+
dirty: false,
|
|
1227
|
+
};
|
|
1228
|
+
states.set(sessionId, state);
|
|
1229
|
+
}
|
|
900
1230
|
if (
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
window.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW)
|
|
1231
|
+
state.eventCount >= MAX_EVENTS_PER_SESSION_WINDOW ||
|
|
1232
|
+
state.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW
|
|
904
1233
|
) {
|
|
905
1234
|
return false;
|
|
906
1235
|
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
byteCount: window.byteCount + byteCount,
|
|
911
|
-
updatedAt: now,
|
|
912
|
-
});
|
|
913
|
-
} else {
|
|
914
|
-
await ctx.db.insert("ingestWindows", {
|
|
915
|
-
siteId,
|
|
916
|
-
sessionId,
|
|
917
|
-
windowStart,
|
|
918
|
-
eventCount: 1,
|
|
919
|
-
byteCount,
|
|
920
|
-
updatedAt: now,
|
|
921
|
-
});
|
|
922
|
-
}
|
|
1236
|
+
state.eventCount += 1;
|
|
1237
|
+
state.byteCount += byteCount;
|
|
1238
|
+
state.dirty = true;
|
|
923
1239
|
return true;
|
|
924
1240
|
}
|
|
925
1241
|
|
|
1242
|
+
async function flushSessionRateLimits(
|
|
1243
|
+
ctx: MutationCtx,
|
|
1244
|
+
states: Map<string, SessionRateWindowState>,
|
|
1245
|
+
now: number,
|
|
1246
|
+
) {
|
|
1247
|
+
const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
1248
|
+
for (const state of states.values()) {
|
|
1249
|
+
if (!state.dirty) continue;
|
|
1250
|
+
if (state.window) {
|
|
1251
|
+
await ctx.db.patch("ingestWindows", state.window._id, {
|
|
1252
|
+
windowStart,
|
|
1253
|
+
eventCount: state.eventCount,
|
|
1254
|
+
byteCount: state.byteCount,
|
|
1255
|
+
updatedAt: now,
|
|
1256
|
+
});
|
|
1257
|
+
} else {
|
|
1258
|
+
await ctx.db.insert("ingestWindows", {
|
|
1259
|
+
siteId: state.siteId,
|
|
1260
|
+
sessionId: state.sessionId,
|
|
1261
|
+
windowStart,
|
|
1262
|
+
eventCount: state.eventCount,
|
|
1263
|
+
byteCount: state.byteCount,
|
|
1264
|
+
updatedAt: now,
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
926
1270
|
async function updateLiveSession(
|
|
927
1271
|
ctx: MutationCtx,
|
|
928
1272
|
siteId: Id<"sites">,
|
|
@@ -930,23 +1274,32 @@ async function updateLiveSession(
|
|
|
930
1274
|
context: IngestContext,
|
|
931
1275
|
session: SessionState,
|
|
932
1276
|
now: number,
|
|
1277
|
+
batchState?: BatchState,
|
|
933
1278
|
) {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
.
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
: existing.expiresAt;
|
|
1279
|
+
await ensureLiveSweep(ctx, siteId, now, batchState);
|
|
1280
|
+
const liveState = batchState
|
|
1281
|
+
? await loadLiveSession(ctx, siteId, event.sessionId, batchState)
|
|
1282
|
+
: undefined;
|
|
1283
|
+
const existing = liveState
|
|
1284
|
+
? liveState.current
|
|
1285
|
+
: await ctx.db
|
|
1286
|
+
.query("liveSessions")
|
|
1287
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
1288
|
+
range.eq("siteId", siteId).eq("sessionId", event.sessionId),
|
|
1289
|
+
)
|
|
1290
|
+
.unique();
|
|
1291
|
+
const deadline = now + LIVE_SESSION_TTL_MS;
|
|
948
1292
|
if (existing) {
|
|
949
|
-
|
|
1293
|
+
const recentHeartbeatEventIds = event.type === "heartbeat"
|
|
1294
|
+
? [
|
|
1295
|
+
...(existing.recentHeartbeatEventIds ??
|
|
1296
|
+
(existing.lastHeartbeatEventId
|
|
1297
|
+
? [existing.lastHeartbeatEventId]
|
|
1298
|
+
: [])),
|
|
1299
|
+
event.eventId,
|
|
1300
|
+
].slice(-8)
|
|
1301
|
+
: undefined;
|
|
1302
|
+
const updates = {
|
|
950
1303
|
visitorId: event.visitorId,
|
|
951
1304
|
path: event.path,
|
|
952
1305
|
lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
|
|
@@ -962,13 +1315,22 @@ async function updateLiveSession(
|
|
|
962
1315
|
pageviewCount: session.pageviewCount,
|
|
963
1316
|
eventCount: session.eventCount,
|
|
964
1317
|
...(event.type === "heartbeat"
|
|
965
|
-
? {
|
|
1318
|
+
? {
|
|
1319
|
+
lastHeartbeatEventId: event.eventId,
|
|
1320
|
+
recentHeartbeatEventIds,
|
|
1321
|
+
}
|
|
966
1322
|
: {}),
|
|
967
|
-
}
|
|
1323
|
+
};
|
|
1324
|
+
if (liveState) {
|
|
1325
|
+
liveState.current = { ...existing, ...updates };
|
|
1326
|
+
liveState.dirty = true;
|
|
1327
|
+
} else {
|
|
1328
|
+
await ctx.db.patch("liveSessions", existing._id, updates);
|
|
1329
|
+
}
|
|
968
1330
|
return;
|
|
969
1331
|
}
|
|
970
1332
|
|
|
971
|
-
const
|
|
1333
|
+
const fields: Omit<Doc<"liveSessions">, "_id" | "_creationTime"> = {
|
|
972
1334
|
siteId,
|
|
973
1335
|
sessionId: event.sessionId,
|
|
974
1336
|
visitorId: event.visitorId,
|
|
@@ -976,7 +1338,6 @@ async function updateLiveSession(
|
|
|
976
1338
|
startedAt: event.timestamp,
|
|
977
1339
|
lastSeenAt: event.timestamp,
|
|
978
1340
|
expiresAt: deadline,
|
|
979
|
-
expiryChainDeadline: deadline,
|
|
980
1341
|
...(context.country ? { country: context.country } : {}),
|
|
981
1342
|
...(context.city ? { city: context.city } : {}),
|
|
982
1343
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
@@ -988,13 +1349,42 @@ async function updateLiveSession(
|
|
|
988
1349
|
pageviewCount: session.pageviewCount,
|
|
989
1350
|
eventCount: session.eventCount,
|
|
990
1351
|
...(event.type === "heartbeat"
|
|
991
|
-
? {
|
|
1352
|
+
? {
|
|
1353
|
+
lastHeartbeatEventId: event.eventId,
|
|
1354
|
+
recentHeartbeatEventIds: [event.eventId],
|
|
1355
|
+
}
|
|
992
1356
|
: {}),
|
|
993
|
-
}
|
|
994
|
-
await ctx.
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1357
|
+
};
|
|
1358
|
+
const liveSessionId = await ctx.db.insert("liveSessions", fields);
|
|
1359
|
+
if (liveState) {
|
|
1360
|
+
liveState.current = {
|
|
1361
|
+
...fields,
|
|
1362
|
+
_id: liveSessionId,
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
async function ensureLiveSweep(
|
|
1368
|
+
ctx: MutationCtx,
|
|
1369
|
+
siteId: Id<"sites">,
|
|
1370
|
+
now: number,
|
|
1371
|
+
batchState?: BatchState,
|
|
1372
|
+
) {
|
|
1373
|
+
const key = String(siteId);
|
|
1374
|
+
if (batchState?.liveSweepSites.has(key)) return;
|
|
1375
|
+
const existing = await ctx.db
|
|
1376
|
+
.query("liveSweepStates")
|
|
1377
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1378
|
+
.unique();
|
|
1379
|
+
if (!existing) {
|
|
1380
|
+
const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
|
|
1381
|
+
await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
|
|
1382
|
+
await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
|
|
1383
|
+
siteId,
|
|
1384
|
+
scheduledAt,
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
batchState?.liveSweepSites.add(key);
|
|
998
1388
|
}
|
|
999
1389
|
|
|
1000
1390
|
function goalMatches(goal: Doc<"goals">, event: TrackerEvent): boolean {
|
|
@@ -1009,20 +1399,38 @@ async function advanceFunnel(
|
|
|
1009
1399
|
siteId: Id<"sites">,
|
|
1010
1400
|
funnel: Doc<"funnels">,
|
|
1011
1401
|
event: TrackerEvent,
|
|
1402
|
+
batchState?: BatchState,
|
|
1012
1403
|
) {
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1404
|
+
const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
|
|
1405
|
+
let progressState = batchState?.funnelProgress.get(cacheKey);
|
|
1406
|
+
if (batchState && !progressState) {
|
|
1407
|
+
const current = await ctx.db
|
|
1408
|
+
.query("funnelProgress")
|
|
1409
|
+
.withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
|
|
1410
|
+
range
|
|
1411
|
+
.eq("siteId", siteId)
|
|
1412
|
+
.eq("funnelId", funnel._id)
|
|
1413
|
+
.eq("visitorId", event.visitorId),
|
|
1414
|
+
)
|
|
1415
|
+
.unique();
|
|
1416
|
+
progressState = { current, dirty: false };
|
|
1417
|
+
batchState.funnelProgress.set(cacheKey, progressState);
|
|
1418
|
+
}
|
|
1419
|
+
const progress = progressState
|
|
1420
|
+
? progressState.current
|
|
1421
|
+
: await ctx.db
|
|
1422
|
+
.query("funnelProgress")
|
|
1423
|
+
.withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
|
|
1424
|
+
range
|
|
1425
|
+
.eq("siteId", siteId)
|
|
1426
|
+
.eq("funnelId", funnel._id)
|
|
1427
|
+
.eq("visitorId", event.visitorId),
|
|
1428
|
+
)
|
|
1429
|
+
.unique();
|
|
1022
1430
|
const matchesFirst = stepMatches(funnel.steps[0], event);
|
|
1023
1431
|
if (!progress) {
|
|
1024
1432
|
if (!matchesFirst) return;
|
|
1025
|
-
|
|
1433
|
+
const fields: Omit<Doc<"funnelProgress">, "_id" | "_creationTime"> = {
|
|
1026
1434
|
siteId,
|
|
1027
1435
|
funnelId: funnel._id,
|
|
1028
1436
|
visitorId: event.visitorId,
|
|
@@ -1030,20 +1438,40 @@ async function advanceFunnel(
|
|
|
1030
1438
|
currentStep: 1,
|
|
1031
1439
|
startedAt: event.timestamp,
|
|
1032
1440
|
lastStepAt: event.timestamp,
|
|
1033
|
-
}
|
|
1441
|
+
};
|
|
1442
|
+
const progressId = await ctx.db.insert("funnelProgress", fields);
|
|
1443
|
+
if (progressState) {
|
|
1444
|
+
progressState.current = {
|
|
1445
|
+
...fields,
|
|
1446
|
+
_id: progressId,
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1034
1449
|
await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
|
|
1035
1450
|
return;
|
|
1036
1451
|
}
|
|
1037
1452
|
if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt) return;
|
|
1038
1453
|
if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
|
|
1039
1454
|
if (!matchesFirst) return;
|
|
1040
|
-
|
|
1455
|
+
const updated = {
|
|
1456
|
+
...progress,
|
|
1041
1457
|
sessionId: event.sessionId,
|
|
1042
1458
|
currentStep: 1,
|
|
1043
1459
|
startedAt: event.timestamp,
|
|
1044
1460
|
lastStepAt: event.timestamp,
|
|
1045
|
-
|
|
1046
|
-
|
|
1461
|
+
};
|
|
1462
|
+
delete updated.completedAt;
|
|
1463
|
+
if (progressState) {
|
|
1464
|
+
progressState.current = updated;
|
|
1465
|
+
progressState.dirty = true;
|
|
1466
|
+
} else {
|
|
1467
|
+
await ctx.db.patch("funnelProgress", progress._id, {
|
|
1468
|
+
sessionId: event.sessionId,
|
|
1469
|
+
currentStep: 1,
|
|
1470
|
+
startedAt: event.timestamp,
|
|
1471
|
+
lastStepAt: event.timestamp,
|
|
1472
|
+
completedAt: undefined,
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1047
1475
|
await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
|
|
1048
1476
|
return;
|
|
1049
1477
|
}
|
|
@@ -1051,12 +1479,18 @@ async function advanceFunnel(
|
|
|
1051
1479
|
if (!nextStep || !stepMatches(nextStep, event)) return;
|
|
1052
1480
|
const currentStep = progress.currentStep + 1;
|
|
1053
1481
|
const completed = currentStep === funnel.steps.length;
|
|
1054
|
-
|
|
1482
|
+
const updates = {
|
|
1055
1483
|
sessionId: event.sessionId,
|
|
1056
1484
|
currentStep,
|
|
1057
1485
|
lastStepAt: event.timestamp,
|
|
1058
1486
|
...(completed ? { completedAt: event.timestamp } : {}),
|
|
1059
|
-
}
|
|
1487
|
+
};
|
|
1488
|
+
if (progressState) {
|
|
1489
|
+
progressState.current = { ...progress, ...updates };
|
|
1490
|
+
progressState.dirty = true;
|
|
1491
|
+
} else {
|
|
1492
|
+
await ctx.db.patch("funnelProgress", progress._id, updates);
|
|
1493
|
+
}
|
|
1060
1494
|
await incrementFunnelStep(
|
|
1061
1495
|
ctx,
|
|
1062
1496
|
siteId,
|
|
@@ -1085,7 +1519,7 @@ async function incrementFunnelStep(
|
|
|
1085
1519
|
completed: boolean,
|
|
1086
1520
|
) {
|
|
1087
1521
|
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
1088
|
-
const shard = stableHash(event.visitorId) %
|
|
1522
|
+
const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
|
|
1089
1523
|
const bucket = await ctx.db
|
|
1090
1524
|
.query("funnelStepBuckets")
|
|
1091
1525
|
.withIndex(
|
|
@@ -1155,7 +1589,7 @@ async function accountConversion(
|
|
|
1155
1589
|
});
|
|
1156
1590
|
if (commissionAffiliate && attribution) {
|
|
1157
1591
|
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
1158
|
-
const shard = stableHash(event.sessionId) %
|
|
1592
|
+
const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
|
|
1159
1593
|
const stats = await ctx.db
|
|
1160
1594
|
.query("affiliateDailyStats")
|
|
1161
1595
|
.withIndex(
|
|
@@ -1198,7 +1632,6 @@ function foldAggregates(
|
|
|
1198
1632
|
recordTelemetry: boolean,
|
|
1199
1633
|
financialConversion: boolean,
|
|
1200
1634
|
) {
|
|
1201
|
-
const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
|
|
1202
1635
|
const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
|
|
1203
1636
|
const dimensionDeltas = dimensions.map(
|
|
1204
1637
|
(dimension): DimensionDelta => ({
|
|
@@ -1208,6 +1641,8 @@ function foldAggregates(
|
|
|
1208
1641
|
}),
|
|
1209
1642
|
);
|
|
1210
1643
|
for (const granularity of ["hour", "day"] as const) {
|
|
1644
|
+
const shard = stableHash(event.sessionId) %
|
|
1645
|
+
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
1211
1646
|
const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
|
|
1212
1647
|
const bucketStart = Math.floor(event.timestamp / interval) * interval;
|
|
1213
1648
|
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
@@ -1325,7 +1760,7 @@ function mergeDimensions(
|
|
|
1325
1760
|
const typeSlots = result.filter(
|
|
1326
1761
|
(candidate) => candidate.type === addition.type,
|
|
1327
1762
|
);
|
|
1328
|
-
if (typeSlots.length < DIMENSION_SLOTS) {
|
|
1763
|
+
if (typeSlots.length < DIMENSION_SLOTS - 1) {
|
|
1329
1764
|
const occupied = Array.from(
|
|
1330
1765
|
{ length: DIMENSION_SLOTS },
|
|
1331
1766
|
() => false,
|
|
@@ -1342,15 +1777,36 @@ function mergeDimensions(
|
|
|
1342
1777
|
});
|
|
1343
1778
|
continue;
|
|
1344
1779
|
}
|
|
1345
|
-
|
|
1780
|
+
const overflow = typeSlots.find(
|
|
1781
|
+
(candidate) => candidate.value === "(other)",
|
|
1782
|
+
);
|
|
1783
|
+
if (overflow) {
|
|
1784
|
+
overflow.count += addition.count;
|
|
1785
|
+
overflow.revenueCents += addition.revenueCents;
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
if (addition.count === 0 && addition.revenueCents === 0) continue;
|
|
1789
|
+
if (typeSlots.length < DIMENSION_SLOTS) {
|
|
1790
|
+
const occupied = new Set(typeSlots.map((candidate) => candidate.slot));
|
|
1791
|
+
let slot = stableHash("(other)") % DIMENSION_SLOTS;
|
|
1792
|
+
while (occupied.has(slot)) slot = (slot + 1) % DIMENSION_SLOTS;
|
|
1793
|
+
result.push({
|
|
1794
|
+
type: addition.type,
|
|
1795
|
+
slot,
|
|
1796
|
+
value: "(other)",
|
|
1797
|
+
count: addition.count,
|
|
1798
|
+
revenueCents: addition.revenueCents,
|
|
1799
|
+
});
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1346
1802
|
|
|
1347
1803
|
let replacement = typeSlots[0];
|
|
1348
1804
|
for (const candidate of typeSlots.slice(1)) {
|
|
1349
1805
|
if (candidate.count < replacement.count) replacement = candidate;
|
|
1350
1806
|
}
|
|
1351
|
-
replacement.value =
|
|
1807
|
+
replacement.value = "(other)";
|
|
1352
1808
|
replacement.count += addition.count;
|
|
1353
|
-
replacement.revenueCents
|
|
1809
|
+
replacement.revenueCents += addition.revenueCents;
|
|
1354
1810
|
}
|
|
1355
1811
|
return result;
|
|
1356
1812
|
}
|