@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.
- package/README.md +579 -57
- package/agent/integration.md +661 -0
- package/agent/manifest.json +187 -0
- package/agent/manifest.schema.json +411 -0
- package/dist/client/federation.d.ts +236 -0
- package/dist/client/federation.d.ts.map +1 -0
- package/dist/client/federation.js +196 -0
- package/dist/client/federation.js.map +1 -0
- package/dist/client/index.d.ts +2467 -18
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +461 -32
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +18 -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 +302 -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 +34 -7
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +47 -7
- 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 +19 -0
- package/dist/component/coverage.d.ts.map +1 -0
- package/dist/component/coverage.js +30 -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 +106 -0
- package/dist/component/eventStore.d.ts.map +1 -0
- package/dist/component/eventStore.js +294 -0
- package/dist/component/eventStore.js.map +1 -0
- 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 +71 -0
- package/dist/component/geo.d.ts.map +1 -0
- package/dist/component/geo.js +611 -0
- package/dist/component/geo.js.map +1 -0
- package/dist/component/goals.d.ts.map +1 -1
- package/dist/component/goals.js +25 -9
- 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 +284 -53
- 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 +1105 -243
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts +8 -0
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +74 -9
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +273 -24
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +1320 -150
- 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 +541 -31
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts +24 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +107 -19
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +462 -55
- package/dist/component/schema.js +195 -15
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +38 -25
- 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 +101 -16
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +19 -2
- 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 +8 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +8 -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 +81 -33
- 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 +171 -0
- 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 +397 -0
- package/docs/federation.md +262 -0
- package/docs/upgrading.md +259 -0
- package/llms.txt +71 -0
- package/package.json +61 -11
- package/scripts/benchmark-ingest.mjs +601 -0
- package/scripts/generate-federation-keys.mjs +20 -0
- package/src/component/_generated/api.ts +18 -0
- package/src/component/_generated/component.ts +366 -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 +47 -7
- package/src/component/convex.config.ts +11 -1
- package/src/component/coverage.ts +36 -0
- package/src/component/diagnostics.ts +65 -0
- package/src/component/errors.ts +2 -1
- package/src/component/eventStore.ts +489 -0
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +781 -0
- package/src/component/goals.ts +45 -16
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +403 -60
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +1652 -291
- package/src/component/live.ts +90 -9
- package/src/component/reports.ts +1929 -199
- package/src/component/retention.ts +774 -97
- package/src/component/sanitize.ts +154 -32
- package/src/component/schema.ts +219 -15
- package/src/component/sites.ts +56 -28
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +29 -1
- package/src/component/visitors.ts +116 -0
- package/src/component/vitals.ts +146 -0
package/src/component/goals.ts
CHANGED
|
@@ -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)))
|
|
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 =
|
|
40
|
-
|
|
41
|
-
|
|
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(
|
|
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)
|
|
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
|
-
|
|
82
|
-
.
|
|
83
|
-
.
|
|
84
|
-
|
|
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", {
|
|
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)
|
|
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
|
},
|
package/src/component/guards.ts
CHANGED
package/src/component/http.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
16
|
-
|
|
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)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
138
|
+
events,
|
|
62
139
|
context,
|
|
63
140
|
});
|
|
64
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
{
|
|
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): {
|
|
133
|
-
|
|
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
|
-
|
|
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 !== "
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
:
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
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
|
+
}
|