@iann29/rastro 0.1.0-alpha.0 → 0.1.0-alpha.10

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.
Files changed (164) hide show
  1. package/README.md +579 -57
  2. package/agent/integration.md +661 -0
  3. package/agent/manifest.json +187 -0
  4. package/agent/manifest.schema.json +411 -0
  5. package/dist/client/federation.d.ts +236 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +196 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +2467 -18
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +461 -32
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +18 -0
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +302 -4
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +4 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/affiliates.d.ts.map +1 -1
  22. package/dist/component/affiliates.js +6 -2
  23. package/dist/component/affiliates.js.map +1 -1
  24. package/dist/component/cardinality.d.ts +12 -0
  25. package/dist/component/cardinality.d.ts.map +1 -0
  26. package/dist/component/cardinality.js +94 -0
  27. package/dist/component/cardinality.js.map +1 -0
  28. package/dist/component/constants.d.ts +34 -7
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +47 -7
  31. package/dist/component/constants.js.map +1 -1
  32. package/dist/component/convex.config.d.ts +6 -1
  33. package/dist/component/convex.config.js +9 -1
  34. package/dist/component/convex.config.js.map +1 -1
  35. package/dist/component/coverage.d.ts +19 -0
  36. package/dist/component/coverage.d.ts.map +1 -0
  37. package/dist/component/coverage.js +30 -0
  38. package/dist/component/coverage.js.map +1 -0
  39. package/dist/component/diagnostics.d.ts +9 -0
  40. package/dist/component/diagnostics.d.ts.map +1 -0
  41. package/dist/component/diagnostics.js +47 -0
  42. package/dist/component/diagnostics.js.map +1 -0
  43. package/dist/component/errors.d.ts +1 -1
  44. package/dist/component/errors.d.ts.map +1 -1
  45. package/dist/component/errors.js.map +1 -1
  46. package/dist/component/eventStore.d.ts +106 -0
  47. package/dist/component/eventStore.d.ts.map +1 -0
  48. package/dist/component/eventStore.js +294 -0
  49. package/dist/component/eventStore.js.map +1 -0
  50. package/dist/component/funnels.d.ts.map +1 -1
  51. package/dist/component/funnels.js +5 -3
  52. package/dist/component/funnels.js.map +1 -1
  53. package/dist/component/geo.d.ts +71 -0
  54. package/dist/component/geo.d.ts.map +1 -0
  55. package/dist/component/geo.js +611 -0
  56. package/dist/component/geo.js.map +1 -0
  57. package/dist/component/goals.d.ts.map +1 -1
  58. package/dist/component/goals.js +25 -9
  59. package/dist/component/goals.js.map +1 -1
  60. package/dist/component/guards.d.ts.map +1 -1
  61. package/dist/component/guards.js.map +1 -1
  62. package/dist/component/http.d.ts.map +1 -1
  63. package/dist/component/http.js +284 -53
  64. package/dist/component/http.js.map +1 -1
  65. package/dist/component/identity.d.ts +13 -0
  66. package/dist/component/identity.d.ts.map +1 -0
  67. package/dist/component/identity.js +58 -0
  68. package/dist/component/identity.js.map +1 -0
  69. package/dist/component/ingest.d.ts +3 -1
  70. package/dist/component/ingest.d.ts.map +1 -1
  71. package/dist/component/ingest.js +1105 -243
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts +8 -0
  74. package/dist/component/live.d.ts.map +1 -1
  75. package/dist/component/live.js +74 -9
  76. package/dist/component/live.js.map +1 -1
  77. package/dist/component/reports.d.ts +273 -24
  78. package/dist/component/reports.d.ts.map +1 -1
  79. package/dist/component/reports.js +1320 -150
  80. package/dist/component/reports.js.map +1 -1
  81. package/dist/component/retention.d.ts +75 -1
  82. package/dist/component/retention.d.ts.map +1 -1
  83. package/dist/component/retention.js +541 -31
  84. package/dist/component/retention.js.map +1 -1
  85. package/dist/component/sanitize.d.ts +24 -1
  86. package/dist/component/sanitize.d.ts.map +1 -1
  87. package/dist/component/sanitize.js +107 -19
  88. package/dist/component/sanitize.js.map +1 -1
  89. package/dist/component/schema.d.ts +462 -55
  90. package/dist/component/schema.js +195 -15
  91. package/dist/component/schema.js.map +1 -1
  92. package/dist/component/sites.d.ts.map +1 -1
  93. package/dist/component/sites.js +38 -25
  94. package/dist/component/sites.js.map +1 -1
  95. package/dist/component/useragent.d.ts +9 -0
  96. package/dist/component/useragent.d.ts.map +1 -0
  97. package/dist/component/useragent.js +152 -0
  98. package/dist/component/useragent.js.map +1 -0
  99. package/dist/component/validators.d.ts +101 -16
  100. package/dist/component/validators.d.ts.map +1 -1
  101. package/dist/component/validators.js +19 -2
  102. package/dist/component/validators.js.map +1 -1
  103. package/dist/component/visitors.d.ts +19 -0
  104. package/dist/component/visitors.d.ts.map +1 -0
  105. package/dist/component/visitors.js +86 -0
  106. package/dist/component/visitors.js.map +1 -0
  107. package/dist/component/vitals.d.ts +41 -0
  108. package/dist/component/vitals.d.ts.map +1 -0
  109. package/dist/component/vitals.js +115 -0
  110. package/dist/component/vitals.js.map +1 -0
  111. package/dist/react/index.d.ts.map +1 -1
  112. package/dist/react/index.js.map +1 -1
  113. package/dist/tracker/generated.d.ts +8 -4
  114. package/dist/tracker/generated.d.ts.map +1 -1
  115. package/dist/tracker/generated.js +8 -4
  116. package/dist/tracker/generated.js.map +1 -1
  117. package/dist/tracker/tracker.d.ts +1 -1
  118. package/dist/tracker/tracker.d.ts.map +1 -1
  119. package/dist/tracker/tracker.js +81 -33
  120. package/dist/tracker/tracker.js.map +1 -1
  121. package/dist/tracker/vitals.d.ts +10 -0
  122. package/dist/tracker/vitals.d.ts.map +1 -0
  123. package/dist/tracker/vitals.js +140 -0
  124. package/dist/tracker/vitals.js.map +1 -0
  125. package/dist/tracker.min.js +1 -1
  126. package/dist/vitals.min.js +1 -0
  127. package/docs/benchmarks/2026-08-20-realistic.md +171 -0
  128. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  129. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  130. package/docs/federation-setup.md +397 -0
  131. package/docs/federation.md +262 -0
  132. package/docs/upgrading.md +259 -0
  133. package/llms.txt +71 -0
  134. package/package.json +61 -11
  135. package/scripts/benchmark-ingest.mjs +601 -0
  136. package/scripts/generate-federation-keys.mjs +20 -0
  137. package/src/component/_generated/api.ts +18 -0
  138. package/src/component/_generated/component.ts +366 -4
  139. package/src/component/_generated/server.ts +4 -0
  140. package/src/component/affiliates.ts +20 -5
  141. package/src/component/cardinality.ts +117 -0
  142. package/src/component/constants.ts +47 -7
  143. package/src/component/convex.config.ts +11 -1
  144. package/src/component/coverage.ts +36 -0
  145. package/src/component/diagnostics.ts +65 -0
  146. package/src/component/errors.ts +2 -1
  147. package/src/component/eventStore.ts +489 -0
  148. package/src/component/funnels.ts +19 -16
  149. package/src/component/geo.ts +781 -0
  150. package/src/component/goals.ts +45 -16
  151. package/src/component/guards.ts +3 -1
  152. package/src/component/http.ts +403 -60
  153. package/src/component/identity.ts +74 -0
  154. package/src/component/ingest.ts +1652 -291
  155. package/src/component/live.ts +90 -9
  156. package/src/component/reports.ts +1929 -199
  157. package/src/component/retention.ts +774 -97
  158. package/src/component/sanitize.ts +154 -32
  159. package/src/component/schema.ts +219 -15
  160. package/src/component/sites.ts +56 -28
  161. package/src/component/useragent.ts +171 -0
  162. package/src/component/validators.ts +29 -1
  163. package/src/component/visitors.ts +116 -0
  164. package/src/component/vitals.ts +146 -0
@@ -5,25 +5,63 @@ 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,
13
+ LIVE_LEAVE_GRACE_MS,
11
14
  LIVE_SESSION_TTL_MS,
15
+ LIVE_SWEEP_INTERVAL_MS,
12
16
  MAX_BATCH_EVENTS,
13
17
  MAX_SITE_BYTES_PER_SHARD_WINDOW,
14
18
  MAX_SITE_EVENTS_PER_SHARD_WINDOW,
15
19
  MAX_BYTES_PER_SESSION_WINDOW,
16
20
  MAX_EVENTS_PER_SESSION_WINDOW,
17
21
  MAX_FUNNELS_PER_SITE,
22
+ MAX_FUNNEL_STEPS,
18
23
  MAX_GOALS_PER_SITE,
24
+ MAX_SITE_MAP_ROUTES_PER_DAY,
19
25
  RATE_LIMIT_WINDOW_MS,
26
+ REPORT_ROLLUP_SHARDS,
27
+ ROUTE_TRANSITION_SLOTS,
28
+ SECONDARY_AGGREGATE_SHARDS,
20
29
  SITE_INGEST_SHARDS,
30
+ SITE_MAP_OTHER_ROUTE,
31
+ VITAL_PAGE_SLOTS,
21
32
  } from "./constants.js";
33
+ import {
34
+ addVisitor,
35
+ createVisitorSketch,
36
+ unionVisitorSketches,
37
+ type VisitorSketch,
38
+ } from "./cardinality.js";
39
+ import {
40
+ addToVitalHistogram,
41
+ emptyVitalHistogram,
42
+ mergeVitalHistograms,
43
+ vitalDevice,
44
+ VITAL_ALL,
45
+ VITAL_OTHER_PAGES,
46
+ type VitalMetric,
47
+ } from "./vitals.js";
22
48
  import { fail } from "./errors.js";
