@iann29/rastro 0.1.0-alpha.8 → 0.1.0

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 (105) hide show
  1. package/README.md +274 -75
  2. package/agent/integration.md +223 -65
  3. package/agent/manifest.json +34 -13
  4. package/agent/manifest.schema.json +53 -11
  5. package/dist/client/federation.d.ts +196 -11
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +160 -6
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/heat.d.ts +37 -0
  10. package/dist/client/heat.d.ts.map +1 -0
  11. package/dist/client/heat.js +43 -0
  12. package/dist/client/heat.js.map +1 -0
  13. package/dist/client/index.d.ts +448 -105
  14. package/dist/client/index.d.ts.map +1 -1
  15. package/dist/client/index.js +207 -17
  16. package/dist/client/index.js.map +1 -1
  17. package/dist/component/_generated/api.d.ts +6 -0
  18. package/dist/component/_generated/api.d.ts.map +1 -1
  19. package/dist/component/_generated/api.js.map +1 -1
  20. package/dist/component/_generated/component.d.ts +53 -16
  21. package/dist/component/_generated/component.d.ts.map +1 -1
  22. package/dist/component/constants.d.ts +5 -1
  23. package/dist/component/constants.d.ts.map +1 -1
  24. package/dist/component/constants.js +8 -1
  25. package/dist/component/constants.js.map +1 -1
  26. package/dist/component/coverage.d.ts +9 -0
  27. package/dist/component/coverage.d.ts.map +1 -1
  28. package/dist/component/coverage.js +36 -6
  29. package/dist/component/coverage.js.map +1 -1
  30. package/dist/component/eventStore.d.ts +1 -1
  31. package/dist/component/geo.d.ts +2 -0
  32. package/dist/component/geo.d.ts.map +1 -1
  33. package/dist/component/geo.js +56 -19
  34. package/dist/component/geo.js.map +1 -1
  35. package/dist/component/http.d.ts.map +1 -1
  36. package/dist/component/http.js +49 -27
  37. package/dist/component/http.js.map +1 -1
  38. package/dist/component/ingest.d.ts +1 -1
  39. package/dist/component/ingest.d.ts.map +1 -1
  40. package/dist/component/ingest.js +252 -305
  41. package/dist/component/ingest.js.map +1 -1
  42. package/dist/component/localTime.d.ts +25 -0
  43. package/dist/component/localTime.d.ts.map +1 -0
  44. package/dist/component/localTime.js +126 -0
  45. package/dist/component/localTime.js.map +1 -0
  46. package/dist/component/reports.d.ts +46 -15
  47. package/dist/component/reports.d.ts.map +1 -1
  48. package/dist/component/reports.js +193 -38
  49. package/dist/component/reports.js.map +1 -1
  50. package/dist/component/retention.js +26 -8
  51. package/dist/component/retention.js.map +1 -1
  52. package/dist/component/rollupStore.d.ts +320 -0
  53. package/dist/component/rollupStore.d.ts.map +1 -0
  54. package/dist/component/rollupStore.js +596 -0
  55. package/dist/component/rollupStore.js.map +1 -0
  56. package/dist/component/rollups.d.ts +20 -0
  57. package/dist/component/rollups.d.ts.map +1 -0
  58. package/dist/component/rollups.js +73 -0
  59. package/dist/component/rollups.js.map +1 -0
  60. package/dist/component/sanitize.d.ts +5 -0
  61. package/dist/component/sanitize.d.ts.map +1 -1
  62. package/dist/component/sanitize.js +15 -0
  63. package/dist/component/sanitize.js.map +1 -1
  64. package/dist/component/schema.d.ts +349 -15
  65. package/dist/component/schema.js +25 -0
  66. package/dist/component/schema.js.map +1 -1
  67. package/dist/component/sites.d.ts +12 -0
  68. package/dist/component/sites.d.ts.map +1 -1
  69. package/dist/component/sites.js +30 -0
  70. package/dist/component/sites.js.map +1 -1
  71. package/dist/component/validators.d.ts +41 -20
  72. package/dist/component/validators.d.ts.map +1 -1
  73. package/dist/component/validators.js +24 -3
  74. package/dist/component/validators.js.map +1 -1
  75. package/dist/tracker/generated.d.ts +7 -4
  76. package/dist/tracker/generated.d.ts.map +1 -1
  77. package/dist/tracker/generated.js +7 -4
  78. package/dist/tracker/generated.js.map +1 -1
  79. package/dist/tracker/tracker.d.ts +1 -1
  80. package/dist/tracker/tracker.d.ts.map +1 -1
  81. package/dist/tracker/tracker.js +24 -2
  82. package/dist/tracker/tracker.js.map +1 -1
  83. package/dist/tracker.min.js +1 -1
  84. package/docs/federation-setup.md +104 -27
  85. package/docs/federation.md +168 -31
  86. package/docs/upgrading.md +191 -16
  87. package/llms.txt +9 -7
  88. package/package.json +2 -1
  89. package/src/component/_generated/api.ts +6 -0
  90. package/src/component/_generated/component.ts +50 -12
  91. package/src/component/constants.ts +8 -1
  92. package/src/component/coverage.ts +46 -6
  93. package/src/component/geo.ts +82 -28
  94. package/src/component/http.ts +62 -26
  95. package/src/component/ingest.ts +378 -440
  96. package/src/component/localTime.ts +167 -0
  97. package/src/component/reports.ts +261 -39
  98. package/src/component/retention.ts +25 -7
  99. package/src/component/rollupStore.ts +799 -0
  100. package/src/component/rollups.ts +82 -0
  101. package/src/component/sanitize.ts +14 -0
  102. package/src/component/schema.ts +35 -0
  103. package/src/component/sites.ts +32 -0
  104. package/src/component/validators.ts +28 -1
  105. package/src/tracker/generated.ts +12 -0
