@iann29/rastro 0.1.0-alpha.1 → 0.1.0-alpha.11

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 (179) hide show
  1. package/README.md +693 -69
  2. package/agent/integration.md +801 -0
  3. package/agent/manifest.json +205 -0
  4. package/agent/manifest.schema.json +444 -0
  5. package/dist/client/federation.d.ts +381 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +274 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +2673 -15
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +565 -33
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +22 -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 +315 -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 +28 -1
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +44 -1
  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 +27 -0
  36. package/dist/component/coverage.d.ts.map +1 -0
  37. package/dist/component/coverage.js +56 -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 +21 -9
  47. package/dist/component/eventStore.d.ts.map +1 -1
  48. package/dist/component/eventStore.js +142 -152
  49. package/dist/component/eventStore.js.map +1 -1
  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 +73 -0
  54. package/dist/component/geo.d.ts.map +1 -0
  55. package/dist/component/geo.js +648 -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 +8 -5
  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 +281 -62
  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 +498 -95
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts.map +1 -1
  74. package/dist/component/live.js +7 -6
  75. package/dist/component/live.js.map +1 -1
  76. package/dist/component/localTime.d.ts +25 -0
  77. package/dist/component/localTime.d.ts.map +1 -0
  78. package/dist/component/localTime.js +126 -0
  79. package/dist/component/localTime.js.map +1 -0
  80. package/dist/component/reports.d.ts +266 -10
  81. package/dist/component/reports.d.ts.map +1 -1
  82. package/dist/component/reports.js +1243 -137
  83. package/dist/component/reports.js.map +1 -1
  84. package/dist/component/retention.d.ts +75 -1
  85. package/dist/component/retention.d.ts.map +1 -1
  86. package/dist/component/retention.js +561 -38
  87. package/dist/component/retention.js.map +1 -1
  88. package/dist/component/rollupStore.d.ts +320 -0
  89. package/dist/component/rollupStore.d.ts.map +1 -0
  90. package/dist/component/rollupStore.js +596 -0
  91. package/dist/component/rollupStore.js.map +1 -0
  92. package/dist/component/rollups.d.ts +20 -0
  93. package/dist/component/rollups.d.ts.map +1 -0
  94. package/dist/component/rollups.js +73 -0
  95. package/dist/component/rollups.js.map +1 -0
  96. package/dist/component/sanitize.d.ts +24 -1
  97. package/dist/component/sanitize.d.ts.map +1 -1
  98. package/dist/component/sanitize.js +96 -16
  99. package/dist/component/sanitize.js.map +1 -1
  100. package/dist/component/schema.d.ts +687 -65
  101. package/dist/component/schema.js +187 -20
  102. package/dist/component/schema.js.map +1 -1
  103. package/dist/component/sites.d.ts +12 -0
  104. package/dist/component/sites.d.ts.map +1 -1
  105. package/dist/component/sites.js +41 -7
  106. package/dist/component/sites.js.map +1 -1
  107. package/dist/component/useragent.d.ts +9 -0
  108. package/dist/component/useragent.d.ts.map +1 -0
  109. package/dist/component/useragent.js +152 -0
  110. package/dist/component/useragent.js.map +1 -0
  111. package/dist/component/validators.d.ts +124 -69
  112. package/dist/component/validators.d.ts.map +1 -1
  113. package/dist/component/validators.js +35 -14
  114. package/dist/component/validators.js.map +1 -1
  115. package/dist/component/visitors.d.ts +19 -0
  116. package/dist/component/visitors.d.ts.map +1 -0
  117. package/dist/component/visitors.js +86 -0
  118. package/dist/component/visitors.js.map +1 -0
  119. package/dist/component/vitals.d.ts +41 -0
  120. package/dist/component/vitals.d.ts.map +1 -0
  121. package/dist/component/vitals.js +115 -0
  122. package/dist/component/vitals.js.map +1 -0
  123. package/dist/react/index.d.ts.map +1 -1
  124. package/dist/react/index.js.map +1 -1
  125. package/dist/tracker/generated.d.ts +11 -4
  126. package/dist/tracker/generated.d.ts.map +1 -1
  127. package/dist/tracker/generated.js +11 -4
  128. package/dist/tracker/generated.js.map +1 -1
  129. package/dist/tracker/tracker.d.ts +1 -1
  130. package/dist/tracker/tracker.d.ts.map +1 -1
  131. package/dist/tracker/tracker.js +57 -20
  132. package/dist/tracker/tracker.js.map +1 -1
  133. package/dist/tracker/vitals.d.ts +10 -0
  134. package/dist/tracker/vitals.d.ts.map +1 -0
  135. package/dist/tracker/vitals.js +140 -0
  136. package/dist/tracker/vitals.js.map +1 -0
  137. package/dist/tracker.min.js +1 -1
  138. package/dist/vitals.min.js +1 -0
  139. package/docs/benchmarks/2026-08-20-realistic.md +76 -76
  140. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  141. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  142. package/docs/federation-setup.md +464 -0
  143. package/docs/federation.md +352 -0
  144. package/docs/upgrading.md +344 -0
  145. package/llms.txt +72 -0
  146. package/package.json +55 -11
  147. package/scripts/benchmark-ingest.mjs +175 -73
  148. package/scripts/generate-federation-keys.mjs +20 -0
  149. package/src/component/_generated/api.ts +22 -0
  150. package/src/component/_generated/component.ts +381 -4
  151. package/src/component/_generated/server.ts +4 -0
  152. package/src/component/affiliates.ts +20 -5
  153. package/src/component/cardinality.ts +117 -0
  154. package/src/component/constants.ts +44 -1
  155. package/src/component/convex.config.ts +11 -1
  156. package/src/component/coverage.ts +71 -0
  157. package/src/component/diagnostics.ts +65 -0
  158. package/src/component/errors.ts +2 -1
  159. package/src/component/eventStore.ts +217 -193
  160. package/src/component/funnels.ts +19 -16
  161. package/src/component/geo.ts +835 -0
  162. package/src/component/goals.ts +29 -21
  163. package/src/component/guards.ts +3 -1
  164. package/src/component/http.ts +404 -70
  165. package/src/component/identity.ts +74 -0
  166. package/src/component/ingest.ts +894 -188
  167. package/src/component/live.ts +13 -7
  168. package/src/component/localTime.ts +167 -0
  169. package/src/component/reports.ts +1872 -197
  170. package/src/component/retention.ts +788 -96
  171. package/src/component/rollupStore.ts +799 -0
  172. package/src/component/rollups.ts +82 -0
  173. package/src/component/sanitize.ts +144 -29
  174. package/src/component/schema.ts +217 -21
  175. package/src/component/sites.ts +59 -12
  176. package/src/component/useragent.ts +171 -0
  177. package/src/component/validators.ts +49 -14
  178. package/src/component/visitors.ts +116 -0
  179. package/src/component/vitals.ts +146 -0
