@iann29/rastro 0.3.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 +80 -36
- package/agent/manifest.json +2 -2
- package/dist/component/_generated/component.d.ts +1 -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 +69 -17
- 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.map +1 -1
- package/dist/component/reports.js +21 -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/upgrading.md +84 -28
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/component/_generated/component.ts +1 -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 +92 -32
- package/src/component/live.ts +51 -4
- package/src/component/reports.ts +24 -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
|
@@ -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,16 +414,6 @@ 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
|
}
|
|
@@ -461,7 +467,8 @@ export const ingestBatch = mutation({
|
|
|
461
467
|
foldSessionDuration(
|
|
462
468
|
aggregateDeltas,
|
|
463
469
|
args.siteId,
|
|
464
|
-
event,
|
|
470
|
+
event.sessionId,
|
|
471
|
+
event.timestamp,
|
|
465
472
|
durationMs - existingSession.durationMs,
|
|
466
473
|
timezone,
|
|
467
474
|
);
|
|
@@ -1421,6 +1428,12 @@ async function attributionForEvent(
|
|
|
1421
1428
|
};
|
|
1422
1429
|
}
|
|
1423
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
|
+
*/
|
|
1424
1437
|
async function accountHeartbeat(
|
|
1425
1438
|
ctx: MutationCtx,
|
|
1426
1439
|
siteId: Id<"sites">,
|
|
@@ -1429,7 +1442,7 @@ async function accountHeartbeat(
|
|
|
1429
1442
|
context: IngestContext,
|
|
1430
1443
|
now: number,
|
|
1431
1444
|
batchState?: BatchState,
|
|
1432
|
-
): Promise<
|
|
1445
|
+
): Promise<void> {
|
|
1433
1446
|
const startedAt = Math.min(session.startedAt, event.timestamp);
|
|
1434
1447
|
const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
|
|
1435
1448
|
const updates = {
|
|
@@ -1437,7 +1450,6 @@ async function accountHeartbeat(
|
|
|
1437
1450
|
lastSeenAt,
|
|
1438
1451
|
exitPath:
|
|
1439
1452
|
event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
|
|
1440
|
-
durationMs: Math.max(0, lastSeenAt - startedAt),
|
|
1441
1453
|
...(context.country ? { country: context.country } : {}),
|
|
1442
1454
|
...(context.city ? { city: context.city } : {}),
|
|
1443
1455
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
@@ -1456,7 +1468,6 @@ async function accountHeartbeat(
|
|
|
1456
1468
|
} else {
|
|
1457
1469
|
await ctx.db.patch("sessions", session._id, updates);
|
|
1458
1470
|
}
|
|
1459
|
-
const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
|
|
1460
1471
|
await updateLiveSession(
|
|
1461
1472
|
ctx,
|
|
1462
1473
|
siteId,
|
|
@@ -1486,7 +1497,54 @@ async function accountHeartbeat(
|
|
|
1486
1497
|
now,
|
|
1487
1498
|
batchState,
|
|
1488
1499
|
);
|
|
1489
|
-
|
|
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
|
+
}
|
|
1490
1548
|
}
|
|
1491
1549
|
|
|
1492
1550
|
async function consumeSiteRateLimit(
|
|
@@ -1872,11 +1930,10 @@ async function advanceFunnel(
|
|
|
1872
1930
|
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1873
1931
|
return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
|
|
1874
1932
|
}
|
|
1875
|
-
if (
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
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.
|
|
1880
1937
|
if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
|
|
1881
1938
|
if (!matchesFirst) return;
|
|
1882
1939
|
const updated = {
|
|
@@ -1910,6 +1967,7 @@ async function advanceFunnel(
|
|
|
1910
1967
|
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1911
1968
|
return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
|
|
1912
1969
|
}
|
|
1970
|
+
if (progress.completedAt !== undefined) return;
|
|
1913
1971
|
const nextStep = funnel.steps[progress.currentStep];
|
|
1914
1972
|
if (!nextStep || !stepMatches(nextStep, event)) return;
|
|
1915
1973
|
const currentStep = progress.currentStep + 1;
|
|
@@ -2428,24 +2486,26 @@ function aggregateBucketStart(
|
|
|
2428
2486
|
return Math.floor(timestamp / interval) * interval;
|
|
2429
2487
|
}
|
|
2430
2488
|
|
|
2431
|
-
/**
|
|
2432
|
-
|
|
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(
|
|
2433
2496
|
aggregateDeltas: AggregateDeltas,
|
|
2434
2497
|
siteId: Id<"sites">,
|
|
2435
|
-
|
|
2498
|
+
shardKey: string,
|
|
2499
|
+
timestamp: number,
|
|
2436
2500
|
durationDeltaMs: number,
|
|
2437
2501
|
timezone?: string,
|
|
2438
2502
|
) {
|
|
2439
2503
|
if (durationDeltaMs <= 0) return;
|
|
2440
2504
|
for (const granularity of aggregateGranularities(timezone)) {
|
|
2441
2505
|
const shard =
|
|
2442
|
-
stableHash(
|
|
2506
|
+
stableHash(shardKey) %
|
|
2443
2507
|
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
2444
|
-
const bucketStart = aggregateBucketStart(
|
|
2445
|
-
granularity,
|
|
2446
|
-
event.timestamp,
|
|
2447
|
-
timezone,
|
|
2448
|
-
);
|
|
2508
|
+
const bucketStart = aggregateBucketStart(granularity, timestamp, timezone);
|
|
2449
2509
|
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
2450
2510
|
const delta = aggregateDeltas.get(key) ?? {
|
|
2451
2511
|
siteId,
|
|
@@ -2472,7 +2532,7 @@ function foldSessionDuration(
|
|
|
2472
2532
|
}
|
|
2473
2533
|
}
|
|
2474
2534
|
|
|
2475
|
-
async function flushAggregates(
|
|
2535
|
+
export async function flushAggregates(
|
|
2476
2536
|
ctx: MutationCtx,
|
|
2477
2537
|
aggregateDeltas: AggregateDeltas,
|
|
2478
2538
|
) {
|
package/src/component/live.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { internal } from "./_generated/api.js";
|
|
3
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
4
|
+
import type { MutationCtx } from "./_generated/server.js";
|
|
3
5
|
import { internalMutation } from "./_generated/server.js";
|
|
4
6
|
import { LIVE_SWEEP_INTERVAL_MS } from "./constants.js";
|
|
7
|
+
import {
|
|
8
|
+
flushAggregates,
|
|
9
|
+
foldSessionDuration,
|
|
10
|
+
type AggregateDeltas,
|
|
11
|
+
} from "./ingest.js";
|
|
12
|
+
import { localDayTimezone } from "./localTime.js";
|
|
5
13
|
|
|
6
14
|
const SWEEP_BATCH_SIZE = 500;
|
|
7
15
|
|
|
@@ -58,7 +66,7 @@ export const expire = internalMutation({
|
|
|
58
66
|
}
|
|
59
67
|
return { expired: false, rescheduled: false, stale: false };
|
|
60
68
|
}
|
|
61
|
-
await ctx.
|
|
69
|
+
await retireLiveSessions(ctx, liveSession.siteId, [liveSession]);
|
|
62
70
|
return { expired: true, rescheduled: false, stale: false };
|
|
63
71
|
},
|
|
64
72
|
});
|
|
@@ -89,9 +97,7 @@ export const sweep = internalMutation({
|
|
|
89
97
|
range.eq("siteId", args.siteId).lte("expiresAt", now),
|
|
90
98
|
)
|
|
91
99
|
.take(SWEEP_BATCH_SIZE);
|
|
92
|
-
|
|
93
|
-
await ctx.db.delete("liveSessions", session._id);
|
|
94
|
-
}
|
|
100
|
+
await retireLiveSessions(ctx, args.siteId, expired);
|
|
95
101
|
|
|
96
102
|
const next = await ctx.db
|
|
97
103
|
.query("liveSessions")
|
|
@@ -115,3 +121,44 @@ export const sweep = internalMutation({
|
|
|
115
121
|
return { expired: expired.length, rescheduled: true, stale: false };
|
|
116
122
|
},
|
|
117
123
|
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Deletes departed live rows and counts the time their heartbeats proved. A
|
|
127
|
+
* heartbeat only moves its session's `lastSeenAt`, and `durationMs` is what
|
|
128
|
+
* the duration counters already hold, so the difference reaches the buckets
|
|
129
|
+
* here, once, in the hour the visitor was last seen. It lands in one shard
|
|
130
|
+
* per site, so a sweep rewrites a few buckets however many visitors it
|
|
131
|
+
* retires.
|
|
132
|
+
*/
|
|
133
|
+
async function retireLiveSessions(
|
|
134
|
+
ctx: MutationCtx,
|
|
135
|
+
siteId: Id<"sites">,
|
|
136
|
+
rows: Doc<"liveSessions">[],
|
|
137
|
+
) {
|
|
138
|
+
if (rows.length === 0) return;
|
|
139
|
+
const site = await ctx.db.get("sites", siteId);
|
|
140
|
+
const timezone = localDayTimezone(site?.timezone);
|
|
141
|
+
const aggregateDeltas: AggregateDeltas = new Map();
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
await ctx.db.delete("liveSessions", row._id);
|
|
144
|
+
const session = await ctx.db
|
|
145
|
+
.query("sessions")
|
|
146
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
147
|
+
range.eq("siteId", siteId).eq("sessionId", row.sessionId),
|
|
148
|
+
)
|
|
149
|
+
.unique();
|
|
150
|
+
if (!session) continue;
|
|
151
|
+
const durationMs = session.lastSeenAt - session.startedAt;
|
|
152
|
+
if (durationMs <= session.durationMs) continue;
|
|
153
|
+
await ctx.db.patch("sessions", session._id, { durationMs });
|
|
154
|
+
foldSessionDuration(
|
|
155
|
+
aggregateDeltas,
|
|
156
|
+
siteId,
|
|
157
|
+
siteId,
|
|
158
|
+
session.lastSeenAt,
|
|
159
|
+
durationMs - session.durationMs,
|
|
160
|
+
timezone,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
await flushAggregates(ctx, aggregateDeltas);
|
|
164
|
+
}
|
package/src/component/reports.ts
CHANGED
|
@@ -346,14 +346,19 @@ export const overview = query({
|
|
|
346
346
|
const bucketCount = localDays
|
|
347
347
|
? bucketStarts.length
|
|
348
348
|
: Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
349
|
+
const shards =
|
|
350
|
+
interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS;
|
|
351
|
+
const perSiteLimit = bucketCount * shards;
|
|
352
352
|
if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
|
|
353
|
+
// The row budget, not MAX_REPORT_RANGE_DAYS, bounds the overview: 101
|
|
354
|
+
// days for one site and 10 for ten; 24 hours for two, 5 for ten.
|
|
355
|
+
const maximumBuckets = Math.floor(
|
|
356
|
+
MAX_OVERVIEW_AGGREGATE_ROWS / (shards * siteIds.length),
|
|
357
|
+
);
|
|
353
358
|
fail(
|
|
354
359
|
"LIMIT_EXCEEDED",
|
|
355
|
-
|
|
356
|
-
{ maximumAggregateRows: MAX_OVERVIEW_AGGREGATE_ROWS },
|
|
360
|
+
`overview range is too wide: ${siteIds.length === 1 ? "one site" : `${siteIds.length} sites`} can read at most ${maximumBuckets} ${interval}s`,
|
|
361
|
+
{ maximumAggregateRows: MAX_OVERVIEW_AGGREGATE_ROWS, maximumBuckets },
|
|
357
362
|
);
|
|
358
363
|
}
|
|
359
364
|
const totals = emptyTotals();
|
|
@@ -382,7 +387,13 @@ export const overview = query({
|
|
|
382
387
|
await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
|
|
383
388
|
|
|
384
389
|
for (const siteId of siteIds) {
|
|
385
|
-
|
|
390
|
+
// Iterated rather than taken whole: within the row budget a busy range
|
|
391
|
+
// can still cross the bytes a query may read. Values are length-capped,
|
|
392
|
+
// so sixteen buckets stay far below the reserve, and running out fails
|
|
393
|
+
// with REPORT_INCOMPLETE, as the feature reports do, instead of a raw
|
|
394
|
+
// limit error.
|
|
395
|
+
const buckets: Doc<"aggregateBuckets">[] = [];
|
|
396
|
+
for await (const bucket of ctx.db
|
|
386
397
|
.query("aggregateBuckets")
|
|
387
398
|
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
|
|
388
399
|
range
|
|
@@ -390,8 +401,13 @@ export const overview = query({
|
|
|
390
401
|
.eq("granularity", granularity)
|
|
391
402
|
.gte("bucketStart", firstBucket)
|
|
392
403
|
.lte("bucketStart", lastBucket),
|
|
393
|
-
)
|
|
394
|
-
.
|
|
404
|
+
)) {
|
|
405
|
+
buckets.push(bucket);
|
|
406
|
+
if (buckets.length > perSiteLimit) break;
|
|
407
|
+
if (buckets.length % 16 === 0) {
|
|
408
|
+
await requireFeatureReadHeadroom(ctx, "overview aggregate buckets");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
395
411
|
failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
|
|
396
412
|
for (const bucket of buckets) {
|
|
397
413
|
if (
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
MAX_FUNNEL_STEPS,
|
|
14
14
|
MAX_SITE_MAP_ROUTES_PER_DAY,
|
|
15
15
|
REPORT_ROLLUP_SHARDS,
|
|
16
|
+
ROLLUP_FOLD_DEBOUNCE_MS,
|
|
16
17
|
ROLLUP_FOLD_LEASE_MS,
|
|
17
18
|
ROUTE_TRANSITION_SLOTS,
|
|
18
19
|
SITE_MAP_OTHER_ROUTE,
|
|
@@ -36,8 +37,8 @@ import {
|
|
|
36
37
|
* whole batches are lost (#52). Ingestion therefore never touches a rollup
|
|
37
38
|
* row. It appends the batch's deltas to `reportRollupQueue` — an insert
|
|
38
39
|
* conflicts with nothing — and one leased fold job per site merges the queue
|
|
39
|
-
* into the rows a
|
|
40
|
-
* single writer.
|
|
40
|
+
* into the rows about a second later (see rollups.ts), so every rollup row
|
|
41
|
+
* has a single writer.
|
|
41
42
|
*/
|
|
42
43
|
|
|
43
44
|
export type GoalRollupDelta = {
|
|
@@ -221,6 +222,10 @@ const queuedVitalValidator = v.object({
|
|
|
221
222
|
export const reportRollupQueueFields = {
|
|
222
223
|
siteId: v.id("sites"),
|
|
223
224
|
createdAt: v.number(),
|
|
225
|
+
// Failed runs in a row with this batch at the head of the queue, and when
|
|
226
|
+
// it was set aside after too many (see rollups.ts).
|
|
227
|
+
foldFailures: v.optional(v.number()),
|
|
228
|
+
deadLetteredAt: v.optional(v.number()),
|
|
224
229
|
goals: v.array(queuedGoalValidator),
|
|
225
230
|
funnels: v.array(queuedFunnelValidator),
|
|
226
231
|
funnelSteps: v.array(queuedFunnelStepValidator),
|
|
@@ -456,8 +461,9 @@ export function mergeQueuedRollups(
|
|
|
456
461
|
/**
|
|
457
462
|
* Appends a batch's rollup deltas to the site's queue and makes sure a fold
|
|
458
463
|
* job will pick them up. The only document this reads is the site's fold
|
|
459
|
-
* state, which
|
|
460
|
-
*
|
|
464
|
+
* state, which no batch rewrites while a fold is pending and the fold job
|
|
465
|
+
* rewrites about twice a minute under load, so concurrent ingests almost
|
|
466
|
+
* never retry because of it.
|
|
461
467
|
*/
|
|
462
468
|
export async function enqueueReportRollups(
|
|
463
469
|
ctx: MutationCtx,
|
|
@@ -482,6 +488,7 @@ export async function ensureRollupFold(
|
|
|
482
488
|
.query("rollupFoldStates")
|
|
483
489
|
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
484
490
|
.unique();
|
|
491
|
+
// A held lease means a chain is running and will fold this batch.
|
|
485
492
|
if (state && state.leaseUntil > now) return;
|
|
486
493
|
const token = crypto.randomUUID();
|
|
487
494
|
const leaseUntil = now + ROLLUP_FOLD_LEASE_MS;
|
|
@@ -490,7 +497,11 @@ export async function ensureRollupFold(
|
|
|
490
497
|
} else {
|
|
491
498
|
await ctx.db.insert("rollupFoldStates", { siteId, leaseUntil, token });
|
|
492
499
|
}
|
|
493
|
-
|
|
500
|
+
// Debounced, so every batch of the next second joins this run.
|
|
501
|
+
await ctx.scheduler.runAfter(ROLLUP_FOLD_DEBOUNCE_MS, internal.rollups.fold, {
|
|
502
|
+
siteId,
|
|
503
|
+
token,
|
|
504
|
+
});
|
|
494
505
|
}
|
|
495
506
|
|
|
496
507
|
// ---------------------------------------------------------------------------
|
|
@@ -632,8 +643,9 @@ export async function flushReportRollups(
|
|
|
632
643
|
for (const delta of deltas.routes.values()) {
|
|
633
644
|
await upsertRouteRollup(ctx, delta, routeDayRowCounts);
|
|
634
645
|
}
|
|
646
|
+
const vitalDayPageRows = new Map<string, number>();
|
|
635
647
|
for (const delta of deltas.vitals.values()) {
|
|
636
|
-
await upsertVitalRollup(ctx, delta);
|
|
648
|
+
await upsertVitalRollup(ctx, delta, vitalDayPageRows);
|
|
637
649
|
}
|
|
638
650
|
}
|
|
639
651
|
|
|
@@ -731,7 +743,11 @@ async function upsertRouteRollup(
|
|
|
731
743
|
}
|
|
732
744
|
}
|
|
733
745
|
|
|
734
|
-
async function upsertVitalRollup(
|
|
746
|
+
async function upsertVitalRollup(
|
|
747
|
+
ctx: MutationCtx,
|
|
748
|
+
delta: VitalRollupDelta,
|
|
749
|
+
dayPageRows: Map<string, number>,
|
|
750
|
+
) {
|
|
735
751
|
const existing = await ctx.db
|
|
736
752
|
.query("vitalRollups")
|
|
737
753
|
.withIndex("by_key", (range) =>
|
|
@@ -748,21 +764,36 @@ async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
|
|
|
748
764
|
delta.device === VITAL_ALL &&
|
|
749
765
|
delta.page !== VITAL_OTHER_PAGES
|
|
750
766
|
) {
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
767
|
+
// New page rows spend one bounded read per day and fold run to respect
|
|
768
|
+
// the daily page slots, as routes do: rereading it for every new page let
|
|
769
|
+
// a fold of many distinct URLs pass the read limit and fail every retry.
|
|
770
|
+
// Past the cap, the day's remaining pages fold into "(other)".
|
|
771
|
+
const capRows = VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS;
|
|
772
|
+
const dayKey = `${delta.siteId}:${delta.bucketStart}`;
|
|
773
|
+
let pageRows = dayPageRows.get(dayKey);
|
|
774
|
+
if (pageRows === undefined) {
|
|
775
|
+
pageRows = (
|
|
776
|
+
await ctx.db
|
|
777
|
+
.query("vitalRollups")
|
|
778
|
+
.withIndex("by_key", (range) =>
|
|
779
|
+
range
|
|
780
|
+
.eq("siteId", delta.siteId)
|
|
781
|
+
.eq("bucketStart", delta.bucketStart)
|
|
782
|
+
.eq("device", VITAL_ALL),
|
|
783
|
+
)
|
|
784
|
+
.take(capRows + 1)
|
|
785
|
+
).length;
|
|
786
|
+
dayPageRows.set(dayKey, pageRows);
|
|
787
|
+
}
|
|
788
|
+
if (pageRows > capRows) {
|
|
789
|
+
await upsertVitalRollup(
|
|
790
|
+
ctx,
|
|
791
|
+
{ ...delta, page: VITAL_OTHER_PAGES },
|
|
792
|
+
dayPageRows,
|
|
793
|
+
);
|
|
764
794
|
return;
|
|
765
795
|
}
|
|
796
|
+
dayPageRows.set(dayKey, pageRows + 1);
|
|
766
797
|
}
|
|
767
798
|
const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
|
|
768
799
|
for (const [metric, addition] of delta.metrics) {
|