@iann29/rastro 0.1.0-alpha.0 → 0.1.0-alpha.10
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 +579 -57
- package/agent/integration.md +661 -0
- package/agent/manifest.json +187 -0
- package/agent/manifest.schema.json +411 -0
- package/dist/client/federation.d.ts +236 -0
- package/dist/client/federation.d.ts.map +1 -0
- package/dist/client/federation.js +196 -0
- package/dist/client/federation.js.map +1 -0
- package/dist/client/index.d.ts +2467 -18
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +461 -32
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +18 -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 +302 -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 +34 -7
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +47 -7
- 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 +19 -0
- package/dist/component/coverage.d.ts.map +1 -0
- package/dist/component/coverage.js +30 -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 +106 -0
- package/dist/component/eventStore.d.ts.map +1 -0
- package/dist/component/eventStore.js +294 -0
- package/dist/component/eventStore.js.map +1 -0
- 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 +71 -0
- package/dist/component/geo.d.ts.map +1 -0
- package/dist/component/geo.js +611 -0
- package/dist/component/geo.js.map +1 -0
- package/dist/component/goals.d.ts.map +1 -1
- package/dist/component/goals.js +25 -9
- 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 +284 -53
- 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 +1105 -243
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts +8 -0
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +74 -9
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +273 -24
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +1320 -150
- 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 +541 -31
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts +24 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +107 -19
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +462 -55
- package/dist/component/schema.js +195 -15
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +38 -25
- 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 +101 -16
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +19 -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 +1 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +81 -33
- 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 +171 -0
- 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 +397 -0
- package/docs/federation.md +262 -0
- package/docs/upgrading.md +259 -0
- package/llms.txt +71 -0
- package/package.json +61 -11
- package/scripts/benchmark-ingest.mjs +601 -0
- package/scripts/generate-federation-keys.mjs +20 -0
- package/src/component/_generated/api.ts +18 -0
- package/src/component/_generated/component.ts +366 -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 +47 -7
- package/src/component/convex.config.ts +11 -1
- package/src/component/coverage.ts +36 -0
- package/src/component/diagnostics.ts +65 -0
- package/src/component/errors.ts +2 -1
- package/src/component/eventStore.ts +489 -0
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +781 -0
- package/src/component/goals.ts +45 -16
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +403 -60
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +1652 -291
- package/src/component/live.ts +90 -9
- package/src/component/reports.ts +1929 -199
- package/src/component/retention.ts +774 -97
- package/src/component/sanitize.ts +154 -32
- package/src/component/schema.ts +219 -15
- package/src/component/sites.ts +56 -28
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +29 -1
- package/src/component/visitors.ts +116 -0
- package/src/component/vitals.ts +146 -0
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
export const MAX_BATCH_EVENTS =
|
|
1
|
+
export const MAX_BATCH_EVENTS = 50;
|
|
2
|
+
export const MAX_BATCH_EVENT_GROUPS = 4;
|
|
2
3
|
export const MAX_BATCH_BYTES = 64 * 1024;
|
|
3
4
|
export const MAX_EVENTS_PER_SESSION_WINDOW = 120;
|
|
4
5
|
export const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
5
6
|
export const MAX_BYTES_PER_SESSION_WINDOW = 256 * 1024;
|
|
6
|
-
export const SITE_INGEST_SHARDS =
|
|
7
|
-
export const MAX_SITE_EVENTS_PER_SHARD_WINDOW =
|
|
7
|
+
export const SITE_INGEST_SHARDS = 4_096;
|
|
8
|
+
export const MAX_SITE_EVENTS_PER_SHARD_WINDOW = 300;
|
|
8
9
|
export const MAX_SITE_BYTES_PER_SHARD_WINDOW = 512 * 1024;
|
|
9
|
-
export const LIVE_SESSION_TTL_MS =
|
|
10
|
-
export const
|
|
10
|
+
export const LIVE_SESSION_TTL_MS = 90_000;
|
|
11
|
+
export const LIVE_LEAVE_GRACE_MS = 10_000;
|
|
12
|
+
export const LIVE_SWEEP_INTERVAL_MS = 5_000;
|
|
13
|
+
export const CLOCK_SKEW_TOLERANCE_MS = 30_000;
|
|
14
|
+
export const AGGREGATE_SHARDS = 128;
|
|
15
|
+
export const HOURLY_AGGREGATE_SHARDS = 256;
|
|
16
|
+
export const SECONDARY_AGGREGATE_SHARDS = 16;
|
|
11
17
|
export const DIMENSION_SLOTS = 16;
|
|
12
18
|
export const MAX_SITE_DOMAINS = 32;
|
|
13
19
|
export const MAX_SITES_PER_OWNER = 100;
|
|
@@ -20,9 +26,43 @@ export const MAX_REPORT_SITES = 10;
|
|
|
20
26
|
export const MAX_REPORT_RANGE_DAYS = 366;
|
|
21
27
|
export const MAX_LIVE_VISITORS = 500;
|
|
22
28
|
export const MAX_JOURNEY_EVENTS = 500;
|
|
29
|
+
export const JOURNEY_READ_BYTE_RESERVE = 4 * 1024 * 1024;
|
|
30
|
+
export const JOURNEY_READ_DOCUMENT_RESERVE = 1_000;
|
|
23
31
|
export const MAX_RETENTION_BATCH = 500;
|
|
24
|
-
export const MAX_OVERVIEW_AGGREGATE_ROWS =
|
|
32
|
+
export const MAX_OVERVIEW_AGGREGATE_ROWS = 13_000;
|
|
33
|
+
export const MAX_REPORT_BUCKETS = 5_000;
|
|
34
|
+
export const MAX_FEATURE_ROLLUP_DAYS = 90;
|
|
35
|
+
export const MAX_FEATURE_ROLLUP_ROWS = 5_000;
|
|
36
|
+
export const FEATURE_REPORT_BYTE_RESERVE = 2 * 1024 * 1024;
|
|
37
|
+
export const FEATURE_REPORT_DOCUMENT_RESERVE = 500;
|
|
38
|
+
export const REPORT_ROLLUP_SHARDS = 2;
|
|
39
|
+
export const ROLLUP_SAFETY_DELAY_MS = 5 * 60_000;
|
|
40
|
+
export const RETENTION_LEASE_MS = 60_000;
|
|
41
|
+
// Shared Web Vitals histogram edges. Every Google rating threshold (LCP
|
|
42
|
+
// 2500/4000, CLS×1000 100/250, INP 200/500, FCP 1800/3000, TTFB 800/1800) is
|
|
43
|
+
// an edge, so rating counts stay exact while one layout serves all metrics.
|
|
44
|
+
export const VITAL_HISTOGRAM_EDGES = [
|
|
45
|
+
100, 200, 250, 300, 500, 800, 1_000, 1_200, 1_800, 2_500, 3_000, 4_000, 5_000,
|
|
46
|
+
7_500, 10_000,
|
|
47
|
+
];
|
|
48
|
+
export const MAX_VITAL_VALUE = 600_000;
|
|
49
|
+
export const VITAL_PAGE_SLOTS = 48;
|
|
50
|
+
export const MAX_VITALS_REPORT_ROWS = 5_000;
|
|
51
|
+
export const VITALS_REPORT_PAGE_LIMIT = 12;
|
|
25
52
|
|
|
26
53
|
export const DAY_MS = 86_400_000;
|
|
27
54
|
export const HOUR_MS = 3_600_000;
|
|
28
|
-
export const
|
|
55
|
+
export const MINUTE_MS = 60_000;
|
|
56
|
+
export const RETENTION_POLICY_INTERVAL_MS = DAY_MS;
|
|
57
|
+
export const MAX_VISITOR_ALIASES = 10;
|
|
58
|
+
// Site map rollups: one row per (site, route, UTC day, shard). A day keeps at
|
|
59
|
+
// most this many route rows per shard before new routes fold into "(other)";
|
|
60
|
+
// each row keeps this many outgoing targets before they fold the same way. A
|
|
61
|
+
// report reads at most MAX_SITE_MAP_ROWS rows, newest day first — about
|
|
62
|
+
// 5 MiB of rows, under the 8 MiB a function may read once the feature-report
|
|
63
|
+
// byte reserve is kept — and declares the older days it could not read as
|
|
64
|
+
// partial coverage: 20 days at the daily cap, 40 for a 100-route site.
|
|
65
|
+
export const MAX_SITE_MAP_ROUTES_PER_DAY = 200;
|
|
66
|
+
export const ROUTE_TRANSITION_SLOTS = 24;
|
|
67
|
+
export const MAX_SITE_MAP_ROWS = 8_000;
|
|
68
|
+
export const SITE_MAP_OTHER_ROUTE = "(other)";
|
|
@@ -1,3 +1,13 @@
|
|
|
1
1
|
import { defineComponent } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
2
3
|
|
|
3
|
-
export default defineComponent("rastroAnalytics"
|
|
4
|
+
export default defineComponent("rastroAnalytics", {
|
|
5
|
+
env: {
|
|
6
|
+
RASTRO_GEOIP_PROVIDER: v.optional(
|
|
7
|
+
v.union(v.literal("ipinfo"), v.literal("ipwhois")),
|
|
8
|
+
),
|
|
9
|
+
RASTRO_GEOIP_TOKEN: v.optional(v.string()),
|
|
10
|
+
RASTRO_GEOIP_DAILY_LIMIT: v.optional(v.string()),
|
|
11
|
+
RASTRO_TRUST_PROXY: v.optional(v.literal("true")),
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Id } from "./_generated/dataModel.js";
|
|
2
|
+
import type { MutationCtx } from "./_generated/server.js";
|
|
3
|
+
import { ROLLUP_SAFETY_DELAY_MS } from "./constants.js";
|
|
4
|
+
|
|
5
|
+
export async function ensureAnalyticsControl(
|
|
6
|
+
ctx: MutationCtx,
|
|
7
|
+
siteId: Id<"sites">,
|
|
8
|
+
now: number,
|
|
9
|
+
retained: Array<{ dataset: string; before: number }> = [],
|
|
10
|
+
) {
|
|
11
|
+
const existing = await ctx.db
|
|
12
|
+
.query("analyticsCoverage")
|
|
13
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
14
|
+
.unique();
|
|
15
|
+
if (existing) {
|
|
16
|
+
if (existing.siteMapSince !== undefined) return existing;
|
|
17
|
+
// A site that predates the site map starts its map coverage at the first
|
|
18
|
+
// ingest after the upgrade; the same safety delay applies as for rollups.
|
|
19
|
+
const siteMapSince = now + ROLLUP_SAFETY_DELAY_MS;
|
|
20
|
+
await ctx.db.patch("analyticsCoverage", existing._id, {
|
|
21
|
+
siteMapSince,
|
|
22
|
+
updatedAt: now,
|
|
23
|
+
});
|
|
24
|
+
return { ...existing, siteMapSince, updatedAt: now };
|
|
25
|
+
}
|
|
26
|
+
const fields = {
|
|
27
|
+
siteId,
|
|
28
|
+
generation: 1 as const,
|
|
29
|
+
rollupCompleteFrom: now + ROLLUP_SAFETY_DELAY_MS,
|
|
30
|
+
siteMapSince: now + ROLLUP_SAFETY_DELAY_MS,
|
|
31
|
+
retained,
|
|
32
|
+
updatedAt: now,
|
|
33
|
+
};
|
|
34
|
+
const _id = await ctx.db.insert("analyticsCoverage", fields);
|
|
35
|
+
return { ...fields, _id, _creationTime: now };
|
|
36
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { query } from "./_generated/server.js";
|
|
3
|
+
import { fail } from "./errors.js";
|
|
4
|
+
import { sanitizeOpaqueId } from "./sanitize.js";
|
|
5
|
+
|
|
6
|
+
const MAX_SESSION_TOTAL_IDS = 100;
|
|
7
|
+
|
|
8
|
+
export const sessionEventTotals = query({
|
|
9
|
+
args: {
|
|
10
|
+
siteId: v.id("sites"),
|
|
11
|
+
sessionIds: v.array(v.string()),
|
|
12
|
+
},
|
|
13
|
+
returns: v.object({
|
|
14
|
+
requestedSessions: v.number(),
|
|
15
|
+
foundSessions: v.number(),
|
|
16
|
+
storedEvents: v.number(),
|
|
17
|
+
}),
|
|
18
|
+
handler: async (ctx, args) => {
|
|
19
|
+
if (
|
|
20
|
+
args.sessionIds.length < 1 ||
|
|
21
|
+
args.sessionIds.length > MAX_SESSION_TOTAL_IDS
|
|
22
|
+
) {
|
|
23
|
+
fail(
|
|
24
|
+
"INVALID_ARGUMENT",
|
|
25
|
+
`sessionIds must contain between 1 and ${MAX_SESSION_TOTAL_IDS} IDs`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
let sessionIds: string[];
|
|
29
|
+
try {
|
|
30
|
+
sessionIds = args.sessionIds.map((sessionId) => {
|
|
31
|
+
const sanitized = sanitizeOpaqueId(sessionId, "sessionId");
|
|
32
|
+
if (sanitized !== sessionId)
|
|
33
|
+
throw new Error("sessionId is not canonical");
|
|
34
|
+
return sanitized;
|
|
35
|
+
});
|
|
36
|
+
} catch (error) {
|
|
37
|
+
fail(
|
|
38
|
+
"INVALID_ARGUMENT",
|
|
39
|
+
error instanceof Error ? error.message : "invalid sessionId",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
if (new Set(sessionIds).size !== sessionIds.length) {
|
|
43
|
+
fail("INVALID_ARGUMENT", "sessionIds must be unique");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const sessions = await Promise.all(
|
|
47
|
+
sessionIds.map((sessionId) =>
|
|
48
|
+
ctx.db
|
|
49
|
+
.query("sessions")
|
|
50
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
51
|
+
range.eq("siteId", args.siteId).eq("sessionId", sessionId),
|
|
52
|
+
)
|
|
53
|
+
.unique(),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
return {
|
|
57
|
+
requestedSessions: sessionIds.length,
|
|
58
|
+
foundSessions: sessions.filter((session) => session !== null).length,
|
|
59
|
+
storedEvents: sessions.reduce(
|
|
60
|
+
(total, session) => total + (session?.eventCount ?? 0),
|
|
61
|
+
0,
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
});
|
package/src/component/errors.ts
CHANGED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
2
|
+
import type { MutationCtx, QueryCtx } from "./_generated/server.js";
|
|
3
|
+
import {
|
|
4
|
+
JOURNEY_READ_BYTE_RESERVE,
|
|
5
|
+
JOURNEY_READ_DOCUMENT_RESERVE,
|
|
6
|
+
MAX_EVENTS_PER_SESSION_WINDOW,
|
|
7
|
+
RATE_LIMIT_WINDOW_MS,
|
|
8
|
+
} from "./constants.js";
|
|
9
|
+
import { fail } from "./errors.js";
|
|
10
|
+
import type { TrackerEvent } from "./validators.js";
|
|
11
|
+
|
|
12
|
+
export type TelemetryMatch = {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
visitorId: string;
|
|
15
|
+
country?: string;
|
|
16
|
+
city?: string;
|
|
17
|
+
latitude?: number;
|
|
18
|
+
longitude?: number;
|
|
19
|
+
browser?: string;
|
|
20
|
+
os?: string;
|
|
21
|
+
device?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type BufferedEventBatch = {
|
|
25
|
+
siteId: Id<"sites">;
|
|
26
|
+
sessionId: string;
|
|
27
|
+
visitorId: string;
|
|
28
|
+
bucketStart: number;
|
|
29
|
+
events: TrackerEvent[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type EventBatchBuffer = Map<string, BufferedEventBatch>;
|
|
33
|
+
|
|
34
|
+
export type JourneyCursor = {
|
|
35
|
+
timestamp: number;
|
|
36
|
+
creationTime: number;
|
|
37
|
+
id: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
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
|
+
};
|
|
50
|
+
|
|
51
|
+
export function eventBucketStart(timestamp: number): number {
|
|
52
|
+
return Math.floor(timestamp / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function eventBatchKey(sessionId: string, bucketStart: number): string {
|
|
56
|
+
return JSON.stringify([sessionId, bucketStart]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function inspectTelemetryMinute(
|
|
60
|
+
ctx: MutationCtx,
|
|
61
|
+
input: {
|
|
62
|
+
siteId: Id<"sites">;
|
|
63
|
+
sessionId: string;
|
|
64
|
+
bucketStart: number;
|
|
65
|
+
},
|
|
66
|
+
) {
|
|
67
|
+
const batches = await loadTelemetryMinute(ctx, input);
|
|
68
|
+
const eventIds = new Set(
|
|
69
|
+
batches.flatMap((batch) => batch.events.map((event) => event.eventId)),
|
|
70
|
+
);
|
|
71
|
+
return {
|
|
72
|
+
eventIds,
|
|
73
|
+
persistedCount: telemetryMinuteCount(batches),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function findTelemetry(
|
|
78
|
+
ctx: MutationCtx,
|
|
79
|
+
siteId: Id<"sites">,
|
|
80
|
+
event: TrackerEvent,
|
|
81
|
+
): Promise<{ match: TelemetryMatch | null; saturated: boolean }> {
|
|
82
|
+
const bucketStart = eventBucketStart(event.timestamp);
|
|
83
|
+
const batches = await loadTelemetryMinute(ctx, {
|
|
84
|
+
siteId,
|
|
85
|
+
sessionId: event.sessionId,
|
|
86
|
+
bucketStart,
|
|
87
|
+
});
|
|
88
|
+
const persistedCount = telemetryMinuteCount(batches);
|
|
89
|
+
const saturated = persistedCount >= MAX_EVENTS_PER_SESSION_WINDOW;
|
|
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
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
match:
|
|
103
|
+
batch && nestedEvent
|
|
104
|
+
? {
|
|
105
|
+
sessionId: batch.sessionId,
|
|
106
|
+
visitorId: batch.visitorId,
|
|
107
|
+
...eventContext(nestedEvent),
|
|
108
|
+
}
|
|
109
|
+
: null,
|
|
110
|
+
saturated,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function bufferEventBatch(
|
|
115
|
+
siteId: Id<"sites">,
|
|
116
|
+
event: TrackerEvent,
|
|
117
|
+
buffer: EventBatchBuffer,
|
|
118
|
+
country: string,
|
|
119
|
+
) {
|
|
120
|
+
const bucketStart = eventBucketStart(event.timestamp);
|
|
121
|
+
const key = eventBatchKey(event.sessionId, bucketStart);
|
|
122
|
+
const existing = buffer.get(key);
|
|
123
|
+
if (existing) {
|
|
124
|
+
if (existing.visitorId !== event.visitorId) {
|
|
125
|
+
throw new Error("event batch visitor does not match its session");
|
|
126
|
+
}
|
|
127
|
+
existing.events.push(eventForBatch(event, country));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
buffer.set(key, {
|
|
131
|
+
siteId,
|
|
132
|
+
sessionId: event.sessionId,
|
|
133
|
+
visitorId: event.visitorId,
|
|
134
|
+
bucketStart,
|
|
135
|
+
events: [eventForBatch(event, country)],
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function insertEventBatch(
|
|
140
|
+
ctx: MutationCtx,
|
|
141
|
+
siteId: Id<"sites">,
|
|
142
|
+
event: TrackerEvent,
|
|
143
|
+
country: string,
|
|
144
|
+
) {
|
|
145
|
+
await ctx.db.insert("eventBatches", {
|
|
146
|
+
siteId,
|
|
147
|
+
sessionId: event.sessionId,
|
|
148
|
+
visitorId: event.visitorId,
|
|
149
|
+
bucketStart: eventBucketStart(event.timestamp),
|
|
150
|
+
events: [eventForBatch(event, country)],
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function flushEventBatches(
|
|
155
|
+
ctx: MutationCtx,
|
|
156
|
+
buffer: EventBatchBuffer,
|
|
157
|
+
) {
|
|
158
|
+
for (const batch of buffer.values()) {
|
|
159
|
+
await ctx.db.insert("eventBatches", batch);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function loadSessionJourneyEvents(
|
|
164
|
+
ctx: QueryCtx,
|
|
165
|
+
input: {
|
|
166
|
+
siteId: Id<"sites">;
|
|
167
|
+
sessionId: string;
|
|
168
|
+
from: number;
|
|
169
|
+
to: number;
|
|
170
|
+
cursor: JourneyCursor | null;
|
|
171
|
+
resultLimit: number;
|
|
172
|
+
},
|
|
173
|
+
): Promise<RawJourneyEvent[]> {
|
|
174
|
+
const startTimestamp = input.cursor?.timestamp ?? input.from;
|
|
175
|
+
const firstBucket = eventBucketStart(startTimestamp);
|
|
176
|
+
const lastBucket = eventBucketStart(input.to);
|
|
177
|
+
// Nested event tiebreakers are not index fields, so the boundary minute must
|
|
178
|
+
// be read completely before applying the cursor.
|
|
179
|
+
const loadBatches = async () => {
|
|
180
|
+
const batches: Doc<"eventBatches">[] = [];
|
|
181
|
+
let qualifyingEvents = 0;
|
|
182
|
+
let completedBucket: number | undefined;
|
|
183
|
+
const query = ctx.db
|
|
184
|
+
.query("eventBatches")
|
|
185
|
+
.withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
|
|
186
|
+
range
|
|
187
|
+
.eq("siteId", input.siteId)
|
|
188
|
+
.eq("sessionId", input.sessionId)
|
|
189
|
+
.gte("bucketStart", firstBucket)
|
|
190
|
+
.lte("bucketStart", lastBucket),
|
|
191
|
+
)
|
|
192
|
+
.order("asc");
|
|
193
|
+
for await (const batch of query) {
|
|
194
|
+
if (
|
|
195
|
+
completedBucket !== undefined &&
|
|
196
|
+
batch.bucketStart !== completedBucket
|
|
197
|
+
) {
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
batches.push(batch);
|
|
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
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return batches;
|
|
218
|
+
};
|
|
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) =>
|
|
252
|
+
range
|
|
253
|
+
.eq("siteId", input.siteId)
|
|
254
|
+
.eq("visitorId", input.visitorId)
|
|
255
|
+
.gte("bucketStart", firstBucket)
|
|
256
|
+
.lte("bucketStart", lastBucket),
|
|
257
|
+
)
|
|
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
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return batches;
|
|
276
|
+
};
|
|
277
|
+
const batches = await loadBatches();
|
|
278
|
+
const rows: RawJourneyEvent[] = [];
|
|
279
|
+
appendBatchEvents(rows, batches);
|
|
280
|
+
return rows
|
|
281
|
+
.filter(
|
|
282
|
+
(event) => event.timestamp >= input.from && event.timestamp <= input.to,
|
|
283
|
+
)
|
|
284
|
+
.sort(compareJourneyEvents)
|
|
285
|
+
.slice(0, input.resultLimit);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function compareJourneyEvents(
|
|
289
|
+
left: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
290
|
+
right: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
291
|
+
): number {
|
|
292
|
+
return (
|
|
293
|
+
left.timestamp - right.timestamp ||
|
|
294
|
+
left._creationTime - right._creationTime ||
|
|
295
|
+
left._id.localeCompare(right._id)
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function hydrateEventContexts(
|
|
300
|
+
ctx: QueryCtx,
|
|
301
|
+
rows: RawJourneyEvent[],
|
|
302
|
+
) {
|
|
303
|
+
const sessionPromises = new Map<string, Promise<Doc<"sessions"> | null>>();
|
|
304
|
+
for (const row of rows) {
|
|
305
|
+
const key = `${row.siteId}:${row.sessionId}`;
|
|
306
|
+
if (!sessionPromises.has(key)) {
|
|
307
|
+
sessionPromises.set(
|
|
308
|
+
key,
|
|
309
|
+
ctx.db
|
|
310
|
+
.query("sessions")
|
|
311
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
312
|
+
range.eq("siteId", row.siteId).eq("sessionId", row.sessionId),
|
|
313
|
+
)
|
|
314
|
+
.unique(),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const sessions = new Map(
|
|
319
|
+
await Promise.all(
|
|
320
|
+
[...sessionPromises].map(
|
|
321
|
+
async ([key, session]) => [key, await session] as const,
|
|
322
|
+
),
|
|
323
|
+
),
|
|
324
|
+
);
|
|
325
|
+
return rows.map((row) => {
|
|
326
|
+
const session = sessions.get(`${row.siteId}:${row.sessionId}`);
|
|
327
|
+
const country = row.country ?? session?.country;
|
|
328
|
+
const city = row.city ?? session?.city;
|
|
329
|
+
const latitude = row.latitude ?? session?.latitude;
|
|
330
|
+
const longitude = row.longitude ?? session?.longitude;
|
|
331
|
+
return {
|
|
332
|
+
...row,
|
|
333
|
+
...(country ? { country } : {}),
|
|
334
|
+
...(city ? { city } : {}),
|
|
335
|
+
...(latitude !== undefined ? { latitude } : {}),
|
|
336
|
+
...(longitude !== undefined ? { longitude } : {}),
|
|
337
|
+
browser: row.browser ?? session?.browser ?? "Other",
|
|
338
|
+
os: row.os ?? session?.os ?? "Other",
|
|
339
|
+
device: row.device ?? session?.device ?? "unknown",
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function deleteTelemetryBefore(
|
|
345
|
+
ctx: MutationCtx,
|
|
346
|
+
input: {
|
|
347
|
+
siteId: Id<"sites">;
|
|
348
|
+
before: number;
|
|
349
|
+
batchSize: number;
|
|
350
|
+
},
|
|
351
|
+
) {
|
|
352
|
+
const batchCutoff = eventBucketStart(input.before);
|
|
353
|
+
const limit = input.batchSize + 1;
|
|
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);
|
|
361
|
+
for (const row of rows) {
|
|
362
|
+
await ctx.db.delete("eventBatches", row._id);
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
deleted: rows.length,
|
|
366
|
+
hasMore: batches.length > input.batchSize,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function loadTelemetryMinute(
|
|
371
|
+
ctx: MutationCtx,
|
|
372
|
+
input: {
|
|
373
|
+
siteId: Id<"sites">;
|
|
374
|
+
sessionId: string;
|
|
375
|
+
bucketStart: number;
|
|
376
|
+
},
|
|
377
|
+
) {
|
|
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),
|
|
385
|
+
)
|
|
386
|
+
.take(MAX_EVENTS_PER_SESSION_WINDOW + 1);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function telemetryMinuteCount(batches: Array<{ events: TrackerEvent[] }>) {
|
|
390
|
+
return batches.reduce((total, batch) => total + batch.events.length, 0);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function eventForBatch(
|
|
394
|
+
event: TrackerEvent,
|
|
395
|
+
country: string,
|
|
396
|
+
): TrackerEvent & { aggregateCountry?: string } {
|
|
397
|
+
return {
|
|
398
|
+
eventId: event.eventId,
|
|
399
|
+
sessionId: event.sessionId,
|
|
400
|
+
visitorId: event.visitorId,
|
|
401
|
+
type: event.type,
|
|
402
|
+
...(event.name ? { name: event.name } : {}),
|
|
403
|
+
path: event.path,
|
|
404
|
+
...(event.referrer ? { referrer: event.referrer } : {}),
|
|
405
|
+
timestamp: event.timestamp,
|
|
406
|
+
sequence: event.sequence,
|
|
407
|
+
...(event.target ? { target: event.target } : {}),
|
|
408
|
+
...(event.href ? { href: event.href } : {}),
|
|
409
|
+
...(event.properties ? { properties: event.properties } : {}),
|
|
410
|
+
...(event.revenueCents !== undefined
|
|
411
|
+
? { revenueCents: event.revenueCents }
|
|
412
|
+
: {}),
|
|
413
|
+
...(event.currency ? { currency: event.currency } : {}),
|
|
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 } : {}),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
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
|
+
|
|
480
|
+
function compareJourneyEventToCursor(
|
|
481
|
+
event: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
|
|
482
|
+
cursor: JourneyCursor,
|
|
483
|
+
) {
|
|
484
|
+
return (
|
|
485
|
+
event.timestamp - cursor.timestamp ||
|
|
486
|
+
event._creationTime - cursor.creationTime ||
|
|
487
|
+
event._id.localeCompare(cursor.id)
|
|
488
|
+
);
|
|
489
|
+
}
|