@iann29/rastro 0.1.0-alpha.9 → 0.2.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 (98) hide show
  1. package/README.md +248 -58
  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 +215 -14
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +233 -9
  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 +443 -98
  14. package/dist/client/index.d.ts.map +1 -1
  15. package/dist/client/index.js +214 -21
  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 +50 -13
  21. package/dist/component/_generated/component.d.ts.map +1 -1
  22. package/dist/component/constants.d.ts +3 -0
  23. package/dist/component/constants.d.ts.map +1 -1
  24. package/dist/component/constants.js +6 -0
  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/geo.d.ts +2 -0
  31. package/dist/component/geo.d.ts.map +1 -1
  32. package/dist/component/geo.js +56 -19
  33. package/dist/component/geo.js.map +1 -1
  34. package/dist/component/http.d.ts.map +1 -1
  35. package/dist/component/http.js +48 -27
  36. package/dist/component/http.js.map +1 -1
  37. package/dist/component/ingest.d.ts.map +1 -1
  38. package/dist/component/ingest.js +223 -304
  39. package/dist/component/ingest.js.map +1 -1
  40. package/dist/component/localTime.d.ts +25 -0
  41. package/dist/component/localTime.d.ts.map +1 -0
  42. package/dist/component/localTime.js +126 -0
  43. package/dist/component/localTime.js.map +1 -0
  44. package/dist/component/reports.d.ts +44 -13
  45. package/dist/component/reports.d.ts.map +1 -1
  46. package/dist/component/reports.js +193 -38
  47. package/dist/component/reports.js.map +1 -1
  48. package/dist/component/retention.js +26 -8
  49. package/dist/component/retention.js.map +1 -1
  50. package/dist/component/rollupStore.d.ts +320 -0
  51. package/dist/component/rollupStore.d.ts.map +1 -0
  52. package/dist/component/rollupStore.js +596 -0
  53. package/dist/component/rollupStore.js.map +1 -0
  54. package/dist/component/rollups.d.ts +20 -0
  55. package/dist/component/rollups.d.ts.map +1 -0
  56. package/dist/component/rollups.js +73 -0
  57. package/dist/component/rollups.js.map +1 -0
  58. package/dist/component/sanitize.d.ts +5 -0
  59. package/dist/component/sanitize.d.ts.map +1 -1
  60. package/dist/component/sanitize.js +15 -0
  61. package/dist/component/sanitize.js.map +1 -1
  62. package/dist/component/schema.d.ts +345 -11
  63. package/dist/component/schema.js +25 -0
  64. package/dist/component/schema.js.map +1 -1
  65. package/dist/component/sites.d.ts +12 -0
  66. package/dist/component/sites.d.ts.map +1 -1
  67. package/dist/component/sites.js +30 -0
  68. package/dist/component/sites.js.map +1 -1
  69. package/dist/component/validators.d.ts +34 -13
  70. package/dist/component/validators.d.ts.map +1 -1
  71. package/dist/component/validators.js +23 -2
  72. package/dist/component/validators.js.map +1 -1
  73. package/dist/tracker/generated.d.ts +3 -0
  74. package/dist/tracker/generated.d.ts.map +1 -1
  75. package/dist/tracker/generated.js +3 -0
  76. package/dist/tracker/generated.js.map +1 -1
  77. package/docs/federation-setup.md +165 -27
  78. package/docs/federation.md +170 -31
  79. package/docs/upgrading.md +181 -21
  80. package/llms.txt +8 -6
  81. package/package.json +2 -1
  82. package/src/component/_generated/api.ts +6 -0
  83. package/src/component/_generated/component.ts +47 -12
  84. package/src/component/constants.ts +6 -0
  85. package/src/component/coverage.ts +46 -6
  86. package/src/component/geo.ts +82 -28
  87. package/src/component/http.ts +61 -26
  88. package/src/component/ingest.ts +327 -439
  89. package/src/component/localTime.ts +167 -0
  90. package/src/component/reports.ts +261 -39
  91. package/src/component/retention.ts +25 -7
  92. package/src/component/rollupStore.ts +799 -0
  93. package/src/component/rollups.ts +82 -0
  94. package/src/component/sanitize.ts +14 -0
  95. package/src/component/schema.ts +35 -0
  96. package/src/component/sites.ts +32 -0
  97. package/src/component/validators.ts +27 -1
  98. 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_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_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();
