@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/src/component/ingest.ts
CHANGED
|
@@ -5,25 +5,63 @@ import { mutation } from "./_generated/server.js";
|
|
|
5
5
|
import { internal } from "./_generated/api.js";
|
|
6
6
|
import {
|
|
7
7
|
AGGREGATE_SHARDS,
|
|
8
|
+
MAX_BATCH_EVENT_GROUPS,
|
|
8
9
|
DAY_MS,
|
|
10
|
+
HOURLY_AGGREGATE_SHARDS,
|
|
9
11
|
DIMENSION_SLOTS,
|
|
10
12
|
HOUR_MS,
|
|
13
|
+
LIVE_LEAVE_GRACE_MS,
|
|
11
14
|
LIVE_SESSION_TTL_MS,
|
|
15
|
+
LIVE_SWEEP_INTERVAL_MS,
|
|
12
16
|
MAX_BATCH_EVENTS,
|
|
13
17
|
MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
14
18
|
MAX_SITE_EVENTS_PER_SHARD_WINDOW,
|
|
15
19
|
MAX_BYTES_PER_SESSION_WINDOW,
|
|
16
20
|
MAX_EVENTS_PER_SESSION_WINDOW,
|
|
17
21
|
MAX_FUNNELS_PER_SITE,
|
|
22
|
+
MAX_FUNNEL_STEPS,
|
|
18
23
|
MAX_GOALS_PER_SITE,
|
|
24
|
+
MAX_SITE_MAP_ROUTES_PER_DAY,
|
|
19
25
|
RATE_LIMIT_WINDOW_MS,
|
|
26
|
+
REPORT_ROLLUP_SHARDS,
|
|
27
|
+
ROUTE_TRANSITION_SLOTS,
|
|
28
|
+
SECONDARY_AGGREGATE_SHARDS,
|
|
20
29
|
SITE_INGEST_SHARDS,
|
|
30
|
+
SITE_MAP_OTHER_ROUTE,
|
|
31
|
+
VITAL_PAGE_SLOTS,
|
|
21
32
|
} from "./constants.js";
|
|
33
|
+
import {
|
|
34
|
+
addVisitor,
|
|
35
|
+
createVisitorSketch,
|
|
36
|
+
unionVisitorSketches,
|
|
37
|
+
type VisitorSketch,
|
|
38
|
+
} from "./cardinality.js";
|
|
39
|
+
import {
|
|
40
|
+
addToVitalHistogram,
|
|
41
|
+
emptyVitalHistogram,
|
|
42
|
+
mergeVitalHistograms,
|
|
43
|
+
vitalDevice,
|
|
44
|
+
VITAL_ALL,
|
|
45
|
+
VITAL_OTHER_PAGES,
|
|
46
|
+
type VitalMetric,
|
|
47
|
+
} from "./vitals.js";
|
|
22
48
|
import { fail } from "./errors.js";
|
|
49
|
+
import { ensureAnalyticsControl } from "./coverage.js";
|
|
50
|
+
import {
|
|
51
|
+
bufferEventBatch,
|
|
52
|
+
eventBatchKey,
|
|
53
|
+
eventBucketStart,
|
|
54
|
+
type EventBatchBuffer,
|
|
55
|
+
findTelemetry,
|
|
56
|
+
flushEventBatches,
|
|
57
|
+
insertEventBatch,
|
|
58
|
+
inspectTelemetryMinute,
|
|
59
|
+
} from "./eventStore.js";
|
|
23
60
|
import {
|
|
24
61
|
cleanString,
|
|
25
|
-
ensureBatchBytes,
|
|
26
62
|
isValidMoney,
|
|
63
|
+
measureBatchBytes,
|
|
64
|
+
normalizeRoute,
|
|
27
65
|
originAllowed,
|
|
28
66
|
sanitizeContext,
|
|
29
67
|
sanitizeCurrency,
|
|
@@ -46,6 +84,7 @@ import {
|
|
|
46
84
|
|
|
47
85
|
type DimensionType =
|
|
48
86
|
| "source"
|
|
87
|
+
| "campaign"
|
|
49
88
|
| "page"
|
|
50
89
|
| "country"
|
|
51
90
|
| "device"
|
|
@@ -67,11 +106,78 @@ type AggregateDelta = {
|
|
|
67
106
|
outboundClicks: number;
|
|
68
107
|
sessions: number;
|
|
69
108
|
visitors: number;
|
|
109
|
+
visitorSketch: VisitorSketch;
|
|
70
110
|
conversions: number;
|
|
71
111
|
revenueCents: number;
|
|
72
112
|
dimensions: Doc<"aggregateBuckets">["dimensions"];
|
|
73
113
|
};
|
|
74
114
|
type AggregateDeltas = Map<string, AggregateDelta>;
|
|
115
|
+
type GoalRollupDelta = {
|
|
116
|
+
siteId: Id<"sites">;
|
|
117
|
+
goalId: Id<"goals">;
|
|
118
|
+
bucketStart: number;
|
|
119
|
+
shard: number;
|
|
120
|
+
goalKey: string;
|
|
121
|
+
completions: number;
|
|
122
|
+
valueCents: number;
|
|
123
|
+
};
|
|
124
|
+
type FunnelRollupDelta = {
|
|
125
|
+
siteId: Id<"sites">;
|
|
126
|
+
funnelId: Id<"funnels">;
|
|
127
|
+
bucketStart: number;
|
|
128
|
+
shard: number;
|
|
129
|
+
steps: Map<
|
|
130
|
+
number,
|
|
131
|
+
{
|
|
132
|
+
step: number;
|
|
133
|
+
entrants: number;
|
|
134
|
+
completions: number;
|
|
135
|
+
}
|
|
136
|
+
>;
|
|
137
|
+
};
|
|
138
|
+
type AffiliateRollupDelta = {
|
|
139
|
+
siteId: Id<"sites">;
|
|
140
|
+
affiliateId: Id<"affiliates">;
|
|
141
|
+
bucketStart: number;
|
|
142
|
+
shard: number;
|
|
143
|
+
affiliateSlug: string;
|
|
144
|
+
visits: number;
|
|
145
|
+
visitorSketch: VisitorSketch;
|
|
146
|
+
conversions: number;
|
|
147
|
+
revenueCents: number;
|
|
148
|
+
commissionCents: number;
|
|
149
|
+
};
|
|
150
|
+
type VitalRollupDelta = {
|
|
151
|
+
siteId: Id<"sites">;
|
|
152
|
+
bucketStart: number;
|
|
153
|
+
device: string;
|
|
154
|
+
page: string;
|
|
155
|
+
shard: number;
|
|
156
|
+
metrics: Map<
|
|
157
|
+
VitalMetric,
|
|
158
|
+
{ count: number; sum: number; histogram: number[] }
|
|
159
|
+
>;
|
|
160
|
+
};
|
|
161
|
+
type RouteRollupDelta = {
|
|
162
|
+
siteId: Id<"sites">;
|
|
163
|
+
bucketStart: number;
|
|
164
|
+
shard: number;
|
|
165
|
+
route: string;
|
|
166
|
+
pageviews: number;
|
|
167
|
+
entries: number;
|
|
168
|
+
continued: number;
|
|
169
|
+
dwellMs: number;
|
|
170
|
+
dwellSamples: number;
|
|
171
|
+
visitorSketch: VisitorSketch;
|
|
172
|
+
outgoing: Map<string, number>;
|
|
173
|
+
};
|
|
174
|
+
type ReportRollupDeltas = {
|
|
175
|
+
goals: Map<string, GoalRollupDelta>;
|
|
176
|
+
funnels: Map<string, FunnelRollupDelta>;
|
|
177
|
+
affiliates: Map<string, AffiliateRollupDelta>;
|
|
178
|
+
vitals: Map<string, VitalRollupDelta>;
|
|
179
|
+
routes: Map<string, RouteRollupDelta>;
|
|
180
|
+
};
|
|
75
181
|
type AttributionSnapshot = {
|
|
76
182
|
affiliateId: Id<"affiliates">;
|
|
77
183
|
affiliateSlug: string;
|
|
@@ -85,6 +191,12 @@ type SessionState = {
|
|
|
85
191
|
isNewVisitorDay: boolean;
|
|
86
192
|
startedAt: number;
|
|
87
193
|
source: string;
|
|
194
|
+
visitorKey: string;
|
|
195
|
+
utmCampaign?: string;
|
|
196
|
+
country?: string;
|
|
197
|
+
city?: string;
|
|
198
|
+
latitude?: number;
|
|
199
|
+
longitude?: number;
|
|
88
200
|
pageviewCount: number;
|
|
89
201
|
eventCount: number;
|
|
90
202
|
affiliateId?: Id<"affiliates">;
|
|
@@ -93,6 +205,36 @@ type SessionState = {
|
|
|
93
205
|
affiliateExpiresAt?: number;
|
|
94
206
|
};
|
|
95
207
|
|
|
208
|
+
type SessionRateWindowState = {
|
|
209
|
+
siteId: Id<"sites">;
|
|
210
|
+
sessionId: string;
|
|
211
|
+
window: Doc<"ingestWindows"> | null;
|
|
212
|
+
eventCount: number;
|
|
213
|
+
byteCount: number;
|
|
214
|
+
dirty: boolean;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
type CachedRow<T> = {
|
|
218
|
+
current: T | null;
|
|
219
|
+
dirty: boolean;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
type CachedDocument<
|
|
223
|
+
TableName extends keyof import("./_generated/dataModel.js").DataModel,
|
|
224
|
+
> = Omit<Doc<TableName>, "_creationTime"> & { _creationTime?: number };
|
|
225
|
+
|
|
226
|
+
type BatchState = {
|
|
227
|
+
sessions: Map<string, CachedRow<CachedDocument<"sessions">>>;
|
|
228
|
+
liveSessions: Map<string, CachedRow<CachedDocument<"liveSessions">>>;
|
|
229
|
+
visitorAttributions: Map<
|
|
230
|
+
string,
|
|
231
|
+
CachedRow<CachedDocument<"visitorAttributions">>
|
|
232
|
+
>;
|
|
233
|
+
funnelProgress: Map<string, CachedRow<CachedDocument<"funnelProgress">>>;
|
|
234
|
+
eventBatches: EventBatchBuffer;
|
|
235
|
+
liveSweepSites: Set<string>;
|
|
236
|
+
};
|
|
237
|
+
|
|
96
238
|
export const ingestBatch = mutation({
|
|
97
239
|
args: {
|
|
98
240
|
siteId: v.id("sites"),
|
|
@@ -110,46 +252,135 @@ export const ingestBatch = mutation({
|
|
|
110
252
|
});
|
|
111
253
|
}
|
|
112
254
|
if (args.events.length < 1 || args.events.length > MAX_BATCH_EVENTS) {
|
|
113
|
-
fail(
|
|
114
|
-
|
|
115
|
-
|
|
255
|
+
fail(
|
|
256
|
+
"BATCH_TOO_LARGE",
|
|
257
|
+
`events must contain 1-${MAX_BATCH_EVENTS} items`,
|
|
258
|
+
{
|
|
259
|
+
limit: MAX_BATCH_EVENTS,
|
|
260
|
+
},
|
|
261
|
+
);
|
|
116
262
|
}
|
|
263
|
+
let eventByteCounts: number[];
|
|
117
264
|
try {
|
|
118
|
-
|
|
265
|
+
({ eventByteCounts } = measureBatchBytes(
|
|
266
|
+
String(args.siteId),
|
|
267
|
+
args.events,
|
|
268
|
+
));
|
|
119
269
|
} catch {
|
|
120
270
|
fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
|
|
121
271
|
}
|
|
122
272
|
|
|
123
273
|
const now = Date.now();
|
|
124
|
-
|
|
274
|
+
await ensureAnalyticsControl(ctx, args.siteId, now);
|
|
275
|
+
await consumeSiteRateLimit(
|
|
125
276
|
ctx,
|
|
126
277
|
args.siteId,
|
|
127
278
|
args.events,
|
|
279
|
+
eventByteCounts,
|
|
128
280
|
now,
|
|
129
281
|
);
|
|
130
282
|
const context = sanitizeContext(args.context);
|
|
131
283
|
const aggregateDeltas: AggregateDeltas = new Map();
|
|
284
|
+
const reportRollupDeltas = createReportRollupDeltas();
|
|
285
|
+
const sessionRateWindows = new Map<string, SessionRateWindowState>();
|
|
286
|
+
const batchState: BatchState = {
|
|
287
|
+
sessions: new Map(),
|
|
288
|
+
liveSessions: new Map(),
|
|
289
|
+
visitorAttributions: new Map(),
|
|
290
|
+
funnelProgress: new Map(),
|
|
291
|
+
eventBatches: new Map(),
|
|
292
|
+
liveSweepSites: new Set(),
|
|
293
|
+
};
|
|
132
294
|
let goals: Doc<"goals">[] | null = null;
|
|
133
295
|
let funnels: Doc<"funnels">[] | null = null;
|
|
134
296
|
let accepted = 0;
|
|
135
297
|
let duplicates = 0;
|
|
136
298
|
let rejected = 0;
|
|
137
|
-
|
|
138
|
-
const input = args.events[eventIndex];
|
|
139
|
-
let event: TrackerEvent;
|
|
299
|
+
const events = args.events.map((input) => {
|
|
140
300
|
try {
|
|
141
|
-
|
|
301
|
+
return sanitizeEvent(input, now);
|
|
142
302
|
} catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
const duplicateInBatch = new Set<number>();
|
|
307
|
+
const eventIdsByGroup = new Map<string, Set<string>>();
|
|
308
|
+
const requestGroups = new Set<string>();
|
|
309
|
+
const dedupeGroups = new Map<
|
|
310
|
+
string,
|
|
311
|
+
{ sessionId: string; bucketStart: number; eventIds: Set<string> }
|
|
312
|
+
>();
|
|
313
|
+
for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
|
|
314
|
+
const event = events[eventIndex];
|
|
315
|
+
if (!event) continue;
|
|
316
|
+
const bucketStart = eventBucketStart(event.timestamp);
|
|
317
|
+
const groupKey = eventBatchKey(event.sessionId, bucketStart);
|
|
318
|
+
requestGroups.add(groupKey);
|
|
319
|
+
if (
|
|
320
|
+
event.type === "heartbeat" ||
|
|
321
|
+
event.type === "vital" ||
|
|
322
|
+
event.type === "leave"
|
|
323
|
+
)
|
|
324
|
+
continue;
|
|
325
|
+
const eventIds = eventIdsByGroup.get(groupKey) ?? new Set<string>();
|
|
326
|
+
if (eventIds.has(event.eventId)) {
|
|
327
|
+
duplicateInBatch.add(eventIndex);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
eventIds.add(event.eventId);
|
|
331
|
+
eventIdsByGroup.set(groupKey, eventIds);
|
|
332
|
+
const group = dedupeGroups.get(groupKey) ?? {
|
|
333
|
+
sessionId: event.sessionId,
|
|
334
|
+
bucketStart,
|
|
335
|
+
eventIds: new Set<string>(),
|
|
336
|
+
};
|
|
337
|
+
group.eventIds.add(event.eventId);
|
|
338
|
+
dedupeGroups.set(groupKey, group);
|
|
339
|
+
}
|
|
340
|
+
if (requestGroups.size > MAX_BATCH_EVENT_GROUPS) {
|
|
341
|
+
fail(
|
|
342
|
+
"BATCH_TOO_LARGE",
|
|
343
|
+
`events may span at most ${MAX_BATCH_EVENT_GROUPS} session-minute groups`,
|
|
344
|
+
{ limit: MAX_BATCH_EVENT_GROUPS },
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
const persistedEventIds = new Map<string, Set<string>>();
|
|
348
|
+
await Promise.all(
|
|
349
|
+
[...dedupeGroups].map(async ([key, group]) => {
|
|
350
|
+
const { eventIds, persistedCount } = await inspectTelemetryMinute(ctx, {
|
|
351
|
+
siteId: args.siteId,
|
|
352
|
+
sessionId: group.sessionId,
|
|
353
|
+
bucketStart: group.bucketStart,
|
|
354
|
+
});
|
|
355
|
+
const newCount = [...group.eventIds].filter(
|
|
356
|
+
(eventId) => !eventIds.has(eventId),
|
|
357
|
+
).length;
|
|
358
|
+
if (
|
|
359
|
+
newCount > 0 &&
|
|
360
|
+
persistedCount + newCount > MAX_EVENTS_PER_SESSION_WINDOW
|
|
361
|
+
) {
|
|
362
|
+
fail("RATE_LIMITED", "session event minute limit exceeded", {
|
|
363
|
+
limit: MAX_EVENTS_PER_SESSION_WINDOW,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
persistedEventIds.set(key, eventIds);
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
|
|
371
|
+
const event = events[eventIndex];
|
|
372
|
+
if (!event) {
|
|
143
373
|
rejected += 1;
|
|
144
374
|
continue;
|
|
145
375
|
}
|
|
146
376
|
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
377
|
+
const sessionRow = await loadSession(
|
|
378
|
+
ctx,
|
|
379
|
+
args.siteId,
|
|
380
|
+
event.sessionId,
|
|
381
|
+
batchState,
|
|
382
|
+
);
|
|
383
|
+
const existingSession = sessionRow.current;
|
|
153
384
|
if (existingSession && existingSession.visitorId !== event.visitorId) {
|
|
154
385
|
rejected += 1;
|
|
155
386
|
continue;
|
|
@@ -160,13 +391,14 @@ export const ingestBatch = mutation({
|
|
|
160
391
|
rejected += 1;
|
|
161
392
|
continue;
|
|
162
393
|
}
|
|
163
|
-
const liveSession =
|
|
164
|
-
.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
.
|
|
169
|
-
|
|
394
|
+
const liveSession = (
|
|
395
|
+
await loadLiveSession(ctx, args.siteId, event.sessionId, batchState)
|
|
396
|
+
).current;
|
|
397
|
+
if (
|
|
398
|
+
liveSession &&
|
|
399
|
+
(liveSession.lastHeartbeatEventId === event.eventId ||
|
|
400
|
+
liveSession.recentHeartbeatEventIds?.includes(event.eventId))
|
|
401
|
+
) {
|
|
170
402
|
duplicates += 1;
|
|
171
403
|
continue;
|
|
172
404
|
}
|
|
@@ -177,6 +409,7 @@ export const ingestBatch = mutation({
|
|
|
177
409
|
event.sessionId,
|
|
178
410
|
eventByteCounts[eventIndex],
|
|
179
411
|
now,
|
|
412
|
+
sessionRateWindows,
|
|
180
413
|
))
|
|
181
414
|
) {
|
|
182
415
|
rejected += 1;
|
|
@@ -189,18 +422,94 @@ export const ingestBatch = mutation({
|
|
|
189
422
|
event,
|
|
190
423
|
context,
|
|
191
424
|
now,
|
|
425
|
+
batchState,
|
|
192
426
|
);
|
|
193
427
|
accepted += 1;
|
|
194
428
|
continue;
|
|
195
429
|
}
|
|
196
430
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
431
|
+
if (event.type === "leave") {
|
|
432
|
+
// A departure only shortens live presence: it stores no telemetry,
|
|
433
|
+
// touches no aggregates, and never extends anything, so a replayed
|
|
434
|
+
// or late leave is inert. The read side filters by expiresAt and the
|
|
435
|
+
// sweep deletes the row, so the dashboard sees the exit in seconds.
|
|
436
|
+
if (!existingSession) {
|
|
437
|
+
rejected += 1;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (
|
|
441
|
+
!(await consumeRateLimit(
|
|
442
|
+
ctx,
|
|
443
|
+
args.siteId,
|
|
444
|
+
event.sessionId,
|
|
445
|
+
eventByteCounts[eventIndex],
|
|
446
|
+
now,
|
|
447
|
+
sessionRateWindows,
|
|
448
|
+
))
|
|
449
|
+
) {
|
|
450
|
+
rejected += 1;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const liveState = await loadLiveSession(
|
|
454
|
+
ctx,
|
|
455
|
+
args.siteId,
|
|
456
|
+
event.sessionId,
|
|
457
|
+
batchState,
|
|
458
|
+
);
|
|
459
|
+
const liveSession = liveState.current;
|
|
460
|
+
if (liveSession) {
|
|
461
|
+
const expiresAt = Math.min(
|
|
462
|
+
liveSession.expiresAt,
|
|
463
|
+
now + LIVE_LEAVE_GRACE_MS,
|
|
464
|
+
);
|
|
465
|
+
if (expiresAt < liveSession.expiresAt) {
|
|
466
|
+
await ctx.db.patch("liveSessions", liveSession._id, { expiresAt });
|
|
467
|
+
liveState.current = { ...liveSession, expiresAt };
|
|
468
|
+
}
|
|
469
|
+
await ensureLiveSweep(ctx, args.siteId, now, batchState);
|
|
470
|
+
}
|
|
471
|
+
accepted += 1;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (event.type === "vital") {
|
|
476
|
+
// A vital annotates telemetry its session already produced; it never
|
|
477
|
+
// creates sessions, live presence, event batches, or aggregate counts.
|
|
478
|
+
if (!existingSession) {
|
|
479
|
+
rejected += 1;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (
|
|
483
|
+
!(await consumeRateLimit(
|
|
484
|
+
ctx,
|
|
485
|
+
args.siteId,
|
|
486
|
+
event.sessionId,
|
|
487
|
+
eventByteCounts[eventIndex],
|
|
488
|
+
now,
|
|
489
|
+
sessionRateWindows,
|
|
490
|
+
))
|
|
491
|
+
) {
|
|
492
|
+
rejected += 1;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
foldVitalRollups(
|
|
496
|
+
reportRollupDeltas,
|
|
497
|
+
args.siteId,
|
|
498
|
+
event,
|
|
499
|
+
vitalDevice(context.device),
|
|
500
|
+
);
|
|
501
|
+
accepted += 1;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (
|
|
506
|
+
duplicateInBatch.has(eventIndex) ||
|
|
507
|
+
persistedEventIds
|
|
508
|
+
.get(
|
|
509
|
+
eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)),
|
|
510
|
+
)
|
|
511
|
+
?.has(event.eventId)
|
|
512
|
+
) {
|
|
204
513
|
duplicates += 1;
|
|
205
514
|
continue;
|
|
206
515
|
}
|
|
@@ -212,6 +521,7 @@ export const ingestBatch = mutation({
|
|
|
212
521
|
event.sessionId,
|
|
213
522
|
eventByteCounts[eventIndex],
|
|
214
523
|
now,
|
|
524
|
+
sessionRateWindows,
|
|
215
525
|
))
|
|
216
526
|
) {
|
|
217
527
|
rejected += 1;
|
|
@@ -231,22 +541,33 @@ export const ingestBatch = mutation({
|
|
|
231
541
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
232
542
|
.take(MAX_FUNNELS_PER_SITE);
|
|
233
543
|
}
|
|
234
|
-
await accountEvent(ctx, {
|
|
544
|
+
const session = await accountEvent(ctx, {
|
|
235
545
|
siteId: args.siteId,
|
|
236
546
|
event,
|
|
237
547
|
context,
|
|
238
548
|
existingSession,
|
|
239
|
-
goals: canMatchDefinition ? goals ?? [] : [],
|
|
240
|
-
funnels: canMatchDefinition ? funnels ?? [] : [],
|
|
549
|
+
goals: canMatchDefinition ? (goals ?? []) : [],
|
|
550
|
+
funnels: canMatchDefinition ? (funnels ?? []) : [],
|
|
241
551
|
aggregateDeltas,
|
|
552
|
+
reportRollupDeltas,
|
|
242
553
|
recordTelemetry: true,
|
|
243
554
|
financialConversion: false,
|
|
244
555
|
trustedGoalKey: undefined,
|
|
245
556
|
now,
|
|
557
|
+
batchState,
|
|
246
558
|
});
|
|
559
|
+
bufferEventBatch(
|
|
560
|
+
args.siteId,
|
|
561
|
+
event,
|
|
562
|
+
batchState.eventBatches,
|
|
563
|
+
session.country ?? "unknown",
|
|
564
|
+
);
|
|
247
565
|
accepted += 1;
|
|
248
566
|
}
|
|
567
|
+
await flushBatchState(ctx, batchState);
|
|
568
|
+
await flushSessionRateLimits(ctx, sessionRateWindows, now);
|
|
249
569
|
await flushAggregates(ctx, aggregateDeltas);
|
|
570
|
+
await flushReportRollups(ctx, reportRollupDeltas);
|
|
250
571
|
return { accepted, duplicates, rejected };
|
|
251
572
|
},
|
|
252
573
|
});
|
|
@@ -258,17 +579,24 @@ export const trackConversion = mutation({
|
|
|
258
579
|
const site = await ctx.db.get("sites", args.siteId);
|
|
259
580
|
if (!site) fail("NOT_FOUND", "site not found");
|
|
260
581
|
if (!isValidMoney(args.revenueCents)) {
|
|
261
|
-
fail(
|
|
582
|
+
fail(
|
|
583
|
+
"INVALID_ARGUMENT",
|
|
584
|
+
"revenueCents must be an integer between 0 and 1000000000",
|
|
585
|
+
);
|
|
262
586
|
}
|
|
263
587
|
let event: TrackerEvent;
|
|
588
|
+
let trustedGoalKey: string | undefined;
|
|
264
589
|
try {
|
|
590
|
+
trustedGoalKey = args.goalKey
|
|
591
|
+
? sanitizeSlug(args.goalKey, "goalKey")
|
|
592
|
+
: undefined;
|
|
265
593
|
event = sanitizeEvent(
|
|
266
594
|
{
|
|
267
595
|
eventId: sanitizeOpaqueId(args.eventId, "eventId"),
|
|
268
596
|
sessionId: sanitizeOpaqueId(args.sessionId, "sessionId"),
|
|
269
597
|
visitorId: sanitizeOpaqueId(args.visitorId, "visitorId"),
|
|
270
598
|
type: "conversion",
|
|
271
|
-
name:
|
|
599
|
+
name: trustedGoalKey ?? "conversion",
|
|
272
600
|
path: sanitizePath(args.path ?? "/conversion"),
|
|
273
601
|
timestamp: args.timestamp,
|
|
274
602
|
sequence: 0,
|
|
@@ -279,16 +607,13 @@ export const trackConversion = mutation({
|
|
|
279
607
|
Date.now(),
|
|
280
608
|
);
|
|
281
609
|
} catch (error) {
|
|
282
|
-
fail(
|
|
610
|
+
fail(
|
|
611
|
+
"INVALID_ARGUMENT",
|
|
612
|
+
error instanceof Error ? error.message : "invalid conversion",
|
|
613
|
+
);
|
|
283
614
|
}
|
|
284
615
|
|
|
285
616
|
const siteCurrency = site.currency ?? "USD";
|
|
286
|
-
if (event.currency !== siteCurrency) {
|
|
287
|
-
fail("CURRENCY_MISMATCH", "conversion currency does not match site currency", {
|
|
288
|
-
siteCurrency,
|
|
289
|
-
conversionCurrency: event.currency ?? "USD",
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
617
|
const priorConversion = await ctx.db
|
|
293
618
|
.query("conversions")
|
|
294
619
|
.withIndex("by_siteId_and_eventId", (range) =>
|
|
@@ -296,15 +621,27 @@ export const trackConversion = mutation({
|
|
|
296
621
|
)
|
|
297
622
|
.unique();
|
|
298
623
|
if (priorConversion) {
|
|
624
|
+
if (!sameTrustedConversion(priorConversion, event, trustedGoalKey)) {
|
|
625
|
+
fail(
|
|
626
|
+
"CONFLICT",
|
|
627
|
+
"conversion eventId is already associated with a different payload",
|
|
628
|
+
);
|
|
629
|
+
}
|
|
299
630
|
return { accepted: 0, duplicates: 1, rejected: 0 };
|
|
300
631
|
}
|
|
632
|
+
if (event.currency !== siteCurrency) {
|
|
633
|
+
fail(
|
|
634
|
+
"CURRENCY_MISMATCH",
|
|
635
|
+
"conversion currency does not match site currency",
|
|
636
|
+
{
|
|
637
|
+
siteCurrency,
|
|
638
|
+
conversionCurrency: event.currency ?? "USD",
|
|
639
|
+
},
|
|
640
|
+
);
|
|
641
|
+
}
|
|
301
642
|
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
.withIndex("by_siteId_and_eventId", (range) =>
|
|
305
|
-
range.eq("siteId", args.siteId).eq("eventId", event.eventId),
|
|
306
|
-
)
|
|
307
|
-
.unique();
|
|
643
|
+
const telemetry = await findTelemetry(ctx, args.siteId, event);
|
|
644
|
+
const existingTelemetry = telemetry.match;
|
|
308
645
|
if (
|
|
309
646
|
existingTelemetry &&
|
|
310
647
|
(existingTelemetry.sessionId !== event.sessionId ||
|
|
@@ -321,20 +658,19 @@ export const trackConversion = mutation({
|
|
|
321
658
|
if (existingSession && existingSession.visitorId !== event.visitorId) {
|
|
322
659
|
fail("CONFLICT", "session belongs to a different visitor");
|
|
323
660
|
}
|
|
324
|
-
const conversionContext: IngestContext =
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
: sanitizeContext(undefined);
|
|
661
|
+
const conversionContext: IngestContext = {
|
|
662
|
+
country: existingTelemetry?.country ?? existingSession?.country,
|
|
663
|
+
city: existingTelemetry?.city ?? existingSession?.city,
|
|
664
|
+
latitude: existingTelemetry?.latitude ?? existingSession?.latitude,
|
|
665
|
+
longitude: existingTelemetry?.longitude ?? existingSession?.longitude,
|
|
666
|
+
browser: existingTelemetry?.browser ?? existingSession?.browser,
|
|
667
|
+
os: existingTelemetry?.os ?? existingSession?.os,
|
|
668
|
+
device: existingTelemetry?.device ?? existingSession?.device,
|
|
669
|
+
};
|
|
335
670
|
|
|
336
671
|
const aggregateDeltas: AggregateDeltas = new Map();
|
|
337
|
-
|
|
672
|
+
const reportRollupDeltas = createReportRollupDeltas();
|
|
673
|
+
const session = await accountEvent(ctx, {
|
|
338
674
|
siteId: args.siteId,
|
|
339
675
|
event,
|
|
340
676
|
context: conversionContext,
|
|
@@ -342,34 +678,149 @@ export const trackConversion = mutation({
|
|
|
342
678
|
goals: [],
|
|
343
679
|
funnels: [],
|
|
344
680
|
aggregateDeltas,
|
|
345
|
-
|
|
681
|
+
reportRollupDeltas,
|
|
682
|
+
recordTelemetry: existingTelemetry === null && !telemetry.saturated,
|
|
346
683
|
financialConversion: true,
|
|
347
|
-
trustedGoalKey
|
|
348
|
-
? sanitizeSlug(args.goalKey, "goalKey")
|
|
349
|
-
: undefined,
|
|
684
|
+
trustedGoalKey,
|
|
350
685
|
now: Date.now(),
|
|
351
686
|
});
|
|
687
|
+
if (!existingTelemetry && !telemetry.saturated) {
|
|
688
|
+
await insertEventBatch(
|
|
689
|
+
ctx,
|
|
690
|
+
args.siteId,
|
|
691
|
+
event,
|
|
692
|
+
session.country ?? "unknown",
|
|
693
|
+
);
|
|
694
|
+
}
|
|
352
695
|
await flushAggregates(ctx, aggregateDeltas);
|
|
696
|
+
await flushReportRollups(ctx, reportRollupDeltas);
|
|
697
|
+
await ensureAnalyticsControl(ctx, args.siteId, Date.now());
|
|
353
698
|
return { accepted: 1, duplicates: 0, rejected: 0 };
|
|
354
699
|
},
|
|
355
700
|
});
|
|
356
701
|
|
|
702
|
+
function sameTrustedConversion(
|
|
703
|
+
existing: Doc<"conversions">,
|
|
704
|
+
event: TrackerEvent,
|
|
705
|
+
goalKey: string | undefined,
|
|
706
|
+
): boolean {
|
|
707
|
+
return (
|
|
708
|
+
existing.source === "trusted" &&
|
|
709
|
+
existing.sessionId === event.sessionId &&
|
|
710
|
+
existing.visitorId === event.visitorId &&
|
|
711
|
+
existing.timestamp === event.timestamp &&
|
|
712
|
+
existing.path === event.path &&
|
|
713
|
+
existing.revenueCents === (event.revenueCents ?? 0) &&
|
|
714
|
+
existing.currency === (event.currency ?? "USD") &&
|
|
715
|
+
existing.goalKey === goalKey
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function loadSession(
|
|
720
|
+
ctx: MutationCtx,
|
|
721
|
+
siteId: Id<"sites">,
|
|
722
|
+
sessionId: string,
|
|
723
|
+
batchState: BatchState,
|
|
724
|
+
): Promise<CachedRow<CachedDocument<"sessions">>> {
|
|
725
|
+
const cached = batchState.sessions.get(sessionId);
|
|
726
|
+
if (cached) return cached;
|
|
727
|
+
const current = await ctx.db
|
|
728
|
+
.query("sessions")
|
|
729
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
730
|
+
range.eq("siteId", siteId).eq("sessionId", sessionId),
|
|
731
|
+
)
|
|
732
|
+
.unique();
|
|
733
|
+
const loaded = { current, dirty: false };
|
|
734
|
+
batchState.sessions.set(sessionId, loaded);
|
|
735
|
+
return loaded;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function loadLiveSession(
|
|
739
|
+
ctx: MutationCtx,
|
|
740
|
+
siteId: Id<"sites">,
|
|
741
|
+
sessionId: string,
|
|
742
|
+
batchState: BatchState,
|
|
743
|
+
): Promise<CachedRow<CachedDocument<"liveSessions">>> {
|
|
744
|
+
const cached = batchState.liveSessions.get(sessionId);
|
|
745
|
+
if (cached) return cached;
|
|
746
|
+
const current = await ctx.db
|
|
747
|
+
.query("liveSessions")
|
|
748
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
749
|
+
range.eq("siteId", siteId).eq("sessionId", sessionId),
|
|
750
|
+
)
|
|
751
|
+
.unique();
|
|
752
|
+
const loaded = { current, dirty: false };
|
|
753
|
+
batchState.liveSessions.set(sessionId, loaded);
|
|
754
|
+
return loaded;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function flushBatchState(ctx: MutationCtx, batchState: BatchState) {
|
|
758
|
+
for (const state of batchState.sessions.values()) {
|
|
759
|
+
if (state.dirty && state.current) {
|
|
760
|
+
await ctx.db.replace(
|
|
761
|
+
"sessions",
|
|
762
|
+
state.current._id,
|
|
763
|
+
withoutSystemFields(state.current),
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
for (const state of batchState.liveSessions.values()) {
|
|
768
|
+
if (state.dirty && state.current) {
|
|
769
|
+
await ctx.db.replace(
|
|
770
|
+
"liveSessions",
|
|
771
|
+
state.current._id,
|
|
772
|
+
withoutSystemFields(state.current),
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
for (const state of batchState.visitorAttributions.values()) {
|
|
777
|
+
if (state.dirty && state.current) {
|
|
778
|
+
await ctx.db.replace(
|
|
779
|
+
"visitorAttributions",
|
|
780
|
+
state.current._id,
|
|
781
|
+
withoutSystemFields(state.current),
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
for (const state of batchState.funnelProgress.values()) {
|
|
786
|
+
if (state.dirty && state.current) {
|
|
787
|
+
await ctx.db.replace(
|
|
788
|
+
"funnelProgress",
|
|
789
|
+
state.current._id,
|
|
790
|
+
withoutSystemFields(state.current),
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
await flushEventBatches(ctx, batchState.eventBatches);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function withoutSystemFields<
|
|
798
|
+
T extends { _id: unknown; _creationTime?: number },
|
|
799
|
+
>(document: T): Omit<T, "_id" | "_creationTime"> {
|
|
800
|
+
const { _id, _creationTime, ...fields } = document;
|
|
801
|
+
void _id;
|
|
802
|
+
void _creationTime;
|
|
803
|
+
return fields;
|
|
804
|
+
}
|
|
805
|
+
|
|
357
806
|
async function accountEvent(
|
|
358
807
|
ctx: MutationCtx,
|
|
359
808
|
input: {
|
|
360
809
|
siteId: Id<"sites">;
|
|
361
810
|
event: TrackerEvent;
|
|
362
811
|
context: IngestContext;
|
|
363
|
-
existingSession:
|
|
812
|
+
existingSession: CachedDocument<"sessions"> | null;
|
|
364
813
|
goals: Doc<"goals">[];
|
|
365
814
|
funnels: Doc<"funnels">[];
|
|
366
815
|
aggregateDeltas: AggregateDeltas;
|
|
816
|
+
reportRollupDeltas: ReportRollupDeltas;
|
|
367
817
|
recordTelemetry: boolean;
|
|
368
818
|
financialConversion: boolean;
|
|
369
819
|
trustedGoalKey: string | undefined;
|
|
370
820
|
now: number;
|
|
821
|
+
batchState?: BatchState;
|
|
371
822
|
},
|
|
372
|
-
) {
|
|
823
|
+
): Promise<SessionState> {
|
|
373
824
|
const { event } = input;
|
|
374
825
|
const session = input.recordTelemetry
|
|
375
826
|
? await upsertSession(
|
|
@@ -379,72 +830,91 @@ async function accountEvent(
|
|
|
379
830
|
input.context,
|
|
380
831
|
input.existingSession,
|
|
381
832
|
input.financialConversion,
|
|
833
|
+
input.reportRollupDeltas,
|
|
834
|
+
input.batchState,
|
|
382
835
|
)
|
|
383
836
|
: await accountTrustedSession(ctx, input.existingSession, event);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
...(event.referrer ? { referrer: event.referrer } : {}),
|
|
395
|
-
timestamp: event.timestamp,
|
|
396
|
-
sequence: event.sequence,
|
|
397
|
-
...(event.target ? { target: event.target } : {}),
|
|
398
|
-
...(event.href ? { href: event.href } : {}),
|
|
399
|
-
...(event.properties ? { properties: event.properties } : {}),
|
|
400
|
-
...(event.revenueCents !== undefined
|
|
401
|
-
? { revenueCents: event.revenueCents }
|
|
402
|
-
: {}),
|
|
403
|
-
...(event.currency ? { currency: event.currency } : {}),
|
|
404
|
-
...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
|
|
405
|
-
...(input.context.country ? { country: input.context.country } : {}),
|
|
406
|
-
...(input.context.city ? { city: input.context.city } : {}),
|
|
407
|
-
...(input.context.latitude !== undefined
|
|
408
|
-
? { latitude: input.context.latitude }
|
|
409
|
-
: {}),
|
|
410
|
-
...(input.context.longitude !== undefined
|
|
411
|
-
? { longitude: input.context.longitude }
|
|
412
|
-
: {}),
|
|
413
|
-
browser: input.context.browser ?? "Other",
|
|
414
|
-
os: input.context.os ?? "Other",
|
|
415
|
-
device: input.context.device ?? "unknown",
|
|
416
|
-
});
|
|
837
|
+
if (input.recordTelemetry && event.type === "pageview") {
|
|
838
|
+
// `existingSession` is the snapshot from before `upsertSession`, so it
|
|
839
|
+
// still carries the route the visitor is leaving.
|
|
840
|
+
foldRouteRollups(
|
|
841
|
+
input.reportRollupDeltas,
|
|
842
|
+
input.siteId,
|
|
843
|
+
event,
|
|
844
|
+
session,
|
|
845
|
+
input.existingSession,
|
|
846
|
+
);
|
|
417
847
|
}
|
|
418
848
|
|
|
419
849
|
const matchedGoalKeys: string[] = [];
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
)
|
|
430
|
-
.
|
|
431
|
-
|
|
850
|
+
const matchingGoals = input.goals.filter(
|
|
851
|
+
(goal) => goal.active && goalMatches(goal, event),
|
|
852
|
+
);
|
|
853
|
+
if (matchingGoals.length > 1) {
|
|
854
|
+
fail("LIMIT_EXCEEDED", "active goals must use unique matchers");
|
|
855
|
+
}
|
|
856
|
+
for (const goal of matchingGoals) {
|
|
857
|
+
const completionEventId = JSON.stringify([
|
|
858
|
+
event.sessionId,
|
|
859
|
+
eventBucketStart(event.timestamp),
|
|
860
|
+
event.eventId,
|
|
861
|
+
]);
|
|
862
|
+
const scopedPrior = (
|
|
863
|
+
await ctx.db
|
|
864
|
+
.query("goalCompletions")
|
|
865
|
+
.withIndex("by_siteId_and_goalId_and_eventId", (range) =>
|
|
866
|
+
range
|
|
867
|
+
.eq("siteId", input.siteId)
|
|
868
|
+
.eq("goalId", goal._id)
|
|
869
|
+
.eq("eventId", completionEventId),
|
|
870
|
+
)
|
|
871
|
+
.take(2)
|
|
872
|
+
).find((completion) => completion.idempotencyVersion === 2);
|
|
873
|
+
const legacyPrior = scopedPrior
|
|
874
|
+
? undefined
|
|
875
|
+
: (
|
|
876
|
+
await ctx.db
|
|
877
|
+
.query("goalCompletions")
|
|
878
|
+
.withIndex("by_siteId_and_goalId_and_eventId", (range) =>
|
|
879
|
+
range
|
|
880
|
+
.eq("siteId", input.siteId)
|
|
881
|
+
.eq("goalId", goal._id)
|
|
882
|
+
.eq("eventId", event.eventId),
|
|
883
|
+
)
|
|
884
|
+
.take(2)
|
|
885
|
+
).find(
|
|
886
|
+
(completion) =>
|
|
887
|
+
completion.idempotencyVersion === undefined &&
|
|
888
|
+
completion.sessionId === event.sessionId &&
|
|
889
|
+
eventBucketStart(completion.timestamp) ===
|
|
890
|
+
eventBucketStart(event.timestamp),
|
|
891
|
+
);
|
|
892
|
+
if (scopedPrior || legacyPrior) continue;
|
|
432
893
|
await ctx.db.insert("goalCompletions", {
|
|
433
894
|
siteId: input.siteId,
|
|
434
895
|
goalId: goal._id,
|
|
435
896
|
goalKey: goal.key,
|
|
436
|
-
eventId:
|
|
897
|
+
eventId: completionEventId,
|
|
898
|
+
idempotencyVersion: 2,
|
|
437
899
|
sessionId: event.sessionId,
|
|
438
900
|
visitorId: event.visitorId,
|
|
439
901
|
timestamp: event.timestamp,
|
|
440
902
|
valueCents: goal.valueCents ?? 0,
|
|
441
903
|
});
|
|
904
|
+
foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
|
|
442
905
|
matchedGoalKeys.push(goal.key);
|
|
443
906
|
}
|
|
444
907
|
|
|
445
908
|
for (const funnel of input.funnels) {
|
|
446
909
|
if (funnel.active) {
|
|
447
|
-
await advanceFunnel(
|
|
910
|
+
await advanceFunnel(
|
|
911
|
+
ctx,
|
|
912
|
+
input.siteId,
|
|
913
|
+
funnel,
|
|
914
|
+
event,
|
|
915
|
+
input.reportRollupDeltas,
|
|
916
|
+
input.batchState,
|
|
917
|
+
);
|
|
448
918
|
}
|
|
449
919
|
}
|
|
450
920
|
|
|
@@ -455,18 +925,23 @@ async function accountEvent(
|
|
|
455
925
|
session,
|
|
456
926
|
event,
|
|
457
927
|
input.trustedGoalKey,
|
|
928
|
+
input.reportRollupDeltas,
|
|
458
929
|
);
|
|
459
930
|
}
|
|
460
931
|
|
|
932
|
+
const campaignDimensions: Dimension[] = session.utmCampaign
|
|
933
|
+
? [{ type: "campaign", value: session.utmCampaign }]
|
|
934
|
+
: [];
|
|
461
935
|
const dimensions: Dimension[] = [
|
|
462
936
|
{ type: "source", value: session.source },
|
|
937
|
+
...campaignDimensions,
|
|
463
938
|
{ type: "page", value: event.path },
|
|
464
|
-
{ type: "country", value:
|
|
939
|
+
{ type: "country", value: session.country ?? "unknown" },
|
|
465
940
|
{ type: "device", value: input.context.device ?? "unknown" },
|
|
466
941
|
{ type: "browser", value: input.context.browser ?? "Other" },
|
|
467
942
|
{
|
|
468
943
|
type: "event",
|
|
469
|
-
value: event.type === "custom" ? event.name ?? "custom" : event.type,
|
|
944
|
+
value: event.type === "custom" ? (event.name ?? "custom") : event.type,
|
|
470
945
|
},
|
|
471
946
|
...matchedGoalKeys.map((value): Dimension => ({ type: "goal", value })),
|
|
472
947
|
];
|
|
@@ -491,13 +966,15 @@ async function accountEvent(
|
|
|
491
966
|
input.context,
|
|
492
967
|
session,
|
|
493
968
|
input.now,
|
|
969
|
+
input.batchState,
|
|
494
970
|
);
|
|
495
971
|
}
|
|
972
|
+
return session;
|
|
496
973
|
}
|
|
497
974
|
|
|
498
975
|
async function accountTrustedSession(
|
|
499
976
|
ctx: MutationCtx,
|
|
500
|
-
existing:
|
|
977
|
+
existing: CachedDocument<"sessions"> | null,
|
|
501
978
|
event: TrackerEvent,
|
|
502
979
|
): Promise<SessionState> {
|
|
503
980
|
if (!existing) {
|
|
@@ -515,6 +992,12 @@ async function accountTrustedSession(
|
|
|
515
992
|
isNewVisitorDay: false,
|
|
516
993
|
startedAt: existing.startedAt,
|
|
517
994
|
source: existing.source,
|
|
995
|
+
utmCampaign: existing.utmCampaign,
|
|
996
|
+
visitorKey: existing.visitorKey ?? existing.visitorId,
|
|
997
|
+
country: existing.country,
|
|
998
|
+
city: existing.city,
|
|
999
|
+
latitude: existing.latitude,
|
|
1000
|
+
longitude: existing.longitude,
|
|
518
1001
|
pageviewCount: existing.pageviewCount,
|
|
519
1002
|
eventCount: existing.eventCount,
|
|
520
1003
|
affiliateId: existing.affiliateId,
|
|
@@ -529,29 +1012,65 @@ async function upsertSession(
|
|
|
529
1012
|
siteId: Id<"sites">,
|
|
530
1013
|
event: TrackerEvent,
|
|
531
1014
|
context: IngestContext,
|
|
532
|
-
existing:
|
|
1015
|
+
existing: CachedDocument<"sessions"> | null,
|
|
533
1016
|
financialConversion: boolean,
|
|
1017
|
+
reportRollupDeltas: ReportRollupDeltas,
|
|
1018
|
+
batchState?: BatchState,
|
|
534
1019
|
): Promise<SessionState> {
|
|
535
|
-
|
|
1020
|
+
// A host-supplied id is the identity; anonymous sessions count through the
|
|
1021
|
+
// daily key derived by the HTTP action, or once per session without one.
|
|
1022
|
+
const visitorKey =
|
|
1023
|
+
existing?.visitorKey ??
|
|
1024
|
+
(event.visitorId !== event.sessionId
|
|
1025
|
+
? event.visitorId
|
|
1026
|
+
: (context.visitorKey ?? event.sessionId));
|
|
536
1027
|
const utmSource = stringProperty(event.properties, "utm_source");
|
|
1028
|
+
// Campaign traffic names its own source; the referrer host is the fallback.
|
|
1029
|
+
const source = utmSource?.toLowerCase() ?? sourceFromReferrer(event.referrer);
|
|
537
1030
|
const utmMedium = stringProperty(event.properties, "utm_medium");
|
|
538
1031
|
const utmCampaign = stringProperty(event.properties, "utm_campaign");
|
|
539
|
-
const attribution = await attributionForEvent(
|
|
1032
|
+
const attribution = await attributionForEvent(
|
|
1033
|
+
ctx,
|
|
1034
|
+
siteId,
|
|
1035
|
+
event,
|
|
1036
|
+
existing,
|
|
1037
|
+
batchState,
|
|
1038
|
+
);
|
|
540
1039
|
if (existing) {
|
|
1040
|
+
if (attribution && existing.affiliateId !== attribution.affiliateId) {
|
|
1041
|
+
foldAffiliateVisitRollup(
|
|
1042
|
+
reportRollupDeltas,
|
|
1043
|
+
siteId,
|
|
1044
|
+
event,
|
|
1045
|
+
attribution,
|
|
1046
|
+
visitorKey,
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
541
1049
|
const startedAt = Math.min(existing.startedAt, event.timestamp);
|
|
542
1050
|
const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
|
|
543
|
-
|
|
1051
|
+
const updates = {
|
|
544
1052
|
startedAt,
|
|
545
1053
|
lastSeenAt,
|
|
546
|
-
entryPath:
|
|
547
|
-
|
|
1054
|
+
entryPath:
|
|
1055
|
+
event.timestamp < existing.startedAt ? event.path : existing.entryPath,
|
|
1056
|
+
exitPath:
|
|
1057
|
+
event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
|
|
548
1058
|
eventCount: existing.eventCount + 1,
|
|
549
|
-
pageviewCount:
|
|
1059
|
+
pageviewCount:
|
|
1060
|
+
existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
|
|
1061
|
+
...(event.type === "pageview"
|
|
1062
|
+
? {
|
|
1063
|
+
lastPageviewAt: Math.max(
|
|
1064
|
+
existing.lastPageviewAt ?? 0,
|
|
1065
|
+
event.timestamp,
|
|
1066
|
+
),
|
|
1067
|
+
}
|
|
1068
|
+
: {}),
|
|
550
1069
|
durationMs: Math.max(0, lastSeenAt - startedAt),
|
|
551
1070
|
conversionCount: existing.conversionCount + Number(financialConversion),
|
|
552
1071
|
revenueCents:
|
|
553
1072
|
existing.revenueCents +
|
|
554
|
-
(financialConversion ? event.revenueCents ?? 0 : 0),
|
|
1073
|
+
(financialConversion ? (event.revenueCents ?? 0) : 0),
|
|
555
1074
|
...(financialConversion && event.currency
|
|
556
1075
|
? { currency: event.currency }
|
|
557
1076
|
: {}),
|
|
@@ -562,11 +1081,21 @@ async function upsertSession(
|
|
|
562
1081
|
...(context.country ? { country: context.country } : {}),
|
|
563
1082
|
...(context.city ? { city: context.city } : {}),
|
|
564
1083
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
565
|
-
...(context.longitude !== undefined
|
|
1084
|
+
...(context.longitude !== undefined
|
|
1085
|
+
? { longitude: context.longitude }
|
|
1086
|
+
: {}),
|
|
566
1087
|
browser: context.browser ?? existing.browser,
|
|
567
1088
|
os: context.os ?? existing.os,
|
|
568
1089
|
device: context.device ?? existing.device,
|
|
569
|
-
}
|
|
1090
|
+
};
|
|
1091
|
+
if (batchState) {
|
|
1092
|
+
const state = batchState.sessions.get(event.sessionId);
|
|
1093
|
+
if (!state) throw new Error("session cache was not initialized");
|
|
1094
|
+
state.current = { ...existing, ...updates };
|
|
1095
|
+
state.dirty = true;
|
|
1096
|
+
} else {
|
|
1097
|
+
await ctx.db.patch("sessions", existing._id, updates);
|
|
1098
|
+
}
|
|
570
1099
|
return {
|
|
571
1100
|
isNewSession: false,
|
|
572
1101
|
isNewVisitorHour:
|
|
@@ -577,6 +1106,12 @@ async function upsertSession(
|
|
|
577
1106
|
Math.floor(event.timestamp / DAY_MS),
|
|
578
1107
|
startedAt,
|
|
579
1108
|
source: existing.source,
|
|
1109
|
+
utmCampaign: existing.utmCampaign,
|
|
1110
|
+
visitorKey,
|
|
1111
|
+
country: context.country ?? existing.country,
|
|
1112
|
+
city: context.city ?? existing.city,
|
|
1113
|
+
latitude: context.latitude ?? existing.latitude,
|
|
1114
|
+
longitude: context.longitude ?? existing.longitude,
|
|
580
1115
|
pageviewCount:
|
|
581
1116
|
existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
|
|
582
1117
|
eventCount: existing.eventCount + 1,
|
|
@@ -584,17 +1119,31 @@ async function upsertSession(
|
|
|
584
1119
|
};
|
|
585
1120
|
}
|
|
586
1121
|
|
|
587
|
-
const
|
|
1122
|
+
const priorVisits = await ctx.db
|
|
588
1123
|
.query("sessions")
|
|
589
1124
|
.withIndex("by_siteId_and_visitorId_and_lastSeenAt", (range) =>
|
|
590
1125
|
range.eq("siteId", siteId).eq("visitorId", event.visitorId),
|
|
591
1126
|
)
|
|
592
1127
|
.order("desc")
|
|
593
1128
|
.take(1);
|
|
594
|
-
|
|
1129
|
+
let priorVisit: CachedDocument<"sessions"> | null = priorVisits[0] ?? null;
|
|
1130
|
+
if (batchState) {
|
|
1131
|
+
for (const state of batchState.sessions.values()) {
|
|
1132
|
+
const candidate = state.current;
|
|
1133
|
+
if (
|
|
1134
|
+
candidate?.visitorId === event.visitorId &&
|
|
1135
|
+
candidate.sessionId !== event.sessionId &&
|
|
1136
|
+
(!priorVisit || candidate.lastSeenAt > priorVisit.lastSeenAt)
|
|
1137
|
+
) {
|
|
1138
|
+
priorVisit = candidate;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
|
|
595
1143
|
siteId,
|
|
596
1144
|
sessionId: event.sessionId,
|
|
597
1145
|
visitorId: event.visitorId,
|
|
1146
|
+
visitorKey,
|
|
598
1147
|
startedAt: event.timestamp,
|
|
599
1148
|
lastSeenAt: event.timestamp,
|
|
600
1149
|
entryPath: event.path,
|
|
@@ -607,46 +1156,71 @@ async function upsertSession(
|
|
|
607
1156
|
...(context.country ? { country: context.country } : {}),
|
|
608
1157
|
...(context.city ? { city: context.city } : {}),
|
|
609
1158
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
610
|
-
...(context.longitude !== undefined
|
|
1159
|
+
...(context.longitude !== undefined
|
|
1160
|
+
? { longitude: context.longitude }
|
|
1161
|
+
: {}),
|
|
611
1162
|
browser: context.browser ?? "Other",
|
|
612
1163
|
os: context.os ?? "Other",
|
|
613
1164
|
device: context.device ?? "unknown",
|
|
614
1165
|
eventCount: 1,
|
|
615
1166
|
pageviewCount: event.type === "pageview" ? 1 : 0,
|
|
1167
|
+
...(event.type === "pageview" ? { lastPageviewAt: event.timestamp } : {}),
|
|
616
1168
|
durationMs: 0,
|
|
617
1169
|
conversionCount: Number(financialConversion),
|
|
618
|
-
revenueCents: financialConversion ? event.revenueCents ?? 0 : 0,
|
|
1170
|
+
revenueCents: financialConversion ? (event.revenueCents ?? 0) : 0,
|
|
619
1171
|
...(financialConversion && event.currency
|
|
620
1172
|
? { currency: event.currency }
|
|
621
1173
|
: {}),
|
|
622
1174
|
...(attribution ?? {}),
|
|
623
|
-
}
|
|
1175
|
+
};
|
|
1176
|
+
const sessionId = await ctx.db.insert("sessions", fields);
|
|
1177
|
+
if (attribution) {
|
|
1178
|
+
foldAffiliateVisitRollup(
|
|
1179
|
+
reportRollupDeltas,
|
|
1180
|
+
siteId,
|
|
1181
|
+
event,
|
|
1182
|
+
attribution,
|
|
1183
|
+
visitorKey,
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
if (batchState) {
|
|
1187
|
+
const state = batchState.sessions.get(event.sessionId);
|
|
1188
|
+
if (!state) throw new Error("session cache was not initialized");
|
|
1189
|
+
state.current = {
|
|
1190
|
+
...fields,
|
|
1191
|
+
_id: sessionId,
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
624
1194
|
return {
|
|
625
1195
|
isNewSession: true,
|
|
626
1196
|
isNewVisitorHour:
|
|
627
|
-
priorVisit
|
|
628
|
-
Math.floor(priorVisit
|
|
1197
|
+
priorVisit === null ||
|
|
1198
|
+
Math.floor(priorVisit.lastSeenAt / HOUR_MS) <
|
|
629
1199
|
Math.floor(event.timestamp / HOUR_MS),
|
|
630
1200
|
isNewVisitorDay:
|
|
631
|
-
priorVisit
|
|
632
|
-
Math.floor(priorVisit
|
|
1201
|
+
priorVisit === null ||
|
|
1202
|
+
Math.floor(priorVisit.lastSeenAt / DAY_MS) <
|
|
633
1203
|
Math.floor(event.timestamp / DAY_MS),
|
|
634
1204
|
startedAt: event.timestamp,
|
|
635
1205
|
source,
|
|
1206
|
+
utmCampaign,
|
|
1207
|
+
visitorKey,
|
|
1208
|
+
country: context.country,
|
|
1209
|
+
city: context.city,
|
|
1210
|
+
latitude: context.latitude,
|
|
1211
|
+
longitude: context.longitude,
|
|
636
1212
|
pageviewCount: event.type === "pageview" ? 1 : 0,
|
|
637
1213
|
eventCount: 1,
|
|
638
1214
|
...(attribution ?? {}),
|
|
639
1215
|
};
|
|
640
1216
|
}
|
|
641
1217
|
|
|
642
|
-
function attributionSnapshot(
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
},
|
|
649
|
-
): AttributionSnapshot | undefined {
|
|
1218
|
+
function attributionSnapshot(value: {
|
|
1219
|
+
affiliateId?: Id<"affiliates">;
|
|
1220
|
+
affiliateSlug?: string;
|
|
1221
|
+
affiliateAttributedAt?: number;
|
|
1222
|
+
affiliateExpiresAt?: number;
|
|
1223
|
+
}): AttributionSnapshot | undefined {
|
|
650
1224
|
if (
|
|
651
1225
|
!value.affiliateId ||
|
|
652
1226
|
!value.affiliateSlug ||
|
|
@@ -663,7 +1237,6 @@ function attributionSnapshot(
|
|
|
663
1237
|
};
|
|
664
1238
|
}
|
|
665
1239
|
|
|
666
|
-
|
|
667
1240
|
function attributionApplies(
|
|
668
1241
|
attribution: AttributionSnapshot,
|
|
669
1242
|
timestamp: number,
|
|
@@ -680,7 +1253,8 @@ async function attributionForEvent(
|
|
|
680
1253
|
ctx: MutationCtx,
|
|
681
1254
|
siteId: Id<"sites">,
|
|
682
1255
|
event: TrackerEvent,
|
|
683
|
-
existingSession:
|
|
1256
|
+
existingSession: CachedDocument<"sessions"> | null,
|
|
1257
|
+
batchState?: BatchState,
|
|
684
1258
|
): Promise<AttributionSnapshot | undefined> {
|
|
685
1259
|
const sessionAttribution = existingSession
|
|
686
1260
|
? attributionSnapshot(existingSession)
|
|
@@ -696,12 +1270,25 @@ async function attributionForEvent(
|
|
|
696
1270
|
return sessionAttribution;
|
|
697
1271
|
}
|
|
698
1272
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1273
|
+
let attributionState = batchState?.visitorAttributions.get(event.visitorId);
|
|
1274
|
+
if (batchState && !attributionState) {
|
|
1275
|
+
const current = await ctx.db
|
|
1276
|
+
.query("visitorAttributions")
|
|
1277
|
+
.withIndex("by_siteId_and_visitorId", (range) =>
|
|
1278
|
+
range.eq("siteId", siteId).eq("visitorId", event.visitorId),
|
|
1279
|
+
)
|
|
1280
|
+
.unique();
|
|
1281
|
+
attributionState = { current, dirty: false };
|
|
1282
|
+
batchState.visitorAttributions.set(event.visitorId, attributionState);
|
|
1283
|
+
}
|
|
1284
|
+
const current = attributionState
|
|
1285
|
+
? attributionState.current
|
|
1286
|
+
: await ctx.db
|
|
1287
|
+
.query("visitorAttributions")
|
|
1288
|
+
.withIndex("by_siteId_and_visitorId", (range) =>
|
|
1289
|
+
range.eq("siteId", siteId).eq("visitorId", event.visitorId),
|
|
1290
|
+
)
|
|
1291
|
+
.unique();
|
|
705
1292
|
if (current) {
|
|
706
1293
|
const snapshot: AttributionSnapshot = {
|
|
707
1294
|
affiliateId: current.affiliateId,
|
|
@@ -733,22 +1320,30 @@ async function attributionForEvent(
|
|
|
733
1320
|
|
|
734
1321
|
const attributedAt = event.timestamp;
|
|
735
1322
|
const expiresAt = attributedAt + affiliate.attributionWindowDays * DAY_MS;
|
|
1323
|
+
const fields = {
|
|
1324
|
+
siteId,
|
|
1325
|
+
visitorId: event.visitorId,
|
|
1326
|
+
affiliateId: affiliate._id,
|
|
1327
|
+
affiliateSlug: affiliate.slug,
|
|
1328
|
+
attributedAt,
|
|
1329
|
+
expiresAt,
|
|
1330
|
+
};
|
|
736
1331
|
if (current) {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
}
|
|
1332
|
+
if (attributionState) {
|
|
1333
|
+
attributionState.current = { ...current, ...fields };
|
|
1334
|
+
attributionState.dirty = true;
|
|
1335
|
+
} else {
|
|
1336
|
+
await ctx.db.patch("visitorAttributions", current._id, fields);
|
|
1337
|
+
}
|
|
743
1338
|
} else {
|
|
744
|
-
await ctx.db.insert("visitorAttributions",
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
}
|
|
1339
|
+
const attributionId = await ctx.db.insert("visitorAttributions", fields);
|
|
1340
|
+
if (attributionState) {
|
|
1341
|
+
attributionState.current = {
|
|
1342
|
+
...fields,
|
|
1343
|
+
_id: attributionId,
|
|
1344
|
+
_creationTime: event.timestamp,
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
752
1347
|
}
|
|
753
1348
|
return {
|
|
754
1349
|
affiliateId: affiliate._id,
|
|
@@ -761,28 +1356,38 @@ async function attributionForEvent(
|
|
|
761
1356
|
async function accountHeartbeat(
|
|
762
1357
|
ctx: MutationCtx,
|
|
763
1358
|
siteId: Id<"sites">,
|
|
764
|
-
session:
|
|
1359
|
+
session: CachedDocument<"sessions">,
|
|
765
1360
|
event: TrackerEvent,
|
|
766
1361
|
context: IngestContext,
|
|
767
1362
|
now: number,
|
|
1363
|
+
batchState?: BatchState,
|
|
768
1364
|
) {
|
|
769
1365
|
const startedAt = Math.min(session.startedAt, event.timestamp);
|
|
770
1366
|
const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
|
|
771
|
-
|
|
1367
|
+
const updates = {
|
|
772
1368
|
startedAt,
|
|
773
1369
|
lastSeenAt,
|
|
774
|
-
exitPath:
|
|
775
|
-
? event.path
|
|
776
|
-
: session.exitPath,
|
|
1370
|
+
exitPath:
|
|
1371
|
+
event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
|
|
777
1372
|
durationMs: Math.max(0, lastSeenAt - startedAt),
|
|
778
1373
|
...(context.country ? { country: context.country } : {}),
|
|
779
1374
|
...(context.city ? { city: context.city } : {}),
|
|
780
1375
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
781
|
-
...(context.longitude !== undefined
|
|
1376
|
+
...(context.longitude !== undefined
|
|
1377
|
+
? { longitude: context.longitude }
|
|
1378
|
+
: {}),
|
|
782
1379
|
browser: context.browser ?? session.browser,
|
|
783
1380
|
os: context.os ?? session.os,
|
|
784
1381
|
device: context.device ?? session.device,
|
|
785
|
-
}
|
|
1382
|
+
};
|
|
1383
|
+
if (batchState) {
|
|
1384
|
+
const state = batchState.sessions.get(event.sessionId);
|
|
1385
|
+
if (!state) throw new Error("session cache was not initialized");
|
|
1386
|
+
state.current = { ...session, ...updates };
|
|
1387
|
+
state.dirty = true;
|
|
1388
|
+
} else {
|
|
1389
|
+
await ctx.db.patch("sessions", session._id, updates);
|
|
1390
|
+
}
|
|
786
1391
|
await updateLiveSession(
|
|
787
1392
|
ctx,
|
|
788
1393
|
siteId,
|
|
@@ -794,11 +1399,17 @@ async function accountHeartbeat(
|
|
|
794
1399
|
isNewVisitorDay: false,
|
|
795
1400
|
startedAt,
|
|
796
1401
|
source: session.source,
|
|
1402
|
+
visitorKey: session.visitorKey ?? session.visitorId,
|
|
1403
|
+
country: context.country ?? session.country,
|
|
1404
|
+
city: context.city ?? session.city,
|
|
1405
|
+
latitude: context.latitude ?? session.latitude,
|
|
1406
|
+
longitude: context.longitude ?? session.longitude,
|
|
797
1407
|
pageviewCount: session.pageviewCount,
|
|
798
1408
|
eventCount: session.eventCount,
|
|
799
1409
|
affiliateSlug: session.affiliateSlug,
|
|
800
1410
|
},
|
|
801
1411
|
now,
|
|
1412
|
+
batchState,
|
|
802
1413
|
);
|
|
803
1414
|
}
|
|
804
1415
|
|
|
@@ -806,17 +1417,18 @@ async function consumeSiteRateLimit(
|
|
|
806
1417
|
ctx: MutationCtx,
|
|
807
1418
|
siteId: Id<"sites">,
|
|
808
1419
|
events: TrackerEvent[],
|
|
1420
|
+
eventByteCounts: number[],
|
|
809
1421
|
now: number,
|
|
810
|
-
): Promise<
|
|
1422
|
+
): Promise<void> {
|
|
811
1423
|
const windowStart =
|
|
812
1424
|
Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
813
|
-
const increments = new Map<
|
|
814
|
-
|
|
815
|
-
|
|
1425
|
+
const increments = new Map<
|
|
1426
|
+
number,
|
|
1427
|
+
{ eventCount: number; byteCount: number }
|
|
1428
|
+
>();
|
|
816
1429
|
for (let index = 0; index < events.length; index += 1) {
|
|
817
1430
|
const event = events[index];
|
|
818
|
-
const byteCount =
|
|
819
|
-
eventByteCounts.push(byteCount);
|
|
1431
|
+
const byteCount = eventByteCounts[index];
|
|
820
1432
|
const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
|
|
821
1433
|
const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
|
|
822
1434
|
current.eventCount += 1;
|
|
@@ -830,15 +1442,16 @@ async function consumeSiteRateLimit(
|
|
|
830
1442
|
window: Doc<"siteIngestWindows"> | null;
|
|
831
1443
|
}> = [];
|
|
832
1444
|
for (const [shard, increment] of increments) {
|
|
833
|
-
const
|
|
1445
|
+
const rows = await ctx.db
|
|
834
1446
|
.query("siteIngestWindows")
|
|
835
1447
|
.withIndex("by_siteId_and_shard_and_windowStart", (range) =>
|
|
836
|
-
range
|
|
837
|
-
.eq("siteId", siteId)
|
|
838
|
-
.eq("shard", shard)
|
|
839
|
-
.eq("windowStart", windowStart),
|
|
1448
|
+
range.eq("siteId", siteId).eq("shard", shard),
|
|
840
1449
|
)
|
|
841
|
-
.
|
|
1450
|
+
.order("desc")
|
|
1451
|
+
.take(1);
|
|
1452
|
+
const storedWindow = rows[0] ?? null;
|
|
1453
|
+
const window =
|
|
1454
|
+
storedWindow?.windowStart === windowStart ? storedWindow : null;
|
|
842
1455
|
if (
|
|
843
1456
|
(window?.eventCount ?? 0) + increment.eventCount >
|
|
844
1457
|
MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
|
|
@@ -850,20 +1463,25 @@ async function consumeSiteRateLimit(
|
|
|
850
1463
|
windowMs: RATE_LIMIT_WINDOW_MS,
|
|
851
1464
|
eventLimitPerShard: MAX_SITE_EVENTS_PER_SHARD_WINDOW,
|
|
852
1465
|
byteLimitPerShard: MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
853
|
-
siteEventLimit:
|
|
854
|
-
|
|
855
|
-
siteByteLimit:
|
|
856
|
-
SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
1466
|
+
siteEventLimit: SITE_INGEST_SHARDS * MAX_SITE_EVENTS_PER_SHARD_WINDOW,
|
|
1467
|
+
siteByteLimit: SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
857
1468
|
});
|
|
858
1469
|
}
|
|
859
|
-
windows.push({ shard, increment, window });
|
|
1470
|
+
windows.push({ shard, increment, window: storedWindow });
|
|
860
1471
|
}
|
|
861
1472
|
|
|
862
1473
|
for (const { shard, increment, window } of windows) {
|
|
863
1474
|
if (window) {
|
|
864
1475
|
await ctx.db.patch("siteIngestWindows", window._id, {
|
|
865
|
-
|
|
866
|
-
|
|
1476
|
+
windowStart,
|
|
1477
|
+
eventCount:
|
|
1478
|
+
window.windowStart === windowStart
|
|
1479
|
+
? window.eventCount + increment.eventCount
|
|
1480
|
+
: increment.eventCount,
|
|
1481
|
+
byteCount:
|
|
1482
|
+
window.windowStart === windowStart
|
|
1483
|
+
? window.byteCount + increment.byteCount
|
|
1484
|
+
: increment.byteCount,
|
|
867
1485
|
updatedAt: now,
|
|
868
1486
|
});
|
|
869
1487
|
} else {
|
|
@@ -877,7 +1495,6 @@ async function consumeSiteRateLimit(
|
|
|
877
1495
|
});
|
|
878
1496
|
}
|
|
879
1497
|
}
|
|
880
|
-
return eventByteCounts;
|
|
881
1498
|
}
|
|
882
1499
|
|
|
883
1500
|
async function consumeRateLimit(
|
|
@@ -886,43 +1503,71 @@ async function consumeRateLimit(
|
|
|
886
1503
|
sessionId: string,
|
|
887
1504
|
byteCount: number,
|
|
888
1505
|
now: number,
|
|
1506
|
+
states: Map<string, SessionRateWindowState>,
|
|
889
1507
|
): Promise<boolean> {
|
|
890
|
-
const windowStart =
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
.eq("
|
|
898
|
-
|
|
899
|
-
|
|
1508
|
+
const windowStart =
|
|
1509
|
+
Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
1510
|
+
let state = states.get(sessionId);
|
|
1511
|
+
if (!state) {
|
|
1512
|
+
const rows = await ctx.db
|
|
1513
|
+
.query("ingestWindows")
|
|
1514
|
+
.withIndex("by_siteId_and_sessionId_and_windowStart", (range) =>
|
|
1515
|
+
range.eq("siteId", siteId).eq("sessionId", sessionId),
|
|
1516
|
+
)
|
|
1517
|
+
.order("desc")
|
|
1518
|
+
.take(1);
|
|
1519
|
+
const window = rows[0] ?? null;
|
|
1520
|
+
state = {
|
|
1521
|
+
siteId,
|
|
1522
|
+
sessionId,
|
|
1523
|
+
window,
|
|
1524
|
+
eventCount: window?.windowStart === windowStart ? window.eventCount : 0,
|
|
1525
|
+
byteCount: window?.windowStart === windowStart ? window.byteCount : 0,
|
|
1526
|
+
dirty: false,
|
|
1527
|
+
};
|
|
1528
|
+
states.set(sessionId, state);
|
|
1529
|
+
}
|
|
900
1530
|
if (
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
window.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW)
|
|
1531
|
+
state.eventCount >= MAX_EVENTS_PER_SESSION_WINDOW ||
|
|
1532
|
+
state.byteCount + byteCount > MAX_BYTES_PER_SESSION_WINDOW
|
|
904
1533
|
) {
|
|
905
1534
|
return false;
|
|
906
1535
|
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
byteCount: window.byteCount + byteCount,
|
|
911
|
-
updatedAt: now,
|
|
912
|
-
});
|
|
913
|
-
} else {
|
|
914
|
-
await ctx.db.insert("ingestWindows", {
|
|
915
|
-
siteId,
|
|
916
|
-
sessionId,
|
|
917
|
-
windowStart,
|
|
918
|
-
eventCount: 1,
|
|
919
|
-
byteCount,
|
|
920
|
-
updatedAt: now,
|
|
921
|
-
});
|
|
922
|
-
}
|
|
1536
|
+
state.eventCount += 1;
|
|
1537
|
+
state.byteCount += byteCount;
|
|
1538
|
+
state.dirty = true;
|
|
923
1539
|
return true;
|
|
924
1540
|
}
|
|
925
1541
|
|
|
1542
|
+
async function flushSessionRateLimits(
|
|
1543
|
+
ctx: MutationCtx,
|
|
1544
|
+
states: Map<string, SessionRateWindowState>,
|
|
1545
|
+
now: number,
|
|
1546
|
+
) {
|
|
1547
|
+
const windowStart =
|
|
1548
|
+
Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
1549
|
+
for (const state of states.values()) {
|
|
1550
|
+
if (!state.dirty) continue;
|
|
1551
|
+
if (state.window) {
|
|
1552
|
+
await ctx.db.patch("ingestWindows", state.window._id, {
|
|
1553
|
+
windowStart,
|
|
1554
|
+
eventCount: state.eventCount,
|
|
1555
|
+
byteCount: state.byteCount,
|
|
1556
|
+
updatedAt: now,
|
|
1557
|
+
});
|
|
1558
|
+
} else {
|
|
1559
|
+
await ctx.db.insert("ingestWindows", {
|
|
1560
|
+
siteId: state.siteId,
|
|
1561
|
+
sessionId: state.sessionId,
|
|
1562
|
+
windowStart,
|
|
1563
|
+
eventCount: state.eventCount,
|
|
1564
|
+
byteCount: state.byteCount,
|
|
1565
|
+
updatedAt: now,
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
926
1571
|
async function updateLiveSession(
|
|
927
1572
|
ctx: MutationCtx,
|
|
928
1573
|
siteId: Id<"sites">,
|
|
@@ -930,45 +1575,73 @@ async function updateLiveSession(
|
|
|
930
1575
|
context: IngestContext,
|
|
931
1576
|
session: SessionState,
|
|
932
1577
|
now: number,
|
|
1578
|
+
batchState?: BatchState,
|
|
933
1579
|
) {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
.
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1580
|
+
await ensureLiveSweep(ctx, siteId, now, batchState);
|
|
1581
|
+
const liveState = batchState
|
|
1582
|
+
? await loadLiveSession(ctx, siteId, event.sessionId, batchState)
|
|
1583
|
+
: undefined;
|
|
1584
|
+
const existing = liveState
|
|
1585
|
+
? liveState.current
|
|
1586
|
+
: await ctx.db
|
|
1587
|
+
.query("liveSessions")
|
|
1588
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
1589
|
+
range.eq("siteId", siteId).eq("sessionId", event.sessionId),
|
|
1590
|
+
)
|
|
1591
|
+
.unique();
|
|
1592
|
+
const deadline = now + LIVE_SESSION_TTL_MS;
|
|
1593
|
+
const country = context.country ?? session.country;
|
|
1594
|
+
const city = context.city ?? session.city;
|
|
1595
|
+
const latitude = context.latitude ?? session.latitude;
|
|
1596
|
+
const longitude = context.longitude ?? session.longitude;
|
|
948
1597
|
if (existing) {
|
|
949
|
-
|
|
1598
|
+
const recentHeartbeatEventIds =
|
|
1599
|
+
event.type === "heartbeat"
|
|
1600
|
+
? [
|
|
1601
|
+
...(existing.recentHeartbeatEventIds ??
|
|
1602
|
+
(existing.lastHeartbeatEventId
|
|
1603
|
+
? [existing.lastHeartbeatEventId]
|
|
1604
|
+
: [])),
|
|
1605
|
+
event.eventId,
|
|
1606
|
+
].slice(-8)
|
|
1607
|
+
: undefined;
|
|
1608
|
+
const updates = {
|
|
950
1609
|
visitorId: event.visitorId,
|
|
951
1610
|
path: event.path,
|
|
952
1611
|
lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
|
|
953
1612
|
expiresAt: deadline,
|
|
954
|
-
...(
|
|
955
|
-
...(
|
|
956
|
-
...(
|
|
957
|
-
...(
|
|
1613
|
+
...(country ? { country } : {}),
|
|
1614
|
+
...(city ? { city } : {}),
|
|
1615
|
+
...(latitude !== undefined ? { latitude } : {}),
|
|
1616
|
+
...(longitude !== undefined ? { longitude } : {}),
|
|
958
1617
|
browser: context.browser ?? existing.browser,
|
|
959
1618
|
os: context.os ?? existing.os,
|
|
960
1619
|
source: session.source,
|
|
961
1620
|
device: context.device ?? existing.device,
|
|
962
1621
|
pageviewCount: session.pageviewCount,
|
|
963
1622
|
eventCount: session.eventCount,
|
|
1623
|
+
// The live map animates a visitor from the route they left to the one
|
|
1624
|
+
// they reached; a heartbeat or click on the same page is not a move.
|
|
1625
|
+
...(event.type === "pageview" && event.path !== existing.path
|
|
1626
|
+
? { previousPath: existing.path, movedAt: event.timestamp }
|
|
1627
|
+
: {}),
|
|
964
1628
|
...(event.type === "heartbeat"
|
|
965
|
-
? {
|
|
1629
|
+
? {
|
|
1630
|
+
lastHeartbeatEventId: event.eventId,
|
|
1631
|
+
recentHeartbeatEventIds,
|
|
1632
|
+
}
|
|
966
1633
|
: {}),
|
|
967
|
-
}
|
|
1634
|
+
};
|
|
1635
|
+
if (liveState) {
|
|
1636
|
+
liveState.current = { ...existing, ...updates };
|
|
1637
|
+
liveState.dirty = true;
|
|
1638
|
+
} else {
|
|
1639
|
+
await ctx.db.patch("liveSessions", existing._id, updates);
|
|
1640
|
+
}
|
|
968
1641
|
return;
|
|
969
1642
|
}
|
|
970
1643
|
|
|
971
|
-
const
|
|
1644
|
+
const fields: Omit<Doc<"liveSessions">, "_id" | "_creationTime"> = {
|
|
972
1645
|
siteId,
|
|
973
1646
|
sessionId: event.sessionId,
|
|
974
1647
|
visitorId: event.visitorId,
|
|
@@ -976,11 +1649,10 @@ async function updateLiveSession(
|
|
|
976
1649
|
startedAt: event.timestamp,
|
|
977
1650
|
lastSeenAt: event.timestamp,
|
|
978
1651
|
expiresAt: deadline,
|
|
979
|
-
|
|
980
|
-
...(
|
|
981
|
-
...(
|
|
982
|
-
...(
|
|
983
|
-
...(context.longitude !== undefined ? { longitude: context.longitude } : {}),
|
|
1652
|
+
...(country ? { country } : {}),
|
|
1653
|
+
...(city ? { city } : {}),
|
|
1654
|
+
...(latitude !== undefined ? { latitude } : {}),
|
|
1655
|
+
...(longitude !== undefined ? { longitude } : {}),
|
|
984
1656
|
browser: context.browser ?? "Other",
|
|
985
1657
|
os: context.os ?? "Other",
|
|
986
1658
|
source: session.source,
|
|
@@ -988,13 +1660,46 @@ async function updateLiveSession(
|
|
|
988
1660
|
pageviewCount: session.pageviewCount,
|
|
989
1661
|
eventCount: session.eventCount,
|
|
990
1662
|
...(event.type === "heartbeat"
|
|
991
|
-
? {
|
|
1663
|
+
? {
|
|
1664
|
+
lastHeartbeatEventId: event.eventId,
|
|
1665
|
+
recentHeartbeatEventIds: [event.eventId],
|
|
1666
|
+
}
|
|
992
1667
|
: {}),
|
|
993
|
-
}
|
|
994
|
-
await ctx.
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1668
|
+
};
|
|
1669
|
+
const liveSessionId = await ctx.db.insert("liveSessions", fields);
|
|
1670
|
+
if (liveState) {
|
|
1671
|
+
liveState.current = {
|
|
1672
|
+
...fields,
|
|
1673
|
+
_id: liveSessionId,
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
async function ensureLiveSweep(
|
|
1679
|
+
ctx: MutationCtx,
|
|
1680
|
+
siteId: Id<"sites">,
|
|
1681
|
+
now: number,
|
|
1682
|
+
batchState?: BatchState,
|
|
1683
|
+
) {
|
|
1684
|
+
const key = String(siteId);
|
|
1685
|
+
if (batchState?.liveSweepSites.has(key)) return;
|
|
1686
|
+
const existing = await ctx.db
|
|
1687
|
+
.query("liveSweepStates")
|
|
1688
|
+
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1689
|
+
.unique();
|
|
1690
|
+
if (!existing || existing.scheduledAt <= now) {
|
|
1691
|
+
const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
|
|
1692
|
+
if (existing) {
|
|
1693
|
+
await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
|
|
1694
|
+
} else {
|
|
1695
|
+
await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
|
|
1696
|
+
}
|
|
1697
|
+
await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
|
|
1698
|
+
siteId,
|
|
1699
|
+
scheduledAt,
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
batchState?.liveSweepSites.add(key);
|
|
998
1703
|
}
|
|
999
1704
|
|
|
1000
1705
|
function goalMatches(goal: Doc<"goals">, event: TrackerEvent): boolean {
|
|
@@ -1009,20 +1714,39 @@ async function advanceFunnel(
|
|
|
1009
1714
|
siteId: Id<"sites">,
|
|
1010
1715
|
funnel: Doc<"funnels">,
|
|
1011
1716
|
event: TrackerEvent,
|
|
1717
|
+
reportRollupDeltas: ReportRollupDeltas,
|
|
1718
|
+
batchState?: BatchState,
|
|
1012
1719
|
) {
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1720
|
+
const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
|
|
1721
|
+
let progressState = batchState?.funnelProgress.get(cacheKey);
|
|
1722
|
+
if (batchState && !progressState) {
|
|
1723
|
+
const current = await ctx.db
|
|
1724
|
+
.query("funnelProgress")
|
|
1725
|
+
.withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
|
|
1726
|
+
range
|
|
1727
|
+
.eq("siteId", siteId)
|
|
1728
|
+
.eq("funnelId", funnel._id)
|
|
1729
|
+
.eq("visitorId", event.visitorId),
|
|
1730
|
+
)
|
|
1731
|
+
.unique();
|
|
1732
|
+
progressState = { current, dirty: false };
|
|
1733
|
+
batchState.funnelProgress.set(cacheKey, progressState);
|
|
1734
|
+
}
|
|
1735
|
+
const progress = progressState
|
|
1736
|
+
? progressState.current
|
|
1737
|
+
: await ctx.db
|
|
1738
|
+
.query("funnelProgress")
|
|
1739
|
+
.withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
|
|
1740
|
+
range
|
|
1741
|
+
.eq("siteId", siteId)
|
|
1742
|
+
.eq("funnelId", funnel._id)
|
|
1743
|
+
.eq("visitorId", event.visitorId),
|
|
1744
|
+
)
|
|
1745
|
+
.unique();
|
|
1022
1746
|
const matchesFirst = stepMatches(funnel.steps[0], event);
|
|
1023
1747
|
if (!progress) {
|
|
1024
1748
|
if (!matchesFirst) return;
|
|
1025
|
-
|
|
1749
|
+
const fields: Omit<Doc<"funnelProgress">, "_id" | "_creationTime"> = {
|
|
1026
1750
|
siteId,
|
|
1027
1751
|
funnelId: funnel._id,
|
|
1028
1752
|
visitorId: event.visitorId,
|
|
@@ -1030,33 +1754,65 @@ async function advanceFunnel(
|
|
|
1030
1754
|
currentStep: 1,
|
|
1031
1755
|
startedAt: event.timestamp,
|
|
1032
1756
|
lastStepAt: event.timestamp,
|
|
1033
|
-
}
|
|
1757
|
+
};
|
|
1758
|
+
const progressId = await ctx.db.insert("funnelProgress", fields);
|
|
1759
|
+
if (progressState) {
|
|
1760
|
+
progressState.current = {
|
|
1761
|
+
...fields,
|
|
1762
|
+
_id: progressId,
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1034
1765
|
await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
|
|
1766
|
+
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1035
1767
|
return;
|
|
1036
1768
|
}
|
|
1037
|
-
if (
|
|
1769
|
+
if (
|
|
1770
|
+
progress.completedAt !== undefined ||
|
|
1771
|
+
event.timestamp < progress.lastStepAt
|
|
1772
|
+
)
|
|
1773
|
+
return;
|
|
1038
1774
|
if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
|
|
1039
1775
|
if (!matchesFirst) return;
|
|
1040
|
-
|
|
1776
|
+
const updated = {
|
|
1777
|
+
...progress,
|
|
1041
1778
|
sessionId: event.sessionId,
|
|
1042
1779
|
currentStep: 1,
|
|
1043
1780
|
startedAt: event.timestamp,
|
|
1044
1781
|
lastStepAt: event.timestamp,
|
|
1045
|
-
|
|
1046
|
-
|
|
1782
|
+
};
|
|
1783
|
+
delete updated.completedAt;
|
|
1784
|
+
if (progressState) {
|
|
1785
|
+
progressState.current = updated;
|
|
1786
|
+
progressState.dirty = true;
|
|
1787
|
+
} else {
|
|
1788
|
+
await ctx.db.patch("funnelProgress", progress._id, {
|
|
1789
|
+
sessionId: event.sessionId,
|
|
1790
|
+
currentStep: 1,
|
|
1791
|
+
startedAt: event.timestamp,
|
|
1792
|
+
lastStepAt: event.timestamp,
|
|
1793
|
+
completedAt: undefined,
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1047
1796
|
await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
|
|
1797
|
+
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1048
1798
|
return;
|
|
1049
1799
|
}
|
|
1050
1800
|
const nextStep = funnel.steps[progress.currentStep];
|
|
1051
1801
|
if (!nextStep || !stepMatches(nextStep, event)) return;
|
|
1052
1802
|
const currentStep = progress.currentStep + 1;
|
|
1053
1803
|
const completed = currentStep === funnel.steps.length;
|
|
1054
|
-
|
|
1804
|
+
const updates = {
|
|
1055
1805
|
sessionId: event.sessionId,
|
|
1056
1806
|
currentStep,
|
|
1057
1807
|
lastStepAt: event.timestamp,
|
|
1058
1808
|
...(completed ? { completedAt: event.timestamp } : {}),
|
|
1059
|
-
}
|
|
1809
|
+
};
|
|
1810
|
+
if (progressState) {
|
|
1811
|
+
progressState.current = { ...progress, ...updates };
|
|
1812
|
+
progressState.dirty = true;
|
|
1813
|
+
} else {
|
|
1814
|
+
await ctx.db.patch("funnelProgress", progress._id, updates);
|
|
1815
|
+
}
|
|
1060
1816
|
await incrementFunnelStep(
|
|
1061
1817
|
ctx,
|
|
1062
1818
|
siteId,
|
|
@@ -1065,6 +1821,14 @@ async function advanceFunnel(
|
|
|
1065
1821
|
progress.currentStep,
|
|
1066
1822
|
completed,
|
|
1067
1823
|
);
|
|
1824
|
+
foldFunnelRollup(
|
|
1825
|
+
reportRollupDeltas,
|
|
1826
|
+
siteId,
|
|
1827
|
+
funnel._id,
|
|
1828
|
+
event,
|
|
1829
|
+
progress.currentStep,
|
|
1830
|
+
completed,
|
|
1831
|
+
);
|
|
1068
1832
|
}
|
|
1069
1833
|
|
|
1070
1834
|
function stepMatches(
|
|
@@ -1085,7 +1849,7 @@ async function incrementFunnelStep(
|
|
|
1085
1849
|
completed: boolean,
|
|
1086
1850
|
) {
|
|
1087
1851
|
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
1088
|
-
const shard = stableHash(event.visitorId) %
|
|
1852
|
+
const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
|
|
1089
1853
|
const bucket = await ctx.db
|
|
1090
1854
|
.query("funnelStepBuckets")
|
|
1091
1855
|
.withIndex(
|
|
@@ -1123,6 +1887,7 @@ async function accountConversion(
|
|
|
1123
1887
|
session: SessionState,
|
|
1124
1888
|
event: TrackerEvent,
|
|
1125
1889
|
trustedGoalKey: string | undefined,
|
|
1890
|
+
reportRollupDeltas: ReportRollupDeltas,
|
|
1126
1891
|
) {
|
|
1127
1892
|
const revenueCents = event.revenueCents ?? 0;
|
|
1128
1893
|
const currency = event.currency ?? "USD";
|
|
@@ -1154,8 +1919,17 @@ async function accountConversion(
|
|
|
1154
1919
|
commissionCents,
|
|
1155
1920
|
});
|
|
1156
1921
|
if (commissionAffiliate && attribution) {
|
|
1922
|
+
foldAffiliateConversionRollup(
|
|
1923
|
+
reportRollupDeltas,
|
|
1924
|
+
siteId,
|
|
1925
|
+
event,
|
|
1926
|
+
commissionAffiliate,
|
|
1927
|
+
attribution.affiliateSlug,
|
|
1928
|
+
revenueCents,
|
|
1929
|
+
commissionCents,
|
|
1930
|
+
);
|
|
1157
1931
|
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
1158
|
-
const shard = stableHash(event.sessionId) %
|
|
1932
|
+
const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
|
|
1159
1933
|
const stats = await ctx.db
|
|
1160
1934
|
.query("affiliateDailyStats")
|
|
1161
1935
|
.withIndex(
|
|
@@ -1189,6 +1963,558 @@ async function accountConversion(
|
|
|
1189
1963
|
}
|
|
1190
1964
|
}
|
|
1191
1965
|
|
|
1966
|
+
function createReportRollupDeltas(): ReportRollupDeltas {
|
|
1967
|
+
return {
|
|
1968
|
+
goals: new Map(),
|
|
1969
|
+
funnels: new Map(),
|
|
1970
|
+
affiliates: new Map(),
|
|
1971
|
+
vitals: new Map(),
|
|
1972
|
+
routes: new Map(),
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
function reportRollupCoordinates(
|
|
1977
|
+
siteId: Id<"sites">,
|
|
1978
|
+
definitionId: string,
|
|
1979
|
+
timestamp: number,
|
|
1980
|
+
visitorId: string,
|
|
1981
|
+
) {
|
|
1982
|
+
const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
|
|
1983
|
+
const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
|
|
1984
|
+
return {
|
|
1985
|
+
bucketStart,
|
|
1986
|
+
shard,
|
|
1987
|
+
key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
function foldGoalRollup(
|
|
1992
|
+
deltas: ReportRollupDeltas,
|
|
1993
|
+
siteId: Id<"sites">,
|
|
1994
|
+
event: TrackerEvent,
|
|
1995
|
+
goal: Doc<"goals">,
|
|
1996
|
+
) {
|
|
1997
|
+
const coordinates = reportRollupCoordinates(
|
|
1998
|
+
siteId,
|
|
1999
|
+
String(goal._id),
|
|
2000
|
+
event.timestamp,
|
|
2001
|
+
event.visitorId,
|
|
2002
|
+
);
|
|
2003
|
+
const current = deltas.goals.get(coordinates.key) ?? {
|
|
2004
|
+
siteId,
|
|
2005
|
+
goalId: goal._id,
|
|
2006
|
+
bucketStart: coordinates.bucketStart,
|
|
2007
|
+
shard: coordinates.shard,
|
|
2008
|
+
goalKey: goal.key,
|
|
2009
|
+
completions: 0,
|
|
2010
|
+
valueCents: 0,
|
|
2011
|
+
};
|
|
2012
|
+
current.completions += 1;
|
|
2013
|
+
current.valueCents += goal.valueCents ?? 0;
|
|
2014
|
+
deltas.goals.set(coordinates.key, current);
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
function foldFunnelRollup(
|
|
2018
|
+
deltas: ReportRollupDeltas,
|
|
2019
|
+
siteId: Id<"sites">,
|
|
2020
|
+
funnelId: Id<"funnels">,
|
|
2021
|
+
event: TrackerEvent,
|
|
2022
|
+
step: number,
|
|
2023
|
+
completed: boolean,
|
|
2024
|
+
) {
|
|
2025
|
+
const coordinates = reportRollupCoordinates(
|
|
2026
|
+
siteId,
|
|
2027
|
+
String(funnelId),
|
|
2028
|
+
event.timestamp,
|
|
2029
|
+
event.visitorId,
|
|
2030
|
+
);
|
|
2031
|
+
const rollup = deltas.funnels.get(coordinates.key) ?? {
|
|
2032
|
+
siteId,
|
|
2033
|
+
funnelId,
|
|
2034
|
+
bucketStart: coordinates.bucketStart,
|
|
2035
|
+
shard: coordinates.shard,
|
|
2036
|
+
steps: new Map(),
|
|
2037
|
+
};
|
|
2038
|
+
const current = rollup.steps.get(step) ?? {
|
|
2039
|
+
step,
|
|
2040
|
+
entrants: 0,
|
|
2041
|
+
completions: 0,
|
|
2042
|
+
};
|
|
2043
|
+
current.entrants += 1;
|
|
2044
|
+
current.completions += Number(completed);
|
|
2045
|
+
rollup.steps.set(step, current);
|
|
2046
|
+
deltas.funnels.set(coordinates.key, rollup);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function foldAffiliateVisitRollup(
|
|
2050
|
+
deltas: ReportRollupDeltas,
|
|
2051
|
+
siteId: Id<"sites">,
|
|
2052
|
+
event: TrackerEvent,
|
|
2053
|
+
attribution: AttributionSnapshot,
|
|
2054
|
+
visitorKey: string,
|
|
2055
|
+
) {
|
|
2056
|
+
const coordinates = reportRollupCoordinates(
|
|
2057
|
+
siteId,
|
|
2058
|
+
String(attribution.affiliateId),
|
|
2059
|
+
event.timestamp,
|
|
2060
|
+
event.visitorId,
|
|
2061
|
+
);
|
|
2062
|
+
const current = deltas.affiliates.get(coordinates.key) ?? {
|
|
2063
|
+
siteId,
|
|
2064
|
+
affiliateId: attribution.affiliateId,
|
|
2065
|
+
bucketStart: coordinates.bucketStart,
|
|
2066
|
+
shard: coordinates.shard,
|
|
2067
|
+
affiliateSlug: attribution.affiliateSlug,
|
|
2068
|
+
visits: 0,
|
|
2069
|
+
visitorSketch: createVisitorSketch(),
|
|
2070
|
+
conversions: 0,
|
|
2071
|
+
revenueCents: 0,
|
|
2072
|
+
commissionCents: 0,
|
|
2073
|
+
};
|
|
2074
|
+
current.visits += 1;
|
|
2075
|
+
current.visitorSketch = addVisitor(current.visitorSketch, visitorKey);
|
|
2076
|
+
deltas.affiliates.set(coordinates.key, current);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
function foldAffiliateConversionRollup(
|
|
2080
|
+
deltas: ReportRollupDeltas,
|
|
2081
|
+
siteId: Id<"sites">,
|
|
2082
|
+
event: TrackerEvent,
|
|
2083
|
+
affiliate: Doc<"affiliates">,
|
|
2084
|
+
affiliateSlug: string,
|
|
2085
|
+
revenueCents: number,
|
|
2086
|
+
commissionCents: number,
|
|
2087
|
+
) {
|
|
2088
|
+
const coordinates = reportRollupCoordinates(
|
|
2089
|
+
siteId,
|
|
2090
|
+
String(affiliate._id),
|
|
2091
|
+
event.timestamp,
|
|
2092
|
+
event.visitorId,
|
|
2093
|
+
);
|
|
2094
|
+
const current = deltas.affiliates.get(coordinates.key) ?? {
|
|
2095
|
+
siteId,
|
|
2096
|
+
affiliateId: affiliate._id,
|
|
2097
|
+
bucketStart: coordinates.bucketStart,
|
|
2098
|
+
shard: coordinates.shard,
|
|
2099
|
+
affiliateSlug,
|
|
2100
|
+
visits: 0,
|
|
2101
|
+
visitorSketch: createVisitorSketch(),
|
|
2102
|
+
conversions: 0,
|
|
2103
|
+
revenueCents: 0,
|
|
2104
|
+
commissionCents: 0,
|
|
2105
|
+
};
|
|
2106
|
+
current.conversions += 1;
|
|
2107
|
+
current.revenueCents += revenueCents;
|
|
2108
|
+
current.commissionCents += commissionCents;
|
|
2109
|
+
deltas.affiliates.set(coordinates.key, current);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
function foldVitalRollups(
|
|
2113
|
+
deltas: ReportRollupDeltas,
|
|
2114
|
+
siteId: Id<"sites">,
|
|
2115
|
+
event: TrackerEvent,
|
|
2116
|
+
device: string,
|
|
2117
|
+
) {
|
|
2118
|
+
const metric = event.name as VitalMetric;
|
|
2119
|
+
const value = event.value ?? 0;
|
|
2120
|
+
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
2121
|
+
const shard = stableHash(event.sessionId) % REPORT_ROLLUP_SHARDS;
|
|
2122
|
+
// Each measurement lands twice: on its page (across devices) and on its
|
|
2123
|
+
// device class (across pages), so both breakdowns stay exact and bounded.
|
|
2124
|
+
for (const target of [
|
|
2125
|
+
{ device: VITAL_ALL, page: event.path },
|
|
2126
|
+
{ device, page: VITAL_ALL },
|
|
2127
|
+
]) {
|
|
2128
|
+
const key = JSON.stringify([
|
|
2129
|
+
siteId,
|
|
2130
|
+
bucketStart,
|
|
2131
|
+
target.device,
|
|
2132
|
+
target.page,
|
|
2133
|
+
shard,
|
|
2134
|
+
]);
|
|
2135
|
+
const row = deltas.vitals.get(key) ?? {
|
|
2136
|
+
siteId,
|
|
2137
|
+
bucketStart,
|
|
2138
|
+
device: target.device,
|
|
2139
|
+
page: target.page,
|
|
2140
|
+
shard,
|
|
2141
|
+
metrics: new Map<
|
|
2142
|
+
VitalMetric,
|
|
2143
|
+
{ count: number; sum: number; histogram: number[] }
|
|
2144
|
+
>(),
|
|
2145
|
+
};
|
|
2146
|
+
const entry = row.metrics.get(metric) ?? {
|
|
2147
|
+
count: 0,
|
|
2148
|
+
sum: 0,
|
|
2149
|
+
histogram: emptyVitalHistogram(),
|
|
2150
|
+
};
|
|
2151
|
+
entry.count += 1;
|
|
2152
|
+
entry.sum += value;
|
|
2153
|
+
entry.histogram = addToVitalHistogram(entry.histogram, value);
|
|
2154
|
+
row.metrics.set(metric, entry);
|
|
2155
|
+
deltas.vitals.set(key, row);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function foldRouteDelta(
|
|
2160
|
+
deltas: ReportRollupDeltas,
|
|
2161
|
+
siteId: Id<"sites">,
|
|
2162
|
+
route: string,
|
|
2163
|
+
timestamp: number,
|
|
2164
|
+
visitorId: string,
|
|
2165
|
+
apply: (delta: RouteRollupDelta) => void,
|
|
2166
|
+
) {
|
|
2167
|
+
const coordinates = reportRollupCoordinates(
|
|
2168
|
+
siteId,
|
|
2169
|
+
`route:${route}`,
|
|
2170
|
+
timestamp,
|
|
2171
|
+
visitorId,
|
|
2172
|
+
);
|
|
2173
|
+
const current = deltas.routes.get(coordinates.key) ?? {
|
|
2174
|
+
siteId,
|
|
2175
|
+
bucketStart: coordinates.bucketStart,
|
|
2176
|
+
shard: coordinates.shard,
|
|
2177
|
+
route,
|
|
2178
|
+
pageviews: 0,
|
|
2179
|
+
entries: 0,
|
|
2180
|
+
continued: 0,
|
|
2181
|
+
dwellMs: 0,
|
|
2182
|
+
dwellSamples: 0,
|
|
2183
|
+
visitorSketch: createVisitorSketch(),
|
|
2184
|
+
outgoing: new Map<string, number>(),
|
|
2185
|
+
};
|
|
2186
|
+
apply(current);
|
|
2187
|
+
deltas.routes.set(coordinates.key, current);
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* One pageview feeds the site map three ways: the route it lands on gains a
|
|
2192
|
+
* view (and an entry when it opens the session), the route it leaves gains
|
|
2193
|
+
* the transition and the time spent there, and the session's entry route is
|
|
2194
|
+
* marked as continued the first time its visitor moves on — which is what
|
|
2195
|
+
* lets the report derive bounces and exits without a session-end hook.
|
|
2196
|
+
*/
|
|
2197
|
+
function foldRouteRollups(
|
|
2198
|
+
deltas: ReportRollupDeltas,
|
|
2199
|
+
siteId: Id<"sites">,
|
|
2200
|
+
event: TrackerEvent,
|
|
2201
|
+
session: SessionState,
|
|
2202
|
+
existing: CachedDocument<"sessions"> | null,
|
|
2203
|
+
) {
|
|
2204
|
+
const route = normalizeRoute(event.path);
|
|
2205
|
+
const priorPageviews = existing?.pageviewCount ?? 0;
|
|
2206
|
+
const firstPageview = existing === null || priorPageviews === 0;
|
|
2207
|
+
foldRouteDelta(
|
|
2208
|
+
deltas,
|
|
2209
|
+
siteId,
|
|
2210
|
+
route,
|
|
2211
|
+
event.timestamp,
|
|
2212
|
+
event.visitorId,
|
|
2213
|
+
(delta) => {
|
|
2214
|
+
delta.pageviews += 1;
|
|
2215
|
+
if (firstPageview) delta.entries += 1;
|
|
2216
|
+
delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
|
|
2217
|
+
},
|
|
2218
|
+
);
|
|
2219
|
+
// Out-of-order telemetry keeps its view but cannot say where it came from.
|
|
2220
|
+
if (existing === null || firstPageview) return;
|
|
2221
|
+
if (event.timestamp < existing.lastSeenAt) return;
|
|
2222
|
+
const previous = normalizeRoute(existing.exitPath);
|
|
2223
|
+
const lastPageviewAt = existing.lastPageviewAt;
|
|
2224
|
+
foldRouteDelta(
|
|
2225
|
+
deltas,
|
|
2226
|
+
siteId,
|
|
2227
|
+
previous,
|
|
2228
|
+
event.timestamp,
|
|
2229
|
+
event.visitorId,
|
|
2230
|
+
(delta) => {
|
|
2231
|
+
delta.outgoing.set(route, (delta.outgoing.get(route) ?? 0) + 1);
|
|
2232
|
+
if (lastPageviewAt !== undefined && event.timestamp >= lastPageviewAt) {
|
|
2233
|
+
delta.dwellMs += event.timestamp - lastPageviewAt;
|
|
2234
|
+
delta.dwellSamples += 1;
|
|
2235
|
+
}
|
|
2236
|
+
},
|
|
2237
|
+
);
|
|
2238
|
+
if (priorPageviews === 1) {
|
|
2239
|
+
foldRouteDelta(
|
|
2240
|
+
deltas,
|
|
2241
|
+
siteId,
|
|
2242
|
+
normalizeRoute(existing.entryPath),
|
|
2243
|
+
existing.startedAt,
|
|
2244
|
+
event.visitorId,
|
|
2245
|
+
(delta) => {
|
|
2246
|
+
delta.continued += 1;
|
|
2247
|
+
},
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
async function upsertRouteRollup(
|
|
2253
|
+
ctx: MutationCtx,
|
|
2254
|
+
delta: RouteRollupDelta,
|
|
2255
|
+
dayRowCounts: Map<string, number>,
|
|
2256
|
+
) {
|
|
2257
|
+
const existing = await ctx.db
|
|
2258
|
+
.query("routeDailyRollups")
|
|
2259
|
+
.withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) =>
|
|
2260
|
+
range
|
|
2261
|
+
.eq("siteId", delta.siteId)
|
|
2262
|
+
.eq("route", delta.route)
|
|
2263
|
+
.eq("bucketStart", delta.bucketStart)
|
|
2264
|
+
.eq("shard", delta.shard),
|
|
2265
|
+
)
|
|
2266
|
+
.unique();
|
|
2267
|
+
if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
|
|
2268
|
+
// A new route row spends one bounded read per batch to respect the daily
|
|
2269
|
+
// cap; past it, the day's remaining routes fold into "(other)".
|
|
2270
|
+
const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
|
|
2271
|
+
const dayKey = `${delta.siteId}:${delta.bucketStart}`;
|
|
2272
|
+
let dayRows = dayRowCounts.get(dayKey);
|
|
2273
|
+
if (dayRows === undefined) {
|
|
2274
|
+
dayRows = (
|
|
2275
|
+
await ctx.db
|
|
2276
|
+
.query("routeDailyRollups")
|
|
2277
|
+
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
2278
|
+
range
|
|
2279
|
+
.eq("siteId", delta.siteId)
|
|
2280
|
+
.eq("bucketStart", delta.bucketStart),
|
|
2281
|
+
)
|
|
2282
|
+
.take(capRows + 1)
|
|
2283
|
+
).length;
|
|
2284
|
+
}
|
|
2285
|
+
if (dayRows >= capRows) {
|
|
2286
|
+
await upsertRouteRollup(
|
|
2287
|
+
ctx,
|
|
2288
|
+
{ ...delta, route: SITE_MAP_OTHER_ROUTE },
|
|
2289
|
+
dayRowCounts,
|
|
2290
|
+
);
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
dayRowCounts.set(dayKey, dayRows + 1);
|
|
2294
|
+
}
|
|
2295
|
+
const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
|
|
2296
|
+
for (const [target, count] of delta.outgoing) {
|
|
2297
|
+
let entry = outgoing.find((candidate) => candidate.route === target);
|
|
2298
|
+
if (!entry) {
|
|
2299
|
+
const named = outgoing.filter(
|
|
2300
|
+
(candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE,
|
|
2301
|
+
).length;
|
|
2302
|
+
const route =
|
|
2303
|
+
target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
|
|
2304
|
+
? SITE_MAP_OTHER_ROUTE
|
|
2305
|
+
: target;
|
|
2306
|
+
entry = outgoing.find((candidate) => candidate.route === route);
|
|
2307
|
+
if (!entry) {
|
|
2308
|
+
entry = { route, count: 0 };
|
|
2309
|
+
outgoing.push(entry);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
entry.count += count;
|
|
2313
|
+
}
|
|
2314
|
+
const fields = {
|
|
2315
|
+
siteId: delta.siteId,
|
|
2316
|
+
bucketStart: delta.bucketStart,
|
|
2317
|
+
shard: delta.shard,
|
|
2318
|
+
generation: 1 as const,
|
|
2319
|
+
route: delta.route,
|
|
2320
|
+
pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
|
|
2321
|
+
entries: (existing?.entries ?? 0) + delta.entries,
|
|
2322
|
+
continued: (existing?.continued ?? 0) + delta.continued,
|
|
2323
|
+
dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
|
|
2324
|
+
dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
|
|
2325
|
+
visitorSketch: unionVisitorSketches(
|
|
2326
|
+
existing?.visitorSketch ?? createVisitorSketch(),
|
|
2327
|
+
delta.visitorSketch,
|
|
2328
|
+
),
|
|
2329
|
+
outgoing,
|
|
2330
|
+
};
|
|
2331
|
+
if (existing) {
|
|
2332
|
+
await ctx.db.replace("routeDailyRollups", existing._id, fields);
|
|
2333
|
+
} else {
|
|
2334
|
+
await ctx.db.insert("routeDailyRollups", fields);
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
|
|
2339
|
+
const existing = await ctx.db
|
|
2340
|
+
.query("vitalRollups")
|
|
2341
|
+
.withIndex("by_key", (range) =>
|
|
2342
|
+
range
|
|
2343
|
+
.eq("siteId", delta.siteId)
|
|
2344
|
+
.eq("bucketStart", delta.bucketStart)
|
|
2345
|
+
.eq("device", delta.device)
|
|
2346
|
+
.eq("page", delta.page)
|
|
2347
|
+
.eq("shard", delta.shard),
|
|
2348
|
+
)
|
|
2349
|
+
.unique();
|
|
2350
|
+
if (
|
|
2351
|
+
!existing &&
|
|
2352
|
+
delta.device === VITAL_ALL &&
|
|
2353
|
+
delta.page !== VITAL_OTHER_PAGES
|
|
2354
|
+
) {
|
|
2355
|
+
// A new page row spends one bounded read to respect the daily page slots;
|
|
2356
|
+
// past the cap, the day's remaining pages fold into "(other)".
|
|
2357
|
+
const pageRows = await ctx.db
|
|
2358
|
+
.query("vitalRollups")
|
|
2359
|
+
.withIndex("by_key", (range) =>
|
|
2360
|
+
range
|
|
2361
|
+
.eq("siteId", delta.siteId)
|
|
2362
|
+
.eq("bucketStart", delta.bucketStart)
|
|
2363
|
+
.eq("device", VITAL_ALL),
|
|
2364
|
+
)
|
|
2365
|
+
.take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
|
|
2366
|
+
if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
|
|
2367
|
+
await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
|
|
2372
|
+
for (const [metric, addition] of delta.metrics) {
|
|
2373
|
+
const entry = metrics.find((candidate) => candidate.metric === metric);
|
|
2374
|
+
if (entry) {
|
|
2375
|
+
entry.count += addition.count;
|
|
2376
|
+
entry.sum += addition.sum;
|
|
2377
|
+
entry.histogram = mergeVitalHistograms(
|
|
2378
|
+
entry.histogram,
|
|
2379
|
+
addition.histogram,
|
|
2380
|
+
);
|
|
2381
|
+
} else {
|
|
2382
|
+
metrics.push({
|
|
2383
|
+
metric,
|
|
2384
|
+
count: addition.count,
|
|
2385
|
+
sum: addition.sum,
|
|
2386
|
+
histogram: [...addition.histogram],
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
const fields = {
|
|
2391
|
+
siteId: delta.siteId,
|
|
2392
|
+
bucketStart: delta.bucketStart,
|
|
2393
|
+
device: delta.device,
|
|
2394
|
+
page: delta.page,
|
|
2395
|
+
shard: delta.shard,
|
|
2396
|
+
metrics,
|
|
2397
|
+
};
|
|
2398
|
+
if (existing) {
|
|
2399
|
+
await ctx.db.replace("vitalRollups", existing._id, fields);
|
|
2400
|
+
} else {
|
|
2401
|
+
await ctx.db.insert("vitalRollups", fields);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
async function flushReportRollups(
|
|
2406
|
+
ctx: MutationCtx,
|
|
2407
|
+
deltas: ReportRollupDeltas,
|
|
2408
|
+
) {
|
|
2409
|
+
for (const delta of deltas.goals.values()) {
|
|
2410
|
+
const existing = await ctx.db
|
|
2411
|
+
.query("goalDailyRollups")
|
|
2412
|
+
.withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) =>
|
|
2413
|
+
range
|
|
2414
|
+
.eq("siteId", delta.siteId)
|
|
2415
|
+
.eq("goalId", delta.goalId)
|
|
2416
|
+
.eq("bucketStart", delta.bucketStart)
|
|
2417
|
+
.eq("shard", delta.shard),
|
|
2418
|
+
)
|
|
2419
|
+
.unique();
|
|
2420
|
+
const fields = {
|
|
2421
|
+
siteId: delta.siteId,
|
|
2422
|
+
goalId: delta.goalId,
|
|
2423
|
+
bucketStart: delta.bucketStart,
|
|
2424
|
+
shard: delta.shard,
|
|
2425
|
+
generation: 1 as const,
|
|
2426
|
+
goalKey: delta.goalKey,
|
|
2427
|
+
completions: (existing?.completions ?? 0) + delta.completions,
|
|
2428
|
+
valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
|
|
2429
|
+
};
|
|
2430
|
+
if (existing) {
|
|
2431
|
+
await ctx.db.replace("goalDailyRollups", existing._id, fields);
|
|
2432
|
+
} else {
|
|
2433
|
+
await ctx.db.insert("goalDailyRollups", fields);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
for (const delta of deltas.funnels.values()) {
|
|
2437
|
+
const existing = await ctx.db
|
|
2438
|
+
.query("funnelDailyRollups")
|
|
2439
|
+
.withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) =>
|
|
2440
|
+
range
|
|
2441
|
+
.eq("siteId", delta.siteId)
|
|
2442
|
+
.eq("funnelId", delta.funnelId)
|
|
2443
|
+
.eq("bucketStart", delta.bucketStart)
|
|
2444
|
+
.eq("shard", delta.shard),
|
|
2445
|
+
)
|
|
2446
|
+
.unique();
|
|
2447
|
+
const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
|
|
2448
|
+
for (const addition of delta.steps.values()) {
|
|
2449
|
+
const current = steps.find((step) => step.step === addition.step);
|
|
2450
|
+
if (current) {
|
|
2451
|
+
current.entrants += addition.entrants;
|
|
2452
|
+
current.completions += addition.completions;
|
|
2453
|
+
} else {
|
|
2454
|
+
if (steps.length >= MAX_FUNNEL_STEPS) {
|
|
2455
|
+
fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
|
|
2456
|
+
}
|
|
2457
|
+
steps.push(addition);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
const fields = {
|
|
2461
|
+
siteId: delta.siteId,
|
|
2462
|
+
funnelId: delta.funnelId,
|
|
2463
|
+
bucketStart: delta.bucketStart,
|
|
2464
|
+
shard: delta.shard,
|
|
2465
|
+
generation: 1 as const,
|
|
2466
|
+
steps,
|
|
2467
|
+
};
|
|
2468
|
+
if (existing) {
|
|
2469
|
+
await ctx.db.replace("funnelDailyRollups", existing._id, fields);
|
|
2470
|
+
} else {
|
|
2471
|
+
await ctx.db.insert("funnelDailyRollups", fields);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
for (const delta of deltas.affiliates.values()) {
|
|
2475
|
+
const existing = await ctx.db
|
|
2476
|
+
.query("affiliateDailyRollups")
|
|
2477
|
+
.withIndex(
|
|
2478
|
+
"by_siteId_and_affiliateId_and_bucketStart_and_shard",
|
|
2479
|
+
(range) =>
|
|
2480
|
+
range
|
|
2481
|
+
.eq("siteId", delta.siteId)
|
|
2482
|
+
.eq("affiliateId", delta.affiliateId)
|
|
2483
|
+
.eq("bucketStart", delta.bucketStart)
|
|
2484
|
+
.eq("shard", delta.shard),
|
|
2485
|
+
)
|
|
2486
|
+
.unique();
|
|
2487
|
+
const fields = {
|
|
2488
|
+
siteId: delta.siteId,
|
|
2489
|
+
affiliateId: delta.affiliateId,
|
|
2490
|
+
bucketStart: delta.bucketStart,
|
|
2491
|
+
shard: delta.shard,
|
|
2492
|
+
generation: 1 as const,
|
|
2493
|
+
affiliateSlug: delta.affiliateSlug,
|
|
2494
|
+
visits: (existing?.visits ?? 0) + delta.visits,
|
|
2495
|
+
visitorSketch: unionVisitorSketches(
|
|
2496
|
+
existing?.visitorSketch ?? createVisitorSketch(),
|
|
2497
|
+
delta.visitorSketch,
|
|
2498
|
+
),
|
|
2499
|
+
conversions: (existing?.conversions ?? 0) + delta.conversions,
|
|
2500
|
+
revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
|
|
2501
|
+
commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
|
|
2502
|
+
};
|
|
2503
|
+
if (existing) {
|
|
2504
|
+
await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
|
|
2505
|
+
} else {
|
|
2506
|
+
await ctx.db.insert("affiliateDailyRollups", fields);
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
const routeDayRowCounts = new Map<string, number>();
|
|
2510
|
+
for (const delta of deltas.routes.values()) {
|
|
2511
|
+
await upsertRouteRollup(ctx, delta, routeDayRowCounts);
|
|
2512
|
+
}
|
|
2513
|
+
for (const delta of deltas.vitals.values()) {
|
|
2514
|
+
await upsertVitalRollup(ctx, delta);
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
|
|
1192
2518
|
function foldAggregates(
|
|
1193
2519
|
aggregateDeltas: AggregateDeltas,
|
|
1194
2520
|
siteId: Id<"sites">,
|
|
@@ -1198,16 +2524,16 @@ function foldAggregates(
|
|
|
1198
2524
|
recordTelemetry: boolean,
|
|
1199
2525
|
financialConversion: boolean,
|
|
1200
2526
|
) {
|
|
1201
|
-
const
|
|
1202
|
-
const
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
revenueCents,
|
|
1208
|
-
}),
|
|
1209
|
-
);
|
|
2527
|
+
const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
|
|
2528
|
+
const dimensionDeltas = dimensions.map((dimension): DimensionDelta => ({
|
|
2529
|
+
...dimension,
|
|
2530
|
+
count: Number(recordTelemetry),
|
|
2531
|
+
revenueCents,
|
|
2532
|
+
}));
|
|
1210
2533
|
for (const granularity of ["hour", "day"] as const) {
|
|
2534
|
+
const shard =
|
|
2535
|
+
stableHash(event.sessionId) %
|
|
2536
|
+
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
1211
2537
|
const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
|
|
1212
2538
|
const bucketStart = Math.floor(event.timestamp / interval) * interval;
|
|
1213
2539
|
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
@@ -1225,6 +2551,7 @@ function foldAggregates(
|
|
|
1225
2551
|
outboundClicks: 0,
|
|
1226
2552
|
sessions: 0,
|
|
1227
2553
|
visitors: 0,
|
|
2554
|
+
visitorSketch: createVisitorSketch(),
|
|
1228
2555
|
conversions: 0,
|
|
1229
2556
|
revenueCents: 0,
|
|
1230
2557
|
dimensions: [],
|
|
@@ -1245,6 +2572,9 @@ function foldAggregates(
|
|
|
1245
2572
|
? Number(session.isNewVisitorHour)
|
|
1246
2573
|
: Number(session.isNewVisitorDay)
|
|
1247
2574
|
: 0;
|
|
2575
|
+
if (recordTelemetry) {
|
|
2576
|
+
delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
|
|
2577
|
+
}
|
|
1248
2578
|
delta.conversions += Number(financialConversion);
|
|
1249
2579
|
delta.revenueCents += revenueCents;
|
|
1250
2580
|
delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
|
|
@@ -1272,6 +2602,13 @@ async function flushAggregates(
|
|
|
1272
2602
|
bucket?.dimensions ?? [],
|
|
1273
2603
|
delta.dimensions,
|
|
1274
2604
|
);
|
|
2605
|
+
const priorSketch = bucket?.visitorSketch;
|
|
2606
|
+
const visitorSketch = priorSketch
|
|
2607
|
+
? unionVisitorSketches(priorSketch, delta.visitorSketch)
|
|
2608
|
+
: delta.visitorSketch;
|
|
2609
|
+
const visitorSketchComplete = bucket
|
|
2610
|
+
? bucket.visitorSketchComplete === true && priorSketch !== undefined
|
|
2611
|
+
: true;
|
|
1275
2612
|
if (bucket) {
|
|
1276
2613
|
await ctx.db.patch("aggregateBuckets", bucket._id, {
|
|
1277
2614
|
events: bucket.events + delta.events,
|
|
@@ -1281,6 +2618,8 @@ async function flushAggregates(
|
|
|
1281
2618
|
outboundClicks: bucket.outboundClicks + delta.outboundClicks,
|
|
1282
2619
|
sessions: bucket.sessions + delta.sessions,
|
|
1283
2620
|
visitors: bucket.visitors + delta.visitors,
|
|
2621
|
+
visitorSketch,
|
|
2622
|
+
visitorSketchComplete,
|
|
1284
2623
|
conversions: bucket.conversions + delta.conversions,
|
|
1285
2624
|
revenueCents: bucket.revenueCents + delta.revenueCents,
|
|
1286
2625
|
dimensions,
|
|
@@ -1298,6 +2637,8 @@ async function flushAggregates(
|
|
|
1298
2637
|
outboundClicks: delta.outboundClicks,
|
|
1299
2638
|
sessions: delta.sessions,
|
|
1300
2639
|
visitors: delta.visitors,
|
|
2640
|
+
visitorSketch,
|
|
2641
|
+
visitorSketchComplete,
|
|
1301
2642
|
conversions: delta.conversions,
|
|
1302
2643
|
revenueCents: delta.revenueCents,
|
|
1303
2644
|
dimensions,
|
|
@@ -1325,11 +2666,8 @@ function mergeDimensions(
|
|
|
1325
2666
|
const typeSlots = result.filter(
|
|
1326
2667
|
(candidate) => candidate.type === addition.type,
|
|
1327
2668
|
);
|
|
1328
|
-
if (typeSlots.length < DIMENSION_SLOTS) {
|
|
1329
|
-
const occupied = Array.from(
|
|
1330
|
-
{ length: DIMENSION_SLOTS },
|
|
1331
|
-
() => false,
|
|
1332
|
-
);
|
|
2669
|
+
if (typeSlots.length < DIMENSION_SLOTS - 1) {
|
|
2670
|
+
const occupied = Array.from({ length: DIMENSION_SLOTS }, () => false);
|
|
1333
2671
|
for (const candidate of typeSlots) occupied[candidate.slot] = true;
|
|
1334
2672
|
let slot = stableHash(addition.value) % DIMENSION_SLOTS;
|
|
1335
2673
|
while (occupied[slot]) slot = (slot + 1) % DIMENSION_SLOTS;
|
|
@@ -1342,15 +2680,36 @@ function mergeDimensions(
|
|
|
1342
2680
|
});
|
|
1343
2681
|
continue;
|
|
1344
2682
|
}
|
|
1345
|
-
|
|
2683
|
+
const overflow = typeSlots.find(
|
|
2684
|
+
(candidate) => candidate.value === "(other)",
|
|
2685
|
+
);
|
|
2686
|
+
if (overflow) {
|
|
2687
|
+
overflow.count += addition.count;
|
|
2688
|
+
overflow.revenueCents += addition.revenueCents;
|
|
2689
|
+
continue;
|
|
2690
|
+
}
|
|
2691
|
+
if (addition.count === 0 && addition.revenueCents === 0) continue;
|
|
2692
|
+
if (typeSlots.length < DIMENSION_SLOTS) {
|
|
2693
|
+
const occupied = new Set(typeSlots.map((candidate) => candidate.slot));
|
|
2694
|
+
let slot = stableHash("(other)") % DIMENSION_SLOTS;
|
|
2695
|
+
while (occupied.has(slot)) slot = (slot + 1) % DIMENSION_SLOTS;
|
|
2696
|
+
result.push({
|
|
2697
|
+
type: addition.type,
|
|
2698
|
+
slot,
|
|
2699
|
+
value: "(other)",
|
|
2700
|
+
count: addition.count,
|
|
2701
|
+
revenueCents: addition.revenueCents,
|
|
2702
|
+
});
|
|
2703
|
+
continue;
|
|
2704
|
+
}
|
|
1346
2705
|
|
|
1347
2706
|
let replacement = typeSlots[0];
|
|
1348
2707
|
for (const candidate of typeSlots.slice(1)) {
|
|
1349
2708
|
if (candidate.count < replacement.count) replacement = candidate;
|
|
1350
2709
|
}
|
|
1351
|
-
replacement.value =
|
|
2710
|
+
replacement.value = "(other)";
|
|
1352
2711
|
replacement.count += addition.count;
|
|
1353
|
-
replacement.revenueCents
|
|
2712
|
+
replacement.revenueCents += addition.revenueCents;
|
|
1354
2713
|
}
|
|
1355
2714
|
return result;
|
|
1356
2715
|
}
|
|
@@ -1360,5 +2719,7 @@ function stringProperty(
|
|
|
1360
2719
|
key: string,
|
|
1361
2720
|
): string | undefined {
|
|
1362
2721
|
const value = properties?.[key];
|
|
1363
|
-
return typeof value === "string"
|
|
2722
|
+
return typeof value === "string"
|
|
2723
|
+
? cleanString(value, 120) || undefined
|
|
2724
|
+
: undefined;
|
|
1364
2725
|
}
|