@@ -10,6 +10,7 @@ import {
10
10
  HOURLY_AGGREGATE_SHARDS,
11
11
  DIMENSION_SLOTS,
12
12
  HOUR_MS,
13
+ LIVE_LEAVE_GRACE_MS,
13
14
  LIVE_SESSION_TTL_MS,
14
15
  LIVE_SWEEP_INTERVAL_MS,
15
16
  MAX_BATCH_EVENTS,
@@ -20,10 +21,25 @@ import {
20
21
  MAX_FUNNELS_PER_SITE,
21
22
  MAX_GOALS_PER_SITE,
22
23
  RATE_LIMIT_WINDOW_MS,
24
+ REPORT_ROLLUP_SHARDS,
23
25
  SECONDARY_AGGREGATE_SHARDS,
24
26
  SITE_INGEST_SHARDS,
25
27
  } from "./constants.js";
28
+ import {
29
+ addVisitor,
30
+ createVisitorSketch,
31
+ unionVisitorSketches,
32
+ type VisitorSketch,
33
+ } from "./cardinality.js";
34
+ import {
35
+ addToVitalHistogram,
36
+ emptyVitalHistogram,
37
+ vitalDevice,
38
+ VITAL_ALL,
39
+ type VitalMetric,
40
+ } from "./vitals.js";
26
41
  import { fail } from "./errors.js";
42
+ import { ensureAnalyticsControl } from "./coverage.js";
27
43
  import {
28
44
  bufferEventBatch,
29
45
  eventBatchKey,
@@ -36,8 +52,9 @@ import {
36
52
  } from "./eventStore.js";
37
53
  import {
38
54
  cleanString,
39
- ensureBatchBytes,
40
55
  isValidMoney,
56
+ measureBatchBytes,
57
+ normalizeRoute,
41
58
  originAllowed,
42
59
  sanitizeContext,
43
60
  sanitizeCurrency,
@@ -57,9 +74,18 @@ import {
57
74
  type IngestContext,
58
75
  type TrackerEvent,
59
76
  } from "./validators.js";
77
+ import {
78
+ createReportRollupDeltas,
79
+ enqueueReportRollups,
80
+ reportRollupCoordinates,
81
+ type ReportRollupDeltas,
82
+ type RouteRollupDelta,
83
+ } from "./rollupStore.js";
84
+ import { localDayStart, localDayTimezone } from "./localTime.js";
60
85
 
61
86
  type DimensionType =
62
87
  | "source"
88
+ | "campaign"
63
89
  | "page"
64
90
  | "country"
65
91
  | "device"
@@ -68,10 +94,15 @@ type DimensionType =
68
94
  | "goal";
69
95
 
70
96
  type Dimension = { type: DimensionType; value: string };
71
- type DimensionDelta = Dimension & { count: number; revenueCents: number };
97
+ type DimensionDelta = Dimension & {
98
+ count: number;
99
+ revenueCents: number;
100
+ pageviews: number;
101
+ sessions: number;
102
+ };
72
103
  type AggregateDelta = {
73
104
  siteId: Id<"sites">;
74
- granularity: "hour" | "day";
105
+ granularity: "hour" | "day" | "localDay";
75
106
  bucketStart: number;
76
107
  shard: number;
77
108
  events: number;
@@ -81,9 +112,13 @@ type AggregateDelta = {
81
112
  outboundClicks: number;
82
113
  sessions: number;
83
114
  visitors: number;
115
+ visitorSketch: VisitorSketch;
84
116
  conversions: number;
85
117
  revenueCents: number;
86
118
  dimensions: Doc<"aggregateBuckets">["dimensions"];
119
+ durationMs: number;
120
+ continuedSessions: number;
121
+ newVisitors: number;
87
122
  };
88
123
  type AggregateDeltas = Map<string, AggregateDelta>;
89
124
  type AttributionSnapshot = {
@@ -97,8 +132,20 @@ type SessionState = {
97
132
  isNewSession: boolean;
98
133
  isNewVisitorHour: boolean;
99
134
  isNewVisitorDay: boolean;
135
+ /** The session is new and its visitor has no earlier session or alias. */
136
+ isNewVisitor: boolean;
137
+ /** How much this event grew the session's duration. */
138
+ durationDeltaMs: number;
139
+ /** This event is the session's second pageview: it did not bounce. */
140
+ continued: boolean;
100
141
  startedAt: number;
101
142
  source: string;
143
+ visitorKey: string;
144
+ utmCampaign?: string;
145
+ country?: string;
146
+ city?: string;
147
+ latitude?: number;
148
+ longitude?: number;
102
149
  pageviewCount: number;
103
150
  eventCount: number;
104
151
  affiliateId?: Id<"affiliates">;
@@ -121,8 +168,9 @@ type CachedRow<T> = {
121
168
  dirty: boolean;
122
169
  };
123
170
 
124
- type CachedDocument<TableName extends keyof import("./_generated/dataModel.js").DataModel> =
125
- Omit<Doc<TableName>, "_creationTime"> & { _creationTime?: number };
171
+ type CachedDocument<
172
+ TableName extends keyof import("./_generated/dataModel.js").DataModel,
173
+ > = Omit<Doc<TableName>, "_creationTime"> & { _creationTime?: number };
126
174
 
127
175
  type BatchState = {
128
176
  sessions: Map<string, CachedRow<CachedDocument<"sessions">>>;
@@ -153,25 +201,37 @@ export const ingestBatch = mutation({
153
201
  });
154
202
  }
155
203
  if (args.events.length < 1 || args.events.length > MAX_BATCH_EVENTS) {
156
- fail("BATCH_TOO_LARGE", `events must contain 1-${MAX_BATCH_EVENTS} items`, {
157
- limit: MAX_BATCH_EVENTS,
158
- });
204
+ fail(
205
+ "BATCH_TOO_LARGE",
206
+ `events must contain 1-${MAX_BATCH_EVENTS} items`,
207
+ {
208
+ limit: MAX_BATCH_EVENTS,
209
+ },
210
+ );
159
211
  }
212
+ let eventByteCounts: number[];
160
213
  try {
161
- ensureBatchBytes({ siteId: String(args.siteId), events: args.events });
214
+ ({ eventByteCounts } = measureBatchBytes(
215
+ String(args.siteId),
216
+ args.events,
217
+ ));
162
218
  } catch {
163
219
  fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
164
220
  }
165
221
 
166
222
  const now = Date.now();
167
- const eventByteCounts = await consumeSiteRateLimit(
223
+ await ensureAnalyticsControl(ctx, args.siteId, now);
224
+ await consumeSiteRateLimit(
168
225
  ctx,
169
226
  args.siteId,
170
227
  args.events,
228
+ eventByteCounts,
171
229
  now,
172
230
  );
173
231
  const context = sanitizeContext(args.context);
232
+ const timezone = localDayTimezone(site.timezone);
174
233
  const aggregateDeltas: AggregateDeltas = new Map();
234
+ const reportRollupDeltas = createReportRollupDeltas();
175
235
  const sessionRateWindows = new Map<string, SessionRateWindowState>();
176
236
  const batchState: BatchState = {
177
237
  sessions: new Map(),
@@ -206,7 +266,12 @@ export const ingestBatch = mutation({
206
266
  const bucketStart = eventBucketStart(event.timestamp);
207
267
  const groupKey = eventBatchKey(event.sessionId, bucketStart);
208
268
  requestGroups.add(groupKey);
209
- if (event.type === "heartbeat") continue;
269
+ if (
270
+ event.type === "heartbeat" ||
271
+ event.type === "vital" ||
272
+ event.type === "leave"
273
+ )
274
+ continue;
210
275
  const eventIds = eventIdsByGroup.get(groupKey) ?? new Set<string>();
211
276
  if (eventIds.has(event.eventId)) {
212
277
  duplicateInBatch.add(eventIndex);
@@ -236,10 +301,9 @@ export const ingestBatch = mutation({
236
301
  siteId: args.siteId,
237
302
  sessionId: group.sessionId,
238
303
  bucketStart: group.bucketStart,
239
- candidateEventIds: group.eventIds,
240
304
  });
241
- const newCount = [...group.eventIds].filter((eventId) =>
242
- !eventIds.has(eventId)
305
+ const newCount = [...group.eventIds].filter(
306
+ (eventId) => !eventIds.has(eventId),
243
307
  ).length;
244
308
  if (
245
309
  newCount > 0 &&
@@ -278,12 +342,7 @@ export const ingestBatch = mutation({
278
342
  continue;
279
343
  }
280
344
  const liveSession = (
281
- await loadLiveSession(
282
- ctx,
283
- args.siteId,
284
- event.sessionId,
285
- batchState,
286
- )
345
+ await loadLiveSession(ctx, args.siteId, event.sessionId, batchState)
287
346
  ).current;
288
347
  if (
289
348
  liveSession &&
@@ -306,7 +365,7 @@ export const ingestBatch = mutation({
306
365
  rejected += 1;
307
366
  continue;
308
367
  }
309
- await accountHeartbeat(
368
+ const durationDeltaMs = await accountHeartbeat(
310
369
  ctx,
311
370
  args.siteId,
312
371
  existingSession,
@@ -315,6 +374,90 @@ export const ingestBatch = mutation({
315
374
  now,
316
375
  batchState,
317
376
  );
377
+ // A heartbeat is presence, not telemetry, but it is what keeps a
378
+ // session's duration honest while the visitor reads: the growth is
379
+ // folded into the buckets the heartbeat landed in.
380
+ foldSessionDuration(
381
+ aggregateDeltas,
382
+ args.siteId,
383
+ event,
384
+ durationDeltaMs,
385
+ timezone,
386
+ );
387
+ accepted += 1;
388
+ continue;
389
+ }
390
+
391
+ if (event.type === "leave") {
392
+ // A departure only shortens live presence: it stores no telemetry,
393
+ // touches no aggregates, and never extends anything, so a replayed
394
+ // or late leave is inert. The read side filters by expiresAt and the
395
+ // sweep deletes the row, so the dashboard sees the exit in seconds.
396
+ if (!existingSession) {
397
+ rejected += 1;
398
+ continue;
399
+ }
400
+ if (
401
+ !(await consumeRateLimit(
402
+ ctx,
403
+ args.siteId,
404
+ event.sessionId,
405
+ eventByteCounts[eventIndex],
406
+ now,
407
+ sessionRateWindows,
408
+ ))
409
+ ) {
410
+ rejected += 1;
411
+ continue;
412
+ }
413
+ const liveState = await loadLiveSession(
414
+ ctx,
415
+ args.siteId,
416
+ event.sessionId,
417
+ batchState,
418
+ );
419
+ const liveSession = liveState.current;
420
+ if (liveSession) {
421
+ const expiresAt = Math.min(
422
+ liveSession.expiresAt,
423
+ now + LIVE_LEAVE_GRACE_MS,
424
+ );
425
+ if (expiresAt < liveSession.expiresAt) {
426
+ await ctx.db.patch("liveSessions", liveSession._id, { expiresAt });
427
+ liveState.current = { ...liveSession, expiresAt };
428
+ }
429
+ await ensureLiveSweep(ctx, args.siteId, now, batchState);
430
+ }
431
+ accepted += 1;
432
+ continue;
433
+ }
434
+
435
+ if (event.type === "vital") {
436
+ // A vital annotates telemetry its session already produced; it never
437
+ // creates sessions, live presence, event batches, or aggregate counts.
438
+ if (!existingSession) {
439
+ rejected += 1;
440
+ continue;
441
+ }
442
+ if (
443
+ !(await consumeRateLimit(
444
+ ctx,
445
+ args.siteId,
446
+ event.sessionId,
447
+ eventByteCounts[eventIndex],
448
+ now,
449
+ sessionRateWindows,
450
+ ))
451
+ ) {
452
+ rejected += 1;
453
+ continue;
454
+ }
455
+ foldVitalRollups(
456
+ reportRollupDeltas,
457
+ args.siteId,
458
+ event,
459
+ vitalDevice(context.device),
460
+ );
318
461
  accepted += 1;
319
462
  continue;
320
463
  }
@@ -322,7 +465,9 @@ export const ingestBatch = mutation({
322
465
  if (
323
466
  duplicateInBatch.has(eventIndex) ||
324
467
  persistedEventIds
325
- .get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
468
+ .get(
469
+ eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)),
470
+ )
326
471
  ?.has(event.eventId)
327
472
  ) {
328
473
  duplicates += 1;
@@ -356,26 +501,34 @@ export const ingestBatch = mutation({
356
501
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
357
502
  .take(MAX_FUNNELS_PER_SITE);
358
503
  }
359
- await accountEvent(ctx, {
504
+ const session = await accountEvent(ctx, {
360
505
  siteId: args.siteId,
361
506
  event,
362
507
  context,
363
508
  existingSession,
364
- goals: canMatchDefinition ? goals ?? [] : [],
365
- funnels: canMatchDefinition ? funnels ?? [] : [],
509
+ goals: canMatchDefinition ? (goals ?? []) : [],
510
+ funnels: canMatchDefinition ? (funnels ?? []) : [],
366
511
  aggregateDeltas,
512
+ reportRollupDeltas,
367
513
  recordTelemetry: true,
514
+ timezone,
368
515
  financialConversion: false,
369
516
  trustedGoalKey: undefined,
370
517
  now,
371
518
  batchState,
372
519
  });
373
- bufferEventBatch(args.siteId, event, batchState.eventBatches);
520
+ bufferEventBatch(
521
+ args.siteId,
522
+ event,
523
+ batchState.eventBatches,
524
+ session.country ?? "unknown",
525
+ );
374
526
  accepted += 1;
375
527
  }
376
528
  await flushBatchState(ctx, batchState);
377
529
  await flushSessionRateLimits(ctx, sessionRateWindows, now);
378
530
  await flushAggregates(ctx, aggregateDeltas);
531
+ await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
379
532
  return { accepted, duplicates, rejected };
380
533
  },
381
534
  });
@@ -387,7 +540,10 @@ export const trackConversion = mutation({
387
540
  const site = await ctx.db.get("sites", args.siteId);
388
541
  if (!site) fail("NOT_FOUND", "site not found");
389
542
  if (!isValidMoney(args.revenueCents)) {
390
- fail("INVALID_ARGUMENT", "revenueCents must be an integer between 0 and 1000000000");
543
+ fail(
544
+ "INVALID_ARGUMENT",
545
+ "revenueCents must be an integer between 0 and 1000000000",
546
+ );
391
547
  }
392
548
  let event: TrackerEvent;
393
549
  let trustedGoalKey: string | undefined;
@@ -412,7 +568,10 @@ export const trackConversion = mutation({
412
568
  Date.now(),
413
569
  );
414
570
  } catch (error) {
415
- fail("INVALID_ARGUMENT", error instanceof Error ? error.message : "invalid conversion");
571
+ fail(
572
+ "INVALID_ARGUMENT",
573
+ error instanceof Error ? error.message : "invalid conversion",
574
+ );
416
575
  }
417
576
 
418
577
  const siteCurrency = site.currency ?? "USD";
@@ -432,10 +591,14 @@ export const trackConversion = mutation({
432
591
  return { accepted: 0, duplicates: 1, rejected: 0 };
433
592
  }
434
593
  if (event.currency !== siteCurrency) {
435
- fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
436
- siteCurrency,
437
- conversionCurrency: event.currency ?? "USD",
438
- });
594
+ fail(
595
+ "CURRENCY_MISMATCH",
596
+ "conversion currency does not match site currency",
597
+ {
598
+ siteCurrency,
599
+ conversionCurrency: event.currency ?? "USD",
600
+ },
601
+ );
439
602
  }
440
603
 
441
604
  const telemetry = await findTelemetry(ctx, args.siteId, event);
@@ -467,7 +630,8 @@ export const trackConversion = mutation({
467
630
  };
468
631
 
469
632
  const aggregateDeltas: AggregateDeltas = new Map();
470
- await accountEvent(ctx, {
633
+ const reportRollupDeltas = createReportRollupDeltas();
634
+ const session = await accountEvent(ctx, {
471
635
  siteId: args.siteId,
472
636
  event,
473
637
  context: conversionContext,
@@ -475,15 +639,25 @@ export const trackConversion = mutation({
475
639
  goals: [],
476
640
  funnels: [],
477
641
  aggregateDeltas,
642
+ reportRollupDeltas,
478
643
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
644
+ timezone: localDayTimezone(site.timezone),
479
645
  financialConversion: true,
480
646
  trustedGoalKey,
481
647
  now: Date.now(),
482
648
  });
483
649
  if (!existingTelemetry && !telemetry.saturated) {
484
- await insertEventBatch(ctx, args.siteId, event);
650
+ await insertEventBatch(
651
+ ctx,
652
+ args.siteId,
653
+ event,
654
+ session.country ?? "unknown",
655
+ );
485
656
  }
486
657
  await flushAggregates(ctx, aggregateDeltas);
658
+ const now = Date.now();
659
+ await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
660
+ await ensureAnalyticsControl(ctx, args.siteId, now);
487
661
  return { accepted: 1, duplicates: 0, rejected: 0 };
488
662
  },
489
663
  });
@@ -586,11 +760,7 @@ async function flushBatchState(ctx: MutationCtx, batchState: BatchState) {
586
760
  function withoutSystemFields<
587
761
  T extends { _id: unknown; _creationTime?: number },
588
762
  >(document: T): Omit<T, "_id" | "_creationTime"> {
589
- const {
590
- _id,
591
- _creationTime,
592
- ...fields
593
- } = document;
763
+ const { _id, _creationTime, ...fields } = document;
594
764
  void _id;
595
765
  void _creationTime;
596
766
  return fields;
@@ -606,13 +776,16 @@ async function accountEvent(
606
776
  goals: Doc<"goals">[];
607
777
  funnels: Doc<"funnels">[];
608
778
  aggregateDeltas: AggregateDeltas;
779
+ reportRollupDeltas: ReportRollupDeltas;
609
780
  recordTelemetry: boolean;
781
+ /** The site's timezone when it keeps local-day buckets. */
782
+ timezone?: string;
610
783
  financialConversion: boolean;
611
784
  trustedGoalKey: string | undefined;
612
785
  now: number;
613
786
  batchState?: BatchState;
614
787
  },
615
- ) {
788
+ ): Promise<SessionState> {
616
789
  const { event } = input;
617
790
  const session = input.recordTelemetry
618
791
  ? await upsertSession(
@@ -622,13 +795,25 @@ async function accountEvent(
622
795
  input.context,
623
796
  input.existingSession,
624
797
  input.financialConversion,
798
+ input.reportRollupDeltas,
625
799
  input.batchState,
626
800
  )
627
801
  : await accountTrustedSession(ctx, input.existingSession, event);
802
+ if (input.recordTelemetry && event.type === "pageview") {
803
+ // `existingSession` is the snapshot from before `upsertSession`, so it
804
+ // still carries the route the visitor is leaving.
805
+ foldRouteRollups(
806
+ input.reportRollupDeltas,
807
+ input.siteId,
808
+ event,
809
+ session,
810
+ input.existingSession,
811
+ );
812
+ }
628
813
 
629
814
  const matchedGoalKeys: string[] = [];
630
- const matchingGoals = input.goals.filter((goal) =>
631
- goal.active && goalMatches(goal, event)
815
+ const matchingGoals = input.goals.filter(
816
+ (goal) => goal.active && goalMatches(goal, event),
632
817
  );
633
818
  if (matchingGoals.length > 1) {
634
819
  fail("LIMIT_EXCEEDED", "active goals must use unique matchers");
@@ -639,33 +824,36 @@ async function accountEvent(
639
824
  eventBucketStart(event.timestamp),
640
825
  event.eventId,
641
826
  ]);
642
- const scopedPrior = (await ctx.db
643
- .query("goalCompletions")
644
- .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
645
- range
646
- .eq("siteId", input.siteId)
647
- .eq("goalId", goal._id)
648
- .eq("eventId", completionEventId),
649
- )
650
- .take(2))
651
- .find((completion) => completion.idempotencyVersion === 2);
827
+ const scopedPrior = (
828
+ await ctx.db
829
+ .query("goalCompletions")
830
+ .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
831
+ range
832
+ .eq("siteId", input.siteId)
833
+ .eq("goalId", goal._id)
834
+ .eq("eventId", completionEventId),
835
+ )
836
+ .take(2)
837
+ ).find((completion) => completion.idempotencyVersion === 2);
652
838
  const legacyPrior = scopedPrior
653
839
  ? undefined
654
- : (await ctx.db
655
- .query("goalCompletions")
656
- .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
657
- range
658
- .eq("siteId", input.siteId)
659
- .eq("goalId", goal._id)
660
- .eq("eventId", event.eventId),
661
- )
662
- .take(2))
663
- .find((completion) =>
840
+ : (
841
+ await ctx.db
842
+ .query("goalCompletions")
843
+ .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
844
+ range
845
+ .eq("siteId", input.siteId)
846
+ .eq("goalId", goal._id)
847
+ .eq("eventId", event.eventId),
848
+ )
849
+ .take(2)
850
+ ).find(
851
+ (completion) =>
664
852
  completion.idempotencyVersion === undefined &&
665
853
  completion.sessionId === event.sessionId &&
666
854
  eventBucketStart(completion.timestamp) ===
667
- eventBucketStart(event.timestamp)
668
- );
855
+ eventBucketStart(event.timestamp),
856
+ );
669
857
  if (scopedPrior || legacyPrior) continue;
670
858
  await ctx.db.insert("goalCompletions", {
671
859
  siteId: input.siteId,
@@ -678,12 +866,20 @@ async function accountEvent(
678
866
  timestamp: event.timestamp,
679
867
  valueCents: goal.valueCents ?? 0,
680
868
  });
869
+ foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
681
870
  matchedGoalKeys.push(goal.key);
682
871
  }
683
872
 
684
873
  for (const funnel of input.funnels) {
685
874
  if (funnel.active) {
686
- await advanceFunnel(ctx, input.siteId, funnel, event, input.batchState);
875
+ await advanceFunnel(
876
+ ctx,
877
+ input.siteId,
878
+ funnel,
879
+ event,
880
+ input.reportRollupDeltas,
881
+ input.batchState,
882
+ );
687
883
  }
688
884
  }
689
885
 
@@ -694,18 +890,23 @@ async function accountEvent(
694
890
  session,
695
891
  event,
696
892
  input.trustedGoalKey,
893
+ input.reportRollupDeltas,
697
894
  );
698
895
  }
699
896
 
897
+ const campaignDimensions: Dimension[] = session.utmCampaign
898
+ ? [{ type: "campaign", value: session.utmCampaign }]
899
+ : [];
700
900
  const dimensions: Dimension[] = [
701
901
  { type: "source", value: session.source },
902
+ ...campaignDimensions,
702
903
  { type: "page", value: event.path },
703
- { type: "country", value: input.context.country ?? "unknown" },
904
+ { type: "country", value: session.country ?? "unknown" },
704
905
  { type: "device", value: input.context.device ?? "unknown" },
705
906
  { type: "browser", value: input.context.browser ?? "Other" },
706
907
  {
707
908
  type: "event",
708
- value: event.type === "custom" ? event.name ?? "custom" : event.type,
909
+ value: event.type === "custom" ? (event.name ?? "custom") : event.type,
709
910
  },
710
911
  ...matchedGoalKeys.map((value): Dimension => ({ type: "goal", value })),
711
912
  ];
@@ -717,6 +918,7 @@ async function accountEvent(
717
918
  dimensions,
718
919
  input.recordTelemetry,
719
920
  input.financialConversion,
921
+ input.timezone,
720
922
  );
721
923
 
722
924
  if (
@@ -733,6 +935,7 @@ async function accountEvent(
733
935
  input.batchState,
734
936
  );
735
937
  }
938
+ return session;
736
939
  }
737
940
 
738
941
  async function accountTrustedSession(
@@ -753,8 +956,17 @@ async function accountTrustedSession(
753
956
  isNewSession: false,
754
957
  isNewVisitorHour: false,
755
958
  isNewVisitorDay: false,
959
+ isNewVisitor: false,
960
+ durationDeltaMs: 0,
961
+ continued: false,
756
962
  startedAt: existing.startedAt,
757
963
  source: existing.source,
964
+ utmCampaign: existing.utmCampaign,
965
+ visitorKey: existing.visitorKey ?? existing.visitorId,
966
+ country: existing.country,
967
+ city: existing.city,
968
+ latitude: existing.latitude,
969
+ longitude: existing.longitude,
758
970
  pageviewCount: existing.pageviewCount,
759
971
  eventCount: existing.eventCount,
760
972
  affiliateId: existing.affiliateId,
@@ -771,10 +983,19 @@ async function upsertSession(
771
983
  context: IngestContext,
772
984
  existing: CachedDocument<"sessions"> | null,
773
985
  financialConversion: boolean,
986
+ reportRollupDeltas: ReportRollupDeltas,
774
987
  batchState?: BatchState,
775
988
  ): Promise<SessionState> {
776
- const source = sourceFromReferrer(event.referrer);
989
+ // A host-supplied id is the identity; anonymous sessions count through the
990
+ // daily key derived by the HTTP action, or once per session without one.
991
+ const visitorKey =
992
+ existing?.visitorKey ??
993
+ (event.visitorId !== event.sessionId
994
+ ? event.visitorId
995
+ : (context.visitorKey ?? event.sessionId));
777
996
  const utmSource = stringProperty(event.properties, "utm_source");
997
+ // Campaign traffic names its own source; the referrer host is the fallback.
998
+ const source = utmSource?.toLowerCase() ?? sourceFromReferrer(event.referrer);
778
999
  const utmMedium = stringProperty(event.properties, "utm_medium");
779
1000
  const utmCampaign = stringProperty(event.properties, "utm_campaign");
780
1001
  const attribution = await attributionForEvent(
@@ -785,20 +1006,40 @@ async function upsertSession(
785
1006
  batchState,
786
1007
  );
787
1008
  if (existing) {
1009
+ if (attribution && existing.affiliateId !== attribution.affiliateId) {
1010
+ foldAffiliateVisitRollup(
1011
+ reportRollupDeltas,
1012
+ siteId,
1013
+ event,
1014
+ attribution,
1015
+ visitorKey,
1016
+ );
1017
+ }
788
1018
  const startedAt = Math.min(existing.startedAt, event.timestamp);
789
1019
  const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
790
1020
  const updates = {
791
1021
  startedAt,
792
1022
  lastSeenAt,
793
- entryPath: event.timestamp < existing.startedAt ? event.path : existing.entryPath,
794
- exitPath: event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
1023
+ entryPath:
1024
+ event.timestamp < existing.startedAt ? event.path : existing.entryPath,
1025
+ exitPath:
1026
+ event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
795
1027
  eventCount: existing.eventCount + 1,
796
- pageviewCount: existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
1028
+ pageviewCount:
1029
+ existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
1030
+ ...(event.type === "pageview"
1031
+ ? {
1032
+ lastPageviewAt: Math.max(
1033
+ existing.lastPageviewAt ?? 0,
1034
+ event.timestamp,
1035
+ ),
1036
+ }
1037
+ : {}),
797
1038
  durationMs: Math.max(0, lastSeenAt - startedAt),
798
1039
  conversionCount: existing.conversionCount + Number(financialConversion),
799
1040
  revenueCents:
800
1041
  existing.revenueCents +
801
- (financialConversion ? event.revenueCents ?? 0 : 0),
1042
+ (financialConversion ? (event.revenueCents ?? 0) : 0),
802
1043
  ...(financialConversion && event.currency
803
1044
  ? { currency: event.currency }
804
1045
  : {}),
@@ -809,7 +1050,9 @@ async function upsertSession(
809
1050
  ...(context.country ? { country: context.country } : {}),
810
1051
  ...(context.city ? { city: context.city } : {}),
811
1052
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
812
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1053
+ ...(context.longitude !== undefined
1054
+ ? { longitude: context.longitude }
1055
+ : {}),
813
1056
  browser: context.browser ?? existing.browser,
814
1057
  os: context.os ?? existing.os,
815
1058
  device: context.device ?? existing.device,
@@ -830,8 +1073,17 @@ async function upsertSession(
830
1073
  isNewVisitorDay:
831
1074
  Math.floor(existing.lastSeenAt / DAY_MS) <
832
1075
  Math.floor(event.timestamp / DAY_MS),
1076
+ isNewVisitor: false,
1077
+ durationDeltaMs: Math.max(0, updates.durationMs - existing.durationMs),
1078
+ continued: event.type === "pageview" && existing.pageviewCount === 1,
833
1079
  startedAt,
834
1080
  source: existing.source,
1081
+ utmCampaign: existing.utmCampaign,
1082
+ visitorKey,
1083
+ country: context.country ?? existing.country,
1084
+ city: context.city ?? existing.city,
1085
+ latitude: context.latitude ?? existing.latitude,
1086
+ longitude: context.longitude ?? existing.longitude,
835
1087
  pageviewCount:
836
1088
  existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
837
1089
  eventCount: existing.eventCount + 1,
@@ -859,10 +1111,24 @@ async function upsertSession(
859
1111
  }
860
1112
  }
861
1113
  }
1114
+ // A visitor is new when nothing was ever recorded under their id. A host
1115
+ // id whose anonymous history was linked at sign-up is a return, not a new
1116
+ // person; a browser without a persistent id is new every session.
1117
+ let isNewVisitor = priorVisit === null;
1118
+ if (isNewVisitor && event.visitorId !== event.sessionId) {
1119
+ const alias = await ctx.db
1120
+ .query("visitorAliases")
1121
+ .withIndex("by_siteId_and_visitorId", (range) =>
1122
+ range.eq("siteId", siteId).eq("visitorId", event.visitorId),
1123
+ )
1124
+ .first();
1125
+ if (alias) isNewVisitor = false;
1126
+ }
862
1127
  const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
863
1128
  siteId,
864
1129
  sessionId: event.sessionId,
865
1130
  visitorId: event.visitorId,
1131
+ visitorKey,
866
1132
  startedAt: event.timestamp,
867
1133
  lastSeenAt: event.timestamp,
868
1134
  entryPath: event.path,
@@ -875,21 +1141,33 @@ async function upsertSession(
875
1141
  ...(context.country ? { country: context.country } : {}),
876
1142
  ...(context.city ? { city: context.city } : {}),
877
1143
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
878
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1144
+ ...(context.longitude !== undefined
1145
+ ? { longitude: context.longitude }
1146
+ : {}),
879
1147
  browser: context.browser ?? "Other",
880
1148
  os: context.os ?? "Other",
881
1149
  device: context.device ?? "unknown",
882
1150
  eventCount: 1,
883
1151
  pageviewCount: event.type === "pageview" ? 1 : 0,
1152
+ ...(event.type === "pageview" ? { lastPageviewAt: event.timestamp } : {}),
884
1153
  durationMs: 0,
885
1154
  conversionCount: Number(financialConversion),
886
- revenueCents: financialConversion ? event.revenueCents ?? 0 : 0,
1155
+ revenueCents: financialConversion ? (event.revenueCents ?? 0) : 0,
887
1156
  ...(financialConversion && event.currency
888
1157
  ? { currency: event.currency }
889
1158
  : {}),
890
1159
  ...(attribution ?? {}),
891
1160
  };
892
1161
  const sessionId = await ctx.db.insert("sessions", fields);
1162
+ if (attribution) {
1163
+ foldAffiliateVisitRollup(
1164
+ reportRollupDeltas,
1165
+ siteId,
1166
+ event,
1167
+ attribution,
1168
+ visitorKey,
1169
+ );
1170
+ }
893
1171
  if (batchState) {
894
1172
  const state = batchState.sessions.get(event.sessionId);
895
1173
  if (!state) throw new Error("session cache was not initialized");
@@ -908,22 +1186,29 @@ async function upsertSession(
908
1186
  priorVisit === null ||
909
1187
  Math.floor(priorVisit.lastSeenAt / DAY_MS) <
910
1188
  Math.floor(event.timestamp / DAY_MS),
1189
+ isNewVisitor,
1190
+ durationDeltaMs: 0,
1191
+ continued: false,
911
1192
  startedAt: event.timestamp,
912
1193
  source,
1194
+ utmCampaign,
1195
+ visitorKey,
1196
+ country: context.country,
1197
+ city: context.city,
1198
+ latitude: context.latitude,
1199
+ longitude: context.longitude,
913
1200
  pageviewCount: event.type === "pageview" ? 1 : 0,
914
1201
  eventCount: 1,
915
1202
  ...(attribution ?? {}),
916
1203
  };
917
1204
  }
918
1205
 
919
- function attributionSnapshot(
920
- value: {
921
- affiliateId?: Id<"affiliates">;
922
- affiliateSlug?: string;
923
- affiliateAttributedAt?: number;
924
- affiliateExpiresAt?: number;
925
- },
926
- ): AttributionSnapshot | undefined {
1206
+ function attributionSnapshot(value: {
1207
+ affiliateId?: Id<"affiliates">;
1208
+ affiliateSlug?: string;
1209
+ affiliateAttributedAt?: number;
1210
+ affiliateExpiresAt?: number;
1211
+ }): AttributionSnapshot | undefined {
927
1212
  if (
928
1213
  !value.affiliateId ||
929
1214
  !value.affiliateSlug ||
@@ -940,7 +1225,6 @@ function attributionSnapshot(
940
1225
  };
941
1226
  }
942
1227
 
943
-
944
1228
  function attributionApplies(
945
1229
  attribution: AttributionSnapshot,
946
1230
  timestamp: number,
@@ -1065,20 +1349,21 @@ async function accountHeartbeat(
1065
1349
  context: IngestContext,
1066
1350
  now: number,
1067
1351
  batchState?: BatchState,
1068
- ) {
1352
+ ): Promise<number> {
1069
1353
  const startedAt = Math.min(session.startedAt, event.timestamp);
1070
1354
  const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
1071
1355
  const updates = {
1072
1356
  startedAt,
1073
1357
  lastSeenAt,
1074
- exitPath: event.timestamp >= session.lastSeenAt
1075
- ? event.path
1076
- : session.exitPath,
1358
+ exitPath:
1359
+ event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
1077
1360
  durationMs: Math.max(0, lastSeenAt - startedAt),
1078
1361
  ...(context.country ? { country: context.country } : {}),
1079
1362
  ...(context.city ? { city: context.city } : {}),
1080
1363
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
1081
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1364
+ ...(context.longitude !== undefined
1365
+ ? { longitude: context.longitude }
1366
+ : {}),
1082
1367
  browser: context.browser ?? session.browser,
1083
1368
  os: context.os ?? session.os,
1084
1369
  device: context.device ?? session.device,
@@ -1091,6 +1376,7 @@ async function accountHeartbeat(
1091
1376
  } else {
1092
1377
  await ctx.db.patch("sessions", session._id, updates);
1093
1378
  }
1379
+ const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
1094
1380
  await updateLiveSession(
1095
1381
  ctx,
1096
1382
  siteId,
@@ -1100,8 +1386,16 @@ async function accountHeartbeat(
1100
1386
  isNewSession: false,
1101
1387
  isNewVisitorHour: false,
1102
1388
  isNewVisitorDay: false,
1389
+ isNewVisitor: false,
1390
+ durationDeltaMs: 0,
1391
+ continued: false,
1103
1392
  startedAt,
1104
1393
  source: session.source,
1394
+ visitorKey: session.visitorKey ?? session.visitorId,
1395
+ country: context.country ?? session.country,
1396
+ city: context.city ?? session.city,
1397
+ latitude: context.latitude ?? session.latitude,
1398
+ longitude: context.longitude ?? session.longitude,
1105
1399
  pageviewCount: session.pageviewCount,
1106
1400
  eventCount: session.eventCount,
1107
1401
  affiliateSlug: session.affiliateSlug,
@@ -1109,23 +1403,25 @@ async function accountHeartbeat(
1109
1403
  now,
1110
1404
  batchState,
1111
1405
  );
1406
+ return durationDeltaMs;
1112
1407
  }
1113
1408
 
1114
1409
  async function consumeSiteRateLimit(
1115
1410
  ctx: MutationCtx,
1116
1411
  siteId: Id<"sites">,
1117
1412
  events: TrackerEvent[],
1413
+ eventByteCounts: number[],
1118
1414
  now: number,
1119
- ): Promise<number[]> {
1415
+ ): Promise<void> {
1120
1416
  const windowStart =
1121
1417
  Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1122
- const increments = new Map<number, { eventCount: number; byteCount: number }>();
1123
- const eventByteCounts: number[] = [];
1124
- const encoder = new TextEncoder();
1418
+ const increments = new Map<
1419
+ number,
1420
+ { eventCount: number; byteCount: number }
1421
+ >();
1125
1422
  for (let index = 0; index < events.length; index += 1) {
1126
1423
  const event = events[index];
1127
- const byteCount = encoder.encode(JSON.stringify(event)).byteLength;
1128
- eventByteCounts.push(byteCount);
1424
+ const byteCount = eventByteCounts[index];
1129
1425
  const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
1130
1426
  const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
1131
1427
  current.eventCount += 1;
@@ -1147,9 +1443,8 @@ async function consumeSiteRateLimit(
1147
1443
  .order("desc")
1148
1444
  .take(1);
1149
1445
  const storedWindow = rows[0] ?? null;
1150
- const window = storedWindow?.windowStart === windowStart
1151
- ? storedWindow
1152
- : null;
1446
+ const window =
1447
+ storedWindow?.windowStart === windowStart ? storedWindow : null;
1153
1448
  if (
1154
1449
  (window?.eventCount ?? 0) + increment.eventCount >
1155
1450
  MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
@@ -1161,10 +1456,8 @@ async function consumeSiteRateLimit(
1161
1456
  windowMs: RATE_LIMIT_WINDOW_MS,
1162
1457
  eventLimitPerShard: MAX_SITE_EVENTS_PER_SHARD_WINDOW,
1163
1458
  byteLimitPerShard: MAX_SITE_BYTES_PER_SHARD_WINDOW,
1164
- siteEventLimit:
1165
- SITE_INGEST_SHARDS * MAX_SITE_EVENTS_PER_SHARD_WINDOW,
1166
- siteByteLimit:
1167
- SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
1459
+ siteEventLimit: SITE_INGEST_SHARDS * MAX_SITE_EVENTS_PER_SHARD_WINDOW,
1460
+ siteByteLimit: SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
1168
1461
  });
1169
1462
  }
1170
1463
  windows.push({ shard, increment, window: storedWindow });
@@ -1195,7 +1488,6 @@ async function consumeSiteRateLimit(
1195
1488
  });
1196
1489
  }
1197
1490
  }
1198
- return eventByteCounts;
1199
1491
  }
1200
1492
 
1201
1493
  async function consumeRateLimit(
@@ -1206,7 +1498,8 @@ async function consumeRateLimit(
1206
1498
  now: number,
1207
1499
  states: Map<string, SessionRateWindowState>,
1208
1500
  ): Promise<boolean> {
1209
- const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1501
+ const windowStart =
1502
+ Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1210
1503
  let state = states.get(sessionId);
1211
1504
  if (!state) {
1212
1505
  const rows = await ctx.db
@@ -1244,7 +1537,8 @@ async function flushSessionRateLimits(
1244
1537
  states: Map<string, SessionRateWindowState>,
1245
1538
  now: number,
1246
1539
  ) {
1247
- const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1540
+ const windowStart =
1541
+ Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1248
1542
  for (const state of states.values()) {
1249
1543
  if (!state.dirty) continue;
1250
1544
  if (state.window) {
@@ -1289,31 +1583,41 @@ async function updateLiveSession(
1289
1583
  )
1290
1584
  .unique();
1291
1585
  const deadline = now + LIVE_SESSION_TTL_MS;
1586
+ const country = context.country ?? session.country;
1587
+ const city = context.city ?? session.city;
1588
+ const latitude = context.latitude ?? session.latitude;
1589
+ const longitude = context.longitude ?? session.longitude;
1292
1590
  if (existing) {
1293
- const recentHeartbeatEventIds = event.type === "heartbeat"
1294
- ? [
1295
- ...(existing.recentHeartbeatEventIds ??
1296
- (existing.lastHeartbeatEventId
1297
- ? [existing.lastHeartbeatEventId]
1298
- : [])),
1299
- event.eventId,
1300
- ].slice(-8)
1301
- : undefined;
1591
+ const recentHeartbeatEventIds =
1592
+ event.type === "heartbeat"
1593
+ ? [
1594
+ ...(existing.recentHeartbeatEventIds ??
1595
+ (existing.lastHeartbeatEventId
1596
+ ? [existing.lastHeartbeatEventId]
1597
+ : [])),
1598
+ event.eventId,
1599
+ ].slice(-8)
1600
+ : undefined;
1302
1601
  const updates = {
1303
1602
  visitorId: event.visitorId,
1304
1603
  path: event.path,
1305
1604
  lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
1306
1605
  expiresAt: deadline,
1307
- ...(context.country ? { country: context.country } : {}),
1308
- ...(context.city ? { city: context.city } : {}),
1309
- ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
1310
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1606
+ ...(country ? { country } : {}),
1607
+ ...(city ? { city } : {}),
1608
+ ...(latitude !== undefined ? { latitude } : {}),
1609
+ ...(longitude !== undefined ? { longitude } : {}),
1311
1610
  browser: context.browser ?? existing.browser,
1312
1611
  os: context.os ?? existing.os,
1313
1612
  source: session.source,
1314
1613
  device: context.device ?? existing.device,
1315
1614
  pageviewCount: session.pageviewCount,
1316
1615
  eventCount: session.eventCount,
1616
+ // The live map animates a visitor from the route they left to the one
1617
+ // they reached; a heartbeat or click on the same page is not a move.
1618
+ ...(event.type === "pageview" && event.path !== existing.path
1619
+ ? { previousPath: existing.path, movedAt: event.timestamp }
1620
+ : {}),
1317
1621
  ...(event.type === "heartbeat"
1318
1622
  ? {
1319
1623
  lastHeartbeatEventId: event.eventId,
@@ -1338,10 +1642,10 @@ async function updateLiveSession(
1338
1642
  startedAt: event.timestamp,
1339
1643
  lastSeenAt: event.timestamp,
1340
1644
  expiresAt: deadline,
1341
- ...(context.country ? { country: context.country } : {}),
1342
- ...(context.city ? { city: context.city } : {}),
1343
- ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
1344
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1645
+ ...(country ? { country } : {}),
1646
+ ...(city ? { city } : {}),
1647
+ ...(latitude !== undefined ? { latitude } : {}),
1648
+ ...(longitude !== undefined ? { longitude } : {}),
1345
1649
  browser: context.browser ?? "Other",
1346
1650
  os: context.os ?? "Other",
1347
1651
  source: session.source,
@@ -1376,9 +1680,13 @@ async function ensureLiveSweep(
1376
1680
  .query("liveSweepStates")
1377
1681
  .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1378
1682
  .unique();
1379
- if (!existing) {
1683
+ if (!existing || existing.scheduledAt <= now) {
1380
1684
  const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
1381
- await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1685
+ if (existing) {
1686
+ await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
1687
+ } else {
1688
+ await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1689
+ }
1382
1690
  await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
1383
1691
  siteId,
1384
1692
  scheduledAt,
@@ -1399,6 +1707,7 @@ async function advanceFunnel(
1399
1707
  siteId: Id<"sites">,
1400
1708
  funnel: Doc<"funnels">,
1401
1709
  event: TrackerEvent,
1710
+ reportRollupDeltas: ReportRollupDeltas,
1402
1711
  batchState?: BatchState,
1403
1712
  ) {
1404
1713
  const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
@@ -1446,10 +1755,22 @@ async function advanceFunnel(
1446
1755
  _id: progressId,
1447
1756
  };
1448
1757
  }
1449
- await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1758
+ foldFunnelStepDelta(
1759
+ reportRollupDeltas,
1760
+ siteId,
1761
+ funnel._id,
1762
+ event,
1763
+ 0,
1764
+ false,
1765
+ );
1766
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1450
1767
  return;
1451
1768
  }
1452
- if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt) return;
1769
+ if (
1770
+ progress.completedAt !== undefined ||
1771
+ event.timestamp < progress.lastStepAt
1772
+ )
1773
+ return;
1453
1774
  if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
1454
1775
  if (!matchesFirst) return;
1455
1776
  const updated = {
@@ -1472,7 +1793,15 @@ async function advanceFunnel(
1472
1793
  completedAt: undefined,
1473
1794
  });
1474
1795
  }
1475
- await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1796
+ foldFunnelStepDelta(
1797
+ reportRollupDeltas,
1798
+ siteId,
1799
+ funnel._id,
1800
+ event,
1801
+ 0,
1802
+ false,
1803
+ );
1804
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1476
1805
  return;
1477
1806
  }
1478
1807
  const nextStep = funnel.steps[progress.currentStep];
@@ -1491,8 +1820,16 @@ async function advanceFunnel(
1491
1820
  } else {
1492
1821
  await ctx.db.patch("funnelProgress", progress._id, updates);
1493
1822
  }
1494
- await incrementFunnelStep(
1495
- ctx,
1823
+ foldFunnelStepDelta(
1824
+ reportRollupDeltas,
1825
+ siteId,
1826
+ funnel._id,
1827
+ event,
1828
+ progress.currentStep,
1829
+ completed,
1830
+ );
1831
+ foldFunnelRollup(
1832
+ reportRollupDeltas,
1496
1833
  siteId,
1497
1834
  funnel._id,
1498
1835
  event,
@@ -1510,53 +1847,13 @@ function stepMatches(
1510
1847
  : event.type === "custom" && event.name === step.value;
1511
1848
  }
1512
1849
 
1513
- async function incrementFunnelStep(
1514
- ctx: MutationCtx,
1515
- siteId: Id<"sites">,
1516
- funnelId: Id<"funnels">,
1517
- event: TrackerEvent,
1518
- step: number,
1519
- completed: boolean,
1520
- ) {
1521
- const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1522
- const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
1523
- const bucket = await ctx.db
1524
- .query("funnelStepBuckets")
1525
- .withIndex(
1526
- "by_siteId_and_funnelId_and_bucketStart_and_step_and_shard",
1527
- (range) =>
1528
- range
1529
- .eq("siteId", siteId)
1530
- .eq("funnelId", funnelId)
1531
- .eq("bucketStart", bucketStart)
1532
- .eq("step", step)
1533
- .eq("shard", shard),
1534
- )
1535
- .unique();
1536
- if (bucket) {
1537
- await ctx.db.patch("funnelStepBuckets", bucket._id, {
1538
- entrants: bucket.entrants + 1,
1539
- completions: bucket.completions + (completed ? 1 : 0),
1540
- });
1541
- } else {
1542
- await ctx.db.insert("funnelStepBuckets", {
1543
- siteId,
1544
- funnelId,
1545
- bucketStart,
1546
- step,
1547
- shard,
1548
- entrants: 1,
1549
- completions: completed ? 1 : 0,
1550
- });
1551
- }
1552
- }
1553
-
1554
1850
  async function accountConversion(
1555
1851
  ctx: MutationCtx,
1556
1852
  siteId: Id<"sites">,
1557
1853
  session: SessionState,
1558
1854
  event: TrackerEvent,
1559
1855
  trustedGoalKey: string | undefined,
1856
+ reportRollupDeltas: ReportRollupDeltas,
1560
1857
  ) {
1561
1858
  const revenueCents = event.revenueCents ?? 0;
1562
1859
  const currency = event.currency ?? "USD";
@@ -1588,6 +1885,15 @@ async function accountConversion(
1588
1885
  commissionCents,
1589
1886
  });
1590
1887
  if (commissionAffiliate && attribution) {
1888
+ foldAffiliateConversionRollup(
1889
+ reportRollupDeltas,
1890
+ siteId,
1891
+ event,
1892
+ commissionAffiliate,
1893
+ attribution.affiliateSlug,
1894
+ revenueCents,
1895
+ commissionCents,
1896
+ );
1591
1897
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1592
1898
  const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
1593
1899
  const stats = await ctx.db
@@ -1623,6 +1929,292 @@ async function accountConversion(
1623
1929
  }
1624
1930
  }
1625
1931
 
1932
+ function foldGoalRollup(
1933
+ deltas: ReportRollupDeltas,
1934
+ siteId: Id<"sites">,
1935
+ event: TrackerEvent,
1936
+ goal: Doc<"goals">,
1937
+ ) {
1938
+ const coordinates = reportRollupCoordinates(
1939
+ siteId,
1940
+ String(goal._id),
1941
+ event.timestamp,
1942
+ event.visitorId,
1943
+ );
1944
+ const current = deltas.goals.get(coordinates.key) ?? {
1945
+ siteId,
1946
+ goalId: goal._id,
1947
+ bucketStart: coordinates.bucketStart,
1948
+ shard: coordinates.shard,
1949
+ goalKey: goal.key,
1950
+ completions: 0,
1951
+ valueCents: 0,
1952
+ };
1953
+ current.completions += 1;
1954
+ current.valueCents += goal.valueCents ?? 0;
1955
+ deltas.goals.set(coordinates.key, current);
1956
+ }
1957
+
1958
+ function foldFunnelRollup(
1959
+ deltas: ReportRollupDeltas,
1960
+ siteId: Id<"sites">,
1961
+ funnelId: Id<"funnels">,
1962
+ event: TrackerEvent,
1963
+ step: number,
1964
+ completed: boolean,
1965
+ ) {
1966
+ const coordinates = reportRollupCoordinates(
1967
+ siteId,
1968
+ String(funnelId),
1969
+ event.timestamp,
1970
+ event.visitorId,
1971
+ );
1972
+ const rollup = deltas.funnels.get(coordinates.key) ?? {
1973
+ siteId,
1974
+ funnelId,
1975
+ bucketStart: coordinates.bucketStart,
1976
+ shard: coordinates.shard,
1977
+ steps: new Map(),
1978
+ };
1979
+ const current = rollup.steps.get(step) ?? {
1980
+ step,
1981
+ entrants: 0,
1982
+ completions: 0,
1983
+ };
1984
+ current.entrants += 1;
1985
+ current.completions += Number(completed);
1986
+ rollup.steps.set(step, current);
1987
+ deltas.funnels.set(coordinates.key, rollup);
1988
+ }
1989
+
1990
+ function foldFunnelStepDelta(
1991
+ deltas: ReportRollupDeltas,
1992
+ siteId: Id<"sites">,
1993
+ funnelId: Id<"funnels">,
1994
+ event: TrackerEvent,
1995
+ step: number,
1996
+ completed: boolean,
1997
+ ) {
1998
+ const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1999
+ const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
2000
+ const key = `${siteId}:${funnelId}:${bucketStart}:${step}:${shard}`;
2001
+ const current = deltas.funnelSteps.get(key) ?? {
2002
+ siteId,
2003
+ funnelId,
2004
+ bucketStart,
2005
+ step,
2006
+ shard,
2007
+ entrants: 0,
2008
+ completions: 0,
2009
+ };
2010
+ current.entrants += 1;
2011
+ current.completions += Number(completed);
2012
+ deltas.funnelSteps.set(key, current);
2013
+ }
2014
+
2015
+ function foldAffiliateVisitRollup(
2016
+ deltas: ReportRollupDeltas,
2017
+ siteId: Id<"sites">,
2018
+ event: TrackerEvent,
2019
+ attribution: AttributionSnapshot,
2020
+ visitorKey: string,
2021
+ ) {
2022
+ const coordinates = reportRollupCoordinates(
2023
+ siteId,
2024
+ String(attribution.affiliateId),
2025
+ event.timestamp,
2026
+ event.visitorId,
2027
+ );
2028
+ const current = deltas.affiliates.get(coordinates.key) ?? {
2029
+ siteId,
2030
+ affiliateId: attribution.affiliateId,
2031
+ bucketStart: coordinates.bucketStart,
2032
+ shard: coordinates.shard,
2033
+ affiliateSlug: attribution.affiliateSlug,
2034
+ visits: 0,
2035
+ visitorKeys: new Set<string>(),
2036
+ conversions: 0,
2037
+ revenueCents: 0,
2038
+ commissionCents: 0,
2039
+ };
2040
+ current.visits += 1;
2041
+ current.visitorKeys.add(visitorKey);
2042
+ deltas.affiliates.set(coordinates.key, current);
2043
+ }
2044
+
2045
+ function foldAffiliateConversionRollup(
2046
+ deltas: ReportRollupDeltas,
2047
+ siteId: Id<"sites">,
2048
+ event: TrackerEvent,
2049
+ affiliate: Doc<"affiliates">,
2050
+ affiliateSlug: string,
2051
+ revenueCents: number,
2052
+ commissionCents: number,
2053
+ ) {
2054
+ const coordinates = reportRollupCoordinates(
2055
+ siteId,
2056
+ String(affiliate._id),
2057
+ event.timestamp,
2058
+ event.visitorId,
2059
+ );
2060
+ const current = deltas.affiliates.get(coordinates.key) ?? {
2061
+ siteId,
2062
+ affiliateId: affiliate._id,
2063
+ bucketStart: coordinates.bucketStart,
2064
+ shard: coordinates.shard,
2065
+ affiliateSlug,
2066
+ visits: 0,
2067
+ visitorKeys: new Set<string>(),
2068
+ conversions: 0,
2069
+ revenueCents: 0,
2070
+ commissionCents: 0,
2071
+ };
2072
+ current.conversions += 1;
2073
+ current.revenueCents += revenueCents;
2074
+ current.commissionCents += commissionCents;
2075
+ deltas.affiliates.set(coordinates.key, current);
2076
+ }
2077
+
2078
+ function foldVitalRollups(
2079
+ deltas: ReportRollupDeltas,
2080
+ siteId: Id<"sites">,
2081
+ event: TrackerEvent,
2082
+ device: string,
2083
+ ) {
2084
+ const metric = event.name as VitalMetric;
2085
+ const value = event.value ?? 0;
2086
+ const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
2087
+ const shard = stableHash(event.sessionId) % REPORT_ROLLUP_SHARDS;
2088
+ // Each measurement lands twice: on its page (across devices) and on its
2089
+ // device class (across pages), so both breakdowns stay exact and bounded.
2090
+ for (const target of [
2091
+ { device: VITAL_ALL, page: event.path },
2092
+ { device, page: VITAL_ALL },
2093
+ ]) {
2094
+ const key = JSON.stringify([
2095
+ siteId,
2096
+ bucketStart,
2097
+ target.device,
2098
+ target.page,
2099
+ shard,
2100
+ ]);
2101
+ const row = deltas.vitals.get(key) ?? {
2102
+ siteId,
2103
+ bucketStart,
2104
+ device: target.device,
2105
+ page: target.page,
2106
+ shard,
2107
+ metrics: new Map<
2108
+ VitalMetric,
2109
+ { count: number; sum: number; histogram: number[] }
2110
+ >(),
2111
+ };
2112
+ const entry = row.metrics.get(metric) ?? {
2113
+ count: 0,
2114
+ sum: 0,
2115
+ histogram: emptyVitalHistogram(),
2116
+ };
2117
+ entry.count += 1;
2118
+ entry.sum += value;
2119
+ entry.histogram = addToVitalHistogram(entry.histogram, value);
2120
+ row.metrics.set(metric, entry);
2121
+ deltas.vitals.set(key, row);
2122
+ }
2123
+ }
2124
+
2125
+ function foldRouteDelta(
2126
+ deltas: ReportRollupDeltas,
2127
+ siteId: Id<"sites">,
2128
+ route: string,
2129
+ timestamp: number,
2130
+ visitorId: string,
2131
+ apply: (delta: RouteRollupDelta) => void,
2132
+ ) {
2133
+ const coordinates = reportRollupCoordinates(
2134
+ siteId,
2135
+ `route:${route}`,
2136
+ timestamp,
2137
+ visitorId,
2138
+ );
2139
+ const current = deltas.routes.get(coordinates.key) ?? {
2140
+ siteId,
2141
+ bucketStart: coordinates.bucketStart,
2142
+ shard: coordinates.shard,
2143
+ route,
2144
+ pageviews: 0,
2145
+ entries: 0,
2146
+ continued: 0,
2147
+ dwellMs: 0,
2148
+ dwellSamples: 0,
2149
+ visitorKeys: new Set<string>(),
2150
+ outgoing: new Map<string, number>(),
2151
+ };
2152
+ apply(current);
2153
+ deltas.routes.set(coordinates.key, current);
2154
+ }
2155
+
2156
+ /**
2157
+ * One pageview feeds the site map three ways: the route it lands on gains a
2158
+ * view (and an entry when it opens the session), the route it leaves gains
2159
+ * the transition and the time spent there, and the session's entry route is
2160
+ * marked as continued the first time its visitor moves on — which is what
2161
+ * lets the report derive bounces and exits without a session-end hook.
2162
+ */
2163
+ function foldRouteRollups(
2164
+ deltas: ReportRollupDeltas,
2165
+ siteId: Id<"sites">,
2166
+ event: TrackerEvent,
2167
+ session: SessionState,
2168
+ existing: CachedDocument<"sessions"> | null,
2169
+ ) {
2170
+ const route = normalizeRoute(event.path);
2171
+ const priorPageviews = existing?.pageviewCount ?? 0;
2172
+ const firstPageview = existing === null || priorPageviews === 0;
2173
+ foldRouteDelta(
2174
+ deltas,
2175
+ siteId,
2176
+ route,
2177
+ event.timestamp,
2178
+ event.visitorId,
2179
+ (delta) => {
2180
+ delta.pageviews += 1;
2181
+ if (firstPageview) delta.entries += 1;
2182
+ delta.visitorKeys.add(session.visitorKey);
2183
+ },
2184
+ );
2185
+ // Out-of-order telemetry keeps its view but cannot say where it came from.
2186
+ if (existing === null || firstPageview) return;
2187
+ if (event.timestamp < existing.lastSeenAt) return;
2188
+ const previous = normalizeRoute(existing.exitPath);
2189
+ const lastPageviewAt = existing.lastPageviewAt;
2190
+ foldRouteDelta(
2191
+ deltas,
2192
+ siteId,
2193
+ previous,
2194
+ event.timestamp,
2195
+ event.visitorId,
2196
+ (delta) => {
2197
+ delta.outgoing.set(route, (delta.outgoing.get(route) ?? 0) + 1);
2198
+ if (lastPageviewAt !== undefined && event.timestamp >= lastPageviewAt) {
2199
+ delta.dwellMs += event.timestamp - lastPageviewAt;
2200
+ delta.dwellSamples += 1;
2201
+ }
2202
+ },
2203
+ );
2204
+ if (priorPageviews === 1) {
2205
+ foldRouteDelta(
2206
+ deltas,
2207
+ siteId,
2208
+ normalizeRoute(existing.entryPath),
2209
+ existing.startedAt,
2210
+ event.visitorId,
2211
+ (delta) => {
2212
+ delta.continued += 1;
2213
+ },
2214
+ );
2215
+ }
2216
+ }
2217
+
1626
2218
  function foldAggregates(
1627
2219
  aggregateDeltas: AggregateDeltas,
1628
2220
  siteId: Id<"sites">,
@@ -1631,20 +2223,25 @@ function foldAggregates(
1631
2223
  dimensions: Dimension[],
1632
2224
  recordTelemetry: boolean,
1633
2225
  financialConversion: boolean,
2226
+ timezone?: string,
1634
2227
  ) {
1635
- const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
1636
- const dimensionDeltas = dimensions.map(
1637
- (dimension): DimensionDelta => ({
1638
- ...dimension,
1639
- count: Number(recordTelemetry),
1640
- revenueCents,
1641
- }),
1642
- );
1643
- for (const granularity of ["hour", "day"] as const) {
1644
- const shard = stableHash(event.sessionId) %
2228
+ const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
2229
+ const dimensionDeltas = dimensions.map((dimension): DimensionDelta => ({
2230
+ ...dimension,
2231
+ count: Number(recordTelemetry),
2232
+ revenueCents,
2233
+ pageviews: Number(recordTelemetry && event.type === "pageview"),
2234
+ sessions: Number(recordTelemetry && session.isNewSession),
2235
+ }));
2236
+ for (const granularity of aggregateGranularities(timezone)) {
2237
+ const shard =
2238
+ stableHash(event.sessionId) %
1645
2239
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
1646
- const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
1647
- const bucketStart = Math.floor(event.timestamp / interval) * interval;
2240
+ const bucketStart = aggregateBucketStart(
2241
+ granularity,
2242
+ event.timestamp,
2243
+ timezone,
2244
+ );
1648
2245
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
1649
2246
  let delta = aggregateDeltas.get(key);
1650
2247
  if (!delta) {
@@ -1660,9 +2257,13 @@ function foldAggregates(
1660
2257
  outboundClicks: 0,
1661
2258
  sessions: 0,
1662
2259
  visitors: 0,
2260
+ visitorSketch: createVisitorSketch(),
1663
2261
  conversions: 0,
1664
2262
  revenueCents: 0,
1665
2263
  dimensions: [],
2264
+ durationMs: 0,
2265
+ continuedSessions: 0,
2266
+ newVisitors: 0,
1666
2267
  };
1667
2268
  aggregateDeltas.set(key, delta);
1668
2269
  }
@@ -1680,9 +2281,83 @@ function foldAggregates(
1680
2281
  ? Number(session.isNewVisitorHour)
1681
2282
  : Number(session.isNewVisitorDay)
1682
2283
  : 0;
2284
+ if (recordTelemetry) {
2285
+ delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
2286
+ }
1683
2287
  delta.conversions += Number(financialConversion);
1684
2288
  delta.revenueCents += revenueCents;
1685
2289
  delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
2290
+ if (recordTelemetry) {
2291
+ delta.durationMs += session.durationDeltaMs;
2292
+ delta.continuedSessions += Number(session.continued);
2293
+ delta.newVisitors += Number(session.isNewSession && session.isNewVisitor);
2294
+ }
2295
+ }
2296
+ }
2297
+
2298
+ /**
2299
+ * The buckets an event lands in: UTC hour and UTC day always, plus the
2300
+ * calendar day in the site's timezone when the site keeps one.
2301
+ */
2302
+ function aggregateGranularities(
2303
+ timezone: string | undefined,
2304
+ ): ReadonlyArray<"hour" | "day" | "localDay"> {
2305
+ return timezone ? ["hour", "day", "localDay"] : ["hour", "day"];
2306
+ }
2307
+
2308
+ function aggregateBucketStart(
2309
+ granularity: "hour" | "day" | "localDay",
2310
+ timestamp: number,
2311
+ timezone: string | undefined,
2312
+ ): number {
2313
+ if (granularity === "localDay" && timezone) {
2314
+ return localDayStart(timestamp, timezone);
2315
+ }
2316
+ const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
2317
+ return Math.floor(timestamp / interval) * interval;
2318
+ }
2319
+
2320
+ /** Folds only session duration growth, for heartbeats. */
2321
+ function foldSessionDuration(
2322
+ aggregateDeltas: AggregateDeltas,
2323
+ siteId: Id<"sites">,
2324
+ event: TrackerEvent,
2325
+ durationDeltaMs: number,
2326
+ timezone?: string,
2327
+ ) {
2328
+ if (durationDeltaMs <= 0) return;
2329
+ for (const granularity of aggregateGranularities(timezone)) {
2330
+ const shard =
2331
+ stableHash(event.sessionId) %
2332
+ (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
2333
+ const bucketStart = aggregateBucketStart(
2334
+ granularity,
2335
+ event.timestamp,
2336
+ timezone,
2337
+ );
2338
+ const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
2339
+ const delta = aggregateDeltas.get(key) ?? {
2340
+ siteId,
2341
+ granularity,
2342
+ bucketStart,
2343
+ shard,
2344
+ events: 0,
2345
+ pageviews: 0,
2346
+ clicks: 0,
2347
+ customEvents: 0,
2348
+ outboundClicks: 0,
2349
+ sessions: 0,
2350
+ visitors: 0,
2351
+ visitorSketch: createVisitorSketch(),
2352
+ conversions: 0,
2353
+ revenueCents: 0,
2354
+ dimensions: [],
2355
+ durationMs: 0,
2356
+ continuedSessions: 0,
2357
+ newVisitors: 0,
2358
+ };
2359
+ delta.durationMs += durationDeltaMs;
2360
+ aggregateDeltas.set(key, delta);
1686
2361
  }
1687
2362
  }
1688
2363
 
@@ -1705,8 +2380,19 @@ async function flushAggregates(
1705
2380
  .unique();
1706
2381
  const dimensions = mergeDimensions(
1707
2382
  bucket?.dimensions ?? [],
1708
- delta.dimensions,
2383
+ delta.dimensions.map((slot) => ({
2384
+ ...slot,
2385
+ pageviews: slot.pageviews ?? 0,
2386
+ sessions: slot.sessions ?? 0,
2387
+ })),
1709
2388
  );
2389
+ const priorSketch = bucket?.visitorSketch;
2390
+ const visitorSketch = priorSketch
2391
+ ? unionVisitorSketches(priorSketch, delta.visitorSketch)
2392
+ : delta.visitorSketch;
2393
+ const visitorSketchComplete = bucket
2394
+ ? bucket.visitorSketchComplete === true && priorSketch !== undefined
2395
+ : true;
1710
2396
  if (bucket) {
1711
2397
  await ctx.db.patch("aggregateBuckets", bucket._id, {
1712
2398
  events: bucket.events + delta.events,
@@ -1716,9 +2402,15 @@ async function flushAggregates(
1716
2402
  outboundClicks: bucket.outboundClicks + delta.outboundClicks,
1717
2403
  sessions: bucket.sessions + delta.sessions,
1718
2404
  visitors: bucket.visitors + delta.visitors,
2405
+ visitorSketch,
2406
+ visitorSketchComplete,
1719
2407
  conversions: bucket.conversions + delta.conversions,
1720
2408
  revenueCents: bucket.revenueCents + delta.revenueCents,
1721
2409
  dimensions,
2410
+ durationMs: (bucket.durationMs ?? 0) + delta.durationMs,
2411
+ continuedSessions:
2412
+ (bucket.continuedSessions ?? 0) + delta.continuedSessions,
2413
+ newVisitors: (bucket.newVisitors ?? 0) + delta.newVisitors,
1722
2414
  });
1723
2415
  } else {
1724
2416
  await ctx.db.insert("aggregateBuckets", {
@@ -1733,9 +2425,14 @@ async function flushAggregates(
1733
2425
  outboundClicks: delta.outboundClicks,
1734
2426
  sessions: delta.sessions,
1735
2427
  visitors: delta.visitors,
2428
+ visitorSketch,
2429
+ visitorSketchComplete,
1736
2430
  conversions: delta.conversions,
1737
2431
  revenueCents: delta.revenueCents,
1738
2432
  dimensions,
2433
+ durationMs: delta.durationMs,
2434
+ continuedSessions: delta.continuedSessions,
2435
+ newVisitors: delta.newVisitors,
1739
2436
  });
1740
2437
  }
1741
2438
  }
@@ -1754,6 +2451,8 @@ function mergeDimensions(
1754
2451
  if (exact) {
1755
2452
  exact.count += addition.count;
1756
2453
  exact.revenueCents += addition.revenueCents;
2454
+ exact.pageviews = (exact.pageviews ?? 0) + addition.pageviews;
2455
+ exact.sessions = (exact.sessions ?? 0) + addition.sessions;
1757
2456
  continue;
1758
2457
  }
1759
2458
 
@@ -1761,10 +2460,7 @@ function mergeDimensions(
1761
2460
  (candidate) => candidate.type === addition.type,
1762
2461
  );
1763
2462
  if (typeSlots.length < DIMENSION_SLOTS - 1) {
1764
- const occupied = Array.from(
1765
- { length: DIMENSION_SLOTS },
1766
- () => false,
1767
- );
2463
+ const occupied = Array.from({ length: DIMENSION_SLOTS }, () => false);
1768
2464
  for (const candidate of typeSlots) occupied[candidate.slot] = true;
1769
2465
  let slot = stableHash(addition.value) % DIMENSION_SLOTS;
1770
2466
  while (occupied[slot]) slot = (slot + 1) % DIMENSION_SLOTS;
@@ -1774,6 +2470,8 @@ function mergeDimensions(
1774
2470
  value: addition.value,
1775
2471
  count: addition.count,
1776
2472
  revenueCents: addition.revenueCents,
2473
+ pageviews: addition.pageviews,
2474
+ sessions: addition.sessions,
1777
2475
  });
1778
2476
  continue;
1779
2477
  }
@@ -1783,6 +2481,8 @@ function mergeDimensions(
1783
2481
  if (overflow) {
1784
2482
  overflow.count += addition.count;
1785
2483
  overflow.revenueCents += addition.revenueCents;
2484
+ overflow.pageviews = (overflow.pageviews ?? 0) + addition.pageviews;
2485
+ overflow.sessions = (overflow.sessions ?? 0) + addition.sessions;
1786
2486
  continue;
1787
2487
  }
1788
2488
  if (addition.count === 0 && addition.revenueCents === 0) continue;
@@ -1796,6 +2496,8 @@ function mergeDimensions(
1796
2496
  value: "(other)",
1797
2497
  count: addition.count,
1798
2498
  revenueCents: addition.revenueCents,
2499
+ pageviews: addition.pageviews,
2500
+ sessions: addition.sessions,
1799
2501
  });
1800
2502
  continue;
1801
2503
  }
@@ -1807,6 +2509,8 @@ function mergeDimensions(
1807
2509
  replacement.value = "(other)";
1808
2510
  replacement.count += addition.count;
1809
2511
  replacement.revenueCents += addition.revenueCents;
2512
+ replacement.pageviews = (replacement.pageviews ?? 0) + addition.pageviews;
2513
+ replacement.sessions = (replacement.sessions ?? 0) + addition.sessions;
1810
2514
  }
1811
2515
  return result;
1812
2516
  }
@@ -1816,5 +2520,7 @@ function stringProperty(
1816
2520
  key: string,
1817
2521
  ): string | undefined {
1818
2522
  const value = properties?.[key];
1819
- return typeof value === "string" ? cleanString(value, 120) || undefined : undefined;
2523
+ return typeof value === "string"
2524
+ ? cleanString(value, 120) || undefined
2525
+ : undefined;
1820
2526
  }