@iann29/rastro 0.1.0-alpha.8 → 0.1.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 +274 -75
- package/agent/integration.md +223 -65
- package/agent/manifest.json +34 -13
- package/agent/manifest.schema.json +53 -11
- package/dist/client/federation.d.ts +196 -11
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +160 -6
- package/dist/client/federation.js.map +1 -1
- package/dist/client/heat.d.ts +37 -0
- package/dist/client/heat.d.ts.map +1 -0
- package/dist/client/heat.js +43 -0
- package/dist/client/heat.js.map +1 -0
- package/dist/client/index.d.ts +448 -105
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +207 -17
- 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 +53 -16
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/constants.d.ts +5 -1
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +8 -1
- package/dist/component/constants.js.map +1 -1
- package/dist/component/coverage.d.ts +9 -0
- package/dist/component/coverage.d.ts.map +1 -1
- package/dist/component/coverage.js +36 -6
- package/dist/component/coverage.js.map +1 -1
- package/dist/component/eventStore.d.ts +1 -1
- package/dist/component/geo.d.ts +2 -0
- package/dist/component/geo.d.ts.map +1 -1
- package/dist/component/geo.js +56 -19
- package/dist/component/geo.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +49 -27
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts +1 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +252 -305
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/localTime.d.ts +25 -0
- package/dist/component/localTime.d.ts.map +1 -0
- package/dist/component/localTime.js +126 -0
- package/dist/component/localTime.js.map +1 -0
- package/dist/component/reports.d.ts +46 -15
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +193 -38
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.js +26 -8
- package/dist/component/retention.js.map +1 -1
- package/dist/component/rollupStore.d.ts +320 -0
- package/dist/component/rollupStore.d.ts.map +1 -0
- package/dist/component/rollupStore.js +596 -0
- package/dist/component/rollupStore.js.map +1 -0
- package/dist/component/rollups.d.ts +20 -0
- package/dist/component/rollups.d.ts.map +1 -0
- package/dist/component/rollups.js +73 -0
- package/dist/component/rollups.js.map +1 -0
- package/dist/component/sanitize.d.ts +5 -0
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +15 -0
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +349 -15
- package/dist/component/schema.js +25 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts +12 -0
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +30 -0
- package/dist/component/sites.js.map +1 -1
- package/dist/component/validators.d.ts +41 -20
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +24 -3
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +7 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +7 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts +1 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +24 -2
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/federation-setup.md +104 -27
- package/docs/federation.md +168 -31
- package/docs/upgrading.md +191 -16
- package/llms.txt +9 -7
- package/package.json +2 -1
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +50 -12
- package/src/component/constants.ts +8 -1
- package/src/component/coverage.ts +46 -6
- package/src/component/geo.ts +82 -28
- package/src/component/http.ts +62 -26
- package/src/component/ingest.ts +378 -440
- package/src/component/localTime.ts +167 -0
- package/src/component/reports.ts +261 -39
- package/src/component/retention.ts +25 -7
- package/src/component/rollupStore.ts +799 -0
- package/src/component/rollups.ts +82 -0
- package/src/component/sanitize.ts +14 -0
- package/src/component/schema.ts +35 -0
- package/src/component/sites.ts +32 -0
- package/src/component/validators.ts +28 -1
- package/src/tracker/generated.ts +12 -0
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { internal } from "./_generated/api.js";
|
|
3
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
4
|
+
import type { MutationCtx } from "./_generated/server.js";
|
|
5
|
+
import {
|
|
6
|
+
addVisitor,
|
|
7
|
+
createVisitorSketch,
|
|
8
|
+
unionVisitorSketches,
|
|
9
|
+
type VisitorSketch,
|
|
10
|
+
} from "./cardinality.js";
|
|
11
|
+
import {
|
|
12
|
+
DAY_MS,
|
|
13
|
+
MAX_FUNNEL_STEPS,
|
|
14
|
+
MAX_SITE_MAP_ROUTES_PER_DAY,
|
|
15
|
+
REPORT_ROLLUP_SHARDS,
|
|
16
|
+
ROLLUP_FOLD_LEASE_MS,
|
|
17
|
+
ROUTE_TRANSITION_SLOTS,
|
|
18
|
+
SITE_MAP_OTHER_ROUTE,
|
|
19
|
+
VITAL_PAGE_SLOTS,
|
|
20
|
+
} from "./constants.js";
|
|
21
|
+
import { fail } from "./errors.js";
|
|
22
|
+
import { stableHash } from "./sanitize.js";
|
|
23
|
+
import { vitalMetricValidator } from "./validators.js";
|
|
24
|
+
import {
|
|
25
|
+
mergeVitalHistograms,
|
|
26
|
+
VITAL_ALL,
|
|
27
|
+
VITAL_OTHER_PAGES,
|
|
28
|
+
type VitalMetric,
|
|
29
|
+
} from "./vitals.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Report rollups (goal, funnel, affiliate, route and Web Vitals daily rows)
|
|
33
|
+
* are the hottest documents in the store: a goal on the home page or one
|
|
34
|
+
* affiliate carrying a campaign means every batch read-modify-writes the same
|
|
35
|
+
* two rows, and under concurrent load Convex's optimistic retries run out and
|
|
36
|
+
* whole batches are lost (#52). Ingestion therefore never touches a rollup
|
|
37
|
+
* row. It appends the batch's deltas to `reportRollupQueue` — an insert
|
|
38
|
+
* conflicts with nothing — and one leased fold job per site merges the queue
|
|
39
|
+
* into the rows a few hundred milliseconds later, so every rollup row has a
|
|
40
|
+
* single writer.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export type GoalRollupDelta = {
|
|
44
|
+
siteId: Id<"sites">;
|
|
45
|
+
goalId: Id<"goals">;
|
|
46
|
+
bucketStart: number;
|
|
47
|
+
shard: number;
|
|
48
|
+
goalKey: string;
|
|
49
|
+
completions: number;
|
|
50
|
+
valueCents: number;
|
|
51
|
+
};
|
|
52
|
+
export type FunnelRollupDelta = {
|
|
53
|
+
siteId: Id<"sites">;
|
|
54
|
+
funnelId: Id<"funnels">;
|
|
55
|
+
bucketStart: number;
|
|
56
|
+
shard: number;
|
|
57
|
+
steps: Map<number, { step: number; entrants: number; completions: number }>;
|
|
58
|
+
};
|
|
59
|
+
export type FunnelStepDelta = {
|
|
60
|
+
siteId: Id<"sites">;
|
|
61
|
+
funnelId: Id<"funnels">;
|
|
62
|
+
bucketStart: number;
|
|
63
|
+
step: number;
|
|
64
|
+
shard: number;
|
|
65
|
+
entrants: number;
|
|
66
|
+
completions: number;
|
|
67
|
+
};
|
|
68
|
+
export type AffiliateRollupDelta = {
|
|
69
|
+
siteId: Id<"sites">;
|
|
70
|
+
affiliateId: Id<"affiliates">;
|
|
71
|
+
bucketStart: number;
|
|
72
|
+
shard: number;
|
|
73
|
+
affiliateSlug: string;
|
|
74
|
+
visits: number;
|
|
75
|
+
visitorKeys: Set<string>;
|
|
76
|
+
conversions: number;
|
|
77
|
+
revenueCents: number;
|
|
78
|
+
commissionCents: number;
|
|
79
|
+
};
|
|
80
|
+
export type VitalRollupDelta = {
|
|
81
|
+
siteId: Id<"sites">;
|
|
82
|
+
bucketStart: number;
|
|
83
|
+
device: string;
|
|
84
|
+
page: string;
|
|
85
|
+
shard: number;
|
|
86
|
+
metrics: Map<
|
|
87
|
+
VitalMetric,
|
|
88
|
+
{ count: number; sum: number; histogram: number[] }
|
|
89
|
+
>;
|
|
90
|
+
};
|
|
91
|
+
export type RouteRollupDelta = {
|
|
92
|
+
siteId: Id<"sites">;
|
|
93
|
+
bucketStart: number;
|
|
94
|
+
shard: number;
|
|
95
|
+
route: string;
|
|
96
|
+
pageviews: number;
|
|
97
|
+
entries: number;
|
|
98
|
+
continued: number;
|
|
99
|
+
dwellMs: number;
|
|
100
|
+
dwellSamples: number;
|
|
101
|
+
visitorKeys: Set<string>;
|
|
102
|
+
outgoing: Map<string, number>;
|
|
103
|
+
};
|
|
104
|
+
export type ReportRollupDeltas = {
|
|
105
|
+
goals: Map<string, GoalRollupDelta>;
|
|
106
|
+
funnels: Map<string, FunnelRollupDelta>;
|
|
107
|
+
funnelSteps: Map<string, FunnelStepDelta>;
|
|
108
|
+
affiliates: Map<string, AffiliateRollupDelta>;
|
|
109
|
+
vitals: Map<string, VitalRollupDelta>;
|
|
110
|
+
routes: Map<string, RouteRollupDelta>;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function createReportRollupDeltas(): ReportRollupDeltas {
|
|
114
|
+
return {
|
|
115
|
+
goals: new Map(),
|
|
116
|
+
funnels: new Map(),
|
|
117
|
+
funnelSteps: new Map(),
|
|
118
|
+
affiliates: new Map(),
|
|
119
|
+
vitals: new Map(),
|
|
120
|
+
routes: new Map(),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function reportRollupCoordinates(
|
|
125
|
+
siteId: Id<"sites">,
|
|
126
|
+
definitionId: string,
|
|
127
|
+
timestamp: number,
|
|
128
|
+
visitorId: string,
|
|
129
|
+
) {
|
|
130
|
+
const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
|
|
131
|
+
const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
|
|
132
|
+
return {
|
|
133
|
+
bucketStart,
|
|
134
|
+
shard,
|
|
135
|
+
key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function countReportRollupRows(deltas: ReportRollupDeltas): number {
|
|
140
|
+
return (
|
|
141
|
+
deltas.goals.size +
|
|
142
|
+
deltas.funnels.size +
|
|
143
|
+
deltas.funnelSteps.size +
|
|
144
|
+
deltas.affiliates.size +
|
|
145
|
+
deltas.vitals.size +
|
|
146
|
+
deltas.routes.size
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// The queue document: the same deltas, serialized. Visitor identity travels as
|
|
152
|
+
// the bounded list of keys a batch saw (a batch is one browser, so usually
|
|
153
|
+
// one) and only becomes a sketch when it is folded into the row.
|
|
154
|
+
|
|
155
|
+
const queuedGoalValidator = v.object({
|
|
156
|
+
goalId: v.id("goals"),
|
|
157
|
+
bucketStart: v.number(),
|
|
158
|
+
shard: v.number(),
|
|
159
|
+
goalKey: v.string(),
|
|
160
|
+
completions: v.number(),
|
|
161
|
+
valueCents: v.number(),
|
|
162
|
+
});
|
|
163
|
+
const queuedFunnelValidator = v.object({
|
|
164
|
+
funnelId: v.id("funnels"),
|
|
165
|
+
bucketStart: v.number(),
|
|
166
|
+
shard: v.number(),
|
|
167
|
+
steps: v.array(
|
|
168
|
+
v.object({
|
|
169
|
+
step: v.number(),
|
|
170
|
+
entrants: v.number(),
|
|
171
|
+
completions: v.number(),
|
|
172
|
+
}),
|
|
173
|
+
),
|
|
174
|
+
});
|
|
175
|
+
const queuedFunnelStepValidator = v.object({
|
|
176
|
+
funnelId: v.id("funnels"),
|
|
177
|
+
bucketStart: v.number(),
|
|
178
|
+
step: v.number(),
|
|
179
|
+
shard: v.number(),
|
|
180
|
+
entrants: v.number(),
|
|
181
|
+
completions: v.number(),
|
|
182
|
+
});
|
|
183
|
+
const queuedAffiliateValidator = v.object({
|
|
184
|
+
affiliateId: v.id("affiliates"),
|
|
185
|
+
bucketStart: v.number(),
|
|
186
|
+
shard: v.number(),
|
|
187
|
+
affiliateSlug: v.string(),
|
|
188
|
+
visits: v.number(),
|
|
189
|
+
visitorKeys: v.array(v.string()),
|
|
190
|
+
conversions: v.number(),
|
|
191
|
+
revenueCents: v.number(),
|
|
192
|
+
commissionCents: v.number(),
|
|
193
|
+
});
|
|
194
|
+
const queuedRouteValidator = v.object({
|
|
195
|
+
route: v.string(),
|
|
196
|
+
bucketStart: v.number(),
|
|
197
|
+
shard: v.number(),
|
|
198
|
+
pageviews: v.number(),
|
|
199
|
+
entries: v.number(),
|
|
200
|
+
continued: v.number(),
|
|
201
|
+
dwellMs: v.number(),
|
|
202
|
+
dwellSamples: v.number(),
|
|
203
|
+
visitorKeys: v.array(v.string()),
|
|
204
|
+
outgoing: v.array(v.object({ route: v.string(), count: v.number() })),
|
|
205
|
+
});
|
|
206
|
+
const queuedVitalValidator = v.object({
|
|
207
|
+
bucketStart: v.number(),
|
|
208
|
+
device: v.string(),
|
|
209
|
+
page: v.string(),
|
|
210
|
+
shard: v.number(),
|
|
211
|
+
metrics: v.array(
|
|
212
|
+
v.object({
|
|
213
|
+
metric: vitalMetricValidator,
|
|
214
|
+
count: v.number(),
|
|
215
|
+
sum: v.number(),
|
|
216
|
+
histogram: v.array(v.number()),
|
|
217
|
+
}),
|
|
218
|
+
),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
export const reportRollupQueueFields = {
|
|
222
|
+
siteId: v.id("sites"),
|
|
223
|
+
createdAt: v.number(),
|
|
224
|
+
goals: v.array(queuedGoalValidator),
|
|
225
|
+
funnels: v.array(queuedFunnelValidator),
|
|
226
|
+
funnelSteps: v.array(queuedFunnelStepValidator),
|
|
227
|
+
affiliates: v.array(queuedAffiliateValidator),
|
|
228
|
+
routes: v.array(queuedRouteValidator),
|
|
229
|
+
vitals: v.array(queuedVitalValidator),
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export const rollupFoldStateFields = {
|
|
233
|
+
siteId: v.id("sites"),
|
|
234
|
+
leaseUntil: v.number(),
|
|
235
|
+
token: v.string(),
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
type QueuedRollups = Omit<Doc<"reportRollupQueue">, "_id" | "_creationTime">;
|
|
239
|
+
|
|
240
|
+
function serializeReportRollupDeltas(
|
|
241
|
+
siteId: Id<"sites">,
|
|
242
|
+
deltas: ReportRollupDeltas,
|
|
243
|
+
createdAt: number,
|
|
244
|
+
): QueuedRollups {
|
|
245
|
+
return {
|
|
246
|
+
siteId,
|
|
247
|
+
createdAt,
|
|
248
|
+
goals: [...deltas.goals.values()].map(
|
|
249
|
+
({ goalId, bucketStart, shard, goalKey, completions, valueCents }) => ({
|
|
250
|
+
goalId,
|
|
251
|
+
bucketStart,
|
|
252
|
+
shard,
|
|
253
|
+
goalKey,
|
|
254
|
+
completions,
|
|
255
|
+
valueCents,
|
|
256
|
+
}),
|
|
257
|
+
),
|
|
258
|
+
funnels: [...deltas.funnels.values()].map(
|
|
259
|
+
({ funnelId, bucketStart, shard, steps }) => ({
|
|
260
|
+
funnelId,
|
|
261
|
+
bucketStart,
|
|
262
|
+
shard,
|
|
263
|
+
steps: [...steps.values()].map((step) => ({ ...step })),
|
|
264
|
+
}),
|
|
265
|
+
),
|
|
266
|
+
funnelSteps: [...deltas.funnelSteps.values()].map(
|
|
267
|
+
({ funnelId, bucketStart, step, shard, entrants, completions }) => ({
|
|
268
|
+
funnelId,
|
|
269
|
+
bucketStart,
|
|
270
|
+
step,
|
|
271
|
+
shard,
|
|
272
|
+
entrants,
|
|
273
|
+
completions,
|
|
274
|
+
}),
|
|
275
|
+
),
|
|
276
|
+
affiliates: [...deltas.affiliates.values()].map(
|
|
277
|
+
({ siteId: _siteId, visitorKeys, ...rest }) => ({
|
|
278
|
+
...rest,
|
|
279
|
+
visitorKeys: [...visitorKeys],
|
|
280
|
+
}),
|
|
281
|
+
),
|
|
282
|
+
routes: [...deltas.routes.values()].map(
|
|
283
|
+
({ siteId: _siteId, visitorKeys, outgoing, ...rest }) => ({
|
|
284
|
+
...rest,
|
|
285
|
+
visitorKeys: [...visitorKeys],
|
|
286
|
+
outgoing: [...outgoing].map(([route, count]) => ({ route, count })),
|
|
287
|
+
}),
|
|
288
|
+
),
|
|
289
|
+
vitals: [...deltas.vitals.values()].map(
|
|
290
|
+
({ siteId: _siteId, metrics, ...rest }) => ({
|
|
291
|
+
...rest,
|
|
292
|
+
metrics: [...metrics].map(([metric, entry]) => ({
|
|
293
|
+
metric,
|
|
294
|
+
count: entry.count,
|
|
295
|
+
sum: entry.sum,
|
|
296
|
+
histogram: [...entry.histogram],
|
|
297
|
+
})),
|
|
298
|
+
}),
|
|
299
|
+
),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Accumulates one queued document into the deltas the fold will write. */
|
|
304
|
+
export function mergeQueuedRollups(
|
|
305
|
+
deltas: ReportRollupDeltas,
|
|
306
|
+
queued: Doc<"reportRollupQueue">,
|
|
307
|
+
) {
|
|
308
|
+
const siteId = queued.siteId;
|
|
309
|
+
for (const goal of queued.goals) {
|
|
310
|
+
const key = `${siteId}:${goal.goalId}:${goal.bucketStart}:${goal.shard}`;
|
|
311
|
+
const current = deltas.goals.get(key) ?? {
|
|
312
|
+
siteId,
|
|
313
|
+
goalId: goal.goalId,
|
|
314
|
+
bucketStart: goal.bucketStart,
|
|
315
|
+
shard: goal.shard,
|
|
316
|
+
goalKey: goal.goalKey,
|
|
317
|
+
completions: 0,
|
|
318
|
+
valueCents: 0,
|
|
319
|
+
};
|
|
320
|
+
current.completions += goal.completions;
|
|
321
|
+
current.valueCents += goal.valueCents;
|
|
322
|
+
current.goalKey = goal.goalKey;
|
|
323
|
+
deltas.goals.set(key, current);
|
|
324
|
+
}
|
|
325
|
+
for (const funnel of queued.funnels) {
|
|
326
|
+
const key = `${siteId}:${funnel.funnelId}:${funnel.bucketStart}:${funnel.shard}`;
|
|
327
|
+
const current = deltas.funnels.get(key) ?? {
|
|
328
|
+
siteId,
|
|
329
|
+
funnelId: funnel.funnelId,
|
|
330
|
+
bucketStart: funnel.bucketStart,
|
|
331
|
+
shard: funnel.shard,
|
|
332
|
+
steps: new Map(),
|
|
333
|
+
};
|
|
334
|
+
for (const step of funnel.steps) {
|
|
335
|
+
const entry = current.steps.get(step.step) ?? {
|
|
336
|
+
step: step.step,
|
|
337
|
+
entrants: 0,
|
|
338
|
+
completions: 0,
|
|
339
|
+
};
|
|
340
|
+
entry.entrants += step.entrants;
|
|
341
|
+
entry.completions += step.completions;
|
|
342
|
+
current.steps.set(step.step, entry);
|
|
343
|
+
}
|
|
344
|
+
deltas.funnels.set(key, current);
|
|
345
|
+
}
|
|
346
|
+
for (const step of queued.funnelSteps) {
|
|
347
|
+
const key = `${siteId}:${step.funnelId}:${step.bucketStart}:${step.step}:${step.shard}`;
|
|
348
|
+
const current = deltas.funnelSteps.get(key) ?? {
|
|
349
|
+
siteId,
|
|
350
|
+
funnelId: step.funnelId,
|
|
351
|
+
bucketStart: step.bucketStart,
|
|
352
|
+
step: step.step,
|
|
353
|
+
shard: step.shard,
|
|
354
|
+
entrants: 0,
|
|
355
|
+
completions: 0,
|
|
356
|
+
};
|
|
357
|
+
current.entrants += step.entrants;
|
|
358
|
+
current.completions += step.completions;
|
|
359
|
+
deltas.funnelSteps.set(key, current);
|
|
360
|
+
}
|
|
361
|
+
for (const affiliate of queued.affiliates) {
|
|
362
|
+
const key = `${siteId}:${affiliate.affiliateId}:${affiliate.bucketStart}:${affiliate.shard}`;
|
|
363
|
+
const current = deltas.affiliates.get(key) ?? {
|
|
364
|
+
siteId,
|
|
365
|
+
affiliateId: affiliate.affiliateId,
|
|
366
|
+
bucketStart: affiliate.bucketStart,
|
|
367
|
+
shard: affiliate.shard,
|
|
368
|
+
affiliateSlug: affiliate.affiliateSlug,
|
|
369
|
+
visits: 0,
|
|
370
|
+
visitorKeys: new Set<string>(),
|
|
371
|
+
conversions: 0,
|
|
372
|
+
revenueCents: 0,
|
|
373
|
+
commissionCents: 0,
|
|
374
|
+
};
|
|
375
|
+
current.affiliateSlug = affiliate.affiliateSlug;
|
|
376
|
+
current.visits += affiliate.visits;
|
|
377
|
+
current.conversions += affiliate.conversions;
|
|
378
|
+
current.revenueCents += affiliate.revenueCents;
|
|
379
|
+
current.commissionCents += affiliate.commissionCents;
|
|
380
|
+
for (const visitorKey of affiliate.visitorKeys) {
|
|
381
|
+
current.visitorKeys.add(visitorKey);
|
|
382
|
+
}
|
|
383
|
+
deltas.affiliates.set(key, current);
|
|
384
|
+
}
|
|
385
|
+
for (const route of queued.routes) {
|
|
386
|
+
const key = `${siteId}:route:${route.route}:${route.bucketStart}:${route.shard}`;
|
|
387
|
+
const current = deltas.routes.get(key) ?? {
|
|
388
|
+
siteId,
|
|
389
|
+
bucketStart: route.bucketStart,
|
|
390
|
+
shard: route.shard,
|
|
391
|
+
route: route.route,
|
|
392
|
+
pageviews: 0,
|
|
393
|
+
entries: 0,
|
|
394
|
+
continued: 0,
|
|
395
|
+
dwellMs: 0,
|
|
396
|
+
dwellSamples: 0,
|
|
397
|
+
visitorKeys: new Set<string>(),
|
|
398
|
+
outgoing: new Map<string, number>(),
|
|
399
|
+
};
|
|
400
|
+
current.pageviews += route.pageviews;
|
|
401
|
+
current.entries += route.entries;
|
|
402
|
+
current.continued += route.continued;
|
|
403
|
+
current.dwellMs += route.dwellMs;
|
|
404
|
+
current.dwellSamples += route.dwellSamples;
|
|
405
|
+
for (const visitorKey of route.visitorKeys) {
|
|
406
|
+
current.visitorKeys.add(visitorKey);
|
|
407
|
+
}
|
|
408
|
+
for (const transition of route.outgoing) {
|
|
409
|
+
current.outgoing.set(
|
|
410
|
+
transition.route,
|
|
411
|
+
(current.outgoing.get(transition.route) ?? 0) + transition.count,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
deltas.routes.set(key, current);
|
|
415
|
+
}
|
|
416
|
+
for (const vital of queued.vitals) {
|
|
417
|
+
const key = JSON.stringify([
|
|
418
|
+
siteId,
|
|
419
|
+
vital.bucketStart,
|
|
420
|
+
vital.device,
|
|
421
|
+
vital.page,
|
|
422
|
+
vital.shard,
|
|
423
|
+
]);
|
|
424
|
+
const current = deltas.vitals.get(key) ?? {
|
|
425
|
+
siteId,
|
|
426
|
+
bucketStart: vital.bucketStart,
|
|
427
|
+
device: vital.device,
|
|
428
|
+
page: vital.page,
|
|
429
|
+
shard: vital.shard,
|
|
430
|
+
metrics: new Map<
|
|
431
|
+
VitalMetric,
|
|
432
|
+
{ count: number; sum: number; histogram: number[] }
|
|
433
|
+
>(),
|
|
434
|
+
};
|
|
435
|
+
for (const metric of vital.metrics) {
|
|
436
|
+
const entry = current.metrics.get(metric.metric);
|
|
437
|
+
if (entry) {
|
|
438
|
+
entry.count += metric.count;
|
|
439
|
+
entry.sum += metric.sum;
|
|
440
|
+
entry.histogram = mergeVitalHistograms(
|
|
441
|
+
entry.histogram,
|
|
442
|
+
metric.histogram,
|
|
443
|
+
);
|
|
444
|
+
} else {
|
|
445
|
+
current.metrics.set(metric.metric, {
|
|
446
|
+
count: metric.count,
|
|
447
|
+
sum: metric.sum,
|
|
448
|
+
histogram: [...metric.histogram],
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
deltas.vitals.set(key, current);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Appends a batch's rollup deltas to the site's queue and makes sure a fold
|
|
458
|
+
* job will pick them up. The only document this reads is the site's fold
|
|
459
|
+
* state, which the fold job rewrites about twice a minute under load, so
|
|
460
|
+
* concurrent ingests almost never retry because of it.
|
|
461
|
+
*/
|
|
462
|
+
export async function enqueueReportRollups(
|
|
463
|
+
ctx: MutationCtx,
|
|
464
|
+
siteId: Id<"sites">,
|
|
465
|
+
deltas: ReportRollupDeltas,
|
|
466
|
+
now: number,
|
|
467
|
+
) {
|
|
468
|
+
if (countReportRollupRows(deltas) === 0) return;
|
|
469
|
+
await ctx.db.insert(
|
|
470
|
+
"reportRollupQueue",
|
|
471
|
+
serializeReportRollupDeltas(siteId, deltas, now),
|
|
472
|
+
);
|
|
473
|
+
await ensureRollupFold(ctx, siteId, now);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export async function ensureRollupFold(
|
|
477
|
+
ctx: MutationCtx,
|
|
478
|
+
siteId: Id<"sites">,
|
|
479
|
+
now: number,
|
|
480
|
+
) {
|
|
481
|
+
const state = await ctx.db
|
|
482
|
+
.query("rollupFoldStates")
|
|
483
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
484
|
+
.unique();
|
|
485
|
+
if (state && state.leaseUntil > now) return;
|
|
486
|
+
const token = crypto.randomUUID();
|
|
487
|
+
const leaseUntil = now + ROLLUP_FOLD_LEASE_MS;
|
|
488
|
+
if (state) {
|
|
489
|
+
await ctx.db.patch("rollupFoldStates", state._id, { leaseUntil, token });
|
|
490
|
+
} else {
|
|
491
|
+
await ctx.db.insert("rollupFoldStates", { siteId, leaseUntil, token });
|
|
492
|
+
}
|
|
493
|
+
await ctx.scheduler.runAfter(0, internal.rollups.fold, { siteId, token });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
// Writing the rows. Only the fold job calls this, one site at a time.
|
|
498
|
+
|
|
499
|
+
export async function flushReportRollups(
|
|
500
|
+
ctx: MutationCtx,
|
|
501
|
+
deltas: ReportRollupDeltas,
|
|
502
|
+
) {
|
|
503
|
+
for (const delta of deltas.goals.values()) {
|
|
504
|
+
const existing = await ctx.db
|
|
505
|
+
.query("goalDailyRollups")
|
|
506
|
+
.withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) =>
|
|
507
|
+
range
|
|
508
|
+
.eq("siteId", delta.siteId)
|
|
509
|
+
.eq("goalId", delta.goalId)
|
|
510
|
+
.eq("bucketStart", delta.bucketStart)
|
|
511
|
+
.eq("shard", delta.shard),
|
|
512
|
+
)
|
|
513
|
+
.unique();
|
|
514
|
+
const fields = {
|
|
515
|
+
siteId: delta.siteId,
|
|
516
|
+
goalId: delta.goalId,
|
|
517
|
+
bucketStart: delta.bucketStart,
|
|
518
|
+
shard: delta.shard,
|
|
519
|
+
generation: 1 as const,
|
|
520
|
+
goalKey: delta.goalKey,
|
|
521
|
+
completions: (existing?.completions ?? 0) + delta.completions,
|
|
522
|
+
valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
|
|
523
|
+
};
|
|
524
|
+
if (existing) {
|
|
525
|
+
await ctx.db.replace("goalDailyRollups", existing._id, fields);
|
|
526
|
+
} else {
|
|
527
|
+
await ctx.db.insert("goalDailyRollups", fields);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
for (const delta of deltas.funnels.values()) {
|
|
531
|
+
const existing = await ctx.db
|
|
532
|
+
.query("funnelDailyRollups")
|
|
533
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) =>
|
|
534
|
+
range
|
|
535
|
+
.eq("siteId", delta.siteId)
|
|
536
|
+
.eq("funnelId", delta.funnelId)
|
|
537
|
+
.eq("bucketStart", delta.bucketStart)
|
|
538
|
+
.eq("shard", delta.shard),
|
|
539
|
+
)
|
|
540
|
+
.unique();
|
|
541
|
+
const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
|
|
542
|
+
for (const addition of delta.steps.values()) {
|
|
543
|
+
const current = steps.find((step) => step.step === addition.step);
|
|
544
|
+
if (current) {
|
|
545
|
+
current.entrants += addition.entrants;
|
|
546
|
+
current.completions += addition.completions;
|
|
547
|
+
} else {
|
|
548
|
+
if (steps.length >= MAX_FUNNEL_STEPS) {
|
|
549
|
+
fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
|
|
550
|
+
}
|
|
551
|
+
steps.push({ ...addition });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const fields = {
|
|
555
|
+
siteId: delta.siteId,
|
|
556
|
+
funnelId: delta.funnelId,
|
|
557
|
+
bucketStart: delta.bucketStart,
|
|
558
|
+
shard: delta.shard,
|
|
559
|
+
generation: 1 as const,
|
|
560
|
+
steps,
|
|
561
|
+
};
|
|
562
|
+
if (existing) {
|
|
563
|
+
await ctx.db.replace("funnelDailyRollups", existing._id, fields);
|
|
564
|
+
} else {
|
|
565
|
+
await ctx.db.insert("funnelDailyRollups", fields);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
for (const delta of deltas.funnelSteps.values()) {
|
|
569
|
+
const bucket = await ctx.db
|
|
570
|
+
.query("funnelStepBuckets")
|
|
571
|
+
.withIndex(
|
|
572
|
+
"by_siteId_and_funnelId_and_bucketStart_and_step_and_shard",
|
|
573
|
+
(range) =>
|
|
574
|
+
range
|
|
575
|
+
.eq("siteId", delta.siteId)
|
|
576
|
+
.eq("funnelId", delta.funnelId)
|
|
577
|
+
.eq("bucketStart", delta.bucketStart)
|
|
578
|
+
.eq("step", delta.step)
|
|
579
|
+
.eq("shard", delta.shard),
|
|
580
|
+
)
|
|
581
|
+
.unique();
|
|
582
|
+
if (bucket) {
|
|
583
|
+
await ctx.db.patch("funnelStepBuckets", bucket._id, {
|
|
584
|
+
entrants: bucket.entrants + delta.entrants,
|
|
585
|
+
completions: bucket.completions + delta.completions,
|
|
586
|
+
});
|
|
587
|
+
} else {
|
|
588
|
+
await ctx.db.insert("funnelStepBuckets", {
|
|
589
|
+
siteId: delta.siteId,
|
|
590
|
+
funnelId: delta.funnelId,
|
|
591
|
+
bucketStart: delta.bucketStart,
|
|
592
|
+
step: delta.step,
|
|
593
|
+
shard: delta.shard,
|
|
594
|
+
entrants: delta.entrants,
|
|
595
|
+
completions: delta.completions,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
for (const delta of deltas.affiliates.values()) {
|
|
600
|
+
const existing = await ctx.db
|
|
601
|
+
.query("affiliateDailyRollups")
|
|
602
|
+
.withIndex(
|
|
603
|
+
"by_siteId_and_affiliateId_and_bucketStart_and_shard",
|
|
604
|
+
(range) =>
|
|
605
|
+
range
|
|
606
|
+
.eq("siteId", delta.siteId)
|
|
607
|
+
.eq("affiliateId", delta.affiliateId)
|
|
608
|
+
.eq("bucketStart", delta.bucketStart)
|
|
609
|
+
.eq("shard", delta.shard),
|
|
610
|
+
)
|
|
611
|
+
.unique();
|
|
612
|
+
const fields = {
|
|
613
|
+
siteId: delta.siteId,
|
|
614
|
+
affiliateId: delta.affiliateId,
|
|
615
|
+
bucketStart: delta.bucketStart,
|
|
616
|
+
shard: delta.shard,
|
|
617
|
+
generation: 1 as const,
|
|
618
|
+
affiliateSlug: delta.affiliateSlug,
|
|
619
|
+
visits: (existing?.visits ?? 0) + delta.visits,
|
|
620
|
+
visitorSketch: sketchWithKeys(existing?.visitorSketch, delta.visitorKeys),
|
|
621
|
+
conversions: (existing?.conversions ?? 0) + delta.conversions,
|
|
622
|
+
revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
|
|
623
|
+
commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
|
|
624
|
+
};
|
|
625
|
+
if (existing) {
|
|
626
|
+
await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
|
|
627
|
+
} else {
|
|
628
|
+
await ctx.db.insert("affiliateDailyRollups", fields);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const routeDayRowCounts = new Map<string, number>();
|
|
632
|
+
for (const delta of deltas.routes.values()) {
|
|
633
|
+
await upsertRouteRollup(ctx, delta, routeDayRowCounts);
|
|
634
|
+
}
|
|
635
|
+
for (const delta of deltas.vitals.values()) {
|
|
636
|
+
await upsertVitalRollup(ctx, delta);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function sketchWithKeys(
|
|
641
|
+
existing: VisitorSketch | undefined,
|
|
642
|
+
visitorKeys: Set<string>,
|
|
643
|
+
): VisitorSketch {
|
|
644
|
+
let sketch = createVisitorSketch();
|
|
645
|
+
for (const visitorKey of visitorKeys) {
|
|
646
|
+
sketch = addVisitor(sketch, visitorKey);
|
|
647
|
+
}
|
|
648
|
+
return existing ? unionVisitorSketches(existing, sketch) : sketch;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function upsertRouteRollup(
|
|
652
|
+
ctx: MutationCtx,
|
|
653
|
+
delta: RouteRollupDelta,
|
|
654
|
+
dayRowCounts: Map<string, number>,
|
|
655
|
+
) {
|
|
656
|
+
const existing = await ctx.db
|
|
657
|
+
.query("routeDailyRollups")
|
|
658
|
+
.withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) =>
|
|
659
|
+
range
|
|
660
|
+
.eq("siteId", delta.siteId)
|
|
661
|
+
.eq("route", delta.route)
|
|
662
|
+
.eq("bucketStart", delta.bucketStart)
|
|
663
|
+
.eq("shard", delta.shard),
|
|
664
|
+
)
|
|
665
|
+
.unique();
|
|
666
|
+
if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
|
|
667
|
+
// A new route row spends one bounded read per fold to respect the daily
|
|
668
|
+
// cap; past it, the day's remaining routes fold into "(other)".
|
|
669
|
+
const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
|
|
670
|
+
const dayKey = `${delta.siteId}:${delta.bucketStart}`;
|
|
671
|
+
let dayRows = dayRowCounts.get(dayKey);
|
|
672
|
+
if (dayRows === undefined) {
|
|
673
|
+
dayRows = (
|
|
674
|
+
await ctx.db
|
|
675
|
+
.query("routeDailyRollups")
|
|
676
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
677
|
+
range
|
|
678
|
+
.eq("siteId", delta.siteId)
|
|
679
|
+
.eq("bucketStart", delta.bucketStart),
|
|
680
|
+
)
|
|
681
|
+
.take(capRows + 1)
|
|
682
|
+
).length;
|
|
683
|
+
}
|
|
684
|
+
if (dayRows >= capRows) {
|
|
685
|
+
await upsertRouteRollup(
|
|
686
|
+
ctx,
|
|
687
|
+
{ ...delta, route: SITE_MAP_OTHER_ROUTE },
|
|
688
|
+
dayRowCounts,
|
|
689
|
+
);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
dayRowCounts.set(dayKey, dayRows + 1);
|
|
693
|
+
}
|
|
694
|
+
const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
|
|
695
|
+
for (const [target, count] of delta.outgoing) {
|
|
696
|
+
let entry = outgoing.find((candidate) => candidate.route === target);
|
|
697
|
+
if (!entry) {
|
|
698
|
+
const named = outgoing.filter(
|
|
699
|
+
(candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE,
|
|
700
|
+
).length;
|
|
701
|
+
const route =
|
|
702
|
+
target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
|
|
703
|
+
? SITE_MAP_OTHER_ROUTE
|
|
704
|
+
: target;
|
|
705
|
+
entry = outgoing.find((candidate) => candidate.route === route);
|
|
706
|
+
if (!entry) {
|
|
707
|
+
entry = { route, count: 0 };
|
|
708
|
+
outgoing.push(entry);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
entry.count += count;
|
|
712
|
+
}
|
|
713
|
+
const fields = {
|
|
714
|
+
siteId: delta.siteId,
|
|
715
|
+
bucketStart: delta.bucketStart,
|
|
716
|
+
shard: delta.shard,
|
|
717
|
+
generation: 1 as const,
|
|
718
|
+
route: delta.route,
|
|
719
|
+
pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
|
|
720
|
+
entries: (existing?.entries ?? 0) + delta.entries,
|
|
721
|
+
continued: (existing?.continued ?? 0) + delta.continued,
|
|
722
|
+
dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
|
|
723
|
+
dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
|
|
724
|
+
visitorSketch: sketchWithKeys(existing?.visitorSketch, delta.visitorKeys),
|
|
725
|
+
outgoing,
|
|
726
|
+
};
|
|
727
|
+
if (existing) {
|
|
728
|
+
await ctx.db.replace("routeDailyRollups", existing._id, fields);
|
|
729
|
+
} else {
|
|
730
|
+
await ctx.db.insert("routeDailyRollups", fields);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
|
|
735
|
+
const existing = await ctx.db
|
|
736
|
+
.query("vitalRollups")
|
|
737
|
+
.withIndex("by_key", (range) =>
|
|
738
|
+
range
|
|
739
|
+
.eq("siteId", delta.siteId)
|
|
740
|
+
.eq("bucketStart", delta.bucketStart)
|
|
741
|
+
.eq("device", delta.device)
|
|
742
|
+
.eq("page", delta.page)
|
|
743
|
+
.eq("shard", delta.shard),
|
|
744
|
+
)
|
|
745
|
+
.unique();
|
|
746
|
+
if (
|
|
747
|
+
!existing &&
|
|
748
|
+
delta.device === VITAL_ALL &&
|
|
749
|
+
delta.page !== VITAL_OTHER_PAGES
|
|
750
|
+
) {
|
|
751
|
+
// A new page row spends one bounded read to respect the daily page slots;
|
|
752
|
+
// past the cap, the day's remaining pages fold into "(other)".
|
|
753
|
+
const pageRows = await ctx.db
|
|
754
|
+
.query("vitalRollups")
|
|
755
|
+
.withIndex("by_key", (range) =>
|
|
756
|
+
range
|
|
757
|
+
.eq("siteId", delta.siteId)
|
|
758
|
+
.eq("bucketStart", delta.bucketStart)
|
|
759
|
+
.eq("device", VITAL_ALL),
|
|
760
|
+
)
|
|
761
|
+
.take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
|
|
762
|
+
if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
|
|
763
|
+
await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
|
|
768
|
+
for (const [metric, addition] of delta.metrics) {
|
|
769
|
+
const entry = metrics.find((candidate) => candidate.metric === metric);
|
|
770
|
+
if (entry) {
|
|
771
|
+
entry.count += addition.count;
|
|
772
|
+
entry.sum += addition.sum;
|
|
773
|
+
entry.histogram = mergeVitalHistograms(
|
|
774
|
+
entry.histogram,
|
|
775
|
+
addition.histogram,
|
|
776
|
+
);
|
|
777
|
+
} else {
|
|
778
|
+
metrics.push({
|
|
779
|
+
metric,
|
|
780
|
+
count: addition.count,
|
|
781
|
+
sum: addition.sum,
|
|
782
|
+
histogram: [...addition.histogram],
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const fields = {
|
|
787
|
+
siteId: delta.siteId,
|
|
788
|
+
bucketStart: delta.bucketStart,
|
|
789
|
+
device: delta.device,
|
|
790
|
+
page: delta.page,
|
|
791
|
+
shard: delta.shard,
|
|
792
|
+
metrics,
|
|
793
|
+
};
|
|
794
|
+
if (existing) {
|
|
795
|
+
await ctx.db.replace("vitalRollups", existing._id, fields);
|
|
796
|
+
} else {
|
|
797
|
+
await ctx.db.insert("vitalRollups", fields);
|
|
798
|
+
}
|
|
799
|
+
}
|