@iann29/rastro 0.1.0-alpha.1 → 0.1.0-alpha.11

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 (179) hide show
  1. package/README.md +693 -69
  2. package/agent/integration.md +801 -0
  3. package/agent/manifest.json +205 -0
  4. package/agent/manifest.schema.json +444 -0
  5. package/dist/client/federation.d.ts +381 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +274 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +2673 -15
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +565 -33
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +22 -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 +315 -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 +28 -1
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +44 -1
  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 +27 -0
  36. package/dist/component/coverage.d.ts.map +1 -0
  37. package/dist/component/coverage.js +56 -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 +21 -9
  47. package/dist/component/eventStore.d.ts.map +1 -1
  48. package/dist/component/eventStore.js +142 -152
  49. package/dist/component/eventStore.js.map +1 -1
  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 +73 -0
  54. package/dist/component/geo.d.ts.map +1 -0
  55. package/dist/component/geo.js +648 -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 +8 -5
  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 +281 -62
  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 +498 -95
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts.map +1 -1
  74. package/dist/component/live.js +7 -6
  75. package/dist/component/live.js.map +1 -1
  76. package/dist/component/localTime.d.ts +25 -0
  77. package/dist/component/localTime.d.ts.map +1 -0
  78. package/dist/component/localTime.js +126 -0
  79. package/dist/component/localTime.js.map +1 -0
  80. package/dist/component/reports.d.ts +266 -10
  81. package/dist/component/reports.d.ts.map +1 -1
  82. package/dist/component/reports.js +1243 -137
  83. package/dist/component/reports.js.map +1 -1
  84. package/dist/component/retention.d.ts +75 -1
  85. package/dist/component/retention.d.ts.map +1 -1
  86. package/dist/component/retention.js +561 -38
  87. package/dist/component/retention.js.map +1 -1
  88. package/dist/component/rollupStore.d.ts +320 -0
  89. package/dist/component/rollupStore.d.ts.map +1 -0
  90. package/dist/component/rollupStore.js +596 -0
  91. package/dist/component/rollupStore.js.map +1 -0
  92. package/dist/component/rollups.d.ts +20 -0
  93. package/dist/component/rollups.d.ts.map +1 -0
  94. package/dist/component/rollups.js +73 -0
  95. package/dist/component/rollups.js.map +1 -0
  96. package/dist/component/sanitize.d.ts +24 -1
  97. package/dist/component/sanitize.d.ts.map +1 -1
  98. package/dist/component/sanitize.js +96 -16
  99. package/dist/component/sanitize.js.map +1 -1
  100. package/dist/component/schema.d.ts +687 -65
  101. package/dist/component/schema.js +187 -20
  102. package/dist/component/schema.js.map +1 -1
  103. package/dist/component/sites.d.ts +12 -0
  104. package/dist/component/sites.d.ts.map +1 -1
  105. package/dist/component/sites.js +41 -7
  106. package/dist/component/sites.js.map +1 -1
  107. package/dist/component/useragent.d.ts +9 -0
  108. package/dist/component/useragent.d.ts.map +1 -0
  109. package/dist/component/useragent.js +152 -0
  110. package/dist/component/useragent.js.map +1 -0
  111. package/dist/component/validators.d.ts +124 -69
  112. package/dist/component/validators.d.ts.map +1 -1
  113. package/dist/component/validators.js +35 -14
  114. package/dist/component/validators.js.map +1 -1
  115. package/dist/component/visitors.d.ts +19 -0
  116. package/dist/component/visitors.d.ts.map +1 -0
  117. package/dist/component/visitors.js +86 -0
  118. package/dist/component/visitors.js.map +1 -0
  119. package/dist/component/vitals.d.ts +41 -0
  120. package/dist/component/vitals.d.ts.map +1 -0
  121. package/dist/component/vitals.js +115 -0
  122. package/dist/component/vitals.js.map +1 -0
  123. package/dist/react/index.d.ts.map +1 -1
  124. package/dist/react/index.js.map +1 -1
  125. package/dist/tracker/generated.d.ts +11 -4
  126. package/dist/tracker/generated.d.ts.map +1 -1
  127. package/dist/tracker/generated.js +11 -4
  128. package/dist/tracker/generated.js.map +1 -1
  129. package/dist/tracker/tracker.d.ts +1 -1
  130. package/dist/tracker/tracker.d.ts.map +1 -1
  131. package/dist/tracker/tracker.js +57 -20
  132. package/dist/tracker/tracker.js.map +1 -1
  133. package/dist/tracker/vitals.d.ts +10 -0
  134. package/dist/tracker/vitals.d.ts.map +1 -0
  135. package/dist/tracker/vitals.js +140 -0
  136. package/dist/tracker/vitals.js.map +1 -0
  137. package/dist/tracker.min.js +1 -1
  138. package/dist/vitals.min.js +1 -0
  139. package/docs/benchmarks/2026-08-20-realistic.md +76 -76
  140. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  141. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  142. package/docs/federation-setup.md +464 -0
  143. package/docs/federation.md +352 -0
  144. package/docs/upgrading.md +344 -0
  145. package/llms.txt +72 -0
  146. package/package.json +55 -11
  147. package/scripts/benchmark-ingest.mjs +175 -73
  148. package/scripts/generate-federation-keys.mjs +20 -0
  149. package/src/component/_generated/api.ts +22 -0
  150. package/src/component/_generated/component.ts +381 -4
  151. package/src/component/_generated/server.ts +4 -0
  152. package/src/component/affiliates.ts +20 -5
  153. package/src/component/cardinality.ts +117 -0
  154. package/src/component/constants.ts +44 -1
  155. package/src/component/convex.config.ts +11 -1
  156. package/src/component/coverage.ts +71 -0
  157. package/src/component/diagnostics.ts +65 -0
  158. package/src/component/errors.ts +2 -1
  159. package/src/component/eventStore.ts +217 -193
  160. package/src/component/funnels.ts +19 -16
  161. package/src/component/geo.ts +835 -0
  162. package/src/component/goals.ts +29 -21
  163. package/src/component/guards.ts +3 -1
  164. package/src/component/http.ts +404 -70
  165. package/src/component/identity.ts +74 -0
  166. package/src/component/ingest.ts +894 -188
  167. package/src/component/live.ts +13 -7
  168. package/src/component/localTime.ts +167 -0
  169. package/src/component/reports.ts +1872 -197
  170. package/src/component/retention.ts +788 -96
  171. package/src/component/rollupStore.ts +799 -0
  172. package/src/component/rollups.ts +82 -0
  173. package/src/component/sanitize.ts +144 -29
  174. package/src/component/schema.ts +217 -21
  175. package/src/component/sites.ts +59 -12
  176. package/src/component/useragent.ts +171 -0
  177. package/src/component/validators.ts +49 -14
  178. package/src/component/visitors.ts +116 -0
  179. package/src/component/vitals.ts +146 -0
