@iann29/rastro 0.4.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 (92) hide show
  1. package/README.md +122 -17
  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 +71 -1
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/constants.d.ts +2 -0
  19. package/dist/component/constants.d.ts.map +1 -1
  20. package/dist/component/constants.js +7 -0
  21. package/dist/component/constants.js.map +1 -1
  22. package/dist/component/coverage.d.ts +1 -0
  23. package/dist/component/coverage.d.ts.map +1 -1
  24. package/dist/component/coverage.js +6 -1
  25. package/dist/component/coverage.js.map +1 -1
  26. package/dist/component/eventStore.d.ts +2 -0
  27. package/dist/component/eventStore.d.ts.map +1 -1
  28. package/dist/component/http.d.ts.map +1 -1
  29. package/dist/component/http.js +51 -1
  30. package/dist/component/http.js.map +1 -1
  31. package/dist/component/ingest.d.ts +2 -0
  32. package/dist/component/ingest.d.ts.map +1 -1
  33. package/dist/component/ingest.js +42 -3
  34. package/dist/component/ingest.js.map +1 -1
  35. package/dist/component/origin.d.ts +70 -0
  36. package/dist/component/origin.d.ts.map +1 -0
  37. package/dist/component/origin.js +230 -0
  38. package/dist/component/origin.js.map +1 -0
  39. package/dist/component/platforms.d.ts +33 -0
  40. package/dist/component/platforms.d.ts.map +1 -0
  41. package/dist/component/platforms.js +328 -0
  42. package/dist/component/platforms.js.map +1 -0
  43. package/dist/component/reports.d.ts +27 -72
  44. package/dist/component/reports.d.ts.map +1 -1
  45. package/dist/component/reports.js +51 -10
  46. package/dist/component/reports.js.map +1 -1
  47. package/dist/component/sanitize.d.ts +5 -0
  48. package/dist/component/sanitize.d.ts.map +1 -1
  49. package/dist/component/sanitize.js +33 -1
  50. package/dist/component/sanitize.js.map +1 -1
  51. package/dist/component/schema.d.ts +62 -7
  52. package/dist/component/schema.js +16 -1
  53. package/dist/component/schema.js.map +1 -1
  54. package/dist/component/trackedLinks.d.ts +91 -0
  55. package/dist/component/trackedLinks.d.ts.map +1 -0
  56. package/dist/component/trackedLinks.js +314 -0
  57. package/dist/component/trackedLinks.js.map +1 -0
  58. package/dist/component/useragent.d.ts +6 -0
  59. package/dist/component/useragent.d.ts.map +1 -1
  60. package/dist/component/useragent.js +9 -0
  61. package/dist/component/useragent.js.map +1 -1
  62. package/dist/component/validators.d.ts +51 -11
  63. package/dist/component/validators.d.ts.map +1 -1
  64. package/dist/component/validators.js +35 -1
  65. package/dist/component/validators.js.map +1 -1
  66. package/dist/tracker/generated.d.ts +6 -6
  67. package/dist/tracker/generated.d.ts.map +1 -1
  68. package/dist/tracker/generated.js +6 -6
  69. package/dist/tracker/generated.js.map +1 -1
  70. package/dist/tracker/tracker.d.ts +1 -0
  71. package/dist/tracker/tracker.d.ts.map +1 -1
  72. package/dist/tracker/tracker.js +16 -4
  73. package/dist/tracker/tracker.js.map +1 -1
  74. package/dist/tracker.min.js +1 -1
  75. package/docs/upgrading.md +152 -19
  76. package/llms.txt +6 -1
  77. package/package.json +2 -2
  78. package/src/component/_generated/api.ts +6 -0
  79. package/src/component/_generated/component.ts +152 -1
  80. package/src/component/constants.ts +7 -0
  81. package/src/component/coverage.ts +7 -1
  82. package/src/component/http.ts +56 -1
  83. package/src/component/ingest.ts +54 -1
  84. package/src/component/origin.ts +273 -0
  85. package/src/component/platforms.ts +359 -0
  86. package/src/component/reports.ts +74 -10
  87. package/src/component/sanitize.ts +39 -1
  88. package/src/component/schema.ts +18 -0
  89. package/src/component/trackedLinks.ts +384 -0
  90. package/src/component/useragent.ts +11 -0
  91. package/src/component/validators.ts +71 -0
  92. package/src/tracker/generated.ts +6 -6
