@iann29/rastro 0.1.0-alpha.4 → 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 +203 -56
- package/agent/integration.md +17 -15
- package/agent/manifest.json +6 -4
- 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 +486 -31
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +209 -41
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +8 -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 +82 -8
- 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 +6 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +12 -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 +3 -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 +71 -37
- 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 +168 -29
- 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 +70 -4
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +232 -59
- 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 +42 -11
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +83 -10
- package/dist/component/schema.js +34 -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/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 +29 -18
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +8 -2
- 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 +8 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +8 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +23 -11
- 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/tracker.min.js +1 -1
- 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/docs/federation.md +1 -1
- package/package.json +27 -9
- package/scripts/benchmark-ingest.mjs +101 -48
- package/src/component/_generated/api.ts +8 -0
- package/src/component/_generated/component.ts +87 -5
- package/src/component/affiliates.ts +20 -5
- package/src/component/cardinality.ts +8 -7
- package/src/component/constants.ts +12 -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 +126 -78
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +349 -130
- package/src/component/live.ts +5 -4
- package/src/component/reports.ts +572 -221
- package/src/component/retention.ts +221 -98
- package/src/component/sanitize.ts +77 -27
- package/src/component/schema.ts +46 -5
- package/src/component/sites.ts +22 -11
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +19 -7
- package/src/component/visitors.ts +116 -0
- package/src/component/vitals.ts +146 -0
|
@@ -185,6 +185,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
185
185
|
latitude?: number;
|
|
186
186
|
longitude?: number;
|
|
187
187
|
os?: string;
|
|
188
|
+
visitorKey?: string;
|
|
188
189
|
};
|
|
189
190
|
events: Array<{
|
|
190
191
|
affiliateSlug?: string;
|
|
@@ -206,7 +207,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
206
207
|
| "custom"
|
|
207
208
|
| "conversion"
|
|
208
209
|
| "heartbeat"
|
|
209
|
-
| "outbound"
|
|
210
|
+
| "outbound"
|
|
211
|
+
| "vital";
|
|
212
|
+
value?: number;
|
|
210
213
|
visitorId: string;
|
|
211
214
|
}>;
|
|
212
215
|
origin: string;
|
|
@@ -302,7 +305,8 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
302
305
|
| "conversions"
|
|
303
306
|
| "goals"
|
|
304
307
|
| "funnels"
|
|
305
|
-
| "affiliates"
|
|
308
|
+
| "affiliates"
|
|
309
|
+
| "vitals";
|
|
306
310
|
}>;
|
|
307
311
|
from: number;
|
|
308
312
|
siteId: string;
|
|
@@ -488,6 +492,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
488
492
|
utmMedium?: string;
|
|
489
493
|
utmSource?: string;
|
|
490
494
|
visitorId: string;
|
|
495
|
+
visitorKey?: string;
|
|
491
496
|
}>;
|
|
492
497
|
pageStatus?: "SplitRecommended" | "SplitRequired" | null;
|
|
493
498
|
splitCursor?: string | null;
|
|
@@ -537,6 +542,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
537
542
|
metadata: {
|
|
538
543
|
breakdowns: {
|
|
539
544
|
topBrowsers: "eventVolume";
|
|
545
|
+
topCampaigns: "eventVolume";
|
|
540
546
|
topCountries: "eventVolume";
|
|
541
547
|
topDevices: "eventVolume";
|
|
542
548
|
topEvents: "eventVolume";
|
|
@@ -552,7 +558,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
552
558
|
state: "complete" | "partial" | "retained" | "unavailable";
|
|
553
559
|
};
|
|
554
560
|
features: {
|
|
555
|
-
botDetection: "
|
|
561
|
+
botDetection: "userAgent";
|
|
556
562
|
errorInsights: "unsupported";
|
|
557
563
|
outboundLinks: "eventVolume";
|
|
558
564
|
sessionReplay: "unsupported";
|
|
@@ -589,6 +595,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
589
595
|
revenueCents: number;
|
|
590
596
|
value: string;
|
|
591
597
|
}>;
|
|
598
|
+
topCampaigns: Array<{
|
|
599
|
+
count: number;
|
|
600
|
+
revenueCents: number;
|
|
601
|
+
value: string;
|
|
602
|
+
}>;
|
|
592
603
|
topCountries: Array<{
|
|
593
604
|
count: number;
|
|
594
605
|
revenueCents: number;
|
|
@@ -683,7 +694,8 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
683
694
|
| "custom"
|
|
684
695
|
| "conversion"
|
|
685
696
|
| "heartbeat"
|
|
686
|
-
| "outbound"
|
|
697
|
+
| "outbound"
|
|
698
|
+
| "vital";
|
|
687
699
|
visitorId: string;
|
|
688
700
|
}>;
|
|
689
701
|
pageStatus?: "SplitRecommended" | "SplitRequired" | null;
|
|
@@ -731,11 +743,69 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
731
743
|
| "custom"
|
|
732
744
|
| "conversion"
|
|
733
745
|
| "heartbeat"
|
|
734
|
-
| "outbound"
|
|
746
|
+
| "outbound"
|
|
747
|
+
| "vital";
|
|
735
748
|
visitorId: string;
|
|
736
749
|
}>,
|
|
737
750
|
Name
|
|
738
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
|
+
>;
|
|
739
809
|
};
|
|
740
810
|
retention: {
|
|
741
811
|
cleanup: FunctionReference<
|
|
@@ -757,6 +827,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
757
827
|
| "goalRollups"
|
|
758
828
|
| "funnelRollups"
|
|
759
829
|
| "affiliateRollups"
|
|
830
|
+
| "vitalRollups"
|
|
760
831
|
| "attributions"
|
|
761
832
|
| "liveSessions"
|
|
762
833
|
| "ingestWindows"
|
|
@@ -791,6 +862,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
791
862
|
| "goalRollups"
|
|
792
863
|
| "funnelRollups"
|
|
793
864
|
| "affiliateRollups"
|
|
865
|
+
| "vitalRollups"
|
|
794
866
|
| "attributions"
|
|
795
867
|
| "liveSessions"
|
|
796
868
|
| "ingestWindows"
|
|
@@ -836,6 +908,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
836
908
|
| "goalRollups"
|
|
837
909
|
| "funnelRollups"
|
|
838
910
|
| "affiliateRollups"
|
|
911
|
+
| "vitalRollups"
|
|
839
912
|
| "attributions"
|
|
840
913
|
| "liveSessions"
|
|
841
914
|
| "ingestWindows"
|
|
@@ -926,4 +999,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
926
999
|
Name
|
|
927
1000
|
>;
|
|
928
1001
|
};
|
|
1002
|
+
visitors: {
|
|
1003
|
+
link: FunctionReference<
|
|
1004
|
+
"mutation",
|
|
1005
|
+
"internal",
|
|
1006
|
+
{ previousVisitorId: string; siteId: string; visitorId: string },
|
|
1007
|
+
{ aliasCount: number; linked: boolean },
|
|
1008
|
+
Name
|
|
1009
|
+
>;
|
|
1010
|
+
};
|
|
929
1011
|
};
|
|
@@ -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,8 +36,20 @@ 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;
|
|
42
53
|
export const MINUTE_MS = 60_000;
|
|
43
54
|
export const RETENTION_POLICY_INTERVAL_MS = DAY_MS;
|
|
55
|
+
export const MAX_VISITOR_ALIASES = 10;
|
|
@@ -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) ?? {
|