@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
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Privacy-first, real-time web analytics built as an isolated Convex component.
6
6
 
7
- Amage Rastro combines a 1.1 KB gzip browser tracker with reactive traffic
7
+ Amage Rastro combines a 1.4 KB gzip browser tracker with reactive traffic
8
8
  metrics, a live visitor atlas, journey timelines, trusted payment attribution,
9
9
  goals, funnels, affiliates, and multi-site reports. Data and functions stay
10
10
  inside your Convex deployment.
@@ -42,7 +42,7 @@ capacity claim.
42
42
 
43
43
  | Capability | Implementation |
44
44
  | --------------------------- | --------------------------------------------------------------------------------------------------------- |
45
- | Live visitor map | Reactive live sessions with city-level coordinates, realtime departures, and a 90-second safety net |
45
+ | Live visitor map | Reactive live sessions with city-level coordinates; hidden or closed tabs leave in 10 s, crashes in 90 s |
46
46
  | Traffic analytics | Sharded hour/day rollups, time series, top pages, sources, countries, devices, browsers, and events |
47
47
  | Field Web Vitals | Opt-in tracker module reporting LCP, CLS, INP, FCP, and TTFB into bounded per-page daily histograms |
48
48
  | Site map | Daily per-route rollups: entries, derived exits and bounces, time on page, and route-to-route transitions |
@@ -54,8 +54,8 @@ capacity claim.
54
54
  | Automatic GeoIP | Opt-in native request metadata plus IPinfo/IPWhois fallback; bounded quota and no raw-IP persistence |
55
55
  | Auto-captured clicks | Anchors, buttons, and `data-rastro-event` controls with a bounded visible label; outbound origin+path |
56
56
  | Bot filtering | Self-declared crawlers, unfurlers, monitors, HTTP libraries, and headless browsers rejected at ingestion |
57
- | Cookieless mode | No cookies: persistent pseudonymous visitor id in `localStorage`, per-tab session id in `sessionStorage` |
58
- | Tiny tracker | 2,132 bytes raw, 1,127 bytes gzip; the test suite enforces `< 1,280` bytes gzip |
57
+ | Cookieless mode | No cookies: pseudonymous visitor id in `localStorage`; sessions end after 30 idle minutes or a sign-in |
58
+ | Tiny tracker | 2,594 bytes raw, 1,395 bytes gzip; the test suite enforces `< 1,400` bytes gzip |
59
59
 
60
60
  ## Quickstart
61
61
 
@@ -82,7 +82,7 @@ Never append `/rastro/tracker.js` to the functions URL.
82
82
  npm install @iann29/rastro
