@iann29/rastro 0.1.0-alpha.0 → 0.1.0-alpha.1
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 +85 -18
- package/dist/component/_generated/api.d.ts +2 -0
- 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 +9 -6
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +9 -6
- package/dist/component/constants.js.map +1 -1
- package/dist/component/eventStore.d.ts +94 -0
- package/dist/component/eventStore.d.ts.map +1 -0
- package/dist/component/eventStore.js +304 -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/reports.d.ts +16 -16
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +250 -66
- 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 +101 -10
- package/dist/component/schema.js +30 -8
- 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 +49 -0
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +11 -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 +10 -4
- package/scripts/benchmark-ingest.mjs +499 -0
- package/src/component/_generated/api.ts +2 -0
- package/src/component/constants.ts +9 -6
- package/src/component/eventStore.ts +465 -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/reports.ts +320 -82
- package/src/component/retention.ts +15 -12
- package/src/component/sanitize.ts +10 -3
- package/src/component/schema.ts +33 -8
- package/src/component/sites.ts +31 -18
- package/src/component/validators.ts +12 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
2
|
+
import type { MutationCtx, QueryCtx } from "./_generated/server.js";
|
|
3
|
+
import {
|
|
4
|
+
MAX_EVENTS_PER_SESSION_WINDOW,
|
|
5
|
+
RATE_LIMIT_WINDOW_MS,
|
|
6
|
+
} from "./constants.js";
|
|
7
|
+
import type { TrackerEvent } from "./validators.js";
|
|
8
|
+
|
|
9
|
+
export type TelemetryMatch = {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
visitorId: string;
|
|
12
|
+
country?: string;
|
|
13
|
+
city?: string;
|
|
14
|
+
latitude?: number;
|
|
15
|
+
longitude?: number;
|
|
16
|
+
browser?: string;
|
|
17
|
+
os?: string;
|
|
18
|
+
device?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type BufferedEventBatch = {
|
|
22
|
+
siteId: Id<"sites">;
|
|
23
|
+
sessionId: string;
|
|
24
|
+
visitorId: string;
|
|
25
|
+
bucketStart: number;
|
|
26
|
+
events: TrackerEvent[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type EventBatchBuffer = Map<string, BufferedEventBatch>;
|
|
30
|
+
|
|
31
|
+
export type JourneyCursor = {
|
|
32
|
+
timestamp: number;
|
|
33
|
+
creationTime: number;
|
|
34
|
+
id: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type RawJourneyEvent = Omit<Doc<"events">, "_id"> & { _id: string };
|
|
38
|
+
|
|
39
|
+
export function eventBucketStart(timestamp: number): number {
|
|
40
|
+
return Math.floor(timestamp / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function eventBatchKey(sessionId: string, bucketStart: number): string {
|
|
44
|
+
return JSON.stringify([sessionId, bucketStart]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function inspectTelemetryMinute(
|
|
48
|
+
ctx: MutationCtx,
|
|
49
|
+
input: {
|
|
50
|
+
siteId: Id<"sites">;
|
|
51
|
+
sessionId: string;
|
|
52
|
+
bucketStart: number;
|
|
53
|
+
candidateEventIds: Set<string>;
|
|
54
|
+
},
|
|
55
|
+
) {
|
|
56
|
+
const [legacyEvents, batches] = await loadTelemetryMinute(ctx, input);
|
|
57
|
+
const eventIds = new Set([
|
|
58
|
+
...legacyEvents.map((event) => event.eventId),
|
|
59
|
+
...batches.flatMap((batch) =>
|
|
60
|
+
batch.events.map((event) => event.eventId),
|
|
61
|
+
),
|
|
62
|
+
]);
|
|
63
|
+
if (legacyEvents.length > MAX_EVENTS_PER_SESSION_WINDOW) {
|
|
64
|
+
await Promise.all(
|
|
65
|
+
[...input.candidateEventIds]
|
|
66
|
+
.filter((eventId) => !eventIds.has(eventId))
|
|
67
|
+
.map(async (eventId) => {
|
|
68
|
+
const legacy = await findLegacyTelemetry(ctx, {
|
|
69
|
+
...input,
|
|
70
|
+
eventId,
|
|
71
|
+
});
|
|
72
|
+
if (legacy) eventIds.add(eventId);
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
eventIds,
|
|
78
|
+
persistedCount: telemetryMinuteCount(legacyEvents, batches),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function findTelemetry(
|
|
83
|
+
ctx: MutationCtx,
|
|
84
|
+
siteId: Id<"sites">,
|
|
85
|
+
event: TrackerEvent,
|
|
86
|
+
): Promise<{ match: TelemetryMatch | null; saturated: boolean }> {
|
|
87
|
+
const bucketStart = eventBucketStart(event.timestamp);
|
|
88
|
+
const [legacyEvents, batches] = await loadTelemetryMinute(ctx, {
|
|
89
|
+
siteId,
|
|
90
|
+
sessionId: event.sessionId,
|
|
91
|
+
bucketStart,
|
|
92
|
+
});
|
|
93
|
+
const persistedCount = telemetryMinuteCount(legacyEvents, batches);
|
|
94
|
+
let legacyEvent = legacyEvents.find((candidate) =>
|
|
95
|
+
candidate.eventId === event.eventId
|
|
96
|
+
);
|
|
97
|
+
if (
|
|
98
|
+
!legacyEvent &&
|
|
99
|
+
legacyEvents.length > MAX_EVENTS_PER_SESSION_WINDOW
|
|
100
|
+
) {
|
|
101
|
+
legacyEvent = await findLegacyTelemetry(ctx, {
|
|
102
|
+
siteId,
|
|
103
|
+
sessionId: event.sessionId,
|
|
104
|
+
bucketStart,
|
|
105
|
+
eventId: event.eventId,
|
|
106
|
+
}) ?? undefined;
|
|
107
|
+
}
|
|
108
|
+
const saturated = persistedCount >= MAX_EVENTS_PER_SESSION_WINDOW;
|
|
109
|
+
if (legacyEvent) return { match: legacyEvent, saturated };
|
|
110
|
+
const batch = batches.find((candidate) =>
|
|
111
|
+
candidate.events.some((nested) => nested.eventId === event.eventId)
|
|
112
|
+
);
|
|
113
|
+
return {
|
|
114
|
+
match: batch
|
|
115
|
+
? { sessionId: batch.sessionId, visitorId: batch.visitorId }
|
|
116
|
+
: null,
|
|
117
|
+
saturated,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function bufferEventBatch(
|
|
122
|
+
siteId: Id<"sites">,
|
|
123
|
+
event: TrackerEvent,
|
|
124
|
+
buffer: EventBatchBuffer,
|
|
125
|
+
) {
|
|
126
|
+
const bucketStart = eventBucketStart(event.timestamp);
|
|
127
|
+
const key = eventBatchKey(event.sessionId, bucketStart);
|
|
128
|
+
const existing = buffer.get(key);
|
|
129
|
+
if (existing) {
|
|
130
|
+
if (existing.visitorId !== event.visitorId) {
|
|
131
|
+
throw new Error("event batch visitor does not match its session");
|
|
132
|
+
}
|
|
133
|
+
existing.events.push(eventForBatch(event));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
buffer.set(key, {
|
|
137
|
+
siteId,
|
|
138
|
+
sessionId: event.sessionId,
|
|
139
|
+
visitorId: event.visitorId,
|
|
140
|
+
bucketStart,
|
|
141
|
+
events: [eventForBatch(event)],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function insertEventBatch(
|
|
146
|
+
ctx: MutationCtx,
|
|
147
|
+
siteId: Id<"sites">,
|
|
148
|
+
event: TrackerEvent,
|
|
149
|
+
) {
|
|
150
|
+
await ctx.db.insert("eventBatches", {
|
|
151
|
+
siteId,
|
|
152
|
+
sessionId: event.sessionId,
|
|
153
|
+
visitorId: event.visitorId,
|
|
154
|
+
bucketStart: eventBucketStart(event.timestamp),
|
|
155
|
+
events: [eventForBatch(event)],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function flushEventBatches(
|
|
160
|
+
ctx: MutationCtx,
|
|
161
|
+
buffer: EventBatchBuffer,
|
|
162
|
+
) {
|
|
163
|
+
for (const batch of buffer.values()) {
|
|
164
|
+
await ctx.db.insert("eventBatches", batch);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function loadSessionJourneyEvents(
|
|
169
|
+
ctx: QueryCtx,
|
|
170
|
+
input: {
|
|
171
|
+
siteId: Id<"sites">;
|
|
172
|
+
sessionId: string;
|
|
173
|
+
from: number;
|
|
174
|
+
to: number;
|
|
175
|
+
cursor: JourneyCursor | null;
|
|
176
|
+
resultLimit: number;
|
|
177
|
+
},
|
|
178
|
+
): Promise<RawJourneyEvent[]> {
|
|
179
|
+
const startTimestamp = input.cursor?.timestamp ?? input.from;
|
|
180
|
+
const firstBucket = eventBucketStart(startTimestamp);
|
|
181
|
+
const lastBucket = eventBucketStart(input.to);
|
|
182
|
+
// Nested event tiebreakers are not index fields, so the boundary minute must
|
|
183
|
+
// be read completely before applying the cursor.
|
|
184
|
+
const loadBatches = async () => {
|
|
185
|
+
const batches: Doc<"eventBatches">[] = [];
|
|
186
|
+
let eventCount = 0;
|
|
187
|
+
const requiredEvents = input.resultLimit + MAX_EVENTS_PER_SESSION_WINDOW;
|
|
188
|
+
let completedBucket: number | undefined;
|
|
189
|
+
const query = ctx.db
|
|
190
|
+
.query("eventBatches")
|
|
191
|
+
.withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
|
|
192
|
+
range
|
|
193
|
+
.eq("siteId", input.siteId)
|
|
194
|
+
.eq("sessionId", input.sessionId)
|
|
195
|
+
.gte("bucketStart", firstBucket)
|
|
196
|
+
.lte("bucketStart", lastBucket),
|
|
197
|
+
)
|
|
198
|
+
.order("asc");
|
|
199
|
+
for await (const batch of query) {
|
|
200
|
+
if (completedBucket !== undefined && batch.bucketStart !== completedBucket) {
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
batches.push(batch);
|
|
204
|
+
eventCount += batch.events.length;
|
|
205
|
+
if (eventCount >= requiredEvents) completedBucket = batch.bucketStart;
|
|
206
|
+
}
|
|
207
|
+
return batches;
|
|
208
|
+
};
|
|
209
|
+
const loadLegacyEvents = async () => {
|
|
210
|
+
const take = input.resultLimit + 1;
|
|
211
|
+
if (!input.cursor) {
|
|
212
|
+
return ctx.db
|
|
213
|
+
.query("events")
|
|
214
|
+
.withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
|
|
215
|
+
range
|
|
216
|
+
.eq("siteId", input.siteId)
|
|
217
|
+
.eq("sessionId", input.sessionId)
|
|
218
|
+
.gte("timestamp", input.from)
|
|
219
|
+
.lte("timestamp", input.to),
|
|
220
|
+
)
|
|
221
|
+
.order("asc")
|
|
222
|
+
.take(take);
|
|
223
|
+
}
|
|
224
|
+
const rows = await ctx.db
|
|
225
|
+
.query("events")
|
|
226
|
+
.withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
|
|
227
|
+
range
|
|
228
|
+
.eq("siteId", input.siteId)
|
|
229
|
+
.eq("sessionId", input.sessionId)
|
|
230
|
+
.eq("timestamp", input.cursor!.timestamp)
|
|
231
|
+
.gt("_creationTime", input.cursor!.creationTime),
|
|
232
|
+
)
|
|
233
|
+
.order("asc")
|
|
234
|
+
.take(take);
|
|
235
|
+
if (rows.length < take) {
|
|
236
|
+
rows.push(...await ctx.db
|
|
237
|
+
.query("events")
|
|
238
|
+
.withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
|
|
239
|
+
range
|
|
240
|
+
.eq("siteId", input.siteId)
|
|
241
|
+
.eq("sessionId", input.sessionId)
|
|
242
|
+
.gt("timestamp", input.cursor!.timestamp)
|
|
243
|
+
.lte("timestamp", input.to),
|
|
244
|
+
)
|
|
245
|
+
.order("asc")
|
|
246
|
+
.take(take - rows.length));
|
|
247
|
+
}
|
|
248
|
+
return rows;
|
|
249
|
+
};
|
|
250
|
+
const [legacyEvents, batches] = await Promise.all([
|
|
251
|
+
loadLegacyEvents(),
|
|
252
|
+
loadBatches(),
|
|
253
|
+
]);
|
|
254
|
+
const rows: RawJourneyEvent[] = legacyEvents.map((event) => ({
|
|
255
|
+
...event,
|
|
256
|
+
_id: String(event._id),
|
|
257
|
+
}));
|
|
258
|
+
for (const batch of batches) {
|
|
259
|
+
for (let index = 0; index < batch.events.length; index += 1) {
|
|
260
|
+
const event = batch.events[index];
|
|
261
|
+
rows.push({
|
|
262
|
+
...event,
|
|
263
|
+
_id: `eventBatch:${batch._id}:${index.toString().padStart(3, "0")}`,
|
|
264
|
+
_creationTime: batch._creationTime,
|
|
265
|
+
siteId: batch.siteId,
|
|
266
|
+
sessionId: batch.sessionId,
|
|
267
|
+
visitorId: batch.visitorId,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return rows
|
|
272
|
+
.filter((event) =>
|
|
273
|
+
event.timestamp >= input.from &&
|
|
274
|
+
event.timestamp <= input.to &&
|
|
275
|
+
(!input.cursor || compareJourneyEventToCursor(event, input.cursor) > 0)
|
|
276
|
+
)
|
|
277
|
+
.sort(compareJourneyEvents)
|
|
278
|
+
.slice(0, input.resultLimit);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function compareJourneyEvents(
|
|
282
|
+
left: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
283
|
+
right: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
284
|
+
): number {
|
|
285
|
+
return left.timestamp - right.timestamp ||
|
|
286
|
+
left._creationTime - right._creationTime ||
|
|
287
|
+
left._id.localeCompare(right._id);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function hydrateEventContexts(
|
|
291
|
+
ctx: QueryCtx,
|
|
292
|
+
rows: RawJourneyEvent[],
|
|
293
|
+
) {
|
|
294
|
+
const sessionPromises = new Map<string, Promise<Doc<"sessions"> | null>>();
|
|
295
|
+
for (const row of rows) {
|
|
296
|
+
const key = `${row.siteId}:${row.sessionId}`;
|
|
297
|
+
if (!sessionPromises.has(key)) {
|
|
298
|
+
sessionPromises.set(
|
|
299
|
+
key,
|
|
300
|
+
ctx.db
|
|
301
|
+
.query("sessions")
|
|
302
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
303
|
+
range.eq("siteId", row.siteId).eq("sessionId", row.sessionId),
|
|
304
|
+
)
|
|
305
|
+
.unique(),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const sessions = new Map(
|
|
310
|
+
await Promise.all(
|
|
311
|
+
[...sessionPromises].map(async ([key, session]) =>
|
|
312
|
+
[key, await session] as const
|
|
313
|
+
),
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
return rows.map((row) => {
|
|
317
|
+
const session = sessions.get(`${row.siteId}:${row.sessionId}`);
|
|
318
|
+
const country = row.country ?? session?.country;
|
|
319
|
+
const city = row.city ?? session?.city;
|
|
320
|
+
const latitude = row.latitude ?? session?.latitude;
|
|
321
|
+
const longitude = row.longitude ?? session?.longitude;
|
|
322
|
+
return {
|
|
323
|
+
...row,
|
|
324
|
+
...(country ? { country } : {}),
|
|
325
|
+
...(city ? { city } : {}),
|
|
326
|
+
...(latitude !== undefined ? { latitude } : {}),
|
|
327
|
+
...(longitude !== undefined ? { longitude } : {}),
|
|
328
|
+
browser: row.browser ?? session?.browser ?? "Other",
|
|
329
|
+
os: row.os ?? session?.os ?? "Other",
|
|
330
|
+
device: row.device ?? session?.device ?? "unknown",
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function deleteTelemetryBefore(
|
|
336
|
+
ctx: MutationCtx,
|
|
337
|
+
input: {
|
|
338
|
+
siteId: Id<"sites">;
|
|
339
|
+
before: number;
|
|
340
|
+
batchSize: number;
|
|
341
|
+
},
|
|
342
|
+
) {
|
|
343
|
+
const batchCutoff = eventBucketStart(input.before);
|
|
344
|
+
const limit = input.batchSize + 1;
|
|
345
|
+
const [legacyEvents, batches] = await Promise.all([
|
|
346
|
+
ctx.db
|
|
347
|
+
.query("events")
|
|
348
|
+
.withIndex("by_siteId_and_timestamp", (range) =>
|
|
349
|
+
range.eq("siteId", input.siteId).lt("timestamp", input.before),
|
|
350
|
+
)
|
|
351
|
+
.take(limit),
|
|
352
|
+
ctx.db
|
|
353
|
+
.query("eventBatches")
|
|
354
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
355
|
+
range.eq("siteId", input.siteId).lt("bucketStart", batchCutoff),
|
|
356
|
+
)
|
|
357
|
+
.take(limit),
|
|
358
|
+
]);
|
|
359
|
+
const rows = [...legacyEvents, ...batches].slice(0, input.batchSize);
|
|
360
|
+
for (const row of rows) {
|
|
361
|
+
if ("bucketStart" in row) {
|
|
362
|
+
await ctx.db.delete("eventBatches", row._id);
|
|
363
|
+
} else {
|
|
364
|
+
await ctx.db.delete("events", row._id);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
deleted: rows.length,
|
|
369
|
+
hasMore:
|
|
370
|
+
legacyEvents.length + batches.length > input.batchSize,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function loadTelemetryMinute(
|
|
375
|
+
ctx: MutationCtx,
|
|
376
|
+
input: {
|
|
377
|
+
siteId: Id<"sites">;
|
|
378
|
+
sessionId: string;
|
|
379
|
+
bucketStart: number;
|
|
380
|
+
},
|
|
381
|
+
) {
|
|
382
|
+
return Promise.all([
|
|
383
|
+
ctx.db
|
|
384
|
+
.query("events")
|
|
385
|
+
.withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
|
|
386
|
+
range
|
|
387
|
+
.eq("siteId", input.siteId)
|
|
388
|
+
.eq("sessionId", input.sessionId)
|
|
389
|
+
.gte("timestamp", input.bucketStart)
|
|
390
|
+
.lt("timestamp", input.bucketStart + RATE_LIMIT_WINDOW_MS),
|
|
391
|
+
)
|
|
392
|
+
.take(MAX_EVENTS_PER_SESSION_WINDOW + 1),
|
|
393
|
+
ctx.db
|
|
394
|
+
.query("eventBatches")
|
|
395
|
+
.withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
|
|
396
|
+
range
|
|
397
|
+
.eq("siteId", input.siteId)
|
|
398
|
+
.eq("sessionId", input.sessionId)
|
|
399
|
+
.eq("bucketStart", input.bucketStart),
|
|
400
|
+
)
|
|
401
|
+
.take(MAX_EVENTS_PER_SESSION_WINDOW + 1),
|
|
402
|
+
]);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function findLegacyTelemetry(
|
|
406
|
+
ctx: MutationCtx,
|
|
407
|
+
input: {
|
|
408
|
+
siteId: Id<"sites">;
|
|
409
|
+
sessionId: string;
|
|
410
|
+
bucketStart: number;
|
|
411
|
+
eventId: string;
|
|
412
|
+
},
|
|
413
|
+
) {
|
|
414
|
+
const candidates = await ctx.db
|
|
415
|
+
.query("events")
|
|
416
|
+
.withIndex("by_siteId_and_eventId", (range) =>
|
|
417
|
+
range.eq("siteId", input.siteId).eq("eventId", input.eventId),
|
|
418
|
+
)
|
|
419
|
+
.take(2);
|
|
420
|
+
return candidates.find((candidate) =>
|
|
421
|
+
candidate.sessionId === input.sessionId &&
|
|
422
|
+
eventBucketStart(candidate.timestamp) === input.bucketStart
|
|
423
|
+
) ?? null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function telemetryMinuteCount(
|
|
427
|
+
legacyEvents: Array<{ eventId: string }>,
|
|
428
|
+
batches: Array<{ events: TrackerEvent[] }>,
|
|
429
|
+
) {
|
|
430
|
+
return legacyEvents.length + batches.reduce(
|
|
431
|
+
(total, batch) => total + batch.events.length,
|
|
432
|
+
0,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function eventForBatch(event: TrackerEvent): TrackerEvent {
|
|
437
|
+
return {
|
|
438
|
+
eventId: event.eventId,
|
|
439
|
+
sessionId: event.sessionId,
|
|
440
|
+
visitorId: event.visitorId,
|
|
441
|
+
type: event.type,
|
|
442
|
+
...(event.name ? { name: event.name } : {}),
|
|
443
|
+
path: event.path,
|
|
444
|
+
...(event.referrer ? { referrer: event.referrer } : {}),
|
|
445
|
+
timestamp: event.timestamp,
|
|
446
|
+
sequence: event.sequence,
|
|
447
|
+
...(event.target ? { target: event.target } : {}),
|
|
448
|
+
...(event.href ? { href: event.href } : {}),
|
|
449
|
+
...(event.properties ? { properties: event.properties } : {}),
|
|
450
|
+
...(event.revenueCents !== undefined
|
|
451
|
+
? { revenueCents: event.revenueCents }
|
|
452
|
+
: {}),
|
|
453
|
+
...(event.currency ? { currency: event.currency } : {}),
|
|
454
|
+
...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function compareJourneyEventToCursor(
|
|
459
|
+
event: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
460
|
+
cursor: JourneyCursor,
|
|
461
|
+
) {
|
|
462
|
+
return event.timestamp - cursor.timestamp ||
|
|
463
|
+
event._creationTime - cursor.creationTime ||
|
|
464
|
+
event._id.localeCompare(cursor.id);
|
|
465
|
+
}
|
package/src/component/goals.ts
CHANGED
|
@@ -62,10 +62,25 @@ export const upsert = mutation({
|
|
|
62
62
|
if (args.goalId && existingByKey && existingByKey._id !== args.goalId) {
|
|
63
63
|
fail("CONFLICT", "another goal already uses this key");
|
|
64
64
|
}
|
|
65
|
+
const goals = await ctx.db
|
|
66
|
+
.query("goals")
|
|
67
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
68
|
+
.take(MAX_GOALS_PER_SITE + 1);
|
|
65
69
|
const now = Date.now();
|
|
66
70
|
if (goalId) {
|
|
67
71
|
const goal = await ctx.db.get("goals", goalId);
|
|
68
72
|
if (!goal || goal.siteId !== args.siteId) fail("NOT_FOUND", "goal not found");
|
|
73
|
+
if (
|
|
74
|
+
(args.active ?? goal.active) &&
|
|
75
|
+
goals.some((candidate) =>
|
|
76
|
+
candidate._id !== goalId &&
|
|
77
|
+
candidate.active &&
|
|
78
|
+
candidate.matchType === args.matchType &&
|
|
79
|
+
candidate.matchValue === matchValue
|
|
80
|
+
)
|
|
81
|
+
) {
|
|
82
|
+
fail("CONFLICT", "another active goal already uses this matcher");
|
|
83
|
+
}
|
|
69
84
|
await ctx.db.patch("goals", goalId, {
|
|
70
85
|
key,
|
|
71
86
|
name,
|
|
@@ -78,10 +93,16 @@ export const upsert = mutation({
|
|
|
78
93
|
return goalId;
|
|
79
94
|
}
|
|
80
95
|
|
|
81
|
-
|
|
82
|
-
.
|
|
83
|
-
.
|
|
84
|
-
|
|
96
|
+
if (
|
|
97
|
+
(args.active ?? true) &&
|
|
98
|
+
goals.some((candidate) =>
|
|
99
|
+
candidate.active &&
|
|
100
|
+
candidate.matchType === args.matchType &&
|
|
101
|
+
candidate.matchValue === matchValue
|
|
102
|
+
)
|
|
103
|
+
) {
|
|
104
|
+
fail("CONFLICT", "another active goal already uses this matcher");
|
|
105
|
+
}
|
|
85
106
|
if (goals.length >= MAX_GOALS_PER_SITE) {
|
|
86
107
|
fail("LIMIT_EXCEEDED", "goal limit reached", { limit: MAX_GOALS_PER_SITE });
|
|
87
108
|
}
|
package/src/component/http.ts
CHANGED
|
@@ -24,16 +24,29 @@ http.route({
|
|
|
24
24
|
handler: httpAction(async (ctx, request) => {
|
|
25
25
|
const origin = request.headers.get("origin");
|
|
26
26
|
if (!origin) return json({ error: { code: "ORIGIN_REQUIRED" } }, 400);
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
const headers = corsHeaders(origin);
|
|
28
|
+
const contentLength = Number(request.headers.get("content-length"));
|
|
29
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_BATCH_BYTES) {
|
|
30
|
+
return json(
|
|
31
|
+
{ error: { code: "BATCH_TOO_LARGE", limitBytes: MAX_BATCH_BYTES } },
|
|
32
|
+
413,
|
|
33
|
+
headers,
|
|
34
|
+
);
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
let payload: { siteId: string; events: TrackerEvent[] };
|
|
33
38
|
try {
|
|
39
|
+
const bytes = await readLimitedBody(request, MAX_BATCH_BYTES);
|
|
34
40
|
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
|
35
41
|
payload = parsePayload(parsed);
|
|
36
42
|
} catch (error) {
|
|
43
|
+
if (error instanceof PayloadTooLargeError) {
|
|
44
|
+
return json(
|
|
45
|
+
{ error: { code: "BATCH_TOO_LARGE", limitBytes: MAX_BATCH_BYTES } },
|
|
46
|
+
413,
|
|
47
|
+
headers,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
37
50
|
return json(
|
|
38
51
|
{
|
|
39
52
|
error: {
|
|
@@ -42,6 +55,7 @@ http.route({
|
|
|
42
55
|
},
|
|
43
56
|
},
|
|
44
57
|
400,
|
|
58
|
+
headers,
|
|
45
59
|
);
|
|
46
60
|
}
|
|
47
61
|
|
|
@@ -61,7 +75,7 @@ http.route({
|
|
|
61
75
|
events: payload.events,
|
|
62
76
|
context,
|
|
63
77
|
});
|
|
64
|
-
return json(result, 200,
|
|
78
|
+
return json(result, 200, headers);
|
|
65
79
|
} catch (error) {
|
|
66
80
|
if (error instanceof ConvexError) {
|
|
67
81
|
const data = error.data as { code?: string; message?: string };
|
|
@@ -71,9 +85,9 @@ http.route({
|
|
|
71
85
|
: data.code === "RATE_LIMITED"
|
|
72
86
|
? 429
|
|
73
87
|
: 400;
|
|
74
|
-
return json({ error: data }, status);
|
|
88
|
+
return json({ error: data }, status, headers);
|
|
75
89
|
}
|
|
76
|
-
return json({ error: { code: "INVALID_REQUEST" } }, 400);
|
|
90
|
+
return json({ error: { code: "INVALID_REQUEST" } }, 400, headers);
|
|
77
91
|
}
|
|
78
92
|
}),
|
|
79
93
|
});
|
|
@@ -136,6 +150,35 @@ function parsePayload(value: unknown): { siteId: string; events: TrackerEvent[]
|
|
|
136
150
|
return { siteId: value.siteId, events: value.events.map(parseEvent) };
|
|
137
151
|
}
|
|
138
152
|
|
|
153
|
+
class PayloadTooLargeError extends Error {}
|
|
154
|
+
|
|
155
|
+
async function readLimitedBody(
|
|
156
|
+
request: Request,
|
|
157
|
+
maximumBytes: number,
|
|
158
|
+
): Promise<Uint8Array> {
|
|
159
|
+
if (!request.body) return new Uint8Array();
|
|
160
|
+
const reader = request.body.getReader();
|
|
161
|
+
const chunks: Uint8Array[] = [];
|
|
162
|
+
let length = 0;
|
|
163
|
+
while (true) {
|
|
164
|
+
const { done, value } = await reader.read();
|
|
165
|
+
if (done) break;
|
|
166
|
+
length += value.byteLength;
|
|
167
|
+
if (length > maximumBytes) {
|
|
168
|
+
await reader.cancel();
|
|
169
|
+
throw new PayloadTooLargeError();
|
|
170
|
+
}
|
|
171
|
+
chunks.push(value);
|
|
172
|
+
}
|
|
173
|
+
const result = new Uint8Array(length);
|
|
174
|
+
let offset = 0;
|
|
175
|
+
for (const chunk of chunks) {
|
|
176
|
+
result.set(chunk, offset);
|
|
177
|
+
offset += chunk.byteLength;
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
139
182
|
function parseEvent(value: unknown): TrackerEvent {
|
|
140
183
|
if (!isPlainRecord(value)) throw new Error("each event must be an object");
|
|
141
184
|
const type = value.type;
|