package/llms.txt CHANGED
@@ -16,6 +16,8 @@ Do not infer federation availability from this website, a dist-tag, or a source
16
16
  ## Architecture
17
17
 
18
18
  - Tracker and telemetry: browser -> customer HTTP-actions URL -> customer Rastro component.
19
+ - The tracker is 2,594 bytes raw and 1,395 gzip. It sends the click-ID key **names** of the landing URL in `clid` (`gclid`, `fbclid`, `ttclid`…), at most eight, never a value, and the referrer as scheme and host, so an `android-app://<package>` referrer keeps its package.
20
+ - Tracked links (0.5.0+): browser -> customer HTTP-actions URL `/rastro/l/<slug>` -> `302` (`Cache-Control: no-store`) to a destination on one of the site's domains, with `utm_source` (platform), `utm_medium` (channel), `utm_campaign` (campaign, or the slug) and `utm_content` (slug) appended; the destination's own query and `utm_*` keys stay. Unknown or disabled slug: `404`. Clicks count in four-shard daily counters; self-declared bots are redirected and counted apart.
19
21
  - Optional field Web Vitals (alpha.6+): a second browser module at `/rastro/vitals.js` reports LCP, CLS, INP, FCP, and TTFB to the same events endpoint, into bounded daily histograms; it needs the base tracker on the page.
20
22
  - Reactive dashboard reads: amagerastro.com browser -> customer functions URL -> `rastroFederation:*`.
21
23
  - Control plane: account, organization, connection metadata, short-lived token issuance, and revocation.
@@ -30,7 +32,8 @@ Do not infer federation availability from this website, a dist-tag, or a source
30
32
  - Resolve an indexed, authoritative local grant on every federated query.
31
33
  - Grant one through ten explicit site IDs. Never grant by owner-wide enumeration.
32
34
  - Use the canonical module name `convex/rastroFederation.ts`.
33
- - Expose only the manifest, connection status, site summaries, reports, journeys, conversions, affiliates, funnels, goals, web vitals, coverage, and the configure functions listed in the manifest; the configure functions run only when the token and the local grant both carry `analytics:configure`. A public link's token carries `analytics:public` alone and reaches only the manifest, connection status, site summaries, a redacted overview, redacted live visitors, and coverage, and only when the local grant lists `analytics:public`.
35
+ - A host on 0.5.0 re-exports `listTrackedLinks`, `createTrackedLink`, `updateTrackedLink` and `deleteTrackedLink` from that module and reverifies, which advertises the optional `trackedLinks` capability; listing needs a reader, the other three need `analytics:configure`. A host that skips it keeps working without the capability.
36
+ - Expose only the manifest, connection status, site summaries, reports, journeys, conversions, affiliates, funnels, goals, web vitals, coverage, tracked links, and the configure functions listed in the manifest; the configure functions run only when the token and the local grant both carry `analytics:configure`. A public link's token carries `analytics:public` alone and reaches only the manifest, connection status, site summaries, a redacted overview, redacted live visitors, and coverage, and only when the local grant lists `analytics:public`.
34
37
  - Use the functions URL as JWT audience and the HTTP-actions URL for tracker ingestion.
35
38
  - Rehearse on an explicit development target. Production changes require fresh operator consent.
36
39
  - Validate manifest, authenticated connection status, redacted site list, real ingestion, reactive live data, and local-first revocation.
@@ -55,6 +58,7 @@ Do not infer federation availability from this website, a dist-tag, or a source
55
58
  - Vitals report maximum: 90 complete UTC days; `vitals` is an optional capability that hosts before `alpha.6` do not advertise.
