@iann29/rastro 0.1.0-alpha.1 → 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 (163) hide show
  1. package/README.md +521 -66
  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 +16 -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 +25 -1
  29. package/dist/component/constants.d.ts.map +1 -1
  30. package/dist/component/constants.js +38 -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 +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 +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 +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 +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 +248 -49
  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 +604 -55
  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/reports.d.ts +259 -10
  77. package/dist/component/reports.d.ts.map +1 -1
  78. package/dist/component/reports.js +1110 -124
  79. package/dist/component/reports.js.map +1 -1
  80. package/dist/component/retention.d.ts +75 -1
  81. package/dist/component/retention.d.ts.map +1 -1
  82. package/dist/component/retention.js +537 -32
  83. package/dist/component/retention.js.map +1 -1
  84. package/dist/component/sanitize.d.ts +24 -1
  85. package/dist/component/sanitize.d.ts.map +1 -1
  86. package/dist/component/sanitize.js +96 -16
  87. package/dist/component/sanitize.js.map +1 -1
  88. package/dist/component/schema.d.ts +378 -62
  89. package/dist/component/schema.js +178 -20
  90. package/dist/component/schema.js.map +1 -1
  91. package/dist/component/sites.d.ts.map +1 -1
  92. package/dist/component/sites.js +11 -7
  93. package/dist/component/sites.js.map +1 -1
  94. package/dist/component/useragent.d.ts +9 -0
  95. package/dist/component/useragent.d.ts.map +1 -0
  96. package/dist/component/useragent.js +152 -0
  97. package/dist/component/useragent.js.map +1 -0
  98. package/dist/component/validators.d.ts +101 -65
  99. package/dist/component/validators.d.ts.map +1 -1
  100. package/dist/component/validators.js +19 -13
  101. package/dist/component/validators.js.map +1 -1
  102. package/dist/component/visitors.d.ts +19 -0
  103. package/dist/component/visitors.d.ts.map +1 -0
  104. package/dist/component/visitors.js +86 -0
  105. package/dist/component/visitors.js.map +1 -0
  106. package/dist/component/vitals.d.ts +41 -0
  107. package/dist/component/vitals.d.ts.map +1 -0
  108. package/dist/component/vitals.js +115 -0
  109. package/dist/component/vitals.js.map +1 -0
  110. package/dist/react/index.d.ts.map +1 -1
  111. package/dist/react/index.js.map +1 -1
  112. package/dist/tracker/generated.d.ts +8 -4
  113. package/dist/tracker/generated.d.ts.map +1 -1
  114. package/dist/tracker/generated.js +8 -4
  115. package/dist/tracker/generated.js.map +1 -1
  116. package/dist/tracker/tracker.d.ts +1 -1
  117. package/dist/tracker/tracker.d.ts.map +1 -1
  118. package/dist/tracker/tracker.js +57 -20
  119. package/dist/tracker/tracker.js.map +1 -1
  120. package/dist/tracker/vitals.d.ts +10 -0
  121. package/dist/tracker/vitals.d.ts.map +1 -0
  122. package/dist/tracker/vitals.js +140 -0
  123. package/dist/tracker/vitals.js.map +1 -0
  124. package/dist/tracker.min.js +1 -1
  125. package/dist/vitals.min.js +1 -0
  126. package/docs/benchmarks/2026-08-20-realistic.md +76 -76
  127. package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
  128. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  129. package/docs/federation-setup.md +397 -0
  130. package/docs/federation.md +262 -0
  131. package/docs/upgrading.md +259 -0
  132. package/llms.txt +71 -0
  133. package/package.json +55 -11
  134. package/scripts/benchmark-ingest.mjs +175 -73
  135. package/scripts/generate-federation-keys.mjs +20 -0
  136. package/src/component/_generated/api.ts +16 -0
  137. package/src/component/_generated/component.ts +366 -4
  138. package/src/component/_generated/server.ts +4 -0
  139. package/src/component/affiliates.ts +20 -5
  140. package/src/component/cardinality.ts +117 -0
  141. package/src/component/constants.ts +38 -1
  142. package/src/component/convex.config.ts +11 -1
  143. package/src/component/coverage.ts +36 -0
  144. package/src/component/diagnostics.ts +65 -0
  145. package/src/component/errors.ts +2 -1
  146. package/src/component/eventStore.ts +217 -193
  147. package/src/component/funnels.ts +19 -16
  148. package/src/component/geo.ts +781 -0
  149. package/src/component/goals.ts +29 -21
  150. package/src/component/guards.ts +3 -1
  151. package/src/component/http.ts +357 -57
  152. package/src/component/identity.ts +74 -0
  153. package/src/component/ingest.ts +1040 -135
  154. package/src/component/live.ts +13 -7
  155. package/src/component/reports.ts +1675 -183
  156. package/src/component/retention.ts +770 -96
  157. package/src/component/sanitize.ts +144 -29
  158. package/src/component/schema.ts +200 -21
  159. package/src/component/sites.ts +27 -12
  160. package/src/component/useragent.ts +171 -0
  161. package/src/component/validators.ts +29 -13
  162. package/src/component/visitors.ts +116 -0
  163. 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
