@iann29/rastro 0.1.0-alpha.3 → 0.1.0-alpha.5

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 (111) hide show
  1. package/README.md +374 -62
  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 +1658 -8
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +228 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +10 -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 +169 -1
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/cardinality.d.ts +12 -0
  19. package/dist/component/cardinality.d.ts.map +1 -0
  20. package/dist/component/cardinality.js +94 -0
  21. package/dist/component/cardinality.js.map +1 -0
  22. package/dist/component/constants.d.ts +11 -0
  23. package/dist/component/constants.d.ts.map +1 -1
  24. package/dist/component/constants.js +11 -0
  25. package/dist/component/constants.js.map +1 -1
  26. package/dist/component/coverage.d.ts +18 -0
  27. package/dist/component/coverage.d.ts.map +1 -0
  28. package/dist/component/coverage.js +19 -0
  29. package/dist/component/coverage.js.map +1 -0
  30. package/dist/component/errors.d.ts +1 -1
  31. package/dist/component/errors.d.ts.map +1 -1
  32. package/dist/component/errors.js.map +1 -1
  33. package/dist/component/eventStore.d.ts +1 -0
  34. package/dist/component/eventStore.d.ts.map +1 -1
  35. package/dist/component/http.d.ts.map +1 -1
  36. package/dist/component/http.js +85 -36
  37. package/dist/component/http.js.map +1 -1
  38. package/dist/component/identity.d.ts +13 -0
  39. package/dist/component/identity.d.ts.map +1 -0
  40. package/dist/component/identity.js +58 -0
  41. package/dist/component/identity.js.map +1 -0
  42. package/dist/component/ingest.d.ts +1 -0
  43. package/dist/component/ingest.d.ts.map +1 -1
  44. package/dist/component/ingest.js +258 -19
  45. package/dist/component/ingest.js.map +1 -1
  46. package/dist/component/live.d.ts.map +1 -1
  47. package/dist/component/live.js +6 -3
  48. package/dist/component/live.js.map +1 -1
  49. package/dist/component/reports.d.ts +120 -2
  50. package/dist/component/reports.d.ts.map +1 -1
  51. package/dist/component/reports.js +659 -91
  52. package/dist/component/reports.js.map +1 -1
  53. package/dist/component/retention.d.ts +75 -1
  54. package/dist/component/retention.d.ts.map +1 -1
  55. package/dist/component/retention.js +517 -54
  56. package/dist/component/retention.js.map +1 -1
  57. package/dist/component/sanitize.d.ts +4 -1
  58. package/dist/component/sanitize.d.ts.map +1 -1
  59. package/dist/component/sanitize.js +13 -5
  60. package/dist/component/sanitize.js.map +1 -1
  61. package/dist/component/schema.d.ts +224 -7
  62. package/dist/component/schema.js +113 -1
  63. package/dist/component/schema.js.map +1 -1
  64. package/dist/component/sites.d.ts.map +1 -1
  65. package/dist/component/sites.js +5 -1
  66. package/dist/component/sites.js.map +1 -1
  67. package/dist/component/useragent.d.ts +9 -0
  68. package/dist/component/useragent.d.ts.map +1 -0
  69. package/dist/component/useragent.js +152 -0
  70. package/dist/component/useragent.js.map +1 -0
  71. package/dist/component/validators.d.ts +42 -11
  72. package/dist/component/validators.d.ts.map +1 -1
  73. package/dist/component/validators.js +10 -1
  74. package/dist/component/validators.js.map +1 -1
  75. package/dist/component/visitors.d.ts +19 -0
  76. package/dist/component/visitors.d.ts.map +1 -0
  77. package/dist/component/visitors.js +86 -0
  78. package/dist/component/visitors.js.map +1 -0
  79. package/dist/tracker/generated.d.ts +4 -4
  80. package/dist/tracker/generated.d.ts.map +1 -1
  81. package/dist/tracker/generated.js +4 -4
  82. package/dist/tracker/generated.js.map +1 -1
  83. package/dist/tracker/tracker.js +23 -11
  84. package/dist/tracker/tracker.js.map +1 -1
  85. package/dist/tracker.min.js +1 -1
  86. package/docs/benchmarks/2026-08-20-realistic.md +4 -4
  87. package/docs/benchmarks/2026-08-21-formal-certification.md +333 -0
  88. package/docs/federation-setup.md +395 -0
  89. package/docs/federation.md +258 -0
  90. package/docs/upgrading.md +130 -0
  91. package/llms.txt +65 -0
  92. package/package.json +41 -11
  93. package/scripts/generate-federation-keys.mjs +20 -0
  94. package/src/component/_generated/api.ts +10 -0
  95. package/src/component/_generated/component.ts +228 -1
  96. package/src/component/cardinality.ts +116 -0
  97. package/src/component/constants.ts +11 -0
  98. package/src/component/coverage.ts +25 -0
  99. package/src/component/errors.ts +2 -1
  100. package/src/component/http.ts +129 -38
  101. package/src/component/identity.ts +74 -0
  102. package/src/component/ingest.ts +414 -13
  103. package/src/component/live.ts +8 -3
  104. package/src/component/reports.ts +910 -98
  105. package/src/component/retention.ts +624 -88
  106. package/src/component/sanitize.ts +19 -5
  107. package/src/component/schema.ts +125 -0
  108. package/src/component/sites.ts +5 -1
  109. package/src/component/useragent.ts +171 -0
  110. package/src/component/validators.ts +11 -0
  111. package/src/component/visitors.ts +105 -0
