@iann29/rastro 0.4.0 → 0.6.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 (112) hide show
  1. package/README.md +146 -24
  2. package/agent/integration.md +66 -29
  3. package/agent/manifest.json +19 -8
  4. package/agent/manifest.schema.json +28 -9
  5. package/dist/client/federation.d.ts +38 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +18 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/identity.d.ts +11 -0
  10. package/dist/client/identity.d.ts.map +1 -0
  11. package/dist/client/identity.js +123 -0
  12. package/dist/client/identity.js.map +1 -0
  13. package/dist/client/index.d.ts +501 -11
  14. package/dist/client/index.d.ts.map +1 -1
  15. package/dist/client/index.js +220 -4
  16. package/dist/client/index.js.map +1 -1
  17. package/dist/component/_generated/api.d.ts +8 -0
  18. package/dist/component/_generated/api.d.ts.map +1 -1
  19. package/dist/component/_generated/api.js.map +1 -1
  20. package/dist/component/_generated/component.d.ts +158 -1
  21. package/dist/component/_generated/component.d.ts.map +1 -1
  22. package/dist/component/constants.d.ts +2 -0
  23. package/dist/component/constants.d.ts.map +1 -1
  24. package/dist/component/constants.js +7 -0
  25. package/dist/component/constants.js.map +1 -1
  26. package/dist/component/coverage.d.ts +1 -0
  27. package/dist/component/coverage.d.ts.map +1 -1
  28. package/dist/component/coverage.js +6 -1
  29. package/dist/component/coverage.js.map +1 -1
  30. package/dist/component/eventStore.d.ts +2 -0
  31. package/dist/component/eventStore.d.ts.map +1 -1
  32. package/dist/component/http.d.ts.map +1 -1
  33. package/dist/component/http.js +51 -1
  34. package/dist/component/http.js.map +1 -1
  35. package/dist/component/ingest.d.ts +2 -0
  36. package/dist/component/ingest.d.ts.map +1 -1
  37. package/dist/component/ingest.js +72 -22
  38. package/dist/component/ingest.js.map +1 -1
  39. package/dist/component/origin.d.ts +70 -0
  40. package/dist/component/origin.d.ts.map +1 -0
  41. package/dist/component/origin.js +230 -0
  42. package/dist/component/origin.js.map +1 -0
  43. package/dist/component/people.d.ts +79 -0
  44. package/dist/component/people.d.ts.map +1 -0
  45. package/dist/component/people.js +249 -0
  46. package/dist/component/people.js.map +1 -0
  47. package/dist/component/platforms.d.ts +33 -0
  48. package/dist/component/platforms.d.ts.map +1 -0
  49. package/dist/component/platforms.js +328 -0
  50. package/dist/component/platforms.js.map +1 -0
  51. package/dist/component/reports.d.ts +35 -74
  52. package/dist/component/reports.d.ts.map +1 -1
  53. package/dist/component/reports.js +59 -14
  54. package/dist/component/reports.js.map +1 -1
  55. package/dist/component/sanitize.d.ts +5 -0
  56. package/dist/component/sanitize.d.ts.map +1 -1
  57. package/dist/component/sanitize.js +33 -1
  58. package/dist/component/sanitize.js.map +1 -1
  59. package/dist/component/schema.d.ts +101 -7
  60. package/dist/component/schema.js +45 -1
  61. package/dist/component/schema.js.map +1 -1
  62. package/dist/component/trackedLinks.d.ts +91 -0
  63. package/dist/component/trackedLinks.d.ts.map +1 -0
  64. package/dist/component/trackedLinks.js +314 -0
  65. package/dist/component/trackedLinks.js.map +1 -0
  66. package/dist/component/useragent.d.ts +6 -0
  67. package/dist/component/useragent.d.ts.map +1 -1
  68. package/dist/component/useragent.js +9 -0
  69. package/dist/component/useragent.js.map +1 -1
  70. package/dist/component/validators.d.ts +101 -11
  71. package/dist/component/validators.d.ts.map +1 -1
  72. package/dist/component/validators.js +62 -1
  73. package/dist/component/validators.js.map +1 -1
  74. package/dist/component/visitors.d.ts +38 -2
  75. package/dist/component/visitors.d.ts.map +1 -1
  76. package/dist/component/visitors.js +162 -42
  77. package/dist/component/visitors.js.map +1 -1
  78. package/dist/react/index.d.ts +9 -5
  79. package/dist/react/index.d.ts.map +1 -1
  80. package/dist/react/index.js +36 -5
  81. package/dist/react/index.js.map +1 -1
  82. package/dist/tracker/generated.d.ts +6 -6
  83. package/dist/tracker/generated.d.ts.map +1 -1
  84. package/dist/tracker/generated.js +6 -6
  85. package/dist/tracker/generated.js.map +1 -1
  86. package/dist/tracker/tracker.d.ts +3 -1
  87. package/dist/tracker/tracker.d.ts.map +1 -1
  88. package/dist/tracker/tracker.js +56 -5
  89. package/dist/tracker/tracker.js.map +1 -1
  90. package/dist/tracker.min.js +1 -1
  91. package/docs/federation.md +24 -0
  92. package/docs/identity.md +307 -0
  93. package/docs/upgrading.md +190 -19
  94. package/llms.txt +11 -2
  95. package/package.json +5 -3
  96. package/src/component/_generated/api.ts +8 -0
  97. package/src/component/_generated/component.ts +251 -1
  98. package/src/component/constants.ts +7 -0
  99. package/src/component/coverage.ts +7 -1
  100. package/src/component/http.ts +56 -1
  101. package/src/component/ingest.ts +100 -28
  102. package/src/component/origin.ts +273 -0
  103. package/src/component/people.ts +321 -0
  104. package/src/component/platforms.ts +359 -0
  105. package/src/component/reports.ts +84 -13
  106. package/src/component/sanitize.ts +39 -1
  107. package/src/component/schema.ts +53 -0
  108. package/src/component/trackedLinks.ts +384 -0
  109. package/src/component/useragent.ts +11 -0
  110. package/src/component/validators.ts +120 -0
  111. package/src/component/visitors.ts +232 -55
  112. package/src/tracker/generated.ts +6 -6
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Amage Rastro
2
2
 
