@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
@@ -10,6 +10,7 @@ import {
10
10
  HOURLY_AGGREGATE_SHARDS,
11
11
  DIMENSION_SLOTS,
12
12
  HOUR_MS,
13
+ LIVE_LEAVE_GRACE_MS,
13
14
  LIVE_SESSION_TTL_MS,
14
15
  LIVE_SWEEP_INTERVAL_MS,
15
16
  MAX_BATCH_EVENTS,
@@ -18,16 +19,11 @@ import {
18
19
  MAX_BYTES_PER_SESSION_WINDOW,
19
20
  MAX_EVENTS_PER_SESSION_WINDOW,
20
21
  MAX_FUNNELS_PER_SITE,
21
- MAX_FUNNEL_STEPS,
22
22
  MAX_GOALS_PER_SITE,
23
- MAX_SITE_MAP_ROUTES_PER_DAY,
24
23
  RATE_LIMIT_WINDOW_MS,
25
24
  REPORT_ROLLUP_SHARDS,
26
- ROUTE_TRANSITION_SLOTS,
27
25
  SECONDARY_AGGREGATE_SHARDS,
28
26
  SITE_INGEST_SHARDS,
29
- SITE_MAP_OTHER_ROUTE,
30
- VITAL_PAGE_SLOTS,
31
27
  } from "./constants.js";
32
28
  import {
33
29
  addVisitor,
@@ -38,10 +34,8 @@ import {
38
34
  import {
39
35
  addToVitalHistogram,
40
36
  emptyVitalHistogram,
41
- mergeVitalHistograms,
42
37
  vitalDevice,
43
38
  VITAL_ALL,
44
- VITAL_OTHER_PAGES,
45
39
  type VitalMetric,
46
40
  } from "./vitals.js";
47
41
  import { fail } from "./errors.js";
@@ -62,6 +56,7 @@ import {
62
56
  measureBatchBytes,
63
57
  normalizeRoute,
64
58
  originAllowed,
59
+ publicUrlHost,
65
60
  sanitizeContext,
66
61
  sanitizeCurrency,
67
62
  sanitizeEvent,
@@ -80,6 +75,14 @@ import {
80
75
  type IngestContext,
81
76
  type TrackerEvent,
82
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";
83
86
 
84
87
  type DimensionType =
85
88
  | "source"
@@ -89,13 +92,20 @@ type DimensionType =
89
92
  | "device"
90
93
  | "browser"
91
94
  | "event"
92
- | "goal";
95
+ | "goal"
96
+ | "medium"
97
+ | "outbound";
93
98
 
94
99
  type Dimension = { type: DimensionType; value: string };
95
- type DimensionDelta = Dimension & { count: number; revenueCents: number };
100
+ type DimensionDelta = Dimension & {
101
+ count: number;
102
+ revenueCents: number;
103
+ pageviews: number;
104
+ sessions: number;
105
+ };
96
106
  type AggregateDelta = {
97
107
  siteId: Id<"sites">;
98
- granularity: "hour" | "day";
108
+ granularity: "hour" | "day" | "localDay";
99
109
  bucketStart: number;
100
110
  shard: number;
101
111
  events: number;
@@ -109,74 +119,11 @@ type AggregateDelta = {
109
119
  conversions: number;
110
120
  revenueCents: number;
111
121
  dimensions: Doc<"aggregateBuckets">["dimensions"];
122
+ durationMs: number;
123
+ continuedSessions: number;
124
+ newVisitors: number;
112
125
  };
113
126
  type AggregateDeltas = Map<string, AggregateDelta>;
114
- type GoalRollupDelta = {
115
- siteId: Id<"sites">;
116
- goalId: Id<"goals">;
117
- bucketStart: number;
118
- shard: number;
119
- goalKey: string;
120
- completions: number;
121
- valueCents: number;
122
- };
123
- type FunnelRollupDelta = {
124
- siteId: Id<"sites">;
125
- funnelId: Id<"funnels">;
126
- bucketStart: number;
127
- shard: number;
128
- steps: Map<
129
- number,
130
- {
131
- step: number;
132
- entrants: number;
133
- completions: number;
134
- }
135
- >;
136
- };
137
- type AffiliateRollupDelta = {
138
- siteId: Id<"sites">;
139
- affiliateId: Id<"affiliates">;
140
- bucketStart: number;
141
- shard: number;
142
- affiliateSlug: string;
143
- visits: number;
144
- visitorSketch: VisitorSketch;
145
- conversions: number;
146
- revenueCents: number;
147
- commissionCents: number;
148
- };
149
- type VitalRollupDelta = {
150
- siteId: Id<"sites">;
151
- bucketStart: number;
152
- device: string;
153
- page: string;
154
- shard: number;
155
- metrics: Map<
156
- VitalMetric,
157
- { count: number; sum: number; histogram: number[] }
158
- >;
159
- };
160
- type RouteRollupDelta = {
161
- siteId: Id<"sites">;
162
- bucketStart: number;
163
- shard: number;
164
- route: string;
165
- pageviews: number;
166
- entries: number;
167
- continued: number;
168
- dwellMs: number;
169
- dwellSamples: number;
170
- visitorSketch: VisitorSketch;
171
- outgoing: Map<string, number>;
172
- };
173
- type ReportRollupDeltas = {
174
- goals: Map<string, GoalRollupDelta>;
175
- funnels: Map<string, FunnelRollupDelta>;
176
- affiliates: Map<string, AffiliateRollupDelta>;
177
- vitals: Map<string, VitalRollupDelta>;
178
- routes: Map<string, RouteRollupDelta>;
179
- };
180
127
  type AttributionSnapshot = {
181
128
  affiliateId: Id<"affiliates">;
182
129
  affiliateSlug: string;
@@ -188,10 +135,17 @@ type SessionState = {
188
135
  isNewSession: boolean;
189
136
  isNewVisitorHour: boolean;
190
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;
191
144
  startedAt: number;
192
145
  source: string;
193
146
  visitorKey: string;
194
147
  utmCampaign?: string;
148
+ utmMedium?: string;
195
149
  country?: string;
196
150
  city?: string;
197
151
  latitude?: number;
@@ -202,8 +156,28 @@ type SessionState = {
202
156
  affiliateSlug?: string;
203
157
  affiliateAttributedAt?: number;
204
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;
205
165
  };
206
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
+
207
181
  type SessionRateWindowState = {
208
182
  siteId: Id<"sites">;
209
183
  sessionId: string;
@@ -279,6 +253,7 @@ export const ingestBatch = mutation({
279
253
  now,
280
254
  );
281
255
  const context = sanitizeContext(args.context);
256
+ const timezone = localDayTimezone(site.timezone);
282
257
  const aggregateDeltas: AggregateDeltas = new Map();
283
258
  const reportRollupDeltas = createReportRollupDeltas();
284
259
  const sessionRateWindows = new Map<string, SessionRateWindowState>();
@@ -315,7 +290,12 @@ export const ingestBatch = mutation({
315
290
  const bucketStart = eventBucketStart(event.timestamp);
316
291
  const groupKey = eventBatchKey(event.sessionId, bucketStart);
317
292
  requestGroups.add(groupKey);
318
- if (event.type === "heartbeat" || event.type === "vital") continue;
293
+ if (
294
+ event.type === "heartbeat" ||
295
+ event.type === "vital" ||
296
+ event.type === "leave"
297
+ )
298
+ continue;
319
299
  const eventIds = eventIdsByGroup.get(groupKey) ?? new Set<string>();
320
300
  if (eventIds.has(event.eventId)) {
321
301
  duplicateInBatch.add(eventIndex);
@@ -409,7 +389,7 @@ export const ingestBatch = mutation({
409
389
  rejected += 1;
410
390
  continue;
411
391
  }
412
- await accountHeartbeat(
392
+ const durationDeltaMs = await accountHeartbeat(
413
393
  ctx,
414
394
  args.siteId,
415
395
  existingSession,
@@ -418,6 +398,60 @@ export const ingestBatch = mutation({
418
398
  now,
419
399
  batchState,
420
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
+ );
411
+ accepted += 1;
412
+ continue;
413
+ }
414
+
415
+ if (event.type === "leave") {
416
+ // A departure only shortens live presence: it stores no telemetry,
417
+ // touches no aggregates, and never extends anything, so a replayed
418
+ // or late leave is inert. The read side filters by expiresAt and the
419
+ // sweep deletes the row, so the dashboard sees the exit in seconds.
420
+ if (!existingSession) {
421
+ rejected += 1;
422
+ continue;
423
+ }
424
+ if (
425
+ !(await consumeRateLimit(
426
+ ctx,
427
+ args.siteId,
428
+ event.sessionId,
429
+ eventByteCounts[eventIndex],
430
+ now,
431
+ sessionRateWindows,
432
+ ))
433
+ ) {
434
+ rejected += 1;
435
+ continue;
436
+ }
437
+ const liveState = await loadLiveSession(
438
+ ctx,
439
+ args.siteId,
440
+ event.sessionId,
441
+ batchState,
442
+ );
443
+ const liveSession = liveState.current;
444
+ if (liveSession) {
445
+ const expiresAt = Math.min(
446
+ liveSession.expiresAt,
447
+ now + LIVE_LEAVE_GRACE_MS,
448
+ );
449
+ if (expiresAt < liveSession.expiresAt) {
450
+ await ctx.db.patch("liveSessions", liveSession._id, { expiresAt });
451
+ liveState.current = { ...liveSession, expiresAt };
452
+ }
453
+ await ensureLiveSweep(ctx, args.siteId, now, batchState);
454
+ }
421
455
  accepted += 1;
422
456
  continue;
423
457
  }
@@ -501,6 +535,7 @@ export const ingestBatch = mutation({
501
535
  aggregateDeltas,
502
536
  reportRollupDeltas,
503
537
  recordTelemetry: true,
538
+ timezone,
504
539
  financialConversion: false,
505
540
  trustedGoalKey: undefined,
506
541
  now,
@@ -517,7 +552,7 @@ export const ingestBatch = mutation({
517
552
  await flushBatchState(ctx, batchState);
518
553
  await flushSessionRateLimits(ctx, sessionRateWindows, now);
519
554
  await flushAggregates(ctx, aggregateDeltas);
520
- await flushReportRollups(ctx, reportRollupDeltas);
555
+ await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
521
556
  return { accepted, duplicates, rejected };
522
557
  },
523
558
  });
@@ -630,6 +665,7 @@ export const trackConversion = mutation({
630
665
  aggregateDeltas,
631
666
  reportRollupDeltas,
632
667
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
668
+ timezone: localDayTimezone(site.timezone),
633
669
  financialConversion: true,
634
670
  trustedGoalKey,
635
671
  now: Date.now(),
@@ -643,8 +679,9 @@ export const trackConversion = mutation({
643
679
  );
644
680
  }
645
681
  await flushAggregates(ctx, aggregateDeltas);
646
- await flushReportRollups(ctx, reportRollupDeltas);
647
- 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);
648
685
  return { accepted: 1, duplicates: 0, rejected: 0 };
649
686
  },
650
687
  });
@@ -765,6 +802,8 @@ async function accountEvent(
765
802
  aggregateDeltas: AggregateDeltas;
766
803
  reportRollupDeltas: ReportRollupDeltas;
767
804
  recordTelemetry: boolean;
805
+ /** The site's timezone when it keeps local-day buckets. */
806
+ timezone?: string;
768
807
  financialConversion: boolean;
769
808
  trustedGoalKey: string | undefined;
770
809
  now: number;
@@ -855,15 +894,19 @@ async function accountEvent(
855
894
  matchedGoalKeys.push(goal.key);
856
895
  }
857
896
 
897
+ let reach: FunnelReach | undefined;
858
898
  for (const funnel of input.funnels) {
859
899
  if (funnel.active) {
860
- await advanceFunnel(
861
- ctx,
862
- input.siteId,
863
- funnel,
864
- event,
865
- input.reportRollupDeltas,
866
- 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
+ ),
867
910
  );
868
911
  }
869
912
  }
@@ -879,12 +922,26 @@ async function accountEvent(
879
922
  );
880
923
  }
881
924
 
882
- const campaignDimensions: Dimension[] = session.utmCampaign
883
- ? [{ type: "campaign", value: session.utmCampaign }]
884
- : [];
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;
885
939
  const dimensions: Dimension[] = [
886
940
  { type: "source", value: session.source },
887
941
  ...campaignDimensions,
942
+ ...(outboundHost
943
+ ? [{ type: "outbound", value: outboundHost } satisfies Dimension]
944
+ : []),
888
945
  { type: "page", value: event.path },
889
946
  { type: "country", value: session.country ?? "unknown" },
890
947
  { type: "device", value: input.context.device ?? "unknown" },
@@ -903,6 +960,7 @@ async function accountEvent(
903
960
  dimensions,
904
961
  input.recordTelemetry,
905
962
  input.financialConversion,
963
+ input.timezone,
906
964
  );
907
965
 
908
966
  if (
@@ -917,6 +975,7 @@ async function accountEvent(
917
975
  session,
918
976
  input.now,
919
977
  input.batchState,
978
+ reach,
920
979
  );
921
980
  }
922
981
  return session;
@@ -940,9 +999,16 @@ async function accountTrustedSession(
940
999
  isNewSession: false,
941
1000
  isNewVisitorHour: false,
942
1001
  isNewVisitorDay: false,
1002
+ isNewVisitor: false,
1003
+ durationDeltaMs: 0,
1004
+ continued: false,
943
1005
  startedAt: existing.startedAt,
944
1006
  source: existing.source,
945
1007
  utmCampaign: existing.utmCampaign,
1008
+ utmMedium: existing.utmMedium,
1009
+ entryPath: existing.entryPath,
1010
+ conversionCount: existing.conversionCount + 1,
1011
+ newVisitor: existing.newVisitor,
946
1012
  visitorKey: existing.visitorKey ?? existing.visitorId,
947
1013
  country: existing.country,
948
1014
  city: existing.city,
@@ -1054,9 +1120,16 @@ async function upsertSession(
1054
1120
  isNewVisitorDay:
1055
1121
  Math.floor(existing.lastSeenAt / DAY_MS) <
1056
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,
1057
1126
  startedAt,
1058
1127
  source: existing.source,
1059
1128
  utmCampaign: existing.utmCampaign,
1129
+ utmMedium: existing.utmMedium,
1130
+ entryPath: existing.entryPath,
1131
+ conversionCount: updates.conversionCount,
1132
+ newVisitor: existing.newVisitor,
1060
1133
  visitorKey,
1061
1134
  country: context.country ?? existing.country,
1062
1135
  city: context.city ?? existing.city,
@@ -1089,6 +1162,19 @@ async function upsertSession(
1089
1162
  }
1090
1163
  }
1091
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
+ }
1092
1178
  const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
1093
1179
  siteId,
1094
1180
  sessionId: event.sessionId,
@@ -1098,6 +1184,7 @@ async function upsertSession(
1098
1184
  lastSeenAt: event.timestamp,
1099
1185
  entryPath: event.path,
1100
1186
  exitPath: event.path,
1187
+ newVisitor: isNewVisitor,
1101
1188
  ...(event.referrer ? { referrer: event.referrer } : {}),
1102
1189
  source,
1103
1190
  ...(utmSource ? { utmSource } : {}),
@@ -1151,9 +1238,16 @@ async function upsertSession(
1151
1238
  priorVisit === null ||
1152
1239
  Math.floor(priorVisit.lastSeenAt / DAY_MS) <
1153
1240
  Math.floor(event.timestamp / DAY_MS),
1241
+ isNewVisitor,
1242
+ durationDeltaMs: 0,
1243
+ continued: false,
1154
1244
  startedAt: event.timestamp,
1155
1245
  source,
1156
1246
  utmCampaign,
1247
+ utmMedium,
1248
+ entryPath: event.path,
1249
+ conversionCount: Number(financialConversion),
1250
+ newVisitor: isNewVisitor,
1157
1251
  visitorKey,
1158
1252
  country: context.country,
1159
1253
  city: context.city,
@@ -1311,7 +1405,7 @@ async function accountHeartbeat(
1311
1405
  context: IngestContext,
1312
1406
  now: number,
1313
1407
  batchState?: BatchState,
1314
- ) {
1408
+ ): Promise<number> {
1315
1409
  const startedAt = Math.min(session.startedAt, event.timestamp);
1316
1410
  const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
1317
1411
  const updates = {
@@ -1338,6 +1432,7 @@ async function accountHeartbeat(
1338
1432
  } else {
1339
1433
  await ctx.db.patch("sessions", session._id, updates);
1340
1434
  }
1435
+ const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
1341
1436
  await updateLiveSession(
1342
1437
  ctx,
1343
1438
  siteId,
@@ -1347,6 +1442,9 @@ async function accountHeartbeat(
1347
1442
  isNewSession: false,
1348
1443
  isNewVisitorHour: false,
1349
1444
  isNewVisitorDay: false,
1445
+ isNewVisitor: false,
1446
+ durationDeltaMs: 0,
1447
+ continued: false,
1350
1448
  startedAt,
1351
1449
  source: session.source,
1352
1450
  visitorKey: session.visitorKey ?? session.visitorId,
@@ -1357,10 +1455,14 @@ async function accountHeartbeat(
1357
1455
  pageviewCount: session.pageviewCount,
1358
1456
  eventCount: session.eventCount,
1359
1457
  affiliateSlug: session.affiliateSlug,
1458
+ entryPath: session.entryPath,
1459
+ conversionCount: session.conversionCount,
1460
+ newVisitor: session.newVisitor,
1360
1461
  },
1361
1462
  now,
1362
1463
  batchState,
1363
1464
  );
1465
+ return durationDeltaMs;
1364
1466
  }
1365
1467
 
1366
1468
  async function consumeSiteRateLimit(
@@ -1518,6 +1620,26 @@ async function flushSessionRateLimits(
1518
1620
  }
1519
1621
  }
1520
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
+
1521
1643
  async function updateLiveSession(
1522
1644
  ctx: MutationCtx,
1523
1645
  siteId: Id<"sites">,
@@ -1526,6 +1648,7 @@ async function updateLiveSession(
1526
1648
  session: SessionState,
1527
1649
  now: number,
1528
1650
  batchState?: BatchState,
1651
+ reach?: FunnelReach,
1529
1652
  ) {
1530
1653
  await ensureLiveSweep(ctx, siteId, now, batchState);
1531
1654
  const liveState = batchState
@@ -1570,6 +1693,7 @@ async function updateLiveSession(
1570
1693
  device: context.device ?? existing.device,
1571
1694
  pageviewCount: session.pageviewCount,
1572
1695
  eventCount: session.eventCount,
1696
+ ...liveIntentFields(session, furtherReach(existing.funnel, reach)),
1573
1697
  // The live map animates a visitor from the route they left to the one
1574
1698
  // they reached; a heartbeat or click on the same page is not a move.
1575
1699
  ...(event.type === "pageview" && event.path !== existing.path
@@ -1609,6 +1733,7 @@ async function updateLiveSession(
1609
1733
  device: context.device ?? "unknown",
1610
1734
  pageviewCount: session.pageviewCount,
1611
1735
  eventCount: session.eventCount,
1736
+ ...liveIntentFields(session, reach),
1612
1737
  ...(event.type === "heartbeat"
1613
1738
  ? {
1614
1739
  lastHeartbeatEventId: event.eventId,
@@ -1666,7 +1791,7 @@ async function advanceFunnel(
1666
1791
  event: TrackerEvent,
1667
1792
  reportRollupDeltas: ReportRollupDeltas,
1668
1793
  batchState?: BatchState,
1669
- ) {
1794
+ ): Promise<FunnelReach | undefined> {
1670
1795
  const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
1671
1796
  let progressState = batchState?.funnelProgress.get(cacheKey);
1672
1797
  if (batchState && !progressState) {
@@ -1712,9 +1837,16 @@ async function advanceFunnel(
1712
1837
  _id: progressId,
1713
1838
  };
1714
1839
  }
1715
- 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
+ );
1716
1848
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1717
- return;
1849
+ return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1718
1850
  }
1719
1851
  if (
1720
1852
  progress.completedAt !== undefined ||
@@ -1743,9 +1875,16 @@ async function advanceFunnel(
1743
1875
  completedAt: undefined,
1744
1876
  });
1745
1877
  }
1746
- 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
+ );
1747
1886
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1748
- return;
1887
+ return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1749
1888
  }
1750
1889
  const nextStep = funnel.steps[progress.currentStep];
1751
1890
  if (!nextStep || !stepMatches(nextStep, event)) return;
@@ -1763,8 +1902,8 @@ async function advanceFunnel(
1763
1902
  } else {
1764
1903
  await ctx.db.patch("funnelProgress", progress._id, updates);
1765
1904
  }
1766
- await incrementFunnelStep(
1767
- ctx,
1905
+ foldFunnelStepDelta(
1906
+ reportRollupDeltas,
1768
1907
  siteId,
1769
1908
  funnel._id,
1770
1909
  event,
@@ -1779,6 +1918,11 @@ async function advanceFunnel(
1779
1918
  progress.currentStep,
1780
1919
  completed,
1781
1920
  );
1921
+ return {
1922
+ funnelId: funnel._id,
1923
+ step: currentStep,
1924
+ steps: funnel.steps.length,
1925
+ };
1782
1926
  }
1783
1927
 
1784
1928
  function stepMatches(
@@ -1790,47 +1934,6 @@ function stepMatches(
1790
1934
  : event.type === "custom" && event.name === step.value;
1791
1935
  }
1792
1936
 
1793
- async function incrementFunnelStep(
1794
- ctx: MutationCtx,
1795
- siteId: Id<"sites">,
1796
- funnelId: Id<"funnels">,
1797
- event: TrackerEvent,
1798
- step: number,
1799
- completed: boolean,
1800
- ) {
1801
- const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1802
- const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
1803
- const bucket = await ctx.db
1804
- .query("funnelStepBuckets")
1805
- .withIndex(
1806
- "by_siteId_and_funnelId_and_bucketStart_and_step_and_shard",
1807
- (range) =>
1808
- range
1809
- .eq("siteId", siteId)
1810
- .eq("funnelId", funnelId)
1811
- .eq("bucketStart", bucketStart)
1812
- .eq("step", step)
1813
- .eq("shard", shard),
1814
- )
1815
- .unique();
1816
- if (bucket) {
1817
- await ctx.db.patch("funnelStepBuckets", bucket._id, {
1818
- entrants: bucket.entrants + 1,
1819
- completions: bucket.completions + (completed ? 1 : 0),
1820
- });
1821
- } else {
1822
- await ctx.db.insert("funnelStepBuckets", {
1823
- siteId,
1824
- funnelId,
1825
- bucketStart,
1826
- step,
1827
- shard,
1828
- entrants: 1,
1829
- completions: completed ? 1 : 0,
1830
- });
1831
- }
1832
- }
1833
-
1834
1937
  async function accountConversion(
1835
1938
  ctx: MutationCtx,
1836
1939
  siteId: Id<"sites">,
@@ -1913,31 +2016,6 @@ async function accountConversion(
1913
2016
  }
1914
2017
  }
1915
2018
 
1916
- function createReportRollupDeltas(): ReportRollupDeltas {
1917
- return {
1918
- goals: new Map(),
1919
- funnels: new Map(),
1920
- affiliates: new Map(),
1921
- vitals: new Map(),
1922
- routes: new Map(),
1923
- };
1924
- }
1925
-
1926
- function reportRollupCoordinates(
1927
- siteId: Id<"sites">,
1928
- definitionId: string,
1929
- timestamp: number,
1930
- visitorId: string,
1931
- ) {
1932
- const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
1933
- const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
1934
- return {
1935
- bucketStart,
1936
- shard,
1937
- key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
1938
- };
1939
- }
1940
-
1941
2019
  function foldGoalRollup(
1942
2020
  deltas: ReportRollupDeltas,
1943
2021
  siteId: Id<"sites">,
@@ -1996,6 +2074,31 @@ function foldFunnelRollup(
1996
2074
  deltas.funnels.set(coordinates.key, rollup);
1997
2075
  }
1998
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
+
1999
2102
  function foldAffiliateVisitRollup(
2000
2103
  deltas: ReportRollupDeltas,
2001
2104
  siteId: Id<"sites">,
@@ -2016,13 +2119,13 @@ function foldAffiliateVisitRollup(
2016
2119
  shard: coordinates.shard,
2017
2120
  affiliateSlug: attribution.affiliateSlug,
2018
2121
  visits: 0,
2019
- visitorSketch: createVisitorSketch(),
2122
+ visitorKeys: new Set<string>(),
2020
2123
  conversions: 0,
2021
2124
  revenueCents: 0,
2022
2125
  commissionCents: 0,
2023
2126
  };
2024
2127
  current.visits += 1;
2025
- current.visitorSketch = addVisitor(current.visitorSketch, visitorKey);
2128
+ current.visitorKeys.add(visitorKey);
2026
2129
  deltas.affiliates.set(coordinates.key, current);
2027
2130
  }
2028
2131
 
@@ -2048,7 +2151,7 @@ function foldAffiliateConversionRollup(
2048
2151
  shard: coordinates.shard,
2049
2152
  affiliateSlug,
2050
2153
  visits: 0,
2051
- visitorSketch: createVisitorSketch(),
2154
+ visitorKeys: new Set<string>(),
2052
2155
  conversions: 0,
2053
2156
  revenueCents: 0,
2054
2157
  commissionCents: 0,
@@ -2130,7 +2233,7 @@ function foldRouteDelta(
2130
2233
  continued: 0,
2131
2234
  dwellMs: 0,
2132
2235
  dwellSamples: 0,
2133
- visitorSketch: createVisitorSketch(),
2236
+ visitorKeys: new Set<string>(),
2134
2237
  outgoing: new Map<string, number>(),
2135
2238
  };
2136
2239
  apply(current);
@@ -2163,7 +2266,7 @@ function foldRouteRollups(
2163
2266
  (delta) => {
2164
2267
  delta.pageviews += 1;
2165
2268
  if (firstPageview) delta.entries += 1;
2166
- delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
2269
+ delta.visitorKeys.add(session.visitorKey);
2167
2270
  },
2168
2271
  );
2169
2272
  // Out-of-order telemetry keeps its view but cannot say where it came from.
@@ -2199,272 +2302,6 @@ function foldRouteRollups(
2199
2302
  }
2200
2303
  }
2201
2304
 
2202
- async function upsertRouteRollup(
2203
- ctx: MutationCtx,
2204
- delta: RouteRollupDelta,
2205
- dayRowCounts: Map<string, number>,
2206
- ) {
2207
- const existing = await ctx.db
2208
- .query("routeDailyRollups")
2209
- .withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) =>
2210
- range
2211
- .eq("siteId", delta.siteId)
2212
- .eq("route", delta.route)
2213
- .eq("bucketStart", delta.bucketStart)
2214
- .eq("shard", delta.shard),
2215
- )
2216
- .unique();
2217
- if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
2218
- // A new route row spends one bounded read per batch to respect the daily
2219
- // cap; past it, the day's remaining routes fold into "(other)".
2220
- const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
2221
- const dayKey = `${delta.siteId}:${delta.bucketStart}`;
2222
- let dayRows = dayRowCounts.get(dayKey);
2223
- if (dayRows === undefined) {
2224
- dayRows = (
2225
- await ctx.db
2226
- .query("routeDailyRollups")
2227
- .withIndex("by_siteId_and_bucketStart", (range) =>
2228
- range
2229
- .eq("siteId", delta.siteId)
2230
- .eq("bucketStart", delta.bucketStart),
2231
- )
2232
- .take(capRows + 1)
2233
- ).length;
2234
- }
2235
- if (dayRows >= capRows) {
2236
- await upsertRouteRollup(
2237
- ctx,
2238
- { ...delta, route: SITE_MAP_OTHER_ROUTE },
2239
- dayRowCounts,
2240
- );
2241
- return;
2242
- }
2243
- dayRowCounts.set(dayKey, dayRows + 1);
2244
- }
2245
- const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
2246
- for (const [target, count] of delta.outgoing) {
2247
- let entry = outgoing.find((candidate) => candidate.route === target);
2248
- if (!entry) {
2249
- const named = outgoing.filter(
2250
- (candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE,
2251
- ).length;
2252
- const route =
2253
- target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
2254
- ? SITE_MAP_OTHER_ROUTE
2255
- : target;
2256
- entry = outgoing.find((candidate) => candidate.route === route);
2257
- if (!entry) {
2258
- entry = { route, count: 0 };
2259
- outgoing.push(entry);
2260
- }
2261
- }
2262
- entry.count += count;
2263
- }
2264
- const fields = {
2265
- siteId: delta.siteId,
2266
- bucketStart: delta.bucketStart,
2267
- shard: delta.shard,
2268
- generation: 1 as const,
2269
- route: delta.route,
2270
- pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
2271
- entries: (existing?.entries ?? 0) + delta.entries,
2272
- continued: (existing?.continued ?? 0) + delta.continued,
2273
- dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
2274
- dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
2275
- visitorSketch: unionVisitorSketches(
2276
- existing?.visitorSketch ?? createVisitorSketch(),
2277
- delta.visitorSketch,
2278
- ),
2279
- outgoing,
2280
- };
2281
- if (existing) {
2282
- await ctx.db.replace("routeDailyRollups", existing._id, fields);
2283
- } else {
2284
- await ctx.db.insert("routeDailyRollups", fields);
2285
- }
2286
- }
2287
-
2288
- async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
2289
- const existing = await ctx.db
2290
- .query("vitalRollups")
2291
- .withIndex("by_key", (range) =>
2292
- range
2293
- .eq("siteId", delta.siteId)
2294
- .eq("bucketStart", delta.bucketStart)
2295
- .eq("device", delta.device)
2296
- .eq("page", delta.page)
2297
- .eq("shard", delta.shard),
2298
- )
2299
- .unique();
2300
- if (
2301
- !existing &&
2302
- delta.device === VITAL_ALL &&
2303
- delta.page !== VITAL_OTHER_PAGES
2304
- ) {
2305
- // A new page row spends one bounded read to respect the daily page slots;
2306
- // past the cap, the day's remaining pages fold into "(other)".
2307
- const pageRows = await ctx.db
2308
- .query("vitalRollups")
2309
- .withIndex("by_key", (range) =>
2310
- range
2311
- .eq("siteId", delta.siteId)
2312
- .eq("bucketStart", delta.bucketStart)
2313
- .eq("device", VITAL_ALL),
2314
- )
2315
- .take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
2316
- if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
2317
- await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
2318
- return;
2319
- }
2320
- }
2321
- const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
2322
- for (const [metric, addition] of delta.metrics) {
2323
- const entry = metrics.find((candidate) => candidate.metric === metric);
2324
- if (entry) {
2325
- entry.count += addition.count;
2326
- entry.sum += addition.sum;
2327
- entry.histogram = mergeVitalHistograms(
2328
- entry.histogram,
2329
- addition.histogram,
2330
- );
2331
- } else {
2332
- metrics.push({
2333
- metric,
2334
- count: addition.count,
2335
- sum: addition.sum,
2336
- histogram: [...addition.histogram],
2337
- });
2338
- }
2339
- }
2340
- const fields = {
2341
- siteId: delta.siteId,
2342
- bucketStart: delta.bucketStart,
2343
- device: delta.device,
2344
- page: delta.page,
2345
- shard: delta.shard,
2346
- metrics,
2347
- };
2348
- if (existing) {
2349
- await ctx.db.replace("vitalRollups", existing._id, fields);
2350
- } else {
2351
- await ctx.db.insert("vitalRollups", fields);
2352
- }
2353
- }
2354
-
2355
- async function flushReportRollups(
2356
- ctx: MutationCtx,
2357
- deltas: ReportRollupDeltas,
2358
- ) {
2359
- for (const delta of deltas.goals.values()) {
2360
- const existing = await ctx.db
2361
- .query("goalDailyRollups")
2362
- .withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) =>
2363
- range
2364
- .eq("siteId", delta.siteId)
2365
- .eq("goalId", delta.goalId)
2366
- .eq("bucketStart", delta.bucketStart)
2367
- .eq("shard", delta.shard),
2368
- )
2369
- .unique();
2370
- const fields = {
2371
- siteId: delta.siteId,
2372
- goalId: delta.goalId,
2373
- bucketStart: delta.bucketStart,
2374
- shard: delta.shard,
2375
- generation: 1 as const,
2376
- goalKey: delta.goalKey,
2377
- completions: (existing?.completions ?? 0) + delta.completions,
2378
- valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
2379
- };
2380
- if (existing) {
2381
- await ctx.db.replace("goalDailyRollups", existing._id, fields);
2382
- } else {
2383
- await ctx.db.insert("goalDailyRollups", fields);
2384
- }
2385
- }
2386
- for (const delta of deltas.funnels.values()) {
2387
- const existing = await ctx.db
2388
- .query("funnelDailyRollups")
2389
- .withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) =>
2390
- range
2391
- .eq("siteId", delta.siteId)
2392
- .eq("funnelId", delta.funnelId)
2393
- .eq("bucketStart", delta.bucketStart)
2394
- .eq("shard", delta.shard),
2395
- )
2396
- .unique();
2397
- const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
2398
- for (const addition of delta.steps.values()) {
2399
- const current = steps.find((step) => step.step === addition.step);
2400
- if (current) {
2401
- current.entrants += addition.entrants;
2402
- current.completions += addition.completions;
2403
- } else {
2404
- if (steps.length >= MAX_FUNNEL_STEPS) {
2405
- fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
2406
- }
2407
- steps.push(addition);
2408
- }
2409
- }
2410
- const fields = {
2411
- siteId: delta.siteId,
2412
- funnelId: delta.funnelId,
2413
- bucketStart: delta.bucketStart,
2414
- shard: delta.shard,
2415
- generation: 1 as const,
2416
- steps,
2417
- };
2418
- if (existing) {
2419
- await ctx.db.replace("funnelDailyRollups", existing._id, fields);
2420
- } else {
2421
- await ctx.db.insert("funnelDailyRollups", fields);
2422
- }
2423
- }
2424
- for (const delta of deltas.affiliates.values()) {
2425
- const existing = await ctx.db
2426
- .query("affiliateDailyRollups")
2427
- .withIndex(
2428
- "by_siteId_and_affiliateId_and_bucketStart_and_shard",
2429
- (range) =>
2430
- range
2431
- .eq("siteId", delta.siteId)
2432
- .eq("affiliateId", delta.affiliateId)
2433
- .eq("bucketStart", delta.bucketStart)
2434
- .eq("shard", delta.shard),
2435
- )
2436
- .unique();
2437
- const fields = {
2438
- siteId: delta.siteId,
2439
- affiliateId: delta.affiliateId,
2440
- bucketStart: delta.bucketStart,
2441
- shard: delta.shard,
2442
- generation: 1 as const,
2443
- affiliateSlug: delta.affiliateSlug,
2444
- visits: (existing?.visits ?? 0) + delta.visits,
2445
- visitorSketch: unionVisitorSketches(
2446
- existing?.visitorSketch ?? createVisitorSketch(),
2447
- delta.visitorSketch,
2448
- ),
2449
- conversions: (existing?.conversions ?? 0) + delta.conversions,
2450
- revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
2451
- commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
2452
- };
2453
- if (existing) {
2454
- await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
2455
- } else {
2456
- await ctx.db.insert("affiliateDailyRollups", fields);
2457
- }
2458
- }
2459
- const routeDayRowCounts = new Map<string, number>();
2460
- for (const delta of deltas.routes.values()) {
2461
- await upsertRouteRollup(ctx, delta, routeDayRowCounts);
2462
- }
2463
- for (const delta of deltas.vitals.values()) {
2464
- await upsertVitalRollup(ctx, delta);
2465
- }
2466
- }
2467
-
2468
2305
  function foldAggregates(
2469
2306
  aggregateDeltas: AggregateDeltas,
2470
2307
  siteId: Id<"sites">,
@@ -2473,19 +2310,25 @@ function foldAggregates(
2473
2310
  dimensions: Dimension[],
2474
2311
  recordTelemetry: boolean,
2475
2312
  financialConversion: boolean,
2313
+ timezone?: string,
2476
2314
  ) {
2477
2315
  const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
2478
2316
  const dimensionDeltas = dimensions.map((dimension): DimensionDelta => ({
2479
2317
  ...dimension,
2480
2318
  count: Number(recordTelemetry),
2481
2319
  revenueCents,
2320
+ pageviews: Number(recordTelemetry && event.type === "pageview"),
2321
+ sessions: Number(recordTelemetry && session.isNewSession),
2482
2322
  }));
2483
- for (const granularity of ["hour", "day"] as const) {
2323
+ for (const granularity of aggregateGranularities(timezone)) {
2484
2324
  const shard =
2485
2325
  stableHash(event.sessionId) %
2486
2326
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
2487
- const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
2488
- const bucketStart = Math.floor(event.timestamp / interval) * interval;
2327
+ const bucketStart = aggregateBucketStart(
2328
+ granularity,
2329
+ event.timestamp,
2330
+ timezone,
2331
+ );
2489
2332
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
2490
2333
  let delta = aggregateDeltas.get(key);
2491
2334
  if (!delta) {
@@ -2505,6 +2348,9 @@ function foldAggregates(
2505
2348
  conversions: 0,
2506
2349
  revenueCents: 0,
2507
2350
  dimensions: [],
2351
+ durationMs: 0,
2352
+ continuedSessions: 0,
2353
+ newVisitors: 0,
2508
2354
  };
2509
2355
  aggregateDeltas.set(key, delta);
2510
2356
  }
@@ -2528,6 +2374,77 @@ function foldAggregates(
2528
2374
  delta.conversions += Number(financialConversion);
2529
2375
  delta.revenueCents += revenueCents;
2530
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);
2531
2448
  }
2532
2449
  }
2533
2450
 
@@ -2550,7 +2467,11 @@ async function flushAggregates(
2550
2467
  .unique();
2551
2468
  const dimensions = mergeDimensions(
2552
2469
  bucket?.dimensions ?? [],
2553
- delta.dimensions,
2470
+ delta.dimensions.map((slot) => ({
2471
+ ...slot,
2472
+ pageviews: slot.pageviews ?? 0,
2473
+ sessions: slot.sessions ?? 0,
2474
+ })),
2554
2475
  );
2555
2476
  const priorSketch = bucket?.visitorSketch;
2556
2477
  const visitorSketch = priorSketch
@@ -2573,6 +2494,10 @@ async function flushAggregates(
2573
2494
  conversions: bucket.conversions + delta.conversions,
2574
2495
  revenueCents: bucket.revenueCents + delta.revenueCents,
2575
2496
  dimensions,
2497
+ durationMs: (bucket.durationMs ?? 0) + delta.durationMs,
2498
+ continuedSessions:
2499
+ (bucket.continuedSessions ?? 0) + delta.continuedSessions,
2500
+ newVisitors: (bucket.newVisitors ?? 0) + delta.newVisitors,
2576
2501
  });
2577
2502
  } else {
2578
2503
  await ctx.db.insert("aggregateBuckets", {
@@ -2592,6 +2517,9 @@ async function flushAggregates(
2592
2517
  conversions: delta.conversions,
2593
2518
  revenueCents: delta.revenueCents,
2594
2519
  dimensions,
2520
+ durationMs: delta.durationMs,
2521
+ continuedSessions: delta.continuedSessions,
2522
+ newVisitors: delta.newVisitors,
2595
2523
  });
2596
2524
  }
2597
2525
  }
@@ -2610,6 +2538,8 @@ function mergeDimensions(
2610
2538
  if (exact) {
2611
2539
  exact.count += addition.count;
2612
2540
  exact.revenueCents += addition.revenueCents;
2541
+ exact.pageviews = (exact.pageviews ?? 0) + addition.pageviews;
2542
+ exact.sessions = (exact.sessions ?? 0) + addition.sessions;
2613
2543
  continue;
2614
2544
  }
2615
2545
 
@@ -2627,6 +2557,8 @@ function mergeDimensions(
2627
2557
  value: addition.value,
2628
2558
  count: addition.count,
2629
2559
  revenueCents: addition.revenueCents,
2560
+ pageviews: addition.pageviews,
2561
+ sessions: addition.sessions,
2630
2562
  });
2631
2563
  continue;
2632
2564
  }
@@ -2636,6 +2568,8 @@ function mergeDimensions(
2636
2568
  if (overflow) {
2637
2569
  overflow.count += addition.count;
2638
2570
  overflow.revenueCents += addition.revenueCents;
2571
+ overflow.pageviews = (overflow.pageviews ?? 0) + addition.pageviews;
2572
+ overflow.sessions = (overflow.sessions ?? 0) + addition.sessions;
2639
2573
  continue;
2640
2574
  }
2641
2575
  if (addition.count === 0 && addition.revenueCents === 0) continue;
@@ -2649,6 +2583,8 @@ function mergeDimensions(
2649
2583
  value: "(other)",
2650
2584
  count: addition.count,
2651
2585
  revenueCents: addition.revenueCents,
2586
+ pageviews: addition.pageviews,
2587
+ sessions: addition.sessions,
2652
2588
  });
2653
2589
  continue;
2654
2590
  }
@@ -2660,6 +2596,8 @@ function mergeDimensions(
2660
2596
  replacement.value = "(other)";
2661
2597
  replacement.count += addition.count;
2662
2598
  replacement.revenueCents += addition.revenueCents;
2599
+ replacement.pageviews = (replacement.pageviews ?? 0) + addition.pageviews;
2600
+ replacement.sessions = (replacement.sessions ?? 0) + addition.sessions;
2663
2601
  }
2664
2602
  return result;
2665
2603
  }