83
83
  ```
84
84
 
85
- The untagged npm `latest` channel is `0.3.0`. Pin the exact version in the host;
85
+ The untagged npm `latest` channel is `0.5.0`. Pin the exact version in the host;
86
86
  the changelog and the upgrading guide say what each release changes.
87
87
 
88
88
  ### 2. Mount the component
@@ -203,6 +203,15 @@ data only when this opt-in is enabled and the runtime IP identifies a private
203
203
  immediate peer. Caller-supplied proxy or geographic headers are ignored when
204
204
  native metadata is absent or public.
205
205
 
206
+ `RASTRO_CLIENT_RATE_LIMIT` is for load-test canaries only. Set to `off`, it
207
+ lifts the per-client ingest budget (see
208
+ [Operational limits](#operational-limits)), so a benchmark driver sending every
209
+ request from one address is bounded by the per-site shards alone. Bind it like
210
+ the others: `RASTRO_CLIENT_RATE_LIMIT: v.optional(v.literal("off"))` in
211
+ `defineApp` and `RASTRO_CLIENT_RATE_LIMIT: app.env.RASTRO_CLIENT_RATE_LIMIT` in
212
+ `app.use`, as the example host does. Leave it unset on every customer
213
+ deployment.
214
+
206
215
  The lookup has a 1.5-second timeout and fails open: telemetry ingestion
207
216
  continues without location when the provider is unavailable. A session reserves
208
217
  its single lookup attempt atomically only after its initial event is persisted;
@@ -228,8 +237,9 @@ until it reloads.
228
237
 
229
238
  The tracker sends no client identification. Ingestion derives `browser`, `os`,
230
239
  and `device` from headers the browser attaches itself, and persists only those
231
- three normalized values; the raw `User-Agent` never reaches a mutation or a
232
- component table.
240
+ three normalized values; it also reads the in-app browser's platform id
241
+ (`instagram`, `facebook`, `tiktok`…) for the session's origin, below. The raw
242
+ `User-Agent` never reaches a mutation or a component table.
233
243
 
234
244
  Chromium attaches the low-entropy `Sec-CH-UA`, `Sec-CH-UA-Mobile`, and
235
245
  `Sec-CH-UA-Platform` client hints to cross-origin requests from secure origins,
@@ -310,9 +320,11 @@ Do not call the authenticated example with a bare CLI command: without an
310
320
  explicit test identity, `ctx.auth.getUserIdentity()` correctly returns `null`.
311
321
 
312
322
  Allowed domains are checked at ingestion. Exact hosts and wildcard subdomains
313
- such as `*.example.com` are supported. `Origin` is an abuse signal, not
314
- authentication; Amage Rastro also enforces per-session and fixed-shard per-site
315
- event/byte budgets.
323
+ such as `*.example.com` are supported. The `Origin` check is a browser control,
324
+ not an anti-abuse one: a browser cannot forge the header, but any other sender
325
+ writes the one it likes, and the site ID is in every page's source. Abuse is
326
+ bounded by event and byte budgets per session, per client network, and per site
327
+ (see [Operational limits](#operational-limits)).
316
328
 
317
329
  Domain entries are hostnames, not host-and-port pairs. Use `localhost` or a full
318
330
  origin such as `http://localhost:3000`; both normalize to the `localhost` host.
@@ -449,10 +461,12 @@ budget; add it only where you want performance data:
449
461
  ></script>
450
462
  ```
451
463
 
452
- The module shares the tracker's session identity through `sessionStorage`,
453
- accepts the same `data-endpoint` and `data-visitor` overrides, and works with or
454
- without the base tracker on the page. Measurements are attributed to the landing
455
- path of the page load, never to later SPA routes. Vitals feed only the
464
+ The module derives the tracker's session and visitor ids by the same rule, from
465
+ `sessionStorage` and `localStorage`, whichever script loads first, and accepts
466
+ the same `data-endpoint` and `data-visitor` overrides. It needs the base tracker
467
+ on the page: vitals annotate the session the tracker opened, and ingestion
468
+ rejects them for a session it never saw. Measurements are attributed to the
469
+ landing path of the page load, never to later SPA routes. Vitals feed only the
456
470
  `vitalsReport` histograms — analytics event counts, sessions, and live presence
457
471
  do not change. Browsers without an entry type simply skip that metric (Safari
458
472
  reports no LCP, CLS, or INP).
@@ -563,17 +577,115 @@ payload-bound by the site's financial ledger.
563
577
 
564
578
  The tracker reads `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, and
565
579
  `utm_content` from the landing URL, keeps them in `sessionStorage` for the rest
