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

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 (164) hide show
  1. package/README.md +579 -57
  2. package/agent/integration.md +661 -0
  3. package/agent/manifest.json +187 -0
  4. package/agent/manifest.schema.json +411 -0
  5. package/dist/client/federation.d.ts +236 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +196 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +2467 -18
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +461 -32
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +18 -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 +302 -4
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +4 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/affiliates.d.ts.map +1 -1
  22. package/dist/component/affiliates.js +6 -2
  23. package/dist/component/affiliates.js.map +1 -1
  24. package/dist/component/cardinality.d.ts +12 -0
  25. package/dist/component/cardinality.d.ts.map +1 -0
  26. package/dist/component/cardinality.js +94 -0
  27. package/dist/component/cardinality.js.map +1 -0
  28. package/dist/component/constants.d.ts +34 -7
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +47 -7
  31. package/dist/component/constants.js.map +1 -1
  32. package/dist/component/convex.config.d.ts +6 -1
  33. package/dist/component/convex.config.js +9 -1
  34. package/dist/component/convex.config.js.map +1 -1
  35. package/dist/component/coverage.d.ts +19 -0
  36. package/dist/component/coverage.d.ts.map +1 -0
  37. package/dist/component/coverage.js +30 -0
  38. package/dist/component/coverage.js.map +1 -0
  39. package/dist/component/diagnostics.d.ts +9 -0
  40. package/dist/component/diagnostics.d.ts.map +1 -0
  41. package/dist/component/diagnostics.js +47 -0
  42. package/dist/component/diagnostics.js.map +1 -0
  43. package/dist/component/errors.d.ts +1 -1
  44. package/dist/component/errors.d.ts.map +1 -1
  45. package/dist/component/errors.js.map +1 -1
  46. package/dist/component/eventStore.d.ts +106 -0
  47. package/dist/component/eventStore.d.ts.map +1 -0
  48. package/dist/component/eventStore.js +294 -0
  49. package/dist/component/eventStore.js.map +1 -0
  50. package/dist/component/funnels.d.ts.map +1 -1
  51. package/dist/component/funnels.js +5 -3
  52. package/dist/component/funnels.js.map +1 -1
  53. package/dist/component/geo.d.ts +71 -0
  54. package/dist/component/geo.d.ts.map +1 -0
  55. package/dist/component/geo.js +611 -0
  56. package/dist/component/geo.js.map +1 -0
  57. package/dist/component/goals.d.ts.map +1 -1
  58. package/dist/component/goals.js +25 -9
  59. package/dist/component/goals.js.map +1 -1
  60. package/dist/component/guards.d.ts.map +1 -1
  61. package/dist/component/guards.js.map +1 -1
  62. package/dist/component/http.d.ts.map +1 -1
  63. package/dist/component/http.js +284 -53
  64. package/dist/component/http.js.map +1 -1
  65. package/dist/component/identity.d.ts +13 -0
  66. package/dist/component/identity.d.ts.map +1 -0
  67. package/dist/component/identity.js +58 -0
  68. package/dist/component/identity.js.map +1 -0
  69. package/dist/component/ingest.d.ts +3 -1
  70. package/dist/component/ingest.d.ts.map +1 -1
  71. package/dist/component/ingest.js +1105 -243
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts +8 -0
  74. package/dist/component/live.d.ts.map +1 -1
  75. package/dist/component/live.js +74 -9
  76. package/dist/component/live.js.map +1 -1
  77. package/dist/component/reports.d.ts +273 -24
  78. package/dist/component/reports.d.ts.map +1 -1
  79. package/dist/component/reports.js +1320 -150
  80. package/dist/component/reports.js.map +1 -1
  81. package/dist/component/retention.d.ts +75 -1
  82. package/dist/component/retention.d.ts.map +1 -1
  83. package/dist/component/retention.js +541 -31
  84. package/dist/component/retention.js.map +1 -1
  85. package/dist/component/sanitize.d.ts +24 -1
  86. package/dist/component/sanitize.d.ts.map +1 -1
  87. package/dist/component/sanitize.js +107 -19
  88. package/dist/component/sanitize.js.map +1 -1
  89. package/dist/component/schema.d.ts +462 -55
  90. package/dist/component/schema.js +195 -15
  91. package/dist/component/schema.js.map +1 -1
  92. package/dist/component/sites.d.ts.map +1 -1
  93. package/dist/component/sites.js +38 -25
  94. package/dist/component/sites.js.map +1 -1
  95. package/dist/component/useragent.d.ts +9 -0
  96. package/dist/component/useragent.d.ts.map +1 -0
  97. package/dist/component/useragent.js +152 -0
  98. package/dist/component/useragent.js.map +1 -0
  99. package/dist/component/validators.d.ts +101 -16
  100. package/dist/component/validators.d.ts.map +1 -1
  101. package/dist/component/validators.js +19 -2
  102. package/dist/component/validators.js.map +1 -1
  103. package/dist/component/visitors.d.ts +19 -0
  104. package/dist/component/visitors.d.ts.map +1 -0
  105. package/dist/component/visitors.js +86 -0
  106. package/dist/component/visitors.js.map +1 -0
  107. package/dist/component/vitals.d.ts +41 -0
  108. package/dist/component/vitals.d.ts.map +1 -0
  109. package/dist/component/vitals.js +115 -0
  110. package/dist/component/vitals.js.map +1 -0
  111. package/dist/react/index.d.ts.map +1 -1
  112. package/dist/react/index.js.map +1 -1
  113. package/dist/tracker/generated.d.ts +8 -4
  114. package/dist/tracker/generated.d.ts.map +1 -1
  115. package/dist/tracker/generated.js +8 -4
  116. package/dist/tracker/generated.js.map +1 -1
  117. package/dist/tracker/tracker.d.ts +1 -1
  118. package/dist/tracker/tracker.d.ts.map +1 -1
  119. package/dist/tracker/tracker.js +81 -33
  120. package/dist/tracker/tracker.js.map +1 -1
  121. package/dist/tracker/vitals.d.ts +10 -0
  122. package/dist/tracker/vitals.d.ts.map +1 -0
  123. package/dist/tracker/vitals.js +140 -0
  124. package/dist/tracker/vitals.js.map +1 -0
  125. package/dist/tracker.min.js +1 -1
  126. package/dist/vitals.min.js +1 -0
  127. package/docs/benchmarks/2026-08-20-realistic.md +171 -0
  128. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  129. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  130. package/docs/federation-setup.md +397 -0
  131. package/docs/federation.md +262 -0
  132. package/docs/upgrading.md +259 -0
  133. package/llms.txt +71 -0
  134. package/package.json +61 -11
  135. package/scripts/benchmark-ingest.mjs +601 -0
  136. package/scripts/generate-federation-keys.mjs +20 -0
  137. package/src/component/_generated/api.ts +18 -0
  138. package/src/component/_generated/component.ts +366 -4
  139. package/src/component/_generated/server.ts +4 -0
  140. package/src/component/affiliates.ts +20 -5
  141. package/src/component/cardinality.ts +117 -0
  142. package/src/component/constants.ts +47 -7
  143. package/src/component/convex.config.ts +11 -1
  144. package/src/component/coverage.ts +36 -0
  145. package/src/component/diagnostics.ts +65 -0
  146. package/src/component/errors.ts +2 -1
  147. package/src/component/eventStore.ts +489 -0
  148. package/src/component/funnels.ts +19 -16
  149. package/src/component/geo.ts +781 -0
  150. package/src/component/goals.ts +45 -16
  151. package/src/component/guards.ts +3 -1
  152. package/src/component/http.ts +403 -60
  153. package/src/component/identity.ts +74 -0
  154. package/src/component/ingest.ts +1652 -291
  155. package/src/component/live.ts +90 -9
  156. package/src/component/reports.ts +1929 -199
  157. package/src/component/retention.ts +774 -97
  158. package/src/component/sanitize.ts +154 -32
  159. package/src/component/schema.ts +219 -15
  160. package/src/component/sites.ts +56 -28
  161. package/src/component/useragent.ts +171 -0
  162. package/src/component/validators.ts +29 -1
  163. package/src/component/visitors.ts +116 -0
  164. package/src/component/vitals.ts +146 -0
