@iann29/rastro 0.1.0-alpha.10 → 0.1.0-alpha.12

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 (89) hide show
  1. package/README.md +215 -46
  2. package/agent/integration.md +186 -46
  3. package/agent/manifest.json +28 -10
  4. package/agent/manifest.schema.json +41 -8
  5. package/dist/client/federation.d.ts +156 -11
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +82 -4
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +306 -97
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +116 -13
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +6 -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 +26 -13
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/constants.d.ts +3 -0
  19. package/dist/component/constants.d.ts.map +1 -1
  20. package/dist/component/constants.js +6 -0
  21. package/dist/component/constants.js.map +1 -1
  22. package/dist/component/coverage.d.ts +8 -0
  23. package/dist/component/coverage.d.ts.map +1 -1
  24. package/dist/component/coverage.js +32 -6
  25. package/dist/component/coverage.js.map +1 -1
  26. package/dist/component/geo.d.ts +2 -0
  27. package/dist/component/geo.d.ts.map +1 -1
  28. package/dist/component/geo.js +56 -19
  29. package/dist/component/geo.js.map +1 -1
  30. package/dist/component/http.d.ts.map +1 -1
  31. package/dist/component/http.js +47 -27
  32. package/dist/component/http.js.map +1 -1
  33. package/dist/component/ingest.d.ts.map +1 -1
  34. package/dist/component/ingest.js +149 -295
  35. package/dist/component/ingest.js.map +1 -1
  36. package/dist/component/localTime.d.ts +25 -0
  37. package/dist/component/localTime.d.ts.map +1 -0
  38. package/dist/component/localTime.js +126 -0
  39. package/dist/component/localTime.js.map +1 -0
  40. package/dist/component/reports.d.ts +20 -13
  41. package/dist/component/reports.d.ts.map +1 -1
  42. package/dist/component/reports.js +158 -38
  43. package/dist/component/reports.js.map +1 -1
  44. package/dist/component/retention.js +26 -8
  45. package/dist/component/retention.js.map +1 -1
  46. package/dist/component/rollupStore.d.ts +320 -0
  47. package/dist/component/rollupStore.d.ts.map +1 -0
  48. package/dist/component/rollupStore.js +596 -0
  49. package/dist/component/rollupStore.js.map +1 -0
  50. package/dist/component/rollups.d.ts +20 -0
  51. package/dist/component/rollups.d.ts.map +1 -0
  52. package/dist/component/rollups.js +73 -0
  53. package/dist/component/rollups.js.map +1 -0
  54. package/dist/component/schema.d.ts +311 -5
  55. package/dist/component/schema.js +9 -0
  56. package/dist/component/schema.js.map +1 -1
  57. package/dist/component/sites.d.ts +12 -0
  58. package/dist/component/sites.d.ts.map +1 -1
  59. package/dist/component/sites.js +30 -0
  60. package/dist/component/sites.js.map +1 -1
  61. package/dist/component/validators.d.ts +24 -5
  62. package/dist/component/validators.d.ts.map +1 -1
  63. package/dist/component/validators.js +16 -1
  64. package/dist/component/validators.js.map +1 -1
  65. package/dist/tracker/generated.d.ts +3 -0
  66. package/dist/tracker/generated.d.ts.map +1 -1
  67. package/dist/tracker/generated.js +3 -0
  68. package/dist/tracker/generated.js.map +1 -1
  69. package/docs/federation-setup.md +93 -26
  70. package/docs/federation.md +120 -30
  71. package/docs/upgrading.md +103 -17
  72. package/llms.txt +6 -5
  73. package/package.json +2 -1
  74. package/src/component/_generated/api.ts +6 -0
  75. package/src/component/_generated/component.ts +27 -12
  76. package/src/component/constants.ts +6 -0
  77. package/src/component/coverage.ts +41 -6
  78. package/src/component/geo.ts +82 -28
  79. package/src/component/http.ts +60 -26
  80. package/src/component/ingest.ts +226 -425
  81. package/src/component/localTime.ts +167 -0
  82. package/src/component/reports.ts +222 -39
  83. package/src/component/retention.ts +25 -7
  84. package/src/component/rollupStore.ts +799 -0
  85. package/src/component/rollups.ts +82 -0
  86. package/src/component/schema.ts +17 -0
  87. package/src/component/sites.ts +32 -0
  88. package/src/component/validators.ts +20 -1
  89. package/src/tracker/generated.ts +12 -0
