@iann29/rastro 0.1.0-alpha.1 → 0.1.0-alpha.11

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 (179) hide show
  1. package/README.md +693 -69
  2. package/agent/integration.md +801 -0
  3. package/agent/manifest.json +205 -0
  4. package/agent/manifest.schema.json +444 -0
  5. package/dist/client/federation.d.ts +381 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +274 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +2673 -15
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +565 -33
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +22 -0
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +315 -4
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +4 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/affiliates.d.ts.map +1 -1
  22. package/dist/component/affiliates.js +6 -2
  23. package/dist/component/affiliates.js.map +1 -1
  24. package/dist/component/cardinality.d.ts +12 -0
  25. package/dist/component/cardinality.d.ts.map +1 -0
  26. package/dist/component/cardinality.js +94 -0
  27. package/dist/component/cardinality.js.map +1 -0
  28. package/dist/component/constants.d.ts +28 -1
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +44 -1
  31. package/dist/component/constants.js.map +1 -1
  32. package/dist/component/convex.config.d.ts +6 -1
  33. package/dist/component/convex.config.js +9 -1
  34. package/dist/component/convex.config.js.map +1 -1
  35. package/dist/component/coverage.d.ts +27 -0
  36. package/dist/component/coverage.d.ts.map +1 -0
  37. package/dist/component/coverage.js +56 -0
  38. package/dist/component/coverage.js.map +1 -0
  39. package/dist/component/diagnostics.d.ts +9 -0
  40. package/dist/component/diagnostics.d.ts.map +1 -0
  41. package/dist/component/diagnostics.js +47 -0
  42. package/dist/component/diagnostics.js.map +1 -0
  43. package/dist/component/errors.d.ts +1 -1
  44. package/dist/component/errors.d.ts.map +1 -1
  45. package/dist/component/errors.js.map +1 -1
  46. package/dist/component/eventStore.d.ts +21 -9
  47. package/dist/component/eventStore.d.ts.map +1 -1
  48. package/dist/component/eventStore.js +142 -152
  49. package/dist/component/eventStore.js.map +1 -1
  50. package/dist/component/funnels.d.ts.map +1 -1
  51. package/dist/component/funnels.js +5 -3
  52. package/dist/component/funnels.js.map +1 -1
  53. package/dist/component/geo.d.ts +73 -0
  54. package/dist/component/geo.d.ts.map +1 -0
  55. package/dist/component/geo.js +648 -0
  56. package/dist/component/geo.js.map +1 -0
  57. package/dist/component/goals.d.ts.map +1 -1
  58. package/dist/component/goals.js +8 -5
  59. package/dist/component/goals.js.map +1 -1
  60. package/dist/component/guards.d.ts.map +1 -1
  61. package/dist/component/guards.js.map +1 -1
  62. package/dist/component/http.d.ts.map +1 -1
  63. package/dist/component/http.js +281 -62
  64. package/dist/component/http.js.map +1 -1
  65. package/dist/component/identity.d.ts +13 -0
  66. package/dist/component/identity.d.ts.map +1 -0
  67. package/dist/component/identity.js +58 -0
  68. package/dist/component/identity.js.map +1 -0
  69. package/dist/component/ingest.d.ts +3 -1
  70. package/dist/component/ingest.d.ts.map +1 -1
  71. package/dist/component/ingest.js +498 -95
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts.map +1 -1
  74. package/dist/component/live.js +7 -6
  75. package/dist/component/live.js.map +1 -1
  76. package/dist/component/localTime.d.ts +25 -0
  77. package/dist/component/localTime.d.ts.map +1 -0
  78. package/dist/component/localTime.js +126 -0
  79. package/dist/component/localTime.js.map +1 -0
  80. package/dist/component/reports.d.ts +266 -10
  81. package/dist/component/reports.d.ts.map +1 -1
  82. package/dist/component/reports.js +1243 -137
  83. package/dist/component/reports.js.map +1 -1
  84. package/dist/component/retention.d.ts +75 -1
  85. package/dist/component/retention.d.ts.map +1 -1
  86. package/dist/component/retention.js +561 -38
  87. package/dist/component/retention.js.map +1 -1
  88. package/dist/component/rollupStore.d.ts +320 -0
  89. package/dist/component/rollupStore.d.ts.map +1 -0
  90. package/dist/component/rollupStore.js +596 -0
  91. package/dist/component/rollupStore.js.map +1 -0
  92. package/dist/component/rollups.d.ts +20 -0
  93. package/dist/component/rollups.d.ts.map +1 -0
  94. package/dist/component/rollups.js +73 -0
  95. package/dist/component/rollups.js.map +1 -0
  96. package/dist/component/sanitize.d.ts +24 -1
  97. package/dist/component/sanitize.d.ts.map +1 -1
  98. package/dist/component/sanitize.js +96 -16
  99. package/dist/component/sanitize.js.map +1 -1
  100. package/dist/component/schema.d.ts +687 -65
  101. package/dist/component/schema.js +187 -20
  102. package/dist/component/schema.js.map +1 -1
  103. package/dist/component/sites.d.ts +12 -0
  104. package/dist/component/sites.d.ts.map +1 -1
  105. package/dist/component/sites.js +41 -7
  106. package/dist/component/sites.js.map +1 -1
  107. package/dist/component/useragent.d.ts +9 -0
  108. package/dist/component/useragent.d.ts.map +1 -0
  109. package/dist/component/useragent.js +152 -0
  110. package/dist/component/useragent.js.map +1 -0
  111. package/dist/component/validators.d.ts +124 -69
  112. package/dist/component/validators.d.ts.map +1 -1
  113. package/dist/component/validators.js +35 -14
  114. package/dist/component/validators.js.map +1 -1
  115. package/dist/component/visitors.d.ts +19 -0
  116. package/dist/component/visitors.d.ts.map +1 -0
  117. package/dist/component/visitors.js +86 -0
  118. package/dist/component/visitors.js.map +1 -0
  119. package/dist/component/vitals.d.ts +41 -0
  120. package/dist/component/vitals.d.ts.map +1 -0
  121. package/dist/component/vitals.js +115 -0
  122. package/dist/component/vitals.js.map +1 -0
  123. package/dist/react/index.d.ts.map +1 -1
  124. package/dist/react/index.js.map +1 -1
  125. package/dist/tracker/generated.d.ts +11 -4
  126. package/dist/tracker/generated.d.ts.map +1 -1
  127. package/dist/tracker/generated.js +11 -4
  128. package/dist/tracker/generated.js.map +1 -1
  129. package/dist/tracker/tracker.d.ts +1 -1
  130. package/dist/tracker/tracker.d.ts.map +1 -1
  131. package/dist/tracker/tracker.js +57 -20
  132. package/dist/tracker/tracker.js.map +1 -1
  133. package/dist/tracker/vitals.d.ts +10 -0
  134. package/dist/tracker/vitals.d.ts.map +1 -0
  135. package/dist/tracker/vitals.js +140 -0
  136. package/dist/tracker/vitals.js.map +1 -0
  137. package/dist/tracker.min.js +1 -1
  138. package/dist/vitals.min.js +1 -0
  139. package/docs/benchmarks/2026-08-20-realistic.md +76 -76
  140. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  141. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  142. package/docs/federation-setup.md +464 -0
  143. package/docs/federation.md +352 -0
  144. package/docs/upgrading.md +344 -0
  145. package/llms.txt +72 -0
  146. package/package.json +55 -11
  147. package/scripts/benchmark-ingest.mjs +175 -73
  148. package/scripts/generate-federation-keys.mjs +20 -0
  149. package/src/component/_generated/api.ts +22 -0
  150. package/src/component/_generated/component.ts +381 -4
  151. package/src/component/_generated/server.ts +4 -0
  152. package/src/component/affiliates.ts +20 -5
  153. package/src/component/cardinality.ts +117 -0
  154. package/src/component/constants.ts +44 -1
  155. package/src/component/convex.config.ts +11 -1
  156. package/src/component/coverage.ts +71 -0
  157. package/src/component/diagnostics.ts +65 -0
  158. package/src/component/errors.ts +2 -1
  159. package/src/component/eventStore.ts +217 -193
  160. package/src/component/funnels.ts +19 -16
  161. package/src/component/geo.ts +835 -0
  162. package/src/component/goals.ts +29 -21
  163. package/src/component/guards.ts +3 -1
  164. package/src/component/http.ts +404 -70
  165. package/src/component/identity.ts +74 -0
  166. package/src/component/ingest.ts +894 -188
  167. package/src/component/live.ts +13 -7
  168. package/src/component/localTime.ts +167 -0
  169. package/src/component/reports.ts +1872 -197
  170. package/src/component/retention.ts +788 -96
  171. package/src/component/rollupStore.ts +799 -0
  172. package/src/component/rollups.ts +82 -0
  173. package/src/component/sanitize.ts +144 -29
  174. package/src/component/schema.ts +217 -21
  175. package/src/component/sites.ts +59 -12
  176. package/src/component/useragent.ts +171 -0
  177. package/src/component/validators.ts +49 -14
  178. package/src/component/visitors.ts +116 -0
  179. package/src/component/vitals.ts +146 -0
