@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.
Files changed (87) hide show
  1. package/README.md +82 -36
  2. package/agent/integration.md +4 -0
  3. package/agent/manifest.json +9 -5
  4. package/agent/manifest.schema.json +7 -1
  5. package/dist/client/federation.d.ts +18 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +7 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +138 -1
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +30 -0
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/component.d.ts +46 -0
  14. package/dist/component/_generated/component.d.ts.map +1 -1
  15. package/dist/component/_generated/server.d.ts +1 -0
  16. package/dist/component/_generated/server.d.ts.map +1 -1
  17. package/dist/component/_generated/server.js.map +1 -1
  18. package/dist/component/constants.d.ts +7 -2
  19. package/dist/component/constants.d.ts.map +1 -1
  20. package/dist/component/constants.js +24 -3
  21. package/dist/component/constants.js.map +1 -1
  22. package/dist/component/convex.config.d.ts +1 -0
  23. package/dist/component/convex.config.js +3 -0
  24. package/dist/component/convex.config.js.map +1 -1
  25. package/dist/component/geo.d.ts +1 -0
  26. package/dist/component/geo.d.ts.map +1 -1
  27. package/dist/component/geo.js +1 -1
  28. package/dist/component/geo.js.map +1 -1
  29. package/dist/component/http.d.ts.map +1 -1
  30. package/dist/component/http.js +14 -1
  31. package/dist/component/http.js.map +1 -1
  32. package/dist/component/identity.d.ts +12 -0
  33. package/dist/component/identity.d.ts.map +1 -1
  34. package/dist/component/identity.js +30 -2
  35. package/dist/component/identity.js.map +1 -1
  36. package/dist/component/ingest.d.ts +34 -0
  37. package/dist/component/ingest.d.ts.map +1 -1
  38. package/dist/component/ingest.js +86 -21
  39. package/dist/component/ingest.js.map +1 -1
  40. package/dist/component/live.d.ts.map +1 -1
  41. package/dist/component/live.js +34 -4
  42. package/dist/component/live.js.map +1 -1
  43. package/dist/component/reports.d.ts +47 -0
  44. package/dist/component/reports.d.ts.map +1 -1
  45. package/dist/component/reports.js +96 -6
  46. package/dist/component/reports.js.map +1 -1
  47. package/dist/component/rollupStore.d.ts +7 -4
  48. package/dist/component/rollupStore.d.ts.map +1 -1
  49. package/dist/component/rollupStore.js +37 -17
  50. package/dist/component/rollupStore.js.map +1 -1
  51. package/dist/component/rollups.d.ts +27 -8
  52. package/dist/component/rollups.d.ts.map +1 -1
  53. package/dist/component/rollups.js +110 -32
  54. package/dist/component/rollups.js.map +1 -1
  55. package/dist/component/schema.d.ts +6 -2
  56. package/dist/component/schema.js +5 -2
  57. package/dist/component/schema.js.map +1 -1
  58. package/dist/tracker/generated.d.ts +11 -11
  59. package/dist/tracker/generated.d.ts.map +1 -1
  60. package/dist/tracker/generated.js +11 -11
  61. package/dist/tracker/generated.js.map +1 -1
  62. package/dist/tracker/tracker.js +88 -43
  63. package/dist/tracker/tracker.js.map +1 -1
  64. package/dist/tracker/vitals.d.ts.map +1 -1
  65. package/dist/tracker/vitals.js +22 -4
  66. package/dist/tracker/vitals.js.map +1 -1
  67. package/dist/tracker.min.js +1 -1
  68. package/dist/vitals.min.js +1 -1
  69. package/docs/federation-setup.md +2 -0
  70. package/docs/federation.md +6 -0
  71. package/docs/upgrading.md +84 -0
  72. package/llms.txt +3 -3
  73. package/package.json +1 -1
  74. package/src/component/_generated/component.ts +49 -0
  75. package/src/component/_generated/server.ts +1 -0
  76. package/src/component/constants.ts +24 -3
  77. package/src/component/convex.config.ts +3 -0
  78. package/src/component/geo.ts +1 -1
  79. package/src/component/http.ts +15 -1
  80. package/src/component/identity.ts +41 -2
  81. package/src/component/ingest.ts +120 -36
  82. package/src/component/live.ts +51 -4
  83. package/src/component/reports.ts +120 -8
  84. package/src/component/rollupStore.ts +51 -20
  85. package/src/component/rollups.ts +141 -32
  86. package/src/component/schema.ts +6 -3
  87. 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
- - Hourly overview maximum: 24 hours.
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` beacon on pagehide retires a visitor about ten seconds after they leave, and a bfcache return 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.
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
@@ -6,7 +6,7 @@
6
6
  "bugs": {
7
7
  "url": "https://github.com/amageweb/amage-rastro/issues"
8
8
  },
9
- "version": "0.2.0",
9
+ "version": "0.4.0",
10
10
  "license": "Apache-2.0",
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -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
- export const SITE_INGEST_SHARDS = 4_096;
8
- export const MAX_SITE_EVENTS_PER_SHARD_WINDOW = 300;
9
- export const MAX_SITE_BYTES_PER_SHARD_WINDOW = 512 * 1024;
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
  });
@@ -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) {
@@ -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
- const dayStart = Math.floor(input.now / DAY_MS) * DAY_MS;
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
- [input.secret, dayStart, input.siteId, input.ip, input.userAgent].join(" "),
101
+ [secret, dayStart, ...parts].join(" "),
63
102
  );
64
103
  const digest = new Uint8Array(
65
104
  await crypto.subtle.digest("SHA-256", material),
@@ -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
- const durationDeltaMs = await accountHeartbeat(
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 only shortens live presence: it stores no telemetry,
417
- // touches no aggregates, and never extends anything, so a replayed
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<number> {
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
- return durationDeltaMs;
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
- progress.completedAt !== undefined ||
1853
- event.timestamp < progress.lastStepAt
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
- /** Folds only session duration growth, for heartbeats. */
2408
- function foldSessionDuration(
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
- event: TrackerEvent,
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(event.sessionId) %
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
  ) {