@@ -1,14 +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_LEAVE_GRACE_MS, LIVE_SESSION_TTL_MS, LIVE_SWEEP_INTERVAL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_FUNNEL_STEPS, MAX_GOALS_PER_SITE, MAX_SITE_MAP_ROUTES_PER_DAY, RATE_LIMIT_WINDOW_MS, REPORT_ROLLUP_SHARDS, ROUTE_TRANSITION_SLOTS, SECONDARY_AGGREGATE_SHARDS, SITE_INGEST_SHARDS, SITE_MAP_OTHER_ROUTE, VITAL_PAGE_SLOTS, } from "./constants.js";
4
+ import { AGGREGATE_SHARDS, MAX_BATCH_EVENT_GROUPS, DAY_MS, HOURLY_AGGREGATE_SHARDS, DIMENSION_SLOTS, HOUR_MS, LIVE_LEAVE_GRACE_MS, LIVE_SESSION_TTL_MS, LIVE_SWEEP_INTERVAL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, RATE_LIMIT_WINDOW_MS, REPORT_ROLLUP_SHARDS, SECONDARY_AGGREGATE_SHARDS, SITE_INGEST_SHARDS, } from "./constants.js";
5
5
  import { addVisitor, createVisitorSketch, unionVisitorSketches, } from "./cardinality.js";
6
- import { addToVitalHistogram, emptyVitalHistogram, mergeVitalHistograms, vitalDevice, VITAL_ALL, VITAL_OTHER_PAGES, } from "./vitals.js";
6
+ import { addToVitalHistogram, emptyVitalHistogram, vitalDevice, VITAL_ALL, } from "./vitals.js";
7
7
  import { fail } from "./errors.js";
8
8
  import { ensureAnalyticsControl } from "./coverage.js";
9
9
  import { bufferEventBatch, eventBatchKey, eventBucketStart, findTelemetry, flushEventBatches, insertEventBatch, inspectTelemetryMinute, } from "./eventStore.js";
