@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/dist/component/ingest.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { mutation } from "./_generated/server.js";
|
|
3
3
|
import { internal } from "./_generated/api.js";
|
|
4
|
-
import { AGGREGATE_SHARDS, DAY_MS, DIMENSION_SLOTS, HOUR_MS, LIVE_SESSION_TTL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, RATE_LIMIT_WINDOW_MS, SITE_INGEST_SHARDS, } from "./constants.js";
|
|
4
|
+
import { AGGREGATE_SHARDS, MAX_BATCH_EVENT_GROUPS, DAY_MS, HOURLY_AGGREGATE_SHARDS, DIMENSION_SLOTS, HOUR_MS, LIVE_SESSION_TTL_MS, LIVE_SWEEP_INTERVAL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, RATE_LIMIT_WINDOW_MS, SECONDARY_AGGREGATE_SHARDS, SITE_INGEST_SHARDS, } from "./constants.js";
|
|
5
5
|
import { fail } from "./errors.js";
|
|
6
|
+
import { bufferEventBatch, eventBatchKey, eventBucketStart, findTelemetry, flushEventBatches, insertEventBatch, inspectTelemetryMinute, } from "./eventStore.js";
|
|
6
7
|
import { cleanString, ensureBatchBytes, isValidMoney, originAllowed, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
|
|
7
8
|
import { ingestContextValidator, ingestResultValidator, trackerEventValidator, trustedConversionValidator, } from "./validators.js";
|
|
8
9
|
export const ingestBatch = mutation({
|
|
@@ -37,25 +38,84 @@ export const ingestBatch = mutation({
|
|
|
37
38
|
const eventByteCounts = await consumeSiteRateLimit(ctx, args.siteId, args.events, now);
|
|
38
39
|
const context = sanitizeContext(args.context);
|
|
39
40
|
const aggregateDeltas = new Map();
|
|
41
|
+
const sessionRateWindows = new Map();
|
|
42
|
+
const batchState = {
|
|
43
|
+
sessions: new Map(),
|
|
44
|
+
liveSessions: new Map(),
|
|
45
|
+
visitorAttributions: new Map(),
|
|
46
|
+
funnelProgress: new Map(),
|
|
47
|
+
eventBatches: new Map(),
|
|
48
|
+
liveSweepSites: new Set(),
|
|
49
|
+
};
|
|
40
50
|
let goals = null;
|
|
41
51
|
let funnels = null;
|
|
42
52
|
let accepted = 0;
|
|
43
53
|
let duplicates = 0;
|
|
44
54
|
let rejected = 0;
|
|
45
|
-
|
|
46
|
-
const input = args.events[eventIndex];
|
|
47
|
-
let event;
|
|
55
|
+
const events = args.events.map((input) => {
|
|
48
56
|
try {
|
|
49
|
-
|
|
57
|
+
return sanitizeEvent(input, now);
|
|
50
58
|
}
|
|
51
59
|
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
const duplicateInBatch = new Set();
|
|
64
|
+
const eventIdsByGroup = new Map();
|
|
65
|
+
const requestGroups = new Set();
|
|
66
|
+
const dedupeGroups = new Map();
|
|
67
|
+
for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
|
|
68
|
+
const event = events[eventIndex];
|
|
69
|
+
if (!event)
|
|
70
|
+
continue;
|
|
71
|
+
const bucketStart = eventBucketStart(event.timestamp);
|
|
72
|
+
const groupKey = eventBatchKey(event.sessionId, bucketStart);
|
|
73
|
+
requestGroups.add(groupKey);
|
|
74
|
+
if (event.type === "heartbeat")
|
|
75
|
+
continue;
|
|
76
|
+
const eventIds = eventIdsByGroup.get(groupKey) ?? new Set();
|
|
77
|
+
if (eventIds.has(event.eventId)) {
|
|
78
|
+
duplicateInBatch.add(eventIndex);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
eventIds.add(event.eventId);
|
|
82
|
+
eventIdsByGroup.set(groupKey, eventIds);
|
|
83
|
+
const group = dedupeGroups.get(groupKey) ?? {
|
|
84
|
+
sessionId: event.sessionId,
|
|
85
|
+
bucketStart,
|
|
86
|
+
eventIds: new Set(),
|
|
87
|
+
};
|
|
88
|
+
group.eventIds.add(event.eventId);
|
|
89
|
+
dedupeGroups.set(groupKey, group);
|
|
90
|
+
}
|
|
91
|
+
if (requestGroups.size > MAX_BATCH_EVENT_GROUPS) {
|
|
92
|
+
fail("BATCH_TOO_LARGE", `events may span at most ${MAX_BATCH_EVENT_GROUPS} session-minute groups`, { limit: MAX_BATCH_EVENT_GROUPS });
|
|
93
|
+
}
|
|
94
|
+
const persistedEventIds = new Map();
|
|
95
|
+
await Promise.all([...dedupeGroups].map(async ([key, group]) => {
|
|
96
|
+
const { eventIds, persistedCount } = await inspectTelemetryMinute(ctx, {
|
|
97
|
+
siteId: args.siteId,
|
|
98
|
+
sessionId: group.sessionId,
|
|
99
|
+
bucketStart: group.bucketStart,
|
|
100
|
+
candidateEventIds: group.eventIds,
|
|
101
|
+
});
|
|
102
|
+
const newCount = [...group.eventIds].filter((eventId) => !eventIds.has(eventId)).length;
|
|
103
|
+
if (newCount > 0 &&
|
|
104
|
+
persistedCount + newCount > MAX_EVENTS_PER_SESSION_WINDOW) {
|
|
105
|
+
fail("RATE_LIMITED", "session event minute limit exceeded", {
|
|
106
|
+
limit: MAX_EVENTS_PER_SESSION_WINDOW,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
persistedEventIds.set(key, eventIds);
|
|
110
|
+
}));
|
|
111
|
+
for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
|
|
112
|
+
const event = events[eventIndex];
|
|
113
|
+
if (!event) {
|
|
52
114
|
rejected += 1;
|
|
53
115
|
continue;
|
|
54
116
|
}
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
.withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", args.siteId).eq("sessionId", event.sessionId))
|
|
58
|
-
.unique();
|
|
117
|
+
const sessionRow = await loadSession(ctx, args.siteId, event.sessionId, batchState);
|
|
118
|
+
const existingSession = sessionRow.current;
|
|
59
119
|
if (existingSession && existingSession.visitorId !== event.visitorId) {
|
|
60
120
|
rejected += 1;
|
|
61
121
|
continue;
|
|
@@ -65,31 +125,29 @@ export const ingestBatch = mutation({
|
|
|
65
125
|
rejected += 1;
|
|
66
126
|
continue;
|
|
67
127
|
}
|
|
68
|
-
const liveSession = await ctx.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (liveSession?.lastHeartbeatEventId === event.eventId) {
|
|
128
|
+
const liveSession = (await loadLiveSession(ctx, args.siteId, event.sessionId, batchState)).current;
|
|
129
|
+
if (liveSession &&
|
|
130
|
+
(liveSession.lastHeartbeatEventId === event.eventId ||
|
|
131
|
+
liveSession.recentHeartbeatEventIds?.includes(event.eventId))) {
|
|
73
132
|
duplicates += 1;
|
|
74
133
|
continue;
|
|
75
134
|
}
|
|
76
|
-
if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now))) {
|
|
135
|
+
if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
|
|
77
136
|
rejected += 1;
|
|
78
137
|
continue;
|
|
79
138
|
}
|
|
80
|
-
await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now);
|
|
139
|
+
await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now, batchState);
|
|
81
140
|
accepted += 1;
|
|
82
141
|
continue;
|
|
83
142
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (duplicate) {
|
|
143
|
+
if (duplicateInBatch.has(eventIndex) ||
|
|
144
|
+
persistedEventIds
|
|
145
|
+
.get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
|
|
146
|
+
?.has(event.eventId)) {
|
|
89
147
|
duplicates += 1;
|
|
90
148
|
continue;
|
|
91
149
|
}
|
|
92
|
-
if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now))) {
|
|
150
|
+
if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
|
|
93
151
|
rejected += 1;
|
|
94
152
|
continue;
|
|
95
153
|
}
|
|
@@ -118,9 +176,13 @@ export const ingestBatch = mutation({
|
|
|
118
176
|
financialConversion: false,
|
|
119
177
|
trustedGoalKey: undefined,
|
|
120
178
|
now,
|
|
179
|
+
batchState,
|
|
121
180
|
});
|
|
181
|
+
bufferEventBatch(args.siteId, event, batchState.eventBatches);
|
|
122
182
|
accepted += 1;
|
|
123
183
|
}
|
|
184
|
+
await flushBatchState(ctx, batchState);
|
|
185
|
+
await flushSessionRateLimits(ctx, sessionRateWindows, now);
|
|
124
186
|
await flushAggregates(ctx, aggregateDeltas);
|
|
125
187
|
return { accepted, duplicates, rejected };
|
|
126
188
|
},
|
|
@@ -136,13 +198,17 @@ export const trackConversion = mutation({
|
|
|
136
198
|
fail("INVALID_ARGUMENT", "revenueCents must be an integer between 0 and 1000000000");
|
|
137
199
|
}
|
|
138
200
|
let event;
|
|
201
|
+
let trustedGoalKey;
|
|
139
202
|
try {
|
|
203
|
+
trustedGoalKey = args.goalKey
|
|
204
|
+
? sanitizeSlug(args.goalKey, "goalKey")
|
|
205
|
+
: undefined;
|
|
140
206
|
event = sanitizeEvent({
|
|
141
207
|
eventId: sanitizeOpaqueId(args.eventId, "eventId"),
|
|
142
208
|
sessionId: sanitizeOpaqueId(args.sessionId, "sessionId"),
|
|
143
209
|
visitorId: sanitizeOpaqueId(args.visitorId, "visitorId"),
|
|
144
210
|
type: "conversion",
|
|
145
|
-
name:
|
|
211
|
+
name: trustedGoalKey ?? "conversion",
|
|
146
212
|
path: sanitizePath(args.path ?? "/conversion"),
|
|
147
213
|
timestamp: args.timestamp,
|
|
148
214
|
sequence: 0,
|
|
@@ -155,23 +221,24 @@ export const trackConversion = mutation({
|
|
|
155
221
|
fail("INVALID_ARGUMENT", error instanceof Error ? error.message : "invalid conversion");
|
|
156
222
|
}
|
|
157
223
|
const siteCurrency = site.currency ?? "USD";
|
|
158
|
-
if (event.currency !== siteCurrency) {
|
|
159
|
-
fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
|
|
160
|
-
siteCurrency,
|
|
161
|
-
conversionCurrency: event.currency ?? "USD",
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
224
|
const priorConversion = await ctx.db
|
|
165
225
|
.query("conversions")
|
|
166
226
|
.withIndex("by_siteId_and_eventId", (range) => range.eq("siteId", args.siteId).eq("eventId", event.eventId))
|
|
167
227
|
.unique();
|
|
168
228
|
if (priorConversion) {
|
|
229
|
+
if (!sameTrustedConversion(priorConversion, event, trustedGoalKey)) {
|
|
230
|
+
fail("CONFLICT", "conversion eventId is already associated with a different payload");
|
|
231
|
+
}
|
|
169
232
|
return { accepted: 0, duplicates: 1, rejected: 0 };
|
|
170
233
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
234
|
+
if (event.currency !== siteCurrency) {
|
|
235
|
+
fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
|
|
236
|
+
siteCurrency,
|
|
237
|
+
conversionCurrency: event.currency ?? "USD",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const telemetry = await findTelemetry(ctx, args.siteId, event);
|
|
241
|
+
const existingTelemetry = telemetry.match;
|
|
175
242
|
if (existingTelemetry &&
|
|
176
243
|
(existingTelemetry.sessionId !== event.sessionId ||
|
|
177
244
|
existingTelemetry.visitorId !== event.visitorId)) {
|
|
@@ -184,17 +251,15 @@ export const trackConversion = mutation({
|
|
|
184
251
|
if (existingSession && existingSession.visitorId !== event.visitorId) {
|
|
185
252
|
fail("CONFLICT", "session belongs to a different visitor");
|
|
186
253
|
}
|
|
187
|
-
const conversionContext =
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
: sanitizeContext(undefined);
|
|
254
|
+
const conversionContext = {
|
|
255
|
+
country: existingTelemetry?.country ?? existingSession?.country,
|
|
256
|
+
city: existingTelemetry?.city ?? existingSession?.city,
|
|
257
|
+
latitude: existingTelemetry?.latitude ?? existingSession?.latitude,
|
|
258
|
+
longitude: existingTelemetry?.longitude ?? existingSession?.longitude,
|
|
259
|
+
browser: existingTelemetry?.browser ?? existingSession?.browser,
|
|
260
|
+
os: existingTelemetry?.os ?? existingSession?.os,
|
|
261
|
+
device: existingTelemetry?.device ?? existingSession?.device,
|
|
262
|
+
};
|
|
198
263
|
const aggregateDeltas = new Map();
|
|
199
264
|
await accountEvent(ctx, {
|
|
200
265
|
siteId: args.siteId,
|
|
@@ -204,73 +269,126 @@ export const trackConversion = mutation({
|
|
|
204
269
|
goals: [],
|
|
205
270
|
funnels: [],
|
|
206
271
|
aggregateDeltas,
|
|
207
|
-
recordTelemetry: existingTelemetry === null,
|
|
272
|
+
recordTelemetry: existingTelemetry === null && !telemetry.saturated,
|
|
208
273
|
financialConversion: true,
|
|
209
|
-
trustedGoalKey
|
|
210
|
-
? sanitizeSlug(args.goalKey, "goalKey")
|
|
211
|
-
: undefined,
|
|
274
|
+
trustedGoalKey,
|
|
212
275
|
now: Date.now(),
|
|
213
276
|
});
|
|
277
|
+
if (!existingTelemetry && !telemetry.saturated) {
|
|
278
|
+
await insertEventBatch(ctx, args.siteId, event);
|
|
279
|
+
}
|
|
214
280
|
await flushAggregates(ctx, aggregateDeltas);
|
|
215
281
|
return { accepted: 1, duplicates: 0, rejected: 0 };
|
|
216
282
|
},
|
|
217
283
|
});
|
|
284
|
+
function sameTrustedConversion(existing, event, goalKey) {
|
|
285
|
+
return (existing.source === "trusted" &&
|
|
286
|
+
existing.sessionId === event.sessionId &&
|
|
287
|
+
existing.visitorId === event.visitorId &&
|
|
288
|
+
existing.timestamp === event.timestamp &&
|
|
289
|
+
existing.path === event.path &&
|
|
290
|
+
existing.revenueCents === (event.revenueCents ?? 0) &&
|
|
291
|
+
existing.currency === (event.currency ?? "USD") &&
|
|
292
|
+
existing.goalKey === goalKey);
|
|
293
|
+
}
|
|
294
|
+
async function loadSession(ctx, siteId, sessionId, batchState) {
|
|
295
|
+
const cached = batchState.sessions.get(sessionId);
|
|
296
|
+
if (cached)
|
|
297
|
+
return cached;
|
|
298
|
+
const current = await ctx.db
|
|
299
|
+
.query("sessions")
|
|
300
|
+
.withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
|
|
301
|
+
.unique();
|
|
302
|
+
const loaded = { current, dirty: false };
|
|
303
|
+
batchState.sessions.set(sessionId, loaded);
|
|
304
|
+
return loaded;
|
|
305
|
+
}
|
|
306
|
+
async function loadLiveSession(ctx, siteId, sessionId, batchState) {
|
|
307
|
+
const cached = batchState.liveSessions.get(sessionId);
|
|
308
|
+
if (cached)
|
|
309
|
+
return cached;
|
|
310
|
+
const current = await ctx.db
|
|
311
|
+
.query("liveSessions")
|
|
312
|
+
.withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
|
|
313
|
+
.unique();
|
|
314
|
+
const loaded = { current, dirty: false };
|
|
315
|
+
batchState.liveSessions.set(sessionId, loaded);
|
|
316
|
+
return loaded;
|
|
317
|
+
}
|
|
318
|
+
async function flushBatchState(ctx, batchState) {
|
|
319
|
+
for (const state of batchState.sessions.values()) {
|
|
320
|
+
if (state.dirty && state.current) {
|
|
321
|
+
await ctx.db.replace("sessions", state.current._id, withoutSystemFields(state.current));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
for (const state of batchState.liveSessions.values()) {
|
|
325
|
+
if (state.dirty && state.current) {
|
|
326
|
+
await ctx.db.replace("liveSessions", state.current._id, withoutSystemFields(state.current));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
for (const state of batchState.visitorAttributions.values()) {
|
|
330
|
+
if (state.dirty && state.current) {
|
|
331
|
+
await ctx.db.replace("visitorAttributions", state.current._id, withoutSystemFields(state.current));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
for (const state of batchState.funnelProgress.values()) {
|
|
335
|
+
if (state.dirty && state.current) {
|
|
336
|
+
await ctx.db.replace("funnelProgress", state.current._id, withoutSystemFields(state.current));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
await flushEventBatches(ctx, batchState.eventBatches);
|
|
340
|
+
}
|
|
341
|
+
function withoutSystemFields(document) {
|
|
342
|
+
const { _id, _creationTime, ...fields } = document;
|
|
343
|
+
void _id;
|
|
344
|
+
void _creationTime;
|
|
345
|
+
return fields;
|
|
346
|
+
}
|
|
218
347
|
async function accountEvent(ctx, input) {
|
|
219
348
|
const { event } = input;
|
|
220
349
|
const session = input.recordTelemetry
|
|
221
|
-
? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion)
|
|
350
|
+
? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion, input.batchState)
|
|
222
351
|
: await accountTrustedSession(ctx, input.existingSession, event);
|
|
223
|
-
if (input.recordTelemetry) {
|
|
224
|
-
await ctx.db.insert("events", {
|
|
225
|
-
siteId: input.siteId,
|
|
226
|
-
eventId: event.eventId,
|
|
227
|
-
sessionId: event.sessionId,
|
|
228
|
-
visitorId: event.visitorId,
|
|
229
|
-
type: event.type,
|
|
230
|
-
...(event.name ? { name: event.name } : {}),
|
|
231
|
-
path: event.path,
|
|
232
|
-
...(event.referrer ? { referrer: event.referrer } : {}),
|
|
233
|
-
timestamp: event.timestamp,
|
|
234
|
-
sequence: event.sequence,
|
|
235
|
-
...(event.target ? { target: event.target } : {}),
|
|
236
|
-
...(event.href ? { href: event.href } : {}),
|
|
237
|
-
...(event.properties ? { properties: event.properties } : {}),
|
|
238
|
-
...(event.revenueCents !== undefined
|
|
239
|
-
? { revenueCents: event.revenueCents }
|
|
240
|
-
: {}),
|
|
241
|
-
...(event.currency ? { currency: event.currency } : {}),
|
|
242
|
-
...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
|
|
243
|
-
...(input.context.country ? { country: input.context.country } : {}),
|
|
244
|
-
...(input.context.city ? { city: input.context.city } : {}),
|
|
245
|
-
...(input.context.latitude !== undefined
|
|
246
|
-
? { latitude: input.context.latitude }
|
|
247
|
-
: {}),
|
|
248
|
-
...(input.context.longitude !== undefined
|
|
249
|
-
? { longitude: input.context.longitude }
|
|
250
|
-
: {}),
|
|
251
|
-
browser: input.context.browser ?? "Other",
|
|
252
|
-
os: input.context.os ?? "Other",
|
|
253
|
-
device: input.context.device ?? "unknown",
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
352
|
const matchedGoalKeys = [];
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
353
|
+
const matchingGoals = input.goals.filter((goal) => goal.active && goalMatches(goal, event));
|
|
354
|
+
if (matchingGoals.length > 1) {
|
|
355
|
+
fail("LIMIT_EXCEEDED", "active goals must use unique matchers");
|
|
356
|
+
}
|
|
357
|
+
for (const goal of matchingGoals) {
|
|
358
|
+
const completionEventId = JSON.stringify([
|
|
359
|
+
event.sessionId,
|
|
360
|
+
eventBucketStart(event.timestamp),
|
|
361
|
+
event.eventId,
|
|
362
|
+
]);
|
|
363
|
+
const scopedPrior = (await ctx.db
|
|
261
364
|
.query("goalCompletions")
|
|
262
365
|
.withIndex("by_siteId_and_goalId_and_eventId", (range) => range
|
|
263
366
|
.eq("siteId", input.siteId)
|
|
264
367
|
.eq("goalId", goal._id)
|
|
265
|
-
.eq("eventId",
|
|
266
|
-
.
|
|
267
|
-
|
|
368
|
+
.eq("eventId", completionEventId))
|
|
369
|
+
.take(2))
|
|
370
|
+
.find((completion) => completion.idempotencyVersion === 2);
|
|
371
|
+
const legacyPrior = scopedPrior
|
|
372
|
+
? undefined
|
|
373
|
+
: (await ctx.db
|
|
374
|
+
.query("goalCompletions")
|
|
375
|
+
.withIndex("by_siteId_and_goalId_and_eventId", (range) => range
|
|
376
|
+
.eq("siteId", input.siteId)
|
|
377
|
+
.eq("goalId", goal._id)
|
|
378
|
+
.eq("eventId", event.eventId))
|
|
379
|
+
.take(2))
|
|
380
|
+
.find((completion) => completion.idempotencyVersion === undefined &&
|
|
381
|
+
completion.sessionId === event.sessionId &&
|
|
382
|
+
eventBucketStart(completion.timestamp) ===
|
|
383
|
+
eventBucketStart(event.timestamp));
|
|
384
|
+
if (scopedPrior || legacyPrior)
|
|
268
385
|
continue;
|
|
269
386
|
await ctx.db.insert("goalCompletions", {
|
|
270
387
|
siteId: input.siteId,
|
|
271
388
|
goalId: goal._id,
|
|
272
389
|
goalKey: goal.key,
|
|
273
|
-
eventId:
|
|
390
|
+
eventId: completionEventId,
|
|
391
|
+
idempotencyVersion: 2,
|
|
274
392
|
sessionId: event.sessionId,
|
|
275
393
|
visitorId: event.visitorId,
|
|
276
394
|
timestamp: event.timestamp,
|
|
@@ -280,7 +398,7 @@ async function accountEvent(ctx, input) {
|
|
|
280
398
|
}
|
|
281
399
|
for (const funnel of input.funnels) {
|
|
282
400
|
if (funnel.active) {
|
|
283
|
-
await advanceFunnel(ctx, input.siteId, funnel, event);
|
|
401
|
+
await advanceFunnel(ctx, input.siteId, funnel, event, input.batchState);
|
|
284
402
|
}
|
|
285
403
|
}
|
|
286
404
|
if (input.financialConversion) {
|
|
@@ -301,7 +419,7 @@ async function accountEvent(ctx, input) {
|
|
|
301
419
|
foldAggregates(input.aggregateDeltas, input.siteId, event, session, dimensions, input.recordTelemetry, input.financialConversion);
|
|
302
420
|
if (input.recordTelemetry &&
|
|
303
421
|
event.timestamp >= input.now - LIVE_SESSION_TTL_MS) {
|
|
304
|
-
await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now);
|
|
422
|
+
await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now, input.batchState);
|
|
305
423
|
}
|
|
306
424
|
}
|
|
307
425
|
async function accountTrustedSession(ctx, existing, event) {
|
|
@@ -328,16 +446,16 @@ async function accountTrustedSession(ctx, existing, event) {
|
|
|
328
446
|
affiliateExpiresAt: existing.affiliateExpiresAt,
|
|
329
447
|
};
|
|
330
448
|
}
|
|
331
|
-
async function upsertSession(ctx, siteId, event, context, existing, financialConversion) {
|
|
449
|
+
async function upsertSession(ctx, siteId, event, context, existing, financialConversion, batchState) {
|
|
332
450
|
const source = sourceFromReferrer(event.referrer);
|
|
333
451
|
const utmSource = stringProperty(event.properties, "utm_source");
|
|
334
452
|
const utmMedium = stringProperty(event.properties, "utm_medium");
|
|
335
453
|
const utmCampaign = stringProperty(event.properties, "utm_campaign");
|
|
336
|
-
const attribution = await attributionForEvent(ctx, siteId, event, existing);
|
|
454
|
+
const attribution = await attributionForEvent(ctx, siteId, event, existing, batchState);
|
|
337
455
|
if (existing) {
|
|
338
456
|
const startedAt = Math.min(existing.startedAt, event.timestamp);
|
|
339
457
|
const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
|
|
340
|
-
|
|
458
|
+
const updates = {
|
|
341
459
|
startedAt,
|
|
342
460
|
lastSeenAt,
|
|
343
461
|
entryPath: event.timestamp < existing.startedAt ? event.path : existing.entryPath,
|
|
@@ -362,7 +480,17 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
|
|
|
362
480
|
browser: context.browser ?? existing.browser,
|
|
363
481
|
os: context.os ?? existing.os,
|
|
364
482
|
device: context.device ?? existing.device,
|
|
365
|
-
}
|
|
483
|
+
};
|
|
484
|
+
if (batchState) {
|
|
485
|
+
const state = batchState.sessions.get(event.sessionId);
|
|
486
|
+
if (!state)
|
|
487
|
+
throw new Error("session cache was not initialized");
|
|
488
|
+
state.current = { ...existing, ...updates };
|
|
489
|
+
state.dirty = true;
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
await ctx.db.patch("sessions", existing._id, updates);
|
|
493
|
+
}
|
|
366
494
|
return {
|
|
367
495
|
isNewSession: false,
|
|
368
496
|
isNewVisitorHour: Math.floor(existing.lastSeenAt / HOUR_MS) <
|
|
@@ -376,12 +504,23 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
|
|
|
376
504
|
...(attribution ?? {}),
|
|
377
505
|
};
|
|
378
506
|
}
|
|
379
|
-
const
|
|
507
|
+
const priorVisits = await ctx.db
|
|
380
508
|
.query("sessions")
|
|
381
509
|
.withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
|
|
382
510
|
.order("desc")
|
|
383
511
|
.take(1);
|
|
384
|
-
|
|
512
|
+
let priorVisit = priorVisits[0] ?? null;
|
|
513
|
+
if (batchState) {
|
|
514
|
+
for (const state of batchState.sessions.values()) {
|
|
515
|
+
const candidate = state.current;
|
|
516
|
+
if (candidate?.visitorId === event.visitorId &&
|
|
517
|
+
candidate.sessionId !== event.sessionId &&
|
|
518
|
+
(!priorVisit || candidate.lastSeenAt > priorVisit.lastSeenAt)) {
|
|
519
|
+
priorVisit = candidate;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const fields = {
|
|
385
524
|
siteId,
|
|
386
525
|
sessionId: event.sessionId,
|
|
387
526
|
visitorId: event.visitorId,
|
|
@@ -410,14 +549,24 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
|
|
|
410
549
|
? { currency: event.currency }
|
|
411
550
|
: {}),
|
|
412
551
|
...(attribution ?? {}),
|
|
413
|
-
}
|
|
552
|
+
};
|
|
553
|
+
const sessionId = await ctx.db.insert("sessions", fields);
|
|
554
|
+
if (batchState) {
|
|
555
|
+
const state = batchState.sessions.get(event.sessionId);
|
|
556
|
+
if (!state)
|
|
557
|
+
throw new Error("session cache was not initialized");
|
|
558
|
+
state.current = {
|
|
559
|
+
...fields,
|
|
560
|
+
_id: sessionId,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
414
563
|
return {
|
|
415
564
|
isNewSession: true,
|
|
416
|
-
isNewVisitorHour: priorVisit
|
|
417
|
-
Math.floor(priorVisit
|
|
565
|
+
isNewVisitorHour: priorVisit === null ||
|
|
566
|
+
Math.floor(priorVisit.lastSeenAt / HOUR_MS) <
|
|
418
567
|
Math.floor(event.timestamp / HOUR_MS),
|
|
419
|
-
isNewVisitorDay: priorVisit
|
|
420
|
-
Math.floor(priorVisit
|
|
568
|
+
isNewVisitorDay: priorVisit === null ||
|
|
569
|
+
Math.floor(priorVisit.lastSeenAt / DAY_MS) <
|
|
421
570
|
Math.floor(event.timestamp / DAY_MS),
|
|
422
571
|
startedAt: event.timestamp,
|
|
423
572
|
source,
|
|
@@ -446,7 +595,7 @@ function attributionApplies(attribution, timestamp) {
|
|
|
446
595
|
}
|
|
447
596
|
// The first valid touch wins through its inclusive expiry. Only a later,
|
|
448
597
|
// in-order valid touch may replace the visitor's single bounded record.
|
|
449
|
-
async function attributionForEvent(ctx, siteId, event, existingSession) {
|
|
598
|
+
async function attributionForEvent(ctx, siteId, event, existingSession, batchState) {
|
|
450
599
|
const sessionAttribution = existingSession
|
|
451
600
|
? attributionSnapshot(existingSession)
|
|
452
601
|
: undefined;
|
|
@@ -458,10 +607,21 @@ async function attributionForEvent(ctx, siteId, event, existingSession) {
|
|
|
458
607
|
attributionApplies(sessionAttribution, event.timestamp)) {
|
|
459
608
|
return sessionAttribution;
|
|
460
609
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
610
|
+
let attributionState = batchState?.visitorAttributions.get(event.visitorId);
|
|
611
|
+
if (batchState && !attributionState) {
|
|
612
|
+
const current = await ctx.db
|
|
613
|
+
.query("visitorAttributions")
|
|
614
|
+
.withIndex("by_siteId_and_visitorId", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
|
|
615
|
+
.unique();
|
|
616
|
+
attributionState = { current, dirty: false };
|
|
617
|
+
batchState.visitorAttributions.set(event.visitorId, attributionState);
|
|
618
|
+
}
|
|
619
|
+
const current = attributionState
|
|
620
|
+
? attributionState.current
|
|
621
|
+
: await ctx.db
|
|
622
|
+
.query("visitorAttributions")
|
|
623
|
+
.withIndex("by_siteId_and_visitorId", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
|
|
624
|
+
.unique();
|
|
465
625
|
if (current) {
|
|
466
626
|
const snapshot = {
|
|
467
627
|
affiliateId: current.affiliateId,
|
|
@@ -489,23 +649,32 @@ async function attributionForEvent(ctx, siteId, event, existingSession) {
|
|
|
489
649
|
}
|
|
490
650
|
const attributedAt = event.timestamp;
|
|
491
651
|
const expiresAt = attributedAt + affiliate.attributionWindowDays * DAY_MS;
|
|
652
|
+
const fields = {
|
|
653
|
+
siteId,
|
|
654
|
+
visitorId: event.visitorId,
|
|
655
|
+
affiliateId: affiliate._id,
|
|
656
|
+
affiliateSlug: affiliate.slug,
|
|
657
|
+
attributedAt,
|
|
658
|
+
expiresAt,
|
|
659
|
+
};
|
|
492
660
|
if (current) {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
661
|
+
if (attributionState) {
|
|
662
|
+
attributionState.current = { ...current, ...fields };
|
|
663
|
+
attributionState.dirty = true;
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
await ctx.db.patch("visitorAttributions", current._id, fields);
|
|
667
|
+
}
|
|
499
668
|
}
|
|
500
669
|
else {
|
|
501
|
-
await ctx.db.insert("visitorAttributions",
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
670
|
+
const attributionId = await ctx.db.insert("visitorAttributions", fields);
|
|
671
|
+
if (attributionState) {
|
|
672
|
+
attributionState.current = {
|
|
673
|
+
...fields,
|
|
674
|
+
_id: attributionId,
|
|
675
|
+
_creationTime: event.timestamp,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
509
678
|
}
|
|
510
679
|
return {
|
|
511
680
|
affiliateId: affiliate._id,
|
|
@@ -514,10 +683,10 @@ async function attributionForEvent(ctx, siteId, event, existingSession) {
|
|
|
514
683
|
affiliateExpiresAt: expiresAt,
|
|
515
684
|
};
|
|
516
685
|
}
|
|
517
|
-
async function accountHeartbeat(ctx, siteId, session, event, context, now) {
|
|
686
|
+
async function accountHeartbeat(ctx, siteId, session, event, context, now, batchState) {
|
|
518
687
|
const startedAt = Math.min(session.startedAt, event.timestamp);
|
|
519
688
|
const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
|
|
520
|
-
|
|
689
|
+
const updates = {
|
|
521
690
|
startedAt,
|
|
522
691
|
lastSeenAt,
|
|
523
692
|
exitPath: event.timestamp >= session.lastSeenAt
|
|
@@ -531,7 +700,17 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now) {
|
|
|
531
700
|
browser: context.browser ?? session.browser,
|
|
532
701
|
os: context.os ?? session.os,
|
|
533
702
|
device: context.device ?? session.device,
|
|
534
|
-
}
|
|
703
|
+
};
|
|
704
|
+
if (batchState) {
|
|
705
|
+
const state = batchState.sessions.get(event.sessionId);
|
|
706
|
+
if (!state)
|
|
707
|
+
throw new Error("session cache was not initialized");
|
|
708
|
+
state.current = { ...session, ...updates };
|
|
709
|
+
state.dirty = true;
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
await ctx.db.patch("sessions", session._id, updates);
|
|
713
|
+
}
|
|
535
714
|
await updateLiveSession(ctx, siteId, event, context, {
|
|
536
715
|
isNewSession: false,
|
|
537
716
|
isNewVisitorHour: false,
|
|
@@ -541,7 +720,7 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now) {
|
|
|
541
720
|
pageviewCount: session.pageviewCount,
|
|
542
721
|
eventCount: session.eventCount,
|
|
543
722
|
affiliateSlug: session.affiliateSlug,
|
|
544
|
-
}, now);
|
|
723
|
+
}, now, batchState);
|
|
545
724
|
}
|
|
546
725
|
async function consumeSiteRateLimit(ctx, siteId, events, now) {
|
|
547
726
|
const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
@@ -560,13 +739,15 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
|
|
|
560
739
|
}
|
|
561
740
|
const windows = [];
|
|
562
741
|
for (const [shard, increment] of increments) {
|
|
563
|
-
const
|
|
742
|
+
const rows = await ctx.db
|
|
564
743
|
.query("siteIngestWindows")
|
|
565
|
-
.withIndex("by_siteId_and_shard_and_windowStart", (range) => range
|
|
566
|
-
.
|
|
567
|
-
.
|
|
568
|
-
|
|
569
|
-
|
|
744
|
+
.withIndex("by_siteId_and_shard_and_windowStart", (range) => range.eq("siteId", siteId).eq("shard", shard))
|
|
745
|
+
.order("desc")
|
|
746
|
+
.take(1);
|
|
747
|
+
const storedWindow = rows[0] ?? null;
|
|
748
|
+
const window = storedWindow?.windowStart === windowStart
|
|
749
|
+
? storedWindow
|
|
750
|
+
: null;
|
|
570
751
|
if ((window?.eventCount ?? 0) + increment.eventCount >
|
|
571
752
|
MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
|
|
572
753
|
(window?.byteCount ?? 0) + increment.byteCount >
|
|
@@ -580,13 +761,18 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
|
|
|
580
761
|
siteByteLimit: SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
581
762
|
});
|
|
582
763
|
}
|
|
583
|
-
windows.push({ shard, increment, window });
|
|
764
|
+
windows.push({ shard, increment, window: storedWindow });
|
|
584
765
|
}
|
|
585
766
|
for (const { shard, increment, window } of windows) {
|
|
586
767
|
if (window) {
|
|
587
768
|
await ctx.db.patch("siteIngestWindows", window._id, {
|
|
588
|
-
|
|
589
|
-
|
|
769
|
+
windowStart,
|
|
770
|
+
eventCount: window.windowStart === windowStart
|
|
771
|
+
? window.eventCount + increment.eventCount
|
|
772
|
+
: increment.eventCount,
|
|
773
|
+
byteCount: window.windowStart === windowStart
|
|
774
|
+
? window.byteCount + increment.byteCount
|
|
775
|
+
: increment.byteCount,
|
|
590
776
|
updatedAt: now,
|
|
591
777
|
});
|
|
592
778
|
}
|
|
@@ -603,53 +789,83 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
|
|
|
603
789
|
}
|
|
604
790
|
return eventByteCounts;
|
|
605
791
|
}
|
|
606
|
-
async function consumeRateLimit(ctx, siteId, sessionId, byteCount, now) {
|
|
792
|
+
async function consumeRateLimit(ctx, siteId, sessionId, byteCount, now, states) {
|
|
607
793
|
const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
window.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW)) {
|
|
618
|
-
return false;
|
|
619
|
-
}
|
|
620
|
-
if (window) {
|
|
621
|
-
await ctx.db.patch("ingestWindows", window._id, {
|
|
622
|
-
eventCount: window.eventCount + 1,
|
|
623
|
-
byteCount: window.byteCount + byteCount,
|
|
624
|
-
updatedAt: now,
|
|
625
|
-
});
|
|
626
|
-
}
|
|
627
|
-
else {
|
|
628
|
-
await ctx.db.insert("ingestWindows", {
|
|
794
|
+
let state = states.get(sessionId);
|
|
795
|
+
if (!state) {
|
|
796
|
+
const rows = await ctx.db
|
|
797
|
+
.query("ingestWindows")
|
|
798
|
+
.withIndex("by_siteId_and_sessionId_and_windowStart", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
|
|
799
|
+
.order("desc")
|
|
800
|
+
.take(1);
|
|
801
|
+
const window = rows[0] ?? null;
|
|
802
|
+
state = {
|
|
629
803
|
siteId,
|
|
630
804
|
sessionId,
|
|
631
|
-
|
|
632
|
-
eventCount:
|
|
633
|
-
byteCount,
|
|
634
|
-
|
|
635
|
-
}
|
|
805
|
+
window,
|
|
806
|
+
eventCount: window?.windowStart === windowStart ? window.eventCount : 0,
|
|
807
|
+
byteCount: window?.windowStart === windowStart ? window.byteCount : 0,
|
|
808
|
+
dirty: false,
|
|
809
|
+
};
|
|
810
|
+
states.set(sessionId, state);
|
|
811
|
+
}
|
|
812
|
+
if (state.eventCount >= MAX_EVENTS_PER_SESSION_WINDOW ||
|
|
813
|
+
state.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW) {
|
|
814
|
+
return false;
|
|
636
815
|
}
|
|
816
|
+
state.eventCount += 1;
|
|
817
|
+
state.byteCount += byteCount;
|
|
818
|
+
state.dirty = true;
|
|
637
819
|
return true;
|
|
638
820
|
}
|
|
639
|
-
async function
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
821
|
+
async function flushSessionRateLimits(ctx, states, now) {
|
|
822
|
+
const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
823
|
+
for (const state of states.values()) {
|
|
824
|
+
if (!state.dirty)
|
|
825
|
+
continue;
|
|
826
|
+
if (state.window) {
|
|
827
|
+
await ctx.db.patch("ingestWindows", state.window._id, {
|
|
828
|
+
windowStart,
|
|
829
|
+
eventCount: state.eventCount,
|
|
830
|
+
byteCount: state.byteCount,
|
|
831
|
+
updatedAt: now,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
await ctx.db.insert("ingestWindows", {
|
|
836
|
+
siteId: state.siteId,
|
|
837
|
+
sessionId: state.sessionId,
|
|
838
|
+
windowStart,
|
|
839
|
+
eventCount: state.eventCount,
|
|
840
|
+
byteCount: state.byteCount,
|
|
841
|
+
updatedAt: now,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
async function updateLiveSession(ctx, siteId, event, context, session, now, batchState) {
|
|
847
|
+
await ensureLiveSweep(ctx, siteId, now, batchState);
|
|
848
|
+
const liveState = batchState
|
|
849
|
+
? await loadLiveSession(ctx, siteId, event.sessionId, batchState)
|
|
850
|
+
: undefined;
|
|
851
|
+
const existing = liveState
|
|
852
|
+
? liveState.current
|
|
853
|
+
: await ctx.db
|
|
854
|
+
.query("liveSessions")
|
|
855
|
+
.withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", event.sessionId))
|
|
856
|
+
.unique();
|
|
857
|
+
const deadline = now + LIVE_SESSION_TTL_MS;
|
|
651
858
|
if (existing) {
|
|
652
|
-
|
|
859
|
+
const recentHeartbeatEventIds = event.type === "heartbeat"
|
|
860
|
+
? [
|
|
861
|
+
...(existing.recentHeartbeatEventIds ??
|
|
862
|
+
(existing.lastHeartbeatEventId
|
|
863
|
+
? [existing.lastHeartbeatEventId]
|
|
864
|
+
: [])),
|
|
865
|
+
event.eventId,
|
|
866
|
+
].slice(-8)
|
|
867
|
+
: undefined;
|
|
868
|
+
const updates = {
|
|
653
869
|
visitorId: event.visitorId,
|
|
654
870
|
path: event.path,
|
|
655
871
|
lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
|
|
@@ -665,12 +881,22 @@ async function updateLiveSession(ctx, siteId, event, context, session, now) {
|
|
|
665
881
|
pageviewCount: session.pageviewCount,
|
|
666
882
|
eventCount: session.eventCount,
|
|
667
883
|
...(event.type === "heartbeat"
|
|
668
|
-
? {
|
|
884
|
+
? {
|
|
885
|
+
lastHeartbeatEventId: event.eventId,
|
|
886
|
+
recentHeartbeatEventIds,
|
|
887
|
+
}
|
|
669
888
|
: {}),
|
|
670
|
-
}
|
|
889
|
+
};
|
|
890
|
+
if (liveState) {
|
|
891
|
+
liveState.current = { ...existing, ...updates };
|
|
892
|
+
liveState.dirty = true;
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
await ctx.db.patch("liveSessions", existing._id, updates);
|
|
896
|
+
}
|
|
671
897
|
return;
|
|
672
898
|
}
|
|
673
|
-
const
|
|
899
|
+
const fields = {
|
|
674
900
|
siteId,
|
|
675
901
|
sessionId: event.sessionId,
|
|
676
902
|
visitorId: event.visitorId,
|
|
@@ -678,7 +904,6 @@ async function updateLiveSession(ctx, siteId, event, context, session, now) {
|
|
|
678
904
|
startedAt: event.timestamp,
|
|
679
905
|
lastSeenAt: event.timestamp,
|
|
680
906
|
expiresAt: deadline,
|
|
681
|
-
expiryChainDeadline: deadline,
|
|
682
907
|
...(context.country ? { country: context.country } : {}),
|
|
683
908
|
...(context.city ? { city: context.city } : {}),
|
|
684
909
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
@@ -690,13 +915,37 @@ async function updateLiveSession(ctx, siteId, event, context, session, now) {
|
|
|
690
915
|
pageviewCount: session.pageviewCount,
|
|
691
916
|
eventCount: session.eventCount,
|
|
692
917
|
...(event.type === "heartbeat"
|
|
693
|
-
? {
|
|
918
|
+
? {
|
|
919
|
+
lastHeartbeatEventId: event.eventId,
|
|
920
|
+
recentHeartbeatEventIds: [event.eventId],
|
|
921
|
+
}
|
|
694
922
|
: {}),
|
|
695
|
-
}
|
|
696
|
-
await ctx.
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
923
|
+
};
|
|
924
|
+
const liveSessionId = await ctx.db.insert("liveSessions", fields);
|
|
925
|
+
if (liveState) {
|
|
926
|
+
liveState.current = {
|
|
927
|
+
...fields,
|
|
928
|
+
_id: liveSessionId,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
async function ensureLiveSweep(ctx, siteId, now, batchState) {
|
|
933
|
+
const key = String(siteId);
|
|
934
|
+
if (batchState?.liveSweepSites.has(key))
|
|
935
|
+
return;
|
|
936
|
+
const existing = await ctx.db
|
|
937
|
+
.query("liveSweepStates")
|
|
938
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
939
|
+
.unique();
|
|
940
|
+
if (!existing) {
|
|
941
|
+
const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
|
|
942
|
+
await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
|
|
943
|
+
await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
|
|
944
|
+
siteId,
|
|
945
|
+
scheduledAt,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
batchState?.liveSweepSites.add(key);
|
|
700
949
|
}
|
|
701
950
|
function goalMatches(goal, event) {
|
|
702
951
|
if (goal.matchType === "pageview") {
|
|
@@ -704,19 +953,34 @@ function goalMatches(goal, event) {
|
|
|
704
953
|
}
|
|
705
954
|
return event.type === "custom" && event.name === goal.matchValue;
|
|
706
955
|
}
|
|
707
|
-
async function advanceFunnel(ctx, siteId, funnel, event) {
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
.
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
956
|
+
async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
|
|
957
|
+
const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
|
|
958
|
+
let progressState = batchState?.funnelProgress.get(cacheKey);
|
|
959
|
+
if (batchState && !progressState) {
|
|
960
|
+
const current = await ctx.db
|
|
961
|
+
.query("funnelProgress")
|
|
962
|
+
.withIndex("by_siteId_and_funnelId_and_visitorId", (range) => range
|
|
963
|
+
.eq("siteId", siteId)
|
|
964
|
+
.eq("funnelId", funnel._id)
|
|
965
|
+
.eq("visitorId", event.visitorId))
|
|
966
|
+
.unique();
|
|
967
|
+
progressState = { current, dirty: false };
|
|
968
|
+
batchState.funnelProgress.set(cacheKey, progressState);
|
|
969
|
+
}
|
|
970
|
+
const progress = progressState
|
|
971
|
+
? progressState.current
|
|
972
|
+
: await ctx.db
|
|
973
|
+
.query("funnelProgress")
|
|
974
|
+
.withIndex("by_siteId_and_funnelId_and_visitorId", (range) => range
|
|
975
|
+
.eq("siteId", siteId)
|
|
976
|
+
.eq("funnelId", funnel._id)
|
|
977
|
+
.eq("visitorId", event.visitorId))
|
|
978
|
+
.unique();
|
|
715
979
|
const matchesFirst = stepMatches(funnel.steps[0], event);
|
|
716
980
|
if (!progress) {
|
|
717
981
|
if (!matchesFirst)
|
|
718
982
|
return;
|
|
719
|
-
|
|
983
|
+
const fields = {
|
|
720
984
|
siteId,
|
|
721
985
|
funnelId: funnel._id,
|
|
722
986
|
visitorId: event.visitorId,
|
|
@@ -724,7 +988,14 @@ async function advanceFunnel(ctx, siteId, funnel, event) {
|
|
|
724
988
|
currentStep: 1,
|
|
725
989
|
startedAt: event.timestamp,
|
|
726
990
|
lastStepAt: event.timestamp,
|
|
727
|
-
}
|
|
991
|
+
};
|
|
992
|
+
const progressId = await ctx.db.insert("funnelProgress", fields);
|
|
993
|
+
if (progressState) {
|
|
994
|
+
progressState.current = {
|
|
995
|
+
...fields,
|
|
996
|
+
_id: progressId,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
728
999
|
await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
|
|
729
1000
|
return;
|
|
730
1001
|
}
|
|
@@ -733,13 +1004,27 @@ async function advanceFunnel(ctx, siteId, funnel, event) {
|
|
|
733
1004
|
if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
|
|
734
1005
|
if (!matchesFirst)
|
|
735
1006
|
return;
|
|
736
|
-
|
|
1007
|
+
const updated = {
|
|
1008
|
+
...progress,
|
|
737
1009
|
sessionId: event.sessionId,
|
|
738
1010
|
currentStep: 1,
|
|
739
1011
|
startedAt: event.timestamp,
|
|
740
1012
|
lastStepAt: event.timestamp,
|
|
741
|
-
|
|
742
|
-
|
|
1013
|
+
};
|
|
1014
|
+
delete updated.completedAt;
|
|
1015
|
+
if (progressState) {
|
|
1016
|
+
progressState.current = updated;
|
|
1017
|
+
progressState.dirty = true;
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
await ctx.db.patch("funnelProgress", progress._id, {
|
|
1021
|
+
sessionId: event.sessionId,
|
|
1022
|
+
currentStep: 1,
|
|
1023
|
+
startedAt: event.timestamp,
|
|
1024
|
+
lastStepAt: event.timestamp,
|
|
1025
|
+
completedAt: undefined,
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
743
1028
|
await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
|
|
744
1029
|
return;
|
|
745
1030
|
}
|
|
@@ -748,12 +1033,19 @@ async function advanceFunnel(ctx, siteId, funnel, event) {
|
|
|
748
1033
|
return;
|
|
749
1034
|
const currentStep = progress.currentStep + 1;
|
|
750
1035
|
const completed = currentStep === funnel.steps.length;
|
|
751
|
-
|
|
1036
|
+
const updates = {
|
|
752
1037
|
sessionId: event.sessionId,
|
|
753
1038
|
currentStep,
|
|
754
1039
|
lastStepAt: event.timestamp,
|
|
755
1040
|
...(completed ? { completedAt: event.timestamp } : {}),
|
|
756
|
-
}
|
|
1041
|
+
};
|
|
1042
|
+
if (progressState) {
|
|
1043
|
+
progressState.current = { ...progress, ...updates };
|
|
1044
|
+
progressState.dirty = true;
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
await ctx.db.patch("funnelProgress", progress._id, updates);
|
|
1048
|
+
}
|
|
757
1049
|
await incrementFunnelStep(ctx, siteId, funnel._id, event, progress.currentStep, completed);
|
|
758
1050
|
}
|
|
759
1051
|
function stepMatches(step, event) {
|
|
@@ -763,7 +1055,7 @@ function stepMatches(step, event) {
|
|
|
763
1055
|
}
|
|
764
1056
|
async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed) {
|
|
765
1057
|
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
766
|
-
const shard = stableHash(event.visitorId) %
|
|
1058
|
+
const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
|
|
767
1059
|
const bucket = await ctx.db
|
|
768
1060
|
.query("funnelStepBuckets")
|
|
769
1061
|
.withIndex("by_siteId_and_funnelId_and_bucketStart_and_step_and_shard", (range) => range
|
|
@@ -821,7 +1113,7 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
|
|
|
821
1113
|
});
|
|
822
1114
|
if (commissionAffiliate && attribution) {
|
|
823
1115
|
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
824
|
-
const shard = stableHash(event.sessionId) %
|
|
1116
|
+
const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
|
|
825
1117
|
const stats = await ctx.db
|
|
826
1118
|
.query("affiliateDailyStats")
|
|
827
1119
|
.withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) => range
|
|
@@ -852,7 +1144,6 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
|
|
|
852
1144
|
}
|
|
853
1145
|
}
|
|
854
1146
|
function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion) {
|
|
855
|
-
const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
|
|
856
1147
|
const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
|
|
857
1148
|
const dimensionDeltas = dimensions.map((dimension) => ({
|
|
858
1149
|
...dimension,
|
|
@@ -860,6 +1151,8 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
|
|
|
860
1151
|
revenueCents,
|
|
861
1152
|
}));
|
|
862
1153
|
for (const granularity of ["hour", "day"]) {
|
|
1154
|
+
const shard = stableHash(event.sessionId) %
|
|
1155
|
+
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
863
1156
|
const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
|
|
864
1157
|
const bucketStart = Math.floor(event.timestamp / interval) * interval;
|
|
865
1158
|
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
@@ -955,7 +1248,7 @@ function mergeDimensions(current, additions) {
|
|
|
955
1248
|
continue;
|
|
956
1249
|
}
|
|
957
1250
|
const typeSlots = result.filter((candidate) => candidate.type === addition.type);
|
|
958
|
-
if (typeSlots.length < DIMENSION_SLOTS) {
|
|
1251
|
+
if (typeSlots.length < DIMENSION_SLOTS - 1) {
|
|
959
1252
|
const occupied = Array.from({ length: DIMENSION_SLOTS }, () => false);
|
|
960
1253
|
for (const candidate of typeSlots)
|
|
961
1254
|
occupied[candidate.slot] = true;
|
|
@@ -971,16 +1264,36 @@ function mergeDimensions(current, additions) {
|
|
|
971
1264
|
});
|
|
972
1265
|
continue;
|
|
973
1266
|
}
|
|
974
|
-
|
|
1267
|
+
const overflow = typeSlots.find((candidate) => candidate.value === "(other)");
|
|
1268
|
+
if (overflow) {
|
|
1269
|
+
overflow.count += addition.count;
|
|
1270
|
+
overflow.revenueCents += addition.revenueCents;
|
|
975
1271
|
continue;
|
|
1272
|
+
}
|
|
1273
|
+
if (addition.count === 0 && addition.revenueCents === 0)
|
|
1274
|
+
continue;
|
|
1275
|
+
if (typeSlots.length < DIMENSION_SLOTS) {
|
|
1276
|
+
const occupied = new Set(typeSlots.map((candidate) => candidate.slot));
|
|
1277
|
+
let slot = stableHash("(other)") % DIMENSION_SLOTS;
|
|
1278
|
+
while (occupied.has(slot))
|
|
1279
|
+
slot = (slot + 1) % DIMENSION_SLOTS;
|
|
1280
|
+
result.push({
|
|
1281
|
+
type: addition.type,
|
|
1282
|
+
slot,
|
|
1283
|
+
value: "(other)",
|
|
1284
|
+
count: addition.count,
|
|
1285
|
+
revenueCents: addition.revenueCents,
|
|
1286
|
+
});
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
976
1289
|
let replacement = typeSlots[0];
|
|
977
1290
|
for (const candidate of typeSlots.slice(1)) {
|
|
978
1291
|
if (candidate.count < replacement.count)
|
|
979
1292
|
replacement = candidate;
|
|
980
1293
|
}
|
|
981
|
-
replacement.value =
|
|
1294
|
+
replacement.value = "(other)";
|
|
982
1295
|
replacement.count += addition.count;
|
|
983
|
-
replacement.revenueCents
|
|
1296
|
+
replacement.revenueCents += addition.revenueCents;
|
|
984
1297
|
}
|
|
985
1298
|
return result;
|
|
986
1299
|
}
|