@@ -1,14 +1,25 @@
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_FUNNEL_STEPS, MAX_GOALS_PER_SITE, MAX_SITE_MAP_ROUTES_PER_DAY, RATE_LIMIT_WINDOW_MS, REPORT_ROLLUP_SHARDS, ROUTE_TRANSITION_SLOTS, SECONDARY_AGGREGATE_SHARDS, SITE_INGEST_SHARDS, SITE_MAP_OTHER_ROUTE, VITAL_PAGE_SLOTS, } from "./constants.js";
4
+ import { AGGREGATE_SHARDS, MAX_BATCH_EVENT_GROUPS, DAY_MS, HOURLY_AGGREGATE_SHARDS, DIMENSION_SLOTS, HOUR_MS, LIVE_LEAVE_GRACE_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, REPORT_ROLLUP_SHARDS, SECONDARY_AGGREGATE_SHARDS, SITE_INGEST_SHARDS, } from "./constants.js";
5
5
  import { addVisitor, createVisitorSketch, unionVisitorSketches, } from "./cardinality.js";
6
- import { addToVitalHistogram, emptyVitalHistogram, mergeVitalHistograms, vitalDevice, VITAL_ALL, VITAL_OTHER_PAGES, } from "./vitals.js";
6
+ import { addToVitalHistogram, emptyVitalHistogram, vitalDevice, VITAL_ALL, } from "./vitals.js";
7
7
  import { fail } from "./errors.js";
8
8
  import { ensureAnalyticsControl } from "./coverage.js";
9
9
  import { bufferEventBatch, eventBatchKey, eventBucketStart, findTelemetry, flushEventBatches, insertEventBatch, inspectTelemetryMinute, } from "./eventStore.js";
