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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +579 -57
  2. package/agent/integration.md +661 -0
  3. package/agent/manifest.json +187 -0
  4. package/agent/manifest.schema.json +411 -0
  5. package/dist/client/federation.d.ts +236 -0
  6. package/dist/client/federation.d.ts.map +1 -0
  7. package/dist/client/federation.js +196 -0
  8. package/dist/client/federation.js.map +1 -0
  9. package/dist/client/index.d.ts +2467 -18
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +461 -32
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +18 -0
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +302 -4
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +4 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/affiliates.d.ts.map +1 -1
  22. package/dist/component/affiliates.js +6 -2
  23. package/dist/component/affiliates.js.map +1 -1
  24. package/dist/component/cardinality.d.ts +12 -0
  25. package/dist/component/cardinality.d.ts.map +1 -0
  26. package/dist/component/cardinality.js +94 -0
  27. package/dist/component/cardinality.js.map +1 -0
  28. package/dist/component/constants.d.ts +34 -7
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +47 -7
  31. package/dist/component/constants.js.map +1 -1
  32. package/dist/component/convex.config.d.ts +6 -1
  33. package/dist/component/convex.config.js +9 -1
  34. package/dist/component/convex.config.js.map +1 -1
  35. package/dist/component/coverage.d.ts +19 -0
  36. package/dist/component/coverage.d.ts.map +1 -0
  37. package/dist/component/coverage.js +30 -0
  38. package/dist/component/coverage.js.map +1 -0
  39. package/dist/component/diagnostics.d.ts +9 -0
  40. package/dist/component/diagnostics.d.ts.map +1 -0
  41. package/dist/component/diagnostics.js +47 -0
  42. package/dist/component/diagnostics.js.map +1 -0
  43. package/dist/component/errors.d.ts +1 -1
  44. package/dist/component/errors.d.ts.map +1 -1
  45. package/dist/component/errors.js.map +1 -1
  46. package/dist/component/eventStore.d.ts +106 -0
  47. package/dist/component/eventStore.d.ts.map +1 -0
  48. package/dist/component/eventStore.js +294 -0
  49. package/dist/component/eventStore.js.map +1 -0
  50. package/dist/component/funnels.d.ts.map +1 -1
  51. package/dist/component/funnels.js +5 -3
  52. package/dist/component/funnels.js.map +1 -1
  53. package/dist/component/geo.d.ts +71 -0
  54. package/dist/component/geo.d.ts.map +1 -0
  55. package/dist/component/geo.js +611 -0
  56. package/dist/component/geo.js.map +1 -0
  57. package/dist/component/goals.d.ts.map +1 -1
  58. package/dist/component/goals.js +25 -9
  59. package/dist/component/goals.js.map +1 -1
  60. package/dist/component/guards.d.ts.map +1 -1
  61. package/dist/component/guards.js.map +1 -1
  62. package/dist/component/http.d.ts.map +1 -1
  63. package/dist/component/http.js +284 -53
  64. package/dist/component/http.js.map +1 -1
  65. package/dist/component/identity.d.ts +13 -0
  66. package/dist/component/identity.d.ts.map +1 -0
  67. package/dist/component/identity.js +58 -0
  68. package/dist/component/identity.js.map +1 -0
  69. package/dist/component/ingest.d.ts +3 -1
  70. package/dist/component/ingest.d.ts.map +1 -1
  71. package/dist/component/ingest.js +1105 -243
  72. package/dist/component/ingest.js.map +1 -1
  73. package/dist/component/live.d.ts +8 -0
  74. package/dist/component/live.d.ts.map +1 -1
  75. package/dist/component/live.js +74 -9
  76. package/dist/component/live.js.map +1 -1
  77. package/dist/component/reports.d.ts +273 -24
  78. package/dist/component/reports.d.ts.map +1 -1
  79. package/dist/component/reports.js +1320 -150
  80. package/dist/component/reports.js.map +1 -1
  81. package/dist/component/retention.d.ts +75 -1
  82. package/dist/component/retention.d.ts.map +1 -1
  83. package/dist/component/retention.js +541 -31
  84. package/dist/component/retention.js.map +1 -1
  85. package/dist/component/sanitize.d.ts +24 -1
  86. package/dist/component/sanitize.d.ts.map +1 -1
  87. package/dist/component/sanitize.js +107 -19
  88. package/dist/component/sanitize.js.map +1 -1
  89. package/dist/component/schema.d.ts +462 -55
  90. package/dist/component/schema.js +195 -15
  91. package/dist/component/schema.js.map +1 -1
  92. package/dist/component/sites.d.ts.map +1 -1
  93. package/dist/component/sites.js +38 -25
  94. package/dist/component/sites.js.map +1 -1
  95. package/dist/component/useragent.d.ts +9 -0
  96. package/dist/component/useragent.d.ts.map +1 -0
  97. package/dist/component/useragent.js +152 -0
  98. package/dist/component/useragent.js.map +1 -0
  99. package/dist/component/validators.d.ts +101 -16
  100. package/dist/component/validators.d.ts.map +1 -1
  101. package/dist/component/validators.js +19 -2
  102. package/dist/component/validators.js.map +1 -1
  103. package/dist/component/visitors.d.ts +19 -0
  104. package/dist/component/visitors.d.ts.map +1 -0
  105. package/dist/component/visitors.js +86 -0
  106. package/dist/component/visitors.js.map +1 -0
  107. package/dist/component/vitals.d.ts +41 -0
  108. package/dist/component/vitals.d.ts.map +1 -0
  109. package/dist/component/vitals.js +115 -0
  110. package/dist/component/vitals.js.map +1 -0
  111. package/dist/react/index.d.ts.map +1 -1
  112. package/dist/react/index.js.map +1 -1
  113. package/dist/tracker/generated.d.ts +8 -4
  114. package/dist/tracker/generated.d.ts.map +1 -1
  115. package/dist/tracker/generated.js +8 -4
  116. package/dist/tracker/generated.js.map +1 -1
  117. package/dist/tracker/tracker.d.ts +1 -1
  118. package/dist/tracker/tracker.d.ts.map +1 -1
  119. package/dist/tracker/tracker.js +81 -33
  120. package/dist/tracker/tracker.js.map +1 -1
  121. package/dist/tracker/vitals.d.ts +10 -0
  122. package/dist/tracker/vitals.d.ts.map +1 -0
  123. package/dist/tracker/vitals.js +140 -0
  124. package/dist/tracker/vitals.js.map +1 -0
  125. package/dist/tracker.min.js +1 -1
  126. package/dist/vitals.min.js +1 -0
  127. package/docs/benchmarks/2026-08-20-realistic.md +171 -0
  128. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  129. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  130. package/docs/federation-setup.md +397 -0
  131. package/docs/federation.md +262 -0
  132. package/docs/upgrading.md +259 -0
  133. package/llms.txt +71 -0
  134. package/package.json +61 -11
  135. package/scripts/benchmark-ingest.mjs +601 -0
  136. package/scripts/generate-federation-keys.mjs +20 -0
  137. package/src/component/_generated/api.ts +18 -0
  138. package/src/component/_generated/component.ts +366 -4
  139. package/src/component/_generated/server.ts +4 -0
  140. package/src/component/affiliates.ts +20 -5
  141. package/src/component/cardinality.ts +117 -0
  142. package/src/component/constants.ts +47 -7
  143. package/src/component/convex.config.ts +11 -1
  144. package/src/component/coverage.ts +36 -0
  145. package/src/component/diagnostics.ts +65 -0
  146. package/src/component/errors.ts +2 -1
  147. package/src/component/eventStore.ts +489 -0
  148. package/src/component/funnels.ts +19 -16
  149. package/src/component/geo.ts +781 -0
  150. package/src/component/goals.ts +45 -16
  151. package/src/component/guards.ts +3 -1
  152. package/src/component/http.ts +403 -60
  153. package/src/component/identity.ts +74 -0
  154. package/src/component/ingest.ts +1652 -291
  155. package/src/component/live.ts +90 -9
  156. package/src/component/reports.ts +1929 -199
  157. package/src/component/retention.ts +774 -97
  158. package/src/component/sanitize.ts +154 -32
  159. package/src/component/schema.ts +219 -15
  160. package/src/component/sites.ts +56 -28
  161. package/src/component/useragent.ts +171 -0
  162. package/src/component/validators.ts +29 -1
  163. package/src/component/visitors.ts +116 -0
  164. package/src/component/vitals.ts +146 -0