@@ -143,7 +155,11 @@ export const ingestBatch = mutation({
143
155
  rejected += 1;
144
156
  continue;
145
157
  }
146
- 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);
147
163
  accepted += 1;
148
164
  continue;
149
165
  }
@@ -222,6 +238,7 @@ export const ingestBatch = mutation({
222
238
  aggregateDeltas,
223
239
  reportRollupDeltas,
224
240
  recordTelemetry: true,
241
+ timezone,
225
242
  financialConversion: false,
226
243
  trustedGoalKey: undefined,
227
244
  now,
@@ -233,7 +250,7 @@ export const ingestBatch = mutation({
233
250
  await flushBatchState(ctx, batchState);
234
251
  await flushSessionRateLimits(ctx, sessionRateWindows, now);
235
252
  await flushAggregates(ctx, aggregateDeltas);
236
- await flushReportRollups(ctx, reportRollupDeltas);
253
+ await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
237
254
  return { accepted, duplicates, rejected };
238
255
  },
239
256
  });
@@ -322,6 +339,7 @@ export const trackConversion = mutation({
322
339
  aggregateDeltas,
323
340
  reportRollupDeltas,
324
341
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
342
+ timezone: localDayTimezone(site.timezone),
325
343
  financialConversion: true,
326
344
  trustedGoalKey,
327
345
  now: Date.now(),
@@ -330,8 +348,9 @@ export const trackConversion = mutation({
330
348
  await insertEventBatch(ctx, args.siteId, event, session.country ?? "unknown");
331
349
  }
332
350
  await flushAggregates(ctx, aggregateDeltas);
333
- await flushReportRollups(ctx, reportRollupDeltas);
334
- 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);
335
354
  return { accepted: 1, duplicates: 0, rejected: 0 };
336
355
  },
337
356
  });
@@ -454,20 +473,34 @@ async function accountEvent(ctx, input) {
454
473
  foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
455
474
  matchedGoalKeys.push(goal.key);
456
475
  }
476
+ let reach;
457
477
  for (const funnel of input.funnels) {
458
478
  if (funnel.active) {
459
- 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));
460
480
  }
461
481
  }
462
482
  if (input.financialConversion) {
463
483
  await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey, input.reportRollupDeltas);
464
484
  }
465
- const campaignDimensions = session.utmCampaign
466
- ? [{ type: "campaign", value: session.utmCampaign }]
467
- : [];
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;
468
498
  const dimensions = [
469
499
  { type: "source", value: session.source },
470
500
  ...campaignDimensions,
501
+ ...(outboundHost
502
+ ? [{ type: "outbound", value: outboundHost }]
503
+ : []),
471
504
  { type: "page", value: event.path },
472
505
  { type: "country", value: session.country ?? "unknown" },
473
506
  { type: "device", value: input.context.device ?? "unknown" },
@@ -478,10 +511,10 @@ async function accountEvent(ctx, input) {
478
511
  },
479
512
  ...matchedGoalKeys.map((value) => ({ type: "goal", value })),
480
513
  ];
481
- 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);
482
515
  if (input.recordTelemetry &&
483
516
  event.timestamp >= input.now - LIVE_SESSION_TTL_MS) {
484
- 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);
485
518
  }
486
519
  return session;
487
520
  }
@@ -499,9 +532,16 @@ async function accountTrustedSession(ctx, existing, event) {
499
532
  isNewSession: false,
500
533
  isNewVisitorHour: false,
501
534
  isNewVisitorDay: false,
535
+ isNewVisitor: false,
536
+ durationDeltaMs: 0,
537
+ continued: false,
502
538
  startedAt: existing.startedAt,
503
539
  source: existing.source,
504
540
  utmCampaign: existing.utmCampaign,
541
+ utmMedium: existing.utmMedium,
542
+ entryPath: existing.entryPath,
543
+ conversionCount: existing.conversionCount + 1,
544
+ newVisitor: existing.newVisitor,
505
545
  visitorKey: existing.visitorKey ?? existing.visitorId,
506
546
  country: existing.country,
507
547
  city: existing.city,
@@ -583,9 +623,16 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
583
623
  Math.floor(event.timestamp / HOUR_MS),
584
624
  isNewVisitorDay: Math.floor(existing.lastSeenAt / DAY_MS) <
585
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,
586
629
  startedAt,
587
630
  source: existing.source,
588
631
  utmCampaign: existing.utmCampaign,
632
+ utmMedium: existing.utmMedium,
633
+ entryPath: existing.entryPath,
634
+ conversionCount: updates.conversionCount,
635
+ newVisitor: existing.newVisitor,
589
636
  visitorKey,
590
637
  country: context.country ?? existing.country,
591
638
  city: context.city ?? existing.city,
@@ -612,6 +659,18 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
612
659
  }
