@iann29/rastro 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +199 -50
  2. package/agent/integration.md +26 -10
  3. package/agent/manifest.json +10 -5
  4. package/agent/manifest.schema.json +14 -5
  5. package/dist/client/federation.d.ts +28 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +12 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +262 -9
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +119 -2
  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 +72 -1
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +1 -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/constants.d.ts +9 -2
  22. package/dist/component/constants.d.ts.map +1 -1
  23. package/dist/component/constants.js +31 -3
  24. package/dist/component/constants.js.map +1 -1
  25. package/dist/component/convex.config.d.ts +1 -0
  26. package/dist/component/convex.config.js +3 -0
  27. package/dist/component/convex.config.js.map +1 -1
  28. package/dist/component/coverage.d.ts +1 -0
  29. package/dist/component/coverage.d.ts.map +1 -1
  30. package/dist/component/coverage.js +6 -1
  31. package/dist/component/coverage.js.map +1 -1
  32. package/dist/component/eventStore.d.ts +2 -0
  33. package/dist/component/eventStore.d.ts.map +1 -1
  34. package/dist/component/geo.d.ts +1 -0
  35. package/dist/component/geo.d.ts.map +1 -1
  36. package/dist/component/geo.js +1 -1
  37. package/dist/component/geo.js.map +1 -1
  38. package/dist/component/http.d.ts.map +1 -1
  39. package/dist/component/http.js +65 -2
  40. package/dist/component/http.js.map +1 -1
  41. package/dist/component/identity.d.ts +12 -0
  42. package/dist/component/identity.d.ts.map +1 -1
  43. package/dist/component/identity.js +30 -2
  44. package/dist/component/identity.js.map +1 -1
  45. package/dist/component/ingest.d.ts +36 -0
  46. package/dist/component/ingest.d.ts.map +1 -1
  47. package/dist/component/ingest.js +111 -20
  48. package/dist/component/ingest.js.map +1 -1
  49. package/dist/component/live.d.ts.map +1 -1
  50. package/dist/component/live.js +34 -4
  51. package/dist/component/live.js.map +1 -1
  52. package/dist/component/origin.d.ts +70 -0
  53. package/dist/component/origin.d.ts.map +1 -0
  54. package/dist/component/origin.js +230 -0
  55. package/dist/component/origin.js.map +1 -0
  56. package/dist/component/platforms.d.ts +33 -0
  57. package/dist/component/platforms.d.ts.map +1 -0
  58. package/dist/component/platforms.js +328 -0
  59. package/dist/component/platforms.js.map +1 -0
  60. package/dist/component/reports.d.ts +27 -72
  61. package/dist/component/reports.d.ts.map +1 -1
  62. package/dist/component/reports.js +72 -16
  63. package/dist/component/reports.js.map +1 -1
  64. package/dist/component/rollupStore.d.ts +7 -4
  65. package/dist/component/rollupStore.d.ts.map +1 -1
  66. package/dist/component/rollupStore.js +37 -17
  67. package/dist/component/rollupStore.js.map +1 -1
  68. package/dist/component/rollups.d.ts +27 -8
  69. package/dist/component/rollups.d.ts.map +1 -1
  70. package/dist/component/rollups.js +110 -32
  71. package/dist/component/rollups.js.map +1 -1
  72. package/dist/component/sanitize.d.ts +5 -0
  73. package/dist/component/sanitize.d.ts.map +1 -1
  74. package/dist/component/sanitize.js +33 -1
  75. package/dist/component/sanitize.js.map +1 -1
  76. package/dist/component/schema.d.ts +68 -9
  77. package/dist/component/schema.js +21 -3
  78. package/dist/component/schema.js.map +1 -1
  79. package/dist/component/trackedLinks.d.ts +91 -0
  80. package/dist/component/trackedLinks.d.ts.map +1 -0
  81. package/dist/component/trackedLinks.js +314 -0
  82. package/dist/component/trackedLinks.js.map +1 -0
  83. package/dist/component/useragent.d.ts +6 -0
  84. package/dist/component/useragent.d.ts.map +1 -1
  85. package/dist/component/useragent.js +9 -0
  86. package/dist/component/useragent.js.map +1 -1
  87. package/dist/component/validators.d.ts +51 -11
  88. package/dist/component/validators.d.ts.map +1 -1
  89. package/dist/component/validators.js +35 -1
  90. package/dist/component/validators.js.map +1 -1
  91. package/dist/tracker/generated.d.ts +11 -11
  92. package/dist/tracker/generated.d.ts.map +1 -1
  93. package/dist/tracker/generated.js +11 -11
  94. package/dist/tracker/generated.js.map +1 -1
  95. package/dist/tracker/tracker.d.ts +1 -0
  96. package/dist/tracker/tracker.d.ts.map +1 -1
  97. package/dist/tracker/tracker.js +104 -47
  98. package/dist/tracker/tracker.js.map +1 -1
  99. package/dist/tracker/vitals.d.ts.map +1 -1
  100. package/dist/tracker/vitals.js +22 -4
  101. package/dist/tracker/vitals.js.map +1 -1
  102. package/dist/tracker.min.js +1 -1
  103. package/dist/vitals.min.js +1 -1
  104. package/docs/upgrading.md +234 -45
  105. package/llms.txt +9 -4
  106. package/package.json +2 -2
  107. package/src/component/_generated/api.ts +6 -0
  108. package/src/component/_generated/component.ts +153 -1
  109. package/src/component/_generated/server.ts +1 -0
  110. package/src/component/constants.ts +31 -3
  111. package/src/component/convex.config.ts +3 -0
  112. package/src/component/coverage.ts +7 -1
  113. package/src/component/geo.ts +1 -1
  114. package/src/component/http.ts +71 -2
  115. package/src/component/identity.ts +41 -2
  116. package/src/component/ingest.ts +146 -33
  117. package/src/component/live.ts +51 -4
  118. package/src/component/origin.ts +273 -0
  119. package/src/component/platforms.ts +359 -0
  120. package/src/component/reports.ts +98 -18
  121. package/src/component/rollupStore.ts +51 -20
  122. package/src/component/rollups.ts +141 -32
  123. package/src/component/sanitize.ts +39 -1
  124. package/src/component/schema.ts +24 -3
  125. package/src/component/trackedLinks.ts +384 -0
  126. package/src/component/useragent.ts +11 -0
  127. package/src/component/validators.ts +71 -0
  128. package/src/tracker/generated.ts +11 -11