10
10
  import { cleanString, isValidMoney, measureBatchBytes, normalizeRoute, originAllowed, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
11
11
  import { ingestContextValidator, ingestResultValidator, trackerEventValidator, trustedConversionValidator, } from "./validators.js";
12
+ import { createReportRollupDeltas, enqueueReportRollups, reportRollupCoordinates, } from "./rollupStore.js";
13
+ import { localDayStart, localDayTimezone } from "./localTime.js";
12
14
  export const ingestBatch = mutation({
13
15
  args: {
14
16
  siteId: v.id("sites"),
@@ -42,6 +44,7 @@ export const ingestBatch = mutation({
42
44
  await ensureAnalyticsControl(ctx, args.siteId, now);
43
45
  await consumeSiteRateLimit(ctx, args.siteId, args.events, eventByteCounts, now);
44
46
  const context = sanitizeContext(args.context);
47
+ const timezone = localDayTimezone(site.timezone);
45
48
  const aggregateDeltas = new Map();
46
49
  const reportRollupDeltas = createReportRollupDeltas();
47
50
  const sessionRateWindows = new Map();
@@ -143,7 +146,11 @@ export const ingestBatch = mutation({
143
146
  rejected += 1;
144
147
  continue;
145
148
  }
146
- 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);
147
154
  accepted += 1;
148
155
  continue;
149
156
  }
@@ -222,6 +229,7 @@ export const ingestBatch = mutation({
222
229
  aggregateDeltas,
223
230
  reportRollupDeltas,
224
231
  recordTelemetry: true,
232
+ timezone,
225
233
  financialConversion: false,
226
234
  trustedGoalKey: undefined,
227
235
  now,
@@ -233,7 +241,7 @@ export const ingestBatch = mutation({
233
241
  await flushBatchState(ctx, batchState);
234
242
  await flushSessionRateLimits(ctx, sessionRateWindows, now);
235
243
  await flushAggregates(ctx, aggregateDeltas);
236
- await flushReportRollups(ctx, reportRollupDeltas);
244
+ await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
237
245
  return { accepted, duplicates, rejected };
238
246
  },
239
247
  });
@@ -322,6 +330,7 @@ export const trackConversion = mutation({
322
330
  aggregateDeltas,
323
331
  reportRollupDeltas,
324
332
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
333
+ timezone: localDayTimezone(site.timezone),
325
334
  financialConversion: true,
326
335
  trustedGoalKey,
327
336
  now: Date.now(),
@@ -330,8 +339,9 @@ export const trackConversion = mutation({
330
339
  await insertEventBatch(ctx, args.siteId, event, session.country ?? "unknown");
331
340
  }
332
341
  await flushAggregates(ctx, aggregateDeltas);
333
- await flushReportRollups(ctx, reportRollupDeltas);
334
- await ensureAnalyticsControl(ctx, args.siteId, Date.now());
342
+ const now = Date.now();
343
+ await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
344
+ await ensureAnalyticsControl(ctx, args.siteId, now);
335
345
  return { accepted: 1, duplicates: 0, rejected: 0 };
336
346
  },
337
347
  });
@@ -478,7 +488,7 @@ async function accountEvent(ctx, input) {
478
488
  },
479
489
  ...matchedGoalKeys.map((value) => ({ type: "goal", value })),
480
490
  ];
481
- 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);
482
492
  if (input.recordTelemetry &&
483
493
  event.timestamp >= input.now - LIVE_SESSION_TTL_MS) {
484
494
  await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now, input.batchState);
@@ -499,6 +509,9 @@ async function accountTrustedSession(ctx, existing, event) {
499
509
  isNewSession: false,
500
510
  isNewVisitorHour: false,
501
511
  isNewVisitorDay: false,
512
+ isNewVisitor: false,
513
+ durationDeltaMs: 0,
514
+ continued: false,
502
515
  startedAt: existing.startedAt,
503
516
  source: existing.source,
504
517
  utmCampaign: existing.utmCampaign,
@@ -583,6 +596,9 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
583
596
  Math.floor(event.timestamp / HOUR_MS),
584
597
  isNewVisitorDay: Math.floor(existing.lastSeenAt / DAY_MS) <
585
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,
586
602
  startedAt,
587
603
  source: existing.source,
588
604
  utmCampaign: existing.utmCampaign,
@@ -612,6 +628,18 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
612
628
  }
613
629
  }
614
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
+ }
615
643
  const fields = {
616
644
  siteId,
617
645
  sessionId: event.sessionId,
@@ -667,6 +695,9 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
667
695
  isNewVisitorDay: priorVisit === null ||
668
696
  Math.floor(priorVisit.lastSeenAt / DAY_MS) <
669
697
  Math.floor(event.timestamp / DAY_MS),
698
+ isNewVisitor,
699
+ durationDeltaMs: 0,
700
+ continued: false,
670
701
  startedAt: event.timestamp,
671
702
  source,
672
703
  utmCampaign,
@@ -816,10 +847,14 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
816
847
  else {
817
848
  await ctx.db.patch("sessions", session._id, updates);
818
849
  }
850
+ const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
819
851
  await updateLiveSession(ctx, siteId, event, context, {
820
852
  isNewSession: false,
821
853
  isNewVisitorHour: false,
822
854
  isNewVisitorDay: false,
855
+ isNewVisitor: false,
856
+ durationDeltaMs: 0,
857
+ continued: false,
823
858
  startedAt,
824
859
  source: session.source,
825
860
  visitorKey: session.visitorKey ?? session.visitorId,
@@ -831,6 +866,7 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
831
866
  eventCount: session.eventCount,
832
867
  affiliateSlug: session.affiliateSlug,
833
868
  }, now, batchState);
869
+ return durationDeltaMs;
834
870
  }
835
871
  async function consumeSiteRateLimit(ctx, siteId, events, eventByteCounts, now) {
836
872
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
@@ -1114,7 +1150,7 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
1114
1150
  _id: progressId,
1115
1151
  };
1116
1152
  }
1117
- await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1153
+ foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1118
1154
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1119
1155
  return;
1120
1156
  }
@@ -1145,7 +1181,7 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
1145
1181
  completedAt: undefined,
