@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
@@ -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(),
@@ -97,7 +102,6 @@ export const ingestBatch = mutation({
97
102
  siteId: args.siteId,
98
103
  sessionId: group.sessionId,
99
104
  bucketStart: group.bucketStart,
100
- candidateEventIds: group.eventIds,
101
105
  });
102
106
  const newCount = [...group.eventIds].filter((eventId) => !eventIds.has(eventId)).length;
103
107
  if (newCount > 0 &&
@@ -164,7 +168,7 @@ export const ingestBatch = mutation({
164
168
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
165
169
  .take(MAX_FUNNELS_PER_SITE);
166
170
  }
167
- await accountEvent(ctx, {
171
+ const session = await accountEvent(ctx, {
168
172
  siteId: args.siteId,
169
173
  event,
170
174
  context,
@@ -172,18 +176,20 @@ export const ingestBatch = mutation({
172
176
  goals: canMatchDefinition ? goals ?? [] : [],
173
177
  funnels: canMatchDefinition ? funnels ?? [] : [],
174
178
  aggregateDeltas,
179
+ reportRollupDeltas,
175
180
  recordTelemetry: true,
176
181
  financialConversion: false,
177
182
  trustedGoalKey: undefined,
178
183
  now,
179
184
  batchState,
180
185
  });
181
- bufferEventBatch(args.siteId, event, batchState.eventBatches);
186
+ bufferEventBatch(args.siteId, event, batchState.eventBatches, session.country ?? "unknown");
182
187
  accepted += 1;
183
188
  }
184
189
  await flushBatchState(ctx, batchState);
185
190
  await flushSessionRateLimits(ctx, sessionRateWindows, now);
186
191
  await flushAggregates(ctx, aggregateDeltas);
192
+ await flushReportRollups(ctx, reportRollupDeltas);
187
193
  return { accepted, duplicates, rejected };
188
194
  },
189
195
  });
@@ -261,7 +267,8 @@ export const trackConversion = mutation({
261
267
  device: existingTelemetry?.device ?? existingSession?.device,
262
268
  };
263
269
  const aggregateDeltas = new Map();
264
- await accountEvent(ctx, {
270
+ const reportRollupDeltas = createReportRollupDeltas();
271
+ const session = await accountEvent(ctx, {
265
272
  siteId: args.siteId,
266
273
  event,
267
274
  context: conversionContext,
@@ -269,15 +276,18 @@ export const trackConversion = mutation({
269
276
  goals: [],
270
277
  funnels: [],
271
278
  aggregateDeltas,
279
+ reportRollupDeltas,
272
280
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
273
281
  financialConversion: true,
274
282
  trustedGoalKey,
275
283
  now: Date.now(),
276
284
  });
277
285
  if (!existingTelemetry && !telemetry.saturated) {
278
- await insertEventBatch(ctx, args.siteId, event);
286
+ await insertEventBatch(ctx, args.siteId, event, session.country ?? "unknown");
279
287
  }
280
288
  await flushAggregates(ctx, aggregateDeltas);
289
+ await flushReportRollups(ctx, reportRollupDeltas);
290
+ await ensureAnalyticsControl(ctx, args.siteId, Date.now());
281
291
  return { accepted: 1, duplicates: 0, rejected: 0 };
282
292
  },
283
293
  });
@@ -347,7 +357,7 @@ function withoutSystemFields(document) {
347
357
  async function accountEvent(ctx, input) {
348
358
  const { event } = input;
349
359
  const session = input.recordTelemetry
350
- ? 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)
351
361
  : await accountTrustedSession(ctx, input.existingSession, event);
352
362
  const matchedGoalKeys = [];
353
363
  const matchingGoals = input.goals.filter((goal) => goal.active && goalMatches(goal, event));
@@ -394,20 +404,21 @@ async function accountEvent(ctx, input) {
394
404
  timestamp: event.timestamp,
395
405
  valueCents: goal.valueCents ?? 0,
396
406
  });
407
+ foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
397
408
  matchedGoalKeys.push(goal.key);
398
409
  }
399
410
  for (const funnel of input.funnels) {
400
411
  if (funnel.active) {
401
- await advanceFunnel(ctx, input.siteId, funnel, event, input.batchState);
412
+ await advanceFunnel(ctx, input.siteId, funnel, event, input.reportRollupDeltas, input.batchState);
402
413
  }
403
414
  }
404
415
  if (input.financialConversion) {
405
- await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey);
416
+ await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey, input.reportRollupDeltas);
406
417
  }
407
418
  const dimensions = [
408
419
  { type: "source", value: session.source },
409
420
  { type: "page", value: event.path },
410
- { type: "country", value: input.context.country ?? "unknown" },
421
+ { type: "country", value: session.country ?? "unknown" },
411
422
  { type: "device", value: input.context.device ?? "unknown" },
412
423
  { type: "browser", value: input.context.browser ?? "Other" },
413
424
  {
@@ -421,6 +432,7 @@ async function accountEvent(ctx, input) {
421
432
  event.timestamp >= input.now - LIVE_SESSION_TTL_MS) {
422
433
  await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now, input.batchState);
423
434
  }
435
+ return session;
424
436
  }
425
437
  async function accountTrustedSession(ctx, existing, event) {
426
438
  if (!existing) {
@@ -438,6 +450,10 @@ async function accountTrustedSession(ctx, existing, event) {
438
450
  isNewVisitorDay: false,
439
451
  startedAt: existing.startedAt,
440
452
  source: existing.source,
453
+ country: existing.country,
454
+ city: existing.city,
455
+ latitude: existing.latitude,
456
+ longitude: existing.longitude,
441
457
  pageviewCount: existing.pageviewCount,
442
458
  eventCount: existing.eventCount,
443
459
  affiliateId: existing.affiliateId,
@@ -446,13 +462,16 @@ async function accountTrustedSession(ctx, existing, event) {
446
462
  affiliateExpiresAt: existing.affiliateExpiresAt,
447
463
  };
448
464
  }
449
- async function upsertSession(ctx, siteId, event, context, existing, financialConversion, batchState) {
465
+ async function upsertSession(ctx, siteId, event, context, existing, financialConversion, reportRollupDeltas, batchState) {
450
466
  const source = sourceFromReferrer(event.referrer);
451
467
  const utmSource = stringProperty(event.properties, "utm_source");
452
468
  const utmMedium = stringProperty(event.properties, "utm_medium");
453
469
  const utmCampaign = stringProperty(event.properties, "utm_campaign");
454
470
  const attribution = await attributionForEvent(ctx, siteId, event, existing, batchState);
455
471
  if (existing) {
472
+ if (attribution && existing.affiliateId !== attribution.affiliateId) {
473
+ foldAffiliateVisitRollup(reportRollupDeltas, siteId, event, attribution);
474
+ }
456
475
  const startedAt = Math.min(existing.startedAt, event.timestamp);
457
476
  const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
458
477
  const updates = {
@@ -499,6 +518,10 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
499
518
  Math.floor(event.timestamp / DAY_MS),
500
519
  startedAt,
501
520
  source: existing.source,
521
+ country: context.country ?? existing.country,
522
+ city: context.city ?? existing.city,
523
+ latitude: context.latitude ?? existing.latitude,
524
+ longitude: context.longitude ?? existing.longitude,
502
525
  pageviewCount: existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
503
526
  eventCount: existing.eventCount + 1,
504
527
  ...(attribution ?? {}),
@@ -551,6 +574,9 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
551
574
  ...(attribution ?? {}),
552
575
  };
553
576
  const sessionId = await ctx.db.insert("sessions", fields);
577
+ if (attribution) {
578
+ foldAffiliateVisitRollup(reportRollupDeltas, siteId, event, attribution);
579
+ }
554
580
  if (batchState) {
555
581
  const state = batchState.sessions.get(event.sessionId);
556
582
  if (!state)
@@ -570,6 +596,10 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
570
596
  Math.floor(event.timestamp / DAY_MS),
571
597
  startedAt: event.timestamp,
572
598
  source,
599
+ country: context.country,
600
+ city: context.city,
601
+ latitude: context.latitude,
602
+ longitude: context.longitude,
573
603
  pageviewCount: event.type === "pageview" ? 1 : 0,
574
604
  eventCount: 1,
575
605
  ...(attribution ?? {}),
@@ -717,20 +747,21 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
717
747
  isNewVisitorDay: false,
718
748
  startedAt,
719
749
  source: session.source,
750
+ country: context.country ?? session.country,
751
+ city: context.city ?? session.city,
752
+ latitude: context.latitude ?? session.latitude,
753
+ longitude: context.longitude ?? session.longitude,
720
754
  pageviewCount: session.pageviewCount,
721
755
  eventCount: session.eventCount,
722
756
  affiliateSlug: session.affiliateSlug,
723
757
  }, now, batchState);
724
758
  }
725
- async function consumeSiteRateLimit(ctx, siteId, events, now) {
759
+ async function consumeSiteRateLimit(ctx, siteId, events, eventByteCounts, now) {
726
760
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
727
761
  const increments = new Map();
728
- const eventByteCounts = [];
729
- const encoder = new TextEncoder();
730
762
  for (let index = 0; index < events.length; index += 1) {
731
763
  const event = events[index];
732
- const byteCount = encoder.encode(JSON.stringify(event)).byteLength;
733
- eventByteCounts.push(byteCount);
764
+ const byteCount = eventByteCounts[index];
734
765
  const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
735
766
  const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
736
767
  current.eventCount += 1;
@@ -787,7 +818,6 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
787
818
  });
788
819
  }
789
820
  }
790
- return eventByteCounts;
791
821
  }
792
822
  async function consumeRateLimit(ctx, siteId, sessionId, byteCount, now, states) {
793
823
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
@@ -855,6 +885,10 @@ async function updateLiveSession(ctx, siteId, event, context, session, now, batc
855
885
  .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", event.sessionId))
856
886
  .unique();
857
887
  const deadline = now + LIVE_SESSION_TTL_MS;
888
+ const country = context.country ?? session.country;
889
+ const city = context.city ?? session.city;
890
+ const latitude = context.latitude ?? session.latitude;
891
+ const longitude = context.longitude ?? session.longitude;
858
892
  if (existing) {
859
893
  const recentHeartbeatEventIds = event.type === "heartbeat"
860
894
  ? [
@@ -870,10 +904,10 @@ async function updateLiveSession(ctx, siteId, event, context, session, now, batc
870
904
  path: event.path,
871
905
  lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
872
906
  expiresAt: deadline,
873
- ...(context.country ? { country: context.country } : {}),
874
- ...(context.city ? { city: context.city } : {}),
875
- ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
876
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
907
+ ...(country ? { country } : {}),
908
+ ...(city ? { city } : {}),
909
+ ...(latitude !== undefined ? { latitude } : {}),
910
+ ...(longitude !== undefined ? { longitude } : {}),
877
911
  browser: context.browser ?? existing.browser,
878
912
  os: context.os ?? existing.os,
879
913
  source: session.source,
@@ -904,10 +938,10 @@ async function updateLiveSession(ctx, siteId, event, context, session, now, batc
904
938
  startedAt: event.timestamp,
905
939
  lastSeenAt: event.timestamp,
906
940
  expiresAt: deadline,
907
- ...(context.country ? { country: context.country } : {}),
908
- ...(context.city ? { city: context.city } : {}),
909
- ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
910
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
941
+ ...(country ? { country } : {}),
942
+ ...(city ? { city } : {}),
943
+ ...(latitude !== undefined ? { latitude } : {}),
944
+ ...(longitude !== undefined ? { longitude } : {}),
911
945
  browser: context.browser ?? "Other",
912
946
  os: context.os ?? "Other",
913
947
  source: session.source,
@@ -937,9 +971,14 @@ async function ensureLiveSweep(ctx, siteId, now, batchState) {
937
971
  .query("liveSweepStates")
938
972
  .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
939
973
  .unique();
940
- if (!existing) {
974
+ if (!existing || existing.scheduledAt <= now) {
941
975
  const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
942
- await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
976
+ if (existing) {
977
+ await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
978
+ }
979
+ else {
980
+ await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
981
+ }
943
982
  await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
944
983
  siteId,
945
984
  scheduledAt,
@@ -953,7 +992,7 @@ function goalMatches(goal, event) {
953
992
  }
954
993
  return event.type === "custom" && event.name === goal.matchValue;
955
994
  }
956
- async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
995
+ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, batchState) {
957
996
  const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
958
997
  let progressState = batchState?.funnelProgress.get(cacheKey);
959
998
  if (batchState && !progressState) {
@@ -997,6 +1036,7 @@ async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
997
1036
  };
998
1037
  }
999
1038
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1039
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1000
1040
  return;
1001
1041
  }
1002
1042
  if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt)
@@ -1026,6 +1066,7 @@ async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
1026
1066
  });
1027
1067
  }
1028
1068
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1069
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1029
1070
  return;
1030
1071
  }
1031
1072
  const nextStep = funnel.steps[progress.currentStep];
@@ -1047,6 +1088,7 @@ async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
1047
1088
  await ctx.db.patch("funnelProgress", progress._id, updates);
1048
1089
  }
1049
1090
  await incrementFunnelStep(ctx, siteId, funnel._id, event, progress.currentStep, completed);
1091
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, progress.currentStep, completed);
1050
1092
  }