@@ -1,9 +1,13 @@
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, DAY_MS, DIMENSION_SLOTS, HOUR_MS, LIVE_SESSION_TTL_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, SITE_INGEST_SHARDS, } from "./constants.js";
4
+ import { AGGREGATE_SHARDS, MAX_BATCH_EVENT_GROUPS, DAY_MS, HOURLY_AGGREGATE_SHARDS, DIMENSION_SLOTS, HOUR_MS, LIVE_LEAVE_GRACE_MS, LIVE_SESSION_TTL_MS, LIVE_SWEEP_INTERVAL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_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";
5
+ import { addVisitor, createVisitorSketch, unionVisitorSketches, } from "./cardinality.js";
6
+ import { addToVitalHistogram, emptyVitalHistogram, mergeVitalHistograms, vitalDevice, VITAL_ALL, VITAL_OTHER_PAGES, } from "./vitals.js";
5
7
  import { fail } from "./errors.js";
6
- import { cleanString, ensureBatchBytes, isValidMoney, originAllowed, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
8
+ import { ensureAnalyticsControl } from "./coverage.js";
9
+ import { bufferEventBatch, eventBatchKey, eventBucketStart, findTelemetry, flushEventBatches, insertEventBatch, inspectTelemetryMinute, } from "./eventStore.js";
10
+ import { cleanString, isValidMoney, measureBatchBytes, normalizeRoute, originAllowed, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
7
11
  import { ingestContextValidator, ingestResultValidator, trackerEventValidator, trustedConversionValidator, } from "./validators.js";
8
12
  export const ingestBatch = mutation({
9
13
  args: {
@@ -27,35 +31,98 @@ export const ingestBatch = mutation({
27
31
  limit: MAX_BATCH_EVENTS,
28
32
  });
29
33
  }
34
+ let eventByteCounts;
30
35
  try {
31
- ensureBatchBytes({ siteId: String(args.siteId), events: args.events });
36
+ ({ eventByteCounts } = measureBatchBytes(String(args.siteId), args.events));
32
37
  }
33
38
  catch {
34
39
  fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
35
40
  }
36
41
  const now = Date.now();
37
- const eventByteCounts = await consumeSiteRateLimit(ctx, args.siteId, args.events, now);
42
+ await ensureAnalyticsControl(ctx, args.siteId, now);
43
+ await consumeSiteRateLimit(ctx, args.siteId, args.events, eventByteCounts, now);
38
44
  const context = sanitizeContext(args.context);
39
45
  const aggregateDeltas = new Map();
46
+ const reportRollupDeltas = createReportRollupDeltas();
47
+ const sessionRateWindows = new Map();
48
+ const batchState = {
49
+ sessions: new Map(),
50
+ liveSessions: new Map(),
51
+ visitorAttributions: new Map(),
52
+ funnelProgress: new Map(),
53
+ eventBatches: new Map(),
54
+ liveSweepSites: new Set(),
55
+ };
40
56
  let goals = null;
41
57
  let funnels = null;
42
58
  let accepted = 0;
43
59
  let duplicates = 0;
44
60
  let rejected = 0;
45
- for (let eventIndex = 0; eventIndex < args.events.length; eventIndex += 1) {
46
- const input = args.events[eventIndex];
47
- let event;
61
+ const events = args.events.map((input) => {
48
62
  try {
49
- event = sanitizeEvent(input, now);
63
+ return sanitizeEvent(input, now);
50
64
  }
51
65
  catch {
66
+ return null;
67
+ }
68
+ });
69
+ const duplicateInBatch = new Set();
70
+ const eventIdsByGroup = new Map();
71
+ const requestGroups = new Set();
72
+ const dedupeGroups = new Map();
73
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
74
+ const event = events[eventIndex];
75
+ if (!event)
76
+ continue;
77
+ const bucketStart = eventBucketStart(event.timestamp);
78
+ const groupKey = eventBatchKey(event.sessionId, bucketStart);
79
+ requestGroups.add(groupKey);
80
+ if (event.type === "heartbeat" ||
81
+ event.type === "vital" ||
82
+ event.type === "leave")
83
+ continue;
84
+ const eventIds = eventIdsByGroup.get(groupKey) ?? new Set();
85
+ if (eventIds.has(event.eventId)) {
86
+ duplicateInBatch.add(eventIndex);
87
+ continue;
88
+ }
89
+ eventIds.add(event.eventId);
90
+ eventIdsByGroup.set(groupKey, eventIds);
91
+ const group = dedupeGroups.get(groupKey) ?? {
92
+ sessionId: event.sessionId,
93
+ bucketStart,
94
+ eventIds: new Set(),
95
+ };
96
+ group.eventIds.add(event.eventId);
97
+ dedupeGroups.set(groupKey, group);
98
+ }
99
+ if (requestGroups.size > MAX_BATCH_EVENT_GROUPS) {
100
+ fail("BATCH_TOO_LARGE", `events may span at most ${MAX_BATCH_EVENT_GROUPS} session-minute groups`, { limit: MAX_BATCH_EVENT_GROUPS });
101
+ }
102
+ const persistedEventIds = new Map();
103
+ await Promise.all([...dedupeGroups].map(async ([key, group]) => {
104
+ const { eventIds, persistedCount } = await inspectTelemetryMinute(ctx, {
105
+ siteId: args.siteId,
106
+ sessionId: group.sessionId,
107
+ bucketStart: group.bucketStart,
108
+ });
109
+ const newCount = [...group.eventIds].filter((eventId) => !eventIds.has(eventId)).length;
110
+ if (newCount > 0 &&
111
+ persistedCount + newCount > MAX_EVENTS_PER_SESSION_WINDOW) {
112
+ fail("RATE_LIMITED", "session event minute limit exceeded", {
113
+ limit: MAX_EVENTS_PER_SESSION_WINDOW,
114
+ });
115
+ }
116
+ persistedEventIds.set(key, eventIds);
117
+ }));
118
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
119
+ const event = events[eventIndex];
120
+ if (!event) {
52
121
  rejected += 1;
53
122
  continue;
54
123
  }
55
- const existingSession = await ctx.db
56
- .query("sessions")
57
- .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", args.siteId).eq("sessionId", event.sessionId))
58
- .unique();
124
+ const sessionRow = await loadSession(ctx, args.siteId, event.sessionId, batchState);
125
+ const existingSession = sessionRow.current;
59
126
  if (existingSession && existingSession.visitorId !== event.visitorId) {
60
127
  rejected += 1;
61
128
  continue;
@@ -65,31 +132,70 @@ export const ingestBatch = mutation({
65
132
  rejected += 1;
66
133
  continue;
67
134
  }
68
- const liveSession = await ctx.db
69
- .query("liveSessions")
70
- .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", args.siteId).eq("sessionId", event.sessionId))
71
- .unique();
72
- if (liveSession?.lastHeartbeatEventId === event.eventId) {
135
+ const liveSession = (await loadLiveSession(ctx, args.siteId, event.sessionId, batchState)).current;
136
+ if (liveSession &&
137
+ (liveSession.lastHeartbeatEventId === event.eventId ||
138
+ liveSession.recentHeartbeatEventIds?.includes(event.eventId))) {
73
139
  duplicates += 1;
74
140
  continue;
75
141
  }
76
- if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now))) {
142
+ if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
77
143
  rejected += 1;
78
144
  continue;
79
145
  }
80
- await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now);
146
+ await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now, batchState);
81
147
  accepted += 1;
82
148
  continue;
83
149
  }
84
- const duplicate = await ctx.db
85
- .query("events")
86
- .withIndex("by_siteId_and_eventId", (range) => range.eq("siteId", args.siteId).eq("eventId", event.eventId))
87
- .unique();
88
- if (duplicate) {
150
+ if (event.type === "leave") {
151
+ // A departure only shortens live presence: it stores no telemetry,
152
+ // touches no aggregates, and never extends anything, so a replayed
153
+ // or late leave is inert. The read side filters by expiresAt and the
154
+ // sweep deletes the row, so the dashboard sees the exit in seconds.
155
+ if (!existingSession) {
156
+ rejected += 1;
157
+ continue;
158
+ }
159
+ if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
160
+ rejected += 1;
161
+ continue;
162
+ }
163
+ const liveState = await loadLiveSession(ctx, args.siteId, event.sessionId, batchState);
164
+ const liveSession = liveState.current;
165
+ if (liveSession) {
166
+ const expiresAt = Math.min(liveSession.expiresAt, now + LIVE_LEAVE_GRACE_MS);
167
+ if (expiresAt < liveSession.expiresAt) {
168
+ await ctx.db.patch("liveSessions", liveSession._id, { expiresAt });
169
+ liveState.current = { ...liveSession, expiresAt };
170
+ }
171
+ await ensureLiveSweep(ctx, args.siteId, now, batchState);
172
+ }
173
+ accepted += 1;
174
+ continue;
175
+ }
176
+ if (event.type === "vital") {
177
+ // A vital annotates telemetry its session already produced; it never
178
+ // creates sessions, live presence, event batches, or aggregate counts.
179
+ if (!existingSession) {
180
+ rejected += 1;
181
+ continue;
182
+ }
183
+ if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
184
+ rejected += 1;
185
+ continue;
186
+ }
187
+ foldVitalRollups(reportRollupDeltas, args.siteId, event, vitalDevice(context.device));
188
+ accepted += 1;
189
+ continue;
190
+ }
191
+ if (duplicateInBatch.has(eventIndex) ||
192
+ persistedEventIds
193
+ .get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
194
+ ?.has(event.eventId)) {
89
195
  duplicates += 1;
90
196
  continue;
91
197
  }
92
- if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now))) {
198
+ if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
93
199
  rejected += 1;
94
200
  continue;
95
201
  }
@@ -106,22 +212,28 @@ export const ingestBatch = mutation({
106
212
  .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
107
213
  .take(MAX_FUNNELS_PER_SITE);
108
214
  }
109
- await accountEvent(ctx, {
215
+ const session = await accountEvent(ctx, {
110
216
  siteId: args.siteId,
111
217
  event,
112
218
  context,
113
219
  existingSession,
114
- goals: canMatchDefinition ? goals ?? [] : [],
115
- funnels: canMatchDefinition ? funnels ?? [] : [],
220
+ goals: canMatchDefinition ? (goals ?? []) : [],
221
+ funnels: canMatchDefinition ? (funnels ?? []) : [],
116
222
  aggregateDeltas,
223
+ reportRollupDeltas,
117
224
  recordTelemetry: true,
118
225
  financialConversion: false,
119
226
  trustedGoalKey: undefined,
120
227
  now,
228
+ batchState,
121
229
  });
230
+ bufferEventBatch(args.siteId, event, batchState.eventBatches, session.country ?? "unknown");
122
231
  accepted += 1;
123
232
  }
233
+ await flushBatchState(ctx, batchState);
234
+ await flushSessionRateLimits(ctx, sessionRateWindows, now);
124
235
  await flushAggregates(ctx, aggregateDeltas);
236
+ await flushReportRollups(ctx, reportRollupDeltas);
125
237
  return { accepted, duplicates, rejected };
126
238
  },
127
239
  });