56
59
  - Site map maximum: 90 complete UTC days, read newest day first within 8,000 route rows; older days it could not read are declared as partial coverage. `siteMap` is an optional capability that hosts before `alpha.8` do not advertise, and its data starts at the first ingest after that upgrade.
57
60
  - Maximum live visitors: 500.
61
+ - Tracked links: at most 200 per site; slugs are 3–32 lowercase letters, digits or hyphens, unique across the deployment; `listTrackedLinks` reports clicks and bots all time and over the 30 UTC days ending on the `now` it is given. `trackedLinks` is an optional capability that hosts before 0.5.0, and hosts that did not re-export the four functions, do not advertise.
58
62
  - Live window: the tracker sends a `leave` when the tab hides or the page unloads, retiring the visitor about ten seconds later; a shown tab or a bfcache return sends a heartbeat at once and revives them. The safety net without a goodbye is 90 seconds since the last event or heartbeat. `POST /rastro/events` re-anchors a batch whose `sentAt` is more than 30 seconds from server time, so a wrong device clock decides neither liveness nor hour buckets.
59
63
  - Maximum journey events: 500.
60
64
  - Token lifetime: 10 minutes, RS256. Every member's token carries `analytics:read`; owners and admins also carry `analytics:configure`, which the host honors only when the local grant lists it (`configure` is an optional capability that hosts before `alpha.11` do not advertise). A public link's token carries `analytics:public` alone, plus `rastro_site_ids` naming the link's sites; hosts before `0.1.0` reject it.
@@ -62,6 +66,7 @@ Do not infer federation availability from this website, a dist-tag, or a source
62
66
  ## Read semantics
63
67
 
64
68
  - Times are inclusive Unix epoch milliseconds in UTC.