3
- > Privacy-first, real-time web analytics for Convex. Telemetry stays in each product's Convex or Synapse deployment; the central dashboard reads only an explicitly granted report surface, and configures a site only when its host's grant opts into the configure scope.
3
+ > Real-time product and web analytics: identified people, journeys, conversions and retention on Convex. Telemetry stays in each product's Convex or Synapse deployment; the central dashboard reads only an explicitly granted report surface, and configures a site only when its host's grant opts into the configure scope.
4
4
 
5
5
  Canonical documentation: https://www.amagerastro.com/docs/
6
6
  Machine manifest: https://www.amagerastro.com/agent/manifest.json
@@ -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,999 bytes raw and 1,565 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.
@@ -71,3 +76,7 @@ Do not infer federation availability from this website, a dist-tag, or a source
71
76
  - `dataCoverage` echoes the authorized site ID but returns no event, visitor, or session IDs.
72
77
  - `vitalsReport` returns p75, averages, and exact good/needs-improvement/poor counts per metric, page, and device — never individual measurements.
73
78
  - `siteMap` returns per-route totals (pageviews, estimated visitors, entries, derived exits and bounces, time on page) and route-to-route transition counts, with numeric, UUID, and long hexadecimal segments collapsed to `:id` — never sessions, visitor IDs, or query strings. `liveVisitors` adds `previousPath` and `movedAt` when a visitor moves between routes, and since 0.1.0 the intent facts of the session — `entryPath`, `returning`, `utmCampaign`, `affiliateSlug`, `conversionCount`, and the furthest `funnel` step — which `visitorHeat` from the package turns into the dashboard's hot/warm/cold score.
79
+
80
+ ## People and identity (0.6.0+)
81
+
82
+ Use the [identity guide](https://www.amagerastro.com/docs/reference/identity.md) for Better Auth, custom Convex auth and authenticated HTTP from other backends. `Rastro.identifyVisitorMutation` resolves the current user on the host; `identifyVisitorHttpAction` authorizes external servers. `getVisitorProfile` requires reader access and is advertised as `visitorProfiles`. `listPeople` adds the optional `people` directory with indexed name/email/ID prefix search and exact filters on project-defined string, number or boolean attributes. Existing profiles need the bounded host-authorized search backfill described in the guide. The browser applies the returned ID with `identify` and calls `reset` on logout.
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": "Real-time product and web analytics for Convex: identified people, journeys, revenue, goals, funnels, affiliates, and a lightweight browser 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.6.0",
10
10
  "license": "Apache-2.0",
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -16,7 +16,7 @@
16
16
  "convex-component",
17
17
  "analytics",
18
18
  "realtime",
19
- "privacy",
19
+ "product-analytics",
20
20
  "cookieless",
21
21
  "session-journey",
22
22
  "conversion-tracking",
@@ -46,6 +46,7 @@
46
46
  "typecheck": "tsc --noEmit && tsc -p control-plane && tsc -p control-plane/convex && tsc -p example/convex",
47
47
  "lint": "eslint .",
48
48
  "test": "vitest run --typecheck",
49
+ "test:layout": "playwright test -c control-plane/playwright.layout.config.ts",
49
50
  "test:watch": "vitest --typecheck --clearScreen false",
50
51
  "test:debug": "vitest --inspect-brk --no-file-parallelism",
51
52
  "test:coverage": "vitest run --coverage --coverage.reporter=text",
@@ -68,6 +69,7 @@
68
69
  "docs/federation.md",
69
70
  "docs/federation-setup.md",
70
71
  "docs/upgrading.md",
72
+ "docs/identity.md",
71
73
  "docs/benchmarks/*.md",
72
74
  "assets/rastro-live-atlas.png"
73
75
  ],
