@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.
- package/README.md +693 -69
- package/agent/integration.md +801 -0
- package/agent/manifest.json +205 -0
- package/agent/manifest.schema.json +444 -0
- package/dist/client/federation.d.ts +381 -0
- package/dist/client/federation.d.ts.map +1 -0
- package/dist/client/federation.js +274 -0
- package/dist/client/federation.js.map +1 -0
- package/dist/client/index.d.ts +2673 -15
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +565 -33
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +22 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +315 -4
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +4 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/affiliates.d.ts.map +1 -1
- package/dist/component/affiliates.js +6 -2
- package/dist/component/affiliates.js.map +1 -1
- package/dist/component/cardinality.d.ts +12 -0
- package/dist/component/cardinality.d.ts.map +1 -0
- package/dist/component/cardinality.js +94 -0
- package/dist/component/cardinality.js.map +1 -0
- package/dist/component/constants.d.ts +28 -1
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +44 -1
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +6 -1
- package/dist/component/convex.config.js +9 -1
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/coverage.d.ts +27 -0
- package/dist/component/coverage.d.ts.map +1 -0
- package/dist/component/coverage.js +56 -0
- package/dist/component/coverage.js.map +1 -0
- package/dist/component/diagnostics.d.ts +9 -0
- package/dist/component/diagnostics.d.ts.map +1 -0
- package/dist/component/diagnostics.js +47 -0
- package/dist/component/diagnostics.js.map +1 -0
- package/dist/component/errors.d.ts +1 -1
- package/dist/component/errors.d.ts.map +1 -1
- package/dist/component/errors.js.map +1 -1
- package/dist/component/eventStore.d.ts +21 -9
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/eventStore.js +142 -152
- package/dist/component/eventStore.js.map +1 -1
- package/dist/component/funnels.d.ts.map +1 -1
- package/dist/component/funnels.js +5 -3
- package/dist/component/funnels.js.map +1 -1
- package/dist/component/geo.d.ts +73 -0
- package/dist/component/geo.d.ts.map +1 -0
- package/dist/component/geo.js +648 -0
- package/dist/component/geo.js.map +1 -0
- package/dist/component/goals.d.ts.map +1 -1
- package/dist/component/goals.js +8 -5
- package/dist/component/goals.js.map +1 -1
- package/dist/component/guards.d.ts.map +1 -1
- package/dist/component/guards.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +281 -62
- package/dist/component/http.js.map +1 -1
- package/dist/component/identity.d.ts +13 -0
- package/dist/component/identity.d.ts.map +1 -0
- package/dist/component/identity.js +58 -0
- package/dist/component/identity.js.map +1 -0
- package/dist/component/ingest.d.ts +3 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +498 -95
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +7 -6
- package/dist/component/live.js.map +1 -1
- package/dist/component/localTime.d.ts +25 -0
- package/dist/component/localTime.d.ts.map +1 -0
- package/dist/component/localTime.js +126 -0
- package/dist/component/localTime.js.map +1 -0
- package/dist/component/reports.d.ts +266 -10
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +1243 -137
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts +75 -1
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +561 -38
- package/dist/component/retention.js.map +1 -1
- package/dist/component/rollupStore.d.ts +320 -0
- package/dist/component/rollupStore.d.ts.map +1 -0
- package/dist/component/rollupStore.js +596 -0
- package/dist/component/rollupStore.js.map +1 -0
- package/dist/component/rollups.d.ts +20 -0
- package/dist/component/rollups.d.ts.map +1 -0
- package/dist/component/rollups.js +73 -0
- package/dist/component/rollups.js.map +1 -0
- package/dist/component/sanitize.d.ts +24 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +96 -16
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +687 -65
- package/dist/component/schema.js +187 -20
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts +12 -0
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +41 -7
- package/dist/component/sites.js.map +1 -1
- package/dist/component/useragent.d.ts +9 -0
- package/dist/component/useragent.d.ts.map +1 -0
- package/dist/component/useragent.js +152 -0
- package/dist/component/useragent.js.map +1 -0
- package/dist/component/validators.d.ts +124 -69
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +35 -14
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts +19 -0
- package/dist/component/visitors.d.ts.map +1 -0
- package/dist/component/visitors.js +86 -0
- package/dist/component/visitors.js.map +1 -0
- package/dist/component/vitals.d.ts +41 -0
- package/dist/component/vitals.d.ts.map +1 -0
- package/dist/component/vitals.js +115 -0
- package/dist/component/vitals.js.map +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/tracker/generated.d.ts +11 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +11 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts +1 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +57 -20
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker/vitals.d.ts +10 -0
- package/dist/tracker/vitals.d.ts.map +1 -0
- package/dist/tracker/vitals.js +140 -0
- package/dist/tracker/vitals.js.map +1 -0
- package/dist/tracker.min.js +1 -1
- package/dist/vitals.min.js +1 -0
- package/docs/benchmarks/2026-08-20-realistic.md +76 -76
- package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
- package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
- package/docs/federation-setup.md +464 -0
- package/docs/federation.md +352 -0
- package/docs/upgrading.md +344 -0
- package/llms.txt +72 -0
- package/package.json +55 -11
- package/scripts/benchmark-ingest.mjs +175 -73
- package/scripts/generate-federation-keys.mjs +20 -0
- package/src/component/_generated/api.ts +22 -0
- package/src/component/_generated/component.ts +381 -4
- package/src/component/_generated/server.ts +4 -0
- package/src/component/affiliates.ts +20 -5
- package/src/component/cardinality.ts +117 -0
- package/src/component/constants.ts +44 -1
- package/src/component/convex.config.ts +11 -1
- package/src/component/coverage.ts +71 -0
- package/src/component/diagnostics.ts +65 -0
- package/src/component/errors.ts +2 -1
- package/src/component/eventStore.ts +217 -193
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +835 -0
- package/src/component/goals.ts +29 -21
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +404 -70
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +894 -188
- package/src/component/live.ts +13 -7
- package/src/component/localTime.ts +167 -0
- package/src/component/reports.ts +1872 -197
- package/src/component/retention.ts +788 -96
- package/src/component/rollupStore.ts +799 -0
- package/src/component/rollups.ts +82 -0
- package/src/component/sanitize.ts +144 -29
- package/src/component/schema.ts +217 -21
- package/src/component/sites.ts +59 -12
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +49 -14
- package/src/component/visitors.ts +116 -0
- package/src/component/vitals.ts +146 -0
package/src/component/http.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
16
|
-
|
|
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)
|
|
27
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
{
|
|
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): {
|
|
147
|
-
|
|
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
|
-
|
|
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 !== "
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
:
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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
|
+
}
|