@@ -8,10 +8,7 @@ import {
8
8
  sanitizePath,
9
9
  sanitizeSlug,
10
10
  } from "./sanitize.js";
11
- import {
12
- goalFieldsValidator,
13
- goalMatchTypeValidator,
14
- } from "./validators.js";
11
+ import { goalFieldsValidator, goalMatchTypeValidator } from "./validators.js";
15
12
 
16
13
  const goalDocumentValidator = goalFieldsValidator.extend({
17
14
  _id: v.id("goals"),
@@ -31,14 +28,16 @@ export const upsert = mutation({
31
28
  },
32
29
  returns: v.id("goals"),
33
30
  handler: async (ctx, args) => {
34
- if (!(await ctx.db.get("sites", args.siteId))) fail("NOT_FOUND", "site not found");
31
+ if (!(await ctx.db.get("sites", args.siteId)))
32
+ fail("NOT_FOUND", "site not found");
35
33
  let key: string;
36
34
  let matchValue: string;
37
35
  try {
38
36
  key = sanitizeSlug(args.key, "key");
39
- matchValue = args.matchType === "pageview"
40
- ? sanitizePath(args.matchValue)
41
- : cleanString(args.matchValue, 80);
37
+ matchValue =
38
+ args.matchType === "pageview"
39
+ ? sanitizePath(args.matchValue)
40
+ : cleanString(args.matchValue, 80);
42
41
  } catch (error) {
43
42
  fail(
44
43
  "INVALID_ARGUMENT",
@@ -49,7 +48,10 @@ export const upsert = mutation({
49
48
  if (!name) fail("INVALID_ARGUMENT", "goal name is required");
50
49
  if (!matchValue) fail("INVALID_ARGUMENT", "goal match value is required");
51
50
  if (args.valueCents !== undefined && !isValidMoney(args.valueCents)) {
52
- fail("INVALID_ARGUMENT", "valueCents must be an integer between 0 and 1000000000");
51
+ fail(
52
+ "INVALID_ARGUMENT",
53
+ "valueCents must be an integer between 0 and 1000000000",
54
+ );
53
55
  }
54
56
 
55
57
  const existingByKey = await ctx.db
@@ -62,10 +64,27 @@ export const upsert = mutation({
62
64
  if (args.goalId && existingByKey && existingByKey._id !== args.goalId) {
63
65
  fail("CONFLICT", "another goal already uses this key");
64
66
  }
67
+ const goals = await ctx.db
68
+ .query("goals")
69
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
70
+ .take(MAX_GOALS_PER_SITE + 1);
65
71
  const now = Date.now();
66
72
  if (goalId) {
67
73
  const goal = await ctx.db.get("goals", goalId);
68
- if (!goal || goal.siteId !== args.siteId) fail("NOT_FOUND", "goal not found");
74
+ if (!goal || goal.siteId !== args.siteId)
75
+ fail("NOT_FOUND", "goal not found");
76
+ if (
77
+ (args.active ?? goal.active) &&
78
+ goals.some(
79
+ (candidate) =>
80
+ candidate._id !== goalId &&
81
+ candidate.active &&
82
+ candidate.matchType === args.matchType &&
83
+ candidate.matchValue === matchValue,
84
+ )
85
+ ) {
86
+ fail("CONFLICT", "another active goal already uses this matcher");
87
+ }
69
88
  await ctx.db.patch("goals", goalId, {
70
89
  key,
71
90
  name,
@@ -78,12 +97,21 @@ export const upsert = mutation({
78
97
  return goalId;
79
98
  }
80
99
 
81
- const goals = await ctx.db
82
- .query("goals")
83
- .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
84
- .take(MAX_GOALS_PER_SITE + 1);
100
+ if (
101
+ (args.active ?? true) &&
102
+ goals.some(
103
+ (candidate) =>
104
+ candidate.active &&
105
+ candidate.matchType === args.matchType &&
106
+ candidate.matchValue === matchValue,
107
+ )
108
+ ) {
109
+ fail("CONFLICT", "another active goal already uses this matcher");
110
+ }
85
111
  if (goals.length >= MAX_GOALS_PER_SITE) {
86
- fail("LIMIT_EXCEEDED", "goal limit reached", { limit: MAX_GOALS_PER_SITE });
112
+ fail("LIMIT_EXCEEDED", "goal limit reached", {
113
+ limit: MAX_GOALS_PER_SITE,
114
+ });
87
115
  }
88
116
  return await ctx.db.insert("goals", {
89
117
  siteId: args.siteId,
@@ -115,7 +143,8 @@ export const remove = mutation({
115
143
  returns: v.null(),
116
144
  handler: async (ctx, args) => {
117
145
  const goal = await ctx.db.get("goals", args.goalId);
118
- if (!goal || goal.siteId !== args.siteId) fail("NOT_FOUND", "goal not found");
146
+ if (!goal || goal.siteId !== args.siteId)
147
+ fail("NOT_FOUND", "goal not found");
119
148
  await ctx.db.delete("goals", args.goalId);
120
149
  return null;
121
150
  },
@@ -1,3 +1,5 @@
1
- export function isPlainRecord(value: unknown): value is Record<string, unknown> {
1
+ export function isPlainRecord(
2
+ value: unknown,
3
+ ): value is Record<string, unknown> {
2
4
  return typeof value === "object" && value !== null && !Array.isArray(value);
3
5
  }
@@ -1,19 +1,40 @@
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 {
10
27
  TRACKER_GZIP_BASE64,
11
28
  TRACKER_GZIP_BYTES,
29
+ VITALS_GZIP_BASE64,
30
+ VITALS_GZIP_BYTES,
12
31
  } from "../tracker/generated.js";
13
32
 
14
- const TRACKER_GZIP = Uint8Array.from(
15
- atob(TRACKER_GZIP_BASE64),
16
- (character) => character.charCodeAt(0),
33
+ const TRACKER_GZIP = Uint8Array.from(atob(TRACKER_GZIP_BASE64), (character) =>
34
+ character.charCodeAt(0),
35
+ );
36
+ const VITALS_GZIP = Uint8Array.from(atob(VITALS_GZIP_BASE64), (character) =>
37
+ character.charCodeAt(0),
17
38
  );
18
39
 
19
40
  const http = httpRouter();
@@ -22,46 +43,141 @@ http.route({
22
43
  path: "/events",
23
44
  method: "POST",
24
45
  handler: httpAction(async (ctx, request) => {
46
+ const requestId = crypto.randomUUID();
25
47
  const origin = request.headers.get("origin");
26
- if (!origin) return json({ error: { code: "ORIGIN_REQUIRED" } }, 400);
27
- const bytes = await request.arrayBuffer();
28
- if (bytes.byteLength > MAX_BATCH_BYTES) {
29
- return json({ error: { code: "BATCH_TOO_LARGE", limitBytes: MAX_BATCH_BYTES } }, 413);
48
+ if (!origin) {
49
+ return json(
50
+ { error: { code: "ORIGIN_REQUIRED" } },
51
+ 400,
52
+ requestIdHeaders(requestId),
53
+ );
54
+ }
55
+ const headers = corsHeaders(origin, requestId);
56
+ const contentLength = Number(request.headers.get("content-length"));
57
+ if (Number.isFinite(contentLength) && contentLength > MAX_BATCH_BYTES) {
58
+ return json(
59
+ { error: { code: "BATCH_TOO_LARGE", limitBytes: MAX_BATCH_BYTES } },
60
+ 413,
61
+ headers,
62
+ );
30
63
  }
31
64
 
32
- let payload: { siteId: string; events: TrackerEvent[] };
65
+ let payload: { siteId: string; events: TrackerEvent[]; sentAt?: number };
33
66
  try {
67
+ const bytes = await readLimitedBody(request, MAX_BATCH_BYTES);
34
68
  const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
35
69
  payload = parsePayload(parsed);
36
70
  } catch (error) {
71
+ if (error instanceof PayloadTooLargeError) {
72
+ return json(
73
+ { error: { code: "BATCH_TOO_LARGE", limitBytes: MAX_BATCH_BYTES } },
74
+ 413,
75
+ headers,
76
+ );
77
+ }
37
78
  return json(
38
79
  {
39
80
  error: {
40
81
  code: "INVALID_BODY",
41
- message: error instanceof Error ? error.message : "invalid JSON body",
82
+ message:
83
+ error instanceof Error ? error.message : "invalid JSON body",
42
84
  },
43
85
  },
44
86
  400,
87
+ headers,
45
88
  );
46
89
  }
90
+ // The sender's clock says when its events happened, but a clock minutes
91
+ // off would keep the visitor out of the live view and file the traffic
92
+ // under another hour. The batch also says when it was sent, and that offset
93
+ // re-anchors every event here, before the transaction and the geo
94
+ // enrichment read them, so both see the same timestamps.
95
+ const events = alignClock(payload.events, payload.sentAt, Date.now());
47
96
 
48
- const userAgent = request.headers.get("user-agent") ?? "";
97
+ const trustProxy = env.RASTRO_TRUST_PROXY === "true";
98
+ const metadata = await readRequestMetadata(ctx);
99
+ const proxyRequestTrusted = isTrustedProxyRequest(metadata.ip, trustProxy);
100
+ const clientIp = resolveClientIp(metadata.ip, request.headers, trustProxy);
101
+ const edgeGeo = proxyRequestTrusted ? geoFromHeaders(request) : {};
102
+ const userAgent =
103
+ metadata.userAgent ?? request.headers.get("user-agent") ?? "";
104
+ // Self-declared bots never reach a transaction; the response keeps the
105
+ // counter shape the tracker expects and names the reason in a header.
106
+ if (isKnownBot(userAgent)) {
107
+ return json(
108
+ { accepted: 0, duplicates: 0, rejected: events.length },
109
+ 200,
110
+ { ...headers, "X-Rastro-Rejected": "bot" },
111
+ );
112
+ }
113
+ // Anonymous batches (visitorId === sessionId) get a salted daily key so
114
+ // repeat visits count once; the IP itself stops here.
115
+ const anonymous = events.some(
116
+ (event) => event.visitorId === event.sessionId,
117
+ );
118
+ const visitorKey =
119
+ anonymous && clientIp.ip && userAgent
120
+ ? await deriveVisitorKey({
121
+ secret: await visitorSecret(ctx),
122
+ now: Date.now(),
123
+ siteId: payload.siteId,
124
+ ip: clientIp.ip,
125
+ userAgent,
126
+ })
127
+ : undefined;
49
128
  const context = {
50
- country: firstHeader(request, ["x-vercel-ip-country", "cf-ipcountry"]),
51
- city: decodeHeader(firstHeader(request, ["x-vercel-ip-city", "cf-ipcity"])),
52
- latitude: numericHeader(request, ["x-vercel-ip-latitude", "cf-iplatitude"]),
53
- longitude: numericHeader(request, ["x-vercel-ip-longitude", "cf-iplongitude"]),
54
- ...classifyUserAgent(userAgent),
129
+ ...edgeGeo,
130
+ ...classifyClient(userAgent, request.headers),
131
+ ...(visitorKey ? { visitorKey } : {}),
55
132
  };
56
133
 
57
134
  try {
58
135
  const result = await ctx.runMutation(api.ingest.ingestBatch, {
59
136
  siteId: payload.siteId as Id<"sites">,
60
137
  origin,
61
- events: payload.events,
138
+ events,
62
139
  context,
63
140
  });
64
- return json(result, 200, corsHeaders(origin));
141
+ if (
142
+ clientIp.ip &&
143
+ !hasCoordinates(edgeGeo) &&
144
+ geoIpStatus(env.RASTRO_GEOIP_PROVIDER, env.RASTRO_GEOIP_TOKEN) ===
145
+ "ready"
146
+ ) {
147
+ const candidates = enrichmentCandidateEvents(events);
148
+ if (candidates) {
149
+ let stage: GeoIpFailureStage = "claim";
150
+ try {
151
+ const claim = await ctx.runMutation(internal.geo.claimEnrichment, {
152
+ siteId: payload.siteId as Id<"sites">,
153
+ events: candidates,
154
+ dailyLimit: geoIpDailyLimit(env.RASTRO_GEOIP_DAILY_LIMIT),
155
+ });
156
+ if (claim.sessionIds.length > 0) {
157
+ stage = "resolve";
158
+ const providerGeo = await resolveGeoIp(
159
+ clientIp.ip,
160
+ env.RASTRO_GEOIP_PROVIDER!,
161
+ env.RASTRO_GEOIP_TOKEN,
162
+ );
163
+ if (providerGeo) {
164
+ stage = "apply";
165
+ await ctx.runMutation(internal.geo.applyEnrichment, {
166
+ siteId: payload.siteId as Id<"sites">,
167
+ sessionIds: claim.sessionIds,
168
+ events: claim.events,
169
+ context: providerGeo,
170
+ });
171
+ } else {
172
+ logGeoIpFailure(requestId, stage, env.RASTRO_GEOIP_PROVIDER!);
173
+ }
174
+ }
175
+ } catch {
176
+ logGeoIpFailure(requestId, stage, env.RASTRO_GEOIP_PROVIDER!);
177
+ }
178
+ }
179
+ }
180
+ return json(result, 200, headers);
65
181
  } catch (error) {
66
182
  if (error instanceof ConvexError) {
67
183
  const data = error.data as { code?: string; message?: string };
@@ -71,9 +187,23 @@ http.route({
71
187
  : data.code === "RATE_LIMITED"
72
188
  ? 429
73
189
  : 400;
74
- return json({ error: data }, status);
190
+ return json(
191
+ { error: data },
192
+ status,
193
+ status === 429
194
+ ? {
195
+ ...headers,
196
+ "Retry-After": String(Math.ceil(RATE_LIMIT_WINDOW_MS / 1_000)),
197
+ }
198
+ : headers,
199
+ );
75
200
  }
76
- return json({ error: { code: "INVALID_REQUEST" } }, 400);
201
+ logInternalHttpError(requestId);
202
+ return json(
203
+ { error: { code: "INTERNAL_ERROR", requestId } },
204
+ 500,
205
+ headers,
206
+ );
77
207
  }
78
208
  }),
79
209
  });
@@ -113,13 +243,65 @@ http.route({
113
243
  }),
114
244
  });
115
245
 
246
+ http.route({
247
+ path: "/vitals.js",
248
+ method: "GET",
249
+ handler: httpAction(async () => {
250
+ return new Response(VITALS_GZIP, {
251
+ status: 200,
252
+ headers: {
253
+ "Content-Type": "text/javascript; charset=utf-8",
254
+ "Content-Encoding": "gzip",
255
+ "Content-Length": String(VITALS_GZIP_BYTES),
256
+ "Cache-Control": "public, max-age=300, stale-while-revalidate=86400",
257
+ "Access-Control-Allow-Origin": "*",
258
+ Vary: "Accept-Encoding",
259
+ },
260
+ });
261
+ }),
262
+ });
116
263
 
117
264
  http.route({
118
265
  path: "/health",
119
266
  method: "GET",
120
- handler: httpAction(async () => {
267
+ handler: httpAction(async (ctx, request) => {
268
+ const trustProxy = env.RASTRO_TRUST_PROXY === "true";
269
+ const metadata = await readRequestMetadata(ctx);
270
+ const proxyRequestTrusted = isTrustedProxyRequest(metadata.ip, trustProxy);
271
+ const clientIp = resolveClientIp(metadata.ip, request.headers, trustProxy);
272
+ const provider = env.RASTRO_GEOIP_PROVIDER;
121
273
  return json(
122
- { ok: true, component: "@iann29/rastro" },
274
+ {
275
+ ok: true,
276
+ component: "@iann29/rastro",
277
+ requestMetadata: {
278
+ runtimeIpKind: classifyIp(metadata.ip),
279
+ clientIpAvailable: clientIp.ip !== null,
280
+ clientIpSource: clientIp.source,
281
+ userAgentAvailable: metadata.userAgent !== null,
282
+ },
283
+ geoIp: {
284
+ provider: provider ?? null,
285
+ status: geoIpStatus(provider, env.RASTRO_GEOIP_TOKEN),
286
+ dailyLimit: geoIpDailyLimit(env.RASTRO_GEOIP_DAILY_LIMIT),
287
+ trustedProxy: trustProxy,
288
+ proxyRequestTrusted,
289
+ edgeHeadersAvailable:
290
+ proxyRequestTrusted &&
291
+ Object.keys(geoFromHeaders(request)).length > 0,
292
+ },
293
+ features: {
294
+ botDetection: "userAgent",
295
+ uniqueVisitors:
296
+ clientIp.ip &&
297
+ (metadata.userAgent ?? request.headers.get("user-agent"))
298
+ ? "dailyHash"
299
+ : "session",
300
+ webVitals: "optIn",
301
+ siteMap: "dailyRollup",
302
+ liveDepartures: "leaveBeacon",
303
+ },
304
+ },
123
305
  200,
124
306
  {
125
307
  "Access-Control-Allow-Origin": "*",
@@ -129,11 +311,53 @@ http.route({
129
311
  }),
130
312
  });
131
313
 
132
- function parsePayload(value: unknown): { siteId: string; events: TrackerEvent[] } {
133
- if (!isPlainRecord(value) || typeof value.siteId !== "string" || !Array.isArray(value.events)) {
314
+ function parsePayload(value: unknown): {
315
+ siteId: string;
316
+ events: TrackerEvent[];
317
+ sentAt?: number;
318
+ } {
319
+ if (
320
+ !isPlainRecord(value) ||
321
+ typeof value.siteId !== "string" ||
322
+ !Array.isArray(value.events)
323
+ ) {
134
324
  throw new Error("body must contain siteId and events");
135
325
  }
136
- return { siteId: value.siteId, events: value.events.map(parseEvent) };
326
+ const sentAt = optionalNumber(value.sentAt, "sentAt");
327
+ return {
328
+ siteId: value.siteId,
329
+ events: value.events.map(parseEvent),
330
+ ...(sentAt === undefined ? {} : { sentAt }),
331
+ };
332
+ }
333
+
334
+ class PayloadTooLargeError extends Error {}
335
+
336
+ async function readLimitedBody(
337
+ request: Request,
338
+ maximumBytes: number,
339
+ ): Promise<Uint8Array> {
340
+ if (!request.body) return new Uint8Array();
341
+ const reader = request.body.getReader();
342
+ const chunks: Uint8Array[] = [];
343
+ let length = 0;
344
+ while (true) {
345
+ const { done, value } = await reader.read();
346
+ if (done) break;
347
+ length += value.byteLength;
348
+ if (length > maximumBytes) {
349
+ await reader.cancel();
350
+ throw new PayloadTooLargeError();
351
+ }
352
+ chunks.push(value);
353
+ }
354
+ const result = new Uint8Array(length);
355
+ let offset = 0;
356
+ for (const chunk of chunks) {
357
+ result.set(chunk, offset);
358
+ offset += chunk.byteLength;
359
+ }
360
+ return result;
137
361
  }
138
362
 
139
363
  function parseEvent(value: unknown): TrackerEvent {
@@ -145,7 +369,9 @@ function parseEvent(value: unknown): TrackerEvent {
145
369
  type !== "custom" &&
146
370
  type !== "conversion" &&
147
371
  type !== "heartbeat" &&
148
- type !== "outbound"
372
+ type !== "leave" &&
373
+ type !== "outbound" &&
374
+ type !== "vital"
149
375
  ) {
150
376
  throw new Error("event type is invalid");
151
377
  }
@@ -190,6 +416,9 @@ function parseEvent(value: unknown): TrackerEvent {
190
416
  ...(optionalString(value.affiliateSlug, "affiliateSlug") !== undefined
191
417
  ? { affiliateSlug: optionalString(value.affiliateSlug, "affiliateSlug") }
192
418
  : {}),
419
+ ...(optionalNumber(value.value, "value") !== undefined
420
+ ? { value: optionalNumber(value.value, "value") }
421
+ : {}),
193
422
  };
194
423
  }
195
424
 
@@ -223,6 +452,90 @@ function optionalNumber(value: unknown, field: string): number | undefined {
223
452
  return value;
224
453
  }
225
454
 
455
+ function enrichmentCandidateEvents(events: TrackerEvent[]):
456
+ | Array<{
457
+ eventId: string;
458
+ sessionId: string;
459
+ visitorId: string;
460
+ timestamp: number;
461
+ sequence: number;
462
+ }>
463
+ | undefined {
464
+ if (events.length === 0 || events.length > MAX_BATCH_EVENTS) return undefined;
465
+ const now = Date.now();
466
+ const sanitized = events.flatMap((event) => {
467
+ try {
468
+ return [sanitizeEvent(event, now)];
469
+ } catch {
470
+ return [];
471
+ }
472
+ });
473
+ const sessionIds = [...new Set(sanitized.map((event) => event.sessionId))];
474
+ if (
475
+ !sanitized.some((event) => event.sequence === 0) ||
476
+ sessionIds.length > MAX_BATCH_EVENT_GROUPS ||
477
+ sessionIds.some(
478
+ (sessionId) => sessionId.length === 0 || sessionId.length > 128,
479
+ )
480
+ ) {
481
+ return undefined;
482
+ }
483
+ return sanitized.map(
484
+ ({ eventId, sessionId, visitorId, timestamp, sequence }) => ({
485
+ eventId,
486
+ sessionId,
487
+ visitorId,
488
+ timestamp,
489
+ sequence,
490
+ }),
491
+ );
492
+ }
493
+
494
+ function geoFromHeaders(request: Request): {
495
+ country?: string;
496
+ city?: string;
497
+ latitude?: number;
498
+ longitude?: number;
499
+ } {
500
+ const country = firstHeader(request, ["x-vercel-ip-country", "cf-ipcountry"]);
501
+ const city = decodeHeader(
502
+ firstHeader(request, ["x-vercel-ip-city", "cf-ipcity"]),
503
+ );
504
+ const latitude = numericHeader(request, [
505
+ "x-vercel-ip-latitude",
506
+ "cf-iplatitude",
507
+ ]);
508
+ const longitude = numericHeader(request, [
509
+ "x-vercel-ip-longitude",
510
+ "cf-iplongitude",
511
+ ]);
512
+ return {
513
+ ...(country ? { country } : {}),
514
+ ...(city ? { city } : {}),
515
+ ...(validCoordinates(latitude, longitude) ? { latitude, longitude } : {}),
516
+ };
517
+ }
518
+
519
+ function hasCoordinates(value: {
520
+ latitude?: number;
521
+ longitude?: number;
522
+ }): boolean {
523
+ return value.latitude !== undefined && value.longitude !== undefined;
524
+ }
525
+
526
+ function validCoordinates(
527
+ latitude: number | undefined,
528
+ longitude: number | undefined,
529
+ ): latitude is number {
530
+ return (
531
+ latitude !== undefined &&
532
+ longitude !== undefined &&
533
+ latitude >= -90 &&
534
+ latitude <= 90 &&
535
+ longitude >= -180 &&
536
+ longitude <= 180
537
+ );
538
+ }
226
539
 
227
540
  function firstHeader(request: Request, names: string[]): string | undefined {
228
541
  for (const name of names) {
@@ -248,45 +561,52 @@ function decodeHeader(value: string | undefined): string | undefined {
248
561
  }
249
562
  }
250
563
 
251
- function classifyUserAgent(userAgent: string): {
252
- browser: string;
253
- os: string;
254
- device: string;
255
- } {
256
- const browser = /Edg\//.test(userAgent)
257
- ? "Edge"
258
- : /Firefox\//.test(userAgent)
259
- ? "Firefox"
260
- : /Chrome\//.test(userAgent)
261
- ? "Chrome"
262
- : /Safari\//.test(userAgent)
263
- ? "Safari"
264
- : "Other";
265
- const os = /Android/.test(userAgent)
266
- ? "Android"
267
- : /iPhone|iPad/.test(userAgent)
268
- ? "iOS"
269
- : /Windows/.test(userAgent)
270
- ? "Windows"
271
- : /Mac OS X/.test(userAgent)
272
- ? "macOS"
273
- : /Linux/.test(userAgent)
274
- ? "Linux"
275
- : "Other";
276
- const device = /iPad|Tablet/.test(userAgent)
277
- ? "tablet"
278
- : /Mobile|Android|iPhone/.test(userAgent)
279
- ? "mobile"
280
- : "desktop";
281
- return { browser, os, device };
564
+ type GeoIpFailureStage = "claim" | "resolve" | "apply";
565
+
566
+ function logInternalHttpError(requestId: string): void {
567
+ console.error(
568
+ JSON.stringify({
569
+ level: "error",
570
+ event: "rastro.http.internal_error",
571
+ requestId,
572
+ route: "/events",
573
+ method: "POST",
574
+ }),
575
+ );
282
576
  }
283
577
 
284
- function corsHeaders(origin: string): HeadersInit {
578
+ function logGeoIpFailure(
579
+ requestId: string,
580
+ stage: GeoIpFailureStage,
581
+ provider: "ipinfo" | "ipwhois",
582
+ ): void {
583
+ console.warn(
584
+ JSON.stringify({
585
+ level: "warn",
586
+ event: "rastro.geoip.failure",
587
+ requestId,
588
+ route: "/events",
589
+ stage,
590
+ provider,
591
+ }),
592
+ );
593
+ }
594
+
595
+ function requestIdHeaders(requestId: string): Record<string, string> {
596
+ return { "X-Request-ID": requestId };
597
+ }
598
+
599
+ function corsHeaders(
600
+ origin: string,
601
+ requestId?: string,
602
+ ): Record<string, string> {
285
603
  return {
286
604
  "Access-Control-Allow-Origin": origin,
287
605
  "Access-Control-Allow-Methods": "POST, OPTIONS",
288
606
  "Access-Control-Allow-Headers": "Content-Type",
607
+ "Access-Control-Expose-Headers": "X-Request-ID, Retry-After",
289
608
  "Access-Control-Max-Age": "86400",
609
+ ...(requestId ? requestIdHeaders(requestId) : {}),
290
610
  Vary: "Origin",
291
611
  };
292
612
  }
@@ -299,3 +619,26 @@ function json(value: unknown, status: number, headers?: HeadersInit): Response {
299
619
  }
300
620
 
301
621
  export default http;
622
+
623
+ let cachedVisitorSecret: string | undefined;
624
+
625
+ /** The per-deployment salt behind anonymous visitor keys, created on first use. */
626
+ async function visitorSecret(ctx: {
627
+ runQuery: (
628
+ reference: typeof internal.identity.secret,
629
+ args: Record<string, never>,
630
+ ) => Promise<string | null>;
631
+ runMutation: (
632
+ reference: typeof internal.identity.ensureSecret,
633
+ args: { candidate: string },
634
+ ) => Promise<string>;
635
+ }): Promise<string> {
636
+ if (cachedVisitorSecret) return cachedVisitorSecret;
637
+ const stored = await ctx.runQuery(internal.identity.secret, {});
638
+ cachedVisitorSecret =
639
+ stored ??
640
+ (await ctx.runMutation(internal.identity.ensureSecret, {
641
+ candidate: randomSecret(),
642
+ }));
643
+ return cachedVisitorSecret;
644
+ }