@iann29/rastro 0.2.0 → 0.4.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 +82 -36
- package/agent/integration.md +4 -0
- package/agent/manifest.json +9 -5
- package/agent/manifest.schema.json +7 -1
- package/dist/client/federation.d.ts +18 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +7 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +138 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +30 -0
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +46 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +1 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/constants.d.ts +7 -2
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +24 -3
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/geo.d.ts +1 -0
- package/dist/component/geo.d.ts.map +1 -1
- package/dist/component/geo.js +1 -1
- package/dist/component/geo.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +14 -1
- package/dist/component/http.js.map +1 -1
- package/dist/component/identity.d.ts +12 -0
- package/dist/component/identity.d.ts.map +1 -1
- package/dist/component/identity.js +30 -2
- package/dist/component/identity.js.map +1 -1
- package/dist/component/ingest.d.ts +34 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +86 -21
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +34 -4
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +47 -0
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +96 -6
- package/dist/component/reports.js.map +1 -1
- package/dist/component/rollupStore.d.ts +7 -4
- package/dist/component/rollupStore.d.ts.map +1 -1
- package/dist/component/rollupStore.js +37 -17
- package/dist/component/rollupStore.js.map +1 -1
- package/dist/component/rollups.d.ts +27 -8
- package/dist/component/rollups.d.ts.map +1 -1
- package/dist/component/rollups.js +110 -32
- package/dist/component/rollups.js.map +1 -1
- package/dist/component/schema.d.ts +6 -2
- package/dist/component/schema.js +5 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/tracker/generated.d.ts +11 -11
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +11 -11
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.js +88 -43
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker/vitals.d.ts.map +1 -1
- package/dist/tracker/vitals.js +22 -4
- package/dist/tracker/vitals.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/dist/vitals.min.js +1 -1
- package/docs/federation-setup.md +2 -0
- package/docs/federation.md +6 -0
- package/docs/upgrading.md +84 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/component/_generated/component.ts +49 -0
- package/src/component/_generated/server.ts +1 -0
- package/src/component/constants.ts +24 -3
- package/src/component/convex.config.ts +3 -0
- package/src/component/geo.ts +1 -1
- package/src/component/http.ts +15 -1
- package/src/component/identity.ts +41 -2
- package/src/component/ingest.ts +120 -36
- package/src/component/live.ts +51 -4
- package/src/component/reports.ts +120 -8
- package/src/component/rollupStore.ts +51 -20
- package/src/component/rollups.ts +141 -32
- package/src/component/schema.ts +6 -3
- package/src/tracker/generated.ts +11 -11
package/docs/upgrading.md
CHANGED
|
@@ -23,12 +23,94 @@ hosts still on them.
|
|
|
23
23
|
| `0.1.0-alpha.11` | Install `0.1.0-alpha.12` directly; same code, the package now ships `src/tracker/generated.ts` | A `convex/` test importing `@iann29/rastro/test` typechecks on push |
|
|
24
24
|
| `0.1.0-alpha.12` | Install `0.1.0` directly; every schema change is an optional field or a new table; add `analytics:public` to a grant only to allow public links | `/rastro/health` reports `tracker.version: "0.1.0"` |
|
|
25
25
|
| `0.1.0` | Install `0.2.0` directly; add the optional host callback and grant version to enable transfers | Health reports `0.2.0`; opted-in connectors advertise `transfer` |
|
|
26
|
+
| `0.2.0` | Install `0.3.0` directly; no new table; export `getSession` and `revenueSummary` from the federation module | Health reports `0.3.0`; the manifest lists `sessionDetails` |
|
|
27
|
+
| `0.3.0` | Install `0.4.0` directly; no re-export; the deploy rebuilds the `reportRollupQueue` index and the tracker changes | Health reports `0.4.0`; a hidden tab leaves **Ao vivo** in ~10 s |
|
|
26
28
|
| Any release whose packed declarations lack federation | Stop and request an eligible exact registry release | Packed declarations contain the federation exports |
|
|
27
29
|
|
|
28
30
|
Do not upgrade a populated legacy deployment directly to a release that removes
|
|
29
31
|
the `events` table. Schema acceptance alone does not prove that telemetry was
|
|
30
32
|
migrated.
|
|
31
33
|
|
|
34
|
+
## Sessions, presence and rollups (0.4.0)
|
|
35
|
+
|
|
36
|
+
`0.4.0` needs no host edit: no new function, capability, or re-export. Install
|
|
37
|
+
`@iann29/rastro@0.4.0`, deploy the host through its normal workflow, then open
|
|
38
|
+
**Conexões** and click **Verificar novamente** on the active connection.
|
|
39
|
+
|
|
40
|
+
What reports show differently:
|
|
41
|
+
|
|
42
|
+
- **Sessions.** A session is one identity's visit in one tab: a sign-in (a new
|
|
43
|
+
`data-visitor`) or 30 minutes without an event opens a new one, keeping the
|
|
44
|
+
referral and campaign. A tab returning after 30 idle minutes counts a pageview
|
|
45
|
+
of its current page. A tab open during the upgrade starts one new session on
|
|
46
|
+
its next page load.
|
|
47
|
+
- **Presence.** A hidden tab sends `leave` and drops out of **Ao vivo** about
|
|
48
|
+
ten seconds later; shown again, it sends a heartbeat at once and returns. A
|
|
49
|
+
hidden tab sends no requests.
|
|
50
|
+
- **Web Vitals** need the base tracker on the page; `vitals.js` now derives its
|
|
51
|
+
ids by the tracker's rule, so the default install's vitals are accepted.
|
|
52
|
+
- **Duration** is counted at departure. Heartbeats no longer rewrite aggregate
|
|
53
|
+
buckets, so a visitor still on a page shows the time up to their last page or
|
|
54
|
+
event; the rest lands when they leave (about ten seconds after a `leave`, at
|
|
55
|
+
most 90 seconds after the last heartbeat). Finished visits are unchanged.
|
|
56
|
+
- **Rollups** fold about once a second per site, so bucketed reports trail
|
|
57
|
+
ingestion by about a second.
|
|
58
|
+
- **Overview.** A busy range fails with `REPORT_INCOMPLETE` instead of a raw
|
|
59
|
+
read-limit error, and `LIMIT_EXCEEDED` names the real cap: 101 days for one
|
|
60
|
+
site, 10 for ten; hourly, 24 hours for two sites, 5 for ten.
|
|
61
|
+
- **Funnels.** A completed funnel can be entered again once its conversion
|
|
62
|
+
window has passed.
|
|
63
|
+
|
|
64
|
+
Operations:
|
|
65
|
+
|
|
66
|
+
- The deploy swaps the `reportRollupQueue` index for
|
|
67
|
+
`by_siteId_and_deadLetteredAt_and_createdAt` and backfills it during the push.
|
|
68
|
+
The queue holds only batches waiting for their fold, so no staged phase is
|
|
69
|
+
needed. A batch that fails three fold runs is logged and set aside for 30
|
|
70
|
+
days.
|
|
71
|
+
- Ingestion admits at most 1,200 events and 2 MiB a minute per site from one
|
|
72
|
+
client network (an IPv4 address or an IPv6 /64) when `/rastro/health` reports
|
|
73
|
+
`clientIpAvailable: true`; visitors behind one NAT share it. The per-site
|
|
74
|
+
budget drops to 384,000 events a minute. Over budget answers 429
|
|
75
|
+
`RATE_LIMITED`.
|
|
76
|
+
- `RASTRO_CLIENT_RATE_LIMIT=off` lifts the per-client budget for load-test
|
|
77
|
+
canaries only; leave it unset everywhere else.
|
|
78
|
+
|
|
79
|
+
Acceptance for this upgrade: `/rastro/health` reports `tracker.version: "0.4.0"`
|
|
80
|
+
and a new `tracker.hash`; `GET /rastro/tracker.js` answers with a new `ETag`,
|
|
81
|
+
which returning browsers pick up within the cache window (see
|
|
82
|
+
[Tracker cache](#tracker-cache)); in a browser context opened after the deploy,
|
|
83
|
+
a hidden tab disappears from **Ao vivo** within about ten seconds and returns
|
|
84
|
+
when shown again.
|
|
85
|
+
|
|
86
|
+
## Session details and complete revenue lists (0.3.0)
|
|
87
|
+
|
|
88
|
+
After upgrading the package, export `getSession` and `revenueSummary` from the
|
|
89
|
+
object returned by `exposeFederatedAnalyticsApi` in
|
|
90
|
+
`convex/rastroFederation.ts`, then deploy the host and reverify its dashboard
|
|
91
|
+
connection. The manifest advertises `sessionDetails` and `revenueSummary`;
|
|
92
|
+
advertising them without exporting the functions leaves an incomplete host
|
|
93
|
+
surface.
|
|
94
|
+
|
|
95
|
+
`getSession({siteId, sessionId})` returns the retained session metadata (or
|
|
96
|
+
null). The dashboard pairs it with the existing paginated `sessionJourney` so a
|
|
97
|
+
copied session link keeps its duration, entry/exit and counters even after a
|
|
98
|
+
reload. Older hosts still provide scoped session events, but cannot restore all
|
|
99
|
+
metadata.
|
|
100
|
+
|
|
101
|
+
`revenueSummary({siteIds, from, to})` returns exact `commissionCents` for the
|
|
102
|
+
inclusive period. Revenue, conversion count, average order and currency reuse
|
|
103
|
+
`overview` totals. The commission report reads existing daily affiliate stats
|
|
104
|
+
for complete UTC days and trusted conversion records only for partial edge days;
|
|
105
|
+
renamed/deleted affiliate definitions do not remove historical commissions. It
|
|
106
|
+
rejects unavailable retained sources, mixed currencies and periods requiring
|
|
107
|
+
more than 5,000 source rows instead of presenting partial totals. No new table,
|
|
108
|
+
backfill or ingestion change is required. Older hosts show commissions as
|
|
109
|
+
unavailable, never as the sum of a loaded list page.
|
|
110
|
+
|
|
111
|
+
Session and conversion lists now expose per-site keyset continuation, merge rows
|
|
112
|
+
in chronological order and label search as filtering only loaded records.
|
|
113
|
+
|
|
32
114
|
## Organization transfers (0.2.0)
|
|
33
115
|
|
|
34
116
|
Install `@iann29/rastro@0.2.0` after rehearsing on a non-production snapshot.
|
|
@@ -399,6 +481,8 @@ control-plane issuer.
|
|
|
399
481
|
functions, a read-only grant refuses `upsertGoal` with
|
|
400
482
|
`FEDERATION_CONFIGURE_FORBIDDEN`, and a grant listing `analytics:configure`
|
|
401
483
|
lets an owner or admin create a goal from the dashboard.
|
|
484
|
+
- On `0.4.0` or later: hiding the site's tab removes the visitor from the live
|
|
485
|
+
view within about ten seconds, and showing it brings them back.
|
|
402
486
|
- Protected overview, journey, goal, funnel, and affiliate reports used by the
|
|
403
487
|
host still run.
|
|
404
488
|
- Trusted conversion idempotency and currency checks still pass.
|
package/llms.txt
CHANGED
|
@@ -16,7 +16,7 @@ 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
|
-
- 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.
|
|
19
|
+
- 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
20
|
- Reactive dashboard reads: amagerastro.com browser -> customer functions URL -> `rastroFederation:*`.
|
|
21
21
|
- Control plane: account, organization, connection metadata, short-lived token issuance, and revocation.
|
|
22
22
|
- The control plane does not proxy or persist customer analytics events.
|
|
@@ -49,13 +49,13 @@ Do not infer federation availability from this website, a dist-tag, or a source
|
|
|
49
49
|
- Protocol: `amage-rastro-analytics` version 1.
|
|
50
50
|
- Maximum sites per connection and report request: 10.
|
|
51
51
|
- Maximum report range: 366 days.
|
|
52
|
-
-
|
|
52
|
+
- Overview maximum, bounded by the aggregate rows it reads: 101 days for one site and 10 for ten; hourly, 24 hours for two sites and 5 for ten. A wider range fails with `LIMIT_EXCEEDED`, a busy one with `REPORT_INCOMPLETE`.
|
|
53
53
|
- Funnel report maximum: 90 complete UTC days.
|
|
54
54
|
- Affiliate reports require complete UTC-day boundaries.
|
|
55
55
|
- Vitals report maximum: 90 complete UTC days; `vitals` is an optional capability that hosts before `alpha.6` do not advertise.
|
|
56
56
|
- 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
57
|
- Maximum live visitors: 500.
|
|
58
|
-
- Live window: a `leave`
|
|
58
|
+
- 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
59
|
- Maximum journey events: 500.
|
|
60
60
|
- 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.
|
|
61
61
|
|
package/package.json
CHANGED
|
@@ -177,6 +177,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
177
177
|
"mutation",
|
|
178
178
|
"internal",
|
|
179
179
|
{
|
|
180
|
+
clientKey?: string;
|
|
180
181
|
context?: {
|
|
181
182
|
browser?: string;
|
|
182
183
|
city?: string;
|
|
@@ -362,6 +363,47 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
362
363
|
}>,
|
|
363
364
|
Name
|
|
364
365
|
>;
|
|
366
|
+
getSession: FunctionReference<
|
|
367
|
+
"query",
|
|
368
|
+
"internal",
|
|
369
|
+
{ sessionId: string; siteId: string },
|
|
370
|
+
{
|
|
371
|
+
_creationTime: number;
|
|
372
|
+
_id: string;
|
|
373
|
+
affiliateAttributedAt?: number;
|
|
374
|
+
affiliateExpiresAt?: number;
|
|
375
|
+
affiliateId?: string;
|
|
376
|
+
affiliateSlug?: string;
|
|
377
|
+
browser: string;
|
|
378
|
+
city?: string;
|
|
379
|
+
conversionCount: number;
|
|
380
|
+
country?: string;
|
|
381
|
+
currency?: string;
|
|
382
|
+
device: string;
|
|
383
|
+
durationMs: number;
|
|
384
|
+
entryPath: string;
|
|
385
|
+
eventCount: number;
|
|
386
|
+
exitPath: string;
|
|
387
|
+
lastSeenAt: number;
|
|
388
|
+
latitude?: number;
|
|
389
|
+
longitude?: number;
|
|
390
|
+
newVisitor?: boolean;
|
|
391
|
+
os: string;
|
|
392
|
+
pageviewCount: number;
|
|
393
|
+
referrer?: string;
|
|
394
|
+
revenueCents: number;
|
|
395
|
+
sessionId: string;
|
|
396
|
+
siteId: string;
|
|
397
|
+
source: string;
|
|
398
|
+
startedAt: number;
|
|
399
|
+
utmCampaign?: string;
|
|
400
|
+
utmMedium?: string;
|
|
401
|
+
utmSource?: string;
|
|
402
|
+
visitorId: string;
|
|
403
|
+
visitorKey?: string;
|
|
404
|
+
} | null,
|
|
405
|
+
Name
|
|
406
|
+
>;
|
|
365
407
|
goalsReport: FunctionReference<
|
|
366
408
|
"query",
|
|
367
409
|
"internal",
|
|
@@ -676,6 +718,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
676
718
|
},
|
|
677
719
|
Name
|
|
678
720
|
>;
|
|
721
|
+
revenueSummary: FunctionReference<
|
|
722
|
+
"query",
|
|
723
|
+
"internal",
|
|
724
|
+
{ from: number; siteIds: Array<string>; to: number },
|
|
725
|
+
{ commissionCents: number },
|
|
726
|
+
Name
|
|
727
|
+
>;
|
|
679
728
|
sessionJourney: FunctionReference<
|
|
680
729
|
"query",
|
|
681
730
|
"internal",
|
|
@@ -39,6 +39,7 @@ import type { DataModel } from "./dataModel.js";
|
|
|
39
39
|
type Env = {
|
|
40
40
|
readonly CONVEX_CLOUD_URL: string;
|
|
41
41
|
readonly CONVEX_SITE_URL: string;
|
|
42
|
+
readonly RASTRO_CLIENT_RATE_LIMIT: "off" | undefined;
|
|
42
43
|
readonly RASTRO_GEOIP_DAILY_LIMIT: string | undefined;
|
|
43
44
|
readonly RASTRO_GEOIP_PROVIDER: "ipinfo" | "ipwhois" | undefined;
|
|
44
45
|
readonly RASTRO_GEOIP_TOKEN: string | undefined;
|
|
@@ -4,9 +4,20 @@ export const MAX_BATCH_BYTES = 64 * 1024;
|
|
|
4
4
|
export const MAX_EVENTS_PER_SESSION_WINDOW = 120;
|
|
5
5
|
export const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
6
6
|
export const MAX_BYTES_PER_SESSION_WINDOW = 256 * 1024;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// A site admits at most SITE_INGEST_SHARDS × the per-shard caps a minute:
|
|
8
|
+
// 384,000 events and 512 MiB. Shards keep every POST off one shared row per
|
|
9
|
+
// site; random session IDs can only fill them evenly, while a real peak lands
|
|
10
|
+
// unevenly — the realistic benchmark (~100k events/min) peaks near 1,050
|
|
11
|
+
// events in its hottest shard-minute, so fewer shards would need a looser cap
|
|
12
|
+
// per shard and more would need a looser cap per site.
|
|
13
|
+
export const SITE_INGEST_SHARDS = 256;
|
|
14
|
+
export const MAX_SITE_EVENTS_PER_SHARD_WINDOW = 1_500;
|
|
15
|
+
export const MAX_SITE_BYTES_PER_SHARD_WINDOW = 2 * 1024 * 1024;
|
|
16
|
+
// Per client network (an IPv4 address or an IPv6 /64, keyed by a salted daily
|
|
17
|
+
// hash) per site and minute. Generous on purpose: an office or carrier-grade
|
|
18
|
+
// NAT puts many visitors behind one address, and they all share this budget.
|
|
19
|
+
export const MAX_EVENTS_PER_CLIENT_WINDOW = 1_200;
|
|
20
|
+
export const MAX_BYTES_PER_CLIENT_WINDOW = 2 * 1024 * 1024;
|
|
10
21
|
export const LIVE_SESSION_TTL_MS = 90_000;
|
|
11
22
|
export const LIVE_LEAVE_GRACE_MS = 10_000;
|
|
12
23
|
export const LIVE_SWEEP_INTERVAL_MS = 5_000;
|
|
@@ -44,6 +55,16 @@ export const RETENTION_LEASE_MS = 60_000;
|
|
|
44
55
|
export const ROLLUP_FOLD_BATCH_DOCS = 200;
|
|
45
56
|
export const ROLLUP_FOLD_MAX_ROWS = 1_500;
|
|
46
57
|
export const ROLLUP_FOLD_LEASE_MS = 60_000;
|
|
58
|
+
// A batch that finds no fold pending schedules one this far out, and a run
|
|
59
|
+
// that folded anything schedules the next one as far again, so one run per
|
|
60
|
+
// second serves every batch of that second; only a run that finds the queue
|
|
61
|
+
// empty releases the lease.
|
|
62
|
+
export const ROLLUP_FOLD_DEBOUNCE_MS = 1_000;
|
|
63
|
+
// A run that throws retries its oldest batch alone; a batch that fails this
|
|
64
|
+
// many runs in a row is set aside as a dead letter, kept this long (30 days)
|
|
65
|
+
// for inspection, so one bad batch cannot stall a site's reports.
|
|
66
|
+
export const ROLLUP_FOLD_MAX_FAILURES = 3;
|
|
67
|
+
export const ROLLUP_DEAD_LETTER_RETENTION_MS = 30 * 86_400_000;
|
|
47
68
|
// Shared Web Vitals histogram edges. Every Google rating threshold (LCP
|
|
48
69
|
// 2500/4000, CLS×1000 100/250, INP 200/500, FCP 1800/3000, TTFB 800/1800) is
|
|
49
70
|
// an edge, so rating counts stay exact while one layout serves all metrics.
|
|
@@ -9,5 +9,8 @@ export default defineComponent("rastroAnalytics", {
|
|
|
9
9
|
RASTRO_GEOIP_TOKEN: v.optional(v.string()),
|
|
10
10
|
RASTRO_GEOIP_DAILY_LIMIT: v.optional(v.string()),
|
|
11
11
|
RASTRO_TRUST_PROXY: v.optional(v.literal("true")),
|
|
12
|
+
// "off" drops the per-client ingest budget, for a load-test canary whose
|
|
13
|
+
// driver sends every request from one address.
|
|
14
|
+
RASTRO_CLIENT_RATE_LIMIT: v.optional(v.literal("off")),
|
|
12
15
|
},
|
|
13
16
|
});
|
package/src/component/geo.ts
CHANGED
|
@@ -510,7 +510,7 @@ function classifyIpv6(ip: string): Exclude<IpKind, "unavailable"> {
|
|
|
510
510
|
return (groups[0]! & 0xe000) === 0x2000 ? "public" : "private";
|
|
511
511
|
}
|
|
512
512
|
|
|
513
|
-
function parseIpv6(ip: string): number[] | undefined {
|
|
513
|
+
export function parseIpv6(ip: string): number[] | undefined {
|
|
514
514
|
let normalized = ip;
|
|
515
515
|
const dottedTail = normalized.match(/(\d+\.\d+\.\d+\.\d+)$/)?.[1];
|
|
516
516
|
if (dottedTail) {
|
package/src/component/http.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from "./geo.js";
|
|
21
21
|
import { isPlainRecord } from "./guards.js";
|
|
22
22
|
import { alignClock, sanitizeEvent } from "./sanitize.js";
|
|
23
|
-
import { deriveVisitorKey, randomSecret } from "./identity.js";
|
|
23
|
+
import { deriveClientKey, deriveVisitorKey, randomSecret } from "./identity.js";
|
|
24
24
|
import { classifyClient, isKnownBot } from "./useragent.js";
|
|
25
25
|
import type { TrackerEvent } from "./validators.js";
|
|
26
26
|
import {
|
|
@@ -179,6 +179,19 @@ http.route({
|
|
|
179
179
|
...classifyClient(userAgent, request.headers),
|
|
180
180
|
...(visitorKey ? { visitorKey } : {}),
|
|
181
181
|
};
|
|
182
|
+
// Origin is a browser control: any other sender writes the header it
|
|
183
|
+
// likes. The per-client budget is keyed like the visitor key above (salted,
|
|
184
|
+
// daily, never the address); without a usable client IP only the per-site
|
|
185
|
+
// shards bound the batch.
|
|
186
|
+
const clientKey =
|
|
187
|
+
clientIp.ip && env.RASTRO_CLIENT_RATE_LIMIT !== "off"
|
|
188
|
+
? await deriveClientKey({
|
|
189
|
+
secret: await visitorSecret(ctx),
|
|
190
|
+
now: Date.now(),
|
|
191
|
+
siteId: payload.siteId,
|
|
192
|
+
ip: clientIp.ip,
|
|
193
|
+
})
|
|
194
|
+
: undefined;
|
|
182
195
|
|
|
183
196
|
try {
|
|
184
197
|
const result = await ctx.runMutation(api.ingest.ingestBatch, {
|
|
@@ -186,6 +199,7 @@ http.route({
|
|
|
186
199
|
origin,
|
|
187
200
|
events,
|
|
188
201
|
context,
|
|
202
|
+
...(clientKey ? { clientKey } : {}),
|
|
189
203
|
});
|
|
190
204
|
if (
|
|
191
205
|
clientIp.ip &&
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { DAY_MS } from "./constants.js";
|
|
3
|
+
import { parseIpv6 } from "./geo.js";
|
|
3
4
|
import { internalMutation, internalQuery } from "./_generated/server.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -57,9 +58,47 @@ export async function deriveVisitorKey(input: {
|
|
|
57
58
|
ip: string;
|
|
58
59
|
userAgent: string;
|
|
59
60
|
}): Promise<string> {
|
|
60
|
-
|
|
61
|
+
return saltedDailyKey(input.secret, input.now, [
|
|
62
|
+
input.siteId,
|
|
63
|
+
input.ip,
|
|
64
|
+
input.userAgent,
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The per-client ingest budget's key: the same salted daily hash, over the
|
|
70
|
+
* client's network instead of its device — the IPv4 address, or the /64 of an
|
|
71
|
+
* IPv6 address, the block one subscriber gets and could otherwise walk through
|
|
72
|
+
* one address per request.
|
|
73
|
+
*/
|
|
74
|
+
export async function deriveClientKey(input: {
|
|
75
|
+
secret: string;
|
|
76
|
+
now: number;
|
|
77
|
+
siteId: string;
|
|
78
|
+
ip: string;
|
|
79
|
+
}): Promise<string> {
|
|
80
|
+
const groups = parseIpv6(input.ip.toLowerCase());
|
|
81
|
+
const network = groups
|
|
82
|
+
? `${groups
|
|
83
|
+
.slice(0, 4)
|
|
84
|
+
.map((group) => group.toString(16))
|
|
85
|
+
.join(":")}::/64`
|
|
86
|
+
: input.ip;
|
|
87
|
+
return saltedDailyKey(input.secret, input.now, [
|
|
88
|
+
"client",
|
|
89
|
+
input.siteId,
|
|
90
|
+
network,
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function saltedDailyKey(
|
|
95
|
+
secret: string,
|
|
96
|
+
now: number,
|
|
97
|
+
parts: string[],
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
const dayStart = Math.floor(now / DAY_MS) * DAY_MS;
|
|
61
100
|
const material = new TextEncoder().encode(
|
|
62
|
-
[
|
|
101
|
+
[secret, dayStart, ...parts].join(" "),
|
|
63
102
|
);
|
|
64
103
|
const digest = new Uint8Array(
|
|
65
104
|
await crypto.subtle.digest("SHA-256", material),
|
package/src/component/ingest.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
LIVE_SESSION_TTL_MS,
|
|
15
15
|
LIVE_SWEEP_INTERVAL_MS,
|
|
16
16
|
MAX_BATCH_EVENTS,
|
|
17
|
+
MAX_BYTES_PER_CLIENT_WINDOW,
|
|
18
|
+
MAX_EVENTS_PER_CLIENT_WINDOW,
|
|
17
19
|
MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
18
20
|
MAX_SITE_EVENTS_PER_SHARD_WINDOW,
|
|
19
21
|
MAX_BYTES_PER_SESSION_WINDOW,
|
|
@@ -123,7 +125,7 @@ type AggregateDelta = {
|
|
|
123
125
|
continuedSessions: number;
|
|
124
126
|
newVisitors: number;
|
|
125
127
|
};
|
|
126
|
-
type AggregateDeltas = Map<string, AggregateDelta>;
|
|
128
|
+
export type AggregateDeltas = Map<string, AggregateDelta>;
|
|
127
129
|
type AttributionSnapshot = {
|
|
128
130
|
affiliateId: Id<"affiliates">;
|
|
129
131
|
affiliateSlug: string;
|
|
@@ -214,6 +216,8 @@ export const ingestBatch = mutation({
|
|
|
214
216
|
origin: v.string(),
|
|
215
217
|
events: v.array(trackerEventValidator),
|
|
216
218
|
context: v.optional(ingestContextValidator),
|
|
219
|
+
// The HTTP action's salted daily client-network key (see identity.ts).
|
|
220
|
+
clientKey: v.optional(v.string()),
|
|
217
221
|
},
|
|
218
222
|
returns: ingestResultValidator,
|
|
219
223
|
handler: async (ctx, args) => {
|
|
@@ -245,6 +249,15 @@ export const ingestBatch = mutation({
|
|
|
245
249
|
|
|
246
250
|
const now = Date.now();
|
|
247
251
|
await ensureAnalyticsControl(ctx, args.siteId, now);
|
|
252
|
+
if (args.clientKey !== undefined) {
|
|
253
|
+
await consumeClientRateLimit(
|
|
254
|
+
ctx,
|
|
255
|
+
args.siteId,
|
|
256
|
+
args.clientKey,
|
|
257
|
+
eventByteCounts,
|
|
258
|
+
now,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
248
261
|
await consumeSiteRateLimit(
|
|
249
262
|
ctx,
|
|
250
263
|
args.siteId,
|
|
@@ -389,7 +402,10 @@ export const ingestBatch = mutation({
|
|
|
389
402
|
rejected += 1;
|
|
390
403
|
continue;
|
|
391
404
|
}
|
|
392
|
-
|
|
405
|
+
// A heartbeat is presence, not telemetry: it moves the session's
|
|
406
|
+
// lastSeenAt and live row only. The time it proves reaches the
|
|
407
|
+
// duration counters once, when the visitor departs (live.ts).
|
|
408
|
+
await accountHeartbeat(
|
|
393
409
|
ctx,
|
|
394
410
|
args.siteId,
|
|
395
411
|
existingSession,
|
|
@@ -398,25 +414,13 @@ export const ingestBatch = mutation({
|
|
|
398
414
|
now,
|
|
399
415
|
batchState,
|
|
400
416
|
);
|
|
401
|
-
// A heartbeat is presence, not telemetry, but it is what keeps a
|
|
402
|
-
// session's duration honest while the visitor reads: the growth is
|
|
403
|
-
// folded into the buckets the heartbeat landed in.
|
|
404
|
-
foldSessionDuration(
|
|
405
|
-
aggregateDeltas,
|
|
406
|
-
args.siteId,
|
|
407
|
-
event,
|
|
408
|
-
durationDeltaMs,
|
|
409
|
-
timezone,
|
|
410
|
-
);
|
|
411
417
|
accepted += 1;
|
|
412
418
|
continue;
|
|
413
419
|
}
|
|
414
420
|
|
|
415
421
|
if (event.type === "leave") {
|
|
416
|
-
// A departure
|
|
417
|
-
//
|
|
418
|
-
// or late leave is inert. The read side filters by expiresAt and the
|
|
419
|
-
// sweep deletes the row, so the dashboard sees the exit in seconds.
|
|
422
|
+
// A departure closes the final duration interval without telemetry
|
|
423
|
+
// or new presence. Older departures cannot retire a bfcache return.
|
|
420
424
|
if (!existingSession) {
|
|
421
425
|
rejected += 1;
|
|
422
426
|
continue;
|
|
@@ -441,7 +445,34 @@ export const ingestBatch = mutation({
|
|
|
441
445
|
batchState,
|
|
442
446
|
);
|
|
443
447
|
const liveSession = liveState.current;
|
|
444
|
-
if (liveSession) {
|
|
448
|
+
if (liveSession && event.timestamp >= liveSession.lastSeenAt) {
|
|
449
|
+
// Only the still-covered tail is trusted; a delayed departure
|
|
450
|
+
// cannot lengthen an expired visit or move its start backwards.
|
|
451
|
+
if (
|
|
452
|
+
event.timestamp > existingSession.lastSeenAt &&
|
|
453
|
+
event.timestamp >= now - LIVE_SESSION_TTL_MS &&
|
|
454
|
+
event.timestamp <= liveSession.expiresAt
|
|
455
|
+
) {
|
|
456
|
+
const durationMs = Math.max(
|
|
457
|
+
existingSession.durationMs,
|
|
458
|
+
event.timestamp - existingSession.startedAt,
|
|
459
|
+
);
|
|
460
|
+
sessionRow.current = {
|
|
461
|
+
...existingSession,
|
|
462
|
+
lastSeenAt: event.timestamp,
|
|
463
|
+
exitPath: event.path,
|
|
464
|
+
durationMs,
|
|
465
|
+
};
|
|
466
|
+
sessionRow.dirty = true;
|
|
467
|
+
foldSessionDuration(
|
|
468
|
+
aggregateDeltas,
|
|
469
|
+
args.siteId,
|
|
470
|
+
event.sessionId,
|
|
471
|
+
event.timestamp,
|
|
472
|
+
durationMs - existingSession.durationMs,
|
|
473
|
+
timezone,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
445
476
|
const expiresAt = Math.min(
|
|
446
477
|
liveSession.expiresAt,
|
|
447
478
|
now + LIVE_LEAVE_GRACE_MS,
|
|
@@ -1397,6 +1428,12 @@ async function attributionForEvent(
|
|
|
1397
1428
|
};
|
|
1398
1429
|
}
|
|
1399
1430
|
|
|
1431
|
+
/**
|
|
1432
|
+
* Moves the session's lastSeenAt and its live row. `durationMs` stays: it is
|
|
1433
|
+
* what the duration counters already hold, and the time a heartbeat proves
|
|
1434
|
+
* is counted when the visitor departs (live.ts), so an idle tab never
|
|
1435
|
+
* rewrites an aggregate bucket.
|
|
1436
|
+
*/
|
|
1400
1437
|
async function accountHeartbeat(
|
|
1401
1438
|
ctx: MutationCtx,
|
|
1402
1439
|
siteId: Id<"sites">,
|
|
@@ -1405,7 +1442,7 @@ async function accountHeartbeat(
|
|
|
1405
1442
|
context: IngestContext,
|
|
1406
1443
|
now: number,
|
|
1407
1444
|
batchState?: BatchState,
|
|
1408
|
-
): Promise<
|
|
1445
|
+
): Promise<void> {
|
|
1409
1446
|
const startedAt = Math.min(session.startedAt, event.timestamp);
|
|
1410
1447
|
const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
|
|
1411
1448
|
const updates = {
|
|
@@ -1413,7 +1450,6 @@ async function accountHeartbeat(
|
|
|
1413
1450
|
lastSeenAt,
|
|
1414
1451
|
exitPath:
|
|
1415
1452
|
event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
|
|
1416
|
-
durationMs: Math.max(0, lastSeenAt - startedAt),
|
|
1417
1453
|
...(context.country ? { country: context.country } : {}),
|
|
1418
1454
|
...(context.city ? { city: context.city } : {}),
|
|
1419
1455
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
@@ -1432,7 +1468,6 @@ async function accountHeartbeat(
|
|
|
1432
1468
|
} else {
|
|
1433
1469
|
await ctx.db.patch("sessions", session._id, updates);
|
|
1434
1470
|
}
|
|
1435
|
-
const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
|
|
1436
1471
|
await updateLiveSession(
|
|
1437
1472
|
ctx,
|
|
1438
1473
|
siteId,
|
|
@@ -1462,7 +1497,54 @@ async function accountHeartbeat(
|
|
|
1462
1497
|
now,
|
|
1463
1498
|
batchState,
|
|
1464
1499
|
);
|
|
1465
|
-
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* One client network's budget per site and minute, kept in the session
|
|
1504
|
+
* windows' table under `~client:<key>`, a key no sanitized session ID can take.
|
|
1505
|
+
* The key is the salted daily hash the HTTP action derived; the address never
|
|
1506
|
+
* reaches this mutation. It is one row per client, so only that client's own
|
|
1507
|
+
* concurrent requests contend on it.
|
|
1508
|
+
*/
|
|
1509
|
+
async function consumeClientRateLimit(
|
|
1510
|
+
ctx: MutationCtx,
|
|
1511
|
+
siteId: Id<"sites">,
|
|
1512
|
+
clientKey: string,
|
|
1513
|
+
eventByteCounts: number[],
|
|
1514
|
+
now: number,
|
|
1515
|
+
): Promise<void> {
|
|
1516
|
+
const windowStart =
|
|
1517
|
+
Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
1518
|
+
const sessionId = `~client:${clientKey}`;
|
|
1519
|
+
const rows = await ctx.db
|
|
1520
|
+
.query("ingestWindows")
|
|
1521
|
+
.withIndex("by_siteId_and_sessionId_and_windowStart", (range) =>
|
|
1522
|
+
range.eq("siteId", siteId).eq("sessionId", sessionId),
|
|
1523
|
+
)
|
|
1524
|
+
.order("desc")
|
|
1525
|
+
.take(1);
|
|
1526
|
+
const window = rows[0] ?? null;
|
|
1527
|
+
const current = window?.windowStart === windowStart ? window : null;
|
|
1528
|
+
const eventCount = (current?.eventCount ?? 0) + eventByteCounts.length;
|
|
1529
|
+
const byteCount =
|
|
1530
|
+
(current?.byteCount ?? 0) +
|
|
1531
|
+
eventByteCounts.reduce((total, bytes) => total + bytes, 0);
|
|
1532
|
+
if (
|
|
1533
|
+
eventCount > MAX_EVENTS_PER_CLIENT_WINDOW ||
|
|
1534
|
+
byteCount > MAX_BYTES_PER_CLIENT_WINDOW
|
|
1535
|
+
) {
|
|
1536
|
+
fail("RATE_LIMITED", "client ingest budget exceeded", {
|
|
1537
|
+
windowMs: RATE_LIMIT_WINDOW_MS,
|
|
1538
|
+
eventLimitPerClient: MAX_EVENTS_PER_CLIENT_WINDOW,
|
|
1539
|
+
byteLimitPerClient: MAX_BYTES_PER_CLIENT_WINDOW,
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
const fields = { windowStart, eventCount, byteCount, updatedAt: now };
|
|
1543
|
+
if (window) {
|
|
1544
|
+
await ctx.db.patch("ingestWindows", window._id, fields);
|
|
1545
|
+
} else {
|
|
1546
|
+
await ctx.db.insert("ingestWindows", { siteId, sessionId, ...fields });
|
|
1547
|
+
}
|
|
1466
1548
|
}
|
|
1467
1549
|
|
|
1468
1550
|
async function consumeSiteRateLimit(
|
|
@@ -1848,11 +1930,10 @@ async function advanceFunnel(
|
|
|
1848
1930
|
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1849
1931
|
return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
|
|
1850
1932
|
}
|
|
1851
|
-
if (
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
return;
|
|
1933
|
+
if (event.timestamp < progress.lastStepAt) return;
|
|
1934
|
+
// An expired window restarts on a first-step event even after a completion,
|
|
1935
|
+
// so a returning buyer can complete again; inside the window a completed
|
|
1936
|
+
// funnel stays done.
|
|
1856
1937
|
if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
|
|
1857
1938
|
if (!matchesFirst) return;
|
|
1858
1939
|
const updated = {
|
|
@@ -1886,6 +1967,7 @@ async function advanceFunnel(
|
|
|
1886
1967
|
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1887
1968
|
return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
|
|
1888
1969
|
}
|
|
1970
|
+
if (progress.completedAt !== undefined) return;
|
|
1889
1971
|
const nextStep = funnel.steps[progress.currentStep];
|
|
1890
1972
|
if (!nextStep || !stepMatches(nextStep, event)) return;
|
|
1891
1973
|
const currentStep = progress.currentStep + 1;
|
|
@@ -2404,24 +2486,26 @@ function aggregateBucketStart(
|
|
|
2404
2486
|
return Math.floor(timestamp / interval) * interval;
|
|
2405
2487
|
}
|
|
2406
2488
|
|
|
2407
|
-
/**
|
|
2408
|
-
|
|
2489
|
+
/**
|
|
2490
|
+
* Folds session duration growth without telemetry: a departure's final
|
|
2491
|
+
* interval, or the heartbeat time of a visitor the live sweep retired. The
|
|
2492
|
+
* shard follows `shardKey`: the session for a departure, the site for a
|
|
2493
|
+
* sweep.
|
|
2494
|
+
*/
|
|
2495
|
+
export function foldSessionDuration(
|
|
2409
2496
|
aggregateDeltas: AggregateDeltas,
|
|
2410
2497
|
siteId: Id<"sites">,
|
|
2411
|
-
|
|
2498
|
+
shardKey: string,
|
|
2499
|
+
timestamp: number,
|
|
2412
2500
|
durationDeltaMs: number,
|
|
2413
2501
|
timezone?: string,
|
|
2414
2502
|
) {
|
|
2415
2503
|
if (durationDeltaMs <= 0) return;
|
|
2416
2504
|
for (const granularity of aggregateGranularities(timezone)) {
|
|
2417
2505
|
const shard =
|
|
2418
|
-
stableHash(
|
|
2506
|
+
stableHash(shardKey) %
|
|
2419
2507
|
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
2420
|
-
const bucketStart = aggregateBucketStart(
|
|
2421
|
-
granularity,
|
|
2422
|
-
event.timestamp,
|
|
2423
|
-
timezone,
|
|
2424
|
-
);
|
|
2508
|
+
const bucketStart = aggregateBucketStart(granularity, timestamp, timezone);
|
|
2425
2509
|
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
2426
2510
|
const delta = aggregateDeltas.get(key) ?? {
|
|
2427
2511
|
siteId,
|
|
@@ -2448,7 +2532,7 @@ function foldSessionDuration(
|
|
|
2448
2532
|
}
|
|
2449
2533
|
}
|
|
2450
2534
|
|
|
2451
|
-
async function flushAggregates(
|
|
2535
|
+
export async function flushAggregates(
|
|
2452
2536
|
ctx: MutationCtx,
|
|
2453
2537
|
aggregateDeltas: AggregateDeltas,
|
|
2454
2538
|
) {
|