@@ -1,11 +1,16 @@
1
1
  import { v } from "convex/values";
2
2
  import { mutation } from "./_generated/server.js";
3
3
  import { internal } from "./_generated/api.js";
4
- import { AGGREGATE_SHARDS, MAX_BATCH_EVENT_GROUPS, DAY_MS, HOURLY_AGGREGATE_SHARDS, DIMENSION_SLOTS, HOUR_MS, LIVE_SESSION_TTL_MS, LIVE_SWEEP_INTERVAL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, RATE_LIMIT_WINDOW_MS, SECONDARY_AGGREGATE_SHARDS, SITE_INGEST_SHARDS, } from "./constants.js";
4
+ import { AGGREGATE_SHARDS, MAX_BATCH_EVENT_GROUPS, DAY_MS, HOURLY_AGGREGATE_SHARDS, DIMENSION_SLOTS, HOUR_MS, LIVE_LEAVE_GRACE_MS, LIVE_SESSION_TTL_MS, LIVE_SWEEP_INTERVAL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, RATE_LIMIT_WINDOW_MS, REPORT_ROLLUP_SHARDS, SECONDARY_AGGREGATE_SHARDS, SITE_INGEST_SHARDS, } from "./constants.js";
5
+ import { addVisitor, createVisitorSketch, unionVisitorSketches, } from "./cardinality.js";
6
+ import { addToVitalHistogram, emptyVitalHistogram, vitalDevice, VITAL_ALL, } from "./vitals.js";
5
7
  import { fail } from "./errors.js";
8
+ import { ensureAnalyticsControl } from "./coverage.js";
6
9
  import { bufferEventBatch, eventBatchKey, eventBucketStart, findTelemetry, flushEventBatches, insertEventBatch, inspectTelemetryMinute, } from "./eventStore.js";