613
660
  }
614
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
+ }
615
674
  const fields = {
616
675
  siteId,
617
676
  sessionId: event.sessionId,
@@ -621,6 +680,7 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
621
680
  lastSeenAt: event.timestamp,
622
681
  entryPath: event.path,
623
682
  exitPath: event.path,
683
+ newVisitor: isNewVisitor,
624
684
  ...(event.referrer ? { referrer: event.referrer } : {}),
625
685
  source,
626
686
  ...(utmSource ? { utmSource } : {}),
@@ -667,9 +727,16 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
667
727
  isNewVisitorDay: priorVisit === null ||
668
728
  Math.floor(priorVisit.lastSeenAt / DAY_MS) <
669
729
  Math.floor(event.timestamp / DAY_MS),
730
+ isNewVisitor,
731
+ durationDeltaMs: 0,
732
+ continued: false,
670
733
  startedAt: event.timestamp,
671
734
  source,
672
735
  utmCampaign,
736
+ utmMedium,
737
+ entryPath: event.path,
738
+ conversionCount: Number(financialConversion),
739
+ newVisitor: isNewVisitor,
673
740
  visitorKey,
674
741
  country: context.country,
675
742
  city: context.city,
@@ -816,10 +883,14 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
816
883
  else {
817
884
  await ctx.db.patch("sessions", session._id, updates);
818
885
  }
886
+ const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
819
887
  await updateLiveSession(ctx, siteId, event, context, {
820
888
  isNewSession: false,
821
889
  isNewVisitorHour: false,
822
890
  isNewVisitorDay: false,
891
+ isNewVisitor: false,
892
+ durationDeltaMs: 0,
893
+ continued: false,
823
894
  startedAt,
824
895
  source: session.source,
825
896
  visitorKey: session.visitorKey ?? session.visitorId,
@@ -830,7 +901,11 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
830
901
  pageviewCount: session.pageviewCount,
831
902
  eventCount: session.eventCount,
832
903
  affiliateSlug: session.affiliateSlug,
904
+ entryPath: session.entryPath,
905
+ conversionCount: session.conversionCount,
906
+ newVisitor: session.newVisitor,
833
907
  }, now, batchState);
908
+ return durationDeltaMs;
834
909
  }
835
910
  async function consumeSiteRateLimit(ctx, siteId, events, eventByteCounts, now) {
836
911
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
@@ -947,7 +1022,26 @@ async function flushSessionRateLimits(ctx, states, now) {
947
1022
  }
948
1023
  }
949
1024
  }
950
- 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) {
951
1045
  await ensureLiveSweep(ctx, siteId, now, batchState);
952
1046
  const liveState = batchState
953
1047
  ? await loadLiveSession(ctx, siteId, event.sessionId, batchState)
@@ -988,6 +1082,7 @@ async function updateLiveSession(ctx, siteId, event, context, session, now, batc
988
1082
  device: context.device ?? existing.device,
989
1083
  pageviewCount: session.pageviewCount,
990
1084
  eventCount: session.eventCount,
1085
+ ...liveIntentFields(session, furtherReach(existing.funnel, reach)),
991
1086
  // The live map animates a visitor from the route they left to the one
992
1087
  // they reached; a heartbeat or click on the same page is not a move.
993
1088
  ...(event.type === "pageview" && event.path !== existing.path
@@ -1027,6 +1122,7 @@ async function updateLiveSession(ctx, siteId, event, context, session, now, batc
1027
1122
  device: context.device ?? "unknown",
1028
1123
  pageviewCount: session.pageviewCount,
1029
1124
  eventCount: session.eventCount,
1125
+ ...liveIntentFields(session, reach),
1030
1126
  ...(event.type === "heartbeat"
1031
1127
  ? {
1032
1128
  lastHeartbeatEventId: event.eventId,
@@ -1114,9 +1210,9 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
1114
1210
  _id: progressId,
1115
1211
  };
1116
1212
  }
1117
- await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1213
+ foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1118
1214
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1119
- return;
1215
+ return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1120
1216
  }
1121
1217
  if (progress.completedAt !== undefined ||
1122
1218
  event.timestamp < progress.lastStepAt)
@@ -1145,9 +1241,9 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
1145
1241
  completedAt: undefined,
1146
1242
  });
1147
1243
  }
