@iann29/rastro 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +199 -50
- package/agent/integration.md +26 -10
- package/agent/manifest.json +10 -5
- package/agent/manifest.schema.json +14 -5
- package/dist/client/federation.d.ts +28 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +12 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +262 -9
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +119 -2
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +6 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +72 -1
- 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 +9 -2
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +31 -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/coverage.d.ts +1 -0
- package/dist/component/coverage.d.ts.map +1 -1
- package/dist/component/coverage.js +6 -1
- package/dist/component/coverage.js.map +1 -1
- package/dist/component/eventStore.d.ts +2 -0
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/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 +65 -2
- 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 +36 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +111 -20
- 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/origin.d.ts +70 -0
- package/dist/component/origin.d.ts.map +1 -0
- package/dist/component/origin.js +230 -0
- package/dist/component/origin.js.map +1 -0
- package/dist/component/platforms.d.ts +33 -0
- package/dist/component/platforms.d.ts.map +1 -0
- package/dist/component/platforms.js +328 -0
- package/dist/component/platforms.js.map +1 -0
- package/dist/component/reports.d.ts +27 -72
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +72 -16
- 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/sanitize.d.ts +5 -0
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +33 -1
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +68 -9
- package/dist/component/schema.js +21 -3
- package/dist/component/schema.js.map +1 -1
- package/dist/component/trackedLinks.d.ts +91 -0
- package/dist/component/trackedLinks.d.ts.map +1 -0
- package/dist/component/trackedLinks.js +314 -0
- package/dist/component/trackedLinks.js.map +1 -0
- package/dist/component/useragent.d.ts +6 -0
- package/dist/component/useragent.d.ts.map +1 -1
- package/dist/component/useragent.js +9 -0
- package/dist/component/useragent.js.map +1 -1
- package/dist/component/validators.d.ts +51 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +35 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +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.d.ts +1 -0
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +104 -47
- 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 +234 -45
- package/llms.txt +9 -4
- package/package.json +2 -2
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +153 -1
- package/src/component/_generated/server.ts +1 -0
- package/src/component/constants.ts +31 -3
- package/src/component/convex.config.ts +3 -0
- package/src/component/coverage.ts +7 -1
- package/src/component/geo.ts +1 -1
- package/src/component/http.ts +71 -2
- package/src/component/identity.ts +41 -2
- package/src/component/ingest.ts +146 -33
- package/src/component/live.ts +51 -4
- package/src/component/origin.ts +273 -0
- package/src/component/platforms.ts +359 -0
- package/src/component/reports.ts +98 -18
- package/src/component/rollupStore.ts +51 -20
- package/src/component/rollups.ts +141 -32
- package/src/component/sanitize.ts +39 -1
- package/src/component/schema.ts +24 -3
- package/src/component/trackedLinks.ts +384 -0
- package/src/component/useragent.ts +11 -0
- package/src/component/validators.ts +71 -0
- package/src/tracker/generated.ts +11 -11
package/src/component/reports.ts
CHANGED
|
@@ -55,6 +55,8 @@ import {
|
|
|
55
55
|
type RawJourneyEvent,
|
|
56
56
|
} from "./eventStore.js";
|
|
57
57
|
import { isPlainRecord } from "./guards.js";
|
|
58
|
+
import { classifyOrigin } from "./origin.js";
|
|
59
|
+
import { externalReferrer } from "./sanitize.js";
|
|
58
60
|
import {
|
|
59
61
|
affiliateFieldsValidator,
|
|
60
62
|
eventFieldsValidator,
|
|
@@ -220,6 +222,11 @@ export const overview = query({
|
|
|
220
222
|
topMediums: v.array(topItemValidator),
|
|
221
223
|
// Destination hosts of outbound clicks, ranked by clicks.
|
|
222
224
|
topOutbound: v.array(topItemValidator),
|
|
225
|
+
// The session's origin (origin.ts), ranked like sources: platform ids,
|
|
226
|
+
// channel ids, and the evidence that decided them.
|
|
227
|
+
topPlatforms: v.array(topItemValidator),
|
|
228
|
+
topChannels: v.array(topItemValidator),
|
|
229
|
+
topEvidence: v.array(topItemValidator),
|
|
223
230
|
metadata: v.object({
|
|
224
231
|
visitors: v.object({
|
|
225
232
|
basis: v.union(
|
|
@@ -242,6 +249,9 @@ export const overview = query({
|
|
|
242
249
|
topGoals: v.literal("goalCompletionVolume"),
|
|
243
250
|
topMediums: breakdownBasisValidator,
|
|
244
251
|
topOutbound: v.literal("eventVolume"),
|
|
252
|
+
topPlatforms: breakdownBasisValidator,
|
|
253
|
+
topChannels: breakdownBasisValidator,
|
|
254
|
+
topEvidence: breakdownBasisValidator,
|
|
245
255
|
}),
|
|
246
256
|
// Since when every site in the request records the medium and outbound
|
|
247
257
|
// slots; null while one of them has not stamped it. A range that starts
|
|
@@ -346,14 +356,19 @@ export const overview = query({
|
|
|
346
356
|
const bucketCount = localDays
|
|
347
357
|
? bucketStarts.length
|
|
348
358
|
: Math.floor((lastBucket - firstBucket) / intervalMs) + 1;
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
359
|
+
const shards =
|
|
360
|
+
interval === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS;
|
|
361
|
+
const perSiteLimit = bucketCount * shards;
|
|
352
362
|
if (perSiteLimit * siteIds.length > MAX_OVERVIEW_AGGREGATE_ROWS) {
|
|
363
|
+
// The row budget, not MAX_REPORT_RANGE_DAYS, bounds the overview: 101
|
|
364
|
+
// days for one site and 10 for ten; 24 hours for two, 5 for ten.
|
|
365
|
+
const maximumBuckets = Math.floor(
|
|
366
|
+
MAX_OVERVIEW_AGGREGATE_ROWS / (shards * siteIds.length),
|
|
367
|
+
);
|
|
353
368
|
fail(
|
|
354
369
|
"LIMIT_EXCEEDED",
|
|
355
|
-
|
|
356
|
-
{ maximumAggregateRows: MAX_OVERVIEW_AGGREGATE_ROWS },
|
|
370
|
+
`overview range is too wide: ${siteIds.length === 1 ? "one site" : `${siteIds.length} sites`} can read at most ${maximumBuckets} ${interval}s`,
|
|
371
|
+
{ maximumAggregateRows: MAX_OVERVIEW_AGGREGATE_ROWS, maximumBuckets },
|
|
357
372
|
);
|
|
358
373
|
}
|
|
359
374
|
const totals = emptyTotals();
|
|
@@ -382,7 +397,13 @@ export const overview = query({
|
|
|
382
397
|
await assertSourceAvailable(ctx, siteIds, [overviewDataset], from);
|
|
383
398
|
|
|
384
399
|
for (const siteId of siteIds) {
|
|
385
|
-
|
|
400
|
+
// Iterated rather than taken whole: within the row budget a busy range
|
|
401
|
+
// can still cross the bytes a query may read. Values are length-capped,
|
|
402
|
+
// so sixteen buckets stay far below the reserve, and running out fails
|
|
403
|
+
// with REPORT_INCOMPLETE, as the feature reports do, instead of a raw
|
|
404
|
+
// limit error.
|
|
405
|
+
const buckets: Doc<"aggregateBuckets">[] = [];
|
|
406
|
+
for await (const bucket of ctx.db
|
|
386
407
|
.query("aggregateBuckets")
|
|
387
408
|
.withIndex("by_siteId_and_granularity_and_bucketStart", (range) =>
|
|
388
409
|
range
|
|
@@ -390,8 +411,13 @@ export const overview = query({
|
|
|
390
411
|
.eq("granularity", granularity)
|
|
391
412
|
.gte("bucketStart", firstBucket)
|
|
392
413
|
.lte("bucketStart", lastBucket),
|
|
393
|
-
)
|
|
394
|
-
.
|
|
414
|
+
)) {
|
|
415
|
+
buckets.push(bucket);
|
|
416
|
+
if (buckets.length > perSiteLimit) break;
|
|
417
|
+
if (buckets.length % 16 === 0) {
|
|
418
|
+
await requireFeatureReadHeadroom(ctx, "overview aggregate buckets");
|
|
419
|
+
}
|
|
420
|
+
}
|
|
395
421
|
failIfIncomplete(buckets, perSiteLimit, "overview aggregate buckets");
|
|
396
422
|
for (const bucket of buckets) {
|
|
397
423
|
if (
|
|
@@ -520,6 +546,9 @@ export const overview = query({
|
|
|
520
546
|
topGoals: top("goal", "eventVolume"),
|
|
521
547
|
topMediums: top("medium", sessionBasis),
|
|
522
548
|
topOutbound: top("outbound", "eventVolume"),
|
|
549
|
+
topPlatforms: top("platform", sessionBasis),
|
|
550
|
+
topChannels: top("channel", sessionBasis),
|
|
551
|
+
topEvidence: top("evidence", sessionBasis),
|
|
523
552
|
metadata: {
|
|
524
553
|
visitors: {
|
|
525
554
|
basis: visitorBasis,
|
|
@@ -539,6 +568,9 @@ export const overview = query({
|
|
|
539
568
|
topGoals: "goalCompletionVolume" as const,
|
|
540
569
|
topMediums: sessionBasis,
|
|
541
570
|
topOutbound: "eventVolume" as const,
|
|
571
|
+
topPlatforms: sessionBasis,
|
|
572
|
+
topChannels: sessionBasis,
|
|
573
|
+
topEvidence: sessionBasis,
|
|
542
574
|
},
|
|
543
575
|
dimensionsSince: controls.every(
|
|
544
576
|
(control) => control?.dimensionsSince !== undefined,
|
|
@@ -932,6 +964,27 @@ async function readRouteRollups(
|
|
|
932
964
|
return { rows, completeFrom: cutAt + DAY_MS };
|
|
933
965
|
}
|
|
934
966
|
|
|
967
|
+
type SessionRow = Omit<
|
|
968
|
+
Doc<"sessions">,
|
|
969
|
+
"geoLookupAttemptedAt" | "lastPageviewAt"
|
|
970
|
+
>;
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* A session recorded before origins existed, classified on read from what it
|
|
974
|
+
* kept (utm fields, referrer, affiliate) and marked `legacy`: it carries no
|
|
975
|
+
* click IDs or in-app signal, and it never enters the aggregates.
|
|
976
|
+
*/
|
|
977
|
+
function withOrigin(session: SessionRow, domains: string[]): SessionRow {
|
|
978
|
+
if (session.platform !== undefined) return session;
|
|
979
|
+
const origin = classifyOrigin({
|
|
980
|
+
utmSource: session.utmSource,
|
|
981
|
+
utmMedium: session.utmMedium,
|
|
982
|
+
referrer: externalReferrer(session.referrer, domains),
|
|
983
|
+
affiliate: session.affiliateSlug !== undefined,
|
|
984
|
+
});
|
|
985
|
+
return { ...session, ...origin, evidence: "legacy" };
|
|
986
|
+
}
|
|
987
|
+
|
|
935
988
|
/** Metadata for a selected session, independent of list pagination. */
|
|
936
989
|
export const getSession = query({
|
|
937
990
|
args: { siteId: v.id("sites"), sessionId: v.string() },
|
|
@@ -949,7 +1002,8 @@ export const getSession = query({
|
|
|
949
1002
|
lastPageviewAt: _pageview,
|
|
950
1003
|
...result
|
|
951
1004
|
} = session;
|
|
952
|
-
|
|
1005
|
+
const site = await ctx.db.get("sites", args.siteId);
|
|
1006
|
+
return withOrigin(result, site?.domains ?? []);
|
|
953
1007
|
},
|
|
954
1008
|
});
|
|
955
1009
|
|
|
@@ -1088,12 +1142,13 @@ export const listSessions = query({
|
|
|
1088
1142
|
.take(take)),
|
|
1089
1143
|
);
|
|
1090
1144
|
}
|
|
1145
|
+
const site = await ctx.db.get("sites", args.siteId);
|
|
1091
1146
|
const publicRows = rows.map(
|
|
1092
1147
|
({
|
|
1093
1148
|
geoLookupAttemptedAt: _attemptedAt,
|
|
1094
1149
|
lastPageviewAt: _lastPageviewAt,
|
|
1095
1150
|
...row
|
|
1096
|
-
}) => row,
|
|
1151
|
+
}) => withOrigin(row, site?.domains ?? []),
|
|
1097
1152
|
);
|
|
1098
1153
|
return keysetPaginationResult(publicRows, pagination, (row) => ({
|
|
1099
1154
|
timestamp: row.lastSeenAt,
|
|
@@ -1810,6 +1865,7 @@ const coverageDatasetValidator = v.union(
|
|
|
1810
1865
|
v.literal("affiliates"),
|
|
1811
1866
|
v.literal("vitals"),
|
|
1812
1867
|
v.literal("siteMap"),
|
|
1868
|
+
v.literal("origin"),
|
|
1813
1869
|
);
|
|
1814
1870
|
|
|
1815
1871
|
export const dataCoverage = query({
|
|
@@ -1843,6 +1899,7 @@ export const dataCoverage = query({
|
|
|
1843
1899
|
"affiliates",
|
|
1844
1900
|
"vitals",
|
|
1845
1901
|
"siteMap",
|
|
1902
|
+
"origin",
|
|
1846
1903
|
] as const;
|
|
1847
1904
|
return {
|
|
1848
1905
|
siteId: args.siteId,
|
|
@@ -2147,7 +2204,26 @@ async function datasetCoverageForRange(
|
|
|
2147
2204
|
to: number,
|
|
2148
2205
|
) {
|
|
2149
2206
|
if (dataset === "siteMap") {
|
|
2150
|
-
return
|
|
2207
|
+
return stampedDatasetCoverage(
|
|
2208
|
+
ctx,
|
|
2209
|
+
siteId,
|
|
2210
|
+
from,
|
|
2211
|
+
to,
|
|
2212
|
+
"siteMapSince",
|
|
2213
|
+
siteMapCoverageDataset,
|
|
2214
|
+
);
|
|
2215
|
+
}
|
|
2216
|
+
// Origins exist in sessions and aggregate slots from the site's stamp, and
|
|
2217
|
+
// the overview reads them from the daily aggregates retention trims.
|
|
2218
|
+
if (dataset === "origin") {
|
|
2219
|
+
return stampedDatasetCoverage(
|
|
2220
|
+
ctx,
|
|
2221
|
+
siteId,
|
|
2222
|
+
from,
|
|
2223
|
+
to,
|
|
2224
|
+
"originSince",
|
|
2225
|
+
"overviewDay",
|
|
2226
|
+
);
|
|
2151
2227
|
}
|
|
2152
2228
|
// Vitals have no legacy fallback source; the rollup either covers the range
|
|
2153
2229
|
// or the coverage states say how much of it exists.
|
|
@@ -2189,23 +2265,26 @@ async function datasetCoverageForRange(
|
|
|
2189
2265
|
}
|
|
2190
2266
|
|
|
2191
2267
|
/**
|
|
2192
|
-
*
|
|
2193
|
-
* `siteMapSince` and ends where retention cut
|
|
2194
|
-
* narrow the window when the range exceeds
|
|
2268
|
+
* A stamped dataset's coverage without reading its rows: it starts at the
|
|
2269
|
+
* site's stamp (`siteMapSince`, `originSince`) and ends where retention cut
|
|
2270
|
+
* it. The site map report may still narrow the window when the range exceeds
|
|
2271
|
+
* its row budget.
|
|
2195
2272
|
*/
|
|
2196
|
-
async function
|
|
2273
|
+
async function stampedDatasetCoverage(
|
|
2197
2274
|
ctx: QueryCtx,
|
|
2198
2275
|
siteId: Id<"sites">,
|
|
2199
2276
|
from: number,
|
|
2200
2277
|
to: number,
|
|
2278
|
+
stamp: "siteMapSince" | "originSince",
|
|
2279
|
+
retainedDataset: string,
|
|
2201
2280
|
) {
|
|
2202
2281
|
const control = await ctx.db
|
|
2203
2282
|
.query("analyticsCoverage")
|
|
2204
2283
|
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
2205
2284
|
.unique();
|
|
2206
|
-
const since = control?.
|
|
2285
|
+
const since = control?.[stamp] ?? null;
|
|
2207
2286
|
const retainedBefore =
|
|
2208
|
-
control?.retained.find((item) => item.dataset ===
|
|
2287
|
+
control?.retained.find((item) => item.dataset === retainedDataset)
|
|
2209
2288
|
?.before ?? null;
|
|
2210
2289
|
const unavailable =
|
|
2211
2290
|
since === null ||
|
|
@@ -2236,7 +2315,8 @@ type PublicCoverageDataset =
|
|
|
2236
2315
|
| "funnels"
|
|
2237
2316
|
| "affiliates"
|
|
2238
2317
|
| "vitals"
|
|
2239
|
-
| "siteMap"
|
|
2318
|
+
| "siteMap"
|
|
2319
|
+
| "origin";
|
|
2240
2320
|
|
|
2241
2321
|
type CoverageSource =
|
|
2242
2322
|
| "overviewHour"
|
|
@@ -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) {
|
package/src/component/rollups.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { v } from "convex/values";
|
|
1
|
+
import { v, type Infer } from "convex/values";
|
|
2
2
|
import { internal } from "./_generated/api.js";
|
|
3
|
+
import type { Id } from "./_generated/dataModel.js";
|
|
4
|
+
import type { MutationCtx } from "./_generated/server.js";
|
|
3
5
|
import { internalMutation } from "./_generated/server.js";
|
|
4
6
|
import {
|
|
7
|
+
ROLLUP_DEAD_LETTER_RETENTION_MS,
|
|
5
8
|
ROLLUP_FOLD_BATCH_DOCS,
|
|
9
|
+
ROLLUP_FOLD_DEBOUNCE_MS,
|
|
6
10
|
ROLLUP_FOLD_LEASE_MS,
|
|
11
|
+
ROLLUP_FOLD_MAX_FAILURES,
|
|
7
12
|
ROLLUP_FOLD_MAX_ROWS,
|
|
8
13
|
} from "./constants.js";
|
|
9
14
|
import {
|
|
@@ -13,24 +18,65 @@ import {
|
|
|
13
18
|
mergeQueuedRollups,
|
|
14
19
|
} from "./rollupStore.js";
|
|
15
20
|
|
|
21
|
+
const foldArgs = { siteId: v.id("sites"), token: v.string() };
|
|
22
|
+
const foldResult = v.object({
|
|
23
|
+
folded: v.number(),
|
|
24
|
+
remaining: v.boolean(),
|
|
25
|
+
stale: v.boolean(),
|
|
26
|
+
});
|
|
27
|
+
type FoldResult = Infer<typeof foldResult>;
|
|
28
|
+
|
|
29
|
+
// A failed run's reads still count against the transaction that catches it,
|
|
30
|
+
// so a run stops short of the platform's read limits (16 MiB, 32,000
|
|
31
|
+
// documents, 4,096 queries) and leaves room to record the failure.
|
|
32
|
+
const RUN_READ_LIMITS = {
|
|
33
|
+
bytesRead: 15 * 1024 * 1024,
|
|
34
|
+
documentsRead: 31_000,
|
|
35
|
+
databaseQueries: 4_000,
|
|
36
|
+
};
|
|
37
|
+
|
|
16
38
|
/**
|
|
17
39
|
* Folds a site's queued report rollup deltas into the rollup rows. Exactly
|
|
18
|
-
* one chain runs per site:
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
40
|
+
* one chain runs per site: a batch that finds the lease free takes it and
|
|
41
|
+
* schedules the first run a debounce away (see ensureRollupFold), every run
|
|
42
|
+
* carries the token of that lease, and a run whose token is no longer
|
|
43
|
+
* current exits without touching anything. Batches that land while the
|
|
44
|
+
* lease is held only append to the queue; they never write the lease or
|
|
45
|
+
* schedule anything.
|
|
46
|
+
*
|
|
47
|
+
* A run folds the batches created before it began and, if it folded any,
|
|
48
|
+
* schedules the next run a debounce later (at once while a backlog remains),
|
|
49
|
+
* so a steady stream costs one run per second and batches landing during a
|
|
50
|
+
* run never conflict with it. Only a run that finds nothing left releases
|
|
51
|
+
* the lease, and that run reads the whole queue range: a batch landing
|
|
52
|
+
* concurrently either commits first, and the run retries and folds it, or
|
|
53
|
+
* reads the released lease and starts a new chain.
|
|
54
|
+
*
|
|
55
|
+
* A run executes as a nested transaction, so one that throws is rolled back
|
|
56
|
+
* whole and its batches stay queued; the oldest is charged the failure and
|
|
57
|
+
* the next run retries it alone. After ROLLUP_FOLD_MAX_FAILURES runs in a
|
|
58
|
+
* row it is set aside as a dead letter and the queue moves on.
|
|
26
59
|
*/
|
|
27
60
|
export const fold = internalMutation({
|
|
28
|
-
args:
|
|
29
|
-
returns:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
61
|
+
args: foldArgs,
|
|
62
|
+
returns: foldResult,
|
|
63
|
+
handler: async (ctx, args) => {
|
|
64
|
+
try {
|
|
65
|
+
const result: FoldResult = await ctx.runMutation(
|
|
66
|
+
internal.rollups.foldRun,
|
|
67
|
+
args,
|
|
68
|
+
{ transactionLimits: RUN_READ_LIMITS },
|
|
69
|
+
);
|
|
70
|
+
return result;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return await setAsideFailedRun(ctx, args, error);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const foldRun = internalMutation({
|
|
78
|
+
args: foldArgs,
|
|
79
|
+
returns: foldResult,
|
|
34
80
|
handler: async (ctx, args) => {
|
|
35
81
|
const state = await ctx.db
|
|
36
82
|
.query("rollupFoldStates")
|
|
@@ -42,20 +88,31 @@ export const fold = internalMutation({
|
|
|
42
88
|
const now = Date.now();
|
|
43
89
|
const queued = await ctx.db
|
|
44
90
|
.query("reportRollupQueue")
|
|
45
|
-
.withIndex("
|
|
46
|
-
range
|
|
91
|
+
.withIndex("by_siteId_and_deadLetteredAt_and_createdAt", (range) =>
|
|
92
|
+
range
|
|
93
|
+
.eq("siteId", args.siteId)
|
|
94
|
+
.eq("deadLetteredAt", undefined)
|
|
95
|
+
.lte("createdAt", now),
|
|
47
96
|
)
|
|
48
|
-
.order("asc")
|
|
49
97
|
.take(ROLLUP_FOLD_BATCH_DOCS + 1);
|
|
50
98
|
if (queued.length === 0) {
|
|
51
|
-
if (
|
|
99
|
+
if (await oldestQueued(ctx, args.siteId)) {
|
|
100
|
+
await ctx.scheduler.runAfter(
|
|
101
|
+
ROLLUP_FOLD_DEBOUNCE_MS,
|
|
102
|
+
internal.rollups.fold,
|
|
103
|
+
args,
|
|
104
|
+
);
|
|
105
|
+
} else if (state.leaseUntil > now) {
|
|
52
106
|
await ctx.db.patch("rollupFoldStates", state._id, { leaseUntil: now });
|
|
53
107
|
}
|
|
54
108
|
return { folded: 0, remaining: false, stale: false };
|
|
55
109
|
}
|
|
110
|
+
// A batch that already failed a run is retried alone, so the next
|
|
111
|
+
// failure is charged to the batch that causes it.
|
|
112
|
+
const batchDocs = queued[0].foldFailures ? 1 : ROLLUP_FOLD_BATCH_DOCS;
|
|
56
113
|
const deltas = createReportRollupDeltas();
|
|
57
114
|
let taken = 0;
|
|
58
|
-
for (const document of queued.slice(0,
|
|
115
|
+
for (const document of queued.slice(0, batchDocs)) {
|
|
59
116
|
mergeQueuedRollups(deltas, document);
|
|
60
117
|
taken += 1;
|
|
61
118
|
if (countReportRollupRows(deltas) >= ROLLUP_FOLD_MAX_ROWS) break;
|
|
@@ -64,19 +121,71 @@ export const fold = internalMutation({
|
|
|
64
121
|
for (const document of queued.slice(0, taken)) {
|
|
65
122
|
await ctx.db.delete("reportRollupQueue", document._id);
|
|
66
123
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
leaseUntil: now + ROLLUP_FOLD_LEASE_MS,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
await ctx.scheduler.runAfter(0, internal.rollups.fold, args);
|
|
77
|
-
} else {
|
|
78
|
-
await ctx.db.patch("rollupFoldStates", state._id, { leaseUntil: now });
|
|
124
|
+
// Renew only when the lease is halfway gone, so the state document is
|
|
125
|
+
// rewritten about twice a minute while batches keep arriving.
|
|
126
|
+
if (state.leaseUntil - now < ROLLUP_FOLD_LEASE_MS / 2) {
|
|
127
|
+
await ctx.db.patch("rollupFoldStates", state._id, {
|
|
128
|
+
leaseUntil: now + ROLLUP_FOLD_LEASE_MS,
|
|
129
|
+
});
|
|
79
130
|
}
|
|
131
|
+
const remaining = queued.length > taken;
|
|
132
|
+
await ctx.scheduler.runAfter(
|
|
133
|
+
remaining ? 0 : ROLLUP_FOLD_DEBOUNCE_MS,
|
|
134
|
+
internal.rollups.fold,
|
|
135
|
+
args,
|
|
136
|
+
);
|
|
80
137
|
return { folded: taken, remaining, stale: false };
|
|
81
138
|
},
|
|
82
139
|
});
|
|
140
|
+
|
|
141
|
+
/** The oldest batch still waiting to fold; dead letters are skipped. */
|
|
142
|
+
function oldestQueued(ctx: MutationCtx, siteId: Id<"sites">) {
|
|
143
|
+
return ctx.db
|
|
144
|
+
.query("reportRollupQueue")
|
|
145
|
+
.withIndex("by_siteId_and_deadLetteredAt_and_createdAt", (range) =>
|
|
146
|
+
range.eq("siteId", siteId).eq("deadLetteredAt", undefined),
|
|
147
|
+
)
|
|
148
|
+
.first();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function setAsideFailedRun(
|
|
152
|
+
ctx: MutationCtx,
|
|
153
|
+
args: { siteId: Id<"sites">; token: string },
|
|
154
|
+
error: unknown,
|
|
155
|
+
): Promise<FoldResult> {
|
|
156
|
+
const state = await ctx.db
|
|
157
|
+
.query("rollupFoldStates")
|
|
158
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
159
|
+
.unique();
|
|
160
|
+
const head = await oldestQueued(ctx, args.siteId);
|
|
161
|
+
if (!head || state?.token !== args.token) throw error;
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
const foldFailures = (head.foldFailures ?? 0) + 1;
|
|
164
|
+
if (foldFailures < ROLLUP_FOLD_MAX_FAILURES) {
|
|
165
|
+
await ctx.db.patch("reportRollupQueue", head._id, { foldFailures });
|
|
166
|
+
} else {
|
|
167
|
+
console.error(
|
|
168
|
+
`rollup fold set aside queued batch ${head._id} of site ${args.siteId} after ${foldFailures} failed runs`,
|
|
169
|
+
error,
|
|
170
|
+
);
|
|
171
|
+
await ctx.db.patch("reportRollupQueue", head._id, {
|
|
172
|
+
foldFailures,
|
|
173
|
+
deadLetteredAt: now,
|
|
174
|
+
});
|
|
175
|
+
// Dead letters stay for inspection; each new one retires expired ones.
|
|
176
|
+
const expired = await ctx.db
|
|
177
|
+
.query("reportRollupQueue")
|
|
178
|
+
.withIndex("by_siteId_and_deadLetteredAt_and_createdAt", (range) =>
|
|
179
|
+
range
|
|
180
|
+
.eq("siteId", args.siteId)
|
|
181
|
+
.gte("deadLetteredAt", 0)
|
|
182
|
+
.lt("deadLetteredAt", now - ROLLUP_DEAD_LETTER_RETENTION_MS),
|
|
183
|
+
)
|
|
184
|
+
.take(ROLLUP_FOLD_BATCH_DOCS);
|
|
185
|
+
for (const document of expired) {
|
|
186
|
+
await ctx.db.delete("reportRollupQueue", document._id);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
await ctx.scheduler.runAfter(0, internal.rollups.fold, args);
|
|
190
|
+
return { folded: 0, remaining: true, stale: false };
|
|
191
|
+
}
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
TrackerEvent,
|
|
11
11
|
} from "./validators.js";
|
|
12
12
|
import { isValidVitalValue, isVitalMetric } from "./vitals.js";
|
|
13
|
+
import { isPlatform, knownClickIds } from "./origin.js";
|
|
13
14
|
|
|
14
15
|
const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
15
16
|
const SAFE_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,39}$/;
|
|
@@ -121,6 +122,27 @@ export function sanitizePublicUrl(value: string): string | undefined {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
const ANDROID_PACKAGE = /^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+$/;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A referrer as sanitizePublicUrl keeps it, or the Android app that opened
|
|
129
|
+
* the link: `android-app://<package>/…` keeps the package alone.
|
|
130
|
+
*/
|
|
131
|
+
function sanitizeReferrer(value: string): string | undefined {
|
|
132
|
+
const input = cleanString(value, 2048);
|
|
133
|
+
if (!input.toLowerCase().startsWith("android-app://")) {
|
|
134
|
+
return sanitizePublicUrl(input);
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const pkg = new URL(input).hostname.toLowerCase();
|
|
138
|
+
return pkg.length <= 120 && ANDROID_PACKAGE.test(pkg)
|
|
139
|
+
? `android-app://${pkg}`
|
|
140
|
+
: undefined;
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
124
146
|
/**
|
|
125
147
|
* The host of a public URL the way the outbound dimension names it: lowercase,
|
|
126
148
|
* without port, path, or query. A relative path has no host.
|
|
@@ -229,7 +251,7 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
|
|
|
229
251
|
referrer:
|
|
230
252
|
event.referrer === undefined
|
|
231
253
|
? undefined
|
|
232
|
-
:
|
|
254
|
+
: sanitizeReferrer(event.referrer),
|
|
233
255
|
timestamp: Math.trunc(event.timestamp),
|
|
234
256
|
sequence: event.sequence,
|
|
235
257
|
target:
|
|
@@ -246,6 +268,7 @@ export function sanitizeEvent(event: TrackerEvent, now: number): TrackerEvent {
|
|
|
246
268
|
event.type === "vital" && event.value !== undefined
|
|
247
269
|
? Math.round(event.value)
|
|
248
270
|
: undefined,
|
|
271
|
+
clid: event.clid === undefined ? undefined : knownClickIds(event.clid),
|
|
249
272
|
};
|
|
250
273
|
}
|
|
251
274
|
|
|
@@ -353,6 +376,8 @@ export function sanitizeContext(
|
|
|
353
376
|
context?.visitorKey && SAFE_OPAQUE_ID.test(context.visitorKey)
|
|
354
377
|
? cleanString(context.visitorKey, 128) || undefined
|
|
355
378
|
: undefined,
|
|
379
|
+
inApp:
|
|
380
|
+
context?.inApp && isPlatform(context.inApp) ? context.inApp : undefined,
|
|
356
381
|
};
|
|
357
382
|
}
|
|
358
383
|
|
|
@@ -393,6 +418,19 @@ export function sourceFromReferrer(referrer: string | undefined): string {
|
|
|
393
418
|
}
|
|
394
419
|
}
|
|
395
420
|
|
|
421
|
+
/**
|
|
422
|
+
* The referrer unless it is internal: a relative path or one of the site's
|
|
423
|
+
* own domains never names where a session came from.
|
|
424
|
+
*/
|
|
425
|
+
export function externalReferrer(
|
|
426
|
+
referrer: string | undefined,
|
|
427
|
+
domains: string[],
|
|
428
|
+
): string | undefined {
|
|
429
|
+
if (!referrer || referrer.startsWith("/") || originAllowed(referrer, domains))
|
|
430
|
+
return undefined;
|
|
431
|
+
return referrer;
|
|
432
|
+
}
|
|
433
|
+
|
|
396
434
|
export function stableHash(value: string): number {
|
|
397
435
|
let hash = 2_166_136_261;
|
|
398
436
|
for (let index = 0; index < value.length; index += 1) {
|