@iann29/rastro 0.1.0-alpha.5 → 0.1.0-alpha.7
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 +82 -29
- package/agent/integration.md +17 -15
- package/agent/manifest.json +7 -5
- package/agent/manifest.schema.json +8 -5
- package/dist/client/federation.d.ts +34 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +17 -3
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +418 -21
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +191 -41
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +2 -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 +63 -7
- package/dist/component/_generated/component.d.ts.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.map +1 -1
- package/dist/component/cardinality.js.map +1 -1
- package/dist/component/constants.d.ts +7 -1
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +13 -1
- package/dist/component/constants.js.map +1 -1
- package/dist/component/diagnostics.d.ts.map +1 -1
- package/dist/component/diagnostics.js.map +1 -1
- package/dist/component/eventStore.d.ts +2 -1
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/eventStore.js +13 -10
- 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.map +1 -1
- package/dist/component/geo.js +3 -2
- package/dist/component/geo.js.map +1 -1
- 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 +51 -17
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts +2 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +143 -23
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +1 -3
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +60 -3
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +215 -49
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts +4 -4
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +54 -22
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts +11 -0
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +58 -11
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +47 -5
- package/dist/component/schema.js +21 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +6 -6
- package/dist/component/sites.js.map +1 -1
- package/dist/component/validators.d.ts +14 -9
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +5 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts.map +1 -1
- package/dist/component/visitors.js.map +1 -1
- 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.map +1 -1
- package/dist/tracker/tracker.js +3 -1
- 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 +71 -71
- package/docs/benchmarks/2026-08-21-formal-certification.md +50 -30
- package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
- package/docs/upgrading.md +46 -8
- package/llms.txt +5 -1
- package/package.json +17 -3
- package/scripts/benchmark-ingest.mjs +101 -48
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +69 -4
- package/src/component/affiliates.ts +20 -5
- package/src/component/cardinality.ts +8 -7
- package/src/component/constants.ts +13 -1
- package/src/component/diagnostics.ts +3 -2
- package/src/component/eventStore.ts +53 -46
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +16 -14
- package/src/component/goals.ts +29 -21
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +93 -54
- package/src/component/ingest.ts +320 -127
- package/src/component/live.ts +5 -4
- package/src/component/reports.ts +563 -219
- package/src/component/retention.ts +221 -98
- package/src/component/sanitize.ts +99 -29
- package/src/component/schema.ts +31 -5
- package/src/component/sites.ts +22 -11
- package/src/component/validators.ts +16 -7
- package/src/component/visitors.ts +16 -5
- package/src/component/vitals.ts +146 -0
package/src/component/http.ts
CHANGED
|
@@ -19,18 +19,23 @@ import {
|
|
|
19
19
|
resolveGeoIp,
|
|
20
20
|
} from "./geo.js";
|
|
21
21
|
import { isPlainRecord } from "./guards.js";
|
|
22
|
-
import { sanitizeEvent } from "./sanitize.js";
|
|
22
|
+
import { alignClock, sanitizeEvent } from "./sanitize.js";
|
|
23
23
|
import { deriveVisitorKey, randomSecret } from "./identity.js";
|
|
24
24
|
import { classifyClient, isKnownBot } from "./useragent.js";
|
|
25
25
|
import type { TrackerEvent } from "./validators.js";
|
|
26
26
|
import {
|
|
27
27
|
TRACKER_GZIP_BASE64,
|
|
28
28
|
TRACKER_GZIP_BYTES,
|
|
29
|
+
VITALS_GZIP_BASE64,
|
|
30
|
+
VITALS_GZIP_BYTES,
|
|
29
31
|
} from "../tracker/generated.js";
|
|
30
32
|
|
|
31
33
|
const TRACKER_GZIP = Uint8Array.from(atob(TRACKER_GZIP_BASE64), (character) =>
|
|
32
34
|
character.charCodeAt(0),
|
|
33
35
|
);
|
|
36
|
+
const VITALS_GZIP = Uint8Array.from(atob(VITALS_GZIP_BASE64), (character) =>
|
|
37
|
+
character.charCodeAt(0),
|
|
38
|
+
);
|
|
34
39
|
|
|
35
40
|
const http = httpRouter();
|
|
36
41
|
|
|
@@ -57,7 +62,7 @@ http.route({
|
|
|
57
62
|
);
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
let payload: { siteId: string; events: TrackerEvent[] };
|
|
65
|
+
let payload: { siteId: string; events: TrackerEvent[]; sentAt?: number };
|
|
61
66
|
try {
|
|
62
67
|
const bytes = await readLimitedBody(request, MAX_BATCH_BYTES);
|
|
63
68
|
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
|
@@ -82,6 +87,12 @@ http.route({
|
|
|
82
87
|
headers,
|
|
83
88
|
);
|
|
84
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());
|
|
85
96
|
|
|
86
97
|
const trustProxy = env.RASTRO_TRUST_PROXY === "true";
|
|
87
98
|
const metadata = await readRequestMetadata(ctx);
|
|
@@ -94,14 +105,14 @@ http.route({
|
|
|
94
105
|
// counter shape the tracker expects and names the reason in a header.
|
|
95
106
|
if (isKnownBot(userAgent)) {
|
|
96
107
|
return json(
|
|
97
|
-
{ accepted: 0, duplicates: 0, rejected:
|
|
108
|
+
{ accepted: 0, duplicates: 0, rejected: events.length },
|
|
98
109
|
200,
|
|
99
110
|
{ ...headers, "X-Rastro-Rejected": "bot" },
|
|
100
111
|
);
|
|
101
112
|
}
|
|
102
113
|
// Anonymous batches (visitorId === sessionId) get a salted daily key so
|
|
103
114
|
// repeat visits count once; the IP itself stops here.
|
|
104
|
-
const anonymous =
|
|
115
|
+
const anonymous = events.some(
|
|
105
116
|
(event) => event.visitorId === event.sessionId,
|
|
106
117
|
);
|
|
107
118
|
const visitorKey =
|
|
@@ -124,7 +135,7 @@ http.route({
|
|
|
124
135
|
const result = await ctx.runMutation(api.ingest.ingestBatch, {
|
|
125
136
|
siteId: payload.siteId as Id<"sites">,
|
|
126
137
|
origin,
|
|
127
|
-
events
|
|
138
|
+
events,
|
|
128
139
|
context,
|
|
129
140
|
});
|
|
130
141
|
if (
|
|
@@ -133,18 +144,15 @@ http.route({
|
|
|
133
144
|
geoIpStatus(env.RASTRO_GEOIP_PROVIDER, env.RASTRO_GEOIP_TOKEN) ===
|
|
134
145
|
"ready"
|
|
135
146
|
) {
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
147
|
+
const candidates = enrichmentCandidateEvents(events);
|
|
148
|
+
if (candidates) {
|
|
138
149
|
let stage: GeoIpFailureStage = "claim";
|
|
139
150
|
try {
|
|
140
|
-
const claim = await ctx.runMutation(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
dailyLimit: geoIpDailyLimit(env.RASTRO_GEOIP_DAILY_LIMIT),
|
|
146
|
-
},
|
|
147
|
-
);
|
|
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
|
+
});
|
|
148
156
|
if (claim.sessionIds.length > 0) {
|
|
149
157
|
stage = "resolve";
|
|
150
158
|
const providerGeo = await resolveGeoIp(
|
|
@@ -161,19 +169,11 @@ http.route({
|
|
|
161
169
|
context: providerGeo,
|
|
162
170
|
});
|
|
163
171
|
} else {
|
|
164
|
-
logGeoIpFailure(
|
|
165
|
-
requestId,
|
|
166
|
-
stage,
|
|
167
|
-
env.RASTRO_GEOIP_PROVIDER!,
|
|
168
|
-
);
|
|
172
|
+
logGeoIpFailure(requestId, stage, env.RASTRO_GEOIP_PROVIDER!);
|
|
169
173
|
}
|
|
170
174
|
}
|
|
171
175
|
} catch {
|
|
172
|
-
logGeoIpFailure(
|
|
173
|
-
requestId,
|
|
174
|
-
stage,
|
|
175
|
-
env.RASTRO_GEOIP_PROVIDER!,
|
|
176
|
-
);
|
|
176
|
+
logGeoIpFailure(requestId, stage, env.RASTRO_GEOIP_PROVIDER!);
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
}
|
|
@@ -193,9 +193,7 @@ http.route({
|
|
|
193
193
|
status === 429
|
|
194
194
|
? {
|
|
195
195
|
...headers,
|
|
196
|
-
"Retry-After": String(
|
|
197
|
-
Math.ceil(RATE_LIMIT_WINDOW_MS / 1_000),
|
|
198
|
-
),
|
|
196
|
+
"Retry-After": String(Math.ceil(RATE_LIMIT_WINDOW_MS / 1_000)),
|
|
199
197
|
}
|
|
200
198
|
: headers,
|
|
201
199
|
);
|
|
@@ -245,6 +243,24 @@ http.route({
|
|
|
245
243
|
}),
|
|
246
244
|
});
|
|
247
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
|
+
});
|
|
263
|
+
|
|
248
264
|
http.route({
|
|
249
265
|
path: "/health",
|
|
250
266
|
method: "GET",
|
|
@@ -271,14 +287,17 @@ http.route({
|
|
|
271
287
|
trustedProxy: trustProxy,
|
|
272
288
|
proxyRequestTrusted,
|
|
273
289
|
edgeHeadersAvailable:
|
|
274
|
-
proxyRequestTrusted &&
|
|
290
|
+
proxyRequestTrusted &&
|
|
291
|
+
Object.keys(geoFromHeaders(request)).length > 0,
|
|
275
292
|
},
|
|
276
293
|
features: {
|
|
277
294
|
botDetection: "userAgent",
|
|
278
295
|
uniqueVisitors:
|
|
279
|
-
clientIp.ip &&
|
|
296
|
+
clientIp.ip &&
|
|
297
|
+
(metadata.userAgent ?? request.headers.get("user-agent"))
|
|
280
298
|
? "dailyHash"
|
|
281
299
|
: "session",
|
|
300
|
+
webVitals: "optIn",
|
|
282
301
|
},
|
|
283
302
|
},
|
|
284
303
|
200,
|
|
@@ -293,6 +312,7 @@ http.route({
|
|
|
293
312
|
function parsePayload(value: unknown): {
|
|
294
313
|
siteId: string;
|
|
295
314
|
events: TrackerEvent[];
|
|
315
|
+
sentAt?: number;
|
|
296
316
|
} {
|
|
297
317
|
if (
|
|
298
318
|
!isPlainRecord(value) ||
|
|
@@ -301,7 +321,12 @@ function parsePayload(value: unknown): {
|
|
|
301
321
|
) {
|
|
302
322
|
throw new Error("body must contain siteId and events");
|
|
303
323
|
}
|
|
304
|
-
|
|
324
|
+
const sentAt = optionalNumber(value.sentAt, "sentAt");
|
|
325
|
+
return {
|
|
326
|
+
siteId: value.siteId,
|
|
327
|
+
events: value.events.map(parseEvent),
|
|
328
|
+
...(sentAt === undefined ? {} : { sentAt }),
|
|
329
|
+
};
|
|
305
330
|
}
|
|
306
331
|
|
|
307
332
|
class PayloadTooLargeError extends Error {}
|
|
@@ -342,7 +367,8 @@ function parseEvent(value: unknown): TrackerEvent {
|
|
|
342
367
|
type !== "custom" &&
|
|
343
368
|
type !== "conversion" &&
|
|
344
369
|
type !== "heartbeat" &&
|
|
345
|
-
type !== "outbound"
|
|
370
|
+
type !== "outbound" &&
|
|
371
|
+
type !== "vital"
|
|
346
372
|
) {
|
|
347
373
|
throw new Error("event type is invalid");
|
|
348
374
|
}
|
|
@@ -387,6 +413,9 @@ function parseEvent(value: unknown): TrackerEvent {
|
|
|
387
413
|
...(optionalString(value.affiliateSlug, "affiliateSlug") !== undefined
|
|
388
414
|
? { affiliateSlug: optionalString(value.affiliateSlug, "affiliateSlug") }
|
|
389
415
|
: {}),
|
|
416
|
+
...(optionalNumber(value.value, "value") !== undefined
|
|
417
|
+
? { value: optionalNumber(value.value, "value") }
|
|
418
|
+
: {}),
|
|
390
419
|
};
|
|
391
420
|
}
|
|
392
421
|
|
|
@@ -480,9 +509,7 @@ function geoFromHeaders(request: Request): {
|
|
|
480
509
|
return {
|
|
481
510
|
...(country ? { country } : {}),
|
|
482
511
|
...(city ? { city } : {}),
|
|
483
|
-
...(validCoordinates(latitude, longitude)
|
|
484
|
-
? { latitude, longitude }
|
|
485
|
-
: {}),
|
|
512
|
+
...(validCoordinates(latitude, longitude) ? { latitude, longitude } : {}),
|
|
486
513
|
};
|
|
487
514
|
}
|
|
488
515
|
|
|
@@ -497,12 +524,14 @@ function validCoordinates(
|
|
|
497
524
|
latitude: number | undefined,
|
|
498
525
|
longitude: number | undefined,
|
|
499
526
|
): latitude is number {
|
|
500
|
-
return
|
|
527
|
+
return (
|
|
528
|
+
latitude !== undefined &&
|
|
501
529
|
longitude !== undefined &&
|
|
502
530
|
latitude >= -90 &&
|
|
503
531
|
latitude <= 90 &&
|
|
504
532
|
longitude >= -180 &&
|
|
505
|
-
longitude <= 180
|
|
533
|
+
longitude <= 180
|
|
534
|
+
);
|
|
506
535
|
}
|
|
507
536
|
|
|
508
537
|
function firstHeader(request: Request, names: string[]): string | undefined {
|
|
@@ -532,13 +561,15 @@ function decodeHeader(value: string | undefined): string | undefined {
|
|
|
532
561
|
type GeoIpFailureStage = "claim" | "resolve" | "apply";
|
|
533
562
|
|
|
534
563
|
function logInternalHttpError(requestId: string): void {
|
|
535
|
-
console.error(
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
564
|
+
console.error(
|
|
565
|
+
JSON.stringify({
|
|
566
|
+
level: "error",
|
|
567
|
+
event: "rastro.http.internal_error",
|
|
568
|
+
requestId,
|
|
569
|
+
route: "/events",
|
|
570
|
+
method: "POST",
|
|
571
|
+
}),
|
|
572
|
+
);
|
|
542
573
|
}
|
|
543
574
|
|
|
544
575
|
function logGeoIpFailure(
|
|
@@ -546,21 +577,26 @@ function logGeoIpFailure(
|
|
|
546
577
|
stage: GeoIpFailureStage,
|
|
547
578
|
provider: "ipinfo" | "ipwhois",
|
|
548
579
|
): void {
|
|
549
|
-
console.warn(
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
580
|
+
console.warn(
|
|
581
|
+
JSON.stringify({
|
|
582
|
+
level: "warn",
|
|
583
|
+
event: "rastro.geoip.failure",
|
|
584
|
+
requestId,
|
|
585
|
+
route: "/events",
|
|
586
|
+
stage,
|
|
587
|
+
provider,
|
|
588
|
+
}),
|
|
589
|
+
);
|
|
557
590
|
}
|
|
558
591
|
|
|
559
592
|
function requestIdHeaders(requestId: string): Record<string, string> {
|
|
560
593
|
return { "X-Request-ID": requestId };
|
|
561
594
|
}
|
|
562
595
|
|
|
563
|
-
function corsHeaders(
|
|
596
|
+
function corsHeaders(
|
|
597
|
+
origin: string,
|
|
598
|
+
requestId?: string,
|
|
599
|
+
): Record<string, string> {
|
|
564
600
|
return {
|
|
565
601
|
"Access-Control-Allow-Origin": origin,
|
|
566
602
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
@@ -585,7 +621,10 @@ let cachedVisitorSecret: string | undefined;
|
|
|
585
621
|
|
|
586
622
|
/** The per-deployment salt behind anonymous visitor keys, created on first use. */
|
|
587
623
|
async function visitorSecret(ctx: {
|
|
588
|
-
runQuery: (
|
|
624
|
+
runQuery: (
|
|
625
|
+
reference: typeof internal.identity.secret,
|
|
626
|
+
args: Record<string, never>,
|
|
627
|
+
) => Promise<string | null>;
|
|
589
628
|
runMutation: (
|
|
590
629
|
reference: typeof internal.identity.ensureSecret,
|
|
591
630
|
args: { candidate: string },
|