@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,30 +1,107 @@
1
1
  import { httpRouter } from "convex/server";
2
2
  import { ConvexError } from "convex/values";
3
3
  import type { Id } from "./_generated/dataModel.js";
4
- import { api } from "./_generated/api.js";
5
- import { httpAction } from "./_generated/server.js";
6
- import { MAX_BATCH_BYTES } from "./constants.js";
4
+ import { api, internal } from "./_generated/api.js";
5
+ import { env, httpAction } from "./_generated/server.js";
6
+ import {
7
+ MAX_BATCH_BYTES,
8
+ MAX_BATCH_EVENT_GROUPS,
9
+ MAX_BATCH_EVENTS,
10
+ RATE_LIMIT_WINDOW_MS,
11
+ } from "./constants.js";
12
+ import {
13
+ classifyIp,
14
+ geoIpDailyLimit,
15
+ geoIpStatus,
16
+ isTrustedProxyRequest,
17
+ readRequestMetadata,
18
+ resolveClientIp,
19
+ resolveGeoIp,
20
+ } from "./geo.js";
7
21
  import { isPlainRecord } from "./guards.js";
22
+ import { alignClock, sanitizeEvent } from "./sanitize.js";
23
+ import { deriveVisitorKey, randomSecret } from "./identity.js";
24
+ import { classifyClient, isKnownBot } from "./useragent.js";
8
25
  import type { TrackerEvent } from "./validators.js";
9
26
  import {
27
+ RASTRO_VERSION,
10
28
  TRACKER_GZIP_BASE64,
11
29
  TRACKER_GZIP_BYTES,
30
+ TRACKER_HASH,
31
+ VITALS_GZIP_BASE64,
32
+ VITALS_GZIP_BYTES,
33
+ VITALS_HASH,
12
34
  } from "../tracker/generated.js";
13
35
 