@@ -24,12 +24,16 @@ 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 people from "../people.js";
29
+ import type * as platforms from "../platforms.js";
27
30
  import type * as reports from "../reports.js";
28
31
  import type * as retention from "../retention.js";
29
32
  import type * as rollupStore from "../rollupStore.js";
30
33
  import type * as rollups from "../rollups.js";
31
34
  import type * as sanitize from "../sanitize.js";
32
35
  import type * as sites from "../sites.js";
36
+ import type * as trackedLinks from "../trackedLinks.js";
33
37
  import type * as useragent from "../useragent.js";
34
38
  import type * as validators from "../validators.js";
35
39
  import type * as visitors from "../visitors.js";
@@ -59,12 +63,16 @@ const fullApi: ApiFromModules<{
59
63
  ingest: typeof ingest;
60
64
  live: typeof live;
61
65
  localTime: typeof localTime;
66
+ origin: typeof origin;
67
+ people: typeof people;
68
+ platforms: typeof platforms;
62
69
  reports: typeof reports;
63
70
  retention: typeof retention;
64
71
  rollupStore: typeof rollupStore;
65
72
  rollups: typeof rollups;
66
73
  sanitize: typeof sanitize;
67
74
  sites: typeof sites;
75
+ trackedLinks: typeof trackedLinks;
68
76
  useragent: typeof useragent;
69
77
  validators: typeof validators;
70
78
  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;
@@ -239,6 +241,46 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
239
241
  Name
240
242
  >;
241
243
  };
244
+ people: {
245
+ backfillSearch: FunctionReference<
246
+ "mutation",
247
+ "internal",
248
+ { siteId: string },
249
+ { isDone: boolean; updated: number },
250
+ Name
251
+ >;
252
+ list: FunctionReference<
253
+ "query",
254
+ "internal",
255
+ {
256
+ filter?:
257
+ | { field: "name" | "email" | "visitorId"; prefix: string }
258
+ | {
259
+ field: "attribute";
260
+ key: string;
261
+ value: string | number | boolean;
262
+ };
263
+ paginationOpts: { cursor: string | null; numItems: number };
264
+ siteId: string;
265
+ },
266
+ {
267
+ continueCursor: string;
268
+ isDone: boolean;
269
+ page: Array<{
270
+ _creationTime: number;
271
+ _id: string;
272
+ attributes?: Record<string, string | number | boolean>;
273
+ email?: string;
274
+ identifiedAt: number;
275
+ name?: string;
276
+ siteId: string;
277
+ updatedAt: number;
278
+ visitorId: string;
279
+ }>;
280
+ },
281
+ Name
282
+ >;
283
+ };
242
284
  reports: {
243
285
  affiliatesReport: FunctionReference<
244
286
  "query",
@@ -310,7 +352,8 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
310
352
  | "funnels"
311
353
  | "affiliates"
312
354
  | "vitals"
313
- | "siteMap";
355
+ | "siteMap"
356
+ | "origin";
314
357
  }>;
315
358
  from: number;
316
359
  siteId: string;
@@ -375,6 +418,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
375
418
  affiliateId?: string;
376
419
  affiliateSlug?: string;
377
420
  browser: string;
421
+ channel?:
422
+ | "direct"
423
+ | "organic_search"
424
+ | "paid_search"
425
+ | "organic_social"
426
+ | "paid_social"
427
+ | "messaging"
428
+ | "email"
429
+ | "affiliate"
430
+ | "ai"
431
+ | "display"
432
+ | "referral"
433
+ | "offline";
378
434
  city?: string;