10
- import { cleanString, isValidMoney, measureBatchBytes, normalizeRoute, originAllowed, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
10
+ import { cleanString, isValidMoney, measureBatchBytes, normalizeRoute, originAllowed, publicUrlHost, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
11
11
  import { ingestContextValidator, ingestResultValidator, trackerEventValidator, trustedConversionValidator, } from "./validators.js";
12
+ import { createReportRollupDeltas, enqueueReportRollups, reportRollupCoordinates, } from "./rollupStore.js";
13
+ import { localDayStart, localDayTimezone } from "./localTime.js";
14
+ function furtherReach(current, next) {
15
+ if (!next)
16
+ return current;
17
+ if (!current)
18
+ return next;
19
+ return next.step / next.steps >= current.step / current.steps
20
+ ? next
21
+ : current;
22
+ }
12
23
  export const ingestBatch = mutation({
13
24
  args: {
14
25
  siteId: v.id("sites"),
@@ -42,6 +53,7 @@ export const ingestBatch = mutation({
42
53
  await ensureAnalyticsControl(ctx, args.siteId, now);
43
54
  await consumeSiteRateLimit(ctx, args.siteId, args.events, eventByteCounts, now);
44
55
  const context = sanitizeContext(args.context);
56
+ const timezone = localDayTimezone(site.timezone);
45
57
  const aggregateDeltas = new Map();
46
58
  const reportRollupDeltas = createReportRollupDeltas();
47
59
  const sessionRateWindows = new Map();
@@ -77,7 +89,9 @@ export const ingestBatch = mutation({
77
89
  const bucketStart = eventBucketStart(event.timestamp);
78
90
  const groupKey = eventBatchKey(event.sessionId, bucketStart);
79
91
  requestGroups.add(groupKey);
80
- if (event.type === "heartbeat" || event.type === "vital")
92
+ if (event.type === "heartbeat" ||
93
+ event.type === "vital" ||
94
+ event.type === "leave")
81
95
  continue;
82
96
  const eventIds = eventIdsByGroup.get(groupKey) ?? new Set();
83
97
  if (eventIds.has(event.eventId)) {
@@ -141,7 +155,37 @@ export const ingestBatch = mutation({
141
155
  rejected += 1;
142
156
  continue;
143
157
  }
144
- await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now, batchState);
158
+ const durationDeltaMs = await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now, batchState);
159
+ // A heartbeat is presence, not telemetry, but it is what keeps a
160
+ // session's duration honest while the visitor reads: the growth is
161
+ // folded into the buckets the heartbeat landed in.
162
+ foldSessionDuration(aggregateDeltas, args.siteId, event, durationDeltaMs, timezone);
163
+ accepted += 1;
164
+ continue;
165
+ }
166
+ if (event.type === "leave") {
167
+ // A departure only shortens live presence: it stores no telemetry,
168
+ // touches no aggregates, and never extends anything, so a replayed
169
+ // or late leave is inert. The read side filters by expiresAt and the
170
+ // sweep deletes the row, so the dashboard sees the exit in seconds.
171
+ if (!existingSession) {
172
+ rejected += 1;
173
+ continue;
174
+ }
175
+ if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
176
+ rejected += 1;
177
+ continue;
178
+ }
179
+ const liveState = await loadLiveSession(ctx, args.siteId, event.sessionId, batchState);
180
+ const liveSession = liveState.current;
181
+ if (liveSession) {
182
+ const expiresAt = Math.min(liveSession.expiresAt, now + LIVE_LEAVE_GRACE_MS);
183
+ if (expiresAt < liveSession.expiresAt) {
184
+ await ctx.db.patch("liveSessions", liveSession._id, { expiresAt });
185
+ liveState.current = { ...liveSession, expiresAt };
186
+ }
187
+ await ensureLiveSweep(ctx, args.siteId, now, batchState);
188
+ }
145
189
  accepted += 1;
146
190
  continue;
147
191
  }
@@ -194,6 +238,7 @@ export const ingestBatch = mutation({
194
238
  aggregateDeltas,
195
239
  reportRollupDeltas,
196
240
  recordTelemetry: true,
241
+ timezone,
197
242
  financialConversion: false,
198
243
  trustedGoalKey: undefined,
199
244
  now,
@@ -205,7 +250,7 @@ export const ingestBatch = mutation({
205
250
  await flushBatchState(ctx, batchState);
206
251
  await flushSessionRateLimits(ctx, sessionRateWindows, now);
207
252
  await flushAggregates(ctx, aggregateDeltas);
208
- await flushReportRollups(ctx, reportRollupDeltas);
253
+ await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
209
254
  return { accepted, duplicates, rejected };
210
255
  },
211
256
  });
@@ -294,6 +339,7 @@ export const trackConversion = mutation({
294
339
  aggregateDeltas,
295
340
  reportRollupDeltas,
296
341
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
342
+ timezone: localDayTimezone(site.timezone),
297
343
  financialConversion: true,
298
344
  trustedGoalKey,
299
345
  now: Date.now(),
@@ -302,8 +348,9 @@ export const trackConversion = mutation({
302
348
  await insertEventBatch(ctx, args.siteId, event, session.country ?? "unknown");
303
349
  }
304
350
  await flushAggregates(ctx, aggregateDeltas);
305
- await flushReportRollups(ctx, reportRollupDeltas);
306
- await ensureAnalyticsControl(ctx, args.siteId, Date.now());
351
+ const now = Date.now();
352
+ await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
353
+ await ensureAnalyticsControl(ctx, args.siteId, now);
307
354
  return { accepted: 1, duplicates: 0, rejected: 0 };
308
355
  },
309
356
  });
@@ -426,20 +473,34 @@ async function accountEvent(ctx, input) {
426
473
  foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
427
474
  matchedGoalKeys.push(goal.key);
428
475
  }
476
+ let reach;
429
477
  for (const funnel of input.funnels) {
430
478
  if (funnel.active) {
431
- await advanceFunnel(ctx, input.siteId, funnel, event, input.reportRollupDeltas, input.batchState);
479
+ reach = furtherReach(reach, await advanceFunnel(ctx, input.siteId, funnel, event, input.reportRollupDeltas, input.batchState));
432
480
  }
433
481
  }
434
482
  if (input.financialConversion) {
435
483
  await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey, input.reportRollupDeltas);
436
484
  }
437
- const campaignDimensions = session.utmCampaign
438
- ? [{ type: "campaign", value: session.utmCampaign }]
439
- : [];
485
+ const campaignDimensions = [];
486
+ if (session.utmCampaign) {
487
+ campaignDimensions.push({ type: "campaign", value: session.utmCampaign });
488
+ }
489
+ if (session.utmMedium) {
490
+ // A medium is a taxonomy ("social", "cpc", "email"), so its case is noise.
491
+ campaignDimensions.push({
492
+ type: "medium",
493
+ value: session.utmMedium.toLowerCase(),
494
+ });
495
+ }
496
+ // An outbound click ranks by where it went: the host alone, never the path.
497
+ const outboundHost = event.type === "outbound" ? publicUrlHost(event.href) : undefined;
440
498
  const dimensions = [
441
499
  { type: "source", value: session.source },
442
500
  ...campaignDimensions,
501
+ ...(outboundHost
502
+ ? [{ type: "outbound", value: outboundHost }]
503
+ : []),
443
504
  { type: "page", value: event.path },
444
505
  { type: "country", value: session.country ?? "unknown" },
445
506
  { type: "device", value: input.context.device ?? "unknown" },
@@ -450,10 +511,10 @@ async function accountEvent(ctx, input) {
450
511
  },
451
512
  ...matchedGoalKeys.map((value) => ({ type: "goal", value })),
452
513
  ];
453
- foldAggregates(input.aggregateDeltas, input.siteId, event, session, dimensions, input.recordTelemetry, input.financialConversion);
514
+ foldAggregates(input.aggregateDeltas, input.siteId, event, session, dimensions, input.recordTelemetry, input.financialConversion, input.timezone);
454
515
  if (input.recordTelemetry &&
455
516
  event.timestamp >= input.now - LIVE_SESSION_TTL_MS) {
456
- await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now, input.batchState);
517
+ await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now, input.batchState, reach);
457
518
  }
458
519
  return session;
459
520
  }
@@ -471,9 +532,16 @@ async function accountTrustedSession(ctx, existing, event) {
471
532
  isNewSession: false,
472
533
  isNewVisitorHour: false,
473
534
  isNewVisitorDay: false,
535
+ isNewVisitor: false,
536
+ durationDeltaMs: 0,
537
+ continued: false,
474
538
  startedAt: existing.startedAt,
475
539
  source: existing.source,
476
540
  utmCampaign: existing.utmCampaign,
541
+ utmMedium: existing.utmMedium,
542
+ entryPath: existing.entryPath,
543
+ conversionCount: existing.conversionCount + 1,
544
+ newVisitor: existing.newVisitor,
477
545
  visitorKey: existing.visitorKey ?? existing.visitorId,
478
546
  country: existing.country,
479
547
  city: existing.city,
@@ -555,9 +623,16 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
555
623
  Math.floor(event.timestamp / HOUR_MS),
556
624
  isNewVisitorDay: Math.floor(existing.lastSeenAt / DAY_MS) <
557
625
  Math.floor(event.timestamp / DAY_MS),
626
+ isNewVisitor: false,
627
+ durationDeltaMs: Math.max(0, updates.durationMs - existing.durationMs),
628
+ continued: event.type === "pageview" && existing.pageviewCount === 1,
558
629
  startedAt,
559
630
  source: existing.source,
560
631
  utmCampaign: existing.utmCampaign,
632
+ utmMedium: existing.utmMedium,
633
+ entryPath: existing.entryPath,
634
+ conversionCount: updates.conversionCount,
635
+ newVisitor: existing.newVisitor,
561
636
  visitorKey,
562
637
  country: context.country ?? existing.country,
563
638
  city: context.city ?? existing.city,
@@ -584,6 +659,18 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
584
659
  }
585
660
  }
586
661
  }
662
+ // A visitor is new when nothing was ever recorded under their id. A host
663
+ // id whose anonymous history was linked at sign-up is a return, not a new
664
+ // person; a browser without a persistent id is new every session.
665
+ let isNewVisitor = priorVisit === null;
666
+ if (isNewVisitor && event.visitorId !== event.sessionId) {
667
+ const alias = await ctx.db
668
+ .query("visitorAliases")
669
+ .withIndex("by_siteId_and_visitorId", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
670
+ .first();
671
+ if (alias)
672
+ isNewVisitor = false;
673
+ }
587
674
  const fields = {
588
675
  siteId,
589
676
  sessionId: event.sessionId,
@@ -593,6 +680,7 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
593
680
  lastSeenAt: event.timestamp,
594
681
  entryPath: event.path,
595
682
  exitPath: event.path,
683
+ newVisitor: isNewVisitor,
596
684
  ...(event.referrer ? { referrer: event.referrer } : {}),
597
685
  source,
598
686
  ...(utmSource ? { utmSource } : {}),
@@ -639,9 +727,16 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
639
727
  isNewVisitorDay: priorVisit === null ||
640
728
  Math.floor(priorVisit.lastSeenAt / DAY_MS) <
641
729
  Math.floor(event.timestamp / DAY_MS),
730
+ isNewVisitor,
731
+ durationDeltaMs: 0,
732
+ continued: false,
642
733
  startedAt: event.timestamp,
643
734
  source,
644
735
  utmCampaign,
736
+ utmMedium,
737
+ entryPath: event.path,
738
+ conversionCount: Number(financialConversion),
739
+ newVisitor: isNewVisitor,
645
740
  visitorKey,
646
741
  country: context.country,
647
742
  city: context.city,
@@ -788,10 +883,14 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
788
883
  else {
789
884
  await ctx.db.patch("sessions", session._id, updates);
790
885
  }
886
+ const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
791
887
  await updateLiveSession(ctx, siteId, event, context, {
792
888
  isNewSession: false,
793
889
  isNewVisitorHour: false,
794
890
  isNewVisitorDay: false,
891
+ isNewVisitor: false,
892
+ durationDeltaMs: 0,
893
+ continued: false,
795
894
  startedAt,
796
895
  source: session.source,
797
896
  visitorKey: session.visitorKey ?? session.visitorId,
@@ -802,7 +901,11 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
802
901
  pageviewCount: session.pageviewCount,
803
902
  eventCount: session.eventCount,
804
903
  affiliateSlug: session.affiliateSlug,
904
+ entryPath: session.entryPath,
905
+ conversionCount: session.conversionCount,
906
+ newVisitor: session.newVisitor,
805
907
  }, now, batchState);
908
+ return durationDeltaMs;
806
909
  }
807
910
  async function consumeSiteRateLimit(ctx, siteId, events, eventByteCounts, now) {
808
911
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
@@ -919,7 +1022,26 @@ async function flushSessionRateLimits(ctx, states, now) {
919
1022
  }
920
1023
  }
921
1024
  }
922
- async function updateLiveSession(ctx, siteId, event, context, session, now, batchState) {
1025
+ /**
1026
+ * The intent facts a live row carries for the dashboard's heat score. A
1027
+ * session row from before 0.1.0 has no first-visit flag, so `returning` stays
1028
+ * unknown rather than guessed.
1029
+ */
1030
+ function liveIntentFields(session, funnel) {
1031
+ return {
1032
+ ...(session.entryPath ? { entryPath: session.entryPath } : {}),
1033
+ ...(session.newVisitor !== undefined
1034
+ ? { returning: !session.newVisitor }
1035
+ : {}),
1036
+ ...(session.utmCampaign ? { utmCampaign: session.utmCampaign } : {}),
1037
+ ...(session.affiliateSlug ? { affiliateSlug: session.affiliateSlug } : {}),
1038
+ ...(session.conversionCount !== undefined
1039
+ ? { conversionCount: session.conversionCount }
1040
+ : {}),
1041
+ ...(funnel ? { funnel } : {}),
1042
+ };
1043
+ }
1044
+ async function updateLiveSession(ctx, siteId, event, context, session, now, batchState, reach) {
923
1045
  await ensureLiveSweep(ctx, siteId, now, batchState);
924
1046
  const liveState = batchState
925
1047
  ? await loadLiveSession(ctx, siteId, event.sessionId, batchState)
@@ -960,6 +1082,7 @@ async function updateLiveSession(ctx, siteId, event, context, session, now, batc
960
1082
  device: context.device ?? existing.device,
961
1083
  pageviewCount: session.pageviewCount,
962
1084
  eventCount: session.eventCount,
1085
+ ...liveIntentFields(session, furtherReach(existing.funnel, reach)),
963
1086
  // The live map animates a visitor from the route they left to the one
964
1087
  // they reached; a heartbeat or click on the same page is not a move.
965
1088
  ...(event.type === "pageview" && event.path !== existing.path
@@ -999,6 +1122,7 @@ async function updateLiveSession(ctx, siteId, event, context, session, now, batc
999
1122
  device: context.device ?? "unknown",
1000
1123
  pageviewCount: session.pageviewCount,
1001
1124
  eventCount: session.eventCount,
1125
+ ...liveIntentFields(session, reach),
1002
1126
  ...(event.type === "heartbeat"
1003
1127
  ? {
1004
1128
  lastHeartbeatEventId: event.eventId,
@@ -1086,9 +1210,9 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
1086
1210
  _id: progressId,
1087
1211
  };
1088
1212
  }
1089
- await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1213
+ foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1090
1214
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1091
- return;
1215
+ return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1092
1216
  }
1093
1217
  if (progress.completedAt !== undefined ||
1094
1218
  event.timestamp < progress.lastStepAt)
@@ -1117,9 +1241,9 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
1117
1241
  completedAt: undefined,
1118
1242
  });
1119
1243
  }
1120
- await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1244
+ foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1121
1245
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1122
- return;
1246
+ return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1123
1247
  }
1124
1248
  const nextStep = funnel.steps[progress.currentStep];
1125
1249
  if (!nextStep || !stepMatches(nextStep, event))
@@ -1139,44 +1263,19 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
1139
1263
  else {
1140
1264
  await ctx.db.patch("funnelProgress", progress._id, updates);
1141
1265
  }
1142
- await incrementFunnelStep(ctx, siteId, funnel._id, event, progress.currentStep, completed);
1266
+ foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, progress.currentStep, completed);
1143
1267
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, progress.currentStep, completed);
1268
+ return {
1269
+ funnelId: funnel._id,
1270
+ step: currentStep,
1271
+ steps: funnel.steps.length,
1272
+ };
1144
1273
  }
