@iann29/rastro 0.1.0-alpha.1 → 0.1.0-alpha.11
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 +693 -69
- package/agent/integration.md +801 -0
- package/agent/manifest.json +205 -0
- package/agent/manifest.schema.json +444 -0
- package/dist/client/federation.d.ts +381 -0
- package/dist/client/federation.d.ts.map +1 -0
- package/dist/client/federation.js +274 -0
- package/dist/client/federation.js.map +1 -0
- package/dist/client/index.d.ts +2673 -15
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +565 -33
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +22 -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 +315 -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 +28 -1
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +44 -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 +27 -0
- package/dist/component/coverage.d.ts.map +1 -0
- package/dist/component/coverage.js +56 -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 +73 -0
- package/dist/component/geo.d.ts.map +1 -0
- package/dist/component/geo.js +648 -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 +281 -62
- 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 +498 -95
- 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/localTime.d.ts +25 -0
- package/dist/component/localTime.d.ts.map +1 -0
- package/dist/component/localTime.js +126 -0
- package/dist/component/localTime.js.map +1 -0
- package/dist/component/reports.d.ts +266 -10
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +1243 -137
- 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 +561 -38
- package/dist/component/retention.js.map +1 -1
- package/dist/component/rollupStore.d.ts +320 -0
- package/dist/component/rollupStore.d.ts.map +1 -0
- package/dist/component/rollupStore.js +596 -0
- package/dist/component/rollupStore.js.map +1 -0
- package/dist/component/rollups.d.ts +20 -0
- package/dist/component/rollups.d.ts.map +1 -0
- package/dist/component/rollups.js +73 -0
- package/dist/component/rollups.js.map +1 -0
- 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 +687 -65
- package/dist/component/schema.js +187 -20
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts +12 -0
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +41 -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 +124 -69
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +35 -14
- 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 +11 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +11 -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 +464 -0
- package/docs/federation.md +352 -0
- package/docs/upgrading.md +344 -0
- package/llms.txt +72 -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 +22 -0
- package/src/component/_generated/component.ts +381 -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 +44 -1
- package/src/component/convex.config.ts +11 -1
- package/src/component/coverage.ts +71 -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 +835 -0
- package/src/component/goals.ts +29 -21
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +404 -70
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +894 -188
- package/src/component/live.ts +13 -7
- package/src/component/localTime.ts +167 -0
- package/src/component/reports.ts +1872 -197
- package/src/component/retention.ts +788 -96
- package/src/component/rollupStore.ts +799 -0
- package/src/component/rollups.ts +82 -0
- package/src/component/sanitize.ts +144 -29
- package/src/component/schema.ts +217 -21
- package/src/component/sites.ts +59 -12
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +49 -14
- 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,
|
|
@@ -20,10 +21,25 @@ import {
|
|
|
20
21
|
MAX_FUNNELS_PER_SITE,
|
|
21
22
|
MAX_GOALS_PER_SITE,
|
|
22
23
|
RATE_LIMIT_WINDOW_MS,
|
|
24
|
+
REPORT_ROLLUP_SHARDS,
|
|
23
25
|
SECONDARY_AGGREGATE_SHARDS,
|
|
24
26
|
SITE_INGEST_SHARDS,
|
|
25
27
|
} from "./constants.js";
|
|
28
|
+
import {
|
|
29
|
+
addVisitor,
|
|
30
|
+
createVisitorSketch,
|
|
31
|
+
unionVisitorSketches,
|
|
32
|
+
type VisitorSketch,
|
|
33
|
+
} from "./cardinality.js";
|
|
34
|
+
import {
|
|
35
|
+
addToVitalHistogram,
|
|
36
|
+
emptyVitalHistogram,
|
|
37
|
+
vitalDevice,
|
|
38
|
+
VITAL_ALL,
|
|
39
|
+
type VitalMetric,
|
|
40
|
+
} from "./vitals.js";
|
|
26
41
|
import { fail } from "./errors.js";
|
|
42
|
+
import { ensureAnalyticsControl } from "./coverage.js";
|
|
27
43
|
import {
|
|
28
44
|
bufferEventBatch,
|
|
29
45
|
eventBatchKey,
|
|
@@ -36,8 +52,9 @@ import {
|
|
|
36
52
|
} from "./eventStore.js";
|
|
37
53
|
import {
|
|
38
54
|
cleanString,
|
|
39
|
-
ensureBatchBytes,
|
|
40
55
|
isValidMoney,
|
|
56
|
+
measureBatchBytes,
|
|
57
|
+
normalizeRoute,
|
|
41
58
|
originAllowed,
|
|
42
59
|
sanitizeContext,
|
|
43
60
|
sanitizeCurrency,
|
|
@@ -57,9 +74,18 @@ import {
|
|
|
57
74
|
type IngestContext,
|
|
58
75
|
type TrackerEvent,
|
|
59
76
|
} from "./validators.js";
|
|
77
|
+
import {
|
|
78
|
+
createReportRollupDeltas,
|
|
79
|
+
enqueueReportRollups,
|
|
80
|
+
reportRollupCoordinates,
|
|
81
|
+
type ReportRollupDeltas,
|
|
82
|
+
type RouteRollupDelta,
|
|
83
|
+
} from "./rollupStore.js";
|
|
84
|
+
import { localDayStart, localDayTimezone } from "./localTime.js";
|
|
60
85
|
|
|
61
86
|
type DimensionType =
|
|
62
87
|
| "source"
|
|
88
|
+
| "campaign"
|
|
63
89
|
| "page"
|
|
64
90
|
| "country"
|
|
65
91
|
| "device"
|
|
@@ -68,10 +94,15 @@ type DimensionType =
|
|
|
68
94
|
| "goal";
|
|
69
95
|
|
|
70
96
|
type Dimension = { type: DimensionType; value: string };
|
|
71
|
-
type DimensionDelta = Dimension & {
|
|
97
|
+
type DimensionDelta = Dimension & {
|
|
98
|
+
count: number;
|
|
99
|
+
revenueCents: number;
|
|
100
|
+
pageviews: number;
|
|
101
|
+
sessions: number;
|
|
102
|
+
};
|
|
72
103
|
type AggregateDelta = {
|
|
73
104
|
siteId: Id<"sites">;
|
|
74
|
-
granularity: "hour" | "day";
|
|
105
|
+
granularity: "hour" | "day" | "localDay";
|
|
75
106
|
bucketStart: number;
|
|
76
107
|
shard: number;
|
|
77
108
|
events: number;
|
|
@@ -81,9 +112,13 @@ type AggregateDelta = {
|
|
|
81
112
|
outboundClicks: number;
|
|
82
113
|
sessions: number;
|
|
83
114
|
visitors: number;
|
|
115
|
+
visitorSketch: VisitorSketch;
|
|
84
116
|
conversions: number;
|
|
85
117
|
revenueCents: number;
|
|
86
118
|
dimensions: Doc<"aggregateBuckets">["dimensions"];
|
|
119
|
+
durationMs: number;
|
|
120
|
+
continuedSessions: number;
|
|
121
|
+
newVisitors: number;
|
|
87
122
|
};
|
|
88
123
|
type AggregateDeltas = Map<string, AggregateDelta>;
|
|
89
124
|
type AttributionSnapshot = {
|
|
@@ -97,8 +132,20 @@ type SessionState = {
|
|
|
97
132
|
isNewSession: boolean;
|
|
98
133
|
isNewVisitorHour: boolean;
|
|
99
134
|
isNewVisitorDay: boolean;
|
|
135
|
+
/** The session is new and its visitor has no earlier session or alias. */
|
|
136
|
+
isNewVisitor: boolean;
|
|
137
|
+
/** How much this event grew the session's duration. */
|
|
138
|
+
durationDeltaMs: number;
|
|
139
|
+
/** This event is the session's second pageview: it did not bounce. */
|
|
140
|
+
continued: boolean;
|
|
100
141
|
startedAt: number;
|
|
101
142
|
source: string;
|
|
143
|
+
visitorKey: string;
|
|
144
|
+
utmCampaign?: string;
|
|
145
|
+
country?: string;
|
|
146
|
+
city?: string;
|
|
147
|
+
latitude?: number;
|
|
148
|
+
longitude?: number;
|
|
102
149
|
pageviewCount: number;
|
|
103
150
|
eventCount: number;
|
|
104
151
|
affiliateId?: Id<"affiliates">;
|
|
@@ -121,8 +168,9 @@ type CachedRow<T> = {
|
|
|
121
168
|
dirty: boolean;
|
|
122
169
|
};
|
|
123
170
|
|
|
124
|
-
type CachedDocument<
|
|
125
|
-
|
|
171
|
+
type CachedDocument<
|
|
172
|
+
TableName extends keyof import("./_generated/dataModel.js").DataModel,
|
|
173
|
+
> = Omit<Doc<TableName>, "_creationTime"> & { _creationTime?: number };
|
|
126
174
|
|
|
127
175
|
type BatchState = {
|
|
128
176
|
sessions: Map<string, CachedRow<CachedDocument<"sessions">>>;
|
|
@@ -153,25 +201,37 @@ export const ingestBatch = mutation({
|
|
|
153
201
|
});
|
|
154
202
|
}
|
|
155
203
|
if (args.events.length < 1 || args.events.length > MAX_BATCH_EVENTS) {
|
|
156
|
-
fail(
|
|
157
|
-
|
|
158
|
-
|
|
204
|
+
fail(
|
|
205
|
+
"BATCH_TOO_LARGE",
|
|
206
|
+
`events must contain 1-${MAX_BATCH_EVENTS} items`,
|
|
207
|
+
{
|
|
208
|
+
limit: MAX_BATCH_EVENTS,
|
|
209
|
+
},
|
|
210
|
+
);
|
|
159
211
|
}
|
|
212
|
+
let eventByteCounts: number[];
|
|
160
213
|
try {
|
|
161
|
-
|
|
214
|
+
({ eventByteCounts } = measureBatchBytes(
|
|
215
|
+
String(args.siteId),
|
|
216
|
+
args.events,
|
|
217
|
+
));
|
|
162
218
|
} catch {
|
|
163
219
|
fail("BATCH_TOO_LARGE", "batch exceeds 64 KiB", { limitBytes: 65_536 });
|
|
164
220
|
}
|
|
165
221
|
|
|
166
222
|
const now = Date.now();
|
|
167
|
-
|
|
223
|
+
await ensureAnalyticsControl(ctx, args.siteId, now);
|
|
224
|
+
await consumeSiteRateLimit(
|
|
168
225
|
ctx,
|
|
169
226
|
args.siteId,
|
|
170
227
|
args.events,
|
|
228
|
+
eventByteCounts,
|
|
171
229
|
now,
|
|
172
230
|
);
|
|
173
231
|
const context = sanitizeContext(args.context);
|
|
232
|
+
const timezone = localDayTimezone(site.timezone);
|
|
174
233
|
const aggregateDeltas: AggregateDeltas = new Map();
|
|
234
|
+
const reportRollupDeltas = createReportRollupDeltas();
|
|
175
235
|
const sessionRateWindows = new Map<string, SessionRateWindowState>();
|
|
176
236
|
const batchState: BatchState = {
|
|
177
237
|
sessions: new Map(),
|
|
@@ -206,7 +266,12 @@ export const ingestBatch = mutation({
|
|
|
206
266
|
const bucketStart = eventBucketStart(event.timestamp);
|
|
207
267
|
const groupKey = eventBatchKey(event.sessionId, bucketStart);
|
|
208
268
|
requestGroups.add(groupKey);
|
|
209
|
-
if (
|
|
269
|
+
if (
|
|
270
|
+
event.type === "heartbeat" ||
|
|
271
|
+
event.type === "vital" ||
|
|
272
|
+
event.type === "leave"
|
|
273
|
+
)
|
|
274
|
+
continue;
|
|
210
275
|
const eventIds = eventIdsByGroup.get(groupKey) ?? new Set<string>();
|
|
211
276
|
if (eventIds.has(event.eventId)) {
|
|
212
277
|
duplicateInBatch.add(eventIndex);
|
|
@@ -236,10 +301,9 @@ export const ingestBatch = mutation({
|
|
|
236
301
|
siteId: args.siteId,
|
|
237
302
|
sessionId: group.sessionId,
|
|
238
303
|
bucketStart: group.bucketStart,
|
|
239
|
-
candidateEventIds: group.eventIds,
|
|
240
304
|
});
|
|
241
|
-
const newCount = [...group.eventIds].filter(
|
|
242
|
-
!eventIds.has(eventId)
|
|
305
|
+
const newCount = [...group.eventIds].filter(
|
|
306
|
+
(eventId) => !eventIds.has(eventId),
|
|
243
307
|
).length;
|
|
244
308
|
if (
|
|
245
309
|
newCount > 0 &&
|
|
@@ -278,12 +342,7 @@ export const ingestBatch = mutation({
|
|
|
278
342
|
continue;
|
|
279
343
|
}
|
|
280
344
|
const liveSession = (
|
|
281
|
-
await loadLiveSession(
|
|
282
|
-
ctx,
|
|
283
|
-
args.siteId,
|
|
284
|
-
event.sessionId,
|
|
285
|
-
batchState,
|
|
286
|
-
)
|
|
345
|
+
await loadLiveSession(ctx, args.siteId, event.sessionId, batchState)
|
|
287
346
|
).current;
|
|
288
347
|
if (
|
|
289
348
|
liveSession &&
|
|
@@ -306,7 +365,7 @@ export const ingestBatch = mutation({
|
|
|
306
365
|
rejected += 1;
|
|
307
366
|
continue;
|
|
308
367
|
}
|
|
309
|
-
await accountHeartbeat(
|
|
368
|
+
const durationDeltaMs = await accountHeartbeat(
|
|
310
369
|
ctx,
|
|
311
370
|
args.siteId,
|
|
312
371
|
existingSession,
|
|
@@ -315,6 +374,90 @@ export const ingestBatch = mutation({
|
|
|
315
374
|
now,
|
|
316
375
|
batchState,
|
|
317
376
|
);
|
|
377
|
+
// A heartbeat is presence, not telemetry, but it is what keeps a
|
|
378
|
+
// session's duration honest while the visitor reads: the growth is
|
|
379
|
+
// folded into the buckets the heartbeat landed in.
|
|
380
|
+
foldSessionDuration(
|
|
381
|
+
aggregateDeltas,
|
|
382
|
+
args.siteId,
|
|
383
|
+
event,
|
|
384
|
+
durationDeltaMs,
|
|
385
|
+
timezone,
|
|
386
|
+
);
|
|
387
|
+
accepted += 1;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (event.type === "leave") {
|
|
392
|
+
// A departure only shortens live presence: it stores no telemetry,
|
|
393
|
+
// touches no aggregates, and never extends anything, so a replayed
|
|
394
|
+
// or late leave is inert. The read side filters by expiresAt and the
|
|
395
|
+
// sweep deletes the row, so the dashboard sees the exit in seconds.
|
|
396
|
+
if (!existingSession) {
|
|
397
|
+
rejected += 1;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (
|
|
401
|
+
!(await consumeRateLimit(
|
|
402
|
+
ctx,
|
|
403
|
+
args.siteId,
|
|
404
|
+
event.sessionId,
|
|
405
|
+
eventByteCounts[eventIndex],
|
|
406
|
+
now,
|
|
407
|
+
sessionRateWindows,
|
|
408
|
+
))
|
|
409
|
+
) {
|
|
410
|
+
rejected += 1;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const liveState = await loadLiveSession(
|
|
414
|
+
ctx,
|
|
415
|
+
args.siteId,
|
|
416
|
+
event.sessionId,
|
|
417
|
+
batchState,
|
|
418
|
+
);
|
|
419
|
+
const liveSession = liveState.current;
|
|
420
|
+
if (liveSession) {
|
|
421
|
+
const expiresAt = Math.min(
|
|
422
|
+
liveSession.expiresAt,
|
|
423
|
+
now + LIVE_LEAVE_GRACE_MS,
|
|
424
|
+
);
|
|
425
|
+
if (expiresAt < liveSession.expiresAt) {
|
|
426
|
+
await ctx.db.patch("liveSessions", liveSession._id, { expiresAt });
|
|
427
|
+
liveState.current = { ...liveSession, expiresAt };
|
|
428
|
+
}
|
|
429
|
+
await ensureLiveSweep(ctx, args.siteId, now, batchState);
|
|
430
|
+
}
|
|
431
|
+
accepted += 1;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (event.type === "vital") {
|
|
436
|
+
// A vital annotates telemetry its session already produced; it never
|
|
437
|
+
// creates sessions, live presence, event batches, or aggregate counts.
|
|
438
|
+
if (!existingSession) {
|
|
439
|
+
rejected += 1;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (
|
|
443
|
+
!(await consumeRateLimit(
|
|
444
|
+
ctx,
|
|
445
|
+
args.siteId,
|
|
446
|
+
event.sessionId,
|
|
447
|
+
eventByteCounts[eventIndex],
|
|
448
|
+
now,
|
|
449
|
+
sessionRateWindows,
|
|
450
|
+
))
|
|
451
|
+
) {
|
|
452
|
+
rejected += 1;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
foldVitalRollups(
|
|
456
|
+
reportRollupDeltas,
|
|
457
|
+
args.siteId,
|
|
458
|
+
event,
|
|
459
|
+
vitalDevice(context.device),
|
|
460
|
+
);
|
|
318
461
|
accepted += 1;
|
|
319
462
|
continue;
|
|
320
463
|
}
|
|
@@ -322,7 +465,9 @@ export const ingestBatch = mutation({
|
|
|
322
465
|
if (
|
|
323
466
|
duplicateInBatch.has(eventIndex) ||
|
|
324
467
|
persistedEventIds
|
|
325
|
-
.get(
|
|
468
|
+
.get(
|
|
469
|
+
eventBatchKey(event.sessionId, eventBucketStart(event.timestamp)),
|
|
470
|
+
)
|
|
326
471
|
?.has(event.eventId)
|
|
327
472
|
) {
|
|
328
473
|
duplicates += 1;
|
|
@@ -356,26 +501,34 @@ export const ingestBatch = mutation({
|
|
|
356
501
|
.withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
|
|
357
502
|
.take(MAX_FUNNELS_PER_SITE);
|
|
358
503
|
}
|
|
359
|
-
await accountEvent(ctx, {
|
|
504
|
+
const session = await accountEvent(ctx, {
|
|
360
505
|
siteId: args.siteId,
|
|
361
506
|
event,
|
|
362
507
|
context,
|
|
363
508
|
existingSession,
|
|
364
|
-
goals: canMatchDefinition ? goals ?? [] : [],
|
|
365
|
-
funnels: canMatchDefinition ? funnels ?? [] : [],
|
|
509
|
+
goals: canMatchDefinition ? (goals ?? []) : [],
|
|
510
|
+
funnels: canMatchDefinition ? (funnels ?? []) : [],
|
|
366
511
|
aggregateDeltas,
|
|
512
|
+
reportRollupDeltas,
|
|
367
513
|
recordTelemetry: true,
|
|
514
|
+
timezone,
|
|
368
515
|
financialConversion: false,
|
|
369
516
|
trustedGoalKey: undefined,
|
|
370
517
|
now,
|
|
371
518
|
batchState,
|
|
372
519
|
});
|
|
373
|
-
bufferEventBatch(
|
|
520
|
+
bufferEventBatch(
|
|
521
|
+
args.siteId,
|
|
522
|
+
event,
|
|
523
|
+
batchState.eventBatches,
|
|
524
|
+
session.country ?? "unknown",
|
|
525
|
+
);
|
|
374
526
|
accepted += 1;
|
|
375
527
|
}
|
|
376
528
|
await flushBatchState(ctx, batchState);
|
|
377
529
|
await flushSessionRateLimits(ctx, sessionRateWindows, now);
|
|
378
530
|
await flushAggregates(ctx, aggregateDeltas);
|
|
531
|
+
await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
|
|
379
532
|
return { accepted, duplicates, rejected };
|
|
380
533
|
},
|
|
381
534
|
});
|
|
@@ -387,7 +540,10 @@ export const trackConversion = mutation({
|
|
|
387
540
|
const site = await ctx.db.get("sites", args.siteId);
|
|
388
541
|
if (!site) fail("NOT_FOUND", "site not found");
|
|
389
542
|
if (!isValidMoney(args.revenueCents)) {
|
|
390
|
-
fail(
|
|
543
|
+
fail(
|
|
544
|
+
"INVALID_ARGUMENT",
|
|
545
|
+
"revenueCents must be an integer between 0 and 1000000000",
|
|
546
|
+
);
|
|
391
547
|
}
|
|
392
548
|
let event: TrackerEvent;
|
|
393
549
|
let trustedGoalKey: string | undefined;
|
|
@@ -412,7 +568,10 @@ export const trackConversion = mutation({
|
|
|
412
568
|
Date.now(),
|
|
413
569
|
);
|
|
414
570
|
} catch (error) {
|
|
415
|
-
fail(
|
|
571
|
+
fail(
|
|
572
|
+
"INVALID_ARGUMENT",
|
|
573
|
+
error instanceof Error ? error.message : "invalid conversion",
|
|
574
|
+
);
|
|
416
575
|
}
|
|
417
576
|
|
|
418
577
|
const siteCurrency = site.currency ?? "USD";
|
|
@@ -432,10 +591,14 @@ export const trackConversion = mutation({
|
|
|
432
591
|
return { accepted: 0, duplicates: 1, rejected: 0 };
|
|
433
592
|
}
|
|
434
593
|
if (event.currency !== siteCurrency) {
|
|
435
|
-
fail(
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
594
|
+
fail(
|
|
595
|
+
"CURRENCY_MISMATCH",
|
|
596
|
+
"conversion currency does not match site currency",
|
|
597
|
+
{
|
|
598
|
+
siteCurrency,
|
|
599
|
+
conversionCurrency: event.currency ?? "USD",
|
|
600
|
+
},
|
|
601
|
+
);
|
|
439
602
|
}
|
|
440
603
|
|
|
441
604
|
const telemetry = await findTelemetry(ctx, args.siteId, event);
|
|
@@ -467,7 +630,8 @@ export const trackConversion = mutation({
|
|
|
467
630
|
};
|
|
468
631
|
|
|
469
632
|
const aggregateDeltas: AggregateDeltas = new Map();
|
|
470
|
-
|
|
633
|
+
const reportRollupDeltas = createReportRollupDeltas();
|
|
634
|
+
const session = await accountEvent(ctx, {
|
|
471
635
|
siteId: args.siteId,
|
|
472
636
|
event,
|
|
473
637
|
context: conversionContext,
|
|
@@ -475,15 +639,25 @@ export const trackConversion = mutation({
|
|
|
475
639
|
goals: [],
|
|
476
640
|
funnels: [],
|
|
477
641
|
aggregateDeltas,
|
|
642
|
+
reportRollupDeltas,
|
|
478
643
|
recordTelemetry: existingTelemetry === null && !telemetry.saturated,
|
|
644
|
+
timezone: localDayTimezone(site.timezone),
|
|
479
645
|
financialConversion: true,
|
|
480
646
|
trustedGoalKey,
|
|
481
647
|
now: Date.now(),
|
|
482
648
|
});
|
|
483
649
|
if (!existingTelemetry && !telemetry.saturated) {
|
|
484
|
-
await insertEventBatch(
|
|
650
|
+
await insertEventBatch(
|
|
651
|
+
ctx,
|
|
652
|
+
args.siteId,
|
|
653
|
+
event,
|
|
654
|
+
session.country ?? "unknown",
|
|
655
|
+
);
|
|
485
656
|
}
|
|
486
657
|
await flushAggregates(ctx, aggregateDeltas);
|
|
658
|
+
const now = Date.now();
|
|
659
|
+
await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
|
|
660
|
+
await ensureAnalyticsControl(ctx, args.siteId, now);
|
|
487
661
|
return { accepted: 1, duplicates: 0, rejected: 0 };
|
|
488
662
|
},
|
|
489
663
|
});
|
|
@@ -586,11 +760,7 @@ async function flushBatchState(ctx: MutationCtx, batchState: BatchState) {
|
|
|
586
760
|
function withoutSystemFields<
|
|
587
761
|
T extends { _id: unknown; _creationTime?: number },
|
|
588
762
|
>(document: T): Omit<T, "_id" | "_creationTime"> {
|
|
589
|
-
const {
|
|
590
|
-
_id,
|
|
591
|
-
_creationTime,
|
|
592
|
-
...fields
|
|
593
|
-
} = document;
|
|
763
|
+
const { _id, _creationTime, ...fields } = document;
|
|
594
764
|
void _id;
|
|
595
765
|
void _creationTime;
|
|
596
766
|
return fields;
|
|
@@ -606,13 +776,16 @@ async function accountEvent(
|
|
|
606
776
|
goals: Doc<"goals">[];
|
|
607
777
|
funnels: Doc<"funnels">[];
|
|
608
778
|
aggregateDeltas: AggregateDeltas;
|
|
779
|
+
reportRollupDeltas: ReportRollupDeltas;
|
|
609
780
|
recordTelemetry: boolean;
|
|
781
|
+
/** The site's timezone when it keeps local-day buckets. */
|
|
782
|
+
timezone?: string;
|
|
610
783
|
financialConversion: boolean;
|
|
611
784
|
trustedGoalKey: string | undefined;
|
|
612
785
|
now: number;
|
|
613
786
|
batchState?: BatchState;
|
|
614
787
|
},
|
|
615
|
-
) {
|
|
788
|
+
): Promise<SessionState> {
|
|
616
789
|
const { event } = input;
|
|
617
790
|
const session = input.recordTelemetry
|
|
618
791
|
? await upsertSession(
|
|
@@ -622,13 +795,25 @@ async function accountEvent(
|
|
|
622
795
|
input.context,
|
|
623
796
|
input.existingSession,
|
|
624
797
|
input.financialConversion,
|
|
798
|
+
input.reportRollupDeltas,
|
|
625
799
|
input.batchState,
|
|
626
800
|
)
|
|
627
801
|
: await accountTrustedSession(ctx, input.existingSession, event);
|
|
802
|
+
if (input.recordTelemetry && event.type === "pageview") {
|
|
803
|
+
// `existingSession` is the snapshot from before `upsertSession`, so it
|
|
804
|
+
// still carries the route the visitor is leaving.
|
|
805
|
+
foldRouteRollups(
|
|
806
|
+
input.reportRollupDeltas,
|
|
807
|
+
input.siteId,
|
|
808
|
+
event,
|
|
809
|
+
session,
|
|
810
|
+
input.existingSession,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
628
813
|
|
|
629
814
|
const matchedGoalKeys: string[] = [];
|
|
630
|
-
const matchingGoals = input.goals.filter(
|
|
631
|
-
goal.active && goalMatches(goal, event)
|
|
815
|
+
const matchingGoals = input.goals.filter(
|
|
816
|
+
(goal) => goal.active && goalMatches(goal, event),
|
|
632
817
|
);
|
|
633
818
|
if (matchingGoals.length > 1) {
|
|
634
819
|
fail("LIMIT_EXCEEDED", "active goals must use unique matchers");
|
|
@@ -639,33 +824,36 @@ async function accountEvent(
|
|
|
639
824
|
eventBucketStart(event.timestamp),
|
|
640
825
|
event.eventId,
|
|
641
826
|
]);
|
|
642
|
-
const scopedPrior = (
|
|
643
|
-
.
|
|
644
|
-
|
|
645
|
-
range
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
827
|
+
const scopedPrior = (
|
|
828
|
+
await ctx.db
|
|
829
|
+
.query("goalCompletions")
|
|
830
|
+
.withIndex("by_siteId_and_goalId_and_eventId", (range) =>
|
|
831
|
+
range
|
|
832
|
+
.eq("siteId", input.siteId)
|
|
833
|
+
.eq("goalId", goal._id)
|
|
834
|
+
.eq("eventId", completionEventId),
|
|
835
|
+
)
|
|
836
|
+
.take(2)
|
|
837
|
+
).find((completion) => completion.idempotencyVersion === 2);
|
|
652
838
|
const legacyPrior = scopedPrior
|
|
653
839
|
? undefined
|
|
654
|
-
: (
|
|
655
|
-
.
|
|
656
|
-
|
|
657
|
-
range
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
840
|
+
: (
|
|
841
|
+
await ctx.db
|
|
842
|
+
.query("goalCompletions")
|
|
843
|
+
.withIndex("by_siteId_and_goalId_and_eventId", (range) =>
|
|
844
|
+
range
|
|
845
|
+
.eq("siteId", input.siteId)
|
|
846
|
+
.eq("goalId", goal._id)
|
|
847
|
+
.eq("eventId", event.eventId),
|
|
848
|
+
)
|
|
849
|
+
.take(2)
|
|
850
|
+
).find(
|
|
851
|
+
(completion) =>
|
|
664
852
|
completion.idempotencyVersion === undefined &&
|
|
665
853
|
completion.sessionId === event.sessionId &&
|
|
666
854
|
eventBucketStart(completion.timestamp) ===
|
|
667
|
-
eventBucketStart(event.timestamp)
|
|
668
|
-
|
|
855
|
+
eventBucketStart(event.timestamp),
|
|
856
|
+
);
|
|
669
857
|
if (scopedPrior || legacyPrior) continue;
|
|
670
858
|
await ctx.db.insert("goalCompletions", {
|
|
671
859
|
siteId: input.siteId,
|
|
@@ -678,12 +866,20 @@ async function accountEvent(
|
|
|
678
866
|
timestamp: event.timestamp,
|
|
679
867
|
valueCents: goal.valueCents ?? 0,
|
|
680
868
|
});
|
|
869
|
+
foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
|
|
681
870
|
matchedGoalKeys.push(goal.key);
|
|
682
871
|
}
|
|
683
872
|
|
|
684
873
|
for (const funnel of input.funnels) {
|
|
685
874
|
if (funnel.active) {
|
|
686
|
-
await advanceFunnel(
|
|
875
|
+
await advanceFunnel(
|
|
876
|
+
ctx,
|
|
877
|
+
input.siteId,
|
|
878
|
+
funnel,
|
|
879
|
+
event,
|
|
880
|
+
input.reportRollupDeltas,
|
|
881
|
+
input.batchState,
|
|
882
|
+
);
|
|
687
883
|
}
|
|
688
884
|
}
|
|
689
885
|
|
|
@@ -694,18 +890,23 @@ async function accountEvent(
|
|
|
694
890
|
session,
|
|
695
891
|
event,
|
|
696
892
|
input.trustedGoalKey,
|
|
893
|
+
input.reportRollupDeltas,
|
|
697
894
|
);
|
|
698
895
|
}
|
|
699
896
|
|
|
897
|
+
const campaignDimensions: Dimension[] = session.utmCampaign
|
|
898
|
+
? [{ type: "campaign", value: session.utmCampaign }]
|
|
899
|
+
: [];
|
|
700
900
|
const dimensions: Dimension[] = [
|
|
701
901
|
{ type: "source", value: session.source },
|
|
902
|
+
...campaignDimensions,
|
|
702
903
|
{ type: "page", value: event.path },
|
|
703
|
-
{ type: "country", value:
|
|
904
|
+
{ type: "country", value: session.country ?? "unknown" },
|
|
704
905
|
{ type: "device", value: input.context.device ?? "unknown" },
|
|
705
906
|
{ type: "browser", value: input.context.browser ?? "Other" },
|
|
706
907
|
{
|
|
707
908
|
type: "event",
|
|
708
|
-
value: event.type === "custom" ? event.name ?? "custom" : event.type,
|
|
909
|
+
value: event.type === "custom" ? (event.name ?? "custom") : event.type,
|
|
709
910
|
},
|
|
710
911
|
...matchedGoalKeys.map((value): Dimension => ({ type: "goal", value })),
|
|
711
912
|
];
|
|
@@ -717,6 +918,7 @@ async function accountEvent(
|
|
|
717
918
|
dimensions,
|
|
718
919
|
input.recordTelemetry,
|
|
719
920
|
input.financialConversion,
|
|
921
|
+
input.timezone,
|
|
720
922
|
);
|
|
721
923
|
|
|
722
924
|
if (
|
|
@@ -733,6 +935,7 @@ async function accountEvent(
|
|
|
733
935
|
input.batchState,
|
|
734
936
|
);
|
|
735
937
|
}
|
|
938
|
+
return session;
|
|
736
939
|
}
|
|
737
940
|
|
|
738
941
|
async function accountTrustedSession(
|
|
@@ -753,8 +956,17 @@ async function accountTrustedSession(
|
|
|
753
956
|
isNewSession: false,
|
|
754
957
|
isNewVisitorHour: false,
|
|
755
958
|
isNewVisitorDay: false,
|
|
959
|
+
isNewVisitor: false,
|
|
960
|
+
durationDeltaMs: 0,
|
|
961
|
+
continued: false,
|
|
756
962
|
startedAt: existing.startedAt,
|
|
757
963
|
source: existing.source,
|
|
964
|
+
utmCampaign: existing.utmCampaign,
|
|
965
|
+
visitorKey: existing.visitorKey ?? existing.visitorId,
|
|
966
|
+
country: existing.country,
|
|
967
|
+
city: existing.city,
|
|
968
|
+
latitude: existing.latitude,
|
|
969
|
+
longitude: existing.longitude,
|
|
758
970
|
pageviewCount: existing.pageviewCount,
|
|
759
971
|
eventCount: existing.eventCount,
|
|
760
972
|
affiliateId: existing.affiliateId,
|
|
@@ -771,10 +983,19 @@ async function upsertSession(
|
|
|
771
983
|
context: IngestContext,
|
|
772
984
|
existing: CachedDocument<"sessions"> | null,
|
|
773
985
|
financialConversion: boolean,
|
|
986
|
+
reportRollupDeltas: ReportRollupDeltas,
|
|
774
987
|
batchState?: BatchState,
|
|
775
988
|
): Promise<SessionState> {
|
|
776
|
-
|
|
989
|
+
// A host-supplied id is the identity; anonymous sessions count through the
|
|
990
|
+
// daily key derived by the HTTP action, or once per session without one.
|
|
991
|
+
const visitorKey =
|
|
992
|
+
existing?.visitorKey ??
|
|
993
|
+
(event.visitorId !== event.sessionId
|
|
994
|
+
? event.visitorId
|
|
995
|
+
: (context.visitorKey ?? event.sessionId));
|
|
777
996
|
const utmSource = stringProperty(event.properties, "utm_source");
|
|
997
|
+
// Campaign traffic names its own source; the referrer host is the fallback.
|
|
998
|
+
const source = utmSource?.toLowerCase() ?? sourceFromReferrer(event.referrer);
|
|
778
999
|
const utmMedium = stringProperty(event.properties, "utm_medium");
|
|
779
1000
|
const utmCampaign = stringProperty(event.properties, "utm_campaign");
|
|
780
1001
|
const attribution = await attributionForEvent(
|
|
@@ -785,20 +1006,40 @@ async function upsertSession(
|
|
|
785
1006
|
batchState,
|
|
786
1007
|
);
|
|
787
1008
|
if (existing) {
|
|
1009
|
+
if (attribution && existing.affiliateId !== attribution.affiliateId) {
|
|
1010
|
+
foldAffiliateVisitRollup(
|
|
1011
|
+
reportRollupDeltas,
|
|
1012
|
+
siteId,
|
|
1013
|
+
event,
|
|
1014
|
+
attribution,
|
|
1015
|
+
visitorKey,
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
788
1018
|
const startedAt = Math.min(existing.startedAt, event.timestamp);
|
|
789
1019
|
const lastSeenAt = Math.max(existing.lastSeenAt, event.timestamp);
|
|
790
1020
|
const updates = {
|
|
791
1021
|
startedAt,
|
|
792
1022
|
lastSeenAt,
|
|
793
|
-
entryPath:
|
|
794
|
-
|
|
1023
|
+
entryPath:
|
|
1024
|
+
event.timestamp < existing.startedAt ? event.path : existing.entryPath,
|
|
1025
|
+
exitPath:
|
|
1026
|
+
event.timestamp >= existing.lastSeenAt ? event.path : existing.exitPath,
|
|
795
1027
|
eventCount: existing.eventCount + 1,
|
|
796
|
-
pageviewCount:
|
|
1028
|
+
pageviewCount:
|
|
1029
|
+
existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
|
|
1030
|
+
...(event.type === "pageview"
|
|
1031
|
+
? {
|
|
1032
|
+
lastPageviewAt: Math.max(
|
|
1033
|
+
existing.lastPageviewAt ?? 0,
|
|
1034
|
+
event.timestamp,
|
|
1035
|
+
),
|
|
1036
|
+
}
|
|
1037
|
+
: {}),
|
|
797
1038
|
durationMs: Math.max(0, lastSeenAt - startedAt),
|
|
798
1039
|
conversionCount: existing.conversionCount + Number(financialConversion),
|
|
799
1040
|
revenueCents:
|
|
800
1041
|
existing.revenueCents +
|
|
801
|
-
(financialConversion ? event.revenueCents ?? 0 : 0),
|
|
1042
|
+
(financialConversion ? (event.revenueCents ?? 0) : 0),
|
|
802
1043
|
...(financialConversion && event.currency
|
|
803
1044
|
? { currency: event.currency }
|
|
804
1045
|
: {}),
|
|
@@ -809,7 +1050,9 @@ async function upsertSession(
|
|
|
809
1050
|
...(context.country ? { country: context.country } : {}),
|
|
810
1051
|
...(context.city ? { city: context.city } : {}),
|
|
811
1052
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
812
|
-
...(context.longitude !== undefined
|
|
1053
|
+
...(context.longitude !== undefined
|
|
1054
|
+
? { longitude: context.longitude }
|
|
1055
|
+
: {}),
|
|
813
1056
|
browser: context.browser ?? existing.browser,
|
|
814
1057
|
os: context.os ?? existing.os,
|
|
815
1058
|
device: context.device ?? existing.device,
|
|
@@ -830,8 +1073,17 @@ async function upsertSession(
|
|
|
830
1073
|
isNewVisitorDay:
|
|
831
1074
|
Math.floor(existing.lastSeenAt / DAY_MS) <
|
|
832
1075
|
Math.floor(event.timestamp / DAY_MS),
|
|
1076
|
+
isNewVisitor: false,
|
|
1077
|
+
durationDeltaMs: Math.max(0, updates.durationMs - existing.durationMs),
|
|
1078
|
+
continued: event.type === "pageview" && existing.pageviewCount === 1,
|
|
833
1079
|
startedAt,
|
|
834
1080
|
source: existing.source,
|
|
1081
|
+
utmCampaign: existing.utmCampaign,
|
|
1082
|
+
visitorKey,
|
|
1083
|
+
country: context.country ?? existing.country,
|
|
1084
|
+
city: context.city ?? existing.city,
|
|
1085
|
+
latitude: context.latitude ?? existing.latitude,
|
|
1086
|
+
longitude: context.longitude ?? existing.longitude,
|
|
835
1087
|
pageviewCount:
|
|
836
1088
|
existing.pageviewCount + (event.type === "pageview" ? 1 : 0),
|
|
837
1089
|
eventCount: existing.eventCount + 1,
|
|
@@ -859,10 +1111,24 @@ async function upsertSession(
|
|
|
859
1111
|
}
|
|
860
1112
|
}
|
|
861
1113
|
}
|
|
1114
|
+
// A visitor is new when nothing was ever recorded under their id. A host
|
|
1115
|
+
// id whose anonymous history was linked at sign-up is a return, not a new
|
|
1116
|
+
// person; a browser without a persistent id is new every session.
|
|
1117
|
+
let isNewVisitor = priorVisit === null;
|
|
1118
|
+
if (isNewVisitor && event.visitorId !== event.sessionId) {
|
|
1119
|
+
const alias = await ctx.db
|
|
1120
|
+
.query("visitorAliases")
|
|
1121
|
+
.withIndex("by_siteId_and_visitorId", (range) =>
|
|
1122
|
+
range.eq("siteId", siteId).eq("visitorId", event.visitorId),
|
|
1123
|
+
)
|
|
1124
|
+
.first();
|
|
1125
|
+
if (alias) isNewVisitor = false;
|
|
1126
|
+
}
|
|
862
1127
|
const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
|
|
863
1128
|
siteId,
|
|
864
1129
|
sessionId: event.sessionId,
|
|
865
1130
|
visitorId: event.visitorId,
|
|
1131
|
+
visitorKey,
|
|
866
1132
|
startedAt: event.timestamp,
|
|
867
1133
|
lastSeenAt: event.timestamp,
|
|
868
1134
|
entryPath: event.path,
|
|
@@ -875,21 +1141,33 @@ async function upsertSession(
|
|
|
875
1141
|
...(context.country ? { country: context.country } : {}),
|
|
876
1142
|
...(context.city ? { city: context.city } : {}),
|
|
877
1143
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
878
|
-
...(context.longitude !== undefined
|
|
1144
|
+
...(context.longitude !== undefined
|
|
1145
|
+
? { longitude: context.longitude }
|
|
1146
|
+
: {}),
|
|
879
1147
|
browser: context.browser ?? "Other",
|
|
880
1148
|
os: context.os ?? "Other",
|
|
881
1149
|
device: context.device ?? "unknown",
|
|
882
1150
|
eventCount: 1,
|
|
883
1151
|
pageviewCount: event.type === "pageview" ? 1 : 0,
|
|
1152
|
+
...(event.type === "pageview" ? { lastPageviewAt: event.timestamp } : {}),
|
|
884
1153
|
durationMs: 0,
|
|
885
1154
|
conversionCount: Number(financialConversion),
|
|
886
|
-
revenueCents: financialConversion ? event.revenueCents ?? 0 : 0,
|
|
1155
|
+
revenueCents: financialConversion ? (event.revenueCents ?? 0) : 0,
|
|
887
1156
|
...(financialConversion && event.currency
|
|
888
1157
|
? { currency: event.currency }
|
|
889
1158
|
: {}),
|
|
890
1159
|
...(attribution ?? {}),
|
|
891
1160
|
};
|
|
892
1161
|
const sessionId = await ctx.db.insert("sessions", fields);
|
|
1162
|
+
if (attribution) {
|
|
1163
|
+
foldAffiliateVisitRollup(
|
|
1164
|
+
reportRollupDeltas,
|
|
1165
|
+
siteId,
|
|
1166
|
+
event,
|
|
1167
|
+
attribution,
|
|
1168
|
+
visitorKey,
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
893
1171
|
if (batchState) {
|
|
894
1172
|
const state = batchState.sessions.get(event.sessionId);
|
|
895
1173
|
if (!state) throw new Error("session cache was not initialized");
|
|
@@ -908,22 +1186,29 @@ async function upsertSession(
|
|
|
908
1186
|
priorVisit === null ||
|
|
909
1187
|
Math.floor(priorVisit.lastSeenAt / DAY_MS) <
|
|
910
1188
|
Math.floor(event.timestamp / DAY_MS),
|
|
1189
|
+
isNewVisitor,
|
|
1190
|
+
durationDeltaMs: 0,
|
|
1191
|
+
continued: false,
|
|
911
1192
|
startedAt: event.timestamp,
|
|
912
1193
|
source,
|
|
1194
|
+
utmCampaign,
|
|
1195
|
+
visitorKey,
|
|
1196
|
+
country: context.country,
|
|
1197
|
+
city: context.city,
|
|
1198
|
+
latitude: context.latitude,
|
|
1199
|
+
longitude: context.longitude,
|
|
913
1200
|
pageviewCount: event.type === "pageview" ? 1 : 0,
|
|
914
1201
|
eventCount: 1,
|
|
915
1202
|
...(attribution ?? {}),
|
|
916
1203
|
};
|
|
917
1204
|
}
|
|
918
1205
|
|
|
919
|
-
function attributionSnapshot(
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
},
|
|
926
|
-
): AttributionSnapshot | undefined {
|
|
1206
|
+
function attributionSnapshot(value: {
|
|
1207
|
+
affiliateId?: Id<"affiliates">;
|
|
1208
|
+
affiliateSlug?: string;
|
|
1209
|
+
affiliateAttributedAt?: number;
|
|
1210
|
+
affiliateExpiresAt?: number;
|
|
1211
|
+
}): AttributionSnapshot | undefined {
|
|
927
1212
|
if (
|
|
928
1213
|
!value.affiliateId ||
|
|
929
1214
|
!value.affiliateSlug ||
|
|
@@ -940,7 +1225,6 @@ function attributionSnapshot(
|
|
|
940
1225
|
};
|
|
941
1226
|
}
|
|
942
1227
|
|
|
943
|
-
|
|
944
1228
|
function attributionApplies(
|
|
945
1229
|
attribution: AttributionSnapshot,
|
|
946
1230
|
timestamp: number,
|
|
@@ -1065,20 +1349,21 @@ async function accountHeartbeat(
|
|
|
1065
1349
|
context: IngestContext,
|
|
1066
1350
|
now: number,
|
|
1067
1351
|
batchState?: BatchState,
|
|
1068
|
-
) {
|
|
1352
|
+
): Promise<number> {
|
|
1069
1353
|
const startedAt = Math.min(session.startedAt, event.timestamp);
|
|
1070
1354
|
const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
|
|
1071
1355
|
const updates = {
|
|
1072
1356
|
startedAt,
|
|
1073
1357
|
lastSeenAt,
|
|
1074
|
-
exitPath:
|
|
1075
|
-
? event.path
|
|
1076
|
-
: session.exitPath,
|
|
1358
|
+
exitPath:
|
|
1359
|
+
event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
|
|
1077
1360
|
durationMs: Math.max(0, lastSeenAt - startedAt),
|
|
1078
1361
|
...(context.country ? { country: context.country } : {}),
|
|
1079
1362
|
...(context.city ? { city: context.city } : {}),
|
|
1080
1363
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
1081
|
-
...(context.longitude !== undefined
|
|
1364
|
+
...(context.longitude !== undefined
|
|
1365
|
+
? { longitude: context.longitude }
|
|
1366
|
+
: {}),
|
|
1082
1367
|
browser: context.browser ?? session.browser,
|
|
1083
1368
|
os: context.os ?? session.os,
|
|
1084
1369
|
device: context.device ?? session.device,
|
|
@@ -1091,6 +1376,7 @@ async function accountHeartbeat(
|
|
|
1091
1376
|
} else {
|
|
1092
1377
|
await ctx.db.patch("sessions", session._id, updates);
|
|
1093
1378
|
}
|
|
1379
|
+
const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
|
|
1094
1380
|
await updateLiveSession(
|
|
1095
1381
|
ctx,
|
|
1096
1382
|
siteId,
|
|
@@ -1100,8 +1386,16 @@ async function accountHeartbeat(
|
|
|
1100
1386
|
isNewSession: false,
|
|
1101
1387
|
isNewVisitorHour: false,
|
|
1102
1388
|
isNewVisitorDay: false,
|
|
1389
|
+
isNewVisitor: false,
|
|
1390
|
+
durationDeltaMs: 0,
|
|
1391
|
+
continued: false,
|
|
1103
1392
|
startedAt,
|
|
1104
1393
|
source: session.source,
|
|
1394
|
+
visitorKey: session.visitorKey ?? session.visitorId,
|
|
1395
|
+
country: context.country ?? session.country,
|
|
1396
|
+
city: context.city ?? session.city,
|
|
1397
|
+
latitude: context.latitude ?? session.latitude,
|
|
1398
|
+
longitude: context.longitude ?? session.longitude,
|
|
1105
1399
|
pageviewCount: session.pageviewCount,
|
|
1106
1400
|
eventCount: session.eventCount,
|
|
1107
1401
|
affiliateSlug: session.affiliateSlug,
|
|
@@ -1109,23 +1403,25 @@ async function accountHeartbeat(
|
|
|
1109
1403
|
now,
|
|
1110
1404
|
batchState,
|
|
1111
1405
|
);
|
|
1406
|
+
return durationDeltaMs;
|
|
1112
1407
|
}
|
|
1113
1408
|
|
|
1114
1409
|
async function consumeSiteRateLimit(
|
|
1115
1410
|
ctx: MutationCtx,
|
|
1116
1411
|
siteId: Id<"sites">,
|
|
1117
1412
|
events: TrackerEvent[],
|
|
1413
|
+
eventByteCounts: number[],
|
|
1118
1414
|
now: number,
|
|
1119
|
-
): Promise<
|
|
1415
|
+
): Promise<void> {
|
|
1120
1416
|
const windowStart =
|
|
1121
1417
|
Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
1122
|
-
const increments = new Map<
|
|
1123
|
-
|
|
1124
|
-
|
|
1418
|
+
const increments = new Map<
|
|
1419
|
+
number,
|
|
1420
|
+
{ eventCount: number; byteCount: number }
|
|
1421
|
+
>();
|
|
1125
1422
|
for (let index = 0; index < events.length; index += 1) {
|
|
1126
1423
|
const event = events[index];
|
|
1127
|
-
const byteCount =
|
|
1128
|
-
eventByteCounts.push(byteCount);
|
|
1424
|
+
const byteCount = eventByteCounts[index];
|
|
1129
1425
|
const shard = stableHash(event.sessionId) % SITE_INGEST_SHARDS;
|
|
1130
1426
|
const current = increments.get(shard) ?? { eventCount: 0, byteCount: 0 };
|
|
1131
1427
|
current.eventCount += 1;
|
|
@@ -1147,9 +1443,8 @@ async function consumeSiteRateLimit(
|
|
|
1147
1443
|
.order("desc")
|
|
1148
1444
|
.take(1);
|
|
1149
1445
|
const storedWindow = rows[0] ?? null;
|
|
1150
|
-
const window =
|
|
1151
|
-
? storedWindow
|
|
1152
|
-
: null;
|
|
1446
|
+
const window =
|
|
1447
|
+
storedWindow?.windowStart === windowStart ? storedWindow : null;
|
|
1153
1448
|
if (
|
|
1154
1449
|
(window?.eventCount ?? 0) + increment.eventCount >
|
|
1155
1450
|
MAX_SITE_EVENTS_PER_SHARD_WINDOW ||
|
|
@@ -1161,10 +1456,8 @@ async function consumeSiteRateLimit(
|
|
|
1161
1456
|
windowMs: RATE_LIMIT_WINDOW_MS,
|
|
1162
1457
|
eventLimitPerShard: MAX_SITE_EVENTS_PER_SHARD_WINDOW,
|
|
1163
1458
|
byteLimitPerShard: MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
1164
|
-
siteEventLimit:
|
|
1165
|
-
|
|
1166
|
-
siteByteLimit:
|
|
1167
|
-
SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
1459
|
+
siteEventLimit: SITE_INGEST_SHARDS * MAX_SITE_EVENTS_PER_SHARD_WINDOW,
|
|
1460
|
+
siteByteLimit: SITE_INGEST_SHARDS * MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
1168
1461
|
});
|
|
1169
1462
|
}
|
|
1170
1463
|
windows.push({ shard, increment, window: storedWindow });
|
|
@@ -1195,7 +1488,6 @@ async function consumeSiteRateLimit(
|
|
|
1195
1488
|
});
|
|
1196
1489
|
}
|
|
1197
1490
|
}
|
|
1198
|
-
return eventByteCounts;
|
|
1199
1491
|
}
|
|
1200
1492
|
|
|
1201
1493
|
async function consumeRateLimit(
|
|
@@ -1206,7 +1498,8 @@ async function consumeRateLimit(
|
|
|
1206
1498
|
now: number,
|
|
1207
1499
|
states: Map<string, SessionRateWindowState>,
|
|
1208
1500
|
): Promise<boolean> {
|
|
1209
|
-
const windowStart =
|
|
1501
|
+
const windowStart =
|
|
1502
|
+
Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
1210
1503
|
let state = states.get(sessionId);
|
|
1211
1504
|
if (!state) {
|
|
1212
1505
|
const rows = await ctx.db
|
|
@@ -1244,7 +1537,8 @@ async function flushSessionRateLimits(
|
|
|
1244
1537
|
states: Map<string, SessionRateWindowState>,
|
|
1245
1538
|
now: number,
|
|
1246
1539
|
) {
|
|
1247
|
-
const windowStart =
|
|
1540
|
+
const windowStart =
|
|
1541
|
+
Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
1248
1542
|
for (const state of states.values()) {
|
|
1249
1543
|
if (!state.dirty) continue;
|
|
1250
1544
|
if (state.window) {
|
|
@@ -1289,31 +1583,41 @@ async function updateLiveSession(
|
|
|
1289
1583
|
)
|
|
1290
1584
|
.unique();
|
|
1291
1585
|
const deadline = now + LIVE_SESSION_TTL_MS;
|
|
1586
|
+
const country = context.country ?? session.country;
|
|
1587
|
+
const city = context.city ?? session.city;
|
|
1588
|
+
const latitude = context.latitude ?? session.latitude;
|
|
1589
|
+
const longitude = context.longitude ?? session.longitude;
|
|
1292
1590
|
if (existing) {
|
|
1293
|
-
const recentHeartbeatEventIds =
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
(existing.
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1591
|
+
const recentHeartbeatEventIds =
|
|
1592
|
+
event.type === "heartbeat"
|
|
1593
|
+
? [
|
|
1594
|
+
...(existing.recentHeartbeatEventIds ??
|
|
1595
|
+
(existing.lastHeartbeatEventId
|
|
1596
|
+
? [existing.lastHeartbeatEventId]
|
|
1597
|
+
: [])),
|
|
1598
|
+
event.eventId,
|
|
1599
|
+
].slice(-8)
|
|
1600
|
+
: undefined;
|
|
1302
1601
|
const updates = {
|
|
1303
1602
|
visitorId: event.visitorId,
|
|
1304
1603
|
path: event.path,
|
|
1305
1604
|
lastSeenAt: Math.max(existing.lastSeenAt, event.timestamp),
|
|
1306
1605
|
expiresAt: deadline,
|
|
1307
|
-
...(
|
|
1308
|
-
...(
|
|
1309
|
-
...(
|
|
1310
|
-
...(
|
|
1606
|
+
...(country ? { country } : {}),
|
|
1607
|
+
...(city ? { city } : {}),
|
|
1608
|
+
...(latitude !== undefined ? { latitude } : {}),
|
|
1609
|
+
...(longitude !== undefined ? { longitude } : {}),
|
|
1311
1610
|
browser: context.browser ?? existing.browser,
|
|
1312
1611
|
os: context.os ?? existing.os,
|
|
1313
1612
|
source: session.source,
|
|
1314
1613
|
device: context.device ?? existing.device,
|
|
1315
1614
|
pageviewCount: session.pageviewCount,
|
|
1316
1615
|
eventCount: session.eventCount,
|
|
1616
|
+
// The live map animates a visitor from the route they left to the one
|
|
1617
|
+
// they reached; a heartbeat or click on the same page is not a move.
|
|
1618
|
+
...(event.type === "pageview" && event.path !== existing.path
|
|
1619
|
+
? { previousPath: existing.path, movedAt: event.timestamp }
|
|
1620
|
+
: {}),
|
|
1317
1621
|
...(event.type === "heartbeat"
|
|
1318
1622
|
? {
|
|
1319
1623
|
lastHeartbeatEventId: event.eventId,
|
|
@@ -1338,10 +1642,10 @@ async function updateLiveSession(
|
|
|
1338
1642
|
startedAt: event.timestamp,
|
|
1339
1643
|
lastSeenAt: event.timestamp,
|
|
1340
1644
|
expiresAt: deadline,
|
|
1341
|
-
...(
|
|
1342
|
-
...(
|
|
1343
|
-
...(
|
|
1344
|
-
...(
|
|
1645
|
+
...(country ? { country } : {}),
|
|
1646
|
+
...(city ? { city } : {}),
|
|
1647
|
+
...(latitude !== undefined ? { latitude } : {}),
|
|
1648
|
+
...(longitude !== undefined ? { longitude } : {}),
|
|
1345
1649
|
browser: context.browser ?? "Other",
|
|
1346
1650
|
os: context.os ?? "Other",
|
|
1347
1651
|
source: session.source,
|
|
@@ -1376,9 +1680,13 @@ async function ensureLiveSweep(
|
|
|
1376
1680
|
.query("liveSweepStates")
|
|
1377
1681
|
.withIndex("by_siteId", (range) => range.eq("siteId", siteId))
|
|
1378
1682
|
.unique();
|
|
1379
|
-
if (!existing) {
|
|
1683
|
+
if (!existing || existing.scheduledAt <= now) {
|
|
1380
1684
|
const scheduledAt = now + LIVE_SWEEP_INTERVAL_MS;
|
|
1381
|
-
|
|
1685
|
+
if (existing) {
|
|
1686
|
+
await ctx.db.patch("liveSweepStates", existing._id, { scheduledAt });
|
|
1687
|
+
} else {
|
|
1688
|
+
await ctx.db.insert("liveSweepStates", { siteId, scheduledAt });
|
|
1689
|
+
}
|
|
1382
1690
|
await ctx.scheduler.runAt(scheduledAt, internal.live.sweep, {
|
|
1383
1691
|
siteId,
|
|
1384
1692
|
scheduledAt,
|
|
@@ -1399,6 +1707,7 @@ async function advanceFunnel(
|
|
|
1399
1707
|
siteId: Id<"sites">,
|
|
1400
1708
|
funnel: Doc<"funnels">,
|
|
1401
1709
|
event: TrackerEvent,
|
|
1710
|
+
reportRollupDeltas: ReportRollupDeltas,
|
|
1402
1711
|
batchState?: BatchState,
|
|
1403
1712
|
) {
|
|
1404
1713
|
const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
|
|
@@ -1446,10 +1755,22 @@ async function advanceFunnel(
|
|
|
1446
1755
|
_id: progressId,
|
|
1447
1756
|
};
|
|
1448
1757
|
}
|
|
1449
|
-
|
|
1758
|
+
foldFunnelStepDelta(
|
|
1759
|
+
reportRollupDeltas,
|
|
1760
|
+
siteId,
|
|
1761
|
+
funnel._id,
|
|
1762
|
+
event,
|
|
1763
|
+
0,
|
|
1764
|
+
false,
|
|
1765
|
+
);
|
|
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 = {
|
|
@@ -1472,7 +1793,15 @@ async function advanceFunnel(
|
|
|
1472
1793
|
completedAt: undefined,
|
|
1473
1794
|
});
|
|
1474
1795
|
}
|
|
1475
|
-
|
|
1796
|
+
foldFunnelStepDelta(
|
|
1797
|
+
reportRollupDeltas,
|
|
1798
|
+
siteId,
|
|
1799
|
+
funnel._id,
|
|
1800
|
+
event,
|
|
1801
|
+
0,
|
|
1802
|
+
false,
|
|
1803
|
+
);
|
|
1804
|
+
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1476
1805
|
return;
|
|
1477
1806
|
}
|
|
1478
1807
|
const nextStep = funnel.steps[progress.currentStep];
|
|
@@ -1491,8 +1820,16 @@ async function advanceFunnel(
|
|
|
1491
1820
|
} else {
|
|
1492
1821
|
await ctx.db.patch("funnelProgress", progress._id, updates);
|
|
1493
1822
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1823
|
+
foldFunnelStepDelta(
|
|
1824
|
+
reportRollupDeltas,
|
|
1825
|
+
siteId,
|
|
1826
|
+
funnel._id,
|
|
1827
|
+
event,
|
|
1828
|
+
progress.currentStep,
|
|
1829
|
+
completed,
|
|
1830
|
+
);
|
|
1831
|
+
foldFunnelRollup(
|
|
1832
|
+
reportRollupDeltas,
|
|
1496
1833
|
siteId,
|
|
1497
1834
|
funnel._id,
|
|
1498
1835
|
event,
|
|
@@ -1510,53 +1847,13 @@ function stepMatches(
|
|
|
1510
1847
|
: event.type === "custom" && event.name === step.value;
|
|
1511
1848
|
}
|
|
1512
1849
|
|
|
1513
|
-
async function incrementFunnelStep(
|
|
1514
|
-
ctx: MutationCtx,
|
|
1515
|
-
siteId: Id<"sites">,
|
|
1516
|
-
funnelId: Id<"funnels">,
|
|
1517
|
-
event: TrackerEvent,
|
|
1518
|
-
step: number,
|
|
1519
|
-
completed: boolean,
|
|
1520
|
-
) {
|
|
1521
|
-
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
1522
|
-
const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
|
|
1523
|
-
const bucket = await ctx.db
|
|
1524
|
-
.query("funnelStepBuckets")
|
|
1525
|
-
.withIndex(
|
|
1526
|
-
"by_siteId_and_funnelId_and_bucketStart_and_step_and_shard",
|
|
1527
|
-
(range) =>
|
|
1528
|
-
range
|
|
1529
|
-
.eq("siteId", siteId)
|
|
1530
|
-
.eq("funnelId", funnelId)
|
|
1531
|
-
.eq("bucketStart", bucketStart)
|
|
1532
|
-
.eq("step", step)
|
|
1533
|
-
.eq("shard", shard),
|
|
1534
|
-
)
|
|
1535
|
-
.unique();
|
|
1536
|
-
if (bucket) {
|
|
1537
|
-
await ctx.db.patch("funnelStepBuckets", bucket._id, {
|
|
1538
|
-
entrants: bucket.entrants + 1,
|
|
1539
|
-
completions: bucket.completions + (completed ? 1 : 0),
|
|
1540
|
-
});
|
|
1541
|
-
} else {
|
|
1542
|
-
await ctx.db.insert("funnelStepBuckets", {
|
|
1543
|
-
siteId,
|
|
1544
|
-
funnelId,
|
|
1545
|
-
bucketStart,
|
|
1546
|
-
step,
|
|
1547
|
-
shard,
|
|
1548
|
-
entrants: 1,
|
|
1549
|
-
completions: completed ? 1 : 0,
|
|
1550
|
-
});
|
|
1551
|
-
}
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
1850
|
async function accountConversion(
|
|
1555
1851
|
ctx: MutationCtx,
|
|
1556
1852
|
siteId: Id<"sites">,
|
|
1557
1853
|
session: SessionState,
|
|
1558
1854
|
event: TrackerEvent,
|
|
1559
1855
|
trustedGoalKey: string | undefined,
|
|
1856
|
+
reportRollupDeltas: ReportRollupDeltas,
|
|
1560
1857
|
) {
|
|
1561
1858
|
const revenueCents = event.revenueCents ?? 0;
|
|
1562
1859
|
const currency = event.currency ?? "USD";
|
|
@@ -1588,6 +1885,15 @@ async function accountConversion(
|
|
|
1588
1885
|
commissionCents,
|
|
1589
1886
|
});
|
|
1590
1887
|
if (commissionAffiliate && attribution) {
|
|
1888
|
+
foldAffiliateConversionRollup(
|
|
1889
|
+
reportRollupDeltas,
|
|
1890
|
+
siteId,
|
|
1891
|
+
event,
|
|
1892
|
+
commissionAffiliate,
|
|
1893
|
+
attribution.affiliateSlug,
|
|
1894
|
+
revenueCents,
|
|
1895
|
+
commissionCents,
|
|
1896
|
+
);
|
|
1591
1897
|
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
1592
1898
|
const shard = stableHash(event.sessionId) % SECONDARY_AGGREGATE_SHARDS;
|
|
1593
1899
|
const stats = await ctx.db
|
|
@@ -1623,6 +1929,292 @@ async function accountConversion(
|
|
|
1623
1929
|
}
|
|
1624
1930
|
}
|
|
1625
1931
|
|
|
1932
|
+
function foldGoalRollup(
|
|
1933
|
+
deltas: ReportRollupDeltas,
|
|
1934
|
+
siteId: Id<"sites">,
|
|
1935
|
+
event: TrackerEvent,
|
|
1936
|
+
goal: Doc<"goals">,
|
|
1937
|
+
) {
|
|
1938
|
+
const coordinates = reportRollupCoordinates(
|
|
1939
|
+
siteId,
|
|
1940
|
+
String(goal._id),
|
|
1941
|
+
event.timestamp,
|
|
1942
|
+
event.visitorId,
|
|
1943
|
+
);
|
|
1944
|
+
const current = deltas.goals.get(coordinates.key) ?? {
|
|
1945
|
+
siteId,
|
|
1946
|
+
goalId: goal._id,
|
|
1947
|
+
bucketStart: coordinates.bucketStart,
|
|
1948
|
+
shard: coordinates.shard,
|
|
1949
|
+
goalKey: goal.key,
|
|
1950
|
+
completions: 0,
|
|
1951
|
+
valueCents: 0,
|
|
1952
|
+
};
|
|
1953
|
+
current.completions += 1;
|
|
1954
|
+
current.valueCents += goal.valueCents ?? 0;
|
|
1955
|
+
deltas.goals.set(coordinates.key, current);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
function foldFunnelRollup(
|
|
1959
|
+
deltas: ReportRollupDeltas,
|
|
1960
|
+
siteId: Id<"sites">,
|
|
1961
|
+
funnelId: Id<"funnels">,
|
|
1962
|
+
event: TrackerEvent,
|
|
1963
|
+
step: number,
|
|
1964
|
+
completed: boolean,
|
|
1965
|
+
) {
|
|
1966
|
+
const coordinates = reportRollupCoordinates(
|
|
1967
|
+
siteId,
|
|
1968
|
+
String(funnelId),
|
|
1969
|
+
event.timestamp,
|
|
1970
|
+
event.visitorId,
|
|
1971
|
+
);
|
|
1972
|
+
const rollup = deltas.funnels.get(coordinates.key) ?? {
|
|
1973
|
+
siteId,
|
|
1974
|
+
funnelId,
|
|
1975
|
+
bucketStart: coordinates.bucketStart,
|
|
1976
|
+
shard: coordinates.shard,
|
|
1977
|
+
steps: new Map(),
|
|
1978
|
+
};
|
|
1979
|
+
const current = rollup.steps.get(step) ?? {
|
|
1980
|
+
step,
|
|
1981
|
+
entrants: 0,
|
|
1982
|
+
completions: 0,
|
|
1983
|
+
};
|
|
1984
|
+
current.entrants += 1;
|
|
1985
|
+
current.completions += Number(completed);
|
|
1986
|
+
rollup.steps.set(step, current);
|
|
1987
|
+
deltas.funnels.set(coordinates.key, rollup);
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
function foldFunnelStepDelta(
|
|
1991
|
+
deltas: ReportRollupDeltas,
|
|
1992
|
+
siteId: Id<"sites">,
|
|
1993
|
+
funnelId: Id<"funnels">,
|
|
1994
|
+
event: TrackerEvent,
|
|
1995
|
+
step: number,
|
|
1996
|
+
completed: boolean,
|
|
1997
|
+
) {
|
|
1998
|
+
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
1999
|
+
const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
|
|
2000
|
+
const key = `${siteId}:${funnelId}:${bucketStart}:${step}:${shard}`;
|
|
2001
|
+
const current = deltas.funnelSteps.get(key) ?? {
|
|
2002
|
+
siteId,
|
|
2003
|
+
funnelId,
|
|
2004
|
+
bucketStart,
|
|
2005
|
+
step,
|
|
2006
|
+
shard,
|
|
2007
|
+
entrants: 0,
|
|
2008
|
+
completions: 0,
|
|
2009
|
+
};
|
|
2010
|
+
current.entrants += 1;
|
|
2011
|
+
current.completions += Number(completed);
|
|
2012
|
+
deltas.funnelSteps.set(key, current);
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
function foldAffiliateVisitRollup(
|
|
2016
|
+
deltas: ReportRollupDeltas,
|
|
2017
|
+
siteId: Id<"sites">,
|
|
2018
|
+
event: TrackerEvent,
|
|
2019
|
+
attribution: AttributionSnapshot,
|
|
2020
|
+
visitorKey: string,
|
|
2021
|
+
) {
|
|
2022
|
+
const coordinates = reportRollupCoordinates(
|
|
2023
|
+
siteId,
|
|
2024
|
+
String(attribution.affiliateId),
|
|
2025
|
+
event.timestamp,
|
|
2026
|
+
event.visitorId,
|
|
2027
|
+
);
|
|
2028
|
+
const current = deltas.affiliates.get(coordinates.key) ?? {
|
|
2029
|
+
siteId,
|
|
2030
|
+
affiliateId: attribution.affiliateId,
|
|
2031
|
+
bucketStart: coordinates.bucketStart,
|
|
2032
|
+
shard: coordinates.shard,
|
|
2033
|
+
affiliateSlug: attribution.affiliateSlug,
|
|
2034
|
+
visits: 0,
|
|
2035
|
+
visitorKeys: new Set<string>(),
|
|
2036
|
+
conversions: 0,
|
|
2037
|
+
revenueCents: 0,
|
|
2038
|
+
commissionCents: 0,
|
|
2039
|
+
};
|
|
2040
|
+
current.visits += 1;
|
|
2041
|
+
current.visitorKeys.add(visitorKey);
|
|
2042
|
+
deltas.affiliates.set(coordinates.key, current);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
function foldAffiliateConversionRollup(
|
|
2046
|
+
deltas: ReportRollupDeltas,
|
|
2047
|
+
siteId: Id<"sites">,
|
|
2048
|
+
event: TrackerEvent,
|
|
2049
|
+
affiliate: Doc<"affiliates">,
|
|
2050
|
+
affiliateSlug: string,
|
|
2051
|
+
revenueCents: number,
|
|
2052
|
+
commissionCents: number,
|
|
2053
|
+
) {
|
|
2054
|
+
const coordinates = reportRollupCoordinates(
|
|
2055
|
+
siteId,
|
|
2056
|
+
String(affiliate._id),
|
|
2057
|
+
event.timestamp,
|
|
2058
|
+
event.visitorId,
|
|
2059
|
+
);
|
|
2060
|
+
const current = deltas.affiliates.get(coordinates.key) ?? {
|
|
2061
|
+
siteId,
|
|
2062
|
+
affiliateId: affiliate._id,
|
|
2063
|
+
bucketStart: coordinates.bucketStart,
|
|
2064
|
+
shard: coordinates.shard,
|
|
2065
|
+
affiliateSlug,
|
|
2066
|
+
visits: 0,
|
|
2067
|
+
visitorKeys: new Set<string>(),
|
|
2068
|
+
conversions: 0,
|
|
2069
|
+
revenueCents: 0,
|
|
2070
|
+
commissionCents: 0,
|
|
2071
|
+
};
|
|
2072
|
+
current.conversions += 1;
|
|
2073
|
+
current.revenueCents += revenueCents;
|
|
2074
|
+
current.commissionCents += commissionCents;
|
|
2075
|
+
deltas.affiliates.set(coordinates.key, current);
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
function foldVitalRollups(
|
|
2079
|
+
deltas: ReportRollupDeltas,
|
|
2080
|
+
siteId: Id<"sites">,
|
|
2081
|
+
event: TrackerEvent,
|
|
2082
|
+
device: string,
|
|
2083
|
+
) {
|
|
2084
|
+
const metric = event.name as VitalMetric;
|
|
2085
|
+
const value = event.value ?? 0;
|
|
2086
|
+
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
2087
|
+
const shard = stableHash(event.sessionId) % REPORT_ROLLUP_SHARDS;
|
|
2088
|
+
// Each measurement lands twice: on its page (across devices) and on its
|
|
2089
|
+
// device class (across pages), so both breakdowns stay exact and bounded.
|
|
2090
|
+
for (const target of [
|
|
2091
|
+
{ device: VITAL_ALL, page: event.path },
|
|
2092
|
+
{ device, page: VITAL_ALL },
|
|
2093
|
+
]) {
|
|
2094
|
+
const key = JSON.stringify([
|
|
2095
|
+
siteId,
|
|
2096
|
+
bucketStart,
|
|
2097
|
+
target.device,
|
|
2098
|
+
target.page,
|
|
2099
|
+
shard,
|
|
2100
|
+
]);
|
|
2101
|
+
const row = deltas.vitals.get(key) ?? {
|
|
2102
|
+
siteId,
|
|
2103
|
+
bucketStart,
|
|
2104
|
+
device: target.device,
|
|
2105
|
+
page: target.page,
|
|
2106
|
+
shard,
|
|
2107
|
+
metrics: new Map<
|
|
2108
|
+
VitalMetric,
|
|
2109
|
+
{ count: number; sum: number; histogram: number[] }
|
|
2110
|
+
>(),
|
|
2111
|
+
};
|
|
2112
|
+
const entry = row.metrics.get(metric) ?? {
|
|
2113
|
+
count: 0,
|
|
2114
|
+
sum: 0,
|
|
2115
|
+
histogram: emptyVitalHistogram(),
|
|
2116
|
+
};
|
|
2117
|
+
entry.count += 1;
|
|
2118
|
+
entry.sum += value;
|
|
2119
|
+
entry.histogram = addToVitalHistogram(entry.histogram, value);
|
|
2120
|
+
row.metrics.set(metric, entry);
|
|
2121
|
+
deltas.vitals.set(key, row);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
function foldRouteDelta(
|
|
2126
|
+
deltas: ReportRollupDeltas,
|
|
2127
|
+
siteId: Id<"sites">,
|
|
2128
|
+
route: string,
|
|
2129
|
+
timestamp: number,
|
|
2130
|
+
visitorId: string,
|
|
2131
|
+
apply: (delta: RouteRollupDelta) => void,
|
|
2132
|
+
) {
|
|
2133
|
+
const coordinates = reportRollupCoordinates(
|
|
2134
|
+
siteId,
|
|
2135
|
+
`route:${route}`,
|
|
2136
|
+
timestamp,
|
|
2137
|
+
visitorId,
|
|
2138
|
+
);
|
|
2139
|
+
const current = deltas.routes.get(coordinates.key) ?? {
|
|
2140
|
+
siteId,
|
|
2141
|
+
bucketStart: coordinates.bucketStart,
|
|
2142
|
+
shard: coordinates.shard,
|
|
2143
|
+
route,
|
|
2144
|
+
pageviews: 0,
|
|
2145
|
+
entries: 0,
|
|
2146
|
+
continued: 0,
|
|
2147
|
+
dwellMs: 0,
|
|
2148
|
+
dwellSamples: 0,
|
|
2149
|
+
visitorKeys: new Set<string>(),
|
|
2150
|
+
outgoing: new Map<string, number>(),
|
|
2151
|
+
};
|
|
2152
|
+
apply(current);
|
|
2153
|
+
deltas.routes.set(coordinates.key, current);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/**
|
|
2157
|
+
* One pageview feeds the site map three ways: the route it lands on gains a
|
|
2158
|
+
* view (and an entry when it opens the session), the route it leaves gains
|
|
2159
|
+
* the transition and the time spent there, and the session's entry route is
|
|
2160
|
+
* marked as continued the first time its visitor moves on — which is what
|
|
2161
|
+
* lets the report derive bounces and exits without a session-end hook.
|
|
2162
|
+
*/
|
|
2163
|
+
function foldRouteRollups(
|
|
2164
|
+
deltas: ReportRollupDeltas,
|
|
2165
|
+
siteId: Id<"sites">,
|
|
2166
|
+
event: TrackerEvent,
|
|
2167
|
+
session: SessionState,
|
|
2168
|
+
existing: CachedDocument<"sessions"> | null,
|
|
2169
|
+
) {
|
|
2170
|
+
const route = normalizeRoute(event.path);
|
|
2171
|
+
const priorPageviews = existing?.pageviewCount ?? 0;
|
|
2172
|
+
const firstPageview = existing === null || priorPageviews === 0;
|
|
2173
|
+
foldRouteDelta(
|
|
2174
|
+
deltas,
|
|
2175
|
+
siteId,
|
|
2176
|
+
route,
|
|
2177
|
+
event.timestamp,
|
|
2178
|
+
event.visitorId,
|
|
2179
|
+
(delta) => {
|
|
2180
|
+
delta.pageviews += 1;
|
|
2181
|
+
if (firstPageview) delta.entries += 1;
|
|
2182
|
+
delta.visitorKeys.add(session.visitorKey);
|
|
2183
|
+
},
|
|
2184
|
+
);
|
|
2185
|
+
// Out-of-order telemetry keeps its view but cannot say where it came from.
|
|
2186
|
+
if (existing === null || firstPageview) return;
|
|
2187
|
+
if (event.timestamp < existing.lastSeenAt) return;
|
|
2188
|
+
const previous = normalizeRoute(existing.exitPath);
|
|
2189
|
+
const lastPageviewAt = existing.lastPageviewAt;
|
|
2190
|
+
foldRouteDelta(
|
|
2191
|
+
deltas,
|
|
2192
|
+
siteId,
|
|
2193
|
+
previous,
|
|
2194
|
+
event.timestamp,
|
|
2195
|
+
event.visitorId,
|
|
2196
|
+
(delta) => {
|
|
2197
|
+
delta.outgoing.set(route, (delta.outgoing.get(route) ?? 0) + 1);
|
|
2198
|
+
if (lastPageviewAt !== undefined && event.timestamp >= lastPageviewAt) {
|
|
2199
|
+
delta.dwellMs += event.timestamp - lastPageviewAt;
|
|
2200
|
+
delta.dwellSamples += 1;
|
|
2201
|
+
}
|
|
2202
|
+
},
|
|
2203
|
+
);
|
|
2204
|
+
if (priorPageviews === 1) {
|
|
2205
|
+
foldRouteDelta(
|
|
2206
|
+
deltas,
|
|
2207
|
+
siteId,
|
|
2208
|
+
normalizeRoute(existing.entryPath),
|
|
2209
|
+
existing.startedAt,
|
|
2210
|
+
event.visitorId,
|
|
2211
|
+
(delta) => {
|
|
2212
|
+
delta.continued += 1;
|
|
2213
|
+
},
|
|
2214
|
+
);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
|
|
1626
2218
|
function foldAggregates(
|
|
1627
2219
|
aggregateDeltas: AggregateDeltas,
|
|
1628
2220
|
siteId: Id<"sites">,
|
|
@@ -1631,20 +2223,25 @@ function foldAggregates(
|
|
|
1631
2223
|
dimensions: Dimension[],
|
|
1632
2224
|
recordTelemetry: boolean,
|
|
1633
2225
|
financialConversion: boolean,
|
|
2226
|
+
timezone?: string,
|
|
1634
2227
|
) {
|
|
1635
|
-
const revenueCents = financialConversion ? event.revenueCents ?? 0 : 0;
|
|
1636
|
-
const dimensionDeltas = dimensions.map(
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
);
|
|
1643
|
-
for (const granularity of
|
|
1644
|
-
const shard =
|
|
2228
|
+
const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
|
|
2229
|
+
const dimensionDeltas = dimensions.map((dimension): DimensionDelta => ({
|
|
2230
|
+
...dimension,
|
|
2231
|
+
count: Number(recordTelemetry),
|
|
2232
|
+
revenueCents,
|
|
2233
|
+
pageviews: Number(recordTelemetry && event.type === "pageview"),
|
|
2234
|
+
sessions: Number(recordTelemetry && session.isNewSession),
|
|
2235
|
+
}));
|
|
2236
|
+
for (const granularity of aggregateGranularities(timezone)) {
|
|
2237
|
+
const shard =
|
|
2238
|
+
stableHash(event.sessionId) %
|
|
1645
2239
|
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
1646
|
-
const
|
|
1647
|
-
|
|
2240
|
+
const bucketStart = aggregateBucketStart(
|
|
2241
|
+
granularity,
|
|
2242
|
+
event.timestamp,
|
|
2243
|
+
timezone,
|
|
2244
|
+
);
|
|
1648
2245
|
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
1649
2246
|
let delta = aggregateDeltas.get(key);
|
|
1650
2247
|
if (!delta) {
|
|
@@ -1660,9 +2257,13 @@ function foldAggregates(
|
|
|
1660
2257
|
outboundClicks: 0,
|
|
1661
2258
|
sessions: 0,
|
|
1662
2259
|
visitors: 0,
|
|
2260
|
+
visitorSketch: createVisitorSketch(),
|
|
1663
2261
|
conversions: 0,
|
|
1664
2262
|
revenueCents: 0,
|
|
1665
2263
|
dimensions: [],
|
|
2264
|
+
durationMs: 0,
|
|
2265
|
+
continuedSessions: 0,
|
|
2266
|
+
newVisitors: 0,
|
|
1666
2267
|
};
|
|
1667
2268
|
aggregateDeltas.set(key, delta);
|
|
1668
2269
|
}
|
|
@@ -1680,9 +2281,83 @@ function foldAggregates(
|
|
|
1680
2281
|
? Number(session.isNewVisitorHour)
|
|
1681
2282
|
: Number(session.isNewVisitorDay)
|
|
1682
2283
|
: 0;
|
|
2284
|
+
if (recordTelemetry) {
|
|
2285
|
+
delta.visitorSketch = addVisitor(delta.visitorSketch, session.visitorKey);
|
|
2286
|
+
}
|
|
1683
2287
|
delta.conversions += Number(financialConversion);
|
|
1684
2288
|
delta.revenueCents += revenueCents;
|
|
1685
2289
|
delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
|
|
2290
|
+
if (recordTelemetry) {
|
|
2291
|
+
delta.durationMs += session.durationDeltaMs;
|
|
2292
|
+
delta.continuedSessions += Number(session.continued);
|
|
2293
|
+
delta.newVisitors += Number(session.isNewSession && session.isNewVisitor);
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
/**
|
|
2299
|
+
* The buckets an event lands in: UTC hour and UTC day always, plus the
|
|
2300
|
+
* calendar day in the site's timezone when the site keeps one.
|
|
2301
|
+
*/
|
|
2302
|
+
function aggregateGranularities(
|
|
2303
|
+
timezone: string | undefined,
|
|
2304
|
+
): ReadonlyArray<"hour" | "day" | "localDay"> {
|
|
2305
|
+
return timezone ? ["hour", "day", "localDay"] : ["hour", "day"];
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
function aggregateBucketStart(
|
|
2309
|
+
granularity: "hour" | "day" | "localDay",
|
|
2310
|
+
timestamp: number,
|
|
2311
|
+
timezone: string | undefined,
|
|
2312
|
+
): number {
|
|
2313
|
+
if (granularity === "localDay" && timezone) {
|
|
2314
|
+
return localDayStart(timestamp, timezone);
|
|
2315
|
+
}
|
|
2316
|
+
const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
|
|
2317
|
+
return Math.floor(timestamp / interval) * interval;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
/** Folds only session duration growth, for heartbeats. */
|
|
2321
|
+
function foldSessionDuration(
|
|
2322
|
+
aggregateDeltas: AggregateDeltas,
|
|
2323
|
+
siteId: Id<"sites">,
|
|
2324
|
+
event: TrackerEvent,
|
|
2325
|
+
durationDeltaMs: number,
|
|
2326
|
+
timezone?: string,
|
|
2327
|
+
) {
|
|
2328
|
+
if (durationDeltaMs <= 0) return;
|
|
2329
|
+
for (const granularity of aggregateGranularities(timezone)) {
|
|
2330
|
+
const shard =
|
|
2331
|
+
stableHash(event.sessionId) %
|
|
2332
|
+
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
2333
|
+
const bucketStart = aggregateBucketStart(
|
|
2334
|
+
granularity,
|
|
2335
|
+
event.timestamp,
|
|
2336
|
+
timezone,
|
|
2337
|
+
);
|
|
2338
|
+
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
2339
|
+
const delta = aggregateDeltas.get(key) ?? {
|
|
2340
|
+
siteId,
|
|
2341
|
+
granularity,
|
|
2342
|
+
bucketStart,
|
|
2343
|
+
shard,
|
|
2344
|
+
events: 0,
|
|
2345
|
+
pageviews: 0,
|
|
2346
|
+
clicks: 0,
|
|
2347
|
+
customEvents: 0,
|
|
2348
|
+
outboundClicks: 0,
|
|
2349
|
+
sessions: 0,
|
|
2350
|
+
visitors: 0,
|
|
2351
|
+
visitorSketch: createVisitorSketch(),
|
|
2352
|
+
conversions: 0,
|
|
2353
|
+
revenueCents: 0,
|
|
2354
|
+
dimensions: [],
|
|
2355
|
+
durationMs: 0,
|
|
2356
|
+
continuedSessions: 0,
|
|
2357
|
+
newVisitors: 0,
|
|
2358
|
+
};
|
|
2359
|
+
delta.durationMs += durationDeltaMs;
|
|
2360
|
+
aggregateDeltas.set(key, delta);
|
|
1686
2361
|
}
|
|
1687
2362
|
}
|
|
1688
2363
|
|
|
@@ -1705,8 +2380,19 @@ async function flushAggregates(
|
|
|
1705
2380
|
.unique();
|
|
1706
2381
|
const dimensions = mergeDimensions(
|
|
1707
2382
|
bucket?.dimensions ?? [],
|
|
1708
|
-
delta.dimensions
|
|
2383
|
+
delta.dimensions.map((slot) => ({
|
|
2384
|
+
...slot,
|
|
2385
|
+
pageviews: slot.pageviews ?? 0,
|
|
2386
|
+
sessions: slot.sessions ?? 0,
|
|
2387
|
+
})),
|
|
1709
2388
|
);
|
|
2389
|
+
const priorSketch = bucket?.visitorSketch;
|
|
2390
|
+
const visitorSketch = priorSketch
|
|
2391
|
+
? unionVisitorSketches(priorSketch, delta.visitorSketch)
|
|
2392
|
+
: delta.visitorSketch;
|
|
2393
|
+
const visitorSketchComplete = bucket
|
|
2394
|
+
? bucket.visitorSketchComplete === true && priorSketch !== undefined
|
|
2395
|
+
: true;
|
|
1710
2396
|
if (bucket) {
|
|
1711
2397
|
await ctx.db.patch("aggregateBuckets", bucket._id, {
|
|
1712
2398
|
events: bucket.events + delta.events,
|
|
@@ -1716,9 +2402,15 @@ async function flushAggregates(
|
|
|
1716
2402
|
outboundClicks: bucket.outboundClicks + delta.outboundClicks,
|
|
1717
2403
|
sessions: bucket.sessions + delta.sessions,
|
|
1718
2404
|
visitors: bucket.visitors + delta.visitors,
|
|
2405
|
+
visitorSketch,
|
|
2406
|
+
visitorSketchComplete,
|
|
1719
2407
|
conversions: bucket.conversions + delta.conversions,
|
|
1720
2408
|
revenueCents: bucket.revenueCents + delta.revenueCents,
|
|
1721
2409
|
dimensions,
|
|
2410
|
+
durationMs: (bucket.durationMs ?? 0) + delta.durationMs,
|
|
2411
|
+
continuedSessions:
|
|
2412
|
+
(bucket.continuedSessions ?? 0) + delta.continuedSessions,
|
|
2413
|
+
newVisitors: (bucket.newVisitors ?? 0) + delta.newVisitors,
|
|
1722
2414
|
});
|
|
1723
2415
|
} else {
|
|
1724
2416
|
await ctx.db.insert("aggregateBuckets", {
|
|
@@ -1733,9 +2425,14 @@ async function flushAggregates(
|
|
|
1733
2425
|
outboundClicks: delta.outboundClicks,
|
|
1734
2426
|
sessions: delta.sessions,
|
|
1735
2427
|
visitors: delta.visitors,
|
|
2428
|
+
visitorSketch,
|
|
2429
|
+
visitorSketchComplete,
|
|
1736
2430
|
conversions: delta.conversions,
|
|
1737
2431
|
revenueCents: delta.revenueCents,
|
|
1738
2432
|
dimensions,
|
|
2433
|
+
durationMs: delta.durationMs,
|
|
2434
|
+
continuedSessions: delta.continuedSessions,
|
|
2435
|
+
newVisitors: delta.newVisitors,
|
|
1739
2436
|
});
|
|
1740
2437
|
}
|
|
1741
2438
|
}
|
|
@@ -1754,6 +2451,8 @@ function mergeDimensions(
|
|
|
1754
2451
|
if (exact) {
|
|
1755
2452
|
exact.count += addition.count;
|
|
1756
2453
|
exact.revenueCents += addition.revenueCents;
|
|
2454
|
+
exact.pageviews = (exact.pageviews ?? 0) + addition.pageviews;
|
|
2455
|
+
exact.sessions = (exact.sessions ?? 0) + addition.sessions;
|
|
1757
2456
|
continue;
|
|
1758
2457
|
}
|
|
1759
2458
|
|
|
@@ -1761,10 +2460,7 @@ function mergeDimensions(
|
|
|
1761
2460
|
(candidate) => candidate.type === addition.type,
|
|
1762
2461
|
);
|
|
1763
2462
|
if (typeSlots.length < DIMENSION_SLOTS - 1) {
|
|
1764
|
-
const occupied = Array.from(
|
|
1765
|
-
{ length: DIMENSION_SLOTS },
|
|
1766
|
-
() => false,
|
|
1767
|
-
);
|
|
2463
|
+
const occupied = Array.from({ length: DIMENSION_SLOTS }, () => false);
|
|
1768
2464
|
for (const candidate of typeSlots) occupied[candidate.slot] = true;
|
|
1769
2465
|
let slot = stableHash(addition.value) % DIMENSION_SLOTS;
|
|
1770
2466
|
while (occupied[slot]) slot = (slot + 1) % DIMENSION_SLOTS;
|
|
@@ -1774,6 +2470,8 @@ function mergeDimensions(
|
|
|
1774
2470
|
value: addition.value,
|
|
1775
2471
|
count: addition.count,
|
|
1776
2472
|
revenueCents: addition.revenueCents,
|
|
2473
|
+
pageviews: addition.pageviews,
|
|
2474
|
+
sessions: addition.sessions,
|
|
1777
2475
|
});
|
|
1778
2476
|
continue;
|
|
1779
2477
|
}
|
|
@@ -1783,6 +2481,8 @@ function mergeDimensions(
|
|
|
1783
2481
|
if (overflow) {
|
|
1784
2482
|
overflow.count += addition.count;
|
|
1785
2483
|
overflow.revenueCents += addition.revenueCents;
|
|
2484
|
+
overflow.pageviews = (overflow.pageviews ?? 0) + addition.pageviews;
|
|
2485
|
+
overflow.sessions = (overflow.sessions ?? 0) + addition.sessions;
|
|
1786
2486
|
continue;
|
|
1787
2487
|
}
|
|
1788
2488
|
if (addition.count === 0 && addition.revenueCents === 0) continue;
|
|
@@ -1796,6 +2496,8 @@ function mergeDimensions(
|
|
|
1796
2496
|
value: "(other)",
|
|
1797
2497
|
count: addition.count,
|
|
1798
2498
|
revenueCents: addition.revenueCents,
|
|
2499
|
+
pageviews: addition.pageviews,
|
|
2500
|
+
sessions: addition.sessions,
|
|
1799
2501
|
});
|
|
1800
2502
|
continue;
|
|
1801
2503
|
}
|
|
@@ -1807,6 +2509,8 @@ function mergeDimensions(
|
|
|
1807
2509
|
replacement.value = "(other)";
|
|
1808
2510
|
replacement.count += addition.count;
|
|
1809
2511
|
replacement.revenueCents += addition.revenueCents;
|
|
2512
|
+
replacement.pageviews = (replacement.pageviews ?? 0) + addition.pageviews;
|
|
2513
|
+
replacement.sessions = (replacement.sessions ?? 0) + addition.sessions;
|
|
1810
2514
|
}
|
|
1811
2515
|
return result;
|
|
1812
2516
|
}
|
|
@@ -1816,5 +2520,7 @@ function stringProperty(
|
|
|
1816
2520
|
key: string,
|
|
1817
2521
|
): string | undefined {
|
|
1818
2522
|
const value = properties?.[key];
|
|
1819
|
-
return typeof value === "string"
|
|
2523
|
+
return typeof value === "string"
|
|
2524
|
+
? cleanString(value, 120) || undefined
|
|
2525
|
+
: undefined;
|
|
1820
2526
|
}
|