@@ -136,13 +248,17 @@ export const trackConversion = mutation({
136
248
  fail("INVALID_ARGUMENT", "revenueCents must be an integer between 0 and 1000000000");
137
249
  }
138
250
  let event;
251
+ let trustedGoalKey;
139
252
  try {
253
+ trustedGoalKey = args.goalKey
254
+ ? sanitizeSlug(args.goalKey, "goalKey")
255
+ : undefined;
140
256
  event = sanitizeEvent({
141
257
  eventId: sanitizeOpaqueId(args.eventId, "eventId"),
142
258
  sessionId: sanitizeOpaqueId(args.sessionId, "sessionId"),
143
259
  visitorId: sanitizeOpaqueId(args.visitorId, "visitorId"),
144
260
  type: "conversion",
145
- name: args.goalKey ? sanitizeSlug(args.goalKey, "goalKey") : "conversion",
261
+ name: trustedGoalKey ?? "conversion",
146
262
  path: sanitizePath(args.path ?? "/conversion"),
147
263
  timestamp: args.timestamp,
148
264
  sequence: 0,
@@ -155,23 +271,24 @@ export const trackConversion = mutation({
155
271
  fail("INVALID_ARGUMENT", error instanceof Error ? error.message : "invalid conversion");
156
272
  }
157
273
  const siteCurrency = site.currency ?? "USD";
158
- if (event.currency !== siteCurrency) {
159
- fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
160
- siteCurrency,
161
- conversionCurrency: event.currency ?? "USD",
162
- });
163
- }
164
274
  const priorConversion = await ctx.db
165
275
  .query("conversions")
166
276
  .withIndex("by_siteId_and_eventId", (range) => range.eq("siteId", args.siteId).eq("eventId", event.eventId))
167
277
  .unique();
168
278
  if (priorConversion) {
279
+ if (!sameTrustedConversion(priorConversion, event, trustedGoalKey)) {
280
+ fail("CONFLICT", "conversion eventId is already associated with a different payload");
281
+ }
169
282
  return { accepted: 0, duplicates: 1, rejected: 0 };
170
283
  }
171
- const existingTelemetry = await ctx.db
172
- .query("events")
173
- .withIndex("by_siteId_and_eventId", (range) => range.eq("siteId", args.siteId).eq("eventId", event.eventId))
174
- .unique();
284
+ if (event.currency !== siteCurrency) {
285
+ fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
286
+ siteCurrency,
287
+ conversionCurrency: event.currency ?? "USD",
288
+ });
289
+ }
290
+ const telemetry = await findTelemetry(ctx, args.siteId, event);
291
+ const existingTelemetry = telemetry.match;
175
292
  if (existingTelemetry &&
176
293
  (existingTelemetry.sessionId !== event.sessionId ||
177
294
  existingTelemetry.visitorId !== event.visitorId)) {
@@ -184,19 +301,18 @@ export const trackConversion = mutation({
184
301
  if (existingSession && existingSession.visitorId !== event.visitorId) {
185
302
  fail("CONFLICT", "session belongs to a different visitor");
186
303
  }
187
- const conversionContext = existingTelemetry
188
- ? {
189
- country: existingTelemetry.country,
190
- city: existingTelemetry.city,
191
- latitude: existingTelemetry.latitude,
192
- longitude: existingTelemetry.longitude,
193
- browser: existingTelemetry.browser,
194
- os: existingTelemetry.os,
195
- device: existingTelemetry.device,
196
- }
197
- : sanitizeContext(undefined);
304
+ const conversionContext = {
305
+ country: existingTelemetry?.country ?? existingSession?.country,
306
+ city: existingTelemetry?.city ?? existingSession?.city,
307
+ latitude: existingTelemetry?.latitude ?? existingSession?.latitude,
308
+ longitude: existingTelemetry?.longitude ?? existingSession?.longitude,
309
+ browser: existingTelemetry?.browser ?? existingSession?.browser,
310
+ os: existingTelemetry?.os ?? existingSession?.os,
311
+ device: existingTelemetry?.device ?? existingSession?.device,
312
+ };
198
313
  const aggregateDeltas = new Map();
199
- await accountEvent(ctx, {
314
+ const reportRollupDeltas = createReportRollupDeltas();
315
+ const session = await accountEvent(ctx, {
200
316
  siteId: args.siteId,
201
317
  event,
202
318
  context: conversionContext,
@@ -204,105 +320,170 @@ export const trackConversion = mutation({
204
320
  goals: [],
205
321
  funnels: [],
206
322
  aggregateDeltas,
207
- recordTelemetry: existingTelemetry === null,
323
+ reportRollupDeltas,
324
+ recordTelemetry: existingTelemetry === null && !telemetry.saturated,
208
325
  financialConversion: true,
209
- trustedGoalKey: args.goalKey
210
- ? sanitizeSlug(args.goalKey, "goalKey")
211
- : undefined,
326
+ trustedGoalKey,
212
327
  now: Date.now(),
213
328
  });
329
+ if (!existingTelemetry && !telemetry.saturated) {
330
+ await insertEventBatch(ctx, args.siteId, event, session.country ?? "unknown");
331
+ }
214
332
  await flushAggregates(ctx, aggregateDeltas);
333
+ await flushReportRollups(ctx, reportRollupDeltas);
334
+ await ensureAnalyticsControl(ctx, args.siteId, Date.now());
215
335
  return { accepted: 1, duplicates: 0, rejected: 0 };
216
336
  },
217
337
  });
338
+ function sameTrustedConversion(existing, event, goalKey) {
339
+ return (existing.source === "trusted" &&
340
+ existing.sessionId === event.sessionId &&
341
+ existing.visitorId === event.visitorId &&
342
+ existing.timestamp === event.timestamp &&
343
+ existing.path === event.path &&
344
+ existing.revenueCents === (event.revenueCents ?? 0) &&
345
+ existing.currency === (event.currency ?? "USD") &&
346
+ existing.goalKey === goalKey);
347
+ }
348
+ async function loadSession(ctx, siteId, sessionId, batchState) {
349
+ const cached = batchState.sessions.get(sessionId);
350
+ if (cached)
351
+ return cached;
352
+ const current = await ctx.db
353
+ .query("sessions")
354
+ .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
355
+ .unique();
356
+ const loaded = { current, dirty: false };
357
+ batchState.sessions.set(sessionId, loaded);
358
+ return loaded;
359
+ }
360
+ async function loadLiveSession(ctx, siteId, sessionId, batchState) {
361
+ const cached = batchState.liveSessions.get(sessionId);
362
+ if (cached)
363
+ return cached;
364
+ const current = await ctx.db
365
+ .query("liveSessions")
366
+ .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
367
+ .unique();
368
+ const loaded = { current, dirty: false };
369
+ batchState.liveSessions.set(sessionId, loaded);
370
+ return loaded;
371
+ }
372
+ async function flushBatchState(ctx, batchState) {
373
+ for (const state of batchState.sessions.values()) {
374
+ if (state.dirty && state.current) {
375
+ await ctx.db.replace("sessions", state.current._id, withoutSystemFields(state.current));
376
+ }
377
+ }
378
+ for (const state of batchState.liveSessions.values()) {
379
+ if (state.dirty && state.current) {
380
+ await ctx.db.replace("liveSessions", state.current._id, withoutSystemFields(state.current));
381
+ }
382
+ }
383
+ for (const state of batchState.visitorAttributions.values()) {
384
+ if (state.dirty && state.current) {
385
+ await ctx.db.replace("visitorAttributions", state.current._id, withoutSystemFields(state.current));
386
+ }
387
+ }
388
+ for (const state of batchState.funnelProgress.values()) {
389
+ if (state.dirty && state.current) {
390
+ await ctx.db.replace("funnelProgress", state.current._id, withoutSystemFields(state.current));
391
+ }
392
+ }
393
+ await flushEventBatches(ctx, batchState.eventBatches);
394
+ }
395
+ function withoutSystemFields(document) {
396
+ const { _id, _creationTime, ...fields } = document;
397
+ void _id;
398
+ void _creationTime;
399
+ return fields;
400
+ }
218
401
  async function accountEvent(ctx, input) {
219
402
  const { event } = input;
220
403
  const session = input.recordTelemetry
221
- ? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion)
404
+ ? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion, input.reportRollupDeltas, input.batchState)
222
405
  : await accountTrustedSession(ctx, input.existingSession, event);
223
- if (input.recordTelemetry) {
224
- await ctx.db.insert("events", {
225
- siteId: input.siteId,
226
- eventId: event.eventId,
227
- sessionId: event.sessionId,
228
- visitorId: event.visitorId,
229
- type: event.type,
230
- ...(event.name ? { name: event.name } : {}),
231
- path: event.path,
232
- ...(event.referrer ? { referrer: event.referrer } : {}),
233
- timestamp: event.timestamp,
234
- sequence: event.sequence,
235
- ...(event.target ? { target: event.target } : {}),
236
- ...(event.href ? { href: event.href } : {}),
237
- ...(event.properties ? { properties: event.properties } : {}),
238
- ...(event.revenueCents !== undefined
239
- ? { revenueCents: event.revenueCents }
240
- : {}),
241
- ...(event.currency ? { currency: event.currency } : {}),
242
- ...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
243
- ...(input.context.country ? { country: input.context.country } : {}),
244
- ...(input.context.city ? { city: input.context.city } : {}),
245
- ...(input.context.latitude !== undefined
246
- ? { latitude: input.context.latitude }
247
- : {}),
248
- ...(input.context.longitude !== undefined
249
- ? { longitude: input.context.longitude }
250
- : {}),
251
- browser: input.context.browser ?? "Other",
252
- os: input.context.os ?? "Other",
253
- device: input.context.device ?? "unknown",
254
- });
406
+ if (input.recordTelemetry && event.type === "pageview") {
407
+ // `existingSession` is the snapshot from before `upsertSession`, so it
408
+ // still carries the route the visitor is leaving.
409
+ foldRouteRollups(input.reportRollupDeltas, input.siteId, event, session, input.existingSession);
255
410
  }
256
411
  const matchedGoalKeys = [];
257
- for (const goal of input.goals) {
258
- if (!goal.active || !goalMatches(goal, event))
259
- continue;
260
- const prior = await ctx.db
412
+ const matchingGoals = input.goals.filter((goal) => goal.active && goalMatches(goal, event));
413
+ if (matchingGoals.length > 1) {
414
+ fail("LIMIT_EXCEEDED", "active goals must use unique matchers");
415
+ }
416
+ for (const goal of matchingGoals) {
417
+ const completionEventId = JSON.stringify([
418
+ event.sessionId,
419
+ eventBucketStart(event.timestamp),
420
+ event.eventId,
421
+ ]);
422
+ const scopedPrior = (await ctx.db
261
423
  .query("goalCompletions")
262
424
  .withIndex("by_siteId_and_goalId_and_eventId", (range) => range
263
425
  .eq("siteId", input.siteId)
264
426
  .eq("goalId", goal._id)
265
- .eq("eventId", event.eventId))
266
- .unique();
267
- if (prior)
427
+ .eq("eventId", completionEventId))
428
+ .take(2)).find((completion) => completion.idempotencyVersion === 2);
429
+ const legacyPrior = scopedPrior
430
+ ? undefined
431
+ : (await ctx.db
432
+ .query("goalCompletions")
433
+ .withIndex("by_siteId_and_goalId_and_eventId", (range) => range
434
+ .eq("siteId", input.siteId)
435
+ .eq("goalId", goal._id)
436
+ .eq("eventId", event.eventId))
437
+ .take(2)).find((completion) => completion.idempotencyVersion === undefined &&
438
+ completion.sessionId === event.sessionId &&
439
+ eventBucketStart(completion.timestamp) ===
440
+ eventBucketStart(event.timestamp));
441
+ if (scopedPrior || legacyPrior)
268
442
  continue;
269
443
  await ctx.db.insert("goalCompletions", {
270
444
  siteId: input.siteId,
271
445
  goalId: goal._id,
272
446
  goalKey: goal.key,
273
- eventId: event.eventId,
447
+ eventId: completionEventId,
448
+ idempotencyVersion: 2,
274
449
  sessionId: event.sessionId,
275
450
  visitorId: event.visitorId,
276
451
  timestamp: event.timestamp,
277
452
  valueCents: goal.valueCents ?? 0,
278
453
  });
454
+ foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
279
455
  matchedGoalKeys.push(goal.key);
280
456
  }
281
457
  for (const funnel of input.funnels) {
282
458
  if (funnel.active) {
283
- await advanceFunnel(ctx, input.siteId, funnel, event);
459
+ await advanceFunnel(ctx, input.siteId, funnel, event, input.reportRollupDeltas, input.batchState);
284
460
  }
285
461
  }
286
462
  if (input.financialConversion) {
287
- await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey);
463
+ await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey, input.reportRollupDeltas);
288
464
  }
465
+ const campaignDimensions = session.utmCampaign
466
+ ? [{ type: "campaign", value: session.utmCampaign }]
467
+ : [];
289
468
  const dimensions = [
290
469
  { type: "source", value: session.source },
470
+ ...campaignDimensions,
291
471
  { type: "page", value: event.path },
292
- { type: "country", value: input.context.country ?? "unknown" },
472
+ { type: "country", value: session.country ?? "unknown" },
293
473
  { type: "device", value: input.context.device ?? "unknown" },
294
474
  { type: "browser", value: input.context.browser ?? "Other" },
295
475
  {
296
476
  type: "event",
297
- value: event.type === "custom" ? event.name ?? "custom" : event.type,
477
+ value: event.type === "custom" ? (event.name ?? "custom") : event.type,
298
478
  },
299
479
  ...matchedGoalKeys.map((value) => ({ type: "goal", value })),
300
480
  ];
301
481
  foldAggregates(input.aggregateDeltas, input.siteId, event, session, dimensions, input.recordTelemetry, input.financialConversion);
302
482
  if (input.recordTelemetry &&
303
483
  event.timestamp >= input.now - LIVE_SESSION_TTL_MS) {
304
- await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now);
484
+ await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now, input.batchState);
305
485
  }
486
+ return session;
306
487
  }
