@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.
- package/README.md +122 -17
- package/agent/integration.md +26 -10
- package/agent/manifest.json +10 -5
- package/agent/manifest.schema.json +14 -5
- package/dist/client/federation.d.ts +28 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +12 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +262 -9
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +119 -2
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +6 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +71 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/constants.d.ts +2 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +7 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/coverage.d.ts +1 -0
- package/dist/component/coverage.d.ts.map +1 -1
- package/dist/component/coverage.js +6 -1
- package/dist/component/coverage.js.map +1 -1
- package/dist/component/eventStore.d.ts +2 -0
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +51 -1
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts +2 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +42 -3
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/origin.d.ts +70 -0
- package/dist/component/origin.d.ts.map +1 -0
- package/dist/component/origin.js +230 -0
- package/dist/component/origin.js.map +1 -0
- package/dist/component/platforms.d.ts +33 -0
- package/dist/component/platforms.d.ts.map +1 -0
- package/dist/component/platforms.js +328 -0
- package/dist/component/platforms.js.map +1 -0
- package/dist/component/reports.d.ts +27 -72
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +51 -10
- package/dist/component/reports.js.map +1 -1
- package/dist/component/sanitize.d.ts +5 -0
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +33 -1
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +62 -7
- package/dist/component/schema.js +16 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/trackedLinks.d.ts +91 -0
- package/dist/component/trackedLinks.d.ts.map +1 -0
- package/dist/component/trackedLinks.js +314 -0
- package/dist/component/trackedLinks.js.map +1 -0
- package/dist/component/useragent.d.ts +6 -0
- package/dist/component/useragent.d.ts.map +1 -1
- package/dist/component/useragent.js +9 -0
- package/dist/component/useragent.js.map +1 -1
- package/dist/component/validators.d.ts +51 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +35 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +6 -6
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +6 -6
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts +1 -0
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +16 -4
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/upgrading.md +152 -19
- package/llms.txt +6 -1
- package/package.json +2 -2
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +152 -1
- package/src/component/constants.ts +7 -0
- package/src/component/coverage.ts +7 -1
- package/src/component/http.ts +56 -1
- package/src/component/ingest.ts +54 -1
- package/src/component/origin.ts +273 -0
- package/src/component/platforms.ts +359 -0
- package/src/component/reports.ts +74 -10
- package/src/component/sanitize.ts +39 -1
- package/src/component/schema.ts +18 -0
- package/src/component/trackedLinks.ts +384 -0
- package/src/component/useragent.ts +11 -0
- package/src/component/validators.ts +71 -0
- package/src/tracker/generated.ts +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
|
-
-
|
|
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.
|
|
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.
|
|
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),
|
|
20
|
+
// bucket), the 0.1.0 dimensions (utm_medium and outbound hosts), and
|
|
21
|
+
// the session origins (platform, channel and evidence).
|
|
21
22
|
const stamps: {
|
|
22
23
|
siteMapSince?: number;
|
|
23
24
|
countersSince?: number;
|
|
24
25
|
localDaySince?: number;
|
|
25
26
|
dimensionsSince?: number;
|
|
27
|
+
originSince?: number;
|
|
26
28
|
} = {};
|
|
27
29
|
if (existing.siteMapSince === undefined) {
|
|
28
30
|
stamps.siteMapSince = now + ROLLUP_SAFETY_DELAY_MS;
|
|
@@ -36,6 +38,9 @@ export async function ensureAnalyticsControl(
|
|
|
36
38
|
if (existing.dimensionsSince === undefined) {
|
|
37
39
|
stamps.dimensionsSince = now + ROLLUP_SAFETY_DELAY_MS;
|
|
38
40
|
}
|
|
41
|
+
if (existing.originSince === undefined) {
|
|
42
|
+
stamps.originSince = now + ROLLUP_SAFETY_DELAY_MS;
|
|
43
|
+
}
|
|
39
44
|
if (Object.keys(stamps).length === 0) return existing;
|
|
40
45
|
await ctx.db.patch("analyticsCoverage", existing._id, {
|
|
41
46
|
...stamps,
|
|
@@ -51,6 +56,7 @@ export async function ensureAnalyticsControl(
|
|
|
51
56
|
countersSince: now + ROLLUP_SAFETY_DELAY_MS,
|
|
52
57
|
localDaySince: now + ROLLUP_SAFETY_DELAY_MS,
|
|
53
58
|
dimensionsSince: now + ROLLUP_SAFETY_DELAY_MS,
|
|
59
|
+
originSince: now + ROLLUP_SAFETY_DELAY_MS,
|
|
54
60
|
retained,
|
|
55
61
|
updatedAt: now,
|
|
56
62
|
};
|
package/src/component/http.ts
CHANGED
|
@@ -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 {
|
|
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");
|
package/src/component/ingest.ts
CHANGED
|
@@ -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,
|