@iann29/rastro 0.3.0 → 0.5.0
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 +199 -50
- package/agent/integration.md +26 -10
- package/agent/manifest.json +10 -5
- package/agent/manifest.schema.json +14 -5
- package/dist/client/federation.d.ts +28 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +12 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +262 -9
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +119 -2
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +6 -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 +72 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +1 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/constants.d.ts +9 -2
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +31 -3
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/coverage.d.ts +1 -0
- package/dist/component/coverage.d.ts.map +1 -1
- package/dist/component/coverage.js +6 -1
- package/dist/component/coverage.js.map +1 -1
- package/dist/component/eventStore.d.ts +2 -0
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/geo.d.ts +1 -0
- package/dist/component/geo.d.ts.map +1 -1
- package/dist/component/geo.js +1 -1
- package/dist/component/geo.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +65 -2
- package/dist/component/http.js.map +1 -1
- package/dist/component/identity.d.ts +12 -0
- package/dist/component/identity.d.ts.map +1 -1
- package/dist/component/identity.js +30 -2
- package/dist/component/identity.js.map +1 -1
- package/dist/component/ingest.d.ts +36 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +111 -20
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +34 -4
- package/dist/component/live.js.map +1 -1
- package/dist/component/origin.d.ts +70 -0
- package/dist/component/origin.d.ts.map +1 -0
- package/dist/component/origin.js +230 -0
- package/dist/component/origin.js.map +1 -0
- package/dist/component/platforms.d.ts +33 -0
- package/dist/component/platforms.d.ts.map +1 -0
- package/dist/component/platforms.js +328 -0
- package/dist/component/platforms.js.map +1 -0
- package/dist/component/reports.d.ts +27 -72
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +72 -16
- package/dist/component/reports.js.map +1 -1
- package/dist/component/rollupStore.d.ts +7 -4
- package/dist/component/rollupStore.d.ts.map +1 -1
- package/dist/component/rollupStore.js +37 -17
- package/dist/component/rollupStore.js.map +1 -1
- package/dist/component/rollups.d.ts +27 -8
- package/dist/component/rollups.d.ts.map +1 -1
- package/dist/component/rollups.js +110 -32
- package/dist/component/rollups.js.map +1 -1
- package/dist/component/sanitize.d.ts +5 -0
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +33 -1
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +68 -9
- package/dist/component/schema.js +21 -3
- package/dist/component/schema.js.map +1 -1
- package/dist/component/trackedLinks.d.ts +91 -0
- package/dist/component/trackedLinks.d.ts.map +1 -0
- package/dist/component/trackedLinks.js +314 -0
- package/dist/component/trackedLinks.js.map +1 -0
- package/dist/component/useragent.d.ts +6 -0
- package/dist/component/useragent.d.ts.map +1 -1
- package/dist/component/useragent.js +9 -0
- package/dist/component/useragent.js.map +1 -1
- package/dist/component/validators.d.ts +51 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +35 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +11 -11
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +11 -11
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts +1 -0
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +104 -47
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker/vitals.d.ts.map +1 -1
- package/dist/tracker/vitals.js +22 -4
- package/dist/tracker/vitals.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/dist/vitals.min.js +1 -1
- package/docs/upgrading.md +234 -45
- package/llms.txt +9 -4
- package/package.json +2 -2
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +153 -1
- package/src/component/_generated/server.ts +1 -0
- package/src/component/constants.ts +31 -3
- package/src/component/convex.config.ts +3 -0
- package/src/component/coverage.ts +7 -1
- package/src/component/geo.ts +1 -1
- package/src/component/http.ts +71 -2
- package/src/component/identity.ts +41 -2
- package/src/component/ingest.ts +146 -33
- package/src/component/live.ts +51 -4
- package/src/component/origin.ts +273 -0
- package/src/component/platforms.ts +359 -0
- package/src/component/reports.ts +98 -18
- package/src/component/rollupStore.ts +51 -20
- package/src/component/rollups.ts +141 -32
- package/src/component/sanitize.ts +39 -1
- package/src/component/schema.ts +24 -3
- package/src/component/trackedLinks.ts +384 -0
- package/src/component/useragent.ts +11 -0
- package/src/component/validators.ts +71 -0
- package/src/tracker/generated.ts +11 -11
|
@@ -177,11 +177,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
177
177
|
"mutation",
|
|
178
178
|
"internal",
|
|
179
179
|
{
|
|
180
|
+
clientKey?: string;
|
|
180
181
|
context?: {
|
|
181
182
|
browser?: string;
|
|
182
183
|
city?: string;
|
|
183
184
|
country?: string;
|
|
184
185
|
device?: string;
|
|
186
|
+
inApp?: string;
|
|
185
187
|
latitude?: number;
|
|
186
188
|
longitude?: number;
|
|
187
189
|
os?: string;
|
|
@@ -189,6 +191,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
189
191
|
};
|
|
190
192
|
events: Array<{
|
|
191
193
|
affiliateSlug?: string;
|
|
194
|
+
clid?: Array<string>;
|
|
192
195
|
currency?: string;
|
|
193
196
|
eventId: string;
|
|
194
197
|
href?: string;
|
|
@@ -309,7 +312,8 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
309
312
|
| "funnels"
|
|
310
313
|
| "affiliates"
|
|
311
314
|
| "vitals"
|
|
312
|
-
| "siteMap"
|
|
315
|
+
| "siteMap"
|
|
316
|
+
| "origin";
|
|
313
317
|
}>;
|
|
314
318
|
from: number;
|
|
315
319
|
siteId: string;
|
|
@@ -374,6 +378,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
374
378
|
affiliateId?: string;
|
|
375
379
|
affiliateSlug?: string;
|
|
376
380
|
browser: string;
|
|
381
|
+
channel?:
|
|
382
|
+
| "direct"
|
|
383
|
+
| "organic_search"
|
|
384
|
+
| "paid_search"
|
|
385
|
+
| "organic_social"
|
|
386
|
+
| "paid_social"
|
|
387
|
+
| "messaging"
|
|
388
|
+
| "email"
|
|
389
|
+
| "affiliate"
|
|
390
|
+
| "ai"
|
|
391
|
+
| "display"
|
|
392
|
+
| "referral"
|
|
393
|
+
| "offline";
|
|
377
394
|
city?: string;
|
|
378
395
|
conversionCount: number;
|
|
379
396
|
country?: string;
|
|
@@ -382,6 +399,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
382
399
|
durationMs: number;
|
|
383
400
|
entryPath: string;
|
|
384
401
|
eventCount: number;
|
|
402
|
+
evidence?:
|
|
403
|
+
| "utm"
|
|
404
|
+
| "clickId"
|
|
405
|
+
| "referrer"
|
|
406
|
+
| "inApp"
|
|
407
|
+
| "affiliate"
|
|
408
|
+
| "none"
|
|
409
|
+
| "legacy";
|
|
385
410
|
exitPath: string;
|
|
386
411
|
lastSeenAt: number;
|
|
387
412
|
latitude?: number;
|
|
@@ -389,6 +414,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
389
414
|
newVisitor?: boolean;
|
|
390
415
|
os: string;
|
|
391
416
|
pageviewCount: number;
|
|
417
|
+
platform?: string;
|
|
392
418
|
referrer?: string;
|
|
393
419
|
revenueCents: number;
|
|
394
420
|
sessionId: string;
|
|
@@ -512,6 +538,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
512
538
|
affiliateId?: string;
|
|
513
539
|
affiliateSlug?: string;
|
|
514
540
|
browser: string;
|
|
541
|
+
channel?:
|
|
542
|
+
| "direct"
|
|
543
|
+
| "organic_search"
|
|
544
|
+
| "paid_search"
|
|
545
|
+
| "organic_social"
|
|
546
|
+
| "paid_social"
|
|
547
|
+
| "messaging"
|
|
548
|
+
| "email"
|
|
549
|
+
| "affiliate"
|
|
550
|
+
| "ai"
|
|
551
|
+
| "display"
|
|
552
|
+
| "referral"
|
|
553
|
+
| "offline";
|
|
515
554
|
city?: string;
|
|
516
555
|
conversionCount: number;
|
|
517
556
|
country?: string;
|
|
@@ -520,6 +559,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
520
559
|
durationMs: number;
|
|
521
560
|
entryPath: string;
|
|
522
561
|
eventCount: number;
|
|
562
|
+
evidence?:
|
|
563
|
+
| "utm"
|
|
564
|
+
| "clickId"
|
|
565
|
+
| "referrer"
|
|
566
|
+
| "inApp"
|
|
567
|
+
| "affiliate"
|
|
568
|
+
| "none"
|
|
569
|
+
| "legacy";
|
|
523
570
|
exitPath: string;
|
|
524
571
|
lastSeenAt: number;
|
|
525
572
|
latitude?: number;
|
|
@@ -527,6 +574,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
527
574
|
newVisitor?: boolean;
|
|
528
575
|
os: string;
|
|
529
576
|
pageviewCount: number;
|
|
577
|
+
platform?: string;
|
|
530
578
|
referrer?: string;
|
|
531
579
|
revenueCents: number;
|
|
532
580
|
sessionId: string;
|
|
@@ -596,13 +644,16 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
596
644
|
breakdowns: {
|
|
597
645
|
topBrowsers: "pageviews" | "sessions" | "eventVolume";
|
|
598
646
|
topCampaigns: "pageviews" | "sessions" | "eventVolume";
|
|
647
|
+
topChannels: "pageviews" | "sessions" | "eventVolume";
|
|
599
648
|
topCountries: "pageviews" | "sessions" | "eventVolume";
|
|
600
649
|
topDevices: "pageviews" | "sessions" | "eventVolume";
|
|
601
650
|
topEvents: "eventVolume";
|
|
651
|
+
topEvidence: "pageviews" | "sessions" | "eventVolume";
|
|
602
652
|
topGoals: "goalCompletionVolume";
|
|
603
653
|
topMediums: "pageviews" | "sessions" | "eventVolume";
|
|
604
654
|
topOutbound: "eventVolume";
|
|
605
655
|
topPages: "pageviews" | "sessions" | "eventVolume";
|
|
656
|
+
topPlatforms: "pageviews" | "sessions" | "eventVolume";
|
|
606
657
|
topSources: "pageviews" | "sessions" | "eventVolume";
|
|
607
658
|
};
|
|
608
659
|
coverage: {
|
|
@@ -660,6 +711,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
660
711
|
revenueCents: number;
|
|
661
712
|
value: string;
|
|
662
713
|
}>;
|
|
714
|
+
topChannels: Array<{
|
|
715
|
+
count: number;
|
|
716
|
+
revenueCents: number;
|
|
717
|
+
value: string;
|
|
718
|
+
}>;
|
|
663
719
|
topCountries: Array<{
|
|
664
720
|
count: number;
|
|
665
721
|
revenueCents: number;
|
|
@@ -675,6 +731,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
675
731
|
revenueCents: number;
|
|
676
732
|
value: string;
|
|
677
733
|
}>;
|
|
734
|
+
topEvidence: Array<{
|
|
735
|
+
count: number;
|
|
736
|
+
revenueCents: number;
|
|
737
|
+
value: string;
|
|
738
|
+
}>;
|
|
678
739
|
topGoals: Array<{
|
|
679
740
|
count: number;
|
|
680
741
|
revenueCents: number;
|
|
@@ -695,6 +756,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
695
756
|
revenueCents: number;
|
|
696
757
|
value: string;
|
|
697
758
|
}>;
|
|
759
|
+
topPlatforms: Array<{
|
|
760
|
+
count: number;
|
|
761
|
+
revenueCents: number;
|
|
762
|
+
value: string;
|
|
763
|
+
}>;
|
|
698
764
|
topSources: Array<{
|
|
699
765
|
count: number;
|
|
700
766
|
revenueCents: number;
|
|
@@ -1139,6 +1205,92 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
1139
1205
|
Name
|
|
1140
1206
|
>;
|
|
1141
1207
|
};
|
|
1208
|
+
trackedLinks: {
|
|
1209
|
+
create: FunctionReference<
|
|
1210
|
+
"mutation",
|
|
1211
|
+
"internal",
|
|
1212
|
+
{
|
|
1213
|
+
campaign?: string;
|
|
1214
|
+
channel:
|
|
1215
|
+
| "organic_social"
|
|
1216
|
+
| "paid_social"
|
|
1217
|
+
| "messaging"
|
|
1218
|
+
| "email"
|
|
1219
|
+
| "affiliate"
|
|
1220
|
+
| "display"
|
|
1221
|
+
| "referral"
|
|
1222
|
+
| "offline";
|
|
1223
|
+
createdBy: string;
|
|
1224
|
+
destination: string;
|
|
1225
|
+
platform: string;
|
|
1226
|
+
siteId: string;
|
|
1227
|
+
slug: string;
|
|
1228
|
+
},
|
|
1229
|
+
string,
|
|
1230
|
+
Name
|
|
1231
|
+
>;
|
|
1232
|
+
list: FunctionReference<
|
|
1233
|
+
"query",
|
|
1234
|
+
"internal",
|
|
1235
|
+
{ now: number; siteId: string },
|
|
1236
|
+
Array<{
|
|
1237
|
+
_creationTime: number;
|
|
1238
|
+
_id: string;
|
|
1239
|
+
bots: number;
|
|
1240
|
+
botsLast30Days: number;
|
|
1241
|
+
campaign?: string;
|
|
1242
|
+
channel:
|
|
1243
|
+
| "organic_social"
|
|
1244
|
+
| "paid_social"
|
|
1245
|
+
| "messaging"
|
|
1246
|
+
| "email"
|
|
1247
|
+
| "affiliate"
|
|
1248
|
+
| "display"
|
|
1249
|
+
| "referral"
|
|
1250
|
+
| "offline";
|
|
1251
|
+
clicks: number;
|
|
1252
|
+
clicksLast30Days: number;
|
|
1253
|
+
createdAt: number;
|
|
1254
|
+
createdBy: string;
|
|
1255
|
+
destination: string;
|
|
1256
|
+
disabledAt?: number;
|
|
1257
|
+
platform: string;
|
|
1258
|
+
siteId: string;
|
|
1259
|
+
slug: string;
|
|
1260
|
+
}>,
|
|
1261
|
+
Name
|
|
1262
|
+
>;
|
|
1263
|
+
remove: FunctionReference<
|
|
1264
|
+
"mutation",
|
|
1265
|
+
"internal",
|
|
1266
|
+
{ linkId: string; siteId: string },
|
|
1267
|
+
null,
|
|
1268
|
+
Name
|
|
1269
|
+
>;
|
|
1270
|
+
update: FunctionReference<
|
|
1271
|
+
"mutation",
|
|
1272
|
+
"internal",
|
|
1273
|
+
{
|
|
1274
|
+
campaign?: string | null;
|
|
1275
|
+
channel?:
|
|
1276
|
+
| "organic_social"
|
|
1277
|
+
| "paid_social"
|
|
1278
|
+
| "messaging"
|
|
1279
|
+
| "email"
|
|
1280
|
+
| "affiliate"
|
|
1281
|
+
| "display"
|
|
1282
|
+
| "referral"
|
|
1283
|
+
| "offline";
|
|
1284
|
+
destination?: string;
|
|
1285
|
+
disabled?: boolean;
|
|
1286
|
+
linkId: string;
|
|
1287
|
+
platform?: string;
|
|
1288
|
+
siteId: string;
|
|
1289
|
+
},
|
|
1290
|
+
null,
|
|
1291
|
+
Name
|
|
1292
|
+
>;
|
|
1293
|
+
};
|
|
1142
1294
|
visitors: {
|
|
1143
1295
|
link: FunctionReference<
|
|
1144
1296
|
"mutation",
|
|
@@ -39,6 +39,7 @@ import type { DataModel } from "./dataModel.js";
|
|
|
39
39
|
type Env = {
|
|
40
40
|
readonly CONVEX_CLOUD_URL: string;
|
|
41
41
|
readonly CONVEX_SITE_URL: string;
|
|
42
|
+
readonly RASTRO_CLIENT_RATE_LIMIT: "off" | undefined;
|
|
42
43
|
readonly RASTRO_GEOIP_DAILY_LIMIT: string | undefined;
|
|
43
44
|
readonly RASTRO_GEOIP_PROVIDER: "ipinfo" | "ipwhois" | undefined;
|
|
44
45
|
readonly RASTRO_GEOIP_TOKEN: string | undefined;
|
|
@@ -4,9 +4,20 @@ export const MAX_BATCH_BYTES = 64 * 1024;
|
|
|
4
4
|
export const MAX_EVENTS_PER_SESSION_WINDOW = 120;
|
|
5
5
|
export const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
6
6
|
export const MAX_BYTES_PER_SESSION_WINDOW = 256 * 1024;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// A site admits at most SITE_INGEST_SHARDS × the per-shard caps a minute:
|
|
8
|
+
// 384,000 events and 512 MiB. Shards keep every POST off one shared row per
|
|
9
|
+
// site; random session IDs can only fill them evenly, while a real peak lands
|
|
10
|
+
// unevenly — the realistic benchmark (~100k events/min) peaks near 1,050
|
|
11
|
+
// events in its hottest shard-minute, so fewer shards would need a looser cap
|
|
12
|
+
// per shard and more would need a looser cap per site.
|
|
13
|
+
export const SITE_INGEST_SHARDS = 256;
|
|
14
|
+
export const MAX_SITE_EVENTS_PER_SHARD_WINDOW = 1_500;
|
|
15
|
+
export const MAX_SITE_BYTES_PER_SHARD_WINDOW = 2 * 1024 * 1024;
|
|
16
|
+
// Per client network (an IPv4 address or an IPv6 /64, keyed by a salted daily
|
|
17
|
+
// hash) per site and minute. Generous on purpose: an office or carrier-grade
|
|
18
|
+
// NAT puts many visitors behind one address, and they all share this budget.
|
|
19
|
+
export const MAX_EVENTS_PER_CLIENT_WINDOW = 1_200;
|
|
20
|
+
export const MAX_BYTES_PER_CLIENT_WINDOW = 2 * 1024 * 1024;
|
|
10
21
|
export const LIVE_SESSION_TTL_MS = 90_000;
|
|
11
22
|
export const LIVE_LEAVE_GRACE_MS = 10_000;
|
|
12
23
|
export const LIVE_SWEEP_INTERVAL_MS = 5_000;
|
|
@@ -20,6 +31,13 @@ export const MAX_SITES_PER_OWNER = 100;
|
|
|
20
31
|
export const MAX_GOALS_PER_SITE = 50;
|
|
21
32
|
export const MAX_FUNNELS_PER_SITE = 20;
|
|
22
33
|
export const MAX_AFFILIATES_PER_SITE = 100;
|
|
34
|
+
export const MAX_TRACKED_LINKS_PER_SITE = 200;
|
|
35
|
+
// A tracked link's clicks spread over this many counter rows, so a link that
|
|
36
|
+
// goes viral writes at four times one document's rate. Four and not more
|
|
37
|
+
// because every shard is read back: the list reads a link's all-time shards
|
|
38
|
+
// plus its last 30 days, and a full site of 200 links must stay under the
|
|
39
|
+
// 32,000 documents a query may read (see trackedLinks.ts).
|
|
40
|
+
export const TRACKED_LINK_CLICK_SHARDS = 4;
|
|
23
41
|
export const MAX_FUNNEL_STEPS = 10;
|
|
24
42
|
export const MAX_PROPERTIES = 16;
|
|
25
43
|
export const MAX_REPORT_SITES = 10;
|
|
@@ -44,6 +62,16 @@ export const RETENTION_LEASE_MS = 60_000;
|
|
|
44
62
|
export const ROLLUP_FOLD_BATCH_DOCS = 200;
|
|
45
63
|
export const ROLLUP_FOLD_MAX_ROWS = 1_500;
|
|
46
64
|
export const ROLLUP_FOLD_LEASE_MS = 60_000;
|
|
65
|
+
// A batch that finds no fold pending schedules one this far out, and a run
|
|
66
|
+
// that folded anything schedules the next one as far again, so one run per
|
|
67
|
+
// second serves every batch of that second; only a run that finds the queue
|
|
68
|
+
// empty releases the lease.
|
|
69
|
+
export const ROLLUP_FOLD_DEBOUNCE_MS = 1_000;
|
|
70
|
+
// A run that throws retries its oldest batch alone; a batch that fails this
|
|
71
|
+
// many runs in a row is set aside as a dead letter, kept this long (30 days)
|
|
72
|
+
// for inspection, so one bad batch cannot stall a site's reports.
|
|
73
|
+
export const ROLLUP_FOLD_MAX_FAILURES = 3;
|
|
74
|
+
export const ROLLUP_DEAD_LETTER_RETENTION_MS = 30 * 86_400_000;
|
|
47
75
|
// Shared Web Vitals histogram edges. Every Google rating threshold (LCP
|
|
48
76
|
// 2500/4000, CLS×1000 100/250, INP 200/500, FCP 1800/3000, TTFB 800/1800) is
|
|
49
77
|
// an edge, so rating counts stay exact while one layout serves all metrics.
|
|
@@ -9,5 +9,8 @@ export default defineComponent("rastroAnalytics", {
|
|
|
9
9
|
RASTRO_GEOIP_TOKEN: v.optional(v.string()),
|
|
10
10
|
RASTRO_GEOIP_DAILY_LIMIT: v.optional(v.string()),
|
|
11
11
|
RASTRO_TRUST_PROXY: v.optional(v.literal("true")),
|
|
12
|
+
// "off" drops the per-client ingest budget, for a load-test canary whose
|
|
13
|
+
// driver sends every request from one address.
|
|
14
|
+
RASTRO_CLIENT_RATE_LIMIT: v.optional(v.literal("off")),
|
|
12
15
|
},
|
|
13
16
|
});
|
|
@@ -17,12 +17,14 @@ export async function ensureAnalyticsControl(
|
|
|
17
17
|
// first ingest after the upgrade, with the same safety delay as rollups:
|
|
18
18
|
// the site map rows, and the alpha.11 counters (pageviews and sessions
|
|
19
19
|
// per dimension slot; duration, continued sessions and new visitors per
|
|
20
|
-
// bucket),
|
|
20
|
+
// bucket), the 0.1.0 dimensions (utm_medium and outbound hosts), and
|
|
21
|
+
// the session origins (platform, channel and evidence).
|
|
21
22
|
const stamps: {
|
|
22
23
|
siteMapSince?: number;
|
|
23
24
|
countersSince?: number;
|
|
24
25
|
localDaySince?: number;
|
|
25
26
|
dimensionsSince?: number;
|
|
27
|
+
originSince?: number;
|
|
26
28
|
} = {};
|
|
27
29
|
if (existing.siteMapSince === undefined) {
|
|
28
30
|
stamps.siteMapSince = now + ROLLUP_SAFETY_DELAY_MS;
|
|
@@ -36,6 +38,9 @@ export async function ensureAnalyticsControl(
|
|
|
36
38
|
if (existing.dimensionsSince === undefined) {
|
|
37
39
|
stamps.dimensionsSince = now + ROLLUP_SAFETY_DELAY_MS;
|
|
38
40
|
}
|
|
41
|
+
if (existing.originSince === undefined) {
|
|
42
|
+
stamps.originSince = now + ROLLUP_SAFETY_DELAY_MS;
|
|
43
|
+
}
|
|
39
44
|
if (Object.keys(stamps).length === 0) return existing;
|
|
40
45
|
await ctx.db.patch("analyticsCoverage", existing._id, {
|
|
41
46
|
...stamps,
|
|
@@ -51,6 +56,7 @@ export async function ensureAnalyticsControl(
|
|
|
51
56
|
countersSince: now + ROLLUP_SAFETY_DELAY_MS,
|
|
52
57
|
localDaySince: now + ROLLUP_SAFETY_DELAY_MS,
|
|
53
58
|
dimensionsSince: now + ROLLUP_SAFETY_DELAY_MS,
|
|
59
|
+
originSince: now + ROLLUP_SAFETY_DELAY_MS,
|
|
54
60
|
retained,
|
|
55
61
|
updatedAt: now,
|
|
56
62
|
};
|
package/src/component/geo.ts
CHANGED
|
@@ -510,7 +510,7 @@ function classifyIpv6(ip: string): Exclude<IpKind, "unavailable"> {
|
|
|
510
510
|
return (groups[0]! & 0xe000) === 0x2000 ? "public" : "private";
|
|
511
511
|
}
|
|
512
512
|
|
|
513
|
-
function parseIpv6(ip: string): number[] | undefined {
|
|
513
|
+
export function parseIpv6(ip: string): number[] | undefined {
|
|
514
514
|
let normalized = ip;
|
|
515
515
|
const dottedTail = normalized.match(/(\d+\.\d+\.\d+\.\d+)$/)?.[1];
|
|
516
516
|
if (dottedTail) {
|
package/src/component/http.ts
CHANGED
|
@@ -20,8 +20,10 @@ import {
|
|
|
20
20
|
} from "./geo.js";
|
|
21
21
|
import { isPlainRecord } from "./guards.js";
|
|
22
22
|
import { alignClock, sanitizeEvent } from "./sanitize.js";
|
|
23
|
-
import { deriveVisitorKey, randomSecret } from "./identity.js";
|
|
24
|
-
import {
|
|
23
|
+
import { deriveClientKey, deriveVisitorKey, randomSecret } from "./identity.js";
|
|
24
|
+
import { isTrackedLinkSlug, trackedLinkLocation } from "./trackedLinks.js";
|
|
25
|
+
import { classifyClient, inAppPlatform, isKnownBot } from "./useragent.js";
|
|
26
|
+
import { knownClickIds } from "./origin.js";
|
|
25
27
|
import type { TrackerEvent } from "./validators.js";
|
|
26
28
|
import {
|
|
27
29
|
RASTRO_VERSION,
|
|
@@ -174,11 +176,26 @@ http.route({
|
|
|
174
176
|
userAgent,
|
|
175
177
|
})
|
|
176
178
|
: undefined;
|
|
179
|
+
const inApp = inAppPlatform(userAgent);
|
|
177
180
|
const context = {
|
|
178
181
|
...edgeGeo,
|
|
179
182
|
...classifyClient(userAgent, request.headers),
|
|
183
|
+
...(inApp ? { inApp } : {}),
|
|
180
184
|
...(visitorKey ? { visitorKey } : {}),
|
|
181
185
|
};
|
|
186
|
+
// Origin is a browser control: any other sender writes the header it
|
|
187
|
+
// likes. The per-client budget is keyed like the visitor key above (salted,
|
|
188
|
+
// daily, never the address); without a usable client IP only the per-site
|
|
189
|
+
// shards bound the batch.
|
|
190
|
+
const clientKey =
|
|
191
|
+
clientIp.ip && env.RASTRO_CLIENT_RATE_LIMIT !== "off"
|
|
192
|
+
? await deriveClientKey({
|
|
193
|
+
secret: await visitorSecret(ctx),
|
|
194
|
+
now: Date.now(),
|
|
195
|
+
siteId: payload.siteId,
|
|
196
|
+
ip: clientIp.ip,
|
|
197
|
+
})
|
|
198
|
+
: undefined;
|
|
182
199
|
|
|
183
200
|
try {
|
|
184
201
|
const result = await ctx.runMutation(api.ingest.ingestBatch, {
|
|
@@ -186,6 +203,7 @@ http.route({
|
|
|
186
203
|
origin,
|
|
187
204
|
events,
|
|
188
205
|
context,
|
|
206
|
+
...(clientKey ? { clientKey } : {}),
|
|
189
207
|
});
|
|
190
208
|
if (
|
|
191
209
|
clientIp.ip &&
|
|
@@ -290,6 +308,46 @@ http.route({
|
|
|
290
308
|
),
|
|
291
309
|
});
|
|
292
310
|
|
|
311
|
+
// A tracked link. The click is counted — as a bot when the agent says it is
|
|
312
|
+
// one, so link unfurlers never inflate the clicks but still get the page — and
|
|
313
|
+
// the visitor goes on with the link's utm_* appended. Unknown and disabled
|
|
314
|
+
// slugs answer 404.
|
|
315
|
+
http.route({
|
|
316
|
+
pathPrefix: "/l/",
|
|
317
|
+
method: "GET",
|
|
318
|
+
handler: httpAction(async (ctx, request) => {
|
|
319
|
+
const slug = (
|
|
320
|
+
new URL(request.url).pathname.split("/").filter(Boolean).pop() ?? ""
|
|
321
|
+
).toLowerCase();
|
|
322
|
+
const link = isTrackedLinkSlug(slug)
|
|
323
|
+
? await ctx.runMutation(internal.trackedLinks.recordClick, {
|
|
324
|
+
slug,
|
|
325
|
+
bot: isKnownBot(
|
|
326
|
+
(await readRequestMetadata(ctx)).userAgent ??
|
|
327
|
+
request.headers.get("user-agent") ??
|
|
328
|
+
"",
|
|
329
|
+
),
|
|
330
|
+
})
|
|
331
|
+
: null;
|
|
332
|
+
if (!link) {
|
|
333
|
+
return new Response("Not found", {
|
|
334
|
+
status: 404,
|
|
335
|
+
headers: {
|
|
336
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
337
|
+
"Cache-Control": "no-store",
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return new Response(null, {
|
|
342
|
+
status: 302,
|
|
343
|
+
headers: {
|
|
344
|
+
Location: trackedLinkLocation(link),
|
|
345
|
+
"Cache-Control": "no-store",
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
}),
|
|
349
|
+
});
|
|
350
|
+
|
|
293
351
|
http.route({
|
|
294
352
|
path: "/health",
|
|
295
353
|
method: "GET",
|
|
@@ -420,6 +478,7 @@ function parseEvent(value: unknown): TrackerEvent {
|
|
|
420
478
|
throw new Error("event is missing required fields");
|
|
421
479
|
}
|
|
422
480
|
const properties = parseProperties(value.properties);
|
|
481
|
+
const clid = parseClickIds(value.clid);
|
|
423
482
|
return {
|
|
424
483
|
eventId: value.eventId,
|
|
425
484
|
sessionId: value.sessionId,
|
|
@@ -453,9 +512,19 @@ function parseEvent(value: unknown): TrackerEvent {
|
|
|
453
512
|
...(optionalNumber(value.value, "value") !== undefined
|
|
454
513
|
? { value: optionalNumber(value.value, "value") }
|
|
455
514
|
: {}),
|
|
515
|
+
...(clid ? { clid } : {}),
|
|
456
516
|
};
|
|
457
517
|
}
|
|
458
518
|
|
|
519
|
+
/** Click-ID key names: unknown keys are dropped, never a reason to reject. */
|
|
520
|
+
function parseClickIds(value: unknown): string[] | undefined {
|
|
521
|
+
if (value === undefined) return undefined;
|
|
522
|
+
if (!Array.isArray(value) || value.some((key) => typeof key !== "string")) {
|
|
523
|
+
throw new Error("clid must be an array of strings");
|
|
524
|
+
}
|
|
525
|
+
return knownClickIds(value as string[]);
|
|
526
|
+
}
|
|
527
|
+
|
|
459
528
|
function parseProperties(value: unknown): TrackerEvent["properties"] {
|
|
460
529
|
if (value === undefined) return undefined;
|
|
461
530
|
if (!isPlainRecord(value)) throw new Error("properties must be an object");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { DAY_MS } from "./constants.js";
|
|
3
|
+
import { parseIpv6 } from "./geo.js";
|
|
3
4
|
import { internalMutation, internalQuery } from "./_generated/server.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -57,9 +58,47 @@ export async function deriveVisitorKey(input: {
|
|
|
57
58
|
ip: string;
|
|
58
59
|
userAgent: string;
|
|
59
60
|
}): Promise<string> {
|
|
60
|
-
|
|
61
|
+
return saltedDailyKey(input.secret, input.now, [
|
|
62
|
+
input.siteId,
|
|
63
|
+
input.ip,
|
|
64
|
+
input.userAgent,
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The per-client ingest budget's key: the same salted daily hash, over the
|
|
70
|
+
* client's network instead of its device — the IPv4 address, or the /64 of an
|
|
71
|
+
* IPv6 address, the block one subscriber gets and could otherwise walk through
|
|
72
|
+
* one address per request.
|
|
73
|
+
*/
|
|
74
|
+
export async function deriveClientKey(input: {
|
|
75
|
+
secret: string;
|
|
76
|
+
now: number;
|
|
77
|
+
siteId: string;
|
|
78
|
+
ip: string;
|
|
79
|
+
}): Promise<string> {
|
|
80
|
+
const groups = parseIpv6(input.ip.toLowerCase());
|
|
81
|
+
const network = groups
|
|
82
|
+
? `${groups
|
|
83
|
+
.slice(0, 4)
|
|
84
|
+
.map((group) => group.toString(16))
|
|
85
|
+
.join(":")}::/64`
|
|
86
|
+
: input.ip;
|
|
87
|
+
return saltedDailyKey(input.secret, input.now, [
|
|
88
|
+
"client",
|
|
89
|
+
input.siteId,
|
|
90
|
+
network,
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function saltedDailyKey(
|
|
95
|
+
secret: string,
|
|
96
|
+
now: number,
|
|
97
|
+
parts: string[],
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
const dayStart = Math.floor(now / DAY_MS) * DAY_MS;
|
|
61
100
|
const material = new TextEncoder().encode(
|
|
62
|
-
[
|
|
101
|
+
[secret, dayStart, ...parts].join(" "),
|
|
63
102
|
);
|
|
64
103
|
const digest = new Uint8Array(
|
|
65
104
|
await crypto.subtle.digest("SHA-256", material),
|