1145
1274
  function stepMatches(step, event) {
1146
1275
  return step.type === "pageview"
1147
1276
  ? event.type === "pageview" && event.path === step.value
1148
1277
  : event.type === "custom" && event.name === step.value;
1149
1278
  }
1150
- async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed) {
1151
- const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1152
- const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
1153
- const bucket = await ctx.db
1154
- .query("funnelStepBuckets")
1155
- .withIndex("by_siteId_and_funnelId_and_bucketStart_and_step_and_shard", (range) => range
1156
- .eq("siteId", siteId)
1157
- .eq("funnelId", funnelId)
1158
- .eq("bucketStart", bucketStart)
1159
- .eq("step", step)
1160
- .eq("shard", shard))
1161
- .unique();
1162
- if (bucket) {
1163
- await ctx.db.patch("funnelStepBuckets", bucket._id, {
1164
- entrants: bucket.entrants + 1,
1165
- completions: bucket.completions + (completed ? 1 : 0),
1166
- });
1167
- }
1168
- else {
1169
- await ctx.db.insert("funnelStepBuckets", {
1170
- siteId,
1171
- funnelId,
1172
- bucketStart,
1173
- step,
1174
- shard,
1175
- entrants: 1,
1176
- completions: completed ? 1 : 0,
1177
- });
1178
- }
1179
- }
1180
1279
  async function accountConversion(ctx, siteId, session, event, trustedGoalKey, reportRollupDeltas) {
1181
1280
  const revenueCents = event.revenueCents ?? 0;
1182
1281
  const currency = event.currency ?? "USD";
@@ -1238,24 +1337,6 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey, re
1238
1337
  }