@@ -1,16 +1,17 @@
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_GOALS_PER_SITE, RATE_LIMIT_WINDOW_MS, REPORT_ROLLUP_SHARDS, 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_BYTES_PER_CLIENT_WINDOW, MAX_EVENTS_PER_CLIENT_WINDOW, 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
6
  import { addToVitalHistogram, emptyVitalHistogram, vitalDevice, VITAL_ALL, } from "./vitals.js";
7
7
  import { fail } from "./errors.js";
8
8
  import { ensureAnalyticsControl } from "./coverage.js";
9
9
  import { bufferEventBatch, eventBatchKey, eventBucketStart, findTelemetry, flushEventBatches, insertEventBatch, inspectTelemetryMinute, } from "./eventStore.js";
10
- import { cleanString, isValidMoney, measureBatchBytes, normalizeRoute, originAllowed, publicUrlHost, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
10
+ import { cleanString, externalReferrer, isValidMoney, measureBatchBytes, normalizeRoute, originAllowed, publicUrlHost, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
11
11
  import { ingestContextValidator, ingestResultValidator, trackerEventValidator, trustedConversionValidator, } from "./validators.js";
12
12
  import { createReportRollupDeltas, enqueueReportRollups, reportRollupCoordinates, } from "./rollupStore.js";
13
13
  import { localDayStart, localDayTimezone } from "./localTime.js";
14
+ import { classifyOrigin, platformDimensionValue } from "./origin.js";
14
15
  function furtherReach(current, next) {
15
16
  if (!next)
16
17
  return current;
@@ -26,6 +27,8 @@ export const ingestBatch = mutation({
26
27
  origin: v.string(),
27
28
  events: v.array(trackerEventValidator),
28
29
  context: v.optional(ingestContextValidator),
30
+ // The HTTP action's salted daily client-network key (see identity.ts).
31
+ clientKey: v.optional(v.string()),
29
32
  },
30
33
  returns: ingestResultValidator,
31
34
  handler: async (ctx, args) => {
@@ -51,6 +54,9 @@ export const ingestBatch = mutation({
51
54
  }
52
55
  const now = Date.now();
53
56
  await ensureAnalyticsControl(ctx, args.siteId, now);
57
+ if (args.clientKey !== undefined) {
58
+ await consumeClientRateLimit(ctx, args.siteId, args.clientKey, eventByteCounts, now);
59
+ }
54
60
  await consumeSiteRateLimit(ctx, args.siteId, args.events, eventByteCounts, now);
55
61
  const context = sanitizeContext(args.context);
56
62
  const timezone = localDayTimezone(site.timezone);
@@ -155,11 +161,10 @@ export const ingestBatch = mutation({
155
161
  rejected += 1;
156
162
  continue;
157
163
  }
158
- const durationDeltaMs = await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now, batchState);
159
- // A heartbeat is presence, not telemetry, but it is what keeps a
160
- // session's duration honest while the visitor reads: the growth is
161
- // folded into the buckets the heartbeat landed in.
162
- foldSessionDuration(aggregateDeltas, args.siteId, event, durationDeltaMs, timezone);
164
+ // A heartbeat is presence, not telemetry: it moves the session's
165
+ // lastSeenAt and live row only. The time it proves reaches the
166
+ // duration counters once, when the visitor departs (live.ts).
167
+ await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now, batchState);
163
168
  accepted += 1;
164
169
  continue;
165
170
  }
@@ -190,7 +195,7 @@ export const ingestBatch = mutation({
190
195
  durationMs,
191
196
  };
192
197
  sessionRow.dirty = true;
193
- foldSessionDuration(aggregateDeltas, args.siteId, event, durationMs - existingSession.durationMs, timezone);
198
+ foldSessionDuration(aggregateDeltas, args.siteId, event.sessionId, event.timestamp, durationMs - existingSession.durationMs, timezone);
194
199
  }
195
200
  const expiresAt = Math.min(liveSession.expiresAt, now + LIVE_LEAVE_GRACE_MS);
196
201
  if (expiresAt < liveSession.expiresAt) {
@@ -252,6 +257,7 @@ export const ingestBatch = mutation({
252
257
  reportRollupDeltas,
253
258
  recordTelemetry: true,
254
259
  timezone,
260
+ siteDomains: site.domains,
255
261
  financialConversion: false,
256
262
  trustedGoalKey: undefined,
257
263
  now,
@@ -353,6 +359,7 @@ export const trackConversion = mutation({
353
359
  reportRollupDeltas,
354
360
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
355
361
  timezone: localDayTimezone(site.timezone),
362
+ siteDomains: site.domains,
356
363
  financialConversion: true,
357
364
  trustedGoalKey,
358
365
  now: Date.now(),
@@ -433,7 +440,7 @@ function withoutSystemFields(document) {
433
440
  async function accountEvent(ctx, input) {
434
441
  const { event } = input;
435
442
  const session = input.recordTelemetry
436
- ? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion, input.reportRollupDeltas, input.batchState)
443
+ ? await upsertSession(ctx, input.siteId, event, input.context, input.siteDomains, input.existingSession, input.financialConversion, input.reportRollupDeltas, input.batchState)
437
444
  : await accountTrustedSession(ctx, input.existingSession, event);
438
445
  if (input.recordTelemetry && event.type === "pageview") {
439
446
  // `existingSession` is the snapshot from before `upsertSession`, so it
@@ -510,6 +517,23 @@ async function accountEvent(ctx, input) {
510
517
  const outboundHost = event.type === "outbound" ? publicUrlHost(event.href) : undefined;
511
518
  const dimensions = [
512
519
  { type: "source", value: session.source },
520
+ // A session recorded before origins carries none, and fills no slot.
521
+ ...(session.platform && session.channel && session.evidence
522
+ ? [
523
+ {
524
+ type: "platform",
525
+ // The value carries the channel too when it is not the
526
+ // platform's default, so the pair survives without its own
527
+ // dimension (splitPlatformDimension reads it back).
528
+ value: platformDimensionValue({
529
+ platform: session.platform,
530
+ channel: session.channel,
531
+ }),
532
+ },
533
+ { type: "channel", value: session.channel },
534
+ { type: "evidence", value: session.evidence },
535
+ ]
536
+ : []),
513
537
  ...campaignDimensions,
514
538
  ...(outboundHost
515
539
  ? [{ type: "outbound", value: outboundHost }]
@@ -550,6 +574,9 @@ async function accountTrustedSession(ctx, existing, event) {
550
574
  continued: false,
551
575
  startedAt: existing.startedAt,
552
576
  source: existing.source,
577
+ platform: existing.platform,
578
+ channel: existing.channel,
579
+ evidence: existing.evidence,
553
580
  utmCampaign: existing.utmCampaign,
554
581
  utmMedium: existing.utmMedium,
555
582
  entryPath: existing.entryPath,
@@ -568,7 +595,7 @@ async function accountTrustedSession(ctx, existing, event) {
568
595
  affiliateExpiresAt: existing.affiliateExpiresAt,
569
596
  };
570
597
  }
571
- async function upsertSession(ctx, siteId, event, context, existing, financialConversion, reportRollupDeltas, batchState) {
598
+ async function upsertSession(ctx, siteId, event, context, siteDomains, existing, financialConversion, reportRollupDeltas, batchState) {
572
599
  // A host-supplied id is the identity; anonymous sessions count through the
573
600
  // daily key derived by the HTTP action, or once per session without one.
574
601
  const visitorKey = existing?.visitorKey ??
@@ -641,6 +668,9 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
641
668
  continued: event.type === "pageview" && existing.pageviewCount === 1,
642
669
  startedAt,
643
670
  source: existing.source,
671
+ platform: existing.platform,
672
+ channel: existing.channel,
673
+ evidence: existing.evidence,
644
674
  utmCampaign: existing.utmCampaign,
645
675
  utmMedium: existing.utmMedium,
646
676
  entryPath: existing.entryPath,
@@ -684,6 +714,17 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
684
714
  if (alias)
685
715
  isNewVisitor = false;
686
716
  }
717
+ // First touch only, like `source`: the session keeps where it came from.
718
+ // An affiliate channel needs this landing's own `ref` to have resolved; a
719
+ // visitor's standing attribution alone is not how this visit arrived.
720
+ const origin = classifyOrigin({
721
+ utmSource,
722
+ utmMedium,
723
+ clickIds: event.clid,
724
+ referrer: externalReferrer(event.referrer, siteDomains),
725
+ inApp: context.inApp,
726
+ affiliate: event.affiliateSlug !== undefined && attribution !== undefined,
727
+ });
687
728
  const fields = {
688
729
  siteId,
689
730
  sessionId: event.sessionId,
@@ -696,6 +737,7 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
696
737
  newVisitor: isNewVisitor,
697
738
  ...(event.referrer ? { referrer: event.referrer } : {}),
698
739
  source,
740
+ ...origin,
699
741
  ...(utmSource ? { utmSource } : {}),
700
742
  ...(utmMedium ? { utmMedium } : {}),
701
743
  ...(utmCampaign ? { utmCampaign } : {}),
@@ -745,6 +787,7 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
745
787
  continued: false,
746
788
  startedAt: event.timestamp,
747
789
  source,
790
+ ...origin,
748
791
  utmCampaign,
749
792
  utmMedium,
750
793
  entryPath: event.path,
@@ -868,6 +911,12 @@ async function attributionForEvent(ctx, siteId, event, existingSession, batchSta
868
911
  affiliateExpiresAt: expiresAt,
869
912
  };
870
913
  }
914
+ /**
915
+ * Moves the session's lastSeenAt and its live row. `durationMs` stays: it is
916
+ * what the duration counters already hold, and the time a heartbeat proves
917
+ * is counted when the visitor departs (live.ts), so an idle tab never
918
+ * rewrites an aggregate bucket.
919
+ */
871
920
  async function accountHeartbeat(ctx, siteId, session, event, context, now, batchState) {
872
921
  const startedAt = Math.min(session.startedAt, event.timestamp);
873
922
  const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
@@ -875,7 +924,6 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
875
924
  startedAt,
876
925
  lastSeenAt,
877
926
  exitPath: event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
878
- durationMs: Math.max(0, lastSeenAt - startedAt),
879
927
  ...(context.country ? { country: context.country } : {}),
880
928
  ...(context.city ? { city: context.city } : {}),
881
929
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
@@ -896,7 +944,6 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
896
944
  else {
897
945
  await ctx.db.patch("sessions", session._id, updates);
898
946
  }
899
- const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
900
947
  await updateLiveSession(ctx, siteId, event, context, {
901
948
  isNewSession: false,
902
949
  isNewVisitorHour: false,
@@ -918,7 +965,42 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
918
965
  conversionCount: session.conversionCount,
919
966
  newVisitor: session.newVisitor,
920
967
  }, now, batchState);
921
- return durationDeltaMs;
968
+ }
969
+ /**
970
+ * One client network's budget per site and minute, kept in the session
971
+ * windows' table under `~client:<key>`, a key no sanitized session ID can take.
972
+ * The key is the salted daily hash the HTTP action derived; the address never
973
+ * reaches this mutation. It is one row per client, so only that client's own
974
+ * concurrent requests contend on it.
975
+ */
976
+ async function consumeClientRateLimit(ctx, siteId, clientKey, eventByteCounts, now) {
977
+ const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
978
+ const sessionId = `~client:${clientKey}`;
979
+ const rows = await ctx.db
980
+ .query("ingestWindows")
981
+ .withIndex("by_siteId_and_sessionId_and_windowStart", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
982
+ .order("desc")
983
+ .take(1);
984
+ const window = rows[0] ?? null;
985
+ const current = window?.windowStart === windowStart ? window : null;
986
+ const eventCount = (current?.eventCount ?? 0) + eventByteCounts.length;
987
+ const byteCount = (current?.byteCount ?? 0) +
988
+ eventByteCounts.reduce((total, bytes) => total + bytes, 0);
989
+ if (eventCount > MAX_EVENTS_PER_CLIENT_WINDOW ||
990
+ byteCount > MAX_BYTES_PER_CLIENT_WINDOW) {
991
+ fail("RATE_LIMITED", "client ingest budget exceeded", {
992
+ windowMs: RATE_LIMIT_WINDOW_MS,
993
+ eventLimitPerClient: MAX_EVENTS_PER_CLIENT_WINDOW,
994
+ byteLimitPerClient: MAX_BYTES_PER_CLIENT_WINDOW,
995
+ });
996
+ }
997
+ const fields = { windowStart, eventCount, byteCount, updatedAt: now };
998
+ if (window) {
999
+ await ctx.db.patch("ingestWindows", window._id, fields);
1000
+ }
1001
+ else {
1002
+ await ctx.db.insert("ingestWindows", { siteId, sessionId, ...fields });
1003
+ }
922
1004
  }
923
1005
  async function consumeSiteRateLimit(ctx, siteId, events, eventByteCounts, now) {
924
1006
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
@@ -1227,9 +1309,11 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
1227
1309
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1228
1310
  return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1229
1311
  }
1230
- if (progress.completedAt !== undefined ||
1231
- event.timestamp < progress.lastStepAt)
1312
+ if (event.timestamp < progress.lastStepAt)
1232
1313
  return;
1314
+ // An expired window restarts on a first-step event even after a completion,
1315
+ // so a returning buyer can complete again; inside the window a completed
1316
+ // funnel stays done.
1233
1317
  if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
1234
1318
  if (!matchesFirst)
1235
1319
  return;
@@ -1258,6 +1342,8 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
1258
1342
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1259
1343
  return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1260
1344
  }
1345
+ if (progress.completedAt !== undefined)
1346
+ return;
1261
1347
  const nextStep = funnel.steps[progress.currentStep];
1262
1348
  if (!nextStep || !stepMatches(nextStep, event))
1263
1349
  return;
@@ -1608,14 +1694,19 @@ function aggregateBucketStart(granularity, timestamp, timezone) {
1608
1694
  const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
1609
1695
  return Math.floor(timestamp / interval) * interval;
1610
1696
  }
1611
- /** Folds session duration growth without telemetry, for heartbeats and leaves. */
1612
- function foldSessionDuration(aggregateDeltas, siteId, event, durationDeltaMs, timezone) {
1697
+ /**
1698
+ * Folds session duration growth without telemetry: a departure's final
1699
+ * interval, or the heartbeat time of a visitor the live sweep retired. The
1700
+ * shard follows `shardKey`: the session for a departure, the site for a
1701
+ * sweep.
1702
+ */
1703
+ export function foldSessionDuration(aggregateDeltas, siteId, shardKey, timestamp, durationDeltaMs, timezone) {
1613
1704
  if (durationDeltaMs <= 0)
1614
1705
  return;
1615
1706
  for (const granularity of aggregateGranularities(timezone)) {
1616
- const shard = stableHash(event.sessionId) %
1707
+ const shard = stableHash(shardKey) %
1617
1708
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
1618
- const bucketStart = aggregateBucketStart(granularity, event.timestamp, timezone);
1709
+ const bucketStart = aggregateBucketStart(granularity, timestamp, timezone);
1619
1710
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
1620
1711
  const delta = aggregateDeltas.get(key) ?? {
1621
1712
  siteId,
@@ -1641,7 +1732,7 @@ function foldSessionDuration(aggregateDeltas, siteId, event, durationDeltaMs, ti
1641
1732
  aggregateDeltas.set(key, delta);
1642
1733
  }
1643
1734
  }
1644
- async function flushAggregates(ctx, aggregateDeltas) {
1735
+ export async function flushAggregates(ctx, aggregateDeltas) {
1645
1736
  for (const delta of aggregateDeltas.values()) {
1646
1737
  const bucket = await ctx.db
1647
1738
  .query("aggregateBuckets")