@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.
- package/README.md +146 -24
- package/agent/integration.md +66 -29
- package/agent/manifest.json +19 -8
- package/agent/manifest.schema.json +28 -9
- package/dist/client/federation.d.ts +38 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +18 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/identity.d.ts +11 -0
- package/dist/client/identity.d.ts.map +1 -0
- package/dist/client/identity.js +123 -0
- package/dist/client/identity.js.map +1 -0
- package/dist/client/index.d.ts +501 -11
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +220 -4
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +8 -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 +158 -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 +72 -22
- 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/people.d.ts +79 -0
- package/dist/component/people.d.ts.map +1 -0
- package/dist/component/people.js +249 -0
- package/dist/component/people.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 +35 -74
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +59 -14
- 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 +101 -7
- package/dist/component/schema.js +45 -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 +101 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +62 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts +38 -2
- package/dist/component/visitors.d.ts.map +1 -1
- package/dist/component/visitors.js +162 -42
- package/dist/component/visitors.js.map +1 -1
- package/dist/react/index.d.ts +9 -5
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +36 -5
- package/dist/react/index.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 +3 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +56 -5
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/federation.md +24 -0
- package/docs/identity.md +307 -0
- package/docs/upgrading.md +190 -19
- package/llms.txt +11 -2
- package/package.json +5 -3
- package/src/component/_generated/api.ts +8 -0
- package/src/component/_generated/component.ts +251 -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 +100 -28
- package/src/component/origin.ts +273 -0
- package/src/component/people.ts +321 -0
- package/src/component/platforms.ts +359 -0
- package/src/component/reports.ts +84 -13
- package/src/component/sanitize.ts +39 -1
- package/src/component/schema.ts +53 -0
- package/src/component/trackedLinks.ts +384 -0
- package/src/component/useragent.ts +11 -0
- package/src/component/validators.ts +120 -0
- package/src/component/visitors.ts +232 -55
- package/src/tracker/generated.ts +6 -6
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Amage Rastro
|
|
2
2
|
|
|
3
|
-
>
|
|
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
|
-
-
|
|
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": "
|
|
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.
|
|
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
|
-
"
|
|
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),
|
|
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");
|