49
+ import { ensureAnalyticsControl } from "./coverage.js";
50
+ import {
51
+ bufferEventBatch,
52
+ eventBatchKey,
53
+ eventBucketStart,
54
+ type EventBatchBuffer,
55
+ findTelemetry,
56
+ flushEventBatches,
57
+ insertEventBatch,
58
+ inspectTelemetryMinute,
59
+ } from "./eventStore.js";
23
60
  import {
24
61
  cleanString,
25
- ensureBatchBytes,
26
62
  isValidMoney,
63
+ measureBatchBytes,
64
+ normalizeRoute,
27
65
  originAllowed,
28
66
  sanitizeContext,
29
67
  sanitizeCurrency,
@@ -46,6 +84,7 @@ import {
46
84
 
47
85
  type DimensionType =
48
86
  | "source"
87
+ | "campaign"
49
88
  | "page"
50
89
  | "country"
51
90
  | "device"
@@ -67,11 +106,78 @@ type AggregateDelta = {
67
106
  outboundClicks: number;
68
107
  sessions: number;
69
108
  visitors: number;
109
+ visitorSketch: VisitorSketch;
70
110
  conversions: number;
71
111
  revenueCents: number;
72
112
  dimensions: Doc<"aggregateBuckets">["dimensions"];
73
113
  };
74
114
  type AggregateDeltas = Map<string, AggregateDelta>;
115
+ type GoalRollupDelta = {
116
+ siteId: Id<"sites">;
117
+ goalId: Id<"goals">;
118
+ bucketStart: number;
119
+ shard: number;
120
+ goalKey: string;
121
+ completions: number;
122
+ valueCents: number;
123
+ };
124
+ type FunnelRollupDelta = {
125
+ siteId: Id<"sites">;
126
+ funnelId: Id<"funnels">;
127
+ bucketStart: number;
128
+ shard: number;
129
+ steps: Map<
130
+ number,
131
+ {
132
+ step: number;
133
+ entrants: number;
134
+ completions: number;
135
+ }
136
+ >;
137
+ };
138
+ type AffiliateRollupDelta = {
139
+ siteId: Id<"sites">;
140
+ affiliateId: Id<"affiliates">;
141
+ bucketStart: number;
142
+ shard: number;
143
+ affiliateSlug: string;
144
+ visits: number;
145
+ visitorSketch: VisitorSketch;
146
+ conversions: number;
147
+ revenueCents: number;
148
+ commissionCents: number;
149
+ };
150
+ type VitalRollupDelta = {
151
+ siteId: Id<"sites">;
152
+ bucketStart: number;
153
+ device: string;
154
+ page: string;
155
+ shard: number;
156
+ metrics: Map<
157
+ VitalMetric,
158
+ { count: number; sum: number; histogram: number[] }
159
+ >;
160
+ };
161
+ type RouteRollupDelta = {
162
+ siteId: Id<"sites">;
163
+ bucketStart: number;
164
+ shard: number;
165
+ route: string;
166
+ pageviews: number;
167
+ entries: number;
168
+ continued: number;
169
+ dwellMs: number;
170
+ dwellSamples: number;
171
+ visitorSketch: VisitorSketch;
172
+ outgoing: Map<string, number>;
173
+ };
174
+ type ReportRollupDeltas = {
175
+ goals: Map<string, GoalRollupDelta>;
176
+ funnels: Map<string, FunnelRollupDelta>;
177
+ affiliates: Map<string, AffiliateRollupDelta>;
178
+ vitals: Map<string, VitalRollupDelta>;
179
+ routes: Map<string, RouteRollupDelta>;
180
+ };
75
181
  type AttributionSnapshot = {
76
182
  affiliateId: Id<"affiliates">;
77
183
  affiliateSlug: string;
@@ -85,6 +191,12 @@ type SessionState = {
85
191
  isNewVisitorDay: boolean;
86
192
  startedAt: number;
87
193
  source: string;
194
+ visitorKey: string;
195
+ utmCampaign?: string;
196
+ country?: string;
197
+ city?: string;
198
+ latitude?: number;
199
+ longitude?: number;
88
200
  pageviewCount: number;
89
201
  eventCount: number;
90
202
  affiliateId?: Id<"affiliates">;
@@ -93,6 +205,36 @@ type SessionState = {
93
205
  affiliateExpiresAt?: number;
94
206
  };
95
207
 
208
+ type SessionRateWindowState = {
209
+ siteId: Id<"sites">;
210
+ sessionId: string;
211
+ window: Doc<"ingestWindows"> | null;
212
+ eventCount: number;
213
+ byteCount: number;
214
+ dirty: boolean;
215
+ };
216
+
217
+ type CachedRow<T> = {
218
+ current: T | null;
219
+ dirty: boolean;
220
+ };
221
+
222
+ type CachedDocument<
223
+ TableName extends keyof import("./_generated/dataModel.js").DataModel,
224
+ > = Omit<Doc<TableName>, "_creationTime"> & { _creationTime?: number };
225
+
226
+ type BatchState = {
227
+ sessions: Map<string, CachedRow<CachedDocument<"sessions">>>;
228
+ liveSessions: Map<string, CachedRow<CachedDocument<"liveSessions">>>;
229
+ visitorAttributions: Map<
230
+ string,
231
+ CachedRow<CachedDocument<"visitorAttributions">>
232
+ >;
233
+ funnelProgress: Map<string, CachedRow<CachedDocument<"funnelProgress">>>;
234
+ eventBatches: EventBatchBuffer;
235
+ liveSweepSites: Set<string>;
236
+ };
237
+
96
238
  export const ingestBatch = mutation({
97
239
  args: {
98
240
  siteId: v.id("sites"),
@@ -110,46 +252,135 @@ export const ingestBatch = mutation({
110
252
  });
111
253
  }
112
254
  if (args.events.length < 1 || args.events.length > MAX_BATCH_EVENTS) {
113
- fail("BATCH_TOO_LARGE", `events must contain 1-${MAX_BATCH_EVENTS} items`, {
114
- limit: MAX_BATCH_EVENTS,
115
- });
255
+ fail(
256
+ "BATCH_TOO_LARGE",
257
+ `events must contain 1-${MAX_BATCH_EVENTS} items`,
258
+ {
259
+ limit: MAX_BATCH_EVENTS,
260
+ },
261
+ );
116
262
  }
263
+ let eventByteCounts: number[];
117
264
  try {
118
- ensureBatchBytes({ siteId: String(args.siteId), events: args.events });
265
+ ({ eventByteCounts } = measureBatchBytes(
266
+ String(args.siteId),
267
+ args.events,
268
+ ));
119
269
  } catch {
120
270
  fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
121
271
  }
122
272
 
123
273
  const now = Date.now();
124
- const eventByteCounts = await consumeSiteRateLimit(
274
+ await ensureAnalyticsControl(ctx, args.siteId, now);
275
+ await consumeSiteRateLimit(
125
276
  ctx,
126
277
  args.siteId,
127
278
  args.events,
279
+ eventByteCounts,
128
280
  now,
129
281
  );
130
282
  const context = sanitizeContext(args.context);
131
283
  const aggregateDeltas: AggregateDeltas = new Map();
284
+ const reportRollupDeltas = createReportRollupDeltas();
285
+ const sessionRateWindows = new Map<string, SessionRateWindowState>();
286
+ const batchState: BatchState = {
287
+ sessions: new Map(),
288
+ liveSessions: new Map(),
289
+ visitorAttributions: new Map(),
290
+ funnelProgress: new Map(),
291
+ eventBatches: new Map(),
292
+ liveSweepSites: new Set(),
293
+ };
132
294
  let goals: Doc<"goals">[] | null = null;
133
295
  let funnels: Doc<"funnels">[] | null = null;
134
296
  let accepted = 0;
135
297
  let duplicates = 0;
136
298
  let rejected = 0;
137
- for (let eventIndex = 0; eventIndex < args.events.length; eventIndex += 1) {
138
- const input = args.events[eventIndex];
139
- let event: TrackerEvent;
299
+ const events = args.events.map((input) => {
140
300
  try {
141
- event = sanitizeEvent(input, now);
301
+ return sanitizeEvent(input, now);
142
302
  } catch {
303
+ return null;
304
+ }
305
+ });
306
+ const duplicateInBatch = new Set<number>();
307
+ const eventIdsByGroup = new Map<string, Set<string>>();
308
+ const requestGroups = new Set<string>();
309
+ const dedupeGroups = new Map<
310
+ string,
311
+ { sessionId: string; bucketStart: number; eventIds: Set<string> }
312
+ >();
313
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
314
+ const event = events[eventIndex];
315
+ if (!event) continue;
316
+ const bucketStart = eventBucketStart(event.timestamp);
317
+ const groupKey = eventBatchKey(event.sessionId, bucketStart);
318
+ requestGroups.add(groupKey);
319
+ if (
320
+ event.type === "heartbeat" ||
321
+ event.type === "vital" ||
322
+ event.type === "leave"
323
+ )
324
+ continue;
325
+ const eventIds = eventIdsByGroup.get(groupKey) ?? new Set<string>();
326
+ if (eventIds.has(event.eventId)) {
327
+ duplicateInBatch.add(eventIndex);
328
+ continue;
329
+ }
330
+ eventIds.add(event.eventId);
331
+ eventIdsByGroup.set(groupKey, eventIds);
332
+ const group = dedupeGroups.get(groupKey) ?? {
333
+ sessionId: event.sessionId,
334
+ bucketStart,
335
+ eventIds: new Set<string>(),
336
+ };
337
+ group.eventIds.add(event.eventId);
338
+ dedupeGroups.set(groupKey, group);
339
+ }
340
+ if (requestGroups.size > MAX_BATCH_EVENT_GROUPS) {
341
+ fail(
342
+ "BATCH_TOO_LARGE",
343
+ `events may span at most ${MAX_BATCH_EVENT_GROUPS} session-minute groups`,
344
+ { limit: MAX_BATCH_EVENT_GROUPS },
345
+ );
346
+ }
347
+ const persistedEventIds = new Map<string, Set<string>>();
348
+ await Promise.all(
349
+ [...dedupeGroups].map(async ([key, group]) => {
350
+ const { eventIds, persistedCount } = await inspectTelemetryMinute(ctx, {
351
+ siteId: args.siteId,
352
+ sessionId: group.sessionId,
353
+ bucketStart: group.bucketStart,
354
+ });
355
+ const newCount = [...group.eventIds].filter(
356
+ (eventId) => !eventIds.has(eventId),
357
+ ).length;
358
+ if (
359
+ newCount > 0 &&
360
+ persistedCount + newCount > MAX_EVENTS_PER_SESSION_WINDOW
361
+ ) {
362
+ fail("RATE_LIMITED", "session event minute limit exceeded", {
363
+ limit: MAX_EVENTS_PER_SESSION_WINDOW,
364
+ });
365
+ }
366
+ persistedEventIds.set(key, eventIds);
367
+ }),
368
+ );
369
+
370
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
371
+ const event = events[eventIndex];
372
+ if (!event) {
143
373
  rejected += 1;
144
374
  continue;
145
375
  }
146
376
 
147
- const existingSession = await ctx.db
148
- .query("sessions")
149
- .withIndex("by_siteId_and_sessionId", (range) =>
150
- range.eq("siteId", args.siteId).eq("sessionId", event.sessionId),
151
- )
152
- .unique();
377
+ const sessionRow = await loadSession(
378
+ ctx,
379
+ args.siteId,
380
+ event.sessionId,
381
+ batchState,
382
+ );
383
+ const existingSession = sessionRow.current;
153
384
  if (existingSession && existingSession.visitorId !== event.visitorId) {
154
385
  rejected += 1;
155
386
  continue;
@@ -160,13 +391,14 @@ export const ingestBatch = mutation({
160
391
  rejected += 1;
161
392
  continue;
162
393
  }
163
- const liveSession = await ctx.db
164
- .query("liveSessions")
165
- .withIndex("by_siteId_and_sessionId", (range) =>
166
- range.eq("siteId", args.siteId).eq("sessionId", event.sessionId),
167
- )
168
- .unique();
169
- if (liveSession?.lastHeartbeatEventId === event.eventId) {
394
+ const liveSession = (
395
+ await loadLiveSession(ctx, args.siteId, event.sessionId, batchState)
396
+ ).current;
397
+ if (
398
+ liveSession &&
399
+ (liveSession.lastHeartbeatEventId === event.eventId ||
400
+ liveSession.recentHeartbeatEventIds?.includes(event.eventId))
401
+ ) {
170
402
  duplicates += 1;
171
403
  continue;
172
404
  }
@@ -177,6 +409,7 @@ export const ingestBatch = mutation({
177
409
  event.sessionId,
178
410
  eventByteCounts[eventIndex],
179
411
  now,
412
+ sessionRateWindows,
180
413
  ))
181
414
  ) {
182
415
  rejected += 1;
@@ -189,18 +422,94 @@ export const ingestBatch = mutation({
189
422
  event,
190
423
  context,
191
424
  now,
425
+ batchState,
192
426
  );
193
427
  accepted += 1;
194
428
  continue;
195
429
  }
196
430
 
197
- const duplicate = await ctx.db
198
- .query("events")
199
- .withIndex("by_siteId_and_eventId", (range) =>
200
- range.eq("siteId", args.siteId).eq("eventId", event.eventId),
201
- )
202
- .unique();
203
- if (duplicate) {
431
+ if (event.type === "leave") {
432
+ // A departure only shortens live presence: it stores no telemetry,
433
+ // touches no aggregates, and never extends anything, so a replayed
434
+ // or late leave is inert. The read side filters by expiresAt and the
435
+ // sweep deletes the row, so the dashboard sees the exit in seconds.
436
+ if (!existingSession) {
437
+ rejected += 1;
438
+ continue;
439
+ }
440
+ if (
441
+ !(await consumeRateLimit(
442
+ ctx,
443
+ args.siteId,
444
+ event.sessionId,
445
+ eventByteCounts[eventIndex],
446
+ now,
447
+ sessionRateWindows,
448
+ ))
449
+ ) {
450
+ rejected += 1;
451
+ continue;
452
+ }
453
+ const liveState = await loadLiveSession(
454
+ ctx,
455
+ args.siteId,
456
+ event.sessionId,
457
+ batchState,
458
+ );
459
+ const liveSession = liveState.current;
460
+ if (liveSession) {
461
+ const expiresAt = Math.min(
462
+ liveSession.expiresAt,
463
+ now + LIVE_LEAVE_GRACE_MS,
464
+ );
465
+ if (expiresAt < liveSession.expiresAt) {
466
+ await ctx.db.patch("liveSessions", liveSession._id, { expiresAt });
467
+ liveState.current = { ...liveSession, expiresAt };
468
+ }
469
+ await ensureLiveSweep(ctx, args.siteId, now, batchState);
470
+ }
471
+ accepted += 1;
472
+ continue;
473
+ }
474
+
475
+ if (event.type === "vital") {
476
+ // A vital annotates telemetry its session already produced; it never
477
+ // creates sessions, live presence, event batches, or aggregate counts.
478
+ if (!existingSession) {
479
+ rejected += 1;
480
+ continue;
481
+ }
482
+ if (
483
+ !(await consumeRateLimit(
484
+ ctx,
485
+ args.siteId,
486
+ event.sessionId,
487
+ eventByteCounts[eventIndex],
488
+ now,
489
+ sessionRateWindows,
490
+ ))
491
+ ) {
492
+ rejected += 1;
493
+ continue;
494
+ }
495
+ foldVitalRollups(
496
+ reportRollupDeltas,
497
+ args.siteId,
498
+ event,
499
+ vitalDevice(context.device),
500
+ );
501
+ accepted += 1;
502
+ continue;
503
+ }
504
+
505
+ if (
506
+ duplicateInBatch.has(eventIndex) ||
507
+ persistedEventIds
508
+ .get(
509
+ eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)),
510
+ )
511
+ ?.has(event.eventId)
512
+ ) {
204
513
  duplicates += 1;
205
514
  continue;
206
515
  }
@@ -212,6 +521,7 @@ export const ingestBatch = mutation({
212
521
  event.sessionId,
213
522
  eventByteCounts[eventIndex],
214
523
  now,
524
+ sessionRateWindows,
215
525
  ))
216
526
  ) {
217
527
  rejected += 1;
@@ -231,22 +541,33 @@ export const ingestBatch = mutation({
231
541
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
232
542
  .take(MAX_FUNNELS_PER_SITE);
233
543
  }
234
- await accountEvent(ctx, {
544
+ const session = await accountEvent(ctx, {
235
545
  siteId: args.siteId,
236
546
  event,
237
547
  context,
238
548
  existingSession,
239
- goals: canMatchDefinition ? goals ?? [] : [],
240
- funnels: canMatchDefinition ? funnels ?? [] : [],
549
+ goals: canMatchDefinition ? (goals ?? []) : [],
550
+ funnels: canMatchDefinition ? (funnels ?? []) : [],
241
551
  aggregateDeltas,
552
+ reportRollupDeltas,
242
553
  recordTelemetry: true,
243
554
  financialConversion: false,
244
555
  trustedGoalKey: undefined,
245
556
  now,
557
+ batchState,
246
558
  });
559
+ bufferEventBatch(
560
+ args.siteId,
561
+ event,
562
+ batchState.eventBatches,
563
+ session.country ?? "unknown",
564
+ );
247
565
  accepted += 1;
248
566
  }
567
+ await flushBatchState(ctx, batchState);
568
+ await flushSessionRateLimits(ctx, sessionRateWindows, now);
249
569
  await flushAggregates(ctx, aggregateDeltas);
570
+ await flushReportRollups(ctx, reportRollupDeltas);
250
571
  return { accepted, duplicates, rejected };
251
572
  },
252
573
  });
@@ -258,17 +579,24 @@ export const trackConversion = mutation({
258
579
  const site = await ctx.db.get("sites", args.siteId);
259
580
  if (!site) fail("NOT_FOUND", "site not found");
260
581
  if (!isValidMoney(args.revenueCents)) {
261
- fail("INVALID_ARGUMENT", "revenueCents must be an integer between 0 and 1000000000");
582
+ fail(
583
+ "INVALID_ARGUMENT",
584
+ "revenueCents must be an integer between 0 and 1000000000",
585
+ );
262
586
  }
263
587
  let event: TrackerEvent;
588
+ let trustedGoalKey: string | undefined;
264
589
  try {
590
+ trustedGoalKey = args.goalKey
591
+ ? sanitizeSlug(args.goalKey, "goalKey")
592
+ : undefined;
265
593
  event = sanitizeEvent(
266
594
  {
267
595
  eventId: sanitizeOpaqueId(args.eventId, "eventId"),
268
596
  sessionId: sanitizeOpaqueId(args.sessionId, "sessionId"),
269
597
  visitorId: sanitizeOpaqueId(args.visitorId, "visitorId"),
270
598
  type: "conversion",
271
- name: args.goalKey ? sanitizeSlug(args.goalKey, "goalKey") : "conversion",
599
+ name: trustedGoalKey ?? "conversion",
272
600
  path: sanitizePath(args.path ?? "/conversion"),
273
601
  timestamp: args.timestamp,
274
602
  sequence: 0,
@@ -279,16 +607,13 @@ export const trackConversion = mutation({
279
607
  Date.now(),
280
608
  );
281
609
  } catch (error) {
282
- fail("INVALID_ARGUMENT", error instanceof Error ? error.message : "invalid conversion");
610
+ fail(
611
+ "INVALID_ARGUMENT",
612
+ error instanceof Error ? error.message : "invalid conversion",
613
+ );
283
614
  }
284
615
 
285
616
  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
617
  const priorConversion = await ctx.db
293
618
  .query("conversions")
294
619
  .withIndex("by_siteId_and_eventId", (range) =>
@@ -296,15 +621,27 @@ export const trackConversion = mutation({
296
621
  )
297
622
  .unique();
298
623
  if (priorConversion) {
624
+ if (!sameTrustedConversion(priorConversion, event, trustedGoalKey)) {
625
+ fail(
626
+ "CONFLICT",
627
+ "conversion eventId is already associated with a different payload",
628
+ );
629
+ }
299
630
  return { accepted: 0, duplicates: 1, rejected: 0 };
300
631
  }
632
+ if (event.currency !== siteCurrency) {
633
+ fail(
634
+ "CURRENCY_MISMATCH",
635
+ "conversion currency does not match site currency",
636
+ {
637
+ siteCurrency,
638
+ conversionCurrency: event.currency ?? "USD",
639
+ },
640
+ );
641
+ }
301
642
 
302
- const existingTelemetry = await ctx.db
303
- .query("events")
304
- .withIndex("by_siteId_and_eventId", (range) =>
305
- range.eq("siteId", args.siteId).eq("eventId", event.eventId),
306
- )
307
- .unique();
643
+ const telemetry = await findTelemetry(ctx, args.siteId, event);
644
+ const existingTelemetry = telemetry.match;
308
645
  if (
309
646
  existingTelemetry &&
310
647
  (existingTelemetry.sessionId !== event.sessionId ||
@@ -321,20 +658,19 @@ export const trackConversion = mutation({
321
658
  if (existingSession && existingSession.visitorId !== event.visitorId) {
322
659
  fail("CONFLICT", "session belongs to a different visitor");
323
660
  }
324
- const conversionContext: IngestContext = existingTelemetry
325
- ? {
326
- country: existingTelemetry.country,
327
- city: existingTelemetry.city,
328
- latitude: existingTelemetry.latitude,
329
- longitude: existingTelemetry.longitude,
330
- browser: existingTelemetry.browser,
331
- os: existingTelemetry.os,
332
- device: existingTelemetry.device,
333
- }
334
- : sanitizeContext(undefined);
661
+ const conversionContext: IngestContext = {
662
+ country: existingTelemetry?.country ?? existingSession?.country,
663
+ city: existingTelemetry?.city ?? existingSession?.city,
664
+ latitude: existingTelemetry?.latitude ?? existingSession?.latitude,
665
+ longitude: existingTelemetry?.longitude ?? existingSession?.longitude,
666
+ browser: existingTelemetry?.browser ?? existingSession?.browser,
667
+ os: existingTelemetry?.os ?? existingSession?.os,
668
+ device: existingTelemetry?.device ?? existingSession?.device,
669
+ };
335
670
 
336
671
  const aggregateDeltas: AggregateDeltas = new Map();
337
- await accountEvent(ctx, {
672
+ const reportRollupDeltas = createReportRollupDeltas();
673
+ const session = await accountEvent(ctx, {
338
674
  siteId: args.siteId,
339
675
  event,
340
676
  context: conversionContext,
@@ -342,34 +678,149 @@ export const trackConversion = mutation({
342
678
  goals: [],
343
679
  funnels: [],
344
680
  aggregateDeltas,
345
- recordTelemetry: existingTelemetry === null,
681
+ reportRollupDeltas,
682
+ recordTelemetry: existingTelemetry === null && !telemetry.saturated,
346
683
  financialConversion: true,
347
- trustedGoalKey: args.goalKey
348
- ? sanitizeSlug(args.goalKey, "goalKey")
349
- : undefined,
684
+ trustedGoalKey,
350
685
  now: Date.now(),
351
686
  });
687
+ if (!existingTelemetry && !telemetry.saturated) {
688
+ await insertEventBatch(
689
+ ctx,
690
+ args.siteId,
691
+ event,
692
+ session.country ?? "unknown",
693
+ );
694
+ }
352
695
  await flushAggregates(ctx, aggregateDeltas);
696
+ await flushReportRollups(ctx, reportRollupDeltas);
697
+ await ensureAnalyticsControl(ctx, args.siteId, Date.now());
353
698
  return { accepted: 1, duplicates: 0, rejected: 0 };
354
699
  },
355
700
  });
356
701
 
702
+ function sameTrustedConversion(
703
+ existing: Doc<"conversions">,
704
+ event: TrackerEvent,
705
+ goalKey: string | undefined,
706
+ ): boolean {
707
+ return (
708
+ existing.source === "trusted" &&
709
+ existing.sessionId === event.sessionId &&
710
+ existing.visitorId === event.visitorId &&
711
+ existing.timestamp === event.timestamp &&
712
+ existing.path === event.path &&
713
+ existing.revenueCents === (event.revenueCents ?? 0) &&
714
+ existing.currency === (event.currency ?? "USD") &&
715
+ existing.goalKey === goalKey
716
+ );
717
+ }
718
+
719
+ async function loadSession(
720
+ ctx: MutationCtx,
721
+ siteId: Id<"sites">,
722
+ sessionId: string,
723
+ batchState: BatchState,
724
+ ): Promise<CachedRow<CachedDocument<"sessions">>> {
725
+ const cached = batchState.sessions.get(sessionId);
726
+ if (cached) return cached;
727
+ const current = await ctx.db
728
+ .query("sessions")
729
+ .withIndex("by_siteId_and_sessionId", (range) =>
730
+ range.eq("siteId", siteId).eq("sessionId", sessionId),
731
+ )
732
+ .unique();
733
+ const loaded = { current, dirty: false };
734
+ batchState.sessions.set(sessionId, loaded);
735
+ return loaded;
736
+ }
737
+
738
+ async function loadLiveSession(
739
+ ctx: MutationCtx,
740
+ siteId: Id<"sites">,
741
+ sessionId: string,
742
+ batchState: BatchState,
743
+ ): Promise<CachedRow<CachedDocument<"liveSessions">>> {
744
+ const cached = batchState.liveSessions.get(sessionId);
745
+ if (cached) return cached;
746
+ const current = await ctx.db
747
+ .query("liveSessions")
748
+ .withIndex("by_siteId_and_sessionId", (range) =>
749
+ range.eq("siteId", siteId).eq("sessionId", sessionId),
750
+ )
751
+ .unique();
752
+ const loaded = { current, dirty: false };
753
+ batchState.liveSessions.set(sessionId, loaded);
754
+ return loaded;
755
+ }
756
+
757
+ async function flushBatchState(ctx: MutationCtx, batchState: BatchState) {
758
+ for (const state of batchState.sessions.values()) {
759
+ if (state.dirty && state.current) {
760
+ await ctx.db.replace(
761
+ "sessions",
762
+ state.current._id,
763
+ withoutSystemFields(state.current),
764
+ );
765
+ }
766
+ }
767
+ for (const state of batchState.liveSessions.values()) {
768
+ if (state.dirty && state.current) {
769
+ await ctx.db.replace(
770
+ "liveSessions",
771
+ state.current._id,
772
+ withoutSystemFields(state.current),
773
+ );
774
+ }
775
+ }
776
+ for (const state of batchState.visitorAttributions.values()) {
777
+ if (state.dirty && state.current) {
778
+ await ctx.db.replace(
779
+ "visitorAttributions",
780
+ state.current._id,
781
+ withoutSystemFields(state.current),
782
+ );
783
+ }
784
+ }
785
+ for (const state of batchState.funnelProgress.values()) {
786
+ if (state.dirty && state.current) {
787
+ await ctx.db.replace(
788
+ "funnelProgress",
789
+ state.current._id,
790
+ withoutSystemFields(state.current),
791
+ );
792
+ }
793
+ }
794
+ await flushEventBatches(ctx, batchState.eventBatches);
795
+ }
796
+
797
+ function withoutSystemFields<
798
+ T extends { _id: unknown; _creationTime?: number },
799
+ >(document: T): Omit<T, "_id" | "_creationTime"> {
800
+ const { _id, _creationTime, ...fields } = document;
801
+ void _id;
802
+ void _creationTime;
803
+ return fields;
804
+ }
805
+
357
806
  async function accountEvent(
358
807
  ctx: MutationCtx,
359
808
  input: {
360
809
  siteId: Id<"sites">;
361
810
  event: TrackerEvent;
362
811
  context: IngestContext;
363
- existingSession: Doc<"sessions"> | null;
812
+ existingSession: CachedDocument<"sessions"> | null;
364
813
  goals: Doc<"goals">[];
365
814
  funnels: Doc<"funnels">[];
366
815
  aggregateDeltas: AggregateDeltas;
816
+ reportRollupDeltas: ReportRollupDeltas;
367
817
  recordTelemetry: boolean;
368
818
  financialConversion: boolean;
369
819
  trustedGoalKey: string | undefined;
370
820
  now: number;
821
+ batchState?: BatchState;
371
822
  },
372
- ) {
823
+ ): Promise<SessionState> {
373
824
  const { event } = input;
374
825
  const session = input.recordTelemetry
375
826
  ? await upsertSession(
@@ -379,72 +830,91 @@ async function accountEvent(
379
830
  input.context,
380
831
  input.existingSession,
381
832
  input.financialConversion,
833
+ input.reportRollupDeltas,
834
+ input.batchState,
382
835
  )
383
836
  : await accountTrustedSession(ctx, input.existingSession, event);
384
-
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
- });
837
+ if (input.recordTelemetry && event.type === "pageview") {
838
+ // `existingSession` is the snapshot from before `upsertSession`, so it
839
+ // still carries the route the visitor is leaving.
840
+ foldRouteRollups(
841
+ input.reportRollupDeltas,
842
+ input.siteId,
843
+ event,
844
+ session,
845
+ input.existingSession,
846
+ );
417
847
  }
418
848
 
419
849
  const matchedGoalKeys: string[] = [];
420
- for (const goal of input.goals) {
421
- if (!goal.active || !goalMatches(goal, event)) continue;
422
- const prior = await ctx.db
423
- .query("goalCompletions")
424
- .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
425
- range
426
- .eq("siteId", input.siteId)
427
- .eq("goalId", goal._id)
428
- .eq("eventId", event.eventId),
429
- )
430
- .unique();
431
- if (prior) continue;
850
+ const matchingGoals = input.goals.filter(
851
+ (goal) => goal.active && goalMatches(goal, event),
852
+ );
853
+ if (matchingGoals.length > 1) {
854
+ fail("LIMIT_EXCEEDED", "active goals must use unique matchers");
855
+ }
856
+ for (const goal of matchingGoals) {
857
+ const completionEventId = JSON.stringify([
858
+ event.sessionId,
859
+ eventBucketStart(event.timestamp),
860
+ event.eventId,
861
+ ]);
862
+ const scopedPrior = (
863
+ await ctx.db
864
+ .query("goalCompletions")
865
+ .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
866
+ range
867
+ .eq("siteId", input.siteId)
868
+ .eq("goalId", goal._id)
869
+ .eq("eventId", completionEventId),
870
+ )
871
+ .take(2)
872
+ ).find((completion) => completion.idempotencyVersion === 2);
873
+ const legacyPrior = scopedPrior
874
+ ? undefined
875
+ : (
876
+ await ctx.db
877
+ .query("goalCompletions")
878
+ .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
879
+ range
880
+ .eq("siteId", input.siteId)
881
+ .eq("goalId", goal._id)
882
+ .eq("eventId", event.eventId),
883
+ )
884
+ .take(2)
885
+ ).find(
886
+ (completion) =>
887
+ completion.idempotencyVersion === undefined &&
888
+ completion.sessionId === event.sessionId &&
889
+ eventBucketStart(completion.timestamp) ===
890
+ eventBucketStart(event.timestamp),
891
+ );
892
+ if (scopedPrior || legacyPrior) continue;
432
893
  await ctx.db.insert("goalCompletions", {
433
894
  siteId: input.siteId,
434
895
  goalId: goal._id,
435
896
  goalKey: goal.key,
436
- eventId: event.eventId,
897
+ eventId: completionEventId,
898
+ idempotencyVersion: 2,
437
899
  sessionId: event.sessionId,
438
900
  visitorId: event.visitorId,
439
901
  timestamp: event.timestamp,
440
902
  valueCents: goal.valueCents ?? 0,
441
903
  });
904
+ foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
442
905
  matchedGoalKeys.push(goal.key);
443
906
  }
444
907
 
445
908
  for (const funnel of input.funnels) {
446
909
  if (funnel.active) {
447
- await advanceFunnel(ctx, input.siteId, funnel, event);
910
+ await advanceFunnel(
911
+ ctx,
912
+ input.siteId,
913
+ funnel,
914
+ event,
915
+ input.reportRollupDeltas,
916
+ input.batchState,
917
+ );
448
918
  }
449
919
  }
450
920
 
@@ -455,18 +925,23 @@ async function accountEvent(
455
925
  session,
456
926
  event,
457
927
  input.trustedGoalKey,
928
+ input.reportRollupDeltas,
458
929
  );
459
930
  }
460
931
 
932
+ const campaignDimensions: Dimension[] = session.utmCampaign
933
+ ? [{ type: "campaign", value: session.utmCampaign }]
934
+ : [];
461
935
  const dimensions: Dimension[] = [
462
936
  { type: "source", value: session.source },
937
+ ...campaignDimensions,
463
938
  { type: "page", value: event.path },
464
- { type: "country", value: input.context.country ?? "unknown" },
939
+ { type: "country", value: session.country ?? "unknown" },
465
940
  { type: "device", value: input.context.device ?? "unknown" },
466
941
  { type: "browser", value: input.context.browser ?? "Other" },
467
942
  {
468
943
  type: "event",
469
- value: event.type === "custom" ? event.name ?? "custom" : event.type,
944
+ value: event.type === "custom" ? (event.name ?? "custom") : event.type,
470
945
  },
471
946
  ...matchedGoalKeys.map((value): Dimension => ({ type: "goal", value })),
472
947
  ];
@@ -491,13 +966,15 @@ async function accountEvent(
491
966
  input.context,
492
967
  session,
493
968
  input.now,
969
+ input.batchState,
494
970
  );
495
971
  }
972
+ return session;
496
973
  }
497
974
 
498
975
  async function accountTrustedSession(
499
976
  ctx: MutationCtx,
500
- existing: Doc<"sessions"> | null,
977
+ existing: CachedDocument<"sessions"> | null,
501
978
  event: TrackerEvent,
502
979
  ): Promise<SessionState> {
503
980
  if (!existing) {
@@ -515,6 +992,12 @@ async function accountTrustedSession(
515
992
  isNewVisitorDay: false,
516
993
  startedAt: existing.startedAt,
517
994
  source: existing.source,
995
+ utmCampaign: existing.utmCampaign,
996
+ visitorKey: existing.visitorKey ?? existing.visitorId,
997
+ country: existing.country,
998
+ city: existing.city,
999
+ latitude: existing.latitude,
1000
+ longitude: existing.longitude,
518
1001
  pageviewCount: existing.pageviewCount,
519
1002
  eventCount: existing.eventCount,
520
1003
  affiliateId: existing.affiliateId,
@@ -529,29 +1012,65 @@ async function upsertSession(
529
1012
  siteId: Id<"sites">,
530
1013
  event: TrackerEvent,
531
1014
  context: IngestContext,
532
- existing: Doc<"sessions"> | null,
1015
+ existing: CachedDocument<"sessions"> | null,
533
1016
  financialConversion: boolean,
1017
+ reportRollupDeltas: ReportRollupDeltas,
1018
+ batchState?: BatchState,
534
1019
  ): Promise<SessionState> {
535
- const source = sourceFromReferrer(event.referrer);
1020
+ // A host-supplied id is the identity; anonymous sessions count through the
1021
+ // daily key derived by the HTTP action, or once per session without one.
1022
+ const visitorKey =
1023
+ existing?.visitorKey ??
1024
+ (event.visitorId !== event.sessionId
1025
+ ? event.visitorId
1026
+ : (context.visitorKey ?? event.sessionId));
536
1027
  const utmSource = stringProperty(event.properties, "utm_source");
1028
+ // Campaign traffic names its own source; the referrer host is the fallback.
1029
+ const source = utmSource?.toLowerCase() ?? sourceFromReferrer(event.referrer);
537
1030
  const utmMedium = stringProperty(event.properties, "utm_medium");
538
1031
  const utmCampaign = stringProperty(event.properties, "utm_campaign");
539
- const attribution = await attributionForEvent(ctx, siteId, event, existing);
1032
+ const attribution = await attributionForEvent(
1033
+ ctx,
1034
+ siteId,
1035
+ event,
1036
+ existing,
1037
+ batchState,
1038
+ );
540
1039
  if (existing) {
1040
+ if (attribution && existing.affiliateId !== attribution.affiliateId) {
1041
+ foldAffiliateVisitRollup(
1042
+ reportRollupDeltas,
1043
+ siteId,
1044
+ event,
1045
+ attribution,
1046
+ visitorKey,
1047
+ );
1048
+ }
541
1049
  const startedAt = Math.min(existing.startedAt, event.timestamp);
542
1050
  const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
543
- await ctx.db.patch("sessions", existing._id, {
1051
+ const updates = {
544
1052
  startedAt,
545
1053
  lastSeenAt,
546
- entryPath: event.timestamp < existing.startedAt ? event.path : existing.entryPath,
547
- exitPath: event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
1054
+ entryPath:
1055
+ event.timestamp < existing.startedAt ? event.path : existing.entryPath,
1056
+ exitPath:
1057
+ event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
548
1058
  eventCount: existing.eventCount + 1,
549
- pageviewCount: existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
1059
+ pageviewCount:
1060
+ existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
1061
+ ...(event.type === "pageview"
1062
+ ? {
1063
+ lastPageviewAt: Math.max(
1064
+ existing.lastPageviewAt ?? 0,
1065
+ event.timestamp,
1066
+ ),
1067
+ }
1068
+ : {}),
550
1069
  durationMs: Math.max(0, lastSeenAt - startedAt),
551
1070
  conversionCount: existing.conversionCount + Number(financialConversion),
552
1071
  revenueCents:
553
1072
  existing.revenueCents +
554
- (financialConversion ? event.revenueCents ?? 0 : 0),
1073
+ (financialConversion ? (event.revenueCents ?? 0) : 0),
555
1074
  ...(financialConversion && event.currency
556
1075
  ? { currency: event.currency }
557
1076
  : {}),
@@ -562,11 +1081,21 @@ async function upsertSession(
562
1081
  ...(context.country ? { country: context.country } : {}),
563
1082
  ...(context.city ? { city: context.city } : {}),
564
1083
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
565
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1084
+ ...(context.longitude !== undefined
1085
+ ? { longitude: context.longitude }
1086
+ : {}),
566
1087
  browser: context.browser ?? existing.browser,
567
1088
  os: context.os ?? existing.os,
568
1089
  device: context.device ?? existing.device,
569
- });
1090
+ };
1091
+ if (batchState) {
1092
+ const state = batchState.sessions.get(event.sessionId);
1093
+ if (!state) throw new Error("session cache was not initialized");
1094
+ state.current = { ...existing, ...updates };
1095
+ state.dirty = true;
1096
+ } else {
1097
+ await ctx.db.patch("sessions", existing._id, updates);
1098
+ }
570
1099
  return {
571
1100
  isNewSession: false,
572
1101
  isNewVisitorHour:
@@ -577,6 +1106,12 @@ async function upsertSession(
577
1106
  Math.floor(event.timestamp / DAY_MS),
578
1107
  startedAt,
579
1108
  source: existing.source,
1109
+ utmCampaign: existing.utmCampaign,
1110
+ visitorKey,
1111
+ country: context.country ?? existing.country,
1112
+ city: context.city ?? existing.city,
1113
+ latitude: context.latitude ?? existing.latitude,
1114
+ longitude: context.longitude ?? existing.longitude,
580
1115
  pageviewCount:
581
1116
  existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
582
1117
  eventCount: existing.eventCount + 1,
@@ -584,17 +1119,31 @@ async function upsertSession(
584
1119
  };
585
1120
  }
586
1121
 
587
- const priorVisit = await ctx.db
1122
+ const priorVisits = await ctx.db
588
1123
  .query("sessions")
589
1124
  .withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) =>
590
1125
  range.eq("siteId", siteId).eq("visitorId", event.visitorId),
591
1126
  )
592
1127
  .order("desc")
593
1128
  .take(1);
594
- await ctx.db.insert("sessions", {
1129
+ let priorVisit: CachedDocument<"sessions"> | null = priorVisits[0] ?? null;
1130
+ if (batchState) {
1131
+ for (const state of batchState.sessions.values()) {
1132
+ const candidate = state.current;
1133
+ if (
1134
+ candidate?.visitorId === event.visitorId &&
1135
+ candidate.sessionId !== event.sessionId &&
1136
+ (!priorVisit || candidate.lastSeenAt > priorVisit.lastSeenAt)
1137
+ ) {
1138
+ priorVisit = candidate;
1139
+ }
1140
+ }
1141
+ }
1142
+ const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
595
1143
  siteId,
596
1144
  sessionId: event.sessionId,
597
1145
  visitorId: event.visitorId,
1146
+ visitorKey,
598
1147
  startedAt: event.timestamp,
599
1148
  lastSeenAt: event.timestamp,
600
1149
  entryPath: event.path,
@@ -607,46 +1156,71 @@ async function upsertSession(
607
1156
  ...(context.country ? { country: context.country } : {}),
608
1157
  ...(context.city ? { city: context.city } : {}),
609
1158
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
610
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1159
+ ...(context.longitude !== undefined
1160
+ ? { longitude: context.longitude }
1161
+ : {}),
611
1162
  browser: context.browser ?? "Other",
612
1163
  os: context.os ?? "Other",
613
1164
  device: context.device ?? "unknown",
614
1165
  eventCount: 1,
615
1166
  pageviewCount: event.type === "pageview" ? 1 : 0,
1167
+ ...(event.type === "pageview" ? { lastPageviewAt: event.timestamp } : {}),
616
1168
  durationMs: 0,
617
1169
  conversionCount: Number(financialConversion),
618
- revenueCents: financialConversion ? event.revenueCents ?? 0 : 0,
1170
+ revenueCents: financialConversion ? (event.revenueCents ?? 0) : 0,
619
1171
  ...(financialConversion && event.currency
620
1172
  ? { currency: event.currency }
621
1173
  : {}),
622
1174
  ...(attribution ?? {}),
623
- });
1175
+ };
1176
+ const sessionId = await ctx.db.insert("sessions", fields);
1177
+ if (attribution) {
1178
+ foldAffiliateVisitRollup(
1179
+ reportRollupDeltas,
1180
+ siteId,
1181
+ event,
1182
+ attribution,
1183
+ visitorKey,
1184
+ );
1185
+ }
1186
+ if (batchState) {
1187
+ const state = batchState.sessions.get(event.sessionId);
1188
+ if (!state) throw new Error("session cache was not initialized");
1189
+ state.current = {
1190
+ ...fields,
1191
+ _id: sessionId,
1192
+ };
1193
+ }
624
1194
  return {
625
1195
  isNewSession: true,
626
1196
  isNewVisitorHour:
627
- priorVisit.length === 0 ||
628
- Math.floor(priorVisit[0].lastSeenAt / HOUR_MS) <
1197
+ priorVisit === null ||
1198
+ Math.floor(priorVisit.lastSeenAt / HOUR_MS) <
629
1199
  Math.floor(event.timestamp / HOUR_MS),
630
1200
  isNewVisitorDay:
631
- priorVisit.length === 0 ||
632
- Math.floor(priorVisit[0].lastSeenAt / DAY_MS) <
1201
+ priorVisit === null ||
1202
+ Math.floor(priorVisit.lastSeenAt / DAY_MS) <
633
1203
  Math.floor(event.timestamp / DAY_MS),
634
1204
  startedAt: event.timestamp,
635
1205
  source,
1206
+ utmCampaign,
1207
+ visitorKey,
1208
+ country: context.country,
1209
+ city: context.city,
1210
+ latitude: context.latitude,
1211
+ longitude: context.longitude,
636
1212
  pageviewCount: event.type === "pageview" ? 1 : 0,
637
1213
  eventCount: 1,
638
1214
  ...(attribution ?? {}),
639
1215
  };
640
1216
  }
641
1217
 
642
- function attributionSnapshot(
643
- value: {
644
- affiliateId?: Id<"affiliates">;
645
- affiliateSlug?: string;
646
- affiliateAttributedAt?: number;
647
- affiliateExpiresAt?: number;
648
- },
649
- ): AttributionSnapshot | undefined {
1218
+ function attributionSnapshot(value: {
1219
+ affiliateId?: Id<"affiliates">;
1220
+ affiliateSlug?: string;
1221
+ affiliateAttributedAt?: number;
1222
+ affiliateExpiresAt?: number;
1223
+ }): AttributionSnapshot | undefined {
650
1224
  if (
651
1225
  !value.affiliateId ||
652
1226
  !value.affiliateSlug ||
@@ -663,7 +1237,6 @@ function attributionSnapshot(
663
1237
  };
664
1238
  }
665
1239
 
666
-
667
1240
  function attributionApplies(
668
1241
  attribution: AttributionSnapshot,
669
1242
  timestamp: number,
@@ -680,7 +1253,8 @@ async function attributionForEvent(
680
1253
  ctx: MutationCtx,
681
1254
  siteId: Id<"sites">,
682
1255
  event: TrackerEvent,
683
- existingSession: Doc<"sessions"> | null,
1256
+ existingSession: CachedDocument<"sessions"> | null,
1257
+ batchState?: BatchState,
684
1258
  ): Promise<AttributionSnapshot | undefined> {
685
1259
  const sessionAttribution = existingSession
686
1260
  ? attributionSnapshot(existingSession)
@@ -696,12 +1270,25 @@ async function attributionForEvent(
696
1270
  return sessionAttribution;
697
1271
  }
698
1272
 
699
- const current = await ctx.db
700
- .query("visitorAttributions")
701
- .withIndex("by_siteId_and_visitorId", (range) =>
702
- range.eq("siteId", siteId).eq("visitorId", event.visitorId),
703
- )
704
- .unique();
1273
+ let attributionState = batchState?.visitorAttributions.get(event.visitorId);
1274
+ if (batchState && !attributionState) {
1275
+ const current = await ctx.db
1276
+ .query("visitorAttributions")
1277
+ .withIndex("by_siteId_and_visitorId", (range) =>
1278
+ range.eq("siteId", siteId).eq("visitorId", event.visitorId),
1279
+ )
1280
+ .unique();
1281
+ attributionState = { current, dirty: false };
1282
+ batchState.visitorAttributions.set(event.visitorId, attributionState);
1283
+ }
1284
+ const current = attributionState
1285
+ ? attributionState.current
1286
+ : await ctx.db
1287
+ .query("visitorAttributions")
1288
+ .withIndex("by_siteId_and_visitorId", (range) =>
1289
+ range.eq("siteId", siteId).eq("visitorId", event.visitorId),
1290
+ )
1291
+ .unique();
705
1292
  if (current) {
706
1293
  const snapshot: AttributionSnapshot = {
707
1294
  affiliateId: current.affiliateId,
@@ -733,22 +1320,30 @@ async function attributionForEvent(
733
1320
 
734
1321
  const attributedAt = event.timestamp;
735
1322
  const expiresAt = attributedAt + affiliate.attributionWindowDays * DAY_MS;
1323
+ const fields = {
1324
+ siteId,
1325
+ visitorId: event.visitorId,
1326
+ affiliateId: affiliate._id,
1327
+ affiliateSlug: affiliate.slug,
1328
+ attributedAt,
1329
+ expiresAt,
1330
+ };
736
1331
  if (current) {
737
- await ctx.db.patch("visitorAttributions", current._id, {
738
- affiliateId: affiliate._id,
739
- affiliateSlug: affiliate.slug,
740
- attributedAt,
741
- expiresAt,
742
- });
1332
+ if (attributionState) {
1333
+ attributionState.current = { ...current, ...fields };
1334
+ attributionState.dirty = true;
1335
+ } else {
1336
+ await ctx.db.patch("visitorAttributions", current._id, fields);
1337
+ }
743
1338
  } else {
744
- await ctx.db.insert("visitorAttributions", {
745
- siteId,
746
- visitorId: event.visitorId,
747
- affiliateId: affiliate._id,
748
- affiliateSlug: affiliate.slug,
749
- attributedAt,
750
- expiresAt,
751
- });
1339
+ const attributionId = await ctx.db.insert("visitorAttributions", fields);
1340
+ if (attributionState) {
1341
+ attributionState.current = {
1342
+ ...fields,
1343
+ _id: attributionId,
1344
+ _creationTime: event.timestamp,
1345
+ };
1346
+ }
752
1347
  }
753
1348
  return {
754
1349
  affiliateId: affiliate._id,
@@ -761,28 +1356,38 @@ async function attributionForEvent(
761
1356
  async function accountHeartbeat(
762
1357
  ctx: MutationCtx,
763
1358
  siteId: Id<"sites">,
764
- session: Doc<"sessions">,
1359
+ session: CachedDocument<"sessions">,
765
1360
  event: TrackerEvent,
766
1361
  context: IngestContext,
767
1362
  now: number,
1363
+ batchState?: BatchState,
768
1364
  ) {
769
1365
  const startedAt = Math.min(session.startedAt, event.timestamp);
770
1366
  const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
771
- await ctx.db.patch("sessions", session._id, {
1367
+ const updates = {
772
1368
  startedAt,
773
1369
  lastSeenAt,
774
- exitPath: event.timestamp >= session.lastSeenAt
775
- ? event.path
776
- : session.exitPath,
1370
+ exitPath:
1371
+ event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
777
1372
  durationMs: Math.max(0, lastSeenAt - startedAt),
778
1373
  ...(context.country ? { country: context.country } : {}),
779
1374
  ...(context.city ? { city: context.city } : {}),
780
1375
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
781
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1376
+ ...(context.longitude !== undefined
1377
+ ? { longitude: context.longitude }
1378
+ : {}),
782
1379
  browser: context.browser ?? session.browser,
783
1380
  os: context.os ?? session.os,
784
1381
  device: context.device ?? session.device,
785
- });
1382
+ };
1383
+ if (batchState) {
1384
+ const state = batchState.sessions.get(event.sessionId);
1385
+ if (!state) throw new Error("session cache was not initialized");
1386
+ state.current = { ...session, ...updates };
1387
+ state.dirty = true;
1388
+ } else {
1389
+ await ctx.db.patch("sessions", session._id, updates);
1390
+ }
786
1391
  await updateLiveSession(
787
1392
  ctx,
788
1393
  siteId,
@@ -794,11 +1399,17 @@ async function accountHeartbeat(
794
1399
  isNewVisitorDay: false,
795
1400
  startedAt,
796
1401
  source: session.source,
1402
+ visitorKey: session.visitorKey ?? session.visitorId,
1403
+ country: context.country ?? session.country,
1404
+ city: context.city ?? session.city,
1405
+ latitude: context.latitude ?? session.latitude,
1406
+ longitude: context.longitude ?? session.longitude,
797
1407
  pageviewCount: session.pageviewCount,
798
1408
  eventCount: session.eventCount,
799
1409
  affiliateSlug: session.affiliateSlug,
800
1410
  },
801
1411
  now,
1412
+ batchState,
802
1413
  );
803
1414
  }
804
1415
 
@@ -806,17 +1417,18 @@ async function consumeSiteRateLimit(
806
1417
  ctx: MutationCtx,
807
1418
  siteId: Id<"sites">,
808
1419
  events: TrackerEvent[],
1420
+ eventByteCounts: number[],
809
1421
  now: number,
810
- ): Promise<number[]> {
1422
+ ): Promise<void> {
811
1423
  const windowStart =
812
1424
  Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
813
- const increments = new Map<number, { eventCount: number; byteCount: number }>();
814
- const eventByteCounts: number[] = [];
815
- const encoder = new TextEncoder();
1425
+ const increments = new Map<
1426
+ number,
1427
+ { eventCount: number; byteCount: number }
1428
+ >();
816
1429
  for (let index = 0; index < events.length; index += 1) {
817
1430
  const event = events[index];
818
- const byteCount = encoder.encode(JSON.stringify(event)).byteLength;
819
- eventByteCounts.push(byteCount);
1431
+ const byteCount = eventByteCounts[index];
820
1432
  const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
821
1433
  const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
822
1434
  current.eventCount += 1;
@@ -830,15 +1442,16 @@ async function consumeSiteRateLimit(
830
1442
  window: Doc<"siteIngestWindows"> | null;
831
1443
  }> = [];
832
1444
  for (const [shard, increment] of increments) {
833
- const window = await ctx.db
1445
+ const rows = await ctx.db
834
1446
  .query("siteIngestWindows")
835
1447
  .withIndex("by_siteId_and_shard_and_windowStart", (range) =>
836
- range
837
- .eq("siteId", siteId)
838
- .eq("shard", shard)
839
- .eq("windowStart", windowStart),
1448
+ range.eq("siteId", siteId).eq("shard", shard),
840
1449
  )
841
- .unique();
1450
+ .order("desc")
1451
+ .take(1);
1452
+ const storedWindow = rows[0] ?? null;
1453
+ const window =
1454
+ storedWindow?.windowStart === windowStart ? storedWindow : null;
842
1455
  if (
843
1456
  (window?.eventCount ?? 0) + increment.eventCount >
844
1457
  MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
@@ -850,20 +1463,25 @@ async function consumeSiteRateLimit(
850
1463
  windowMs: RATE_LIMIT_WINDOW_MS,
851
1464
  eventLimitPerShard: MAX_SITE_EVENTS_PER_SHARD_WINDOW,
852
1465
  byteLimitPerShard: MAX_SITE_BYTES_PER_SHARD_WINDOW,
853
- siteEventLimit:
854
- SITE_INGEST_SHARDS * MAX_SITE_EVENTS_PER_SHARD_WINDOW,
855
- siteByteLimit:
856
- SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
1466
+ siteEventLimit: SITE_INGEST_SHARDS * MAX_SITE_EVENTS_PER_SHARD_WINDOW,
1467
+ siteByteLimit: SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
857
1468
  });
858
1469
  }
859
- windows.push({ shard, increment, window });
1470
+ windows.push({ shard, increment, window: storedWindow });
860
1471
  }
861
1472
 
862
1473
  for (const { shard, increment, window } of windows) {
863
1474
  if (window) {
864
1475
  await ctx.db.patch("siteIngestWindows", window._id, {
865
- eventCount: window.eventCount + increment.eventCount,
866
- byteCount: window.byteCount + increment.byteCount,
1476
+ windowStart,
1477
+ eventCount:
1478
+ window.windowStart === windowStart
1479
+ ? window.eventCount + increment.eventCount
1480
+ : increment.eventCount,
1481
+ byteCount:
1482
+ window.windowStart === windowStart
1483
+ ? window.byteCount + increment.byteCount
1484
+ : increment.byteCount,
867
1485
  updatedAt: now,
868
1486
  });
869
1487
  } else {
@@ -877,7 +1495,6 @@ async function consumeSiteRateLimit(
877
1495
  });
878
1496
  }
879
1497
  }
880
- return eventByteCounts;
881
1498
  }
882
1499
 
883
1500
  async function consumeRateLimit(
@@ -886,43 +1503,71 @@ async function consumeRateLimit(
886
1503
  sessionId: string,
887
1504
  byteCount: number,
888
1505
  now: number,
1506
+ states: Map<string, SessionRateWindowState>,
889
1507
  ): Promise<boolean> {
890
- const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
891
- const window = await ctx.db
892
- .query("ingestWindows")
893
- .withIndex("by_siteId_and_sessionId_and_windowStart", (range) =>
894
- range
895
- .eq("siteId", siteId)
896
- .eq("sessionId", sessionId)
897
- .eq("windowStart", windowStart),
898
- )
899
- .unique();
1508
+ const windowStart =
1509
+ Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1510
+ let state = states.get(sessionId);
1511
+ if (!state) {
1512
+ const rows = await ctx.db
1513
+ .query("ingestWindows")
1514
+ .withIndex("by_siteId_and_sessionId_and_windowStart", (range) =>
1515
+ range.eq("siteId", siteId).eq("sessionId", sessionId),
1516
+ )
1517
+ .order("desc")
1518
+ .take(1);
1519
+ const window = rows[0] ?? null;
1520
+ state = {
1521
+ siteId,
1522
+ sessionId,
1523
+ window,
1524
+ eventCount: window?.windowStart === windowStart ? window.eventCount : 0,
1525
+ byteCount: window?.windowStart === windowStart ? window.byteCount : 0,
1526
+ dirty: false,
1527
+ };
1528
+ states.set(sessionId, state);
1529
+ }
900
1530
  if (
901
- window &&
902
- (window.eventCount >= MAX_EVENTS_PER_SESSION_WINDOW ||
903
- window.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW)
1531
+ state.eventCount >= MAX_EVENTS_PER_SESSION_WINDOW ||
1532
+ state.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW
904
1533
  ) {
905
1534
  return false;
906
1535
  }
907
- if (window) {
908
- await ctx.db.patch("ingestWindows", window._id, {
909
- eventCount: window.eventCount + 1,
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
- }
1536
+ state.eventCount += 1;
1537
+ state.byteCount += byteCount;
1538
+ state.dirty = true;
923
1539
  return true;
924
1540
  }
925
1541
 
1542
+ async function flushSessionRateLimits(
1543
+ ctx: MutationCtx,
1544
+ states: Map<string, SessionRateWindowState>,
1545
+ now: number,
1546
+ ) {
1547
+ const windowStart =
1548
+ Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1549
+ for (const state of states.values()) {
1550
+ if (!state.dirty) continue;
1551
+ if (state.window) {
1552
+ await ctx.db.patch("ingestWindows", state.window._id, {
1553
+ windowStart,
1554
+ eventCount: state.eventCount,
1555
+ byteCount: state.byteCount,
1556
+ updatedAt: now,
1557
+ });
1558
+ } else {
1559
+ await ctx.db.insert("ingestWindows", {
1560
+ siteId: state.siteId,
1561
+ sessionId: state.sessionId,
1562
+ windowStart,
1563
+ eventCount: state.eventCount,
1564
+ byteCount: state.byteCount,
1565
+ updatedAt: now,
1566
+ });
1567
+ }
1568
+ }
1569
+ }
1570
+
926
1571
  async function updateLiveSession(
927
1572
  ctx: MutationCtx,
928
1573
  siteId: Id<"sites">,
@@ -930,45 +1575,73 @@ async function updateLiveSession(
930
1575
  context: IngestContext,
931
1576
  session: SessionState,
932
1577
  now: number,
1578
+ batchState?: BatchState,
933
1579
  ) {
934
- const existing = await ctx.db
935
- .query("liveSessions")
936
- .withIndex("by_siteId_and_sessionId", (range) =>
937
- range.eq("siteId", siteId).eq("sessionId", event.sessionId),
938
- )
939
- .unique();
940
- const renewsExpiry =
941
- !existing ||
942
- existing.expiresAt <= now ||
943
- event.type === "pageview" ||
944
- event.type === "heartbeat";
945
- const deadline = renewsExpiry
946
- ? now + LIVE_SESSION_TTL_MS
947
- : existing.expiresAt;
1580
+ await ensureLiveSweep(ctx, siteId, now, batchState);
1581
+ const liveState = batchState
1582
+ ? await loadLiveSession(ctx, siteId, event.sessionId, batchState)
1583
+ : undefined;
1584
+ const existing = liveState
1585
+ ? liveState.current
1586
+ : await ctx.db
1587
+ .query("liveSessions")
1588
+ .withIndex("by_siteId_and_sessionId", (range) =>
1589
+ range.eq("siteId", siteId).eq("sessionId", event.sessionId),
1590
+ )
1591
+ .unique();
1592
+ const deadline = now + LIVE_SESSION_TTL_MS;
1593
+ const country = context.country ?? session.country;
1594
+ const city = context.city ?? session.city;
1595
+ const latitude = context.latitude ?? session.latitude;
1596
+ const longitude = context.longitude ?? session.longitude;
948
1597
  if (existing) {
949
- await ctx.db.patch("liveSessions", existing._id, {
1598
+ const recentHeartbeatEventIds =
1599
+ event.type === "heartbeat"
1600
+ ? [
1601
+ ...(existing.recentHeartbeatEventIds ??
1602
+ (existing.lastHeartbeatEventId
1603
+ ? [existing.lastHeartbeatEventId]
1604
+ : [])),
1605
+ event.eventId,
1606
+ ].slice(-8)
1607
+ : undefined;
1608
+ const updates = {
950
1609
  visitorId: event.visitorId,
951
1610
  path: event.path,
952
1611
  lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
953
1612
  expiresAt: deadline,
954
- ...(context.country ? { country: context.country } : {}),
955
- ...(context.city ? { city: context.city } : {}),
956
- ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
957
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1613
+ ...(country ? { country } : {}),
1614
+ ...(city ? { city } : {}),
1615
+ ...(latitude !== undefined ? { latitude } : {}),
1616
+ ...(longitude !== undefined ? { longitude } : {}),
958
1617
  browser: context.browser ?? existing.browser,
959
1618
  os: context.os ?? existing.os,
960
1619
  source: session.source,
961
1620
  device: context.device ?? existing.device,
962
1621
  pageviewCount: session.pageviewCount,
963
1622
  eventCount: session.eventCount,
1623
+ // The live map animates a visitor from the route they left to the one
1624
+ // they reached; a heartbeat or click on the same page is not a move.
1625
+ ...(event.type === "pageview" && event.path !== existing.path
1626
+ ? { previousPath: existing.path, movedAt: event.timestamp }
1627
+ : {}),
964
1628
  ...(event.type === "heartbeat"
965
- ? { lastHeartbeatEventId: event.eventId }
1629
+ ? {
1630
+ lastHeartbeatEventId: event.eventId,
1631
+ recentHeartbeatEventIds,
1632
+ }
966
1633
  : {}),
967
- });
1634
+ };
1635
+ if (liveState) {
1636
+ liveState.current = { ...existing, ...updates };
1637
+ liveState.dirty = true;
1638
+ } else {
1639
+ await ctx.db.patch("liveSessions", existing._id, updates);
1640
+ }
968
1641
  return;
969
1642
  }
970
1643
 
971
- const liveSessionId = await ctx.db.insert("liveSessions", {
1644
+ const fields: Omit<Doc<"liveSessions">, "_id" | "_creationTime"> = {
972
1645
  siteId,
973
1646
  sessionId: event.sessionId,
974
1647
  visitorId: event.visitorId,
@@ -976,11 +1649,10 @@ async function updateLiveSession(
976
1649
  startedAt: event.timestamp,
977
1650
  lastSeenAt: event.timestamp,
978
1651
  expiresAt: deadline,
979
- expiryChainDeadline: deadline,
980
- ...(context.country ? { country: context.country } : {}),
981
- ...(context.city ? { city: context.city } : {}),
982
- ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
983
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1652
+ ...(country ? { country } : {}),
1653
+ ...(city ? { city } : {}),
1654
+ ...(latitude !== undefined ? { latitude } : {}),
1655
+ ...(longitude !== undefined ? { longitude } : {}),
984
1656
  browser: context.browser ?? "Other",
985
1657
  os: context.os ?? "Other",
986
1658
  source: session.source,
@@ -988,13 +1660,46 @@ async function updateLiveSession(
988
1660
  pageviewCount: session.pageviewCount,
989
1661
  eventCount: session.eventCount,
990
1662
  ...(event.type === "heartbeat"
991
- ? { lastHeartbeatEventId: event.eventId }
1663
+ ? {
1664
+ lastHeartbeatEventId: event.eventId,
1665
+ recentHeartbeatEventIds: [event.eventId],
1666
+ }
992
1667
  : {}),
993
- });
994
- await ctx.scheduler.runAt(deadline, internal.live.expire, {
995
- liveSessionId,
996
- scheduledDeadline: deadline,
997
- });
1668
+ };
1669
+ const liveSessionId = await ctx.db.insert("liveSessions", fields);
1670
+ if (liveState) {
1671
+ liveState.current = {
1672
+ ...fields,
1673
+ _id: liveSessionId,
1674
+ };
1675
+ }
1676
+ }
1677
+
1678
+ async function ensureLiveSweep(
1679
+ ctx: MutationCtx,
1680
+ siteId: Id<"sites">,
1681
+ now: number,
1682
+ batchState?: BatchState,
1683
+ ) {
1684
+ const key = String(siteId);
1685
+ if (batchState?.liveSweepSites.has(key)) return;
1686
+ const existing = await ctx.db
1687
+ .query("liveSweepStates")
1688
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1689
+ .unique();
1690
+ if (!existing || existing.scheduledAt <= now) {
1691
+ const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
1692
+ if (existing) {
1693
+ await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
1694
+ } else {
1695
+ await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1696
+ }
1697
+ await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
1698
+ siteId,
1699
+ scheduledAt,
1700
+ });
1701
+ }
1702
+ batchState?.liveSweepSites.add(key);
998
1703
  }
999
1704
 
1000
1705
  function goalMatches(goal: Doc<"goals">, event: TrackerEvent): boolean {
@@ -1009,20 +1714,39 @@ async function advanceFunnel(
1009
1714
  siteId: Id<"sites">,
1010
1715
  funnel: Doc<"funnels">,
1011
1716
  event: TrackerEvent,
1717
+ reportRollupDeltas: ReportRollupDeltas,
1718
+ batchState?: BatchState,
1012
1719
  ) {
1013
- const progress = await ctx.db
1014
- .query("funnelProgress")
1015
- .withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
1016
- range
1017
- .eq("siteId", siteId)
1018
- .eq("funnelId", funnel._id)
1019
- .eq("visitorId", event.visitorId),
1020
- )
1021
- .unique();
1720
+ const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
1721
+ let progressState = batchState?.funnelProgress.get(cacheKey);
1722
+ if (batchState && !progressState) {
1723
+ const current = await ctx.db
1724
+ .query("funnelProgress")
1725
+ .withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
1726
+ range
1727
+ .eq("siteId", siteId)
1728
+ .eq("funnelId", funnel._id)
1729
+ .eq("visitorId", event.visitorId),
1730
+ )
1731
+ .unique();
1732
+ progressState = { current, dirty: false };
1733
+ batchState.funnelProgress.set(cacheKey, progressState);
1734
+ }
1735
+ const progress = progressState
1736
+ ? progressState.current
1737
+ : await ctx.db
1738
+ .query("funnelProgress")
1739
+ .withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
1740
+ range
1741
+ .eq("siteId", siteId)
1742
+ .eq("funnelId", funnel._id)
1743
+ .eq("visitorId", event.visitorId),
1744
+ )
1745
+ .unique();
1022
1746
  const matchesFirst = stepMatches(funnel.steps[0], event);
1023
1747
  if (!progress) {
1024
1748
  if (!matchesFirst) return;
1025
- await ctx.db.insert("funnelProgress", {
1749
+ const fields: Omit<Doc<"funnelProgress">, "_id" | "_creationTime"> = {
1026
1750
  siteId,
1027
1751
  funnelId: funnel._id,
1028
1752
  visitorId: event.visitorId,
@@ -1030,33 +1754,65 @@ async function advanceFunnel(
1030
1754
  currentStep: 1,
1031
1755
  startedAt: event.timestamp,
1032
1756
  lastStepAt: event.timestamp,
1033
- });
1757
+ };
1758
+ const progressId = await ctx.db.insert("funnelProgress", fields);
1759
+ if (progressState) {
1760
+ progressState.current = {
1761
+ ...fields,
1762
+ _id: progressId,
1763
+ };
1764
+ }
1034
1765
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1766
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1035
1767
  return;
1036
1768
  }
1037
- if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt) return;
1769
+ if (
1770
+ progress.completedAt !== undefined ||
1771
+ event.timestamp < progress.lastStepAt
1772
+ )
1773
+ return;
1038
1774
  if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
1039
1775
  if (!matchesFirst) return;
1040
- await ctx.db.patch("funnelProgress", progress._id, {
1776
+ const updated = {
1777
+ ...progress,
1041
1778
  sessionId: event.sessionId,
1042
1779
  currentStep: 1,
1043
1780
  startedAt: event.timestamp,
1044
1781
  lastStepAt: event.timestamp,
1045
- completedAt: undefined,
1046
- });
1782
+ };
1783
+ delete updated.completedAt;
1784
+ if (progressState) {
1785
+ progressState.current = updated;
1786
+ progressState.dirty = true;
1787
+ } else {
1788
+ await ctx.db.patch("funnelProgress", progress._id, {
1789
+ sessionId: event.sessionId,
1790
+ currentStep: 1,
1791
+ startedAt: event.timestamp,
1792
+ lastStepAt: event.timestamp,
1793
+ completedAt: undefined,
1794
+ });
1795
+ }
1047
1796
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1797
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1048
1798
  return;
1049
1799
  }
1050
1800
  const nextStep = funnel.steps[progress.currentStep];
1051
1801
  if (!nextStep || !stepMatches(nextStep, event)) return;
1052
1802
  const currentStep = progress.currentStep + 1;
1053
1803
  const completed = currentStep === funnel.steps.length;
1054
- await ctx.db.patch("funnelProgress", progress._id, {
1804
+ const updates = {
1055
1805
  sessionId: event.sessionId,
1056
1806
  currentStep,
1057
1807
  lastStepAt: event.timestamp,
1058
1808
  ...(completed ? { completedAt: event.timestamp } : {}),
1059
- });
1809
+ };
1810
+ if (progressState) {
1811
+ progressState.current = { ...progress, ...updates };
1812
+ progressState.dirty = true;
1813
+ } else {
1814
+ await ctx.db.patch("funnelProgress", progress._id, updates);
1815
+ }
1060
1816
  await incrementFunnelStep(
1061
1817
  ctx,
1062
1818
  siteId,
@@ -1065,6 +1821,14 @@ async function advanceFunnel(
1065
1821
  progress.currentStep,
1066
1822
  completed,
1067
1823
  );
1824
+ foldFunnelRollup(
1825
+ reportRollupDeltas,
1826
+ siteId,
1827
+ funnel._id,
1828
+ event,
1829
+ progress.currentStep,
1830
+ completed,
1831
+ );
1068
1832
  }
1069
1833
 
1070
1834
  function stepMatches(
@@ -1085,7 +1849,7 @@ async function incrementFunnelStep(
1085
1849
  completed: boolean,
1086
1850
  ) {
1087
1851
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1088
- const shard = stableHash(event.visitorId) % AGGREGATE_SHARDS;
1852
+ const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
1089
1853
  const bucket = await ctx.db
1090
1854
  .query("funnelStepBuckets")
1091
1855
  .withIndex(
@@ -1123,6 +1887,7 @@ async function accountConversion(
1123
1887
  session: SessionState,
1124
1888
  event: TrackerEvent,
1125
1889
  trustedGoalKey: string | undefined,
1890
+ reportRollupDeltas: ReportRollupDeltas,
1126
1891
  ) {
1127
1892
  const revenueCents = event.revenueCents ?? 0;
1128
1893
  const currency = event.currency ?? "USD";
@@ -1154,8 +1919,17 @@ async function accountConversion(
1154
1919
  commissionCents,
1155
1920
  });
1156
1921
  if (commissionAffiliate && attribution) {
1922
+ foldAffiliateConversionRollup(
1923
+ reportRollupDeltas,
1924
+ siteId,
1925
+ event,
1926
+ commissionAffiliate,
1927
+ attribution.affiliateSlug,
1928
+ revenueCents,
1929
+ commissionCents,
1930
+ );
1157
1931
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1158
- const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
1932
+ const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
1159
1933
  const stats = await ctx.db
1160
1934
  .query("affiliateDailyStats")
1161
1935
  .withIndex(
@@ -1189,6 +1963,558 @@ async function accountConversion(
1189
1963
  }
1190
1964
  }
1191
1965
 
1966
+ function createReportRollupDeltas(): ReportRollupDeltas {
1967
+ return {
1968
+ goals: new Map(),
1969
+ funnels: new Map(),
1970
+ affiliates: new Map(),
1971
+ vitals: new Map(),
1972
+ routes: new Map(),
1973
+ };
1974
+ }
1975
+
1976
+ function reportRollupCoordinates(
1977
+ siteId: Id<"sites">,
1978
+ definitionId: string,
1979
+ timestamp: number,
1980
+ visitorId: string,
1981
+ ) {
1982
+ const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
1983
+ const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
1984
+ return {
1985
+ bucketStart,
1986
+ shard,
1987
+ key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
1988
+ };
1989
+ }
1990
+
1991
+ function foldGoalRollup(
1992
+ deltas: ReportRollupDeltas,
1993
+ siteId: Id<"sites">,
1994
+ event: TrackerEvent,
1995
+ goal: Doc<"goals">,
1996
+ ) {
1997
+ const coordinates = reportRollupCoordinates(
1998
+ siteId,
1999
+ String(goal._id),
2000
+ event.timestamp,
2001
+ event.visitorId,
2002
+ );
2003
+ const current = deltas.goals.get(coordinates.key) ?? {
2004
+ siteId,
2005
+ goalId: goal._id,
2006
+ bucketStart: coordinates.bucketStart,
2007
+ shard: coordinates.shard,
2008
+ goalKey: goal.key,
2009
+ completions: 0,
2010
+ valueCents: 0,
2011
+ };
2012
+ current.completions += 1;
2013
+ current.valueCents += goal.valueCents ?? 0;
2014
+ deltas.goals.set(coordinates.key, current);
2015
+ }
2016
+
2017
+ function foldFunnelRollup(
2018
+ deltas: ReportRollupDeltas,
2019
+ siteId: Id<"sites">,
2020
+ funnelId: Id<"funnels">,
2021
+ event: TrackerEvent,
2022
+ step: number,
2023
+ completed: boolean,
2024
+ ) {
2025
+ const coordinates = reportRollupCoordinates(
2026
+ siteId,
2027
+ String(funnelId),
2028
+ event.timestamp,
2029
+ event.visitorId,
2030
+ );
2031
+ const rollup = deltas.funnels.get(coordinates.key) ?? {
2032
+ siteId,
2033
+ funnelId,
2034
+ bucketStart: coordinates.bucketStart,
2035
+ shard: coordinates.shard,
2036
+ steps: new Map(),
2037
+ };
2038
+ const current = rollup.steps.get(step) ?? {
2039
+ step,
2040
+ entrants: 0,
2041
+ completions: 0,
2042
+ };
2043
+ current.entrants += 1;
2044
+ current.completions += Number(completed);
2045
+ rollup.steps.set(step, current);
2046
+ deltas.funnels.set(coordinates.key, rollup);
2047
+ }
2048
+
2049
+ function foldAffiliateVisitRollup(
2050
+ deltas: ReportRollupDeltas,
2051
+ siteId: Id<"sites">,
2052
+ event: TrackerEvent,
2053
+ attribution: AttributionSnapshot,
2054
+ visitorKey: string,
2055
+ ) {
2056
+ const coordinates = reportRollupCoordinates(
2057
+ siteId,
2058
+ String(attribution.affiliateId),
2059
+ event.timestamp,
2060
+ event.visitorId,
2061
+ );
2062
+ const current = deltas.affiliates.get(coordinates.key) ?? {
2063
+ siteId,
2064
+ affiliateId: attribution.affiliateId,
2065
+ bucketStart: coordinates.bucketStart,
2066
+ shard: coordinates.shard,
2067
+ affiliateSlug: attribution.affiliateSlug,
2068
+ visits: 0,
2069
+ visitorSketch: createVisitorSketch(),
2070
+ conversions: 0,
2071
+ revenueCents: 0,
2072
+ commissionCents: 0,
2073
+ };
2074
+ current.visits += 1;
2075
+ current.visitorSketch = addVisitor(current.visitorSketch, visitorKey);
2076
+ deltas.affiliates.set(coordinates.key, current);
2077
+ }
2078
+
2079
+ function foldAffiliateConversionRollup(
2080
+ deltas: ReportRollupDeltas,
2081
+ siteId: Id<"sites">,
2082
+ event: TrackerEvent,
2083
+ affiliate: Doc<"affiliates">,
2084
+ affiliateSlug: string,
2085
+ revenueCents: number,
2086
+ commissionCents: number,
2087
+ ) {
2088
+ const coordinates = reportRollupCoordinates(
2089
+ siteId,
2090
+ String(affiliate._id),
2091
+ event.timestamp,
2092
+ event.visitorId,
2093
+ );
2094
+ const current = deltas.affiliates.get(coordinates.key) ?? {
2095
+ siteId,
2096
+ affiliateId: affiliate._id,
2097
+ bucketStart: coordinates.bucketStart,
2098
+ shard: coordinates.shard,
2099
+ affiliateSlug,
2100
+ visits: 0,
2101
+ visitorSketch: createVisitorSketch(),
2102
+ conversions: 0,
2103
+ revenueCents: 0,
2104
+ commissionCents: 0,
2105
+ };
2106
+ current.conversions += 1;
2107
+ current.revenueCents += revenueCents;
2108
+ current.commissionCents += commissionCents;
2109
+ deltas.affiliates.set(coordinates.key, current);
2110
+ }
2111
+
2112
+ function foldVitalRollups(
2113
+ deltas: ReportRollupDeltas,
2114
+ siteId: Id<"sites">,
2115
+ event: TrackerEvent,
2116
+ device: string,
2117
+ ) {
2118
+ const metric = event.name as VitalMetric;
2119
+ const value = event.value ?? 0;
2120
+ const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
2121
+ const shard = stableHash(event.sessionId) % REPORT_ROLLUP_SHARDS;
2122
+ // Each measurement lands twice: on its page (across devices) and on its
2123
+ // device class (across pages), so both breakdowns stay exact and bounded.
2124
+ for (const target of [
2125
+ { device: VITAL_ALL, page: event.path },
2126
+ { device, page: VITAL_ALL },
2127
+ ]) {
2128
+ const key = JSON.stringify([
2129
+ siteId,
2130
+ bucketStart,
2131
+ target.device,
2132
+ target.page,
2133
+ shard,
2134
+ ]);
2135
+ const row = deltas.vitals.get(key) ?? {
2136
+ siteId,
2137
+ bucketStart,
2138
+ device: target.device,
2139
+ page: target.page,
2140
+ shard,
2141
+ metrics: new Map<
2142
+ VitalMetric,
2143
+ { count: number; sum: number; histogram: number[] }
2144
+ >(),
2145
+ };
2146
+ const entry = row.metrics.get(metric) ?? {
2147
+ count: 0,
2148
+ sum: 0,
2149
+ histogram: emptyVitalHistogram(),
2150
+ };
2151
+ entry.count += 1;
2152
+ entry.sum += value;
2153
+ entry.histogram = addToVitalHistogram(entry.histogram, value);
2154
+ row.metrics.set(metric, entry);
2155
+ deltas.vitals.set(key, row);
2156
+ }
2157
+ }
2158
+
2159
+ function foldRouteDelta(
2160
+ deltas: ReportRollupDeltas,
2161
+ siteId: Id<"sites">,
2162
+ route: string,
2163
+ timestamp: number,
2164
+ visitorId: string,
2165
+ apply: (delta: RouteRollupDelta) => void,
2166
+ ) {
2167
+ const coordinates = reportRollupCoordinates(
2168
+ siteId,
2169
+ `route:${route}`,
2170
+ timestamp,
2171
+ visitorId,
2172
+ );
2173
+ const current = deltas.routes.get(coordinates.key) ?? {
2174
+ siteId,
2175
+ bucketStart: coordinates.bucketStart,
2176
+ shard: coordinates.shard,
2177
+ route,
2178
+ pageviews: 0,
2179
+ entries: 0,
2180
+ continued: 0,
2181
+ dwellMs: 0,
2182
+ dwellSamples: 0,
2183
+ visitorSketch: createVisitorSketch(),
2184
+ outgoing: new Map<string, number>(),
2185
+ };
2186
+ apply(current);
2187
+ deltas.routes.set(coordinates.key, current);
2188
+ }
2189
+
2190
+ /**
2191
+ * One pageview feeds the site map three ways: the route it lands on gains a
2192
+ * view (and an entry when it opens the session), the route it leaves gains
2193
+ * the transition and the time spent there, and the session's entry route is
2194
+ * marked as continued the first time its visitor moves on — which is what
2195
+ * lets the report derive bounces and exits without a session-end hook.
2196
+ */
2197
+ function foldRouteRollups(
2198
+ deltas: ReportRollupDeltas,
2199
+ siteId: Id<"sites">,
2200
+ event: TrackerEvent,
2201
+ session: SessionState,
2202
+ existing: CachedDocument<"sessions"> | null,
2203
+ ) {
2204
+ const route = normalizeRoute(event.path);
2205
+ const priorPageviews = existing?.pageviewCount ?? 0;
2206
+ const firstPageview = existing === null || priorPageviews === 0;
2207
+ foldRouteDelta(
2208
+ deltas,
2209
+ siteId,
2210
+ route,
2211
+ event.timestamp,
2212
+ event.visitorId,
2213
+ (delta) => {
2214
+ delta.pageviews += 1;
2215
+ if (firstPageview) delta.entries += 1;
2216
+ delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
2217
+ },
2218
+ );
2219
+ // Out-of-order telemetry keeps its view but cannot say where it came from.
2220
+ if (existing === null || firstPageview) return;
2221
+ if (event.timestamp < existing.lastSeenAt) return;
2222
+ const previous = normalizeRoute(existing.exitPath);
2223
+ const lastPageviewAt = existing.lastPageviewAt;
2224
+ foldRouteDelta(
2225
+ deltas,
2226
+ siteId,
2227
+ previous,
2228
+ event.timestamp,
2229
+ event.visitorId,
2230
+ (delta) => {
2231
+ delta.outgoing.set(route, (delta.outgoing.get(route) ?? 0) + 1);
2232
+ if (lastPageviewAt !== undefined && event.timestamp >= lastPageviewAt) {
2233
+ delta.dwellMs += event.timestamp - lastPageviewAt;
2234
+ delta.dwellSamples += 1;
2235
+ }
2236
+ },
2237
+ );
2238
+ if (priorPageviews === 1) {
2239
+ foldRouteDelta(
2240
+ deltas,
2241
+ siteId,
2242
+ normalizeRoute(existing.entryPath),
2243
+ existing.startedAt,
2244
+ event.visitorId,
2245
+ (delta) => {
2246
+ delta.continued += 1;
2247
+ },
2248
+ );
2249
+ }
2250
+ }
2251
+
2252
+ async function upsertRouteRollup(
2253
+ ctx: MutationCtx,
2254
+ delta: RouteRollupDelta,
2255
+ dayRowCounts: Map<string, number>,
2256
+ ) {
2257
+ const existing = await ctx.db
2258
+ .query("routeDailyRollups")
2259
+ .withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) =>
2260
+ range
2261
+ .eq("siteId", delta.siteId)
2262
+ .eq("route", delta.route)
2263
+ .eq("bucketStart", delta.bucketStart)
2264
+ .eq("shard", delta.shard),
2265
+ )
2266
+ .unique();
2267
+ if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
2268
+ // A new route row spends one bounded read per batch to respect the daily
2269
+ // cap; past it, the day's remaining routes fold into "(other)".
2270
+ const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
2271
+ const dayKey = `${delta.siteId}:${delta.bucketStart}`;
2272
+ let dayRows = dayRowCounts.get(dayKey);
2273
+ if (dayRows === undefined) {
2274
+ dayRows = (
2275
+ await ctx.db
2276
+ .query("routeDailyRollups")
2277
+ .withIndex("by_siteId_and_bucketStart", (range) =>
2278
+ range
2279
+ .eq("siteId", delta.siteId)
2280
+ .eq("bucketStart", delta.bucketStart),
2281
+ )
2282
+ .take(capRows + 1)
2283
+ ).length;
2284
+ }
2285
+ if (dayRows >= capRows) {
2286
+ await upsertRouteRollup(
2287
+ ctx,
2288
+ { ...delta, route: SITE_MAP_OTHER_ROUTE },
2289
+ dayRowCounts,
2290
+ );
2291
+ return;
2292
+ }
2293
+ dayRowCounts.set(dayKey, dayRows + 1);
2294
+ }
2295
+ const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
2296
+ for (const [target, count] of delta.outgoing) {
2297
+ let entry = outgoing.find((candidate) => candidate.route === target);
2298
+ if (!entry) {
2299
+ const named = outgoing.filter(
2300
+ (candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE,
2301
+ ).length;
2302
+ const route =
2303
+ target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
2304
+ ? SITE_MAP_OTHER_ROUTE
2305
+ : target;
2306
+ entry = outgoing.find((candidate) => candidate.route === route);
2307
+ if (!entry) {
2308
+ entry = { route, count: 0 };
2309
+ outgoing.push(entry);
2310
+ }
2311
+ }
2312
+ entry.count += count;
2313
+ }
2314
+ const fields = {
2315
+ siteId: delta.siteId,
2316
+ bucketStart: delta.bucketStart,
2317
+ shard: delta.shard,
2318
+ generation: 1 as const,
2319
+ route: delta.route,
2320
+ pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
2321
+ entries: (existing?.entries ?? 0) + delta.entries,
2322
+ continued: (existing?.continued ?? 0) + delta.continued,
2323
+ dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
2324
+ dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
2325
+ visitorSketch: unionVisitorSketches(
2326
+ existing?.visitorSketch ?? createVisitorSketch(),
2327
+ delta.visitorSketch,
2328
+ ),
2329
+ outgoing,
2330
+ };
2331
+ if (existing) {
2332
+ await ctx.db.replace("routeDailyRollups", existing._id, fields);
2333
+ } else {
2334
+ await ctx.db.insert("routeDailyRollups", fields);
2335
+ }
2336
+ }
2337
+
2338
+ async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
2339
+ const existing = await ctx.db
2340
+ .query("vitalRollups")
2341
+ .withIndex("by_key", (range) =>
2342
+ range
2343
+ .eq("siteId", delta.siteId)
2344
+ .eq("bucketStart", delta.bucketStart)
2345
+ .eq("device", delta.device)
2346
+ .eq("page", delta.page)
2347
+ .eq("shard", delta.shard),
2348
+ )
2349
+ .unique();
2350
+ if (
2351
+ !existing &&
2352
+ delta.device === VITAL_ALL &&
2353
+ delta.page !== VITAL_OTHER_PAGES
2354
+ ) {
2355
+ // A new page row spends one bounded read to respect the daily page slots;
2356
+ // past the cap, the day's remaining pages fold into "(other)".
2357
+ const pageRows = await ctx.db
2358
+ .query("vitalRollups")
2359
+ .withIndex("by_key", (range) =>
2360
+ range
2361
+ .eq("siteId", delta.siteId)
2362
+ .eq("bucketStart", delta.bucketStart)
2363
+ .eq("device", VITAL_ALL),
2364
+ )
2365
+ .take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
2366
+ if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
2367
+ await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
2368
+ return;
2369
+ }
2370
+ }
2371
+ const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
2372
+ for (const [metric, addition] of delta.metrics) {
2373
+ const entry = metrics.find((candidate) => candidate.metric === metric);
2374
+ if (entry) {
2375
+ entry.count += addition.count;
2376
+ entry.sum += addition.sum;
2377
+ entry.histogram = mergeVitalHistograms(
2378
+ entry.histogram,
2379
+ addition.histogram,
2380
+ );
2381
+ } else {
2382
+ metrics.push({
2383
+ metric,
2384
+ count: addition.count,
2385
+ sum: addition.sum,
2386
+ histogram: [...addition.histogram],
2387
+ });
2388
+ }
2389
+ }
2390
+ const fields = {
2391
+ siteId: delta.siteId,
2392
+ bucketStart: delta.bucketStart,
2393
+ device: delta.device,
2394
+ page: delta.page,
2395
+ shard: delta.shard,
2396
+ metrics,
2397
+ };
2398
+ if (existing) {
2399
+ await ctx.db.replace("vitalRollups", existing._id, fields);
2400
+ } else {
2401
+ await ctx.db.insert("vitalRollups", fields);
2402
+ }
2403
+ }
2404
+
2405
+ async function flushReportRollups(
2406
+ ctx: MutationCtx,
2407
+ deltas: ReportRollupDeltas,
2408
+ ) {
2409
+ for (const delta of deltas.goals.values()) {
2410
+ const existing = await ctx.db
2411
+ .query("goalDailyRollups")
2412
+ .withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) =>
2413
+ range
2414
+ .eq("siteId", delta.siteId)
2415
+ .eq("goalId", delta.goalId)
2416
+ .eq("bucketStart", delta.bucketStart)
2417
+ .eq("shard", delta.shard),
2418
+ )
2419
+ .unique();
2420
+ const fields = {
2421
+ siteId: delta.siteId,
2422
+ goalId: delta.goalId,
2423
+ bucketStart: delta.bucketStart,
2424
+ shard: delta.shard,
2425
+ generation: 1 as const,
2426
+ goalKey: delta.goalKey,
2427
+ completions: (existing?.completions ?? 0) + delta.completions,
2428
+ valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
2429
+ };
2430
+ if (existing) {
2431
+ await ctx.db.replace("goalDailyRollups", existing._id, fields);
2432
+ } else {
2433
+ await ctx.db.insert("goalDailyRollups", fields);
2434
+ }
2435
+ }
2436
+ for (const delta of deltas.funnels.values()) {
2437
+ const existing = await ctx.db
2438
+ .query("funnelDailyRollups")
2439
+ .withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) =>
2440
+ range
2441
+ .eq("siteId", delta.siteId)
2442
+ .eq("funnelId", delta.funnelId)
2443
+ .eq("bucketStart", delta.bucketStart)
2444
+ .eq("shard", delta.shard),
2445
+ )
2446
+ .unique();
2447
+ const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
2448
+ for (const addition of delta.steps.values()) {
2449
+ const current = steps.find((step) => step.step === addition.step);
2450
+ if (current) {
2451
+ current.entrants += addition.entrants;
2452
+ current.completions += addition.completions;
2453
+ } else {
2454
+ if (steps.length >= MAX_FUNNEL_STEPS) {
2455
+ fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
2456
+ }
2457
+ steps.push(addition);
2458
+ }
2459
+ }
2460
+ const fields = {
2461
+ siteId: delta.siteId,
2462
+ funnelId: delta.funnelId,
2463
+ bucketStart: delta.bucketStart,
2464
+ shard: delta.shard,
2465
+ generation: 1 as const,
2466
+ steps,
2467
+ };
2468
+ if (existing) {
2469
+ await ctx.db.replace("funnelDailyRollups", existing._id, fields);
2470
+ } else {
2471
+ await ctx.db.insert("funnelDailyRollups", fields);
2472
+ }
2473
+ }
2474
+ for (const delta of deltas.affiliates.values()) {
2475
+ const existing = await ctx.db
2476
+ .query("affiliateDailyRollups")
2477
+ .withIndex(
2478
+ "by_siteId_and_affiliateId_and_bucketStart_and_shard",
2479
+ (range) =>
2480
+ range
2481
+ .eq("siteId", delta.siteId)
2482
+ .eq("affiliateId", delta.affiliateId)
2483
+ .eq("bucketStart", delta.bucketStart)
2484
+ .eq("shard", delta.shard),
2485
+ )
2486
+ .unique();
2487
+ const fields = {
2488
+ siteId: delta.siteId,
2489
+ affiliateId: delta.affiliateId,
2490
+ bucketStart: delta.bucketStart,
2491
+ shard: delta.shard,
2492
+ generation: 1 as const,
2493
+ affiliateSlug: delta.affiliateSlug,
2494
+ visits: (existing?.visits ?? 0) + delta.visits,
2495
+ visitorSketch: unionVisitorSketches(
2496
+ existing?.visitorSketch ?? createVisitorSketch(),
2497
+ delta.visitorSketch,
2498
+ ),
2499
+ conversions: (existing?.conversions ?? 0) + delta.conversions,
2500
+ revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
2501
+ commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
2502
+ };
2503
+ if (existing) {
2504
+ await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
2505
+ } else {
2506
+ await ctx.db.insert("affiliateDailyRollups", fields);
2507
+ }
2508
+ }
2509
+ const routeDayRowCounts = new Map<string, number>();
2510
+ for (const delta of deltas.routes.values()) {
2511
+ await upsertRouteRollup(ctx, delta, routeDayRowCounts);
2512
+ }
2513
+ for (const delta of deltas.vitals.values()) {
2514
+ await upsertVitalRollup(ctx, delta);
2515
+ }
2516
+ }
2517
+
1192
2518
  function foldAggregates(
1193
2519
  aggregateDeltas: AggregateDeltas,
1194
2520
  siteId: Id<"sites">,
@@ -1198,16 +2524,16 @@ function foldAggregates(
1198
2524
  recordTelemetry: boolean,
1199
2525
  financialConversion: boolean,
1200
2526
  ) {
1201
- const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
1202
- const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
1203
- const dimensionDeltas = dimensions.map(
1204
- (dimension): DimensionDelta => ({
1205
- ...dimension,
1206
- count: Number(recordTelemetry),
1207
- revenueCents,
1208
- }),
1209
- );
2527
+ const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
2528
+ const dimensionDeltas = dimensions.map((dimension): DimensionDelta => ({
2529
+ ...dimension,
2530
+ count: Number(recordTelemetry),
2531
+ revenueCents,
2532
+ }));
1210
2533
  for (const granularity of ["hour", "day"] as const) {
2534
+ const shard =
2535
+ stableHash(event.sessionId) %
2536
+ (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
1211
2537
  const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
1212
2538
  const bucketStart = Math.floor(event.timestamp / interval) * interval;
1213
2539
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
@@ -1225,6 +2551,7 @@ function foldAggregates(
1225
2551
  outboundClicks: 0,
1226
2552
  sessions: 0,
1227
2553
  visitors: 0,
2554
+ visitorSketch: createVisitorSketch(),
1228
2555
  conversions: 0,
1229
2556
  revenueCents: 0,
1230
2557
  dimensions: [],
@@ -1245,6 +2572,9 @@ function foldAggregates(
1245
2572
  ? Number(session.isNewVisitorHour)
1246
2573
  : Number(session.isNewVisitorDay)
1247
2574
  : 0;
2575
+ if (recordTelemetry) {
2576
+ delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
2577
+ }
1248
2578
  delta.conversions += Number(financialConversion);
1249
2579
  delta.revenueCents += revenueCents;
1250
2580
  delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
@@ -1272,6 +2602,13 @@ async function flushAggregates(
1272
2602
  bucket?.dimensions ?? [],
1273
2603
  delta.dimensions,
1274
2604
  );
2605
+ const priorSketch = bucket?.visitorSketch;
2606
+ const visitorSketch = priorSketch
2607
+ ? unionVisitorSketches(priorSketch, delta.visitorSketch)
2608
+ : delta.visitorSketch;
2609
+ const visitorSketchComplete = bucket
2610
+ ? bucket.visitorSketchComplete === true && priorSketch !== undefined
2611
+ : true;
1275
2612
  if (bucket) {
1276
2613
  await ctx.db.patch("aggregateBuckets", bucket._id, {
1277
2614
  events: bucket.events + delta.events,
@@ -1281,6 +2618,8 @@ async function flushAggregates(
1281
2618
  outboundClicks: bucket.outboundClicks + delta.outboundClicks,
1282
2619
  sessions: bucket.sessions + delta.sessions,
1283
2620
  visitors: bucket.visitors + delta.visitors,
2621
+ visitorSketch,
2622
+ visitorSketchComplete,
1284
2623
  conversions: bucket.conversions + delta.conversions,
1285
2624
  revenueCents: bucket.revenueCents + delta.revenueCents,
1286
2625
  dimensions,
@@ -1298,6 +2637,8 @@ async function flushAggregates(
1298
2637
  outboundClicks: delta.outboundClicks,
1299
2638
  sessions: delta.sessions,
1300
2639
  visitors: delta.visitors,
2640
+ visitorSketch,
2641
+ visitorSketchComplete,
1301
2642
  conversions: delta.conversions,
1302
2643
  revenueCents: delta.revenueCents,
1303
2644
  dimensions,
@@ -1325,11 +2666,8 @@ function mergeDimensions(
1325
2666
  const typeSlots = result.filter(
1326
2667
  (candidate) => candidate.type === addition.type,
1327
2668
  );
1328
- if (typeSlots.length < DIMENSION_SLOTS) {
1329
- const occupied = Array.from(
1330
- { length: DIMENSION_SLOTS },
1331
- () => false,
1332
- );
2669
+ if (typeSlots.length < DIMENSION_SLOTS - 1) {
2670
+ const occupied = Array.from({ length: DIMENSION_SLOTS }, () => false);
1333
2671
  for (const candidate of typeSlots) occupied[candidate.slot] = true;
1334
2672
  let slot = stableHash(addition.value) % DIMENSION_SLOTS;
1335
2673
  while (occupied[slot]) slot = (slot + 1) % DIMENSION_SLOTS;
@@ -1342,15 +2680,36 @@ function mergeDimensions(
1342
2680
  });
1343
2681
  continue;
1344
2682
  }
1345
- if (addition.count === 0) continue;
2683
+ const overflow = typeSlots.find(
2684
+ (candidate) => candidate.value === "(other)",
2685
+ );
2686
+ if (overflow) {
2687
+ overflow.count += addition.count;
2688
+ overflow.revenueCents += addition.revenueCents;
2689
+ continue;
2690
+ }
2691
+ if (addition.count === 0 && addition.revenueCents === 0) continue;
2692
+ if (typeSlots.length < DIMENSION_SLOTS) {
2693
+ const occupied = new Set(typeSlots.map((candidate) => candidate.slot));
2694
+ let slot = stableHash("(other)") % DIMENSION_SLOTS;
2695
+ while (occupied.has(slot)) slot = (slot + 1) % DIMENSION_SLOTS;
2696
+ result.push({
2697
+ type: addition.type,
2698
+ slot,
2699
+ value: "(other)",
2700
+ count: addition.count,
2701
+ revenueCents: addition.revenueCents,
2702
+ });
2703
+ continue;
2704
+ }
1346
2705
 
1347
2706
  let replacement = typeSlots[0];
1348
2707
  for (const candidate of typeSlots.slice(1)) {
1349
2708
  if (candidate.count < replacement.count) replacement = candidate;
1350
2709
  }
1351
- replacement.value = addition.value;
2710
+ replacement.value = "(other)";
1352
2711
  replacement.count += addition.count;
1353
- replacement.revenueCents = addition.revenueCents;
2712
+ replacement.revenueCents += addition.revenueCents;
1354
2713
  }
1355
2714
  return result;
1356
2715
  }
@@ -1360,5 +2719,7 @@ function stringProperty(
1360
2719
  key: string,
1361
2720
  ): string | undefined {
1362
2721
  const value = properties?.[key];
1363
- return typeof value === "string" ? cleanString(value, 120) || undefined : undefined;
2722
+ return typeof value === "string"
2723
+ ? cleanString(value, 120) || undefined
2724
+ : undefined;
1364
2725
  }