@iann29/rastro 0.1.0-alpha.1 → 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 (163) hide show
  1. package/README.md +521 -66
  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 +16 -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 +25 -1
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +38 -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 +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 +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 +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 +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 +248 -49
  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 +604 -55
  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/reports.d.ts +259 -10
  77. package/dist/component/reports.d.ts.map +1 -1
  78. package/dist/component/reports.js +1110 -124
  79. package/dist/component/reports.js.map +1 -1
  80. package/dist/component/retention.d.ts +75 -1
  81. package/dist/component/retention.d.ts.map +1 -1
  82. package/dist/component/retention.js +537 -32
  83. package/dist/component/retention.js.map +1 -1
  84. package/dist/component/sanitize.d.ts +24 -1
  85. package/dist/component/sanitize.d.ts.map +1 -1
  86. package/dist/component/sanitize.js +96 -16
  87. package/dist/component/sanitize.js.map +1 -1
  88. package/dist/component/schema.d.ts +378 -62
  89. package/dist/component/schema.js +178 -20
  90. package/dist/component/schema.js.map +1 -1
  91. package/dist/component/sites.d.ts.map +1 -1
  92. package/dist/component/sites.js +11 -7
  93. package/dist/component/sites.js.map +1 -1
  94. package/dist/component/useragent.d.ts +9 -0
  95. package/dist/component/useragent.d.ts.map +1 -0
  96. package/dist/component/useragent.js +152 -0
  97. package/dist/component/useragent.js.map +1 -0
  98. package/dist/component/validators.d.ts +101 -65
  99. package/dist/component/validators.d.ts.map +1 -1
  100. package/dist/component/validators.js +19 -13
  101. package/dist/component/validators.js.map +1 -1
  102. package/dist/component/visitors.d.ts +19 -0
  103. package/dist/component/visitors.d.ts.map +1 -0
  104. package/dist/component/visitors.js +86 -0
  105. package/dist/component/visitors.js.map +1 -0
  106. package/dist/component/vitals.d.ts +41 -0
  107. package/dist/component/vitals.d.ts.map +1 -0
  108. package/dist/component/vitals.js +115 -0
  109. package/dist/component/vitals.js.map +1 -0
  110. package/dist/react/index.d.ts.map +1 -1
  111. package/dist/react/index.js.map +1 -1
  112. package/dist/tracker/generated.d.ts +8 -4
  113. package/dist/tracker/generated.d.ts.map +1 -1
  114. package/dist/tracker/generated.js +8 -4
  115. package/dist/tracker/generated.js.map +1 -1
  116. package/dist/tracker/tracker.d.ts +1 -1
  117. package/dist/tracker/tracker.d.ts.map +1 -1
  118. package/dist/tracker/tracker.js +57 -20
  119. package/dist/tracker/tracker.js.map +1 -1
  120. package/dist/tracker/vitals.d.ts +10 -0
  121. package/dist/tracker/vitals.d.ts.map +1 -0
  122. package/dist/tracker/vitals.js +140 -0
  123. package/dist/tracker/vitals.js.map +1 -0
  124. package/dist/tracker.min.js +1 -1
  125. package/dist/vitals.min.js +1 -0
  126. package/docs/benchmarks/2026-08-20-realistic.md +76 -76
  127. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  128. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  129. package/docs/federation-setup.md +397 -0
  130. package/docs/federation.md +262 -0
  131. package/docs/upgrading.md +259 -0
  132. package/llms.txt +71 -0
  133. package/package.json +55 -11
  134. package/scripts/benchmark-ingest.mjs +175 -73
  135. package/scripts/generate-federation-keys.mjs +20 -0
  136. package/src/component/_generated/api.ts +16 -0
  137. package/src/component/_generated/component.ts +366 -4
  138. package/src/component/_generated/server.ts +4 -0
  139. package/src/component/affiliates.ts +20 -5
  140. package/src/component/cardinality.ts +117 -0
  141. package/src/component/constants.ts +38 -1
  142. package/src/component/convex.config.ts +11 -1
  143. package/src/component/coverage.ts +36 -0
  144. package/src/component/diagnostics.ts +65 -0
  145. package/src/component/errors.ts +2 -1
  146. package/src/component/eventStore.ts +217 -193
  147. package/src/component/funnels.ts +19 -16
  148. package/src/component/geo.ts +781 -0
  149. package/src/component/goals.ts +29 -21
  150. package/src/component/guards.ts +3 -1
  151. package/src/component/http.ts +357 -57
  152. package/src/component/identity.ts +74 -0
  153. package/src/component/ingest.ts +1040 -135
  154. package/src/component/live.ts +13 -7
  155. package/src/component/reports.ts +1675 -183
  156. package/src/component/retention.ts +770 -96
  157. package/src/component/sanitize.ts +144 -29
  158. package/src/component/schema.ts +200 -21
  159. package/src/component/sites.ts +27 -12
  160. package/src/component/useragent.ts +171 -0
  161. package/src/component/validators.ts +29 -13
  162. package/src/component/visitors.ts +116 -0
  163. 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,