69
+ - Origin (0.5.0+): a session is classified once, on its first touch, into `platform` (about forty ids), `channel` (twelve, `direct` to `ai` plus `offline`) and `evidence`, the rule that decided it. Decision order, first match wins: declared `utm_source`, click-ID name, referrer host by whole label, in-app browser, affiliate `ref`; nothing at all is the only way to be `direct`. `overview.topPlatforms`, `topChannels` and `topEvidence` rank them beside `topSources`; a `topPlatforms` value is the bare platform id for its default channel and `platform@channel` otherwise. `dataCoverage`'s `origin` dataset says from when a site carries origins; older sessions are classified on read, marked `evidence: "legacy"`, and never enter the aggregates. `source` is unchanged.
65
70
  - Goal, funnel, and affiliate reports include read-only definitions plus aggregates. Configuration writes (goals, funnels, affiliates, a site's name/domains/timezone, retention) exist only in the configure scope and fail with `FEDERATION_CONFIGURE_FORBIDDEN` for a read-only token or grant; `listSites` never returns domains, only `siteSettings` does, to a connection allowed to change them.
66
71
  - A public token (`analytics:public`) gets `overview` with zero `conversions` and `revenueCents` and empty `topCampaigns`, `topMediums`, `topEvents`, and `topGoals`, `liveVisitors` with `visitorId` equal to `sessionId` and without `entryPath`, `returning`, `utmCampaign`, `affiliateSlug`, `conversionCount`, or `funnel`, and `FEDERATION_PUBLIC_FORBIDDEN` for sessions, journeys, conversions, goals, funnels, affiliates, vitals, the site map, and the configure scope.
67
72
  - Session and visitor journeys include bounded custom properties. Hosts must never place personal data or secrets in event properties.
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@iann29/rastro",
3
- "description": "Privacy-first, real-time web analytics for Convex: live visitor atlas, journeys, revenue, goals, funnels, affiliates, and a 1.1 KB tracker.",
3
+ "description": "Privacy-first, real-time web analytics for Convex: live visitor atlas, journeys, revenue, goals, funnels, affiliates, and a 1.4 KB tracker.",
4
4
  "repository": "github:amageweb/amage-rastro",
5
5
  "homepage": "https://github.com/amageweb/amage-rastro#readme",
6
6
  "bugs": {
7
7
  "url": "https://github.com/amageweb/amage-rastro/issues"
8
8
  },
9
- "version": "0.4.0",
9
+ "version": "0.5.0",
10
10
  "license": "Apache-2.0",
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -24,12 +24,15 @@ import type * as identity from "../identity.js";
24
24
  import type * as ingest from "../ingest.js";
25
25
  import type * as live from "../live.js";
26
26
  import type * as localTime from "../localTime.js";
27
+ import type * as origin from "../origin.js";
28
+ import type * as platforms from "../platforms.js";
27
29
  import type * as reports from "../reports.js";
28
30
  import type * as retention from "../retention.js";
29
31
  import type * as rollupStore from "../rollupStore.js";
30
32
  import type * as rollups from "../rollups.js";
31
33
  import type * as sanitize from "../sanitize.js";
32
34
  import type * as sites from "../sites.js";
35
+ import type * as trackedLinks from "../trackedLinks.js";
33
36
  import type * as useragent from "../useragent.js";
34
37
  import type * as validators from "../validators.js";
35
38
  import type * as visitors from "../visitors.js";
@@ -59,12 +62,15 @@ const fullApi: ApiFromModules<{
59
62
  ingest: typeof ingest;
60
63
  live: typeof live;
61
64
  localTime: typeof localTime;
65
+ origin: typeof origin;
66
+ platforms: typeof platforms;
62
67
  reports: typeof reports;
63
68
  retention: typeof retention;
64
69
  rollupStore: typeof rollupStore;
65
70
  rollups: typeof rollups;
66
71
  sanitize: typeof sanitize;
67
72
  sites: typeof sites;
73
+ trackedLinks: typeof trackedLinks;
68
74
  useragent: typeof useragent;
69
75
  validators: typeof validators;
70
76
  visitors: typeof visitors;
@@ -183,6 +183,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
183
183
  city?: string;
184
184
  country?: string;
185
185
  device?: string;
186
+ inApp?: string;
186
187
  latitude?: number;
187
188
  longitude?: number;
188
189
  os?: string;
@@ -190,6 +191,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
190
191
  };
191
192
  events: Array<{
192
193
  affiliateSlug?: string;
194
+ clid?: Array<string>;
193
195
  currency?: string;
194
196
  eventId: string;
195
197
  href?: string;
@@ -310,7 +312,8 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
310
312
  | "funnels"
311
313
  | "affiliates"
312
314
  | "vitals"
313
- | "siteMap";
315
+ | "siteMap"
316
+ | "origin";
314
317
  }>;
315
318
  from: number;
316
319
  siteId: string;
@@ -375,6 +378,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
375
378
  affiliateId?: string;
376
379
  affiliateSlug?: string;
377
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";
378
394
  city?: string;
379
395
  conversionCount: number;
380
396
  country?: string;
@@ -383,6 +399,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
383
399
  durationMs: number;
384
400
  entryPath: string;
385
401
  eventCount: number;
402
+ evidence?:
403
+ | "utm"
404
+ | "clickId"
405
+ | "referrer"
406
+ | "inApp"
407
+ | "affiliate"
408
+ | "none"
409
+ | "legacy";
386
410
  exitPath: string;
387
411
  lastSeenAt: number;
388
412
  latitude?: number;
@@ -390,6 +414,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
390
414
  newVisitor?: boolean;
391
415
  os: string;
392
416
  pageviewCount: number;
417
+ platform?: string;
393
418
  referrer?: string;
394
419
  revenueCents: number;
395
420
  sessionId: string;
@@ -513,6 +538,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
513
538
  affiliateId?: string;
514
539
  affiliateSlug?: string;
515
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";
516
554
  city?: string;
517
555
  conversionCount: number;
518
556
  country?: string;
@@ -521,6 +559,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
521
559
  durationMs: number;
522
560
  entryPath: string;
523
561
  eventCount: number;
562
+ evidence?:
563
+ | "utm"
564
+ | "clickId"
565
+ | "referrer"
566
+ | "inApp"
567
+ | "affiliate"
568
+ | "none"
569
+ | "legacy";
524
570
  exitPath: string;
525
571
  lastSeenAt: number;
526
572
  latitude?: number;
@@ -528,6 +574,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
528
574
  newVisitor?: boolean;
529
575
  os: string;
530
576
  pageviewCount: number;
577
+ platform?: string;
531
578
  referrer?: string;
532
579
  revenueCents: number;
533
580
  sessionId: string;
@@ -597,13 +644,16 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
597
644
  breakdowns: {
598
645
  topBrowsers: "pageviews" | "sessions" | "eventVolume";
599
646
  topCampaigns: "pageviews" | "sessions" | "eventVolume";
647
+ topChannels: "pageviews" | "sessions" | "eventVolume";
600
648
  topCountries: "pageviews" | "sessions" | "eventVolume";
601
649
  topDevices: "pageviews" | "sessions" | "eventVolume";
602
650
  topEvents: "eventVolume";
651
+ topEvidence: "pageviews" | "sessions" | "eventVolume";
603
652
  topGoals: "goalCompletionVolume";
604
653
  topMediums: "pageviews" | "sessions" | "eventVolume";
605
654
  topOutbound: "eventVolume";
606
655
  topPages: "pageviews" | "sessions" | "eventVolume";
656
+ topPlatforms: "pageviews" | "sessions" | "eventVolume";
607
657
  topSources: "pageviews" | "sessions" | "eventVolume";
608
658
  };
609
659
  coverage: {
@@ -661,6 +711,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
661
711
  revenueCents: number;
662
712
  value: string;
663
713
  }>;
714
+ topChannels: Array<{
715
+ count: number;
716
+ revenueCents: number;
717
+ value: string;
718
+ }>;
664
719
  topCountries: Array<{
665
720
  count: number;
666
721
  revenueCents: number;
@@ -676,6 +731,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
676
731
  revenueCents: number;
677
732
  value: string;
678
733
  }>;
734
+ topEvidence: Array<{
735
+ count: number;
736
+ revenueCents: number;
737
+ value: string;
738
+ }>;
679
739
  topGoals: Array<{
680
740
  count: number;
681
741
  revenueCents: number;
@@ -696,6 +756,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
696
756
  revenueCents: number;
697
757
  value: string;
698
758
  }>;
759
+ topPlatforms: Array<{
760
+ count: number;
761
+ revenueCents: number;
762
+ value: string;
763
+ }>;
699
764
  topSources: Array<{
700
765
  count: number;
701
766
  revenueCents: number;
@@ -1140,6 +1205,92 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1140
1205
  Name
1141
1206
  >;
1142
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
+ };
1143
1294
  visitors: {
1144
1295
  link: FunctionReference<
1145
1296
  "mutation",
@@ -31,6 +31,13 @@ export const MAX_SITES_PER_OWNER = 100;
31
31
  export const MAX_GOALS_PER_SITE = 50;
32
32
  export const MAX_FUNNELS_PER_SITE = 20;
33
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;
34
41
  export const MAX_FUNNEL_STEPS = 10;
35
42
  export const MAX_PROPERTIES = 16;
36
43
  export const MAX_REPORT_SITES = 10;
@@ -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
  };
@@ -21,7 +21,9 @@ import {
21
21
  import { isPlainRecord } from "./guards.js";
22
22
  import { alignClock, sanitizeEvent } from "./sanitize.js";
23
23
  import { deriveClientKey, deriveVisitorKey, randomSecret } from "./identity.js";
24
- import { classifyClient, isKnownBot } from "./useragent.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,9 +176,11 @@ 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
  };
182
186
  // Origin is a browser control: any other sender writes the header it
@@ -304,6 +308,46 @@ http.route({
304
308
  ),
305
309
  });