1051
1093
  function stepMatches(step, event) {
1052
1094
  return step.type === "pageview"
@@ -1083,7 +1125,7 @@ async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed
1083
1125
  });
1084
1126
  }
1085
1127
  }
1086
- async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
1128
+ async function accountConversion(ctx, siteId, session, event, trustedGoalKey, reportRollupDeltas) {
1087
1129
  const revenueCents = event.revenueCents ?? 0;
1088
1130
  const currency = event.currency ?? "USD";
1089
1131
  const candidate = attributionSnapshot(session);
@@ -1112,6 +1154,7 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
1112
1154
  commissionCents,
1113
1155
  });
1114
1156
  if (commissionAffiliate && attribution) {
1157
+ foldAffiliateConversionRollup(reportRollupDeltas, siteId, event, commissionAffiliate, attribution.affiliateSlug, revenueCents, commissionCents);
1115
1158
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1116
1159
  const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
1117
1160
  const stats = await ctx.db
@@ -1143,6 +1186,188 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
1143
1186
  }
1144
1187
  }
1145
1188
  }
1189
+ function createReportRollupDeltas() {
1190
+ return {
1191
+ goals: new Map(),
1192
+ funnels: new Map(),
1193
+ affiliates: new Map(),
1194
+ };
1195
+ }
1196
+ function reportRollupCoordinates(siteId, definitionId, timestamp, visitorId) {
1197
+ const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
1198
+ const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
1199
+ return {
1200
+ bucketStart,
1201
+ shard,
1202
+ key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
1203
+ };
1204
+ }
1205
+ function foldGoalRollup(deltas, siteId, event, goal) {
1206
+ const coordinates = reportRollupCoordinates(siteId, String(goal._id), event.timestamp, event.visitorId);
1207
+ const current = deltas.goals.get(coordinates.key) ?? {
1208
+ siteId,
1209
+ goalId: goal._id,
1210
+ bucketStart: coordinates.bucketStart,
1211
+ shard: coordinates.shard,
1212
+ goalKey: goal.key,
1213
+ completions: 0,
1214
+ valueCents: 0,
1215
+ };
1216
+ current.completions += 1;
1217
+ current.valueCents += goal.valueCents ?? 0;
1218
+ deltas.goals.set(coordinates.key, current);
1219
+ }
1220
+ function foldFunnelRollup(deltas, siteId, funnelId, event, step, completed) {
1221
+ const coordinates = reportRollupCoordinates(siteId, String(funnelId), event.timestamp, event.visitorId);
1222
+ const rollup = deltas.funnels.get(coordinates.key) ?? {
1223
+ siteId,
1224
+ funnelId,
1225
+ bucketStart: coordinates.bucketStart,
1226
+ shard: coordinates.shard,
1227
+ steps: new Map(),
1228
+ };
1229
+ const current = rollup.steps.get(step) ?? {
1230
+ step,
1231
+ entrants: 0,
1232
+ completions: 0,
1233
+ };
1234
+ current.entrants += 1;
1235
+ current.completions += Number(completed);
1236
+ rollup.steps.set(step, current);
1237
+ deltas.funnels.set(coordinates.key, rollup);
1238
+ }
1239
+ function foldAffiliateVisitRollup(deltas, siteId, event, attribution) {
1240
+ const coordinates = reportRollupCoordinates(siteId, String(attribution.affiliateId), event.timestamp, event.visitorId);
1241
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1242
+ siteId,
1243
+ affiliateId: attribution.affiliateId,
1244
+ bucketStart: coordinates.bucketStart,
1245
+ shard: coordinates.shard,
1246
+ affiliateSlug: attribution.affiliateSlug,
1247
+ visits: 0,
1248
+ visitorSketch: createVisitorSketch(),
1249
+ conversions: 0,
1250
+ revenueCents: 0,
1251
+ commissionCents: 0,
1252
+ };
1253
+ current.visits += 1;
1254
+ current.visitorSketch = addVisitor(current.visitorSketch, event.visitorId);
1255
+ deltas.affiliates.set(coordinates.key, current);
1256
+ }
1257
+ function foldAffiliateConversionRollup(deltas, siteId, event, affiliate, affiliateSlug, revenueCents, commissionCents) {
1258
+ const coordinates = reportRollupCoordinates(siteId, String(affiliate._id), event.timestamp, event.visitorId);
1259
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1260
+ siteId,
1261
+ affiliateId: affiliate._id,
1262
+ bucketStart: coordinates.bucketStart,
1263
+ shard: coordinates.shard,
1264
+ affiliateSlug,
1265
+ visits: 0,
1266
+ visitorSketch: createVisitorSketch(),
1267
+ conversions: 0,
1268
+ revenueCents: 0,
1269
+ commissionCents: 0,
1270
+ };
1271
+ current.conversions += 1;
1272
+ current.revenueCents += revenueCents;
1273
+ current.commissionCents += commissionCents;
1274
+ deltas.affiliates.set(coordinates.key, current);
1275
+ }
1276
+ async function flushReportRollups(ctx, deltas) {
1277
+ for (const delta of deltas.goals.values()) {
1278
+ const existing = await ctx.db
1279
+ .query("goalDailyRollups")
1280
+ .withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) => range
1281
+ .eq("siteId", delta.siteId)
1282
+ .eq("goalId", delta.goalId)
1283
+ .eq("bucketStart", delta.bucketStart)
1284
+ .eq("shard", delta.shard))
1285
+ .unique();
1286
+ const fields = {
1287
+ siteId: delta.siteId,
1288
+ goalId: delta.goalId,
1289
+ bucketStart: delta.bucketStart,
1290
+ shard: delta.shard,
1291
+ generation: 1,
1292
+ goalKey: delta.goalKey,
1293
+ completions: (existing?.completions ?? 0) + delta.completions,
1294
+ valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
1295
+ };
1296
+ if (existing) {
1297
+ await ctx.db.replace("goalDailyRollups", existing._id, fields);
1298
+ }
1299
+ else {
1300
+ await ctx.db.insert("goalDailyRollups", fields);
1301
+ }
1302
+ }
1303
+ for (const delta of deltas.funnels.values()) {
1304
+ const existing = await ctx.db
1305
+ .query("funnelDailyRollups")
1306
+ .withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) => range
1307
+ .eq("siteId", delta.siteId)
1308
+ .eq("funnelId", delta.funnelId)
1309
+ .eq("bucketStart", delta.bucketStart)
1310
+ .eq("shard", delta.shard))
1311
+ .unique();
1312
+ const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
1313
+ for (const addition of delta.steps.values()) {
1314
+ const current = steps.find((step) => step.step === addition.step);
1315
+ if (current) {
1316
+ current.entrants += addition.entrants;
1317
+ current.completions += addition.completions;
1318
+ }
1319
+ else {
1320
+ if (steps.length >= MAX_FUNNEL_STEPS) {
1321
+ fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
1322
+ }
1323
+ steps.push(addition);
1324
+ }
1325
+ }
1326
+ const fields = {
1327
+ siteId: delta.siteId,
1328
+ funnelId: delta.funnelId,
1329
+ bucketStart: delta.bucketStart,
1330
+ shard: delta.shard,
1331
+ generation: 1,
1332
+ steps,
1333
+ };
1334
+ if (existing) {
1335
+ await ctx.db.replace("funnelDailyRollups", existing._id, fields);
1336
+ }
1337
+ else {
1338
+ await ctx.db.insert("funnelDailyRollups", fields);
1339
+ }
1340
+ }
1341
+ for (const delta of deltas.affiliates.values()) {
1342
+ const existing = await ctx.db
1343
+ .query("affiliateDailyRollups")
1344
+ .withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) => range
1345
+ .eq("siteId", delta.siteId)
1346
+ .eq("affiliateId", delta.affiliateId)
1347
+ .eq("bucketStart", delta.bucketStart)
1348
+ .eq("shard", delta.shard))
1349
+ .unique();
1350
+ const fields = {
1351
+ siteId: delta.siteId,
1352
+ affiliateId: delta.affiliateId,
1353
+ bucketStart: delta.bucketStart,
1354
+ shard: delta.shard,
1355
+ generation: 1,
1356
+ affiliateSlug: delta.affiliateSlug,
1357
+ visits: (existing?.visits ?? 0) + delta.visits,
1358
+ visitorSketch: unionVisitorSketches(existing?.visitorSketch ?? createVisitorSketch(), delta.visitorSketch),
1359
+ conversions: (existing?.conversions ?? 0) + delta.conversions,
1360
+ revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
1361
+ commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
1362
+ };
1363
+ if (existing) {
1364
+ await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
1365
+ }
1366
+ else {
1367
+ await ctx.db.insert("affiliateDailyRollups", fields);
1368
+ }
1369
+ }
1370
+ }
1146
1371
  function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion) {
1147
1372
  const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
1148
1373
  const dimensionDeltas = dimensions.map((dimension) => ({
@@ -1170,6 +1395,7 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
1170
1395
  outboundClicks: 0,
1171
1396
  sessions: 0,
1172
1397
  visitors: 0,
1398
+ visitorSketch: createVisitorSketch(),
1173
1399
  conversions: 0,
1174
1400
  revenueCents: 0,
1175
1401
  dimensions: [],
@@ -1188,6 +1414,9 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
1188
1414
  ? Number(session.isNewVisitorHour)
1189
1415
  : Number(session.isNewVisitorDay)
1190
1416
  : 0;
1417
+ if (recordTelemetry) {
1418
+ delta.visitorSketch = addVisitor(delta.visitorSketch, event.visitorId);
1419
+ }
1191
1420
  delta.conversions += Number(financialConversion);
1192
1421
  delta.revenueCents += revenueCents;
1193
1422
  delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
@@ -1204,6 +1433,13 @@ async function flushAggregates(ctx, aggregateDeltas) {
1204
1433
  .eq("shard", delta.shard))
1205
1434
  .unique();
1206
1435
  const dimensions = mergeDimensions(bucket?.dimensions ?? [], delta.dimensions);
1436
+ const priorSketch = bucket?.visitorSketch;
1437
+ const visitorSketch = priorSketch
1438
+ ? unionVisitorSketches(priorSketch, delta.visitorSketch)
1439
+ : delta.visitorSketch;
1440
+ const visitorSketchComplete = bucket
1441
+ ? bucket.visitorSketchComplete === true && priorSketch !== undefined
1442
+ : true;
1207
1443
  if (bucket) {
1208
1444
  await ctx.db.patch("aggregateBuckets", bucket._id, {
1209
1445
  events: bucket.events + delta.events,
@@ -1213,6 +1449,8 @@ async function flushAggregates(ctx, aggregateDeltas) {
1213
1449
  outboundClicks: bucket.outboundClicks + delta.outboundClicks,
1214
1450
  sessions: bucket.sessions + delta.sessions,
1215
1451
  visitors: bucket.visitors + delta.visitors,
1452
+ visitorSketch,
1453
+ visitorSketchComplete,
1216
1454
  conversions: bucket.conversions + delta.conversions,
1217
1455
  revenueCents: bucket.revenueCents + delta.revenueCents,
1218
1456
  dimensions,
@@ -1231,6 +1469,8 @@ async function flushAggregates(ctx, aggregateDeltas) {
1231
1469
  outboundClicks: delta.outboundClicks,
1232
1470
  sessions: delta.sessions,
1233
1471
  visitors: delta.visitors,
1472
+ visitorSketch,
1473
+ visitorSketchComplete,
1234
1474
  conversions: delta.conversions,
1235
1475
  revenueCents: delta.revenueCents,
1236
1476
  dimensions,