@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
@@ -14,6 +14,8 @@ import {
14
14
  LIVE_SESSION_TTL_MS,
15
15
  LIVE_SWEEP_INTERVAL_MS,
16
16
  MAX_BATCH_EVENTS,
17
+ MAX_BYTES_PER_CLIENT_WINDOW,
18
+ MAX_EVENTS_PER_CLIENT_WINDOW,
17
19
  MAX_SITE_BYTES_PER_SHARD_WINDOW,
18
20
  MAX_SITE_EVENTS_PER_SHARD_WINDOW,
19
21
  MAX_BYTES_PER_SESSION_WINDOW,
@@ -52,6 +54,7 @@ import {
52
54
  } from "./eventStore.js";
53
55
  import {
54
56
  cleanString,
57
+ externalReferrer,
55
58
  isValidMoney,
56
59
  measureBatchBytes,
57
60
  normalizeRoute,
@@ -72,6 +75,8 @@ import {
72
75
  ingestResultValidator,
73
76
  trackerEventValidator,
74
77
  trustedConversionValidator,
78
+ type Channel,
79
+ type Evidence,
75
80
  type IngestContext,
76
81
  type TrackerEvent,
77
82
  } from "./validators.js";
@@ -83,6 +88,7 @@ import {
83
88
  type RouteRollupDelta,
84
89
  } from "./rollupStore.js";
85
90
  import { localDayStart, localDayTimezone } from "./localTime.js";
91
+ import { classifyOrigin, platformDimensionValue } from "./origin.js";
86
92
 
87
93
  type DimensionType =
88
94
  | "source"
@@ -94,7 +100,10 @@ type DimensionType =
94
100
  | "event"
95
101
  | "goal"
96
102
  | "medium"
97
- | "outbound";
103
+ | "outbound"
104
+ | "platform"
105
+ | "channel"
106
+ | "evidence";
98
107
 
99
108
  type Dimension = { type: DimensionType; value: string };
100
109
  type DimensionDelta = Dimension & {
@@ -123,7 +132,7 @@ type AggregateDelta = {
123
132
  continuedSessions: number;
124
133
  newVisitors: number;
125
134
  };
126
- type AggregateDeltas = Map<string, AggregateDelta>;
135
+ export type AggregateDeltas = Map<string, AggregateDelta>;
127
136
  type AttributionSnapshot = {
128
137
  affiliateId: Id<"affiliates">;
129
138
  affiliateSlug: string;
@@ -143,6 +152,10 @@ type SessionState = {
143
152
  continued: boolean;
144
153
  startedAt: number;
145
154
  source: string;
155
+ /** The first touch's origin; absent on sessions recorded before it. */
156
+ platform?: string;
157
+ channel?: Channel;
158
+ evidence?: Evidence;
146
159
  visitorKey: string;
147
160
  utmCampaign?: string;
148
161
  utmMedium?: string;
@@ -214,6 +227,8 @@ export const ingestBatch = mutation({
214
227
  origin: v.string(),
215
228
  events: v.array(trackerEventValidator),
216
229
  context: v.optional(ingestContextValidator),
230
+ // The HTTP action's salted daily client-network key (see identity.ts).
231
+ clientKey: v.optional(v.string()),
217
232
  },
218
233
  returns: ingestResultValidator,
219
234
  handler: async (ctx, args) => {
@@ -245,6 +260,15 @@ export const ingestBatch = mutation({
245
260
 
246
261
  const now = Date.now();
247
262
  await ensureAnalyticsControl(ctx, args.siteId, now);
263
+ if (args.clientKey !== undefined) {
264
+ await consumeClientRateLimit(
265
+ ctx,
266
+ args.siteId,
267
+ args.clientKey,
268
+ eventByteCounts,
269
+ now,
270
+ );
271
+ }
248
272
  await consumeSiteRateLimit(
249
273
  ctx,
250
274
  args.siteId,
@@ -389,7 +413,10 @@ export const ingestBatch = mutation({
389
413
  rejected += 1;
390
414
  continue;
391
415
  }
392
- const durationDeltaMs = await accountHeartbeat(
416
+ // A heartbeat is presence, not telemetry: it moves the session's
417
+ // lastSeenAt and live row only. The time it proves reaches the
418
+ // duration counters once, when the visitor departs (live.ts).
419
+ await accountHeartbeat(
393
420
  ctx,
394
421
  args.siteId,
395
422
  existingSession,
@@ -398,16 +425,6 @@ export const ingestBatch = mutation({
398
425
  now,
399
426
  batchState,
400
427
  );
401
- // A heartbeat is presence, not telemetry, but it is what keeps a
402
- // session's duration honest while the visitor reads: the growth is
403
- // folded into the buckets the heartbeat landed in.
404
- foldSessionDuration(
405
- aggregateDeltas,
406
- args.siteId,
407
- event,
408
- durationDeltaMs,
409
- timezone,
410
- );
411
428
  accepted += 1;
412
429
  continue;
413
430
  }
@@ -461,7 +478,8 @@ export const ingestBatch = mutation({
461
478
  foldSessionDuration(
462
479
  aggregateDeltas,
463
480
  args.siteId,
464
- event,
481
+ event.sessionId,
482
+ event.timestamp,
465
483
  durationMs - existingSession.durationMs,
466
484
  timezone,
467
485
  );
@@ -560,6 +578,7 @@ export const ingestBatch = mutation({
560
578
  reportRollupDeltas,
561
579
  recordTelemetry: true,
562
580
  timezone,
581
+ siteDomains: site.domains,
563
582
  financialConversion: false,
564
583
  trustedGoalKey: undefined,
565
584
  now,
@@ -690,6 +709,7 @@ export const trackConversion = mutation({
690
709
  reportRollupDeltas,
691
710
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
692
711
  timezone: localDayTimezone(site.timezone),
712
+ siteDomains: site.domains,
693
713
  financialConversion: true,
694
714
  trustedGoalKey,
695
715
  now: Date.now(),
@@ -828,6 +848,8 @@ async function accountEvent(
828
848
  recordTelemetry: boolean;
829
849
  /** The site's timezone when it keeps local-day buckets. */
830
850
  timezone?: string;
851
+ /** The site's own domains: a referrer on them is not an origin. */
852
+ siteDomains: string[];
831
853
  financialConversion: boolean;
832
854
  trustedGoalKey: string | undefined;
833
855
  now: number;
@@ -841,6 +863,7 @@ async function accountEvent(
841
863
  input.siteId,
842
864
  event,
843
865
  input.context,
866
+ input.siteDomains,
844
867
  input.existingSession,
845
868
  input.financialConversion,
846
869
  input.reportRollupDeltas,
@@ -962,6 +985,23 @@ async function accountEvent(
962
985
  event.type === "outbound" ? publicUrlHost(event.href) : undefined;
963
986
  const dimensions: Dimension[] = [
964
987
  { type: "source", value: session.source },
988
+ // A session recorded before origins carries none, and fills no slot.
989
+ ...(session.platform && session.channel && session.evidence
990
+ ? ([
991
+ {
992
+ type: "platform",
993
+ // The value carries the channel too when it is not the
994
+ // platform's default, so the pair survives without its own
995
+ // dimension (splitPlatformDimension reads it back).
996
+ value: platformDimensionValue({
997
+ platform: session.platform,
998
+ channel: session.channel,
999
+ }),
1000
+ },
1001
+ { type: "channel", value: session.channel },
1002
+ { type: "evidence", value: session.evidence },
1003
+ ] satisfies Dimension[])
1004
+ : []),
965
1005
  ...campaignDimensions,
966
1006
  ...(outboundHost
967
1007
  ? [{ type: "outbound", value: outboundHost } satisfies Dimension]
@@ -1028,6 +1068,9 @@ async function accountTrustedSession(
1028
1068
  continued: false,
1029
1069
  startedAt: existing.startedAt,
1030
1070
  source: existing.source,
1071
+ platform: existing.platform,
1072
+ channel: existing.channel,
1073
+ evidence: existing.evidence,
1031
1074
  utmCampaign: existing.utmCampaign,
1032
1075
  utmMedium: existing.utmMedium,
1033
1076
  entryPath: existing.entryPath,
@@ -1052,6 +1095,7 @@ async function upsertSession(
1052
1095
  siteId: Id<"sites">,
1053
1096
  event: TrackerEvent,
1054
1097
  context: IngestContext,
1098
+ siteDomains: string[],
1055
1099
  existing: CachedDocument<"sessions"> | null,
1056
1100
  financialConversion: boolean,
1057
1101
  reportRollupDeltas: ReportRollupDeltas,
@@ -1149,6 +1193,9 @@ async function upsertSession(
1149
1193
  continued: event.type === "pageview" && existing.pageviewCount === 1,
1150
1194
  startedAt,
1151
1195
  source: existing.source,
1196
+ platform: existing.platform,
1197
+ channel: existing.channel,
1198
+ evidence: existing.evidence,
1152
1199
  utmCampaign: existing.utmCampaign,
1153
1200
  utmMedium: existing.utmMedium,
1154
1201
  entryPath: existing.entryPath,
@@ -1199,6 +1246,17 @@ async function upsertSession(
1199
1246
  .first();
1200
1247
  if (alias) isNewVisitor = false;
1201
1248
  }
1249
+ // First touch only, like `source`: the session keeps where it came from.
1250
+ // An affiliate channel needs this landing's own `ref` to have resolved; a
1251
+ // visitor's standing attribution alone is not how this visit arrived.
1252
+ const origin = classifyOrigin({
1253
+ utmSource,
1254
+ utmMedium,
1255
+ clickIds: event.clid,
1256
+ referrer: externalReferrer(event.referrer, siteDomains),
1257
+ inApp: context.inApp,
1258
+ affiliate: event.affiliateSlug !== undefined && attribution !== undefined,
1259
+ });
1202
1260
  const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
1203
1261
  siteId,
1204
1262
  sessionId: event.sessionId,
@@ -1211,6 +1269,7 @@ async function upsertSession(
1211
1269
  newVisitor: isNewVisitor,
1212
1270
  ...(event.referrer ? { referrer: event.referrer } : {}),
1213
1271
  source,
1272
+ ...origin,
1214
1273
  ...(utmSource ? { utmSource } : {}),
1215
1274
  ...(utmMedium ? { utmMedium } : {}),
1216
1275
  ...(utmCampaign ? { utmCampaign } : {}),
@@ -1267,6 +1326,7 @@ async function upsertSession(
1267
1326
  continued: false,
1268
1327
  startedAt: event.timestamp,
1269
1328
  source,
1329
+ ...origin,
1270
1330
  utmCampaign,
1271
1331
  utmMedium,
1272
1332
  entryPath: event.path,
@@ -1421,6 +1481,12 @@ async function attributionForEvent(
1421
1481
  };
1422
1482
  }
1423
1483
 
1484
+ /**
1485
+ * Moves the session's lastSeenAt and its live row. `durationMs` stays: it is
1486
+ * what the duration counters already hold, and the time a heartbeat proves
1487
+ * is counted when the visitor departs (live.ts), so an idle tab never
1488
+ * rewrites an aggregate bucket.
1489
+ */
1424
1490
  async function accountHeartbeat(
1425
1491
  ctx: MutationCtx,
1426
1492
  siteId: Id<"sites">,
@@ -1429,7 +1495,7 @@ async function accountHeartbeat(
1429
1495
  context: IngestContext,
1430
1496
  now: number,
1431
1497
  batchState?: BatchState,
1432
- ): Promise<number> {
1498
+ ): Promise<void> {
1433
1499
  const startedAt = Math.min(session.startedAt, event.timestamp);
1434
1500
  const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
1435
1501
  const updates = {
@@ -1437,7 +1503,6 @@ async function accountHeartbeat(
1437
1503
  lastSeenAt,
1438
1504
  exitPath:
1439
1505
  event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
1440
- durationMs: Math.max(0, lastSeenAt - startedAt),
1441
1506
  ...(context.country ? { country: context.country } : {}),
1442
1507
  ...(context.city ? { city: context.city } : {}),
1443
1508
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
@@ -1456,7 +1521,6 @@ async function accountHeartbeat(
1456
1521
  } else {
1457
1522
  await ctx.db.patch("sessions", session._id, updates);
1458
1523
  }
1459
- const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
1460
1524
  await updateLiveSession(
1461
1525
  ctx,
1462
1526
  siteId,
@@ -1486,7 +1550,54 @@ async function accountHeartbeat(
1486
1550
  now,
1487
1551
  batchState,
1488
1552
  );
1489
- return durationDeltaMs;
1553
+ }
1554
+
1555
+ /**
1556
+ * One client network's budget per site and minute, kept in the session
1557
+ * windows' table under `~client:<key>`, a key no sanitized session ID can take.
1558
+ * The key is the salted daily hash the HTTP action derived; the address never
1559
+ * reaches this mutation. It is one row per client, so only that client's own
1560
+ * concurrent requests contend on it.
1561
+ */
1562
+ async function consumeClientRateLimit(
1563
+ ctx: MutationCtx,
1564
+ siteId: Id<"sites">,
1565
+ clientKey: string,
1566
+ eventByteCounts: number[],
1567
+ now: number,
1568
+ ): Promise<void> {
1569
+ const windowStart =
1570
+ Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1571
+ const sessionId = `~client:${clientKey}`;
1572
+ const rows = await ctx.db
1573
+ .query("ingestWindows")
1574
+ .withIndex("by_siteId_and_sessionId_and_windowStart", (range) =>
1575
+ range.eq("siteId", siteId).eq("sessionId", sessionId),
1576
+ )
1577
+ .order("desc")
1578
+ .take(1);
1579
+ const window = rows[0] ?? null;
1580
+ const current = window?.windowStart === windowStart ? window : null;
1581
+ const eventCount = (current?.eventCount ?? 0) + eventByteCounts.length;
1582
+ const byteCount =
1583
+ (current?.byteCount ?? 0) +
1584
+ eventByteCounts.reduce((total, bytes) => total + bytes, 0);
1585
+ if (
1586
+ eventCount > MAX_EVENTS_PER_CLIENT_WINDOW ||
1587
+ byteCount > MAX_BYTES_PER_CLIENT_WINDOW
1588
+ ) {
1589
+ fail("RATE_LIMITED", "client ingest budget exceeded", {
1590
+ windowMs: RATE_LIMIT_WINDOW_MS,
1591
+ eventLimitPerClient: MAX_EVENTS_PER_CLIENT_WINDOW,
1592
+ byteLimitPerClient: MAX_BYTES_PER_CLIENT_WINDOW,
1593
+ });
1594
+ }
1595
+ const fields = { windowStart, eventCount, byteCount, updatedAt: now };
1596
+ if (window) {
1597
+ await ctx.db.patch("ingestWindows", window._id, fields);
1598
+ } else {
1599
+ await ctx.db.insert("ingestWindows", { siteId, sessionId, ...fields });
1600
+ }
1490
1601
  }
1491
1602
 
1492
1603
  async function consumeSiteRateLimit(
@@ -1872,11 +1983,10 @@ async function advanceFunnel(
1872
1983
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1873
1984
  return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1874
1985
  }
1875
- if (
1876
- progress.completedAt !== undefined ||
1877
- event.timestamp < progress.lastStepAt
1878
- )
1879
- return;
1986
+ if (event.timestamp < progress.lastStepAt) return;
1987
+ // An expired window restarts on a first-step event even after a completion,
1988
+ // so a returning buyer can complete again; inside the window a completed
1989
+ // funnel stays done.
1880
1990
  if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
1881
1991
  if (!matchesFirst) return;
1882
1992
  const updated = {
@@ -1910,6 +2020,7 @@ async function advanceFunnel(
1910
2020
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1911
2021
  return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
1912
2022
  }
2023
+ if (progress.completedAt !== undefined) return;
1913
2024
  const nextStep = funnel.steps[progress.currentStep];
1914
2025
  if (!nextStep || !stepMatches(nextStep, event)) return;
1915
2026
  const currentStep = progress.currentStep + 1;
@@ -2428,24 +2539,26 @@ function aggregateBucketStart(
2428
2539
  return Math.floor(timestamp / interval) * interval;
2429
2540
  }
2430
2541
 
2431
- /** Folds session duration growth without telemetry, for heartbeats and leaves. */
2432
- function foldSessionDuration(
2542
+ /**
2543
+ * Folds session duration growth without telemetry: a departure's final
2544
+ * interval, or the heartbeat time of a visitor the live sweep retired. The
2545
+ * shard follows `shardKey`: the session for a departure, the site for a
2546
+ * sweep.
2547
+ */
2548
+ export function foldSessionDuration(
2433
2549
  aggregateDeltas: AggregateDeltas,
2434
2550
  siteId: Id<"sites">,
2435
- event: TrackerEvent,
2551
+ shardKey: string,
2552
+ timestamp: number,
2436
2553
  durationDeltaMs: number,
2437
2554
  timezone?: string,
2438
2555
  ) {
2439
2556
  if (durationDeltaMs <= 0) return;
2440
2557
  for (const granularity of aggregateGranularities(timezone)) {
2441
2558
  const shard =
2442
- stableHash(event.sessionId) %
2559
+ stableHash(shardKey) %
2443
2560
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
2444
- const bucketStart = aggregateBucketStart(
2445
- granularity,
2446
- event.timestamp,
2447
- timezone,
2448
- );
2561
+ const bucketStart = aggregateBucketStart(granularity, timestamp, timezone);
2449
2562
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
2450
2563
  const delta = aggregateDeltas.get(key) ?? {
2451
2564
  siteId,
@@ -2472,7 +2585,7 @@ function foldSessionDuration(
2472
2585
  }
2473
2586
  }
2474
2587
 
2475
- async function flushAggregates(
2588
+ export async function flushAggregates(
2476
2589
  ctx: MutationCtx,
2477
2590
  aggregateDeltas: AggregateDeltas,
2478
2591
  ) {
@@ -1,7 +1,15 @@
1
1
  import { v } from "convex/values";
2
2
  import { internal } from "./_generated/api.js";
3
+ import type { Doc, Id } from "./_generated/dataModel.js";
4
+ import type { MutationCtx } from "./_generated/server.js";
3
5
  import { internalMutation } from "./_generated/server.js";
4
6
  import { LIVE_SWEEP_INTERVAL_MS } from "./constants.js";
7
+ import {
8
+ flushAggregates,
9
+ foldSessionDuration,
10
+ type AggregateDeltas,
11
+ } from "./ingest.js";
12
+ import { localDayTimezone } from "./localTime.js";
5
13
 
6
14
  const SWEEP_BATCH_SIZE = 500;
7
15
 
@@ -58,7 +66,7 @@ export const expire = internalMutation({
58
66
  }
59
67
  return { expired: false, rescheduled: false, stale: false };
60
68
  }
61
- await ctx.db.delete("liveSessions", liveSession._id);
69
+ await retireLiveSessions(ctx, liveSession.siteId, [liveSession]);
62
70
  return { expired: true, rescheduled: false, stale: false };
63
71
  },
64
72
  });
@@ -89,9 +97,7 @@ export const sweep = internalMutation({
89
97
  range.eq("siteId", args.siteId).lte("expiresAt", now),
90
98
  )
91
99
  .take(SWEEP_BATCH_SIZE);
92
- for (const session of expired) {
93
- await ctx.db.delete("liveSessions", session._id);
94
- }
100
+ await retireLiveSessions(ctx, args.siteId, expired);
95
101
 
96
102
  const next = await ctx.db
97
103
  .query("liveSessions")
@@ -115,3 +121,44 @@ export const sweep = internalMutation({
115
121
  return { expired: expired.length, rescheduled: true, stale: false };
116
122
  },
117
123
  });
124
+
125
+ /**
126
+ * Deletes departed live rows and counts the time their heartbeats proved. A
127
+ * heartbeat only moves its session's `lastSeenAt`, and `durationMs` is what
128
+ * the duration counters already hold, so the difference reaches the buckets
129
+ * here, once, in the hour the visitor was last seen. It lands in one shard
130
+ * per site, so a sweep rewrites a few buckets however many visitors it
131
+ * retires.
132
+ */
133
+ async function retireLiveSessions(
134
+ ctx: MutationCtx,
135
+ siteId: Id<"sites">,
136
+ rows: Doc<"liveSessions">[],
137
+ ) {
138
+ if (rows.length === 0) return;
139
+ const site = await ctx.db.get("sites", siteId);
140
+ const timezone = localDayTimezone(site?.timezone);
141
+ const aggregateDeltas: AggregateDeltas = new Map();
142
+ for (const row of rows) {
143
+ await ctx.db.delete("liveSessions", row._id);
144
+ const session = await ctx.db
145
+ .query("sessions")
146
+ .withIndex("by_siteId_and_sessionId", (range) =>
147
+ range.eq("siteId", siteId).eq("sessionId", row.sessionId),
148
+ )
149
+ .unique();
150
+ if (!session) continue;
151
+ const durationMs = session.lastSeenAt - session.startedAt;
152
+ if (durationMs <= session.durationMs) continue;
153
+ await ctx.db.patch("sessions", session._id, { durationMs });
154
+ foldSessionDuration(
155
+ aggregateDeltas,
156
+ siteId,
157
+ siteId,
158
+ session.lastSeenAt,
159
+ durationMs - session.durationMs,
160
+ timezone,
161
+ );
162
+ }
163
+ await flushAggregates(ctx, aggregateDeltas);
164
+ }