307
488
  async function accountTrustedSession(ctx, existing, event) {
308
489
  if (!existing) {
@@ -320,6 +501,12 @@ async function accountTrustedSession(ctx, existing, event) {
320
501
  isNewVisitorDay: false,
321
502
  startedAt: existing.startedAt,
322
503
  source: existing.source,
504
+ utmCampaign: existing.utmCampaign,
505
+ visitorKey: existing.visitorKey ?? existing.visitorId,
506
+ country: existing.country,
507
+ city: existing.city,
508
+ latitude: existing.latitude,
509
+ longitude: existing.longitude,
323
510
  pageviewCount: existing.pageviewCount,
324
511
  eventCount: existing.eventCount,
325
512
  affiliateId: existing.affiliateId,
@@ -328,26 +515,41 @@ async function accountTrustedSession(ctx, existing, event) {
328
515
  affiliateExpiresAt: existing.affiliateExpiresAt,
329
516
  };
330
517
  }
331
- async function upsertSession(ctx, siteId, event, context, existing, financialConversion) {
332
- const source = sourceFromReferrer(event.referrer);
518
+ async function upsertSession(ctx, siteId, event, context, existing, financialConversion, reportRollupDeltas, batchState) {
519
+ // A host-supplied id is the identity; anonymous sessions count through the
520
+ // daily key derived by the HTTP action, or once per session without one.
521
+ const visitorKey = existing?.visitorKey ??
522
+ (event.visitorId !== event.sessionId
523
+ ? event.visitorId
524
+ : (context.visitorKey ?? event.sessionId));
333
525
  const utmSource = stringProperty(event.properties, "utm_source");
526
+ // Campaign traffic names its own source; the referrer host is the fallback.
527
+ const source = utmSource?.toLowerCase() ?? sourceFromReferrer(event.referrer);
334
528
  const utmMedium = stringProperty(event.properties, "utm_medium");
335
529
  const utmCampaign = stringProperty(event.properties, "utm_campaign");
336
- const attribution = await attributionForEvent(ctx, siteId, event, existing);
530
+ const attribution = await attributionForEvent(ctx, siteId, event, existing, batchState);
337
531
  if (existing) {
532
+ if (attribution && existing.affiliateId !== attribution.affiliateId) {
533
+ foldAffiliateVisitRollup(reportRollupDeltas, siteId, event, attribution, visitorKey);
534
+ }
338
535
  const startedAt = Math.min(existing.startedAt, event.timestamp);
339
536
  const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
340
- await ctx.db.patch("sessions", existing._id, {
537
+ const updates = {
341
538
  startedAt,
342
539
  lastSeenAt,
343
540
  entryPath: event.timestamp < existing.startedAt ? event.path : existing.entryPath,
344
541
  exitPath: event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
345
542
  eventCount: existing.eventCount + 1,
346
543
  pageviewCount: existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
544
+ ...(event.type === "pageview"
545
+ ? {
546
+ lastPageviewAt: Math.max(existing.lastPageviewAt ?? 0, event.timestamp),
547
+ }
548
+ : {}),
347
549
  durationMs: Math.max(0, lastSeenAt - startedAt),
348
550
  conversionCount: existing.conversionCount + Number(financialConversion),
349
551
  revenueCents: existing.revenueCents +
350
- (financialConversion ? event.revenueCents ?? 0 : 0),
552
+ (financialConversion ? (event.revenueCents ?? 0) : 0),
351
553
  ...(financialConversion && event.currency
352
554
  ? { currency: event.currency }
353
555
  : {}),
@@ -358,11 +560,23 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
358
560
  ...(context.country ? { country: context.country } : {}),
359
561
  ...(context.city ? { city: context.city } : {}),
360
562
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
361
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
563
+ ...(context.longitude !== undefined
564
+ ? { longitude: context.longitude }
565
+ : {}),
362
566
  browser: context.browser ?? existing.browser,
363
567
  os: context.os ?? existing.os,
364
568
  device: context.device ?? existing.device,
365
- });
569
+ };
570
+ if (batchState) {
571
+ const state = batchState.sessions.get(event.sessionId);
572
+ if (!state)
573
+ throw new Error("session cache was not initialized");
574
+ state.current = { ...existing, ...updates };
575
+ state.dirty = true;
576
+ }
577
+ else {
578
+ await ctx.db.patch("sessions", existing._id, updates);
579
+ }
366
580
  return {
367
581
  isNewSession: false,
368
582
  isNewVisitorHour: Math.floor(existing.lastSeenAt / HOUR_MS) <
@@ -371,20 +585,38 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
371
585
  Math.floor(event.timestamp / DAY_MS),
372
586
  startedAt,
373
587
  source: existing.source,
588
+ utmCampaign: existing.utmCampaign,
589
+ visitorKey,
590
+ country: context.country ?? existing.country,
591
+ city: context.city ?? existing.city,
592
+ latitude: context.latitude ?? existing.latitude,
593
+ longitude: context.longitude ?? existing.longitude,
374
594
  pageviewCount: existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
375
595
  eventCount: existing.eventCount + 1,
376
596
  ...(attribution ?? {}),
377
597
  };
378
598
  }
379
- const priorVisit = await ctx.db
599
+ const priorVisits = await ctx.db
380
600
  .query("sessions")
381
601
  .withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
382
602
  .order("desc")
383
603
  .take(1);
384
- await ctx.db.insert("sessions", {
604
+ let priorVisit = priorVisits[0] ?? null;
605
+ if (batchState) {
606
+ for (const state of batchState.sessions.values()) {
607
+ const candidate = state.current;
608
+ if (candidate?.visitorId === event.visitorId &&
609
+ candidate.sessionId !== event.sessionId &&
610
+ (!priorVisit || candidate.lastSeenAt > priorVisit.lastSeenAt)) {
611
+ priorVisit = candidate;
612
+ }
613
+ }
614
+ }
615
+ const fields = {
385
616
  siteId,
386
617
  sessionId: event.sessionId,
387
618
  visitorId: event.visitorId,
619
+ visitorKey,
388
620
  startedAt: event.timestamp,
389
621
  lastSeenAt: event.timestamp,
390
622
  entryPath: event.path,
@@ -397,30 +629,52 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
397
629
  ...(context.country ? { country: context.country } : {}),
398
630
  ...(context.city ? { city: context.city } : {}),
399
631
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
400
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
632
+ ...(context.longitude !== undefined
633
+ ? { longitude: context.longitude }
634
+ : {}),
401
635
  browser: context.browser ?? "Other",
402
636
  os: context.os ?? "Other",
403
637
  device: context.device ?? "unknown",
404
638
  eventCount: 1,
405
639
  pageviewCount: event.type === "pageview" ? 1 : 0,
640
+ ...(event.type === "pageview" ? { lastPageviewAt: event.timestamp } : {}),
406
641
  durationMs: 0,
407
642
  conversionCount: Number(financialConversion),
408
- revenueCents: financialConversion ? event.revenueCents ?? 0 : 0,
643
+ revenueCents: financialConversion ? (event.revenueCents ?? 0) : 0,
409
644
  ...(financialConversion && event.currency
410
645
  ? { currency: event.currency }
411
646
  : {}),
412
647
  ...(attribution ?? {}),
413
- });
648
+ };
649
+ const sessionId = await ctx.db.insert("sessions", fields);
650
+ if (attribution) {
651
+ foldAffiliateVisitRollup(reportRollupDeltas, siteId, event, attribution, visitorKey);
652
+ }
653
+ if (batchState) {
654
+ const state = batchState.sessions.get(event.sessionId);
655
+ if (!state)
656
+ throw new Error("session cache was not initialized");
657
+ state.current = {
658
+ ...fields,
659
+ _id: sessionId,
660
+ };
661
+ }
414
662
  return {
415
663
  isNewSession: true,
416
- isNewVisitorHour: priorVisit.length === 0 ||
417
- Math.floor(priorVisit[0].lastSeenAt / HOUR_MS) <
664
+ isNewVisitorHour: priorVisit === null ||
665
+ Math.floor(priorVisit.lastSeenAt / HOUR_MS) <
418
666
  Math.floor(event.timestamp / HOUR_MS),
419
- isNewVisitorDay: priorVisit.length === 0 ||
420
- Math.floor(priorVisit[0].lastSeenAt / DAY_MS) <
667
+ isNewVisitorDay: priorVisit === null ||
668
+ Math.floor(priorVisit.lastSeenAt / DAY_MS) <
421
669
  Math.floor(event.timestamp / DAY_MS),
422
670
  startedAt: event.timestamp,
423
671
  source,
672
+ utmCampaign,
673
+ visitorKey,
674
+ country: context.country,
675
+ city: context.city,
676
+ latitude: context.latitude,
677
+ longitude: context.longitude,
424
678
  pageviewCount: event.type === "pageview" ? 1 : 0,
425
679
  eventCount: 1,
426
680
  ...(attribution ?? {}),
@@ -446,7 +700,7 @@ function attributionApplies(attribution, timestamp) {
446
700
  }
447
701
  // The first valid touch wins through its inclusive expiry. Only a later,
448
702
  // in-order valid touch may replace the visitor's single bounded record.
449
- async function attributionForEvent(ctx, siteId, event, existingSession) {
703
+ async function attributionForEvent(ctx, siteId, event, existingSession, batchState) {
450
704
  const sessionAttribution = existingSession
451
705
  ? attributionSnapshot(existingSession)
452
706
  : undefined;
@@ -458,10 +712,21 @@ async function attributionForEvent(ctx, siteId, event, existingSession) {
458
712
  attributionApplies(sessionAttribution, event.timestamp)) {
459
713
  return sessionAttribution;
460
714
  }
461
- const current = await ctx.db
462
- .query("visitorAttributions")
463
- .withIndex("by_siteId_and_visitorId", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
464
- .unique();
715
+ let attributionState = batchState?.visitorAttributions.get(event.visitorId);
716
+ if (batchState && !attributionState) {
717
+ const current = await ctx.db
718
+ .query("visitorAttributions")
719
+ .withIndex("by_siteId_and_visitorId", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
720
+ .unique();
721
+ attributionState = { current, dirty: false };
722
+ batchState.visitorAttributions.set(event.visitorId, attributionState);
723
+ }
724
+ const current = attributionState
725
+ ? attributionState.current
726
+ : await ctx.db
727
+ .query("visitorAttributions")
728
+ .withIndex("by_siteId_and_visitorId", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
729
+ .unique();
465
730
  if (current) {
466
731
  const snapshot = {
467
732
  affiliateId: current.affiliateId,
@@ -489,23 +754,32 @@ async function attributionForEvent(ctx, siteId, event, existingSession) {
489
754
  }
490
755
  const attributedAt = event.timestamp;
491
756
  const expiresAt = attributedAt + affiliate.attributionWindowDays * DAY_MS;
757
+ const fields = {
758
+ siteId,
759
+ visitorId: event.visitorId,
760
+ affiliateId: affiliate._id,
761
+ affiliateSlug: affiliate.slug,
762
+ attributedAt,
763
+ expiresAt,
764
+ };
492
765
  if (current) {
493
- await ctx.db.patch("visitorAttributions", current._id, {
494
- affiliateId: affiliate._id,
495
- affiliateSlug: affiliate.slug,
496
- attributedAt,
497
- expiresAt,
498
- });
766
+ if (attributionState) {
767
+ attributionState.current = { ...current, ...fields };
768
+ attributionState.dirty = true;
769
+ }
770
+ else {
771
+ await ctx.db.patch("visitorAttributions", current._id, fields);
772
+ }
499
773
  }
500
774
  else {
501
- await ctx.db.insert("visitorAttributions", {
502
- siteId,
503
- visitorId: event.visitorId,
504
- affiliateId: affiliate._id,
505
- affiliateSlug: affiliate.slug,
506
- attributedAt,
507
- expiresAt,
508
- });
775
+ const attributionId = await ctx.db.insert("visitorAttributions", fields);
776
+ if (attributionState) {
777
+ attributionState.current = {
778
+ ...fields,
779
+ _id: attributionId,
780
+ _creationTime: event.timestamp,
781
+ };
782
+ }
509
783
  }
510
784
  return {
511
785
  affiliateId: affiliate._id,
@@ -514,44 +788,56 @@ async function attributionForEvent(ctx, siteId, event, existingSession) {
514
788
  affiliateExpiresAt: expiresAt,
515
789
  };
516
790
  }
517
- async function accountHeartbeat(ctx, siteId, session, event, context, now) {
791
+ async function accountHeartbeat(ctx, siteId, session, event, context, now, batchState) {
518
792
  const startedAt = Math.min(session.startedAt, event.timestamp);
519
793
  const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
520
- await ctx.db.patch("sessions", session._id, {
794
+ const updates = {
521
795
  startedAt,
522
796
  lastSeenAt,
523
- exitPath: event.timestamp >= session.lastSeenAt
524
- ? event.path
525
- : session.exitPath,
797
+ exitPath: event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
526
798
  durationMs: Math.max(0, lastSeenAt - startedAt),
527
799
  ...(context.country ? { country: context.country } : {}),
528
800
  ...(context.city ? { city: context.city } : {}),
529
801
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
530
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
802
+ ...(context.longitude !== undefined
803
+ ? { longitude: context.longitude }
804
+ : {}),
531
805
  browser: context.browser ?? session.browser,
532
806
  os: context.os ?? session.os,
533
807
  device: context.device ?? session.device,
534
- });
808
+ };
809
+ if (batchState) {
810
+ const state = batchState.sessions.get(event.sessionId);
811
+ if (!state)
812
+ throw new Error("session cache was not initialized");
813
+ state.current = { ...session, ...updates };
814
+ state.dirty = true;
815
+ }
816
+ else {
817
+ await ctx.db.patch("sessions", session._id, updates);
818
+ }
535
819
  await updateLiveSession(ctx, siteId, event, context, {
536
820
  isNewSession: false,
537
821
  isNewVisitorHour: false,
538
822
  isNewVisitorDay: false,
539
823
  startedAt,
540
824
  source: session.source,
825
+ visitorKey: session.visitorKey ?? session.visitorId,
826
+ country: context.country ?? session.country,
827
+ city: context.city ?? session.city,
828
+ latitude: context.latitude ?? session.latitude,
829
+ longitude: context.longitude ?? session.longitude,
541
830
  pageviewCount: session.pageviewCount,
542
831
  eventCount: session.eventCount,
543
832
  affiliateSlug: session.affiliateSlug,
544
- }, now);
833
+ }, now, batchState);
545
834
  }
546
- async function consumeSiteRateLimit(ctx, siteId, events, now) {
835
+ async function consumeSiteRateLimit(ctx, siteId, events, eventByteCounts, now) {
547
836
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
548
837
  const increments = new Map();
549
- const eventByteCounts = [];
550
- const encoder = new TextEncoder();
551
838
  for (let index = 0; index < events.length; index += 1) {
552
839
  const event = events[index];
553
- const byteCount = encoder.encode(JSON.stringify(event)).byteLength;
554
- eventByteCounts.push(byteCount);
840
+ const byteCount = eventByteCounts[index];
555
841
  const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
556
842
  const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
557
843
  current.eventCount += 1;
@@ -560,13 +846,13 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
560
846
  }
561
847
  const windows = [];
562
848
  for (const [shard, increment] of increments) {
563
- const window = await ctx.db
849
+ const rows = await ctx.db
564
850
  .query("siteIngestWindows")
565
- .withIndex("by_siteId_and_shard_and_windowStart", (range) => range
566
- .eq("siteId", siteId)
567
- .eq("shard", shard)
568
- .eq("windowStart", windowStart))
569
- .unique();
851
+ .withIndex("by_siteId_and_shard_and_windowStart", (range) => range.eq("siteId", siteId).eq("shard", shard))
852
+ .order("desc")
853
+ .take(1);
854
+ const storedWindow = rows[0] ?? null;
855
+ const window = storedWindow?.windowStart === windowStart ? storedWindow : null;
570
856
  if ((window?.eventCount ?? 0) + increment.eventCount >
571
857
  MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
572
858
  (window?.byteCount ?? 0) + increment.byteCount >
@@ -580,13 +866,18 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
580
866
  siteByteLimit: SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
581
867
  });
582
868
  }
583
- windows.push({ shard, increment, window });
869
+ windows.push({ shard, increment, window: storedWindow });
584
870
  }
585
871
  for (const { shard, increment, window } of windows) {
586
872
  if (window) {
587
873
  await ctx.db.patch("siteIngestWindows", window._id, {
588
- eventCount: window.eventCount + increment.eventCount,
589
- byteCount: window.byteCount + increment.byteCount,
874
+ windowStart,
875
+ eventCount: window.windowStart === windowStart
876
+ ? window.eventCount + increment.eventCount
877
+ : increment.eventCount,
878
+ byteCount: window.windowStart === windowStart
879
+ ? window.byteCount + increment.byteCount
880
+ : increment.byteCount,
590
881
  updatedAt: now,
591
882
  });
592
883
  }
@@ -601,76 +892,124 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
601
892
  });
602
893
  }
603
894
  }
604
- return eventByteCounts;
605
895
  }
606
- async function consumeRateLimit(ctx, siteId, sessionId, byteCount, now) {
896
+ async function consumeRateLimit(ctx, siteId, sessionId, byteCount, now, states) {
607
897
  const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
608
- const window = await ctx.db
609
- .query("ingestWindows")
610
- .withIndex("by_siteId_and_sessionId_and_windowStart", (range) => range
611
- .eq("siteId", siteId)
612
- .eq("sessionId", sessionId)
613
- .eq("windowStart", windowStart))
614
- .unique();
615
- if (window &&
616
- (window.eventCount >= MAX_EVENTS_PER_SESSION_WINDOW ||
617
- window.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW)) {
618
- return false;
619
- }
620
- if (window) {
621
- await ctx.db.patch("ingestWindows", window._id, {
622
- eventCount: window.eventCount + 1,
623
- byteCount: window.byteCount + byteCount,
624
- updatedAt: now,
625
- });
626
- }
627
- else {
628
- await ctx.db.insert("ingestWindows", {
898
+ let state = states.get(sessionId);
899
+ if (!state) {
900
+ const rows = await ctx.db
901
+ .query("ingestWindows")
902
+ .withIndex("by_siteId_and_sessionId_and_windowStart", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
903
+ .order("desc")
904
+ .take(1);
905
+ const window = rows[0] ?? null;
906
+ state = {
629
907
  siteId,
630
908
  sessionId,
631
- windowStart,
632
- eventCount: 1,
633
- byteCount,
634
- updatedAt: now,
635
- });
909
+ window,
910
+ eventCount: window?.windowStart === windowStart ? window.eventCount : 0,
911
+ byteCount: window?.windowStart === windowStart ? window.byteCount : 0,
912
+ dirty: false,
913
+ };
914
+ states.set(sessionId, state);
636
915
  }
916
+ if (state.eventCount >= MAX_EVENTS_PER_SESSION_WINDOW ||
917
+ state.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW) {
918
+ return false;
919
+ }
920
+ state.eventCount += 1;
921
+ state.byteCount += byteCount;
922
+ state.dirty = true;
637
923
  return true;
638
924
  }
639
- async function updateLiveSession(ctx, siteId, event, context, session, now) {
640
- const existing = await ctx.db
641
- .query("liveSessions")
642
- .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", event.sessionId))
643
- .unique();
644
- const renewsExpiry = !existing ||
645
- existing.expiresAt <= now ||
646
- event.type === "pageview" ||
647
- event.type === "heartbeat";
648
- const deadline = renewsExpiry
649
- ? now + LIVE_SESSION_TTL_MS
650
- : existing.expiresAt;
925
+ async function flushSessionRateLimits(ctx, states, now) {
926
+ const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
927
+ for (const state of states.values()) {
928
+ if (!state.dirty)
929
+ continue;
930
+ if (state.window) {
931
+ await ctx.db.patch("ingestWindows", state.window._id, {
932
+ windowStart,
933
+ eventCount: state.eventCount,
934
+ byteCount: state.byteCount,
935
+ updatedAt: now,
936
+ });
937
+ }
938
+ else {
939
+ await ctx.db.insert("ingestWindows", {
940
+ siteId: state.siteId,
941
+ sessionId: state.sessionId,
942
+ windowStart,
943
+ eventCount: state.eventCount,
944
+ byteCount: state.byteCount,
945
+ updatedAt: now,
946
+ });
947
+ }
948
+ }
949
+ }
950
+ async function updateLiveSession(ctx, siteId, event, context, session, now, batchState) {
951
+ await ensureLiveSweep(ctx, siteId, now, batchState);
952
+ const liveState = batchState
953
+ ? await loadLiveSession(ctx, siteId, event.sessionId, batchState)
954
+ : undefined;
955
+ const existing = liveState
956
+ ? liveState.current
957
+ : await ctx.db
958
+ .query("liveSessions")
959
+ .withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", event.sessionId))
960
+ .unique();
961
+ const deadline = now + LIVE_SESSION_TTL_MS;
962
+ const country = context.country ?? session.country;
963
+ const city = context.city ?? session.city;
964
+ const latitude = context.latitude ?? session.latitude;
965
+ const longitude = context.longitude ?? session.longitude;
651
966
  if (existing) {
652
- await ctx.db.patch("liveSessions", existing._id, {
967
+ const recentHeartbeatEventIds = event.type === "heartbeat"
968
+ ? [
969
+ ...(existing.recentHeartbeatEventIds ??
970
+ (existing.lastHeartbeatEventId
971
+ ? [existing.lastHeartbeatEventId]
972
+ : [])),
973
+ event.eventId,
974
+ ].slice(-8)
975
+ : undefined;
976
+ const updates = {
653
977
  visitorId: event.visitorId,
654
978
  path: event.path,
655
979
  lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
656
980
  expiresAt: deadline,
657
- ...(context.country ? { country: context.country } : {}),
658
- ...(context.city ? { city: context.city } : {}),
659
- ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
660
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
981
+ ...(country ? { country } : {}),
982
+ ...(city ? { city } : {}),
983
+ ...(latitude !== undefined ? { latitude } : {}),
984
+ ...(longitude !== undefined ? { longitude } : {}),
661
985
  browser: context.browser ?? existing.browser,
662
986
  os: context.os ?? existing.os,
663
987
  source: session.source,
664
988
  device: context.device ?? existing.device,
665
989
  pageviewCount: session.pageviewCount,
666
990
  eventCount: session.eventCount,
991
+ // The live map animates a visitor from the route they left to the one
992
+ // they reached; a heartbeat or click on the same page is not a move.
993
+ ...(event.type === "pageview" && event.path !== existing.path
994
+ ? { previousPath: existing.path, movedAt: event.timestamp }
995
+ : {}),
667
996
  ...(event.type === "heartbeat"
668
- ? { lastHeartbeatEventId: event.eventId }
997
+ ? {
998
+ lastHeartbeatEventId: event.eventId,
999
+ recentHeartbeatEventIds,
1000
+ }
669
1001
  : {}),
670
- });
1002
+ };
1003
+ if (liveState) {
1004
+ liveState.current = { ...existing, ...updates };
1005
+ liveState.dirty = true;
1006
+ }
1007
+ else {
1008
+ await ctx.db.patch("liveSessions", existing._id, updates);
1009
+ }
671
1010
  return;
672
1011
  }
673
- const liveSessionId = await ctx.db.insert("liveSessions", {
1012
+ const fields = {
674
1013
  siteId,
675
1014
  sessionId: event.sessionId,
676
1015
  visitorId: event.visitorId,
@@ -678,11 +1017,10 @@ async function updateLiveSession(ctx, siteId, event, context, session, now) {
678
1017
  startedAt: event.timestamp,
679
1018
  lastSeenAt: event.timestamp,
680
1019
  expiresAt: deadline,
681
- expiryChainDeadline: deadline,
682
- ...(context.country ? { country: context.country } : {}),
683
- ...(context.city ? { city: context.city } : {}),
684
- ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
685
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1020
+ ...(country ? { country } : {}),
1021
+ ...(city ? { city } : {}),
1022
+ ...(latitude !== undefined ? { latitude } : {}),
1023
+ ...(longitude !== undefined ? { longitude } : {}),
686
1024
  browser: context.browser ?? "Other",
687
1025
  os: context.os ?? "Other",
688
1026
  source: session.source,
@@ -690,13 +1028,42 @@ async function updateLiveSession(ctx, siteId, event, context, session, now) {
690
1028
  pageviewCount: session.pageviewCount,
691
1029
  eventCount: session.eventCount,
692
1030
  ...(event.type === "heartbeat"
693
- ? { lastHeartbeatEventId: event.eventId }
1031
+ ? {
1032
+ lastHeartbeatEventId: event.eventId,
1033
+ recentHeartbeatEventIds: [event.eventId],
1034
+ }
694
1035
  : {}),
695
- });
696
- await ctx.scheduler.runAt(deadline, internal.live.expire, {
697
- liveSessionId,
698
- scheduledDeadline: deadline,
699
- });
1036
+ };
1037
+ const liveSessionId = await ctx.db.insert("liveSessions", fields);
1038
+ if (liveState) {
1039
+ liveState.current = {
1040
+ ...fields,
1041
+ _id: liveSessionId,
1042
+ };
1043
+ }
1044
+ }
1045
+ async function ensureLiveSweep(ctx, siteId, now, batchState) {
1046
+ const key = String(siteId);
1047
+ if (batchState?.liveSweepSites.has(key))
1048
+ return;
1049
+ const existing = await ctx.db
1050
+ .query("liveSweepStates")
1051
+ .withIndex("by_siteId", (range) => range.eq("siteId", siteId))
1052
+ .unique();
1053
+ if (!existing || existing.scheduledAt <= now) {
1054
+ const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
1055
+ if (existing) {
1056
+ await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
1057
+ }
1058
+ else {
1059
+ await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
1060
+ }
1061
+ await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
1062
+ siteId,
1063
+ scheduledAt,
1064
+ });
1065
+ }
1066
+ batchState?.liveSweepSites.add(key);
700
1067
  }
701
1068
  function goalMatches(goal, event) {
702
1069
  if (goal.matchType === "pageview") {
@@ -704,19 +1071,34 @@ function goalMatches(goal, event) {
704
1071
  }
705
1072
  return event.type === "custom" && event.name === goal.matchValue;
706
1073
  }
707
- async function advanceFunnel(ctx, siteId, funnel, event) {
708
- const progress = await ctx.db
709
- .query("funnelProgress")
710
- .withIndex("by_siteId_and_funnelId_and_visitorId", (range) => range
711
- .eq("siteId", siteId)
712
- .eq("funnelId", funnel._id)
713
- .eq("visitorId", event.visitorId))
714
- .unique();
1074
+ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, batchState) {
1075
+ const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
1076
+ let progressState = batchState?.funnelProgress.get(cacheKey);
1077
+ if (batchState && !progressState) {
1078
+ const current = await ctx.db
1079
+ .query("funnelProgress")
1080
+ .withIndex("by_siteId_and_funnelId_and_visitorId", (range) => range
1081
+ .eq("siteId", siteId)
1082
+ .eq("funnelId", funnel._id)
1083
+ .eq("visitorId", event.visitorId))
1084
+ .unique();
1085
+ progressState = { current, dirty: false };
1086
+ batchState.funnelProgress.set(cacheKey, progressState);
1087
+ }
1088
+ const progress = progressState
1089
+ ? progressState.current
1090
+ : await ctx.db
1091
+ .query("funnelProgress")
1092
+ .withIndex("by_siteId_and_funnelId_and_visitorId", (range) => range
1093
+ .eq("siteId", siteId)
1094
+ .eq("funnelId", funnel._id)
1095
+ .eq("visitorId", event.visitorId))
1096
+ .unique();
715
1097
  const matchesFirst = stepMatches(funnel.steps[0], event);
716
1098
  if (!progress) {
717
1099
  if (!matchesFirst)
718
1100
  return;
719
- await ctx.db.insert("funnelProgress", {
1101
+ const fields = {
720
1102
  siteId,
721
1103
  funnelId: funnel._id,
722
1104
  visitorId: event.visitorId,
@@ -724,23 +1106,47 @@ async function advanceFunnel(ctx, siteId, funnel, event) {
724
1106
  currentStep: 1,
725
1107
  startedAt: event.timestamp,
726
1108
  lastStepAt: event.timestamp,
727
- });
1109
+ };
1110
+ const progressId = await ctx.db.insert("funnelProgress", fields);
1111
+ if (progressState) {
1112
+ progressState.current = {
1113
+ ...fields,
1114
+ _id: progressId,
1115
+ };
1116
+ }
728
1117
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1118
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
729
1119
  return;
730
1120
  }
731
- if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt)
1121
+ if (progress.completedAt !== undefined ||
1122
+ event.timestamp < progress.lastStepAt)
732
1123
  return;
733
1124
  if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
734
1125
  if (!matchesFirst)
735
1126
  return;
736
- await ctx.db.patch("funnelProgress", progress._id, {
1127
+ const updated = {
1128
+ ...progress,
737
1129
  sessionId: event.sessionId,
738
1130
  currentStep: 1,
739
1131
  startedAt: event.timestamp,
740
1132
  lastStepAt: event.timestamp,
741
- completedAt: undefined,
742
- });
1133
+ };
1134
+ delete updated.completedAt;
1135
+ if (progressState) {
1136
+ progressState.current = updated;
1137
+ progressState.dirty = true;
1138
+ }
1139
+ else {
1140
+ await ctx.db.patch("funnelProgress", progress._id, {
1141
+ sessionId: event.sessionId,
1142
+ currentStep: 1,
1143
+ startedAt: event.timestamp,
1144
+ lastStepAt: event.timestamp,
1145
+ completedAt: undefined,
1146
+ });
1147
+ }
743
1148
  await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1149
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
744
1150
  return;
745
1151
  }
746
1152
  const nextStep = funnel.steps[progress.currentStep];
@@ -748,13 +1154,21 @@ async function advanceFunnel(ctx, siteId, funnel, event) {
748
1154
  return;
749
1155
  const currentStep = progress.currentStep + 1;
750
1156
  const completed = currentStep === funnel.steps.length;
751
- await ctx.db.patch("funnelProgress", progress._id, {
1157
+ const updates = {
752
1158
  sessionId: event.sessionId,
753
1159
  currentStep,
754
1160
  lastStepAt: event.timestamp,
755
1161
  ...(completed ? { completedAt: event.timestamp } : {}),
756
- });
1162
+ };
1163
+ if (progressState) {
1164
+ progressState.current = { ...progress, ...updates };
1165
+ progressState.dirty = true;
1166
+ }
1167
+ else {
1168
+ await ctx.db.patch("funnelProgress", progress._id, updates);
1169
+ }
757
1170
  await incrementFunnelStep(ctx, siteId, funnel._id, event, progress.currentStep, completed);
1171
+ foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, progress.currentStep, completed);
758
1172
  }
759
1173
  function stepMatches(step, event) {
760
1174
  return step.type === "pageview"
@@ -763,7 +1177,7 @@ function stepMatches(step, event) {
763
1177
  }
764
1178
  async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed) {
765
1179
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
766
- const shard = stableHash(event.visitorId) % AGGREGATE_SHARDS;
1180
+ const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
767
1181
  const bucket = await ctx.db
768
1182
  .query("funnelStepBuckets")
769
1183
  .withIndex("by_siteId_and_funnelId_and_bucketStart_and_step_and_shard", (range) => range
@@ -791,7 +1205,7 @@ async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed
791
1205
  });
792
1206
  }
793
1207
  }
794
- async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
1208
+ async function accountConversion(ctx, siteId, session, event, trustedGoalKey, reportRollupDeltas) {
795
1209
  const revenueCents = event.revenueCents ?? 0;
796
1210
  const currency = event.currency ?? "USD";
797
1211
  const candidate = attributionSnapshot(session);
@@ -820,8 +1234,9 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
820
1234
  commissionCents,
821
1235
  });
822
1236
  if (commissionAffiliate && attribution) {
1237
+ foldAffiliateConversionRollup(reportRollupDeltas, siteId, event, commissionAffiliate, attribution.affiliateSlug, revenueCents, commissionCents);
823
1238
  const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
824
- const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
1239
+ const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
825
1240
  const stats = await ctx.db
826
1241
  .query("affiliateDailyStats")
827
1242
  .withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) => range
@@ -851,15 +1266,425 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
851
1266
  }
852
1267
  }
853
1268
  }
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
+ function foldGoalRollup(deltas, siteId, event, goal) {
1288
+ const coordinates = reportRollupCoordinates(siteId, String(goal._id), event.timestamp, event.visitorId);
1289
+ const current = deltas.goals.get(coordinates.key) ?? {
1290
+ siteId,
1291
+ goalId: goal._id,
1292
+ bucketStart: coordinates.bucketStart,
1293
+ shard: coordinates.shard,
1294
+ goalKey: goal.key,
1295
+ completions: 0,
1296
+ valueCents: 0,
1297
+ };
1298
+ current.completions += 1;
1299
+ current.valueCents += goal.valueCents ?? 0;
1300
+ deltas.goals.set(coordinates.key, current);
1301
+ }
1302
+ function foldFunnelRollup(deltas, siteId, funnelId, event, step, completed) {
1303
+ const coordinates = reportRollupCoordinates(siteId, String(funnelId), event.timestamp, event.visitorId);
1304
+ const rollup = deltas.funnels.get(coordinates.key) ?? {
1305
+ siteId,
1306
+ funnelId,
1307
+ bucketStart: coordinates.bucketStart,
1308
+ shard: coordinates.shard,
1309
+ steps: new Map(),
1310
+ };
1311
+ const current = rollup.steps.get(step) ?? {
1312
+ step,
1313
+ entrants: 0,
1314
+ completions: 0,
1315
+ };
1316
+ current.entrants += 1;
1317
+ current.completions += Number(completed);
1318
+ rollup.steps.set(step, current);
1319
+ deltas.funnels.set(coordinates.key, rollup);
1320
+ }
1321
+ function foldAffiliateVisitRollup(deltas, siteId, event, attribution, visitorKey) {
1322
+ const coordinates = reportRollupCoordinates(siteId, String(attribution.affiliateId), event.timestamp, event.visitorId);
1323
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1324
+ siteId,
1325
+ affiliateId: attribution.affiliateId,
1326
+ bucketStart: coordinates.bucketStart,
1327
+ shard: coordinates.shard,
1328
+ affiliateSlug: attribution.affiliateSlug,
1329
+ visits: 0,
1330
+ visitorSketch: createVisitorSketch(),
1331
+ conversions: 0,
1332
+ revenueCents: 0,
1333
+ commissionCents: 0,
1334
+ };
1335
+ current.visits += 1;
1336
+ current.visitorSketch = addVisitor(current.visitorSketch, visitorKey);
1337
+ deltas.affiliates.set(coordinates.key, current);
1338
+ }
1339
+ function foldAffiliateConversionRollup(deltas, siteId, event, affiliate, affiliateSlug, revenueCents, commissionCents) {
1340
+ const coordinates = reportRollupCoordinates(siteId, String(affiliate._id), event.timestamp, event.visitorId);
1341
+ const current = deltas.affiliates.get(coordinates.key) ?? {
1342
+ siteId,
1343
+ affiliateId: affiliate._id,
1344
+ bucketStart: coordinates.bucketStart,
1345
+ shard: coordinates.shard,
1346
+ affiliateSlug,
1347
+ visits: 0,
1348
+ visitorSketch: createVisitorSketch(),
1349
+ conversions: 0,
1350
+ revenueCents: 0,
1351
+ commissionCents: 0,
1352
+ };
1353
+ current.conversions += 1;
1354
+ current.revenueCents += revenueCents;
1355
+ current.commissionCents += commissionCents;
1356
+ deltas.affiliates.set(coordinates.key, current);
1357
+ }
1358
+ function foldVitalRollups(deltas, siteId, event, device) {
1359
+ const metric = event.name;
1360
+ const value = event.value ?? 0;
1361
+ const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1362
+ const shard = stableHash(event.sessionId) % REPORT_ROLLUP_SHARDS;
1363
+ // Each measurement lands twice: on its page (across devices) and on its
1364
+ // device class (across pages), so both breakdowns stay exact and bounded.
1365
+ for (const target of [
1366
+ { device: VITAL_ALL, page: event.path },
1367
+ { device, page: VITAL_ALL },
1368
+ ]) {
1369
+ const key = JSON.stringify([
1370
+ siteId,
1371
+ bucketStart,
1372
+ target.device,
1373
+ target.page,
1374
+ shard,
1375
+ ]);
1376
+ const row = deltas.vitals.get(key) ?? {
1377
+ siteId,
1378
+ bucketStart,
1379
+ device: target.device,
1380
+ page: target.page,
1381
+ shard,
1382
+ metrics: new Map(),
1383
+ };
1384
+ const entry = row.metrics.get(metric) ?? {
1385
+ count: 0,
1386
+ sum: 0,
1387
+ histogram: emptyVitalHistogram(),
1388
+ };
1389
+ entry.count += 1;
1390
+ entry.sum += value;
1391
+ entry.histogram = addToVitalHistogram(entry.histogram, value);
1392
+ row.metrics.set(metric, entry);
1393
+ deltas.vitals.set(key, row);
1394
+ }
1395
+ }
1396
+ function foldRouteDelta(deltas, siteId, route, timestamp, visitorId, apply) {
1397
+ const coordinates = reportRollupCoordinates(siteId, `route:${route}`, timestamp, visitorId);
1398
+ const current = deltas.routes.get(coordinates.key) ?? {
1399
+ siteId,
1400
+ bucketStart: coordinates.bucketStart,
1401
+ shard: coordinates.shard,
1402
+ route,
1403
+ pageviews: 0,
1404
+ entries: 0,
1405
+ continued: 0,
1406
+ dwellMs: 0,
1407
+ dwellSamples: 0,
1408
+ visitorSketch: createVisitorSketch(),
1409
+ outgoing: new Map(),
1410
+ };
1411
+ apply(current);
1412
+ deltas.routes.set(coordinates.key, current);
1413
+ }
1414
+ /**
1415
+ * One pageview feeds the site map three ways: the route it lands on gains a
1416
+ * view (and an entry when it opens the session), the route it leaves gains
1417
+ * the transition and the time spent there, and the session's entry route is
1418
+ * marked as continued the first time its visitor moves on — which is what
1419
+ * lets the report derive bounces and exits without a session-end hook.
1420
+ */
1421
+ function foldRouteRollups(deltas, siteId, event, session, existing) {
1422
+ const route = normalizeRoute(event.path);
1423
+ const priorPageviews = existing?.pageviewCount ?? 0;
1424
+ const firstPageview = existing === null || priorPageviews === 0;
1425
+ foldRouteDelta(deltas, siteId, route, event.timestamp, event.visitorId, (delta) => {
1426
+ delta.pageviews += 1;
1427
+ if (firstPageview)
1428
+ delta.entries += 1;
1429
+ delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
1430
+ });
1431
+ // Out-of-order telemetry keeps its view but cannot say where it came from.
1432
+ if (existing === null || firstPageview)
1433
+ return;
1434
+ if (event.timestamp < existing.lastSeenAt)
1435
+ return;
1436
+ const previous = normalizeRoute(existing.exitPath);
1437
+ const lastPageviewAt = existing.lastPageviewAt;
1438
+ foldRouteDelta(deltas, siteId, previous, event.timestamp, event.visitorId, (delta) => {
1439
+ delta.outgoing.set(route, (delta.outgoing.get(route) ?? 0) + 1);
1440
+ if (lastPageviewAt !== undefined && event.timestamp >= lastPageviewAt) {
1441
+ delta.dwellMs += event.timestamp - lastPageviewAt;
1442
+ delta.dwellSamples += 1;
1443
+ }
1444
+ });
1445
+ if (priorPageviews === 1) {
1446
+ foldRouteDelta(deltas, siteId, normalizeRoute(existing.entryPath), existing.startedAt, event.visitorId, (delta) => {
1447
+ delta.continued += 1;
1448
+ });
1449
+ }
1450
+ }
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
+ }
854
1678
  function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion) {
855
- const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
856
- const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
1679
+ const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
857
1680
  const dimensionDeltas = dimensions.map((dimension) => ({
858
1681
  ...dimension,
859
1682
  count: Number(recordTelemetry),
860
1683
  revenueCents,
861
1684
  }));
862
1685
  for (const granularity of ["hour", "day"]) {
1686
+ const shard = stableHash(event.sessionId) %
1687
+ (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
863
1688
  const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
864
1689
  const bucketStart = Math.floor(event.timestamp / interval) * interval;
865
1690
  const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
@@ -877,6 +1702,7 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
877
1702
  outboundClicks: 0,
878
1703
  sessions: 0,
879
1704
  visitors: 0,
1705
+ visitorSketch: createVisitorSketch(),
880
1706
  conversions: 0,
881
1707
  revenueCents: 0,
882
1708
  dimensions: [],
@@ -895,6 +1721,9 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
895
1721
  ? Number(session.isNewVisitorHour)
896
1722
  : Number(session.isNewVisitorDay)
897
1723
  : 0;
1724
+ if (recordTelemetry) {
1725
+ delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
1726
+ }
898
1727
  delta.conversions += Number(financialConversion);
899
1728
  delta.revenueCents += revenueCents;
900
1729
  delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
@@ -911,6 +1740,13 @@ async function flushAggregates(ctx, aggregateDeltas) {
911
1740
  .eq("shard", delta.shard))
912
1741
  .unique();
913
1742
  const dimensions = mergeDimensions(bucket?.dimensions ?? [], delta.dimensions);
1743
+ const priorSketch = bucket?.visitorSketch;
1744
+ const visitorSketch = priorSketch
1745
+ ? unionVisitorSketches(priorSketch, delta.visitorSketch)
1746
+ : delta.visitorSketch;
1747
+ const visitorSketchComplete = bucket
1748
+ ? bucket.visitorSketchComplete === true && priorSketch !== undefined
1749
+ : true;
914
1750
  if (bucket) {
915
1751
  await ctx.db.patch("aggregateBuckets", bucket._id, {
916
1752
  events: bucket.events + delta.events,
@@ -920,6 +1756,8 @@ async function flushAggregates(ctx, aggregateDeltas) {
920
1756
  outboundClicks: bucket.outboundClicks + delta.outboundClicks,
921
1757
  sessions: bucket.sessions + delta.sessions,
922
1758
  visitors: bucket.visitors + delta.visitors,
1759
+ visitorSketch,
1760
+ visitorSketchComplete,
923
1761
  conversions: bucket.conversions + delta.conversions,
924
1762
  revenueCents: bucket.revenueCents + delta.revenueCents,
925
1763
  dimensions,
@@ -938,6 +1776,8 @@ async function flushAggregates(ctx, aggregateDeltas) {
938
1776
  outboundClicks: delta.outboundClicks,
939
1777
  sessions: delta.sessions,
940
1778
  visitors: delta.visitors,
1779
+ visitorSketch,
1780
+ visitorSketchComplete,
941
1781
  conversions: delta.conversions,
942
1782
  revenueCents: delta.revenueCents,
943
1783
  dimensions,
@@ -955,7 +1795,7 @@ function mergeDimensions(current, additions) {
955
1795
  continue;
956
1796
  }
957
1797
  const typeSlots = result.filter((candidate) => candidate.type === addition.type);
958
- if (typeSlots.length < DIMENSION_SLOTS) {
1798
+ if (typeSlots.length < DIMENSION_SLOTS - 1) {
959
1799
  const occupied = Array.from({ length: DIMENSION_SLOTS }, () => false);
960
1800
  for (const candidate of typeSlots)
961
1801
  occupied[candidate.slot] = true;
@@ -971,21 +1811,43 @@ function mergeDimensions(current, additions) {
971
1811
  });
972
1812
  continue;
973
1813
  }
974
- if (addition.count === 0)
1814
+ const overflow = typeSlots.find((candidate) => candidate.value === "(other)");
1815
+ if (overflow) {
1816
+ overflow.count += addition.count;
1817
+ overflow.revenueCents += addition.revenueCents;
975
1818
  continue;
1819
+ }
1820
+ if (addition.count === 0 && addition.revenueCents === 0)
1821
+ continue;
1822
+ if (typeSlots.length < DIMENSION_SLOTS) {
1823
+ const occupied = new Set(typeSlots.map((candidate) => candidate.slot));
1824
+ let slot = stableHash("(other)") % DIMENSION_SLOTS;
1825
+ while (occupied.has(slot))
1826
+ slot = (slot + 1) % DIMENSION_SLOTS;
1827
+ result.push({
1828
+ type: addition.type,
1829
+ slot,
1830
+ value: "(other)",
1831
+ count: addition.count,
1832
+ revenueCents: addition.revenueCents,
1833
+ });
1834
+ continue;
1835
+ }
976
1836
  let replacement = typeSlots[0];
977
1837
  for (const candidate of typeSlots.slice(1)) {
978
1838
  if (candidate.count < replacement.count)
979
1839
  replacement = candidate;
980
1840
  }
981
- replacement.value = addition.value;
1841
+ replacement.value = "(other)";
982
1842
  replacement.count += addition.count;
983
- replacement.revenueCents = addition.revenueCents;
1843
+ replacement.revenueCents += addition.revenueCents;
984
1844
  }
985
1845
  return result;
986
1846
  }
987
1847
  function stringProperty(properties, key) {
988
1848
  const value = properties?.[key];
989
- return typeof value === "string" ? cleanString(value, 120) || undefined : undefined;
1849
+ return typeof value === "string"
1850
+ ? cleanString(value, 120) || undefined
1851
+ : undefined;
990
1852
  }
991
1853
  //# sourceMappingURL=ingest.js.map