1148
- await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1244
+ foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1149
1245
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1150
- return;
1246
+ return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1151
1247
  }
1152
1248
  const nextStep = funnel.steps[progress.currentStep];
1153
1249
  if (!nextStep || !stepMatches(nextStep, event))
@@ -1167,44 +1263,19 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
1167
1263
  else {
1168
1264
  await ctx.db.patch("funnelProgress", progress._id, updates);
1169
1265
  }
1170
- await incrementFunnelStep(ctx, siteId, funnel._id, event, progress.currentStep, completed);
1266
+ foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, progress.currentStep, completed);
1171
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
+ };
1172
1273
  }
1173
1274
  function stepMatches(step, event) {
1174
1275
  return step.type === "pageview"
1175
1276
  ? event.type === "pageview" && event.path === step.value
1176
1277
  : event.type === "custom" && event.name === step.value;
1177
1278
  }
1178
- async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed) {
1179
- const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1180
- const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
1181
- const bucket = await ctx.db
1182
- .query("funnelStepBuckets")
1183
- .withIndex("by_siteId_and_funnelId_and_bucketStart_and_step_and_shard", (range) => range
1184
- .eq("siteId", siteId)
1185
- .eq("funnelId", funnelId)
1186
- .eq("bucketStart", bucketStart)
1187
- .eq("step", step)
1188
- .eq("shard", shard))
1189
- .unique();
1190
- if (bucket) {
1191
- await ctx.db.patch("funnelStepBuckets", bucket._id, {
1192
- entrants: bucket.entrants + 1,
1193
- completions: bucket.completions + (completed ? 1 : 0),
1194
- });
1195
- }
1196
- else {
1197
- await ctx.db.insert("funnelStepBuckets", {
1198
- siteId,
1199
- funnelId,
1200
- bucketStart,
1201
- step,
1202
- shard,
1203
- entrants: 1,
1204
- completions: completed ? 1 : 0,
1205
- });
1206
- }
1207
- }
1208
1279
  async function accountConversion(ctx, siteId, session, event, trustedGoalKey, reportRollupDeltas) {
1209
1280
  const revenueCents = event.revenueCents ?? 0;
1210
1281
  const currency = event.currency ?? "USD";
@@ -1266,24 +1337,6 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey, re
1266
1337
  }
1267
1338
  }
1268
1339
  }
