@iann29/rastro 0.1.0-alpha.2 → 0.1.0-alpha.4

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 (124) hide show
  1. package/README.md +325 -63
  2. package/agent/integration.md +651 -0
  3. package/agent/manifest.json +183 -0
  4. package/agent/manifest.schema.json +405 -0
  5. package/dist/client/federation.d.ts +205 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +179 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +1600 -8
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +210 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +9 -5
  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 +161 -1
  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/cardinality.d.ts +12 -0
  22. package/dist/component/cardinality.d.ts.map +1 -0
  23. package/dist/component/cardinality.js +94 -0
  24. package/dist/component/cardinality.js.map +1 -0
  25. package/dist/component/constants.d.ts +10 -0
  26. package/dist/component/constants.d.ts.map +1 -1
  27. package/dist/component/constants.js +10 -0
  28. package/dist/component/constants.js.map +1 -1
  29. package/dist/component/convex.config.d.ts +7 -2
  30. package/dist/component/convex.config.d.ts.map +1 -1
  31. package/dist/component/convex.config.js +9 -4
  32. package/dist/component/convex.config.js.map +1 -1
  33. package/dist/component/coverage.d.ts +18 -0
  34. package/dist/component/coverage.d.ts.map +1 -0
  35. package/dist/component/coverage.js +19 -0
  36. package/dist/component/coverage.js.map +1 -0
  37. package/dist/component/diagnostics.d.ts +9 -0
  38. package/dist/component/diagnostics.d.ts.map +1 -0
  39. package/dist/component/diagnostics.js +47 -0
  40. package/dist/component/diagnostics.js.map +1 -0
  41. package/dist/component/errors.d.ts +1 -1
  42. package/dist/component/errors.d.ts.map +1 -1
  43. package/dist/component/errors.js.map +1 -1
  44. package/dist/component/eventStore.d.ts +11 -9
  45. package/dist/component/eventStore.d.ts.map +1 -1
  46. package/dist/component/eventStore.js +55 -227
  47. package/dist/component/eventStore.js.map +1 -1
  48. package/dist/component/geo.d.ts +71 -0
  49. package/dist/component/geo.d.ts.map +1 -0
  50. package/dist/component/geo.js +610 -0
  51. package/dist/component/geo.js.map +1 -0
  52. package/dist/component/http.d.ts.map +1 -1
  53. package/dist/component/http.js +168 -17
  54. package/dist/component/http.js.map +1 -1
  55. package/dist/component/ingest.d.ts.map +1 -1
  56. package/dist/component/ingest.js +272 -32
  57. package/dist/component/ingest.js.map +1 -1
  58. package/dist/component/live.d.ts.map +1 -1
  59. package/dist/component/live.js +6 -3
  60. package/dist/component/live.js.map +1 -1
  61. package/dist/component/reports.d.ts +117 -8
  62. package/dist/component/reports.d.ts.map +1 -1
  63. package/dist/component/reports.js +641 -79
  64. package/dist/component/reports.js.map +1 -1
  65. package/dist/component/retention.d.ts +75 -1
  66. package/dist/component/retention.d.ts.map +1 -1
  67. package/dist/component/retention.js +517 -54
  68. package/dist/component/retention.js.map +1 -1
  69. package/dist/component/sanitize.d.ts +4 -1
  70. package/dist/component/sanitize.d.ts.map +1 -1
  71. package/dist/component/sanitize.js +9 -4
  72. package/dist/component/sanitize.js.map +1 -1
  73. package/dist/component/schema.d.ts +202 -53
  74. package/dist/component/schema.js +113 -16
  75. package/dist/component/schema.js.map +1 -1
  76. package/dist/component/sites.d.ts.map +1 -1
  77. package/dist/component/sites.js +5 -1
  78. package/dist/component/sites.js.map +1 -1
  79. package/dist/component/validators.d.ts +29 -51
  80. package/dist/component/validators.d.ts.map +1 -1
  81. package/dist/component/validators.js +11 -12
  82. package/dist/component/validators.js.map +1 -1
  83. package/dist/tracker/generated.d.ts +4 -4
  84. package/dist/tracker/generated.d.ts.map +1 -1
  85. package/dist/tracker/generated.js +4 -4
  86. package/dist/tracker/generated.js.map +1 -1
  87. package/dist/tracker/tracker.js +7 -6
  88. package/dist/tracker/tracker.js.map +1 -1
  89. package/dist/tracker.min.js +1 -1
  90. package/docs/benchmarks/2026-08-20-realistic.md +5 -5
  91. package/docs/benchmarks/2026-08-21-formal-certification.md +333 -0
  92. package/docs/federation-setup.md +395 -0
  93. package/docs/federation.md +258 -0
  94. package/docs/upgrading.md +130 -0
  95. package/llms.txt +65 -0
  96. package/package.json +29 -7
  97. package/scripts/benchmark-ingest.mjs +81 -32
  98. package/scripts/generate-federation-keys.mjs +20 -0
  99. package/src/component/_generated/api.ts +9 -5
  100. package/src/component/_generated/component.ts +224 -1
  101. package/src/component/_generated/server.ts +4 -0
  102. package/src/component/cardinality.ts +116 -0
  103. package/src/component/constants.ts +10 -0
  104. package/src/component/convex.config.ts +11 -5
  105. package/src/component/coverage.ts +25 -0
  106. package/src/component/diagnostics.ts +64 -0
  107. package/src/component/errors.ts +2 -1
  108. package/src/component/eventStore.ts +78 -280
  109. package/src/component/geo.ts +779 -0
  110. package/src/component/http.ts +259 -22
  111. package/src/component/ingest.ts +436 -27
  112. package/src/component/live.ts +8 -3
  113. package/src/component/reports.ts +895 -87
  114. package/src/component/retention.ts +624 -88
  115. package/src/component/sanitize.ts +15 -4
  116. package/src/component/schema.ts +125 -16
  117. package/src/component/sites.ts +5 -1
  118. package/src/component/validators.ts +12 -13
  119. package/src/test.ts +0 -2
  120. package/dist/component/migrations.d.ts +0 -17
  121. package/dist/component/migrations.d.ts.map +0 -1
  122. package/dist/component/migrations.js +0 -44
  123. package/dist/component/migrations.js.map +0 -1
  124. package/src/component/migrations.ts +0 -52
