@iann29/rastro 0.1.0-alpha.5 → 0.1.0-alpha.6
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 +75 -27
- package/agent/integration.md +17 -15
- package/agent/manifest.json +5 -3
- package/agent/manifest.schema.json +8 -5
- package/dist/client/federation.d.ts +34 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +17 -3
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +418 -21
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +191 -41
- package/dist/client/index.js.map +1 -1
- 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/_generated/component.d.ts +63 -7
- package/dist/component/_generated/component.d.ts.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.map +1 -1
- package/dist/component/cardinality.js.map +1 -1
- package/dist/component/constants.d.ts +5 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +11 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/diagnostics.d.ts.map +1 -1
- package/dist/component/diagnostics.js.map +1 -1
- package/dist/component/eventStore.d.ts +2 -1
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/eventStore.js +13 -10
- 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.map +1 -1
- package/dist/component/geo.js +3 -2
- package/dist/component/geo.js.map +1 -1
- 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 +32 -9
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts +2 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +143 -23
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +1 -3
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +60 -3
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +215 -49
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts +4 -4
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +54 -22
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +38 -10
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +47 -5
- package/dist/component/schema.js +21 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +6 -6
- package/dist/component/sites.js.map +1 -1
- package/dist/component/validators.d.ts +14 -9
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +5 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts.map +1 -1
- package/dist/component/visitors.js.map +1 -1
- 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 +4 -0
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +4 -0
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- 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 +137 -0
- package/dist/tracker/vitals.js.map +1 -0
- package/dist/vitals.min.js +1 -0
- package/docs/benchmarks/2026-08-20-realistic.md +71 -71
- package/docs/benchmarks/2026-08-21-formal-certification.md +50 -30
- package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
- package/package.json +17 -3
- package/scripts/benchmark-ingest.mjs +101 -48
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +69 -4
- package/src/component/affiliates.ts +20 -5
- package/src/component/cardinality.ts +8 -7
- package/src/component/constants.ts +11 -0
- package/src/component/diagnostics.ts +3 -2
- package/src/component/eventStore.ts +53 -46
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +16 -14
- package/src/component/goals.ts +29 -21
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +73 -46
- package/src/component/ingest.ts +320 -127
- package/src/component/live.ts +5 -4
- package/src/component/reports.ts +563 -219
- package/src/component/retention.ts +221 -98
- package/src/component/sanitize.ts +76 -29
- package/src/component/schema.ts +31 -5
- package/src/component/sites.ts +22 -11
- package/src/component/validators.ts +16 -7
- package/src/component/visitors.ts +16 -5
- package/src/component/vitals.ts +146 -0
|
@@ -207,7 +207,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
207
207
|
| "custom"
|
|
208
208
|
| "conversion"
|
|
209
209
|
| "heartbeat"
|
|
210
|
-
| "outbound"
|
|
210
|
+
| "outbound"
|
|
211
|
+
| "vital";
|
|
212
|
+
value?: number;
|
|
211
213
|
visitorId: string;
|
|
212
214
|
}>;
|
|
213
215
|
origin: string;
|
|
@@ -303,7 +305,8 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
303
305
|
| "conversions"
|
|
304
306
|
| "goals"
|
|
305
307
|
| "funnels"
|
|
306
|
-
| "affiliates"
|
|
308
|
+
| "affiliates"
|
|
309
|
+
| "vitals";
|
|
307
310
|
}>;
|
|
308
311
|
from: number;
|
|
309
312
|
siteId: string;
|
|
@@ -691,7 +694,8 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
691
694
|
| "custom"
|
|
692
695
|
| "conversion"
|
|
693
696
|
| "heartbeat"
|
|
694
|
-
| "outbound"
|
|
697
|
+
| "outbound"
|
|
698
|
+
| "vital";
|
|
695
699
|
visitorId: string;
|
|
696
700
|
}>;
|
|
697
701
|
pageStatus?: "SplitRecommended" | "SplitRequired" | null;
|
|
@@ -739,11 +743,69 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
739
743
|
| "custom"
|
|
740
744
|
| "conversion"
|
|
741
745
|
| "heartbeat"
|
|
742
|
-
| "outbound"
|
|
746
|
+
| "outbound"
|
|
747
|
+
| "vital";
|
|
743
748
|
visitorId: string;
|
|
744
749
|
}>,
|
|
745
750
|
Name
|
|
746
751
|
>;
|
|
752
|
+
vitalsReport: FunctionReference<
|
|
753
|
+
"query",
|
|
754
|
+
"internal",
|
|
755
|
+
{ from: number; siteId: string; to: number },
|
|
756
|
+
{
|
|
757
|
+
metadata: {
|
|
758
|
+
basis: "dailyRollup" | "boundedLegacy";
|
|
759
|
+
coverage: {
|
|
760
|
+
availableFrom: number | null;
|
|
761
|
+
availableThrough: number | null;
|
|
762
|
+
generation: number | null;
|
|
763
|
+
retainedBefore: number | null;
|
|
764
|
+
state: "complete" | "partial" | "retained" | "unavailable";
|
|
765
|
+
};
|
|
766
|
+
range: {
|
|
767
|
+
boundary: "inclusive";
|
|
768
|
+
from: number;
|
|
769
|
+
timezone: "UTC";
|
|
770
|
+
to: number;
|
|
771
|
+
};
|
|
772
|
+
};
|
|
773
|
+
metrics: Array<{
|
|
774
|
+
average: number;
|
|
775
|
+
count: number;
|
|
776
|
+
devices: Array<{
|
|
777
|
+
average: number;
|
|
778
|
+
count: number;
|
|
779
|
+
device: string;
|
|
780
|
+
good: number;
|
|
781
|
+
needsImprovement: number;
|
|
782
|
+
p75: number;
|
|
783
|
+
poor: number;
|
|
784
|
+
}>;
|
|
785
|
+
good: number;
|
|
786
|
+
metric: "LCP" | "CLS" | "INP" | "FCP" | "TTFB";
|
|
787
|
+
needsImprovement: number;
|
|
788
|
+
p75: number;
|
|
789
|
+
pages: Array<{
|
|
790
|
+
average: number;
|
|
791
|
+
count: number;
|
|
792
|
+
good: number;
|
|
793
|
+
needsImprovement: number;
|
|
794
|
+
p75: number;
|
|
795
|
+
page: string;
|
|
796
|
+
poor: number;
|
|
797
|
+
}>;
|
|
798
|
+
poor: number;
|
|
799
|
+
timeSeries: Array<{
|
|
800
|
+
count: number;
|
|
801
|
+
p75: number;
|
|
802
|
+
timestamp: number;
|
|
803
|
+
}>;
|
|
804
|
+
}>;
|
|
805
|
+
truncated: boolean;
|
|
806
|
+
},
|
|
807
|
+
Name
|
|
808
|
+
>;
|
|
747
809
|
};
|
|
748
810
|
retention: {
|
|
749
811
|
cleanup: FunctionReference<
|
|
@@ -765,6 +827,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
765
827
|
| "goalRollups"
|
|
766
828
|
| "funnelRollups"
|
|
767
829
|
| "affiliateRollups"
|
|
830
|
+
| "vitalRollups"
|
|
768
831
|
| "attributions"
|
|
769
832
|
| "liveSessions"
|
|
770
833
|
| "ingestWindows"
|
|
@@ -799,6 +862,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
799
862
|
| "goalRollups"
|
|
800
863
|
| "funnelRollups"
|
|
801
864
|
| "affiliateRollups"
|
|
865
|
+
| "vitalRollups"
|
|
802
866
|
| "attributions"
|
|
803
867
|
| "liveSessions"
|
|
804
868
|
| "ingestWindows"
|
|
@@ -844,6 +908,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
844
908
|
| "goalRollups"
|
|
845
909
|
| "funnelRollups"
|
|
846
910
|
| "affiliateRollups"
|
|
911
|
+
| "vitalRollups"
|
|
847
912
|
| "attributions"
|
|
848
913
|
| "liveSessions"
|
|
849
914
|
| "ingestWindows"
|
|
@@ -22,16 +22,27 @@ export const upsert = mutation({
|
|
|
22
22
|
},
|
|
23
23
|
returns: v.id("affiliates"),
|
|
24
24
|
handler: async (ctx, args) => {
|
|
25
|
-
if (!(await ctx.db.get("sites", args.siteId)))
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
if (!(await ctx.db.get("sites", args.siteId)))
|
|
26
|
+
fail("NOT_FOUND", "site not found");
|
|
27
|
+
if (
|
|
28
|
+
!Number.isSafeInteger(args.commissionBps) ||
|
|
29
|
+
args.commissionBps < 0 ||
|
|
30
|
+
args.commissionBps > 10_000
|
|
31
|
+
) {
|
|
32
|
+
fail(
|
|
33
|
+
"INVALID_ARGUMENT",
|
|
34
|
+
"commissionBps must be an integer between 0 and 10000",
|
|
35
|
+
);
|
|
28
36
|
}
|
|
29
37
|
if (
|
|
30
38
|
!Number.isSafeInteger(args.attributionWindowDays) ||
|
|
31
39
|
args.attributionWindowDays < 1 ||
|
|
32
40
|
args.attributionWindowDays > 365
|
|
33
41
|
) {
|
|
34
|
-
fail(
|
|
42
|
+
fail(
|
|
43
|
+
"INVALID_ARGUMENT",
|
|
44
|
+
"attributionWindowDays must be an integer between 1 and 365",
|
|
45
|
+
);
|
|
35
46
|
}
|
|
36
47
|
let slug: string;
|
|
37
48
|
try {
|
|
@@ -52,7 +63,11 @@ export const upsert = mutation({
|
|
|
52
63
|
)
|
|
53
64
|
.unique();
|
|
54
65
|
const affiliateId = args.affiliateId ?? existingBySlug?._id;
|
|
55
|
-
if (
|
|
66
|
+
if (
|
|
67
|
+
args.affiliateId &&
|
|
68
|
+
existingBySlug &&
|
|
69
|
+
existingBySlug._id !== args.affiliateId
|
|
70
|
+
) {
|
|
56
71
|
fail("CONFLICT", "another affiliate already uses this slug");
|
|
57
72
|
}
|
|
58
73
|
const now = Date.now();
|
|
@@ -60,13 +60,14 @@ export function unionVisitorSketches(
|
|
|
60
60
|
export function estimateVisitors(sketch: VisitorSketch): number {
|
|
61
61
|
const registers = checkedRegisters(sketch);
|
|
62
62
|
const count = registers.length;
|
|
63
|
-
const alpha =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
const alpha =
|
|
64
|
+
count === 16
|
|
65
|
+
? 0.673
|
|
66
|
+
: count === 32
|
|
67
|
+
? 0.697
|
|
68
|
+
: count === 64
|
|
69
|
+
? 0.709
|
|
70
|
+
: 0.7213 / (1 + 1.079 / count);
|
|
70
71
|
let denominator = 0;
|
|
71
72
|
let empty = 0;
|
|
72
73
|
for (const register of registers) {
|
|
@@ -36,6 +36,17 @@ export const FEATURE_REPORT_DOCUMENT_RESERVE = 500;
|
|
|
36
36
|
export const REPORT_ROLLUP_SHARDS = 2;
|
|
37
37
|
export const ROLLUP_SAFETY_DELAY_MS = 5 * 60_000;
|
|
38
38
|
export const RETENTION_LEASE_MS = 60_000;
|
|
39
|
+
// Shared Web Vitals histogram edges. Every Google rating threshold (LCP
|
|
40
|
+
// 2500/4000, CLS×1000 100/250, INP 200/500, FCP 1800/3000, TTFB 800/1800) is
|
|
41
|
+
// an edge, so rating counts stay exact while one layout serves all metrics.
|
|
42
|
+
export const VITAL_HISTOGRAM_EDGES = [
|
|
43
|
+
100, 200, 250, 300, 500, 800, 1_000, 1_200, 1_800, 2_500, 3_000, 4_000, 5_000,
|
|
44
|
+
7_500, 10_000,
|
|
45
|
+
];
|
|
46
|
+
export const MAX_VITAL_VALUE = 600_000;
|
|
47
|
+
export const VITAL_PAGE_SLOTS = 48;
|
|
48
|
+
export const MAX_VITALS_REPORT_ROWS = 5_000;
|
|
49
|
+
export const VITALS_REPORT_PAGE_LIMIT = 12;
|
|
39
50
|
|
|
40
51
|
export const DAY_MS = 86_400_000;
|
|
41
52
|
export const HOUR_MS = 3_600_000;
|
|
@@ -29,7 +29,8 @@ export const sessionEventTotals = query({
|
|
|
29
29
|
try {
|
|
30
30
|
sessionIds = args.sessionIds.map((sessionId) => {
|
|
31
31
|
const sanitized = sanitizeOpaqueId(sessionId, "sessionId");
|
|
32
|
-
if (sanitized !== sessionId)
|
|
32
|
+
if (sanitized !== sessionId)
|
|
33
|
+
throw new Error("sessionId is not canonical");
|
|
33
34
|
return sanitized;
|
|
34
35
|
});
|
|
35
36
|
} catch (error) {
|
|
@@ -49,7 +50,7 @@ export const sessionEventTotals = query({
|
|
|
49
50
|
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
50
51
|
range.eq("siteId", args.siteId).eq("sessionId", sessionId),
|
|
51
52
|
)
|
|
52
|
-
.unique()
|
|
53
|
+
.unique(),
|
|
53
54
|
),
|
|
54
55
|
);
|
|
55
56
|
return {
|
|
@@ -90,8 +90,8 @@ export async function findTelemetry(
|
|
|
90
90
|
let batch: Doc<"eventBatches"> | undefined;
|
|
91
91
|
let nestedEvent: Doc<"eventBatches">["events"][number] | undefined;
|
|
92
92
|
for (const candidate of batches) {
|
|
93
|
-
nestedEvent = candidate.events.find(
|
|
94
|
-
nested.eventId === event.eventId
|
|
93
|
+
nestedEvent = candidate.events.find(
|
|
94
|
+
(nested) => nested.eventId === event.eventId,
|
|
95
95
|
);
|
|
96
96
|
if (nestedEvent) {
|
|
97
97
|
batch = candidate;
|
|
@@ -99,13 +99,14 @@ export async function findTelemetry(
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
return {
|
|
102
|
-
match:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
102
|
+
match:
|
|
103
|
+
batch && nestedEvent
|
|
104
|
+
? {
|
|
105
|
+
sessionId: batch.sessionId,
|
|
106
|
+
visitorId: batch.visitorId,
|
|
107
|
+
...eventContext(nestedEvent),
|
|
108
|
+
}
|
|
109
|
+
: null,
|
|
109
110
|
saturated,
|
|
110
111
|
};
|
|
111
112
|
}
|
|
@@ -190,17 +191,23 @@ export async function loadSessionJourneyEvents(
|
|
|
190
191
|
)
|
|
191
192
|
.order("asc");
|
|
192
193
|
for await (const batch of query) {
|
|
193
|
-
if (
|
|
194
|
+
if (
|
|
195
|
+
completedBucket !== undefined &&
|
|
196
|
+
batch.bucketStart !== completedBucket
|
|
197
|
+
) {
|
|
194
198
|
break;
|
|
195
199
|
}
|
|
196
200
|
batches.push(batch);
|
|
197
201
|
qualifyingEvents += batch.events.reduce((count, _event, index) => {
|
|
198
202
|
const event = eventFromBatch(batch, index);
|
|
199
|
-
return
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
)
|
|
204
211
|
);
|
|
205
212
|
}, 0);
|
|
206
213
|
if (qualifyingEvents >= input.resultLimit) {
|
|
@@ -213,10 +220,11 @@ export async function loadSessionJourneyEvents(
|
|
|
213
220
|
const rows: RawJourneyEvent[] = [];
|
|
214
221
|
appendBatchEvents(rows, batches);
|
|
215
222
|
return rows
|
|
216
|
-
.filter(
|
|
217
|
-
event
|
|
218
|
-
|
|
219
|
-
|
|
223
|
+
.filter(
|
|
224
|
+
(event) =>
|
|
225
|
+
event.timestamp >= input.from &&
|
|
226
|
+
event.timestamp <= input.to &&
|
|
227
|
+
(!input.cursor || compareJourneyEventToCursor(event, input.cursor) > 0),
|
|
220
228
|
)
|
|
221
229
|
.sort(compareJourneyEvents)
|
|
222
230
|
.slice(0, input.resultLimit);
|
|
@@ -249,13 +257,16 @@ export async function loadVisitorJourneyEvents(
|
|
|
249
257
|
)
|
|
250
258
|
.order("asc");
|
|
251
259
|
for await (const batch of query) {
|
|
252
|
-
if (
|
|
260
|
+
if (
|
|
261
|
+
completedBucket !== undefined &&
|
|
262
|
+
batch.bucketStart !== completedBucket
|
|
263
|
+
) {
|
|
253
264
|
break;
|
|
254
265
|
}
|
|
255
266
|
batches.push(batch);
|
|
256
267
|
await requireJourneyReadHeadroom(ctx);
|
|
257
|
-
qualifyingEvents += batch.events.filter(
|
|
258
|
-
event.timestamp >= input.from && event.timestamp <= input.to
|
|
268
|
+
qualifyingEvents += batch.events.filter(
|
|
269
|
+
(event) => event.timestamp >= input.from && event.timestamp <= input.to,
|
|
259
270
|
).length;
|
|
260
271
|
if (qualifyingEvents >= input.resultLimit) {
|
|
261
272
|
completedBucket = batch.bucketStart;
|
|
@@ -267,8 +278,8 @@ export async function loadVisitorJourneyEvents(
|
|
|
267
278
|
const rows: RawJourneyEvent[] = [];
|
|
268
279
|
appendBatchEvents(rows, batches);
|
|
269
280
|
return rows
|
|
270
|
-
.filter(
|
|
271
|
-
event.timestamp >= input.from && event.timestamp <= input.to
|
|
281
|
+
.filter(
|
|
282
|
+
(event) => event.timestamp >= input.from && event.timestamp <= input.to,
|
|
272
283
|
)
|
|
273
284
|
.sort(compareJourneyEvents)
|
|
274
285
|
.slice(0, input.resultLimit);
|
|
@@ -278,9 +289,11 @@ export function compareJourneyEvents(
|
|
|
278
289
|
left: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
279
290
|
right: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
280
291
|
): number {
|
|
281
|
-
return
|
|
292
|
+
return (
|
|
293
|
+
left.timestamp - right.timestamp ||
|
|
282
294
|
left._creationTime - right._creationTime ||
|
|
283
|
-
left._id.localeCompare(right._id)
|
|
295
|
+
left._id.localeCompare(right._id)
|
|
296
|
+
);
|
|
284
297
|
}
|
|
285
298
|
|
|
286
299
|
export async function hydrateEventContexts(
|
|
@@ -304,8 +317,8 @@ export async function hydrateEventContexts(
|
|
|
304
317
|
}
|
|
305
318
|
const sessions = new Map(
|
|
306
319
|
await Promise.all(
|
|
307
|
-
[...sessionPromises].map(
|
|
308
|
-
[key, await session] as const
|
|
320
|
+
[...sessionPromises].map(
|
|
321
|
+
async ([key, session]) => [key, await session] as const,
|
|
309
322
|
),
|
|
310
323
|
),
|
|
311
324
|
);
|
|
@@ -373,13 +386,8 @@ async function loadTelemetryMinute(
|
|
|
373
386
|
.take(MAX_EVENTS_PER_SESSION_WINDOW + 1);
|
|
374
387
|
}
|
|
375
388
|
|
|
376
|
-
function telemetryMinuteCount(
|
|
377
|
-
batches
|
|
378
|
-
) {
|
|
379
|
-
return batches.reduce(
|
|
380
|
-
(total, batch) => total + batch.events.length,
|
|
381
|
-
0,
|
|
382
|
-
);
|
|
389
|
+
function telemetryMinuteCount(batches: Array<{ events: TrackerEvent[] }>) {
|
|
390
|
+
return batches.reduce((total, batch) => total + batch.events.length, 0);
|
|
383
391
|
}
|
|
384
392
|
|
|
385
393
|
function eventForBatch(
|
|
@@ -423,7 +431,8 @@ function eventFromBatch(
|
|
|
423
431
|
batch: Doc<"eventBatches">,
|
|
424
432
|
index: number,
|
|
425
433
|
): RawJourneyEvent {
|
|
426
|
-
const { aggregateCountry: _aggregateCountry, ...event } =
|
|
434
|
+
const { aggregateCountry: _aggregateCountry, ...event } =
|
|
435
|
+
batch.events[index]!;
|
|
427
436
|
void _aggregateCountry;
|
|
428
437
|
return {
|
|
429
438
|
...event,
|
|
@@ -461,14 +470,10 @@ async function requireJourneyReadHeadroom(ctx: QueryCtx) {
|
|
|
461
470
|
metrics.bytesRead.remaining < JOURNEY_READ_BYTE_RESERVE ||
|
|
462
471
|
metrics.documentsRead.remaining < JOURNEY_READ_DOCUMENT_RESERVE
|
|
463
472
|
) {
|
|
464
|
-
fail(
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
bytesRead: metrics.bytesRead.used,
|
|
469
|
-
documentsRead: metrics.documentsRead.used,
|
|
470
|
-
},
|
|
471
|
-
);
|
|
473
|
+
fail("LIMIT_EXCEEDED", "visitor journey exceeds the safe read budget", {
|
|
474
|
+
bytesRead: metrics.bytesRead.used,
|
|
475
|
+
documentsRead: metrics.documentsRead.used,
|
|
476
|
+
});
|
|
472
477
|
}
|
|
473
478
|
}
|
|
474
479
|
|
|
@@ -476,7 +481,9 @@ function compareJourneyEventToCursor(
|
|
|
476
481
|
event: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
477
482
|
cursor: JourneyCursor,
|
|
478
483
|
) {
|
|
479
|
-
return
|
|
484
|
+
return (
|
|
485
|
+
event.timestamp - cursor.timestamp ||
|
|
480
486
|
event._creationTime - cursor.creationTime ||
|
|
481
|
-
event._id.localeCompare(cursor.id)
|
|
487
|
+
event._id.localeCompare(cursor.id)
|
|
488
|
+
);
|
|
482
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
|
},
|
package/src/component/geo.ts
CHANGED
|
@@ -15,10 +15,7 @@ import {
|
|
|
15
15
|
import { eventBatchKey, eventBucketStart } from "./eventStore.js";
|
|
16
16
|
import { isPlainRecord } from "./guards.js";
|
|
17
17
|
import { sanitizeContext, stableHash } from "./sanitize.js";
|
|
18
|
-
import {
|
|
19
|
-
trackerEventValidator,
|
|
20
|
-
type IngestContext,
|
|
21
|
-
} from "./validators.js";
|
|
18
|
+
import { trackerEventValidator, type IngestContext } from "./validators.js";
|
|
22
19
|
|
|
23
20
|
export type GeoIpProvider = "ipinfo" | "ipwhois";
|
|
24
21
|
export type GeoIpStatus = "disabled" | "ready" | "missingToken";
|
|
@@ -153,10 +150,13 @@ export const claimEnrichment = internalMutation({
|
|
|
153
150
|
batches.flatMap((batch) =>
|
|
154
151
|
batch.events
|
|
155
152
|
.filter((event) => group.eventIds.has(event.eventId))
|
|
156
|
-
.map(
|
|
157
|
-
event
|
|
158
|
-
|
|
159
|
-
|
|
153
|
+
.map(
|
|
154
|
+
(event) =>
|
|
155
|
+
[
|
|
156
|
+
event.eventId,
|
|
157
|
+
event.aggregateCountry ?? event.country,
|
|
158
|
+
] as const,
|
|
159
|
+
),
|
|
160
160
|
),
|
|
161
161
|
),
|
|
162
162
|
);
|
|
@@ -237,16 +237,16 @@ export const claimEnrichment = internalMutation({
|
|
|
237
237
|
seenEvents.has(key) ||
|
|
238
238
|
!claimed.has(event.sessionId) ||
|
|
239
239
|
!persistedEvents
|
|
240
|
-
.get(
|
|
240
|
+
.get(
|
|
241
|
+
eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)),
|
|
242
|
+
)
|
|
241
243
|
?.has(event.eventId)
|
|
242
244
|
) {
|
|
243
245
|
continue;
|
|
244
246
|
}
|
|
245
247
|
seenEvents.add(key);
|
|
246
248
|
const priorCountry = persistedEvents
|
|
247
|
-
.get(
|
|
248
|
-
eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)),
|
|
249
|
-
)
|
|
249
|
+
.get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
|
|
250
250
|
?.get(event.eventId);
|
|
251
251
|
if (priorCountry === undefined) continue;
|
|
252
252
|
correctionEvents.push({
|
|
@@ -469,7 +469,8 @@ function classifyIpv6(ip: string): Exclude<IpKind, "unavailable"> {
|
|
|
469
469
|
(groups[0]! & 0xffc0) === 0xfe80 ||
|
|
470
470
|
(groups[0]! & 0xffc0) === 0xfec0 ||
|
|
471
471
|
(groups[0]! & 0xff00) === 0xff00 ||
|
|
472
|
-
(groups[0] === 0x0100 &&
|
|
472
|
+
(groups[0] === 0x0100 &&
|
|
473
|
+
groups.slice(1, 4).every((group) => group === 0)) ||
|
|
473
474
|
(groups[0] === 0x0064 && groups[1] === 0xff9b && groups[2] === 1) ||
|
|
474
475
|
(groups[0] === 0x2001 && (groups[1]! & 0xfe00) === 0) ||
|
|
475
476
|
(groups[0] === 0x2001 && groups[1] === 0x0db8) ||
|
|
@@ -563,7 +564,8 @@ async function correctCountryAggregates(
|
|
|
563
564
|
for (const granularity of ["hour", "day"] as const) {
|
|
564
565
|
const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
|
|
565
566
|
const bucketStart = Math.floor(event.timestamp / interval) * interval;
|
|
566
|
-
const shard =
|
|
567
|
+
const shard =
|
|
568
|
+
stableHash(event.sessionId) %
|
|
567
569
|
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
568
570
|
const key = `${granularity}:${bucketStart}:${shard}`;
|
|
569
571
|
const correction = corrections.get(key) ?? {
|
package/src/component/goals.ts
CHANGED
|
@@ -8,10 +8,7 @@ import {
|
|
|
8
8
|
sanitizePath,
|
|
9
9
|
sanitizeSlug,
|
|
10
10
|
} from "./sanitize.js";
|
|
11
|
-
import {
|
|
12
|
-
goalFieldsValidator,
|
|
13
|
-
goalMatchTypeValidator,
|
|
14
|
-
} from "./validators.js";
|
|
11
|
+
import { goalFieldsValidator, goalMatchTypeValidator } from "./validators.js";
|
|
15
12
|
|
|
16
13
|
const goalDocumentValidator = goalFieldsValidator.extend({
|
|
17
14
|
_id: v.id("goals"),
|
|
@@ -31,14 +28,16 @@ export const upsert = mutation({
|
|
|
31
28
|
},
|
|
32
29
|
returns: v.id("goals"),
|
|
33
30
|
handler: async (ctx, args) => {
|
|
34
|
-
if (!(await ctx.db.get("sites", args.siteId)))
|
|
31
|
+
if (!(await ctx.db.get("sites", args.siteId)))
|
|
32
|
+
fail("NOT_FOUND", "site not found");
|
|
35
33
|
let key: string;
|
|
36
34
|
let matchValue: string;
|
|
37
35
|
try {
|
|
38
36
|
key = sanitizeSlug(args.key, "key");
|
|
39
|
-
matchValue =
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
matchValue =
|
|
38
|
+
args.matchType === "pageview"
|
|
39
|
+
? sanitizePath(args.matchValue)
|
|
40
|
+
: cleanString(args.matchValue, 80);
|
|
42
41
|
} catch (error) {
|
|
43
42
|
fail(
|
|
44
43
|
"INVALID_ARGUMENT",
|
|
@@ -49,7 +48,10 @@ export const upsert = mutation({
|
|
|
49
48
|
if (!name) fail("INVALID_ARGUMENT", "goal name is required");
|
|
50
49
|
if (!matchValue) fail("INVALID_ARGUMENT", "goal match value is required");
|
|
51
50
|
if (args.valueCents !== undefined && !isValidMoney(args.valueCents)) {
|
|
52
|
-
fail(
|
|
51
|
+
fail(
|
|
52
|
+
"INVALID_ARGUMENT",
|
|
53
|
+
"valueCents must be an integer between 0 and 1000000000",
|
|
54
|
+
);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
const existingByKey = await ctx.db
|
|
@@ -69,14 +71,16 @@ export const upsert = mutation({
|
|
|
69
71
|
const now = Date.now();
|
|
70
72
|
if (goalId) {
|
|
71
73
|
const goal = await ctx.db.get("goals", goalId);
|
|
72
|
-
if (!goal || goal.siteId !== args.siteId)
|
|
74
|
+
if (!goal || goal.siteId !== args.siteId)
|
|
75
|
+
fail("NOT_FOUND", "goal not found");
|
|
73
76
|
if (
|
|
74
77
|
(args.active ?? goal.active) &&
|
|
75
|
-
goals.some(
|
|
76
|
-
candidate
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
goals.some(
|
|
79
|
+
(candidate) =>
|
|
80
|
+
candidate._id !== goalId &&
|
|
81
|
+
candidate.active &&
|
|
82
|
+
candidate.matchType === args.matchType &&
|
|
83
|
+
candidate.matchValue === matchValue,
|
|
80
84
|
)
|
|
81
85
|
) {
|
|
82
86
|
fail("CONFLICT", "another active goal already uses this matcher");
|
|
@@ -95,16 +99,19 @@ export const upsert = mutation({
|
|
|
95
99
|
|
|
96
100
|
if (
|
|
97
101
|
(args.active ?? true) &&
|
|
98
|
-
goals.some(
|
|
99
|
-
candidate
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
goals.some(
|
|
103
|
+
(candidate) =>
|
|
104
|
+
candidate.active &&
|
|
105
|
+
candidate.matchType === args.matchType &&
|
|
106
|
+
candidate.matchValue === matchValue,
|
|
102
107
|
)
|
|
103
108
|
) {
|
|
104
109
|
fail("CONFLICT", "another active goal already uses this matcher");
|
|
105
110
|
}
|
|
106
111
|
if (goals.length >= MAX_GOALS_PER_SITE) {
|
|
107
|
-
fail("LIMIT_EXCEEDED", "goal limit reached", {
|
|
112
|
+
fail("LIMIT_EXCEEDED", "goal limit reached", {
|
|
113
|
+
limit: MAX_GOALS_PER_SITE,
|
|
114
|
+
});
|
|
108
115
|
}
|
|
109
116
|
return await ctx.db.insert("goals", {
|
|
110
117
|
siteId: args.siteId,
|
|
@@ -136,7 +143,8 @@ export const remove = mutation({
|
|
|
136
143
|
returns: v.null(),
|
|
137
144
|
handler: async (ctx, args) => {
|
|
138
145
|
const goal = await ctx.db.get("goals", args.goalId);
|
|
139
|
-
if (!goal || goal.siteId !== args.siteId)
|
|
146
|
+
if (!goal || goal.siteId !== args.siteId)
|
|
147
|
+
fail("NOT_FOUND", "goal not found");
|
|
140
148
|
await ctx.db.delete("goals", args.goalId);
|
|
141
149
|
return null;
|
|
142
150
|
},
|