1269
- function createReportRollupDeltas() {
1270
- return {
1271
- goals: new Map(),
1272
- funnels: new Map(),
1273
- affiliates: new Map(),
1274
- vitals: new Map(),
1275
- routes: new Map(),
1276
- };
1277
- }
1278
- function reportRollupCoordinates(siteId, definitionId, timestamp, visitorId) {
1279
- const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
1280
- const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
1281
- return {
1282
- bucketStart,
1283
- shard,
1284
- key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
1285
- };
1286
- }
1287
1340
  function foldGoalRollup(deltas, siteId, event, goal) {
1288
1341
  const coordinates = reportRollupCoordinates(siteId, String(goal._id), event.timestamp, event.visitorId);
1289
1342
  const current = deltas.goals.get(coordinates.key) ?? {
@@ -1318,6 +1371,23 @@ function foldFunnelRollup(deltas, siteId, funnelId, event, step, completed) {
1318
1371
  rollup.steps.set(step, current);
1319
1372
  deltas.funnels.set(coordinates.key, rollup);
1320
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
+ }
1321
1391
  function foldAffiliateVisitRollup(deltas, siteId, event, attribution, visitorKey) {
1322
1392
  const coordinates = reportRollupCoordinates(siteId, String(attribution.affiliateId), event.timestamp, event.visitorId);
1323
1393
  const current = deltas.affiliates.get(coordinates.key) ?? {
@@ -1327,13 +1397,13 @@ function foldAffiliateVisitRollup(deltas, siteId, event, attribution, visitorKey
1327
1397
  shard: coordinates.shard,
1328
1398
  affiliateSlug: attribution.affiliateSlug,
1329
1399
  visits: 0,
1330
- visitorSketch: createVisitorSketch(),
1400
+ visitorKeys: new Set(),
1331
1401
  conversions: 0,
1332
1402
  revenueCents: 0,
1333
1403
  commissionCents: 0,
1334
1404
  };
1335
1405
  current.visits += 1;
1336
- current.visitorSketch = addVisitor(current.visitorSketch, visitorKey);
1406
+ current.visitorKeys.add(visitorKey);
1337
1407
  deltas.affiliates.set(coordinates.key, current);
1338
1408
  }
1339
1409
  function foldAffiliateConversionRollup(deltas, siteId, event, affiliate, affiliateSlug, revenueCents, commissionCents) {
@@ -1345,7 +1415,7 @@ function foldAffiliateConversionRollup(deltas, siteId, event, affiliate, affilia
1345
1415
  shard: coordinates.shard,
1346
1416
  affiliateSlug,
1347
1417
  visits: 0,
1348
- visitorSketch: createVisitorSketch(),
1418
+ visitorKeys: new Set(),
1349
1419
  conversions: 0,
1350
1420
  revenueCents: 0,
1351
1421
  commissionCents: 0,
@@ -1405,7 +1475,7 @@ function foldRouteDelta(deltas, siteId, route, timestamp, visitorId, apply) {
1405
1475
  continued: 0,
1406
1476
  dwellMs: 0,
1407
1477
  dwellSamples: 0,
1408
- visitorSketch: createVisitorSketch(),
1478
+ visitorKeys: new Set(),
1409
1479
  outgoing: new Map(),
1410
1480
  };
1411
1481
  apply(current);
@@ -1426,7 +1496,7 @@ function foldRouteRollups(deltas, siteId, event, session, existing) {
1426
1496
  delta.pageviews += 1;
1427
1497
  if (firstPageview)
1428
1498
  delta.entries += 1;
1429
- delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
1499
+ delta.visitorKeys.add(session.visitorKey);
1430
1500
  });
1431
1501
  // Out-of-order telemetry keeps its view but cannot say where it came from.
1432
1502
  if (existing === null || firstPageview)
@@ -1448,245 +1518,19 @@ function foldRouteRollups(deltas, siteId, event, session, existing) {
1448
1518
  });
1449
1519
  }
1450
1520
  }
1451
- async function upsertRouteRollup(ctx, delta, dayRowCounts) {
1452
- const existing = await ctx.db
1453
- .query("routeDailyRollups")
1454
- .withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) => range
1455
- .eq("siteId", delta.siteId)
1456
- .eq("route", delta.route)
1457
- .eq("bucketStart", delta.bucketStart)
1458
- .eq("shard", delta.shard))
1459
- .unique();
1460
- if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
1461
- // A new route row spends one bounded read per batch to respect the daily
1462
- // cap; past it, the day's remaining routes fold into "(other)".
1463
- const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
1464
- const dayKey = `${delta.siteId}:${delta.bucketStart}`;
1465
- let dayRows = dayRowCounts.get(dayKey);
1466
- if (dayRows === undefined) {
1467
- dayRows = (await ctx.db
1468
- .query("routeDailyRollups")
1469
- .withIndex("by_siteId_and_bucketStart", (range) => range
1470
- .eq("siteId", delta.siteId)
1471
- .eq("bucketStart", delta.bucketStart))
1472
- .take(capRows + 1)).length;
1473
- }
1474
- if (dayRows >= capRows) {
1475
- await upsertRouteRollup(ctx, { ...delta, route: SITE_MAP_OTHER_ROUTE }, dayRowCounts);
1476
- return;
1477
- }
1478
- dayRowCounts.set(dayKey, dayRows + 1);
1479
- }
1480
- const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
1481
- for (const [target, count] of delta.outgoing) {
1482
- let entry = outgoing.find((candidate) => candidate.route === target);
1483
- if (!entry) {
1484
- const named = outgoing.filter((candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE).length;
1485
- const route = target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
1486
- ? SITE_MAP_OTHER_ROUTE
1487
- : target;
1488
- entry = outgoing.find((candidate) => candidate.route === route);
1489
- if (!entry) {
1490
- entry = { route, count: 0 };
1491
- outgoing.push(entry);
1492
- }
1493
- }
1494
- entry.count += count;
1495
- }
1496
- const fields = {
1497
- siteId: delta.siteId,
1498
- bucketStart: delta.bucketStart,
1499
- shard: delta.shard,
1500
- generation: 1,
1501
- route: delta.route,
1502
- pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
1503
- entries: (existing?.entries ?? 0) + delta.entries,
1504
- continued: (existing?.continued ?? 0) + delta.continued,
1505
- dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
1506
- dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
1507
- visitorSketch: unionVisitorSketches(existing?.visitorSketch ?? createVisitorSketch(), delta.visitorSketch),
1508
- outgoing,
1509
- };
1510
- if (existing) {
1511
- await ctx.db.replace("routeDailyRollups", existing._id, fields);
1512
- }
1513
- else {
1514
- await ctx.db.insert("routeDailyRollups", fields);
1515
- }
1516
- }
1517
- async function upsertVitalRollup(ctx, delta) {
1518
- const existing = await ctx.db
1519
- .query("vitalRollups")
1520
- .withIndex("by_key", (range) => range
1521
- .eq("siteId", delta.siteId)
1522
- .eq("bucketStart", delta.bucketStart)
1523
- .eq("device", delta.device)
1524
- .eq("page", delta.page)
1525
- .eq("shard", delta.shard))
1526
- .unique();
1527
- if (!existing &&
1528
- delta.device === VITAL_ALL &&
1529
- delta.page !== VITAL_OTHER_PAGES) {
1530
- // A new page row spends one bounded read to respect the daily page slots;
1531
- // past the cap, the day's remaining pages fold into "(other)".
1532
- const pageRows = await ctx.db
1533
- .query("vitalRollups")
1534
- .withIndex("by_key", (range) => range
1535
- .eq("siteId", delta.siteId)
1536
- .eq("bucketStart", delta.bucketStart)
1537
- .eq("device", VITAL_ALL))
1538
- .take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
1539
- if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
1540
- await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
1541
- return;
1542
- }
1543
- }
1544
- const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
1545
- for (const [metric, addition] of delta.metrics) {
1546
- const entry = metrics.find((candidate) => candidate.metric === metric);
1547
- if (entry) {
1548
- entry.count += addition.count;
1549
- entry.sum += addition.sum;
1550
- entry.histogram = mergeVitalHistograms(entry.histogram, addition.histogram);
1551
- }
1552
- else {
1553
- metrics.push({
1554
- metric,
1555
- count: addition.count,
1556
- sum: addition.sum,
1557
- histogram: [...addition.histogram],
1558
- });
1559
- }
1560
- }
1561
- const fields = {
1562
- siteId: delta.siteId,
1563
- bucketStart: delta.bucketStart,
1564
- device: delta.device,
1565
- page: delta.page,
1566
- shard: delta.shard,
1567
- metrics,
1568
- };
1569
- if (existing) {
1570
- await ctx.db.replace("vitalRollups", existing._id, fields);
1571
- }
1572
- else {
1573
- await ctx.db.insert("vitalRollups", fields);
1574
- }
1575
- }
1576
- async function flushReportRollups(ctx, deltas) {
1577
- for (const delta of deltas.goals.values()) {
1578
- const existing = await ctx.db
1579
- .query("goalDailyRollups")
1580
- .withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) => range
1581
- .eq("siteId", delta.siteId)
1582
- .eq("goalId", delta.goalId)
1583
- .eq("bucketStart", delta.bucketStart)
1584
- .eq("shard", delta.shard))
1585
- .unique();
1586
- const fields = {
1587
- siteId: delta.siteId,
1588
- goalId: delta.goalId,
1589
- bucketStart: delta.bucketStart,
1590
- shard: delta.shard,
1591
- generation: 1,
1592
- goalKey: delta.goalKey,
1593
- completions: (existing?.completions ?? 0) + delta.completions,
1594
- valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
1595
- };
1596
- if (existing) {
1597
- await ctx.db.replace("goalDailyRollups", existing._id, fields);
1598
- }
1599
- else {
1600
- await ctx.db.insert("goalDailyRollups", fields);
1601
- }
1602
- }
1603
- for (const delta of deltas.funnels.values()) {
1604
- const existing = await ctx.db
1605
- .query("funnelDailyRollups")
1606
- .withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) => range
1607
- .eq("siteId", delta.siteId)
1608
- .eq("funnelId", delta.funnelId)
1609
- .eq("bucketStart", delta.bucketStart)
1610
- .eq("shard", delta.shard))
1611
- .unique();
1612
- const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
1613
- for (const addition of delta.steps.values()) {
1614
- const current = steps.find((step) => step.step === addition.step);
1615
- if (current) {
1616
- current.entrants += addition.entrants;
1617
- current.completions += addition.completions;
1618
- }
1619
- else {
1620
- if (steps.length >= MAX_FUNNEL_STEPS) {
1621
- fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
1622
- }
1623
- steps.push(addition);
1624
- }
1625
- }
1626
- const fields = {
1627
- siteId: delta.siteId,
1628
- funnelId: delta.funnelId,
1629
- bucketStart: delta.bucketStart,
1630
- shard: delta.shard,
1631
- generation: 1,
1632
- steps,
1633
- };
1634
- if (existing) {
1635
- await ctx.db.replace("funnelDailyRollups", existing._id, fields);
1636
- }
1637
- else {
1638
- await ctx.db.insert("funnelDailyRollups", fields);
1639
- }
1640
- }
1641
- for (const delta of deltas.affiliates.values()) {
1642
- const existing = await ctx.db
1643
- .query("affiliateDailyRollups")
1644
- .withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) => range
1645
- .eq("siteId", delta.siteId)
1646
- .eq("affiliateId", delta.affiliateId)
1647
- .eq("bucketStart", delta.bucketStart)
1648
- .eq("shard", delta.shard))
1649
- .unique();
1650
- const fields = {
1651
- siteId: delta.siteId,
1652
- affiliateId: delta.affiliateId,
1653
- bucketStart: delta.bucketStart,
1654
- shard: delta.shard,
1655
- generation: 1,
1656
- affiliateSlug: delta.affiliateSlug,
1657
- visits: (existing?.visits ?? 0) + delta.visits,
1658
- visitorSketch: unionVisitorSketches(existing?.visitorSketch ?? createVisitorSketch(), delta.visitorSketch),
1659
- conversions: (existing?.conversions ?? 0) + delta.conversions,
1660
- revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
1661
- commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
1662
- };
1663
- if (existing) {
1664
- await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
1665
- }
1666
- else {
1667
- await ctx.db.insert("affiliateDailyRollups", fields);
1668
- }
1669
- }
1670
- const routeDayRowCounts = new Map();
1671
- for (const delta of deltas.routes.values()) {
1672
- await upsertRouteRollup(ctx, delta, routeDayRowCounts);
1673
- }
1674
- for (const delta of deltas.vitals.values()) {
1675
- await upsertVitalRollup(ctx, delta);
1676
- }
1677
- }
1678
- function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion) {
1521
+ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion, timezone) {
1679
1522
  const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
1680
1523
  const dimensionDeltas = dimensions.map((dimension) => ({
1681
1524
  ...dimension,
1682
1525
  count: Number(recordTelemetry),
1683
1526
  revenueCents,
1527
+ pageviews: Number(recordTelemetry && event.type === "pageview"),
1528
+ sessions: Number(recordTelemetry && session.isNewSession),
1684
1529
  }));
1685
- for (const granularity of ["hour", "day"]) {
1530
+ for (const granularity of aggregateGranularities(timezone)) {
1686
1531
  const shard = stableHash(event.sessionId) %
1687
1532
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
1688
- const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
1689
- const bucketStart = Math.floor(event.timestamp / interval) * interval;
1533
+ const bucketStart = aggregateBucketStart(granularity, event.timestamp, timezone);
1690
1534
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
1691
1535
  let delta = aggregateDeltas.get(key);
1692
1536
  if (!delta) {
@@ -1706,6 +1550,9 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
1706
1550
  conversions: 0,
1707
1551
  revenueCents: 0,
1708
1552
  dimensions: [],
1553
+ durationMs: 0,
1554
+ continuedSessions: 0,
1555
+ newVisitors: 0,
1709
1556
  };
1710
1557
  aggregateDeltas.set(key, delta);
1711
1558
  }
@@ -1727,6 +1574,58 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
1727
1574
  delta.conversions += Number(financialConversion);
1728
1575
  delta.revenueCents += revenueCents;
1729
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);
1730
1629
  }
