@iann29/rastro 0.1.0-alpha.0 → 0.1.0-alpha.2
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 +98 -18
- package/dist/component/_generated/api.d.ts +7 -1
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/constants.d.ts +11 -6
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +11 -6
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +2 -2
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/convex.config.js +4 -1
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/eventStore.d.ts +102 -0
- package/dist/component/eventStore.d.ts.map +1 -0
- package/dist/component/eventStore.js +463 -0
- package/dist/component/eventStore.js.map +1 -0
- package/dist/component/goals.d.ts.map +1 -1
- package/dist/component/goals.js +17 -4
- package/dist/component/goals.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +39 -7
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +520 -207
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts +8 -0
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +72 -8
- package/dist/component/live.js.map +1 -1
- package/dist/component/migrations.d.ts +17 -0
- package/dist/component/migrations.d.ts.map +1 -0
- package/dist/component/migrations.js +44 -0
- package/dist/component/migrations.js.map +1 -0
- package/dist/component/reports.d.ts +16 -16
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +228 -65
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +15 -10
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +11 -3
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +132 -9
- package/dist/component/schema.js +32 -7
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +27 -18
- package/dist/component/sites.js.map +1 -1
- package/dist/component/validators.d.ts +96 -0
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +12 -0
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +4 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +4 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.js +33 -22
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/benchmarks/2026-08-20-realistic.md +171 -0
- package/package.json +15 -5
- package/scripts/benchmark-ingest.mjs +499 -0
- package/src/component/_generated/api.ts +7 -1
- package/src/component/constants.ts +11 -6
- package/src/component/convex.config.ts +5 -1
- package/src/component/eventStore.ts +684 -0
- package/src/component/goals.ts +25 -4
- package/src/component/http.ts +49 -6
- package/src/component/ingest.ts +671 -215
- package/src/component/live.ts +83 -8
- package/src/component/migrations.ts +52 -0
- package/src/component/reports.ts +295 -86
- package/src/component/retention.ts +15 -12
- package/src/component/sanitize.ts +10 -3
- package/src/component/schema.ts +35 -7
- package/src/component/sites.ts +31 -18
- package/src/component/validators.ts +16 -0
- package/src/test.ts +2 -0
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
2
|
+
import type { MutationCtx, QueryCtx } from "./_generated/server.js";
|
|
3
|
+
import {
|
|
4
|
+
JOURNEY_READ_BYTE_RESERVE,
|
|
5
|
+
JOURNEY_READ_DOCUMENT_RESERVE,
|
|
6
|
+
MAX_BATCH_EVENTS,
|
|
7
|
+
MAX_EVENTS_PER_SESSION_WINDOW,
|
|
8
|
+
RATE_LIMIT_WINDOW_MS,
|
|
9
|
+
} from "./constants.js";
|
|
10
|
+
import { fail } from "./errors.js";
|
|
11
|
+
import type { TrackerEvent } from "./validators.js";
|
|
12
|
+
|
|
13
|
+
export type TelemetryMatch = {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
visitorId: string;
|
|
16
|
+
country?: string;
|
|
17
|
+
city?: string;
|
|
18
|
+
latitude?: number;
|
|
19
|
+
longitude?: number;
|
|
20
|
+
browser?: string;
|
|
21
|
+
os?: string;
|
|
22
|
+
device?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type BufferedEventBatch = {
|
|
26
|
+
siteId: Id<"sites">;
|
|
27
|
+
sessionId: string;
|
|
28
|
+
visitorId: string;
|
|
29
|
+
bucketStart: number;
|
|
30
|
+
events: TrackerEvent[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type EventBatchBuffer = Map<string, BufferedEventBatch>;
|
|
34
|
+
|
|
35
|
+
type MigrationBatch = Pick<
|
|
36
|
+
Doc<"eventBatches">,
|
|
37
|
+
"_id" | "visitorId" | "events" | "migrationVersion"
|
|
38
|
+
>;
|
|
39
|
+
|
|
40
|
+
const migrationBatchCache = new WeakMap<
|
|
41
|
+
MutationCtx,
|
|
42
|
+
Map<string, MigrationBatch[]>
|
|
43
|
+
>();
|
|
44
|
+
|
|
45
|
+
export type JourneyCursor = {
|
|
46
|
+
timestamp: number;
|
|
47
|
+
creationTime: number;
|
|
48
|
+
id: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type RawJourneyEvent = Omit<Doc<"events">, "_id"> & { _id: string };
|
|
52
|
+
|
|
53
|
+
export function eventBucketStart(timestamp: number): number {
|
|
54
|
+
return Math.floor(timestamp / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function eventBatchKey(sessionId: string, bucketStart: number): string {
|
|
58
|
+
return JSON.stringify([sessionId, bucketStart]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function inspectTelemetryMinute(
|
|
62
|
+
ctx: MutationCtx,
|
|
63
|
+
input: {
|
|
64
|
+
siteId: Id<"sites">;
|
|
65
|
+
sessionId: string;
|
|
66
|
+
bucketStart: number;
|
|
67
|
+
candidateEventIds: Set<string>;
|
|
68
|
+
},
|
|
69
|
+
) {
|
|
70
|
+
const [legacyEvents, batches] = await loadTelemetryMinute(ctx, input);
|
|
71
|
+
const eventIds = new Set([
|
|
72
|
+
...legacyEvents.map((event) => event.eventId),
|
|
73
|
+
...batches.flatMap((batch) =>
|
|
74
|
+
batch.events.map((event) => event.eventId),
|
|
75
|
+
),
|
|
76
|
+
]);
|
|
77
|
+
if (legacyEvents.length > MAX_EVENTS_PER_SESSION_WINDOW) {
|
|
78
|
+
await Promise.all(
|
|
79
|
+
[...input.candidateEventIds]
|
|
80
|
+
.filter((eventId) => !eventIds.has(eventId))
|
|
81
|
+
.map(async (eventId) => {
|
|
82
|
+
const legacy = await findLegacyTelemetry(ctx, {
|
|
83
|
+
...input,
|
|
84
|
+
eventId,
|
|
85
|
+
});
|
|
86
|
+
if (legacy) eventIds.add(eventId);
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
eventIds,
|
|
92
|
+
persistedCount: telemetryMinuteCount(legacyEvents, batches),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function findTelemetry(
|
|
97
|
+
ctx: MutationCtx,
|
|
98
|
+
siteId: Id<"sites">,
|
|
99
|
+
event: TrackerEvent,
|
|
100
|
+
): Promise<{ match: TelemetryMatch | null; saturated: boolean }> {
|
|
101
|
+
const bucketStart = eventBucketStart(event.timestamp);
|
|
102
|
+
const [legacyEvents, batches] = await loadTelemetryMinute(ctx, {
|
|
103
|
+
siteId,
|
|
104
|
+
sessionId: event.sessionId,
|
|
105
|
+
bucketStart,
|
|
106
|
+
});
|
|
107
|
+
const persistedCount = telemetryMinuteCount(legacyEvents, batches);
|
|
108
|
+
let legacyEvent = legacyEvents.find((candidate) =>
|
|
109
|
+
candidate.eventId === event.eventId
|
|
110
|
+
);
|
|
111
|
+
if (
|
|
112
|
+
!legacyEvent &&
|
|
113
|
+
legacyEvents.length > MAX_EVENTS_PER_SESSION_WINDOW
|
|
114
|
+
) {
|
|
115
|
+
legacyEvent = await findLegacyTelemetry(ctx, {
|
|
116
|
+
siteId,
|
|
117
|
+
sessionId: event.sessionId,
|
|
118
|
+
bucketStart,
|
|
119
|
+
eventId: event.eventId,
|
|
120
|
+
}) ?? undefined;
|
|
121
|
+
}
|
|
122
|
+
const saturated = persistedCount >= MAX_EVENTS_PER_SESSION_WINDOW;
|
|
123
|
+
if (legacyEvent) return { match: legacyEvent, saturated };
|
|
124
|
+
let batch: Doc<"eventBatches"> | undefined;
|
|
125
|
+
let nestedEvent: Doc<"eventBatches">["events"][number] | undefined;
|
|
126
|
+
for (const candidate of batches) {
|
|
127
|
+
nestedEvent = candidate.events.find((nested) =>
|
|
128
|
+
nested.eventId === event.eventId
|
|
129
|
+
);
|
|
130
|
+
if (nestedEvent) {
|
|
131
|
+
batch = candidate;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
match: batch && nestedEvent
|
|
137
|
+
? {
|
|
138
|
+
sessionId: batch.sessionId,
|
|
139
|
+
visitorId: batch.visitorId,
|
|
140
|
+
...eventContext(nestedEvent),
|
|
141
|
+
}
|
|
142
|
+
: null,
|
|
143
|
+
saturated,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function bufferEventBatch(
|
|
148
|
+
siteId: Id<"sites">,
|
|
149
|
+
event: TrackerEvent,
|
|
150
|
+
buffer: EventBatchBuffer,
|
|
151
|
+
) {
|
|
152
|
+
const bucketStart = eventBucketStart(event.timestamp);
|
|
153
|
+
const key = eventBatchKey(event.sessionId, bucketStart);
|
|
154
|
+
const existing = buffer.get(key);
|
|
155
|
+
if (existing) {
|
|
156
|
+
if (existing.visitorId !== event.visitorId) {
|
|
157
|
+
throw new Error("event batch visitor does not match its session");
|
|
158
|
+
}
|
|
159
|
+
existing.events.push(eventForBatch(event));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
buffer.set(key, {
|
|
163
|
+
siteId,
|
|
164
|
+
sessionId: event.sessionId,
|
|
165
|
+
visitorId: event.visitorId,
|
|
166
|
+
bucketStart,
|
|
167
|
+
events: [eventForBatch(event)],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function insertEventBatch(
|
|
172
|
+
ctx: MutationCtx,
|
|
173
|
+
siteId: Id<"sites">,
|
|
174
|
+
event: TrackerEvent,
|
|
175
|
+
) {
|
|
176
|
+
await ctx.db.insert("eventBatches", {
|
|
177
|
+
siteId,
|
|
178
|
+
sessionId: event.sessionId,
|
|
179
|
+
visitorId: event.visitorId,
|
|
180
|
+
bucketStart: eventBucketStart(event.timestamp),
|
|
181
|
+
events: [eventForBatch(event)],
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function flushEventBatches(
|
|
186
|
+
ctx: MutationCtx,
|
|
187
|
+
buffer: EventBatchBuffer,
|
|
188
|
+
) {
|
|
189
|
+
for (const batch of buffer.values()) {
|
|
190
|
+
await ctx.db.insert("eventBatches", batch);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function migrateLegacyEventToBatch(
|
|
195
|
+
ctx: MutationCtx,
|
|
196
|
+
event: Doc<"events">,
|
|
197
|
+
) {
|
|
198
|
+
const bucketStart = eventBucketStart(event.timestamp);
|
|
199
|
+
let transactionCache = migrationBatchCache.get(ctx);
|
|
200
|
+
if (!transactionCache) {
|
|
201
|
+
transactionCache = new Map();
|
|
202
|
+
migrationBatchCache.set(ctx, transactionCache);
|
|
203
|
+
}
|
|
204
|
+
const cacheKey = JSON.stringify([
|
|
205
|
+
event.siteId,
|
|
206
|
+
event.sessionId,
|
|
207
|
+
bucketStart,
|
|
208
|
+
]);
|
|
209
|
+
let batches = transactionCache.get(cacheKey);
|
|
210
|
+
if (!batches) {
|
|
211
|
+
batches = await ctx.db
|
|
212
|
+
.query("eventBatches")
|
|
213
|
+
.withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
|
|
214
|
+
range
|
|
215
|
+
.eq("siteId", event.siteId)
|
|
216
|
+
.eq("sessionId", event.sessionId)
|
|
217
|
+
.eq("bucketStart", bucketStart),
|
|
218
|
+
)
|
|
219
|
+
.order("desc")
|
|
220
|
+
.take(MAX_EVENTS_PER_SESSION_WINDOW + 1);
|
|
221
|
+
transactionCache.set(cacheKey, batches);
|
|
222
|
+
}
|
|
223
|
+
const duplicateBatch = batches.find((batch) =>
|
|
224
|
+
batch.events.some((candidate) => candidate.eventId === event.eventId)
|
|
225
|
+
);
|
|
226
|
+
const context = eventContext(event);
|
|
227
|
+
if (duplicateBatch) {
|
|
228
|
+
if (Object.keys(context).length > 0) {
|
|
229
|
+
const events = duplicateBatch.events.map((candidate) =>
|
|
230
|
+
candidate.eventId === event.eventId
|
|
231
|
+
? { ...candidate, ...context }
|
|
232
|
+
: candidate
|
|
233
|
+
);
|
|
234
|
+
await ctx.db.patch("eventBatches", duplicateBatch._id, {
|
|
235
|
+
events,
|
|
236
|
+
});
|
|
237
|
+
duplicateBatch.events = events;
|
|
238
|
+
}
|
|
239
|
+
await ctx.db.delete("events", event._id);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const migratedEvent = {
|
|
243
|
+
...eventForBatch(event),
|
|
244
|
+
...context,
|
|
245
|
+
};
|
|
246
|
+
const latestBatch = batches[0];
|
|
247
|
+
if (
|
|
248
|
+
latestBatch?.migrationVersion === 1 &&
|
|
249
|
+
latestBatch.visitorId === event.visitorId &&
|
|
250
|
+
latestBatch.events.length < MAX_BATCH_EVENTS
|
|
251
|
+
) {
|
|
252
|
+
const events = [...latestBatch.events, migratedEvent];
|
|
253
|
+
await ctx.db.patch("eventBatches", latestBatch._id, {
|
|
254
|
+
events,
|
|
255
|
+
});
|
|
256
|
+
latestBatch.events = events;
|
|
257
|
+
} else {
|
|
258
|
+
const batch = {
|
|
259
|
+
siteId: event.siteId,
|
|
260
|
+
sessionId: event.sessionId,
|
|
261
|
+
visitorId: event.visitorId,
|
|
262
|
+
bucketStart,
|
|
263
|
+
events: [migratedEvent],
|
|
264
|
+
migrationVersion: 1 as const,
|
|
265
|
+
};
|
|
266
|
+
const batchId = await ctx.db.insert("eventBatches", batch);
|
|
267
|
+
batches.unshift({ _id: batchId, ...batch });
|
|
268
|
+
}
|
|
269
|
+
await ctx.db.delete("events", event._id);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function loadSessionJourneyEvents(
|
|
273
|
+
ctx: QueryCtx,
|
|
274
|
+
input: {
|
|
275
|
+
siteId: Id<"sites">;
|
|
276
|
+
sessionId: string;
|
|
277
|
+
from: number;
|
|
278
|
+
to: number;
|
|
279
|
+
cursor: JourneyCursor | null;
|
|
280
|
+
resultLimit: number;
|
|
281
|
+
},
|
|
282
|
+
): Promise<RawJourneyEvent[]> {
|
|
283
|
+
const startTimestamp = input.cursor?.timestamp ?? input.from;
|
|
284
|
+
const firstBucket = eventBucketStart(startTimestamp);
|
|
285
|
+
const lastBucket = eventBucketStart(input.to);
|
|
286
|
+
// Nested event tiebreakers are not index fields, so the boundary minute must
|
|
287
|
+
// be read completely before applying the cursor.
|
|
288
|
+
const loadBatches = async () => {
|
|
289
|
+
const batches: Doc<"eventBatches">[] = [];
|
|
290
|
+
let eventCount = 0;
|
|
291
|
+
const requiredEvents = input.resultLimit + MAX_EVENTS_PER_SESSION_WINDOW;
|
|
292
|
+
let completedBucket: number | undefined;
|
|
293
|
+
const query = ctx.db
|
|
294
|
+
.query("eventBatches")
|
|
295
|
+
.withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
|
|
296
|
+
range
|
|
297
|
+
.eq("siteId", input.siteId)
|
|
298
|
+
.eq("sessionId", input.sessionId)
|
|
299
|
+
.gte("bucketStart", firstBucket)
|
|
300
|
+
.lte("bucketStart", lastBucket),
|
|
301
|
+
)
|
|
302
|
+
.order("asc");
|
|
303
|
+
for await (const batch of query) {
|
|
304
|
+
if (completedBucket !== undefined && batch.bucketStart !== completedBucket) {
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
batches.push(batch);
|
|
308
|
+
eventCount += batch.events.length;
|
|
309
|
+
if (eventCount >= requiredEvents) completedBucket = batch.bucketStart;
|
|
310
|
+
}
|
|
311
|
+
return batches;
|
|
312
|
+
};
|
|
313
|
+
const loadLegacyEvents = async () => {
|
|
314
|
+
const take = input.resultLimit + 1;
|
|
315
|
+
if (!input.cursor) {
|
|
316
|
+
return ctx.db
|
|
317
|
+
.query("events")
|
|
318
|
+
.withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
|
|
319
|
+
range
|
|
320
|
+
.eq("siteId", input.siteId)
|
|
321
|
+
.eq("sessionId", input.sessionId)
|
|
322
|
+
.gte("timestamp", input.from)
|
|
323
|
+
.lte("timestamp", input.to),
|
|
324
|
+
)
|
|
325
|
+
.order("asc")
|
|
326
|
+
.take(take);
|
|
327
|
+
}
|
|
328
|
+
const rows = await ctx.db
|
|
329
|
+
.query("events")
|
|
330
|
+
.withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
|
|
331
|
+
range
|
|
332
|
+
.eq("siteId", input.siteId)
|
|
333
|
+
.eq("sessionId", input.sessionId)
|
|
334
|
+
.eq("timestamp", input.cursor!.timestamp)
|
|
335
|
+
.gt("_creationTime", input.cursor!.creationTime),
|
|
336
|
+
)
|
|
337
|
+
.order("asc")
|
|
338
|
+
.take(take);
|
|
339
|
+
if (rows.length < take) {
|
|
340
|
+
rows.push(...await ctx.db
|
|
341
|
+
.query("events")
|
|
342
|
+
.withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
|
|
343
|
+
range
|
|
344
|
+
.eq("siteId", input.siteId)
|
|
345
|
+
.eq("sessionId", input.sessionId)
|
|
346
|
+
.gt("timestamp", input.cursor!.timestamp)
|
|
347
|
+
.lte("timestamp", input.to),
|
|
348
|
+
)
|
|
349
|
+
.order("asc")
|
|
350
|
+
.take(take - rows.length));
|
|
351
|
+
}
|
|
352
|
+
return rows;
|
|
353
|
+
};
|
|
354
|
+
const [legacyEvents, batches] = await Promise.all([
|
|
355
|
+
loadLegacyEvents(),
|
|
356
|
+
loadBatches(),
|
|
357
|
+
]);
|
|
358
|
+
const rows: RawJourneyEvent[] = legacyEvents.map((event) => ({
|
|
359
|
+
...event,
|
|
360
|
+
_id: String(event._id),
|
|
361
|
+
}));
|
|
362
|
+
appendBatchEvents(rows, batches);
|
|
363
|
+
return rows
|
|
364
|
+
.filter((event) =>
|
|
365
|
+
event.timestamp >= input.from &&
|
|
366
|
+
event.timestamp <= input.to &&
|
|
367
|
+
(!input.cursor || compareJourneyEventToCursor(event, input.cursor) > 0)
|
|
368
|
+
)
|
|
369
|
+
.sort(compareJourneyEvents)
|
|
370
|
+
.slice(0, input.resultLimit);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function loadVisitorJourneyEvents(
|
|
374
|
+
ctx: QueryCtx,
|
|
375
|
+
input: {
|
|
376
|
+
siteId: Id<"sites">;
|
|
377
|
+
visitorId: string;
|
|
378
|
+
from: number;
|
|
379
|
+
to: number;
|
|
380
|
+
resultLimit: number;
|
|
381
|
+
},
|
|
382
|
+
): Promise<RawJourneyEvent[]> {
|
|
383
|
+
const firstBucket = eventBucketStart(input.from);
|
|
384
|
+
const lastBucket = eventBucketStart(input.to);
|
|
385
|
+
const loadBatches = async () => {
|
|
386
|
+
const batches: Doc<"eventBatches">[] = [];
|
|
387
|
+
let qualifyingEvents = 0;
|
|
388
|
+
let completedBucket: number | undefined;
|
|
389
|
+
const query = ctx.db
|
|
390
|
+
.query("eventBatches")
|
|
391
|
+
.withIndex("by_siteId_and_visitorId_and_bucketStart", (range) =>
|
|
392
|
+
range
|
|
393
|
+
.eq("siteId", input.siteId)
|
|
394
|
+
.eq("visitorId", input.visitorId)
|
|
395
|
+
.gte("bucketStart", firstBucket)
|
|
396
|
+
.lte("bucketStart", lastBucket),
|
|
397
|
+
)
|
|
398
|
+
.order("asc");
|
|
399
|
+
for await (const batch of query) {
|
|
400
|
+
if (completedBucket !== undefined && batch.bucketStart !== completedBucket) {
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
batches.push(batch);
|
|
404
|
+
await requireJourneyReadHeadroom(ctx);
|
|
405
|
+
qualifyingEvents += batch.events.filter((event) =>
|
|
406
|
+
event.timestamp >= input.from && event.timestamp <= input.to
|
|
407
|
+
).length;
|
|
408
|
+
if (qualifyingEvents >= input.resultLimit) {
|
|
409
|
+
completedBucket = batch.bucketStart;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return batches;
|
|
413
|
+
};
|
|
414
|
+
const legacyEvents: Doc<"events">[] = [];
|
|
415
|
+
const legacyQuery = ctx.db
|
|
416
|
+
.query("events")
|
|
417
|
+
.withIndex("by_siteId_and_visitorId_and_timestamp", (range) =>
|
|
418
|
+
range
|
|
419
|
+
.eq("siteId", input.siteId)
|
|
420
|
+
.eq("visitorId", input.visitorId)
|
|
421
|
+
.gte("timestamp", input.from)
|
|
422
|
+
.lte("timestamp", input.to),
|
|
423
|
+
)
|
|
424
|
+
.order("asc");
|
|
425
|
+
for await (const event of legacyQuery) {
|
|
426
|
+
legacyEvents.push(event);
|
|
427
|
+
await requireJourneyReadHeadroom(ctx);
|
|
428
|
+
if (legacyEvents.length >= input.resultLimit) break;
|
|
429
|
+
}
|
|
430
|
+
const batches = await loadBatches();
|
|
431
|
+
const rows: RawJourneyEvent[] = legacyEvents.map((event) => ({
|
|
432
|
+
...event,
|
|
433
|
+
_id: String(event._id),
|
|
434
|
+
}));
|
|
435
|
+
appendBatchEvents(rows, batches);
|
|
436
|
+
return rows
|
|
437
|
+
.filter((event) =>
|
|
438
|
+
event.timestamp >= input.from && event.timestamp <= input.to
|
|
439
|
+
)
|
|
440
|
+
.sort(compareJourneyEvents)
|
|
441
|
+
.slice(0, input.resultLimit);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function compareJourneyEvents(
|
|
445
|
+
left: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
446
|
+
right: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
447
|
+
): number {
|
|
448
|
+
return left.timestamp - right.timestamp ||
|
|
449
|
+
left._creationTime - right._creationTime ||
|
|
450
|
+
left._id.localeCompare(right._id);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function hydrateEventContexts(
|
|
454
|
+
ctx: QueryCtx,
|
|
455
|
+
rows: RawJourneyEvent[],
|
|
456
|
+
) {
|
|
457
|
+
const sessionPromises = new Map<string, Promise<Doc<"sessions"> | null>>();
|
|
458
|
+
for (const row of rows) {
|
|
459
|
+
const key = `${row.siteId}:${row.sessionId}`;
|
|
460
|
+
if (!sessionPromises.has(key)) {
|
|
461
|
+
sessionPromises.set(
|
|
462
|
+
key,
|
|
463
|
+
ctx.db
|
|
464
|
+
.query("sessions")
|
|
465
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
466
|
+
range.eq("siteId", row.siteId).eq("sessionId", row.sessionId),
|
|
467
|
+
)
|
|
468
|
+
.unique(),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const sessions = new Map(
|
|
473
|
+
await Promise.all(
|
|
474
|
+
[...sessionPromises].map(async ([key, session]) =>
|
|
475
|
+
[key, await session] as const
|
|
476
|
+
),
|
|
477
|
+
),
|
|
478
|
+
);
|
|
479
|
+
return rows.map((row) => {
|
|
480
|
+
const session = sessions.get(`${row.siteId}:${row.sessionId}`);
|
|
481
|
+
const country = row.country ?? session?.country;
|
|
482
|
+
const city = row.city ?? session?.city;
|
|
483
|
+
const latitude = row.latitude ?? session?.latitude;
|
|
484
|
+
const longitude = row.longitude ?? session?.longitude;
|
|
485
|
+
return {
|
|
486
|
+
...row,
|
|
487
|
+
...(country ? { country } : {}),
|
|
488
|
+
...(city ? { city } : {}),
|
|
489
|
+
...(latitude !== undefined ? { latitude } : {}),
|
|
490
|
+
...(longitude !== undefined ? { longitude } : {}),
|
|
491
|
+
browser: row.browser ?? session?.browser ?? "Other",
|
|
492
|
+
os: row.os ?? session?.os ?? "Other",
|
|
493
|
+
device: row.device ?? session?.device ?? "unknown",
|
|
494
|
+
};
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export async function deleteTelemetryBefore(
|
|
499
|
+
ctx: MutationCtx,
|
|
500
|
+
input: {
|
|
501
|
+
siteId: Id<"sites">;
|
|
502
|
+
before: number;
|
|
503
|
+
batchSize: number;
|
|
504
|
+
},
|
|
505
|
+
) {
|
|
506
|
+
const batchCutoff = eventBucketStart(input.before);
|
|
507
|
+
const limit = input.batchSize + 1;
|
|
508
|
+
const [legacyEvents, batches] = await Promise.all([
|
|
509
|
+
ctx.db
|
|
510
|
+
.query("events")
|
|
511
|
+
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
512
|
+
range.eq("siteId", input.siteId).lt("timestamp", input.before),
|
|
513
|
+
)
|
|
514
|
+
.take(limit),
|
|
515
|
+
ctx.db
|
|
516
|
+
.query("eventBatches")
|
|
517
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
518
|
+
range.eq("siteId", input.siteId).lt("bucketStart", batchCutoff),
|
|
519
|
+
)
|
|
520
|
+
.take(limit),
|
|
521
|
+
]);
|
|
522
|
+
const rows = [...legacyEvents, ...batches].slice(0, input.batchSize);
|
|
523
|
+
for (const row of rows) {
|
|
524
|
+
if ("bucketStart" in row) {
|
|
525
|
+
await ctx.db.delete("eventBatches", row._id);
|
|
526
|
+
} else {
|
|
527
|
+
await ctx.db.delete("events", row._id);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
deleted: rows.length,
|
|
532
|
+
hasMore:
|
|
533
|
+
legacyEvents.length + batches.length > input.batchSize,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function loadTelemetryMinute(
|
|
538
|
+
ctx: MutationCtx,
|
|
539
|
+
input: {
|
|
540
|
+
siteId: Id<"sites">;
|
|
541
|
+
sessionId: string;
|
|
542
|
+
bucketStart: number;
|
|
543
|
+
},
|
|
544
|
+
) {
|
|
545
|
+
return Promise.all([
|
|
546
|
+
ctx.db
|
|
547
|
+
.query("events")
|
|
548
|
+
.withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
|
|
549
|
+
range
|
|
550
|
+
.eq("siteId", input.siteId)
|
|
551
|
+
.eq("sessionId", input.sessionId)
|
|
552
|
+
.gte("timestamp", input.bucketStart)
|
|
553
|
+
.lt("timestamp", input.bucketStart + RATE_LIMIT_WINDOW_MS),
|
|
554
|
+
)
|
|
555
|
+
.take(MAX_EVENTS_PER_SESSION_WINDOW + 1),
|
|
556
|
+
ctx.db
|
|
557
|
+
.query("eventBatches")
|
|
558
|
+
.withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
|
|
559
|
+
range
|
|
560
|
+
.eq("siteId", input.siteId)
|
|
561
|
+
.eq("sessionId", input.sessionId)
|
|
562
|
+
.eq("bucketStart", input.bucketStart),
|
|
563
|
+
)
|
|
564
|
+
.take(MAX_EVENTS_PER_SESSION_WINDOW + 1),
|
|
565
|
+
]);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function findLegacyTelemetry(
|
|
569
|
+
ctx: MutationCtx,
|
|
570
|
+
input: {
|
|
571
|
+
siteId: Id<"sites">;
|
|
572
|
+
sessionId: string;
|
|
573
|
+
bucketStart: number;
|
|
574
|
+
eventId: string;
|
|
575
|
+
},
|
|
576
|
+
) {
|
|
577
|
+
const candidates = await ctx.db
|
|
578
|
+
.query("events")
|
|
579
|
+
.withIndex("by_siteId_and_eventId", (range) =>
|
|
580
|
+
range.eq("siteId", input.siteId).eq("eventId", input.eventId),
|
|
581
|
+
)
|
|
582
|
+
.take(2);
|
|
583
|
+
return candidates.find((candidate) =>
|
|
584
|
+
candidate.sessionId === input.sessionId &&
|
|
585
|
+
eventBucketStart(candidate.timestamp) === input.bucketStart
|
|
586
|
+
) ?? null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function telemetryMinuteCount(
|
|
590
|
+
legacyEvents: Array<{ eventId: string }>,
|
|
591
|
+
batches: Array<{ events: TrackerEvent[] }>,
|
|
592
|
+
) {
|
|
593
|
+
return legacyEvents.length + batches.reduce(
|
|
594
|
+
(total, batch) => total + batch.events.length,
|
|
595
|
+
0,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function eventForBatch(event: TrackerEvent): TrackerEvent {
|
|
600
|
+
return {
|
|
601
|
+
eventId: event.eventId,
|
|
602
|
+
sessionId: event.sessionId,
|
|
603
|
+
visitorId: event.visitorId,
|
|
604
|
+
type: event.type,
|
|
605
|
+
...(event.name ? { name: event.name } : {}),
|
|
606
|
+
path: event.path,
|
|
607
|
+
...(event.referrer ? { referrer: event.referrer } : {}),
|
|
608
|
+
timestamp: event.timestamp,
|
|
609
|
+
sequence: event.sequence,
|
|
610
|
+
...(event.target ? { target: event.target } : {}),
|
|
611
|
+
...(event.href ? { href: event.href } : {}),
|
|
612
|
+
...(event.properties ? { properties: event.properties } : {}),
|
|
613
|
+
...(event.revenueCents !== undefined
|
|
614
|
+
? { revenueCents: event.revenueCents }
|
|
615
|
+
: {}),
|
|
616
|
+
...(event.currency ? { currency: event.currency } : {}),
|
|
617
|
+
...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function appendBatchEvents(
|
|
622
|
+
rows: RawJourneyEvent[],
|
|
623
|
+
batches: Doc<"eventBatches">[],
|
|
624
|
+
) {
|
|
625
|
+
for (const batch of batches) {
|
|
626
|
+
for (let index = 0; index < batch.events.length; index += 1) {
|
|
627
|
+
const event = batch.events[index];
|
|
628
|
+
rows.push({
|
|
629
|
+
...event,
|
|
630
|
+
_id: `eventBatch:${batch._id}:${index.toString().padStart(3, "0")}`,
|
|
631
|
+
_creationTime: batch._creationTime,
|
|
632
|
+
siteId: batch.siteId,
|
|
633
|
+
sessionId: batch.sessionId,
|
|
634
|
+
visitorId: batch.visitorId,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function eventContext(event: {
|
|
641
|
+
country?: string;
|
|
642
|
+
city?: string;
|
|
643
|
+
latitude?: number;
|
|
644
|
+
longitude?: number;
|
|
645
|
+
browser?: string;
|
|
646
|
+
os?: string;
|
|
647
|
+
device?: string;
|
|
648
|
+
}) {
|
|
649
|
+
return {
|
|
650
|
+
...(event.country ? { country: event.country } : {}),
|
|
651
|
+
...(event.city ? { city: event.city } : {}),
|
|
652
|
+
...(event.latitude !== undefined ? { latitude: event.latitude } : {}),
|
|
653
|
+
...(event.longitude !== undefined ? { longitude: event.longitude } : {}),
|
|
654
|
+
...(event.browser ? { browser: event.browser } : {}),
|
|
655
|
+
...(event.os ? { os: event.os } : {}),
|
|
656
|
+
...(event.device ? { device: event.device } : {}),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function requireJourneyReadHeadroom(ctx: QueryCtx) {
|
|
661
|
+
const metrics = await ctx.meta.getTransactionMetrics();
|
|
662
|
+
if (
|
|
663
|
+
metrics.bytesRead.remaining < JOURNEY_READ_BYTE_RESERVE ||
|
|
664
|
+
metrics.documentsRead.remaining < JOURNEY_READ_DOCUMENT_RESERVE
|
|
665
|
+
) {
|
|
666
|
+
fail(
|
|
667
|
+
"LIMIT_EXCEEDED",
|
|
668
|
+
"visitor journey exceeds the safe read budget",
|
|
669
|
+
{
|
|
670
|
+
bytesRead: metrics.bytesRead.used,
|
|
671
|
+
documentsRead: metrics.documentsRead.used,
|
|
672
|
+
},
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function compareJourneyEventToCursor(
|
|
678
|
+
event: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
679
|
+
cursor: JourneyCursor,
|
|
680
|
+
) {
|
|
681
|
+
return event.timestamp - cursor.timestamp ||
|
|
682
|
+
event._creationTime - cursor.creationTime ||
|
|
683
|
+
event._id.localeCompare(cursor.id);
|
|
684
|
+
}
|