7
- import { cleanString, ensureBatchBytes, isValidMoney, originAllowed, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
10
+ import { cleanString, isValidMoney, measureBatchBytes, normalizeRoute, originAllowed, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
8
11
  import { ingestContextValidator, ingestResultValidator, trackerEventValidator, trustedConversionValidator, } from "./validators.js";
12
+ import { createReportRollupDeltas, enqueueReportRollups, reportRollupCoordinates, } from "./rollupStore.js";
13
+ import { localDayStart, localDayTimezone } from "./localTime.js";
9
14
  export const ingestBatch = mutation({
10
15
  args: {
11
16
  siteId: v.id("sites"),
@@ -28,16 +33,20 @@ export const ingestBatch = mutation({
28
33
  limit: MAX_BATCH_EVENTS,
29
34
  });
30
35
  }
36
+ let eventByteCounts;
31
37
  try {
32
- ensureBatchBytes({ siteId: String(args.siteId), events: args.events });
38
+ ({ eventByteCounts } = measureBatchBytes(String(args.siteId), args.events));
33
39
  }
34
40
  catch {
35
41
  fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
36
42
  }
37
43
  const now = Date.now();
38
- const eventByteCounts = await consumeSiteRateLimit(ctx, args.siteId, args.events, now);
44
+ await ensureAnalyticsControl(ctx, args.siteId, now);
45
+ await consumeSiteRateLimit(ctx, args.siteId, args.events, eventByteCounts, now);
39
46
  const context = sanitizeContext(args.context);
47
+ const timezone = localDayTimezone(site.timezone);
40
48
  const aggregateDeltas = new Map();
49
+ const reportRollupDeltas = createReportRollupDeltas();
41
50
  const sessionRateWindows = new Map();
42
51
  const batchState = {
43
52
  sessions: new Map(),
@@ -71,7 +80,9 @@ export const ingestBatch = mutation({
71
80
  const bucketStart = eventBucketStart(event.timestamp);
72
81
  const groupKey = eventBatchKey(event.sessionId, bucketStart);
73
82
  requestGroups.add(groupKey);
74
- if (event.type === "heartbeat")
83
+ if (event.type === "heartbeat" ||
84
+ event.type === "vital" ||
85
+ event.type === "leave")
75
86
  continue;
76
87
  const eventIds = eventIdsByGroup.get(groupKey) ?? new Set();
77
88
  if (eventIds.has(event.eventId)) {
@@ -97,7 +108,6 @@ export const ingestBatch = mutation({
97
108
  siteId: args.siteId,
98
109
  sessionId: group.sessionId,
99
110
  bucketStart: group.bucketStart,
100
- candidateEventIds: group.eventIds,
101
111
  });
102
112
  const newCount = [...group.eventIds].filter((eventId) => !eventIds.has(eventId)).length;
103
113
  if (newCount > 0 &&
@@ -136,7 +146,52 @@ export const ingestBatch = mutation({
136
146
  rejected += 1;
137
147
  continue;
138
148
  }
139
- await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now, batchState);
149
+ const durationDeltaMs = await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now, batchState);
150
+ // A heartbeat is presence, not telemetry, but it is what keeps a
151
+ // session's duration honest while the visitor reads: the growth is
152
+ // folded into the buckets the heartbeat landed in.
153
+ foldSessionDuration(aggregateDeltas, args.siteId, event, durationDeltaMs, timezone);
154
+ accepted += 1;
155
+ continue;
156
+ }
157
+ if (event.type === "leave") {
158
+ // A departure only shortens live presence: it stores no telemetry,
159
+ // touches no aggregates, and never extends anything, so a replayed
160
+ // or late leave is inert. The read side filters by expiresAt and the
161
+ // sweep deletes the row, so the dashboard sees the exit in seconds.
162
+ if (!existingSession) {
163
+ rejected += 1;
164
+ continue;
165
+ }
166
+ if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
167
+ rejected += 1;
168
+ continue;
169
+ }
170
+ const liveState = await loadLiveSession(ctx, args.siteId, event.sessionId, batchState);
171
+ const liveSession = liveState.current;
172
+ if (liveSession) {
173
+ const expiresAt = Math.min(liveSession.expiresAt, now + LIVE_LEAVE_GRACE_MS);
174
+ if (expiresAt < liveSession.expiresAt) {
175
+ await ctx.db.patch("liveSessions", liveSession._id, { expiresAt });
176
+ liveState.current = { ...liveSession, expiresAt };
177
+ }
178
+ await ensureLiveSweep(ctx, args.siteId, now, batchState);
179
+ }
180
+ accepted += 1;
181
+ continue;
182
+ }
183
+ if (event.type === "vital") {
184
+ // A vital annotates telemetry its session already produced; it never
185
+ // creates sessions, live presence, event batches, or aggregate counts.
186
+ if (!existingSession) {
187
+ rejected += 1;
188
+ continue;
189
+ }
190
+ if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
191
+ rejected += 1;
192
+ continue;
193
+ }
194
+ foldVitalRollups(reportRollupDeltas, args.siteId, event, vitalDevice(context.device));
140
195
  accepted += 1;
141
196
  continue;
142
197
  }
@@ -164,26 +219,29 @@ export const ingestBatch = mutation({
164
219
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
165
220
  .take(MAX_FUNNELS_PER_SITE);
166
221
  }
167
- await accountEvent(ctx, {
222
+ const session = await accountEvent(ctx, {
168
223
  siteId: args.siteId,
169
224
  event,
170
225
  context,
171
226
  existingSession,
172
- goals: canMatchDefinition ? goals ?? [] : [],
173
- funnels: canMatchDefinition ? funnels ?? [] : [],
227
+ goals: canMatchDefinition ? (goals ?? []) : [],
228
+ funnels: canMatchDefinition ? (funnels ?? []) : [],
174
229
  aggregateDeltas,
230
+ reportRollupDeltas,
175
231
  recordTelemetry: true,
232
+ timezone,
176
233
  financialConversion: false,
177
234
  trustedGoalKey: undefined,
178
235
  now,
179
236
  batchState,
180
237
  });
181
- bufferEventBatch(args.siteId, event, batchState.eventBatches);
238
+ bufferEventBatch(args.siteId, event, batchState.eventBatches, session.country ?? "unknown");
182
239
  accepted += 1;
183
240
  }
184
241
  await flushBatchState(ctx, batchState);
185
242
  await flushSessionRateLimits(ctx, sessionRateWindows, now);
186
243
  await flushAggregates(ctx, aggregateDeltas);
244
+ await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
187
245
  return { accepted, duplicates, rejected };
188
246
  },
189
247
  });
@@ -261,7 +319,8 @@ export const trackConversion = mutation({
261
319
  device: existingTelemetry?.device ?? existingSession?.device,
262
320
  };
263
321
  const aggregateDeltas = new Map();
264
- await accountEvent(ctx, {
322
+ const reportRollupDeltas = createReportRollupDeltas();
323
+ const session = await accountEvent(ctx, {
265
324
  siteId: args.siteId,
266
325
  event,
267
326
  context: conversionContext,
@@ -269,15 +328,20 @@ export const trackConversion = mutation({
269
328
  goals: [],
270
329
  funnels: [],
271
330
  aggregateDeltas,
331
+ reportRollupDeltas,
272
332
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
333
+ timezone: localDayTimezone(site.timezone),
273
334
  financialConversion: true,
274
335
  trustedGoalKey,
275
336
  now: Date.now(),
276
337
  });
277
338
  if (!existingTelemetry && !telemetry.saturated) {
278
- await insertEventBatch(ctx, args.siteId, event);
339
+ await insertEventBatch(ctx, args.siteId, event, session.country ?? "unknown");
279
340
  }
280
341
  await flushAggregates(ctx, aggregateDeltas);
342
+ const now = Date.now();
343
+ await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
344
+ await ensureAnalyticsControl(ctx, args.siteId, now);
281
345
  return { accepted: 1, duplicates: 0, rejected: 0 };
282
346
  },
283
347
  });
@@ -347,8 +411,13 @@ function withoutSystemFields(document) {
347
411
  async function accountEvent(ctx, input) {
348
412
  const { event } = input;
349
413
  const session = input.recordTelemetry
350
- ? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion, input.batchState)
414
+ ? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion, input.reportRollupDeltas, input.batchState)
351
415
  : await accountTrustedSession(ctx, input.existingSession, event);
416
+ if (input.recordTelemetry && event.type === "pageview") {
417
+ // `existingSession` is the snapshot from before `upsertSession`, so it
418
+ // still carries the route the visitor is leaving.
419
+ foldRouteRollups(input.reportRollupDeltas, input.siteId, event, session, input.existingSession);
420
+ }
352
421
  const matchedGoalKeys = [];
353
422
  const matchingGoals = input.goals.filter((goal) => goal.active && goalMatches(goal, event));
354
423
  if (matchingGoals.length > 1) {
@@ -366,8 +435,7 @@ async function accountEvent(ctx, input) {
366
435
  .eq("siteId", input.siteId)
367
436
  .eq("goalId", goal._id)
368
437
  .eq("eventId", completionEventId))
369
- .take(2))
370
- .find((completion) => completion.idempotencyVersion === 2);
438
+ .take(2)).find((completion) => completion.idempotencyVersion === 2);
371
439
  const legacyPrior = scopedPrior
372
440
  ? undefined
373
441
  : (await ctx.db
@@ -376,8 +444,7 @@ async function accountEvent(ctx, input) {
376
444
  .eq("siteId", input.siteId)
377
445
  .eq("goalId", goal._id)
378
446
  .eq("eventId", event.eventId))
379
- .take(2))
380
- .find((completion) => completion.idempotencyVersion === undefined &&
447
+ .take(2)).find((completion) => completion.idempotencyVersion === undefined &&
381
448
  completion.sessionId === event.sessionId &&
382
449
  eventBucketStart(completion.timestamp) ===
383
450
  eventBucketStart(event.timestamp));
@@ -394,33 +461,39 @@ async function accountEvent(ctx, input) {
394
461
  timestamp: event.timestamp,
395
462
  valueCents: goal.valueCents ?? 0,
396
463
  });
464
+ foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
397
465
  matchedGoalKeys.push(goal.key);
398
466
  }
399
467
  for (const funnel of input.funnels) {
400
468
  if (funnel.active) {
401
- await advanceFunnel(ctx, input.siteId, funnel, event, input.batchState);
469
+ await advanceFunnel(ctx, input.siteId, funnel, event, input.reportRollupDeltas, input.batchState);
402
470
  }
403
471
  }
404
472
  if (input.financialConversion) {
405
- await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey);
473
+ await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey, input.reportRollupDeltas);
406
474
  }
475
+ const campaignDimensions = session.utmCampaign
476
+ ? [{ type: "campaign", value: session.utmCampaign }]
477
+ : [];
407
478
  const dimensions = [
408
479
  { type: "source", value: session.source },
480
+ ...campaignDimensions,
409
481
  { type: "page", value: event.path },
410
- { type: "country", value: input.context.country ?? "unknown" },
482
+ { type: "country", value: session.country ?? "unknown" },
411
483
  { type: "device", value: input.context.device ?? "unknown" },
412
484
  { type: "browser", value: input.context.browser ?? "Other" },
413
485
  {
414
486
  type: "event",
415
- value: event.type === "custom" ? event.name ?? "custom" : event.type,
487
+ value: event.type === "custom" ? (event.name ?? "custom") : event.type,
416
488
  },
417
489
  ...matchedGoalKeys.map((value) => ({ type: "goal", value })),
418
490
  ];
419
- foldAggregates(input.aggregateDeltas, input.siteId, event, session, dimensions, input.recordTelemetry, input.financialConversion);
491
+ foldAggregates(input.aggregateDeltas, input.siteId, event, session, dimensions, input.recordTelemetry, input.financialConversion, input.timezone);
420
492
  if (input.recordTelemetry &&
421
493
  event.timestamp >= input.now - LIVE_SESSION_TTL_MS) {
422
494
  await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now, input.batchState);
423
495
  }
