@iann29/rastro 0.1.0-alpha.1 → 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 +521 -66
- 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 +16 -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 +25 -1
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +38 -1
- 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 +21 -9
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/eventStore.js +142 -152
- package/dist/component/eventStore.js.map +1 -1
- package/dist/component/funnels.d.ts.map +1 -1
- package/dist/component/funnels.js +5 -3
- package/dist/component/funnels.js.map +1 -1
- package/dist/component/geo.d.ts +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 +8 -5
- package/dist/component/goals.js.map +1 -1
- package/dist/component/guards.d.ts.map +1 -1
- package/dist/component/guards.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +248 -49
- 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 +604 -55
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +7 -6
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +259 -10
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +1110 -124
- 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 +537 -32
- 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 +96 -16
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +378 -62
- package/dist/component/schema.js +178 -20
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +11 -7
- 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 -65
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +19 -13
- 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 +57 -20
- 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 +76 -76
- 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 +55 -11
- package/scripts/benchmark-ingest.mjs +175 -73
- package/scripts/generate-federation-keys.mjs +20 -0
- package/src/component/_generated/api.ts +16 -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 +38 -1
- 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 +217 -193
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +781 -0
- package/src/component/goals.ts +29 -21
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +357 -57
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +1040 -135
- package/src/component/live.ts +13 -7
- package/src/component/reports.ts +1675 -183
- package/src/component/retention.ts +770 -96
- package/src/component/sanitize.ts +144 -29
- package/src/component/schema.ts +200 -21
- package/src/component/sites.ts +27 -12
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +29 -13
- package/src/component/visitors.ts +116 -0
- package/src/component/vitals.ts +146 -0
package/src/component/ingest.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
HOURLY_AGGREGATE_SHARDS,
|
|
11
11
|
DIMENSION_SLOTS,
|
|
12
12
|
HOUR_MS,
|
|
13
|
+
LIVE_LEAVE_GRACE_MS,
|
|
13
14
|
LIVE_SESSION_TTL_MS,
|
|
14
15
|
LIVE_SWEEP_INTERVAL_MS,
|
|
15
16
|
MAX_BATCH_EVENTS,
|
|
@@ -18,12 +19,34 @@ import {
|
|
|
18
19
|
MAX_BYTES_PER_SESSION_WINDOW,
|
|
19
20
|
MAX_EVENTS_PER_SESSION_WINDOW,
|
|
20
21
|
MAX_FUNNELS_PER_SITE,
|
|
22
|
+
MAX_FUNNEL_STEPS,
|
|
21
23
|
MAX_GOALS_PER_SITE,
|
|
24
|
+
MAX_SITE_MAP_ROUTES_PER_DAY,
|
|
22
25
|
RATE_LIMIT_WINDOW_MS,
|
|
26
|
+
REPORT_ROLLUP_SHARDS,
|
|
27
|
+
ROUTE_TRANSITION_SLOTS,
|
|
23
28
|
SECONDARY_AGGREGATE_SHARDS,
|
|
24
29
|
SITE_INGEST_SHARDS,
|
|
30
|
+
SITE_MAP_OTHER_ROUTE,
|
|
31
|
+
VITAL_PAGE_SLOTS,
|
|
25
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";
|
|
26
48
|
import { fail } from "./errors.js";
|
|
49
|
+
import { ensureAnalyticsControl } from "./coverage.js";
|
|
27
50
|
import {
|
|
28
51
|
bufferEventBatch,
|
|
29
52
|
eventBatchKey,
|
|
@@ -36,8 +59,9 @@ import {
|
|
|
36
59
|
} from "./eventStore.js";
|
|
37
60
|
import {
|
|
38
61
|
cleanString,
|
|
39
|
-
ensureBatchBytes,
|
|
40
62
|
isValidMoney,
|
|
63
|
+
measureBatchBytes,
|
|
64
|
+
normalizeRoute,
|
|
41
65
|
originAllowed,
|
|
42
66
|
sanitizeContext,
|
|
43
67
|
sanitizeCurrency,
|
|
@@ -60,6 +84,7 @@ import {
|
|
|
60
84
|
|
|
61
85
|
type DimensionType =
|
|
62
86
|
| "source"
|
|
87
|
+
| "campaign"
|
|
63
88
|
| "page"
|
|
64
89
|
| "country"
|
|
65
90
|
| "device"
|
|
@@ -81,11 +106,78 @@ type AggregateDelta = {
|
|
|
81
106
|
outboundClicks: number;
|
|
82
107
|
sessions: number;
|
|
83
108
|
visitors: number;
|
|
109
|
+
visitorSketch: VisitorSketch;
|
|
84
110
|
conversions: number;
|
|
85
111
|
revenueCents: number;
|
|
86
112
|
dimensions: Doc<"aggregateBuckets">["dimensions"];
|
|
87
113
|
};
|
|
88
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
|
+
};
|
|
89
181
|
type AttributionSnapshot = {
|
|
90
182
|
affiliateId: Id<"affiliates">;
|
|
91
183
|
affiliateSlug: string;
|
|
@@ -99,6 +191,12 @@ type SessionState = {
|
|
|
99
191
|
isNewVisitorDay: boolean;
|
|
100
192
|
startedAt: number;
|
|
101
193
|
source: string;
|
|
194
|
+
visitorKey: string;
|
|
195
|
+
utmCampaign?: string;
|
|
196
|
+
country?: string;
|
|
197
|
+
city?: string;
|
|
198
|
+
latitude?: number;
|
|
199
|
+
longitude?: number;
|
|
102
200
|
pageviewCount: number;
|
|
103
201
|
eventCount: number;
|
|
104
202
|
affiliateId?: Id<"affiliates">;
|
|
@@ -121,8 +219,9 @@ type CachedRow<T> = {
|
|
|
121
219
|
dirty: boolean;
|
|
122
220
|
};
|
|
123
221
|
|
|
124
|
-
type CachedDocument<
|
|
125
|
-
|
|
222
|
+
type CachedDocument<
|
|
223
|
+
TableName extends keyof import("./_generated/dataModel.js").DataModel,
|
|
224
|
+
> = Omit<Doc<TableName>, "_creationTime"> & { _creationTime?: number };
|
|
126
225
|
|
|
127
226
|
type BatchState = {
|
|
128
227
|
sessions: Map<string, CachedRow<CachedDocument<"sessions">>>;
|
|
@@ -153,25 +252,36 @@ export const ingestBatch = mutation({
|
|
|
153
252
|
});
|
|
154
253
|
}
|
|
155
254
|
if (args.events.length < 1 || args.events.length > MAX_BATCH_EVENTS) {
|
|
156
|
-
fail(
|
|
157
|
-
|
|
158
|
-
|
|
255
|
+
fail(
|
|
256
|
+
"BATCH_TOO_LARGE",
|
|
257
|
+
`events must contain 1-${MAX_BATCH_EVENTS} items`,
|
|
258
|
+
{
|
|
259
|
+
limit: MAX_BATCH_EVENTS,
|
|
260
|
+
},
|
|
261
|
+
);
|
|
159
262
|
}
|
|
263
|
+
let eventByteCounts: number[];
|
|
160
264
|
try {
|
|
161
|
-
|
|
265
|
+
({ eventByteCounts } = measureBatchBytes(
|
|
266
|
+
String(args.siteId),
|
|
267
|
+
args.events,
|
|
268
|
+
));
|
|
162
269
|
} catch {
|
|
163
270
|
fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
|
|
164
271
|
}
|
|
165
272
|
|
|
166
273
|
const now = Date.now();
|
|
167
|
-
|
|
274
|
+
await ensureAnalyticsControl(ctx, args.siteId, now);
|
|
275
|
+
await consumeSiteRateLimit(
|
|
168
276
|
ctx,
|
|
169
277
|
args.siteId,
|
|
170
278
|
args.events,
|
|
279
|
+
eventByteCounts,
|
|
171
280
|
now,
|
|
172
281
|
);
|
|
173
282
|
const context = sanitizeContext(args.context);
|
|
174
283
|
const aggregateDeltas: AggregateDeltas = new Map();
|
|
284
|
+
const reportRollupDeltas = createReportRollupDeltas();
|
|
175
285
|
const sessionRateWindows = new Map<string, SessionRateWindowState>();
|
|
176
286
|
const batchState: BatchState = {
|
|
177
287
|
sessions: new Map(),
|
|
@@ -206,7 +316,12 @@ export const ingestBatch = mutation({
|
|
|
206
316
|
const bucketStart = eventBucketStart(event.timestamp);
|
|
207
317
|
const groupKey = eventBatchKey(event.sessionId, bucketStart);
|
|
208
318
|
requestGroups.add(groupKey);
|
|
209
|
-
if (
|
|
319
|
+
if (
|
|
320
|
+
event.type === "heartbeat" ||
|
|
321
|
+
event.type === "vital" ||
|
|
322
|
+
event.type === "leave"
|
|
323
|
+
)
|
|
324
|
+
continue;
|
|
210
325
|
const eventIds = eventIdsByGroup.get(groupKey) ?? new Set<string>();
|
|
211
326
|
if (eventIds.has(event.eventId)) {
|
|
212
327
|
duplicateInBatch.add(eventIndex);
|
|
@@ -236,10 +351,9 @@ export const ingestBatch = mutation({
|
|
|
236
351
|
siteId: args.siteId,
|
|
237
352
|
sessionId: group.sessionId,
|
|
238
353
|
bucketStart: group.bucketStart,
|
|
239
|
-
candidateEventIds: group.eventIds,
|
|
240
354
|
});
|
|
241
|
-
const newCount = [...group.eventIds].filter(
|
|
242
|
-
!eventIds.has(eventId)
|
|
355
|
+
const newCount = [...group.eventIds].filter(
|
|
356
|
+
(eventId) => !eventIds.has(eventId),
|
|
243
357
|
).length;
|
|
244
358
|
if (
|
|
245
359
|
newCount > 0 &&
|
|
@@ -278,12 +392,7 @@ export const ingestBatch = mutation({
|
|
|
278
392
|
continue;
|
|
279
393
|
}
|
|
280
394
|
const liveSession = (
|
|
281
|
-
await loadLiveSession(
|
|
282
|
-
ctx,
|
|
283
|
-
args.siteId,
|
|
284
|
-
event.sessionId,
|
|
285
|
-
batchState,
|
|
286
|
-
)
|
|
395
|
+
await loadLiveSession(ctx, args.siteId, event.sessionId, batchState)
|
|
287
396
|
).current;
|
|
288
397
|
if (
|
|
289
398
|
liveSession &&
|
|
@@ -319,10 +428,86 @@ export const ingestBatch = mutation({
|
|
|
319
428
|
continue;
|
|
320
429
|
}
|
|
321
430
|
|
|
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
|
+
|
|
322
505
|
if (
|
|
323
506
|
duplicateInBatch.has(eventIndex) ||
|
|
324
507
|
persistedEventIds
|
|
325
|
-
.get(
|
|
508
|
+
.get(
|
|
509
|
+
eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)),
|
|
510
|
+
)
|
|
326
511
|
?.has(event.eventId)
|
|
327
512
|
) {
|
|
328
513
|
duplicates += 1;
|
|
@@ -356,26 +541,33 @@ export const ingestBatch = mutation({
|
|
|
356
541
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
357
542
|
.take(MAX_FUNNELS_PER_SITE);
|
|
358
543
|
}
|
|
359
|
-
await accountEvent(ctx, {
|
|
544
|
+
const session = await accountEvent(ctx, {
|
|
360
545
|
siteId: args.siteId,
|
|
361
546
|
event,
|
|
362
547
|
context,
|
|
363
548
|
existingSession,
|
|
364
|
-
goals: canMatchDefinition ? goals ?? [] : [],
|
|
365
|
-
funnels: canMatchDefinition ? funnels ?? [] : [],
|
|
549
|
+
goals: canMatchDefinition ? (goals ?? []) : [],
|
|
550
|
+
funnels: canMatchDefinition ? (funnels ?? []) : [],
|
|
366
551
|
aggregateDeltas,
|
|
552
|
+
reportRollupDeltas,
|
|
367
553
|
recordTelemetry: true,
|
|
368
554
|
financialConversion: false,
|
|
369
555
|
trustedGoalKey: undefined,
|
|
370
556
|
now,
|
|
371
557
|
batchState,
|
|
372
558
|
});
|
|
373
|
-
bufferEventBatch(
|
|
559
|
+
bufferEventBatch(
|
|
560
|
+
args.siteId,
|
|
561
|
+
event,
|
|
562
|
+
batchState.eventBatches,
|
|
563
|
+
session.country ?? "unknown",
|
|
564
|
+
);
|
|
374
565
|
accepted += 1;
|
|
375
566
|
}
|
|
376
567
|
await flushBatchState(ctx, batchState);
|
|
377
568
|
await flushSessionRateLimits(ctx, sessionRateWindows, now);
|
|
378
569
|
await flushAggregates(ctx, aggregateDeltas);
|
|
570
|
+
await flushReportRollups(ctx, reportRollupDeltas);
|
|
379
571
|
return { accepted, duplicates, rejected };
|
|
380
572
|
},
|
|
381
573
|
});
|
|
@@ -387,7 +579,10 @@ export const trackConversion = mutation({
|
|
|
387
579
|
const site = await ctx.db.get("sites", args.siteId);
|
|
388
580
|
if (!site) fail("NOT_FOUND", "site not found");
|
|
389
581
|
if (!isValidMoney(args.revenueCents)) {
|
|
390
|
-
fail(
|
|
582
|
+
fail(
|
|
583
|
+
"INVALID_ARGUMENT",
|
|
584
|
+
"revenueCents must be an integer between 0 and 1000000000",
|
|
585
|
+
);
|
|
391
586
|
}
|
|
392
587
|
let event: TrackerEvent;
|
|
393
588
|
let trustedGoalKey: string | undefined;
|
|
@@ -412,7 +607,10 @@ export const trackConversion = mutation({
|
|
|
412
607
|
Date.now(),
|
|
413
608
|
);
|
|
414
609
|
} catch (error) {
|
|
415
|
-
fail(
|
|
610
|
+
fail(
|
|
611
|
+
"INVALID_ARGUMENT",
|
|
612
|
+
error instanceof Error ? error.message : "invalid conversion",
|
|
613
|
+
);
|
|
416
614
|
}
|
|
417
615
|
|
|
418
616
|
const siteCurrency = site.currency ?? "USD";
|
|
@@ -432,10 +630,14 @@ export const trackConversion = mutation({
|
|
|
432
630
|
return { accepted: 0, duplicates: 1, rejected: 0 };
|
|
433
631
|
}
|
|
434
632
|
if (event.currency !== siteCurrency) {
|
|
435
|
-
fail(
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
633
|
+
fail(
|
|
634
|
+
"CURRENCY_MISMATCH",
|
|
635
|
+
"conversion currency does not match site currency",
|
|
636
|
+
{
|
|
637
|
+
siteCurrency,
|
|
638
|
+
conversionCurrency: event.currency ?? "USD",
|
|
639
|
+
},
|
|
640
|
+
);
|
|
439
641
|
}
|
|
440
642
|
|
|
441
643
|
const telemetry = await findTelemetry(ctx, args.siteId, event);
|
|
@@ -467,7 +669,8 @@ export const trackConversion = mutation({
|
|
|
467
669
|
};
|
|
468
670
|
|
|
469
671
|
const aggregateDeltas: AggregateDeltas = new Map();
|
|
470
|
-
|
|
672
|
+
const reportRollupDeltas = createReportRollupDeltas();
|
|
673
|
+
const session = await accountEvent(ctx, {
|
|
471
674
|
siteId: args.siteId,
|
|
472
675
|
event,
|
|
473
676
|
context: conversionContext,
|
|
@@ -475,15 +678,23 @@ export const trackConversion = mutation({
|
|
|
475
678
|
goals: [],
|
|
476
679
|
funnels: [],
|
|
477
680
|
aggregateDeltas,
|
|
681
|
+
reportRollupDeltas,
|
|
478
682
|
recordTelemetry: existingTelemetry === null && !telemetry.saturated,
|
|
479
683
|
financialConversion: true,
|
|
480
684
|
trustedGoalKey,
|
|
481
685
|
now: Date.now(),
|
|
482
686
|
});
|
|
483
687
|
if (!existingTelemetry && !telemetry.saturated) {
|
|
484
|
-
await insertEventBatch(
|
|
688
|
+
await insertEventBatch(
|
|
689
|
+
ctx,
|
|
690
|
+
args.siteId,
|
|
691
|
+
event,
|
|
692
|
+
session.country ?? "unknown",
|
|
693
|
+
);
|
|
485
694
|
}
|
|
486
695
|
await flushAggregates(ctx, aggregateDeltas);
|
|
696
|
+
await flushReportRollups(ctx, reportRollupDeltas);
|
|
697
|
+
await ensureAnalyticsControl(ctx, args.siteId, Date.now());
|
|
487
698
|
return { accepted: 1, duplicates: 0, rejected: 0 };
|
|
488
699
|
},
|
|
489
700
|
});
|
|
@@ -586,11 +797,7 @@ async function flushBatchState(ctx: MutationCtx, batchState: BatchState) {
|
|
|
586
797
|
function withoutSystemFields<
|
|
587
798
|
T extends { _id: unknown; _creationTime?: number },
|
|
588
799
|
>(document: T): Omit<T, "_id" | "_creationTime"> {
|
|
589
|
-
const {
|
|
590
|
-
_id,
|
|
591
|
-
_creationTime,
|
|
592
|
-
...fields
|
|
593
|
-
} = document;
|
|
800
|
+
const { _id, _creationTime, ...fields } = document;
|
|
594
801
|
void _id;
|
|
595
802
|
void _creationTime;
|
|
596
803
|
return fields;
|
|
@@ -606,13 +813,14 @@ async function accountEvent(
|
|
|
606
813
|
goals: Doc<"goals">[];
|
|
607
814
|
funnels: Doc<"funnels">[];
|
|
608
815
|
aggregateDeltas: AggregateDeltas;
|
|
816
|
+
reportRollupDeltas: ReportRollupDeltas;
|
|
609
817
|
recordTelemetry: boolean;
|
|
610
818
|
financialConversion: boolean;
|
|
611
819
|
trustedGoalKey: string | undefined;
|
|
612
820
|
now: number;
|
|
613
821
|
batchState?: BatchState;
|
|
614
822
|
},
|
|
615
|
-
) {
|
|
823
|
+
): Promise<SessionState> {
|
|
616
824
|
const { event } = input;
|
|
617
825
|
const session = input.recordTelemetry
|
|
618
826
|
? await upsertSession(
|
|
@@ -622,13 +830,25 @@ async function accountEvent(
|
|
|
622
830
|
input.context,
|
|
623
831
|
input.existingSession,
|
|
624
832
|
input.financialConversion,
|
|
833
|
+
input.reportRollupDeltas,
|
|
625
834
|
input.batchState,
|
|
626
835
|
)
|
|
627
836
|
: await accountTrustedSession(ctx, input.existingSession, event);
|
|
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
|
+
);
|
|
847
|
+
}
|
|
628
848
|
|
|
629
849
|
const matchedGoalKeys: string[] = [];
|
|
630
|
-
const matchingGoals = input.goals.filter(
|
|
631
|
-
goal.active && goalMatches(goal, event)
|
|
850
|
+
const matchingGoals = input.goals.filter(
|
|
851
|
+
(goal) => goal.active && goalMatches(goal, event),
|
|
632
852
|
);
|
|
633
853
|
if (matchingGoals.length > 1) {
|
|
634
854
|
fail("LIMIT_EXCEEDED", "active goals must use unique matchers");
|
|
@@ -639,33 +859,36 @@ async function accountEvent(
|
|
|
639
859
|
eventBucketStart(event.timestamp),
|
|
640
860
|
event.eventId,
|
|
641
861
|
]);
|
|
642
|
-
const scopedPrior = (
|
|
643
|
-
.
|
|
644
|
-
|
|
645
|
-
range
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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);
|
|
652
873
|
const legacyPrior = scopedPrior
|
|
653
874
|
? undefined
|
|
654
|
-
: (
|
|
655
|
-
.
|
|
656
|
-
|
|
657
|
-
range
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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) =>
|
|
664
887
|
completion.idempotencyVersion === undefined &&
|
|
665
888
|
completion.sessionId === event.sessionId &&
|
|
666
889
|
eventBucketStart(completion.timestamp) ===
|
|
667
|
-
eventBucketStart(event.timestamp)
|
|
668
|
-
|
|
890
|
+
eventBucketStart(event.timestamp),
|
|
891
|
+
);
|
|
669
892
|
if (scopedPrior || legacyPrior) continue;
|
|
670
893
|
await ctx.db.insert("goalCompletions", {
|
|
671
894
|
siteId: input.siteId,
|
|
@@ -678,12 +901,20 @@ async function accountEvent(
|
|
|
678
901
|
timestamp: event.timestamp,
|
|
679
902
|
valueCents: goal.valueCents ?? 0,
|
|
680
903
|
});
|
|
904
|
+
foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
|
|
681
905
|
matchedGoalKeys.push(goal.key);
|
|
682
906
|
}
|
|
683
907
|
|
|
684
908
|
for (const funnel of input.funnels) {
|
|
685
909
|
if (funnel.active) {
|
|
686
|
-
await advanceFunnel(
|
|
910
|
+
await advanceFunnel(
|
|
911
|
+
ctx,
|
|
912
|
+
input.siteId,
|
|
913
|
+
funnel,
|
|
914
|
+
event,
|
|
915
|
+
input.reportRollupDeltas,
|
|
916
|
+
input.batchState,
|
|
917
|
+
);
|
|
687
918
|
}
|
|
688
919
|
}
|
|
689
920
|
|
|
@@ -694,18 +925,23 @@ async function accountEvent(
|
|
|
694
925
|
session,
|
|
695
926
|
event,
|
|
696
927
|
input.trustedGoalKey,
|
|
928
|
+
input.reportRollupDeltas,
|
|
697
929
|
);
|
|
698
930
|
}
|
|
699
931
|
|
|
932
|
+
const campaignDimensions: Dimension[] = session.utmCampaign
|
|
933
|
+
? [{ type: "campaign", value: session.utmCampaign }]
|
|
934
|
+
: [];
|
|
700
935
|
const dimensions: Dimension[] = [
|
|
701
936
|
{ type: "source", value: session.source },
|
|
937
|
+
...campaignDimensions,
|
|
702
938
|
{ type: "page", value: event.path },
|
|
703
|
-
{ type: "country", value:
|
|
939
|
+
{ type: "country", value: session.country ?? "unknown" },
|
|
704
940
|
{ type: "device", value: input.context.device ?? "unknown" },
|
|
705
941
|
{ type: "browser", value: input.context.browser ?? "Other" },
|
|
706
942
|
{
|
|
707
943
|
type: "event",
|
|
708
|
-
value: event.type === "custom" ? event.name ?? "custom" : event.type,
|
|
944
|
+
value: event.type === "custom" ? (event.name ?? "custom") : event.type,
|
|
709
945
|
},
|
|
710
946
|
...matchedGoalKeys.map((value): Dimension => ({ type: "goal", value })),
|
|
711
947
|
];
|
|
@@ -733,6 +969,7 @@ async function accountEvent(
|
|
|
733
969
|
input.batchState,
|
|
734
970
|
);
|
|
735
971
|
}
|
|
972
|
+
return session;
|
|
736
973
|
}
|
|
737
974
|
|
|
738
975
|
async function accountTrustedSession(
|
|
@@ -755,6 +992,12 @@ async function accountTrustedSession(
|
|
|
755
992
|
isNewVisitorDay: false,
|
|
756
993
|
startedAt: existing.startedAt,
|
|
757
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,
|
|
758
1001
|
pageviewCount: existing.pageviewCount,
|
|
759
1002
|
eventCount: existing.eventCount,
|
|
760
1003
|
affiliateId: existing.affiliateId,
|
|
@@ -771,10 +1014,19 @@ async function upsertSession(
|
|
|
771
1014
|
context: IngestContext,
|
|
772
1015
|
existing: CachedDocument<"sessions"> | null,
|
|
773
1016
|
financialConversion: boolean,
|
|
1017
|
+
reportRollupDeltas: ReportRollupDeltas,
|
|
774
1018
|
batchState?: BatchState,
|
|
775
1019
|
): Promise<SessionState> {
|
|
776
|
-
|
|
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));
|
|
777
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);
|
|
778
1030
|
const utmMedium = stringProperty(event.properties, "utm_medium");
|
|
779
1031
|
const utmCampaign = stringProperty(event.properties, "utm_campaign");
|
|
780
1032
|
const attribution = await attributionForEvent(
|
|
@@ -785,20 +1037,40 @@ async function upsertSession(
|
|
|
785
1037
|
batchState,
|
|
786
1038
|
);
|
|
787
1039
|
if (existing) {
|
|
1040
|
+
if (attribution && existing.affiliateId !== attribution.affiliateId) {
|
|
1041
|
+
foldAffiliateVisitRollup(
|
|
1042
|
+
reportRollupDeltas,
|
|
1043
|
+
siteId,
|
|
1044
|
+
event,
|
|
1045
|
+
attribution,
|
|
1046
|
+
visitorKey,
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
788
1049
|
const startedAt = Math.min(existing.startedAt, event.timestamp);
|
|
789
1050
|
const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
|
|
790
1051
|
const updates = {
|
|
791
1052
|
startedAt,
|
|
792
1053
|
lastSeenAt,
|
|
793
|
-
entryPath:
|
|
794
|
-
|
|
1054
|
+
entryPath:
|
|
1055
|
+
event.timestamp < existing.startedAt ? event.path : existing.entryPath,
|
|
1056
|
+
exitPath:
|
|
1057
|
+
event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
|
|
795
1058
|
eventCount: existing.eventCount + 1,
|
|
796
|
-
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
|
+
: {}),
|
|
797
1069
|
durationMs: Math.max(0, lastSeenAt - startedAt),
|
|
798
1070
|
conversionCount: existing.conversionCount + Number(financialConversion),
|
|
799
1071
|
revenueCents:
|
|
800
1072
|
existing.revenueCents +
|
|
801
|
-
(financialConversion ? event.revenueCents ?? 0 : 0),
|
|
1073
|
+
(financialConversion ? (event.revenueCents ?? 0) : 0),
|
|
802
1074
|
...(financialConversion && event.currency
|
|
803
1075
|
? { currency: event.currency }
|
|
804
1076
|
: {}),
|
|
@@ -809,7 +1081,9 @@ async function upsertSession(
|
|
|
809
1081
|
...(context.country ? { country: context.country } : {}),
|
|
810
1082
|
...(context.city ? { city: context.city } : {}),
|
|
811
1083
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
812
|
-
...(context.longitude !== undefined
|
|
1084
|
+
...(context.longitude !== undefined
|
|
1085
|
+
? { longitude: context.longitude }
|
|
1086
|
+
: {}),
|
|
813
1087
|
browser: context.browser ?? existing.browser,
|
|
814
1088
|
os: context.os ?? existing.os,
|
|
815
1089
|
device: context.device ?? existing.device,
|
|
@@ -832,6 +1106,12 @@ async function upsertSession(
|
|
|
832
1106
|
Math.floor(event.timestamp / DAY_MS),
|
|
833
1107
|
startedAt,
|
|
834
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,
|
|
835
1115
|
pageviewCount:
|
|
836
1116
|
existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
|
|
837
1117
|
eventCount: existing.eventCount + 1,
|
|
@@ -863,6 +1143,7 @@ async function upsertSession(
|
|
|
863
1143
|
siteId,
|
|
864
1144
|
sessionId: event.sessionId,
|
|
865
1145
|
visitorId: event.visitorId,
|
|
1146
|
+
visitorKey,
|
|
866
1147
|
startedAt: event.timestamp,
|
|
867
1148
|
lastSeenAt: event.timestamp,
|
|
868
1149
|
entryPath: event.path,
|
|
@@ -875,21 +1156,33 @@ async function upsertSession(
|
|
|
875
1156
|
...(context.country ? { country: context.country } : {}),
|
|
876
1157
|
...(context.city ? { city: context.city } : {}),
|
|
877
1158
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
878
|
-
...(context.longitude !== undefined
|
|
1159
|
+
...(context.longitude !== undefined
|
|
1160
|
+
? { longitude: context.longitude }
|
|
1161
|
+
: {}),
|
|
879
1162
|
browser: context.browser ?? "Other",
|
|
880
1163
|
os: context.os ?? "Other",
|
|
881
1164
|
device: context.device ?? "unknown",
|
|
882
1165
|
eventCount: 1,
|
|
883
1166
|
pageviewCount: event.type === "pageview" ? 1 : 0,
|
|
1167
|
+
...(event.type === "pageview" ? { lastPageviewAt: event.timestamp } : {}),
|
|
884
1168
|
durationMs: 0,
|
|
885
1169
|
conversionCount: Number(financialConversion),
|
|
886
|
-
revenueCents: financialConversion ? event.revenueCents ?? 0 : 0,
|
|
1170
|
+
revenueCents: financialConversion ? (event.revenueCents ?? 0) : 0,
|
|
887
1171
|
...(financialConversion && event.currency
|
|
888
1172
|
? { currency: event.currency }
|
|
889
1173
|
: {}),
|
|
890
1174
|
...(attribution ?? {}),
|
|
891
1175
|
};
|
|
892
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
|
+
}
|
|
893
1186
|
if (batchState) {
|
|
894
1187
|
const state = batchState.sessions.get(event.sessionId);
|
|
895
1188
|
if (!state) throw new Error("session cache was not initialized");
|
|
@@ -910,20 +1203,24 @@ async function upsertSession(
|
|
|
910
1203
|
Math.floor(event.timestamp / DAY_MS),
|
|
911
1204
|
startedAt: event.timestamp,
|
|
912
1205
|
source,
|
|
1206
|
+
utmCampaign,
|
|
1207
|
+
visitorKey,
|
|
1208
|
+
country: context.country,
|
|
1209
|
+
city: context.city,
|
|
1210
|
+
latitude: context.latitude,
|
|
1211
|
+
longitude: context.longitude,
|
|
913
1212
|
pageviewCount: event.type === "pageview" ? 1 : 0,
|
|
914
1213
|
eventCount: 1,
|
|
915
1214
|
...(attribution ?? {}),
|
|
916
1215
|
};
|
|
917
1216
|
}
|
|
918
1217
|
|
|
919
|
-
function attributionSnapshot(
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
},
|
|
926
|
-
): AttributionSnapshot | undefined {
|
|
1218
|
+
function attributionSnapshot(value: {
|
|
1219
|
+
affiliateId?: Id<"affiliates">;
|
|
1220
|
+
affiliateSlug?: string;
|
|
1221
|
+
affiliateAttributedAt?: number;
|
|
1222
|
+
affiliateExpiresAt?: number;
|
|
1223
|
+
}): AttributionSnapshot | undefined {
|
|
927
1224
|
if (
|
|
928
1225
|
!value.affiliateId ||
|
|
929
1226
|
!value.affiliateSlug ||
|
|
@@ -940,7 +1237,6 @@ function attributionSnapshot(
|
|
|
940
1237
|
};
|
|
941
1238
|
}
|
|
942
1239
|
|
|
943
|
-
|
|
944
1240
|
function attributionApplies(
|
|
945
1241
|
attribution: AttributionSnapshot,
|
|
946
1242
|
timestamp: number,
|
|
@@ -1071,14 +1367,15 @@ async function accountHeartbeat(
|
|
|
1071
1367
|
const updates = {
|
|
1072
1368
|
startedAt,
|
|
1073
1369
|
lastSeenAt,
|
|
1074
|
-
exitPath:
|
|
1075
|
-
? event.path
|
|
1076
|
-
: session.exitPath,
|
|
1370
|
+
exitPath:
|
|
1371
|
+
event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
|
|
1077
1372
|
durationMs: Math.max(0, lastSeenAt - startedAt),
|
|
1078
1373
|
...(context.country ? { country: context.country } : {}),
|
|
1079
1374
|
...(context.city ? { city: context.city } : {}),
|
|
1080
1375
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
1081
|
-
...(context.longitude !== undefined
|
|
1376
|
+
...(context.longitude !== undefined
|
|
1377
|
+
? { longitude: context.longitude }
|
|
1378
|
+
: {}),
|
|
1082
1379
|
browser: context.browser ?? session.browser,
|
|
1083
1380
|
os: context.os ?? session.os,
|
|
1084
1381
|
device: context.device ?? session.device,
|
|
@@ -1102,6 +1399,11 @@ async function accountHeartbeat(
|
|
|
1102
1399
|
isNewVisitorDay: false,
|
|
1103
1400
|
startedAt,
|
|
1104
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,
|
|
1105
1407
|
pageviewCount: session.pageviewCount,
|
|
1106
1408
|
eventCount: session.eventCount,
|
|
1107
1409
|
affiliateSlug: session.affiliateSlug,
|
|
@@ -1115,17 +1417,18 @@ async function consumeSiteRateLimit(
|
|
|
1115
1417
|
ctx: MutationCtx,
|
|
1116
1418
|
siteId: Id<"sites">,
|
|
1117
1419
|
events: TrackerEvent[],
|
|
1420
|
+
eventByteCounts: number[],
|
|
1118
1421
|
now: number,
|
|
1119
|
-
): Promise<
|
|
1422
|
+
): Promise<void> {
|
|
1120
1423
|
const windowStart =
|
|
1121
1424
|
Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
1122
|
-
const increments = new Map<
|
|
1123
|
-
|
|
1124
|
-
|
|
1425
|
+
const increments = new Map<
|
|
1426
|
+
number,
|
|
1427
|
+
{ eventCount: number; byteCount: number }
|
|
1428
|
+
>();
|
|
1125
1429
|
for (let index = 0; index < events.length; index += 1) {
|
|
1126
1430
|
const event = events[index];
|
|
1127
|
-
const byteCount =
|
|
1128
|
-
eventByteCounts.push(byteCount);
|
|
1431
|
+
const byteCount = eventByteCounts[index];
|
|
1129
1432
|
const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
|
|
1130
1433
|
const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
|
|
1131
1434
|
current.eventCount += 1;
|
|
@@ -1147,9 +1450,8 @@ async function consumeSiteRateLimit(
|
|
|
1147
1450
|
.order("desc")
|
|
1148
1451
|
.take(1);
|
|
1149
1452
|
const storedWindow = rows[0] ?? null;
|
|
1150
|
-
const window =
|
|
1151
|
-
? storedWindow
|
|
1152
|
-
: null;
|
|
1453
|
+
const window =
|
|
1454
|
+
storedWindow?.windowStart === windowStart ? storedWindow : null;
|
|
1153
1455
|
if (
|
|
1154
1456
|
(window?.eventCount ?? 0) + increment.eventCount >
|
|
1155
1457
|
MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
|
|
@@ -1161,10 +1463,8 @@ async function consumeSiteRateLimit(
|
|
|
1161
1463
|
windowMs: RATE_LIMIT_WINDOW_MS,
|
|
1162
1464
|
eventLimitPerShard: MAX_SITE_EVENTS_PER_SHARD_WINDOW,
|
|
1163
1465
|
byteLimitPerShard: MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
1164
|
-
siteEventLimit:
|
|
1165
|
-
|
|
1166
|
-
siteByteLimit:
|
|
1167
|
-
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,
|
|
1168
1468
|
});
|
|
1169
1469
|
}
|
|
1170
1470
|
windows.push({ shard, increment, window: storedWindow });
|
|
@@ -1195,7 +1495,6 @@ async function consumeSiteRateLimit(
|
|
|
1195
1495
|
});
|
|
1196
1496
|
}
|
|
1197
1497
|
}
|
|
1198
|
-
return eventByteCounts;
|
|
1199
1498
|
}
|
|
1200
1499
|
|
|
1201
1500
|
async function consumeRateLimit(
|
|
@@ -1206,7 +1505,8 @@ async function consumeRateLimit(
|
|
|
1206
1505
|
now: number,
|
|
1207
1506
|
states: Map<string, SessionRateWindowState>,
|
|
1208
1507
|
): Promise<boolean> {
|
|
1209
|
-
const windowStart =
|
|
1508
|
+
const windowStart =
|
|
1509
|
+
Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
1210
1510
|
let state = states.get(sessionId);
|
|
1211
1511
|
if (!state) {
|
|
1212
1512
|
const rows = await ctx.db
|
|
@@ -1244,7 +1544,8 @@ async function flushSessionRateLimits(
|
|
|
1244
1544
|
states: Map<string, SessionRateWindowState>,
|
|
1245
1545
|
now: number,
|
|
1246
1546
|
) {
|
|
1247
|
-
const windowStart =
|
|
1547
|
+
const windowStart =
|
|
1548
|
+
Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
1248
1549
|
for (const state of states.values()) {
|
|
1249
1550
|
if (!state.dirty) continue;
|
|
1250
1551
|
if (state.window) {
|
|
@@ -1289,31 +1590,41 @@ async function updateLiveSession(
|
|
|
1289
1590
|
)
|
|
1290
1591
|
.unique();
|
|
1291
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;
|
|
1292
1597
|
if (existing) {
|
|
1293
|
-
const recentHeartbeatEventIds =
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
(existing.
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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;
|
|
1302
1608
|
const updates = {
|
|
1303
1609
|
visitorId: event.visitorId,
|
|
1304
1610
|
path: event.path,
|
|
1305
1611
|
lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
|
|
1306
1612
|
expiresAt: deadline,
|
|
1307
|
-
...(
|
|
1308
|
-
...(
|
|
1309
|
-
...(
|
|
1310
|
-
...(
|
|
1613
|
+
...(country ? { country } : {}),
|
|
1614
|
+
...(city ? { city } : {}),
|
|
1615
|
+
...(latitude !== undefined ? { latitude } : {}),
|
|
1616
|
+
...(longitude !== undefined ? { longitude } : {}),
|
|
1311
1617
|
browser: context.browser ?? existing.browser,
|
|
1312
1618
|
os: context.os ?? existing.os,
|
|
1313
1619
|
source: session.source,
|
|
1314
1620
|
device: context.device ?? existing.device,
|
|
1315
1621
|
pageviewCount: session.pageviewCount,
|
|
1316
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
|
+
: {}),
|
|
1317
1628
|
...(event.type === "heartbeat"
|
|
1318
1629
|
? {
|
|
1319
1630
|
lastHeartbeatEventId: event.eventId,
|
|
@@ -1338,10 +1649,10 @@ async function updateLiveSession(
|
|
|
1338
1649
|
startedAt: event.timestamp,
|
|
1339
1650
|
lastSeenAt: event.timestamp,
|
|
1340
1651
|
expiresAt: deadline,
|
|
1341
|
-
...(
|
|
1342
|
-
...(
|
|
1343
|
-
...(
|
|
1344
|
-
...(
|
|
1652
|
+
...(country ? { country } : {}),
|
|
1653
|
+
...(city ? { city } : {}),
|
|
1654
|
+
...(latitude !== undefined ? { latitude } : {}),
|
|
1655
|
+
...(longitude !== undefined ? { longitude } : {}),
|
|
1345
1656
|
browser: context.browser ?? "Other",
|
|
1346
1657
|
os: context.os ?? "Other",
|
|
1347
1658
|
source: session.source,
|
|
@@ -1376,9 +1687,13 @@ async function ensureLiveSweep(
|
|
|
1376
1687
|
.query("liveSweepStates")
|
|
1377
1688
|
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1378
1689
|
.unique();
|
|
1379
|
-
if (!existing) {
|
|
1690
|
+
if (!existing || existing.scheduledAt <= now) {
|
|
1380
1691
|
const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
|
|
1381
|
-
|
|
1692
|
+
if (existing) {
|
|
1693
|
+
await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
|
|
1694
|
+
} else {
|
|
1695
|
+
await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
|
|
1696
|
+
}
|
|
1382
1697
|
await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
|
|
1383
1698
|
siteId,
|
|
1384
1699
|
scheduledAt,
|
|
@@ -1399,6 +1714,7 @@ async function advanceFunnel(
|
|
|
1399
1714
|
siteId: Id<"sites">,
|
|
1400
1715
|
funnel: Doc<"funnels">,
|
|
1401
1716
|
event: TrackerEvent,
|
|
1717
|
+
reportRollupDeltas: ReportRollupDeltas,
|
|
1402
1718
|
batchState?: BatchState,
|
|
1403
1719
|
) {
|
|
1404
1720
|
const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
|
|
@@ -1447,9 +1763,14 @@ async function advanceFunnel(
|
|
|
1447
1763
|
};
|
|
1448
1764
|
}
|
|
1449
1765
|
await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
|
|
1766
|
+
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1450
1767
|
return;
|
|
1451
1768
|
}
|
|
1452
|
-
if (
|
|
1769
|
+
if (
|
|
1770
|
+
progress.completedAt !== undefined ||
|
|
1771
|
+
event.timestamp < progress.lastStepAt
|
|
1772
|
+
)
|
|
1773
|
+
return;
|
|
1453
1774
|
if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
|
|
1454
1775
|
if (!matchesFirst) return;
|
|
1455
1776
|
const updated = {
|
|
@@ -1473,6 +1794,7 @@ async function advanceFunnel(
|
|
|
1473
1794
|
});
|
|
1474
1795
|
}
|
|
1475
1796
|
await incrementFunnelStep(ctx, siteId, funnel._id, event, 0, false);
|
|
1797
|
+
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1476
1798
|
return;
|
|
1477
1799
|
}
|
|
1478
1800
|
const nextStep = funnel.steps[progress.currentStep];
|
|
@@ -1499,6 +1821,14 @@ async function advanceFunnel(
|
|
|
1499
1821
|
progress.currentStep,
|
|
1500
1822
|
completed,
|
|
1501
1823
|
);
|
|
1824
|
+
foldFunnelRollup(
|
|
1825
|
+
reportRollupDeltas,
|
|
1826
|
+
siteId,
|
|
1827
|
+
funnel._id,
|
|
1828
|
+
event,
|
|
1829
|
+
progress.currentStep,
|
|
1830
|
+
completed,
|
|
1831
|
+
);
|
|
1502
1832
|
}
|
|
1503
1833
|
|
|
1504
1834
|
function stepMatches(
|
|
@@ -1557,6 +1887,7 @@ async function accountConversion(
|
|
|
1557
1887
|
session: SessionState,
|
|
1558
1888
|
event: TrackerEvent,
|
|
1559
1889
|
trustedGoalKey: string | undefined,
|
|
1890
|
+
reportRollupDeltas: ReportRollupDeltas,
|
|
1560
1891
|
) {
|
|
1561
1892
|
const revenueCents = event.revenueCents ?? 0;
|
|
1562
1893
|
const currency = event.currency ?? "USD";
|
|
@@ -1588,6 +1919,15 @@ async function accountConversion(
|
|
|
1588
1919
|
commissionCents,
|
|
1589
1920
|
});
|
|
1590
1921
|
if (commissionAffiliate && attribution) {
|
|
1922
|
+
foldAffiliateConversionRollup(
|
|
1923
|
+
reportRollupDeltas,
|
|
1924
|
+
siteId,
|
|
1925
|
+
event,
|
|
1926
|
+
commissionAffiliate,
|
|
1927
|
+
attribution.affiliateSlug,
|
|
1928
|
+
revenueCents,
|
|
1929
|
+
commissionCents,
|
|
1930
|
+
);
|
|
1591
1931
|
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
1592
1932
|
const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
|
|
1593
1933
|
const stats = await ctx.db
|
|
@@ -1623,6 +1963,558 @@ async function accountConversion(
|
|
|
1623
1963
|
}
|
|
1624
1964
|
}
|
|
1625
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
|
+
|
|
1626
2518
|
function foldAggregates(
|
|
1627
2519
|
aggregateDeltas: AggregateDeltas,
|
|
1628
2520
|
siteId: Id<"sites">,
|
|
@@ -1632,16 +2524,15 @@ function foldAggregates(
|
|
|
1632
2524
|
recordTelemetry: boolean,
|
|
1633
2525
|
financialConversion: boolean,
|
|
1634
2526
|
) {
|
|
1635
|
-
const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
|
|
1636
|
-
const dimensionDeltas = dimensions.map(
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
}),
|
|
1642
|
-
);
|
|
2527
|
+
const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
|
|
2528
|
+
const dimensionDeltas = dimensions.map((dimension): DimensionDelta => ({
|
|
2529
|
+
...dimension,
|
|
2530
|
+
count: Number(recordTelemetry),
|
|
2531
|
+
revenueCents,
|
|
2532
|
+
}));
|
|
1643
2533
|
for (const granularity of ["hour", "day"] as const) {
|
|
1644
|
-
const shard =
|
|
2534
|
+
const shard =
|
|
2535
|
+
stableHash(event.sessionId) %
|
|
1645
2536
|
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
1646
2537
|
const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
|
|
1647
2538
|
const bucketStart = Math.floor(event.timestamp / interval) * interval;
|
|
@@ -1660,6 +2551,7 @@ function foldAggregates(
|
|
|
1660
2551
|
outboundClicks: 0,
|
|
1661
2552
|
sessions: 0,
|
|
1662
2553
|
visitors: 0,
|
|
2554
|
+
visitorSketch: createVisitorSketch(),
|
|
1663
2555
|
conversions: 0,
|
|
1664
2556
|
revenueCents: 0,
|
|
1665
2557
|
dimensions: [],
|
|
@@ -1680,6 +2572,9 @@ function foldAggregates(
|
|
|
1680
2572
|
? Number(session.isNewVisitorHour)
|
|
1681
2573
|
: Number(session.isNewVisitorDay)
|
|
1682
2574
|
: 0;
|
|
2575
|
+
if (recordTelemetry) {
|
|
2576
|
+
delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
|
|
2577
|
+
}
|
|
1683
2578
|
delta.conversions += Number(financialConversion);
|
|
1684
2579
|
delta.revenueCents += revenueCents;
|
|
1685
2580
|
delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
|
|
@@ -1707,6 +2602,13 @@ async function flushAggregates(
|
|
|
1707
2602
|
bucket?.dimensions ?? [],
|
|
1708
2603
|
delta.dimensions,
|
|
1709
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;
|
|
1710
2612
|
if (bucket) {
|
|
1711
2613
|
await ctx.db.patch("aggregateBuckets", bucket._id, {
|
|
1712
2614
|
events: bucket.events + delta.events,
|
|
@@ -1716,6 +2618,8 @@ async function flushAggregates(
|
|
|
1716
2618
|
outboundClicks: bucket.outboundClicks + delta.outboundClicks,
|
|
1717
2619
|
sessions: bucket.sessions + delta.sessions,
|
|
1718
2620
|
visitors: bucket.visitors + delta.visitors,
|
|
2621
|
+
visitorSketch,
|
|
2622
|
+
visitorSketchComplete,
|
|
1719
2623
|
conversions: bucket.conversions + delta.conversions,
|
|
1720
2624
|
revenueCents: bucket.revenueCents + delta.revenueCents,
|
|
1721
2625
|
dimensions,
|
|
@@ -1733,6 +2637,8 @@ async function flushAggregates(
|
|
|
1733
2637
|
outboundClicks: delta.outboundClicks,
|
|
1734
2638
|
sessions: delta.sessions,
|
|
1735
2639
|
visitors: delta.visitors,
|
|
2640
|
+
visitorSketch,
|
|
2641
|
+
visitorSketchComplete,
|
|
1736
2642
|
conversions: delta.conversions,
|
|
1737
2643
|
revenueCents: delta.revenueCents,
|
|
1738
2644
|
dimensions,
|
|
@@ -1761,10 +2667,7 @@ function mergeDimensions(
|
|
|
1761
2667
|
(candidate) => candidate.type === addition.type,
|
|
1762
2668
|
);
|
|
1763
2669
|
if (typeSlots.length < DIMENSION_SLOTS - 1) {
|
|
1764
|
-
const occupied = Array.from(
|
|
1765
|
-
{ length: DIMENSION_SLOTS },
|
|
1766
|
-
() => false,
|
|
1767
|
-
);
|
|
2670
|
+
const occupied = Array.from({ length: DIMENSION_SLOTS }, () => false);
|
|
1768
2671
|
for (const candidate of typeSlots) occupied[candidate.slot] = true;
|
|
1769
2672
|
let slot = stableHash(addition.value) % DIMENSION_SLOTS;
|
|
1770
2673
|
while (occupied[slot]) slot = (slot + 1) % DIMENSION_SLOTS;
|
|
@@ -1816,5 +2719,7 @@ function stringProperty(
|
|
|
1816
2719
|
key: string,
|
|
1817
2720
|
): string | undefined {
|
|
1818
2721
|
const value = properties?.[key];
|
|
1819
|
-
return typeof value === "string"
|
|
2722
|
+
return typeof value === "string"
|
|
2723
|
+
? cleanString(value, 120) || undefined
|
|
2724
|
+
: undefined;
|
|
1820
2725
|
}
|