@iann29/rastro 0.1.0-alpha.9 → 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 (98) hide show
  1. package/README.md +246 -57
  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 +441 -98
  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 +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 +104 -27
  78. package/docs/federation.md +168 -31
  79. package/docs/upgrading.md +158 -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
@@ -19,16 +19,11 @@ import {
19
19
  MAX_BYTES_PER_SESSION_WINDOW,
20
20
  MAX_EVENTS_PER_SESSION_WINDOW,
21
21
  MAX_FUNNELS_PER_SITE,
22
- MAX_FUNNEL_STEPS,
23
22
  MAX_GOALS_PER_SITE,
24
- MAX_SITE_MAP_ROUTES_PER_DAY,
25
23
  RATE_LIMIT_WINDOW_MS,
26
24
  REPORT_ROLLUP_SHARDS,
27
- ROUTE_TRANSITION_SLOTS,
28
25
  SECONDARY_AGGREGATE_SHARDS,
29
26
  SITE_INGEST_SHARDS,
30
- SITE_MAP_OTHER_ROUTE,
31
- VITAL_PAGE_SLOTS,
32
27
  } from "./constants.js";
33
28
  import {
34
29
  addVisitor,
@@ -39,10 +34,8 @@ import {
39
34
  import {
40
35
  addToVitalHistogram,
41
36
  emptyVitalHistogram,
42
- mergeVitalHistograms,
43
37
  vitalDevice,
44
38
  VITAL_ALL,
45
- VITAL_OTHER_PAGES,
46
39
  type VitalMetric,
47
40
  } from "./vitals.js";
48
41
  import { fail } from "./errors.js";
@@ -63,6 +56,7 @@ import {
63
56
  measureBatchBytes,
64
57
  normalizeRoute,
65
58
  originAllowed,
59
+ publicUrlHost,
66
60
  sanitizeContext,
67
61
  sanitizeCurrency,
68
62
  sanitizeEvent,
@@ -81,6 +75,14 @@ import {
81
75
  type IngestContext,
82
76
  type TrackerEvent,
83
77
  } from "./validators.js";
78
+ import {
79
+ createReportRollupDeltas,
80
+ enqueueReportRollups,
81
+ reportRollupCoordinates,
82
+ type ReportRollupDeltas,
83
+ type RouteRollupDelta,
84
+ } from "./rollupStore.js";
85
+ import { localDayStart, localDayTimezone } from "./localTime.js";
84
86
 
85
87
  type DimensionType =
86
88
  | "source"
@@ -90,13 +92,20 @@ type DimensionType =
90
92
  | "device"
91
93
  | "browser"
92
94
  | "event"
93
- | "goal";
95
+ | "goal"
96
+ | "medium"
97
+ | "outbound";
94
98
 
95
99
  type Dimension = { type: DimensionType; value: string };
96
- type DimensionDelta = Dimension & { count: number; revenueCents: number };
100
+ type DimensionDelta = Dimension & {
101
+ count: number;
102
+ revenueCents: number;
103
+ pageviews: number;
104
+ sessions: number;
105
+ };
97
106
  type AggregateDelta = {
98
107
  siteId: Id<"sites">;
99
- granularity: "hour" | "day";
108
+ granularity: "hour" | "day" | "localDay";
100
109
  bucketStart: number;
101
110
  shard: number;
102
111
  events: number;
@@ -110,74 +119,11 @@ type AggregateDelta = {
110
119
  conversions: number;
111
120
  revenueCents: number;
112
121
  dimensions: Doc<"aggregateBuckets">["dimensions"];
122
+ durationMs: number;
123
+ continuedSessions: number;
124
+ newVisitors: number;
113
125
  };
114
126
  type AggregateDeltas = Map<string, AggregateDelta>;
115
- type GoalRollupDelta = {
116
- siteId: Id<"sites">;
117
- goalId: Id<"goals">;
118
- bucketStart: number;
119
- shard: number;
120
- goalKey: string;
121
- completions: number;
122
- valueCents: number;
123
- };
124
- type FunnelRollupDelta = {
125
- siteId: Id<"sites">;
126
- funnelId: Id<"funnels">;
127
- bucketStart: number;
128
- shard: number;
129
- steps: Map<
130
- number,
131
- {
132
- step: number;
133
- entrants: number;
134
- completions: number;
135
- }
136
- >;
137
- };
138
- type AffiliateRollupDelta = {
139
- siteId: Id<"sites">;
140
- affiliateId: Id<"affiliates">;
141
- bucketStart: number;
142
- shard: number;
143
- affiliateSlug: string;
144
- visits: number;
145
- visitorSketch: VisitorSketch;
146
- conversions: number;
147
- revenueCents: number;
148
- commissionCents: number;
149
- };
150
- type VitalRollupDelta = {
151
- siteId: Id<"sites">;
152
- bucketStart: number;
153
- device: string;
154
- page: string;
155
- shard: number;
156
- metrics: Map<
157
- VitalMetric,
158
- { count: number; sum: number; histogram: number[] }
159
- >;
160
- };
161
- type RouteRollupDelta = {
162
- siteId: Id<"sites">;
163
- bucketStart: number;
164
- shard: number;
165
- route: string;
166
- pageviews: number;
167
- entries: number;
168
- continued: number;
169
- dwellMs: number;
170
- dwellSamples: number;
171
- visitorSketch: VisitorSketch;
172
- outgoing: Map<string, number>;
173
- };
174
- type ReportRollupDeltas = {
175
- goals: Map<string, GoalRollupDelta>;
176
- funnels: Map<string, FunnelRollupDelta>;
177
- affiliates: Map<string, AffiliateRollupDelta>;
178
- vitals: Map<string, VitalRollupDelta>;
179
- routes: Map<string, RouteRollupDelta>;
180
- };
181
127
  type AttributionSnapshot = {
182
128
  affiliateId: Id<"affiliates">;
183
129
  affiliateSlug: string;
@@ -189,10 +135,17 @@ type SessionState = {
189
135
  isNewSession: boolean;
190
136
  isNewVisitorHour: boolean;
191
137
  isNewVisitorDay: boolean;
138
+ /** The session is new and its visitor has no earlier session or alias. */
139
+ isNewVisitor: boolean;
140
+ /** How much this event grew the session's duration. */
141
+ durationDeltaMs: number;
142
+ /** This event is the session's second pageview: it did not bounce. */
143
+ continued: boolean;
192
144
  startedAt: number;
193
145
  source: string;
194
146
  visitorKey: string;
195
147
  utmCampaign?: string;
148
+ utmMedium?: string;
196
149
  country?: string;
197
150
  city?: string;
198
151
  latitude?: number;
@@ -203,8 +156,28 @@ type SessionState = {
203
156
  affiliateSlug?: string;
204
157
  affiliateAttributedAt?: number;
205
158
  affiliateExpiresAt?: number;
159
+ /** The session's landing path, for the live row. */
160
+ entryPath?: string;
161
+ /** Conversions the session holds after this event, for the live row. */
162
+ conversionCount?: number;
163
+ /** The session's stored first-visit flag; unknown for older rows. */
164
+ newVisitor?: boolean;
206
165
  };
207
166
 
167
+ /** The furthest step of a funnel an event took its visitor to. */
168
+ type FunnelReach = { funnelId: Id<"funnels">; step: number; steps: number };
169
+
170
+ function furtherReach(
171
+ current: FunnelReach | undefined,
172
+ next: FunnelReach | undefined,
173
+ ): FunnelReach | undefined {
174
+ if (!next) return current;
175
+ if (!current) return next;
176
+ return next.step / next.steps >= current.step / current.steps
177
+ ? next
178
+ : current;
179
+ }
180
+
208
181
  type SessionRateWindowState = {
209
182
  siteId: Id<"sites">;
210
183
  sessionId: string;
@@ -280,6 +253,7 @@ export const ingestBatch = mutation({
280
253
  now,
281
254
  );
282
255
  const context = sanitizeContext(args.context);
256
+ const timezone = localDayTimezone(site.timezone);
283
257
  const aggregateDeltas: AggregateDeltas = new Map();
284
258
  const reportRollupDeltas = createReportRollupDeltas();
285
259
  const sessionRateWindows = new Map<string, SessionRateWindowState>();
@@ -415,7 +389,7 @@ export const ingestBatch = mutation({
415
389
  rejected += 1;
416
390
  continue;
417
391
  }
418
- await accountHeartbeat(
392
+ const durationDeltaMs = await accountHeartbeat(
419
393
  ctx,
420
394
  args.siteId,
421
395
  existingSession,
@@ -424,6 +398,16 @@ export const ingestBatch = mutation({
424
398
  now,
425
399
  batchState,
426
400
  );
401
+ // A heartbeat is presence, not telemetry, but it is what keeps a
402
+ // session's duration honest while the visitor reads: the growth is
403
+ // folded into the buckets the heartbeat landed in.
404
+ foldSessionDuration(
405
+ aggregateDeltas,
406
+ args.siteId,
407
+ event,
408
+ durationDeltaMs,
409
+ timezone,
410
+ );
427
411
  accepted += 1;
428
412
  continue;
429
413
  }
@@ -551,6 +535,7 @@ export const ingestBatch = mutation({
551
535
  aggregateDeltas,
552
536
  reportRollupDeltas,
553
537
  recordTelemetry: true,
538
+ timezone,
554
539
  financialConversion: false,
555
540
  trustedGoalKey: undefined,
556
541
  now,
@@ -567,7 +552,7 @@ export const ingestBatch = mutation({
567
552
  await flushBatchState(ctx, batchState);
568
553
  await flushSessionRateLimits(ctx, sessionRateWindows, now);
569
554
  await flushAggregates(ctx, aggregateDeltas);
570
- await flushReportRollups(ctx, reportRollupDeltas);
555
+ await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
571
556
  return { accepted, duplicates, rejected };
572
557
  },
573
558
  });
@@ -680,6 +665,7 @@ export const trackConversion = mutation({
680
665
  aggregateDeltas,
681
666
  reportRollupDeltas,
682
667
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
668
+ timezone: localDayTimezone(site.timezone),
683
669
  financialConversion: true,
684
670
  trustedGoalKey,
685
671
  now: Date.now(),
@@ -693,8 +679,9 @@ export const trackConversion = mutation({
693
679
  );
694
680
  }
695
681
  await flushAggregates(ctx, aggregateDeltas);
696
- await flushReportRollups(ctx, reportRollupDeltas);
697
- await ensureAnalyticsControl(ctx, args.siteId, Date.now());
682
+ const now = Date.now();
683
+ await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
684
+ await ensureAnalyticsControl(ctx, args.siteId, now);
698
685
  return { accepted: 1, duplicates: 0, rejected: 0 };
699
686
  },
700
687
  });
@@ -815,6 +802,8 @@ async function accountEvent(
815
802
  aggregateDeltas: AggregateDeltas;
816
803
  reportRollupDeltas: ReportRollupDeltas;
817
804
  recordTelemetry: boolean;
805
+ /** The site's timezone when it keeps local-day buckets. */
806
+ timezone?: string;
818
807
  financialConversion: boolean;
819
808
  trustedGoalKey: string | undefined;
820
809
  now: number;
@@ -905,15 +894,19 @@ async function accountEvent(
905
894
  matchedGoalKeys.push(goal.key);
906
895
  }
907
896
 
897
+ let reach: FunnelReach | undefined;
908
898
  for (const funnel of input.funnels) {
909
899
  if (funnel.active) {
910
- await advanceFunnel(
911
- ctx,
912
- input.siteId,
913
- funnel,
914
- event,
915
- input.reportRollupDeltas,
916
- input.batchState,
900
+ reach = furtherReach(
901
+ reach,
902
+ await advanceFunnel(
903
+ ctx,
904
+ input.siteId,
905
+ funnel,
906
+ event,
907
+ input.reportRollupDeltas,
908
+ input.batchState,
909
+ ),
917
910
  );
918
911
  }
919
912
  }
@@ -929,12 +922,26 @@ async function accountEvent(
929
922
  );
930
923
  }
931
924
 
932
- const campaignDimensions: Dimension[] = session.utmCampaign
933
- ? [{ type: "campaign", value: session.utmCampaign }]
934
- : [];
925
+ const campaignDimensions: Dimension[] = [];
926
+ if (session.utmCampaign) {
927
+ campaignDimensions.push({ type: "campaign", value: session.utmCampaign });
928
+ }
929
+ if (session.utmMedium) {
930
+ // A medium is a taxonomy ("social", "cpc", "email"), so its case is noise.
931
+ campaignDimensions.push({
932
+ type: "medium",
933
+ value: session.utmMedium.toLowerCase(),
934
+ });
935
+ }
936
+ // An outbound click ranks by where it went: the host alone, never the path.
937
+ const outboundHost =
938
+ event.type === "outbound" ? publicUrlHost(event.href) : undefined;
935
939
  const dimensions: Dimension[] = [
936
940
  { type: "source", value: session.source },
937
941
  ...campaignDimensions,
942
+ ...(outboundHost
943
+ ? [{ type: "outbound", value: outboundHost } satisfies Dimension]
944
+ : []),
938
945
  { type: "page", value: event.path },
939
946
  { type: "country", value: session.country ?? "unknown" },
940
947
  { type: "device", value: input.context.device ?? "unknown" },
@@ -953,6 +960,7 @@ async function accountEvent(
953
960
  dimensions,
954
961
  input.recordTelemetry,
955
962
  input.financialConversion,
963
+ input.timezone,
956
964
  );
957
965
 
958
966
  if (
@@ -967,6 +975,7 @@ async function accountEvent(
967
975
  session,
968
976
  input.now,
969
977
  input.batchState,
978
+ reach,
970
979
  );
971
980
  }
972
981
  return session;
@@ -990,9 +999,16 @@ async function accountTrustedSession(
990
999
  isNewSession: false,
991
1000
  isNewVisitorHour: false,
992
1001
  isNewVisitorDay: false,
1002
+ isNewVisitor: false,
1003
+ durationDeltaMs: 0,
1004
+ continued: false,
993
1005
  startedAt: existing.startedAt,
994
1006
  source: existing.source,
995
1007
  utmCampaign: existing.utmCampaign,
1008
+ utmMedium: existing.utmMedium,
1009
+ entryPath: existing.entryPath,
1010
+ conversionCount: existing.conversionCount + 1,
1011
+ newVisitor: existing.newVisitor,
996
1012
  visitorKey: existing.visitorKey ?? existing.visitorId,
997
1013
  country: existing.country,
998
1014
  city: existing.city,
@@ -1104,9 +1120,16 @@ async function upsertSession(
1104
1120
  isNewVisitorDay:
1105
1121
  Math.floor(existing.lastSeenAt / DAY_MS) <
1106
1122
  Math.floor(event.timestamp / DAY_MS),
1123
+ isNewVisitor: false,
1124
+ durationDeltaMs: Math.max(0, updates.durationMs - existing.durationMs),
1125
+ continued: event.type === "pageview" && existing.pageviewCount === 1,
1107
1126
  startedAt,
1108
1127
  source: existing.source,
1109
1128
  utmCampaign: existing.utmCampaign,
1129
+ utmMedium: existing.utmMedium,
1130
+ entryPath: existing.entryPath,
1131
+ conversionCount: updates.conversionCount,
1132
+ newVisitor: existing.newVisitor,
1110
1133
  visitorKey,
1111
1134
  country: context.country ?? existing.country,
1112
1135
  city: context.city ?? existing.city,
@@ -1139,6 +1162,19 @@ async function upsertSession(
1139
1162
  }
1140
1163
  }
1141
1164
  }
1165
+ // A visitor is new when nothing was ever recorded under their id. A host
1166
+ // id whose anonymous history was linked at sign-up is a return, not a new
1167
+ // person; a browser without a persistent id is new every session.
1168
+ let isNewVisitor = priorVisit === null;
1169
+ if (isNewVisitor && event.visitorId !== event.sessionId) {
1170
+ const alias = await ctx.db
1171
+ .query("visitorAliases")
1172
+ .withIndex("by_siteId_and_visitorId", (range) =>
1173
+ range.eq("siteId", siteId).eq("visitorId", event.visitorId),
1174
+ )
1175
+ .first();
1176
+ if (alias) isNewVisitor = false;
1177
+ }
1142
1178
  const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
1143
1179
  siteId,
1144
1180
  sessionId: event.sessionId,
@@ -1148,6 +1184,7 @@ async function upsertSession(
1148
1184
  lastSeenAt: event.timestamp,
1149
1185
  entryPath: event.path,
1150
1186
  exitPath: event.path,
1187
+ newVisitor: isNewVisitor,
1151
1188
  ...(event.referrer ? { referrer: event.referrer } : {}),
1152
1189
  source,
1153
1190
  ...(utmSource ? { utmSource } : {}),
@@ -1201,9 +1238,16 @@ async function upsertSession(
1201
1238
  priorVisit === null ||
1202
1239
  Math.floor(priorVisit.lastSeenAt / DAY_MS) <
1203
1240
  Math.floor(event.timestamp / DAY_MS),
1241
+ isNewVisitor,
1242
+ durationDeltaMs: 0,
1243
+ continued: false,
1204
1244
  startedAt: event.timestamp,
1205
1245
  source,
1206
1246
  utmCampaign,
1247
+ utmMedium,
1248
+ entryPath: event.path,
1249
+ conversionCount: Number(financialConversion),
1250
+ newVisitor: isNewVisitor,
1207
1251
  visitorKey,
1208
1252
  country: context.country,
1209
1253
  city: context.city,
@@ -1361,7 +1405,7 @@ async function accountHeartbeat(
1361
1405
  context: IngestContext,
1362
1406
  now: number,
1363
1407
  batchState?: BatchState,
1364
- ) {
1408
+ ): Promise<number> {
1365
1409
  const startedAt = Math.min(session.startedAt, event.timestamp);
1366
1410
  const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
1367
1411
  const updates = {
@@ -1388,6 +1432,7 @@ async function accountHeartbeat(
1388
1432
  } else {
1389
1433
  await ctx.db.patch("sessions", session._id, updates);
1390
1434
  }
1435
+ const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
1391
1436
  await updateLiveSession(
1392
1437
  ctx,
1393
1438
  siteId,
@@ -1397,6 +1442,9 @@ async function accountHeartbeat(
1397
1442
  isNewSession: false,
1398
1443
  isNewVisitorHour: false,
1399
1444
  isNewVisitorDay: false,
1445
+ isNewVisitor: false,
1446
+ durationDeltaMs: 0,
1447
+ continued: false,
1400
1448
  startedAt,
1401
1449
  source: session.source,
1402
1450
  visitorKey: session.visitorKey ?? session.visitorId,
@@ -1407,10 +1455,14 @@ async function accountHeartbeat(
1407
1455
  pageviewCount: session.pageviewCount,
1408
1456
  eventCount: session.eventCount,
1409
1457
  affiliateSlug: session.affiliateSlug,
1458
+ entryPath: session.entryPath,
1459
+ conversionCount: session.conversionCount,
1460
+ newVisitor: session.newVisitor,
1410
1461
  },
1411
1462
  now,
1412
1463
  batchState,
1413
1464
  );
1465
+ return durationDeltaMs;
1414
1466
  }
1415
1467
 
1416
1468
  async function consumeSiteRateLimit(
@@ -1568,6 +1620,26 @@ async function flushSessionRateLimits(
1568
1620
  }
1569
1621
  }
1570
1622
 
1623
+ /**
1624
+ * The intent facts a live row carries for the dashboard's heat score. A
1625
+ * session row from before 0.1.0 has no first-visit flag, so `returning` stays
1626
+ * unknown rather than guessed.
1627
+ */
1628
+ function liveIntentFields(session: SessionState, funnel?: FunnelReach) {
1629
+ return {
1630
+ ...(session.entryPath ? { entryPath: session.entryPath } : {}),
1631
+ ...(session.newVisitor !== undefined
1632
+ ? { returning: !session.newVisitor }
1633
+ : {}),
1634
+ ...(session.utmCampaign ? { utmCampaign: session.utmCampaign } : {}),
1635
+ ...(session.affiliateSlug ? { affiliateSlug: session.affiliateSlug } : {}),
1636
+ ...(session.conversionCount !== undefined
1637
+ ? { conversionCount: session.conversionCount }
1638
+ : {}),
1639
+ ...(funnel ? { funnel } : {}),
1640
+ };
1641
+ }
1642
+
1571
1643
  async function updateLiveSession(
1572
1644
  ctx: MutationCtx,
1573
1645
  siteId: Id<"sites">,
@@ -1576,6 +1648,7 @@ async function updateLiveSession(
1576
1648
  session: SessionState,
1577
1649
  now: number,
1578
1650
  batchState?: BatchState,
1651
+ reach?: FunnelReach,
1579
1652
  ) {
1580
1653
  await ensureLiveSweep(ctx, siteId, now, batchState);
1581
1654
  const liveState = batchState
@@ -1620,6 +1693,7 @@ async function updateLiveSession(
1620
1693
  device: context.device ?? existing.device,
1621
1694
  pageviewCount: session.pageviewCount,
1622
1695
  eventCount: session.eventCount,
1696
+ ...liveIntentFields(session, furtherReach(existing.funnel, reach)),
1623
1697
  // The live map animates a visitor from the route they left to the one
1624
1698
  // they reached; a heartbeat or click on the same page is not a move.
1625
1699
  ...(event.type === "pageview" && event.path !== existing.path
@@ -1659,6 +1733,7 @@ async function updateLiveSession(
1659
1733
  device: context.device ?? "unknown",
1660
1734
  pageviewCount: session.pageviewCount,
1661
1735
  eventCount: session.eventCount,
1736
+ ...liveIntentFields(session, reach),
1662
1737
  ...(event.type === "heartbeat"
1663
1738
  ? {
1664
1739
  lastHeartbeatEventId: event.eventId,
@@ -1716,7 +1791,7 @@ async function advanceFunnel(
1716
1791
  event: TrackerEvent,
1717
1792
  reportRollupDeltas: ReportRollupDeltas,
1718
1793
  batchState?: BatchState,
1719
- ) {
1794
+ ): Promise<FunnelReach | undefined> {
1720
1795
  const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
1721
1796
  let progressState = batchState?.funnelProgress.get(cacheKey);
1722
1797
  if (batchState && !progressState) {
@@ -1762,9 +1837,16 @@ async function advanceFunnel(
1762
1837
  _id: progressId,
1763
1838
  };
1764
1839
  }
1765
- await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1840
+ foldFunnelStepDelta(
1841
+ reportRollupDeltas,
1842
+ siteId,
1843
+ funnel._id,
1844
+ event,
1845
+ 0,
1846
+ false,
1847
+ );
1766
1848
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1767
- return;
1849
+ return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1768
1850
  }
1769
1851
  if (
1770
1852
  progress.completedAt !== undefined ||
@@ -1793,9 +1875,16 @@ async function advanceFunnel(
1793
1875
  completedAt: undefined,
1794
1876
  });
1795
1877
  }
1796
- await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1878
+ foldFunnelStepDelta(
1879
+ reportRollupDeltas,
1880
+ siteId,
1881
+ funnel._id,
1882
+ event,
1883
+ 0,
1884
+ false,
1885
+ );
1797
1886
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1798
- return;
1887
+ return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1799
1888
  }
1800
1889
  const nextStep = funnel.steps[progress.currentStep];
1801
1890
  if (!nextStep || !stepMatches(nextStep, event)) return;
@@ -1813,8 +1902,8 @@ async function advanceFunnel(
1813
1902
  } else {
1814
1903
  await ctx.db.patch("funnelProgress", progress._id, updates);
1815
1904
  }
1816
- await incrementFunnelStep(
1817
- ctx,
1905
+ foldFunnelStepDelta(
1906
+ reportRollupDeltas,
1818
1907
  siteId,
1819
1908
  funnel._id,
1820
1909
  event,
@@ -1829,6 +1918,11 @@ async function advanceFunnel(
1829
1918
  progress.currentStep,
1830
1919
  completed,
1831
1920
  );
1921
+ return {
1922
+ funnelId: funnel._id,
1923
+ step: currentStep,
1924
+ steps: funnel.steps.length,
1925
+ };
1832
1926
  }
1833
1927
 
1834
1928
  function stepMatches(
@@ -1840,47 +1934,6 @@ function stepMatches(
1840
1934
  : event.type === "custom" && event.name === step.value;
1841
1935
  }
1842
1936
 
1843
- async function incrementFunnelStep(
1844
- ctx: MutationCtx,
1845
- siteId: Id<"sites">,
1846
- funnelId: Id<"funnels">,
1847
- event: TrackerEvent,
1848
- step: number,
1849
- completed: boolean,
1850
- ) {
1851
- const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1852
- const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
1853
- const bucket = await ctx.db
1854
- .query("funnelStepBuckets")
1855
- .withIndex(
1856
- "by_siteId_and_funnelId_and_bucketStart_and_step_and_shard",
1857
- (range) =>
1858
- range
1859
- .eq("siteId", siteId)
1860
- .eq("funnelId", funnelId)
1861
- .eq("bucketStart", bucketStart)
1862
- .eq("step", step)
1863
- .eq("shard", shard),
1864
- )
1865
- .unique();
1866
- if (bucket) {
1867
- await ctx.db.patch("funnelStepBuckets", bucket._id, {
1868
- entrants: bucket.entrants + 1,
1869
- completions: bucket.completions + (completed ? 1 : 0),
1870
- });
1871
- } else {
1872
- await ctx.db.insert("funnelStepBuckets", {
1873
- siteId,
1874
- funnelId,
1875
- bucketStart,
1876
- step,
1877
- shard,
1878
- entrants: 1,
1879
- completions: completed ? 1 : 0,
1880
- });
1881
- }
1882
- }
1883
-
1884
1937
  async function accountConversion(
1885
1938
  ctx: MutationCtx,
1886
1939
  siteId: Id<"sites">,
@@ -1963,31 +2016,6 @@ async function accountConversion(
1963
2016
  }
1964
2017
  }
1965
2018
 
1966
- function createReportRollupDeltas(): ReportRollupDeltas {
1967
- return {
1968
- goals: new Map(),
1969
- funnels: new Map(),
1970
- affiliates: new Map(),
1971
- vitals: new Map(),
1972
- routes: new Map(),
1973
- };
1974
- }
1975
-
1976
- function reportRollupCoordinates(
1977
- siteId: Id<"sites">,
1978
- definitionId: string,
1979
- timestamp: number,
1980
- visitorId: string,
1981
- ) {
1982
- const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
1983
- const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
1984
- return {
1985
- bucketStart,
1986
- shard,
1987
- key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
1988
- };
1989
- }
1990
-
1991
2019
  function foldGoalRollup(
1992
2020
  deltas: ReportRollupDeltas,
1993
2021
  siteId: Id<"sites">,
@@ -2046,6 +2074,31 @@ function foldFunnelRollup(
2046
2074
  deltas.funnels.set(coordinates.key, rollup);
2047
2075
  }
2048
2076
 
2077
+ function foldFunnelStepDelta(
2078
+ deltas: ReportRollupDeltas,
2079
+ siteId: Id<"sites">,
2080
+ funnelId: Id<"funnels">,
2081
+ event: TrackerEvent,
2082
+ step: number,
2083
+ completed: boolean,
2084
+ ) {
2085
+ const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
2086
+ const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
2087
+ const key = `${siteId}:${funnelId}:${bucketStart}:${step}:${shard}`;
2088
+ const current = deltas.funnelSteps.get(key) ?? {
2089
+ siteId,
2090
+ funnelId,
2091
+ bucketStart,
2092
+ step,
2093
+ shard,
2094
+ entrants: 0,
2095
+ completions: 0,
2096
+ };
2097
+ current.entrants += 1;
2098
+ current.completions += Number(completed);
2099
+ deltas.funnelSteps.set(key, current);
2100
+ }
2101
+
2049
2102
  function foldAffiliateVisitRollup(
2050
2103
  deltas: ReportRollupDeltas,
2051
2104
  siteId: Id<"sites">,
@@ -2066,13 +2119,13 @@ function foldAffiliateVisitRollup(
2066
2119
  shard: coordinates.shard,
2067
2120
  affiliateSlug: attribution.affiliateSlug,
2068
2121
  visits: 0,
2069
- visitorSketch: createVisitorSketch(),
2122
+ visitorKeys: new Set<string>(),
2070
2123
  conversions: 0,
2071
2124
  revenueCents: 0,
2072
2125
  commissionCents: 0,
2073
2126
  };
2074
2127
  current.visits += 1;
2075
- current.visitorSketch = addVisitor(current.visitorSketch, visitorKey);
2128
+ current.visitorKeys.add(visitorKey);
2076
2129
  deltas.affiliates.set(coordinates.key, current);
2077
2130
  }
2078
2131
 
@@ -2098,7 +2151,7 @@ function foldAffiliateConversionRollup(
2098
2151
  shard: coordinates.shard,
2099
2152
  affiliateSlug,
2100
2153
  visits: 0,
2101
- visitorSketch: createVisitorSketch(),
2154
+ visitorKeys: new Set<string>(),
2102
2155
  conversions: 0,
2103
2156
  revenueCents: 0,
2104
2157
  commissionCents: 0,
@@ -2180,7 +2233,7 @@ function foldRouteDelta(
2180
2233
  continued: 0,
2181
2234
  dwellMs: 0,
2182
2235
  dwellSamples: 0,
2183
- visitorSketch: createVisitorSketch(),
2236
+ visitorKeys: new Set<string>(),
2184
2237
  outgoing: new Map<string, number>(),
2185
2238
  };
2186
2239
  apply(current);
@@ -2213,7 +2266,7 @@ function foldRouteRollups(
2213
2266
  (delta) => {
2214
2267
  delta.pageviews += 1;
2215
2268
  if (firstPageview) delta.entries += 1;
2216
- delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
2269
+ delta.visitorKeys.add(session.visitorKey);
2217
2270
  },
2218
2271
  );
2219
2272
  // Out-of-order telemetry keeps its view but cannot say where it came from.
@@ -2249,272 +2302,6 @@ function foldRouteRollups(
2249
2302
  }
2250
2303
  }
2251
2304
 
2252
- async function upsertRouteRollup(
2253
- ctx: MutationCtx,
2254
- delta: RouteRollupDelta,
2255
- dayRowCounts: Map<string, number>,
2256
- ) {
2257
- const existing = await ctx.db
2258
- .query("routeDailyRollups")
2259
- .withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) =>
2260
- range
2261
- .eq("siteId", delta.siteId)
2262
- .eq("route", delta.route)
2263
- .eq("bucketStart", delta.bucketStart)
2264
- .eq("shard", delta.shard),
2265
- )
2266
- .unique();
2267
- if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
2268
- // A new route row spends one bounded read per batch to respect the daily
2269
- // cap; past it, the day's remaining routes fold into "(other)".
2270
- const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
2271
- const dayKey = `${delta.siteId}:${delta.bucketStart}`;
2272
- let dayRows = dayRowCounts.get(dayKey);
2273
- if (dayRows === undefined) {
2274
- dayRows = (
2275
- await ctx.db
2276
- .query("routeDailyRollups")
2277
- .withIndex("by_siteId_and_bucketStart", (range) =>
2278
- range
2279
- .eq("siteId", delta.siteId)
2280
- .eq("bucketStart", delta.bucketStart),
2281
- )
2282
- .take(capRows + 1)
2283
- ).length;
2284
- }
2285
- if (dayRows >= capRows) {
2286
- await upsertRouteRollup(
2287
- ctx,
2288
- { ...delta, route: SITE_MAP_OTHER_ROUTE },
2289
- dayRowCounts,
2290
- );
2291
- return;
2292
- }
2293
- dayRowCounts.set(dayKey, dayRows + 1);
2294
- }
2295
- const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
2296
- for (const [target, count] of delta.outgoing) {
2297
- let entry = outgoing.find((candidate) => candidate.route === target);
2298
- if (!entry) {
2299
- const named = outgoing.filter(
2300
- (candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE,
2301
- ).length;
2302
- const route =
2303
- target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
2304
- ? SITE_MAP_OTHER_ROUTE
2305
- : target;
2306
- entry = outgoing.find((candidate) => candidate.route === route);
2307
- if (!entry) {
2308
- entry = { route, count: 0 };
2309
- outgoing.push(entry);
2310
- }
2311
- }
2312
- entry.count += count;
2313
- }
2314
- const fields = {
2315
- siteId: delta.siteId,
2316
- bucketStart: delta.bucketStart,
2317
- shard: delta.shard,
2318
- generation: 1 as const,
2319
- route: delta.route,
2320
- pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
2321
- entries: (existing?.entries ?? 0) + delta.entries,
2322
- continued: (existing?.continued ?? 0) + delta.continued,
2323
- dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
2324
- dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
2325
- visitorSketch: unionVisitorSketches(
2326
- existing?.visitorSketch ?? createVisitorSketch(),
2327
- delta.visitorSketch,
2328
- ),
2329
- outgoing,
2330
- };
2331
- if (existing) {
2332
- await ctx.db.replace("routeDailyRollups", existing._id, fields);
2333
- } else {
2334
- await ctx.db.insert("routeDailyRollups", fields);
2335
- }
2336
- }
2337
-
2338
- async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
2339
- const existing = await ctx.db
2340
- .query("vitalRollups")
2341
- .withIndex("by_key", (range) =>
2342
- range
2343
- .eq("siteId", delta.siteId)
2344
- .eq("bucketStart", delta.bucketStart)
2345
- .eq("device", delta.device)
2346
- .eq("page", delta.page)
2347
- .eq("shard", delta.shard),
2348
- )
2349
- .unique();
2350
- if (
2351
- !existing &&
2352
- delta.device === VITAL_ALL &&
2353
- delta.page !== VITAL_OTHER_PAGES
2354
- ) {
2355
- // A new page row spends one bounded read to respect the daily page slots;
2356
- // past the cap, the day's remaining pages fold into "(other)".
2357
- const pageRows = await ctx.db
2358
- .query("vitalRollups")
2359
- .withIndex("by_key", (range) =>
2360
- range
2361
- .eq("siteId", delta.siteId)
2362
- .eq("bucketStart", delta.bucketStart)
2363
- .eq("device", VITAL_ALL),
2364
- )
2365
- .take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
2366
- if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
2367
- await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
2368
- return;
2369
- }
2370
- }
2371
- const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
2372
- for (const [metric, addition] of delta.metrics) {
2373
- const entry = metrics.find((candidate) => candidate.metric === metric);
2374
- if (entry) {
2375
- entry.count += addition.count;
2376
- entry.sum += addition.sum;
2377
- entry.histogram = mergeVitalHistograms(
2378
- entry.histogram,
2379
- addition.histogram,
2380
- );
2381
- } else {
2382
- metrics.push({
2383
- metric,
2384
- count: addition.count,
2385
- sum: addition.sum,
2386
- histogram: [...addition.histogram],
2387
- });
2388
- }
2389
- }
2390
- const fields = {
2391
- siteId: delta.siteId,
2392
- bucketStart: delta.bucketStart,
2393
- device: delta.device,
2394
- page: delta.page,
2395
- shard: delta.shard,
2396
- metrics,
2397
- };
2398
- if (existing) {
2399
- await ctx.db.replace("vitalRollups", existing._id, fields);
2400
- } else {
2401
- await ctx.db.insert("vitalRollups", fields);
2402
- }
2403
- }
2404
-
2405
- async function flushReportRollups(
2406
- ctx: MutationCtx,
2407
- deltas: ReportRollupDeltas,
2408
- ) {
2409
- for (const delta of deltas.goals.values()) {
2410
- const existing = await ctx.db
2411
- .query("goalDailyRollups")
2412
- .withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) =>
2413
- range
2414
- .eq("siteId", delta.siteId)
2415
- .eq("goalId", delta.goalId)
2416
- .eq("bucketStart", delta.bucketStart)
2417
- .eq("shard", delta.shard),
2418
- )
2419
- .unique();
2420
- const fields = {
2421
- siteId: delta.siteId,
2422
- goalId: delta.goalId,
2423
- bucketStart: delta.bucketStart,
2424
- shard: delta.shard,
2425
- generation: 1 as const,
2426
- goalKey: delta.goalKey,
2427
- completions: (existing?.completions ?? 0) + delta.completions,
2428
- valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
2429
- };
2430
- if (existing) {
2431
- await ctx.db.replace("goalDailyRollups", existing._id, fields);
2432
- } else {
2433
- await ctx.db.insert("goalDailyRollups", fields);
2434
- }
2435
- }
2436
- for (const delta of deltas.funnels.values()) {
2437
- const existing = await ctx.db
2438
- .query("funnelDailyRollups")
2439
- .withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) =>
2440
- range
2441
- .eq("siteId", delta.siteId)
2442
- .eq("funnelId", delta.funnelId)
2443
- .eq("bucketStart", delta.bucketStart)
2444
- .eq("shard", delta.shard),
2445
- )
2446
- .unique();
2447
- const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
2448
- for (const addition of delta.steps.values()) {
2449
- const current = steps.find((step) => step.step === addition.step);
2450
- if (current) {
2451
- current.entrants += addition.entrants;
2452
- current.completions += addition.completions;
2453
- } else {
2454
- if (steps.length >= MAX_FUNNEL_STEPS) {
2455
- fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
2456
- }
2457
- steps.push(addition);
2458
- }
2459
- }
2460
- const fields = {
2461
- siteId: delta.siteId,
2462
- funnelId: delta.funnelId,
2463
- bucketStart: delta.bucketStart,
2464
- shard: delta.shard,
2465
- generation: 1 as const,
2466
- steps,
2467
- };
2468
- if (existing) {
2469
- await ctx.db.replace("funnelDailyRollups", existing._id, fields);
2470
- } else {
2471
- await ctx.db.insert("funnelDailyRollups", fields);
2472
- }
2473
- }
2474
- for (const delta of deltas.affiliates.values()) {
2475
- const existing = await ctx.db
2476
- .query("affiliateDailyRollups")
2477
- .withIndex(
2478
- "by_siteId_and_affiliateId_and_bucketStart_and_shard",
2479
- (range) =>
2480
- range
2481
- .eq("siteId", delta.siteId)
2482
- .eq("affiliateId", delta.affiliateId)
2483
- .eq("bucketStart", delta.bucketStart)
2484
- .eq("shard", delta.shard),
2485
- )
2486
- .unique();
2487
- const fields = {
2488
- siteId: delta.siteId,
2489
- affiliateId: delta.affiliateId,
2490
- bucketStart: delta.bucketStart,
2491
- shard: delta.shard,
2492
- generation: 1 as const,
2493
- affiliateSlug: delta.affiliateSlug,
2494
- visits: (existing?.visits ?? 0) + delta.visits,
2495
- visitorSketch: unionVisitorSketches(
2496
- existing?.visitorSketch ?? createVisitorSketch(),
2497
- delta.visitorSketch,
2498
- ),
2499
- conversions: (existing?.conversions ?? 0) + delta.conversions,
2500
- revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
2501
- commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
2502
- };
2503
- if (existing) {
2504
- await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
2505
- } else {
2506
- await ctx.db.insert("affiliateDailyRollups", fields);
2507
- }
2508
- }
2509
- const routeDayRowCounts = new Map<string, number>();
2510
- for (const delta of deltas.routes.values()) {
2511
- await upsertRouteRollup(ctx, delta, routeDayRowCounts);
2512
- }
2513
- for (const delta of deltas.vitals.values()) {
2514
- await upsertVitalRollup(ctx, delta);
2515
- }
2516
- }
2517
-
2518
2305
  function foldAggregates(
2519
2306
  aggregateDeltas: AggregateDeltas,
2520
2307
  siteId: Id<"sites">,
@@ -2523,19 +2310,25 @@ function foldAggregates(
2523
2310
  dimensions: Dimension[],
2524
2311
  recordTelemetry: boolean,
2525
2312
  financialConversion: boolean,
2313
+ timezone?: string,
2526
2314
  ) {
2527
2315
  const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
2528
2316
  const dimensionDeltas = dimensions.map((dimension): DimensionDelta => ({
2529
2317
  ...dimension,
2530
2318
  count: Number(recordTelemetry),
2531
2319
  revenueCents,
2320
+ pageviews: Number(recordTelemetry && event.type === "pageview"),
2321
+ sessions: Number(recordTelemetry && session.isNewSession),
2532
2322
  }));
2533
- for (const granularity of ["hour", "day"] as const) {
2323
+ for (const granularity of aggregateGranularities(timezone)) {
2534
2324
  const shard =
2535
2325
  stableHash(event.sessionId) %
2536
2326
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
2537
- const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
2538
- const bucketStart = Math.floor(event.timestamp / interval) * interval;
2327
+ const bucketStart = aggregateBucketStart(
2328
+ granularity,
2329
+ event.timestamp,
2330
+ timezone,
2331
+ );
2539
2332
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
2540
2333
  let delta = aggregateDeltas.get(key);
2541
2334
  if (!delta) {
@@ -2555,6 +2348,9 @@ function foldAggregates(
2555
2348
  conversions: 0,
2556
2349
  revenueCents: 0,
2557
2350
  dimensions: [],
2351
+ durationMs: 0,
2352
+ continuedSessions: 0,
2353
+ newVisitors: 0,
2558
2354
  };
2559
2355
  aggregateDeltas.set(key, delta);
2560
2356
  }
@@ -2578,6 +2374,77 @@ function foldAggregates(
2578
2374
  delta.conversions += Number(financialConversion);
2579
2375
  delta.revenueCents += revenueCents;
2580
2376
  delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
2377
+ if (recordTelemetry) {
2378
+ delta.durationMs += session.durationDeltaMs;
2379
+ delta.continuedSessions += Number(session.continued);
2380
+ delta.newVisitors += Number(session.isNewSession && session.isNewVisitor);
2381
+ }
2382
+ }
2383
+ }
2384
+
2385
+ /**
2386
+ * The buckets an event lands in: UTC hour and UTC day always, plus the
2387
+ * calendar day in the site's timezone when the site keeps one.
2388
+ */
2389
+ function aggregateGranularities(
2390
+ timezone: string | undefined,
2391
+ ): ReadonlyArray<"hour" | "day" | "localDay"> {
2392
+ return timezone ? ["hour", "day", "localDay"] : ["hour", "day"];
2393
+ }
2394
+
2395
+ function aggregateBucketStart(
2396
+ granularity: "hour" | "day" | "localDay",
2397
+ timestamp: number,
2398
+ timezone: string | undefined,
2399
+ ): number {
2400
+ if (granularity === "localDay" && timezone) {
2401
+ return localDayStart(timestamp, timezone);
2402
+ }
2403
+ const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
2404
+ return Math.floor(timestamp / interval) * interval;
2405
+ }
2406
+
2407
+ /** Folds only session duration growth, for heartbeats. */
2408
+ function foldSessionDuration(
2409
+ aggregateDeltas: AggregateDeltas,
2410
+ siteId: Id<"sites">,
2411
+ event: TrackerEvent,
2412
+ durationDeltaMs: number,
2413
+ timezone?: string,
2414
+ ) {
2415
+ if (durationDeltaMs <= 0) return;
2416
+ for (const granularity of aggregateGranularities(timezone)) {
2417
+ const shard =
2418
+ stableHash(event.sessionId) %
2419
+ (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
2420
+ const bucketStart = aggregateBucketStart(
2421
+ granularity,
2422
+ event.timestamp,
2423
+ timezone,
2424
+ );
2425
+ const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
2426
+ const delta = aggregateDeltas.get(key) ?? {
2427
+ siteId,
2428
+ granularity,
2429
+ bucketStart,
2430
+ shard,
2431
+ events: 0,
2432
+ pageviews: 0,
2433
+ clicks: 0,
2434
+ customEvents: 0,
2435
+ outboundClicks: 0,
2436
+ sessions: 0,
2437
+ visitors: 0,
2438
+ visitorSketch: createVisitorSketch(),
2439
+ conversions: 0,
2440
+ revenueCents: 0,
2441
+ dimensions: [],
2442
+ durationMs: 0,
2443
+ continuedSessions: 0,
2444
+ newVisitors: 0,
2445
+ };
2446
+ delta.durationMs += durationDeltaMs;
2447
+ aggregateDeltas.set(key, delta);
2581
2448
  }
2582
2449
  }
2583
2450
 
@@ -2600,7 +2467,11 @@ async function flushAggregates(
2600
2467
  .unique();
2601
2468
  const dimensions = mergeDimensions(
2602
2469
  bucket?.dimensions ?? [],
2603
- delta.dimensions,
2470
+ delta.dimensions.map((slot) => ({
2471
+ ...slot,
2472
+ pageviews: slot.pageviews ?? 0,
2473
+ sessions: slot.sessions ?? 0,
2474
+ })),
2604
2475
  );
2605
2476
  const priorSketch = bucket?.visitorSketch;
2606
2477
  const visitorSketch = priorSketch
@@ -2623,6 +2494,10 @@ async function flushAggregates(
2623
2494
  conversions: bucket.conversions + delta.conversions,
2624
2495
  revenueCents: bucket.revenueCents + delta.revenueCents,
2625
2496
  dimensions,
2497
+ durationMs: (bucket.durationMs ?? 0) + delta.durationMs,
2498
+ continuedSessions:
2499
+ (bucket.continuedSessions ?? 0) + delta.continuedSessions,
2500
+ newVisitors: (bucket.newVisitors ?? 0) + delta.newVisitors,
2626
2501
  });
2627
2502
  } else {
2628
2503
  await ctx.db.insert("aggregateBuckets", {
@@ -2642,6 +2517,9 @@ async function flushAggregates(
2642
2517
  conversions: delta.conversions,
2643
2518
  revenueCents: delta.revenueCents,
2644
2519
  dimensions,
2520
+ durationMs: delta.durationMs,
2521
+ continuedSessions: delta.continuedSessions,
2522
+ newVisitors: delta.newVisitors,
2645
2523
  });
2646
2524
  }
2647
2525
  }
@@ -2660,6 +2538,8 @@ function mergeDimensions(
2660
2538
  if (exact) {
2661
2539
  exact.count += addition.count;
2662
2540
  exact.revenueCents += addition.revenueCents;
2541
+ exact.pageviews = (exact.pageviews ?? 0) + addition.pageviews;
2542
+ exact.sessions = (exact.sessions ?? 0) + addition.sessions;
2663
2543
  continue;
2664
2544
  }
2665
2545
 
@@ -2677,6 +2557,8 @@ function mergeDimensions(
2677
2557
  value: addition.value,
2678
2558
  count: addition.count,
2679
2559
  revenueCents: addition.revenueCents,
2560
+ pageviews: addition.pageviews,
2561
+ sessions: addition.sessions,
2680
2562
  });
2681
2563
  continue;
2682
2564
  }
@@ -2686,6 +2568,8 @@ function mergeDimensions(
2686
2568
  if (overflow) {
2687
2569
  overflow.count += addition.count;
2688
2570
  overflow.revenueCents += addition.revenueCents;
2571
+ overflow.pageviews = (overflow.pageviews ?? 0) + addition.pageviews;
2572
+ overflow.sessions = (overflow.sessions ?? 0) + addition.sessions;
2689
2573
  continue;
2690
2574
  }
2691
2575
  if (addition.count === 0 && addition.revenueCents === 0) continue;
@@ -2699,6 +2583,8 @@ function mergeDimensions(
2699
2583
  value: "(other)",
2700
2584
  count: addition.count,
2701
2585
  revenueCents: addition.revenueCents,
2586
+ pageviews: addition.pageviews,
2587
+ sessions: addition.sessions,
2702
2588
  });
2703
2589
  continue;
2704
2590
  }
@@ -2710,6 +2596,8 @@ function mergeDimensions(
2710
2596
  replacement.value = "(other)";
2711
2597
  replacement.count += addition.count;
2712
2598
  replacement.revenueCents += addition.revenueCents;
2599
+ replacement.pageviews = (replacement.pageviews ?? 0) + addition.pageviews;
2600
+ replacement.sessions = (replacement.sessions ?? 0) + addition.sessions;
2713
2601
  }
2714
2602
  return result;
2715
2603
  }