@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.
- package/README.md +521 -66
- 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 +16 -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 +25 -1
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +38 -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 +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 +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 +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 +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 +248 -49
- 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 +604 -55
- 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/reports.d.ts +259 -10
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +1110 -124
- 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 +537 -32
- 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 +96 -16
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +378 -62
- package/dist/component/schema.js +178 -20
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +11 -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 +101 -65
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +19 -13
- 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 +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 +397 -0
- package/docs/federation.md +262 -0
- package/docs/upgrading.md +259 -0
- package/llms.txt +71 -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 +16 -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 +38 -1
- 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 +217 -193
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +781 -0
- package/src/component/goals.ts +29 -21
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +357 -57
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +1040 -135
- package/src/component/live.ts +13 -7
- package/src/component/reports.ts +1675 -183
- package/src/component/retention.ts +770 -96
- package/src/component/sanitize.ts +144 -29
- package/src/component/schema.ts +200 -21
- package/src/component/sites.ts +27 -12
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +29 -13
- 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
|
|
@@ -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)
|
|
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(
|
|
76
|
-
candidate
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(
|
|
99
|
-
candidate
|
|
100
|
-
|
|
101
|
-
|
|
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", {
|
|
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)
|
|
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
|
},
|
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,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)
|
|
27
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
{
|
|
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): {
|
|
147
|
-
|
|
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
|
-
|
|
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 !== "
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
:
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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(
|
|
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
|
+
}
|