@iann29/rastro 0.1.0-alpha.4 → 0.1.0-alpha.6

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 (135) hide show
  1. package/README.md +203 -56
  2. package/agent/integration.md +17 -15
  3. package/agent/manifest.json +6 -4
  4. package/agent/manifest.schema.json +8 -5
  5. package/dist/client/federation.d.ts +34 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +17 -3
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +486 -31
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +209 -41
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +8 -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 +82 -8
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/affiliates.d.ts.map +1 -1
  19. package/dist/component/affiliates.js +6 -2
  20. package/dist/component/affiliates.js.map +1 -1
  21. package/dist/component/cardinality.d.ts.map +1 -1
  22. package/dist/component/cardinality.js.map +1 -1
  23. package/dist/component/constants.d.ts +6 -0
  24. package/dist/component/constants.d.ts.map +1 -1
  25. package/dist/component/constants.js +12 -0
  26. package/dist/component/constants.js.map +1 -1
  27. package/dist/component/diagnostics.d.ts.map +1 -1
  28. package/dist/component/diagnostics.js.map +1 -1
  29. package/dist/component/eventStore.d.ts +3 -1
  30. package/dist/component/eventStore.d.ts.map +1 -1
  31. package/dist/component/eventStore.js +13 -10
  32. package/dist/component/eventStore.js.map +1 -1
  33. package/dist/component/funnels.d.ts.map +1 -1
  34. package/dist/component/funnels.js +5 -3
  35. package/dist/component/funnels.js.map +1 -1
  36. package/dist/component/geo.d.ts.map +1 -1
  37. package/dist/component/geo.js +3 -2
  38. package/dist/component/geo.js.map +1 -1
  39. package/dist/component/goals.d.ts.map +1 -1
  40. package/dist/component/goals.js +8 -5
  41. package/dist/component/goals.js.map +1 -1
  42. package/dist/component/guards.d.ts.map +1 -1
  43. package/dist/component/guards.js.map +1 -1
  44. package/dist/component/http.d.ts.map +1 -1
  45. package/dist/component/http.js +71 -37
  46. package/dist/component/http.js.map +1 -1
  47. package/dist/component/identity.d.ts +13 -0
  48. package/dist/component/identity.d.ts.map +1 -0
  49. package/dist/component/identity.js +58 -0
  50. package/dist/component/identity.js.map +1 -0
  51. package/dist/component/ingest.d.ts +3 -1
  52. package/dist/component/ingest.d.ts.map +1 -1
  53. package/dist/component/ingest.js +168 -29
  54. package/dist/component/ingest.js.map +1 -1
  55. package/dist/component/live.d.ts.map +1 -1
  56. package/dist/component/live.js +1 -3
  57. package/dist/component/live.js.map +1 -1
  58. package/dist/component/reports.d.ts +70 -4
  59. package/dist/component/reports.d.ts.map +1 -1
  60. package/dist/component/reports.js +232 -59
  61. package/dist/component/reports.js.map +1 -1
  62. package/dist/component/retention.d.ts +4 -4
  63. package/dist/component/retention.d.ts.map +1 -1
  64. package/dist/component/retention.js +54 -22
  65. package/dist/component/retention.js.map +1 -1
  66. package/dist/component/sanitize.d.ts.map +1 -1
  67. package/dist/component/sanitize.js +42 -11
  68. package/dist/component/sanitize.js.map +1 -1
  69. package/dist/component/schema.d.ts +83 -10
  70. package/dist/component/schema.js +34 -1
  71. package/dist/component/schema.js.map +1 -1
  72. package/dist/component/sites.d.ts.map +1 -1
  73. package/dist/component/sites.js +6 -6
  74. package/dist/component/sites.js.map +1 -1
  75. package/dist/component/useragent.d.ts +9 -0
  76. package/dist/component/useragent.d.ts.map +1 -0
  77. package/dist/component/useragent.js +152 -0
  78. package/dist/component/useragent.js.map +1 -0
  79. package/dist/component/validators.d.ts +29 -18
  80. package/dist/component/validators.d.ts.map +1 -1
  81. package/dist/component/validators.js +8 -2
  82. package/dist/component/validators.js.map +1 -1
  83. package/dist/component/visitors.d.ts +19 -0
  84. package/dist/component/visitors.d.ts.map +1 -0
  85. package/dist/component/visitors.js +86 -0
  86. package/dist/component/visitors.js.map +1 -0
  87. package/dist/component/vitals.d.ts +41 -0
  88. package/dist/component/vitals.d.ts.map +1 -0
  89. package/dist/component/vitals.js +115 -0
  90. package/dist/component/vitals.js.map +1 -0
  91. package/dist/react/index.d.ts.map +1 -1
  92. package/dist/react/index.js.map +1 -1
  93. package/dist/tracker/generated.d.ts +8 -4
  94. package/dist/tracker/generated.d.ts.map +1 -1
  95. package/dist/tracker/generated.js +8 -4
  96. package/dist/tracker/generated.js.map +1 -1
  97. package/dist/tracker/tracker.d.ts.map +1 -1
  98. package/dist/tracker/tracker.js +23 -11
  99. package/dist/tracker/tracker.js.map +1 -1
  100. package/dist/tracker/vitals.d.ts +10 -0
  101. package/dist/tracker/vitals.d.ts.map +1 -0
  102. package/dist/tracker/vitals.js +137 -0
  103. package/dist/tracker/vitals.js.map +1 -0
  104. package/dist/tracker.min.js +1 -1
  105. package/dist/vitals.min.js +1 -0
  106. package/docs/benchmarks/2026-08-20-realistic.md +71 -71
  107. package/docs/benchmarks/2026-08-21-formal-certification.md +50 -30
  108. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  109. package/docs/federation.md +1 -1
  110. package/package.json +27 -9
  111. package/scripts/benchmark-ingest.mjs +101 -48
  112. package/src/component/_generated/api.ts +8 -0
  113. package/src/component/_generated/component.ts +87 -5
  114. package/src/component/affiliates.ts +20 -5
  115. package/src/component/cardinality.ts +8 -7
  116. package/src/component/constants.ts +12 -0
  117. package/src/component/diagnostics.ts +3 -2
  118. package/src/component/eventStore.ts +53 -46
  119. package/src/component/funnels.ts +19 -16
  120. package/src/component/geo.ts +16 -14
  121. package/src/component/goals.ts +29 -21
  122. package/src/component/guards.ts +3 -1
  123. package/src/component/http.ts +126 -78
  124. package/src/component/identity.ts +74 -0
  125. package/src/component/ingest.ts +349 -130
  126. package/src/component/live.ts +5 -4
  127. package/src/component/reports.ts +572 -221
  128. package/src/component/retention.ts +221 -98
  129. package/src/component/sanitize.ts +77 -27
  130. package/src/component/schema.ts +46 -5
  131. package/src/component/sites.ts +22 -11
  132. package/src/component/useragent.ts +171 -0
  133. package/src/component/validators.ts +19 -7
  134. package/src/component/visitors.ts +116 -0
  135. package/src/component/vitals.ts +146 -0