@@ -1,7 +1,10 @@
1
1
  import { v } from "convex/values";
2
2
  import { internalQuery, mutation, query } from "./_generated/server.js";
3
3
  import { MAX_SITES_PER_OWNER } from "./constants.js";
4
+ import { ensureAnalyticsControl } from "./coverage.js";
4
5
  import { fail } from "./errors.js";
6
+ import { resetLocalDayCoverage } from "./coverage.js";
7
+ import { localDayTimezone } from "./localTime.js";
5
8
  import {
6
9
  cleanString,
7
10
  normalizeDomains,
@@ -31,7 +34,8 @@ export const create = mutation({
31
34
  handler: async (ctx, args) => {
32
35
  const ownerId = cleanString(args.ownerId, 128);
33
36
  const name = cleanString(args.name, 120);
34
- if (!ownerId || !name) fail("INVALID_SITE", "ownerId and name are required");
37
+ if (!ownerId || !name)
38
+ fail("INVALID_SITE", "ownerId and name are required");
35
39
 
36
40
  let domains: string[];
37
41
  let timezone: string | undefined;
@@ -45,7 +49,10 @@ export const create = mutation({
45
49
  : undefined;
46
50
  currency = sanitizeCurrency(args.currency ?? "USD");
47
51
  } catch (error) {
48
- fail("INVALID_SITE", error instanceof Error ? error.message : "invalid site");
52
+ fail(
53
+ "INVALID_SITE",
54
+ error instanceof Error ? error.message : "invalid site",
55
+ );
49
56
  }
50
57
  const cookieless = args.cookieless ?? true;
51
58
 
@@ -64,8 +71,12 @@ export const create = mutation({
64
71
  (existing.currency ?? "USD") !== currency ||
65
72
  existing.cookieless !== cookieless
66
73
  ) {
67
- fail("CONFLICT", "site name is already associated with different settings");
74
+ fail(
75
+ "CONFLICT",
76
+ "site name is already associated with different settings",
77
+ );
68
78
  }
79
+ await ensureAnalyticsControl(ctx, existing._id, Date.now());
69
80
  return existing._id;
70
81
  }
71
82
 
@@ -80,7 +91,7 @@ export const create = mutation({
80
91
  }
81
92
 
82
93
  const now = Date.now();
83
- return await ctx.db.insert("sites", {
94
+ const siteId = await ctx.db.insert("sites", {
84
95
  ownerId,
85
96
  name,
86
97
  domains,
@@ -91,6 +102,8 @@ export const create = mutation({
91
102
  createdAt: now,
92
103
  updatedAt: now,
93
104
  });
105
+ await ensureAnalyticsControl(ctx, siteId, now);
106
+ return siteId;
94
107
  },
95
108
  });
96
109
 
@@ -133,22 +146,26 @@ export const update = mutation({
133
146
  patch.name = name;
134
147
  }
135
148
  try {
136
- if (args.domains !== undefined) patch.domains = normalizeDomains(args.domains);
149
+ if (args.domains !== undefined)
150
+ patch.domains = normalizeDomains(args.domains);
137
151
  if (args.timezone !== undefined) {
138
- patch.timezone = args.timezone === null
139
- ? undefined
140
- : validateTimezone(args.timezone);
152
+ patch.timezone =
153
+ args.timezone === null ? undefined : validateTimezone(args.timezone);
141
154
  }
142
155
  if (args.networkId !== undefined) {
143
- patch.networkId = args.networkId === null
144
- ? undefined
145
- : sanitizeOpaqueId(args.networkId, "networkId");
156
+ patch.networkId =
157
+ args.networkId === null
158
+ ? undefined
159
+ : sanitizeOpaqueId(args.networkId, "networkId");
146
160
  }
147
161
  if (args.currency !== undefined) {
148
162
  patch.currency = sanitizeCurrency(args.currency);
149
163
  }
150
164
  } catch (error) {
151
- fail("INVALID_SITE", error instanceof Error ? error.message : "invalid site");
165
+ fail(
166
+ "INVALID_SITE",
167
+ error instanceof Error ? error.message : "invalid site",
168
+ );
152
169
  }
153
170
  if (
154
171
  patch.currency !== undefined &&
@@ -158,6 +175,9 @@ export const update = mutation({
158
175
  }
159
176
  if (args.cookieless !== undefined) patch.cookieless = args.cookieless;
160
177
  await ctx.db.patch("sites", args.siteId, patch);
178
+ if (args.timezone !== undefined && site.timezone !== patch.timezone) {
179
+ await resetLocalDayCoverage(ctx, args.siteId, patch.updatedAt);
180
+ }
161
181
  return null;
162
182
  },
163
183
  });
@@ -170,6 +190,33 @@ export const get = query({
170
190
  },
171
191
  });
172
192
 
193
+ /**
194
+ * The calendar days a site's daily overview can answer in: the timezone its
195
+ * local-day buckets follow and the instant from which they are complete, or
196
+ * null when the site keeps none (no timezone, or a zone that never leaves
197
+ * UTC, whose days are the UTC days already kept).
198
+ */
199
+ export const localDays = query({
200
+ args: { siteId: v.id("sites") },
201
+ returns: v.union(
202
+ v.object({ timezone: v.string(), since: v.number() }),
203
+ v.null(),
204
+ ),
205
+ handler: async (ctx, args) => {
206
+ const site = await ctx.db.get("sites", args.siteId);
207
+ const timezone = localDayTimezone(site?.timezone);
208
+ if (!timezone) return null;
209
+ const control = await ctx.db
210
+ .query("analyticsCoverage")
211
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
212
+ .unique();
213
+ // A site that has not ingested since the upgrade has no stamp yet; its
214
+ // days begin with its first batch, which is later than now.
215
+ if (control?.localDaySince === undefined) return null;
216
+ return { timezone, since: control.localDaySince };
217
+ },
218
+ });
219
+
173
220
  export const listByOwner = query({
174
221
  args: {
175
222
  ownerId: v.string(),
@@ -0,0 +1,171 @@
1
+ export type ClientClassification = {
2
+ browser: string;
3
+ os: string;
4
+ device: string;
5
+ };
6
+
7
+ // The tracker never reports the client: everything here is derived from headers
8
+ // the browser sets itself. Chromium sends the low-entropy `Sec-CH-UA` hints on
9
+ // cross-origin requests without an `Accept-CH` opt-in, which is the only way to
10
+ // tell Brave apart from Chrome — Brave deliberately ships Chrome's User-Agent.
11
+ // Firefox and Safari implement no client hints, so the User-Agent stays the
12
+ // fallback for them and for insecure origins, where no hint is sent at all.
13
+ const MAX_HINT_LENGTH = 512;
14
+ const MAX_HINT_BRANDS = 16;
15
+
16
+ const BRAND_PATTERN =
17
+ /"((?:[^"\\]|\\.)*)"(?:\s*;\s*v\s*=\s*"(?:[^"\\]|\\.)*")?/g;
18
+ const PLATFORM_PATTERN = /^\s*"((?:[^"\\]|\\.)*)"\s*$/;
19
+
20
+ // `Sec-CH-UA` is attacker-controlled on a public ingestion route, so brands are
21
+ // allowlisted: anything unrecognized (including the GREASE entries Chromium
22
+ // injects, such as `Not.A/Brand`) falls back to the User-Agent instead of
23
+ // entering the browser dimension verbatim.
24
+ const HINT_BRANDS = new Map<string, string>([
25
+ ["brave", "Brave"],
26
+ ["chromium", "Chromium"],
27
+ ["google chrome", "Chrome"],
28
+ ["microsoft edge", "Edge"],
29
+ ["opera", "Opera"],
30
+ ["opera gx", "Opera"],
31
+ ["samsung internet", "Samsung Internet"],
32
+ ["vivaldi", "Vivaldi"],
33
+ ["yandex", "Yandex"],
34
+ ]);
35
+
36
+ // Every Chromium brand list contains "Chromium", and Chrome adds "Google
37
+ // Chrome" beside it; both only win when no fork identifies itself.
38
+ const GENERIC_BRANDS = new Set(["Chrome", "Chromium"]);
39
+
40
+ const HINT_PLATFORMS = new Map<string, string>([
41
+ ["android", "Android"],
42
+ ["chrome os", "ChromeOS"],
43
+ ["chromium os", "ChromeOS"],
44
+ ["ios", "iOS"],
45
+ ["linux", "Linux"],
46
+ ["macos", "macOS"],
47
+ ["windows", "Windows"],
48
+ ]);
49
+
50
+ // Ordered: every Chromium fork carries `Chrome/`, and every Chromium and Gecko
51
+ // browser on iOS carries `Safari/`, so the specific tokens must be tested
52
+ // first. Brave has no token at all and is only detectable through the hints.
53
+ const AGENT_BROWSERS: readonly (readonly [RegExp, string])[] = [
54
+ [/Edg(?:A|iOS)?\//, "Edge"],
55
+ [/(?:Firefox|FxiOS)\//, "Firefox"],
56
+ [/OPR\//, "Opera"],
57
+ [/SamsungBrowser\//, "Samsung Internet"],
58
+ [/Vivaldi\//, "Vivaldi"],
59
+ [/YaBrowser\//, "Yandex"],
60
+ [/(?:Chrome|CriOS)\//, "Chrome"],
61
+ [/Safari\//, "Safari"],
62
+ ];
63
+
64
+ // Ordered: Android and ChromeOS user agents also carry the `Linux` token.
65
+ const AGENT_PLATFORMS: readonly (readonly [RegExp, string])[] = [
66
+ [/Android/, "Android"],
67
+ [/iPhone|iPad|iPod/, "iOS"],
68
+ [/CrOS/, "ChromeOS"],
69
+ [/Windows/, "Windows"],
70
+ [/Mac OS X/, "macOS"],
71
+ [/Linux/, "Linux"],
72
+ ];
73
+
74
+ // Ordered: tablet user agents that carry `Android` must not be read as mobile.
75
+ const AGENT_DEVICES: readonly (readonly [RegExp, string])[] = [
76
+ [/iPad|Tablet/, "tablet"],
77
+ [/Mobile|Android|iPhone/, "mobile"],
78
+ ];
79
+
80
+ export function classifyClient(
81
+ userAgent: string,
82
+ headers: Headers,
83
+ ): ClientClassification {
84
+ const agent = classifyUserAgent(userAgent);
85
+ const brand = brandFromClientHints(headers.get("sec-ch-ua"));
86
+ const platform = platformFromClientHints(headers.get("sec-ch-ua-platform"));
87
+ const mobile = mobileFromClientHints(headers.get("sec-ch-ua-mobile"));
88
+ return {
89
+ browser: brand ?? agent.browser,
90
+ os: platform ?? agent.os,
91
+ device: deviceFromClientHints(mobile, platform) ?? agent.device,
92
+ };
93
+ }
94
+
95
+ export function classifyUserAgent(userAgent: string): ClientClassification {
96
+ return {
97
+ browser: matchAgent(AGENT_BROWSERS, userAgent) ?? "Other",
98
+ os: matchAgent(AGENT_PLATFORMS, userAgent) ?? "Other",
99
+ device: matchAgent(AGENT_DEVICES, userAgent) ?? "desktop",
100
+ };
101
+ }
102
+
103
+ function matchAgent(
104
+ table: readonly (readonly [RegExp, string])[],
105
+ userAgent: string,
106
+ ): string | undefined {
107
+ for (const [pattern, value] of table) {
108
+ if (pattern.test(userAgent)) return value;
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ function brandFromClientHints(header: string | null): string | undefined {
114
+ if (!header || header.length > MAX_HINT_LENGTH) return undefined;
115
+ let generic: string | undefined;
116
+ let seen = 0;
117
+ for (const match of header.matchAll(BRAND_PATTERN)) {
118
+ if (seen >= MAX_HINT_BRANDS) break;
119
+ seen += 1;
120
+ const brand = HINT_BRANDS.get(unquote(match[1]).trim().toLowerCase());
121
+ if (!brand) continue;
122
+ if (!GENERIC_BRANDS.has(brand)) return brand;
123
+ if (brand === "Chrome" || generic === undefined) generic = brand;
124
+ }
125
+ return generic;
126
+ }
127
+
128
+ function platformFromClientHints(header: string | null): string | undefined {
129
+ if (!header || header.length > MAX_HINT_LENGTH) return undefined;
130
+ const match = PLATFORM_PATTERN.exec(header);
131
+ if (!match) return undefined;
132
+ return HINT_PLATFORMS.get(unquote(match[1]).trim().toLowerCase());
133
+ }
134
+
135
+ function mobileFromClientHints(header: string | null): boolean | undefined {
136
+ if (header === null) return undefined;
137
+ const value = header.trim();
138
+ if (value === "?1") return true;
139
+ if (value === "?0") return false;
140
+ return undefined;
141
+ }
142
+
143
+ function deviceFromClientHints(
144
+ mobile: boolean | undefined,
145
+ platform: string | undefined,
146
+ ): string | undefined {
147
+ if (mobile === undefined) return undefined;
148
+ if (mobile) return "mobile";
149
+ // Chrome on Android tablets reports `Sec-CH-UA-Mobile: ?0`. The explicit
150
+ // `Sec-CH-UA-Form-Factors` hint is high entropy, so it would need an
151
+ // `Accept-CH` opt-in and a permissions-policy delegation from every tracked
152
+ // site; "Android but not mobile" is the only tablet signal available here.
153
+ if (platform === "Android") return "tablet";
154
+ return "desktop";
155
+ }
156
+
157
+ function unquote(value: string): string {
158
+ return value.replace(/\\(.)/g, "$1");
159
+ }
160
+
161
+ // Self-declared crawlers, link unfurlers, uptime monitors, HTTP libraries, and
162
+ // headless browsers. A URL inside the User-Agent is the crawler convention
163
+ // ("+https://…/bot.html"); no browser ships one. An empty User-Agent is left
164
+ // alone because some runtimes do not expose it at all, and a scripted browser
165
+ // that spoofs a stock User-Agent is indistinguishable from a person here.
166
+ const BOT_PATTERN =
167
+ /bot|crawl|spider|slurp|headless|phantomjs|lighthouse|pingdom|gtmetrix|uptime|monitor|scrap|fetch|curl\/|wget\/|python|java\/|go-http-client|okhttp|axios|libwww|httpclient|facebookexternalhit|whatsapp|embedly|quora link preview|preview|mediapartners|feedfetcher|validator|https?:\/\//i;
168
+
169
+ export function isKnownBot(userAgent: string): boolean {
170
+ return BOT_PATTERN.test(userAgent);
171
+ }
@@ -6,7 +6,17 @@ export const eventTypeValidator = v.union(
6
6
  v.literal("custom"),
7
7
  v.literal("conversion"),
8
8
  v.literal("heartbeat"),
9
+ v.literal("leave"),
9
10
  v.literal("outbound"),
11
+ v.literal("vital"),
12
+ );
13
+
14
+ export const vitalMetricValidator = v.union(
15
+ v.literal("LCP"),
16
+ v.literal("CLS"),
17
+ v.literal("INP"),
18
+ v.literal("FCP"),
19
+ v.literal("TTFB"),
10
20
  );
11
21
 
12
22
  export const propertyValueValidator = v.union(
@@ -37,6 +47,9 @@ export const trackerEventValidator = v.object({
37
47
  revenueCents: v.optional(v.number()),
38
48
  currency: v.optional(v.string()),
39
49
  affiliateSlug: v.optional(v.string()),
50
+ // Web Vitals measurement: milliseconds, or CLS scaled by 1000. Only "vital"
51
+ // events carry it; sanitization strips it from every other type.
52
+ value: v.optional(v.number()),
40
53
  });
41
54
 
42
55
  export const ingestContextValidator = v.object({
@@ -47,6 +60,12 @@ export const ingestContextValidator = v.object({
47
60
  browser: v.optional(v.string()),
48
61
  os: v.optional(v.string()),
49
62
  device: v.optional(v.string()),
63
+ visitorKey: v.optional(v.string()),
64
+ });
65
+
66
+ export const batchedEventValidator = trackerEventValidator.extend({
67
+ ...ingestContextValidator.fields,
68
+ aggregateCountry: v.optional(v.string()),
50
69
  });
51
70
 
52
71
  export const siteFieldsValidator = v.object({
@@ -65,6 +84,7 @@ export const sessionFieldsValidator = v.object({
65
84
  siteId: v.id("sites"),
66
85
  sessionId: v.string(),
67
86
  visitorId: v.string(),
87
+ visitorKey: v.optional(v.string()),
68
88
  startedAt: v.number(),
69
89
  lastSeenAt: v.number(),
70
90
  entryPath: v.string(),
@@ -119,20 +139,9 @@ export const eventFieldsValidator = v.object({
119
139
  device: v.string(),
120
140
  });
121
141
 
122
- export const storedEventFieldsValidator = eventFieldsValidator
123
- .omit("country", "city", "latitude", "longitude", "browser", "os", "device")
124
- .extend({
125
- country: v.optional(v.string()),
126
- city: v.optional(v.string()),
127
- latitude: v.optional(v.number()),
128
- longitude: v.optional(v.number()),
129
- browser: v.optional(v.string()),
130
- os: v.optional(v.string()),
131
- device: v.optional(v.string()),
132
- });
133
-
134
142
  export const dimensionTypeValidator = v.union(
135
143
  v.literal("source"),
144
+ v.literal("campaign"),
136
145
  v.literal("page"),
137
146
  v.literal("country"),
138
147
  v.literal("device"),
@@ -147,11 +156,30 @@ export const dimensionSlotValidator = v.object({
147
156
  value: v.string(),
148
157
  count: v.number(),
149
158
  revenueCents: v.number(),
159
+ // Slots written since alpha.11 also count the pageviews and the sessions
160
+ // (a session's first event) they saw, so pages rank by views and every
161
+ // other dimension by sessions instead of by raw event volume.
162
+ pageviews: v.optional(v.number()),
163
+ sessions: v.optional(v.number()),
150
164
  });
151
165
 
166
+ export const visitorSketchValidator = v.object({
167
+ version: v.literal(1),
168
+ precision: v.number(),
169
+ registers: v.bytes(),
170
+ });
171
+
172
+ export const aggregateGranularityValidator = v.union(
173
+ v.literal("hour"),
174
+ v.literal("day"),
175
+ // One bucket per calendar day in the site's timezone, written alongside
176
+ // the UTC day for sites with a timezone (see localTime.ts).
177
+ v.literal("localDay"),
178
+ );
179
+
152
180
  export const aggregateFieldsValidator = v.object({
153
181
  siteId: v.id("sites"),
154
- granularity: v.union(v.literal("hour"), v.literal("day")),
182
+ granularity: aggregateGranularityValidator,
155
183
  bucketStart: v.number(),
156
184
  shard: v.number(),
157
185
  events: v.number(),
@@ -161,9 +189,17 @@ export const aggregateFieldsValidator = v.object({
161
189
  outboundClicks: v.number(),
162
190
  sessions: v.number(),
163
191
  visitors: v.number(),
192
+ visitorSketch: v.optional(visitorSketchValidator),
193
+ visitorSketchComplete: v.optional(v.boolean()),
164
194
  conversions: v.number(),
165
195
  revenueCents: v.number(),
166
196
  dimensions: v.array(dimensionSlotValidator),
197
+ // Engagement counters recorded since alpha.11: how much session duration
198
+ // grew from the activity that landed in this bucket, sessions that reached
199
+ // a second pageview here, and sessions of visitors never seen before.
200
+ durationMs: v.optional(v.number()),
201
+ continuedSessions: v.optional(v.number()),
202
+ newVisitors: v.optional(v.number()),
167
203
  });
168
204
 
169
205
  export const goalMatchTypeValidator = v.union(
@@ -230,7 +266,6 @@ export const trustedConversionValidator = v.object({
230
266
  properties: v.optional(eventPropertiesValidator),
231
267
  });
232
268
 
233
-
234
269
  export type TrackerEvent = Infer<typeof trackerEventValidator>;
235
270
  export type IngestContext = Infer<typeof ingestContextValidator>;
236
271
  export type EventProperties = Infer<typeof eventPropertiesValidator>;
@@ -0,0 +1,116 @@
1
+ import { v } from "convex/values";
2
+ import { MAX_VISITOR_ALIASES } from "./constants.js";
3
+ import { fail } from "./errors.js";
4
+ import { sanitizeOpaqueId } from "./sanitize.js";
5
+ import type { Id } from "./_generated/dataModel.js";
6
+ import { mutation, type QueryCtx } from "./_generated/server.js";
7
+
8
+ /**
9
+ * Folds an anonymous visitor id into the pseudonymous identity the host
10
+ * supplies after signup or sign-in. Links are one level deep: an alias can
11
+ * belong to one identity, an identity can never become an alias, and an
12
+ * alias can never own aliases, so journeys stay bounded and unambiguous.
13
+ */
14
+ export const link = mutation({
15
+ args: {
16
+ siteId: v.id("sites"),
17
+ visitorId: v.string(),
18
+ previousVisitorId: v.string(),
19
+ },
20
+ returns: v.object({ linked: v.boolean(), aliasCount: v.number() }),
21
+ handler: async (ctx, args) => {
22
+ const site = await ctx.db.get("sites", args.siteId);
23
+ if (!site) fail("NOT_FOUND", "site not found");
24
+ let visitorId: string;
25
+ let previousVisitorId: string;
26
+ try {
27
+ visitorId = sanitizeOpaqueId(args.visitorId, "visitorId");
28
+ previousVisitorId = sanitizeOpaqueId(
29
+ args.previousVisitorId,
30
+ "previousVisitorId",
31
+ );
32
+ } catch (error) {
33
+ fail(
34
+ "INVALID_ARGUMENT",
35
+ error instanceof Error ? error.message : "invalid visitor id",
36
+ );
37
+ }
38
+ if (visitorId === previousVisitorId) {
39
+ fail("INVALID_ARGUMENT", "previousVisitorId must differ from visitorId");
40
+ }
41
+ if (await findAlias(ctx, args.siteId, visitorId)) {
42
+ fail("CONFLICT", "visitorId is already linked to another visitor");
43
+ }
44
+ const previousOwnsAliases = await ctx.db
45
+ .query("visitorAliases")
46
+ .withIndex("by_siteId_and_visitorId", (range) =>
47
+ range.eq("siteId", args.siteId).eq("visitorId", previousVisitorId),
48
+ )
49
+ .first();
50
+ if (previousOwnsAliases) {
51
+ fail("CONFLICT", "previousVisitorId already owns linked visitors");
52
+ }
53
+ const aliases = await ctx.db
54
+ .query("visitorAliases")
55
+ .withIndex("by_siteId_and_visitorId", (range) =>
56
+ range.eq("siteId", args.siteId).eq("visitorId", visitorId),
57
+ )
58
+ .take(MAX_VISITOR_ALIASES);
59
+ const existing = await findAlias(ctx, args.siteId, previousVisitorId);
60
+ if (existing) {
61
+ if (existing.visitorId !== visitorId) {
62
+ fail(
63
+ "CONFLICT",
64
+ "previousVisitorId is already linked to a different visitor",
65
+ );
66
+ }
67
+ return { linked: false, aliasCount: aliases.length };
68
+ }
69
+ if (aliases.length >= MAX_VISITOR_ALIASES) {
70
+ fail(
71
+ "LIMIT_EXCEEDED",
72
+ `a visitor can hold at most ${MAX_VISITOR_ALIASES} aliases`,
73
+ {
74
+ limit: MAX_VISITOR_ALIASES,
75
+ },
76
+ );
77
+ }
78
+ await ctx.db.insert("visitorAliases", {
79
+ siteId: args.siteId,
80
+ visitorId,
81
+ previousVisitorId,
82
+ linkedAt: Date.now(),
83
+ });
84
+ return { linked: true, aliasCount: aliases.length + 1 };
85
+ },
86
+ });
87
+
88
+ /** The identity a visitor id resolves to, followed by every alias it owns. */
89
+ export async function resolveVisitorIdentities(
90
+ ctx: QueryCtx,
91
+ siteId: Id<"sites">,
92
+ visitorId: string,
93
+ ): Promise<string[]> {
94
+ const alias = await findAlias(ctx, siteId, visitorId);
95
+ const identity = alias?.visitorId ?? visitorId;
96
+ const aliases = await ctx.db
97
+ .query("visitorAliases")
98
+ .withIndex("by_siteId_and_visitorId", (range) =>
99
+ range.eq("siteId", siteId).eq("visitorId", identity),
100
+ )
101
+ .take(MAX_VISITOR_ALIASES);
102
+ return [identity, ...aliases.map((row) => row.previousVisitorId)];
103
+ }
104
+
105
+ function findAlias(
106
+ ctx: QueryCtx,
107
+ siteId: Id<"sites">,
108
+ previousVisitorId: string,
109
+ ) {
110
+ return ctx.db
111
+ .query("visitorAliases")
112
+ .withIndex("by_siteId_and_previousVisitorId", (range) =>
113
+ range.eq("siteId", siteId).eq("previousVisitorId", previousVisitorId),
114
+ )
115
+ .unique();
116
+ }