@iann29/rastro 0.1.0-alpha.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 (126) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +352 -0
  3. package/assets/rastro-live-atlas.png +0 -0
  4. package/dist/client/_generated/_ignore.d.ts +1 -0
  5. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  6. package/dist/client/_generated/_ignore.js +3 -0
  7. package/dist/client/_generated/_ignore.js.map +1 -0
  8. package/dist/client/index.d.ts +960 -0
  9. package/dist/client/index.d.ts.map +1 -0
  10. package/dist/client/index.js +591 -0
  11. package/dist/client/index.js.map +1 -0
  12. package/dist/component/_generated/api.d.ts +60 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -0
  14. package/dist/component/_generated/api.js +31 -0
  15. package/dist/component/_generated/api.js.map +1 -0
  16. package/dist/component/_generated/component.d.ts +569 -0
  17. package/dist/component/_generated/component.d.ts.map +1 -0
  18. package/dist/component/_generated/component.js +11 -0
  19. package/dist/component/_generated/component.js.map +1 -0
  20. package/dist/component/_generated/dataModel.d.ts +46 -0
  21. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  22. package/dist/component/_generated/dataModel.js +11 -0
  23. package/dist/component/_generated/dataModel.js.map +1 -0
  24. package/dist/component/_generated/server.d.ts +133 -0
  25. package/dist/component/_generated/server.d.ts.map +1 -0
  26. package/dist/component/_generated/server.js +80 -0
  27. package/dist/component/_generated/server.js.map +1 -0
  28. package/dist/component/affiliates.d.ts +28 -0
  29. package/dist/component/affiliates.d.ts.map +1 -0
  30. package/dist/component/affiliates.js +110 -0
  31. package/dist/component/affiliates.js.map +1 -0
  32. package/dist/component/constants.d.ts +28 -0
  33. package/dist/component/constants.d.ts.map +1 -0
  34. package/dist/component/constants.js +28 -0
  35. package/dist/component/constants.js.map +1 -0
  36. package/dist/component/convex.config.d.ts +3 -0
  37. package/dist/component/convex.config.d.ts.map +1 -0
  38. package/dist/component/convex.config.js +3 -0
  39. package/dist/component/convex.config.js.map +1 -0
  40. package/dist/component/errors.d.ts +3 -0
  41. package/dist/component/errors.d.ts.map +1 -0
  42. package/dist/component/errors.js +5 -0
  43. package/dist/component/errors.js.map +1 -0
  44. package/dist/component/funnels.d.ts +36 -0
  45. package/dist/component/funnels.d.ts.map +1 -0
  46. package/dist/component/funnels.js +117 -0
  47. package/dist/component/funnels.js.map +1 -0
  48. package/dist/component/goals.d.ts +30 -0
  49. package/dist/component/goals.d.ts.map +1 -0
  50. package/dist/component/goals.js +110 -0
  51. package/dist/component/goals.js.map +1 -0
  52. package/dist/component/guards.d.ts +2 -0
  53. package/dist/component/guards.d.ts.map +1 -0
  54. package/dist/component/guards.js +4 -0
  55. package/dist/component/guards.js.map +1 -0
  56. package/dist/component/http.d.ts +3 -0
  57. package/dist/component/http.d.ts.map +1 -0
  58. package/dist/component/http.js +270 -0
  59. package/dist/component/http.js.map +1 -0
  60. package/dist/component/ingest.d.ts +51 -0
  61. package/dist/component/ingest.d.ts.map +1 -0
  62. package/dist/component/ingest.js +991 -0
  63. package/dist/component/ingest.js.map +1 -0
  64. package/dist/component/live.d.ts +9 -0
  65. package/dist/component/live.d.ts.map +1 -0
  66. package/dist/component/live.js +36 -0
  67. package/dist/component/live.js.map +1 -0
  68. package/dist/component/reports.d.ts +330 -0
  69. package/dist/component/reports.d.ts.map +1 -0
  70. package/dist/component/reports.js +595 -0
  71. package/dist/component/reports.js.map +1 -0
  72. package/dist/component/retention.d.ts +10 -0
  73. package/dist/component/retention.d.ts.map +1 -0
  74. package/dist/component/retention.js +107 -0
  75. package/dist/component/retention.js.map +1 -0
  76. package/dist/component/sanitize.d.ts +20 -0
  77. package/dist/component/sanitize.d.ts.map +1 -0
  78. package/dist/component/sanitize.js +269 -0
  79. package/dist/component/sanitize.js.map +1 -0
  80. package/dist/component/schema.d.ts +517 -0
  81. package/dist/component/schema.d.ts.map +1 -0
  82. package/dist/component/schema.js +209 -0
  83. package/dist/component/schema.js.map +1 -0
  84. package/dist/component/sites.d.ts +54 -0
  85. package/dist/component/sites.d.ts.map +1 -0
  86. package/dist/component/sites.js +164 -0
  87. package/dist/component/sites.js.map +1 -0
  88. package/dist/component/validators.d.ts +377 -0
  89. package/dist/component/validators.d.ts.map +1 -0
  90. package/dist/component/validators.js +176 -0
  91. package/dist/component/validators.js.map +1 -0
  92. package/dist/react/index.d.ts +40 -0
  93. package/dist/react/index.d.ts.map +1 -0
  94. package/dist/react/index.js +31 -0
  95. package/dist/react/index.js.map +1 -0
  96. package/dist/tracker/generated.d.ts +5 -0
  97. package/dist/tracker/generated.d.ts.map +1 -0
  98. package/dist/tracker/generated.js +6 -0
  99. package/dist/tracker/generated.js.map +1 -0
  100. package/dist/tracker/tracker.d.ts +27 -0
  101. package/dist/tracker/tracker.d.ts.map +1 -0
  102. package/dist/tracker/tracker.js +108 -0
  103. package/dist/tracker/tracker.js.map +1 -0
  104. package/dist/tracker.min.js +1 -0
  105. package/package.json +121 -0
  106. package/src/component/_generated/api.ts +76 -0
  107. package/src/component/_generated/component.ts +706 -0
  108. package/src/component/_generated/dataModel.ts +60 -0
  109. package/src/component/_generated/server.ts +169 -0
  110. package/src/component/affiliates.ts +119 -0
  111. package/src/component/constants.ts +28 -0
  112. package/src/component/convex.config.ts +3 -0
  113. package/src/component/errors.ts +20 -0
  114. package/src/component/funnels.ts +134 -0
  115. package/src/component/goals.ts +122 -0
  116. package/src/component/guards.ts +3 -0
  117. package/src/component/http.ts +301 -0
  118. package/src/component/ingest.ts +1364 -0
  119. package/src/component/live.ts +36 -0
  120. package/src/component/reports.ts +720 -0
  121. package/src/component/retention.ts +180 -0
  122. package/src/component/sanitize.ts +278 -0
  123. package/src/component/schema.ts +232 -0
  124. package/src/component/sites.ts +188 -0
  125. package/src/component/validators.ts +226 -0
  126. package/src/test.ts +19 -0