496
+ return session;
424
497
  }
425
498
  async function accountTrustedSession(ctx, existing, event) {
426
499
  if (!existing) {
@@ -436,8 +509,17 @@ async function accountTrustedSession(ctx, existing, event) {
436
509
  isNewSession: false,
437
510
  isNewVisitorHour: false,
438
511
  isNewVisitorDay: false,
512
+ isNewVisitor: false,
513
+ durationDeltaMs: 0,
514
+ continued: false,
439
515
  startedAt: existing.startedAt,
440
516
  source: existing.source,
517
+ utmCampaign: existing.utmCampaign,
518
+ visitorKey: existing.visitorKey ?? existing.visitorId,
519
+ country: existing.country,
520
+ city: existing.city,
521
+ latitude: existing.latitude,
522
+ longitude: existing.longitude,
441
523
  pageviewCount: existing.pageviewCount,
442
524
  eventCount: existing.eventCount,
443
525
  affiliateId: existing.affiliateId,
@@ -446,13 +528,23 @@ async function accountTrustedSession(ctx, existing, event) {
446
528
  affiliateExpiresAt: existing.affiliateExpiresAt,
447
529
  };
448
530
  }
449
- async function upsertSession(ctx, siteId, event, context, existing, financialConversion, batchState) {
450
- const source = sourceFromReferrer(event.referrer);
531
+ async function upsertSession(ctx, siteId, event, context, existing, financialConversion, reportRollupDeltas, batchState) {
532
+ // A host-supplied id is the identity; anonymous sessions count through the
533
+ // daily key derived by the HTTP action, or once per session without one.
534
+ const visitorKey = existing?.visitorKey ??
535
+ (event.visitorId !== event.sessionId
536
+ ? event.visitorId
537
+ : (context.visitorKey ?? event.sessionId));
451
538
  const utmSource = stringProperty(event.properties, "utm_source");
539
+ // Campaign traffic names its own source; the referrer host is the fallback.
540
+ const source = utmSource?.toLowerCase() ?? sourceFromReferrer(event.referrer);
452
541
  const utmMedium = stringProperty(event.properties, "utm_medium");
453
542
  const utmCampaign = stringProperty(event.properties, "utm_campaign");
454
543
  const attribution = await attributionForEvent(ctx, siteId, event, existing, batchState);
455
544
  if (existing) {
545
+ if (attribution && existing.affiliateId !== attribution.affiliateId) {
546
+ foldAffiliateVisitRollup(reportRollupDeltas, siteId, event, attribution, visitorKey);
547
+ }
456
548
  const startedAt = Math.min(existing.startedAt, event.timestamp);
457
549
  const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
458
550
  const updates = {
@@ -462,10 +554,15 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
462
554
  exitPath: event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
463
555
  eventCount: existing.eventCount + 1,
464
556
  pageviewCount: existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
557
+ ...(event.type === "pageview"
558
+ ? {
559
+ lastPageviewAt: Math.max(existing.lastPageviewAt ?? 0, event.timestamp),
560
+ }
561
+ : {}),
465
562
  durationMs: Math.max(0, lastSeenAt - startedAt),
466
563
  conversionCount: existing.conversionCount + Number(financialConversion),
467
564
  revenueCents: existing.revenueCents +
468
- (financialConversion ? event.revenueCents ?? 0 : 0),
565
+ (financialConversion ? (event.revenueCents ?? 0) : 0),
469
566
  ...(financialConversion && event.currency
470
567
  ? { currency: event.currency }
471
568
  : {}),
@@ -476,7 +573,9 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
476
573
  ...(context.country ? { country: context.country } : {}),
477
574
  ...(context.city ? { city: context.city } : {}),
478
575
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
479
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
576
+ ...(context.longitude !== undefined
577
+ ? { longitude: context.longitude }
578
+ : {}),
480
579
  browser: context.browser ?? existing.browser,
481
580
  os: context.os ?? existing.os,
482
581
  device: context.device ?? existing.device,
@@ -497,8 +596,17 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
497
596
  Math.floor(event.timestamp / HOUR_MS),
498
597
  isNewVisitorDay: Math.floor(existing.lastSeenAt / DAY_MS) <
499
598
  Math.floor(event.timestamp / DAY_MS),
599
+ isNewVisitor: false,
600
+ durationDeltaMs: Math.max(0, updates.durationMs - existing.durationMs),
601
+ continued: event.type === "pageview" && existing.pageviewCount === 1,
500
602
  startedAt,
501
603
  source: existing.source,
604
+ utmCampaign: existing.utmCampaign,
605
+ visitorKey,
606
+ country: context.country ?? existing.country,
607
+ city: context.city ?? existing.city,
608
+ latitude: context.latitude ?? existing.latitude,
609
+ longitude: context.longitude ?? existing.longitude,
502
610
  pageviewCount: existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
503
611
  eventCount: existing.eventCount + 1,
504
612
  ...(attribution ?? {}),
@@ -520,10 +628,23 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
520
628
  }
521
629
  }
522
630
  }
631
+ // A visitor is new when nothing was ever recorded under their id. A host
632
+ // id whose anonymous history was linked at sign-up is a return, not a new
633
+ // person; a browser without a persistent id is new every session.
634
+ let isNewVisitor = priorVisit === null;
635
+ if (isNewVisitor && event.visitorId !== event.sessionId) {
636
+ const alias = await ctx.db
637
+ .query("visitorAliases")
638
+ .withIndex("by_siteId_and_visitorId", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
639
+ .first();
640
+ if (alias)
641
+ isNewVisitor = false;
642
+ }
523
643
  const fields = {
524
644
  siteId,
525
645
  sessionId: event.sessionId,
526
646
  visitorId: event.visitorId,
647
+ visitorKey,
527
648
  startedAt: event.timestamp,
528
649
  lastSeenAt: event.timestamp,
529
650
  entryPath: event.path,
@@ -536,21 +657,27 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
536
657
  ...(context.country ? { country: context.country } : {}),
537
658
  ...(context.city ? { city: context.city } : {}),
538
659
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
539
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
660
+ ...(context.longitude !== undefined
661
+ ? { longitude: context.longitude }
662
+ : {}),
540
663
  browser: context.browser ?? "Other",
541
664
  os: context.os ?? "Other",
542
665
  device: context.device ?? "unknown",
543
666
  eventCount: 1,
544
667
  pageviewCount: event.type === "pageview" ? 1 : 0,
668
+ ...(event.type === "pageview" ? { lastPageviewAt: event.timestamp } : {}),
545
669
  durationMs: 0,
546
670
  conversionCount: Number(financialConversion),
547
- revenueCents: financialConversion ? event.revenueCents ?? 0 : 0,
671
+ revenueCents: financialConversion ? (event.revenueCents ?? 0) : 0,
548
672
  ...(financialConversion && event.currency
549
673
  ? { currency: event.currency }
550
674
  : {}),
551
675
  ...(attribution ?? {}),
552
676
  };
553
677
  const sessionId = await ctx.db.insert("sessions", fields);
678
+ if (attribution) {
679
+ foldAffiliateVisitRollup(reportRollupDeltas, siteId, event, attribution, visitorKey);
680
+ }
554
681
  if (batchState) {
555
682
  const state = batchState.sessions.get(event.sessionId);
556
683
  if (!state)
@@ -568,8 +695,17 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
568
695
  isNewVisitorDay: priorVisit === null ||
569
696
  Math.floor(priorVisit.lastSeenAt / DAY_MS) <
570
697
  Math.floor(event.timestamp / DAY_MS),
698
+ isNewVisitor,
699
+ durationDeltaMs: 0,
700
+ continued: false,
571
701
  startedAt: event.timestamp,
572
702
  source,
703
+ utmCampaign,
704
+ visitorKey,
705
+ country: context.country,
706
+ city: context.city,
707
+ latitude: context.latitude,
708
+ longitude: context.longitude,
573
709
  pageviewCount: event.type === "pageview" ? 1 : 0,
574
710
  eventCount: 1,
575
711
  ...(attribution ?? {}),
@@ -689,14 +825,14 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
689
825
  const updates = {
690
826
  startedAt,
691
827
  lastSeenAt,
692
- exitPath: event.timestamp >= session.lastSeenAt
693
- ? event.path
694
- : session.exitPath,
828
+ exitPath: event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
695
829
  durationMs: Math.max(0, lastSeenAt - startedAt),
696
830
  ...(context.country ? { country: context.country } : {}),
697
831
  ...(context.city ? { city: context.city } : {}),
698
832
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
699
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
833
+ ...(context.longitude !== undefined
834
+ ? { longitude: context.longitude }
835
+ : {}),
700
836
  browser: context.browser ?? session.browser,
701
837
  os: context.os ?? session.os,
702
838
  device: context.device ?? session.device,
@@ -711,26 +847,33 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
711
847
  else {
712
848
  await ctx.db.patch("sessions", session._id, updates);
713
849
  }
850
+ const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
714
851
  await updateLiveSession(ctx, siteId, event, context, {
715
852
  isNewSession: false,
716
853
  isNewVisitorHour: false,
717
854
  isNewVisitorDay: false,
855
+ isNewVisitor: false,
856
+ durationDeltaMs: 0,
857
+ continued: false,
718
858
  startedAt,
719
859
  source: session.source,
860
+ visitorKey: session.visitorKey ?? session.visitorId,
861
+ country: context.country ?? session.country,
862
+ city: context.city ?? session.city,
863
+ latitude: context.latitude ?? session.latitude,
864
+ longitude: context.longitude ?? session.longitude,
720
865
  pageviewCount: session.pageviewCount,
721
866
  eventCount: session.eventCount,
722
867
  affiliateSlug: session.affiliateSlug,
723
868
  }, now, batchState);
869
+ return durationDeltaMs;
724
870
  }
725
- async function consumeSiteRateLimit(ctx, siteId, events, now) {
871
+ async function consumeSiteRateLimit(ctx, siteId, events, eventByteCounts, now) {
726
872
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
727
873
  const increments = new Map();
728
- const eventByteCounts = [];
729
- const encoder = new TextEncoder();
730
874
  for (let index = 0; index < events.length; index += 1) {
731
875
  const event = events[index];
732
- const byteCount = encoder.encode(JSON.stringify(event)).byteLength;
733
- eventByteCounts.push(byteCount);
876
+ const byteCount = eventByteCounts[index];
734
877
  const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
735
878
  const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
736
879
  current.eventCount += 1;
@@ -745,9 +888,7 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
745
888
  .order("desc")
746
889
  .take(1);
747
890
  const storedWindow = rows[0] ?? null;
748
- const window = storedWindow?.windowStart === windowStart
749
- ? storedWindow
750
- : null;
891
+ const window = storedWindow?.windowStart === windowStart ? storedWindow : null;
751
892
  if ((window?.eventCount ?? 0) + increment.eventCount >
752
893
  MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
753
894
  (window?.byteCount ?? 0) + increment.byteCount >
@@ -787,7 +928,6 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
787
928
  });
788
929
  }
789
930
  }
790
- return eventByteCounts;
791
931
  }
792
932
  async function consumeRateLimit(ctx, siteId, sessionId, byteCount, now, states) {
793
933
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
@@ -855,6 +995,10 @@ async function updateLiveSession(ctx, siteId, event, context, session, now, batc
855
995
  .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", event.sessionId))
856
996
  .unique();
857
997
  const deadline = now + LIVE_SESSION_TTL_MS;
998
+ const country = context.country ?? session.country;
999
+ const city = context.city ?? session.city;
1000
+ const latitude = context.latitude ?? session.latitude;
1001
+ const longitude = context.longitude ?? session.longitude;
858
1002
  if (existing) {
859
1003
  const recentHeartbeatEventIds = event.type === "heartbeat"
860
1004
  ? [
@@ -870,16 +1014,21 @@ async function updateLiveSession(ctx, siteId, event, context, session, now, batc
870
1014
  path: event.path,
871
1015
  lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
872
1016
  expiresAt: deadline,
873
- ...(context.country ? { country: context.country } : {}),
874
- ...(context.city ? { city: context.city } : {}),
875
- ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
876
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1017
+ ...(country ? { country } : {}),
1018
+ ...(city ? { city } : {}),
1019
+ ...(latitude !== undefined ? { latitude } : {}),
1020
+ ...(longitude !== undefined ? { longitude } : {}),
877
1021
  browser: context.browser ?? existing.browser,
878
1022
  os: context.os ?? existing.os,
879
1023
  source: session.source,
880
1024
  device: context.device ?? existing.device,
881
1025
  pageviewCount: session.pageviewCount,
882
1026
  eventCount: session.eventCount,
1027
+ // The live map animates a visitor from the route they left to the one
1028
+ // they reached; a heartbeat or click on the same page is not a move.
1029
+ ...(event.type === "pageview" && event.path !== existing.path
1030
+ ? { previousPath: existing.path, movedAt: event.timestamp }
1031
+ : {}),
883
1032
  ...(event.type === "heartbeat"
884
1033
  ? {
885
1034
  lastHeartbeatEventId: event.eventId,
@@ -904,10 +1053,10 @@ async function updateLiveSession(ctx, siteId, event, context, session, now, batc
904
1053
  startedAt: event.timestamp,
905
1054
  lastSeenAt: event.timestamp,
906
1055
  expiresAt: deadline,
907
- ...(context.country ? { country: context.country } : {}),
908
- ...(context.city ? { city: context.city } : {}),
909
- ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
910
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1056
+ ...(country ? { country } : {}),
1057
+ ...(city ? { city } : {}),
1058
+ ...(latitude !== undefined ? { latitude } : {}),
1059
+ ...(longitude !== undefined ? { longitude } : {}),
911
1060
  browser: context.browser ?? "Other",
912
1061
  os: context.os ?? "Other",
913
1062
  source: session.source,
@@ -937,9 +1086,14 @@ async function ensureLiveSweep(ctx, siteId, now, batchState) {
937
1086
  .query("liveSweepStates")
938
1087
  .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
939
1088
  .unique();
940
- if (!existing) {
1089
+ if (!existing || existing.scheduledAt <= now) {
941
1090
  const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
942
- await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1091
+ if (existing) {
1092
+ await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
1093
+ }
1094
+ else {
1095
+ await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1096
+ }
943
1097
  await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
944
1098
  siteId,
945
1099
  scheduledAt,
@@ -953,7 +1107,7 @@ function goalMatches(goal, event) {
953
1107
  }
954
1108
  return event.type === "custom" && event.name === goal.matchValue;
955
1109
  }
956
- async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
1110
+ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, batchState) {
957
1111
  const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
958
1112
  let progressState = batchState?.funnelProgress.get(cacheKey);
959
1113
  if (batchState && !progressState) {
@@ -996,10 +1150,12 @@ async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
996
1150
  _id: progressId,
997
1151
  };
998
1152
  }
999
- await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1153
+ foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1154
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1000
1155
  return;
1001
1156
  }
1002
- if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt)
1157
+ if (progress.completedAt !== undefined ||
1158
+ event.timestamp < progress.lastStepAt)
1003
1159
  return;
1004
1160
  if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
1005
1161
  if (!matchesFirst)
@@ -1025,7 +1181,8 @@ async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
1025
1181
  completedAt: undefined,
1026
1182
  });
1027
1183
  }
1028
- await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1184
+ foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1185
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1029
1186
  return;
1030
1187
  }
1031
1188
  const nextStep = funnel.steps[progress.currentStep];
@@ -1046,44 +1203,15 @@ async function advanceFunnel(ctx, siteId, funnel, event, batchState) {
1046
1203
  else {
1047
1204
  await ctx.db.patch("funnelProgress", progress._id, updates);
1048
1205
  }
1049
- await incrementFunnelStep(ctx, siteId, funnel._id, event, progress.currentStep, completed);
1206
+ foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, progress.currentStep, completed);
1207
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, progress.currentStep, completed);
1050
1208
  }
1051
1209
  function stepMatches(step, event) {
1052
1210
  return step.type === "pageview"
1053
1211
  ? event.type === "pageview" && event.path === step.value
1054
1212
  : event.type === "custom" && event.name === step.value;
1055
1213
  }
1056
- async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed) {
1057
- const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1058
- const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
1059
- const bucket = await ctx.db
1060
- .query("funnelStepBuckets")
1061
- .withIndex("by_siteId_and_funnelId_and_bucketStart_and_step_and_shard", (range) => range
1062
- .eq("siteId", siteId)
1063
- .eq("funnelId", funnelId)
1064
- .eq("bucketStart", bucketStart)
1065
- .eq("step", step)
1066
- .eq("shard", shard))
1067
- .unique();
1068
- if (bucket) {
1069
- await ctx.db.patch("funnelStepBuckets", bucket._id, {
1070
- entrants: bucket.entrants + 1,
1071
- completions: bucket.completions + (completed ? 1 : 0),
1072
- });
1073
- }
1074
- else {
1075
- await ctx.db.insert("funnelStepBuckets", {
1076
- siteId,
1077
- funnelId,
1078
- bucketStart,
1079
- step,
1080
- shard,
1081
- entrants: 1,
1082
- completions: completed ? 1 : 0,
1083
- });
1084
- }
1085
- }
1086
- async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
1214
+ async function accountConversion(ctx, siteId, session, event, trustedGoalKey, reportRollupDeltas) {
1087
1215
  const revenueCents = event.revenueCents ?? 0;
1088
1216
  const currency = event.currency ?? "USD";
1089
1217
  const candidate = attributionSnapshot(session);
@@ -1112,6 +1240,7 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
1112
1240
  commissionCents,
1113
1241
  });
1114
1242
  if (commissionAffiliate && attribution) {
1243
+ foldAffiliateConversionRollup(reportRollupDeltas, siteId, event, commissionAffiliate, attribution.affiliateSlug, revenueCents, commissionCents);
1115
1244
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1116
1245
  const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
1117
1246
  const stats = await ctx.db
@@ -1143,18 +1272,200 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
1143
1272
  }
1144
1273
  }
1145
1274
  }
1146
- function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion) {
1147
- const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
1275
+ function foldGoalRollup(deltas, siteId, event, goal) {
1276
+ const coordinates = reportRollupCoordinates(siteId, String(goal._id), event.timestamp, event.visitorId);
1277
+ const current = deltas.goals.get(coordinates.key) ?? {
1278
+ siteId,
1279
+ goalId: goal._id,
1280
+ bucketStart: coordinates.bucketStart,
1281
+ shard: coordinates.shard,
1282
+ goalKey: goal.key,
1283
+ completions: 0,
1284
+ valueCents: 0,
1285
+ };
1286
+ current.completions += 1;
1287
+ current.valueCents += goal.valueCents ?? 0;
1288
+ deltas.goals.set(coordinates.key, current);
1289
+ }
1290
+ function foldFunnelRollup(deltas, siteId, funnelId, event, step, completed) {
1291
+ const coordinates = reportRollupCoordinates(siteId, String(funnelId), event.timestamp, event.visitorId);
1292
+ const rollup = deltas.funnels.get(coordinates.key) ?? {
1293
+ siteId,
1294
+ funnelId,
1295
+ bucketStart: coordinates.bucketStart,
1296
+ shard: coordinates.shard,
1297
+ steps: new Map(),
1298
+ };
1299
+ const current = rollup.steps.get(step) ?? {
1300
+ step,
1301
+ entrants: 0,
1302
+ completions: 0,
1303
+ };
1304
+ current.entrants += 1;
1305
+ current.completions += Number(completed);
1306
+ rollup.steps.set(step, current);
1307
+ deltas.funnels.set(coordinates.key, rollup);
1308
+ }
1309
+ function foldFunnelStepDelta(deltas, siteId, funnelId, event, step, completed) {
1310
+ const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1311
+ const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
1312
+ const key = `${siteId}:${funnelId}:${bucketStart}:${step}:${shard}`;
1313
+ const current = deltas.funnelSteps.get(key) ?? {
1314
+ siteId,
1315
+ funnelId,
1316
+ bucketStart,
1317
+ step,
1318
+ shard,
1319
+ entrants: 0,
1320
+ completions: 0,
1321
+ };
1322
+ current.entrants += 1;
1323
+ current.completions += Number(completed);
1324
+ deltas.funnelSteps.set(key, current);
1325
+ }
1326
+ function foldAffiliateVisitRollup(deltas, siteId, event, attribution, visitorKey) {
1327
+ const coordinates = reportRollupCoordinates(siteId, String(attribution.affiliateId), event.timestamp, event.visitorId);
1328
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1329
+ siteId,
1330
+ affiliateId: attribution.affiliateId,
1331
+ bucketStart: coordinates.bucketStart,
1332
+ shard: coordinates.shard,
1333
+ affiliateSlug: attribution.affiliateSlug,
1334
+ visits: 0,
1335
+ visitorKeys: new Set(),
1336
+ conversions: 0,
1337
+ revenueCents: 0,
1338
+ commissionCents: 0,
1339
+ };
1340
+ current.visits += 1;
1341
+ current.visitorKeys.add(visitorKey);
1342
+ deltas.affiliates.set(coordinates.key, current);
1343
+ }
1344
+ function foldAffiliateConversionRollup(deltas, siteId, event, affiliate, affiliateSlug, revenueCents, commissionCents) {
1345
+ const coordinates = reportRollupCoordinates(siteId, String(affiliate._id), event.timestamp, event.visitorId);
1346
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1347
+ siteId,
1348
+ affiliateId: affiliate._id,
1349
+ bucketStart: coordinates.bucketStart,
1350
+ shard: coordinates.shard,
1351
+ affiliateSlug,
1352
+ visits: 0,
1353
+ visitorKeys: new Set(),
1354
+ conversions: 0,
1355
+ revenueCents: 0,
1356
+ commissionCents: 0,
1357
+ };
1358
+ current.conversions += 1;
1359
+ current.revenueCents += revenueCents;
1360
+ current.commissionCents += commissionCents;
1361
+ deltas.affiliates.set(coordinates.key, current);
1362
+ }
1363
+ function foldVitalRollups(deltas, siteId, event, device) {
1364
+ const metric = event.name;
1365
+ const value = event.value ?? 0;
1366
+ const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1367
+ const shard = stableHash(event.sessionId) % REPORT_ROLLUP_SHARDS;
1368
+ // Each measurement lands twice: on its page (across devices) and on its
1369
+ // device class (across pages), so both breakdowns stay exact and bounded.
1370
+ for (const target of [
1371
+ { device: VITAL_ALL, page: event.path },
1372
+ { device, page: VITAL_ALL },
1373
+ ]) {
1374
+ const key = JSON.stringify([
1375
+ siteId,
1376
+ bucketStart,
1377
+ target.device,
1378
+ target.page,
1379
+ shard,
1380
+ ]);
1381
+ const row = deltas.vitals.get(key) ?? {
1382
+ siteId,
1383
+ bucketStart,
1384
+ device: target.device,
1385
+ page: target.page,
1386
+ shard,
1387
+ metrics: new Map(),
1388
+ };
1389
+ const entry = row.metrics.get(metric) ?? {
1390
+ count: 0,
1391
+ sum: 0,
1392
+ histogram: emptyVitalHistogram(),
1393
+ };
1394
+ entry.count += 1;
1395
+ entry.sum += value;
1396
+ entry.histogram = addToVitalHistogram(entry.histogram, value);
1397
+ row.metrics.set(metric, entry);
1398
+ deltas.vitals.set(key, row);
1399
+ }
1400
+ }
1401
+ function foldRouteDelta(deltas, siteId, route, timestamp, visitorId, apply) {
1402
+ const coordinates = reportRollupCoordinates(siteId, `route:${route}`, timestamp, visitorId);
1403
+ const current = deltas.routes.get(coordinates.key) ?? {
1404
+ siteId,
1405
+ bucketStart: coordinates.bucketStart,
1406
+ shard: coordinates.shard,
1407
+ route,
1408
+ pageviews: 0,
1409
+ entries: 0,
1410
+ continued: 0,
1411
+ dwellMs: 0,
1412
+ dwellSamples: 0,
1413
+ visitorKeys: new Set(),
1414
+ outgoing: new Map(),
1415
+ };
1416
+ apply(current);
1417
+ deltas.routes.set(coordinates.key, current);
1418
+ }
1419
+ /**
1420
+ * One pageview feeds the site map three ways: the route it lands on gains a
1421
+ * view (and an entry when it opens the session), the route it leaves gains
1422
+ * the transition and the time spent there, and the session's entry route is
1423
+ * marked as continued the first time its visitor moves on — which is what
1424
+ * lets the report derive bounces and exits without a session-end hook.
1425
+ */
1426
+ function foldRouteRollups(deltas, siteId, event, session, existing) {
1427
+ const route = normalizeRoute(event.path);
1428
+ const priorPageviews = existing?.pageviewCount ?? 0;
1429
+ const firstPageview = existing === null || priorPageviews === 0;
1430
+ foldRouteDelta(deltas, siteId, route, event.timestamp, event.visitorId, (delta) => {
1431
+ delta.pageviews += 1;
1432
+ if (firstPageview)
1433
+ delta.entries += 1;
1434
+ delta.visitorKeys.add(session.visitorKey);
1435
+ });
1436
+ // Out-of-order telemetry keeps its view but cannot say where it came from.
1437
+ if (existing === null || firstPageview)
1438
+ return;
1439
+ if (event.timestamp < existing.lastSeenAt)
1440
+ return;
1441
+ const previous = normalizeRoute(existing.exitPath);
1442
+ const lastPageviewAt = existing.lastPageviewAt;
1443
+ foldRouteDelta(deltas, siteId, previous, event.timestamp, event.visitorId, (delta) => {
1444
+ delta.outgoing.set(route, (delta.outgoing.get(route) ?? 0) + 1);
1445
+ if (lastPageviewAt !== undefined && event.timestamp >= lastPageviewAt) {
1446
+ delta.dwellMs += event.timestamp - lastPageviewAt;
1447
+ delta.dwellSamples += 1;
1448
+ }
1449
+ });
1450
+ if (priorPageviews === 1) {
1451
+ foldRouteDelta(deltas, siteId, normalizeRoute(existing.entryPath), existing.startedAt, event.visitorId, (delta) => {
1452
+ delta.continued += 1;
1453
+ });
1454
+ }
1455
+ }
1456
+ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion, timezone) {
1457
+ const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
1148
1458
  const dimensionDeltas = dimensions.map((dimension) => ({
1149
1459
  ...dimension,
1150
1460
  count: Number(recordTelemetry),
1151
1461
  revenueCents,
1462
+ pageviews: Number(recordTelemetry && event.type === "pageview"),
1463
+ sessions: Number(recordTelemetry && session.isNewSession),
1152
1464
  }));
1153
- for (const granularity of ["hour", "day"]) {
1465
+ for (const granularity of aggregateGranularities(timezone)) {
1154
1466
  const shard = stableHash(event.sessionId) %
1155
1467
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
1156
- const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
1157
- const bucketStart = Math.floor(event.timestamp / interval) * interval;
1468
+ const bucketStart = aggregateBucketStart(granularity, event.timestamp, timezone);
1158
1469
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
1159
1470
  let delta = aggregateDeltas.get(key);
1160
1471
  if (!delta) {
@@ -1170,9 +1481,13 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
1170
1481
  outboundClicks: 0,
1171
1482
  sessions: 0,
1172
1483
  visitors: 0,
1484
+ visitorSketch: createVisitorSketch(),
1173
1485
  conversions: 0,
1174
1486
  revenueCents: 0,
1175
1487
  dimensions: [],
1488
+ durationMs: 0,
1489
+ continuedSessions: 0,
1490
+ newVisitors: 0,
1176
1491
  };
1177
1492
  aggregateDeltas.set(key, delta);
1178
1493
  }
@@ -1188,9 +1503,64 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
1188
1503
  ? Number(session.isNewVisitorHour)
1189
1504
  : Number(session.isNewVisitorDay)
1190
1505
  : 0;
1506
+ if (recordTelemetry) {
1507
+ delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
1508
+ }
1191
1509
  delta.conversions += Number(financialConversion);
1192
1510
  delta.revenueCents += revenueCents;
1193
1511
  delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
1512
+ if (recordTelemetry) {
1513
+ delta.durationMs += session.durationDeltaMs;
1514
+ delta.continuedSessions += Number(session.continued);
1515
+ delta.newVisitors += Number(session.isNewSession && session.isNewVisitor);
1516
+ }
1517
+ }
1518
+ }
1519
+ /**
1520
+ * The buckets an event lands in: UTC hour and UTC day always, plus the
1521
+ * calendar day in the site's timezone when the site keeps one.
1522
+ */
1523
+ function aggregateGranularities(timezone) {
1524
+ return timezone ? ["hour", "day", "localDay"] : ["hour", "day"];
1525
+ }
1526
+ function aggregateBucketStart(granularity, timestamp, timezone) {
1527
+ if (granularity === "localDay" && timezone) {
1528
+ return localDayStart(timestamp, timezone);
1529
+ }
1530
+ const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
1531
+ return Math.floor(timestamp / interval) * interval;
1532
+ }
1533
+ /** Folds only session duration growth, for heartbeats. */
1534
+ function foldSessionDuration(aggregateDeltas, siteId, event, durationDeltaMs, timezone) {
1535
+ if (durationDeltaMs <= 0)
1536
+ return;
1537
+ for (const granularity of aggregateGranularities(timezone)) {
1538
+ const shard = stableHash(event.sessionId) %
1539
+ (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
1540
+ const bucketStart = aggregateBucketStart(granularity, event.timestamp, timezone);
1541
+ const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
1542
+ const delta = aggregateDeltas.get(key) ?? {
1543
+ siteId,
1544
+ granularity,
1545
+ bucketStart,
1546
+ shard,
1547
+ events: 0,
1548
+ pageviews: 0,
1549
+ clicks: 0,
1550
+ customEvents: 0,
1551
+ outboundClicks: 0,
1552
+ sessions: 0,
1553
+ visitors: 0,
1554
+ visitorSketch: createVisitorSketch(),
1555
+ conversions: 0,
1556
+ revenueCents: 0,
1557
+ dimensions: [],
1558
+ durationMs: 0,
1559
+ continuedSessions: 0,
1560
+ newVisitors: 0,
1561
+ };
1562
+ delta.durationMs += durationDeltaMs;
1563
+ aggregateDeltas.set(key, delta);
1194
1564
  }
1195
1565
  }
1196
1566
  async function flushAggregates(ctx, aggregateDeltas) {
@@ -1203,7 +1573,18 @@ async function flushAggregates(ctx, aggregateDeltas) {
1203
1573
  .eq("bucketStart", delta.bucketStart)
1204
1574
  .eq("shard", delta.shard))
1205
1575
  .unique();
1206
- const dimensions = mergeDimensions(bucket?.dimensions ?? [], delta.dimensions);
1576
+ const dimensions = mergeDimensions(bucket?.dimensions ?? [], delta.dimensions.map((slot) => ({
1577
+ ...slot,
1578
+ pageviews: slot.pageviews ?? 0,
1579
+ sessions: slot.sessions ?? 0,
1580
+ })));
1581
+ const priorSketch = bucket?.visitorSketch;
1582
+ const visitorSketch = priorSketch
1583
+ ? unionVisitorSketches(priorSketch, delta.visitorSketch)
1584
+ : delta.visitorSketch;
1585
+ const visitorSketchComplete = bucket
1586
+ ? bucket.visitorSketchComplete === true && priorSketch !== undefined
1587
+ : true;
1207
1588
  if (bucket) {
1208
1589
  await ctx.db.patch("aggregateBuckets", bucket._id, {
1209
1590
  events: bucket.events + delta.events,
@@ -1213,9 +1594,14 @@ async function flushAggregates(ctx, aggregateDeltas) {
1213
1594
  outboundClicks: bucket.outboundClicks + delta.outboundClicks,
1214
1595
  sessions: bucket.sessions + delta.sessions,
1215
1596
  visitors: bucket.visitors + delta.visitors,
1597
+ visitorSketch,
1598
+ visitorSketchComplete,
1216
1599
  conversions: bucket.conversions + delta.conversions,
1217
1600
  revenueCents: bucket.revenueCents + delta.revenueCents,
1218
1601
  dimensions,
1602
+ durationMs: (bucket.durationMs ?? 0) + delta.durationMs,
1603
+ continuedSessions: (bucket.continuedSessions ?? 0) + delta.continuedSessions,
1604
+ newVisitors: (bucket.newVisitors ?? 0) + delta.newVisitors,
1219
1605
  });
1220
1606
  }
1221
1607
  else {
@@ -1231,9 +1617,14 @@ async function flushAggregates(ctx, aggregateDeltas) {
1231
1617
  outboundClicks: delta.outboundClicks,
1232
1618
  sessions: delta.sessions,
1233
1619
  visitors: delta.visitors,
1620
+ visitorSketch,
1621
+ visitorSketchComplete,
1234
1622
  conversions: delta.conversions,
1235
1623
  revenueCents: delta.revenueCents,
1236
1624
  dimensions,
1625
+ durationMs: delta.durationMs,
1626
+ continuedSessions: delta.continuedSessions,
1627
+ newVisitors: delta.newVisitors,
1237
1628
  });
1238
1629
  }
1239
1630
  }
@@ -1245,6 +1636,8 @@ function mergeDimensions(current, additions) {
1245
1636
  if (exact) {
1246
1637
  exact.count += addition.count;
1247
1638
  exact.revenueCents += addition.revenueCents;
1639
+ exact.pageviews = (exact.pageviews ?? 0) + addition.pageviews;
1640
+ exact.sessions = (exact.sessions ?? 0) + addition.sessions;
1248
1641
  continue;
1249
1642
  }
1250
1643
  const typeSlots = result.filter((candidate) => candidate.type === addition.type);
@@ -1261,6 +1654,8 @@ function mergeDimensions(current, additions) {
1261
1654
  value: addition.value,
1262
1655
  count: addition.count,
1263
1656
  revenueCents: addition.revenueCents,
1657
+ pageviews: addition.pageviews,
1658
+ sessions: addition.sessions,
1264
1659
  });
1265
1660
  continue;
1266
1661
  }