@@ -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,
@@ -60,6 +69,7 @@ import {
60
69
 
61
70
  type DimensionType =
62
71
  | "source"
72
+ | "campaign"
63
73
  | "page"
64
74
  | "country"
65
75
  | "device"
@@ -81,11 +91,49 @@ type AggregateDelta = {
81
91
  outboundClicks: number;
82
92
  sessions: number;
83
93
  visitors: number;
94
+ visitorSketch: VisitorSketch;
84
95
  conversions: number;
85
96
  revenueCents: number;
86
97
  dimensions: Doc<"aggregateBuckets">["dimensions"];
87
98
  };
88
99
  type AggregateDeltas = Map<string, AggregateDelta>;
100
+ type GoalRollupDelta = {
101
+ siteId: Id<"sites">;
102
+ goalId: Id<"goals">;
103
+ bucketStart: number;
104
+ shard: number;
105
+ goalKey: string;
106
+ completions: number;
107
+ valueCents: number;
108
+ };
109
+ type FunnelRollupDelta = {
110
+ siteId: Id<"sites">;
111
+ funnelId: Id<"funnels">;
112
+ bucketStart: number;
113
+ shard: number;
114
+ steps: Map<number, {
115
+ step: number;
116
+ entrants: number;
117
+ completions: number;
118
+ }>;
119
+ };
120
+ type AffiliateRollupDelta = {
121
+ siteId: Id<"sites">;
122
+ affiliateId: Id<"affiliates">;
123
+ bucketStart: number;
124
+ shard: number;
125
+ affiliateSlug: string;
126
+ visits: number;
127
+ visitorSketch: VisitorSketch;
128
+ conversions: number;
129
+ revenueCents: number;
130
+ commissionCents: number;
131
+ };
132
+ type ReportRollupDeltas = {
133
+ goals: Map<string, GoalRollupDelta>;
134
+ funnels: Map<string, FunnelRollupDelta>;
135
+ affiliates: Map<string, AffiliateRollupDelta>;
136
+ };
89
137
  type AttributionSnapshot = {
90
138
  affiliateId: Id<"affiliates">;
91
139
  affiliateSlug: string;
@@ -99,6 +147,8 @@ type SessionState = {
99
147
  isNewVisitorDay: boolean;
100
148
  startedAt: number;
101
149
  source: string;
150
+ visitorKey: string;
151
+ utmCampaign?: string;
102
152
  country?: string;
103
153
  city?: string;
104
154
  latitude?: number;
@@ -161,21 +211,25 @@ export const ingestBatch = mutation({
161
211
  limit: MAX_BATCH_EVENTS,
162
212
  });
163
213
  }
214
+ let eventByteCounts: number[];
164
215
  try {
165
- ensureBatchBytes({ siteId: String(args.siteId), events: args.events });
216
+ ({ eventByteCounts } = measureBatchBytes(String(args.siteId), args.events));
166
217
  } catch {
167
218
  fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
168
219
  }
169
220
 
170
221
  const now = Date.now();
171
- const eventByteCounts = await consumeSiteRateLimit(
222
+ await ensureAnalyticsControl(ctx, args.siteId, now);
223
+ await consumeSiteRateLimit(
172
224
  ctx,
173
225
  args.siteId,
174
226
  args.events,
227
+ eventByteCounts,
175
228
  now,
176
229
  );
177
230
  const context = sanitizeContext(args.context);
178
231
  const aggregateDeltas: AggregateDeltas = new Map();
232
+ const reportRollupDeltas = createReportRollupDeltas();
179
233
  const sessionRateWindows = new Map<string, SessionRateWindowState>();
180
234
  const batchState: BatchState = {
181
235
  sessions: new Map(),
@@ -367,6 +421,7 @@ export const ingestBatch = mutation({
367
421
  goals: canMatchDefinition ? goals ?? [] : [],
368
422
  funnels: canMatchDefinition ? funnels ?? [] : [],
369
423
  aggregateDeltas,
424
+ reportRollupDeltas,
370
425
  recordTelemetry: true,
371
426
  financialConversion: false,
372
427
  trustedGoalKey: undefined,
@@ -384,6 +439,7 @@ export const ingestBatch = mutation({
384
439
  await flushBatchState(ctx, batchState);
385
440
  await flushSessionRateLimits(ctx, sessionRateWindows, now);
386
441
  await flushAggregates(ctx, aggregateDeltas);
442
+ await flushReportRollups(ctx, reportRollupDeltas);
387
443
  return { accepted, duplicates, rejected };
388
444
  },
389
445
  });
@@ -475,6 +531,7 @@ export const trackConversion = mutation({
475
531
  };
476
532
 
477
533
  const aggregateDeltas: AggregateDeltas = new Map();
534
+ const reportRollupDeltas = createReportRollupDeltas();
478
535
  const session = await accountEvent(ctx, {
479
536
  siteId: args.siteId,
480
537
  event,
@@ -483,6 +540,7 @@ export const trackConversion = mutation({
483
540
  goals: [],
484
541
  funnels: [],
485
542
  aggregateDeltas,
543
+ reportRollupDeltas,
486
544
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
487
545
  financialConversion: true,
488
546
  trustedGoalKey,
@@ -497,6 +555,8 @@ export const trackConversion = mutation({
497
555
  );
498
556
  }
499
557
  await flushAggregates(ctx, aggregateDeltas);
558
+ await flushReportRollups(ctx, reportRollupDeltas);
559
+ await ensureAnalyticsControl(ctx, args.siteId, Date.now());
500
560
  return { accepted: 1, duplicates: 0, rejected: 0 };
501
561
  },
502
562
  });
@@ -619,6 +679,7 @@ async function accountEvent(
619
679
  goals: Doc<"goals">[];
620
680
  funnels: Doc<"funnels">[];
621
681
  aggregateDeltas: AggregateDeltas;
682
+ reportRollupDeltas: ReportRollupDeltas;
622
683
  recordTelemetry: boolean;
623
684
  financialConversion: boolean;
624
685
  trustedGoalKey: string | undefined;
@@ -635,6 +696,7 @@ async function accountEvent(
635
696
  input.context,
636
697
  input.existingSession,
637
698
  input.financialConversion,
699
+ input.reportRollupDeltas,
638
700
  input.batchState,
639
701
  )
640
702
  : await accountTrustedSession(ctx, input.existingSession, event);
@@ -691,12 +753,25 @@ async function accountEvent(
691
753
  timestamp: event.timestamp,
692
754
  valueCents: goal.valueCents ?? 0,
693
755
  });
756
+ foldGoalRollup(
757
+ input.reportRollupDeltas,
758
+ input.siteId,
759
+ event,
760
+ goal,
761
+ );
694
762
  matchedGoalKeys.push(goal.key);
695
763
  }
696
764
 
697
765
  for (const funnel of input.funnels) {
698
766
  if (funnel.active) {
699
- await advanceFunnel(ctx, input.siteId, funnel, event, input.batchState);
767
+ await advanceFunnel(
768
+ ctx,
769
+ input.siteId,
770
+ funnel,
771
+ event,
772
+ input.reportRollupDeltas,
773
+ input.batchState,
774
+ );
700
775
  }
701
776
  }
702
777
 
@@ -707,11 +782,16 @@ async function accountEvent(
707
782
  session,
708
783
  event,
709
784
  input.trustedGoalKey,
785
+ input.reportRollupDeltas,
710
786
  );
711
787
  }
712
788
 
789
+ const campaignDimensions: Dimension[] = session.utmCampaign
790
+ ? [{ type: "campaign", value: session.utmCampaign }]
791
+ : [];
713
792
  const dimensions: Dimension[] = [
714
793
  { type: "source", value: session.source },
794
+ ...campaignDimensions,
715
795
  { type: "page", value: event.path },
716
796
  { type: "country", value: session.country ?? "unknown" },
717
797
  { type: "device", value: input.context.device ?? "unknown" },
@@ -769,6 +849,8 @@ async function accountTrustedSession(
769
849
  isNewVisitorDay: false,
770
850
  startedAt: existing.startedAt,
771
851
  source: existing.source,
852
+ utmCampaign: existing.utmCampaign,
853
+ visitorKey: existing.visitorKey ?? existing.visitorId,
772
854
  country: existing.country,
773
855
  city: existing.city,
774
856
  latitude: existing.latitude,
@@ -789,10 +871,19 @@ async function upsertSession(
789
871
  context: IngestContext,
790
872
  existing: CachedDocument<"sessions"> | null,
791
873
  financialConversion: boolean,
874
+ reportRollupDeltas: ReportRollupDeltas,
792
875
  batchState?: BatchState,
793
876
  ): Promise<SessionState> {
794
- const source = sourceFromReferrer(event.referrer);
877
+ // A host-supplied id is the identity; anonymous sessions count through the
878
+ // daily key derived by the HTTP action, or once per session without one.
879
+ const visitorKey =
880
+ existing?.visitorKey ??
881
+ (event.visitorId !== event.sessionId
882
+ ? event.visitorId
883
+ : (context.visitorKey ?? event.sessionId));
795
884
  const utmSource = stringProperty(event.properties, "utm_source");
885
+ // Campaign traffic names its own source; the referrer host is the fallback.
886
+ const source = utmSource?.toLowerCase() ?? sourceFromReferrer(event.referrer);
796
887
  const utmMedium = stringProperty(event.properties, "utm_medium");
797
888
  const utmCampaign = stringProperty(event.properties, "utm_campaign");
798
889
  const attribution = await attributionForEvent(
@@ -803,6 +894,15 @@ async function upsertSession(
803
894
  batchState,
804
895
  );
805
896
  if (existing) {
897
+ if (attribution && existing.affiliateId !== attribution.affiliateId) {
898
+ foldAffiliateVisitRollup(
899
+ reportRollupDeltas,
900
+ siteId,
901
+ event,
902
+ attribution,
903
+ visitorKey,
904
+ );
905
+ }
806
906
  const startedAt = Math.min(existing.startedAt, event.timestamp);
807
907
  const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
808
908
  const updates = {
@@ -850,6 +950,8 @@ async function upsertSession(
850
950
  Math.floor(event.timestamp / DAY_MS),
851
951
  startedAt,
852
952
  source: existing.source,
953
+ utmCampaign: existing.utmCampaign,
954
+ visitorKey,
853
955
  country: context.country ?? existing.country,
854
956
  city: context.city ?? existing.city,
855
957
  latitude: context.latitude ?? existing.latitude,
@@ -885,6 +987,7 @@ async function upsertSession(
885
987
  siteId,
886
988
  sessionId: event.sessionId,
887
989
  visitorId: event.visitorId,
990
+ visitorKey,
888
991
  startedAt: event.timestamp,
889
992
  lastSeenAt: event.timestamp,
890
993
  entryPath: event.path,
@@ -912,6 +1015,15 @@ async function upsertSession(
912
1015
  ...(attribution ?? {}),
913
1016
  };
914
1017
  const sessionId = await ctx.db.insert("sessions", fields);
1018
+ if (attribution) {
1019
+ foldAffiliateVisitRollup(
1020
+ reportRollupDeltas,
1021
+ siteId,
1022
+ event,
1023
+ attribution,
1024
+ visitorKey,
1025
+ );
1026
+ }
915
1027
  if (batchState) {
916
1028
  const state = batchState.sessions.get(event.sessionId);
917
1029
  if (!state) throw new Error("session cache was not initialized");
@@ -932,6 +1044,8 @@ async function upsertSession(
932
1044
  Math.floor(event.timestamp / DAY_MS),
933
1045
  startedAt: event.timestamp,
934
1046
  source,
1047
+ utmCampaign,
1048
+ visitorKey,
935
1049
  country: context.country,
936
1050
  city: context.city,
937
1051
  latitude: context.latitude,
@@ -1128,6 +1242,7 @@ async function accountHeartbeat(
1128
1242
  isNewVisitorDay: false,
1129
1243
  startedAt,
1130
1244
  source: session.source,
1245
+ visitorKey: session.visitorKey ?? session.visitorId,
1131
1246
  country: context.country ?? session.country,
1132
1247
  city: context.city ?? session.city,
1133
1248
  latitude: context.latitude ?? session.latitude,
@@ -1145,17 +1260,15 @@ async function consumeSiteRateLimit(
1145
1260
  ctx: MutationCtx,
1146
1261
  siteId: Id<"sites">,
1147
1262
  events: TrackerEvent[],
1263
+ eventByteCounts: number[],
1148
1264
  now: number,
1149
- ): Promise<number[]> {
1265
+ ): Promise<void> {
1150
1266
  const windowStart =
1151
1267
  Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1152
1268
  const increments = new Map<number, { eventCount: number; byteCount: number }>();
1153
- const eventByteCounts: number[] = [];
1154
- const encoder = new TextEncoder();
1155
1269
  for (let index = 0; index < events.length; index += 1) {
1156
1270
  const event = events[index];
1157
- const byteCount = encoder.encode(JSON.stringify(event)).byteLength;
1158
- eventByteCounts.push(byteCount);
1271
+ const byteCount = eventByteCounts[index];
1159
1272
  const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
1160
1273
  const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
1161
1274
  current.eventCount += 1;
@@ -1225,7 +1338,6 @@ async function consumeSiteRateLimit(
1225
1338
  });
1226
1339
  }
1227
1340
  }
1228
- return eventByteCounts;
1229
1341
  }
1230
1342
 
1231
1343
  async function consumeRateLimit(
@@ -1410,9 +1522,13 @@ async function ensureLiveSweep(
1410
1522
  .query("liveSweepStates")
1411
1523
  .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1412
1524
  .unique();
1413
- if (!existing) {
1525
+ if (!existing || existing.scheduledAt <= now) {
1414
1526
  const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
1415
- await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1527
+ if (existing) {
1528
+ await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
1529
+ } else {
1530
+ await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1531
+ }
1416
1532
  await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
1417
1533
  siteId,
1418
1534
  scheduledAt,
@@ -1433,6 +1549,7 @@ async function advanceFunnel(
1433
1549
  siteId: Id<"sites">,
1434
1550
  funnel: Doc<"funnels">,
1435
1551
  event: TrackerEvent,
1552
+ reportRollupDeltas: ReportRollupDeltas,
1436
1553
  batchState?: BatchState,
1437
1554
  ) {
1438
1555
  const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
@@ -1481,6 +1598,7 @@ async function advanceFunnel(
1481
1598
  };
1482
1599
  }
1483
1600
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1601
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1484
1602
  return;
1485
1603
  }
1486
1604
  if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt) return;
@@ -1507,6 +1625,7 @@ async function advanceFunnel(
1507
1625
  });
1508
1626
  }
1509
1627
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1628
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1510
1629
  return;
1511
1630
  }
1512
1631
  const nextStep = funnel.steps[progress.currentStep];
@@ -1533,6 +1652,14 @@ async function advanceFunnel(
1533
1652
  progress.currentStep,
1534
1653
  completed,
1535
1654
  );
1655
+ foldFunnelRollup(
1656
+ reportRollupDeltas,
1657
+ siteId,
1658
+ funnel._id,
1659
+ event,
1660
+ progress.currentStep,
1661
+ completed,
1662
+ );
1536
1663
  }
1537
1664
 
1538
1665
  function stepMatches(
@@ -1591,6 +1718,7 @@ async function accountConversion(
1591
1718
  session: SessionState,
1592
1719
  event: TrackerEvent,
1593
1720
  trustedGoalKey: string | undefined,
1721
+ reportRollupDeltas: ReportRollupDeltas,
1594
1722
  ) {
1595
1723
  const revenueCents = event.revenueCents ?? 0;
1596
1724
  const currency = event.currency ?? "USD";
@@ -1622,6 +1750,15 @@ async function accountConversion(
1622
1750
  commissionCents,
1623
1751
  });
1624
1752
  if (commissionAffiliate && attribution) {
1753
+ foldAffiliateConversionRollup(
1754
+ reportRollupDeltas,
1755
+ siteId,
1756
+ event,
1757
+ commissionAffiliate,
1758
+ attribution.affiliateSlug,
1759
+ revenueCents,
1760
+ commissionCents,
1761
+ );
1625
1762
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1626
1763
  const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
1627
1764
  const stats = await ctx.db
@@ -1657,6 +1794,255 @@ async function accountConversion(
1657
1794
  }
1658
1795
  }
1659
1796
 
1797
+ function createReportRollupDeltas(): ReportRollupDeltas {
1798
+ return {
1799
+ goals: new Map(),
1800
+ funnels: new Map(),
1801
+ affiliates: new Map(),
1802
+ };
1803
+ }
1804
+
1805
+ function reportRollupCoordinates(
1806
+ siteId: Id<"sites">,
1807
+ definitionId: string,
1808
+ timestamp: number,
1809
+ visitorId: string,
1810
+ ) {
1811
+ const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
1812
+ const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
1813
+ return {
1814
+ bucketStart,
1815
+ shard,
1816
+ key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
1817
+ };
1818
+ }
1819
+
1820
+ function foldGoalRollup(
1821
+ deltas: ReportRollupDeltas,
1822
+ siteId: Id<"sites">,
1823
+ event: TrackerEvent,
1824
+ goal: Doc<"goals">,
1825
+ ) {
1826
+ const coordinates = reportRollupCoordinates(
1827
+ siteId,
1828
+ String(goal._id),
1829
+ event.timestamp,
1830
+ event.visitorId,
1831
+ );
1832
+ const current = deltas.goals.get(coordinates.key) ?? {
1833
+ siteId,
1834
+ goalId: goal._id,
1835
+ bucketStart: coordinates.bucketStart,
1836
+ shard: coordinates.shard,
1837
+ goalKey: goal.key,
1838
+ completions: 0,
1839
+ valueCents: 0,
1840
+ };
1841
+ current.completions += 1;
1842
+ current.valueCents += goal.valueCents ?? 0;
1843
+ deltas.goals.set(coordinates.key, current);
1844
+ }
1845
+
1846
+ function foldFunnelRollup(
1847
+ deltas: ReportRollupDeltas,
1848
+ siteId: Id<"sites">,
1849
+ funnelId: Id<"funnels">,
1850
+ event: TrackerEvent,
1851
+ step: number,
1852
+ completed: boolean,
1853
+ ) {
1854
+ const coordinates = reportRollupCoordinates(
1855
+ siteId,
1856
+ String(funnelId),
1857
+ event.timestamp,
1858
+ event.visitorId,
1859
+ );
1860
+ const rollup = deltas.funnels.get(coordinates.key) ?? {
1861
+ siteId,
1862
+ funnelId,
1863
+ bucketStart: coordinates.bucketStart,
1864
+ shard: coordinates.shard,
1865
+ steps: new Map(),
1866
+ };
1867
+ const current = rollup.steps.get(step) ?? {
1868
+ step,
1869
+ entrants: 0,
1870
+ completions: 0,
1871
+ };
1872
+ current.entrants += 1;
1873
+ current.completions += Number(completed);
1874
+ rollup.steps.set(step, current);
1875
+ deltas.funnels.set(coordinates.key, rollup);
1876
+ }
1877
+
1878
+ function foldAffiliateVisitRollup(
1879
+ deltas: ReportRollupDeltas,
1880
+ siteId: Id<"sites">,
1881
+ event: TrackerEvent,
1882
+ attribution: AttributionSnapshot,
1883
+ visitorKey: string,
1884
+ ) {
1885
+ const coordinates = reportRollupCoordinates(
1886
+ siteId,
1887
+ String(attribution.affiliateId),
1888
+ event.timestamp,
1889
+ event.visitorId,
1890
+ );
1891
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1892
+ siteId,
1893
+ affiliateId: attribution.affiliateId,
1894
+ bucketStart: coordinates.bucketStart,
1895
+ shard: coordinates.shard,
1896
+ affiliateSlug: attribution.affiliateSlug,
1897
+ visits: 0,
1898
+ visitorSketch: createVisitorSketch(),
1899
+ conversions: 0,
1900
+ revenueCents: 0,
1901
+ commissionCents: 0,
1902
+ };
1903
+ current.visits += 1;
1904
+ current.visitorSketch = addVisitor(current.visitorSketch, visitorKey);
1905
+ deltas.affiliates.set(coordinates.key, current);
1906
+ }
1907
+
1908
+ function foldAffiliateConversionRollup(
1909
+ deltas: ReportRollupDeltas,
1910
+ siteId: Id<"sites">,
1911
+ event: TrackerEvent,
1912
+ affiliate: Doc<"affiliates">,
1913
+ affiliateSlug: string,
1914
+ revenueCents: number,
1915
+ commissionCents: number,
1916
+ ) {
1917
+ const coordinates = reportRollupCoordinates(
1918
+ siteId,
1919
+ String(affiliate._id),
1920
+ event.timestamp,
1921
+ event.visitorId,
1922
+ );
1923
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1924
+ siteId,
1925
+ affiliateId: affiliate._id,
1926
+ bucketStart: coordinates.bucketStart,
1927
+ shard: coordinates.shard,
1928
+ affiliateSlug,
1929
+ visits: 0,
1930
+ visitorSketch: createVisitorSketch(),
1931
+ conversions: 0,
1932
+ revenueCents: 0,
1933
+ commissionCents: 0,
1934
+ };
1935
+ current.conversions += 1;
1936
+ current.revenueCents += revenueCents;
1937
+ current.commissionCents += commissionCents;
1938
+ deltas.affiliates.set(coordinates.key, current);
1939
+ }
1940
+
1941
+ async function flushReportRollups(
1942
+ ctx: MutationCtx,
1943
+ deltas: ReportRollupDeltas,
1944
+ ) {
1945
+ for (const delta of deltas.goals.values()) {
1946
+ const existing = await ctx.db
1947
+ .query("goalDailyRollups")
1948
+ .withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) =>
1949
+ range
1950
+ .eq("siteId", delta.siteId)
1951
+ .eq("goalId", delta.goalId)
1952
+ .eq("bucketStart", delta.bucketStart)
1953
+ .eq("shard", delta.shard),
1954
+ )
1955
+ .unique();
1956
+ const fields = {
1957
+ siteId: delta.siteId,
1958
+ goalId: delta.goalId,
1959
+ bucketStart: delta.bucketStart,
1960
+ shard: delta.shard,
1961
+ generation: 1 as const,
1962
+ goalKey: delta.goalKey,
1963
+ completions: (existing?.completions ?? 0) + delta.completions,
1964
+ valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
1965
+ };
1966
+ if (existing) {
1967
+ await ctx.db.replace("goalDailyRollups", existing._id, fields);
1968
+ } else {
1969
+ await ctx.db.insert("goalDailyRollups", fields);
1970
+ }
1971
+ }
1972
+ for (const delta of deltas.funnels.values()) {
1973
+ const existing = await ctx.db
1974
+ .query("funnelDailyRollups")
1975
+ .withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) =>
1976
+ range
1977
+ .eq("siteId", delta.siteId)
1978
+ .eq("funnelId", delta.funnelId)
1979
+ .eq("bucketStart", delta.bucketStart)
1980
+ .eq("shard", delta.shard),
1981
+ )
1982
+ .unique();
1983
+ const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
1984
+ for (const addition of delta.steps.values()) {
1985
+ const current = steps.find((step) => step.step === addition.step);
1986
+ if (current) {
1987
+ current.entrants += addition.entrants;
1988
+ current.completions += addition.completions;
1989
+ } else {
1990
+ if (steps.length >= MAX_FUNNEL_STEPS) {
1991
+ fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
1992
+ }
1993
+ steps.push(addition);
1994
+ }
1995
+ }
1996
+ const fields = {
1997
+ siteId: delta.siteId,
1998
+ funnelId: delta.funnelId,
1999
+ bucketStart: delta.bucketStart,
2000
+ shard: delta.shard,
2001
+ generation: 1 as const,
2002
+ steps,
2003
+ };
2004
+ if (existing) {
2005
+ await ctx.db.replace("funnelDailyRollups", existing._id, fields);
2006
+ } else {
2007
+ await ctx.db.insert("funnelDailyRollups", fields);
2008
+ }
2009
+ }
2010
+ for (const delta of deltas.affiliates.values()) {
2011
+ const existing = await ctx.db
2012
+ .query("affiliateDailyRollups")
2013
+ .withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) =>
2014
+ range
2015
+ .eq("siteId", delta.siteId)
2016
+ .eq("affiliateId", delta.affiliateId)
2017
+ .eq("bucketStart", delta.bucketStart)
2018
+ .eq("shard", delta.shard),
2019
+ )
2020
+ .unique();
2021
+ const fields = {
2022
+ siteId: delta.siteId,
2023
+ affiliateId: delta.affiliateId,
2024
+ bucketStart: delta.bucketStart,
2025
+ shard: delta.shard,
2026
+ generation: 1 as const,
2027
+ affiliateSlug: delta.affiliateSlug,
2028
+ visits: (existing?.visits ?? 0) + delta.visits,
2029
+ visitorSketch: unionVisitorSketches(
2030
+ existing?.visitorSketch ?? createVisitorSketch(),
2031
+ delta.visitorSketch,
2032
+ ),
2033
+ conversions: (existing?.conversions ?? 0) + delta.conversions,
2034
+ revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
2035
+ commissionCents:
2036
+ (existing?.commissionCents ?? 0) + delta.commissionCents,
2037
+ };
2038
+ if (existing) {
2039
+ await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
2040
+ } else {
2041
+ await ctx.db.insert("affiliateDailyRollups", fields);
2042
+ }
2043
+ }
2044
+ }
2045
+
1660
2046
  function foldAggregates(
1661
2047
  aggregateDeltas: AggregateDeltas,
1662
2048
  siteId: Id<"sites">,
@@ -1694,6 +2080,7 @@ function foldAggregates(
1694
2080
  outboundClicks: 0,
1695
2081
  sessions: 0,
1696
2082
  visitors: 0,
2083
+ visitorSketch: createVisitorSketch(),
1697
2084
  conversions: 0,
1698
2085
  revenueCents: 0,
1699
2086
  dimensions: [],
@@ -1714,6 +2101,9 @@ function foldAggregates(
1714
2101
  ? Number(session.isNewVisitorHour)
1715
2102
  : Number(session.isNewVisitorDay)
1716
2103
  : 0;
2104
+ if (recordTelemetry) {
2105
+ delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
2106
+ }
1717
2107
  delta.conversions += Number(financialConversion);
1718
2108
  delta.revenueCents += revenueCents;
1719
2109
  delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
@@ -1741,6 +2131,13 @@ async function flushAggregates(
1741
2131
  bucket?.dimensions ?? [],
1742
2132
  delta.dimensions,
1743
2133
  );
2134
+ const priorSketch = bucket?.visitorSketch;
2135
+ const visitorSketch = priorSketch
2136
+ ? unionVisitorSketches(priorSketch, delta.visitorSketch)
2137
+ : delta.visitorSketch;
2138
+ const visitorSketchComplete = bucket
2139
+ ? bucket.visitorSketchComplete === true && priorSketch !== undefined
2140
+ : true;
1744
2141
  if (bucket) {
1745
2142
  await ctx.db.patch("aggregateBuckets", bucket._id, {
1746
2143
  events: bucket.events + delta.events,
@@ -1750,6 +2147,8 @@ async function flushAggregates(
1750
2147
  outboundClicks: bucket.outboundClicks + delta.outboundClicks,
1751
2148
  sessions: bucket.sessions + delta.sessions,
1752
2149
  visitors: bucket.visitors + delta.visitors,
2150
+ visitorSketch,
2151
+ visitorSketchComplete,
1753
2152
  conversions: bucket.conversions + delta.conversions,
1754
2153
  revenueCents: bucket.revenueCents + delta.revenueCents,
1755
2154
  dimensions,
@@ -1767,6 +2166,8 @@ async function flushAggregates(
1767
2166
  outboundClicks: delta.outboundClicks,
1768
2167
  sessions: delta.sessions,
1769
2168
  visitors: delta.visitors,
2169
+ visitorSketch,
2170
+ visitorSketchComplete,
1770
2171
  conversions: delta.conversions,
1771
2172
  revenueCents: delta.revenueCents,
1772
2173
  dimensions,