@@ -18,12 +19,34 @@ import {
18
19
  MAX_BYTES_PER_SESSION_WINDOW,
19
20
  MAX_EVENTS_PER_SESSION_WINDOW,
20
21
  MAX_FUNNELS_PER_SITE,
22
+ MAX_FUNNEL_STEPS,
21
23
  MAX_GOALS_PER_SITE,
24
+ MAX_SITE_MAP_ROUTES_PER_DAY,
22
25
  RATE_LIMIT_WINDOW_MS,
26
+ REPORT_ROLLUP_SHARDS,
27
+ ROUTE_TRANSITION_SLOTS,
23
28
  SECONDARY_AGGREGATE_SHARDS,
24
29
  SITE_INGEST_SHARDS,
30
+ SITE_MAP_OTHER_ROUTE,
31
+ VITAL_PAGE_SLOTS,
25
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";
26
48
  import { fail } from "./errors.js";
49
+ import { ensureAnalyticsControl } from "./coverage.js";
27
50
  import {
28
51
  bufferEventBatch,
29
52
  eventBatchKey,
@@ -36,8 +59,9 @@ import {
36
59
  } from "./eventStore.js";
37
60
  import {
38
61
  cleanString,
39
- ensureBatchBytes,
40
62
  isValidMoney,
63
+ measureBatchBytes,
64
+ normalizeRoute,
41
65
  originAllowed,
42
66
  sanitizeContext,
43
67
  sanitizeCurrency,
@@ -60,6 +84,7 @@ import {
60
84
 
61
85
  type DimensionType =
62
86
  | "source"
87
+ | "campaign"
63
88
  | "page"
64
89
  | "country"
65
90
  | "device"
@@ -81,11 +106,78 @@ type AggregateDelta = {
81
106
  outboundClicks: number;
82
107
  sessions: number;
83
108
  visitors: number;
109
+ visitorSketch: VisitorSketch;
84
110
  conversions: number;
85
111
  revenueCents: number;
86
112
  dimensions: Doc<"aggregateBuckets">["dimensions"];
87
113
  };
88
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
+ };
89
181
  type AttributionSnapshot = {
90
182
  affiliateId: Id<"affiliates">;
91
183
  affiliateSlug: string;
@@ -99,6 +191,12 @@ type SessionState = {
99
191
  isNewVisitorDay: boolean;
100
192
  startedAt: number;
101
193
  source: string;
194
+ visitorKey: string;
195
+ utmCampaign?: string;
196
+ country?: string;
197
+ city?: string;
198
+ latitude?: number;
199
+ longitude?: number;
102
200
  pageviewCount: number;
103
201
  eventCount: number;
104
202
  affiliateId?: Id<"affiliates">;
@@ -121,8 +219,9 @@ type CachedRow<T> = {
121
219
  dirty: boolean;
122
220
  };
123
221
 
124
- type CachedDocument<TableName extends keyof import("./_generated/dataModel.js").DataModel> =
125
- Omit<Doc<TableName>, "_creationTime"> & { _creationTime?: number };
222
+ type CachedDocument<
223
+ TableName extends keyof import("./_generated/dataModel.js").DataModel,
224
+ > = Omit<Doc<TableName>, "_creationTime"> & { _creationTime?: number };
126
225
 
127
226
  type BatchState = {
128
227
  sessions: Map<string, CachedRow<CachedDocument<"sessions">>>;
@@ -153,25 +252,36 @@ export const ingestBatch = mutation({
153
252
  });
154
253
  }
155
254
  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
- });
255
+ fail(
256
+ "BATCH_TOO_LARGE",
257
+ `events must contain 1-${MAX_BATCH_EVENTS} items`,
258
+ {
259
+ limit: MAX_BATCH_EVENTS,
260
+ },
261
+ );
159
262
  }
263
+ let eventByteCounts: number[];
160
264
  try {
161
- ensureBatchBytes({ siteId: String(args.siteId), events: args.events });
265
+ ({ eventByteCounts } = measureBatchBytes(
266
+ String(args.siteId),
267
+ args.events,
268
+ ));
162
269
  } catch {
163
270
  fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
164
271
  }
165
272
 
166
273
  const now = Date.now();
167
- const eventByteCounts = await consumeSiteRateLimit(
274
+ await ensureAnalyticsControl(ctx, args.siteId, now);
275
+ await consumeSiteRateLimit(
168
276
  ctx,
169
277
  args.siteId,
170
278
  args.events,
279
+ eventByteCounts,
171
280
  now,
172
281
  );
173
282
  const context = sanitizeContext(args.context);
174
283
  const aggregateDeltas: AggregateDeltas = new Map();
284
+ const reportRollupDeltas = createReportRollupDeltas();
175
285
  const sessionRateWindows = new Map<string, SessionRateWindowState>();
176
286
  const batchState: BatchState = {
177
287
  sessions: new Map(),
@@ -206,7 +316,12 @@ export const ingestBatch = mutation({
206
316
  const bucketStart = eventBucketStart(event.timestamp);
207
317
  const groupKey = eventBatchKey(event.sessionId, bucketStart);
208
318
  requestGroups.add(groupKey);
209
- if (event.type === "heartbeat") continue;
319
+ if (
320
+ event.type === "heartbeat" ||
321
+ event.type === "vital" ||
322
+ event.type === "leave"
323
+ )
324
+ continue;
210
325
  const eventIds = eventIdsByGroup.get(groupKey) ?? new Set<string>();
211
326
  if (eventIds.has(event.eventId)) {
212
327
  duplicateInBatch.add(eventIndex);
@@ -236,10 +351,9 @@ export const ingestBatch = mutation({
236
351
  siteId: args.siteId,
237
352
  sessionId: group.sessionId,
238
353
  bucketStart: group.bucketStart,
239
- candidateEventIds: group.eventIds,
240
354
  });
241
- const newCount = [...group.eventIds].filter((eventId) =>
242
- !eventIds.has(eventId)
355
+ const newCount = [...group.eventIds].filter(
356
+ (eventId) => !eventIds.has(eventId),
243
357
  ).length;
244
358
  if (
245
359
  newCount > 0 &&
@@ -278,12 +392,7 @@ export const ingestBatch = mutation({
278
392
  continue;
279
393
  }
280
394
  const liveSession = (
281
- await loadLiveSession(
282
- ctx,
283
- args.siteId,
284
- event.sessionId,
285
- batchState,
286
- )
395
+ await loadLiveSession(ctx, args.siteId, event.sessionId, batchState)
287
396
  ).current;
288
397
  if (
289
398
  liveSession &&
@@ -319,10 +428,86 @@ export const ingestBatch = mutation({
319
428
  continue;
320
429
  }
321
430
 
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
+
322
505
  if (
323
506
  duplicateInBatch.has(eventIndex) ||
324
507
  persistedEventIds
325
- .get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
508
+ .get(
509
+ eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)),
510
+ )
326
511
  ?.has(event.eventId)
327
512
  ) {
328
513
  duplicates += 1;
@@ -356,26 +541,33 @@ export const ingestBatch = mutation({
356
541
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
357
542
  .take(MAX_FUNNELS_PER_SITE);
358
543
  }
359
- await accountEvent(ctx, {
544
+ const session = await accountEvent(ctx, {
360
545
  siteId: args.siteId,
361
546
  event,
362
547
  context,
363
548
  existingSession,
364
- goals: canMatchDefinition ? goals ?? [] : [],
365
- funnels: canMatchDefinition ? funnels ?? [] : [],
549
+ goals: canMatchDefinition ? (goals ?? []) : [],
550
+ funnels: canMatchDefinition ? (funnels ?? []) : [],
366
551
  aggregateDeltas,
552
+ reportRollupDeltas,
367
553
  recordTelemetry: true,
368
554
  financialConversion: false,
369
555
  trustedGoalKey: undefined,
370
556
  now,
371
557
  batchState,
372
558
  });
373
- bufferEventBatch(args.siteId, event, batchState.eventBatches);
559
+ bufferEventBatch(
560
+ args.siteId,
561
+ event,
562
+ batchState.eventBatches,
563
+ session.country ?? "unknown",
564
+ );
374
565
  accepted += 1;
375
566
  }
376
567
  await flushBatchState(ctx, batchState);
377
568
  await flushSessionRateLimits(ctx, sessionRateWindows, now);
378
569
  await flushAggregates(ctx, aggregateDeltas);
570
+ await flushReportRollups(ctx, reportRollupDeltas);
379
571
  return { accepted, duplicates, rejected };
380
572
  },
381
573
  });
@@ -387,7 +579,10 @@ export const trackConversion = mutation({
387
579
  const site = await ctx.db.get("sites", args.siteId);
388
580
  if (!site) fail("NOT_FOUND", "site not found");
389
581
  if (!isValidMoney(args.revenueCents)) {
390
- 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
+ );
391
586
  }
392
587
  let event: TrackerEvent;
393
588
  let trustedGoalKey: string | undefined;
@@ -412,7 +607,10 @@ export const trackConversion = mutation({
412
607
  Date.now(),
413
608
  );
414
609
  } catch (error) {
415
- fail("INVALID_ARGUMENT", error instanceof Error ? error.message : "invalid conversion");
610
+ fail(
611
+ "INVALID_ARGUMENT",
612
+ error instanceof Error ? error.message : "invalid conversion",
613
+ );
416
614
  }
417
615
 
418
616
  const siteCurrency = site.currency ?? "USD";
@@ -432,10 +630,14 @@ export const trackConversion = mutation({
432
630
  return { accepted: 0, duplicates: 1, rejected: 0 };
433
631
  }
434
632
  if (event.currency !== siteCurrency) {
435
- fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
436
- siteCurrency,
437
- conversionCurrency: event.currency ?? "USD",
438
- });
633
+ fail(
634
+ "CURRENCY_MISMATCH",
635
+ "conversion currency does not match site currency",
636
+ {
637
+ siteCurrency,
638
+ conversionCurrency: event.currency ?? "USD",
639
+ },
640
+ );
439
641
  }
440
642
 
441
643
  const telemetry = await findTelemetry(ctx, args.siteId, event);
@@ -467,7 +669,8 @@ export const trackConversion = mutation({
467
669
  };
468
670
 
469
671
  const aggregateDeltas: AggregateDeltas = new Map();
470
- await accountEvent(ctx, {
672
+ const reportRollupDeltas = createReportRollupDeltas();
673
+ const session = await accountEvent(ctx, {
471
674
  siteId: args.siteId,
472
675
  event,
473
676
  context: conversionContext,
@@ -475,15 +678,23 @@ export const trackConversion = mutation({
475
678
  goals: [],
476
679
  funnels: [],
477
680
  aggregateDeltas,
681
+ reportRollupDeltas,
478
682
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
479
683
  financialConversion: true,
480
684
  trustedGoalKey,
481
685
  now: Date.now(),
482
686
  });
483
687
  if (!existingTelemetry && !telemetry.saturated) {
484
- await insertEventBatch(ctx, args.siteId, event);
688
+ await insertEventBatch(
689
+ ctx,
690
+ args.siteId,
691
+ event,
692
+ session.country ?? "unknown",
693
+ );
485
694
  }
486
695
  await flushAggregates(ctx, aggregateDeltas);
696
+ await flushReportRollups(ctx, reportRollupDeltas);
697
+ await ensureAnalyticsControl(ctx, args.siteId, Date.now());
487
698
  return { accepted: 1, duplicates: 0, rejected: 0 };
488
699
  },
489
700
  });
@@ -586,11 +797,7 @@ async function flushBatchState(ctx: MutationCtx, batchState: BatchState) {
586
797
  function withoutSystemFields<
587
798
  T extends { _id: unknown; _creationTime?: number },
588
799
  >(document: T): Omit<T, "_id" | "_creationTime"> {
589
- const {
590
- _id,
591
- _creationTime,
592
- ...fields
593
- } = document;
800
+ const { _id, _creationTime, ...fields } = document;
594
801
  void _id;
595
802
  void _creationTime;
596
803
  return fields;
@@ -606,13 +813,14 @@ async function accountEvent(
606
813
  goals: Doc<"goals">[];
607
814
  funnels: Doc<"funnels">[];
608
815
  aggregateDeltas: AggregateDeltas;
816
+ reportRollupDeltas: ReportRollupDeltas;
609
817
  recordTelemetry: boolean;
610
818
  financialConversion: boolean;
611
819
  trustedGoalKey: string | undefined;
612
820
  now: number;
613
821
  batchState?: BatchState;
614
822
  },
615
- ) {
823
+ ): Promise<SessionState> {
616
824
  const { event } = input;
617
825
  const session = input.recordTelemetry
618
826
  ? await upsertSession(
@@ -622,13 +830,25 @@ async function accountEvent(
622
830
  input.context,
623
831
  input.existingSession,
624
832
  input.financialConversion,
833
+ input.reportRollupDeltas,
625
834
  input.batchState,
626
835
  )
627
836
  : await accountTrustedSession(ctx, input.existingSession, event);
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
+ );
847
+ }
628
848
 
629
849
  const matchedGoalKeys: string[] = [];
630
- const matchingGoals = input.goals.filter((goal) =>
631
- goal.active && goalMatches(goal, event)
850
+ const matchingGoals = input.goals.filter(
851
+ (goal) => goal.active && goalMatches(goal, event),
632
852
  );
633
853
  if (matchingGoals.length > 1) {
634
854
  fail("LIMIT_EXCEEDED", "active goals must use unique matchers");
@@ -639,33 +859,36 @@ async function accountEvent(
639
859
  eventBucketStart(event.timestamp),
640
860
  event.eventId,
641
861
  ]);
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);
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);
652
873
  const legacyPrior = scopedPrior
653
874
  ? 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) =>
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) =>
664
887
  completion.idempotencyVersion === undefined &&
665
888
  completion.sessionId === event.sessionId &&
666
889
  eventBucketStart(completion.timestamp) ===
667
- eventBucketStart(event.timestamp)
668
- );
890
+ eventBucketStart(event.timestamp),
891
+ );
669
892
  if (scopedPrior || legacyPrior) continue;
670
893
  await ctx.db.insert("goalCompletions", {
671
894
  siteId: input.siteId,
@@ -678,12 +901,20 @@ async function accountEvent(
678
901
  timestamp: event.timestamp,
679
902
  valueCents: goal.valueCents ?? 0,
680
903
  });
904
+ foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
681
905
  matchedGoalKeys.push(goal.key);
682
906
  }
683
907
 
684
908
  for (const funnel of input.funnels) {
685
909
  if (funnel.active) {
686
- await advanceFunnel(ctx, input.siteId, funnel, event, input.batchState);
910
+ await advanceFunnel(
911
+ ctx,
912
+ input.siteId,
913
+ funnel,
914
+ event,
915
+ input.reportRollupDeltas,
916
+ input.batchState,
917
+ );
687
918
  }
688
919
  }
689
920
 
@@ -694,18 +925,23 @@ async function accountEvent(
694
925
  session,
695
926
  event,
696
927
  input.trustedGoalKey,
928
+ input.reportRollupDeltas,
697
929
  );
698
930
  }
699
931
 
932
+ const campaignDimensions: Dimension[] = session.utmCampaign
933
+ ? [{ type: "campaign", value: session.utmCampaign }]
934
+ : [];
700
935
  const dimensions: Dimension[] = [
701
936
  { type: "source", value: session.source },
937
+ ...campaignDimensions,
702
938
  { type: "page", value: event.path },
703
- { type: "country", value: input.context.country ?? "unknown" },
939
+ { type: "country", value: session.country ?? "unknown" },
704
940
  { type: "device", value: input.context.device ?? "unknown" },
705
941
  { type: "browser", value: input.context.browser ?? "Other" },
706
942
  {
707
943
  type: "event",
708
- value: event.type === "custom" ? event.name ?? "custom" : event.type,
944
+ value: event.type === "custom" ? (event.name ?? "custom") : event.type,
709
945
  },
710
946
  ...matchedGoalKeys.map((value): Dimension => ({ type: "goal", value })),
711
947
  ];
@@ -733,6 +969,7 @@ async function accountEvent(
733
969
  input.batchState,
734
970
  );
735
971
  }
972
+ return session;
736
973
  }
737
974
 
738
975
  async function accountTrustedSession(
@@ -755,6 +992,12 @@ async function accountTrustedSession(
755
992
  isNewVisitorDay: false,
756
993
  startedAt: existing.startedAt,
757
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,
758
1001
  pageviewCount: existing.pageviewCount,
759
1002
  eventCount: existing.eventCount,
760
1003
  affiliateId: existing.affiliateId,
@@ -771,10 +1014,19 @@ async function upsertSession(
771
1014
  context: IngestContext,
772
1015
  existing: CachedDocument<"sessions"> | null,
773
1016
  financialConversion: boolean,
1017
+ reportRollupDeltas: ReportRollupDeltas,
774
1018
  batchState?: BatchState,
775
1019
  ): Promise<SessionState> {
776
- 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));
777
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);
778
1030
  const utmMedium = stringProperty(event.properties, "utm_medium");
779
1031
  const utmCampaign = stringProperty(event.properties, "utm_campaign");
780
1032
  const attribution = await attributionForEvent(
@@ -785,20 +1037,40 @@ async function upsertSession(
785
1037
  batchState,
786
1038
  );
787
1039
  if (existing) {
1040
+ if (attribution && existing.affiliateId !== attribution.affiliateId) {
1041
+ foldAffiliateVisitRollup(
1042
+ reportRollupDeltas,
1043
+ siteId,
1044
+ event,
1045
+ attribution,
1046
+ visitorKey,
1047
+ );
1048
+ }
788
1049
  const startedAt = Math.min(existing.startedAt, event.timestamp);
789
1050
  const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
790
1051
  const updates = {
791
1052
  startedAt,
792
1053
  lastSeenAt,
793
- entryPath: event.timestamp < existing.startedAt ? event.path : existing.entryPath,
794
- 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,
795
1058
  eventCount: existing.eventCount + 1,
796
- 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
+ : {}),
797
1069
  durationMs: Math.max(0, lastSeenAt - startedAt),
798
1070
  conversionCount: existing.conversionCount + Number(financialConversion),
799
1071
  revenueCents:
800
1072
  existing.revenueCents +
801
- (financialConversion ? event.revenueCents ?? 0 : 0),
1073
+ (financialConversion ? (event.revenueCents ?? 0) : 0),
802
1074
  ...(financialConversion && event.currency
803
1075
  ? { currency: event.currency }
804
1076
  : {}),
@@ -809,7 +1081,9 @@ async function upsertSession(
809
1081
  ...(context.country ? { country: context.country } : {}),
810
1082
  ...(context.city ? { city: context.city } : {}),
811
1083
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
812
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1084
+ ...(context.longitude !== undefined
1085
+ ? { longitude: context.longitude }
1086
+ : {}),
813
1087
  browser: context.browser ?? existing.browser,
814
1088
  os: context.os ?? existing.os,
815
1089
  device: context.device ?? existing.device,
@@ -832,6 +1106,12 @@ async function upsertSession(
832
1106
  Math.floor(event.timestamp / DAY_MS),
833
1107
  startedAt,
834
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,
835
1115
  pageviewCount:
836
1116
  existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
837
1117
  eventCount: existing.eventCount + 1,
@@ -863,6 +1143,7 @@ async function upsertSession(
863
1143
  siteId,
864
1144
  sessionId: event.sessionId,
865
1145
  visitorId: event.visitorId,
1146
+ visitorKey,
866
1147
  startedAt: event.timestamp,
867
1148
  lastSeenAt: event.timestamp,
868
1149
  entryPath: event.path,
@@ -875,21 +1156,33 @@ async function upsertSession(
875
1156
  ...(context.country ? { country: context.country } : {}),
876
1157
  ...(context.city ? { city: context.city } : {}),
877
1158
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
878
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1159
+ ...(context.longitude !== undefined
1160
+ ? { longitude: context.longitude }
1161
+ : {}),
879
1162
  browser: context.browser ?? "Other",
880
1163
  os: context.os ?? "Other",
881
1164
  device: context.device ?? "unknown",
882
1165
  eventCount: 1,
883
1166
  pageviewCount: event.type === "pageview" ? 1 : 0,
1167
+ ...(event.type === "pageview" ? { lastPageviewAt: event.timestamp } : {}),
884
1168
  durationMs: 0,
885
1169
  conversionCount: Number(financialConversion),
886
- revenueCents: financialConversion ? event.revenueCents ?? 0 : 0,
1170
+ revenueCents: financialConversion ? (event.revenueCents ?? 0) : 0,
887
1171
  ...(financialConversion && event.currency
888
1172
  ? { currency: event.currency }
889
1173
  : {}),
890
1174
  ...(attribution ?? {}),
891
1175
  };
892
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
+ }
893
1186
  if (batchState) {
894
1187
  const state = batchState.sessions.get(event.sessionId);
895
1188
  if (!state) throw new Error("session cache was not initialized");
@@ -910,20 +1203,24 @@ async function upsertSession(
910
1203
  Math.floor(event.timestamp / DAY_MS),
911
1204
  startedAt: event.timestamp,
912
1205
  source,
1206
+ utmCampaign,
1207
+ visitorKey,
1208
+ country: context.country,
1209
+ city: context.city,
1210
+ latitude: context.latitude,
1211
+ longitude: context.longitude,
913
1212
  pageviewCount: event.type === "pageview" ? 1 : 0,
914
1213
  eventCount: 1,
915
1214
  ...(attribution ?? {}),
916
1215
  };
917
1216
  }
918
1217
 
919
- function attributionSnapshot(
920
- value: {
921
- affiliateId?: Id<"affiliates">;
922
- affiliateSlug?: string;
923
- affiliateAttributedAt?: number;
924
- affiliateExpiresAt?: number;
925
- },
926
- ): AttributionSnapshot | undefined {
1218
+ function attributionSnapshot(value: {
1219
+ affiliateId?: Id<"affiliates">;
1220
+ affiliateSlug?: string;
1221
+ affiliateAttributedAt?: number;
1222
+ affiliateExpiresAt?: number;
1223
+ }): AttributionSnapshot | undefined {
927
1224
  if (
928
1225
  !value.affiliateId ||
929
1226
  !value.affiliateSlug ||
@@ -940,7 +1237,6 @@ function attributionSnapshot(
940
1237
  };
941
1238
  }
942
1239
 
943
-
944
1240
  function attributionApplies(
945
1241
  attribution: AttributionSnapshot,
946
1242
  timestamp: number,
@@ -1071,14 +1367,15 @@ async function accountHeartbeat(
1071
1367
  const updates = {
1072
1368
  startedAt,
1073
1369
  lastSeenAt,
1074
- exitPath: event.timestamp >= session.lastSeenAt
1075
- ? event.path
1076
- : session.exitPath,
1370
+ exitPath:
1371
+ event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
1077
1372
  durationMs: Math.max(0, lastSeenAt - startedAt),
1078
1373
  ...(context.country ? { country: context.country } : {}),
1079
1374
  ...(context.city ? { city: context.city } : {}),
1080
1375
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
1081
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1376
+ ...(context.longitude !== undefined
1377
+ ? { longitude: context.longitude }
1378
+ : {}),
1082
1379
  browser: context.browser ?? session.browser,
1083
1380
  os: context.os ?? session.os,
1084
1381
  device: context.device ?? session.device,
@@ -1102,6 +1399,11 @@ async function accountHeartbeat(
1102
1399
  isNewVisitorDay: false,
1103
1400
  startedAt,
1104
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,
1105
1407
  pageviewCount: session.pageviewCount,
1106
1408
  eventCount: session.eventCount,
1107
1409
  affiliateSlug: session.affiliateSlug,
@@ -1115,17 +1417,18 @@ async function consumeSiteRateLimit(
1115
1417
  ctx: MutationCtx,
1116
1418
  siteId: Id<"sites">,
1117
1419
  events: TrackerEvent[],
1420
+ eventByteCounts: number[],
1118
1421
  now: number,
1119
- ): Promise<number[]> {
1422
+ ): Promise<void> {
1120
1423
  const windowStart =
1121
1424
  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();
1425
+ const increments = new Map<
1426
+ number,
1427
+ { eventCount: number; byteCount: number }
1428
+ >();
1125
1429
  for (let index = 0; index < events.length; index += 1) {
1126
1430
  const event = events[index];
1127
- const byteCount = encoder.encode(JSON.stringify(event)).byteLength;
1128
- eventByteCounts.push(byteCount);
1431
+ const byteCount = eventByteCounts[index];
1129
1432
  const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
1130
1433
  const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
1131
1434
  current.eventCount += 1;
@@ -1147,9 +1450,8 @@ async function consumeSiteRateLimit(
1147
1450
  .order("desc")
1148
1451
  .take(1);
1149
1452
  const storedWindow = rows[0] ?? null;
1150
- const window = storedWindow?.windowStart === windowStart
1151
- ? storedWindow
1152
- : null;
1453
+ const window =
1454
+ storedWindow?.windowStart === windowStart ? storedWindow : null;
1153
1455
  if (
1154
1456
  (window?.eventCount ?? 0) + increment.eventCount >
1155
1457
  MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
@@ -1161,10 +1463,8 @@ async function consumeSiteRateLimit(
1161
1463
  windowMs: RATE_LIMIT_WINDOW_MS,
1162
1464
  eventLimitPerShard: MAX_SITE_EVENTS_PER_SHARD_WINDOW,
1163
1465
  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,
1466
+ siteEventLimit: SITE_INGEST_SHARDS * MAX_SITE_EVENTS_PER_SHARD_WINDOW,
1467
+ siteByteLimit: SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
1168
1468
  });
1169
1469
  }
1170
1470
  windows.push({ shard, increment, window: storedWindow });
@@ -1195,7 +1495,6 @@ async function consumeSiteRateLimit(
1195
1495
  });
1196
1496
  }
1197
1497
  }
1198
- return eventByteCounts;
1199
1498
  }
1200
1499
 
1201
1500
  async function consumeRateLimit(
@@ -1206,7 +1505,8 @@ async function consumeRateLimit(
1206
1505
  now: number,
1207
1506
  states: Map<string, SessionRateWindowState>,
1208
1507
  ): Promise<boolean> {
1209
- const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1508
+ const windowStart =
1509
+ Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1210
1510
  let state = states.get(sessionId);
1211
1511
  if (!state) {
1212
1512
  const rows = await ctx.db
@@ -1244,7 +1544,8 @@ async function flushSessionRateLimits(
1244
1544
  states: Map<string, SessionRateWindowState>,
1245
1545
  now: number,
1246
1546
  ) {
1247
- const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1547
+ const windowStart =
1548
+ Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1248
1549
  for (const state of states.values()) {
1249
1550
  if (!state.dirty) continue;
1250
1551
  if (state.window) {
@@ -1289,31 +1590,41 @@ async function updateLiveSession(
1289
1590
  )
1290
1591
  .unique();
1291
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;
1292
1597
  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;
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;
1302
1608
  const updates = {
1303
1609
  visitorId: event.visitorId,
1304
1610
  path: event.path,
1305
1611
  lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
1306
1612
  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 } : {}),
1613
+ ...(country ? { country } : {}),
1614
+ ...(city ? { city } : {}),
1615
+ ...(latitude !== undefined ? { latitude } : {}),
1616
+ ...(longitude !== undefined ? { longitude } : {}),
1311
1617
  browser: context.browser ?? existing.browser,
1312
1618
  os: context.os ?? existing.os,
1313
1619
  source: session.source,
1314
1620
  device: context.device ?? existing.device,
1315
1621
  pageviewCount: session.pageviewCount,
1316
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
+ : {}),
1317
1628
  ...(event.type === "heartbeat"
1318
1629
  ? {
1319
1630
  lastHeartbeatEventId: event.eventId,
@@ -1338,10 +1649,10 @@ async function updateLiveSession(
1338
1649
  startedAt: event.timestamp,
1339
1650
  lastSeenAt: event.timestamp,
1340
1651
  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 } : {}),
1652
+ ...(country ? { country } : {}),
1653
+ ...(city ? { city } : {}),
1654
+ ...(latitude !== undefined ? { latitude } : {}),
1655
+ ...(longitude !== undefined ? { longitude } : {}),
1345
1656
  browser: context.browser ?? "Other",
1346
1657
  os: context.os ?? "Other",
1347
1658
  source: session.source,
@@ -1376,9 +1687,13 @@ async function ensureLiveSweep(
1376
1687
  .query("liveSweepStates")
1377
1688
  .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1378
1689
  .unique();
1379
- if (!existing) {
1690
+ if (!existing || existing.scheduledAt <= now) {
1380
1691
  const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
1381
- await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1692
+ if (existing) {
1693
+ await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
1694
+ } else {
1695
+ await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1696
+ }
1382
1697
  await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
1383
1698
  siteId,
1384
1699
  scheduledAt,
@@ -1399,6 +1714,7 @@ async function advanceFunnel(
1399
1714
  siteId: Id<"sites">,
1400
1715
  funnel: Doc<"funnels">,
1401
1716
  event: TrackerEvent,
1717
+ reportRollupDeltas: ReportRollupDeltas,
1402
1718
  batchState?: BatchState,
1403
1719
  ) {
1404
1720
  const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
@@ -1447,9 +1763,14 @@ async function advanceFunnel(
1447
1763
  };
1448
1764
  }
1449
1765
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
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 = {
@@ -1473,6 +1794,7 @@ async function advanceFunnel(
1473
1794
  });
1474
1795
  }
1475
1796
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1797
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1476
1798
  return;
1477
1799
  }
1478
1800
  const nextStep = funnel.steps[progress.currentStep];
@@ -1499,6 +1821,14 @@ async function advanceFunnel(
1499
1821
  progress.currentStep,
1500
1822
  completed,
1501
1823
  );
1824
+ foldFunnelRollup(
1825
+ reportRollupDeltas,
1826
+ siteId,
1827
+ funnel._id,
1828
+ event,
1829
+ progress.currentStep,
1830
+ completed,
1831
+ );
1502
1832
  }
1503
1833
 
1504
1834
  function stepMatches(
@@ -1557,6 +1887,7 @@ async function accountConversion(
1557
1887
  session: SessionState,
1558
1888
  event: TrackerEvent,
1559
1889
  trustedGoalKey: string | undefined,
1890
+ reportRollupDeltas: ReportRollupDeltas,
1560
1891
  ) {
1561
1892
  const revenueCents = event.revenueCents ?? 0;
1562
1893
  const currency = event.currency ?? "USD";
@@ -1588,6 +1919,15 @@ async function accountConversion(
1588
1919
  commissionCents,
1589
1920
  });
1590
1921
  if (commissionAffiliate && attribution) {
1922
+ foldAffiliateConversionRollup(
1923
+ reportRollupDeltas,
1924
+ siteId,
1925
+ event,
1926
+ commissionAffiliate,
1927
+ attribution.affiliateSlug,
1928
+ revenueCents,
1929
+ commissionCents,
1930
+ );
1591
1931
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1592
1932
  const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
1593
1933
  const stats = await ctx.db
@@ -1623,6 +1963,558 @@ async function accountConversion(
1623
1963
  }
1624
1964
  }
1625
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
+
1626
2518
  function foldAggregates(
1627
2519
  aggregateDeltas: AggregateDeltas,
1628
2520
  siteId: Id<"sites">,
@@ -1632,16 +2524,15 @@ function foldAggregates(
1632
2524
  recordTelemetry: boolean,
1633
2525
  financialConversion: boolean,
1634
2526
  ) {
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
- );
2527
+ const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
2528
+ const dimensionDeltas = dimensions.map((dimension): DimensionDelta => ({
2529
+ ...dimension,
2530
+ count: Number(recordTelemetry),
2531
+ revenueCents,
2532
+ }));
1643
2533
  for (const granularity of ["hour", "day"] as const) {
1644
- const shard = stableHash(event.sessionId) %
2534
+ const shard =
2535
+ stableHash(event.sessionId) %
1645
2536
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
1646
2537
  const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
1647
2538
  const bucketStart = Math.floor(event.timestamp / interval) * interval;
@@ -1660,6 +2551,7 @@ function foldAggregates(
1660
2551
  outboundClicks: 0,
1661
2552
  sessions: 0,
1662
2553
  visitors: 0,
2554
+ visitorSketch: createVisitorSketch(),
1663
2555
  conversions: 0,
1664
2556
  revenueCents: 0,
1665
2557
  dimensions: [],
@@ -1680,6 +2572,9 @@ function foldAggregates(
1680
2572
  ? Number(session.isNewVisitorHour)
1681
2573
  : Number(session.isNewVisitorDay)
1682
2574
  : 0;
2575
+ if (recordTelemetry) {
2576
+ delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
2577
+ }
1683
2578
  delta.conversions += Number(financialConversion);
1684
2579
  delta.revenueCents += revenueCents;
1685
2580
  delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
@@ -1707,6 +2602,13 @@ async function flushAggregates(
1707
2602
  bucket?.dimensions ?? [],
1708
2603
  delta.dimensions,
1709
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;
1710
2612
  if (bucket) {
1711
2613
  await ctx.db.patch("aggregateBuckets", bucket._id, {
1712
2614
  events: bucket.events + delta.events,
@@ -1716,6 +2618,8 @@ async function flushAggregates(
1716
2618
  outboundClicks: bucket.outboundClicks + delta.outboundClicks,
1717
2619
  sessions: bucket.sessions + delta.sessions,
1718
2620
  visitors: bucket.visitors + delta.visitors,
2621
+ visitorSketch,
2622
+ visitorSketchComplete,
1719
2623
  conversions: bucket.conversions + delta.conversions,
1720
2624
  revenueCents: bucket.revenueCents + delta.revenueCents,
1721
2625
  dimensions,
@@ -1733,6 +2637,8 @@ async function flushAggregates(
1733
2637
  outboundClicks: delta.outboundClicks,
1734
2638
  sessions: delta.sessions,
1735
2639
  visitors: delta.visitors,
2640
+ visitorSketch,
2641
+ visitorSketchComplete,
1736
2642
  conversions: delta.conversions,
1737
2643
  revenueCents: delta.revenueCents,
1738
2644
  dimensions,
@@ -1761,10 +2667,7 @@ function mergeDimensions(
1761
2667
  (candidate) => candidate.type === addition.type,
1762
2668
  );
1763
2669
  if (typeSlots.length < DIMENSION_SLOTS - 1) {
1764
- const occupied = Array.from(
1765
- { length: DIMENSION_SLOTS },
1766
- () => false,
1767
- );
2670
+ const occupied = Array.from({ length: DIMENSION_SLOTS }, () => false);
1768
2671
  for (const candidate of typeSlots) occupied[candidate.slot] = true;
1769
2672
  let slot = stableHash(addition.value) % DIMENSION_SLOTS;
1770
2673
  while (occupied[slot]) slot = (slot + 1) % DIMENSION_SLOTS;
@@ -1816,5 +2719,7 @@ function stringProperty(
1816
2719
  key: string,
1817
2720
  ): string | undefined {
1818
2721
  const value = properties?.[key];
1819
- return typeof value === "string" ? cleanString(value, 120) || undefined : undefined;
2722
+ return typeof value === "string"
2723
+ ? cleanString(value, 120) || undefined
2724
+ : undefined;
1820
2725
  }