1146
1182
  });
1147
1183
  }
1148
- await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1184
+ foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1149
1185
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1150
1186
  return;
1151
1187
  }
@@ -1167,7 +1203,7 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
1167
1203
  else {
1168
1204
  await ctx.db.patch("funnelProgress", progress._id, updates);
1169
1205
  }
1170
- await incrementFunnelStep(ctx, siteId, funnel._id, event, progress.currentStep, completed);
1206
+ foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, progress.currentStep, completed);
1171
1207
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, progress.currentStep, completed);
1172
1208
  }
1173
1209
  function stepMatches(step, event) {
@@ -1175,36 +1211,6 @@ function stepMatches(step, event) {
1175
1211
  ? event.type === "pageview" && event.path === step.value
1176
1212
  : event.type === "custom" && event.name === step.value;
1177
1213
  }
1178
- async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed) {
1179
- const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1180
- const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
1181
- const bucket = await ctx.db
1182
- .query("funnelStepBuckets")
1183
- .withIndex("by_siteId_and_funnelId_and_bucketStart_and_step_and_shard", (range) => range
1184
- .eq("siteId", siteId)
1185
- .eq("funnelId", funnelId)
1186
- .eq("bucketStart", bucketStart)
1187
- .eq("step", step)
1188
- .eq("shard", shard))
1189
- .unique();
1190
- if (bucket) {
1191
- await ctx.db.patch("funnelStepBuckets", bucket._id, {
1192
- entrants: bucket.entrants + 1,
1193
- completions: bucket.completions + (completed ? 1 : 0),
1194
- });
1195
- }
1196
- else {
1197
- await ctx.db.insert("funnelStepBuckets", {
1198
- siteId,
1199
- funnelId,
1200
- bucketStart,
1201
- step,
1202
- shard,
1203
- entrants: 1,
1204
- completions: completed ? 1 : 0,
1205
- });
1206
- }
1207
- }
1208
1214
  async function accountConversion(ctx, siteId, session, event, trustedGoalKey, reportRollupDeltas) {
1209
1215
  const revenueCents = event.revenueCents ?? 0;
1210
1216
  const currency = event.currency ?? "USD";
@@ -1266,24 +1272,6 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey, re
1266
1272
  }
1267
1273
  }
1268
1274
  }