@@ -69,14 +71,16 @@ export const upsert = mutation({
69
71
  const now = Date.now();
70
72
  if (goalId) {
71
73
  const goal = await ctx.db.get("goals", goalId);
72
- 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");
73
76
  if (
74
77
  (args.active ?? goal.active) &&
75
- goals.some((candidate) =>
76
- candidate._id !== goalId &&
77
- candidate.active &&
78
- candidate.matchType === args.matchType &&
79
- candidate.matchValue === matchValue
78
+ goals.some(
79
+ (candidate) =>
80
+ candidate._id !== goalId &&
81
+ candidate.active &&
82
+ candidate.matchType === args.matchType &&
83
+ candidate.matchValue === matchValue,
80
84
  )
81
85
  ) {
82
86
  fail("CONFLICT", "another active goal already uses this matcher");
@@ -95,16 +99,19 @@ export const upsert = mutation({
95
99
 
96
100
  if (
97
101
  (args.active ?? true) &&
98
- goals.some((candidate) =>
99
- candidate.active &&
100
- candidate.matchType === args.matchType &&
101
- candidate.matchValue === matchValue
102
+ goals.some(
103
+ (candidate) =>
104
+ candidate.active &&
105
+ candidate.matchType === args.matchType &&
106
+ candidate.matchValue === matchValue,
102
107
  )
103
108
  ) {
104
109
  fail("CONFLICT", "another active goal already uses this matcher");
105
110
  }
106
111
  if (goals.length >= MAX_GOALS_PER_SITE) {
107
- 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
+ });
108
115
  }
109
116
  return await ctx.db.insert("goals", {
110
117
  siteId: args.siteId,
@@ -136,7 +143,8 @@ export const remove = mutation({
136
143
  returns: v.null(),
137
144
  handler: async (ctx, args) => {
138
145
  const goal = await ctx.db.get("goals", args.goalId);
139
- 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");
140
148
  await ctx.db.delete("goals", args.goalId);
141
149
  return null;
142
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,9 +43,16 @@ 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 headers = corsHeaders(origin);
48
+ if (!origin) {
49
+ return json(
50
+ { error: { code: "ORIGIN_REQUIRED" } },
51
+ 400,
52
+ requestIdHeaders(requestId),
53
+ );
54
+ }
55
+ const headers = corsHeaders(origin, requestId);
28
56
  const contentLength = Number(request.headers.get("content-length"));
29
57
  if (Number.isFinite(contentLength) && contentLength > MAX_BATCH_BYTES) {
30
58
  return json(
@@ -34,7 +62,7 @@ http.route({
34
62
  );
35
63
  }
36
64
 
37
- let payload: { siteId: string; events: TrackerEvent[] };
65
+ let payload: { siteId: string; events: TrackerEvent[]; sentAt?: number };
38
66
  try {
39
67
  const bytes = await readLimitedBody(request, MAX_BATCH_BYTES);
40
68
  const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
@@ -51,30 +79,104 @@ http.route({
51
79
  {
52
80
  error: {
53
81
  code: "INVALID_BODY",
54
- message: error instanceof Error ? error.message : "invalid JSON body",
82
+ message:
83
+ error instanceof Error ? error.message : "invalid JSON body",
55
84
  },
56
85
  },
57
86
  400,
58
87
  headers,
59
88
  );
60
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());
61
96
 
62
- 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;
63
128
  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),
129
+ ...edgeGeo,
130
+ ...classifyClient(userAgent, request.headers),
131
+ ...(visitorKey ? { visitorKey } : {}),
69
132
  };
70
133
 
71
134
  try {
72
135
  const result = await ctx.runMutation(api.ingest.ingestBatch, {
73
136
  siteId: payload.siteId as Id<"sites">,
74
137
  origin,
75
- events: payload.events,
138
+ events,
76
139
  context,
77
140
  });
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
+ }
78
180
  return json(result, 200, headers);
79
181
  } catch (error) {
80
182
  if (error instanceof ConvexError) {
@@ -85,9 +187,23 @@ http.route({
85
187
  : data.code === "RATE_LIMITED"
86
188
  ? 429
87
189
  : 400;
88
- return json({ error: data }, status, headers);
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
+ );
89
200
  }
90
- return json({ error: { code: "INVALID_REQUEST" } }, 400, headers);
201
+ logInternalHttpError(requestId);
202
+ return json(
203
+ { error: { code: "INTERNAL_ERROR", requestId } },
204
+ 500,
205
+ headers,
206
+ );
91
207
  }
92
208
  }),
93
209
  });
@@ -127,13 +243,65 @@ http.route({
127
243
  }),
128
244
  });
129
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
+ });
130
263
 
131
264
  http.route({
132
265
  path: "/health",
133
266
  method: "GET",
134
- 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;
135
273
  return json(
136
- { 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
+ },
137
305
  200,
138
306
  {
139
307
  "Access-Control-Allow-Origin": "*",
@@ -143,11 +311,24 @@ http.route({
143
311
  }),
144
312
  });
145
313
 
146
- function parsePayload(value: unknown): { siteId: string; events: TrackerEvent[] } {
147
- 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
+ ) {
148
324
  throw new Error("body must contain siteId and events");
149
325
  }
150
- 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
+ };
151
332
  }
152
333
 
153
334
  class PayloadTooLargeError extends Error {}
@@ -188,7 +369,9 @@ function parseEvent(value: unknown): TrackerEvent {
188
369
  type !== "custom" &&
189
370
  type !== "conversion" &&
190
371
  type !== "heartbeat" &&
191
- type !== "outbound"
372
+ type !== "leave" &&
373
+ type !== "outbound" &&
374
+ type !== "vital"
192
375
  ) {
193
376
  throw new Error("event type is invalid");
194
377
  }
@@ -233,6 +416,9 @@ function parseEvent(value: unknown): TrackerEvent {
233
416
  ...(optionalString(value.affiliateSlug, "affiliateSlug") !== undefined
234
417
  ? { affiliateSlug: optionalString(value.affiliateSlug, "affiliateSlug") }
235
418
  : {}),
419
+ ...(optionalNumber(value.value, "value") !== undefined
420
+ ? { value: optionalNumber(value.value, "value") }
421
+ : {}),
236
422
  };
237
423
  }
238
424
 
@@ -266,6 +452,90 @@ function optionalNumber(value: unknown, field: string): number | undefined {
266
452
  return value;
267
453
  }
268
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
+ }
269
539
 
270
540
  function firstHeader(request: Request, names: string[]): string | undefined {
271
541
  for (const name of names) {
@@ -291,45 +561,52 @@ function decodeHeader(value: string | undefined): string | undefined {
291
561
  }
292
562
  }
293
563
 
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 };
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
+ );
576
+ }
577
+
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 };
325
597
  }
326
598
 
327
- function corsHeaders(origin: string): HeadersInit {
599
+ function corsHeaders(
600
+ origin: string,
601
+ requestId?: string,
602
+ ): Record<string, string> {
328
603
  return {
329
604
  "Access-Control-Allow-Origin": origin,
330
605
  "Access-Control-Allow-Methods": "POST, OPTIONS",
331
606
  "Access-Control-Allow-Headers": "Content-Type",
607
+ "Access-Control-Expose-Headers": "X-Request-ID, Retry-After",
332
608
  "Access-Control-Max-Age": "86400",
609
+ ...(requestId ? requestIdHeaders(requestId) : {}),
333
610
  Vary: "Origin",
334
611
  };
335
612
  }
@@ -342,3 +619,26 @@ function json(value: unknown, status: number, headers?: HeadersInit): Response {
342
619
  }
343
620
 
344
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
+ }