14
- const TRACKER_GZIP = Uint8Array.from(
15
- atob(TRACKER_GZIP_BASE64),
16
- (character) => character.charCodeAt(0),
36
+ const TRACKER_GZIP = Uint8Array.from(atob(TRACKER_GZIP_BASE64), (character) =>
37
+ character.charCodeAt(0),
38
+ );
39
+ const VITALS_GZIP = Uint8Array.from(atob(VITALS_GZIP_BASE64), (character) =>
40
+ character.charCodeAt(0),
17
41
  );
18
42
 
43
+ // Five minutes fresh, then up to an hour of stale-while-revalidate: a visitor
44
+ // who returns after a deploy gets at most one stale load before the browser's
45
+ // background revalidation, and that revalidation is a 304 against the ETag.
46
+ const SCRIPT_CACHE_CONTROL = "public, max-age=300, stale-while-revalidate=3600";
47
+
48
+ /**
49
+ * Serves one pre-gzipped module with a strong ETag from its build-time content
50
+ * hash. A matching If-None-Match answers 304 with the same caching and CORS
51
+ * headers, so a revalidation costs a header exchange instead of the script.
52
+ */
53
+ function scriptResponse(
54
+ request: Request,
55
+ body: Uint8Array<ArrayBuffer>,
56
+ byteLength: number,
57
+ hash: string,
58
+ ): Response {
59
+ const etag = `"${hash}"`;
60
+ const headers = {
61
+ ETag: etag,
62
+ "Cache-Control": SCRIPT_CACHE_CONTROL,
63
+ "Access-Control-Allow-Origin": "*",
64
+ Vary: "Accept-Encoding",
65
+ };
66
+ if (etagMatches(request.headers.get("if-none-match"), etag)) {
67
+ return new Response(null, { status: 304, headers });
68
+ }
69
+ return new Response(body, {
70
+ status: 200,
71
+ headers: {
72
+ ...headers,
73
+ "Content-Type": "text/javascript; charset=utf-8",
74
+ "Content-Encoding": "gzip",
75
+ "Content-Length": String(byteLength),
76
+ },
77
+ });
78
+ }
79
+
80
+ /** RFC 9110 If-None-Match: a list of entity tags or "*", weak markers ignored. */
81
+ function etagMatches(header: string | null, etag: string): boolean {
82
+ if (!header) return false;
83
+ return header.split(",").some((candidate) => {
84
+ const value = candidate.trim().replace(/^W\//, "");
85
+ return value === "*" || value === etag;
86
+ });
87
+ }
88
+
19
89
  const http = httpRouter();
20
90
 
21
91
  http.route({
22
92
  path: "/events",
23
93
  method: "POST",
24
94
  handler: httpAction(async (ctx, request) => {
95
+ const requestId = crypto.randomUUID();
25
96
  const origin = request.headers.get("origin");
26
- if (!origin) return json({ error: { code: "ORIGIN_REQUIRED" } }, 400);
27
- const headers = corsHeaders(origin);
97
+ if (!origin) {
98
+ return json(
99
+ { error: { code: "ORIGIN_REQUIRED" } },
100
+ 400,
101
+ requestIdHeaders(requestId),
102
+ );
103
+ }
104
+ const headers = corsHeaders(origin, requestId);
28
105
  const contentLength = Number(request.headers.get("content-length"));
29
106
  if (Number.isFinite(contentLength) && contentLength > MAX_BATCH_BYTES) {
30
107
  return json(
@@ -34,7 +111,7 @@ http.route({
34
111
  );
35
112
  }
36
113
 
37
- let payload: { siteId: string; events: TrackerEvent[] };
114
+ let payload: { siteId: string; events: TrackerEvent[]; sentAt?: number };
38
115
  try {
39
116
  const bytes = await readLimitedBody(request, MAX_BATCH_BYTES);
40
117
  const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
@@ -51,30 +128,104 @@ http.route({
51
128
  {
52
129
  error: {
53
130
  code: "INVALID_BODY",
54
- message: error instanceof Error ? error.message : "invalid JSON body",
131
+ message:
132
+ error instanceof Error ? error.message : "invalid JSON body",
55
133
  },
56
134
  },
57
135
  400,
58
136
  headers,
59
137
  );
60
138
  }
139
+ // The sender's clock says when its events happened, but a clock minutes
140
+ // off would keep the visitor out of the live view and file the traffic
141
+ // under another hour. The batch also says when it was sent, and that offset
142
+ // re-anchors every event here, before the transaction and the geo
143
+ // enrichment read them, so both see the same timestamps.
144
+ const events = alignClock(payload.events, payload.sentAt, Date.now());
61
145
 
62
- const userAgent = request.headers.get("user-agent") ?? "";
146
+ const trustProxy = env.RASTRO_TRUST_PROXY === "true";
147
+ const metadata = await readRequestMetadata(ctx);
148
+ const proxyRequestTrusted = isTrustedProxyRequest(metadata.ip, trustProxy);
149
+ const clientIp = resolveClientIp(metadata.ip, request.headers, trustProxy);
150
+ const edgeGeo = proxyRequestTrusted ? geoFromHeaders(request) : {};
151
+ const userAgent =
152
+ metadata.userAgent ?? request.headers.get("user-agent") ?? "";
153
+ // Self-declared bots never reach a transaction; the response keeps the
154
+ // counter shape the tracker expects and names the reason in a header.
155
+ if (isKnownBot(userAgent)) {
156
+ return json(
157
+ { accepted: 0, duplicates: 0, rejected: events.length },
158
+ 200,
159
+ { ...headers, "X-Rastro-Rejected": "bot" },
160
+ );
161
+ }
162
+ // Anonymous batches (visitorId === sessionId) get a salted daily key so
163
+ // repeat visits count once; the IP itself stops here.
164
+ const anonymous = events.some(
165
+ (event) => event.visitorId === event.sessionId,
166
+ );
167
+ const visitorKey =
168
+ anonymous && clientIp.ip && userAgent
169
+ ? await deriveVisitorKey({
170
+ secret: await visitorSecret(ctx),
171
+ now: Date.now(),
172
+ siteId: payload.siteId,
173
+ ip: clientIp.ip,
174
+ userAgent,
175
+ })
176
+ : undefined;
63
177
  const context = {
64
- country: firstHeader(request, ["x-vercel-ip-country", "cf-ipcountry"]),
65
- city: decodeHeader(firstHeader(request, ["x-vercel-ip-city", "cf-ipcity"])),
66
- latitude: numericHeader(request, ["x-vercel-ip-latitude", "cf-iplatitude"]),
67
- longitude: numericHeader(request, ["x-vercel-ip-longitude", "cf-iplongitude"]),
68
- ...classifyUserAgent(userAgent),
178
+ ...edgeGeo,
179
+ ...classifyClient(userAgent, request.headers),
180
+ ...(visitorKey ? { visitorKey } : {}),
69
181
  };
70
182
 
71
183
  try {
72
184
  const result = await ctx.runMutation(api.ingest.ingestBatch, {
73
185
  siteId: payload.siteId as Id<"sites">,
74
186
  origin,
75
- events: payload.events,
187
+ events,
76
188
  context,
77
189
  });
190
+ if (
191
+ clientIp.ip &&
192
+ !hasCoordinates(edgeGeo) &&
193
+ geoIpStatus(env.RASTRO_GEOIP_PROVIDER, env.RASTRO_GEOIP_TOKEN) ===
194
+ "ready"
195
+ ) {
196
+ const candidates = enrichmentCandidateEvents(events);
197
+ if (candidates) {
198
+ let stage: GeoIpFailureStage = "claim";
199
+ try {
200
+ const claim = await ctx.runMutation(internal.geo.claimEnrichment, {
201
+ siteId: payload.siteId as Id<"sites">,
202
+ events: candidates,
203
+ dailyLimit: geoIpDailyLimit(env.RASTRO_GEOIP_DAILY_LIMIT),
204
+ });
205
+ if (claim.sessionIds.length > 0) {
206
+ stage = "resolve";
207
+ const providerGeo = await resolveGeoIp(
208
+ clientIp.ip,
209
+ env.RASTRO_GEOIP_PROVIDER!,
210
+ env.RASTRO_GEOIP_TOKEN,
211
+ );
212
+ if (providerGeo) {
213
+ stage = "apply";
214
+ await ctx.runMutation(internal.geo.applyEnrichment, {
215
+ siteId: payload.siteId as Id<"sites">,
216
+ sessionIds: claim.sessionIds,
217
+ events: claim.events,
218
+ context: providerGeo,
219
+ });
220
+ } else {
221
+ logGeoIpFailure(requestId, stage, env.RASTRO_GEOIP_PROVIDER!);
222
+ }
223
+ }
224
+ } catch {
225
+ logGeoIpFailure(requestId, stage, env.RASTRO_GEOIP_PROVIDER!);
226
+ }
227
+ }
228
+ }
78
229
  return json(result, 200, headers);
79
230
  } catch (error) {
80
231
  if (error instanceof ConvexError) {
@@ -85,9 +236,23 @@ http.route({
85
236
  : data.code === "RATE_LIMITED"
86
237
  ? 429
87
238
  : 400;
88
- return json({ error: data }, status, headers);
239
+ return json(
240
+ { error: data },
241
+ status,
242
+ status === 429
243
+ ? {
244
+ ...headers,
245
+ "Retry-After": String(Math.ceil(RATE_LIMIT_WINDOW_MS / 1_000)),
246
+ }
247
+ : headers,
248
+ );
89
249
  }
90
- return json({ error: { code: "INVALID_REQUEST" } }, 400, headers);
250
+ logInternalHttpError(requestId);
251
+ return json(
252
+ { error: { code: "INTERNAL_ERROR", requestId } },
253
+ 500,
254
+ headers,
255
+ );
91
256
  }
92
257
  }),
93
258
  });
@@ -112,28 +277,65 @@ http.route({
112
277
  http.route({
113
278
  path: "/tracker.js",
114
279
  method: "GET",
115
- handler: httpAction(async () => {
116
- return new Response(TRACKER_GZIP, {
117
- status: 200,
118
- headers: {
119
- "Content-Type": "text/javascript; charset=utf-8",
120
- "Content-Encoding": "gzip",
121
- "Content-Length": String(TRACKER_GZIP_BYTES),
122
- "Cache-Control": "public, max-age=300, stale-while-revalidate=86400",
123
- "Access-Control-Allow-Origin": "*",
124
- Vary: "Accept-Encoding",
125
- },
126
- });
127
- }),
280
+ handler: httpAction(async (_ctx, request) =>
281
+ scriptResponse(request, TRACKER_GZIP, TRACKER_GZIP_BYTES, TRACKER_HASH),
282
+ ),
128
283
  });
129
284
 
285
+ http.route({
286
+ path: "/vitals.js",
287
+ method: "GET",
288
+ handler: httpAction(async (_ctx, request) =>
289
+ scriptResponse(request, VITALS_GZIP, VITALS_GZIP_BYTES, VITALS_HASH),
290
+ ),
291
+ });
130
292
 
131
293
  http.route({
132
294
  path: "/health",
133
295
  method: "GET",
134
- handler: httpAction(async () => {
296
+ handler: httpAction(async (ctx, request) => {
297
+ const trustProxy = env.RASTRO_TRUST_PROXY === "true";
298
+ const metadata = await readRequestMetadata(ctx);
299
+ const proxyRequestTrusted = isTrustedProxyRequest(metadata.ip, trustProxy);
300
+ const clientIp = resolveClientIp(metadata.ip, request.headers, trustProxy);
301
+ const provider = env.RASTRO_GEOIP_PROVIDER;
135
302
  return json(
136
- { ok: true, component: "@iann29/rastro" },
303
+ {
304
+ ok: true,
305
+ component: "@iann29/rastro",
306
+ requestMetadata: {
307
+ runtimeIpKind: classifyIp(metadata.ip),
308
+ clientIpAvailable: clientIp.ip !== null,
309
+ clientIpSource: clientIp.source,
310
+ userAgentAvailable: metadata.userAgent !== null,
311
+ },
312
+ geoIp: {
313
+ provider: provider ?? null,
314
+ status: geoIpStatus(provider, env.RASTRO_GEOIP_TOKEN),
315
+ dailyLimit: geoIpDailyLimit(env.RASTRO_GEOIP_DAILY_LIMIT),
316
+ trustedProxy: trustProxy,
317
+ proxyRequestTrusted,
318
+ edgeHeadersAvailable:
319
+ proxyRequestTrusted &&
320
+ Object.keys(geoFromHeaders(request)).length > 0,
321
+ },
322
+ features: {
323
+ botDetection: "userAgent",
324
+ uniqueVisitors:
325
+ clientIp.ip &&
326
+ (metadata.userAgent ?? request.headers.get("user-agent"))
327
+ ? "dailyHash"
328
+ : "session",
329
+ webVitals: "optIn",
330
+ siteMap: "dailyRollup",
331
+ liveDepartures: "leaveBeacon",
332
+ },
333
+ tracker: {
334
+ version: RASTRO_VERSION,
335
+ hash: TRACKER_HASH,
336
+ vitalsHash: VITALS_HASH,
337
+ },
338
+ },
137
339
  200,
138
340
  {
139
341
  "Access-Control-Allow-Origin": "*",
@@ -143,11 +345,24 @@ http.route({
143
345
  }),
144
346
  });
145
347
 
146
- function parsePayload(value: unknown): { siteId: string; events: TrackerEvent[] } {
147
- if (!isPlainRecord(value) || typeof value.siteId !== "string" || !Array.isArray(value.events)) {
348
+ function parsePayload(value: unknown): {
349
+ siteId: string;
350
+ events: TrackerEvent[];
351
+ sentAt?: number;
352
+ } {
353
+ if (
354
+ !isPlainRecord(value) ||
355
+ typeof value.siteId !== "string" ||
356
+ !Array.isArray(value.events)
357
+ ) {
148
358
  throw new Error("body must contain siteId and events");
149
359
  }
150
- return { siteId: value.siteId, events: value.events.map(parseEvent) };
360
+ const sentAt = optionalNumber(value.sentAt, "sentAt");
361
+ return {
362
+ siteId: value.siteId,
363
+ events: value.events.map(parseEvent),
364
+ ...(sentAt === undefined ? {} : { sentAt }),
365
+ };
151
366
  }
152
367
 
153
368
  class PayloadTooLargeError extends Error {}
@@ -188,7 +403,9 @@ function parseEvent(value: unknown): TrackerEvent {
188
403
  type !== "custom" &&
189
404
  type !== "conversion" &&
190
405
  type !== "heartbeat" &&
191
- type !== "outbound"
406
+ type !== "leave" &&
407
+ type !== "outbound" &&
408
+ type !== "vital"
192
409
  ) {
193
410
  throw new Error("event type is invalid");
194
411
  }
@@ -233,6 +450,9 @@ function parseEvent(value: unknown): TrackerEvent {
233
450
  ...(optionalString(value.affiliateSlug, "affiliateSlug") !== undefined
234
451
  ? { affiliateSlug: optionalString(value.affiliateSlug, "affiliateSlug") }
235
452
  : {}),
453
+ ...(optionalNumber(value.value, "value") !== undefined
454
+ ? { value: optionalNumber(value.value, "value") }
455
+ : {}),
236
456
  };
237
457
  }
238
458
 
@@ -266,6 +486,90 @@ function optionalNumber(value: unknown, field: string): number | undefined {
266
486
  return value;
267
487
  }
268
488
 
489
+ function enrichmentCandidateEvents(events: TrackerEvent[]):
490
+ | Array<{
491
+ eventId: string;
492
+ sessionId: string;
493
+ visitorId: string;
494
+ timestamp: number;
495
+ sequence: number;
496
+ }>
497
+ | undefined {
498
+ if (events.length === 0 || events.length > MAX_BATCH_EVENTS) return undefined;
499
+ const now = Date.now();
500
+ const sanitized = events.flatMap((event) => {
501
+ try {
502
+ return [sanitizeEvent(event, now)];
503
+ } catch {
504
+ return [];
505
+ }
506
+ });
507
+ const sessionIds = [...new Set(sanitized.map((event) => event.sessionId))];
508
+ if (
509
+ !sanitized.some((event) => event.sequence === 0) ||
510
+ sessionIds.length > MAX_BATCH_EVENT_GROUPS ||
511
+ sessionIds.some(
512
+ (sessionId) => sessionId.length === 0 || sessionId.length > 128,
513
+ )
514
+ ) {
515
+ return undefined;
516
+ }
517
+ return sanitized.map(
518
+ ({ eventId, sessionId, visitorId, timestamp, sequence }) => ({
519
+ eventId,
520
+ sessionId,
521
+ visitorId,
522
+ timestamp,
523
+ sequence,
524
+ }),
525
+ );
526
+ }
527
+
528
+ function geoFromHeaders(request: Request): {
529
+ country?: string;
530
+ city?: string;
531
+ latitude?: number;
532
+ longitude?: number;
533
+ } {
534
+ const country = firstHeader(request, ["x-vercel-ip-country", "cf-ipcountry"]);
535
+ const city = decodeHeader(
536
+ firstHeader(request, ["x-vercel-ip-city", "cf-ipcity"]),
537
+ );
538
+ const latitude = numericHeader(request, [
539
+ "x-vercel-ip-latitude",
540
+ "cf-iplatitude",
541
+ ]);
542
+ const longitude = numericHeader(request, [
543
+ "x-vercel-ip-longitude",
544
+ "cf-iplongitude",
545
+ ]);
546
+ return {
547
+ ...(country ? { country } : {}),
548
+ ...(city ? { city } : {}),
549
+ ...(validCoordinates(latitude, longitude) ? { latitude, longitude } : {}),
550
+ };
551
+ }
552
+
553
+ function hasCoordinates(value: {
554
+ latitude?: number;
555
+ longitude?: number;
556
+ }): boolean {
557
+ return value.latitude !== undefined && value.longitude !== undefined;
558
+ }
559
+
560
+ function validCoordinates(
561
+ latitude: number | undefined,
562
+ longitude: number | undefined,
563
+ ): latitude is number {
564
+ return (
565
+ latitude !== undefined &&
566
+ longitude !== undefined &&
567
+ latitude >= -90 &&
568
+ latitude <= 90 &&
569
+ longitude >= -180 &&
570
+ longitude <= 180
571
+ );
572
+ }
269
573
 
270
574
  function firstHeader(request: Request, names: string[]): string | undefined {
271
575
  for (const name of names) {
@@ -291,45 +595,52 @@ function decodeHeader(value: string | undefined): string | undefined {
291
595
  }
292
596
  }
293
597
 
294
- function classifyUserAgent(userAgent: string): {
295
- browser: string;
296
- os: string;
297
- device: string;
298
- } {
299
- const browser = /Edg\//.test(userAgent)
300
- ? "Edge"
301
- : /Firefox\//.test(userAgent)
302
- ? "Firefox"
303
- : /Chrome\//.test(userAgent)
304
- ? "Chrome"
305
- : /Safari\//.test(userAgent)
306
- ? "Safari"
307
- : "Other";
308
- const os = /Android/.test(userAgent)
309
- ? "Android"
310
- : /iPhone|iPad/.test(userAgent)
311
- ? "iOS"
312
- : /Windows/.test(userAgent)
313
- ? "Windows"
314
- : /Mac OS X/.test(userAgent)
315
- ? "macOS"
316
- : /Linux/.test(userAgent)
317
- ? "Linux"
318
- : "Other";
319
- const device = /iPad|Tablet/.test(userAgent)
320
- ? "tablet"
321
- : /Mobile|Android|iPhone/.test(userAgent)
322
- ? "mobile"
323
- : "desktop";
324
- return { browser, os, device };
598
+ type GeoIpFailureStage = "claim" | "resolve" | "apply";
599
+
600
+ function logInternalHttpError(requestId: string): void {
601
+ console.error(
602
+ JSON.stringify({
603
+ level: "error",
604
+ event: "rastro.http.internal_error",
605
+ requestId,
606
+ route: "/events",
607
+ method: "POST",
608
+ }),
609
+ );
325
610
  }
326
611
 
327
- function corsHeaders(origin: string): HeadersInit {
612
+ function logGeoIpFailure(
613
+ requestId: string,
614
+ stage: GeoIpFailureStage,
615
+ provider: "ipinfo" | "ipwhois",
616
+ ): void {
617
+ console.warn(
618
+ JSON.stringify({
619
+ level: "warn",
620
+ event: "rastro.geoip.failure",
621
+ requestId,
622
+ route: "/events",
623
+ stage,
624
+ provider,
625
+ }),
626
+ );
627
+ }
628
+
629
+ function requestIdHeaders(requestId: string): Record<string, string> {
630
+ return { "X-Request-ID": requestId };
631
+ }
632
+
633
+ function corsHeaders(
634
+ origin: string,
635
+ requestId?: string,
636
+ ): Record<string, string> {
328
637
  return {
329
638
  "Access-Control-Allow-Origin": origin,
330
639
  "Access-Control-Allow-Methods": "POST, OPTIONS",
331
640
  "Access-Control-Allow-Headers": "Content-Type",
641
+ "Access-Control-Expose-Headers": "X-Request-ID, Retry-After",
332
642
  "Access-Control-Max-Age": "86400",
643
+ ...(requestId ? requestIdHeaders(requestId) : {}),
333
644
  Vary: "Origin",
334
645
  };
335
646
  }
@@ -342,3 +653,26 @@ function json(value: unknown, status: number, headers?: HeadersInit): Response {
342
653
  }
343
654
 
344
655
  export default http;
656
+
657
+ let cachedVisitorSecret: string | undefined;
658
+
659
+ /** The per-deployment salt behind anonymous visitor keys, created on first use. */
660
+ async function visitorSecret(ctx: {
661
+ runQuery: (
662
+ reference: typeof internal.identity.secret,
663
+ args: Record<string, never>,
664
+ ) => Promise<string | null>;
665
+ runMutation: (
666
+ reference: typeof internal.identity.ensureSecret,
667
+ args: { candidate: string },
668
+ ) => Promise<string>;
669
+ }): Promise<string> {
670
+ if (cachedVisitorSecret) return cachedVisitorSecret;
671
+ const stored = await ctx.runQuery(internal.identity.secret, {});
672
+ cachedVisitorSecret =
673
+ stored ??
674
+ (await ctx.runMutation(internal.identity.ensureSecret, {
675
+ candidate: randomSecret(),
676
+ }));
677
+ return cachedVisitorSecret;
678
+ }
@@ -0,0 +1,74 @@
1
+ import { v } from "convex/values";
2
+ import { DAY_MS } from "./constants.js";
3
+ import { internalMutation, internalQuery } from "./_generated/server.js";
4
+
5
+ /**
6
+ * Anonymous visitors (no host-supplied id) are counted through a salted hash of
7
+ * the request IP and User-Agent that rotates every UTC day. The hash is derived
8
+ * in the HTTP action, so the raw IP never reaches a mutation or a table, and the
9
+ * salt is a per-deployment secret, so nobody holding a key can walk the IPv4
10
+ * space back to an address. Same device and network on the same day count once;
11
+ * the next day they are a new visitor by design.
12
+ */
13
+ const KEY_HEX_LENGTH = 24;
14
+ const SECRET_PATTERN = /^[0-9a-f]{64}$/;
15
+
16
+ export const secret = internalQuery({
17
+ args: {},
18
+ returns: v.union(v.string(), v.null()),
19
+ handler: async (ctx) => {
20
+ const row = await ctx.db
21
+ .query("visitorSecrets")
22
+ .withIndex("by_key", (range) => range.eq("key", "current"))
23
+ .unique();
24
+ return row?.value ?? null;
25
+ },
26
+ });
27
+
28
+ export const ensureSecret = internalMutation({
29
+ args: { candidate: v.string() },
30
+ returns: v.string(),
31
+ handler: async (ctx, args) => {
32
+ const existing = await ctx.db
33
+ .query("visitorSecrets")
34
+ .withIndex("by_key", (range) => range.eq("key", "current"))
35
+ .unique();
36
+ if (existing) return existing.value;
37
+ if (!SECRET_PATTERN.test(args.candidate)) {
38
+ throw new Error("visitor secret candidate must be 64 hex characters");
39
+ }
40
+ await ctx.db.insert("visitorSecrets", {
41
+ key: "current",
42
+ value: args.candidate,
43
+ createdAt: Date.now(),
44
+ });
45
+ return args.candidate;
46
+ },
47
+ });
48
+
49
+ export function randomSecret(): string {
50
+ return hex(crypto.getRandomValues(new Uint8Array(32)));
51
+ }
52
+
53
+ export async function deriveVisitorKey(input: {
54
+ secret: string;
55
+ now: number;
56
+ siteId: string;
57
+ ip: string;
58
+ userAgent: string;
59
+ }): Promise<string> {
60
+ const dayStart = Math.floor(input.now / DAY_MS) * DAY_MS;
61
+ const material = new TextEncoder().encode(
62
+ [input.secret, dayStart, input.siteId, input.ip, input.userAgent].join(" "),
63
+ );
64
+ const digest = new Uint8Array(
65
+ await crypto.subtle.digest("SHA-256", material),
66
+ );
67
+ return hex(digest).slice(0, KEY_HEX_LENGTH);
68
+ }
69
+
70
+ function hex(bytes: Uint8Array): string {
71
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
72
+ "",
73
+ );
74
+ }