1239
1338
  }
1240
1339
  }
1241
- function createReportRollupDeltas() {
1242
- return {
1243
- goals: new Map(),
1244
- funnels: new Map(),
1245
- affiliates: new Map(),
1246
- vitals: new Map(),
1247
- routes: new Map(),
1248
- };
1249
- }
1250
- function reportRollupCoordinates(siteId, definitionId, timestamp, visitorId) {
1251
- const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
1252
- const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
1253
- return {
1254
- bucketStart,
1255
- shard,
1256
- key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
1257
- };
1258
- }
1259
1340
  function foldGoalRollup(deltas, siteId, event, goal) {
1260
1341
  const coordinates = reportRollupCoordinates(siteId, String(goal._id), event.timestamp, event.visitorId);
1261
1342
  const current = deltas.goals.get(coordinates.key) ?? {
@@ -1290,6 +1371,23 @@ function foldFunnelRollup(deltas, siteId, funnelId, event, step, completed) {
1290
1371
  rollup.steps.set(step, current);
1291
1372
  deltas.funnels.set(coordinates.key, rollup);
1292
1373
  }
1374
+ function foldFunnelStepDelta(deltas, siteId, funnelId, event, step, completed) {
1375
+ const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1376
+ const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
1377
+ const key = `${siteId}:${funnelId}:${bucketStart}:${step}:${shard}`;
1378
+ const current = deltas.funnelSteps.get(key) ?? {
1379
+ siteId,
1380
+ funnelId,
1381
+ bucketStart,
1382
+ step,
1383
+ shard,
1384
+ entrants: 0,
1385
+ completions: 0,
1386
+ };
1387
+ current.entrants += 1;
1388
+ current.completions += Number(completed);
1389
+ deltas.funnelSteps.set(key, current);
1390
+ }
1293
1391
  function foldAffiliateVisitRollup(deltas, siteId, event, attribution, visitorKey) {
1294
1392
  const coordinates = reportRollupCoordinates(siteId, String(attribution.affiliateId), event.timestamp, event.visitorId);
1295
1393
  const current = deltas.affiliates.get(coordinates.key) ?? {
@@ -1299,13 +1397,13 @@ function foldAffiliateVisitRollup(deltas, siteId, event, attribution, visitorKey
1299
1397
  shard: coordinates.shard,
1300
1398
  affiliateSlug: attribution.affiliateSlug,
1301
1399
  visits: 0,
1302
- visitorSketch: createVisitorSketch(),
1400
+ visitorKeys: new Set(),
1303
1401
  conversions: 0,
1304
1402
  revenueCents: 0,
1305
1403
  commissionCents: 0,
1306
1404
  };
1307
1405
  current.visits += 1;
1308
- current.visitorSketch = addVisitor(current.visitorSketch, visitorKey);
1406
+ current.visitorKeys.add(visitorKey);
1309
1407
  deltas.affiliates.set(coordinates.key, current);
1310
1408
  }
1311
1409
  function foldAffiliateConversionRollup(deltas, siteId, event, affiliate, affiliateSlug, revenueCents, commissionCents) {
@@ -1317,7 +1415,7 @@ function foldAffiliateConversionRollup(deltas, siteId, event, affiliate, affilia
1317
1415
  shard: coordinates.shard,
1318
1416
  affiliateSlug,
1319
1417
  visits: 0,
1320
- visitorSketch: createVisitorSketch(),
1418
+ visitorKeys: new Set(),
1321
1419
  conversions: 0,
1322
1420
  revenueCents: 0,
1323
1421
  commissionCents: 0,
@@ -1377,7 +1475,7 @@ function foldRouteDelta(deltas, siteId, route, timestamp, visitorId, apply) {
1377
1475
  continued: 0,
1378
1476
  dwellMs: 0,
1379
1477
  dwellSamples: 0,
1380
- visitorSketch: createVisitorSketch(),
1478
+ visitorKeys: new Set(),
1381
1479
  outgoing: new Map(),
1382
1480
  };
1383
1481
  apply(current);
@@ -1398,7 +1496,7 @@ function foldRouteRollups(deltas, siteId, event, session, existing) {
1398
1496
  delta.pageviews += 1;
1399
1497
  if (firstPageview)
1400
1498
  delta.entries += 1;
1401
- delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
1499
+ delta.visitorKeys.add(session.visitorKey);
1402
1500
  });
1403
1501
  // Out-of-order telemetry keeps its view but cannot say where it came from.
1404
1502
  if (existing === null || firstPageview)
@@ -1420,245 +1518,19 @@ function foldRouteRollups(deltas, siteId, event, session, existing) {
1420
1518
  });
1421
1519
  }
1422
1520
  }
1423
- async function upsertRouteRollup(ctx, delta, dayRowCounts) {
1424
- const existing = await ctx.db
1425
- .query("routeDailyRollups")
1426
- .withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) => range
1427
- .eq("siteId", delta.siteId)
1428
- .eq("route", delta.route)
1429
- .eq("bucketStart", delta.bucketStart)
1430
- .eq("shard", delta.shard))
1431
- .unique();
1432
- if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
1433
- // A new route row spends one bounded read per batch to respect the daily
1434
- // cap; past it, the day's remaining routes fold into "(other)".
1435
- const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
1436
- const dayKey = `${delta.siteId}:${delta.bucketStart}`;
1437
- let dayRows = dayRowCounts.get(dayKey);
1438
- if (dayRows === undefined) {
1439
- dayRows = (await ctx.db
1440
- .query("routeDailyRollups")
1441
- .withIndex("by_siteId_and_bucketStart", (range) => range
1442
- .eq("siteId", delta.siteId)
1443
- .eq("bucketStart", delta.bucketStart))
1444
- .take(capRows + 1)).length;
1445
- }
1446
- if (dayRows >= capRows) {
1447
- await upsertRouteRollup(ctx, { ...delta, route: SITE_MAP_OTHER_ROUTE }, dayRowCounts);
1448
- return;
1449
- }
1450
- dayRowCounts.set(dayKey, dayRows + 1);
1451
- }
1452
- const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
1453
- for (const [target, count] of delta.outgoing) {
1454
- let entry = outgoing.find((candidate) => candidate.route === target);
1455
- if (!entry) {
1456
- const named = outgoing.filter((candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE).length;
1457
- const route = target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
1458
- ? SITE_MAP_OTHER_ROUTE
1459
- : target;
1460
- entry = outgoing.find((candidate) => candidate.route === route);
1461
- if (!entry) {
1462
- entry = { route, count: 0 };
1463
- outgoing.push(entry);
1464
- }
1465
- }
1466
- entry.count += count;
1467
- }
1468
- const fields = {
1469
- siteId: delta.siteId,
1470
- bucketStart: delta.bucketStart,
1471
- shard: delta.shard,
1472
- generation: 1,
1473
- route: delta.route,
1474
- pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
1475
- entries: (existing?.entries ?? 0) + delta.entries,
1476
- continued: (existing?.continued ?? 0) + delta.continued,
1477
- dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
1478
- dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
1479
- visitorSketch: unionVisitorSketches(existing?.visitorSketch ?? createVisitorSketch(), delta.visitorSketch),
1480
- outgoing,
1481
- };
1482
- if (existing) {
1483
- await ctx.db.replace("routeDailyRollups", existing._id, fields);
1484
- }
1485
- else {
1486
- await ctx.db.insert("routeDailyRollups", fields);
1487
- }
1488
- }
1489
- async function upsertVitalRollup(ctx, delta) {
1490
- const existing = await ctx.db
1491
- .query("vitalRollups")
1492
- .withIndex("by_key", (range) => range
1493
- .eq("siteId", delta.siteId)
1494
- .eq("bucketStart", delta.bucketStart)
1495
- .eq("device", delta.device)
1496
- .eq("page", delta.page)
1497
- .eq("shard", delta.shard))
1498
- .unique();
1499
- if (!existing &&
1500
- delta.device === VITAL_ALL &&
1501
- delta.page !== VITAL_OTHER_PAGES) {
1502
- // A new page row spends one bounded read to respect the daily page slots;
1503
- // past the cap, the day's remaining pages fold into "(other)".
1504
- const pageRows = await ctx.db
1505
- .query("vitalRollups")
1506
- .withIndex("by_key", (range) => range
1507
- .eq("siteId", delta.siteId)
1508
- .eq("bucketStart", delta.bucketStart)
1509
- .eq("device", VITAL_ALL))
1510
- .take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
1511
- if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
1512
- await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
1513
- return;
1514
- }
1515
- }
1516
- const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
1517
- for (const [metric, addition] of delta.metrics) {
1518
- const entry = metrics.find((candidate) => candidate.metric === metric);
1519
- if (entry) {
1520
- entry.count += addition.count;
1521
- entry.sum += addition.sum;
1522
- entry.histogram = mergeVitalHistograms(entry.histogram, addition.histogram);
1523
- }
1524
- else {
1525
- metrics.push({
1526
- metric,
1527
- count: addition.count,
1528
- sum: addition.sum,
1529
- histogram: [...addition.histogram],
1530
- });
1531
- }
1532
- }
1533
- const fields = {
1534
- siteId: delta.siteId,
1535
- bucketStart: delta.bucketStart,
1536
- device: delta.device,
1537
- page: delta.page,
1538
- shard: delta.shard,
1539
- metrics,
1540
- };
1541
- if (existing) {
1542
- await ctx.db.replace("vitalRollups", existing._id, fields);
1543
- }
1544
- else {
1545
- await ctx.db.insert("vitalRollups", fields);
1546
- }
1547
- }
1548
- async function flushReportRollups(ctx, deltas) {
1549
- for (const delta of deltas.goals.values()) {
1550
- const existing = await ctx.db
1551
- .query("goalDailyRollups")
1552
- .withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) => range
1553
- .eq("siteId", delta.siteId)
1554
- .eq("goalId", delta.goalId)
1555
- .eq("bucketStart", delta.bucketStart)
1556
- .eq("shard", delta.shard))
1557
- .unique();
1558
- const fields = {
1559
- siteId: delta.siteId,
1560
- goalId: delta.goalId,
1561
- bucketStart: delta.bucketStart,
1562
- shard: delta.shard,
1563
- generation: 1,
1564
- goalKey: delta.goalKey,
1565
- completions: (existing?.completions ?? 0) + delta.completions,
1566
- valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
1567
- };
1568
- if (existing) {
1569
- await ctx.db.replace("goalDailyRollups", existing._id, fields);
1570
- }
1571
- else {
1572
- await ctx.db.insert("goalDailyRollups", fields);
1573
- }
1574
- }
1575
- for (const delta of deltas.funnels.values()) {
1576
- const existing = await ctx.db
1577
- .query("funnelDailyRollups")
1578
- .withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) => range
1579
- .eq("siteId", delta.siteId)
1580
- .eq("funnelId", delta.funnelId)
1581
- .eq("bucketStart", delta.bucketStart)
1582
- .eq("shard", delta.shard))
1583
- .unique();
1584
- const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
1585
- for (const addition of delta.steps.values()) {
1586
- const current = steps.find((step) => step.step === addition.step);
1587
- if (current) {
1588
- current.entrants += addition.entrants;
1589
- current.completions += addition.completions;
1590
- }
1591
- else {
1592
- if (steps.length >= MAX_FUNNEL_STEPS) {
1593
- fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
1594
- }
1595
- steps.push(addition);
1596
- }
1597
- }
1598
- const fields = {
1599
- siteId: delta.siteId,
1600
- funnelId: delta.funnelId,
1601
- bucketStart: delta.bucketStart,
1602
- shard: delta.shard,
1603
- generation: 1,
1604
- steps,
1605
- };
1606
- if (existing) {
1607
- await ctx.db.replace("funnelDailyRollups", existing._id, fields);
1608
- }
1609
- else {
1610
- await ctx.db.insert("funnelDailyRollups", fields);
1611
- }
1612
- }
1613
- for (const delta of deltas.affiliates.values()) {
1614
- const existing = await ctx.db
1615
- .query("affiliateDailyRollups")
1616
- .withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) => range
1617
- .eq("siteId", delta.siteId)
1618
- .eq("affiliateId", delta.affiliateId)
1619
- .eq("bucketStart", delta.bucketStart)
1620
- .eq("shard", delta.shard))
1621
- .unique();
1622
- const fields = {
1623
- siteId: delta.siteId,
1624
- affiliateId: delta.affiliateId,
1625
- bucketStart: delta.bucketStart,
1626
- shard: delta.shard,
1627
- generation: 1,
1628
- affiliateSlug: delta.affiliateSlug,
1629
- visits: (existing?.visits ?? 0) + delta.visits,
1630
- visitorSketch: unionVisitorSketches(existing?.visitorSketch ?? createVisitorSketch(), delta.visitorSketch),
1631
- conversions: (existing?.conversions ?? 0) + delta.conversions,
1632
- revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
1633
- commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
1634
- };
1635
- if (existing) {
1636
- await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
1637
- }
1638
- else {
1639
- await ctx.db.insert("affiliateDailyRollups", fields);
1640
- }
1641
- }
1642
- const routeDayRowCounts = new Map();
1643
- for (const delta of deltas.routes.values()) {
1644
- await upsertRouteRollup(ctx, delta, routeDayRowCounts);
1645
- }
1646
- for (const delta of deltas.vitals.values()) {
1647
- await upsertVitalRollup(ctx, delta);
1648
- }
1649
- }
1650
- function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion) {
1521
+ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion, timezone) {
1651
1522
  const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
1652
1523
  const dimensionDeltas = dimensions.map((dimension) => ({
1653
1524
  ...dimension,
1654
1525
  count: Number(recordTelemetry),
1655
1526
  revenueCents,
1527
+ pageviews: Number(recordTelemetry && event.type === "pageview"),
1528
+ sessions: Number(recordTelemetry && session.isNewSession),
1656
1529
  }));
1657
- for (const granularity of ["hour", "day"]) {
1530
+ for (const granularity of aggregateGranularities(timezone)) {
1658
1531
  const shard = stableHash(event.sessionId) %
1659
1532
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
1660
- const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
1661
- const bucketStart = Math.floor(event.timestamp / interval) * interval;
1533
+ const bucketStart = aggregateBucketStart(granularity, event.timestamp, timezone);
1662
1534
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
1663
1535
  let delta = aggregateDeltas.get(key);
1664
1536
  if (!delta) {
@@ -1678,6 +1550,9 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
1678
1550
  conversions: 0,
1679
1551
  revenueCents: 0,
1680
1552
  dimensions: [],
1553
+ durationMs: 0,
1554
+ continuedSessions: 0,
1555
+ newVisitors: 0,
1681
1556
  };
1682
1557
  aggregateDeltas.set(key, delta);
1683
1558
  }
@@ -1699,6 +1574,58 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
1699
1574
  delta.conversions += Number(financialConversion);
1700
1575
  delta.revenueCents += revenueCents;
1701
1576
  delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
1577
+ if (recordTelemetry) {
1578
+ delta.durationMs += session.durationDeltaMs;
1579
+ delta.continuedSessions += Number(session.continued);
1580
+ delta.newVisitors += Number(session.isNewSession && session.isNewVisitor);
1581
+ }
1582
+ }
1583
+ }
1584
+ /**
1585
+ * The buckets an event lands in: UTC hour and UTC day always, plus the
1586
+ * calendar day in the site's timezone when the site keeps one.
1587
+ */
1588
+ function aggregateGranularities(timezone) {
1589
+ return timezone ? ["hour", "day", "localDay"] : ["hour", "day"];
1590
+ }
1591
+ function aggregateBucketStart(granularity, timestamp, timezone) {
1592
+ if (granularity === "localDay" && timezone) {
1593
+ return localDayStart(timestamp, timezone);
1594
+ }
1595
+ const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
1596
+ return Math.floor(timestamp / interval) * interval;
1597
+ }
1598
+ /** Folds only session duration growth, for heartbeats. */
1599
+ function foldSessionDuration(aggregateDeltas, siteId, event, durationDeltaMs, timezone) {
1600
+ if (durationDeltaMs <= 0)
1601
+ return;
1602
+ for (const granularity of aggregateGranularities(timezone)) {
1603
+ const shard = stableHash(event.sessionId) %
1604
+ (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
1605
+ const bucketStart = aggregateBucketStart(granularity, event.timestamp, timezone);
1606
+ const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
1607
+ const delta = aggregateDeltas.get(key) ?? {
1608
+ siteId,
1609
+ granularity,
1610
+ bucketStart,
1611
+ shard,
1612
+ events: 0,
1613
+ pageviews: 0,
1614
+ clicks: 0,
1615
+ customEvents: 0,
1616
+ outboundClicks: 0,
1617
+ sessions: 0,
1618
+ visitors: 0,
1619
+ visitorSketch: createVisitorSketch(),
1620
+ conversions: 0,
1621
+ revenueCents: 0,
1622
+ dimensions: [],
1623
+ durationMs: 0,
1624
+ continuedSessions: 0,
1625
+ newVisitors: 0,
1626
+ };
1627
+ delta.durationMs += durationDeltaMs;
1628
+ aggregateDeltas.set(key, delta);
1702
1629
  }
1703
1630
  }
1704
1631
  async function flushAggregates(ctx, aggregateDeltas) {
@@ -1711,7 +1638,11 @@ async function flushAggregates(ctx, aggregateDeltas) {
1711
1638
  .eq("bucketStart", delta.bucketStart)
1712
1639
  .eq("shard", delta.shard))
1713
1640
  .unique();
1714
- const dimensions = mergeDimensions(bucket?.dimensions ?? [], delta.dimensions);
1641
+ const dimensions = mergeDimensions(bucket?.dimensions ?? [], delta.dimensions.map((slot) => ({
1642
+ ...slot,
1643
+ pageviews: slot.pageviews ?? 0,
1644
+ sessions: slot.sessions ?? 0,
1645
+ })));
1715
1646
  const priorSketch = bucket?.visitorSketch;
1716
1647
  const visitorSketch = priorSketch
1717
1648
  ? unionVisitorSketches(priorSketch, delta.visitorSketch)
@@ -1733,6 +1664,9 @@ async function flushAggregates(ctx, aggregateDeltas) {
1733
1664
  conversions: bucket.conversions + delta.conversions,
1734
1665
  revenueCents: bucket.revenueCents + delta.revenueCents,
1735
1666
  dimensions,
1667
+ durationMs: (bucket.durationMs ?? 0) + delta.durationMs,
1668
+ continuedSessions: (bucket.continuedSessions ?? 0) + delta.continuedSessions,
1669
+ newVisitors: (bucket.newVisitors ?? 0) + delta.newVisitors,
1736
1670
  });
1737
1671
  }