@@ -1268,6 +1663,8 @@ function mergeDimensions(current, additions) {
1268
1663
  if (overflow) {
1269
1664
  overflow.count += addition.count;
1270
1665
  overflow.revenueCents += addition.revenueCents;
1666
+ overflow.pageviews = (overflow.pageviews ?? 0) + addition.pageviews;
1667
+ overflow.sessions = (overflow.sessions ?? 0) + addition.sessions;
1271
1668
  continue;
1272
1669
  }
1273
1670
  if (addition.count === 0 && addition.revenueCents === 0)
@@ -1283,6 +1680,8 @@ function mergeDimensions(current, additions) {
1283
1680
  value: "(other)",
1284
1681
  count: addition.count,
1285
1682
  revenueCents: addition.revenueCents,
1683
+ pageviews: addition.pageviews,
1684
+ sessions: addition.sessions,
1286
1685
  });
1287
1686
  continue;
1288
1687
  }
@@ -1294,11 +1693,15 @@ function mergeDimensions(current, additions) {
1294
1693
  replacement.value = "(other)";
1295
1694
  replacement.count += addition.count;
1296
1695
  replacement.revenueCents += addition.revenueCents;
1696
+ replacement.pageviews = (replacement.pageviews ?? 0) + addition.pageviews;
1697
+ replacement.sessions = (replacement.sessions ?? 0) + addition.sessions;
1297
1698
  }
1298
1699
  return result;
1299
1700
  }
1300
1701
  function stringProperty(properties, key) {
1301
1702
  const value = properties?.[key];
1302
- return typeof value === "string" ? cleanString(value, 120) || undefined : undefined;
1703
+ return typeof value === "string"
1704
+ ? cleanString(value, 120) || undefined
1705
+ : undefined;
1303
1706
  }
1304
1707
  //# sourceMappingURL=ingest.js.map