@@ -0,0 +1,1364 @@
1
+ import { v } from "convex/values";
2
+ import type { Doc, Id } from "./_generated/dataModel.js";
3
+ import type { MutationCtx } from "./_generated/server.js";
4
+ import { mutation } from "./_generated/server.js";
5
+ import { internal } from "./_generated/api.js";
6
+ import {
7
+ AGGREGATE_SHARDS,
8
+ DAY_MS,
9
+ DIMENSION_SLOTS,
10
+ HOUR_MS,
11
+ LIVE_SESSION_TTL_MS,
12
+ MAX_BATCH_EVENTS,
13
+ MAX_SITE_BYTES_PER_SHARD_WINDOW,
14
+ MAX_SITE_EVENTS_PER_SHARD_WINDOW,
15
+ MAX_BYTES_PER_SESSION_WINDOW,
16
+ MAX_EVENTS_PER_SESSION_WINDOW,
17
+ MAX_FUNNELS_PER_SITE,
18
+ MAX_GOALS_PER_SITE,
19
+ RATE_LIMIT_WINDOW_MS,
20
+ SITE_INGEST_SHARDS,
21
+ } from "./constants.js";
22
+ import { fail } from "./errors.js";
23
+ import {
24
+ cleanString,
25
+ ensureBatchBytes,
26
+ isValidMoney,
27
+ originAllowed,
28
+ sanitizeContext,
29
+ sanitizeCurrency,
30
+ sanitizeEvent,
31
+ sanitizeOpaqueId,
32
+ sanitizePath,
33
+ sanitizeProperties,
34
+ sanitizeSlug,
35
+ sourceFromReferrer,
36
+ stableHash,
37
+ } from "./sanitize.js";
38
+ import {
39
+ ingestContextValidator,
40
+ ingestResultValidator,
41
+ trackerEventValidator,
42
+ trustedConversionValidator,
43
+ type IngestContext,
44
+ type TrackerEvent,
45
+ } from "./validators.js";
46
+
47
+ type DimensionType =
48
+ | "source"
49
+ | "page"
50
+ | "country"
51
+ | "device"
52
+ | "browser"
53
+ | "event"
54
+ | "goal";
55
+
56
+ type Dimension = { type: DimensionType; value: string };
57
+ type DimensionDelta = Dimension & { count: number; revenueCents: number };
58
+ type AggregateDelta = {
59
+ siteId: Id<"sites">;
60
+ granularity: "hour" | "day";
61
+ bucketStart: number;
62
+ shard: number;
63
+ events: number;
64
+ pageviews: number;
65
+ clicks: number;
66
+ customEvents: number;
67
+ outboundClicks: number;
68
+ sessions: number;
69
+ visitors: number;
70
+ conversions: number;
71
+ revenueCents: number;
72
+ dimensions: Doc<"aggregateBuckets">["dimensions"];
73
+ };
74
+ type AggregateDeltas = Map<string, AggregateDelta>;
75
+ type AttributionSnapshot = {
76
+ affiliateId: Id<"affiliates">;
77
+ affiliateSlug: string;
78
+ affiliateAttributedAt: number;
79
+ affiliateExpiresAt: number;
80
+ };
81
+
82
+ type SessionState = {
83
+ isNewSession: boolean;
84
+ isNewVisitorHour: boolean;
85
+ isNewVisitorDay: boolean;
86
+ startedAt: number;
87
+ source: string;
88
+ pageviewCount: number;
89
+ eventCount: number;
90
+ affiliateId?: Id<"affiliates">;
91
+ affiliateSlug?: string;
92
+ affiliateAttributedAt?: number;
93
+ affiliateExpiresAt?: number;
94
+ };
95
+
96
+ export const ingestBatch = mutation({
97
+ args: {
98
+ siteId: v.id("sites"),
99
+ origin: v.string(),
100
+ events: v.array(trackerEventValidator),
101
+ context: v.optional(ingestContextValidator),
102
+ },
103
+ returns: ingestResultValidator,
104
+ handler: async (ctx, args) => {
105
+ const site = await ctx.db.get("sites", args.siteId);
106
+ if (!site) fail("NOT_FOUND", "site not found");
107
+ if (!originAllowed(args.origin, site.domains)) {
108
+ fail("ORIGIN_NOT_ALLOWED", "request origin is not allowed", {
109
+ siteId: String(args.siteId),
110
+ });
111
+ }
112
+ if (args.events.length < 1 || args.events.length > MAX_BATCH_EVENTS) {
113
+ fail("BATCH_TOO_LARGE", `events must contain 1-${MAX_BATCH_EVENTS} items`, {
114
+ limit: MAX_BATCH_EVENTS,
115
+ });
116
+ }
117
+ try {
118
+ ensureBatchBytes({ siteId: String(args.siteId), events: args.events });
119
+ } catch {
120
+ fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
121
+ }
122
+
123
+ const now = Date.now();
124
+ const eventByteCounts = await consumeSiteRateLimit(
125
+ ctx,
126
+ args.siteId,
127
+ args.events,
128
+ now,
129
+ );
130
+ const context = sanitizeContext(args.context);
131
+ const aggregateDeltas: AggregateDeltas = new Map();
132
+ let goals: Doc<"goals">[] | null = null;
133
+ let funnels: Doc<"funnels">[] | null = null;
134
+ let accepted = 0;
135
+ let duplicates = 0;
136
+ let rejected = 0;
137
+ for (let eventIndex = 0; eventIndex < args.events.length; eventIndex += 1) {
138
+ const input = args.events[eventIndex];
139
+ let event: TrackerEvent;
140
+ try {
141
+ event = sanitizeEvent(input, now);
142
+ } catch {
143
+ rejected += 1;
144
+ continue;
145
+ }
146
+
147
+ const existingSession = await ctx.db
148
+ .query("sessions")
149
+ .withIndex("by_siteId_and_sessionId", (range) =>
150
+ range.eq("siteId", args.siteId).eq("sessionId", event.sessionId),
151
+ )
152
+ .unique();
153
+ if (existingSession && existingSession.visitorId !== event.visitorId) {
154
+ rejected += 1;
155
+ continue;
156
+ }
157
+
158
+ if (event.type === "heartbeat") {
159
+ if (!existingSession || event.timestamp < now - LIVE_SESSION_TTL_MS) {
160
+ rejected += 1;
161
+ continue;
162
+ }
163
+ const liveSession = await ctx.db
164
+ .query("liveSessions")
165
+ .withIndex("by_siteId_and_sessionId", (range) =>
166
+ range.eq("siteId", args.siteId).eq("sessionId", event.sessionId),
167
+ )
168
+ .unique();
169
+ if (liveSession?.lastHeartbeatEventId === event.eventId) {
170
+ duplicates += 1;
171
+ continue;
172
+ }
173
+ if (
174
+ !(await consumeRateLimit(
175
+ ctx,
176
+ args.siteId,
177
+ event.sessionId,
178
+ eventByteCounts[eventIndex],
179
+ now,
180
+ ))
181
+ ) {
182
+ rejected += 1;
183
+ continue;
184
+ }
185
+ await accountHeartbeat(
186
+ ctx,
187
+ args.siteId,
188
+ existingSession,
189
+ event,
190
+ context,
191
+ now,
192
+ );
193
+ accepted += 1;
194
+ continue;
195
+ }
196
+
197
+ const duplicate = await ctx.db
198
+ .query("events")
199
+ .withIndex("by_siteId_and_eventId", (range) =>
200
+ range.eq("siteId", args.siteId).eq("eventId", event.eventId),
201
+ )
202
+ .unique();
203
+ if (duplicate) {
204
+ duplicates += 1;
205
+ continue;
206
+ }
207
+
208
+ if (
209
+ !(await consumeRateLimit(
210
+ ctx,
211
+ args.siteId,
212
+ event.sessionId,
213
+ eventByteCounts[eventIndex],
214
+ now,
215
+ ))
216
+ ) {
217
+ rejected += 1;
218
+ continue;
219
+ }
220
+ const canMatchDefinition =
221
+ event.type === "pageview" || event.type === "custom";
222
+ if (canMatchDefinition && goals === null) {
223
+ goals = await ctx.db
224
+ .query("goals")
225
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
226
+ .take(MAX_GOALS_PER_SITE);
227
+ }
228
+ if (canMatchDefinition && funnels === null) {
229
+ funnels = await ctx.db
230
+ .query("funnels")
231
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
232
+ .take(MAX_FUNNELS_PER_SITE);
233
+ }
234
+ await accountEvent(ctx, {
235
+ siteId: args.siteId,
236
+ event,
237
+ context,
238
+ existingSession,
239
+ goals: canMatchDefinition ? goals ?? [] : [],
240
+ funnels: canMatchDefinition ? funnels ?? [] : [],
241
+ aggregateDeltas,
242
+ recordTelemetry: true,
243
+ financialConversion: false,
244
+ trustedGoalKey: undefined,
245
+ now,
246
+ });
247
+ accepted += 1;
248
+ }
249
+ await flushAggregates(ctx, aggregateDeltas);
250
+ return { accepted, duplicates, rejected };
251
+ },
252
+ });
253
+
254
+ export const trackConversion = mutation({
255
+ args: trustedConversionValidator.fields,
256
+ returns: ingestResultValidator,
257
+ handler: async (ctx, args) => {
258
+ const site = await ctx.db.get("sites", args.siteId);
259
+ if (!site) fail("NOT_FOUND", "site not found");
260
+ if (!isValidMoney(args.revenueCents)) {
261
+ fail("INVALID_ARGUMENT", "revenueCents must be an integer between 0 and 1000000000");
262
+ }
263
+ let event: TrackerEvent;
264
+ try {
265
+ event = sanitizeEvent(
266
+ {
267
+ eventId: sanitizeOpaqueId(args.eventId, "eventId"),
268
+ sessionId: sanitizeOpaqueId(args.sessionId, "sessionId"),
269
+ visitorId: sanitizeOpaqueId(args.visitorId, "visitorId"),
270
+ type: "conversion",
271
+ name: args.goalKey ? sanitizeSlug(args.goalKey, "goalKey") : "conversion",
272
+ path: sanitizePath(args.path ?? "/conversion"),
273
+ timestamp: args.timestamp,
274
+ sequence: 0,
275
+ properties: sanitizeProperties(args.properties),
276
+ revenueCents: args.revenueCents,
277
+ currency: sanitizeCurrency(args.currency),
278
+ },
279
+ Date.now(),
280
+ );
281
+ } catch (error) {
282
+ fail("INVALID_ARGUMENT", error instanceof Error ? error.message : "invalid conversion");
283
+ }
284
+
285
+ const siteCurrency = site.currency ?? "USD";
286
+ if (event.currency !== siteCurrency) {
287
+ fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
288
+ siteCurrency,
289
+ conversionCurrency: event.currency ?? "USD",
290
+ });
291
+ }
292
+ const priorConversion = await ctx.db
293
+ .query("conversions")
294
+ .withIndex("by_siteId_and_eventId", (range) =>
295
+ range.eq("siteId", args.siteId).eq("eventId", event.eventId),
296
+ )
297
+ .unique();
298
+ if (priorConversion) {
299
+ return { accepted: 0, duplicates: 1, rejected: 0 };
300
+ }
301
+
302
+ const existingTelemetry = await ctx.db
303
+ .query("events")
304
+ .withIndex("by_siteId_and_eventId", (range) =>
305
+ range.eq("siteId", args.siteId).eq("eventId", event.eventId),
306
+ )
307
+ .unique();
308
+ if (
309
+ existingTelemetry &&
310
+ (existingTelemetry.sessionId !== event.sessionId ||
311
+ existingTelemetry.visitorId !== event.visitorId)
312
+ ) {
313
+ fail("CONFLICT", "event belongs to a different session or visitor");
314
+ }
315
+ const existingSession = await ctx.db
316
+ .query("sessions")
317
+ .withIndex("by_siteId_and_sessionId", (range) =>
318
+ range.eq("siteId", args.siteId).eq("sessionId", event.sessionId),
319
+ )
320
+ .unique();
321
+ if (existingSession && existingSession.visitorId !== event.visitorId) {
322
+ fail("CONFLICT", "session belongs to a different visitor");
323
+ }
324
+ const conversionContext: IngestContext = existingTelemetry
325
+ ? {
326
+ country: existingTelemetry.country,
327
+ city: existingTelemetry.city,
328
+ latitude: existingTelemetry.latitude,
329
+ longitude: existingTelemetry.longitude,
330
+ browser: existingTelemetry.browser,
331
+ os: existingTelemetry.os,
332
+ device: existingTelemetry.device,
333
+ }
334
+ : sanitizeContext(undefined);
335
+
336
+ const aggregateDeltas: AggregateDeltas = new Map();
337
+ await accountEvent(ctx, {
338
+ siteId: args.siteId,
339
+ event,
340
+ context: conversionContext,
341
+ existingSession,
342
+ goals: [],
343
+ funnels: [],
344
+ aggregateDeltas,
345
+ recordTelemetry: existingTelemetry === null,
346
+ financialConversion: true,
347
+ trustedGoalKey: args.goalKey
348
+ ? sanitizeSlug(args.goalKey, "goalKey")
349
+ : undefined,
350
+ now: Date.now(),
351
+ });
352
+ await flushAggregates(ctx, aggregateDeltas);
353
+ return { accepted: 1, duplicates: 0, rejected: 0 };
354
+ },
355
+ });
356
+
357
+ async function accountEvent(
358
+ ctx: MutationCtx,
359
+ input: {
360
+ siteId: Id<"sites">;
361
+ event: TrackerEvent;
362
+ context: IngestContext;
363
+ existingSession: Doc<"sessions"> | null;
364
+ goals: Doc<"goals">[];
365
+ funnels: Doc<"funnels">[];
366
+ aggregateDeltas: AggregateDeltas;
367
+ recordTelemetry: boolean;
368
+ financialConversion: boolean;
369
+ trustedGoalKey: string | undefined;
370
+ now: number;
371
+ },
372
+ ) {
373
+ const { event } = input;
374
+ const session = input.recordTelemetry
375
+ ? await upsertSession(
376
+ ctx,
377
+ input.siteId,
378
+ event,
379
+ input.context,
380
+ input.existingSession,
381
+ input.financialConversion,
382
+ )
383
+ : await accountTrustedSession(ctx, input.existingSession, event);
384
+
385
+ if (input.recordTelemetry) {
386
+ await ctx.db.insert("events", {
387
+ siteId: input.siteId,
388
+ eventId: event.eventId,
389
+ sessionId: event.sessionId,
390
+ visitorId: event.visitorId,
391
+ type: event.type,
392
+ ...(event.name ? { name: event.name } : {}),
393
+ path: event.path,
394
+ ...(event.referrer ? { referrer: event.referrer } : {}),
395
+ timestamp: event.timestamp,
396
+ sequence: event.sequence,
397
+ ...(event.target ? { target: event.target } : {}),
398
+ ...(event.href ? { href: event.href } : {}),
399
+ ...(event.properties ? { properties: event.properties } : {}),
400
+ ...(event.revenueCents !== undefined
401
+ ? { revenueCents: event.revenueCents }
402
+ : {}),
403
+ ...(event.currency ? { currency: event.currency } : {}),
404
+ ...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
405
+ ...(input.context.country ? { country: input.context.country } : {}),
406
+ ...(input.context.city ? { city: input.context.city } : {}),
407
+ ...(input.context.latitude !== undefined
408
+ ? { latitude: input.context.latitude }
409
+ : {}),
410
+ ...(input.context.longitude !== undefined
411
+ ? { longitude: input.context.longitude }
412
+ : {}),
413
+ browser: input.context.browser ?? "Other",
414
+ os: input.context.os ?? "Other",
415
+ device: input.context.device ?? "unknown",
416
+ });
417
+ }
418
+
419
+ const matchedGoalKeys: string[] = [];
420
+ for (const goal of input.goals) {
421
+ if (!goal.active || !goalMatches(goal, event)) continue;
422
+ const prior = await ctx.db
423
+ .query("goalCompletions")
424
+ .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
425
+ range
426
+ .eq("siteId", input.siteId)
427
+ .eq("goalId", goal._id)
428
+ .eq("eventId", event.eventId),
429
+ )
430
+ .unique();
431
+ if (prior) continue;
432
+ await ctx.db.insert("goalCompletions", {
433
+ siteId: input.siteId,
434
+ goalId: goal._id,
435
+ goalKey: goal.key,
436
+ eventId: event.eventId,
437
+ sessionId: event.sessionId,
438
+ visitorId: event.visitorId,
439
+ timestamp: event.timestamp,
440
+ valueCents: goal.valueCents ?? 0,
441
+ });
442
+ matchedGoalKeys.push(goal.key);
443
+ }
444
+
445
+ for (const funnel of input.funnels) {
446
+ if (funnel.active) {
447
+ await advanceFunnel(ctx, input.siteId, funnel, event);
448
+ }
449
+ }
450
+
451
+ if (input.financialConversion) {
452
+ await accountConversion(
453
+ ctx,
454
+ input.siteId,
455
+ session,
456
+ event,
457
+ input.trustedGoalKey,
458
+ );
459
+ }
460
+
461
+ const dimensions: Dimension[] = [
462
+ { type: "source", value: session.source },
463
+ { type: "page", value: event.path },
464
+ { type: "country", value: input.context.country ?? "unknown" },
465
+ { type: "device", value: input.context.device ?? "unknown" },
466
+ { type: "browser", value: input.context.browser ?? "Other" },
467
+ {
468
+ type: "event",
469
+ value: event.type === "custom" ? event.name ?? "custom" : event.type,
470
+ },
471
+ ...matchedGoalKeys.map((value): Dimension => ({ type: "goal", value })),
472
+ ];
473
+ foldAggregates(
474
+ input.aggregateDeltas,
475
+ input.siteId,
476
+ event,
477
+ session,
478
+ dimensions,
479
+ input.recordTelemetry,
480
+ input.financialConversion,
481
+ );
482
+
483
+ if (
484
+ input.recordTelemetry &&
485
+ event.timestamp >= input.now - LIVE_SESSION_TTL_MS
486
+ ) {
487
+ await updateLiveSession(
488
+ ctx,
489
+ input.siteId,
490
+ event,
491
+ input.context,
492
+ session,
493
+ input.now,
494
+ );
495
+ }
496
+ }
497
+
498
+ async function accountTrustedSession(
499
+ ctx: MutationCtx,
500
+ existing: Doc<"sessions"> | null,
501
+ event: TrackerEvent,
502
+ ): Promise<SessionState> {
503
+ if (!existing) {
504
+ fail("CONFLICT", "trusted conversion telemetry has no matching session");
505
+ }
506
+ const revenueCents = event.revenueCents ?? 0;
507
+ await ctx.db.patch("sessions", existing._id, {
508
+ conversionCount: existing.conversionCount + 1,
509
+ revenueCents: existing.revenueCents + revenueCents,
510
+ currency: event.currency ?? "USD",
511
+ });
512
+ return {
513
+ isNewSession: false,
514
+ isNewVisitorHour: false,
515
+ isNewVisitorDay: false,
516
+ startedAt: existing.startedAt,
517
+ source: existing.source,
518
+ pageviewCount: existing.pageviewCount,
519
+ eventCount: existing.eventCount,
520
+ affiliateId: existing.affiliateId,
521
+ affiliateSlug: existing.affiliateSlug,
522
+ affiliateAttributedAt: existing.affiliateAttributedAt,
523
+ affiliateExpiresAt: existing.affiliateExpiresAt,
524
+ };
525
+ }
526
+
527
+ async function upsertSession(
528
+ ctx: MutationCtx,
529
+ siteId: Id<"sites">,
530
+ event: TrackerEvent,
531
+ context: IngestContext,
532
+ existing: Doc<"sessions"> | null,
533
+ financialConversion: boolean,
534
+ ): Promise<SessionState> {
535
+ const source = sourceFromReferrer(event.referrer);
536
+ const utmSource = stringProperty(event.properties, "utm_source");
537
+ const utmMedium = stringProperty(event.properties, "utm_medium");
538
+ const utmCampaign = stringProperty(event.properties, "utm_campaign");
539
+ const attribution = await attributionForEvent(ctx, siteId, event, existing);
540
+ if (existing) {
541
+ const startedAt = Math.min(existing.startedAt, event.timestamp);
542
+ const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
543
+ await ctx.db.patch("sessions", existing._id, {
544
+ startedAt,
545
+ lastSeenAt,
546
+ entryPath: event.timestamp < existing.startedAt ? event.path : existing.entryPath,
547
+ exitPath: event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
548
+ eventCount: existing.eventCount + 1,
549
+ pageviewCount: existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
550
+ durationMs: Math.max(0, lastSeenAt - startedAt),
551
+ conversionCount: existing.conversionCount + Number(financialConversion),
552
+ revenueCents:
553
+ existing.revenueCents +
554
+ (financialConversion ? event.revenueCents ?? 0 : 0),
555
+ ...(financialConversion && event.currency
556
+ ? { currency: event.currency }
557
+ : {}),
558
+ ...(attribution ?? {}),
559
+ ...(utmSource ? { utmSource } : {}),
560
+ ...(utmMedium ? { utmMedium } : {}),
561
+ ...(utmCampaign ? { utmCampaign } : {}),
562
+ ...(context.country ? { country: context.country } : {}),
563
+ ...(context.city ? { city: context.city } : {}),
564
+ ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
565
+ ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
566
+ browser: context.browser ?? existing.browser,
567
+ os: context.os ?? existing.os,
568
+ device: context.device ?? existing.device,
569
+ });
570
+ return {
571
+ isNewSession: false,
572
+ isNewVisitorHour:
573
+ Math.floor(existing.lastSeenAt / HOUR_MS) <
574
+ Math.floor(event.timestamp / HOUR_MS),
575
+ isNewVisitorDay:
576
+ Math.floor(existing.lastSeenAt / DAY_MS) <
577
+ Math.floor(event.timestamp / DAY_MS),
578
+ startedAt,
579
+ source: existing.source,
580
+ pageviewCount:
581
+ existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
582
+ eventCount: existing.eventCount + 1,
583
+ ...(attribution ?? {}),
584
+ };
585
+ }
586
+
587
+ const priorVisit = await ctx.db
588
+ .query("sessions")
589
+ .withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) =>
590
+ range.eq("siteId", siteId).eq("visitorId", event.visitorId),
591
+ )
592
+ .order("desc")
593
+ .take(1);
594
+ await ctx.db.insert("sessions", {
595
+ siteId,
596
+ sessionId: event.sessionId,
597
+ visitorId: event.visitorId,
598
+ startedAt: event.timestamp,
599
+ lastSeenAt: event.timestamp,
600
+ entryPath: event.path,
601
+ exitPath: event.path,
602
+ ...(event.referrer ? { referrer: event.referrer } : {}),
603
+ source,
604
+ ...(utmSource ? { utmSource } : {}),
605
+ ...(utmMedium ? { utmMedium } : {}),
606
+ ...(utmCampaign ? { utmCampaign } : {}),
607
+ ...(context.country ? { country: context.country } : {}),
608
+ ...(context.city ? { city: context.city } : {}),
609
+ ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
610
+ ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
611
+ browser: context.browser ?? "Other",
612
+ os: context.os ?? "Other",
613
+ device: context.device ?? "unknown",
614
+ eventCount: 1,
615
+ pageviewCount: event.type === "pageview" ? 1 : 0,
616
+ durationMs: 0,
617
+ conversionCount: Number(financialConversion),
618
+ revenueCents: financialConversion ? event.revenueCents ?? 0 : 0,
619
+ ...(financialConversion && event.currency
620
+ ? { currency: event.currency }
621
+ : {}),
622
+ ...(attribution ?? {}),
623
+ });
624
+ return {
625
+ isNewSession: true,
626
+ isNewVisitorHour:
627
+ priorVisit.length === 0 ||
628
+ Math.floor(priorVisit[0].lastSeenAt / HOUR_MS) <
629
+ Math.floor(event.timestamp / HOUR_MS),
630
+ isNewVisitorDay:
631
+ priorVisit.length === 0 ||
632
+ Math.floor(priorVisit[0].lastSeenAt / DAY_MS) <
633
+ Math.floor(event.timestamp / DAY_MS),
634
+ startedAt: event.timestamp,
635
+ source,
636
+ pageviewCount: event.type === "pageview" ? 1 : 0,
637
+ eventCount: 1,
638
+ ...(attribution ?? {}),
639
+ };
640
+ }
641
+
642
+ function attributionSnapshot(
643
+ value: {
644
+ affiliateId?: Id<"affiliates">;
645
+ affiliateSlug?: string;
646
+ affiliateAttributedAt?: number;
647
+ affiliateExpiresAt?: number;
648
+ },
649
+ ): AttributionSnapshot | undefined {
650
+ if (
651
+ !value.affiliateId ||
652
+ !value.affiliateSlug ||
653
+ value.affiliateAttributedAt === undefined ||
654
+ value.affiliateExpiresAt === undefined
655
+ ) {
656
+ return undefined;
657
+ }
658
+ return {
659
+ affiliateId: value.affiliateId,
660
+ affiliateSlug: value.affiliateSlug,
661
+ affiliateAttributedAt: value.affiliateAttributedAt,
662
+ affiliateExpiresAt: value.affiliateExpiresAt,
663
+ };
664
+ }
665
+
666
+
667
+ function attributionApplies(
668
+ attribution: AttributionSnapshot,
669
+ timestamp: number,
670
+ ): boolean {
671
+ return (
672
+ timestamp >= attribution.affiliateAttributedAt &&
673
+ timestamp <= attribution.affiliateExpiresAt
674
+ );
675
+ }
676
+
677
+ // The first valid touch wins through its inclusive expiry. Only a later,
678
+ // in-order valid touch may replace the visitor's single bounded record.
679
+ async function attributionForEvent(
680
+ ctx: MutationCtx,
681
+ siteId: Id<"sites">,
682
+ event: TrackerEvent,
683
+ existingSession: Doc<"sessions"> | null,
684
+ ): Promise<AttributionSnapshot | undefined> {
685
+ const sessionAttribution = existingSession
686
+ ? attributionSnapshot(existingSession)
687
+ : undefined;
688
+ if (!event.affiliateSlug && sessionAttribution) {
689
+ return sessionAttribution;
690
+ }
691
+ if (
692
+ event.affiliateSlug &&
693
+ sessionAttribution &&
694
+ attributionApplies(sessionAttribution, event.timestamp)
695
+ ) {
696
+ return sessionAttribution;
697
+ }
698
+
699
+ const current = await ctx.db
700
+ .query("visitorAttributions")
701
+ .withIndex("by_siteId_and_visitorId", (range) =>
702
+ range.eq("siteId", siteId).eq("visitorId", event.visitorId),
703
+ )
704
+ .unique();
705
+ if (current) {
706
+ const snapshot: AttributionSnapshot = {
707
+ affiliateId: current.affiliateId,
708
+ affiliateSlug: current.affiliateSlug,
709
+ affiliateAttributedAt: current.attributedAt,
710
+ affiliateExpiresAt: current.expiresAt,
711
+ };
712
+ if (attributionApplies(snapshot, event.timestamp)) {
713
+ return snapshot;
714
+ }
715
+ if (event.timestamp <= current.expiresAt) {
716
+ return sessionAttribution;
717
+ }
718
+ }
719
+ const affiliateSlug = event.affiliateSlug;
720
+ if (!affiliateSlug) {
721
+ return sessionAttribution;
722
+ }
723
+
724
+ const affiliate = await ctx.db
725
+ .query("affiliates")
726
+ .withIndex("by_siteId_and_slug", (range) =>
727
+ range.eq("siteId", siteId).eq("slug", affiliateSlug),
728
+ )
729
+ .unique();
730
+ if (!affiliate?.active) {
731
+ return sessionAttribution;
732
+ }
733
+
734
+ const attributedAt = event.timestamp;
735
+ const expiresAt = attributedAt + affiliate.attributionWindowDays * DAY_MS;
736
+ if (current) {
737
+ await ctx.db.patch("visitorAttributions", current._id, {
738
+ affiliateId: affiliate._id,
739
+ affiliateSlug: affiliate.slug,
740
+ attributedAt,
741
+ expiresAt,
742
+ });
743
+ } else {
744
+ await ctx.db.insert("visitorAttributions", {
745
+ siteId,
746
+ visitorId: event.visitorId,
747
+ affiliateId: affiliate._id,
748
+ affiliateSlug: affiliate.slug,
749
+ attributedAt,
750
+ expiresAt,
751
+ });
752
+ }
753
+ return {
754
+ affiliateId: affiliate._id,
755
+ affiliateSlug: affiliate.slug,
756
+ affiliateAttributedAt: attributedAt,
757
+ affiliateExpiresAt: expiresAt,
758
+ };
759
+ }
760
+
761
+ async function accountHeartbeat(
762
+ ctx: MutationCtx,
763
+ siteId: Id<"sites">,
764
+ session: Doc<"sessions">,
765
+ event: TrackerEvent,
766
+ context: IngestContext,
767
+ now: number,
768
+ ) {
769
+ const startedAt = Math.min(session.startedAt, event.timestamp);
770
+ const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
771
+ await ctx.db.patch("sessions", session._id, {
772
+ startedAt,
773
+ lastSeenAt,
774
+ exitPath: event.timestamp >= session.lastSeenAt
775
+ ? event.path
776
+ : session.exitPath,
777
+ durationMs: Math.max(0, lastSeenAt - startedAt),
778
+ ...(context.country ? { country: context.country } : {}),
779
+ ...(context.city ? { city: context.city } : {}),
780
+ ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
781
+ ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
782
+ browser: context.browser ?? session.browser,
783
+ os: context.os ?? session.os,
784
+ device: context.device ?? session.device,
785
+ });
786
+ await updateLiveSession(
787
+ ctx,
788
+ siteId,
789
+ event,
790
+ context,
791
+ {
792
+ isNewSession: false,
793
+ isNewVisitorHour: false,
794
+ isNewVisitorDay: false,
795
+ startedAt,
796
+ source: session.source,
797
+ pageviewCount: session.pageviewCount,
798
+ eventCount: session.eventCount,
799
+ affiliateSlug: session.affiliateSlug,
800
+ },
801
+ now,
802
+ );
803
+ }
804
+
805
+ async function consumeSiteRateLimit(
806
+ ctx: MutationCtx,
807
+ siteId: Id<"sites">,
808
+ events: TrackerEvent[],
809
+ now: number,
810
+ ): Promise<number[]> {
811
+ const windowStart =
812
+ Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
813
+ const increments = new Map<number, { eventCount: number; byteCount: number }>();
814
+ const eventByteCounts: number[] = [];
815
+ const encoder = new TextEncoder();
816
+ for (let index = 0; index < events.length; index += 1) {
817
+ const event = events[index];
818
+ const byteCount = encoder.encode(JSON.stringify(event)).byteLength;
819
+ eventByteCounts.push(byteCount);
820
+ const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
821
+ const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
822
+ current.eventCount += 1;
823
+ current.byteCount += byteCount;
824
+ increments.set(shard, current);
825
+ }
826
+
827
+ const windows: Array<{
828
+ shard: number;
829
+ increment: { eventCount: number; byteCount: number };
830
+ window: Doc<"siteIngestWindows"> | null;
831
+ }> = [];
832
+ for (const [shard, increment] of increments) {
833
+ const window = await ctx.db
834
+ .query("siteIngestWindows")
835
+ .withIndex("by_siteId_and_shard_and_windowStart", (range) =>
836
+ range
837
+ .eq("siteId", siteId)
838
+ .eq("shard", shard)
839
+ .eq("windowStart", windowStart),
840
+ )
841
+ .unique();
842
+ if (
843
+ (window?.eventCount ?? 0) + increment.eventCount >
844
+ MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
845
+ (window?.byteCount ?? 0) + increment.byteCount >
846
+ MAX_SITE_BYTES_PER_SHARD_WINDOW
847
+ ) {
848
+ fail("RATE_LIMITED", "site ingest budget exceeded", {
849
+ siteShard: shard,
850
+ windowMs: RATE_LIMIT_WINDOW_MS,
851
+ eventLimitPerShard: MAX_SITE_EVENTS_PER_SHARD_WINDOW,
852
+ byteLimitPerShard: MAX_SITE_BYTES_PER_SHARD_WINDOW,
853
+ siteEventLimit:
854
+ SITE_INGEST_SHARDS * MAX_SITE_EVENTS_PER_SHARD_WINDOW,
855
+ siteByteLimit:
856
+ SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
857
+ });
858
+ }
859
+ windows.push({ shard, increment, window });
860
+ }
861
+
862
+ for (const { shard, increment, window } of windows) {
863
+ if (window) {
864
+ await ctx.db.patch("siteIngestWindows", window._id, {
865
+ eventCount: window.eventCount + increment.eventCount,
866
+ byteCount: window.byteCount + increment.byteCount,
867
+ updatedAt: now,
868
+ });
869
+ } else {
870
+ await ctx.db.insert("siteIngestWindows", {
871
+ siteId,
872
+ shard,
873
+ windowStart,
874
+ eventCount: increment.eventCount,
875
+ byteCount: increment.byteCount,
876
+ updatedAt: now,
877
+ });
878
+ }
879
+ }
880
+ return eventByteCounts;
881
+ }
882
+
883
+ async function consumeRateLimit(
884
+ ctx: MutationCtx,
885
+ siteId: Id<"sites">,
886
+ sessionId: string,
887
+ byteCount: number,
888
+ now: number,
889
+ ): Promise<boolean> {
890
+ const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
891
+ const window = await ctx.db
892
+ .query("ingestWindows")
893
+ .withIndex("by_siteId_and_sessionId_and_windowStart", (range) =>
894
+ range
895
+ .eq("siteId", siteId)
896
+ .eq("sessionId", sessionId)
897
+ .eq("windowStart", windowStart),
898
+ )
899
+ .unique();
900
+ if (
901
+ window &&
902
+ (window.eventCount >= MAX_EVENTS_PER_SESSION_WINDOW ||
903
+ window.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW)
904
+ ) {
905
+ return false;
906
+ }
907
+ if (window) {
908
+ await ctx.db.patch("ingestWindows", window._id, {
909
+ eventCount: window.eventCount + 1,
910
+ byteCount: window.byteCount + byteCount,
911
+ updatedAt: now,
912
+ });
913
+ } else {
914
+ await ctx.db.insert("ingestWindows", {
915
+ siteId,
916
+ sessionId,
917
+ windowStart,
918
+ eventCount: 1,
919
+ byteCount,
920
+ updatedAt: now,
921
+ });
922
+ }
923
+ return true;
924
+ }
925
+
926
+ async function updateLiveSession(
927
+ ctx: MutationCtx,
928
+ siteId: Id<"sites">,
929
+ event: TrackerEvent,
930
+ context: IngestContext,
931
+ session: SessionState,
932
+ now: number,
933
+ ) {
934
+ const existing = await ctx.db
935
+ .query("liveSessions")
936
+ .withIndex("by_siteId_and_sessionId", (range) =>
937
+ range.eq("siteId", siteId).eq("sessionId", event.sessionId),
938
+ )
939
+ .unique();
940
+ const renewsExpiry =
941
+ !existing ||
942
+ existing.expiresAt <= now ||
943
+ event.type === "pageview" ||
944
+ event.type === "heartbeat";
945
+ const deadline = renewsExpiry
946
+ ? now + LIVE_SESSION_TTL_MS
947
+ : existing.expiresAt;
948
+ if (existing) {
949
+ await ctx.db.patch("liveSessions", existing._id, {
950
+ visitorId: event.visitorId,
951
+ path: event.path,
952
+ lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
953
+ expiresAt: deadline,
954
+ ...(context.country ? { country: context.country } : {}),
955
+ ...(context.city ? { city: context.city } : {}),
956
+ ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
957
+ ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
958
+ browser: context.browser ?? existing.browser,
959
+ os: context.os ?? existing.os,
960
+ source: session.source,
961
+ device: context.device ?? existing.device,
962
+ pageviewCount: session.pageviewCount,
963
+ eventCount: session.eventCount,
964
+ ...(event.type === "heartbeat"
965
+ ? { lastHeartbeatEventId: event.eventId }
966
+ : {}),
967
+ });
968
+ return;
969
+ }
970
+
971
+ const liveSessionId = await ctx.db.insert("liveSessions", {
972
+ siteId,
973
+ sessionId: event.sessionId,
974
+ visitorId: event.visitorId,
975
+ path: event.path,
976
+ startedAt: event.timestamp,
977
+ lastSeenAt: event.timestamp,
978
+ expiresAt: deadline,
979
+ expiryChainDeadline: deadline,
980
+ ...(context.country ? { country: context.country } : {}),
981
+ ...(context.city ? { city: context.city } : {}),
982
+ ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
983
+ ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
984
+ browser: context.browser ?? "Other",
985
+ os: context.os ?? "Other",
986
+ source: session.source,
987
+ device: context.device ?? "unknown",
988
+ pageviewCount: session.pageviewCount,
989
+ eventCount: session.eventCount,
990
+ ...(event.type === "heartbeat"
991
+ ? { lastHeartbeatEventId: event.eventId }
992
+ : {}),
993
+ });
994
+ await ctx.scheduler.runAt(deadline, internal.live.expire, {
995
+ liveSessionId,
996
+ scheduledDeadline: deadline,
997
+ });
998
+ }
999
+
1000
+ function goalMatches(goal: Doc<"goals">, event: TrackerEvent): boolean {
1001
+ if (goal.matchType === "pageview") {
1002
+ return event.type === "pageview" && event.path === goal.matchValue;
1003
+ }
1004
+ return event.type === "custom" && event.name === goal.matchValue;
1005
+ }
1006
+
1007
+ async function advanceFunnel(
1008
+ ctx: MutationCtx,
1009
+ siteId: Id<"sites">,
1010
+ funnel: Doc<"funnels">,
1011
+ event: TrackerEvent,
1012
+ ) {
1013
+ const progress = await ctx.db
1014
+ .query("funnelProgress")
1015
+ .withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
1016
+ range
1017
+ .eq("siteId", siteId)
1018
+ .eq("funnelId", funnel._id)
1019
+ .eq("visitorId", event.visitorId),
1020
+ )
1021
+ .unique();
1022
+ const matchesFirst = stepMatches(funnel.steps[0], event);
1023
+ if (!progress) {
1024
+ if (!matchesFirst) return;
1025
+ await ctx.db.insert("funnelProgress", {
1026
+ siteId,
1027
+ funnelId: funnel._id,
1028
+ visitorId: event.visitorId,
1029
+ sessionId: event.sessionId,
1030
+ currentStep: 1,
1031
+ startedAt: event.timestamp,
1032
+ lastStepAt: event.timestamp,
1033
+ });
1034
+ await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1035
+ return;
1036
+ }
1037
+ if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt) return;
1038
+ if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
1039
+ if (!matchesFirst) return;
1040
+ await ctx.db.patch("funnelProgress", progress._id, {
1041
+ sessionId: event.sessionId,
1042
+ currentStep: 1,
1043
+ startedAt: event.timestamp,
1044
+ lastStepAt: event.timestamp,
1045
+ completedAt: undefined,
1046
+ });
1047
+ await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
1048
+ return;
1049
+ }
1050
+ const nextStep = funnel.steps[progress.currentStep];
1051
+ if (!nextStep || !stepMatches(nextStep, event)) return;
1052
+ const currentStep = progress.currentStep + 1;
1053
+ const completed = currentStep === funnel.steps.length;
1054
+ await ctx.db.patch("funnelProgress", progress._id, {
1055
+ sessionId: event.sessionId,
1056
+ currentStep,
1057
+ lastStepAt: event.timestamp,
1058
+ ...(completed ? { completedAt: event.timestamp } : {}),
1059
+ });
1060
+ await incrementFunnelStep(
1061
+ ctx,
1062
+ siteId,
1063
+ funnel._id,
1064
+ event,
1065
+ progress.currentStep,
1066
+ completed,
1067
+ );
1068
+ }
1069
+
1070
+ function stepMatches(
1071
+ step: { type: "pageview" | "custom"; value: string },
1072
+ event: TrackerEvent,
1073
+ ): boolean {
1074
+ return step.type === "pageview"
1075
+ ? event.type === "pageview" && event.path === step.value
1076
+ : event.type === "custom" && event.name === step.value;
1077
+ }
1078
+
1079
+ async function incrementFunnelStep(
1080
+ ctx: MutationCtx,
1081
+ siteId: Id<"sites">,
1082
+ funnelId: Id<"funnels">,
1083
+ event: TrackerEvent,
1084
+ step: number,
1085
+ completed: boolean,
1086
+ ) {
1087
+ const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1088
+ const shard = stableHash(event.visitorId) % AGGREGATE_SHARDS;
1089
+ const bucket = await ctx.db
1090
+ .query("funnelStepBuckets")
1091
+ .withIndex(
1092
+ "by_siteId_and_funnelId_and_bucketStart_and_step_and_shard",
1093
+ (range) =>
1094
+ range
1095
+ .eq("siteId", siteId)
1096
+ .eq("funnelId", funnelId)
1097
+ .eq("bucketStart", bucketStart)
1098
+ .eq("step", step)
1099
+ .eq("shard", shard),
1100
+ )
1101
+ .unique();
1102
+ if (bucket) {
1103
+ await ctx.db.patch("funnelStepBuckets", bucket._id, {
1104
+ entrants: bucket.entrants + 1,
1105
+ completions: bucket.completions + (completed ? 1 : 0),
1106
+ });
1107
+ } else {
1108
+ await ctx.db.insert("funnelStepBuckets", {
1109
+ siteId,
1110
+ funnelId,
1111
+ bucketStart,
1112
+ step,
1113
+ shard,
1114
+ entrants: 1,
1115
+ completions: completed ? 1 : 0,
1116
+ });
1117
+ }
1118
+ }
1119
+
1120
+ async function accountConversion(
1121
+ ctx: MutationCtx,
1122
+ siteId: Id<"sites">,
1123
+ session: SessionState,
1124
+ event: TrackerEvent,
1125
+ trustedGoalKey: string | undefined,
1126
+ ) {
1127
+ const revenueCents = event.revenueCents ?? 0;
1128
+ const currency = event.currency ?? "USD";
1129
+ const candidate = attributionSnapshot(session);
1130
+ const attribution =
1131
+ candidate && attributionApplies(candidate, event.timestamp)
1132
+ ? candidate
1133
+ : undefined;
1134
+ const affiliate = attribution
1135
+ ? await ctx.db.get("affiliates", attribution.affiliateId)
1136
+ : null;
1137
+ const commissionAffiliate =
1138
+ affiliate?.active && affiliate.siteId === siteId ? affiliate : null;
1139
+ const commissionCents = commissionAffiliate
1140
+ ? Math.round((revenueCents * commissionAffiliate.commissionBps) / 10_000)
1141
+ : 0;
1142
+ await ctx.db.insert("conversions", {
1143
+ siteId,
1144
+ eventId: event.eventId,
1145
+ sessionId: event.sessionId,
1146
+ visitorId: event.visitorId,
1147
+ timestamp: event.timestamp,
1148
+ path: event.path,
1149
+ revenueCents,
1150
+ currency,
1151
+ source: "trusted",
1152
+ ...(trustedGoalKey ? { goalKey: trustedGoalKey } : {}),
1153
+ ...(attribution ?? {}),
1154
+ commissionCents,
1155
+ });
1156
+ if (commissionAffiliate && attribution) {
1157
+ const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
1158
+ const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
1159
+ const stats = await ctx.db
1160
+ .query("affiliateDailyStats")
1161
+ .withIndex(
1162
+ "by_siteId_and_affiliateId_and_bucketStart_and_shard",
1163
+ (range) =>
1164
+ range
1165
+ .eq("siteId", siteId)
1166
+ .eq("affiliateId", commissionAffiliate._id)
1167
+ .eq("bucketStart", bucketStart)
1168
+ .eq("shard", shard),
1169
+ )
1170
+ .unique();
1171
+ if (stats) {
1172
+ await ctx.db.patch("affiliateDailyStats", stats._id, {
1173
+ conversions: stats.conversions + 1,
1174
+ revenueCents: stats.revenueCents + revenueCents,
1175
+ commissionCents: stats.commissionCents + commissionCents,
1176
+ });
1177
+ } else {
1178
+ await ctx.db.insert("affiliateDailyStats", {
1179
+ siteId,
1180
+ affiliateId: commissionAffiliate._id,
1181
+ affiliateSlug: attribution.affiliateSlug,
1182
+ bucketStart,
1183
+ shard,
1184
+ conversions: 1,
1185
+ revenueCents,
1186
+ commissionCents,
1187
+ });
1188
+ }
1189
+ }
1190
+ }
1191
+
1192
+ function foldAggregates(
1193
+ aggregateDeltas: AggregateDeltas,
1194
+ siteId: Id<"sites">,
1195
+ event: TrackerEvent,
1196
+ session: SessionState,
1197
+ dimensions: Dimension[],
1198
+ recordTelemetry: boolean,
1199
+ financialConversion: boolean,
1200
+ ) {
1201
+ const shard = stableHash(event.sessionId) % AGGREGATE_SHARDS;
1202
+ const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
1203
+ const dimensionDeltas = dimensions.map(
1204
+ (dimension): DimensionDelta => ({
1205
+ ...dimension,
1206
+ count: Number(recordTelemetry),
1207
+ revenueCents,
1208
+ }),
1209
+ );
1210
+ for (const granularity of ["hour", "day"] as const) {
1211
+ const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
1212
+ const bucketStart = Math.floor(event.timestamp / interval) * interval;
1213
+ const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
1214
+ let delta = aggregateDeltas.get(key);
1215
+ if (!delta) {
1216
+ delta = {
1217
+ siteId,
1218
+ granularity,
1219
+ bucketStart,
1220
+ shard,
1221
+ events: 0,
1222
+ pageviews: 0,
1223
+ clicks: 0,
1224
+ customEvents: 0,
1225
+ outboundClicks: 0,
1226
+ sessions: 0,
1227
+ visitors: 0,
1228
+ conversions: 0,
1229
+ revenueCents: 0,
1230
+ dimensions: [],
1231
+ };
1232
+ aggregateDeltas.set(key, delta);
1233
+ }
1234
+ const eventCount = Number(recordTelemetry);
1235
+ delta.events += eventCount;
1236
+ delta.pageviews += Number(recordTelemetry && event.type === "pageview");
1237
+ delta.clicks += Number(recordTelemetry && event.type === "click");
1238
+ delta.customEvents += Number(recordTelemetry && event.type === "custom");
1239
+ delta.outboundClicks += Number(
1240
+ recordTelemetry && event.type === "outbound",
1241
+ );
1242
+ delta.sessions += Number(recordTelemetry && session.isNewSession);
1243
+ delta.visitors += recordTelemetry
1244
+ ? granularity === "hour"
1245
+ ? Number(session.isNewVisitorHour)
1246
+ : Number(session.isNewVisitorDay)
1247
+ : 0;
1248
+ delta.conversions += Number(financialConversion);
1249
+ delta.revenueCents += revenueCents;
1250
+ delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
1251
+ }
1252
+ }
1253
+
1254
+ async function flushAggregates(
1255
+ ctx: MutationCtx,
1256
+ aggregateDeltas: AggregateDeltas,
1257
+ ) {
1258
+ for (const delta of aggregateDeltas.values()) {
1259
+ const bucket = await ctx.db
1260
+ .query("aggregateBuckets")
1261
+ .withIndex(
1262
+ "by_siteId_and_granularity_and_bucketStart_and_shard",
1263
+ (range) =>
1264
+ range
1265
+ .eq("siteId", delta.siteId)
1266
+ .eq("granularity", delta.granularity)
1267
+ .eq("bucketStart", delta.bucketStart)
1268
+ .eq("shard", delta.shard),
1269
+ )
1270
+ .unique();
1271
+ const dimensions = mergeDimensions(
1272
+ bucket?.dimensions ?? [],
1273
+ delta.dimensions,
1274
+ );
1275
+ if (bucket) {
1276
+ await ctx.db.patch("aggregateBuckets", bucket._id, {
1277
+ events: bucket.events + delta.events,
1278
+ pageviews: bucket.pageviews + delta.pageviews,
1279
+ clicks: bucket.clicks + delta.clicks,
1280
+ customEvents: bucket.customEvents + delta.customEvents,
1281
+ outboundClicks: bucket.outboundClicks + delta.outboundClicks,
1282
+ sessions: bucket.sessions + delta.sessions,
1283
+ visitors: bucket.visitors + delta.visitors,
1284
+ conversions: bucket.conversions + delta.conversions,
1285
+ revenueCents: bucket.revenueCents + delta.revenueCents,
1286
+ dimensions,
1287
+ });
1288
+ } else {
1289
+ await ctx.db.insert("aggregateBuckets", {
1290
+ siteId: delta.siteId,
1291
+ granularity: delta.granularity,
1292
+ bucketStart: delta.bucketStart,
1293
+ shard: delta.shard,
1294
+ events: delta.events,
1295
+ pageviews: delta.pageviews,
1296
+ clicks: delta.clicks,
1297
+ customEvents: delta.customEvents,
1298
+ outboundClicks: delta.outboundClicks,
1299
+ sessions: delta.sessions,
1300
+ visitors: delta.visitors,
1301
+ conversions: delta.conversions,
1302
+ revenueCents: delta.revenueCents,
1303
+ dimensions,
1304
+ });
1305
+ }
1306
+ }
1307
+ }
1308
+
1309
+ function mergeDimensions(
1310
+ current: Doc<"aggregateBuckets">["dimensions"],
1311
+ additions: DimensionDelta[],
1312
+ ): Doc<"aggregateBuckets">["dimensions"] {
1313
+ const result = current.map((slot) => ({ ...slot }));
1314
+ for (const addition of additions) {
1315
+ const exact = result.find(
1316
+ (candidate) =>
1317
+ candidate.type === addition.type && candidate.value === addition.value,
1318
+ );
1319
+ if (exact) {
1320
+ exact.count += addition.count;
1321
+ exact.revenueCents += addition.revenueCents;
1322
+ continue;
1323
+ }
1324
+
1325
+ const typeSlots = result.filter(
1326
+ (candidate) => candidate.type === addition.type,
1327
+ );
1328
+ if (typeSlots.length < DIMENSION_SLOTS) {
1329
+ const occupied = Array.from(
1330
+ { length: DIMENSION_SLOTS },
1331
+ () => false,
1332
+ );
1333
+ for (const candidate of typeSlots) occupied[candidate.slot] = true;
1334
+ let slot = stableHash(addition.value) % DIMENSION_SLOTS;
1335
+ while (occupied[slot]) slot = (slot + 1) % DIMENSION_SLOTS;
1336
+ result.push({
1337
+ type: addition.type,
1338
+ slot,
1339
+ value: addition.value,
1340
+ count: addition.count,
1341
+ revenueCents: addition.revenueCents,
1342
+ });
1343
+ continue;
1344
+ }
1345
+ if (addition.count === 0) continue;
1346
+
1347
+ let replacement = typeSlots[0];
1348
+ for (const candidate of typeSlots.slice(1)) {
1349
+ if (candidate.count < replacement.count) replacement = candidate;
1350
+ }
1351
+ replacement.value = addition.value;
1352
+ replacement.count += addition.count;
1353
+ replacement.revenueCents = addition.revenueCents;
1354
+ }
1355
+ return result;
1356
+ }
1357
+
1358
+ function stringProperty(
1359
+ properties: TrackerEvent["properties"],
1360
+ key: string,
1361
+ ): string | undefined {
1362
+ const value = properties?.[key];
1363
+ return typeof value === "string" ? cleanString(value, 120) || undefined : undefined;
1364
+ }