@iann29/rastro 0.2.0 → 0.4.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 (87) hide show
  1. package/README.md +82 -36
  2. package/agent/integration.md +4 -0
  3. package/agent/manifest.json +9 -5
  4. package/agent/manifest.schema.json +7 -1
  5. package/dist/client/federation.d.ts +18 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +7 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +138 -1
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +30 -0
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/component.d.ts +46 -0
  14. package/dist/component/_generated/component.d.ts.map +1 -1
  15. package/dist/component/_generated/server.d.ts +1 -0
  16. package/dist/component/_generated/server.d.ts.map +1 -1
  17. package/dist/component/_generated/server.js.map +1 -1
  18. package/dist/component/constants.d.ts +7 -2
  19. package/dist/component/constants.d.ts.map +1 -1
  20. package/dist/component/constants.js +24 -3
  21. package/dist/component/constants.js.map +1 -1
  22. package/dist/component/convex.config.d.ts +1 -0
  23. package/dist/component/convex.config.js +3 -0
  24. package/dist/component/convex.config.js.map +1 -1
  25. package/dist/component/geo.d.ts +1 -0
  26. package/dist/component/geo.d.ts.map +1 -1
  27. package/dist/component/geo.js +1 -1
  28. package/dist/component/geo.js.map +1 -1
  29. package/dist/component/http.d.ts.map +1 -1
  30. package/dist/component/http.js +14 -1
  31. package/dist/component/http.js.map +1 -1
  32. package/dist/component/identity.d.ts +12 -0
  33. package/dist/component/identity.d.ts.map +1 -1
  34. package/dist/component/identity.js +30 -2
  35. package/dist/component/identity.js.map +1 -1
  36. package/dist/component/ingest.d.ts +34 -0
  37. package/dist/component/ingest.d.ts.map +1 -1
  38. package/dist/component/ingest.js +86 -21
  39. package/dist/component/ingest.js.map +1 -1
  40. package/dist/component/live.d.ts.map +1 -1
  41. package/dist/component/live.js +34 -4
  42. package/dist/component/live.js.map +1 -1
  43. package/dist/component/reports.d.ts +47 -0
  44. package/dist/component/reports.d.ts.map +1 -1
  45. package/dist/component/reports.js +96 -6
  46. package/dist/component/reports.js.map +1 -1
  47. package/dist/component/rollupStore.d.ts +7 -4
  48. package/dist/component/rollupStore.d.ts.map +1 -1
  49. package/dist/component/rollupStore.js +37 -17
  50. package/dist/component/rollupStore.js.map +1 -1
  51. package/dist/component/rollups.d.ts +27 -8
  52. package/dist/component/rollups.d.ts.map +1 -1
  53. package/dist/component/rollups.js +110 -32
  54. package/dist/component/rollups.js.map +1 -1
  55. package/dist/component/schema.d.ts +6 -2
  56. package/dist/component/schema.js +5 -2
  57. package/dist/component/schema.js.map +1 -1
  58. package/dist/tracker/generated.d.ts +11 -11
  59. package/dist/tracker/generated.d.ts.map +1 -1
  60. package/dist/tracker/generated.js +11 -11
  61. package/dist/tracker/generated.js.map +1 -1
  62. package/dist/tracker/tracker.js +88 -43
  63. package/dist/tracker/tracker.js.map +1 -1
  64. package/dist/tracker/vitals.d.ts.map +1 -1
  65. package/dist/tracker/vitals.js +22 -4
  66. package/dist/tracker/vitals.js.map +1 -1
  67. package/dist/tracker.min.js +1 -1
  68. package/dist/vitals.min.js +1 -1
  69. package/docs/federation-setup.md +2 -0
  70. package/docs/federation.md +6 -0
  71. package/docs/upgrading.md +84 -0
  72. package/llms.txt +3 -3
  73. package/package.json +1 -1
  74. package/src/component/_generated/component.ts +49 -0
  75. package/src/component/_generated/server.ts +1 -0
  76. package/src/component/constants.ts +24 -3
  77. package/src/component/convex.config.ts +3 -0
  78. package/src/component/geo.ts +1 -1
  79. package/src/component/http.ts +15 -1
  80. package/src/component/identity.ts +41 -2
  81. package/src/component/ingest.ts +120 -36
  82. package/src/component/live.ts +51 -4
  83. package/src/component/reports.ts +120 -8
  84. package/src/component/rollupStore.ts +51 -20
  85. package/src/component/rollups.ts +141 -32
  86. package/src/component/schema.ts +6 -3
  87. package/src/tracker/generated.ts +11 -11
