@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
@@ -1,10 +1,12 @@
1
1
  import { v } from "convex/values";
2
2
  import { mutation } from "./_generated/server.js";
3
3
  import { internal } from "./_generated/api.js";
4
- import { AGGREGATE_SHARDS, MAX_BATCH_EVENT_GROUPS, DAY_MS, HOURLY_AGGREGATE_SHARDS, DIMENSION_SLOTS, HOUR_MS, LIVE_SESSION_TTL_MS, LIVE_SWEEP_INTERVAL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, RATE_LIMIT_WINDOW_MS, SECONDARY_AGGREGATE_SHARDS, SITE_INGEST_SHARDS, } from "./constants.js";
4
+ import { AGGREGATE_SHARDS, MAX_BATCH_EVENT_GROUPS, DAY_MS, HOURLY_AGGREGATE_SHARDS, DIMENSION_SLOTS, HOUR_MS, LIVE_SESSION_TTL_MS, LIVE_SWEEP_INTERVAL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_FUNNEL_STEPS, MAX_GOALS_PER_SITE, RATE_LIMIT_WINDOW_MS, REPORT_ROLLUP_SHARDS, SECONDARY_AGGREGATE_SHARDS, SITE_INGEST_SHARDS, } from "./constants.js";
5
+ import { addVisitor, createVisitorSketch, unionVisitorSketches, } from "./cardinality.js";
5
6
  import { fail } from "./errors.js";
7
+ import { ensureAnalyticsControl } from "./coverage.js";
6
8
  import { bufferEventBatch, eventBatchKey, eventBucketStart, findTelemetry, flushEventBatches, insertEventBatch, inspectTelemetryMinute, } from "./eventStore.js";