566
- of the session, and sends them as properties of every pageview; no other query
567
- parameter ever leaves the page. A session's `source` is the lowercase
568
- `utm_source` when present and the referrer host otherwise, so campaign traffic
569
- no longer reads as `direct`. `utmSource`, `utmMedium`, and `utmCampaign` stay on
570
- the session for journeys; `overview.topCampaigns` ranks `utm_campaign` and
571
- `overview.topMediums` the lowercase `utm_medium` next to `topSources`, by
572
- sessions once the range is covered by the counters. `overview.topOutbound` ranks
573
- the hosts outbound clicks reached — the host alone, never the path or query — by
574
- clicks. Both lists are recorded from `metadata.dimensionsSince` (each site's
575
- first ingest on 0.1.0 plus the rollup delay); a range that starts earlier ranks
576
- them from partial data, and the dashboard says so.
580
+ of the tab — a later session the tab opens after a sign-in or 30 idle minutes
581
+ keeps them too and sends them as properties of every pageview. Besides those
582
+ five, the only thing the landing URL ever yields is the **name** of a click-ID
583
+ key it carried (below); no other query parameter, and no query value at all,
584
+ ever leaves the page. A session's `source` is the lowercase `utm_source` when
585
+ present and the referrer host otherwise, so campaign traffic no longer reads as
586
+ `direct`. `utmSource`, `utmMedium`, and `utmCampaign` stay on the session for
587
+ journeys; `overview.topCampaigns` ranks `utm_campaign` and `overview.topMediums`
588
+ the lowercase `utm_medium` next to `topSources`, by sessions once the range is
589
+ covered by the counters. `overview.topOutbound` ranks the hosts outbound clicks
590
+ reached the host alone, never the path or query — by clicks. Both lists are
591
+ recorded from `metadata.dimensionsSince` (each site's first ingest on 0.1.0 plus
592
+ the rollup delay); a range that starts earlier ranks them from partial data, and
593
+ the dashboard says so.
594
+
595
+ A session also keeps where it came from, classified once on its first touch and
596
+ never guessed: `platform` (an id from a closed, versioned table of about forty —
597
+ `instagram`, `google`, `whatsapp`, `chatgpt` — or `unknown` for a host or
598
+ `utm_source` outside it, or `none`), `channel` (`direct`, `organic_search`,
599
+ `paid_search`, `organic_social`, `paid_social`, `messaging`, `email`,
600
+ `affiliate`, `ai`, `display`, `referral`, `offline`), and `evidence`, the rule
601
+ that decided it. One rule decides and the next only runs when it did not:
602
+ `utm_source` through the alias table, then click-ID names, then the referrer
603
+ host by registrable domain, then the in-app browser, then an affiliate `ref`,
604
+ which sets the channel and keeps the platform an earlier rule found. Hosts match
605
+ by whole label, never by substring, so `l.instagram.com`, `instagram.com` and
606
+ `com.instagram.android` are one platform while `docs.google.com` is not Google;
607
+ `utm_medium` picks the channel from a closed table that also accepts the channel
608
+ ids themselves, which is what a tracked link (below) emits, `offline` included.
609
+ Nothing at all is `direct`, the only way to be direct, and a referrer on the
610
+ site's own domains is an internal hop, never an origin. `overview.topPlatforms`,
611
+ `topChannels` and `topEvidence` rank them beside `topSources`; a `topPlatforms`
612
+ value is the platform id alone when the session arrived by that platform's
613
+ default channel and `platform@channel` when it did not (`google@paid_search`),
614
+ so one platform ranks once per channel it was reached by —
615
+ `splitPlatformDimension` reads the pair back. `dataCoverage`'s `origin` dataset
616
+ says from when a site carries them; sessions older than that are classified on
617
+ read and marked `evidence: "legacy"`. `source` keeps the raw `utm_source` or
618
+ host for the drill-down, exactly as before.
619
+
620
+ The tracker reads the click-ID keys of its landing URL and sends them in `clid`:
621
+ their **names** only (`fbclid`, `gclid`, `ttclid`, `mc_cid`…), never their
622
+ values, so no advertising identifier leaves the page. It matches the same
623
+ allowlist the server does, in the same ranking order, and sends at most eight;
624
+ ingestion drops any name outside the allowlist and keeps at most eight of what
625
+ is left. The referrer it sends is scheme and host, so a referrer of the form
626
+ `android-app://<package>` keeps the package alone and the app that opened the
627
+ link is a platform (`com.google.android.gm` is Gmail) instead of nothing.
628
+
629
+ ## Tracked links
630
+
631
+ Some traffic arrives with nothing to read. A link tapped inside WhatsApp,
632
+ Telegram, an SMS or a native e-mail app, or scanned from a QR code, opens with
633
+ no referrer and no campaign, and any analytics tool files it as `direct`. A
634
+ tracked link closes that gap: the dashboard's **Links** tab (or the host API)
635
+ creates `/rastro/l/<slug>` for one place the link will be published, and every
636
+ click lands on the site with that place in its `utm_*`.
637
+
638
+ `GET /rastro/l/<slug>` answers `302` with `Cache-Control: no-store` to the
639
+ link's destination, after appending:
640
+
641
+ | Parameter | Value |
642
+ | -------------- | ------------------------------------------------------------------------------------------------------------------- |
643
+ | `utm_source` | the platform: `instagram`, `whatsapp`, `qr`, or any id of 2–24 lowercase letters, digits or underscores |
644
+ | `utm_medium` | the channel: `organic_social`, `paid_social`, `messaging`, `email`, `affiliate`, `display`, `referral` or `offline` |
645
+ | `utm_campaign` | the campaign, or the slug when the link has none |
646
+ | `utm_content` | the slug |
647
+
648
+ The destination's own query stays exactly as it was, and a `utm_*` key it
649
+ already carries is never overridden. Unknown and disabled slugs answer `404` in
650
+ plain text. The tracker needs nothing new: the landing reads the `utm_*` like
651
+ any campaign link.
652
+
653
+ - **Clicks and bots.** Each click lands in a daily counter spread over four
654
+ shards, so a link that goes viral never serializes on one document. A
655
+ self-declared bot, including the WhatsApp, Facebook and Telegram link
656
+ previews, is still redirected but counted under `bots`, never `clicks`.
657
+ `listTrackedLinks` reports both, all time and over the 30 UTC days ending on
658
+ the day of the `now` it is given.
659
+ - **No open redirect.** A destination must be an absolute `http(s)` URL without
660
+ credentials on one of the site's domains (exact, or a `*.` pattern for
661
+ subdomains). The rule is checked when a link is created or changed and again
662
+ on every click, so a domain removed from the site stops redirecting at once.
663
+ - **Slugs** are 3–32 lowercase letters, digits or hyphens, starting and ending
664
+ with a letter or digit, and unique across the deployment because the route
665
+ names no site. A site keeps at most 200 links. Deleting a link frees its slug
666
+ and removes its counters; disabling it keeps both.
667
+ - **A short domain.** The route lives on the deployment's HTTP origin
668
+ (`https://<deployment>.convex.site/rastro/l/promo`). A host that wants a
669
+ presentable link serves that origin under a short domain of its own, such as
670
+ `go.example.com/rastro/l/promo`; nothing in the component changes.
671
+
672
+ ```ts
673
+ await analytics.createTrackedLink(ctx, {
674
+ siteId,
675
+ slug: "bio-instagram",
676
+ destination: "https://www.example.com/promo",
677
+ platform: "instagram",
678
+ channel: "organic_social",
679
+ campaign: "spring-sale",
680
+ createdBy: userId, // an opaque id, never an e-mail address
681
+ });
682
+ ```
683
+
684
+ A federated dashboard reaches the same functions once the host re-exports
685
+ `listTrackedLinks`, `createTrackedLink`, `updateTrackedLink`, and
686
+ `deleteTrackedLink` from `convex/rastroFederation.ts`. The connection then
687
+ advertises the `trackedLinks` capability: any reader lists the links, and
688
+ changing them needs `analytics:configure`, like goals and affiliates.
577
689
 