1738
1672
  else {
@@ -1753,6 +1687,9 @@ async function flushAggregates(ctx, aggregateDeltas) {
1753
1687
  conversions: delta.conversions,
1754
1688
  revenueCents: delta.revenueCents,
1755
1689
  dimensions,
1690
+ durationMs: delta.durationMs,
1691
+ continuedSessions: delta.continuedSessions,
1692
+ newVisitors: delta.newVisitors,
1756
1693
  });
1757
1694
  }
1758
1695
  }
@@ -1764,6 +1701,8 @@ function mergeDimensions(current, additions) {
1764
1701
  if (exact) {
1765
1702
  exact.count += addition.count;
1766
1703
  exact.revenueCents += addition.revenueCents;
1704
+ exact.pageviews = (exact.pageviews ?? 0) + addition.pageviews;
1705
+ exact.sessions = (exact.sessions ?? 0) + addition.sessions;
1767
1706
  continue;
1768
1707
  }
1769
1708
  const typeSlots = result.filter((candidate) => candidate.type === addition.type);
@@ -1780,6 +1719,8 @@ function mergeDimensions(current, additions) {
1780
1719
  value: addition.value,
1781
1720
  count: addition.count,
1782
1721
  revenueCents: addition.revenueCents,
1722
+ pageviews: addition.pageviews,
1723
+ sessions: addition.sessions,
1783
1724
  });