@@ -1,5 +1,6 @@
1
1
  import { v } from "convex/values";
2
2
  import { DAY_MS } from "./constants.js";
3
+ import { parseIpv6 } from "./geo.js";
3
4
  import { internalMutation, internalQuery } from "./_generated/server.js";
4
5
  /**
5
6
  * Anonymous visitors (no host-supplied id) are counted through a salted hash of
@@ -47,8 +48,35 @@ export function randomSecret() {
47
48
  return hex(crypto.getRandomValues(new Uint8Array(32)));
48
49
  }
49
50
  export async function deriveVisitorKey(input) {
50
- const dayStart = Math.floor(input.now / DAY_MS) * DAY_MS;
51
- const material = new TextEncoder().encode([input.secret, dayStart, input.siteId, input.ip, input.userAgent].join(" "));
51
+ return saltedDailyKey(input.secret, input.now, [
52
+ input.siteId,
53
+ input.ip,
54
+ input.userAgent,
55
+ ]);
56
+ }
57
+ /**
58
+ * The per-client ingest budget's key: the same salted daily hash, over the
59
+ * client's network instead of its device — the IPv4 address, or the /64 of an
60
+ * IPv6 address, the block one subscriber gets and could otherwise walk through
61
+ * one address per request.
62
+ */
63
+ export async function deriveClientKey(input) {
64
+ const groups = parseIpv6(input.ip.toLowerCase());
65
+ const network = groups
66
+ ? `${groups
67
+ .slice(0, 4)
68
+ .map((group) => group.toString(16))
69
+ .join(":")}::/64`
70
+ : input.ip;
71
+ return saltedDailyKey(input.secret, input.now, [
72
+ "client",
73
+ input.siteId,
74
+ network,
75
+ ]);
76
+ }
77
+ async function saltedDailyKey(secret, now, parts) {
78
+ const dayStart = Math.floor(now / DAY_MS) * DAY_MS;
79
+ const material = new TextEncoder().encode([secret, dayStart, ...parts].join(" "));
52
80
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", material));
53
81
  return hex(digest).slice(0, KEY_HEX_LENGTH);
54
82
  }