1731
1630
  }
1732
1631
  async function flushAggregates(ctx, aggregateDeltas) {
@@ -1739,7 +1638,11 @@ async function flushAggregates(ctx, aggregateDeltas) {
1739
1638
  .eq("bucketStart", delta.bucketStart)
1740
1639
  .eq("shard", delta.shard))
1741
1640
  .unique();
1742
- 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
+ })));
1743
1646
  const priorSketch = bucket?.visitorSketch;
1744
1647
  const visitorSketch = priorSketch
1745
1648
  ? unionVisitorSketches(priorSketch, delta.visitorSketch)
@@ -1761,6 +1664,9 @@ async function flushAggregates(ctx, aggregateDeltas) {
1761
1664
  conversions: bucket.conversions + delta.conversions,
1762
1665
  revenueCents: bucket.revenueCents + delta.revenueCents,
1763
1666
  dimensions,
1667
+ durationMs: (bucket.durationMs ?? 0) + delta.durationMs,
1668
+ continuedSessions: (bucket.continuedSessions ?? 0) + delta.continuedSessions,
1669
+ newVisitors: (bucket.newVisitors ?? 0) + delta.newVisitors,
1764
1670
  });
1765
1671
  }
1766
1672
  else {
@@ -1781,6 +1687,9 @@ async function flushAggregates(ctx, aggregateDeltas) {
1781
1687
  conversions: delta.conversions,
1782
1688
  revenueCents: delta.revenueCents,
1783
1689
  dimensions,
1690
+ durationMs: delta.durationMs,
1691
+ continuedSessions: delta.continuedSessions,
1692
+ newVisitors: delta.newVisitors,
1784
1693
  });