1269
- function createReportRollupDeltas() {
1270
- return {
1271
- goals: new Map(),
1272
- funnels: new Map(),
1273
- affiliates: new Map(),
1274
- vitals: new Map(),
1275
- routes: new Map(),
1276
- };
1277
- }
1278
- function reportRollupCoordinates(siteId, definitionId, timestamp, visitorId) {
1279
- const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
1280
- const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
1281
- return {
1282
- bucketStart,
1283
- shard,
1284
- key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
1285
- };
1286
- }
1287
1275
  function foldGoalRollup(deltas, siteId, event, goal) {
1288
1276
  const coordinates = reportRollupCoordinates(siteId, String(goal._id), event.timestamp, event.visitorId);
1289
1277
  const current = deltas.goals.get(coordinates.key) ?? {
@@ -1318,6 +1306,23 @@ function foldFunnelRollup(deltas, siteId, funnelId, event, step, completed) {
1318
1306
  rollup.steps.set(step, current);
1319
1307
  deltas.funnels.set(coordinates.key, rollup);
1320
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
+ }
1321
1326
  function foldAffiliateVisitRollup(deltas, siteId, event, attribution, visitorKey) {
1322
1327
  const coordinates = reportRollupCoordinates(siteId, String(attribution.affiliateId), event.timestamp, event.visitorId);
1323
1328
  const current = deltas.affiliates.get(coordinates.key) ?? {
@@ -1327,13 +1332,13 @@ function foldAffiliateVisitRollup(deltas, siteId, event, attribution, visitorKey
1327
1332
  shard: coordinates.shard,
1328
1333
  affiliateSlug: attribution.affiliateSlug,
1329
1334
  visits: 0,
1330
- visitorSketch: createVisitorSketch(),
1335
+ visitorKeys: new Set(),
1331
1336
  conversions: 0,
1332
1337
  revenueCents: 0,
1333
1338
  commissionCents: 0,
1334
1339
  };
1335
1340
  current.visits += 1;
1336
- current.visitorSketch = addVisitor(current.visitorSketch, visitorKey);
1341
+ current.visitorKeys.add(visitorKey);
1337
1342
  deltas.affiliates.set(coordinates.key, current);
1338
1343
  }
1339
1344
  function foldAffiliateConversionRollup(deltas, siteId, event, affiliate, affiliateSlug, revenueCents, commissionCents) {
@@ -1345,7 +1350,7 @@ function foldAffiliateConversionRollup(deltas, siteId, event, affiliate, affilia
1345
1350
  shard: coordinates.shard,
1346
1351
  affiliateSlug,
1347
1352
  visits: 0,
1348
- visitorSketch: createVisitorSketch(),
1353
+ visitorKeys: new Set(),
1349
1354
  conversions: 0,
1350
1355
  revenueCents: 0,
1351
1356
  commissionCents: 0,
@@ -1405,7 +1410,7 @@ function foldRouteDelta(deltas, siteId, route, timestamp, visitorId, apply) {
1405
1410
  continued: 0,
1406
1411
  dwellMs: 0,
1407
1412
  dwellSamples: 0,
1408
- visitorSketch: createVisitorSketch(),
1413
+ visitorKeys: new Set(),
1409
1414
  outgoing: new Map(),
1410
1415
  };
1411
1416
  apply(current);
@@ -1426,7 +1431,7 @@ function foldRouteRollups(deltas, siteId, event, session, existing) {
1426
1431
  delta.pageviews += 1;
1427
1432
  if (firstPageview)
1428
1433
  delta.entries += 1;
1429
- delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
1434
+ delta.visitorKeys.add(session.visitorKey);
1430
1435
  });
1431
1436
  // Out-of-order telemetry keeps its view but cannot say where it came from.
1432
1437
  if (existing === null || firstPageview)
@@ -1448,245 +1453,19 @@ function foldRouteRollups(deltas, siteId, event, session, existing) {
1448
1453
  });
1449
1454
  }
1450
1455
  }
1451
- async function upsertRouteRollup(ctx, delta, dayRowCounts) {
1452
- const existing = await ctx.db
1453
- .query("routeDailyRollups")
1454
- .withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) => range
1455
- .eq("siteId", delta.siteId)
1456
- .eq("route", delta.route)
1457
- .eq("bucketStart", delta.bucketStart)
1458
- .eq("shard", delta.shard))
1459
- .unique();
1460
- if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
1461
- // A new route row spends one bounded read per batch to respect the daily
1462
- // cap; past it, the day's remaining routes fold into "(other)".
1463
- const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
1464
- const dayKey = `${delta.siteId}:${delta.bucketStart}`;
1465
- let dayRows = dayRowCounts.get(dayKey);
1466
- if (dayRows === undefined) {
1467
- dayRows = (await ctx.db
1468
- .query("routeDailyRollups")
1469
- .withIndex("by_siteId_and_bucketStart", (range) => range
1470
- .eq("siteId", delta.siteId)
1471
- .eq("bucketStart", delta.bucketStart))
1472
- .take(capRows + 1)).length;
1473
- }
1474
- if (dayRows >= capRows) {
1475
- await upsertRouteRollup(ctx, { ...delta, route: SITE_MAP_OTHER_ROUTE }, dayRowCounts);
1476
- return;
1477
- }
1478
- dayRowCounts.set(dayKey, dayRows + 1);
1479
- }
1480
- const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
1481
- for (const [target, count] of delta.outgoing) {
1482
- let entry = outgoing.find((candidate) => candidate.route === target);
1483
- if (!entry) {
1484
- const named = outgoing.filter((candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE).length;
1485
- const route = target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
1486
- ? SITE_MAP_OTHER_ROUTE
1487
- : target;
1488
- entry = outgoing.find((candidate) => candidate.route === route);
1489
- if (!entry) {
1490
- entry = { route, count: 0 };
1491
- outgoing.push(entry);
1492
- }
1493
- }
1494
- entry.count += count;
1495
- }
1496
- const fields = {
1497
- siteId: delta.siteId,
1498
- bucketStart: delta.bucketStart,
1499
- shard: delta.shard,
1500
- generation: 1,
1501
- route: delta.route,
1502
- pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
1503
- entries: (existing?.entries ?? 0) + delta.entries,
1504
- continued: (existing?.continued ?? 0) + delta.continued,
1505
- dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
1506
- dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
1507
- visitorSketch: unionVisitorSketches(existing?.visitorSketch ?? createVisitorSketch(), delta.visitorSketch),
1508
- outgoing,
1509
- };
1510
- if (existing) {
1511
- await ctx.db.replace("routeDailyRollups", existing._id, fields);
1512
- }
1513
- else {
1514
- await ctx.db.insert("routeDailyRollups", fields);
1515
- }
1516
- }
1517
- async function upsertVitalRollup(ctx, delta) {
1518
- const existing = await ctx.db
1519
- .query("vitalRollups")
1520
- .withIndex("by_key", (range) => range
1521
- .eq("siteId", delta.siteId)
1522
- .eq("bucketStart", delta.bucketStart)
1523
- .eq("device", delta.device)
1524
- .eq("page", delta.page)
1525
- .eq("shard", delta.shard))
1526
- .unique();
1527
- if (!existing &&
1528
- delta.device === VITAL_ALL &&
1529
- delta.page !== VITAL_OTHER_PAGES) {
1530
- // A new page row spends one bounded read to respect the daily page slots;
1531
- // past the cap, the day's remaining pages fold into "(other)".
1532
- const pageRows = await ctx.db
1533
- .query("vitalRollups")
1534
- .withIndex("by_key", (range) => range
1535
- .eq("siteId", delta.siteId)
1536
- .eq("bucketStart", delta.bucketStart)
1537
- .eq("device", VITAL_ALL))
1538
- .take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
1539
- if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
1540
- await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
1541
- return;
1542
- }
1543
- }
1544
- const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
1545
- for (const [metric, addition] of delta.metrics) {
1546
- const entry = metrics.find((candidate) => candidate.metric === metric);
1547
- if (entry) {
1548
- entry.count += addition.count;
1549
- entry.sum += addition.sum;
1550
- entry.histogram = mergeVitalHistograms(entry.histogram, addition.histogram);
1551
- }
1552
- else {
1553
- metrics.push({
1554
- metric,
1555
- count: addition.count,
1556
- sum: addition.sum,
1557
- histogram: [...addition.histogram],
1558
- });
1559
- }
1560
- }
1561
- const fields = {
1562
- siteId: delta.siteId,
1563
- bucketStart: delta.bucketStart,
1564
- device: delta.device,
1565
- page: delta.page,
1566
- shard: delta.shard,
1567
- metrics,
1568
- };
1569
- if (existing) {
1570
- await ctx.db.replace("vitalRollups", existing._id, fields);
1571
- }
1572
- else {
1573
- await ctx.db.insert("vitalRollups", fields);
1574
- }
1575
- }
1576
- async function flushReportRollups(ctx, deltas) {
1577
- for (const delta of deltas.goals.values()) {
1578
- const existing = await ctx.db
1579
- .query("goalDailyRollups")
1580
- .withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) => range
1581
- .eq("siteId", delta.siteId)
1582
- .eq("goalId", delta.goalId)
1583
- .eq("bucketStart", delta.bucketStart)
1584
- .eq("shard", delta.shard))
1585
- .unique();
1586
- const fields = {
1587
- siteId: delta.siteId,
1588
- goalId: delta.goalId,
1589
- bucketStart: delta.bucketStart,
1590
- shard: delta.shard,
1591
- generation: 1,
1592
- goalKey: delta.goalKey,
1593
- completions: (existing?.completions ?? 0) + delta.completions,
1594
- valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
1595
- };
1596
- if (existing) {
1597
- await ctx.db.replace("goalDailyRollups", existing._id, fields);
1598
- }
1599
- else {
1600
- await ctx.db.insert("goalDailyRollups", fields);
1601
- }
1602
- }
1603
- for (const delta of deltas.funnels.values()) {
1604
- const existing = await ctx.db
1605
- .query("funnelDailyRollups")
1606
- .withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) => range
1607
- .eq("siteId", delta.siteId)
1608
- .eq("funnelId", delta.funnelId)
1609
- .eq("bucketStart", delta.bucketStart)
1610
- .eq("shard", delta.shard))
1611
- .unique();
1612
- const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
1613
- for (const addition of delta.steps.values()) {
1614
- const current = steps.find((step) => step.step === addition.step);
1615
- if (current) {
1616
- current.entrants += addition.entrants;
1617
- current.completions += addition.completions;
1618
- }
1619
- else {
1620
- if (steps.length >= MAX_FUNNEL_STEPS) {
1621
- fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
1622
- }
1623
- steps.push(addition);
1624
- }
1625
- }
1626
- const fields = {
1627
- siteId: delta.siteId,
1628
- funnelId: delta.funnelId,
1629
- bucketStart: delta.bucketStart,
1630
- shard: delta.shard,
1631
- generation: 1,
1632
- steps,
1633
- };
1634
- if (existing) {
1635
- await ctx.db.replace("funnelDailyRollups", existing._id, fields);
1636
- }
1637
- else {
1638
- await ctx.db.insert("funnelDailyRollups", fields);
1639
- }
1640
- }
1641
- for (const delta of deltas.affiliates.values()) {
1642
- const existing = await ctx.db
1643
- .query("affiliateDailyRollups")
1644
- .withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) => range
1645
- .eq("siteId", delta.siteId)
1646
- .eq("affiliateId", delta.affiliateId)
1647
- .eq("bucketStart", delta.bucketStart)
1648
- .eq("shard", delta.shard))
1649
- .unique();
1650
- const fields = {
1651
- siteId: delta.siteId,
1652
- affiliateId: delta.affiliateId,
1653
- bucketStart: delta.bucketStart,
1654
- shard: delta.shard,
1655
- generation: 1,
1656
- affiliateSlug: delta.affiliateSlug,
1657
- visits: (existing?.visits ?? 0) + delta.visits,
1658
- visitorSketch: unionVisitorSketches(existing?.visitorSketch ?? createVisitorSketch(), delta.visitorSketch),
1659
- conversions: (existing?.conversions ?? 0) + delta.conversions,
1660
- revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
1661
- commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
1662
- };
1663
- if (existing) {
1664
- await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
1665
- }
1666
- else {
1667
- await ctx.db.insert("affiliateDailyRollups", fields);
1668
- }
1669
- }
1670
- const routeDayRowCounts = new Map();
1671
- for (const delta of deltas.routes.values()) {
1672
- await upsertRouteRollup(ctx, delta, routeDayRowCounts);
1673
- }
1674
- for (const delta of deltas.vitals.values()) {
1675
- await upsertVitalRollup(ctx, delta);
1676
- }
1677
- }
1678
- function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion) {
1456
+ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion, timezone) {
1679
1457
  const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
1680
1458
  const dimensionDeltas = dimensions.map((dimension) => ({
1681
1459
  ...dimension,
1682
1460
  count: Number(recordTelemetry),
1683
1461
  revenueCents,
1462
+ pageviews: Number(recordTelemetry && event.type === "pageview"),
1463
+ sessions: Number(recordTelemetry && session.isNewSession),
1684
1464
  }));
1685
- for (const granularity of ["hour", "day"]) {
1465
+ for (const granularity of aggregateGranularities(timezone)) {
1686
1466
  const shard = stableHash(event.sessionId) %
1687
1467
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
1688
- const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
1689
- const bucketStart = Math.floor(event.timestamp / interval) * interval;
1468
+ const bucketStart = aggregateBucketStart(granularity, event.timestamp, timezone);
1690
1469
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
1691
1470
  let delta = aggregateDeltas.get(key);
1692
1471
  if (!delta) {
@@ -1706,6 +1485,9 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
1706
1485
  conversions: 0,
1707
1486
  revenueCents: 0,
1708
1487
  dimensions: [],
1488
+ durationMs: 0,
1489
+ continuedSessions: 0,
1490
+ newVisitors: 0,
1709
1491
  };
1710
1492
  aggregateDeltas.set(key, delta);
1711
1493
  }
@@ -1727,6 +1509,58 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
1727
1509
  delta.conversions += Number(financialConversion);
1728
1510
  delta.revenueCents += revenueCents;
1729
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);
1730
1564
  }
