@iann29/rastro 0.1.0-alpha.1 → 0.1.0-alpha.11
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 +693 -69
- package/agent/integration.md +801 -0
- package/agent/manifest.json +205 -0
- package/agent/manifest.schema.json +444 -0
- package/dist/client/federation.d.ts +381 -0
- package/dist/client/federation.d.ts.map +1 -0
- package/dist/client/federation.js +274 -0
- package/dist/client/federation.js.map +1 -0
- package/dist/client/index.d.ts +2673 -15
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +565 -33
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +22 -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 +315 -4
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +4 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/affiliates.d.ts.map +1 -1
- package/dist/component/affiliates.js +6 -2
- package/dist/component/affiliates.js.map +1 -1
- package/dist/component/cardinality.d.ts +12 -0
- package/dist/component/cardinality.d.ts.map +1 -0
- package/dist/component/cardinality.js +94 -0
- package/dist/component/cardinality.js.map +1 -0
- package/dist/component/constants.d.ts +28 -1
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +44 -1
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +6 -1
- package/dist/component/convex.config.js +9 -1
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/coverage.d.ts +27 -0
- package/dist/component/coverage.d.ts.map +1 -0
- package/dist/component/coverage.js +56 -0
- package/dist/component/coverage.js.map +1 -0
- package/dist/component/diagnostics.d.ts +9 -0
- package/dist/component/diagnostics.d.ts.map +1 -0
- package/dist/component/diagnostics.js +47 -0
- package/dist/component/diagnostics.js.map +1 -0
- package/dist/component/errors.d.ts +1 -1
- package/dist/component/errors.d.ts.map +1 -1
- package/dist/component/errors.js.map +1 -1
- package/dist/component/eventStore.d.ts +21 -9
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/eventStore.js +142 -152
- package/dist/component/eventStore.js.map +1 -1
- package/dist/component/funnels.d.ts.map +1 -1
- package/dist/component/funnels.js +5 -3
- package/dist/component/funnels.js.map +1 -1
- package/dist/component/geo.d.ts +73 -0
- package/dist/component/geo.d.ts.map +1 -0
- package/dist/component/geo.js +648 -0
- package/dist/component/geo.js.map +1 -0
- package/dist/component/goals.d.ts.map +1 -1
- package/dist/component/goals.js +8 -5
- package/dist/component/goals.js.map +1 -1
- package/dist/component/guards.d.ts.map +1 -1
- package/dist/component/guards.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +281 -62
- package/dist/component/http.js.map +1 -1
- package/dist/component/identity.d.ts +13 -0
- package/dist/component/identity.d.ts.map +1 -0
- package/dist/component/identity.js +58 -0
- package/dist/component/identity.js.map +1 -0
- package/dist/component/ingest.d.ts +3 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +498 -95
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +7 -6
- package/dist/component/live.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 +266 -10
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +1243 -137
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts +75 -1
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +561 -38
- 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 +24 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +96 -16
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +687 -65
- package/dist/component/schema.js +187 -20
- 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 +41 -7
- package/dist/component/sites.js.map +1 -1
- package/dist/component/useragent.d.ts +9 -0
- package/dist/component/useragent.d.ts.map +1 -0
- package/dist/component/useragent.js +152 -0
- package/dist/component/useragent.js.map +1 -0
- package/dist/component/validators.d.ts +124 -69
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +35 -14
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts +19 -0
- package/dist/component/visitors.d.ts.map +1 -0
- package/dist/component/visitors.js +86 -0
- package/dist/component/visitors.js.map +1 -0
- package/dist/component/vitals.d.ts +41 -0
- package/dist/component/vitals.d.ts.map +1 -0
- package/dist/component/vitals.js +115 -0
- package/dist/component/vitals.js.map +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/tracker/generated.d.ts +11 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +11 -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 +57 -20
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker/vitals.d.ts +10 -0
- package/dist/tracker/vitals.d.ts.map +1 -0
- package/dist/tracker/vitals.js +140 -0
- package/dist/tracker/vitals.js.map +1 -0
- package/dist/tracker.min.js +1 -1
- package/dist/vitals.min.js +1 -0
- package/docs/benchmarks/2026-08-20-realistic.md +76 -76
- package/docs/benchmarks/2026-08-21-formal-certification.md +353 -0
- package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
- package/docs/federation-setup.md +464 -0
- package/docs/federation.md +352 -0
- package/docs/upgrading.md +344 -0
- package/llms.txt +72 -0
- package/package.json +55 -11
- package/scripts/benchmark-ingest.mjs +175 -73
- package/scripts/generate-federation-keys.mjs +20 -0
- package/src/component/_generated/api.ts +22 -0
- package/src/component/_generated/component.ts +381 -4
- package/src/component/_generated/server.ts +4 -0
- package/src/component/affiliates.ts +20 -5
- package/src/component/cardinality.ts +117 -0
- package/src/component/constants.ts +44 -1
- package/src/component/convex.config.ts +11 -1
- package/src/component/coverage.ts +71 -0
- package/src/component/diagnostics.ts +65 -0
- package/src/component/errors.ts +2 -1
- package/src/component/eventStore.ts +217 -193
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +835 -0
- package/src/component/goals.ts +29 -21
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +404 -70
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +894 -188
- package/src/component/live.ts +13 -7
- package/src/component/localTime.ts +167 -0
- package/src/component/reports.ts +1872 -197
- package/src/component/retention.ts +788 -96
- package/src/component/rollupStore.ts +799 -0
- package/src/component/rollups.ts +82 -0
- package/src/component/sanitize.ts +144 -29
- package/src/component/schema.ts +217 -21
- package/src/component/sites.ts +59 -12
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +49 -14
- package/src/component/visitors.ts +116 -0
- package/src/component/vitals.ts +146 -0
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
2
2
|
import type { MutationCtx, QueryCtx } from "./_generated/server.js";
|
|
3
3
|
import {
|
|
4
|
+
JOURNEY_READ_BYTE_RESERVE,
|
|
5
|
+
JOURNEY_READ_DOCUMENT_RESERVE,
|
|
4
6
|
MAX_EVENTS_PER_SESSION_WINDOW,
|
|
5
7
|
RATE_LIMIT_WINDOW_MS,
|
|
6
8
|
} from "./constants.js";
|
|
9
|
+
import { fail } from "./errors.js";
|
|
7
10
|
import type { TrackerEvent } from "./validators.js";
|
|
8
11
|
|
|
9
12
|
export type TelemetryMatch = {
|
|
@@ -34,7 +37,16 @@ export type JourneyCursor = {
|
|
|
34
37
|
id: string;
|
|
35
38
|
};
|
|
36
39
|
|
|
37
|
-
export type RawJourneyEvent = Omit<
|
|
40
|
+
export type RawJourneyEvent = Omit<
|
|
41
|
+
Doc<"eventBatches">["events"][number],
|
|
42
|
+
"aggregateCountry"
|
|
43
|
+
> & {
|
|
44
|
+
_id: string;
|
|
45
|
+
_creationTime: number;
|
|
46
|
+
siteId: Id<"sites">;
|
|
47
|
+
sessionId: string;
|
|
48
|
+
visitorId: string;
|
|
49
|
+
};
|
|
38
50
|
|
|
39
51
|
export function eventBucketStart(timestamp: number): number {
|
|
40
52
|
return Math.floor(timestamp / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
@@ -50,32 +62,15 @@ export async function inspectTelemetryMinute(
|
|
|
50
62
|
siteId: Id<"sites">;
|
|
51
63
|
sessionId: string;
|
|
52
64
|
bucketStart: number;
|
|
53
|
-
candidateEventIds: Set<string>;
|
|
54
65
|
},
|
|
55
66
|
) {
|
|
56
|
-
const
|
|
57
|
-
const eventIds = new Set(
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
}
|
|
67
|
+
const batches = await loadTelemetryMinute(ctx, input);
|
|
68
|
+
const eventIds = new Set(
|
|
69
|
+
batches.flatMap((batch) => batch.events.map((event) => event.eventId)),
|
|
70
|
+
);
|
|
76
71
|
return {
|
|
77
72
|
eventIds,
|
|
78
|
-
persistedCount: telemetryMinuteCount(
|
|
73
|
+
persistedCount: telemetryMinuteCount(batches),
|
|
79
74
|
};
|
|
80
75
|
}
|
|
81
76
|
|
|
@@ -85,35 +80,33 @@ export async function findTelemetry(
|
|
|
85
80
|
event: TrackerEvent,
|
|
86
81
|
): Promise<{ match: TelemetryMatch | null; saturated: boolean }> {
|
|
87
82
|
const bucketStart = eventBucketStart(event.timestamp);
|
|
88
|
-
const
|
|
83
|
+
const batches = await loadTelemetryMinute(ctx, {
|
|
89
84
|
siteId,
|
|
90
85
|
sessionId: event.sessionId,
|
|
91
86
|
bucketStart,
|
|
92
87
|
});
|
|
93
|
-
const persistedCount = telemetryMinuteCount(
|
|
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
|
-
}
|
|
88
|
+
const persistedCount = telemetryMinuteCount(batches);
|
|
108
89
|
const saturated = persistedCount >= MAX_EVENTS_PER_SESSION_WINDOW;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
90
|
+
let batch: Doc<"eventBatches"> | undefined;
|
|
91
|
+
let nestedEvent: Doc<"eventBatches">["events"][number] | undefined;
|
|
92
|
+
for (const candidate of batches) {
|
|
93
|
+
nestedEvent = candidate.events.find(
|
|
94
|
+
(nested) => nested.eventId === event.eventId,
|
|
95
|
+
);
|
|
96
|
+
if (nestedEvent) {
|
|
97
|
+
batch = candidate;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
113
101
|
return {
|
|
114
|
-
match:
|
|
115
|
-
|
|
116
|
-
|
|
102
|
+
match:
|
|
103
|
+
batch && nestedEvent
|
|
104
|
+
? {
|
|
105
|
+
sessionId: batch.sessionId,
|
|
106
|
+
visitorId: batch.visitorId,
|
|
107
|
+
...eventContext(nestedEvent),
|
|
108
|
+
}
|
|
109
|
+
: null,
|
|
117
110
|
saturated,
|
|
118
111
|
};
|
|
119
112
|
}
|
|
@@ -122,6 +115,7 @@ export function bufferEventBatch(
|
|
|
122
115
|
siteId: Id<"sites">,
|
|
123
116
|
event: TrackerEvent,
|
|
124
117
|
buffer: EventBatchBuffer,
|
|
118
|
+
country: string,
|
|
125
119
|
) {
|
|
126
120
|
const bucketStart = eventBucketStart(event.timestamp);
|
|
127
121
|
const key = eventBatchKey(event.sessionId, bucketStart);
|
|
@@ -130,7 +124,7 @@ export function bufferEventBatch(
|
|
|
130
124
|
if (existing.visitorId !== event.visitorId) {
|
|
131
125
|
throw new Error("event batch visitor does not match its session");
|
|
132
126
|
}
|
|
133
|
-
existing.events.push(eventForBatch(event));
|
|
127
|
+
existing.events.push(eventForBatch(event, country));
|
|
134
128
|
return;
|
|
135
129
|
}
|
|
136
130
|
buffer.set(key, {
|
|
@@ -138,7 +132,7 @@ export function bufferEventBatch(
|
|
|
138
132
|
sessionId: event.sessionId,
|
|
139
133
|
visitorId: event.visitorId,
|
|
140
134
|
bucketStart,
|
|
141
|
-
events: [eventForBatch(event)],
|
|
135
|
+
events: [eventForBatch(event, country)],
|
|
142
136
|
});
|
|
143
137
|
}
|
|
144
138
|
|
|
@@ -146,13 +140,14 @@ export async function insertEventBatch(
|
|
|
146
140
|
ctx: MutationCtx,
|
|
147
141
|
siteId: Id<"sites">,
|
|
148
142
|
event: TrackerEvent,
|
|
143
|
+
country: string,
|
|
149
144
|
) {
|
|
150
145
|
await ctx.db.insert("eventBatches", {
|
|
151
146
|
siteId,
|
|
152
147
|
sessionId: event.sessionId,
|
|
153
148
|
visitorId: event.visitorId,
|
|
154
149
|
bucketStart: eventBucketStart(event.timestamp),
|
|
155
|
-
events: [eventForBatch(event)],
|
|
150
|
+
events: [eventForBatch(event, country)],
|
|
156
151
|
});
|
|
157
152
|
}
|
|
158
153
|
|
|
@@ -183,8 +178,7 @@ export async function loadSessionJourneyEvents(
|
|
|
183
178
|
// be read completely before applying the cursor.
|
|
184
179
|
const loadBatches = async () => {
|
|
185
180
|
const batches: Doc<"eventBatches">[] = [];
|
|
186
|
-
let
|
|
187
|
-
const requiredEvents = input.resultLimit + MAX_EVENTS_PER_SESSION_WINDOW;
|
|
181
|
+
let qualifyingEvents = 0;
|
|
188
182
|
let completedBucket: number | undefined;
|
|
189
183
|
const query = ctx.db
|
|
190
184
|
.query("eventBatches")
|
|
@@ -197,82 +191,95 @@ export async function loadSessionJourneyEvents(
|
|
|
197
191
|
)
|
|
198
192
|
.order("asc");
|
|
199
193
|
for await (const batch of query) {
|
|
200
|
-
if (
|
|
194
|
+
if (
|
|
195
|
+
completedBucket !== undefined &&
|
|
196
|
+
batch.bucketStart !== completedBucket
|
|
197
|
+
) {
|
|
201
198
|
break;
|
|
202
199
|
}
|
|
203
200
|
batches.push(batch);
|
|
204
|
-
|
|
205
|
-
|
|
201
|
+
qualifyingEvents += batch.events.reduce((count, _event, index) => {
|
|
202
|
+
const event = eventFromBatch(batch, index);
|
|
203
|
+
return (
|
|
204
|
+
count +
|
|
205
|
+
Number(
|
|
206
|
+
event.timestamp >= input.from &&
|
|
207
|
+
event.timestamp <= input.to &&
|
|
208
|
+
(!input.cursor ||
|
|
209
|
+
compareJourneyEventToCursor(event, input.cursor) > 0),
|
|
210
|
+
)
|
|
211
|
+
);
|
|
212
|
+
}, 0);
|
|
213
|
+
if (qualifyingEvents >= input.resultLimit) {
|
|
214
|
+
completedBucket = batch.bucketStart;
|
|
215
|
+
}
|
|
206
216
|
}
|
|
207
217
|
return batches;
|
|
208
218
|
};
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
219
|
+
const batches = await loadBatches();
|
|
220
|
+
const rows: RawJourneyEvent[] = [];
|
|
221
|
+
appendBatchEvents(rows, batches);
|
|
222
|
+
return rows
|
|
223
|
+
.filter(
|
|
224
|
+
(event) =>
|
|
225
|
+
event.timestamp >= input.from &&
|
|
226
|
+
event.timestamp <= input.to &&
|
|
227
|
+
(!input.cursor || compareJourneyEventToCursor(event, input.cursor) > 0),
|
|
228
|
+
)
|
|
229
|
+
.sort(compareJourneyEvents)
|
|
230
|
+
.slice(0, input.resultLimit);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function loadVisitorJourneyEvents(
|
|
234
|
+
ctx: QueryCtx,
|
|
235
|
+
input: {
|
|
236
|
+
siteId: Id<"sites">;
|
|
237
|
+
visitorId: string;
|
|
238
|
+
from: number;
|
|
239
|
+
to: number;
|
|
240
|
+
resultLimit: number;
|
|
241
|
+
},
|
|
242
|
+
): Promise<RawJourneyEvent[]> {
|
|
243
|
+
const firstBucket = eventBucketStart(input.from);
|
|
244
|
+
const lastBucket = eventBucketStart(input.to);
|
|
245
|
+
const loadBatches = async () => {
|
|
246
|
+
const batches: Doc<"eventBatches">[] = [];
|
|
247
|
+
let qualifyingEvents = 0;
|
|
248
|
+
let completedBucket: number | undefined;
|
|
249
|
+
const query = ctx.db
|
|
250
|
+
.query("eventBatches")
|
|
251
|
+
.withIndex("by_siteId_and_visitorId_and_bucketStart", (range) =>
|
|
227
252
|
range
|
|
228
253
|
.eq("siteId", input.siteId)
|
|
229
|
-
.eq("
|
|
230
|
-
.
|
|
231
|
-
.
|
|
254
|
+
.eq("visitorId", input.visitorId)
|
|
255
|
+
.gte("bucketStart", firstBucket)
|
|
256
|
+
.lte("bucketStart", lastBucket),
|
|
232
257
|
)
|
|
233
|
-
.order("asc")
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
|
|
258
|
+
.order("asc");
|
|
259
|
+
for await (const batch of query) {
|
|
260
|
+
if (
|
|
261
|
+
completedBucket !== undefined &&
|
|
262
|
+
batch.bucketStart !== completedBucket
|
|
263
|
+
) {
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
batches.push(batch);
|
|
267
|
+
await requireJourneyReadHeadroom(ctx);
|
|
268
|
+
qualifyingEvents += batch.events.filter(
|
|
269
|
+
(event) => event.timestamp >= input.from && event.timestamp <= input.to,
|
|
270
|
+
).length;
|
|
271
|
+
if (qualifyingEvents >= input.resultLimit) {
|
|
272
|
+
completedBucket = batch.bucketStart;
|
|
273
|
+
}
|
|
247
274
|
}
|
|
248
|
-
return
|
|
275
|
+
return batches;
|
|
249
276
|
};
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
}
|
|
277
|
+
const batches = await loadBatches();
|
|
278
|
+
const rows: RawJourneyEvent[] = [];
|
|
279
|
+
appendBatchEvents(rows, batches);
|
|
271
280
|
return rows
|
|
272
|
-
.filter(
|
|
273
|
-
event.timestamp >= input.from &&
|
|
274
|
-
event.timestamp <= input.to &&
|
|
275
|
-
(!input.cursor || compareJourneyEventToCursor(event, input.cursor) > 0)
|
|
281
|
+
.filter(
|
|
282
|
+
(event) => event.timestamp >= input.from && event.timestamp <= input.to,
|
|
276
283
|
)
|
|
277
284
|
.sort(compareJourneyEvents)
|
|
278
285
|
.slice(0, input.resultLimit);
|
|
@@ -282,9 +289,11 @@ export function compareJourneyEvents(
|
|
|
282
289
|
left: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
283
290
|
right: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
284
291
|
): number {
|
|
285
|
-
return
|
|
292
|
+
return (
|
|
293
|
+
left.timestamp - right.timestamp ||
|
|
286
294
|
left._creationTime - right._creationTime ||
|
|
287
|
-
left._id.localeCompare(right._id)
|
|
295
|
+
left._id.localeCompare(right._id)
|
|
296
|
+
);
|
|
288
297
|
}
|
|
289
298
|
|
|
290
299
|
export async function hydrateEventContexts(
|
|
@@ -308,8 +317,8 @@ export async function hydrateEventContexts(
|
|
|
308
317
|
}
|
|
309
318
|
const sessions = new Map(
|
|
310
319
|
await Promise.all(
|
|
311
|
-
[...sessionPromises].map(
|
|
312
|
-
[key, await session] as const
|
|
320
|
+
[...sessionPromises].map(
|
|
321
|
+
async ([key, session]) => [key, await session] as const,
|
|
313
322
|
),
|
|
314
323
|
),
|
|
315
324
|
);
|
|
@@ -342,32 +351,19 @@ export async function deleteTelemetryBefore(
|
|
|
342
351
|
) {
|
|
343
352
|
const batchCutoff = eventBucketStart(input.before);
|
|
344
353
|
const limit = input.batchSize + 1;
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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);
|
|
354
|
+
const batches = await ctx.db
|
|
355
|
+
.query("eventBatches")
|
|
356
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
357
|
+
range.eq("siteId", input.siteId).lt("bucketStart", batchCutoff),
|
|
358
|
+
)
|
|
359
|
+
.take(limit);
|
|
360
|
+
const rows = batches.slice(0, input.batchSize);
|
|
360
361
|
for (const row of rows) {
|
|
361
|
-
|
|
362
|
-
await ctx.db.delete("eventBatches", row._id);
|
|
363
|
-
} else {
|
|
364
|
-
await ctx.db.delete("events", row._id);
|
|
365
|
-
}
|
|
362
|
+
await ctx.db.delete("eventBatches", row._id);
|
|
366
363
|
}
|
|
367
364
|
return {
|
|
368
365
|
deleted: rows.length,
|
|
369
|
-
hasMore:
|
|
370
|
-
legacyEvents.length + batches.length > input.batchSize,
|
|
366
|
+
hasMore: batches.length > input.batchSize,
|
|
371
367
|
};
|
|
372
368
|
}
|
|
373
369
|
|
|
@@ -379,61 +375,25 @@ async function loadTelemetryMinute(
|
|
|
379
375
|
bucketStart: number;
|
|
380
376
|
},
|
|
381
377
|
) {
|
|
382
|
-
return
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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),
|
|
378
|
+
return ctx.db
|
|
379
|
+
.query("eventBatches")
|
|
380
|
+
.withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
|
|
381
|
+
range
|
|
382
|
+
.eq("siteId", input.siteId)
|
|
383
|
+
.eq("sessionId", input.sessionId)
|
|
384
|
+
.eq("bucketStart", input.bucketStart),
|
|
418
385
|
)
|
|
419
|
-
.take(
|
|
420
|
-
return candidates.find((candidate) =>
|
|
421
|
-
candidate.sessionId === input.sessionId &&
|
|
422
|
-
eventBucketStart(candidate.timestamp) === input.bucketStart
|
|
423
|
-
) ?? null;
|
|
386
|
+
.take(MAX_EVENTS_PER_SESSION_WINDOW + 1);
|
|
424
387
|
}
|
|
425
388
|
|
|
426
|
-
function telemetryMinuteCount(
|
|
427
|
-
|
|
428
|
-
batches: Array<{ events: TrackerEvent[] }>,
|
|
429
|
-
) {
|
|
430
|
-
return legacyEvents.length + batches.reduce(
|
|
431
|
-
(total, batch) => total + batch.events.length,
|
|
432
|
-
0,
|
|
433
|
-
);
|
|
389
|
+
function telemetryMinuteCount(batches: Array<{ events: TrackerEvent[] }>) {
|
|
390
|
+
return batches.reduce((total, batch) => total + batch.events.length, 0);
|
|
434
391
|
}
|
|
435
392
|
|
|
436
|
-
function eventForBatch(
|
|
393
|
+
function eventForBatch(
|
|
394
|
+
event: TrackerEvent,
|
|
395
|
+
country: string,
|
|
396
|
+
): TrackerEvent & { aggregateCountry?: string } {
|
|
437
397
|
return {
|
|
438
398
|
eventId: event.eventId,
|
|
439
399
|
sessionId: event.sessionId,
|
|
@@ -452,14 +412,78 @@ function eventForBatch(event: TrackerEvent): TrackerEvent {
|
|
|
452
412
|
: {}),
|
|
453
413
|
...(event.currency ? { currency: event.currency } : {}),
|
|
454
414
|
...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
|
|
415
|
+
aggregateCountry: country,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function appendBatchEvents(
|
|
420
|
+
rows: RawJourneyEvent[],
|
|
421
|
+
batches: Doc<"eventBatches">[],
|
|
422
|
+
) {
|
|
423
|
+
for (const batch of batches) {
|
|
424
|
+
for (let index = 0; index < batch.events.length; index += 1) {
|
|
425
|
+
rows.push(eventFromBatch(batch, index));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function eventFromBatch(
|
|
431
|
+
batch: Doc<"eventBatches">,
|
|
432
|
+
index: number,
|
|
433
|
+
): RawJourneyEvent {
|
|
434
|
+
const { aggregateCountry: _aggregateCountry, ...event } =
|
|
435
|
+
batch.events[index]!;
|
|
436
|
+
void _aggregateCountry;
|
|
437
|
+
return {
|
|
438
|
+
...event,
|
|
439
|
+
_id: `eventBatch:${batch._id}:${index.toString().padStart(3, "0")}`,
|
|
440
|
+
_creationTime: batch._creationTime,
|
|
441
|
+
siteId: batch.siteId,
|
|
442
|
+
sessionId: batch.sessionId,
|
|
443
|
+
visitorId: batch.visitorId,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function eventContext(event: {
|
|
448
|
+
country?: string;
|
|
449
|
+
city?: string;
|
|
450
|
+
latitude?: number;
|
|
451
|
+
longitude?: number;
|
|
452
|
+
browser?: string;
|
|
453
|
+
os?: string;
|
|
454
|
+
device?: string;
|
|
455
|
+
}) {
|
|
456
|
+
return {
|
|
457
|
+
...(event.country ? { country: event.country } : {}),
|
|
458
|
+
...(event.city ? { city: event.city } : {}),
|
|
459
|
+
...(event.latitude !== undefined ? { latitude: event.latitude } : {}),
|
|
460
|
+
...(event.longitude !== undefined ? { longitude: event.longitude } : {}),
|
|
461
|
+
...(event.browser ? { browser: event.browser } : {}),
|
|
462
|
+
...(event.os ? { os: event.os } : {}),
|
|
463
|
+
...(event.device ? { device: event.device } : {}),
|
|
455
464
|
};
|
|
456
465
|
}
|
|
457
466
|
|
|
467
|
+
async function requireJourneyReadHeadroom(ctx: QueryCtx) {
|
|
468
|
+
const metrics = await ctx.meta.getTransactionMetrics();
|
|
469
|
+
if (
|
|
470
|
+
metrics.bytesRead.remaining < JOURNEY_READ_BYTE_RESERVE ||
|
|
471
|
+
metrics.documentsRead.remaining < JOURNEY_READ_DOCUMENT_RESERVE
|
|
472
|
+
) {
|
|
473
|
+
fail("LIMIT_EXCEEDED", "visitor journey exceeds the safe read budget", {
|
|
474
|
+
bytesRead: metrics.bytesRead.used,
|
|
475
|
+
documentsRead: metrics.documentsRead.used,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
458
480
|
function compareJourneyEventToCursor(
|
|
459
481
|
event: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
460
482
|
cursor: JourneyCursor,
|
|
461
483
|
) {
|
|
462
|
-
return
|
|
484
|
+
return (
|
|
485
|
+
event.timestamp - cursor.timestamp ||
|
|
463
486
|
event._creationTime - cursor.creationTime ||
|
|
464
|
-
event._id.localeCompare(cursor.id)
|
|
487
|
+
event._id.localeCompare(cursor.id)
|
|
488
|
+
);
|
|
465
489
|
}
|
package/src/component/funnels.ts
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { mutation, query } from "./_generated/server.js";
|
|
3
|
-
import {
|
|
4
|
-
MAX_FUNNELS_PER_SITE,
|
|
5
|
-
MAX_FUNNEL_STEPS,
|
|
6
|
-
} from "./constants.js";
|
|
3
|
+
import { MAX_FUNNELS_PER_SITE, MAX_FUNNEL_STEPS } from "./constants.js";
|
|
7
4
|
import { fail } from "./errors.js";
|
|
8
5
|
import { cleanString, sanitizePath, sanitizeSlug } from "./sanitize.js";
|
|
9
|
-
import {
|
|
10
|
-
funnelFieldsValidator,
|
|
11
|
-
funnelStepValidator,
|
|
12
|
-
} from "./validators.js";
|
|
6
|
+
import { funnelFieldsValidator, funnelStepValidator } from "./validators.js";
|
|
13
7
|
|
|
14
8
|
const funnelDocumentValidator = funnelFieldsValidator.extend({
|
|
15
9
|
_id: v.id("funnels"),
|
|
@@ -28,7 +22,8 @@ export const upsert = mutation({
|
|
|
28
22
|
},
|
|
29
23
|
returns: v.id("funnels"),
|
|
30
24
|
handler: async (ctx, args) => {
|
|
31
|
-
if (!(await ctx.db.get("sites", args.siteId)))
|
|
25
|
+
if (!(await ctx.db.get("sites", args.siteId)))
|
|
26
|
+
fail("NOT_FOUND", "site not found");
|
|
32
27
|
if (args.steps.length < 2 || args.steps.length > MAX_FUNNEL_STEPS) {
|
|
33
28
|
fail("INVALID_ARGUMENT", `funnels require 2-${MAX_FUNNEL_STEPS} steps`);
|
|
34
29
|
}
|
|
@@ -37,7 +32,10 @@ export const upsert = mutation({
|
|
|
37
32
|
args.conversionWindowMs < 60_000 ||
|
|
38
33
|
args.conversionWindowMs > 90 * 86_400_000
|
|
39
34
|
) {
|
|
40
|
-
fail(
|
|
35
|
+
fail(
|
|
36
|
+
"INVALID_ARGUMENT",
|
|
37
|
+
"conversionWindowMs must be between one minute and 90 days",
|
|
38
|
+
);
|
|
41
39
|
}
|
|
42
40
|
let key: string;
|
|
43
41
|
let steps: Array<{
|
|
@@ -49,9 +47,10 @@ export const upsert = mutation({
|
|
|
49
47
|
key = sanitizeSlug(args.key, "key");
|
|
50
48
|
steps = args.steps.map((step) => {
|
|
51
49
|
const stepName = cleanString(step.name, 80);
|
|
52
|
-
const value =
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
const value =
|
|
51
|
+
step.type === "pageview"
|
|
52
|
+
? sanitizePath(step.value)
|
|
53
|
+
: cleanString(step.value, 80);
|
|
55
54
|
if (!stepName || !value) {
|
|
56
55
|
throw new Error("funnel steps require name and value");
|
|
57
56
|
}
|
|
@@ -79,7 +78,8 @@ export const upsert = mutation({
|
|
|
79
78
|
const now = Date.now();
|
|
80
79
|
if (funnelId) {
|
|
81
80
|
const funnel = await ctx.db.get("funnels", funnelId);
|
|
82
|
-
if (!funnel || funnel.siteId !== args.siteId)
|
|
81
|
+
if (!funnel || funnel.siteId !== args.siteId)
|
|
82
|
+
fail("NOT_FOUND", "funnel not found");
|
|
83
83
|
await ctx.db.patch("funnels", funnelId, {
|
|
84
84
|
key,
|
|
85
85
|
name,
|
|
@@ -96,7 +96,9 @@ export const upsert = mutation({
|
|
|
96
96
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
97
97
|
.take(MAX_FUNNELS_PER_SITE + 1);
|
|
98
98
|
if (funnels.length >= MAX_FUNNELS_PER_SITE) {
|
|
99
|
-
fail("LIMIT_EXCEEDED", "funnel limit reached", {
|
|
99
|
+
fail("LIMIT_EXCEEDED", "funnel limit reached", {
|
|
100
|
+
limit: MAX_FUNNELS_PER_SITE,
|
|
101
|
+
});
|
|
100
102
|
}
|
|
101
103
|
return await ctx.db.insert("funnels", {
|
|
102
104
|
siteId: args.siteId,
|
|
@@ -127,7 +129,8 @@ export const remove = mutation({
|
|
|
127
129
|
returns: v.null(),
|
|
128
130
|
handler: async (ctx, args) => {
|
|
129
131
|
const funnel = await ctx.db.get("funnels", args.funnelId);
|
|
130
|
-
if (!funnel || funnel.siteId !== args.siteId)
|
|
132
|
+
if (!funnel || funnel.siteId !== args.siteId)
|
|
133
|
+
fail("NOT_FOUND", "funnel not found");
|
|
131
134
|
await ctx.db.delete("funnels", args.funnelId);
|
|
132
135
|
return null;
|
|
133
136
|
},
|