1784
1725
  continue;
1785
1726
  }
@@ -1787,6 +1728,8 @@ function mergeDimensions(current, additions) {
1787
1728
  if (overflow) {
1788
1729
  overflow.count += addition.count;
1789
1730
  overflow.revenueCents += addition.revenueCents;
1731
+ overflow.pageviews = (overflow.pageviews ?? 0) + addition.pageviews;
1732
+ overflow.sessions = (overflow.sessions ?? 0) + addition.sessions;
1790
1733
  continue;
1791
1734
  }
1792
1735
  if (addition.count === 0 && addition.revenueCents === 0)
@@ -1802,6 +1745,8 @@ function mergeDimensions(current, additions) {
1802
1745
  value: "(other)",
1803
1746
  count: addition.count,
1804
1747
  revenueCents: addition.revenueCents,
1748
+ pageviews: addition.pageviews,
1749
+ sessions: addition.sessions,
1805
1750
  });
1806
1751
  continue;
1807
1752
  }
@@ -1813,6 +1758,8 @@ function mergeDimensions(current, additions) {
1813
1758
  replacement.value = "(other)";
1814
1759
  replacement.count += addition.count;
1815
1760
  replacement.revenueCents += addition.revenueCents;
1761
+ replacement.pageviews = (replacement.pageviews ?? 0) + addition.pageviews;
1762
+ replacement.sessions = (replacement.sessions ?? 0) + addition.sessions;
1816
1763
  }
1817
1764
  return result;
1818
1765
  }