306
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
+
307
351
  http.route({
308
352
  path: "/health",
309
353
  method: "GET",
@@ -434,6 +478,7 @@ function parseEvent(value: unknown): TrackerEvent {
434
478
  throw new Error("event is missing required fields");
435
479
  }
436
480
  const properties = parseProperties(value.properties);
481
+ const clid = parseClickIds(value.clid);
437
482
  return {
438
483
  eventId: value.eventId,
439
484
  sessionId: value.sessionId,
@@ -467,9 +512,19 @@ function parseEvent(value: unknown): TrackerEvent {
467
512
  ...(optionalNumber(value.value, "value") !== undefined
468
513
  ? { value: optionalNumber(value.value, "value") }
469
514
  : {}),
515
+ ...(clid ? { clid } : {}),
470
516
  };
471
517
  }
472
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
+
473
528
  function parseProperties(value: unknown): TrackerEvent["properties"] {
474
529
  if (value === undefined) return undefined;
475
530
  if (!isPlainRecord(value)) throw new Error("properties must be an object");
@@ -54,6 +54,7 @@ import {
54
54
  } from "./eventStore.js";
55
55
  import {
56
56
  cleanString,
57
+ externalReferrer,
57
58
  isValidMoney,
58
59
  measureBatchBytes,
59
60
  normalizeRoute,
@@ -74,6 +75,8 @@ import {
74
75
  ingestResultValidator,
75
76
  trackerEventValidator,
76
77
  trustedConversionValidator,
78
+ type Channel,
79
+ type Evidence,
77
80
  type IngestContext,
78
81
  type TrackerEvent,
79
82
  } from "./validators.js";
@@ -85,6 +88,7 @@ import {
85
88
  type RouteRollupDelta,
86
89
  } from "./rollupStore.js";
87
90
  import { localDayStart, localDayTimezone } from "./localTime.js";
91
+ import { classifyOrigin, platformDimensionValue } from "./origin.js";
88
92
 
89
93
  type DimensionType =
90
94
  | "source"
@@ -96,7 +100,10 @@ type DimensionType =
96
100
  | "event"
97
101
  | "goal"
98
102
  | "medium"
99
- | "outbound";
103
+ | "outbound"
104
+ | "platform"
105
+ | "channel"
106
+ | "evidence";
100
107
 
101
108
  type Dimension = { type: DimensionType; value: string };
102
109
  type DimensionDelta = Dimension & {
@@ -145,6 +152,10 @@ type SessionState = {
145
152
  continued: boolean;
146
153
  startedAt: number;
147
154
  source: string;
155
+ /** The first touch's origin; absent on sessions recorded before it. */
156
+ platform?: string;
157
+ channel?: Channel;
158
+ evidence?: Evidence;
148
159
  visitorKey: string;
149
160
  utmCampaign?: string;
150
161
  utmMedium?: string;
@@ -567,6 +578,7 @@ export const ingestBatch = mutation({
567
578
  reportRollupDeltas,
568
579
  recordTelemetry: true,
569
580
  timezone,
581
+ siteDomains: site.domains,
570
582
  financialConversion: false,
571
583
  trustedGoalKey: undefined,
572
584
  now,
@@ -697,6 +709,7 @@ export const trackConversion = mutation({
697
709
  reportRollupDeltas,
698
710
  recordTelemetry: existingTelemetry === null && !telemetry.saturated,
699
711
  timezone: localDayTimezone(site.timezone),
712
+ siteDomains: site.domains,
700
713
  financialConversion: true,
701
714
  trustedGoalKey,
702
715
  now: Date.now(),
@@ -835,6 +848,8 @@ async function accountEvent(
835
848
  recordTelemetry: boolean;
836
849
  /** The site's timezone when it keeps local-day buckets. */
837
850
  timezone?: string;
851
+ /** The site's own domains: a referrer on them is not an origin. */
852
+ siteDomains: string[];
838
853
  financialConversion: boolean;
839
854
  trustedGoalKey: string | undefined;
840
855
  now: number;
@@ -848,6 +863,7 @@ async function accountEvent(
848
863
  input.siteId,
849
864
  event,
850
865
  input.context,
866
+ input.siteDomains,
851
867
  input.existingSession,
852
868
  input.financialConversion,
853
869
  input.reportRollupDeltas,
@@ -969,6 +985,23 @@ async function accountEvent(
969
985
  event.type === "outbound" ? publicUrlHost(event.href) : undefined;
970
986
  const dimensions: Dimension[] = [
971
987
  { type: "source", value: session.source },
988
+ // A session recorded before origins carries none, and fills no slot.
989
+ ...(session.platform && session.channel && session.evidence
990
+ ? ([
991
+ {
992
+ type: "platform",
993
+ // The value carries the channel too when it is not the
994
+ // platform's default, so the pair survives without its own
995
+ // dimension (splitPlatformDimension reads it back).
996
+ value: platformDimensionValue({
997
+ platform: session.platform,
998
+ channel: session.channel,
999
+ }),
1000
+ },
1001
+ { type: "channel", value: session.channel },
1002
+ { type: "evidence", value: session.evidence },
1003
+ ] satisfies Dimension[])
1004
+ : []),
972
1005
  ...campaignDimensions,
973
1006
  ...(outboundHost
974
1007
  ? [{ type: "outbound", value: outboundHost } satisfies Dimension]
@@ -1035,6 +1068,9 @@ async function accountTrustedSession(
1035
1068
  continued: false,
1036
1069
  startedAt: existing.startedAt,
1037
1070
  source: existing.source,
1071
+ platform: existing.platform,
1072
+ channel: existing.channel,
1073
+ evidence: existing.evidence,
1038
1074
  utmCampaign: existing.utmCampaign,
1039
1075
  utmMedium: existing.utmMedium,
1040
1076
  entryPath: existing.entryPath,
@@ -1059,6 +1095,7 @@ async function upsertSession(
1059
1095
  siteId: Id<"sites">,
1060
1096
  event: TrackerEvent,
1061
1097
  context: IngestContext,
1098
+ siteDomains: string[],
1062
1099
  existing: CachedDocument<"sessions"> | null,
1063
1100
  financialConversion: boolean,
1064
1101
  reportRollupDeltas: ReportRollupDeltas,
@@ -1156,6 +1193,9 @@ async function upsertSession(
1156
1193
  continued: event.type === "pageview" && existing.pageviewCount === 1,
1157
1194
  startedAt,
1158
1195
  source: existing.source,
1196
+ platform: existing.platform,
1197
+ channel: existing.channel,
1198
+ evidence: existing.evidence,
1159
1199
  utmCampaign: existing.utmCampaign,
1160
1200
  utmMedium: existing.utmMedium,
1161
1201
  entryPath: existing.entryPath,
@@ -1206,6 +1246,17 @@ async function upsertSession(
1206
1246
  .first();
1207
1247
  if (alias) isNewVisitor = false;
1208
1248
  }
1249
+ // First touch only, like `source`: the session keeps where it came from.
1250
+ // An affiliate channel needs this landing's own `ref` to have resolved; a
1251
+ // visitor's standing attribution alone is not how this visit arrived.
1252
+ const origin = classifyOrigin({
1253
+ utmSource,
1254
+ utmMedium,
1255
+ clickIds: event.clid,
1256
+ referrer: externalReferrer(event.referrer, siteDomains),
1257
+ inApp: context.inApp,
1258
+ affiliate: event.affiliateSlug !== undefined && attribution !== undefined,
1259
+ });
1209
1260
  const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
1210
1261
  siteId,
1211
1262
  sessionId: event.sessionId,
@@ -1218,6 +1269,7 @@ async function upsertSession(
1218
1269
  newVisitor: isNewVisitor,
1219
1270
  ...(event.referrer ? { referrer: event.referrer } : {}),
1220
1271
  source,
1272
+ ...origin,
1221
1273
  ...(utmSource ? { utmSource } : {}),
1222
1274
  ...(utmMedium ? { utmMedium } : {}),
1223
1275
  ...(utmCampaign ? { utmCampaign } : {}),
@@ -1274,6 +1326,7 @@ async function upsertSession(
1274
1326
  continued: false,
1275
1327
  startedAt: event.timestamp,
1276
1328
  source,
1329
+ ...origin,
1277
1330
  utmCampaign,
1278
1331
  utmMedium,
1279
1332
  entryPath: event.path,