7
- import { cleanString, ensureBatchBytes, isValidMoney, originAllowed, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
9
+ import { cleanString, isValidMoney, measureBatchBytes, originAllowed, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
8
10
  import { ingestContextValidator, ingestResultValidator, trackerEventValidator, trustedConversionValidator, } from "./validators.js";
9
11
  export const ingestBatch = mutation({
10
12
  args: {
@@ -28,16 +30,19 @@ export const ingestBatch = mutation({
28
30
  limit: MAX_BATCH_EVENTS,
29
31
  });
30
32
  }
33
+ let eventByteCounts;
31
34
  try {
32
- ensureBatchBytes({ siteId: String(args.siteId), events: args.events });
35
+ ({ eventByteCounts } = measureBatchBytes(String(args.siteId), args.events));
33
36
  }
34
37
  catch {
35
38
  fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
36
39
  }
37
40
  const now = Date.now();
38
- const eventByteCounts = await consumeSiteRateLimit(ctx, args.siteId, args.events, now);
41
+ await ensureAnalyticsControl(ctx, args.siteId, now);
42
+ await consumeSiteRateLimit(ctx, args.siteId, args.events, eventByteCounts, now);
39
43
  const context = sanitizeContext(args.context);
40
44
  const aggregateDeltas = new Map();
45
+ const reportRollupDeltas = createReportRollupDeltas();
41
46
  const sessionRateWindows = new Map();
42
47
  const batchState = {
43
48
  sessions: new Map(),
@@ -171,6 +176,7 @@ export const ingestBatch = mutation({
171
176
  goals: canMatchDefinition ? goals ?? [] : [],
172
177
  funnels: canMatchDefinition ? funnels ?? [] : [],
173
178
  aggregateDeltas,
179
+ reportRollupDeltas,
174
180
  recordTelemetry: true,
175
181
  financialConversion: false,
176
182
  trustedGoalKey: undefined,
@@ -183,6 +189,7 @@ export const ingestBatch = mutation({
183
189
  await flushBatchState(ctx, batchState);
184
190
  await flushSessionRateLimits(ctx, sessionRateWindows, now);
185
191
  await flushAggregates(ctx, aggregateDeltas);
192
+ await flushReportRollups(ctx, reportRollupDeltas);
186
193
  return { accepted, duplicates, rejected };
187
194
  },
188
195
  });
@@ -260,6 +267,7 @@ export const trackConversion = mutation({
260
267
  device: existingTelemetry?.device ?? existingSession?.device,
261
268
  };
262
269
  const aggregateDeltas = new Map();
270
+ const reportRollupDeltas = createReportRollupDeltas();
263
271
  const session = await accountEvent(ctx, {
264
272
  siteId: args.siteId,
265
273
  event,
@@ -268,6 +276,7 @@ export const trackConversion = mutation({
268
276
  goals: [],
269
277
  funnels: [],
270
278
  aggregateDeltas,
279
+ reportRollupDeltas,
271
280
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
272
281
  financialConversion: true,
273
282
  trustedGoalKey,
@@ -277,6 +286,8 @@ export const trackConversion = mutation({
277
286
  await insertEventBatch(ctx, args.siteId, event, session.country ?? "unknown");
278
287
  }
279
288
  await flushAggregates(ctx, aggregateDeltas);
289
+ await flushReportRollups(ctx, reportRollupDeltas);
290
+ await ensureAnalyticsControl(ctx, args.siteId, Date.now());
280
291
  return { accepted: 1, duplicates: 0, rejected: 0 };
281
292
  },
282
293
  });
@@ -346,7 +357,7 @@ function withoutSystemFields(document) {
346
357
  async function accountEvent(ctx, input) {
347
358
  const { event } = input;
348
359
  const session = input.recordTelemetry
349
- ? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion, input.batchState)
360
+ ? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion, input.reportRollupDeltas, input.batchState)
350
361
  : await accountTrustedSession(ctx, input.existingSession, event);
351
362
  const matchedGoalKeys = [];
352
363
  const matchingGoals = input.goals.filter((goal) => goal.active && goalMatches(goal, event));
@@ -393,18 +404,23 @@ async function accountEvent(ctx, input) {
393
404
  timestamp: event.timestamp,
394
405
  valueCents: goal.valueCents ?? 0,
395
406
  });
407
+ foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
396
408
  matchedGoalKeys.push(goal.key);
397
409
  }
398
410
  for (const funnel of input.funnels) {
399
411
  if (funnel.active) {
400
- await advanceFunnel(ctx, input.siteId, funnel, event, input.batchState);
412
+ await advanceFunnel(ctx, input.siteId, funnel, event, input.reportRollupDeltas, input.batchState);
401
413
  }
402
414
  }
403
415
  if (input.financialConversion) {
404
- await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey);
416
+ await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey, input.reportRollupDeltas);
405
417
  }
418
+ const campaignDimensions = session.utmCampaign
419
+ ? [{ type: "campaign", value: session.utmCampaign }]
420
+ : [];
406
421
  const dimensions = [
407
422
  { type: "source", value: session.source },
423
+ ...campaignDimensions,
408
424
  { type: "page", value: event.path },
409
425
  { type: "country", value: session.country ?? "unknown" },
410
426
  { type: "device", value: input.context.device ?? "unknown" },
@@ -438,6 +454,8 @@ async function accountTrustedSession(ctx, existing, event) {
438
454
  isNewVisitorDay: false,
439
455
  startedAt: existing.startedAt,
440
456
  source: existing.source,
457
+ utmCampaign: existing.utmCampaign,
458
+ visitorKey: existing.visitorKey ?? existing.visitorId,
441
459
  country: existing.country,
442
460
  city: existing.city,
443
461
  latitude: existing.latitude,
@@ -450,13 +468,23 @@ async function accountTrustedSession(ctx, existing, event) {
450
468
  affiliateExpiresAt: existing.affiliateExpiresAt,
451
469
  };
452
470
  }
453
- async function upsertSession(ctx, siteId, event, context, existing, financialConversion, batchState) {
454
- const source = sourceFromReferrer(event.referrer);
471
+ async function upsertSession(ctx, siteId, event, context, existing, financialConversion, reportRollupDeltas, batchState) {
472
+ // A host-supplied id is the identity; anonymous sessions count through the
473
+ // daily key derived by the HTTP action, or once per session without one.
474
+ const visitorKey = existing?.visitorKey ??
475
+ (event.visitorId !== event.sessionId
476
+ ? event.visitorId
477
+ : (context.visitorKey ?? event.sessionId));
455
478
  const utmSource = stringProperty(event.properties, "utm_source");
479
+ // Campaign traffic names its own source; the referrer host is the fallback.
480
+ const source = utmSource?.toLowerCase() ?? sourceFromReferrer(event.referrer);
456
481
  const utmMedium = stringProperty(event.properties, "utm_medium");
457
482
  const utmCampaign = stringProperty(event.properties, "utm_campaign");
458
483
  const attribution = await attributionForEvent(ctx, siteId, event, existing, batchState);
459
484
  if (existing) {
485
+ if (attribution && existing.affiliateId !== attribution.affiliateId) {
486
+ foldAffiliateVisitRollup(reportRollupDeltas, siteId, event, attribution, visitorKey);
487
+ }
460
488
  const startedAt = Math.min(existing.startedAt, event.timestamp);
461
489
  const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
462
490
  const updates = {
@@ -503,6 +531,8 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
503
531
  Math.floor(event.timestamp / DAY_MS),
504
532
  startedAt,
505
533
  source: existing.source,
534
+ utmCampaign: existing.utmCampaign,
535
+ visitorKey,
506
536
  country: context.country ?? existing.country,
507
537
  city: context.city ?? existing.city,
508
538
  latitude: context.latitude ?? existing.latitude,
@@ -532,6 +562,7 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
532
562
  siteId,
533
563
  sessionId: event.sessionId,
534
564
  visitorId: event.visitorId,
565
+ visitorKey,
535
566
  startedAt: event.timestamp,
536
567
  lastSeenAt: event.timestamp,
537
568
  entryPath: event.path,
@@ -559,6 +590,9 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
559
590
  ...(attribution ?? {}),
560
591
  };
561
592
  const sessionId = await ctx.db.insert("sessions", fields);
593
+ if (attribution) {
594
+ foldAffiliateVisitRollup(reportRollupDeltas, siteId, event, attribution, visitorKey);
595
+ }
562
596
  if (batchState) {
563
597
  const state = batchState.sessions.get(event.sessionId);
564
598
  if (!state)
@@ -578,6 +612,8 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
578
612
  Math.floor(event.timestamp / DAY_MS),
579
613
  startedAt: event.timestamp,
580
614
  source,
615
+ utmCampaign,
616
+ visitorKey,
581
617
  country: context.country,
582
618
  city: context.city,
583
619
  latitude: context.latitude,
@@ -729,6 +765,7 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
729
765
  isNewVisitorDay: false,
730
766
  startedAt,
731
767
  source: session.source,
768
+ visitorKey: session.visitorKey ?? session.visitorId,
732
769
  country: context.country ?? session.country,
733
770
  city: context.city ?? session.city,
734
771
  latitude: context.latitude ?? session.latitude,
@@ -738,15 +775,12 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
738
775
  affiliateSlug: session.affiliateSlug,
739
776
  }, now, batchState);
740
777
  }
741
- async function consumeSiteRateLimit(ctx, siteId, events, now) {
778
+ async function consumeSiteRateLimit(ctx, siteId, events, eventByteCounts, now) {
742
779
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
743
780
  const increments = new Map();
744
- const eventByteCounts = [];
745
- const encoder = new TextEncoder();
746
781
  for (let index = 0; index < events.length; index += 1) {
747
782
  const event = events[index];
748
- const byteCount = encoder.encode(JSON.stringify(event)).byteLength;
749
- eventByteCounts.push(byteCount);
783
+ const byteCount = eventByteCounts[index];
750
784
  const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
751
785
  const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
752
786
  current.eventCount += 1;
@@ -803,7 +837,6 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
803
837
  });
804
838
  }
805
839
  }
806
- return eventByteCounts;
807
840
  }
808
841
  async function consumeRateLimit(ctx, siteId, sessionId, byteCount, now, states) {
809
842
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
@@ -957,9 +990,14 @@ async function ensureLiveSweep(ctx, siteId, now, batchState) {
957
990
  .query("liveSweepStates")
958
991
  .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
959
992
  .unique();
960
- if (!existing) {
993
+ if (!existing || existing.scheduledAt <= now) {
961
994
  const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
962
- await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
995
+ if (existing) {
996
+ await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
997
+ }
998
+ else {
999
+ await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1000
+ }
963
1001
  await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
964
1002
  siteId,
965
1003
  scheduledAt,
@@ -973,7 +1011,7 @@ function goalMatches(goal, event) {
973
1011
  }
974
1012
  return event.type === "custom" && event.name === goal.matchValue;
975
1013
  }
976
- async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
1014
+ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, batchState) {
977
1015
  const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
978
1016
  let progressState = batchState?.funnelProgress.get(cacheKey);
979
1017
  if (batchState && !progressState) {
@@ -1017,6 +1055,7 @@ async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
1017
1055
  };
1018
1056
  }
1019
1057
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1058
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1020
1059
  return;
1021
1060
  }
1022
1061
  if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt)
@@ -1046,6 +1085,7 @@ async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
1046
1085
  });
1047
1086
  }
1048
1087
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1088
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1049
1089
  return;
1050
1090
  }
1051
1091
  const nextStep = funnel.steps[progress.currentStep];
@@ -1067,6 +1107,7 @@ async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
1067
1107
  await ctx.db.patch("funnelProgress", progress._id, updates);
1068
1108
  }