379
435
  conversionCount: number;
380
436
  country?: string;
@@ -383,6 +439,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
383
439
  durationMs: number;
384
440
  entryPath: string;
385
441
  eventCount: number;
442
+ evidence?:
443
+ | "utm"
444
+ | "clickId"
445
+ | "referrer"
446
+ | "inApp"
447
+ | "affiliate"
448
+ | "none"
449
+ | "legacy";
386
450
  exitPath: string;
387
451
  lastSeenAt: number;
388
452
  latitude?: number;
@@ -390,6 +454,15 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
390
454
  newVisitor?: boolean;
391
455
  os: string;
392
456
  pageviewCount: number;
457
+ platform?: string;
458
+ profile?: {
459
+ attributes?: Record<string, string | number | boolean>;
460
+ email?: string;
461
+ identifiedAt: number;
462
+ name?: string;
463
+ updatedAt: number;
464
+ visitorId: string;
465
+ };
393
466
  referrer?: string;
394
467
  revenueCents: number;
395
468
  sessionId: string;
@@ -513,6 +586,19 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
513
586
  affiliateId?: string;
514
587
  affiliateSlug?: string;
515
588
  browser: string;
589
+ channel?:
590
+ | "direct"
591
+ | "organic_search"
592
+ | "paid_search"
593
+ | "organic_social"
594
+ | "paid_social"
595
+ | "messaging"
596
+ | "email"
597
+ | "affiliate"
598
+ | "ai"
599
+ | "display"
600
+ | "referral"
601
+ | "offline";
516
602
  city?: string;
517
603
  conversionCount: number;
518
604
  country?: string;
@@ -521,6 +607,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
521
607
  durationMs: number;
522
608
  entryPath: string;
523
609
  eventCount: number;
610
+ evidence?:
611
+ | "utm"
612
+ | "clickId"
613
+ | "referrer"
614
+ | "inApp"
615
+ | "affiliate"
616
+ | "none"
617
+ | "legacy";
524
618
  exitPath: string;
525
619
  lastSeenAt: number;
526
620
  latitude?: number;
@@ -528,6 +622,15 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
528
622
  newVisitor?: boolean;
529
623
  os: string;
530
624
  pageviewCount: number;
625
+ platform?: string;
626
+ profile?: {
627
+ attributes?: Record<string, string | number | boolean>;
628
+ email?: string;
629
+ identifiedAt: number;
630
+ name?: string;
631
+ updatedAt: number;
632
+ visitorId: string;
633
+ };
531
634
  referrer?: string;
532
635
  revenueCents: number;
533
636
  sessionId: string;
@@ -570,6 +673,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
570
673
  pageviewCount: number;
571
674
  path: string;
572
675
  previousPath?: string;
676
+ profile?: {
677
+ attributes?: Record<string, string | number | boolean>;
678
+ email?: string;
679
+ identifiedAt: number;
680
+ name?: string;
681
+ updatedAt: number;
682
+ visitorId: string;
683
+ };
573
684
  returning?: boolean;
574
685
  sessionId: string;
575
686
  siteId: string;
@@ -597,13 +708,16 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
597
708
  breakdowns: {
598
709
  topBrowsers: "pageviews" | "sessions" | "eventVolume";
599
710
  topCampaigns: "pageviews" | "sessions" | "eventVolume";
711
+ topChannels: "pageviews" | "sessions" | "eventVolume";
600
712
  topCountries: "pageviews" | "sessions" | "eventVolume";
601
713
  topDevices: "pageviews" | "sessions" | "eventVolume";
602
714
  topEvents: "eventVolume";
715
+ topEvidence: "pageviews" | "sessions" | "eventVolume";
603
716
  topGoals: "goalCompletionVolume";
604
717
  topMediums: "pageviews" | "sessions" | "eventVolume";
605
718
  topOutbound: "eventVolume";
606
719
  topPages: "pageviews" | "sessions" | "eventVolume";
720
+ topPlatforms: "pageviews" | "sessions" | "eventVolume";
607
721
  topSources: "pageviews" | "sessions" | "eventVolume";
608
722
  };
609
723
  coverage: {
@@ -661,6 +775,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
661
775
  revenueCents: number;
662
776
  value: string;
663
777
  }>;
778
+ topChannels: Array<{
779
+ count: number;
780
+ revenueCents: number;
781
+ value: string;
782
+ }>;
664
783
  topCountries: Array<{
665
784
  count: number;
666
785
  revenueCents: number;
@@ -676,6 +795,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
676
795
  revenueCents: number;
677
796
  value: string;
678
797
  }>;