@@ -24,6 +24,7 @@ import {
24
24
  REPORT_ROLLUP_SHARDS,
25
25
  SECONDARY_AGGREGATE_SHARDS,
26
26
  SITE_INGEST_SHARDS,
27
+ VITAL_PAGE_SLOTS,
27
28
  } from "./constants.js";
28
29
  import {
29
30
  addVisitor,
@@ -31,6 +32,15 @@ import {
31
32
  unionVisitorSketches,
32
33
  type VisitorSketch,
33
34
  } from "./cardinality.js";
35
+ import {
36
+ addToVitalHistogram,
37
+ emptyVitalHistogram,
38
+ mergeVitalHistograms,
39
+ vitalDevice,
40
+ VITAL_ALL,
41
+ VITAL_OTHER_PAGES,
42
+ type VitalMetric,
43
+ } from "./vitals.js";
34
44
  import { fail } from "./errors.js";
35
45
  import { ensureAnalyticsControl } from "./coverage.js";
36
46
  import {
@@ -69,6 +79,7 @@ import {
69
79
 
70
80
  type DimensionType =
71
81
  | "source"
82
+ | "campaign"
72
83
  | "page"
73
84
  | "country"
74
85
  | "device"
@@ -110,11 +121,14 @@ type FunnelRollupDelta = {
110
121
  funnelId: Id<"funnels">;
111
122
  bucketStart: number;
112
123
  shard: number;
113
- steps: Map<number, {
114
- step: number;
115
- entrants: number;
116
- completions: number;
117
- }>;
124
+ steps: Map<
125
+ number,
126
+ {
127
+ step: number;
128
+ entrants: number;
129
+ completions: number;
130
+ }
131
+ >;
118
132
  };
119
133
  type AffiliateRollupDelta = {
120
134
  siteId: Id<"sites">;
@@ -128,10 +142,22 @@ type AffiliateRollupDelta = {
128
142
  revenueCents: number;
129
143
  commissionCents: number;
130
144
  };
145
+ type VitalRollupDelta = {
146
+ siteId: Id<"sites">;
147
+ bucketStart: number;
148
+ device: string;
149
+ page: string;
150
+ shard: number;
151
+ metrics: Map<
152
+ VitalMetric,
153
+ { count: number; sum: number; histogram: number[] }
154
+ >;
155
+ };
131
156
  type ReportRollupDeltas = {
132
157
  goals: Map<string, GoalRollupDelta>;
133
158
  funnels: Map<string, FunnelRollupDelta>;
134
159
  affiliates: Map<string, AffiliateRollupDelta>;
160
+ vitals: Map<string, VitalRollupDelta>;
135
161
  };
136
162
  type AttributionSnapshot = {
137
163
  affiliateId: Id<"affiliates">;
@@ -146,6 +172,8 @@ type SessionState = {
146
172
  isNewVisitorDay: boolean;
147
173
  startedAt: number;
148
174
  source: string;
175
+ visitorKey: string;
176
+ utmCampaign?: string;
149
177
  country?: string;
150
178
  city?: string;
151
179
  latitude?: number;
@@ -172,8 +200,9 @@ type CachedRow<T> = {
172
200
  dirty: boolean;
173
201
  };
174
202
 
175
- type CachedDocument<TableName extends keyof import("./_generated/dataModel.js").DataModel> =
176
- Omit<Doc<TableName>, "_creationTime"> & { _creationTime?: number };
203
+ type CachedDocument<
204
+ TableName extends keyof import("./_generated/dataModel.js").DataModel,
205
+ > = Omit<Doc<TableName>, "_creationTime"> & { _creationTime?: number };
177
206
 
178
207
  type BatchState = {
179
208
  sessions: Map<string, CachedRow<CachedDocument<"sessions">>>;
@@ -204,13 +233,20 @@ export const ingestBatch = mutation({
204
233
  });
205
234
  }
206
235
  if (args.events.length < 1 || args.events.length > MAX_BATCH_EVENTS) {
207
- fail("BATCH_TOO_LARGE", `events must contain 1-${MAX_BATCH_EVENTS} items`, {
208
- limit: MAX_BATCH_EVENTS,
209
- });
236
+ fail(
237
+ "BATCH_TOO_LARGE",
238
+ `events must contain 1-${MAX_BATCH_EVENTS} items`,
239
+ {
240
+ limit: MAX_BATCH_EVENTS,
241
+ },
242
+ );
210
243
  }
211
244
  let eventByteCounts: number[];
212
245
  try {
213
- ({ eventByteCounts } = measureBatchBytes(String(args.siteId), args.events));
246
+ ({ eventByteCounts } = measureBatchBytes(
247
+ String(args.siteId),
248
+ args.events,
249
+ ));
214
250
  } catch {
215
251
  fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
216
252
  }
@@ -261,7 +297,7 @@ export const ingestBatch = mutation({
261
297
  const bucketStart = eventBucketStart(event.timestamp);
262
298
  const groupKey = eventBatchKey(event.sessionId, bucketStart);
263
299
  requestGroups.add(groupKey);
264
- if (event.type === "heartbeat") continue;
300
+ if (event.type === "heartbeat" || event.type === "vital") continue;
265
301
  const eventIds = eventIdsByGroup.get(groupKey) ?? new Set<string>();
266
302
  if (eventIds.has(event.eventId)) {
267
303
  duplicateInBatch.add(eventIndex);
@@ -292,8 +328,8 @@ export const ingestBatch = mutation({
292
328
  sessionId: group.sessionId,
293
329
  bucketStart: group.bucketStart,
294
330
  });
295
- const newCount = [...group.eventIds].filter((eventId) =>
296
- !eventIds.has(eventId)
331
+ const newCount = [...group.eventIds].filter(
332
+ (eventId) => !eventIds.has(eventId),
297
333
  ).length;
298
334
  if (
299
335
  newCount > 0 &&
@@ -332,12 +368,7 @@ export const ingestBatch = mutation({
332
368
  continue;
333
369
  }
334
370
  const liveSession = (
335
- await loadLiveSession(
336
- ctx,
337
- args.siteId,
338
- event.sessionId,
339
- batchState,
340
- )
371
+ await loadLiveSession(ctx, args.siteId, event.sessionId, batchState)
341
372
  ).current;
342
373
  if (
343
374
  liveSession &&
@@ -373,10 +404,42 @@ export const ingestBatch = mutation({
373
404
  continue;
374
405
  }
375
406
 
407
+ if (event.type === "vital") {
408
+ // A vital annotates telemetry its session already produced; it never
409
+ // creates sessions, live presence, event batches, or aggregate counts.
410
+ if (!existingSession) {
411
+ rejected += 1;
412
+ continue;
413
+ }
414
+ if (
415
+ !(await consumeRateLimit(
416
+ ctx,
417
+ args.siteId,
418
+ event.sessionId,
419
+ eventByteCounts[eventIndex],
420
+ now,
421
+ sessionRateWindows,
422
+ ))
423
+ ) {
424
+ rejected += 1;
425
+ continue;
426
+ }
427
+ foldVitalRollups(
428
+ reportRollupDeltas,
429
+ args.siteId,
430
+ event,
431
+ vitalDevice(context.device),
432
+ );
433
+ accepted += 1;
434
+ continue;
435
+ }
436
+
376
437
  if (
377
438
  duplicateInBatch.has(eventIndex) ||
378
439
  persistedEventIds
379
- .get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
440
+ .get(
441
+ eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)),
442
+ )
380
443
  ?.has(event.eventId)
381
444
  ) {
382
445
  duplicates += 1;
@@ -415,8 +478,8 @@ export const ingestBatch = mutation({
415
478
  event,
416
479
  context,
417
480
  existingSession,
418
- goals: canMatchDefinition ? goals ?? [] : [],
419
- funnels: canMatchDefinition ? funnels ?? [] : [],
481
+ goals: canMatchDefinition ? (goals ?? []) : [],
482
+ funnels: canMatchDefinition ? (funnels ?? []) : [],
420
483
  aggregateDeltas,
421
484
  reportRollupDeltas,
422
485
  recordTelemetry: true,
@@ -448,7 +511,10 @@ export const trackConversion = mutation({
448
511
  const site = await ctx.db.get("sites", args.siteId);
449
512
  if (!site) fail("NOT_FOUND", "site not found");
450
513
  if (!isValidMoney(args.revenueCents)) {
451
- fail("INVALID_ARGUMENT", "revenueCents must be an integer between 0 and 1000000000");
514
+ fail(
515
+ "INVALID_ARGUMENT",
516
+ "revenueCents must be an integer between 0 and 1000000000",
517
+ );
452
518
  }
453
519
  let event: TrackerEvent;
454
520
  let trustedGoalKey: string | undefined;
@@ -473,7 +539,10 @@ export const trackConversion = mutation({
473
539
  Date.now(),
474
540
  );
475
541
  } catch (error) {
476
- fail("INVALID_ARGUMENT", error instanceof Error ? error.message : "invalid conversion");
542
+ fail(
543
+ "INVALID_ARGUMENT",
544
+ error instanceof Error ? error.message : "invalid conversion",
545
+ );
477
546
  }
478
547
 
479
548
  const siteCurrency = site.currency ?? "USD";
@@ -493,10 +562,14 @@ export const trackConversion = mutation({
493
562
  return { accepted: 0, duplicates: 1, rejected: 0 };
494
563
  }
495
564
  if (event.currency !== siteCurrency) {
496
- fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
497
- siteCurrency,
498
- conversionCurrency: event.currency ?? "USD",
499
- });
565
+ fail(
566
+ "CURRENCY_MISMATCH",
567
+ "conversion currency does not match site currency",
568
+ {
569
+ siteCurrency,
570
+ conversionCurrency: event.currency ?? "USD",
571
+ },
572
+ );
500
573
  }
501
574
 
502
575
  const telemetry = await findTelemetry(ctx, args.siteId, event);
@@ -656,11 +729,7 @@ async function flushBatchState(ctx: MutationCtx, batchState: BatchState) {
656
729
  function withoutSystemFields<
657
730
  T extends { _id: unknown; _creationTime?: number },
658
731
  >(document: T): Omit<T, "_id" | "_creationTime"> {
659
- const {
660
- _id,
661
- _creationTime,
662
- ...fields
663
- } = document;
732
+ const { _id, _creationTime, ...fields } = document;
664
733
  void _id;
665
734
  void _creationTime;
666
735
  return fields;
@@ -699,8 +768,8 @@ async function accountEvent(
699
768
  : await accountTrustedSession(ctx, input.existingSession, event);
700
769
 
701
770
  const matchedGoalKeys: string[] = [];
702
- const matchingGoals = input.goals.filter((goal) =>
703
- goal.active && goalMatches(goal, event)
771
+ const matchingGoals = input.goals.filter(
772
+ (goal) => goal.active && goalMatches(goal, event),
704
773
  );
705
774
  if (matchingGoals.length > 1) {
706
775
  fail("LIMIT_EXCEEDED", "active goals must use unique matchers");
@@ -711,33 +780,36 @@ async function accountEvent(
711
780
  eventBucketStart(event.timestamp),
712
781
  event.eventId,
713
782
  ]);
714
- const scopedPrior = (await ctx.db
715
- .query("goalCompletions")
716
- .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
717
- range
718
- .eq("siteId", input.siteId)
719
- .eq("goalId", goal._id)
720
- .eq("eventId", completionEventId),
721
- )
722
- .take(2))
723
- .find((completion) => completion.idempotencyVersion === 2);
783
+ const scopedPrior = (
784
+ await ctx.db
785
+ .query("goalCompletions")
786
+ .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
787
+ range
788
+ .eq("siteId", input.siteId)
789
+ .eq("goalId", goal._id)
790
+ .eq("eventId", completionEventId),
791
+ )
792
+ .take(2)
793
+ ).find((completion) => completion.idempotencyVersion === 2);
724
794
  const legacyPrior = scopedPrior
725
795
  ? undefined
726
- : (await ctx.db
727
- .query("goalCompletions")
728
- .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
729
- range
730
- .eq("siteId", input.siteId)
731
- .eq("goalId", goal._id)
732
- .eq("eventId", event.eventId),
733
- )
734
- .take(2))
735
- .find((completion) =>
796
+ : (
797
+ await ctx.db
798
+ .query("goalCompletions")
799
+ .withIndex("by_siteId_and_goalId_and_eventId", (range) =>
800
+ range
801
+ .eq("siteId", input.siteId)
802
+ .eq("goalId", goal._id)
803
+ .eq("eventId", event.eventId),
804
+ )
805
+ .take(2)
806
+ ).find(
807
+ (completion) =>
736
808
  completion.idempotencyVersion === undefined &&
737
809
  completion.sessionId === event.sessionId &&
738
810
  eventBucketStart(completion.timestamp) ===
739
- eventBucketStart(event.timestamp)
740
- );
811
+ eventBucketStart(event.timestamp),
812
+ );
741
813
  if (scopedPrior || legacyPrior) continue;
742
814
  await ctx.db.insert("goalCompletions", {
743
815
  siteId: input.siteId,
@@ -750,12 +822,7 @@ async function accountEvent(
750
822
  timestamp: event.timestamp,
751
823
  valueCents: goal.valueCents ?? 0,
752
824
  });
753
- foldGoalRollup(
754
- input.reportRollupDeltas,
755
- input.siteId,
756
- event,
757
- goal,
758
- );
825
+ foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
759
826
  matchedGoalKeys.push(goal.key);
760
827
  }
761
828
 
@@ -783,15 +850,19 @@ async function accountEvent(
783
850
  );
784
851
  }
785
852
 
853
+ const campaignDimensions: Dimension[] = session.utmCampaign
854
+ ? [{ type: "campaign", value: session.utmCampaign }]
855
+ : [];
786
856
  const dimensions: Dimension[] = [
787
857
  { type: "source", value: session.source },
858
+ ...campaignDimensions,
788
859
  { type: "page", value: event.path },
789
860
  { type: "country", value: session.country ?? "unknown" },
790
861
  { type: "device", value: input.context.device ?? "unknown" },
791
862
  { type: "browser", value: input.context.browser ?? "Other" },
792
863
  {
793
864
  type: "event",
794
- value: event.type === "custom" ? event.name ?? "custom" : event.type,
865
+ value: event.type === "custom" ? (event.name ?? "custom") : event.type,
795
866
  },
796
867
  ...matchedGoalKeys.map((value): Dimension => ({ type: "goal", value })),
797
868
  ];
@@ -842,6 +913,8 @@ async function accountTrustedSession(
842
913
  isNewVisitorDay: false,
843
914
  startedAt: existing.startedAt,
844
915
  source: existing.source,
916
+ utmCampaign: existing.utmCampaign,
917
+ visitorKey: existing.visitorKey ?? existing.visitorId,
845
918
  country: existing.country,
846
919
  city: existing.city,
847
920
  latitude: existing.latitude,
@@ -865,8 +938,16 @@ async function upsertSession(
865
938
  reportRollupDeltas: ReportRollupDeltas,
866
939
  batchState?: BatchState,
867
940
  ): Promise<SessionState> {
868
- const source = sourceFromReferrer(event.referrer);
941
+ // A host-supplied id is the identity; anonymous sessions count through the
942
+ // daily key derived by the HTTP action, or once per session without one.
943
+ const visitorKey =
944
+ existing?.visitorKey ??
945
+ (event.visitorId !== event.sessionId
946
+ ? event.visitorId
947
+ : (context.visitorKey ?? event.sessionId));
869
948
  const utmSource = stringProperty(event.properties, "utm_source");
949
+ // Campaign traffic names its own source; the referrer host is the fallback.
950
+ const source = utmSource?.toLowerCase() ?? sourceFromReferrer(event.referrer);
870
951
  const utmMedium = stringProperty(event.properties, "utm_medium");
871
952
  const utmCampaign = stringProperty(event.properties, "utm_campaign");
872
953
  const attribution = await attributionForEvent(
@@ -883,6 +964,7 @@ async function upsertSession(
883
964
  siteId,
884
965
  event,
885
966
  attribution,
967
+ visitorKey,
886
968
  );
887
969
  }
888
970
  const startedAt = Math.min(existing.startedAt, event.timestamp);
@@ -890,15 +972,18 @@ async function upsertSession(
890
972
  const updates = {
891
973
  startedAt,
892
974
  lastSeenAt,
893
- entryPath: event.timestamp < existing.startedAt ? event.path : existing.entryPath,
894
- exitPath: event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
975
+ entryPath:
976
+ event.timestamp < existing.startedAt ? event.path : existing.entryPath,
977
+ exitPath:
978
+ event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
895
979
  eventCount: existing.eventCount + 1,
896
- pageviewCount: existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
980
+ pageviewCount:
981
+ existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
897
982
  durationMs: Math.max(0, lastSeenAt - startedAt),
898
983
  conversionCount: existing.conversionCount + Number(financialConversion),
899
984
  revenueCents:
900
985
  existing.revenueCents +
901
- (financialConversion ? event.revenueCents ?? 0 : 0),
986
+ (financialConversion ? (event.revenueCents ?? 0) : 0),
902
987
  ...(financialConversion && event.currency
903
988
  ? { currency: event.currency }
904
989
  : {}),
@@ -909,7 +994,9 @@ async function upsertSession(
909
994
  ...(context.country ? { country: context.country } : {}),
910
995
  ...(context.city ? { city: context.city } : {}),
911
996
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
912
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
997
+ ...(context.longitude !== undefined
998
+ ? { longitude: context.longitude }
999
+ : {}),
913
1000
  browser: context.browser ?? existing.browser,
914
1001
  os: context.os ?? existing.os,
915
1002
  device: context.device ?? existing.device,
@@ -932,6 +1019,8 @@ async function upsertSession(
932
1019
  Math.floor(event.timestamp / DAY_MS),
933
1020
  startedAt,
934
1021
  source: existing.source,
1022
+ utmCampaign: existing.utmCampaign,
1023
+ visitorKey,
935
1024
  country: context.country ?? existing.country,
936
1025
  city: context.city ?? existing.city,
937
1026
  latitude: context.latitude ?? existing.latitude,
@@ -967,6 +1056,7 @@ async function upsertSession(
967
1056
  siteId,
968
1057
  sessionId: event.sessionId,
969
1058
  visitorId: event.visitorId,
1059
+ visitorKey,
970
1060
  startedAt: event.timestamp,
971
1061
  lastSeenAt: event.timestamp,
972
1062
  entryPath: event.path,
@@ -979,7 +1069,9 @@ async function upsertSession(
979
1069
  ...(context.country ? { country: context.country } : {}),
980
1070
  ...(context.city ? { city: context.city } : {}),
981
1071
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
982
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1072
+ ...(context.longitude !== undefined
1073
+ ? { longitude: context.longitude }
1074
+ : {}),
983
1075
  browser: context.browser ?? "Other",
984
1076
  os: context.os ?? "Other",
985
1077
  device: context.device ?? "unknown",
@@ -987,7 +1079,7 @@ async function upsertSession(
987
1079
  pageviewCount: event.type === "pageview" ? 1 : 0,
988
1080
  durationMs: 0,
989
1081
  conversionCount: Number(financialConversion),
990
- revenueCents: financialConversion ? event.revenueCents ?? 0 : 0,
1082
+ revenueCents: financialConversion ? (event.revenueCents ?? 0) : 0,
991
1083
  ...(financialConversion && event.currency
992
1084
  ? { currency: event.currency }
993
1085
  : {}),
@@ -1000,6 +1092,7 @@ async function upsertSession(
1000
1092
  siteId,
1001
1093
  event,
1002
1094
  attribution,
1095
+ visitorKey,
1003
1096
  );
1004
1097
  }
1005
1098
  if (batchState) {
@@ -1022,6 +1115,8 @@ async function upsertSession(
1022
1115
  Math.floor(event.timestamp / DAY_MS),
1023
1116
  startedAt: event.timestamp,
1024
1117
  source,
1118
+ utmCampaign,
1119
+ visitorKey,
1025
1120
  country: context.country,
1026
1121
  city: context.city,
1027
1122
  latitude: context.latitude,
@@ -1032,14 +1127,12 @@ async function upsertSession(
1032
1127
  };
1033
1128
  }
1034
1129
 
1035
- function attributionSnapshot(
1036
- value: {
1037
- affiliateId?: Id<"affiliates">;
1038
- affiliateSlug?: string;
1039
- affiliateAttributedAt?: number;
1040
- affiliateExpiresAt?: number;
1041
- },
1042
- ): AttributionSnapshot | undefined {
1130
+ function attributionSnapshot(value: {
1131
+ affiliateId?: Id<"affiliates">;
1132
+ affiliateSlug?: string;
1133
+ affiliateAttributedAt?: number;
1134
+ affiliateExpiresAt?: number;
1135
+ }): AttributionSnapshot | undefined {
1043
1136
  if (
1044
1137
  !value.affiliateId ||
1045
1138
  !value.affiliateSlug ||
@@ -1056,7 +1149,6 @@ function attributionSnapshot(
1056
1149
  };
1057
1150
  }
1058
1151
 
1059
-
1060
1152
  function attributionApplies(
1061
1153
  attribution: AttributionSnapshot,
1062
1154
  timestamp: number,
@@ -1187,14 +1279,15 @@ async function accountHeartbeat(
1187
1279
  const updates = {
1188
1280
  startedAt,
1189
1281
  lastSeenAt,
1190
- exitPath: event.timestamp >= session.lastSeenAt
1191
- ? event.path
1192
- : session.exitPath,
1282
+ exitPath:
1283
+ event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
1193
1284
  durationMs: Math.max(0, lastSeenAt - startedAt),
1194
1285
  ...(context.country ? { country: context.country } : {}),
1195
1286
  ...(context.city ? { city: context.city } : {}),
1196
1287
  ...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
1197
- ...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
1288
+ ...(context.longitude !== undefined
1289
+ ? { longitude: context.longitude }
1290
+ : {}),
1198
1291
  browser: context.browser ?? session.browser,
1199
1292
  os: context.os ?? session.os,
1200
1293
  device: context.device ?? session.device,
@@ -1218,6 +1311,7 @@ async function accountHeartbeat(
1218
1311
  isNewVisitorDay: false,
1219
1312
  startedAt,
1220
1313
  source: session.source,
1314
+ visitorKey: session.visitorKey ?? session.visitorId,
1221
1315
  country: context.country ?? session.country,
1222
1316
  city: context.city ?? session.city,
1223
1317
  latitude: context.latitude ?? session.latitude,
@@ -1240,7 +1334,10 @@ async function consumeSiteRateLimit(
1240
1334
  ): Promise<void> {
1241
1335
  const windowStart =
1242
1336
  Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1243
- const increments = new Map<number, { eventCount: number; byteCount: number }>();
1337
+ const increments = new Map<
1338
+ number,
1339
+ { eventCount: number; byteCount: number }
1340
+ >();
1244
1341
  for (let index = 0; index < events.length; index += 1) {
1245
1342
  const event = events[index];
1246
1343
  const byteCount = eventByteCounts[index];
@@ -1265,9 +1362,8 @@ async function consumeSiteRateLimit(
1265
1362
  .order("desc")
1266
1363
  .take(1);
1267
1364
  const storedWindow = rows[0] ?? null;
1268
- const window = storedWindow?.windowStart === windowStart
1269
- ? storedWindow
1270
- : null;
1365
+ const window =
1366
+ storedWindow?.windowStart === windowStart ? storedWindow : null;
1271
1367
  if (
1272
1368
  (window?.eventCount ?? 0) + increment.eventCount >
1273
1369
  MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
@@ -1279,10 +1375,8 @@ async function consumeSiteRateLimit(
1279
1375
  windowMs: RATE_LIMIT_WINDOW_MS,
1280
1376
  eventLimitPerShard: MAX_SITE_EVENTS_PER_SHARD_WINDOW,
1281
1377
  byteLimitPerShard: MAX_SITE_BYTES_PER_SHARD_WINDOW,
1282
- siteEventLimit:
1283
- SITE_INGEST_SHARDS * MAX_SITE_EVENTS_PER_SHARD_WINDOW,
1284
- siteByteLimit:
1285
- SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
1378
+ siteEventLimit: SITE_INGEST_SHARDS * MAX_SITE_EVENTS_PER_SHARD_WINDOW,
1379
+ siteByteLimit: SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
1286
1380
  });
1287
1381
  }
1288
1382
  windows.push({ shard, increment, window: storedWindow });
@@ -1323,7 +1417,8 @@ async function consumeRateLimit(
1323
1417
  now: number,
1324
1418
  states: Map<string, SessionRateWindowState>,
1325
1419
  ): Promise<boolean> {
1326
- const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1420
+ const windowStart =
1421
+ Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1327
1422
  let state = states.get(sessionId);
1328
1423
  if (!state) {
1329
1424
  const rows = await ctx.db
@@ -1361,7 +1456,8 @@ async function flushSessionRateLimits(
1361
1456
  states: Map<string, SessionRateWindowState>,
1362
1457
  now: number,
1363
1458
  ) {
1364
- const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1459
+ const windowStart =
1460
+ Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
1365
1461
  for (const state of states.values()) {
1366
1462
  if (!state.dirty) continue;
1367
1463
  if (state.window) {
@@ -1411,15 +1507,16 @@ async function updateLiveSession(
1411
1507
  const latitude = context.latitude ?? session.latitude;
1412
1508
  const longitude = context.longitude ?? session.longitude;
1413
1509
  if (existing) {
1414
- const recentHeartbeatEventIds = event.type === "heartbeat"
1415
- ? [
1416
- ...(existing.recentHeartbeatEventIds ??
1417
- (existing.lastHeartbeatEventId
1418
- ? [existing.lastHeartbeatEventId]
1419
- : [])),
1420
- event.eventId,
1421
- ].slice(-8)
1422
- : undefined;
1510
+ const recentHeartbeatEventIds =
1511
+ event.type === "heartbeat"
1512
+ ? [
1513
+ ...(existing.recentHeartbeatEventIds ??
1514
+ (existing.lastHeartbeatEventId
1515
+ ? [existing.lastHeartbeatEventId]
1516
+ : [])),
1517
+ event.eventId,
1518
+ ].slice(-8)
1519
+ : undefined;
1423
1520
  const updates = {
1424
1521
  visitorId: event.visitorId,
1425
1522
  path: event.path,
@@ -1576,7 +1673,11 @@ async function advanceFunnel(
1576
1673
  foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
1577
1674
  return;
1578
1675
  }
1579
- if (progress.completedAt !== undefined || event.timestamp < progress.lastStepAt) return;
1676
+ if (
1677
+ progress.completedAt !== undefined ||
1678
+ event.timestamp < progress.lastStepAt
1679
+ )
1680
+ return;
1580
1681
  if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
1581
1682
  if (!matchesFirst) return;
1582
1683
  const updated = {
@@ -1774,6 +1875,7 @@ function createReportRollupDeltas(): ReportRollupDeltas {
1774
1875
  goals: new Map(),
1775
1876
  funnels: new Map(),
1776
1877
  affiliates: new Map(),
1878
+ vitals: new Map(),
1777
1879
  };
1778
1880
  }
1779
1881
 
@@ -1855,6 +1957,7 @@ function foldAffiliateVisitRollup(
1855
1957
  siteId: Id<"sites">,
1856
1958
  event: TrackerEvent,
1857
1959
  attribution: AttributionSnapshot,
1960
+ visitorKey: string,
1858
1961
  ) {
1859
1962
  const coordinates = reportRollupCoordinates(
1860
1963
  siteId,
@@ -1875,7 +1978,7 @@ function foldAffiliateVisitRollup(
1875
1978
  commissionCents: 0,
1876
1979
  };
1877
1980
  current.visits += 1;
1878
- current.visitorSketch = addVisitor(current.visitorSketch, event.visitorId);
1981
+ current.visitorSketch = addVisitor(current.visitorSketch, visitorKey);
1879
1982
  deltas.affiliates.set(coordinates.key, current);
1880
1983
  }
1881
1984
 
@@ -1912,6 +2015,120 @@ function foldAffiliateConversionRollup(
1912
2015
  deltas.affiliates.set(coordinates.key, current);
1913
2016
  }
1914
2017
 
2018
+ function foldVitalRollups(
2019
+ deltas: ReportRollupDeltas,
2020
+ siteId: Id<"sites">,
2021
+ event: TrackerEvent,
2022
+ device: string,
2023
+ ) {
2024
+ const metric = event.name as VitalMetric;
2025
+ const value = event.value ?? 0;
2026
+ const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
2027
+ const shard = stableHash(event.sessionId) % REPORT_ROLLUP_SHARDS;
2028
+ // Each measurement lands twice: on its page (across devices) and on its
2029
+ // device class (across pages), so both breakdowns stay exact and bounded.
2030
+ for (const target of [
2031
+ { device: VITAL_ALL, page: event.path },
2032
+ { device, page: VITAL_ALL },
2033
+ ]) {
2034
+ const key = JSON.stringify([
2035
+ siteId,
2036
+ bucketStart,
2037
+ target.device,
2038
+ target.page,
2039
+ shard,
2040
+ ]);
2041
+ const row = deltas.vitals.get(key) ?? {
2042
+ siteId,
2043
+ bucketStart,
2044
+ device: target.device,
2045
+ page: target.page,
2046
+ shard,
2047
+ metrics: new Map<
2048
+ VitalMetric,
2049
+ { count: number; sum: number; histogram: number[] }
2050
+ >(),
2051
+ };
2052
+ const entry = row.metrics.get(metric) ?? {
2053
+ count: 0,
2054
+ sum: 0,
2055
+ histogram: emptyVitalHistogram(),
2056
+ };
2057
+ entry.count += 1;
2058
+ entry.sum += value;
2059
+ entry.histogram = addToVitalHistogram(entry.histogram, value);
2060
+ row.metrics.set(metric, entry);
2061
+ deltas.vitals.set(key, row);
2062
+ }
2063
+ }
2064
+
2065
+ async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
2066
+ const existing = await ctx.db
2067
+ .query("vitalRollups")
2068
+ .withIndex("by_key", (range) =>
2069
+ range
2070
+ .eq("siteId", delta.siteId)
2071
+ .eq("bucketStart", delta.bucketStart)
2072
+ .eq("device", delta.device)
2073
+ .eq("page", delta.page)
2074
+ .eq("shard", delta.shard),
2075
+ )
2076
+ .unique();
2077
+ if (
2078
+ !existing &&
2079
+ delta.device === VITAL_ALL &&
2080
+ delta.page !== VITAL_OTHER_PAGES
2081
+ ) {
2082
+ // A new page row spends one bounded read to respect the daily page slots;
2083
+ // past the cap, the day's remaining pages fold into "(other)".
2084
+ const pageRows = await ctx.db
2085
+ .query("vitalRollups")
2086
+ .withIndex("by_key", (range) =>
2087
+ range
2088
+ .eq("siteId", delta.siteId)
2089
+ .eq("bucketStart", delta.bucketStart)
2090
+ .eq("device", VITAL_ALL),
2091
+ )
2092
+ .take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
2093
+ if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
2094
+ await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
2095
+ return;
2096
+ }
2097
+ }
2098
+ const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
2099
+ for (const [metric, addition] of delta.metrics) {
2100
+ const entry = metrics.find((candidate) => candidate.metric === metric);
2101
+ if (entry) {
2102
+ entry.count += addition.count;
2103
+ entry.sum += addition.sum;
2104
+ entry.histogram = mergeVitalHistograms(
2105
+ entry.histogram,
2106
+ addition.histogram,
2107
+ );
2108
+ } else {
2109
+ metrics.push({
2110
+ metric,
2111
+ count: addition.count,
2112
+ sum: addition.sum,
2113
+ histogram: [...addition.histogram],
2114
+ });
2115
+ }
2116
+ }
2117
+ const fields = {
2118
+ siteId: delta.siteId,
2119
+ bucketStart: delta.bucketStart,
2120
+ device: delta.device,
2121
+ page: delta.page,
2122
+ shard: delta.shard,
2123
+ metrics,
2124
+ };
2125
+ if (existing) {
2126
+ await ctx.db.replace("vitalRollups", existing._id, fields);
2127
+ } else {
2128
+ await ctx.db.insert("vitalRollups", fields);
2129
+ }
2130
+ }
2131
+
1915
2132
  async function flushReportRollups(
1916
2133
  ctx: MutationCtx,
1917
2134
  deltas: ReportRollupDeltas,
@@ -1984,12 +2201,14 @@ async function flushReportRollups(
1984
2201
  for (const delta of deltas.affiliates.values()) {
1985
2202
  const existing = await ctx.db
1986
2203
  .query("affiliateDailyRollups")
1987
- .withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) =>
1988
- range
1989
- .eq("siteId", delta.siteId)
1990
- .eq("affiliateId", delta.affiliateId)
1991
- .eq("bucketStart", delta.bucketStart)
1992
- .eq("shard", delta.shard),
2204
+ .withIndex(
2205
+ "by_siteId_and_affiliateId_and_bucketStart_and_shard",
2206
+ (range) =>
2207
+ range
2208
+ .eq("siteId", delta.siteId)
2209
+ .eq("affiliateId", delta.affiliateId)
2210
+ .eq("bucketStart", delta.bucketStart)
2211
+ .eq("shard", delta.shard),
1993
2212
  )
1994
2213
  .unique();
1995
2214
  const fields = {
@@ -2006,8 +2225,7 @@ async function flushReportRollups(
2006
2225
  ),
2007
2226
  conversions: (existing?.conversions ?? 0) + delta.conversions,
2008
2227
  revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
2009
- commissionCents:
2010
- (existing?.commissionCents ?? 0) + delta.commissionCents,
2228
+ commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
2011
2229
  };
2012
2230
  if (existing) {
2013
2231
  await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
@@ -2015,6 +2233,9 @@ async function flushReportRollups(
2015
2233
  await ctx.db.insert("affiliateDailyRollups", fields);
2016
2234
  }
2017
2235
  }
2236
+ for (const delta of deltas.vitals.values()) {
2237
+ await upsertVitalRollup(ctx, delta);
2238
+ }
2018
2239
  }
2019
2240
 
2020
2241
  function foldAggregates(
@@ -2026,16 +2247,15 @@ function foldAggregates(
2026
2247
  recordTelemetry: boolean,
2027
2248
  financialConversion: boolean,
2028
2249
  ) {
2029
- const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
2030
- const dimensionDeltas = dimensions.map(
2031
- (dimension): DimensionDelta => ({
2032
- ...dimension,
2033
- count: Number(recordTelemetry),
2034
- revenueCents,
2035
- }),
2036
- );
2250
+ const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
2251
+ const dimensionDeltas = dimensions.map((dimension): DimensionDelta => ({
2252
+ ...dimension,
2253
+ count: Number(recordTelemetry),
2254
+ revenueCents,
2255
+ }));
2037
2256
  for (const granularity of ["hour", "day"] as const) {
2038
- const shard = stableHash(event.sessionId) %
2257
+ const shard =
2258
+ stableHash(event.sessionId) %
2039
2259
  (granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
2040
2260
  const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
2041
2261
  const bucketStart = Math.floor(event.timestamp / interval) * interval;
@@ -2076,7 +2296,7 @@ function foldAggregates(
2076
2296
  : Number(session.isNewVisitorDay)
2077
2297
  : 0;
2078
2298
  if (recordTelemetry) {
2079
- delta.visitorSketch = addVisitor(delta.visitorSketch, event.visitorId);
2299
+ delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
2080
2300
  }
2081
2301
  delta.conversions += Number(financialConversion);
2082
2302
  delta.revenueCents += revenueCents;
@@ -2170,10 +2390,7 @@ function mergeDimensions(
2170
2390
  (candidate) => candidate.type === addition.type,
2171
2391
  );
2172
2392
  if (typeSlots.length < DIMENSION_SLOTS - 1) {
2173
- const occupied = Array.from(
2174
- { length: DIMENSION_SLOTS },
2175
- () => false,
2176
- );
2393
+ const occupied = Array.from({ length: DIMENSION_SLOTS }, () => false);
2177
2394
  for (const candidate of typeSlots) occupied[candidate.slot] = true;
2178
2395
  let slot = stableHash(addition.value) % DIMENSION_SLOTS;
2179
2396
  while (occupied[slot]) slot = (slot + 1) % DIMENSION_SLOTS;
@@ -2225,5 +2442,7 @@ function stringProperty(
2225
2442
  key: string,
2226
2443
  ): string | undefined {
2227
2444
  const value = properties?.[key];
2228
- return typeof value === "string" ? cleanString(value, 120) || undefined : undefined;
2445
+ return typeof value === "string"
2446
+ ? cleanString(value, 120) || undefined
2447
+ : undefined;
2229
2448
  }