1731
1565
  }
1732
1566
  async function flushAggregates(ctx, aggregateDeltas) {
@@ -1739,7 +1573,11 @@ async function flushAggregates(ctx, aggregateDeltas) {
1739
1573
  .eq("bucketStart", delta.bucketStart)
1740
1574
  .eq("shard", delta.shard))
1741
1575
  .unique();
1742
- 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
+ })));
1743
1581
  const priorSketch = bucket?.visitorSketch;
1744
1582
  const visitorSketch = priorSketch
1745
1583
  ? unionVisitorSketches(priorSketch, delta.visitorSketch)
@@ -1761,6 +1599,9 @@ async function flushAggregates(ctx, aggregateDeltas) {
1761
1599
  conversions: bucket.conversions + delta.conversions,
1762
1600
  revenueCents: bucket.revenueCents + delta.revenueCents,
1763
1601
  dimensions,
1602
+ durationMs: (bucket.durationMs ?? 0) + delta.durationMs,
1603
+ continuedSessions: (bucket.continuedSessions ?? 0) + delta.continuedSessions,
1604
+ newVisitors: (bucket.newVisitors ?? 0) + delta.newVisitors,
1764
1605
  });
1765
1606
  }
1766
1607
  else {
@@ -1781,6 +1622,9 @@ async function flushAggregates(ctx, aggregateDeltas) {
1781
1622
  conversions: delta.conversions,
1782
1623
  revenueCents: delta.revenueCents,
1783
1624
  dimensions,
1625
+ durationMs: delta.durationMs,
1626
+ continuedSessions: delta.continuedSessions,
1627
+ newVisitors: delta.newVisitors,
1784
1628
  });