798
+ topEvidence: Array<{
799
+ count: number;
800
+ revenueCents: number;
801
+ value: string;
802
+ }>;
679
803
  topGoals: Array<{
680
804
  count: number;
681
805
  revenueCents: number;
@@ -696,6 +820,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
696
820
  revenueCents: number;
697
821
  value: string;
698
822
  }>;
823
+ topPlatforms: Array<{
824
+ count: number;
825
+ revenueCents: number;
826
+ value: string;
827
+ }>;
699
828
  topSources: Array<{
700
829
  count: number;
701
830
  revenueCents: number;
@@ -1140,7 +1269,128 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1140
1269
  Name
1141
1270
  >;
1142
1271
  };
1272
+ trackedLinks: {
1273
+ create: FunctionReference<
1274
+ "mutation",
1275
+ "internal",
1276
+ {
1277
+ campaign?: string;
1278
+ channel:
1279
+ | "organic_social"
1280
+ | "paid_social"
1281
+ | "messaging"
1282
+ | "email"
1283
+ | "affiliate"
1284
+ | "display"
1285
+ | "referral"
1286
+ | "offline";
1287
+ createdBy: string;
1288
+ destination: string;
1289
+ platform: string;
1290
+ siteId: string;
1291
+ slug: string;
1292
+ },
1293
+ string,
1294
+ Name
1295
+ >;
1296
+ list: FunctionReference<
1297
+ "query",
1298
+ "internal",
1299
+ { now: number; siteId: string },
1300
+ Array<{
1301
+ _creationTime: number;
1302
+ _id: string;
1303
+ bots: number;
1304
+ botsLast30Days: number;
1305
+ campaign?: string;
1306
+ channel:
1307
+ | "organic_social"
1308
+ | "paid_social"
1309
+ | "messaging"
1310
+ | "email"
1311
+ | "affiliate"
1312
+ | "display"
1313
+ | "referral"
1314
+ | "offline";
1315
+ clicks: number;
1316
+ clicksLast30Days: number;
1317
+ createdAt: number;
1318
+ createdBy: string;
1319
+ destination: string;
1320
+ disabledAt?: number;
1321
+ platform: string;
1322
+ siteId: string;
1323
+ slug: string;
1324
+ }>,
1325
+ Name
1326
+ >;
1327
+ remove: FunctionReference<
1328
+ "mutation",
1329
+ "internal",
1330
+ { linkId: string; siteId: string },
1331
+ null,
1332
+ Name
1333
+ >;
1334
+ update: FunctionReference<
1335
+ "mutation",
1336
+ "internal",
1337
+ {
1338
+ campaign?: string | null;
1339
+ channel?:
1340
+ | "organic_social"
1341
+ | "paid_social"
1342
+ | "messaging"
1343
+ | "email"
1344
+ | "affiliate"
1345
+ | "display"
1346
+ | "referral"
1347
+ | "offline";
1348
+ destination?: string;
1349
+ disabled?: boolean;
1350
+ linkId: string;
1351
+ platform?: string;
1352
+ siteId: string;
1353
+ },
1354
+ null,
1355
+ Name
1356
+ >;
1357
+ };
1143
1358
  visitors: {
1359
+ getProfile: FunctionReference<
1360
+ "query",
1361
+ "internal",
1362
+ { siteId: string; visitorId: string },
1363
+ {
1364
+ attributes?: Record<string, string | number | boolean>;
1365
+ email?: string;
1366
+ identifiedAt: number;
1367
+ name?: string;
1368
+ updatedAt: number;
1369
+ visitorId: string;
1370
+ } | null,
1371
+ Name
1372
+ >;
1373
+ identify: FunctionReference<
1374
+ "mutation",
1375
+ "internal",
1376
+ {
1377
+ attributes?: Record<string, string | number | boolean | null>;
1378
+ email?: string | null;
1379
+ name?: string | null;
1380
+ previousVisitorId?: string;
1381
+ siteId: string;
1382
+ visitorId: string;
1383
+ },
1384
+ {
1385
+ attributes?: Record<string, string | number | boolean>;
1386
+ email?: string;
1387
+ identifiedAt: number;
1388
+ name?: string;
1389
+ updatedAt: number;
1390
+ visitorId: string;
1391
+ },
1392
+ Name
1393
+ >;
1144
1394
  link: FunctionReference<
1145
1395
  "mutation",
1146
1396
  "internal",
@@ -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");