@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.
Files changed (128) hide show
  1. package/README.md +199 -50
  2. package/agent/integration.md +26 -10
  3. package/agent/manifest.json +10 -5
  4. package/agent/manifest.schema.json +14 -5
  5. package/dist/client/federation.d.ts +28 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +12 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +262 -9
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +119 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +6 -0
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +72 -1
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/_generated/server.d.ts +1 -0
  19. package/dist/component/_generated/server.d.ts.map +1 -1
  20. package/dist/component/_generated/server.js.map +1 -1
  21. package/dist/component/constants.d.ts +9 -2
  22. package/dist/component/constants.d.ts.map +1 -1
  23. package/dist/component/constants.js +31 -3
  24. package/dist/component/constants.js.map +1 -1
  25. package/dist/component/convex.config.d.ts +1 -0
  26. package/dist/component/convex.config.js +3 -0
  27. package/dist/component/convex.config.js.map +1 -1
  28. package/dist/component/coverage.d.ts +1 -0
  29. package/dist/component/coverage.d.ts.map +1 -1
  30. package/dist/component/coverage.js +6 -1
  31. package/dist/component/coverage.js.map +1 -1
  32. package/dist/component/eventStore.d.ts +2 -0
  33. package/dist/component/eventStore.d.ts.map +1 -1
  34. package/dist/component/geo.d.ts +1 -0
  35. package/dist/component/geo.d.ts.map +1 -1
  36. package/dist/component/geo.js +1 -1
  37. package/dist/component/geo.js.map +1 -1
  38. package/dist/component/http.d.ts.map +1 -1
  39. package/dist/component/http.js +65 -2
  40. package/dist/component/http.js.map +1 -1
  41. package/dist/component/identity.d.ts +12 -0
  42. package/dist/component/identity.d.ts.map +1 -1
  43. package/dist/component/identity.js +30 -2
  44. package/dist/component/identity.js.map +1 -1
  45. package/dist/component/ingest.d.ts +36 -0
  46. package/dist/component/ingest.d.ts.map +1 -1
  47. package/dist/component/ingest.js +111 -20
  48. package/dist/component/ingest.js.map +1 -1
  49. package/dist/component/live.d.ts.map +1 -1
  50. package/dist/component/live.js +34 -4
  51. package/dist/component/live.js.map +1 -1
  52. package/dist/component/origin.d.ts +70 -0
  53. package/dist/component/origin.d.ts.map +1 -0
  54. package/dist/component/origin.js +230 -0
  55. package/dist/component/origin.js.map +1 -0
  56. package/dist/component/platforms.d.ts +33 -0
  57. package/dist/component/platforms.d.ts.map +1 -0
  58. package/dist/component/platforms.js +328 -0
  59. package/dist/component/platforms.js.map +1 -0
  60. package/dist/component/reports.d.ts +27 -72
  61. package/dist/component/reports.d.ts.map +1 -1
  62. package/dist/component/reports.js +72 -16
  63. package/dist/component/reports.js.map +1 -1
  64. package/dist/component/rollupStore.d.ts +7 -4
  65. package/dist/component/rollupStore.d.ts.map +1 -1
  66. package/dist/component/rollupStore.js +37 -17
  67. package/dist/component/rollupStore.js.map +1 -1
  68. package/dist/component/rollups.d.ts +27 -8
  69. package/dist/component/rollups.d.ts.map +1 -1
  70. package/dist/component/rollups.js +110 -32
  71. package/dist/component/rollups.js.map +1 -1
  72. package/dist/component/sanitize.d.ts +5 -0
  73. package/dist/component/sanitize.d.ts.map +1 -1
  74. package/dist/component/sanitize.js +33 -1
  75. package/dist/component/sanitize.js.map +1 -1
  76. package/dist/component/schema.d.ts +68 -9
  77. package/dist/component/schema.js +21 -3
  78. package/dist/component/schema.js.map +1 -1
  79. package/dist/component/trackedLinks.d.ts +91 -0
  80. package/dist/component/trackedLinks.d.ts.map +1 -0
  81. package/dist/component/trackedLinks.js +314 -0
  82. package/dist/component/trackedLinks.js.map +1 -0
  83. package/dist/component/useragent.d.ts +6 -0
  84. package/dist/component/useragent.d.ts.map +1 -1
  85. package/dist/component/useragent.js +9 -0
  86. package/dist/component/useragent.js.map +1 -1
  87. package/dist/component/validators.d.ts +51 -11
  88. package/dist/component/validators.d.ts.map +1 -1
  89. package/dist/component/validators.js +35 -1
  90. package/dist/component/validators.js.map +1 -1
  91. package/dist/tracker/generated.d.ts +11 -11
  92. package/dist/tracker/generated.d.ts.map +1 -1
  93. package/dist/tracker/generated.js +11 -11
  94. package/dist/tracker/generated.js.map +1 -1
  95. package/dist/tracker/tracker.d.ts +1 -0
  96. package/dist/tracker/tracker.d.ts.map +1 -1
  97. package/dist/tracker/tracker.js +104 -47
  98. package/dist/tracker/tracker.js.map +1 -1
  99. package/dist/tracker/vitals.d.ts.map +1 -1
  100. package/dist/tracker/vitals.js +22 -4
  101. package/dist/tracker/vitals.js.map +1 -1
  102. package/dist/tracker.min.js +1 -1
  103. package/dist/vitals.min.js +1 -1
  104. package/docs/upgrading.md +234 -45
  105. package/llms.txt +9 -4
  106. package/package.json +2 -2
  107. package/src/component/_generated/api.ts +6 -0
  108. package/src/component/_generated/component.ts +153 -1
  109. package/src/component/_generated/server.ts +1 -0
  110. package/src/component/constants.ts +31 -3
  111. package/src/component/convex.config.ts +3 -0
  112. package/src/component/coverage.ts +7 -1
  113. package/src/component/geo.ts +1 -1
  114. package/src/component/http.ts +71 -2
  115. package/src/component/identity.ts +41 -2
  116. package/src/component/ingest.ts +146 -33
  117. package/src/component/live.ts +51 -4
  118. package/src/component/origin.ts +273 -0
  119. package/src/component/platforms.ts +359 -0
  120. package/src/component/reports.ts +98 -18
  121. package/src/component/rollupStore.ts +51 -20
  122. package/src/component/rollups.ts +141 -32
  123. package/src/component/sanitize.ts +39 -1
  124. package/src/component/schema.ts +24 -3
  125. package/src/component/trackedLinks.ts +384 -0
  126. package/src/component/useragent.ts +11 -0
  127. package/src/component/validators.ts +71 -0
  128. 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
- export const SITE_INGEST_SHARDS = 4_096;
8
- export const MAX_SITE_EVENTS_PER_SHARD_WINDOW = 300;
9
- export const MAX_SITE_BYTES_PER_SHARD_WINDOW = 512 * 1024;
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), and the 0.1.0 dimensions (utm_medium and outbound hosts).
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
  };
@@ -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) {
@@ -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 { classifyClient, isKnownBot } from "./useragent.js";
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
- const dayStart = Math.floor(input.now / DAY_MS) * DAY_MS;
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
- [input.secret, dayStart, input.siteId, input.ip, input.userAgent].join(" "),
101
+ [secret, dayStart, ...parts].join(" "),
63
102
  );
64
103
  const digest = new Uint8Array(
65
104
  await crypto.subtle.digest("SHA-256", material),