1785
1629
  }
1786
1630
  }
@@ -1792,6 +1636,8 @@ function mergeDimensions(current, additions) {
1792
1636
  if (exact) {
1793
1637
  exact.count += addition.count;
1794
1638
  exact.revenueCents += addition.revenueCents;
1639
+ exact.pageviews = (exact.pageviews ?? 0) + addition.pageviews;
1640
+ exact.sessions = (exact.sessions ?? 0) + addition.sessions;
1795
1641
  continue;
1796
1642
  }
1797
1643
  const typeSlots = result.filter((candidate) => candidate.type === addition.type);
@@ -1808,6 +1654,8 @@ function mergeDimensions(current, additions) {
1808
1654
  value: addition.value,
1809
1655
  count: addition.count,
1810
1656
  revenueCents: addition.revenueCents,
1657
+ pageviews: addition.pageviews,
1658
+ sessions: addition.sessions,
1811
1659
  });
1812
1660
  continue;
1813
1661
  }
@@ -1815,6 +1663,8 @@ function mergeDimensions(current, additions) {
1815
1663
  if (overflow) {
1816
1664
  overflow.count += addition.count;
1817
1665
  overflow.revenueCents += addition.revenueCents;
1666
+ overflow.pageviews = (overflow.pageviews ?? 0) + addition.pageviews;
1667
+ overflow.sessions = (overflow.sessions ?? 0) + addition.sessions;
1818
1668
  continue;
1819
1669
  }
1820
1670
  if (addition.count === 0 && addition.revenueCents === 0)
@@ -1830,6 +1680,8 @@ function mergeDimensions(current, additions) {
1830
1680
  value: "(other)",
1831
1681
  count: addition.count,
1832
1682
  revenueCents: addition.revenueCents,
1683
+ pageviews: addition.pageviews,
1684
+ sessions: addition.sessions,
1833
1685
  });
1834
1686
  continue;
1835
1687
  }
@@ -1841,6 +1693,8 @@ function mergeDimensions(current, additions) {
1841
1693
  replacement.value = "(other)";
1842
1694
  replacement.count += addition.count;
1843
1695
  replacement.revenueCents += addition.revenueCents;
1696
+ replacement.pageviews = (replacement.pageviews ?? 0) + addition.pageviews;
1697
+ replacement.sessions = (replacement.sessions ?? 0) + addition.sessions;
1844
1698
  }
1845
1699
  return result;
1846
1700
  }