@@ -18,12 +18,21 @@ import {
18
18
  MAX_BYTES_PER_SESSION_WINDOW,
19
19
  MAX_EVENTS_PER_SESSION_WINDOW,
20
20
  MAX_FUNNELS_PER_SITE,
21
+ MAX_FUNNEL_STEPS,
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";
26
34
  import { fail } from "./errors.js";
35
+ import { ensureAnalyticsControl } from "./coverage.js";
27
36
  import {
28
37
  bufferEventBatch,
29
38
  eventBatchKey,
@@ -36,8 +45,8 @@ import {
36
45
  } from "./eventStore.js";
37
46
  import {
38
47
  cleanString,
39
- ensureBatchBytes,
40
48
  isValidMoney,
49
+ measureBatchBytes,
41
50
  originAllowed,
42
51
  sanitizeContext,
43
52
  sanitizeCurrency,
@@ -81,11 +90,49 @@ type AggregateDelta = {
81
90
  outboundClicks: number;
82
91
  sessions: number;
83
92
  visitors: number;
93
+ visitorSketch: VisitorSketch;
84
94
  conversions: number;
85
95
  revenueCents: number;
86
96
  dimensions: Doc<"aggregateBuckets">["dimensions"];
87
97
  };
88
98
  type AggregateDeltas = Map<string, AggregateDelta>;
99
+ type GoalRollupDelta = {
100
+ siteId: Id<"sites">;
101
+ goalId: Id<"goals">;
102
+ bucketStart: number;
103
+ shard: number;
104
+ goalKey: string;
105
+ completions: number;
106
+ valueCents: number;
107
+ };
108
+ type FunnelRollupDelta = {
109
+ siteId: Id<"sites">;
110
+ funnelId: Id<"funnels">;
111
+ bucketStart: number;
112
+ shard: number;
113
+ steps: Map<number, {
114
+ step: number;
115
+ entrants: number;
116
+ completions: number;
117
+ }>;
118
+ };
119
+ type AffiliateRollupDelta = {
120
+ siteId: Id<"sites">;
121
+ affiliateId: Id<"affiliates">;
122
+ bucketStart: number;
123
+ shard: number;
124
+ affiliateSlug: string;
125
+ visits: number;
126
+ visitorSketch: VisitorSketch;
127
+ conversions: number;
128
+ revenueCents: number;
129
+ commissionCents: number;
130
+ };
131
+ type ReportRollupDeltas = {
132
+ goals: Map<string, GoalRollupDelta>;
133
+ funnels: Map<string, FunnelRollupDelta>;
134
+ affiliates: Map<string, AffiliateRollupDelta>;
135
+ };
89
136
  type AttributionSnapshot = {
90
137
  affiliateId: Id<"affiliates">;
91
138
  affiliateSlug: string;
@@ -99,6 +146,10 @@ type SessionState = {
99
146
  isNewVisitorDay: boolean;
100
147
  startedAt: number;
101
148
  source: string;
149
+ country?: string;
150
+ city?: string;
151
+ latitude?: number;
152
+ longitude?: number;
102
153
  pageviewCount: number;
103
154
  eventCount: number;
104
155
  affiliateId?: Id<"affiliates">;
@@ -157,21 +208,25 @@ export const ingestBatch = mutation({
157
208
  limit: MAX_BATCH_EVENTS,
158
209
  });
159
210
  }
211
+ let eventByteCounts: number[];
160
212
  try {
161
- ensureBatchBytes({ siteId: String(args.siteId), events: args.events });
213
+ ({ eventByteCounts } = measureBatchBytes(String(args.siteId), args.events));
162
214
  } catch {
163
215
  fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
164
216
  }
165
217
 
166
218
  const now = Date.now();
167
- const eventByteCounts = await consumeSiteRateLimit(
219
+ await ensureAnalyticsControl(ctx, args.siteId, now);
220
+ await consumeSiteRateLimit(
168
221
  ctx,
169
222
  args.siteId,
170
223
  args.events,
224
+ eventByteCounts,
171
225
  now,
172
226
  );
173
227
  const context = sanitizeContext(args.context);
174
228
  const aggregateDeltas: AggregateDeltas = new Map();
229
+ const reportRollupDeltas = createReportRollupDeltas();
175
230
  const sessionRateWindows = new Map<string, SessionRateWindowState>();
176
231
  const batchState: BatchState = {
177
232
  sessions: new Map(),
@@ -236,7 +291,6 @@ export const ingestBatch = mutation({
236
291
  siteId: args.siteId,
237
292
  sessionId: group.sessionId,
238
293
  bucketStart: group.bucketStart,
239
- candidateEventIds: group.eventIds,
240
294
  });
241
295
  const newCount = [...group.eventIds].filter((eventId) =>
242
296
  !eventIds.has(eventId)
@@ -356,7 +410,7 @@ export const ingestBatch = mutation({
356
410
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
357
411
  .take(MAX_FUNNELS_PER_SITE);
358
412
  }
359
- await accountEvent(ctx, {
413
+ const session = await accountEvent(ctx, {
360
414
  siteId: args.siteId,
361
415
  event,
362
416
  context,
@@ -364,18 +418,25 @@ export const ingestBatch = mutation({
364
418
  goals: canMatchDefinition ? goals ?? [] : [],
365
419
  funnels: canMatchDefinition ? funnels ?? [] : [],
366
420
  aggregateDeltas,
421
+ reportRollupDeltas,
367
422
  recordTelemetry: true,
368
423
  financialConversion: false,
369
424
  trustedGoalKey: undefined,
370
425
  now,
371
426
  batchState,
372
427
  });
373
- bufferEventBatch(args.siteId, event, batchState.eventBatches);
428
+ bufferEventBatch(
429
+ args.siteId,
430
+ event,
431
+ batchState.eventBatches,
432
+ session.country ?? "unknown",
433
+ );
374
434
  accepted += 1;
375
435
  }
376
436
  await flushBatchState(ctx, batchState);
377
437
  await flushSessionRateLimits(ctx, sessionRateWindows, now);
378
438
  await flushAggregates(ctx, aggregateDeltas);
439
+ await flushReportRollups(ctx, reportRollupDeltas);
379
440
  return { accepted, duplicates, rejected };
380
441
  },
381
442
  });
@@ -467,7 +528,8 @@ export const trackConversion = mutation({
467
528
  };
468
529
 
469
530
  const aggregateDeltas: AggregateDeltas = new Map();
470
- await accountEvent(ctx, {
531
+ const reportRollupDeltas = createReportRollupDeltas();
532
+ const session = await accountEvent(ctx, {
471
533
  siteId: args.siteId,
472
534
  event,
473
535
  context: conversionContext,
@@ -475,15 +537,23 @@ export const trackConversion = mutation({
475
537
  goals: [],
476
538
  funnels: [],
477
539
  aggregateDeltas,
540
+ reportRollupDeltas,
478
541
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
479
542
  financialConversion: true,
480
543
  trustedGoalKey,
481
544
  now: Date.now(),
482
545
  });
483
546
  if (!existingTelemetry && !telemetry.saturated) {
484
- await insertEventBatch(ctx, args.siteId, event);
547
+ await insertEventBatch(
548
+ ctx,
549
+ args.siteId,
550
+ event,
551
+ session.country ?? "unknown",
552
+ );
485
553
  }
486
554
  await flushAggregates(ctx, aggregateDeltas);
555
+ await flushReportRollups(ctx, reportRollupDeltas);
556
+ await ensureAnalyticsControl(ctx, args.siteId, Date.now());
487
557
  return { accepted: 1, duplicates: 0, rejected: 0 };
488
558
  },
489
559
  });
@@ -606,13 +676,14 @@ async function accountEvent(
606
676
  goals: Doc<"goals">[];
607
677
  funnels: Doc<"funnels">[];
608
678
  aggregateDeltas: AggregateDeltas;
679
+ reportRollupDeltas: ReportRollupDeltas;
609
680
  recordTelemetry: boolean;
610
681
  financialConversion: boolean;
611
682
  trustedGoalKey: string | undefined;
612
683
  now: number;
613
684
  batchState?: BatchState;
614
685
  },
615
- ) {
686
+ ): Promise<SessionState> {
616
687
  const { event } = input;
617
688
  const session = input.recordTelemetry
618
689
  ? await upsertSession(
@@ -622,6 +693,7 @@ async function accountEvent(
622
693
  input.context,
623
694
  input.existingSession,
624
695
  input.financialConversion,
696
+ input.reportRollupDeltas,
625
697
  input.batchState,
626
698
  )
627
699
  : await accountTrustedSession(ctx, input.existingSession, event);
@@ -678,12 +750,25 @@ async function accountEvent(
678
750
  timestamp: event.timestamp,
679
751
  valueCents: goal.valueCents ?? 0,
680
752
  });
753
+ foldGoalRollup(
754
+ input.reportRollupDeltas,
755
+ input.siteId,
756
+ event,
757
+ goal,
758
+ );
681
759
  matchedGoalKeys.push(goal.key);
682
760
  }
683
761
 
684
762
  for (const funnel of input.funnels) {
685
763
  if (funnel.active) {
686
- await advanceFunnel(ctx, input.siteId, funnel, event, input.batchState);
764
+ await advanceFunnel(
765
+ ctx,
766
+ input.siteId,
767
+ funnel,
768
+ event,
769
+ input.reportRollupDeltas,
770
+ input.batchState,
771
+ );
687
772
  }
688
773
  }
689
774
 
@@ -694,13 +779,14 @@ async function accountEvent(
694
779
  session,
695
780
  event,
696
781
  input.trustedGoalKey,
782
+ input.reportRollupDeltas,
697
783
  );
698
784
  }
699
785
 
700
786
  const dimensions: Dimension[] = [
701
787
  { type: "source", value: session.source },
702
788
  { type: "page", value: event.path },
703
- { type: "country", value: input.context.country ?? "unknown" },
789
+ { type: "country", value: session.country ?? "unknown" },
704
790
  { type: "device", value: input.context.device ?? "unknown" },
705
791
  { type: "browser", value: input.context.browser ?? "Other" },
706
792
  {
@@ -733,6 +819,7 @@ async function accountEvent(
733
819
  input.batchState,
734
820
  );
735
821
  }
822
+ return session;
736
823
  }
737
824
 
738
825
  async function accountTrustedSession(
@@ -755,6 +842,10 @@ async function accountTrustedSession(
755
842
  isNewVisitorDay: false,
756
843
  startedAt: existing.startedAt,
757
844
  source: existing.source,
845
+ country: existing.country,
846
+ city: existing.city,
847
+ latitude: existing.latitude,
848
+ longitude: existing.longitude,
758
849
  pageviewCount: existing.pageviewCount,
759
850
  eventCount: existing.eventCount,
760
851
  affiliateId: existing.affiliateId,
@@ -771,6 +862,7 @@ async function upsertSession(
771
862
  context: IngestContext,
772
863
  existing: CachedDocument<"sessions"> | null,
773
864
  financialConversion: boolean,
865
+ reportRollupDeltas: ReportRollupDeltas,
774
866
  batchState?: BatchState,
775
867
  ): Promise<SessionState> {
776
868
  const source = sourceFromReferrer(event.referrer);
@@ -785,6 +877,14 @@ async function upsertSession(
785
877
  batchState,
786
878
  );
787
879
  if (existing) {
880
+ if (attribution && existing.affiliateId !== attribution.affiliateId) {
881
+ foldAffiliateVisitRollup(
882
+ reportRollupDeltas,
883
+ siteId,
884
+ event,
885
+ attribution,
886
+ );
887
+ }
788
888
  const startedAt = Math.min(existing.startedAt, event.timestamp);
789
889
  const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
790
890
  const updates = {
@@ -832,6 +932,10 @@ async function upsertSession(
832
932
  Math.floor(event.timestamp / DAY_MS),
833
933
  startedAt,
834
934
  source: existing.source,
935
+ country: context.country ?? existing.country,
936
+ city: context.city ?? existing.city,
937
+ latitude: context.latitude ?? existing.latitude,
938
+ longitude: context.longitude ?? existing.longitude,
835
939
  pageviewCount:
836
940
  existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
837
941
  eventCount: existing.eventCount + 1,
@@ -890,6 +994,14 @@ async function upsertSession(
890
994
  ...(attribution ?? {}),
891
995
  };
892
996
  const sessionId = await ctx.db.insert("sessions", fields);
997
+ if (attribution) {
998
+ foldAffiliateVisitRollup(
999
+ reportRollupDeltas,
1000
+ siteId,
1001
+ event,
1002
+ attribution,
1003
+ );
1004
+ }
893
1005
  if (batchState) {
894
1006
  const state = batchState.sessions.get(event.sessionId);
895
1007
  if (!state) throw new Error("session cache was not initialized");
@@ -910,6 +1022,10 @@ async function upsertSession(
910
1022
  Math.floor(event.timestamp / DAY_MS),
911
1023
  startedAt: event.timestamp,
912
1024
  source,
1025
+ country: context.country,
1026
+ city: context.city,
1027
+ latitude: context.latitude,
1028
+ longitude: context.longitude,
913
1029
  pageviewCount: event.type === "pageview" ? 1 : 0,
914
1030
  eventCount: 1,
915
1031
  ...(attribution ?? {}),
@@ -1102,6 +1218,10 @@ async function accountHeartbeat(
1102
1218
  isNewVisitorDay: false,
1103
1219
  startedAt,
1104
1220
  source: session.source,
1221
+ country: context.country ?? session.country,
1222
+ city: context.city ?? session.city,
1223
+ latitude: context.latitude ?? session.latitude,
1224
+ longitude: context.longitude ?? session.longitude,
1105
1225
  pageviewCount: session.pageviewCount,
1106
1226
  eventCount: session.eventCount,
1107
1227
  affiliateSlug: session.affiliateSlug,
@@ -1115,17 +1235,15 @@ async function consumeSiteRateLimit(
1115
1235
  ctx: MutationCtx,
1116
1236
  siteId: Id<"sites">,
1117
1237
  events: TrackerEvent[],
1238
+ eventByteCounts: number[],
1118
1239
  now: number,
1119
- ): Promise<number[]> {
1240
+ ): Promise<void> {
1120
1241
  const windowStart =
1121
1242
  Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1122
1243
  const increments = new Map<number, { eventCount: number; byteCount: number }>();
1123
- const eventByteCounts: number[] = [];
1124
- const encoder = new TextEncoder();
1125
1244
  for (let index = 0; index < events.length; index += 1) {
1126
1245
  const event = events[index];
1127
- const byteCount = encoder.encode(JSON.stringify(event)).byteLength;
1128
- eventByteCounts.push(byteCount);
1246
+ const byteCount = eventByteCounts[index];
1129
1247
  const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
1130
1248
  const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
1131
1249
  current.eventCount += 1;
@@ -1195,7 +1313,6 @@ async function consumeSiteRateLimit(
1195
1313
  });
1196
1314
  }
1197
1315
  }
1198
- return eventByteCounts;
1199
1316
  }
1200
1317
 
1201
1318
  async function consumeRateLimit(
@@ -1289,6 +1406,10 @@ async function updateLiveSession(
1289
1406
  )
1290
1407
  .unique();
1291
1408
  const deadline = now + LIVE_SESSION_TTL_MS;
1409
+ const country = context.country ?? session.country;
1410
+ const city = context.city ?? session.city;
1411
+ const latitude = context.latitude ?? session.latitude;
1412
+ const longitude = context.longitude ?? session.longitude;
1292
1413
  if (existing) {
1293
1414
  const recentHeartbeatEventIds = event.type === "heartbeat"
1294
1415
  ? [
@@ -1304,10 +1425,10 @@ async function updateLiveSession(
1304
1425
  path: event.path,
1305
1426
  lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
1306
1427
  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 } : {}),
1428
+ ...(country ? { country } : {}),
1429
+ ...(city ? { city } : {}),
1430
+ ...(latitude !== undefined ? { latitude } : {}),
1431
+ ...(longitude !== undefined ? { longitude } : {}),
1311
1432
  browser: context.browser ?? existing.browser,
1312
1433
  os: context.os ?? existing.os,
1313
1434
  source: session.source,
@@ -1338,10 +1459,10 @@ async function updateLiveSession(
1338
1459
  startedAt: event.timestamp,
1339
1460
  lastSeenAt: event.timestamp,
1340
1461
  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 } : {}),
1462
+ ...(country ? { country } : {}),
1463
+ ...(city ? { city } : {}),
1464
+ ...(latitude !== undefined ? { latitude } : {}),
1465
+ ...(longitude !== undefined ? { longitude } : {}),
1345
1466
  browser: context.browser ?? "Other",
1346
1467
  os: context.os ?? "Other",
1347
1468
  source: session.source,
@@ -1376,9 +1497,13 @@ async function ensureLiveSweep(
1376
1497
  .query("liveSweepStates")
1377
1498
  .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1378
1499
  .unique();
1379
- if (!existing) {
1500
+ if (!existing || existing.scheduledAt <= now) {
1380
1501
  const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
1381
- await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1502
+ if (existing) {
1503
+ await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
1504
+ } else {
1505
+ await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1506
+ }
1382
1507
  await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
1383
1508
  siteId,
1384
1509
  scheduledAt,
@@ -1399,6 +1524,7 @@ async function advanceFunnel(
1399
1524
  siteId: Id<"sites">,
1400
1525
  funnel: Doc<"funnels">,
1401
1526
  event: TrackerEvent,
1527
+ reportRollupDeltas: ReportRollupDeltas,
1402
1528
  batchState?: BatchState,
1403
1529
  ) {
1404
1530
  const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
@@ -1447,6 +1573,7 @@ async function advanceFunnel(
1447
1573
  };
1448
1574
  }
1449
1575
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1576
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1450
1577
  return;
1451
1578
  }
1452
1579
  if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt) return;
@@ -1473,6 +1600,7 @@ async function advanceFunnel(
1473
1600
  });
1474
1601
  }
1475
1602
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1603
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1476
1604
  return;
1477
1605
  }
1478
1606
  const nextStep = funnel.steps[progress.currentStep];
@@ -1499,6 +1627,14 @@ async function advanceFunnel(
1499
1627
  progress.currentStep,
1500
1628
  completed,
1501
1629
  );
1630
+ foldFunnelRollup(
1631
+ reportRollupDeltas,
1632
+ siteId,
1633
+ funnel._id,
1634
+ event,
1635
+ progress.currentStep,
1636
+ completed,
1637
+ );
1502
1638
  }
1503
1639
 
1504
1640
  function stepMatches(
@@ -1557,6 +1693,7 @@ async function accountConversion(
1557
1693
  session: SessionState,
1558
1694
  event: TrackerEvent,
1559
1695
  trustedGoalKey: string | undefined,
1696
+ reportRollupDeltas: ReportRollupDeltas,
1560
1697
  ) {
1561
1698
  const revenueCents = event.revenueCents ?? 0;
1562
1699
  const currency = event.currency ?? "USD";
@@ -1588,6 +1725,15 @@ async function accountConversion(
1588
1725
  commissionCents,
1589
1726
  });
1590
1727
  if (commissionAffiliate && attribution) {
1728
+ foldAffiliateConversionRollup(
1729
+ reportRollupDeltas,
1730
+ siteId,
1731
+ event,
1732
+ commissionAffiliate,
1733
+ attribution.affiliateSlug,
1734
+ revenueCents,
1735
+ commissionCents,
1736
+ );
1591
1737
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1592
1738
  const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
1593
1739
  const stats = await ctx.db
@@ -1623,6 +1769,254 @@ async function accountConversion(
1623
1769
  }
1624
1770
  }
1625
1771
 
1772
+ function createReportRollupDeltas(): ReportRollupDeltas {
1773
+ return {
1774
+ goals: new Map(),
1775
+ funnels: new Map(),
1776
+ affiliates: new Map(),
1777
+ };
1778
+ }
1779
+
1780
+ function reportRollupCoordinates(
1781
+ siteId: Id<"sites">,
1782
+ definitionId: string,
1783
+ timestamp: number,
1784
+ visitorId: string,
1785
+ ) {
1786
+ const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
1787
+ const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
1788
+ return {
1789
+ bucketStart,
1790
+ shard,
1791
+ key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
1792
+ };
1793
+ }
1794
+
1795
+ function foldGoalRollup(
1796
+ deltas: ReportRollupDeltas,
1797
+ siteId: Id<"sites">,
1798
+ event: TrackerEvent,
1799
+ goal: Doc<"goals">,
1800
+ ) {
1801
+ const coordinates = reportRollupCoordinates(
1802
+ siteId,
1803
+ String(goal._id),
1804
+ event.timestamp,
1805
+ event.visitorId,
1806
+ );
1807
+ const current = deltas.goals.get(coordinates.key) ?? {
1808
+ siteId,
1809
+ goalId: goal._id,
1810
+ bucketStart: coordinates.bucketStart,
1811
+ shard: coordinates.shard,
1812
+ goalKey: goal.key,
1813
+ completions: 0,
1814
+ valueCents: 0,
1815
+ };
1816
+ current.completions += 1;
1817
+ current.valueCents += goal.valueCents ?? 0;
1818
+ deltas.goals.set(coordinates.key, current);
1819
+ }
1820
+
1821
+ function foldFunnelRollup(
1822
+ deltas: ReportRollupDeltas,
1823
+ siteId: Id<"sites">,
1824
+ funnelId: Id<"funnels">,
1825
+ event: TrackerEvent,
1826
+ step: number,
1827
+ completed: boolean,
1828
+ ) {
1829
+ const coordinates = reportRollupCoordinates(
1830
+ siteId,
1831
+ String(funnelId),
1832
+ event.timestamp,
1833
+ event.visitorId,
1834
+ );
1835
+ const rollup = deltas.funnels.get(coordinates.key) ?? {
1836
+ siteId,
1837
+ funnelId,
1838
+ bucketStart: coordinates.bucketStart,
1839
+ shard: coordinates.shard,
1840
+ steps: new Map(),
1841
+ };
1842
+ const current = rollup.steps.get(step) ?? {
1843
+ step,
1844
+ entrants: 0,
1845
+ completions: 0,
1846
+ };
1847
+ current.entrants += 1;
1848
+ current.completions += Number(completed);
1849
+ rollup.steps.set(step, current);
1850
+ deltas.funnels.set(coordinates.key, rollup);
1851
+ }
1852
+
1853
+ function foldAffiliateVisitRollup(
1854
+ deltas: ReportRollupDeltas,
1855
+ siteId: Id<"sites">,
1856
+ event: TrackerEvent,
1857
+ attribution: AttributionSnapshot,
1858
+ ) {
1859
+ const coordinates = reportRollupCoordinates(
1860
+ siteId,
1861
+ String(attribution.affiliateId),
1862
+ event.timestamp,
1863
+ event.visitorId,
1864
+ );
1865
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1866
+ siteId,
1867
+ affiliateId: attribution.affiliateId,
1868
+ bucketStart: coordinates.bucketStart,
1869
+ shard: coordinates.shard,
1870
+ affiliateSlug: attribution.affiliateSlug,
1871
+ visits: 0,
1872
+ visitorSketch: createVisitorSketch(),
1873
+ conversions: 0,
1874
+ revenueCents: 0,
1875
+ commissionCents: 0,
1876
+ };
1877
+ current.visits += 1;
1878
+ current.visitorSketch = addVisitor(current.visitorSketch, event.visitorId);
1879
+ deltas.affiliates.set(coordinates.key, current);
1880
+ }
1881
+
1882
+ function foldAffiliateConversionRollup(
1883
+ deltas: ReportRollupDeltas,
1884
+ siteId: Id<"sites">,
1885
+ event: TrackerEvent,
1886
+ affiliate: Doc<"affiliates">,
1887
+ affiliateSlug: string,
1888
+ revenueCents: number,
1889
+ commissionCents: number,
1890
+ ) {
1891
+ const coordinates = reportRollupCoordinates(
1892
+ siteId,
1893
+ String(affiliate._id),
1894
+ event.timestamp,
1895
+ event.visitorId,
1896
+ );
1897
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1898
+ siteId,
1899
+ affiliateId: affiliate._id,
1900
+ bucketStart: coordinates.bucketStart,
1901
+ shard: coordinates.shard,
1902
+ affiliateSlug,
1903
+ visits: 0,
1904
+ visitorSketch: createVisitorSketch(),
1905
+ conversions: 0,
1906
+ revenueCents: 0,
1907
+ commissionCents: 0,
1908
+ };
1909
+ current.conversions += 1;
1910
+ current.revenueCents += revenueCents;
1911
+ current.commissionCents += commissionCents;
1912
+ deltas.affiliates.set(coordinates.key, current);
1913
+ }
1914
+
1915
+ async function flushReportRollups(
1916
+ ctx: MutationCtx,
1917
+ deltas: ReportRollupDeltas,
1918
+ ) {
1919
+ for (const delta of deltas.goals.values()) {
1920
+ const existing = await ctx.db
1921
+ .query("goalDailyRollups")
1922
+ .withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) =>
1923
+ range
1924
+ .eq("siteId", delta.siteId)
1925
+ .eq("goalId", delta.goalId)
1926
+ .eq("bucketStart", delta.bucketStart)
1927
+ .eq("shard", delta.shard),
1928
+ )
1929
+ .unique();
1930
+ const fields = {
1931
+ siteId: delta.siteId,
1932
+ goalId: delta.goalId,
1933
+ bucketStart: delta.bucketStart,
1934
+ shard: delta.shard,
1935
+ generation: 1 as const,
1936
+ goalKey: delta.goalKey,
1937
+ completions: (existing?.completions ?? 0) + delta.completions,
1938
+ valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
1939
+ };
1940
+ if (existing) {
1941
+ await ctx.db.replace("goalDailyRollups", existing._id, fields);
1942
+ } else {
1943
+ await ctx.db.insert("goalDailyRollups", fields);
1944
+ }
1945
+ }
1946
+ for (const delta of deltas.funnels.values()) {
1947
+ const existing = await ctx.db
1948
+ .query("funnelDailyRollups")
1949
+ .withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) =>
1950
+ range
1951
+ .eq("siteId", delta.siteId)
1952
+ .eq("funnelId", delta.funnelId)
1953
+ .eq("bucketStart", delta.bucketStart)
1954
+ .eq("shard", delta.shard),
1955
+ )
1956
+ .unique();
1957
+ const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
1958
+ for (const addition of delta.steps.values()) {
1959
+ const current = steps.find((step) => step.step === addition.step);
1960
+ if (current) {
1961
+ current.entrants += addition.entrants;
1962
+ current.completions += addition.completions;
1963
+ } else {
1964
+ if (steps.length >= MAX_FUNNEL_STEPS) {
1965
+ fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
1966
+ }
1967
+ steps.push(addition);
1968
+ }
1969
+ }
1970
+ const fields = {
1971
+ siteId: delta.siteId,
1972
+ funnelId: delta.funnelId,
1973
+ bucketStart: delta.bucketStart,
1974
+ shard: delta.shard,
1975
+ generation: 1 as const,
1976
+ steps,
1977
+ };
1978
+ if (existing) {
1979
+ await ctx.db.replace("funnelDailyRollups", existing._id, fields);
1980
+ } else {
1981
+ await ctx.db.insert("funnelDailyRollups", fields);
1982
+ }
1983
+ }
1984
+ for (const delta of deltas.affiliates.values()) {
1985
+ const existing = await ctx.db
1986
+ .query("affiliateDailyRollups")
1987
+ .withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) =>
1988
+ range
1989
+ .eq("siteId", delta.siteId)
1990
+ .eq("affiliateId", delta.affiliateId)
1991
+ .eq("bucketStart", delta.bucketStart)
1992
+ .eq("shard", delta.shard),
1993
+ )
1994
+ .unique();
1995
+ const fields = {
1996
+ siteId: delta.siteId,
1997
+ affiliateId: delta.affiliateId,
1998
+ bucketStart: delta.bucketStart,
1999
+ shard: delta.shard,
2000
+ generation: 1 as const,
2001
+ affiliateSlug: delta.affiliateSlug,
2002
+ visits: (existing?.visits ?? 0) + delta.visits,
2003
+ visitorSketch: unionVisitorSketches(
2004
+ existing?.visitorSketch ?? createVisitorSketch(),
2005
+ delta.visitorSketch,
2006
+ ),
2007
+ conversions: (existing?.conversions ?? 0) + delta.conversions,
2008
+ revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
2009
+ commissionCents:
2010
+ (existing?.commissionCents ?? 0) + delta.commissionCents,
2011
+ };
2012
+ if (existing) {
2013
+ await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
2014
+ } else {
2015
+ await ctx.db.insert("affiliateDailyRollups", fields);
2016
+ }
2017
+ }
2018
+ }
2019
+
1626
2020
  function foldAggregates(
1627
2021
  aggregateDeltas: AggregateDeltas,
1628
2022
  siteId: Id<"sites">,
@@ -1660,6 +2054,7 @@ function foldAggregates(
1660
2054
  outboundClicks: 0,
1661
2055
  sessions: 0,
1662
2056
  visitors: 0,
2057
+ visitorSketch: createVisitorSketch(),
1663
2058
  conversions: 0,
1664
2059
  revenueCents: 0,
1665
2060
  dimensions: [],
@@ -1680,6 +2075,9 @@ function foldAggregates(
1680
2075
  ? Number(session.isNewVisitorHour)
1681
2076
  : Number(session.isNewVisitorDay)
1682
2077
  : 0;
2078
+ if (recordTelemetry) {
2079
+ delta.visitorSketch = addVisitor(delta.visitorSketch, event.visitorId);
2080
+ }
1683
2081
  delta.conversions += Number(financialConversion);
1684
2082
  delta.revenueCents += revenueCents;
1685
2083
  delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
@@ -1707,6 +2105,13 @@ async function flushAggregates(
1707
2105
  bucket?.dimensions ?? [],
1708
2106
  delta.dimensions,
1709
2107
  );
2108
+ const priorSketch = bucket?.visitorSketch;
2109
+ const visitorSketch = priorSketch
2110
+ ? unionVisitorSketches(priorSketch, delta.visitorSketch)
2111
+ : delta.visitorSketch;
2112
+ const visitorSketchComplete = bucket
2113
+ ? bucket.visitorSketchComplete === true && priorSketch !== undefined
2114
+ : true;
1710
2115
  if (bucket) {
1711
2116
  await ctx.db.patch("aggregateBuckets", bucket._id, {
1712
2117
  events: bucket.events + delta.events,
@@ -1716,6 +2121,8 @@ async function flushAggregates(
1716
2121
  outboundClicks: bucket.outboundClicks + delta.outboundClicks,
1717
2122
  sessions: bucket.sessions + delta.sessions,
1718
2123
  visitors: bucket.visitors + delta.visitors,
2124
+ visitorSketch,
2125
+ visitorSketchComplete,
1719
2126
  conversions: bucket.conversions + delta.conversions,
1720
2127
  revenueCents: bucket.revenueCents + delta.revenueCents,
1721
2128
  dimensions,
@@ -1733,6 +2140,8 @@ async function flushAggregates(
1733
2140
  outboundClicks: delta.outboundClicks,
1734
2141
  sessions: delta.sessions,
1735
2142
  visitors: delta.visitors,
2143
+ visitorSketch,
2144
+ visitorSketchComplete,
1736
2145
  conversions: delta.conversions,
1737
2146
  revenueCents: delta.revenueCents,
1738
2147
  dimensions,