1069
1109
  await incrementFunnelStep(ctx, siteId, funnel._id, event, progress.currentStep, completed);
1110
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, progress.currentStep, completed);
1070
1111
  }
1071
1112
  function stepMatches(step, event) {
1072
1113
  return step.type === "pageview"
@@ -1103,7 +1144,7 @@ async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed
1103
1144
  });
1104
1145
  }
1105
1146
  }
1106
- async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
1147
+ async function accountConversion(ctx, siteId, session, event, trustedGoalKey, reportRollupDeltas) {
1107
1148
  const revenueCents = event.revenueCents ?? 0;
1108
1149
  const currency = event.currency ?? "USD";
1109
1150
  const candidate = attributionSnapshot(session);
@@ -1132,6 +1173,7 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
1132
1173
  commissionCents,
1133
1174
  });
1134
1175
  if (commissionAffiliate && attribution) {
1176
+ foldAffiliateConversionRollup(reportRollupDeltas, siteId, event, commissionAffiliate, attribution.affiliateSlug, revenueCents, commissionCents);
1135
1177
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1136
1178
  const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
1137
1179
  const stats = await ctx.db
@@ -1163,6 +1205,188 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
1163
1205
  }
1164
1206
  }
1165
1207
  }
1208
+ function createReportRollupDeltas() {
1209
+ return {
1210
+ goals: new Map(),
1211
+ funnels: new Map(),
1212
+ affiliates: new Map(),
1213
+ };
1214
+ }
1215
+ function reportRollupCoordinates(siteId, definitionId, timestamp, visitorId) {
1216
+ const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
1217
+ const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
1218
+ return {
1219
+ bucketStart,
1220
+ shard,
1221
+ key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
1222
+ };
1223
+ }
1224
+ function foldGoalRollup(deltas, siteId, event, goal) {
1225
+ const coordinates = reportRollupCoordinates(siteId, String(goal._id), event.timestamp, event.visitorId);
1226
+ const current = deltas.goals.get(coordinates.key) ?? {
1227
+ siteId,
1228
+ goalId: goal._id,
1229
+ bucketStart: coordinates.bucketStart,
1230
+ shard: coordinates.shard,
1231
+ goalKey: goal.key,
1232
+ completions: 0,
1233
+ valueCents: 0,
1234
+ };
1235
+ current.completions += 1;
1236
+ current.valueCents += goal.valueCents ?? 0;
1237
+ deltas.goals.set(coordinates.key, current);
1238
+ }
1239
+ function foldFunnelRollup(deltas, siteId, funnelId, event, step, completed) {
1240
+ const coordinates = reportRollupCoordinates(siteId, String(funnelId), event.timestamp, event.visitorId);
1241
+ const rollup = deltas.funnels.get(coordinates.key) ?? {
1242
+ siteId,
1243
+ funnelId,
1244
+ bucketStart: coordinates.bucketStart,
1245
+ shard: coordinates.shard,
1246
+ steps: new Map(),
1247
+ };
1248
+ const current = rollup.steps.get(step) ?? {
1249
+ step,
1250
+ entrants: 0,
1251
+ completions: 0,
1252
+ };
1253
+ current.entrants += 1;
1254
+ current.completions += Number(completed);
1255
+ rollup.steps.set(step, current);
1256
+ deltas.funnels.set(coordinates.key, rollup);
1257
+ }
1258
+ function foldAffiliateVisitRollup(deltas, siteId, event, attribution, visitorKey) {
1259
+ const coordinates = reportRollupCoordinates(siteId, String(attribution.affiliateId), event.timestamp, event.visitorId);
1260
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1261
+ siteId,
1262
+ affiliateId: attribution.affiliateId,
1263
+ bucketStart: coordinates.bucketStart,
1264
+ shard: coordinates.shard,
1265
+ affiliateSlug: attribution.affiliateSlug,
1266
+ visits: 0,
1267
+ visitorSketch: createVisitorSketch(),
1268
+ conversions: 0,
1269
+ revenueCents: 0,
1270
+ commissionCents: 0,
1271
+ };
1272
+ current.visits += 1;
1273
+ current.visitorSketch = addVisitor(current.visitorSketch, visitorKey);
1274
+ deltas.affiliates.set(coordinates.key, current);
1275
+ }
1276
+ function foldAffiliateConversionRollup(deltas, siteId, event, affiliate, affiliateSlug, revenueCents, commissionCents) {
1277
+ const coordinates = reportRollupCoordinates(siteId, String(affiliate._id), event.timestamp, event.visitorId);
1278
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1279
+ siteId,
1280
+ affiliateId: affiliate._id,
1281
+ bucketStart: coordinates.bucketStart,
1282
+ shard: coordinates.shard,
1283
+ affiliateSlug,
1284
+ visits: 0,
1285
+ visitorSketch: createVisitorSketch(),
1286
+ conversions: 0,
1287
+ revenueCents: 0,
1288
+ commissionCents: 0,
1289
+ };
1290
+ current.conversions += 1;
1291
+ current.revenueCents += revenueCents;
1292
+ current.commissionCents += commissionCents;
1293
+ deltas.affiliates.set(coordinates.key, current);
1294
+ }
1295
+ async function flushReportRollups(ctx, deltas) {
1296
+ for (const delta of deltas.goals.values()) {
1297
+ const existing = await ctx.db
1298
+ .query("goalDailyRollups")
1299
+ .withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) => range
1300
+ .eq("siteId", delta.siteId)
1301
+ .eq("goalId", delta.goalId)
1302
+ .eq("bucketStart", delta.bucketStart)
1303
+ .eq("shard", delta.shard))
1304
+ .unique();
1305
+ const fields = {
1306
+ siteId: delta.siteId,
1307
+ goalId: delta.goalId,
1308
+ bucketStart: delta.bucketStart,
1309
+ shard: delta.shard,
1310
+ generation: 1,
1311
+ goalKey: delta.goalKey,
1312
+ completions: (existing?.completions ?? 0) + delta.completions,
1313
+ valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
1314
+ };
1315
+ if (existing) {
1316
+ await ctx.db.replace("goalDailyRollups", existing._id, fields);
1317
+ }
1318
+ else {
1319
+ await ctx.db.insert("goalDailyRollups", fields);
1320
+ }
1321
+ }
1322
+ for (const delta of deltas.funnels.values()) {
1323
+ const existing = await ctx.db
1324
+ .query("funnelDailyRollups")
1325
+ .withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) => range
1326
+ .eq("siteId", delta.siteId)
1327
+ .eq("funnelId", delta.funnelId)
1328
+ .eq("bucketStart", delta.bucketStart)
1329
+ .eq("shard", delta.shard))
1330
+ .unique();
1331
+ const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
1332
+ for (const addition of delta.steps.values()) {
1333
+ const current = steps.find((step) => step.step === addition.step);
1334
+ if (current) {
1335
+ current.entrants += addition.entrants;
1336
+ current.completions += addition.completions;
1337
+ }
1338
+ else {
1339
+ if (steps.length >= MAX_FUNNEL_STEPS) {
1340
+ fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
1341
+ }
1342
+ steps.push(addition);
1343
+ }
1344
+ }
1345
+ const fields = {
1346
+ siteId: delta.siteId,
1347
+ funnelId: delta.funnelId,
1348
+ bucketStart: delta.bucketStart,
1349
+ shard: delta.shard,
1350
+ generation: 1,
1351
+ steps,
1352
+ };
1353
+ if (existing) {
1354
+ await ctx.db.replace("funnelDailyRollups", existing._id, fields);
1355
+ }
1356
+ else {
1357
+ await ctx.db.insert("funnelDailyRollups", fields);
1358
+ }
1359
+ }
1360
+ for (const delta of deltas.affiliates.values()) {
1361
+ const existing = await ctx.db
1362
+ .query("affiliateDailyRollups")
1363
+ .withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) => range
1364
+ .eq("siteId", delta.siteId)
1365
+ .eq("affiliateId", delta.affiliateId)
1366
+ .eq("bucketStart", delta.bucketStart)
1367
+ .eq("shard", delta.shard))
1368
+ .unique();
1369
+ const fields = {
1370
+ siteId: delta.siteId,
1371
+ affiliateId: delta.affiliateId,
1372
+ bucketStart: delta.bucketStart,
1373
+ shard: delta.shard,
1374
+ generation: 1,
1375
+ affiliateSlug: delta.affiliateSlug,
1376
+ visits: (existing?.visits ?? 0) + delta.visits,
1377
+ visitorSketch: unionVisitorSketches(existing?.visitorSketch ?? createVisitorSketch(), delta.visitorSketch),
1378
+ conversions: (existing?.conversions ?? 0) + delta.conversions,
1379
+ revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
1380
+ commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
1381
+ };
1382
+ if (existing) {
1383
+ await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
1384
+ }
1385
+ else {
1386
+ await ctx.db.insert("affiliateDailyRollups", fields);
1387
+ }
1388
+ }
1389
+ }
1166
1390
  function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion) {
1167
1391
  const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
1168
1392
  const dimensionDeltas = dimensions.map((dimension) => ({
@@ -1190,6 +1414,7 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
1190
1414
  outboundClicks: 0,
1191
1415
  sessions: 0,
1192
1416
  visitors: 0,
1417
+ visitorSketch: createVisitorSketch(),
1193
1418
  conversions: 0,
1194
1419
  revenueCents: 0,
1195
1420
  dimensions: [],
@@ -1208,6 +1433,9 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
1208
1433
  ? Number(session.isNewVisitorHour)
1209
1434
  : Number(session.isNewVisitorDay)
1210
1435
  : 0;
1436
+ if (recordTelemetry) {
1437
+ delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
1438
+ }
1211
1439
  delta.conversions += Number(financialConversion);
1212
1440
  delta.revenueCents += revenueCents;
1213
1441
  delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
@@ -1224,6 +1452,13 @@ async function flushAggregates(ctx, aggregateDeltas) {
1224
1452
  .eq("shard", delta.shard))
1225
1453
  .unique();
1226
1454
  const dimensions = mergeDimensions(bucket?.dimensions ?? [], delta.dimensions);
1455
+ const priorSketch = bucket?.visitorSketch;
1456
+ const visitorSketch = priorSketch
1457
+ ? unionVisitorSketches(priorSketch, delta.visitorSketch)
1458
+ : delta.visitorSketch;
1459
+ const visitorSketchComplete = bucket
1460
+ ? bucket.visitorSketchComplete === true && priorSketch !== undefined
1461
+ : true;
1227
1462
  if (bucket) {
1228
1463
  await ctx.db.patch("aggregateBuckets", bucket._id, {
1229
1464
  events: bucket.events + delta.events,
@@ -1233,6 +1468,8 @@ async function flushAggregates(ctx, aggregateDeltas) {
1233
1468
  outboundClicks: bucket.outboundClicks + delta.outboundClicks,
1234
1469
  sessions: bucket.sessions + delta.sessions,
1235
1470
  visitors: bucket.visitors + delta.visitors,
1471
+ visitorSketch,
1472
+ visitorSketchComplete,
1236
1473
  conversions: bucket.conversions + delta.conversions,
1237
1474
  revenueCents: bucket.revenueCents + delta.revenueCents,
1238
1475
  dimensions,
@@ -1251,6 +1488,8 @@ async function flushAggregates(ctx, aggregateDeltas) {
1251
1488
  outboundClicks: delta.outboundClicks,
1252
1489
  sessions: delta.sessions,
1253
1490
  visitors: delta.visitors,
1491
+ visitorSketch,
1492
+ visitorSketchComplete,
1254
1493
  conversions: delta.conversions,
1255
1494
  revenueCents: delta.revenueCents,
1256
1495
  dimensions,