@@ -1 +1 @@
1
- {"version":3,"file":"identity.js","sourceRoot":"","sources":["../../src/component/identity.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"identity.js","sourceRoot":"","sources":["../../src/component/identity.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,29 @@
1
+ import type { Doc, Id } from "./_generated/dataModel.js";
2
+ import type { MutationCtx } from "./_generated/server.js";
3
+ import { type VisitorSketch } from "./cardinality.js";
4
+ type AggregateDelta = {
5
+ siteId: Id<"sites">;
6
+ granularity: "hour" | "day" | "localDay";
7
+ bucketStart: number;
8
+ shard: number;
9
+ events: number;
10
+ pageviews: number;
11
+ clicks: number;
12
+ customEvents: number;
13
+ outboundClicks: number;
14
+ sessions: number;
15
+ visitors: number;
16
+ visitorSketch: VisitorSketch;
17
+ conversions: number;
18
+ revenueCents: number;
19
+ dimensions: Doc<"aggregateBuckets">["dimensions"];
20
+ durationMs: number;
21
+ continuedSessions: number;
22
+ newVisitors: number;
23
+ };
24
+ export type AggregateDeltas = Map<string, AggregateDelta>;
1
25
  export declare const ingestBatch: import("convex/server").RegisteredMutation<"public", {
26
+ clientKey?: string | undefined;
2
27
  context?: {
3
28
  browser?: string | undefined;
4
29
  city?: string | undefined;
@@ -50,4 +75,13 @@ export declare const trackConversion: import("convex/server").RegisteredMutation
50
75
  duplicates: number;
51
76
  rejected: number;
52
77
  }>>;
78
+ /**
79
+ * Folds session duration growth without telemetry: a departure's final
80
+ * interval, or the heartbeat time of a visitor the live sweep retired. The
81
+ * shard follows `shardKey`: the session for a departure, the site for a
82
+ * sweep.
83
+ */
84
+ export declare function foldSessionDuration(aggregateDeltas: AggregateDeltas, siteId: Id<"sites">, shardKey: string, timestamp: number, durationDeltaMs: number, timezone?: string): void;
85
+ export declare function flushAggregates(ctx: MutationCtx, aggregateDeltas: AggregateDeltas): Promise<void>;
86
+ export {};
53
87
  //# sourceMappingURL=ingest.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ingest.d.ts","sourceRoot":"","sources":["../../src/component/ingest.ts"],"names":[],"mappings":"AAkNA,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2VtB,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;GA+H1B,CAAC"}
1
+ {"version":3,"file":"ingest.d.ts","sourceRoot":"","sources":["../../src/component/ingest.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,GAAG,EAAE,EAAE,EAAE,MAAM,2BAA2B,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AA2B1D,OAAO,EAIL,KAAK,aAAa,EACnB,MAAM,kBAAkB,CAAC;AAyE1B,KAAK,cAAc,GAAG;IACpB,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC;IACpB,WAAW,EAAE,MAAM,GAAG,KAAK,GAAG,UAAU,CAAC;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,aAAa,CAAC;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,GAAG,CAAC,kBAAkB,CAAC,CAAC,YAAY,CAAC,CAAC;IAClD,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;CACrB,CAAC;AACF,MAAM,MAAM,eAAe,GAAG,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;AAqF1D,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwXtB,CAAC;AAEH,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;GA+H1B,CAAC;AA2uDH;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,eAAe,EAAE,eAAe,EAChC,MAAM,EAAE,EAAE,CAAC,OAAO,CAAC,EACnB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,eAAe,EAAE,MAAM,EACvB,QAAQ,CAAC,EAAE,MAAM,QAgClB;AAED,wBAAsB,eAAe,CACnC,GAAG,EAAE,WAAW,EAChB,eAAe,EAAE,eAAe,iBAyEjC"}
@@ -1,7 +1,7 @@
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";
@@ -26,6 +26,8 @@ export const ingestBatch = mutation({
26
26
  origin: v.string(),
27
27
  events: v.array(trackerEventValidator),
28
28
  context: v.optional(ingestContextValidator),
29
+ // The HTTP action's salted daily client-network key (see identity.ts).
30
+ clientKey: v.optional(v.string()),
29
31
  },
30
32
  returns: ingestResultValidator,
31
33
  handler: async (ctx, args) => {
@@ -51,6 +53,9 @@ export const ingestBatch = mutation({
51
53
  }
52
54
  const now = Date.now();
53
55
  await ensureAnalyticsControl(ctx, args.siteId, now);
56
+ if (args.clientKey !== undefined) {
57
+ await consumeClientRateLimit(ctx, args.siteId, args.clientKey, eventByteCounts, now);
58
+ }
54
59
  await consumeSiteRateLimit(ctx, args.siteId, args.events, eventByteCounts, now);
55
60
  const context = sanitizeContext(args.context);
56
61
  const timezone = localDayTimezone(site.timezone);
@@ -155,19 +160,16 @@ export const ingestBatch = mutation({
155
160
  rejected += 1;
156
161
  continue;
157
162
  }
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);
163
+ // A heartbeat is presence, not telemetry: it moves the session's
164
+ // lastSeenAt and live row only. The time it proves reaches the
165
+ // duration counters once, when the visitor departs (live.ts).
166
+ await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now, batchState);
163
167
  accepted += 1;
164
168
  continue;
165
169
  }
166
170
  if (event.type === "leave") {
167
- // A departure only shortens live presence: it stores no telemetry,
168
- // touches no aggregates, and never extends anything, so a replayed
169
- // or late leave is inert. The read side filters by expiresAt and the
170
- // sweep deletes the row, so the dashboard sees the exit in seconds.
171
+ // A departure closes the final duration interval without telemetry
172
+ // or new presence. Older departures cannot retire a bfcache return.
171
173
  if (!existingSession) {
172
174
  rejected += 1;
173
175
  continue;
@@ -178,7 +180,22 @@ export const ingestBatch = mutation({
178
180
  }
179
181
  const liveState = await loadLiveSession(ctx, args.siteId, event.sessionId, batchState);
180
182
  const liveSession = liveState.current;
181
- if (liveSession) {
183
+ if (liveSession && event.timestamp >= liveSession.lastSeenAt) {
184
+ // Only the still-covered tail is trusted; a delayed departure
185
+ // cannot lengthen an expired visit or move its start backwards.
186
+ if (event.timestamp > existingSession.lastSeenAt &&
187
+ event.timestamp >= now - LIVE_SESSION_TTL_MS &&
188
+ event.timestamp <= liveSession.expiresAt) {
189
+ const durationMs = Math.max(existingSession.durationMs, event.timestamp - existingSession.startedAt);
190
+ sessionRow.current = {
191
+ ...existingSession,
192
+ lastSeenAt: event.timestamp,
193
+ exitPath: event.path,
194
+ durationMs,
195
+ };
196
+ sessionRow.dirty = true;
197
+ foldSessionDuration(aggregateDeltas, args.siteId, event.sessionId, event.timestamp, durationMs - existingSession.durationMs, timezone);
198
+ }
182
199
  const expiresAt = Math.min(liveSession.expiresAt, now + LIVE_LEAVE_GRACE_MS);
183
200
  if (expiresAt < liveSession.expiresAt) {
184
201
  await ctx.db.patch("liveSessions", liveSession._id, { expiresAt });
@@ -855,6 +872,12 @@ async function attributionForEvent(ctx, siteId, event, existingSession, batchSta
855
872
  affiliateExpiresAt: expiresAt,
856
873
  };
857
874
  }
875
+ /**
876
+ * Moves the session's lastSeenAt and its live row. `durationMs` stays: it is
877
+ * what the duration counters already hold, and the time a heartbeat proves
878
+ * is counted when the visitor departs (live.ts), so an idle tab never
879
+ * rewrites an aggregate bucket.
880
+ */
858
881
  async function accountHeartbeat(ctx, siteId, session, event, context, now, batchState) {
859
882
  const startedAt = Math.min(session.startedAt, event.timestamp);
860
883
  const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
@@ -862,7 +885,6 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
862
885
  startedAt,
863
886
  lastSeenAt,
864
887
  exitPath: event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
865
- durationMs: Math.max(0, lastSeenAt - startedAt),
866
888
  ...(context.country ? { country: context.country } : {}),
867
889
  ...(context.city ? { city: context.city } : {}),
868
890
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
@@ -883,7 +905,6 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
883
905
  else {
884
906
  await ctx.db.patch("sessions", session._id, updates);
885
907
  }
886
- const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
887
908
  await updateLiveSession(ctx, siteId, event, context, {
888
909
  isNewSession: false,
889
910
  isNewVisitorHour: false,
@@ -905,7 +926,42 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
905
926
  conversionCount: session.conversionCount,
906
927
  newVisitor: session.newVisitor,
907
928
  }, now, batchState);
908
- return durationDeltaMs;
929
+ }
930
+ /**
931
+ * One client network's budget per site and minute, kept in the session
932
+ * windows' table under `~client:<key>`, a key no sanitized session ID can take.
933
+ * The key is the salted daily hash the HTTP action derived; the address never
934
+ * reaches this mutation. It is one row per client, so only that client's own
935
+ * concurrent requests contend on it.
936
+ */
937
+ async function consumeClientRateLimit(ctx, siteId, clientKey, eventByteCounts, now) {
938
+ const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
939
+ const sessionId = `~client:${clientKey}`;
940
+ const rows = await ctx.db
941
+ .query("ingestWindows")
942
+ .withIndex("by_siteId_and_sessionId_and_windowStart", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
943
+ .order("desc")
944
+ .take(1);
945
+ const window = rows[0] ?? null;
946
+ const current = window?.windowStart === windowStart ? window : null;
947
+ const eventCount = (current?.eventCount ?? 0) + eventByteCounts.length;
948
+ const byteCount = (current?.byteCount ?? 0) +
949
+ eventByteCounts.reduce((total, bytes) => total + bytes, 0);
950
+ if (eventCount > MAX_EVENTS_PER_CLIENT_WINDOW ||
951
+ byteCount > MAX_BYTES_PER_CLIENT_WINDOW) {
952
+ fail("RATE_LIMITED", "client ingest budget exceeded", {
953
+ windowMs: RATE_LIMIT_WINDOW_MS,
954
+ eventLimitPerClient: MAX_EVENTS_PER_CLIENT_WINDOW,
955
+ byteLimitPerClient: MAX_BYTES_PER_CLIENT_WINDOW,
956
+ });
957
+ }
958
+ const fields = { windowStart, eventCount, byteCount, updatedAt: now };
959
+ if (window) {
960
+ await ctx.db.patch("ingestWindows", window._id, fields);
961
+ }
962
+ else {
963
+ await ctx.db.insert("ingestWindows", { siteId, sessionId, ...fields });
964
+ }
909
965
  }
910
966
  async function consumeSiteRateLimit(ctx, siteId, events, eventByteCounts, now) {
911
967
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
@@ -1214,9 +1270,11 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
1214
1270
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1215
1271
  return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1216
1272
  }
1217
- if (progress.completedAt !== undefined ||
1218
- event.timestamp < progress.lastStepAt)
1273
+ if (event.timestamp < progress.lastStepAt)
1219
1274
  return;
1275
+ // An expired window restarts on a first-step event even after a completion,
1276
+ // so a returning buyer can complete again; inside the window a completed
1277
+ // funnel stays done.
1220
1278
  if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
1221
1279
  if (!matchesFirst)
1222
1280
  return;
@@ -1245,6 +1303,8 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
1245
1303
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1246
1304
  return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1247
1305
  }
1306
+ if (progress.completedAt !== undefined)
1307
+ return;
1248
1308
  const nextStep = funnel.steps[progress.currentStep];
1249
1309
  if (!nextStep || !stepMatches(nextStep, event))
1250
1310
  return;
@@ -1595,14 +1655,19 @@ function aggregateBucketStart(granularity, timestamp, timezone) {
1595
1655
  const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
1596
1656
  return Math.floor(timestamp / interval) * interval;
1597
1657
  }
1598
- /** Folds only session duration growth, for heartbeats. */
1599
- function foldSessionDuration(aggregateDeltas, siteId, event, durationDeltaMs, timezone) {
1658
+ /**
1659
+ * Folds session duration growth without telemetry: a departure's final
1660
+ * interval, or the heartbeat time of a visitor the live sweep retired. The
1661
+ * shard follows `shardKey`: the session for a departure, the site for a
1662
+ * sweep.
1663
+ */
1664
+ export function foldSessionDuration(aggregateDeltas, siteId, shardKey, timestamp, durationDeltaMs, timezone) {
1600
1665
  if (durationDeltaMs <= 0)
1601
1666
  return;
1602
1667
  for (const granularity of aggregateGranularities(timezone)) {
1603
- const shard = stableHash(event.sessionId) %
1668
+ const shard = stableHash(shardKey) %
1604
1669
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
1605
- const bucketStart = aggregateBucketStart(granularity, event.timestamp, timezone);
1670
+ const bucketStart = aggregateBucketStart(granularity, timestamp, timezone);
1606
1671
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
1607
1672
  const delta = aggregateDeltas.get(key) ?? {
1608
1673
  siteId,
@@ -1628,7 +1693,7 @@ function foldSessionDuration(aggregateDeltas, siteId, event, durationDeltaMs, ti
1628
1693
  aggregateDeltas.set(key, delta);
1629
1694
  }
1630
1695
  }
1631
- async function flushAggregates(ctx, aggregateDeltas) {
1696
+ export async function flushAggregates(ctx, aggregateDeltas) {
1632
1697
  for (const delta of aggregateDeltas.values()) {
1633
1698
  const bucket = await ctx.db
1634
1699
  .query("aggregateBuckets")