578
690
  ## Identify visitors after signup
579
691
 
@@ -596,14 +708,16 @@ await analytics.linkVisitor(ctx, {
596
708
  ```
597
709
 
598
710
  `visitorJourney` for either id then returns one chronological journey; every
599
- event keeps the id it was recorded with. Links are idempotent, an anonymous id
600
- belongs to at most one identity, a linked id cannot be linked again, chains and
601
- identity merges are rejected, and an identity holds at most ten aliases. The
602
- tracker still sets no cookie: the host's own session identifies the account and
603
- the anonymous ids live in the browser's own storage (the visitor id in
604
- `localStorage`, the session id in `sessionStorage`). Linking anonymous history
605
- to an account turns it into personal data under the host's privacy policy;
606
- disclose it and offer an opt-out.
711
+ event keeps the id it was recorded with. A session belongs to one identity: the
712
+ first page rendered with the new `data-visitor` opens a new session in the same
713
+ tab, carrying the referral and campaign over, and the link joins both sessions
714
+ in the journey. Links are idempotent, an anonymous id belongs to at most one
715
+ identity, a linked id cannot be linked again, chains and identity merges are
716
+ rejected, and an identity holds at most ten aliases. The tracker still sets no
717
+ cookie: the host's own session identifies the account and the anonymous ids live
718
+ in the browser's own storage (the visitor id in `localStorage`, the session id
719
+ in `sessionStorage`). Linking anonymous history to an account turns it into
720
+ personal data under the host's privacy policy; disclose it and offer an opt-out.
607
721
 
608
722
  ## Attribute trusted payments
609
723
 
@@ -738,6 +852,8 @@ The `Rastro` class wraps the component boundary for host functions:
738
852
  - Configuration: `upsertGoal`, `listGoals`, `removeGoal`, `upsertFunnel`,
739
853
  `listFunnels`, `removeFunnel`, `upsertAffiliate`, `listAffiliates`,
740
854
  `removeAffiliate`
855
+ - Tracked links: `createTrackedLink`, `updateTrackedLink`, `deleteTrackedLink`,
856
+ `listTrackedLinks`
741
857
  - Retention: `cleanup`, `setRetentionPolicy`, `disableRetentionPolicy`,
742
858
  `runRetentionPolicy`, `retentionStatus`
743
859
 
@@ -832,24 +948,50 @@ boundaries use different bucket alignment.
832
948
  never written by the ingestion transaction: a batch appends one document to
833
949
  the site's rollup queue and one leased fold job per site merges up to 200
834
950
  queued batches per run into the rows, so every rollup row has a single writer
835
- and concurrent ingestion cannot conflict on it. Reports see a batch's rollups
836
- a few hundred milliseconds after ingestion accepts it; a fold chain that dies
837
- is restarted by the next batch once its 60-second lease lapses
838
- - Site ingress budget: 4,096 deterministic per-minute shards, each capped at 300
839
- events and 512 KiB (1,228,800 admitted events/minute at uniform load)
840
- - Rate-window storage: one reusable row per active session and site shard
841
- - Live session timeout: a `leave` beacon on `pagehide` retires the visitor about
842
- ten seconds after they leave and a back/forward-cache return revives them
843
- immediately; the safety net for a browser that never says goodbye is 90
844
- seconds since the last event or heartbeat (heartbeats fire every 20 seconds,
845
- within background-tab throttling), with one bounded five-second expiry sweep
846
- per site; legacy per-session jobs hand off to that sweep during the cutover
951
+ and concurrent ingestion cannot conflict on it. Runs are debounced: the first
952
+ batch schedules a run one second out, batches arriving while it is pending
953
+ neither write the lease nor schedule anything, and the chain keeps running
954
+ once a second until a run finds the queue empty. Reports see a batch's rollups
955
+ about a second after ingestion accepts it; a fold chain that dies is restarted
956
+ by the next batch once its 60-second lease lapses. A run that throws is rolled
957
+ back and retries its oldest batch alone; a batch that fails three runs in a
958
+ row is set aside as a logged dead letter (kept 30 days) so the rest of the
959
+ queue keeps folding
960
+ - Session ingress budget: 120 events and 256 KiB per session per minute
961
+ - Client ingress budget: 1,200 events and 2 MiB per site per minute from one
962
+ client network, an IPv4 address or an IPv6 /64. The window is keyed by the
963
+ salted daily hash behind anonymous visitor keys, never by the address, and
964
+ every visitor behind one office or carrier-grade NAT shares it. It applies
965
+ when `/rastro/health` reports `clientIpAvailable: true`;
966
+ `RASTRO_CLIENT_RATE_LIMIT=off` lifts it on a load-test canary
967
+ - Site ingress budget: 256 deterministic per-minute shards, each capped at 1,500
968
+ events and 2 MiB, so a site admits at most 384,000 events and 512 MiB a
969
+ minute. Only evenly spread load (random session IDs) reaches that; a real peak
970
+ is uneven, and the realistic benchmark's ~100k events/minute tops out near
971
+ 1,050 events in its hottest shard-minute
972
+ - Rate-window storage: one reusable row per active session, client network and
973
+ UTC day, and site shard
974
+ - Live session timeout: a `leave` beacon when the tab hides or the page unloads
975
+ retires the visitor about ten seconds later, and showing the tab again or a
976
+ back/forward-cache return revives them immediately; heartbeats fire every 20
977
+ seconds only while the tab is visible, so a hidden tab sends nothing; the
978
+ safety net for a browser that never says goodbye is 90 seconds since the last
979
+ event or heartbeat, with one bounded five-second expiry sweep per site; legacy
980
+ per-session jobs hand off to that sweep during the cutover
981
+ - Session: one identity's visit in one tab; a new `data-visitor` or 30 minutes
982
+ without an event opens the next one, which starts with a pageview of the
983
+ current page
847
984
  - Clock alignment: the tracker and the vitals module stamp each batch with
848
985
  `sentAt`; when that clock is more than 30 seconds from the server's, every
849
986
  event in the batch is re-anchored by the offset before ingestion, so a visitor
850
987
  whose device clock is wrong still goes live and is filed under the right hour
851
- - Dashboard overview: up to ten sites and bounded report ranges
852
- - Hourly overview: up to 24 hours; wider ranges use daily buckets
988
+ - Dashboard overview: up to ten sites. One overview reads at most 13,000
989
+ aggregate rows and a day holds 128 per site, so a daily range covers at most
990
+ 101 days for one site, 50 for two, and 10 for ten; a wider range fails with
991
+ `LIMIT_EXCEEDED` naming its bound. A busy range that would cross the 8 MiB a
992
+ query may read fails with `REPORT_INCOMPLETE` first
993
+ - Hourly overview: up to 24 hours (an hour holds 256 rows per site, so 16 hours
994
+ for three sites and 5 for ten); wider ranges use daily buckets
853
995
  - Retention cleanup: bounded batches. `setRetentionPolicy` automatically starts
854
996
  leased, persisted cleanup chains for every policy-managed dataset; interrupted
855
997
  attempts resume after lease expiry and stale generations cannot mutate the
@@ -888,7 +1030,8 @@ driver session pool is 4,096: at current speed the historical 2,048-session pool
888
1030
  pushes single sessions past the documented 120 events/minute budget. Results
889
1031
  came from one client process against the same non-production Synapse DEV
890
1032
  deployment and site as the 2026-08-21 certification; they are engineering
891
- evidence, not a production SLA.
1033
+ evidence, not a production SLA. Because that one client process is one address,
1034
+ a certification canary now also sets `RASTRO_CLIENT_RATE_LIMIT=off`.
892
1035
 
893
1036
  Two caveats stand deliberately. The heavy row measured a store already holding
894
1037
  the battery's own multi-million-event history; the 2026-08-22 fresh-store result
@@ -985,7 +1128,8 @@ deployments need no legacy-event migration.
985
1128
  Amage Rastro's default tracker:
986
1129
 
987
1130
  - sets no cookies;
988
- - stores a pseudonymous session ID and referral slug in `sessionStorage`, and a
1131
+ - stores a pseudonymous session ID, the visitor ID it opened with, the time of
1132
+ its last event, and the referral slug and campaign in `sessionStorage`, and a
989
1133
  persistent pseudonymous visitor ID in `localStorage` — scoped to the site's
990
1134
  origin, never shared across sites, gone when the visitor clears site data;
991
1135
  - never persists raw visitor IP addresses in component tables;
@@ -1005,7 +1149,12 @@ Amage Rastro's default tracker:
1005
1149
  clicked link or button (at most 64 characters, replaced by `data-rastro-label`
1006
1150
  when present), and outbound link origins and pathnames (the overview ranks
1007
1151
  destinations by host alone);
1008
- - strips query strings and fragments from stored paths and links.
1152
+ - strips query strings and fragments from stored paths and links; of the landing
1153
+ query it keeps the five `utm_*` values and the bare **names** of the
1154
+ advertising click-ID keys it recognizes (`fbclid`, `gclid`…), never a click-ID
1155
+ value, and never any other parameter;
1156
+ - sends the referrer as scheme and host only — an `android-app://<package>`
1157
+ included — never its path or query.
1009
1158
 
1010
1159
  The optional Web Vitals module inherits this contract: it reports at most five
1011
1160
  timing numbers per page load (milliseconds, or CLS scaled by 1000) against the
@@ -519,21 +519,25 @@ export const {
519
519
  retentionStatus,
520
520
  setRetentionPolicy,
521
521
  disableRetentionPolicy,
522
+ listTrackedLinks,
523
+ createTrackedLink,
524
+ updateTrackedLink,
525
+ deleteTrackedLink,
522
526
  } = federated;
523
527
  ```
524
528
 
525
529
  `manifest` is public static metadata. Every report function is read-only and
526
530
  requires both a valid JWT and a matching, non-revoked local grant. The functions
527
- from `siteSettings` down form the configure scope (optional capability
528
- `configure`, hosts from `alpha.11`): they additionally require
529
- `analytics:configure` on the token — the control plane claims it only for
530
- organization owners and admins — and in the grant's `permissions`, and fail with
531
- `FEDERATION_CONFIGURE_FORBIDDEN` otherwise. Export them even when every grant
532
- stays read only, so a later opt-in needs no host deploy; the dashboard never
533
- calls them for a read-only connection. The issuer falls back to the production
534
- control plane so the module compiles and pushes before the environment variable
535
- exists; tokens are still rejected until `auth.config.ts` trusts the same issuer,
536
- which needs both variables set and a push afterwards.
531
+ from `siteSettings` to `disableRetentionPolicy` form the configure scope
532
+ (optional capability `configure`, hosts from `alpha.11`): they additionally
533
+ require `analytics:configure` on the token — the control plane claims it only
534
+ for organization owners and admins — and in the grant's `permissions`, and fail
535
+ with `FEDERATION_CONFIGURE_FORBIDDEN` otherwise. Export them even when every
536
+ grant stays read only, so a later opt-in needs no host deploy; the dashboard
537
+ never calls them for a read-only connection. The issuer falls back to the
538
+ production control plane so the module compiles and pushes before the
539
+ environment variable exists; tokens are still rejected until `auth.config.ts`
540
+ trusts the same issuer, which needs both variables set and a push afterwards.
537
541
 
538
542
  A public link's token carries `analytics:public` instead of `analytics:read` and
539
543
  `rastro_site_ids` naming the link's sites. It runs only `manifest`,
@@ -587,6 +591,18 @@ the token and in the grant:
587
591
  | `setRetentionPolicy` | `siteId` and five day counts between 1 and 3650 | Starts the recurring cleanup chains |
588
592
  | `disableRetentionPolicy` | one `siteId` | Stops automatic cleanup; nothing is restored |
589
593
 
594
+ Tracked links, behind the optional `trackedLinks` capability. Listing needs only
595
+ a reader; the three writes require `analytics:configure` like the configure
596
+ scope above. A host serves them by re-exporting the four functions; one that
597
+ does not keeps working and advertises no `trackedLinks` capability.
598
+
599
+ | Function | Input summary | Important output/behavior |
600
+ | ------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
601
+ | `listTrackedLinks` | one `siteId` and `now` | Definitions with clicks and bot hits, all time and over 30 UTC days |
602
+ | `createTrackedLink` | `siteId`, slug, destination, platform, channel, campaign, `createdBy` | Returns the link ID; the destination must be on one of the site's domains |
603
+ | `updateTrackedLink` | `siteId`, `linkId`, optional destination, platform, channel, campaign, disabled | Changes everything but the slug; a disabled link answers 404 |
604
+ | `deleteTrackedLink` | `siteId`, `linkId` | Deletes the link, frees its slug, and purges its click counters |
605
+
590
606
  The federated surface deliberately excludes ingestion, owner IDs, network IDs,
591
607
  and arbitrary host functions, and shows a site's domains only through
592
608
  `siteSettings` to a connection allowed to change them. Goal, funnel, and
@@ -15,11 +15,11 @@
15
15
  "upgrading": "https://www.amagerastro.com/docs/reference/upgrading.md",
16
16
  "integrity": {
17
17
  "algorithm": "sha256",
18
- "agent": "e26de56729dd0b474669d2b40292afb314224200da4e70f6dabd407ce7aea6e7",
19
- "llms": "08e9581c9ee626642753db971e9c4da22d131ddb62870cca402d2e93ce4f7567",
18
+ "agent": "994ba7a57ada6238b9d8fdc3280e352c185c0a97772e627ee1bdc1afda1185cb",
19
+ "llms": "46fb9a206d2f41d30f72f80ac9764d30535d7d1d65afe475e03d72b075cf6359",
20
20
  "protocol": "f2be7e3db250824d0c1293eb31b2e4514ebed8b21fe4974d4f4797fd86abe04f",
21
21
  "setup": "bf2509a87a8eab9c047276b813f029bb2c9ddd58c0e54754432e3fef7891f3c9",
22
- "upgrading": "a6ed4626b3502a60c646448bdd512ab63e148322e0855ba819a77478db987f8d"
22
+ "upgrading": "e675d73c3df963ad85aa918c3b4bd8acb74c9179940497bf034ff566fa513cc7"
23
23
  }
24
24
  },
25
25
  "controlPlane": {
@@ -85,7 +85,11 @@
85
85
  "removeAffiliate": "removeAffiliate",
86
86
  "retentionStatus": "retentionStatus",
87
87
  "setRetentionPolicy": "setRetentionPolicy",
88
- "disableRetentionPolicy": "disableRetentionPolicy"
88
+ "disableRetentionPolicy": "disableRetentionPolicy",
89
+ "listTrackedLinks": "listTrackedLinks",
90
+ "createTrackedLink": "createTrackedLink",
91
+ "updateTrackedLink": "updateTrackedLink",
92
+ "deleteTrackedLink": "deleteTrackedLink"
89
93
  },
90
94
  "capabilities": [
91
95
  "siteSummaries",
@@ -104,7 +108,8 @@
104
108
  "vitals",
105
109
  "siteMap",
106
110
  "localDays",
107
- "configure"
111
+ "configure",
112
+ "trackedLinks"
108
113
  ],
109
114
  "limits": {
110
115
  "maxSitesPerRequest": 10,
@@ -185,7 +185,11 @@
185
185
  "removeAffiliate",
186
186
  "retentionStatus",
187
187
  "setRetentionPolicy",
188
- "disableRetentionPolicy"
188
+ "disableRetentionPolicy",
189
+ "listTrackedLinks",
190
+ "createTrackedLink",
191
+ "updateTrackedLink",
192
+ "deleteTrackedLink"
189
193
  ],
190
194
  "properties": {
191
195
  "manifest": { "const": "manifest" },
@@ -218,14 +222,18 @@
218
222
  "removeAffiliate": { "const": "removeAffiliate" },
219
223
  "retentionStatus": { "const": "retentionStatus" },
220
224
  "setRetentionPolicy": { "const": "setRetentionPolicy" },
221
- "disableRetentionPolicy": { "const": "disableRetentionPolicy" }
225
+ "disableRetentionPolicy": { "const": "disableRetentionPolicy" },
226
+ "listTrackedLinks": { "const": "listTrackedLinks" },
227
+ "createTrackedLink": { "const": "createTrackedLink" },
228
+ "updateTrackedLink": { "const": "updateTrackedLink" },
229
+ "deleteTrackedLink": { "const": "deleteTrackedLink" }
222
230
  },
223
231
  "additionalProperties": false
224
232
  },
225
233
  "capabilities": {
226
234
  "type": "array",
227
- "minItems": 15,
228
- "maxItems": 17,
235
+ "minItems": 16,
236
+ "maxItems": 18,
229
237
  "uniqueItems": true,
230
238
  "items": {
231
239
  "enum": [
@@ -245,7 +253,8 @@
245
253
  "vitals",
246
254
  "siteMap",
247
255
  "localDays",
248
- "configure"
256
+ "configure",
257
+ "trackedLinks"
249
258
  ]
250
259
  }
251
260
  },