@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
package/dist/component/ingest.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { mutation } from "./_generated/server.js";
|
|
3
3
|
import { internal } from "./_generated/api.js";
|
|
4
|
-
import { AGGREGATE_SHARDS, DAY_MS, DIMENSION_SLOTS, HOUR_MS, LIVE_SESSION_TTL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, RATE_LIMIT_WINDOW_MS, SITE_INGEST_SHARDS, } from "./constants.js";
|
|
4
|
+
import { AGGREGATE_SHARDS, MAX_BATCH_EVENT_GROUPS, DAY_MS, HOURLY_AGGREGATE_SHARDS, DIMENSION_SLOTS, HOUR_MS, LIVE_LEAVE_GRACE_MS, LIVE_SESSION_TTL_MS, LIVE_SWEEP_INTERVAL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_FUNNEL_STEPS, MAX_GOALS_PER_SITE, MAX_SITE_MAP_ROUTES_PER_DAY, RATE_LIMIT_WINDOW_MS, REPORT_ROLLUP_SHARDS, ROUTE_TRANSITION_SLOTS, SECONDARY_AGGREGATE_SHARDS, SITE_INGEST_SHARDS, SITE_MAP_OTHER_ROUTE, VITAL_PAGE_SLOTS, } from "./constants.js";
|
|
5
|
+
import { addVisitor, createVisitorSketch, unionVisitorSketches, } from "./cardinality.js";
|
|
6
|
+
import { addToVitalHistogram, emptyVitalHistogram, mergeVitalHistograms, vitalDevice, VITAL_ALL, VITAL_OTHER_PAGES, } from "./vitals.js";
|
|
5
7
|
import { fail } from "./errors.js";
|
|
6
|
-
import {
|
|
8
|
+
import { ensureAnalyticsControl } from "./coverage.js";
|
|
9
|
+
import { bufferEventBatch, eventBatchKey, eventBucketStart, findTelemetry, flushEventBatches, insertEventBatch, inspectTelemetryMinute, } from "./eventStore.js";
|
|
10
|
+
import { cleanString, isValidMoney, measureBatchBytes, normalizeRoute, originAllowed, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
|
|
7
11
|
import { ingestContextValidator, ingestResultValidator, trackerEventValidator, trustedConversionValidator, } from "./validators.js";
|
|
8
12
|
export const ingestBatch = mutation({
|
|
9
13
|
args: {
|
|
@@ -27,35 +31,98 @@ export const ingestBatch = mutation({
|
|
|
27
31
|
limit: MAX_BATCH_EVENTS,
|
|
28
32
|
});
|
|
29
33
|
}
|
|
34
|
+
let eventByteCounts;
|
|
30
35
|
try {
|
|
31
|
-
|
|
36
|
+
({ eventByteCounts } = measureBatchBytes(String(args.siteId), args.events));
|
|
32
37
|
}
|
|
33
38
|
catch {
|
|
34
39
|
fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
|
|
35
40
|
}
|
|
36
41
|
const now = Date.now();
|
|
37
|
-
|
|
42
|
+
await ensureAnalyticsControl(ctx, args.siteId, now);
|
|
43
|
+
await consumeSiteRateLimit(ctx, args.siteId, args.events, eventByteCounts, now);
|
|
38
44
|
const context = sanitizeContext(args.context);
|
|
39
45
|
const aggregateDeltas = new Map();
|
|
46
|
+
const reportRollupDeltas = createReportRollupDeltas();
|
|
47
|
+
const sessionRateWindows = new Map();
|
|
48
|
+
const batchState = {
|
|
49
|
+
sessions: new Map(),
|
|
50
|
+
liveSessions: new Map(),
|
|
51
|
+
visitorAttributions: new Map(),
|
|
52
|
+
funnelProgress: new Map(),
|
|
53
|
+
eventBatches: new Map(),
|
|
54
|
+
liveSweepSites: new Set(),
|
|
55
|
+
};
|
|
40
56
|
let goals = null;
|
|
41
57
|
let funnels = null;
|
|
42
58
|
let accepted = 0;
|
|
43
59
|
let duplicates = 0;
|
|
44
60
|
let rejected = 0;
|
|
45
|
-
|
|
46
|
-
const input = args.events[eventIndex];
|
|
47
|
-
let event;
|
|
61
|
+
const events = args.events.map((input) => {
|
|
48
62
|
try {
|
|
49
|
-
|
|
63
|
+
return sanitizeEvent(input, now);
|
|
50
64
|
}
|
|
51
65
|
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
const duplicateInBatch = new Set();
|
|
70
|
+
const eventIdsByGroup = new Map();
|
|
71
|
+
const requestGroups = new Set();
|
|
72
|
+
const dedupeGroups = new Map();
|
|
73
|
+
for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
|
|
74
|
+
const event = events[eventIndex];
|
|
75
|
+
if (!event)
|
|
76
|
+
continue;
|
|
77
|
+
const bucketStart = eventBucketStart(event.timestamp);
|
|
78
|
+
const groupKey = eventBatchKey(event.sessionId, bucketStart);
|
|
79
|
+
requestGroups.add(groupKey);
|
|
80
|
+
if (event.type === "heartbeat" ||
|
|
81
|
+
event.type === "vital" ||
|
|
82
|
+
event.type === "leave")
|
|
83
|
+
continue;
|
|
84
|
+
const eventIds = eventIdsByGroup.get(groupKey) ?? new Set();
|
|
85
|
+
if (eventIds.has(event.eventId)) {
|
|
86
|
+
duplicateInBatch.add(eventIndex);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
eventIds.add(event.eventId);
|
|
90
|
+
eventIdsByGroup.set(groupKey, eventIds);
|
|
91
|
+
const group = dedupeGroups.get(groupKey) ?? {
|
|
92
|
+
sessionId: event.sessionId,
|
|
93
|
+
bucketStart,
|
|
94
|
+
eventIds: new Set(),
|
|
95
|
+
};
|
|
96
|
+
group.eventIds.add(event.eventId);
|
|
97
|
+
dedupeGroups.set(groupKey, group);
|
|
98
|
+
}
|
|
99
|
+
if (requestGroups.size > MAX_BATCH_EVENT_GROUPS) {
|
|
100
|
+
fail("BATCH_TOO_LARGE", `events may span at most ${MAX_BATCH_EVENT_GROUPS} session-minute groups`, { limit: MAX_BATCH_EVENT_GROUPS });
|
|
101
|
+
}
|
|
102
|
+
const persistedEventIds = new Map();
|
|
103
|
+
await Promise.all([...dedupeGroups].map(async ([key, group]) => {
|
|
104
|
+
const { eventIds, persistedCount } = await inspectTelemetryMinute(ctx, {
|
|
105
|
+
siteId: args.siteId,
|
|
106
|
+
sessionId: group.sessionId,
|
|
107
|
+
bucketStart: group.bucketStart,
|
|
108
|
+
});
|
|
109
|
+
const newCount = [...group.eventIds].filter((eventId) => !eventIds.has(eventId)).length;
|
|
110
|
+
if (newCount > 0 &&
|
|
111
|
+
persistedCount + newCount > MAX_EVENTS_PER_SESSION_WINDOW) {
|
|
112
|
+
fail("RATE_LIMITED", "session event minute limit exceeded", {
|
|
113
|
+
limit: MAX_EVENTS_PER_SESSION_WINDOW,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
persistedEventIds.set(key, eventIds);
|
|
117
|
+
}));
|
|
118
|
+
for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
|
|
119
|
+
const event = events[eventIndex];
|
|
120
|
+
if (!event) {
|
|
52
121
|
rejected += 1;
|
|
53
122
|
continue;
|
|
54
123
|
}
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
.withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", args.siteId).eq("sessionId", event.sessionId))
|
|
58
|
-
.unique();
|
|
124
|
+
const sessionRow = await loadSession(ctx, args.siteId, event.sessionId, batchState);
|
|
125
|
+
const existingSession = sessionRow.current;
|
|
59
126
|
if (existingSession && existingSession.visitorId !== event.visitorId) {
|
|
60
127
|
rejected += 1;
|
|
61
128
|
continue;
|
|
@@ -65,31 +132,70 @@ export const ingestBatch = mutation({
|
|
|
65
132
|
rejected += 1;
|
|
66
133
|
continue;
|
|
67
134
|
}
|
|
68
|
-
const liveSession = await ctx.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (liveSession?.lastHeartbeatEventId === event.eventId) {
|
|
135
|
+
const liveSession = (await loadLiveSession(ctx, args.siteId, event.sessionId, batchState)).current;
|
|
136
|
+
if (liveSession &&
|
|
137
|
+
(liveSession.lastHeartbeatEventId === event.eventId ||
|
|
138
|
+
liveSession.recentHeartbeatEventIds?.includes(event.eventId))) {
|
|
73
139
|
duplicates += 1;
|
|
74
140
|
continue;
|
|
75
141
|
}
|
|
76
|
-
if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now))) {
|
|
142
|
+
if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
|
|
77
143
|
rejected += 1;
|
|
78
144
|
continue;
|
|
79
145
|
}
|
|
80
|
-
await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now);
|
|
146
|
+
await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now, batchState);
|
|
81
147
|
accepted += 1;
|
|
82
148
|
continue;
|
|
83
149
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
.
|
|
88
|
-
|
|
150
|
+
if (event.type === "leave") {
|
|
151
|
+
// A departure only shortens live presence: it stores no telemetry,
|
|
152
|
+
// touches no aggregates, and never extends anything, so a replayed
|
|
153
|
+
// or late leave is inert. The read side filters by expiresAt and the
|
|
154
|
+
// sweep deletes the row, so the dashboard sees the exit in seconds.
|
|
155
|
+
if (!existingSession) {
|
|
156
|
+
rejected += 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
|
|
160
|
+
rejected += 1;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const liveState = await loadLiveSession(ctx, args.siteId, event.sessionId, batchState);
|
|
164
|
+
const liveSession = liveState.current;
|
|
165
|
+
if (liveSession) {
|
|
166
|
+
const expiresAt = Math.min(liveSession.expiresAt, now + LIVE_LEAVE_GRACE_MS);
|
|
167
|
+
if (expiresAt < liveSession.expiresAt) {
|
|
168
|
+
await ctx.db.patch("liveSessions", liveSession._id, { expiresAt });
|
|
169
|
+
liveState.current = { ...liveSession, expiresAt };
|
|
170
|
+
}
|
|
171
|
+
await ensureLiveSweep(ctx, args.siteId, now, batchState);
|
|
172
|
+
}
|
|
173
|
+
accepted += 1;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (event.type === "vital") {
|
|
177
|
+
// A vital annotates telemetry its session already produced; it never
|
|
178
|
+
// creates sessions, live presence, event batches, or aggregate counts.
|
|
179
|
+
if (!existingSession) {
|
|
180
|
+
rejected += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
|
|
184
|
+
rejected += 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
foldVitalRollups(reportRollupDeltas, args.siteId, event, vitalDevice(context.device));
|
|
188
|
+
accepted += 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (duplicateInBatch.has(eventIndex) ||
|
|
192
|
+
persistedEventIds
|
|
193
|
+
.get(eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)))
|
|
194
|
+
?.has(event.eventId)) {
|
|
89
195
|
duplicates += 1;
|
|
90
196
|
continue;
|
|
91
197
|
}
|
|
92
|
-
if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now))) {
|
|
198
|
+
if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
|
|
93
199
|
rejected += 1;
|
|
94
200
|
continue;
|
|
95
201
|
}
|
|
@@ -106,22 +212,28 @@ export const ingestBatch = mutation({
|
|
|
106
212
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
107
213
|
.take(MAX_FUNNELS_PER_SITE);
|
|
108
214
|
}
|
|
109
|
-
await accountEvent(ctx, {
|
|
215
|
+
const session = await accountEvent(ctx, {
|
|
110
216
|
siteId: args.siteId,
|
|
111
217
|
event,
|
|
112
218
|
context,
|
|
113
219
|
existingSession,
|
|
114
|
-
goals: canMatchDefinition ? goals ?? [] : [],
|
|
115
|
-
funnels: canMatchDefinition ? funnels ?? [] : [],
|
|
220
|
+
goals: canMatchDefinition ? (goals ?? []) : [],
|
|
221
|
+
funnels: canMatchDefinition ? (funnels ?? []) : [],
|
|
116
222
|
aggregateDeltas,
|
|
223
|
+
reportRollupDeltas,
|
|
117
224
|
recordTelemetry: true,
|
|
118
225
|
financialConversion: false,
|
|
119
226
|
trustedGoalKey: undefined,
|
|
120
227
|
now,
|
|
228
|
+
batchState,
|
|
121
229
|
});
|
|
230
|
+
bufferEventBatch(args.siteId, event, batchState.eventBatches, session.country ?? "unknown");
|
|
122
231
|
accepted += 1;
|
|
123
232
|
}
|
|
233
|
+
await flushBatchState(ctx, batchState);
|
|
234
|
+
await flushSessionRateLimits(ctx, sessionRateWindows, now);
|
|
124
235
|
await flushAggregates(ctx, aggregateDeltas);
|
|
236
|
+
await flushReportRollups(ctx, reportRollupDeltas);
|
|
125
237
|
return { accepted, duplicates, rejected };
|
|
126
238
|
},
|
|
127
239
|
});
|
|
@@ -136,13 +248,17 @@ export const trackConversion = mutation({
|
|
|
136
248
|
fail("INVALID_ARGUMENT", "revenueCents must be an integer between 0 and 1000000000");
|
|
137
249
|
}
|
|
138
250
|
let event;
|
|
251
|
+
let trustedGoalKey;
|
|
139
252
|
try {
|
|
253
|
+
trustedGoalKey = args.goalKey
|
|
254
|
+
? sanitizeSlug(args.goalKey, "goalKey")
|
|
255
|
+
: undefined;
|
|
140
256
|
event = sanitizeEvent({
|
|
141
257
|
eventId: sanitizeOpaqueId(args.eventId, "eventId"),
|
|
142
258
|
sessionId: sanitizeOpaqueId(args.sessionId, "sessionId"),
|
|
143
259
|
visitorId: sanitizeOpaqueId(args.visitorId, "visitorId"),
|
|
144
260
|
type: "conversion",
|
|
145
|
-
name:
|
|
261
|
+
name: trustedGoalKey ?? "conversion",
|
|
146
262
|
path: sanitizePath(args.path ?? "/conversion"),
|
|
147
263
|
timestamp: args.timestamp,
|
|
148
264
|
sequence: 0,
|
|
@@ -155,23 +271,24 @@ export const trackConversion = mutation({
|
|
|
155
271
|
fail("INVALID_ARGUMENT", error instanceof Error ? error.message : "invalid conversion");
|
|
156
272
|
}
|
|
157
273
|
const siteCurrency = site.currency ?? "USD";
|
|
158
|
-
if (event.currency !== siteCurrency) {
|
|
159
|
-
fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
|
|
160
|
-
siteCurrency,
|
|
161
|
-
conversionCurrency: event.currency ?? "USD",
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
274
|
const priorConversion = await ctx.db
|
|
165
275
|
.query("conversions")
|
|
166
276
|
.withIndex("by_siteId_and_eventId", (range) => range.eq("siteId", args.siteId).eq("eventId", event.eventId))
|
|
167
277
|
.unique();
|
|
168
278
|
if (priorConversion) {
|
|
279
|
+
if (!sameTrustedConversion(priorConversion, event, trustedGoalKey)) {
|
|
280
|
+
fail("CONFLICT", "conversion eventId is already associated with a different payload");
|
|
281
|
+
}
|
|
169
282
|
return { accepted: 0, duplicates: 1, rejected: 0 };
|
|
170
283
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
284
|
+
if (event.currency !== siteCurrency) {
|
|
285
|
+
fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
|
|
286
|
+
siteCurrency,
|
|
287
|
+
conversionCurrency: event.currency ?? "USD",
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
const telemetry = await findTelemetry(ctx, args.siteId, event);
|
|
291
|
+
const existingTelemetry = telemetry.match;
|
|
175
292
|
if (existingTelemetry &&
|
|
176
293
|
(existingTelemetry.sessionId !== event.sessionId ||
|
|
177
294
|
existingTelemetry.visitorId !== event.visitorId)) {
|
|
@@ -184,19 +301,18 @@ export const trackConversion = mutation({
|
|
|
184
301
|
if (existingSession && existingSession.visitorId !== event.visitorId) {
|
|
185
302
|
fail("CONFLICT", "session belongs to a different visitor");
|
|
186
303
|
}
|
|
187
|
-
const conversionContext =
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
: sanitizeContext(undefined);
|
|
304
|
+
const conversionContext = {
|
|
305
|
+
country: existingTelemetry?.country ?? existingSession?.country,
|
|
306
|
+
city: existingTelemetry?.city ?? existingSession?.city,
|
|
307
|
+
latitude: existingTelemetry?.latitude ?? existingSession?.latitude,
|
|
308
|
+
longitude: existingTelemetry?.longitude ?? existingSession?.longitude,
|
|
309
|
+
browser: existingTelemetry?.browser ?? existingSession?.browser,
|
|
310
|
+
os: existingTelemetry?.os ?? existingSession?.os,
|
|
311
|
+
device: existingTelemetry?.device ?? existingSession?.device,
|
|
312
|
+
};
|
|
198
313
|
const aggregateDeltas = new Map();
|
|
199
|
-
|
|
314
|
+
const reportRollupDeltas = createReportRollupDeltas();
|
|
315
|
+
const session = await accountEvent(ctx, {
|
|
200
316
|
siteId: args.siteId,
|
|
201
317
|
event,
|
|
202
318
|
context: conversionContext,
|
|
@@ -204,105 +320,170 @@ export const trackConversion = mutation({
|
|
|
204
320
|
goals: [],
|
|
205
321
|
funnels: [],
|
|
206
322
|
aggregateDeltas,
|
|
207
|
-
|
|
323
|
+
reportRollupDeltas,
|
|
324
|
+
recordTelemetry: existingTelemetry === null && !telemetry.saturated,
|
|
208
325
|
financialConversion: true,
|
|
209
|
-
trustedGoalKey
|
|
210
|
-
? sanitizeSlug(args.goalKey, "goalKey")
|
|
211
|
-
: undefined,
|
|
326
|
+
trustedGoalKey,
|
|
212
327
|
now: Date.now(),
|
|
213
328
|
});
|
|
329
|
+
if (!existingTelemetry && !telemetry.saturated) {
|
|
330
|
+
await insertEventBatch(ctx, args.siteId, event, session.country ?? "unknown");
|
|
331
|
+
}
|
|
214
332
|
await flushAggregates(ctx, aggregateDeltas);
|
|
333
|
+
await flushReportRollups(ctx, reportRollupDeltas);
|
|
334
|
+
await ensureAnalyticsControl(ctx, args.siteId, Date.now());
|
|
215
335
|
return { accepted: 1, duplicates: 0, rejected: 0 };
|
|
216
336
|
},
|
|
217
337
|
});
|
|
338
|
+
function sameTrustedConversion(existing, event, goalKey) {
|
|
339
|
+
return (existing.source === "trusted" &&
|
|
340
|
+
existing.sessionId === event.sessionId &&
|
|
341
|
+
existing.visitorId === event.visitorId &&
|
|
342
|
+
existing.timestamp === event.timestamp &&
|
|
343
|
+
existing.path === event.path &&
|
|
344
|
+
existing.revenueCents === (event.revenueCents ?? 0) &&
|
|
345
|
+
existing.currency === (event.currency ?? "USD") &&
|
|
346
|
+
existing.goalKey === goalKey);
|
|
347
|
+
}
|
|
348
|
+
async function loadSession(ctx, siteId, sessionId, batchState) {
|
|
349
|
+
const cached = batchState.sessions.get(sessionId);
|
|
350
|
+
if (cached)
|
|
351
|
+
return cached;
|
|
352
|
+
const current = await ctx.db
|
|
353
|
+
.query("sessions")
|
|
354
|
+
.withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
|
|
355
|
+
.unique();
|
|
356
|
+
const loaded = { current, dirty: false };
|
|
357
|
+
batchState.sessions.set(sessionId, loaded);
|
|
358
|
+
return loaded;
|
|
359
|
+
}
|
|
360
|
+
async function loadLiveSession(ctx, siteId, sessionId, batchState) {
|
|
361
|
+
const cached = batchState.liveSessions.get(sessionId);
|
|
362
|
+
if (cached)
|
|
363
|
+
return cached;
|
|
364
|
+
const current = await ctx.db
|
|
365
|
+
.query("liveSessions")
|
|
366
|
+
.withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
|
|
367
|
+
.unique();
|
|
368
|
+
const loaded = { current, dirty: false };
|
|
369
|
+
batchState.liveSessions.set(sessionId, loaded);
|
|
370
|
+
return loaded;
|
|
371
|
+
}
|
|
372
|
+
async function flushBatchState(ctx, batchState) {
|
|
373
|
+
for (const state of batchState.sessions.values()) {
|
|
374
|
+
if (state.dirty && state.current) {
|
|
375
|
+
await ctx.db.replace("sessions", state.current._id, withoutSystemFields(state.current));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
for (const state of batchState.liveSessions.values()) {
|
|
379
|
+
if (state.dirty && state.current) {
|
|
380
|
+
await ctx.db.replace("liveSessions", state.current._id, withoutSystemFields(state.current));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
for (const state of batchState.visitorAttributions.values()) {
|
|
384
|
+
if (state.dirty && state.current) {
|
|
385
|
+
await ctx.db.replace("visitorAttributions", state.current._id, withoutSystemFields(state.current));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const state of batchState.funnelProgress.values()) {
|
|
389
|
+
if (state.dirty && state.current) {
|
|
390
|
+
await ctx.db.replace("funnelProgress", state.current._id, withoutSystemFields(state.current));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
await flushEventBatches(ctx, batchState.eventBatches);
|
|
394
|
+
}
|
|
395
|
+
function withoutSystemFields(document) {
|
|
396
|
+
const { _id, _creationTime, ...fields } = document;
|
|
397
|
+
void _id;
|
|
398
|
+
void _creationTime;
|
|
399
|
+
return fields;
|
|
400
|
+
}
|
|
218
401
|
async function accountEvent(ctx, input) {
|
|
219
402
|
const { event } = input;
|
|
220
403
|
const session = input.recordTelemetry
|
|
221
|
-
? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion)
|
|
404
|
+
? await upsertSession(ctx, input.siteId, event, input.context, input.existingSession, input.financialConversion, input.reportRollupDeltas, input.batchState)
|
|
222
405
|
: await accountTrustedSession(ctx, input.existingSession, event);
|
|
223
|
-
if (input.recordTelemetry) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
sessionId: event.sessionId,
|
|
228
|
-
visitorId: event.visitorId,
|
|
229
|
-
type: event.type,
|
|
230
|
-
...(event.name ? { name: event.name } : {}),
|
|
231
|
-
path: event.path,
|
|
232
|
-
...(event.referrer ? { referrer: event.referrer } : {}),
|
|
233
|
-
timestamp: event.timestamp,
|
|
234
|
-
sequence: event.sequence,
|
|
235
|
-
...(event.target ? { target: event.target } : {}),
|
|
236
|
-
...(event.href ? { href: event.href } : {}),
|
|
237
|
-
...(event.properties ? { properties: event.properties } : {}),
|
|
238
|
-
...(event.revenueCents !== undefined
|
|
239
|
-
? { revenueCents: event.revenueCents }
|
|
240
|
-
: {}),
|
|
241
|
-
...(event.currency ? { currency: event.currency } : {}),
|
|
242
|
-
...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
|
|
243
|
-
...(input.context.country ? { country: input.context.country } : {}),
|
|
244
|
-
...(input.context.city ? { city: input.context.city } : {}),
|
|
245
|
-
...(input.context.latitude !== undefined
|
|
246
|
-
? { latitude: input.context.latitude }
|
|
247
|
-
: {}),
|
|
248
|
-
...(input.context.longitude !== undefined
|
|
249
|
-
? { longitude: input.context.longitude }
|
|
250
|
-
: {}),
|
|
251
|
-
browser: input.context.browser ?? "Other",
|
|
252
|
-
os: input.context.os ?? "Other",
|
|
253
|
-
device: input.context.device ?? "unknown",
|
|
254
|
-
});
|
|
406
|
+
if (input.recordTelemetry && event.type === "pageview") {
|
|
407
|
+
// `existingSession` is the snapshot from before `upsertSession`, so it
|
|
408
|
+
// still carries the route the visitor is leaving.
|
|
409
|
+
foldRouteRollups(input.reportRollupDeltas, input.siteId, event, session, input.existingSession);
|
|
255
410
|
}
|
|
256
411
|
const matchedGoalKeys = [];
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
412
|
+
const matchingGoals = input.goals.filter((goal) => goal.active && goalMatches(goal, event));
|
|
413
|
+
if (matchingGoals.length > 1) {
|
|
414
|
+
fail("LIMIT_EXCEEDED", "active goals must use unique matchers");
|
|
415
|
+
}
|
|
416
|
+
for (const goal of matchingGoals) {
|
|
417
|
+
const completionEventId = JSON.stringify([
|
|
418
|
+
event.sessionId,
|
|
419
|
+
eventBucketStart(event.timestamp),
|
|
420
|
+
event.eventId,
|
|
421
|
+
]);
|
|
422
|
+
const scopedPrior = (await ctx.db
|
|
261
423
|
.query("goalCompletions")
|
|
262
424
|
.withIndex("by_siteId_and_goalId_and_eventId", (range) => range
|
|
263
425
|
.eq("siteId", input.siteId)
|
|
264
426
|
.eq("goalId", goal._id)
|
|
265
|
-
.eq("eventId",
|
|
266
|
-
.
|
|
267
|
-
|
|
427
|
+
.eq("eventId", completionEventId))
|
|
428
|
+
.take(2)).find((completion) => completion.idempotencyVersion === 2);
|
|
429
|
+
const legacyPrior = scopedPrior
|
|
430
|
+
? undefined
|
|
431
|
+
: (await ctx.db
|
|
432
|
+
.query("goalCompletions")
|
|
433
|
+
.withIndex("by_siteId_and_goalId_and_eventId", (range) => range
|
|
434
|
+
.eq("siteId", input.siteId)
|
|
435
|
+
.eq("goalId", goal._id)
|
|
436
|
+
.eq("eventId", event.eventId))
|
|
437
|
+
.take(2)).find((completion) => completion.idempotencyVersion === undefined &&
|
|
438
|
+
completion.sessionId === event.sessionId &&
|
|
439
|
+
eventBucketStart(completion.timestamp) ===
|
|
440
|
+
eventBucketStart(event.timestamp));
|
|
441
|
+
if (scopedPrior || legacyPrior)
|
|
268
442
|
continue;
|
|
269
443
|
await ctx.db.insert("goalCompletions", {
|
|
270
444
|
siteId: input.siteId,
|
|
271
445
|
goalId: goal._id,
|
|
272
446
|
goalKey: goal.key,
|
|
273
|
-
eventId:
|
|
447
|
+
eventId: completionEventId,
|
|
448
|
+
idempotencyVersion: 2,
|
|
274
449
|
sessionId: event.sessionId,
|
|
275
450
|
visitorId: event.visitorId,
|
|
276
451
|
timestamp: event.timestamp,
|
|
277
452
|
valueCents: goal.valueCents ?? 0,
|
|
278
453
|
});
|
|
454
|
+
foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
|
|
279
455
|
matchedGoalKeys.push(goal.key);
|
|
280
456
|
}
|
|
281
457
|
for (const funnel of input.funnels) {
|
|
282
458
|
if (funnel.active) {
|
|
283
|
-
await advanceFunnel(ctx, input.siteId, funnel, event);
|
|
459
|
+
await advanceFunnel(ctx, input.siteId, funnel, event, input.reportRollupDeltas, input.batchState);
|
|
284
460
|
}
|
|
285
461
|
}
|
|
286
462
|
if (input.financialConversion) {
|
|
287
|
-
await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey);
|
|
463
|
+
await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey, input.reportRollupDeltas);
|
|
288
464
|
}
|
|
465
|
+
const campaignDimensions = session.utmCampaign
|
|
466
|
+
? [{ type: "campaign", value: session.utmCampaign }]
|
|
467
|
+
: [];
|
|
289
468
|
const dimensions = [
|
|
290
469
|
{ type: "source", value: session.source },
|
|
470
|
+
...campaignDimensions,
|
|
291
471
|
{ type: "page", value: event.path },
|
|
292
|
-
{ type: "country", value:
|
|
472
|
+
{ type: "country", value: session.country ?? "unknown" },
|
|
293
473
|
{ type: "device", value: input.context.device ?? "unknown" },
|
|
294
474
|
{ type: "browser", value: input.context.browser ?? "Other" },
|
|
295
475
|
{
|
|
296
476
|
type: "event",
|
|
297
|
-
value: event.type === "custom" ? event.name ?? "custom" : event.type,
|
|
477
|
+
value: event.type === "custom" ? (event.name ?? "custom") : event.type,
|
|
298
478
|
},
|
|
299
479
|
...matchedGoalKeys.map((value) => ({ type: "goal", value })),
|
|
300
480
|
];
|
|
301
481
|
foldAggregates(input.aggregateDeltas, input.siteId, event, session, dimensions, input.recordTelemetry, input.financialConversion);
|
|
302
482
|
if (input.recordTelemetry &&
|
|
303
483
|
event.timestamp >= input.now - LIVE_SESSION_TTL_MS) {
|
|
304
|
-
await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now);
|
|
484
|
+
await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now, input.batchState);
|
|
305
485
|
}
|
|
486
|
+
return session;
|
|
306
487
|
}
|
|
307
488
|
async function accountTrustedSession(ctx, existing, event) {
|
|
308
489
|
if (!existing) {
|
|
@@ -320,6 +501,12 @@ async function accountTrustedSession(ctx, existing, event) {
|
|
|
320
501
|
isNewVisitorDay: false,
|
|
321
502
|
startedAt: existing.startedAt,
|
|
322
503
|
source: existing.source,
|
|
504
|
+
utmCampaign: existing.utmCampaign,
|
|
505
|
+
visitorKey: existing.visitorKey ?? existing.visitorId,
|
|
506
|
+
country: existing.country,
|
|
507
|
+
city: existing.city,
|
|
508
|
+
latitude: existing.latitude,
|
|
509
|
+
longitude: existing.longitude,
|
|
323
510
|
pageviewCount: existing.pageviewCount,
|
|
324
511
|
eventCount: existing.eventCount,
|
|
325
512
|
affiliateId: existing.affiliateId,
|
|
@@ -328,26 +515,41 @@ async function accountTrustedSession(ctx, existing, event) {
|
|
|
328
515
|
affiliateExpiresAt: existing.affiliateExpiresAt,
|
|
329
516
|
};
|
|
330
517
|
}
|
|
331
|
-
async function upsertSession(ctx, siteId, event, context, existing, financialConversion) {
|
|
332
|
-
|
|
518
|
+
async function upsertSession(ctx, siteId, event, context, existing, financialConversion, reportRollupDeltas, batchState) {
|
|
519
|
+
// A host-supplied id is the identity; anonymous sessions count through the
|
|
520
|
+
// daily key derived by the HTTP action, or once per session without one.
|
|
521
|
+
const visitorKey = existing?.visitorKey ??
|
|
522
|
+
(event.visitorId !== event.sessionId
|
|
523
|
+
? event.visitorId
|
|
524
|
+
: (context.visitorKey ?? event.sessionId));
|
|
333
525
|
const utmSource = stringProperty(event.properties, "utm_source");
|
|
526
|
+
// Campaign traffic names its own source; the referrer host is the fallback.
|
|
527
|
+
const source = utmSource?.toLowerCase() ?? sourceFromReferrer(event.referrer);
|
|
334
528
|
const utmMedium = stringProperty(event.properties, "utm_medium");
|
|
335
529
|
const utmCampaign = stringProperty(event.properties, "utm_campaign");
|
|
336
|
-
const attribution = await attributionForEvent(ctx, siteId, event, existing);
|
|
530
|
+
const attribution = await attributionForEvent(ctx, siteId, event, existing, batchState);
|
|
337
531
|
if (existing) {
|
|
532
|
+
if (attribution && existing.affiliateId !== attribution.affiliateId) {
|
|
533
|
+
foldAffiliateVisitRollup(reportRollupDeltas, siteId, event, attribution, visitorKey);
|
|
534
|
+
}
|
|
338
535
|
const startedAt = Math.min(existing.startedAt, event.timestamp);
|
|
339
536
|
const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
|
|
340
|
-
|
|
537
|
+
const updates = {
|
|
341
538
|
startedAt,
|
|
342
539
|
lastSeenAt,
|
|
343
540
|
entryPath: event.timestamp < existing.startedAt ? event.path : existing.entryPath,
|
|
344
541
|
exitPath: event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
|
|
345
542
|
eventCount: existing.eventCount + 1,
|
|
346
543
|
pageviewCount: existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
|
|
544
|
+
...(event.type === "pageview"
|
|
545
|
+
? {
|
|
546
|
+
lastPageviewAt: Math.max(existing.lastPageviewAt ?? 0, event.timestamp),
|
|
547
|
+
}
|
|
548
|
+
: {}),
|
|
347
549
|
durationMs: Math.max(0, lastSeenAt - startedAt),
|
|
348
550
|
conversionCount: existing.conversionCount + Number(financialConversion),
|
|
349
551
|
revenueCents: existing.revenueCents +
|
|
350
|
-
(financialConversion ? event.revenueCents ?? 0 : 0),
|
|
552
|
+
(financialConversion ? (event.revenueCents ?? 0) : 0),
|
|
351
553
|
...(financialConversion && event.currency
|
|
352
554
|
? { currency: event.currency }
|
|
353
555
|
: {}),
|
|
@@ -358,11 +560,23 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
|
|
|
358
560
|
...(context.country ? { country: context.country } : {}),
|
|
359
561
|
...(context.city ? { city: context.city } : {}),
|
|
360
562
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
361
|
-
...(context.longitude !== undefined
|
|
563
|
+
...(context.longitude !== undefined
|
|
564
|
+
? { longitude: context.longitude }
|
|
565
|
+
: {}),
|
|
362
566
|
browser: context.browser ?? existing.browser,
|
|
363
567
|
os: context.os ?? existing.os,
|
|
364
568
|
device: context.device ?? existing.device,
|
|
365
|
-
}
|
|
569
|
+
};
|
|
570
|
+
if (batchState) {
|
|
571
|
+
const state = batchState.sessions.get(event.sessionId);
|
|
572
|
+
if (!state)
|
|
573
|
+
throw new Error("session cache was not initialized");
|
|
574
|
+
state.current = { ...existing, ...updates };
|
|
575
|
+
state.dirty = true;
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
await ctx.db.patch("sessions", existing._id, updates);
|
|
579
|
+
}
|
|
366
580
|
return {
|
|
367
581
|
isNewSession: false,
|
|
368
582
|
isNewVisitorHour: Math.floor(existing.lastSeenAt / HOUR_MS) <
|
|
@@ -371,20 +585,38 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
|
|
|
371
585
|
Math.floor(event.timestamp / DAY_MS),
|
|
372
586
|
startedAt,
|
|
373
587
|
source: existing.source,
|
|
588
|
+
utmCampaign: existing.utmCampaign,
|
|
589
|
+
visitorKey,
|
|
590
|
+
country: context.country ?? existing.country,
|
|
591
|
+
city: context.city ?? existing.city,
|
|
592
|
+
latitude: context.latitude ?? existing.latitude,
|
|
593
|
+
longitude: context.longitude ?? existing.longitude,
|
|
374
594
|
pageviewCount: existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
|
|
375
595
|
eventCount: existing.eventCount + 1,
|
|
376
596
|
...(attribution ?? {}),
|
|
377
597
|
};
|
|
378
598
|
}
|
|
379
|
-
const
|
|
599
|
+
const priorVisits = await ctx.db
|
|
380
600
|
.query("sessions")
|
|
381
601
|
.withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
|
|
382
602
|
.order("desc")
|
|
383
603
|
.take(1);
|
|
384
|
-
|
|
604
|
+
let priorVisit = priorVisits[0] ?? null;
|
|
605
|
+
if (batchState) {
|
|
606
|
+
for (const state of batchState.sessions.values()) {
|
|
607
|
+
const candidate = state.current;
|
|
608
|
+
if (candidate?.visitorId === event.visitorId &&
|
|
609
|
+
candidate.sessionId !== event.sessionId &&
|
|
610
|
+
(!priorVisit || candidate.lastSeenAt > priorVisit.lastSeenAt)) {
|
|
611
|
+
priorVisit = candidate;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
const fields = {
|
|
385
616
|
siteId,
|
|
386
617
|
sessionId: event.sessionId,
|
|
387
618
|
visitorId: event.visitorId,
|
|
619
|
+
visitorKey,
|
|
388
620
|
startedAt: event.timestamp,
|
|
389
621
|
lastSeenAt: event.timestamp,
|
|
390
622
|
entryPath: event.path,
|
|
@@ -397,30 +629,52 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
|
|
|
397
629
|
...(context.country ? { country: context.country } : {}),
|
|
398
630
|
...(context.city ? { city: context.city } : {}),
|
|
399
631
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
400
|
-
...(context.longitude !== undefined
|
|
632
|
+
...(context.longitude !== undefined
|
|
633
|
+
? { longitude: context.longitude }
|
|
634
|
+
: {}),
|
|
401
635
|
browser: context.browser ?? "Other",
|
|
402
636
|
os: context.os ?? "Other",
|
|
403
637
|
device: context.device ?? "unknown",
|
|
404
638
|
eventCount: 1,
|
|
405
639
|
pageviewCount: event.type === "pageview" ? 1 : 0,
|
|
640
|
+
...(event.type === "pageview" ? { lastPageviewAt: event.timestamp } : {}),
|
|
406
641
|
durationMs: 0,
|
|
407
642
|
conversionCount: Number(financialConversion),
|
|
408
|
-
revenueCents: financialConversion ? event.revenueCents ?? 0 : 0,
|
|
643
|
+
revenueCents: financialConversion ? (event.revenueCents ?? 0) : 0,
|
|
409
644
|
...(financialConversion && event.currency
|
|
410
645
|
? { currency: event.currency }
|
|
411
646
|
: {}),
|
|
412
647
|
...(attribution ?? {}),
|
|
413
|
-
}
|
|
648
|
+
};
|
|
649
|
+
const sessionId = await ctx.db.insert("sessions", fields);
|
|
650
|
+
if (attribution) {
|
|
651
|
+
foldAffiliateVisitRollup(reportRollupDeltas, siteId, event, attribution, visitorKey);
|
|
652
|
+
}
|
|
653
|
+
if (batchState) {
|
|
654
|
+
const state = batchState.sessions.get(event.sessionId);
|
|
655
|
+
if (!state)
|
|
656
|
+
throw new Error("session cache was not initialized");
|
|
657
|
+
state.current = {
|
|
658
|
+
...fields,
|
|
659
|
+
_id: sessionId,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
414
662
|
return {
|
|
415
663
|
isNewSession: true,
|
|
416
|
-
isNewVisitorHour: priorVisit
|
|
417
|
-
Math.floor(priorVisit
|
|
664
|
+
isNewVisitorHour: priorVisit === null ||
|
|
665
|
+
Math.floor(priorVisit.lastSeenAt / HOUR_MS) <
|
|
418
666
|
Math.floor(event.timestamp / HOUR_MS),
|
|
419
|
-
isNewVisitorDay: priorVisit
|
|
420
|
-
Math.floor(priorVisit
|
|
667
|
+
isNewVisitorDay: priorVisit === null ||
|
|
668
|
+
Math.floor(priorVisit.lastSeenAt / DAY_MS) <
|
|
421
669
|
Math.floor(event.timestamp / DAY_MS),
|
|
422
670
|
startedAt: event.timestamp,
|
|
423
671
|
source,
|
|
672
|
+
utmCampaign,
|
|
673
|
+
visitorKey,
|
|
674
|
+
country: context.country,
|
|
675
|
+
city: context.city,
|
|
676
|
+
latitude: context.latitude,
|
|
677
|
+
longitude: context.longitude,
|
|
424
678
|
pageviewCount: event.type === "pageview" ? 1 : 0,
|
|
425
679
|
eventCount: 1,
|
|
426
680
|
...(attribution ?? {}),
|
|
@@ -446,7 +700,7 @@ function attributionApplies(attribution, timestamp) {
|
|
|
446
700
|
}
|
|
447
701
|
// The first valid touch wins through its inclusive expiry. Only a later,
|
|
448
702
|
// in-order valid touch may replace the visitor's single bounded record.
|
|
449
|
-
async function attributionForEvent(ctx, siteId, event, existingSession) {
|
|
703
|
+
async function attributionForEvent(ctx, siteId, event, existingSession, batchState) {
|
|
450
704
|
const sessionAttribution = existingSession
|
|
451
705
|
? attributionSnapshot(existingSession)
|
|
452
706
|
: undefined;
|
|
@@ -458,10 +712,21 @@ async function attributionForEvent(ctx, siteId, event, existingSession) {
|
|
|
458
712
|
attributionApplies(sessionAttribution, event.timestamp)) {
|
|
459
713
|
return sessionAttribution;
|
|
460
714
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
715
|
+
let attributionState = batchState?.visitorAttributions.get(event.visitorId);
|
|
716
|
+
if (batchState && !attributionState) {
|
|
717
|
+
const current = await ctx.db
|
|
718
|
+
.query("visitorAttributions")
|
|
719
|
+
.withIndex("by_siteId_and_visitorId", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
|
|
720
|
+
.unique();
|
|
721
|
+
attributionState = { current, dirty: false };
|
|
722
|
+
batchState.visitorAttributions.set(event.visitorId, attributionState);
|
|
723
|
+
}
|
|
724
|
+
const current = attributionState
|
|
725
|
+
? attributionState.current
|
|
726
|
+
: await ctx.db
|
|
727
|
+
.query("visitorAttributions")
|
|
728
|
+
.withIndex("by_siteId_and_visitorId", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
|
|
729
|
+
.unique();
|
|
465
730
|
if (current) {
|
|
466
731
|
const snapshot = {
|
|
467
732
|
affiliateId: current.affiliateId,
|
|
@@ -489,23 +754,32 @@ async function attributionForEvent(ctx, siteId, event, existingSession) {
|
|
|
489
754
|
}
|
|
490
755
|
const attributedAt = event.timestamp;
|
|
491
756
|
const expiresAt = attributedAt + affiliate.attributionWindowDays * DAY_MS;
|
|
757
|
+
const fields = {
|
|
758
|
+
siteId,
|
|
759
|
+
visitorId: event.visitorId,
|
|
760
|
+
affiliateId: affiliate._id,
|
|
761
|
+
affiliateSlug: affiliate.slug,
|
|
762
|
+
attributedAt,
|
|
763
|
+
expiresAt,
|
|
764
|
+
};
|
|
492
765
|
if (current) {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
766
|
+
if (attributionState) {
|
|
767
|
+
attributionState.current = { ...current, ...fields };
|
|
768
|
+
attributionState.dirty = true;
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
await ctx.db.patch("visitorAttributions", current._id, fields);
|
|
772
|
+
}
|
|
499
773
|
}
|
|
500
774
|
else {
|
|
501
|
-
await ctx.db.insert("visitorAttributions",
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
775
|
+
const attributionId = await ctx.db.insert("visitorAttributions", fields);
|
|
776
|
+
if (attributionState) {
|
|
777
|
+
attributionState.current = {
|
|
778
|
+
...fields,
|
|
779
|
+
_id: attributionId,
|
|
780
|
+
_creationTime: event.timestamp,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
509
783
|
}
|
|
510
784
|
return {
|
|
511
785
|
affiliateId: affiliate._id,
|
|
@@ -514,44 +788,56 @@ async function attributionForEvent(ctx, siteId, event, existingSession) {
|
|
|
514
788
|
affiliateExpiresAt: expiresAt,
|
|
515
789
|
};
|
|
516
790
|
}
|
|
517
|
-
async function accountHeartbeat(ctx, siteId, session, event, context, now) {
|
|
791
|
+
async function accountHeartbeat(ctx, siteId, session, event, context, now, batchState) {
|
|
518
792
|
const startedAt = Math.min(session.startedAt, event.timestamp);
|
|
519
793
|
const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
|
|
520
|
-
|
|
794
|
+
const updates = {
|
|
521
795
|
startedAt,
|
|
522
796
|
lastSeenAt,
|
|
523
|
-
exitPath: event.timestamp >= session.lastSeenAt
|
|
524
|
-
? event.path
|
|
525
|
-
: session.exitPath,
|
|
797
|
+
exitPath: event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
|
|
526
798
|
durationMs: Math.max(0, lastSeenAt - startedAt),
|
|
527
799
|
...(context.country ? { country: context.country } : {}),
|
|
528
800
|
...(context.city ? { city: context.city } : {}),
|
|
529
801
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
530
|
-
...(context.longitude !== undefined
|
|
802
|
+
...(context.longitude !== undefined
|
|
803
|
+
? { longitude: context.longitude }
|
|
804
|
+
: {}),
|
|
531
805
|
browser: context.browser ?? session.browser,
|
|
532
806
|
os: context.os ?? session.os,
|
|
533
807
|
device: context.device ?? session.device,
|
|
534
|
-
}
|
|
808
|
+
};
|
|
809
|
+
if (batchState) {
|
|
810
|
+
const state = batchState.sessions.get(event.sessionId);
|
|
811
|
+
if (!state)
|
|
812
|
+
throw new Error("session cache was not initialized");
|
|
813
|
+
state.current = { ...session, ...updates };
|
|
814
|
+
state.dirty = true;
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
await ctx.db.patch("sessions", session._id, updates);
|
|
818
|
+
}
|
|
535
819
|
await updateLiveSession(ctx, siteId, event, context, {
|
|
536
820
|
isNewSession: false,
|
|
537
821
|
isNewVisitorHour: false,
|
|
538
822
|
isNewVisitorDay: false,
|
|
539
823
|
startedAt,
|
|
540
824
|
source: session.source,
|
|
825
|
+
visitorKey: session.visitorKey ?? session.visitorId,
|
|
826
|
+
country: context.country ?? session.country,
|
|
827
|
+
city: context.city ?? session.city,
|
|
828
|
+
latitude: context.latitude ?? session.latitude,
|
|
829
|
+
longitude: context.longitude ?? session.longitude,
|
|
541
830
|
pageviewCount: session.pageviewCount,
|
|
542
831
|
eventCount: session.eventCount,
|
|
543
832
|
affiliateSlug: session.affiliateSlug,
|
|
544
|
-
}, now);
|
|
833
|
+
}, now, batchState);
|
|
545
834
|
}
|
|
546
|
-
async function consumeSiteRateLimit(ctx, siteId, events, now) {
|
|
835
|
+
async function consumeSiteRateLimit(ctx, siteId, events, eventByteCounts, now) {
|
|
547
836
|
const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
548
837
|
const increments = new Map();
|
|
549
|
-
const eventByteCounts = [];
|
|
550
|
-
const encoder = new TextEncoder();
|
|
551
838
|
for (let index = 0; index < events.length; index += 1) {
|
|
552
839
|
const event = events[index];
|
|
553
|
-
const byteCount =
|
|
554
|
-
eventByteCounts.push(byteCount);
|
|
840
|
+
const byteCount = eventByteCounts[index];
|
|
555
841
|
const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
|
|
556
842
|
const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
|
|
557
843
|
current.eventCount += 1;
|
|
@@ -560,13 +846,13 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
|
|
|
560
846
|
}
|
|
561
847
|
const windows = [];
|
|
562
848
|
for (const [shard, increment] of increments) {
|
|
563
|
-
const
|
|
849
|
+
const rows = await ctx.db
|
|
564
850
|
.query("siteIngestWindows")
|
|
565
|
-
.withIndex("by_siteId_and_shard_and_windowStart", (range) => range
|
|
566
|
-
.
|
|
567
|
-
.
|
|
568
|
-
|
|
569
|
-
|
|
851
|
+
.withIndex("by_siteId_and_shard_and_windowStart", (range) => range.eq("siteId", siteId).eq("shard", shard))
|
|
852
|
+
.order("desc")
|
|
853
|
+
.take(1);
|
|
854
|
+
const storedWindow = rows[0] ?? null;
|
|
855
|
+
const window = storedWindow?.windowStart === windowStart ? storedWindow : null;
|
|
570
856
|
if ((window?.eventCount ?? 0) + increment.eventCount >
|
|
571
857
|
MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
|
|
572
858
|
(window?.byteCount ?? 0) + increment.byteCount >
|
|
@@ -580,13 +866,18 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
|
|
|
580
866
|
siteByteLimit: SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
581
867
|
});
|
|
582
868
|
}
|
|
583
|
-
windows.push({ shard, increment, window });
|
|
869
|
+
windows.push({ shard, increment, window: storedWindow });
|
|
584
870
|
}
|
|
585
871
|
for (const { shard, increment, window } of windows) {
|
|
586
872
|
if (window) {
|
|
587
873
|
await ctx.db.patch("siteIngestWindows", window._id, {
|
|
588
|
-
|
|
589
|
-
|
|
874
|
+
windowStart,
|
|
875
|
+
eventCount: window.windowStart === windowStart
|
|
876
|
+
? window.eventCount + increment.eventCount
|
|
877
|
+
: increment.eventCount,
|
|
878
|
+
byteCount: window.windowStart === windowStart
|
|
879
|
+
? window.byteCount + increment.byteCount
|
|
880
|
+
: increment.byteCount,
|
|
590
881
|
updatedAt: now,
|
|
591
882
|
});
|
|
592
883
|
}
|
|
@@ -601,76 +892,124 @@ async function consumeSiteRateLimit(ctx, siteId, events, now) {
|
|
|
601
892
|
});
|
|
602
893
|
}
|
|
603
894
|
}
|
|
604
|
-
return eventByteCounts;
|
|
605
895
|
}
|
|
606
|
-
async function consumeRateLimit(ctx, siteId, sessionId, byteCount, now) {
|
|
896
|
+
async function consumeRateLimit(ctx, siteId, sessionId, byteCount, now, states) {
|
|
607
897
|
const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
window.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW)) {
|
|
618
|
-
return false;
|
|
619
|
-
}
|
|
620
|
-
if (window) {
|
|
621
|
-
await ctx.db.patch("ingestWindows", window._id, {
|
|
622
|
-
eventCount: window.eventCount + 1,
|
|
623
|
-
byteCount: window.byteCount + byteCount,
|
|
624
|
-
updatedAt: now,
|
|
625
|
-
});
|
|
626
|
-
}
|
|
627
|
-
else {
|
|
628
|
-
await ctx.db.insert("ingestWindows", {
|
|
898
|
+
let state = states.get(sessionId);
|
|
899
|
+
if (!state) {
|
|
900
|
+
const rows = await ctx.db
|
|
901
|
+
.query("ingestWindows")
|
|
902
|
+
.withIndex("by_siteId_and_sessionId_and_windowStart", (range) => range.eq("siteId", siteId).eq("sessionId", sessionId))
|
|
903
|
+
.order("desc")
|
|
904
|
+
.take(1);
|
|
905
|
+
const window = rows[0] ?? null;
|
|
906
|
+
state = {
|
|
629
907
|
siteId,
|
|
630
908
|
sessionId,
|
|
631
|
-
|
|
632
|
-
eventCount:
|
|
633
|
-
byteCount,
|
|
634
|
-
|
|
635
|
-
}
|
|
909
|
+
window,
|
|
910
|
+
eventCount: window?.windowStart === windowStart ? window.eventCount : 0,
|
|
911
|
+
byteCount: window?.windowStart === windowStart ? window.byteCount : 0,
|
|
912
|
+
dirty: false,
|
|
913
|
+
};
|
|
914
|
+
states.set(sessionId, state);
|
|
636
915
|
}
|
|
916
|
+
if (state.eventCount >= MAX_EVENTS_PER_SESSION_WINDOW ||
|
|
917
|
+
state.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW) {
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
state.eventCount += 1;
|
|
921
|
+
state.byteCount += byteCount;
|
|
922
|
+
state.dirty = true;
|
|
637
923
|
return true;
|
|
638
924
|
}
|
|
639
|
-
async function
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
925
|
+
async function flushSessionRateLimits(ctx, states, now) {
|
|
926
|
+
const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
927
|
+
for (const state of states.values()) {
|
|
928
|
+
if (!state.dirty)
|
|
929
|
+
continue;
|
|
930
|
+
if (state.window) {
|
|
931
|
+
await ctx.db.patch("ingestWindows", state.window._id, {
|
|
932
|
+
windowStart,
|
|
933
|
+
eventCount: state.eventCount,
|
|
934
|
+
byteCount: state.byteCount,
|
|
935
|
+
updatedAt: now,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
await ctx.db.insert("ingestWindows", {
|
|
940
|
+
siteId: state.siteId,
|
|
941
|
+
sessionId: state.sessionId,
|
|
942
|
+
windowStart,
|
|
943
|
+
eventCount: state.eventCount,
|
|
944
|
+
byteCount: state.byteCount,
|
|
945
|
+
updatedAt: now,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
async function updateLiveSession(ctx, siteId, event, context, session, now, batchState) {
|
|
951
|
+
await ensureLiveSweep(ctx, siteId, now, batchState);
|
|
952
|
+
const liveState = batchState
|
|
953
|
+
? await loadLiveSession(ctx, siteId, event.sessionId, batchState)
|
|
954
|
+
: undefined;
|
|
955
|
+
const existing = liveState
|
|
956
|
+
? liveState.current
|
|
957
|
+
: await ctx.db
|
|
958
|
+
.query("liveSessions")
|
|
959
|
+
.withIndex("by_siteId_and_sessionId", (range) => range.eq("siteId", siteId).eq("sessionId", event.sessionId))
|
|
960
|
+
.unique();
|
|
961
|
+
const deadline = now + LIVE_SESSION_TTL_MS;
|
|
962
|
+
const country = context.country ?? session.country;
|
|
963
|
+
const city = context.city ?? session.city;
|
|
964
|
+
const latitude = context.latitude ?? session.latitude;
|
|
965
|
+
const longitude = context.longitude ?? session.longitude;
|
|
651
966
|
if (existing) {
|
|
652
|
-
|
|
967
|
+
const recentHeartbeatEventIds = event.type === "heartbeat"
|
|
968
|
+
? [
|
|
969
|
+
...(existing.recentHeartbeatEventIds ??
|
|
970
|
+
(existing.lastHeartbeatEventId
|
|
971
|
+
? [existing.lastHeartbeatEventId]
|
|
972
|
+
: [])),
|
|
973
|
+
event.eventId,
|
|
974
|
+
].slice(-8)
|
|
975
|
+
: undefined;
|
|
976
|
+
const updates = {
|
|
653
977
|
visitorId: event.visitorId,
|
|
654
978
|
path: event.path,
|
|
655
979
|
lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
|
|
656
980
|
expiresAt: deadline,
|
|
657
|
-
...(
|
|
658
|
-
...(
|
|
659
|
-
...(
|
|
660
|
-
...(
|
|
981
|
+
...(country ? { country } : {}),
|
|
982
|
+
...(city ? { city } : {}),
|
|
983
|
+
...(latitude !== undefined ? { latitude } : {}),
|
|
984
|
+
...(longitude !== undefined ? { longitude } : {}),
|
|
661
985
|
browser: context.browser ?? existing.browser,
|
|
662
986
|
os: context.os ?? existing.os,
|
|
663
987
|
source: session.source,
|
|
664
988
|
device: context.device ?? existing.device,
|
|
665
989
|
pageviewCount: session.pageviewCount,
|
|
666
990
|
eventCount: session.eventCount,
|
|
991
|
+
// The live map animates a visitor from the route they left to the one
|
|
992
|
+
// they reached; a heartbeat or click on the same page is not a move.
|
|
993
|
+
...(event.type === "pageview" && event.path !== existing.path
|
|
994
|
+
? { previousPath: existing.path, movedAt: event.timestamp }
|
|
995
|
+
: {}),
|
|
667
996
|
...(event.type === "heartbeat"
|
|
668
|
-
? {
|
|
997
|
+
? {
|
|
998
|
+
lastHeartbeatEventId: event.eventId,
|
|
999
|
+
recentHeartbeatEventIds,
|
|
1000
|
+
}
|
|
669
1001
|
: {}),
|
|
670
|
-
}
|
|
1002
|
+
};
|
|
1003
|
+
if (liveState) {
|
|
1004
|
+
liveState.current = { ...existing, ...updates };
|
|
1005
|
+
liveState.dirty = true;
|
|
1006
|
+
}
|
|
1007
|
+
else {
|
|
1008
|
+
await ctx.db.patch("liveSessions", existing._id, updates);
|
|
1009
|
+
}
|
|
671
1010
|
return;
|
|
672
1011
|
}
|
|
673
|
-
const
|
|
1012
|
+
const fields = {
|
|
674
1013
|
siteId,
|
|
675
1014
|
sessionId: event.sessionId,
|
|
676
1015
|
visitorId: event.visitorId,
|
|
@@ -678,11 +1017,10 @@ async function updateLiveSession(ctx, siteId, event, context, session, now) {
|
|
|
678
1017
|
startedAt: event.timestamp,
|
|
679
1018
|
lastSeenAt: event.timestamp,
|
|
680
1019
|
expiresAt: deadline,
|
|
681
|
-
|
|
682
|
-
...(
|
|
683
|
-
...(
|
|
684
|
-
...(
|
|
685
|
-
...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
|
|
1020
|
+
...(country ? { country } : {}),
|
|
1021
|
+
...(city ? { city } : {}),
|
|
1022
|
+
...(latitude !== undefined ? { latitude } : {}),
|
|
1023
|
+
...(longitude !== undefined ? { longitude } : {}),
|
|
686
1024
|
browser: context.browser ?? "Other",
|
|
687
1025
|
os: context.os ?? "Other",
|
|
688
1026
|
source: session.source,
|
|
@@ -690,13 +1028,42 @@ async function updateLiveSession(ctx, siteId, event, context, session, now) {
|
|
|
690
1028
|
pageviewCount: session.pageviewCount,
|
|
691
1029
|
eventCount: session.eventCount,
|
|
692
1030
|
...(event.type === "heartbeat"
|
|
693
|
-
? {
|
|
1031
|
+
? {
|
|
1032
|
+
lastHeartbeatEventId: event.eventId,
|
|
1033
|
+
recentHeartbeatEventIds: [event.eventId],
|
|
1034
|
+
}
|
|
694
1035
|
: {}),
|
|
695
|
-
}
|
|
696
|
-
await ctx.
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1036
|
+
};
|
|
1037
|
+
const liveSessionId = await ctx.db.insert("liveSessions", fields);
|
|
1038
|
+
if (liveState) {
|
|
1039
|
+
liveState.current = {
|
|
1040
|
+
...fields,
|
|
1041
|
+
_id: liveSessionId,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
async function ensureLiveSweep(ctx, siteId, now, batchState) {
|
|
1046
|
+
const key = String(siteId);
|
|
1047
|
+
if (batchState?.liveSweepSites.has(key))
|
|
1048
|
+
return;
|
|
1049
|
+
const existing = await ctx.db
|
|
1050
|
+
.query("liveSweepStates")
|
|
1051
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1052
|
+
.unique();
|
|
1053
|
+
if (!existing || existing.scheduledAt <= now) {
|
|
1054
|
+
const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
|
|
1055
|
+
if (existing) {
|
|
1056
|
+
await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
|
|
1060
|
+
}
|
|
1061
|
+
await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
|
|
1062
|
+
siteId,
|
|
1063
|
+
scheduledAt,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
batchState?.liveSweepSites.add(key);
|
|
700
1067
|
}
|
|
701
1068
|
function goalMatches(goal, event) {
|
|
702
1069
|
if (goal.matchType === "pageview") {
|
|
@@ -704,19 +1071,34 @@ function goalMatches(goal, event) {
|
|
|
704
1071
|
}
|
|
705
1072
|
return event.type === "custom" && event.name === goal.matchValue;
|
|
706
1073
|
}
|
|
707
|
-
async function advanceFunnel(ctx, siteId, funnel, event) {
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
.
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1074
|
+
async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, batchState) {
|
|
1075
|
+
const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
|
|
1076
|
+
let progressState = batchState?.funnelProgress.get(cacheKey);
|
|
1077
|
+
if (batchState && !progressState) {
|
|
1078
|
+
const current = await ctx.db
|
|
1079
|
+
.query("funnelProgress")
|
|
1080
|
+
.withIndex("by_siteId_and_funnelId_and_visitorId", (range) => range
|
|
1081
|
+
.eq("siteId", siteId)
|
|
1082
|
+
.eq("funnelId", funnel._id)
|
|
1083
|
+
.eq("visitorId", event.visitorId))
|
|
1084
|
+
.unique();
|
|
1085
|
+
progressState = { current, dirty: false };
|
|
1086
|
+
batchState.funnelProgress.set(cacheKey, progressState);
|
|
1087
|
+
}
|
|
1088
|
+
const progress = progressState
|
|
1089
|
+
? progressState.current
|
|
1090
|
+
: await ctx.db
|
|
1091
|
+
.query("funnelProgress")
|
|
1092
|
+
.withIndex("by_siteId_and_funnelId_and_visitorId", (range) => range
|
|
1093
|
+
.eq("siteId", siteId)
|
|
1094
|
+
.eq("funnelId", funnel._id)
|
|
1095
|
+
.eq("visitorId", event.visitorId))
|
|
1096
|
+
.unique();
|
|
715
1097
|
const matchesFirst = stepMatches(funnel.steps[0], event);
|
|
716
1098
|
if (!progress) {
|
|
717
1099
|
if (!matchesFirst)
|
|
718
1100
|
return;
|
|
719
|
-
|
|
1101
|
+
const fields = {
|
|
720
1102
|
siteId,
|
|
721
1103
|
funnelId: funnel._id,
|
|
722
1104
|
visitorId: event.visitorId,
|
|
@@ -724,23 +1106,47 @@ async function advanceFunnel(ctx, siteId, funnel, event) {
|
|
|
724
1106
|
currentStep: 1,
|
|
725
1107
|
startedAt: event.timestamp,
|
|
726
1108
|
lastStepAt: event.timestamp,
|
|
727
|
-
}
|
|
1109
|
+
};
|
|
1110
|
+
const progressId = await ctx.db.insert("funnelProgress", fields);
|
|
1111
|
+
if (progressState) {
|
|
1112
|
+
progressState.current = {
|
|
1113
|
+
...fields,
|
|
1114
|
+
_id: progressId,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
728
1117
|
await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
|
|
1118
|
+
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
729
1119
|
return;
|
|
730
1120
|
}
|
|
731
|
-
if (progress.completedAt !== undefined ||
|
|
1121
|
+
if (progress.completedAt !== undefined ||
|
|
1122
|
+
event.timestamp < progress.lastStepAt)
|
|
732
1123
|
return;
|
|
733
1124
|
if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
|
|
734
1125
|
if (!matchesFirst)
|
|
735
1126
|
return;
|
|
736
|
-
|
|
1127
|
+
const updated = {
|
|
1128
|
+
...progress,
|
|
737
1129
|
sessionId: event.sessionId,
|
|
738
1130
|
currentStep: 1,
|
|
739
1131
|
startedAt: event.timestamp,
|
|
740
1132
|
lastStepAt: event.timestamp,
|
|
741
|
-
|
|
742
|
-
|
|
1133
|
+
};
|
|
1134
|
+
delete updated.completedAt;
|
|
1135
|
+
if (progressState) {
|
|
1136
|
+
progressState.current = updated;
|
|
1137
|
+
progressState.dirty = true;
|
|
1138
|
+
}
|
|
1139
|
+
else {
|
|
1140
|
+
await ctx.db.patch("funnelProgress", progress._id, {
|
|
1141
|
+
sessionId: event.sessionId,
|
|
1142
|
+
currentStep: 1,
|
|
1143
|
+
startedAt: event.timestamp,
|
|
1144
|
+
lastStepAt: event.timestamp,
|
|
1145
|
+
completedAt: undefined,
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
743
1148
|
await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
|
|
1149
|
+
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
744
1150
|
return;
|
|
745
1151
|
}
|
|
746
1152
|
const nextStep = funnel.steps[progress.currentStep];
|
|
@@ -748,13 +1154,21 @@ async function advanceFunnel(ctx, siteId, funnel, event) {
|
|
|
748
1154
|
return;
|
|
749
1155
|
const currentStep = progress.currentStep + 1;
|
|
750
1156
|
const completed = currentStep === funnel.steps.length;
|
|
751
|
-
|
|
1157
|
+
const updates = {
|
|
752
1158
|
sessionId: event.sessionId,
|
|
753
1159
|
currentStep,
|
|
754
1160
|
lastStepAt: event.timestamp,
|
|
755
1161
|
...(completed ? { completedAt: event.timestamp } : {}),
|
|
756
|
-
}
|
|
1162
|
+
};
|
|
1163
|
+
if (progressState) {
|
|
1164
|
+
progressState.current = { ...progress, ...updates };
|
|
1165
|
+
progressState.dirty = true;
|
|
1166
|
+
}
|
|
1167
|
+
else {
|
|
1168
|
+
await ctx.db.patch("funnelProgress", progress._id, updates);
|
|
1169
|
+
}
|
|
757
1170
|
await incrementFunnelStep(ctx, siteId, funnel._id, event, progress.currentStep, completed);
|
|
1171
|
+
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, progress.currentStep, completed);
|
|
758
1172
|
}
|
|
759
1173
|
function stepMatches(step, event) {
|
|
760
1174
|
return step.type === "pageview"
|
|
@@ -763,7 +1177,7 @@ function stepMatches(step, event) {
|
|
|
763
1177
|
}
|
|
764
1178
|
async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed) {
|
|
765
1179
|
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
766
|
-
const shard = stableHash(event.visitorId) %
|
|
1180
|
+
const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
|
|
767
1181
|
const bucket = await ctx.db
|
|
768
1182
|
.query("funnelStepBuckets")
|
|
769
1183
|
.withIndex("by_siteId_and_funnelId_and_bucketStart_and_step_and_shard", (range) => range
|
|
@@ -791,7 +1205,7 @@ async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed
|
|
|
791
1205
|
});
|
|
792
1206
|
}
|
|
793
1207
|
}
|
|
794
|
-
async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
|
|
1208
|
+
async function accountConversion(ctx, siteId, session, event, trustedGoalKey, reportRollupDeltas) {
|
|
795
1209
|
const revenueCents = event.revenueCents ?? 0;
|
|
796
1210
|
const currency = event.currency ?? "USD";
|
|
797
1211
|
const candidate = attributionSnapshot(session);
|
|
@@ -820,8 +1234,9 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
|
|
|
820
1234
|
commissionCents,
|
|
821
1235
|
});
|
|
822
1236
|
if (commissionAffiliate && attribution) {
|
|
1237
|
+
foldAffiliateConversionRollup(reportRollupDeltas, siteId, event, commissionAffiliate, attribution.affiliateSlug, revenueCents, commissionCents);
|
|
823
1238
|
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
824
|
-
const shard = stableHash(event.sessionId) %
|
|
1239
|
+
const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
|
|
825
1240
|
const stats = await ctx.db
|
|
826
1241
|
.query("affiliateDailyStats")
|
|
827
1242
|
.withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) => range
|
|
@@ -851,15 +1266,425 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey) {
|
|
|
851
1266
|
}
|
|
852
1267
|
}
|
|
853
1268
|
}
|
|
1269
|
+
function createReportRollupDeltas() {
|
|
1270
|
+
return {
|
|
1271
|
+
goals: new Map(),
|
|
1272
|
+
funnels: new Map(),
|
|
1273
|
+
affiliates: new Map(),
|
|
1274
|
+
vitals: new Map(),
|
|
1275
|
+
routes: new Map(),
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
function reportRollupCoordinates(siteId, definitionId, timestamp, visitorId) {
|
|
1279
|
+
const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
|
|
1280
|
+
const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
|
|
1281
|
+
return {
|
|
1282
|
+
bucketStart,
|
|
1283
|
+
shard,
|
|
1284
|
+
key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
function foldGoalRollup(deltas, siteId, event, goal) {
|
|
1288
|
+
const coordinates = reportRollupCoordinates(siteId, String(goal._id), event.timestamp, event.visitorId);
|
|
1289
|
+
const current = deltas.goals.get(coordinates.key) ?? {
|
|
1290
|
+
siteId,
|
|
1291
|
+
goalId: goal._id,
|
|
1292
|
+
bucketStart: coordinates.bucketStart,
|
|
1293
|
+
shard: coordinates.shard,
|
|
1294
|
+
goalKey: goal.key,
|
|
1295
|
+
completions: 0,
|
|
1296
|
+
valueCents: 0,
|
|
1297
|
+
};
|
|
1298
|
+
current.completions += 1;
|
|
1299
|
+
current.valueCents += goal.valueCents ?? 0;
|
|
1300
|
+
deltas.goals.set(coordinates.key, current);
|
|
1301
|
+
}
|
|
1302
|
+
function foldFunnelRollup(deltas, siteId, funnelId, event, step, completed) {
|
|
1303
|
+
const coordinates = reportRollupCoordinates(siteId, String(funnelId), event.timestamp, event.visitorId);
|
|
1304
|
+
const rollup = deltas.funnels.get(coordinates.key) ?? {
|
|
1305
|
+
siteId,
|
|
1306
|
+
funnelId,
|
|
1307
|
+
bucketStart: coordinates.bucketStart,
|
|
1308
|
+
shard: coordinates.shard,
|
|
1309
|
+
steps: new Map(),
|
|
1310
|
+
};
|
|
1311
|
+
const current = rollup.steps.get(step) ?? {
|
|
1312
|
+
step,
|
|
1313
|
+
entrants: 0,
|
|
1314
|
+
completions: 0,
|
|
1315
|
+
};
|
|
1316
|
+
current.entrants += 1;
|
|
1317
|
+
current.completions += Number(completed);
|
|
1318
|
+
rollup.steps.set(step, current);
|
|
1319
|
+
deltas.funnels.set(coordinates.key, rollup);
|
|
1320
|
+
}
|
|
1321
|
+
function foldAffiliateVisitRollup(deltas, siteId, event, attribution, visitorKey) {
|
|
1322
|
+
const coordinates = reportRollupCoordinates(siteId, String(attribution.affiliateId), event.timestamp, event.visitorId);
|
|
1323
|
+
const current = deltas.affiliates.get(coordinates.key) ?? {
|
|
1324
|
+
siteId,
|
|
1325
|
+
affiliateId: attribution.affiliateId,
|
|
1326
|
+
bucketStart: coordinates.bucketStart,
|
|
1327
|
+
shard: coordinates.shard,
|
|
1328
|
+
affiliateSlug: attribution.affiliateSlug,
|
|
1329
|
+
visits: 0,
|
|
1330
|
+
visitorSketch: createVisitorSketch(),
|
|
1331
|
+
conversions: 0,
|
|
1332
|
+
revenueCents: 0,
|
|
1333
|
+
commissionCents: 0,
|
|
1334
|
+
};
|
|
1335
|
+
current.visits += 1;
|
|
1336
|
+
current.visitorSketch = addVisitor(current.visitorSketch, visitorKey);
|
|
1337
|
+
deltas.affiliates.set(coordinates.key, current);
|
|
1338
|
+
}
|
|
1339
|
+
function foldAffiliateConversionRollup(deltas, siteId, event, affiliate, affiliateSlug, revenueCents, commissionCents) {
|
|
1340
|
+
const coordinates = reportRollupCoordinates(siteId, String(affiliate._id), event.timestamp, event.visitorId);
|
|
1341
|
+
const current = deltas.affiliates.get(coordinates.key) ?? {
|
|
1342
|
+
siteId,
|
|
1343
|
+
affiliateId: affiliate._id,
|
|
1344
|
+
bucketStart: coordinates.bucketStart,
|
|
1345
|
+
shard: coordinates.shard,
|
|
1346
|
+
affiliateSlug,
|
|
1347
|
+
visits: 0,
|
|
1348
|
+
visitorSketch: createVisitorSketch(),
|
|
1349
|
+
conversions: 0,
|
|
1350
|
+
revenueCents: 0,
|
|
1351
|
+
commissionCents: 0,
|
|
1352
|
+
};
|
|
1353
|
+
current.conversions += 1;
|
|
1354
|
+
current.revenueCents += revenueCents;
|
|
1355
|
+
current.commissionCents += commissionCents;
|
|
1356
|
+
deltas.affiliates.set(coordinates.key, current);
|
|
1357
|
+
}
|
|
1358
|
+
function foldVitalRollups(deltas, siteId, event, device) {
|
|
1359
|
+
const metric = event.name;
|
|
1360
|
+
const value = event.value ?? 0;
|
|
1361
|
+
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
1362
|
+
const shard = stableHash(event.sessionId) % REPORT_ROLLUP_SHARDS;
|
|
1363
|
+
// Each measurement lands twice: on its page (across devices) and on its
|
|
1364
|
+
// device class (across pages), so both breakdowns stay exact and bounded.
|
|
1365
|
+
for (const target of [
|
|
1366
|
+
{ device: VITAL_ALL, page: event.path },
|
|
1367
|
+
{ device, page: VITAL_ALL },
|
|
1368
|
+
]) {
|
|
1369
|
+
const key = JSON.stringify([
|
|
1370
|
+
siteId,
|
|
1371
|
+
bucketStart,
|
|
1372
|
+
target.device,
|
|
1373
|
+
target.page,
|
|
1374
|
+
shard,
|
|
1375
|
+
]);
|
|
1376
|
+
const row = deltas.vitals.get(key) ?? {
|
|
1377
|
+
siteId,
|
|
1378
|
+
bucketStart,
|
|
1379
|
+
device: target.device,
|
|
1380
|
+
page: target.page,
|
|
1381
|
+
shard,
|
|
1382
|
+
metrics: new Map(),
|
|
1383
|
+
};
|
|
1384
|
+
const entry = row.metrics.get(metric) ?? {
|
|
1385
|
+
count: 0,
|
|
1386
|
+
sum: 0,
|
|
1387
|
+
histogram: emptyVitalHistogram(),
|
|
1388
|
+
};
|
|
1389
|
+
entry.count += 1;
|
|
1390
|
+
entry.sum += value;
|
|
1391
|
+
entry.histogram = addToVitalHistogram(entry.histogram, value);
|
|
1392
|
+
row.metrics.set(metric, entry);
|
|
1393
|
+
deltas.vitals.set(key, row);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
function foldRouteDelta(deltas, siteId, route, timestamp, visitorId, apply) {
|
|
1397
|
+
const coordinates = reportRollupCoordinates(siteId, `route:${route}`, timestamp, visitorId);
|
|
1398
|
+
const current = deltas.routes.get(coordinates.key) ?? {
|
|
1399
|
+
siteId,
|
|
1400
|
+
bucketStart: coordinates.bucketStart,
|
|
1401
|
+
shard: coordinates.shard,
|
|
1402
|
+
route,
|
|
1403
|
+
pageviews: 0,
|
|
1404
|
+
entries: 0,
|
|
1405
|
+
continued: 0,
|
|
1406
|
+
dwellMs: 0,
|
|
1407
|
+
dwellSamples: 0,
|
|
1408
|
+
visitorSketch: createVisitorSketch(),
|
|
1409
|
+
outgoing: new Map(),
|
|
1410
|
+
};
|
|
1411
|
+
apply(current);
|
|
1412
|
+
deltas.routes.set(coordinates.key, current);
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* One pageview feeds the site map three ways: the route it lands on gains a
|
|
1416
|
+
* view (and an entry when it opens the session), the route it leaves gains
|
|
1417
|
+
* the transition and the time spent there, and the session's entry route is
|
|
1418
|
+
* marked as continued the first time its visitor moves on — which is what
|
|
1419
|
+
* lets the report derive bounces and exits without a session-end hook.
|
|
1420
|
+
*/
|
|
1421
|
+
function foldRouteRollups(deltas, siteId, event, session, existing) {
|
|
1422
|
+
const route = normalizeRoute(event.path);
|
|
1423
|
+
const priorPageviews = existing?.pageviewCount ?? 0;
|
|
1424
|
+
const firstPageview = existing === null || priorPageviews === 0;
|
|
1425
|
+
foldRouteDelta(deltas, siteId, route, event.timestamp, event.visitorId, (delta) => {
|
|
1426
|
+
delta.pageviews += 1;
|
|
1427
|
+
if (firstPageview)
|
|
1428
|
+
delta.entries += 1;
|
|
1429
|
+
delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
|
|
1430
|
+
});
|
|
1431
|
+
// Out-of-order telemetry keeps its view but cannot say where it came from.
|
|
1432
|
+
if (existing === null || firstPageview)
|
|
1433
|
+
return;
|
|
1434
|
+
if (event.timestamp < existing.lastSeenAt)
|
|
1435
|
+
return;
|
|
1436
|
+
const previous = normalizeRoute(existing.exitPath);
|
|
1437
|
+
const lastPageviewAt = existing.lastPageviewAt;
|
|
1438
|
+
foldRouteDelta(deltas, siteId, previous, event.timestamp, event.visitorId, (delta) => {
|
|
1439
|
+
delta.outgoing.set(route, (delta.outgoing.get(route) ?? 0) + 1);
|
|
1440
|
+
if (lastPageviewAt !== undefined && event.timestamp >= lastPageviewAt) {
|
|
1441
|
+
delta.dwellMs += event.timestamp - lastPageviewAt;
|
|
1442
|
+
delta.dwellSamples += 1;
|
|
1443
|
+
}
|
|
1444
|
+
});
|
|
1445
|
+
if (priorPageviews === 1) {
|
|
1446
|
+
foldRouteDelta(deltas, siteId, normalizeRoute(existing.entryPath), existing.startedAt, event.visitorId, (delta) => {
|
|
1447
|
+
delta.continued += 1;
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
async function upsertRouteRollup(ctx, delta, dayRowCounts) {
|
|
1452
|
+
const existing = await ctx.db
|
|
1453
|
+
.query("routeDailyRollups")
|
|
1454
|
+
.withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) => range
|
|
1455
|
+
.eq("siteId", delta.siteId)
|
|
1456
|
+
.eq("route", delta.route)
|
|
1457
|
+
.eq("bucketStart", delta.bucketStart)
|
|
1458
|
+
.eq("shard", delta.shard))
|
|
1459
|
+
.unique();
|
|
1460
|
+
if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
|
|
1461
|
+
// A new route row spends one bounded read per batch to respect the daily
|
|
1462
|
+
// cap; past it, the day's remaining routes fold into "(other)".
|
|
1463
|
+
const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
|
|
1464
|
+
const dayKey = `${delta.siteId}:${delta.bucketStart}`;
|
|
1465
|
+
let dayRows = dayRowCounts.get(dayKey);
|
|
1466
|
+
if (dayRows === undefined) {
|
|
1467
|
+
dayRows = (await ctx.db
|
|
1468
|
+
.query("routeDailyRollups")
|
|
1469
|
+
.withIndex("by_siteId_and_bucketStart", (range) => range
|
|
1470
|
+
.eq("siteId", delta.siteId)
|
|
1471
|
+
.eq("bucketStart", delta.bucketStart))
|
|
1472
|
+
.take(capRows + 1)).length;
|
|
1473
|
+
}
|
|
1474
|
+
if (dayRows >= capRows) {
|
|
1475
|
+
await upsertRouteRollup(ctx, { ...delta, route: SITE_MAP_OTHER_ROUTE }, dayRowCounts);
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
dayRowCounts.set(dayKey, dayRows + 1);
|
|
1479
|
+
}
|
|
1480
|
+
const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
|
|
1481
|
+
for (const [target, count] of delta.outgoing) {
|
|
1482
|
+
let entry = outgoing.find((candidate) => candidate.route === target);
|
|
1483
|
+
if (!entry) {
|
|
1484
|
+
const named = outgoing.filter((candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE).length;
|
|
1485
|
+
const route = target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
|
|
1486
|
+
? SITE_MAP_OTHER_ROUTE
|
|
1487
|
+
: target;
|
|
1488
|
+
entry = outgoing.find((candidate) => candidate.route === route);
|
|
1489
|
+
if (!entry) {
|
|
1490
|
+
entry = { route, count: 0 };
|
|
1491
|
+
outgoing.push(entry);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
entry.count += count;
|
|
1495
|
+
}
|
|
1496
|
+
const fields = {
|
|
1497
|
+
siteId: delta.siteId,
|
|
1498
|
+
bucketStart: delta.bucketStart,
|
|
1499
|
+
shard: delta.shard,
|
|
1500
|
+
generation: 1,
|
|
1501
|
+
route: delta.route,
|
|
1502
|
+
pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
|
|
1503
|
+
entries: (existing?.entries ?? 0) + delta.entries,
|
|
1504
|
+
continued: (existing?.continued ?? 0) + delta.continued,
|
|
1505
|
+
dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
|
|
1506
|
+
dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
|
|
1507
|
+
visitorSketch: unionVisitorSketches(existing?.visitorSketch ?? createVisitorSketch(), delta.visitorSketch),
|
|
1508
|
+
outgoing,
|
|
1509
|
+
};
|
|
1510
|
+
if (existing) {
|
|
1511
|
+
await ctx.db.replace("routeDailyRollups", existing._id, fields);
|
|
1512
|
+
}
|
|
1513
|
+
else {
|
|
1514
|
+
await ctx.db.insert("routeDailyRollups", fields);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
async function upsertVitalRollup(ctx, delta) {
|
|
1518
|
+
const existing = await ctx.db
|
|
1519
|
+
.query("vitalRollups")
|
|
1520
|
+
.withIndex("by_key", (range) => range
|
|
1521
|
+
.eq("siteId", delta.siteId)
|
|
1522
|
+
.eq("bucketStart", delta.bucketStart)
|
|
1523
|
+
.eq("device", delta.device)
|
|
1524
|
+
.eq("page", delta.page)
|
|
1525
|
+
.eq("shard", delta.shard))
|
|
1526
|
+
.unique();
|
|
1527
|
+
if (!existing &&
|
|
1528
|
+
delta.device === VITAL_ALL &&
|
|
1529
|
+
delta.page !== VITAL_OTHER_PAGES) {
|
|
1530
|
+
// A new page row spends one bounded read to respect the daily page slots;
|
|
1531
|
+
// past the cap, the day's remaining pages fold into "(other)".
|
|
1532
|
+
const pageRows = await ctx.db
|
|
1533
|
+
.query("vitalRollups")
|
|
1534
|
+
.withIndex("by_key", (range) => range
|
|
1535
|
+
.eq("siteId", delta.siteId)
|
|
1536
|
+
.eq("bucketStart", delta.bucketStart)
|
|
1537
|
+
.eq("device", VITAL_ALL))
|
|
1538
|
+
.take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
|
|
1539
|
+
if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
|
|
1540
|
+
await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
|
|
1545
|
+
for (const [metric, addition] of delta.metrics) {
|
|
1546
|
+
const entry = metrics.find((candidate) => candidate.metric === metric);
|
|
1547
|
+
if (entry) {
|
|
1548
|
+
entry.count += addition.count;
|
|
1549
|
+
entry.sum += addition.sum;
|
|
1550
|
+
entry.histogram = mergeVitalHistograms(entry.histogram, addition.histogram);
|
|
1551
|
+
}
|
|
1552
|
+
else {
|
|
1553
|
+
metrics.push({
|
|
1554
|
+
metric,
|
|
1555
|
+
count: addition.count,
|
|
1556
|
+
sum: addition.sum,
|
|
1557
|
+
histogram: [...addition.histogram],
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
const fields = {
|
|
1562
|
+
siteId: delta.siteId,
|
|
1563
|
+
bucketStart: delta.bucketStart,
|
|
1564
|
+
device: delta.device,
|
|
1565
|
+
page: delta.page,
|
|
1566
|
+
shard: delta.shard,
|
|
1567
|
+
metrics,
|
|
1568
|
+
};
|
|
1569
|
+
if (existing) {
|
|
1570
|
+
await ctx.db.replace("vitalRollups", existing._id, fields);
|
|
1571
|
+
}
|
|
1572
|
+
else {
|
|
1573
|
+
await ctx.db.insert("vitalRollups", fields);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
async function flushReportRollups(ctx, deltas) {
|
|
1577
|
+
for (const delta of deltas.goals.values()) {
|
|
1578
|
+
const existing = await ctx.db
|
|
1579
|
+
.query("goalDailyRollups")
|
|
1580
|
+
.withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) => range
|
|
1581
|
+
.eq("siteId", delta.siteId)
|
|
1582
|
+
.eq("goalId", delta.goalId)
|
|
1583
|
+
.eq("bucketStart", delta.bucketStart)
|
|
1584
|
+
.eq("shard", delta.shard))
|
|
1585
|
+
.unique();
|
|
1586
|
+
const fields = {
|
|
1587
|
+
siteId: delta.siteId,
|
|
1588
|
+
goalId: delta.goalId,
|
|
1589
|
+
bucketStart: delta.bucketStart,
|
|
1590
|
+
shard: delta.shard,
|
|
1591
|
+
generation: 1,
|
|
1592
|
+
goalKey: delta.goalKey,
|
|
1593
|
+
completions: (existing?.completions ?? 0) + delta.completions,
|
|
1594
|
+
valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
|
|
1595
|
+
};
|
|
1596
|
+
if (existing) {
|
|
1597
|
+
await ctx.db.replace("goalDailyRollups", existing._id, fields);
|
|
1598
|
+
}
|
|
1599
|
+
else {
|
|
1600
|
+
await ctx.db.insert("goalDailyRollups", fields);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
for (const delta of deltas.funnels.values()) {
|
|
1604
|
+
const existing = await ctx.db
|
|
1605
|
+
.query("funnelDailyRollups")
|
|
1606
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) => range
|
|
1607
|
+
.eq("siteId", delta.siteId)
|
|
1608
|
+
.eq("funnelId", delta.funnelId)
|
|
1609
|
+
.eq("bucketStart", delta.bucketStart)
|
|
1610
|
+
.eq("shard", delta.shard))
|
|
1611
|
+
.unique();
|
|
1612
|
+
const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
|
|
1613
|
+
for (const addition of delta.steps.values()) {
|
|
1614
|
+
const current = steps.find((step) => step.step === addition.step);
|
|
1615
|
+
if (current) {
|
|
1616
|
+
current.entrants += addition.entrants;
|
|
1617
|
+
current.completions += addition.completions;
|
|
1618
|
+
}
|
|
1619
|
+
else {
|
|
1620
|
+
if (steps.length >= MAX_FUNNEL_STEPS) {
|
|
1621
|
+
fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
|
|
1622
|
+
}
|
|
1623
|
+
steps.push(addition);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
const fields = {
|
|
1627
|
+
siteId: delta.siteId,
|
|
1628
|
+
funnelId: delta.funnelId,
|
|
1629
|
+
bucketStart: delta.bucketStart,
|
|
1630
|
+
shard: delta.shard,
|
|
1631
|
+
generation: 1,
|
|
1632
|
+
steps,
|
|
1633
|
+
};
|
|
1634
|
+
if (existing) {
|
|
1635
|
+
await ctx.db.replace("funnelDailyRollups", existing._id, fields);
|
|
1636
|
+
}
|
|
1637
|
+
else {
|
|
1638
|
+
await ctx.db.insert("funnelDailyRollups", fields);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
for (const delta of deltas.affiliates.values()) {
|
|
1642
|
+
const existing = await ctx.db
|
|
1643
|
+
.query("affiliateDailyRollups")
|
|
1644
|
+
.withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) => range
|
|
1645
|
+
.eq("siteId", delta.siteId)
|
|
1646
|
+
.eq("affiliateId", delta.affiliateId)
|
|
1647
|
+
.eq("bucketStart", delta.bucketStart)
|
|
1648
|
+
.eq("shard", delta.shard))
|
|
1649
|
+
.unique();
|
|
1650
|
+
const fields = {
|
|
1651
|
+
siteId: delta.siteId,
|
|
1652
|
+
affiliateId: delta.affiliateId,
|
|
1653
|
+
bucketStart: delta.bucketStart,
|
|
1654
|
+
shard: delta.shard,
|
|
1655
|
+
generation: 1,
|
|
1656
|
+
affiliateSlug: delta.affiliateSlug,
|
|
1657
|
+
visits: (existing?.visits ?? 0) + delta.visits,
|
|
1658
|
+
visitorSketch: unionVisitorSketches(existing?.visitorSketch ?? createVisitorSketch(), delta.visitorSketch),
|
|
1659
|
+
conversions: (existing?.conversions ?? 0) + delta.conversions,
|
|
1660
|
+
revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
|
|
1661
|
+
commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
|
|
1662
|
+
};
|
|
1663
|
+
if (existing) {
|
|
1664
|
+
await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
|
|
1665
|
+
}
|
|
1666
|
+
else {
|
|
1667
|
+
await ctx.db.insert("affiliateDailyRollups", fields);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
const routeDayRowCounts = new Map();
|
|
1671
|
+
for (const delta of deltas.routes.values()) {
|
|
1672
|
+
await upsertRouteRollup(ctx, delta, routeDayRowCounts);
|
|
1673
|
+
}
|
|
1674
|
+
for (const delta of deltas.vitals.values()) {
|
|
1675
|
+
await upsertVitalRollup(ctx, delta);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
854
1678
|
function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion) {
|
|
855
|
-
const
|
|
856
|
-
const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
|
|
1679
|
+
const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
|
|
857
1680
|
const dimensionDeltas = dimensions.map((dimension) => ({
|
|
858
1681
|
...dimension,
|
|
859
1682
|
count: Number(recordTelemetry),
|
|
860
1683
|
revenueCents,
|
|
861
1684
|
}));
|
|
862
1685
|
for (const granularity of ["hour", "day"]) {
|
|
1686
|
+
const shard = stableHash(event.sessionId) %
|
|
1687
|
+
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
863
1688
|
const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
|
|
864
1689
|
const bucketStart = Math.floor(event.timestamp / interval) * interval;
|
|
865
1690
|
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
@@ -877,6 +1702,7 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
|
|
|
877
1702
|
outboundClicks: 0,
|
|
878
1703
|
sessions: 0,
|
|
879
1704
|
visitors: 0,
|
|
1705
|
+
visitorSketch: createVisitorSketch(),
|
|
880
1706
|
conversions: 0,
|
|
881
1707
|
revenueCents: 0,
|
|
882
1708
|
dimensions: [],
|
|
@@ -895,6 +1721,9 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
|
|
|
895
1721
|
? Number(session.isNewVisitorHour)
|
|
896
1722
|
: Number(session.isNewVisitorDay)
|
|
897
1723
|
: 0;
|
|
1724
|
+
if (recordTelemetry) {
|
|
1725
|
+
delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
|
|
1726
|
+
}
|
|
898
1727
|
delta.conversions += Number(financialConversion);
|
|
899
1728
|
delta.revenueCents += revenueCents;
|
|
900
1729
|
delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
|
|
@@ -911,6 +1740,13 @@ async function flushAggregates(ctx, aggregateDeltas) {
|
|
|
911
1740
|
.eq("shard", delta.shard))
|
|
912
1741
|
.unique();
|
|
913
1742
|
const dimensions = mergeDimensions(bucket?.dimensions ?? [], delta.dimensions);
|
|
1743
|
+
const priorSketch = bucket?.visitorSketch;
|
|
1744
|
+
const visitorSketch = priorSketch
|
|
1745
|
+
? unionVisitorSketches(priorSketch, delta.visitorSketch)
|
|
1746
|
+
: delta.visitorSketch;
|
|
1747
|
+
const visitorSketchComplete = bucket
|
|
1748
|
+
? bucket.visitorSketchComplete === true && priorSketch !== undefined
|
|
1749
|
+
: true;
|
|
914
1750
|
if (bucket) {
|
|
915
1751
|
await ctx.db.patch("aggregateBuckets", bucket._id, {
|
|
916
1752
|
events: bucket.events + delta.events,
|
|
@@ -920,6 +1756,8 @@ async function flushAggregates(ctx, aggregateDeltas) {
|
|
|
920
1756
|
outboundClicks: bucket.outboundClicks + delta.outboundClicks,
|
|
921
1757
|
sessions: bucket.sessions + delta.sessions,
|
|
922
1758
|
visitors: bucket.visitors + delta.visitors,
|
|
1759
|
+
visitorSketch,
|
|
1760
|
+
visitorSketchComplete,
|
|
923
1761
|
conversions: bucket.conversions + delta.conversions,
|
|
924
1762
|
revenueCents: bucket.revenueCents + delta.revenueCents,
|
|
925
1763
|
dimensions,
|
|
@@ -938,6 +1776,8 @@ async function flushAggregates(ctx, aggregateDeltas) {
|
|
|
938
1776
|
outboundClicks: delta.outboundClicks,
|
|
939
1777
|
sessions: delta.sessions,
|
|
940
1778
|
visitors: delta.visitors,
|
|
1779
|
+
visitorSketch,
|
|
1780
|
+
visitorSketchComplete,
|
|
941
1781
|
conversions: delta.conversions,
|
|
942
1782
|
revenueCents: delta.revenueCents,
|
|
943
1783
|
dimensions,
|
|
@@ -955,7 +1795,7 @@ function mergeDimensions(current, additions) {
|
|
|
955
1795
|
continue;
|
|
956
1796
|
}
|
|
957
1797
|
const typeSlots = result.filter((candidate) => candidate.type === addition.type);
|
|
958
|
-
if (typeSlots.length < DIMENSION_SLOTS) {
|
|
1798
|
+
if (typeSlots.length < DIMENSION_SLOTS - 1) {
|
|
959
1799
|
const occupied = Array.from({ length: DIMENSION_SLOTS }, () => false);
|
|
960
1800
|
for (const candidate of typeSlots)
|
|
961
1801
|
occupied[candidate.slot] = true;
|
|
@@ -971,21 +1811,43 @@ function mergeDimensions(current, additions) {
|
|
|
971
1811
|
});
|
|
972
1812
|
continue;
|
|
973
1813
|
}
|
|
974
|
-
|
|
1814
|
+
const overflow = typeSlots.find((candidate) => candidate.value === "(other)");
|
|
1815
|
+
if (overflow) {
|
|
1816
|
+
overflow.count += addition.count;
|
|
1817
|
+
overflow.revenueCents += addition.revenueCents;
|
|
975
1818
|
continue;
|
|
1819
|
+
}
|
|
1820
|
+
if (addition.count === 0 && addition.revenueCents === 0)
|
|
1821
|
+
continue;
|
|
1822
|
+
if (typeSlots.length < DIMENSION_SLOTS) {
|
|
1823
|
+
const occupied = new Set(typeSlots.map((candidate) => candidate.slot));
|
|
1824
|
+
let slot = stableHash("(other)") % DIMENSION_SLOTS;
|
|
1825
|
+
while (occupied.has(slot))
|
|
1826
|
+
slot = (slot + 1) % DIMENSION_SLOTS;
|
|
1827
|
+
result.push({
|
|
1828
|
+
type: addition.type,
|
|
1829
|
+
slot,
|
|
1830
|
+
value: "(other)",
|
|
1831
|
+
count: addition.count,
|
|
1832
|
+
revenueCents: addition.revenueCents,
|
|
1833
|
+
});
|
|
1834
|
+
continue;
|
|
1835
|
+
}
|
|
976
1836
|
let replacement = typeSlots[0];
|
|
977
1837
|
for (const candidate of typeSlots.slice(1)) {
|
|
978
1838
|
if (candidate.count < replacement.count)
|
|
979
1839
|
replacement = candidate;
|
|
980
1840
|
}
|
|
981
|
-
replacement.value =
|
|
1841
|
+
replacement.value = "(other)";
|
|
982
1842
|
replacement.count += addition.count;
|
|
983
|
-
replacement.revenueCents
|
|
1843
|
+
replacement.revenueCents += addition.revenueCents;
|
|
984
1844
|
}
|
|
985
1845
|
return result;
|
|
986
1846
|
}
|
|
987
1847
|
function stringProperty(properties, key) {
|
|
988
1848
|
const value = properties?.[key];
|
|
989
|
-
return typeof value === "string"
|
|
1849
|
+
return typeof value === "string"
|
|
1850
|
+
? cleanString(value, 120) || undefined
|
|
1851
|
+
: undefined;
|
|
990
1852
|
}
|
|
991
1853
|
//# sourceMappingURL=ingest.js.map
|