1785
1694
  }
1786
1695
  }
@@ -1792,6 +1701,8 @@ function mergeDimensions(current, additions) {
1792
1701
  if (exact) {
1793
1702
  exact.count += addition.count;
1794
1703
  exact.revenueCents += addition.revenueCents;
1704
+ exact.pageviews = (exact.pageviews ?? 0) + addition.pageviews;
1705
+ exact.sessions = (exact.sessions ?? 0) + addition.sessions;
1795
1706
  continue;
1796
1707
  }
1797
1708
  const typeSlots = result.filter((candidate) => candidate.type === addition.type);
@@ -1808,6 +1719,8 @@ function mergeDimensions(current, additions) {
1808
1719
  value: addition.value,
1809
1720
  count: addition.count,
1810
1721
  revenueCents: addition.revenueCents,
1722
+ pageviews: addition.pageviews,
1723
+ sessions: addition.sessions,
1811
1724
  });
1812
1725
  continue;
1813
1726
  }
@@ -1815,6 +1728,8 @@ function mergeDimensions(current, additions) {
1815
1728
  if (overflow) {
1816
1729
  overflow.count += addition.count;
1817
1730
  overflow.revenueCents += addition.revenueCents;
1731
+ overflow.pageviews = (overflow.pageviews ?? 0) + addition.pageviews;
1732
+ overflow.sessions = (overflow.sessions ?? 0) + addition.sessions;
1818
1733
  continue;
1819
1734
  }
1820
1735
  if (addition.count === 0 && addition.revenueCents === 0)
@@ -1830,6 +1745,8 @@ function mergeDimensions(current, additions) {
1830
1745
  value: "(other)",
1831
1746
  count: addition.count,
1832
1747
  revenueCents: addition.revenueCents,
1748
+ pageviews: addition.pageviews,
1749
+ sessions: addition.sessions,
1833
1750
  });
1834
1751
  continue;
1835
1752
  }
@@ -1841,6 +1758,8 @@ function mergeDimensions(current, additions) {
1841
1758
  replacement.value = "(other)";
1842
1759
  replacement.count += addition.count;
1843
1760
  replacement.revenueCents += addition.revenueCents;
1761
+ replacement.pageviews = (replacement.pageviews ?? 0) + addition.pageviews;
1762
+ replacement.sessions = (replacement.sessions ?? 0) + addition.sessions;
1844
1763
  }
1845
1764
  return result;
1846
1765
  }