@iann29/rastro 0.1.0-alpha.8 → 0.1.0
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 +274 -75
- package/agent/integration.md +223 -65
- package/agent/manifest.json +34 -13
- package/agent/manifest.schema.json +53 -11
- package/dist/client/federation.d.ts +196 -11
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +160 -6
- package/dist/client/federation.js.map +1 -1
- package/dist/client/heat.d.ts +37 -0
- package/dist/client/heat.d.ts.map +1 -0
- package/dist/client/heat.js +43 -0
- package/dist/client/heat.js.map +1 -0
- package/dist/client/index.d.ts +448 -105
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +207 -17
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +6 -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 +53 -16
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/constants.d.ts +5 -1
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +8 -1
- package/dist/component/constants.js.map +1 -1
- package/dist/component/coverage.d.ts +9 -0
- package/dist/component/coverage.d.ts.map +1 -1
- package/dist/component/coverage.js +36 -6
- package/dist/component/coverage.js.map +1 -1
- package/dist/component/eventStore.d.ts +1 -1
- package/dist/component/geo.d.ts +2 -0
- package/dist/component/geo.d.ts.map +1 -1
- package/dist/component/geo.js +56 -19
- package/dist/component/geo.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +49 -27
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts +1 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +252 -305
- package/dist/component/ingest.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 +46 -15
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +193 -38
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.js +26 -8
- 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 +5 -0
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +15 -0
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +349 -15
- package/dist/component/schema.js +25 -0
- 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 +30 -0
- package/dist/component/sites.js.map +1 -1
- package/dist/component/validators.d.ts +41 -20
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +24 -3
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +7 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +7 -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 +24 -2
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/federation-setup.md +104 -27
- package/docs/federation.md +168 -31
- package/docs/upgrading.md +191 -16
- package/llms.txt +9 -7
- package/package.json +2 -1
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +50 -12
- package/src/component/constants.ts +8 -1
- package/src/component/coverage.ts +46 -6
- package/src/component/geo.ts +82 -28
- package/src/component/http.ts +62 -26
- package/src/component/ingest.ts +378 -440
- package/src/component/localTime.ts +167 -0
- package/src/component/reports.ts +261 -39
- package/src/component/retention.ts +25 -7
- package/src/component/rollupStore.ts +799 -0
- package/src/component/rollups.ts +82 -0
- package/src/component/sanitize.ts +14 -0
- package/src/component/schema.ts +35 -0
- package/src/component/sites.ts +32 -0
- package/src/component/validators.ts +28 -1
- package/src/tracker/generated.ts +12 -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,16 +19,11 @@ import {
|
|
|
18
19
|
MAX_BYTES_PER_SESSION_WINDOW,
|
|
19
20
|
MAX_EVENTS_PER_SESSION_WINDOW,
|
|
20
21
|
MAX_FUNNELS_PER_SITE,
|
|
21
|
-
MAX_FUNNEL_STEPS,
|
|
22
22
|
MAX_GOALS_PER_SITE,
|
|
23
|
-
MAX_SITE_MAP_ROUTES_PER_DAY,
|
|
24
23
|
RATE_LIMIT_WINDOW_MS,
|
|
25
24
|
REPORT_ROLLUP_SHARDS,
|
|
26
|
-
ROUTE_TRANSITION_SLOTS,
|
|
27
25
|
SECONDARY_AGGREGATE_SHARDS,
|
|
28
26
|
SITE_INGEST_SHARDS,
|
|
29
|
-
SITE_MAP_OTHER_ROUTE,
|
|
30
|
-
VITAL_PAGE_SLOTS,
|
|
31
27
|
} from "./constants.js";
|
|
32
28
|
import {
|
|
33
29
|
addVisitor,
|
|
@@ -38,10 +34,8 @@ import {
|
|
|
38
34
|
import {
|
|
39
35
|
addToVitalHistogram,
|
|
40
36
|
emptyVitalHistogram,
|
|
41
|
-
mergeVitalHistograms,
|
|
42
37
|
vitalDevice,
|
|
43
38
|
VITAL_ALL,
|
|
44
|
-
VITAL_OTHER_PAGES,
|
|
45
39
|
type VitalMetric,
|
|
46
40
|
} from "./vitals.js";
|
|
47
41
|
import { fail } from "./errors.js";
|
|
@@ -62,6 +56,7 @@ import {
|
|
|
62
56
|
measureBatchBytes,
|
|
63
57
|
normalizeRoute,
|
|
64
58
|
originAllowed,
|
|
59
|
+
publicUrlHost,
|
|
65
60
|
sanitizeContext,
|
|
66
61
|
sanitizeCurrency,
|
|
67
62
|
sanitizeEvent,
|
|
@@ -80,6 +75,14 @@ import {
|
|
|
80
75
|
type IngestContext,
|
|
81
76
|
type TrackerEvent,
|
|
82
77
|
} from "./validators.js";
|
|
78
|
+
import {
|
|
79
|
+
createReportRollupDeltas,
|
|
80
|
+
enqueueReportRollups,
|
|
81
|
+
reportRollupCoordinates,
|
|
82
|
+
type ReportRollupDeltas,
|
|
83
|
+
type RouteRollupDelta,
|
|
84
|
+
} from "./rollupStore.js";
|
|
85
|
+
import { localDayStart, localDayTimezone } from "./localTime.js";
|
|
83
86
|
|
|
84
87
|
type DimensionType =
|
|
85
88
|
| "source"
|
|
@@ -89,13 +92,20 @@ type DimensionType =
|
|
|
89
92
|
| "device"
|
|
90
93
|
| "browser"
|
|
91
94
|
| "event"
|
|
92
|
-
| "goal"
|
|
95
|
+
| "goal"
|
|
96
|
+
| "medium"
|
|
97
|
+
| "outbound";
|
|
93
98
|
|
|
94
99
|
type Dimension = { type: DimensionType; value: string };
|
|
95
|
-
type DimensionDelta = Dimension & {
|
|
100
|
+
type DimensionDelta = Dimension & {
|
|
101
|
+
count: number;
|
|
102
|
+
revenueCents: number;
|
|
103
|
+
pageviews: number;
|
|
104
|
+
sessions: number;
|
|
105
|
+
};
|
|
96
106
|
type AggregateDelta = {
|
|
97
107
|
siteId: Id<"sites">;
|
|
98
|
-
granularity: "hour" | "day";
|
|
108
|
+
granularity: "hour" | "day" | "localDay";
|
|
99
109
|
bucketStart: number;
|
|
100
110
|
shard: number;
|
|
101
111
|
events: number;
|
|
@@ -109,74 +119,11 @@ type AggregateDelta = {
|
|
|
109
119
|
conversions: number;
|
|
110
120
|
revenueCents: number;
|
|
111
121
|
dimensions: Doc<"aggregateBuckets">["dimensions"];
|
|
122
|
+
durationMs: number;
|
|
123
|
+
continuedSessions: number;
|
|
124
|
+
newVisitors: number;
|
|
112
125
|
};
|
|
113
126
|
type AggregateDeltas = Map<string, AggregateDelta>;
|
|
114
|
-
type GoalRollupDelta = {
|
|
115
|
-
siteId: Id<"sites">;
|
|
116
|
-
goalId: Id<"goals">;
|
|
117
|
-
bucketStart: number;
|
|
118
|
-
shard: number;
|
|
119
|
-
goalKey: string;
|
|
120
|
-
completions: number;
|
|
121
|
-
valueCents: number;
|
|
122
|
-
};
|
|
123
|
-
type FunnelRollupDelta = {
|
|
124
|
-
siteId: Id<"sites">;
|
|
125
|
-
funnelId: Id<"funnels">;
|
|
126
|
-
bucketStart: number;
|
|
127
|
-
shard: number;
|
|
128
|
-
steps: Map<
|
|
129
|
-
number,
|
|
130
|
-
{
|
|
131
|
-
step: number;
|
|
132
|
-
entrants: number;
|
|
133
|
-
completions: number;
|
|
134
|
-
}
|
|
135
|
-
>;
|
|
136
|
-
};
|
|
137
|
-
type AffiliateRollupDelta = {
|
|
138
|
-
siteId: Id<"sites">;
|
|
139
|
-
affiliateId: Id<"affiliates">;
|
|
140
|
-
bucketStart: number;
|
|
141
|
-
shard: number;
|
|
142
|
-
affiliateSlug: string;
|
|
143
|
-
visits: number;
|
|
144
|
-
visitorSketch: VisitorSketch;
|
|
145
|
-
conversions: number;
|
|
146
|
-
revenueCents: number;
|
|
147
|
-
commissionCents: number;
|
|
148
|
-
};
|
|
149
|
-
type VitalRollupDelta = {
|
|
150
|
-
siteId: Id<"sites">;
|
|
151
|
-
bucketStart: number;
|
|
152
|
-
device: string;
|
|
153
|
-
page: string;
|
|
154
|
-
shard: number;
|
|
155
|
-
metrics: Map<
|
|
156
|
-
VitalMetric,
|
|
157
|
-
{ count: number; sum: number; histogram: number[] }
|
|
158
|
-
>;
|
|
159
|
-
};
|
|
160
|
-
type RouteRollupDelta = {
|
|
161
|
-
siteId: Id<"sites">;
|
|
162
|
-
bucketStart: number;
|
|
163
|
-
shard: number;
|
|
164
|
-
route: string;
|
|
165
|
-
pageviews: number;
|
|
166
|
-
entries: number;
|
|
167
|
-
continued: number;
|
|
168
|
-
dwellMs: number;
|
|
169
|
-
dwellSamples: number;
|
|
170
|
-
visitorSketch: VisitorSketch;
|
|
171
|
-
outgoing: Map<string, number>;
|
|
172
|
-
};
|
|
173
|
-
type ReportRollupDeltas = {
|
|
174
|
-
goals: Map<string, GoalRollupDelta>;
|
|
175
|
-
funnels: Map<string, FunnelRollupDelta>;
|
|
176
|
-
affiliates: Map<string, AffiliateRollupDelta>;
|
|
177
|
-
vitals: Map<string, VitalRollupDelta>;
|
|
178
|
-
routes: Map<string, RouteRollupDelta>;
|
|
179
|
-
};
|
|
180
127
|
type AttributionSnapshot = {
|
|
181
128
|
affiliateId: Id<"affiliates">;
|
|
182
129
|
affiliateSlug: string;
|
|
@@ -188,10 +135,17 @@ type SessionState = {
|
|
|
188
135
|
isNewSession: boolean;
|
|
189
136
|
isNewVisitorHour: boolean;
|
|
190
137
|
isNewVisitorDay: boolean;
|
|
138
|
+
/** The session is new and its visitor has no earlier session or alias. */
|
|
139
|
+
isNewVisitor: boolean;
|
|
140
|
+
/** How much this event grew the session's duration. */
|
|
141
|
+
durationDeltaMs: number;
|
|
142
|
+
/** This event is the session's second pageview: it did not bounce. */
|
|
143
|
+
continued: boolean;
|
|
191
144
|
startedAt: number;
|
|
192
145
|
source: string;
|
|
193
146
|
visitorKey: string;
|
|
194
147
|
utmCampaign?: string;
|
|
148
|
+
utmMedium?: string;
|
|
195
149
|
country?: string;
|
|
196
150
|
city?: string;
|
|
197
151
|
latitude?: number;
|
|
@@ -202,8 +156,28 @@ type SessionState = {
|
|
|
202
156
|
affiliateSlug?: string;
|
|
203
157
|
affiliateAttributedAt?: number;
|
|
204
158
|
affiliateExpiresAt?: number;
|
|
159
|
+
/** The session's landing path, for the live row. */
|
|
160
|
+
entryPath?: string;
|
|
161
|
+
/** Conversions the session holds after this event, for the live row. */
|
|
162
|
+
conversionCount?: number;
|
|
163
|
+
/** The session's stored first-visit flag; unknown for older rows. */
|
|
164
|
+
newVisitor?: boolean;
|
|
205
165
|
};
|
|
206
166
|
|
|
167
|
+
/** The furthest step of a funnel an event took its visitor to. */
|
|
168
|
+
type FunnelReach = { funnelId: Id<"funnels">; step: number; steps: number };
|
|
169
|
+
|
|
170
|
+
function furtherReach(
|
|
171
|
+
current: FunnelReach | undefined,
|
|
172
|
+
next: FunnelReach | undefined,
|
|
173
|
+
): FunnelReach | undefined {
|
|
174
|
+
if (!next) return current;
|
|
175
|
+
if (!current) return next;
|
|
176
|
+
return next.step / next.steps >= current.step / current.steps
|
|
177
|
+
? next
|
|
178
|
+
: current;
|
|
179
|
+
}
|
|
180
|
+
|
|
207
181
|
type SessionRateWindowState = {
|
|
208
182
|
siteId: Id<"sites">;
|
|
209
183
|
sessionId: string;
|
|
@@ -279,6 +253,7 @@ export const ingestBatch = mutation({
|
|
|
279
253
|
now,
|
|
280
254
|
);
|
|
281
255
|
const context = sanitizeContext(args.context);
|
|
256
|
+
const timezone = localDayTimezone(site.timezone);
|
|
282
257
|
const aggregateDeltas: AggregateDeltas = new Map();
|
|
283
258
|
const reportRollupDeltas = createReportRollupDeltas();
|
|
284
259
|
const sessionRateWindows = new Map<string, SessionRateWindowState>();
|
|
@@ -315,7 +290,12 @@ export const ingestBatch = mutation({
|
|
|
315
290
|
const bucketStart = eventBucketStart(event.timestamp);
|
|
316
291
|
const groupKey = eventBatchKey(event.sessionId, bucketStart);
|
|
317
292
|
requestGroups.add(groupKey);
|
|
318
|
-
if (
|
|
293
|
+
if (
|
|
294
|
+
event.type === "heartbeat" ||
|
|
295
|
+
event.type === "vital" ||
|
|
296
|
+
event.type === "leave"
|
|
297
|
+
)
|
|
298
|
+
continue;
|
|
319
299
|
const eventIds = eventIdsByGroup.get(groupKey) ?? new Set<string>();
|
|
320
300
|
if (eventIds.has(event.eventId)) {
|
|
321
301
|
duplicateInBatch.add(eventIndex);
|
|
@@ -409,7 +389,7 @@ export const ingestBatch = mutation({
|
|
|
409
389
|
rejected += 1;
|
|
410
390
|
continue;
|
|
411
391
|
}
|
|
412
|
-
await accountHeartbeat(
|
|
392
|
+
const durationDeltaMs = await accountHeartbeat(
|
|
413
393
|
ctx,
|
|
414
394
|
args.siteId,
|
|
415
395
|
existingSession,
|
|
@@ -418,6 +398,60 @@ export const ingestBatch = mutation({
|
|
|
418
398
|
now,
|
|
419
399
|
batchState,
|
|
420
400
|
);
|
|
401
|
+
// A heartbeat is presence, not telemetry, but it is what keeps a
|
|
402
|
+
// session's duration honest while the visitor reads: the growth is
|
|
403
|
+
// folded into the buckets the heartbeat landed in.
|
|
404
|
+
foldSessionDuration(
|
|
405
|
+
aggregateDeltas,
|
|
406
|
+
args.siteId,
|
|
407
|
+
event,
|
|
408
|
+
durationDeltaMs,
|
|
409
|
+
timezone,
|
|
410
|
+
);
|
|
411
|
+
accepted += 1;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (event.type === "leave") {
|
|
416
|
+
// A departure only shortens live presence: it stores no telemetry,
|
|
417
|
+
// touches no aggregates, and never extends anything, so a replayed
|
|
418
|
+
// or late leave is inert. The read side filters by expiresAt and the
|
|
419
|
+
// sweep deletes the row, so the dashboard sees the exit in seconds.
|
|
420
|
+
if (!existingSession) {
|
|
421
|
+
rejected += 1;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (
|
|
425
|
+
!(await consumeRateLimit(
|
|
426
|
+
ctx,
|
|
427
|
+
args.siteId,
|
|
428
|
+
event.sessionId,
|
|
429
|
+
eventByteCounts[eventIndex],
|
|
430
|
+
now,
|
|
431
|
+
sessionRateWindows,
|
|
432
|
+
))
|
|
433
|
+
) {
|
|
434
|
+
rejected += 1;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const liveState = await loadLiveSession(
|
|
438
|
+
ctx,
|
|
439
|
+
args.siteId,
|
|
440
|
+
event.sessionId,
|
|
441
|
+
batchState,
|
|
442
|
+
);
|
|
443
|
+
const liveSession = liveState.current;
|
|
444
|
+
if (liveSession) {
|
|
445
|
+
const expiresAt = Math.min(
|
|
446
|
+
liveSession.expiresAt,
|
|
447
|
+
now + LIVE_LEAVE_GRACE_MS,
|
|
448
|
+
);
|
|
449
|
+
if (expiresAt < liveSession.expiresAt) {
|
|
450
|
+
await ctx.db.patch("liveSessions", liveSession._id, { expiresAt });
|
|
451
|
+
liveState.current = { ...liveSession, expiresAt };
|
|
452
|
+
}
|
|
453
|
+
await ensureLiveSweep(ctx, args.siteId, now, batchState);
|
|
454
|
+
}
|
|
421
455
|
accepted += 1;
|
|
422
456
|
continue;
|
|
423
457
|
}
|
|
@@ -501,6 +535,7 @@ export const ingestBatch = mutation({
|
|
|
501
535
|
aggregateDeltas,
|
|
502
536
|
reportRollupDeltas,
|
|
503
537
|
recordTelemetry: true,
|
|
538
|
+
timezone,
|
|
504
539
|
financialConversion: false,
|
|
505
540
|
trustedGoalKey: undefined,
|
|
506
541
|
now,
|
|
@@ -517,7 +552,7 @@ export const ingestBatch = mutation({
|
|
|
517
552
|
await flushBatchState(ctx, batchState);
|
|
518
553
|
await flushSessionRateLimits(ctx, sessionRateWindows, now);
|
|
519
554
|
await flushAggregates(ctx, aggregateDeltas);
|
|
520
|
-
await
|
|
555
|
+
await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
|
|
521
556
|
return { accepted, duplicates, rejected };
|
|
522
557
|
},
|
|
523
558
|
});
|
|
@@ -630,6 +665,7 @@ export const trackConversion = mutation({
|
|
|
630
665
|
aggregateDeltas,
|
|
631
666
|
reportRollupDeltas,
|
|
632
667
|
recordTelemetry: existingTelemetry === null && !telemetry.saturated,
|
|
668
|
+
timezone: localDayTimezone(site.timezone),
|
|
633
669
|
financialConversion: true,
|
|
634
670
|
trustedGoalKey,
|
|
635
671
|
now: Date.now(),
|
|
@@ -643,8 +679,9 @@ export const trackConversion = mutation({
|
|
|
643
679
|
);
|
|
644
680
|
}
|
|
645
681
|
await flushAggregates(ctx, aggregateDeltas);
|
|
646
|
-
|
|
647
|
-
await
|
|
682
|
+
const now = Date.now();
|
|
683
|
+
await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
|
|
684
|
+
await ensureAnalyticsControl(ctx, args.siteId, now);
|
|
648
685
|
return { accepted: 1, duplicates: 0, rejected: 0 };
|
|
649
686
|
},
|
|
650
687
|
});
|
|
@@ -765,6 +802,8 @@ async function accountEvent(
|
|
|
765
802
|
aggregateDeltas: AggregateDeltas;
|
|
766
803
|
reportRollupDeltas: ReportRollupDeltas;
|
|
767
804
|
recordTelemetry: boolean;
|
|
805
|
+
/** The site's timezone when it keeps local-day buckets. */
|
|
806
|
+
timezone?: string;
|
|
768
807
|
financialConversion: boolean;
|
|
769
808
|
trustedGoalKey: string | undefined;
|
|
770
809
|
now: number;
|
|
@@ -855,15 +894,19 @@ async function accountEvent(
|
|
|
855
894
|
matchedGoalKeys.push(goal.key);
|
|
856
895
|
}
|
|
857
896
|
|
|
897
|
+
let reach: FunnelReach | undefined;
|
|
858
898
|
for (const funnel of input.funnels) {
|
|
859
899
|
if (funnel.active) {
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
900
|
+
reach = furtherReach(
|
|
901
|
+
reach,
|
|
902
|
+
await advanceFunnel(
|
|
903
|
+
ctx,
|
|
904
|
+
input.siteId,
|
|
905
|
+
funnel,
|
|
906
|
+
event,
|
|
907
|
+
input.reportRollupDeltas,
|
|
908
|
+
input.batchState,
|
|
909
|
+
),
|
|
867
910
|
);
|
|
868
911
|
}
|
|
869
912
|
}
|
|
@@ -879,12 +922,26 @@ async function accountEvent(
|
|
|
879
922
|
);
|
|
880
923
|
}
|
|
881
924
|
|
|
882
|
-
const campaignDimensions: Dimension[] =
|
|
883
|
-
|
|
884
|
-
:
|
|
925
|
+
const campaignDimensions: Dimension[] = [];
|
|
926
|
+
if (session.utmCampaign) {
|
|
927
|
+
campaignDimensions.push({ type: "campaign", value: session.utmCampaign });
|
|
928
|
+
}
|
|
929
|
+
if (session.utmMedium) {
|
|
930
|
+
// A medium is a taxonomy ("social", "cpc", "email"), so its case is noise.
|
|
931
|
+
campaignDimensions.push({
|
|
932
|
+
type: "medium",
|
|
933
|
+
value: session.utmMedium.toLowerCase(),
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
// An outbound click ranks by where it went: the host alone, never the path.
|
|
937
|
+
const outboundHost =
|
|
938
|
+
event.type === "outbound" ? publicUrlHost(event.href) : undefined;
|
|
885
939
|
const dimensions: Dimension[] = [
|
|
886
940
|
{ type: "source", value: session.source },
|
|
887
941
|
...campaignDimensions,
|
|
942
|
+
...(outboundHost
|
|
943
|
+
? [{ type: "outbound", value: outboundHost } satisfies Dimension]
|
|
944
|
+
: []),
|
|
888
945
|
{ type: "page", value: event.path },
|
|
889
946
|
{ type: "country", value: session.country ?? "unknown" },
|
|
890
947
|
{ type: "device", value: input.context.device ?? "unknown" },
|
|
@@ -903,6 +960,7 @@ async function accountEvent(
|
|
|
903
960
|
dimensions,
|
|
904
961
|
input.recordTelemetry,
|
|
905
962
|
input.financialConversion,
|
|
963
|
+
input.timezone,
|
|
906
964
|
);
|
|
907
965
|
|
|
908
966
|
if (
|
|
@@ -917,6 +975,7 @@ async function accountEvent(
|
|
|
917
975
|
session,
|
|
918
976
|
input.now,
|
|
919
977
|
input.batchState,
|
|
978
|
+
reach,
|
|
920
979
|
);
|
|
921
980
|
}
|
|
922
981
|
return session;
|
|
@@ -940,9 +999,16 @@ async function accountTrustedSession(
|
|
|
940
999
|
isNewSession: false,
|
|
941
1000
|
isNewVisitorHour: false,
|
|
942
1001
|
isNewVisitorDay: false,
|
|
1002
|
+
isNewVisitor: false,
|
|
1003
|
+
durationDeltaMs: 0,
|
|
1004
|
+
continued: false,
|
|
943
1005
|
startedAt: existing.startedAt,
|
|
944
1006
|
source: existing.source,
|
|
945
1007
|
utmCampaign: existing.utmCampaign,
|
|
1008
|
+
utmMedium: existing.utmMedium,
|
|
1009
|
+
entryPath: existing.entryPath,
|
|
1010
|
+
conversionCount: existing.conversionCount + 1,
|
|
1011
|
+
newVisitor: existing.newVisitor,
|
|
946
1012
|
visitorKey: existing.visitorKey ?? existing.visitorId,
|
|
947
1013
|
country: existing.country,
|
|
948
1014
|
city: existing.city,
|
|
@@ -1054,9 +1120,16 @@ async function upsertSession(
|
|
|
1054
1120
|
isNewVisitorDay:
|
|
1055
1121
|
Math.floor(existing.lastSeenAt / DAY_MS) <
|
|
1056
1122
|
Math.floor(event.timestamp / DAY_MS),
|
|
1123
|
+
isNewVisitor: false,
|
|
1124
|
+
durationDeltaMs: Math.max(0, updates.durationMs - existing.durationMs),
|
|
1125
|
+
continued: event.type === "pageview" && existing.pageviewCount === 1,
|
|
1057
1126
|
startedAt,
|
|
1058
1127
|
source: existing.source,
|
|
1059
1128
|
utmCampaign: existing.utmCampaign,
|
|
1129
|
+
utmMedium: existing.utmMedium,
|
|
1130
|
+
entryPath: existing.entryPath,
|
|
1131
|
+
conversionCount: updates.conversionCount,
|
|
1132
|
+
newVisitor: existing.newVisitor,
|
|
1060
1133
|
visitorKey,
|
|
1061
1134
|
country: context.country ?? existing.country,
|
|
1062
1135
|
city: context.city ?? existing.city,
|
|
@@ -1089,6 +1162,19 @@ async function upsertSession(
|
|
|
1089
1162
|
}
|
|
1090
1163
|
}
|
|
1091
1164
|
}
|
|
1165
|
+
// A visitor is new when nothing was ever recorded under their id. A host
|
|
1166
|
+
// id whose anonymous history was linked at sign-up is a return, not a new
|
|
1167
|
+
// person; a browser without a persistent id is new every session.
|
|
1168
|
+
let isNewVisitor = priorVisit === null;
|
|
1169
|
+
if (isNewVisitor && event.visitorId !== event.sessionId) {
|
|
1170
|
+
const alias = await ctx.db
|
|
1171
|
+
.query("visitorAliases")
|
|
1172
|
+
.withIndex("by_siteId_and_visitorId", (range) =>
|
|
1173
|
+
range.eq("siteId", siteId).eq("visitorId", event.visitorId),
|
|
1174
|
+
)
|
|
1175
|
+
.first();
|
|
1176
|
+
if (alias) isNewVisitor = false;
|
|
1177
|
+
}
|
|
1092
1178
|
const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
|
|
1093
1179
|
siteId,
|
|
1094
1180
|
sessionId: event.sessionId,
|
|
@@ -1098,6 +1184,7 @@ async function upsertSession(
|
|
|
1098
1184
|
lastSeenAt: event.timestamp,
|
|
1099
1185
|
entryPath: event.path,
|
|
1100
1186
|
exitPath: event.path,
|
|
1187
|
+
newVisitor: isNewVisitor,
|
|
1101
1188
|
...(event.referrer ? { referrer: event.referrer } : {}),
|
|
1102
1189
|
source,
|
|
1103
1190
|
...(utmSource ? { utmSource } : {}),
|
|
@@ -1151,9 +1238,16 @@ async function upsertSession(
|
|
|
1151
1238
|
priorVisit === null ||
|
|
1152
1239
|
Math.floor(priorVisit.lastSeenAt / DAY_MS) <
|
|
1153
1240
|
Math.floor(event.timestamp / DAY_MS),
|
|
1241
|
+
isNewVisitor,
|
|
1242
|
+
durationDeltaMs: 0,
|
|
1243
|
+
continued: false,
|
|
1154
1244
|
startedAt: event.timestamp,
|
|
1155
1245
|
source,
|
|
1156
1246
|
utmCampaign,
|
|
1247
|
+
utmMedium,
|
|
1248
|
+
entryPath: event.path,
|
|
1249
|
+
conversionCount: Number(financialConversion),
|
|
1250
|
+
newVisitor: isNewVisitor,
|
|
1157
1251
|
visitorKey,
|
|
1158
1252
|
country: context.country,
|
|
1159
1253
|
city: context.city,
|
|
@@ -1311,7 +1405,7 @@ async function accountHeartbeat(
|
|
|
1311
1405
|
context: IngestContext,
|
|
1312
1406
|
now: number,
|
|
1313
1407
|
batchState?: BatchState,
|
|
1314
|
-
) {
|
|
1408
|
+
): Promise<number> {
|
|
1315
1409
|
const startedAt = Math.min(session.startedAt, event.timestamp);
|
|
1316
1410
|
const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
|
|
1317
1411
|
const updates = {
|
|
@@ -1338,6 +1432,7 @@ async function accountHeartbeat(
|
|
|
1338
1432
|
} else {
|
|
1339
1433
|
await ctx.db.patch("sessions", session._id, updates);
|
|
1340
1434
|
}
|
|
1435
|
+
const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
|
|
1341
1436
|
await updateLiveSession(
|
|
1342
1437
|
ctx,
|
|
1343
1438
|
siteId,
|
|
@@ -1347,6 +1442,9 @@ async function accountHeartbeat(
|
|
|
1347
1442
|
isNewSession: false,
|
|
1348
1443
|
isNewVisitorHour: false,
|
|
1349
1444
|
isNewVisitorDay: false,
|
|
1445
|
+
isNewVisitor: false,
|
|
1446
|
+
durationDeltaMs: 0,
|
|
1447
|
+
continued: false,
|
|
1350
1448
|
startedAt,
|
|
1351
1449
|
source: session.source,
|
|
1352
1450
|
visitorKey: session.visitorKey ?? session.visitorId,
|
|
@@ -1357,10 +1455,14 @@ async function accountHeartbeat(
|
|
|
1357
1455
|
pageviewCount: session.pageviewCount,
|
|
1358
1456
|
eventCount: session.eventCount,
|
|
1359
1457
|
affiliateSlug: session.affiliateSlug,
|
|
1458
|
+
entryPath: session.entryPath,
|
|
1459
|
+
conversionCount: session.conversionCount,
|
|
1460
|
+
newVisitor: session.newVisitor,
|
|
1360
1461
|
},
|
|
1361
1462
|
now,
|
|
1362
1463
|
batchState,
|
|
1363
1464
|
);
|
|
1465
|
+
return durationDeltaMs;
|
|
1364
1466
|
}
|
|
1365
1467
|
|
|
1366
1468
|
async function consumeSiteRateLimit(
|
|
@@ -1518,6 +1620,26 @@ async function flushSessionRateLimits(
|
|
|
1518
1620
|
}
|
|
1519
1621
|
}
|
|
1520
1622
|
|
|
1623
|
+
/**
|
|
1624
|
+
* The intent facts a live row carries for the dashboard's heat score. A
|
|
1625
|
+
* session row from before 0.1.0 has no first-visit flag, so `returning` stays
|
|
1626
|
+
* unknown rather than guessed.
|
|
1627
|
+
*/
|
|
1628
|
+
function liveIntentFields(session: SessionState, funnel?: FunnelReach) {
|
|
1629
|
+
return {
|
|
1630
|
+
...(session.entryPath ? { entryPath: session.entryPath } : {}),
|
|
1631
|
+
...(session.newVisitor !== undefined
|
|
1632
|
+
? { returning: !session.newVisitor }
|
|
1633
|
+
: {}),
|
|
1634
|
+
...(session.utmCampaign ? { utmCampaign: session.utmCampaign } : {}),
|
|
1635
|
+
...(session.affiliateSlug ? { affiliateSlug: session.affiliateSlug } : {}),
|
|
1636
|
+
...(session.conversionCount !== undefined
|
|
1637
|
+
? { conversionCount: session.conversionCount }
|
|
1638
|
+
: {}),
|
|
1639
|
+
...(funnel ? { funnel } : {}),
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1521
1643
|
async function updateLiveSession(
|
|
1522
1644
|
ctx: MutationCtx,
|
|
1523
1645
|
siteId: Id<"sites">,
|
|
@@ -1526,6 +1648,7 @@ async function updateLiveSession(
|
|
|
1526
1648
|
session: SessionState,
|
|
1527
1649
|
now: number,
|
|
1528
1650
|
batchState?: BatchState,
|
|
1651
|
+
reach?: FunnelReach,
|
|
1529
1652
|
) {
|
|
1530
1653
|
await ensureLiveSweep(ctx, siteId, now, batchState);
|
|
1531
1654
|
const liveState = batchState
|
|
@@ -1570,6 +1693,7 @@ async function updateLiveSession(
|
|
|
1570
1693
|
device: context.device ?? existing.device,
|
|
1571
1694
|
pageviewCount: session.pageviewCount,
|
|
1572
1695
|
eventCount: session.eventCount,
|
|
1696
|
+
...liveIntentFields(session, furtherReach(existing.funnel, reach)),
|
|
1573
1697
|
// The live map animates a visitor from the route they left to the one
|
|
1574
1698
|
// they reached; a heartbeat or click on the same page is not a move.
|
|
1575
1699
|
...(event.type === "pageview" && event.path !== existing.path
|
|
@@ -1609,6 +1733,7 @@ async function updateLiveSession(
|
|
|
1609
1733
|
device: context.device ?? "unknown",
|
|
1610
1734
|
pageviewCount: session.pageviewCount,
|
|
1611
1735
|
eventCount: session.eventCount,
|
|
1736
|
+
...liveIntentFields(session, reach),
|
|
1612
1737
|
...(event.type === "heartbeat"
|
|
1613
1738
|
? {
|
|
1614
1739
|
lastHeartbeatEventId: event.eventId,
|
|
@@ -1666,7 +1791,7 @@ async function advanceFunnel(
|
|
|
1666
1791
|
event: TrackerEvent,
|
|
1667
1792
|
reportRollupDeltas: ReportRollupDeltas,
|
|
1668
1793
|
batchState?: BatchState,
|
|
1669
|
-
) {
|
|
1794
|
+
): Promise<FunnelReach | undefined> {
|
|
1670
1795
|
const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
|
|
1671
1796
|
let progressState = batchState?.funnelProgress.get(cacheKey);
|
|
1672
1797
|
if (batchState && !progressState) {
|
|
@@ -1712,9 +1837,16 @@ async function advanceFunnel(
|
|
|
1712
1837
|
_id: progressId,
|
|
1713
1838
|
};
|
|
1714
1839
|
}
|
|
1715
|
-
|
|
1840
|
+
foldFunnelStepDelta(
|
|
1841
|
+
reportRollupDeltas,
|
|
1842
|
+
siteId,
|
|
1843
|
+
funnel._id,
|
|
1844
|
+
event,
|
|
1845
|
+
0,
|
|
1846
|
+
false,
|
|
1847
|
+
);
|
|
1716
1848
|
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1717
|
-
return;
|
|
1849
|
+
return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
|
|
1718
1850
|
}
|
|
1719
1851
|
if (
|
|
1720
1852
|
progress.completedAt !== undefined ||
|
|
@@ -1743,9 +1875,16 @@ async function advanceFunnel(
|
|
|
1743
1875
|
completedAt: undefined,
|
|
1744
1876
|
});
|
|
1745
1877
|
}
|
|
1746
|
-
|
|
1878
|
+
foldFunnelStepDelta(
|
|
1879
|
+
reportRollupDeltas,
|
|
1880
|
+
siteId,
|
|
1881
|
+
funnel._id,
|
|
1882
|
+
event,
|
|
1883
|
+
0,
|
|
1884
|
+
false,
|
|
1885
|
+
);
|
|
1747
1886
|
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1748
|
-
return;
|
|
1887
|
+
return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
|
|
1749
1888
|
}
|
|
1750
1889
|
const nextStep = funnel.steps[progress.currentStep];
|
|
1751
1890
|
if (!nextStep || !stepMatches(nextStep, event)) return;
|
|
@@ -1763,8 +1902,8 @@ async function advanceFunnel(
|
|
|
1763
1902
|
} else {
|
|
1764
1903
|
await ctx.db.patch("funnelProgress", progress._id, updates);
|
|
1765
1904
|
}
|
|
1766
|
-
|
|
1767
|
-
|
|
1905
|
+
foldFunnelStepDelta(
|
|
1906
|
+
reportRollupDeltas,
|
|
1768
1907
|
siteId,
|
|
1769
1908
|
funnel._id,
|
|
1770
1909
|
event,
|
|
@@ -1779,6 +1918,11 @@ async function advanceFunnel(
|
|
|
1779
1918
|
progress.currentStep,
|
|
1780
1919
|
completed,
|
|
1781
1920
|
);
|
|
1921
|
+
return {
|
|
1922
|
+
funnelId: funnel._id,
|
|
1923
|
+
step: currentStep,
|
|
1924
|
+
steps: funnel.steps.length,
|
|
1925
|
+
};
|
|
1782
1926
|
}
|
|
1783
1927
|
|
|
1784
1928
|
function stepMatches(
|
|
@@ -1790,47 +1934,6 @@ function stepMatches(
|
|
|
1790
1934
|
: event.type === "custom" && event.name === step.value;
|
|
1791
1935
|
}
|
|
1792
1936
|
|
|
1793
|
-
async function incrementFunnelStep(
|
|
1794
|
-
ctx: MutationCtx,
|
|
1795
|
-
siteId: Id<"sites">,
|
|
1796
|
-
funnelId: Id<"funnels">,
|
|
1797
|
-
event: TrackerEvent,
|
|
1798
|
-
step: number,
|
|
1799
|
-
completed: boolean,
|
|
1800
|
-
) {
|
|
1801
|
-
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
1802
|
-
const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
|
|
1803
|
-
const bucket = await ctx.db
|
|
1804
|
-
.query("funnelStepBuckets")
|
|
1805
|
-
.withIndex(
|
|
1806
|
-
"by_siteId_and_funnelId_and_bucketStart_and_step_and_shard",
|
|
1807
|
-
(range) =>
|
|
1808
|
-
range
|
|
1809
|
-
.eq("siteId", siteId)
|
|
1810
|
-
.eq("funnelId", funnelId)
|
|
1811
|
-
.eq("bucketStart", bucketStart)
|
|
1812
|
-
.eq("step", step)
|
|
1813
|
-
.eq("shard", shard),
|
|
1814
|
-
)
|
|
1815
|
-
.unique();
|
|
1816
|
-
if (bucket) {
|
|
1817
|
-
await ctx.db.patch("funnelStepBuckets", bucket._id, {
|
|
1818
|
-
entrants: bucket.entrants + 1,
|
|
1819
|
-
completions: bucket.completions + (completed ? 1 : 0),
|
|
1820
|
-
});
|
|
1821
|
-
} else {
|
|
1822
|
-
await ctx.db.insert("funnelStepBuckets", {
|
|
1823
|
-
siteId,
|
|
1824
|
-
funnelId,
|
|
1825
|
-
bucketStart,
|
|
1826
|
-
step,
|
|
1827
|
-
shard,
|
|
1828
|
-
entrants: 1,
|
|
1829
|
-
completions: completed ? 1 : 0,
|
|
1830
|
-
});
|
|
1831
|
-
}
|
|
1832
|
-
}
|
|
1833
|
-
|
|
1834
1937
|
async function accountConversion(
|
|
1835
1938
|
ctx: MutationCtx,
|
|
1836
1939
|
siteId: Id<"sites">,
|
|
@@ -1913,31 +2016,6 @@ async function accountConversion(
|
|
|
1913
2016
|
}
|
|
1914
2017
|
}
|
|
1915
2018
|
|
|
1916
|
-
function createReportRollupDeltas(): ReportRollupDeltas {
|
|
1917
|
-
return {
|
|
1918
|
-
goals: new Map(),
|
|
1919
|
-
funnels: new Map(),
|
|
1920
|
-
affiliates: new Map(),
|
|
1921
|
-
vitals: new Map(),
|
|
1922
|
-
routes: new Map(),
|
|
1923
|
-
};
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
function reportRollupCoordinates(
|
|
1927
|
-
siteId: Id<"sites">,
|
|
1928
|
-
definitionId: string,
|
|
1929
|
-
timestamp: number,
|
|
1930
|
-
visitorId: string,
|
|
1931
|
-
) {
|
|
1932
|
-
const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
|
|
1933
|
-
const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
|
|
1934
|
-
return {
|
|
1935
|
-
bucketStart,
|
|
1936
|
-
shard,
|
|
1937
|
-
key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
|
|
1938
|
-
};
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
2019
|
function foldGoalRollup(
|
|
1942
2020
|
deltas: ReportRollupDeltas,
|
|
1943
2021
|
siteId: Id<"sites">,
|
|
@@ -1996,6 +2074,31 @@ function foldFunnelRollup(
|
|
|
1996
2074
|
deltas.funnels.set(coordinates.key, rollup);
|
|
1997
2075
|
}
|
|
1998
2076
|
|
|
2077
|
+
function foldFunnelStepDelta(
|
|
2078
|
+
deltas: ReportRollupDeltas,
|
|
2079
|
+
siteId: Id<"sites">,
|
|
2080
|
+
funnelId: Id<"funnels">,
|
|
2081
|
+
event: TrackerEvent,
|
|
2082
|
+
step: number,
|
|
2083
|
+
completed: boolean,
|
|
2084
|
+
) {
|
|
2085
|
+
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
2086
|
+
const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
|
|
2087
|
+
const key = `${siteId}:${funnelId}:${bucketStart}:${step}:${shard}`;
|
|
2088
|
+
const current = deltas.funnelSteps.get(key) ?? {
|
|
2089
|
+
siteId,
|
|
2090
|
+
funnelId,
|
|
2091
|
+
bucketStart,
|
|
2092
|
+
step,
|
|
2093
|
+
shard,
|
|
2094
|
+
entrants: 0,
|
|
2095
|
+
completions: 0,
|
|
2096
|
+
};
|
|
2097
|
+
current.entrants += 1;
|
|
2098
|
+
current.completions += Number(completed);
|
|
2099
|
+
deltas.funnelSteps.set(key, current);
|
|
2100
|
+
}
|
|
2101
|
+
|
|
1999
2102
|
function foldAffiliateVisitRollup(
|
|
2000
2103
|
deltas: ReportRollupDeltas,
|
|
2001
2104
|
siteId: Id<"sites">,
|
|
@@ -2016,13 +2119,13 @@ function foldAffiliateVisitRollup(
|
|
|
2016
2119
|
shard: coordinates.shard,
|
|
2017
2120
|
affiliateSlug: attribution.affiliateSlug,
|
|
2018
2121
|
visits: 0,
|
|
2019
|
-
|
|
2122
|
+
visitorKeys: new Set<string>(),
|
|
2020
2123
|
conversions: 0,
|
|
2021
2124
|
revenueCents: 0,
|
|
2022
2125
|
commissionCents: 0,
|
|
2023
2126
|
};
|
|
2024
2127
|
current.visits += 1;
|
|
2025
|
-
current.
|
|
2128
|
+
current.visitorKeys.add(visitorKey);
|
|
2026
2129
|
deltas.affiliates.set(coordinates.key, current);
|
|
2027
2130
|
}
|
|
2028
2131
|
|
|
@@ -2048,7 +2151,7 @@ function foldAffiliateConversionRollup(
|
|
|
2048
2151
|
shard: coordinates.shard,
|
|
2049
2152
|
affiliateSlug,
|
|
2050
2153
|
visits: 0,
|
|
2051
|
-
|
|
2154
|
+
visitorKeys: new Set<string>(),
|
|
2052
2155
|
conversions: 0,
|
|
2053
2156
|
revenueCents: 0,
|
|
2054
2157
|
commissionCents: 0,
|
|
@@ -2130,7 +2233,7 @@ function foldRouteDelta(
|
|
|
2130
2233
|
continued: 0,
|
|
2131
2234
|
dwellMs: 0,
|
|
2132
2235
|
dwellSamples: 0,
|
|
2133
|
-
|
|
2236
|
+
visitorKeys: new Set<string>(),
|
|
2134
2237
|
outgoing: new Map<string, number>(),
|
|
2135
2238
|
};
|
|
2136
2239
|
apply(current);
|
|
@@ -2163,7 +2266,7 @@ function foldRouteRollups(
|
|
|
2163
2266
|
(delta) => {
|
|
2164
2267
|
delta.pageviews += 1;
|
|
2165
2268
|
if (firstPageview) delta.entries += 1;
|
|
2166
|
-
delta.
|
|
2269
|
+
delta.visitorKeys.add(session.visitorKey);
|
|
2167
2270
|
},
|
|
2168
2271
|
);
|
|
2169
2272
|
// Out-of-order telemetry keeps its view but cannot say where it came from.
|
|
@@ -2199,272 +2302,6 @@ function foldRouteRollups(
|
|
|
2199
2302
|
}
|
|
2200
2303
|
}
|
|
2201
2304
|
|
|
2202
|
-
async function upsertRouteRollup(
|
|
2203
|
-
ctx: MutationCtx,
|
|
2204
|
-
delta: RouteRollupDelta,
|
|
2205
|
-
dayRowCounts: Map<string, number>,
|
|
2206
|
-
) {
|
|
2207
|
-
const existing = await ctx.db
|
|
2208
|
-
.query("routeDailyRollups")
|
|
2209
|
-
.withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) =>
|
|
2210
|
-
range
|
|
2211
|
-
.eq("siteId", delta.siteId)
|
|
2212
|
-
.eq("route", delta.route)
|
|
2213
|
-
.eq("bucketStart", delta.bucketStart)
|
|
2214
|
-
.eq("shard", delta.shard),
|
|
2215
|
-
)
|
|
2216
|
-
.unique();
|
|
2217
|
-
if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
|
|
2218
|
-
// A new route row spends one bounded read per batch to respect the daily
|
|
2219
|
-
// cap; past it, the day's remaining routes fold into "(other)".
|
|
2220
|
-
const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
|
|
2221
|
-
const dayKey = `${delta.siteId}:${delta.bucketStart}`;
|
|
2222
|
-
let dayRows = dayRowCounts.get(dayKey);
|
|
2223
|
-
if (dayRows === undefined) {
|
|
2224
|
-
dayRows = (
|
|
2225
|
-
await ctx.db
|
|
2226
|
-
.query("routeDailyRollups")
|
|
2227
|
-
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
2228
|
-
range
|
|
2229
|
-
.eq("siteId", delta.siteId)
|
|
2230
|
-
.eq("bucketStart", delta.bucketStart),
|
|
2231
|
-
)
|
|
2232
|
-
.take(capRows + 1)
|
|
2233
|
-
).length;
|
|
2234
|
-
}
|
|
2235
|
-
if (dayRows >= capRows) {
|
|
2236
|
-
await upsertRouteRollup(
|
|
2237
|
-
ctx,
|
|
2238
|
-
{ ...delta, route: SITE_MAP_OTHER_ROUTE },
|
|
2239
|
-
dayRowCounts,
|
|
2240
|
-
);
|
|
2241
|
-
return;
|
|
2242
|
-
}
|
|
2243
|
-
dayRowCounts.set(dayKey, dayRows + 1);
|
|
2244
|
-
}
|
|
2245
|
-
const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
|
|
2246
|
-
for (const [target, count] of delta.outgoing) {
|
|
2247
|
-
let entry = outgoing.find((candidate) => candidate.route === target);
|
|
2248
|
-
if (!entry) {
|
|
2249
|
-
const named = outgoing.filter(
|
|
2250
|
-
(candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE,
|
|
2251
|
-
).length;
|
|
2252
|
-
const route =
|
|
2253
|
-
target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
|
|
2254
|
-
? SITE_MAP_OTHER_ROUTE
|
|
2255
|
-
: target;
|
|
2256
|
-
entry = outgoing.find((candidate) => candidate.route === route);
|
|
2257
|
-
if (!entry) {
|
|
2258
|
-
entry = { route, count: 0 };
|
|
2259
|
-
outgoing.push(entry);
|
|
2260
|
-
}
|
|
2261
|
-
}
|
|
2262
|
-
entry.count += count;
|
|
2263
|
-
}
|
|
2264
|
-
const fields = {
|
|
2265
|
-
siteId: delta.siteId,
|
|
2266
|
-
bucketStart: delta.bucketStart,
|
|
2267
|
-
shard: delta.shard,
|
|
2268
|
-
generation: 1 as const,
|
|
2269
|
-
route: delta.route,
|
|
2270
|
-
pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
|
|
2271
|
-
entries: (existing?.entries ?? 0) + delta.entries,
|
|
2272
|
-
continued: (existing?.continued ?? 0) + delta.continued,
|
|
2273
|
-
dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
|
|
2274
|
-
dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
|
|
2275
|
-
visitorSketch: unionVisitorSketches(
|
|
2276
|
-
existing?.visitorSketch ?? createVisitorSketch(),
|
|
2277
|
-
delta.visitorSketch,
|
|
2278
|
-
),
|
|
2279
|
-
outgoing,
|
|
2280
|
-
};
|
|
2281
|
-
if (existing) {
|
|
2282
|
-
await ctx.db.replace("routeDailyRollups", existing._id, fields);
|
|
2283
|
-
} else {
|
|
2284
|
-
await ctx.db.insert("routeDailyRollups", fields);
|
|
2285
|
-
}
|
|
2286
|
-
}
|
|
2287
|
-
|
|
2288
|
-
async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
|
|
2289
|
-
const existing = await ctx.db
|
|
2290
|
-
.query("vitalRollups")
|
|
2291
|
-
.withIndex("by_key", (range) =>
|
|
2292
|
-
range
|
|
2293
|
-
.eq("siteId", delta.siteId)
|
|
2294
|
-
.eq("bucketStart", delta.bucketStart)
|
|
2295
|
-
.eq("device", delta.device)
|
|
2296
|
-
.eq("page", delta.page)
|
|
2297
|
-
.eq("shard", delta.shard),
|
|
2298
|
-
)
|
|
2299
|
-
.unique();
|
|
2300
|
-
if (
|
|
2301
|
-
!existing &&
|
|
2302
|
-
delta.device === VITAL_ALL &&
|
|
2303
|
-
delta.page !== VITAL_OTHER_PAGES
|
|
2304
|
-
) {
|
|
2305
|
-
// A new page row spends one bounded read to respect the daily page slots;
|
|
2306
|
-
// past the cap, the day's remaining pages fold into "(other)".
|
|
2307
|
-
const pageRows = await ctx.db
|
|
2308
|
-
.query("vitalRollups")
|
|
2309
|
-
.withIndex("by_key", (range) =>
|
|
2310
|
-
range
|
|
2311
|
-
.eq("siteId", delta.siteId)
|
|
2312
|
-
.eq("bucketStart", delta.bucketStart)
|
|
2313
|
-
.eq("device", VITAL_ALL),
|
|
2314
|
-
)
|
|
2315
|
-
.take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
|
|
2316
|
-
if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
|
|
2317
|
-
await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
|
|
2318
|
-
return;
|
|
2319
|
-
}
|
|
2320
|
-
}
|
|
2321
|
-
const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
|
|
2322
|
-
for (const [metric, addition] of delta.metrics) {
|
|
2323
|
-
const entry = metrics.find((candidate) => candidate.metric === metric);
|
|
2324
|
-
if (entry) {
|
|
2325
|
-
entry.count += addition.count;
|
|
2326
|
-
entry.sum += addition.sum;
|
|
2327
|
-
entry.histogram = mergeVitalHistograms(
|
|
2328
|
-
entry.histogram,
|
|
2329
|
-
addition.histogram,
|
|
2330
|
-
);
|
|
2331
|
-
} else {
|
|
2332
|
-
metrics.push({
|
|
2333
|
-
metric,
|
|
2334
|
-
count: addition.count,
|
|
2335
|
-
sum: addition.sum,
|
|
2336
|
-
histogram: [...addition.histogram],
|
|
2337
|
-
});
|
|
2338
|
-
}
|
|
2339
|
-
}
|
|
2340
|
-
const fields = {
|
|
2341
|
-
siteId: delta.siteId,
|
|
2342
|
-
bucketStart: delta.bucketStart,
|
|
2343
|
-
device: delta.device,
|
|
2344
|
-
page: delta.page,
|
|
2345
|
-
shard: delta.shard,
|
|
2346
|
-
metrics,
|
|
2347
|
-
};
|
|
2348
|
-
if (existing) {
|
|
2349
|
-
await ctx.db.replace("vitalRollups", existing._id, fields);
|
|
2350
|
-
} else {
|
|
2351
|
-
await ctx.db.insert("vitalRollups", fields);
|
|
2352
|
-
}
|
|
2353
|
-
}
|
|
2354
|
-
|
|
2355
|
-
async function flushReportRollups(
|
|
2356
|
-
ctx: MutationCtx,
|
|
2357
|
-
deltas: ReportRollupDeltas,
|
|
2358
|
-
) {
|
|
2359
|
-
for (const delta of deltas.goals.values()) {
|
|
2360
|
-
const existing = await ctx.db
|
|
2361
|
-
.query("goalDailyRollups")
|
|
2362
|
-
.withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) =>
|
|
2363
|
-
range
|
|
2364
|
-
.eq("siteId", delta.siteId)
|
|
2365
|
-
.eq("goalId", delta.goalId)
|
|
2366
|
-
.eq("bucketStart", delta.bucketStart)
|
|
2367
|
-
.eq("shard", delta.shard),
|
|
2368
|
-
)
|
|
2369
|
-
.unique();
|
|
2370
|
-
const fields = {
|
|
2371
|
-
siteId: delta.siteId,
|
|
2372
|
-
goalId: delta.goalId,
|
|
2373
|
-
bucketStart: delta.bucketStart,
|
|
2374
|
-
shard: delta.shard,
|
|
2375
|
-
generation: 1 as const,
|
|
2376
|
-
goalKey: delta.goalKey,
|
|
2377
|
-
completions: (existing?.completions ?? 0) + delta.completions,
|
|
2378
|
-
valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
|
|
2379
|
-
};
|
|
2380
|
-
if (existing) {
|
|
2381
|
-
await ctx.db.replace("goalDailyRollups", existing._id, fields);
|
|
2382
|
-
} else {
|
|
2383
|
-
await ctx.db.insert("goalDailyRollups", fields);
|
|
2384
|
-
}
|
|
2385
|
-
}
|
|
2386
|
-
for (const delta of deltas.funnels.values()) {
|
|
2387
|
-
const existing = await ctx.db
|
|
2388
|
-
.query("funnelDailyRollups")
|
|
2389
|
-
.withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) =>
|
|
2390
|
-
range
|
|
2391
|
-
.eq("siteId", delta.siteId)
|
|
2392
|
-
.eq("funnelId", delta.funnelId)
|
|
2393
|
-
.eq("bucketStart", delta.bucketStart)
|
|
2394
|
-
.eq("shard", delta.shard),
|
|
2395
|
-
)
|
|
2396
|
-
.unique();
|
|
2397
|
-
const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
|
|
2398
|
-
for (const addition of delta.steps.values()) {
|
|
2399
|
-
const current = steps.find((step) => step.step === addition.step);
|
|
2400
|
-
if (current) {
|
|
2401
|
-
current.entrants += addition.entrants;
|
|
2402
|
-
current.completions += addition.completions;
|
|
2403
|
-
} else {
|
|
2404
|
-
if (steps.length >= MAX_FUNNEL_STEPS) {
|
|
2405
|
-
fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
|
|
2406
|
-
}
|
|
2407
|
-
steps.push(addition);
|
|
2408
|
-
}
|
|
2409
|
-
}
|
|
2410
|
-
const fields = {
|
|
2411
|
-
siteId: delta.siteId,
|
|
2412
|
-
funnelId: delta.funnelId,
|
|
2413
|
-
bucketStart: delta.bucketStart,
|
|
2414
|
-
shard: delta.shard,
|
|
2415
|
-
generation: 1 as const,
|
|
2416
|
-
steps,
|
|
2417
|
-
};
|
|
2418
|
-
if (existing) {
|
|
2419
|
-
await ctx.db.replace("funnelDailyRollups", existing._id, fields);
|
|
2420
|
-
} else {
|
|
2421
|
-
await ctx.db.insert("funnelDailyRollups", fields);
|
|
2422
|
-
}
|
|
2423
|
-
}
|
|
2424
|
-
for (const delta of deltas.affiliates.values()) {
|
|
2425
|
-
const existing = await ctx.db
|
|
2426
|
-
.query("affiliateDailyRollups")
|
|
2427
|
-
.withIndex(
|
|
2428
|
-
"by_siteId_and_affiliateId_and_bucketStart_and_shard",
|
|
2429
|
-
(range) =>
|
|
2430
|
-
range
|
|
2431
|
-
.eq("siteId", delta.siteId)
|
|
2432
|
-
.eq("affiliateId", delta.affiliateId)
|
|
2433
|
-
.eq("bucketStart", delta.bucketStart)
|
|
2434
|
-
.eq("shard", delta.shard),
|
|
2435
|
-
)
|
|
2436
|
-
.unique();
|
|
2437
|
-
const fields = {
|
|
2438
|
-
siteId: delta.siteId,
|
|
2439
|
-
affiliateId: delta.affiliateId,
|
|
2440
|
-
bucketStart: delta.bucketStart,
|
|
2441
|
-
shard: delta.shard,
|
|
2442
|
-
generation: 1 as const,
|
|
2443
|
-
affiliateSlug: delta.affiliateSlug,
|
|
2444
|
-
visits: (existing?.visits ?? 0) + delta.visits,
|
|
2445
|
-
visitorSketch: unionVisitorSketches(
|
|
2446
|
-
existing?.visitorSketch ?? createVisitorSketch(),
|
|
2447
|
-
delta.visitorSketch,
|
|
2448
|
-
),
|
|
2449
|
-
conversions: (existing?.conversions ?? 0) + delta.conversions,
|
|
2450
|
-
revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
|
|
2451
|
-
commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
|
|
2452
|
-
};
|
|
2453
|
-
if (existing) {
|
|
2454
|
-
await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
|
|
2455
|
-
} else {
|
|
2456
|
-
await ctx.db.insert("affiliateDailyRollups", fields);
|
|
2457
|
-
}
|
|
2458
|
-
}
|
|
2459
|
-
const routeDayRowCounts = new Map<string, number>();
|
|
2460
|
-
for (const delta of deltas.routes.values()) {
|
|
2461
|
-
await upsertRouteRollup(ctx, delta, routeDayRowCounts);
|
|
2462
|
-
}
|
|
2463
|
-
for (const delta of deltas.vitals.values()) {
|
|
2464
|
-
await upsertVitalRollup(ctx, delta);
|
|
2465
|
-
}
|
|
2466
|
-
}
|
|
2467
|
-
|
|
2468
2305
|
function foldAggregates(
|
|
2469
2306
|
aggregateDeltas: AggregateDeltas,
|
|
2470
2307
|
siteId: Id<"sites">,
|
|
@@ -2473,19 +2310,25 @@ function foldAggregates(
|
|
|
2473
2310
|
dimensions: Dimension[],
|
|
2474
2311
|
recordTelemetry: boolean,
|
|
2475
2312
|
financialConversion: boolean,
|
|
2313
|
+
timezone?: string,
|
|
2476
2314
|
) {
|
|
2477
2315
|
const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
|
|
2478
2316
|
const dimensionDeltas = dimensions.map((dimension): DimensionDelta => ({
|
|
2479
2317
|
...dimension,
|
|
2480
2318
|
count: Number(recordTelemetry),
|
|
2481
2319
|
revenueCents,
|
|
2320
|
+
pageviews: Number(recordTelemetry && event.type === "pageview"),
|
|
2321
|
+
sessions: Number(recordTelemetry && session.isNewSession),
|
|
2482
2322
|
}));
|
|
2483
|
-
for (const granularity of
|
|
2323
|
+
for (const granularity of aggregateGranularities(timezone)) {
|
|
2484
2324
|
const shard =
|
|
2485
2325
|
stableHash(event.sessionId) %
|
|
2486
2326
|
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
2487
|
-
const
|
|
2488
|
-
|
|
2327
|
+
const bucketStart = aggregateBucketStart(
|
|
2328
|
+
granularity,
|
|
2329
|
+
event.timestamp,
|
|
2330
|
+
timezone,
|
|
2331
|
+
);
|
|
2489
2332
|
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
2490
2333
|
let delta = aggregateDeltas.get(key);
|
|
2491
2334
|
if (!delta) {
|
|
@@ -2505,6 +2348,9 @@ function foldAggregates(
|
|
|
2505
2348
|
conversions: 0,
|
|
2506
2349
|
revenueCents: 0,
|
|
2507
2350
|
dimensions: [],
|
|
2351
|
+
durationMs: 0,
|
|
2352
|
+
continuedSessions: 0,
|
|
2353
|
+
newVisitors: 0,
|
|
2508
2354
|
};
|
|
2509
2355
|
aggregateDeltas.set(key, delta);
|
|
2510
2356
|
}
|
|
@@ -2528,6 +2374,77 @@ function foldAggregates(
|
|
|
2528
2374
|
delta.conversions += Number(financialConversion);
|
|
2529
2375
|
delta.revenueCents += revenueCents;
|
|
2530
2376
|
delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
|
|
2377
|
+
if (recordTelemetry) {
|
|
2378
|
+
delta.durationMs += session.durationDeltaMs;
|
|
2379
|
+
delta.continuedSessions += Number(session.continued);
|
|
2380
|
+
delta.newVisitors += Number(session.isNewSession && session.isNewVisitor);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
/**
|
|
2386
|
+
* The buckets an event lands in: UTC hour and UTC day always, plus the
|
|
2387
|
+
* calendar day in the site's timezone when the site keeps one.
|
|
2388
|
+
*/
|
|
2389
|
+
function aggregateGranularities(
|
|
2390
|
+
timezone: string | undefined,
|
|
2391
|
+
): ReadonlyArray<"hour" | "day" | "localDay"> {
|
|
2392
|
+
return timezone ? ["hour", "day", "localDay"] : ["hour", "day"];
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
function aggregateBucketStart(
|
|
2396
|
+
granularity: "hour" | "day" | "localDay",
|
|
2397
|
+
timestamp: number,
|
|
2398
|
+
timezone: string | undefined,
|
|
2399
|
+
): number {
|
|
2400
|
+
if (granularity === "localDay" && timezone) {
|
|
2401
|
+
return localDayStart(timestamp, timezone);
|
|
2402
|
+
}
|
|
2403
|
+
const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
|
|
2404
|
+
return Math.floor(timestamp / interval) * interval;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
/** Folds only session duration growth, for heartbeats. */
|
|
2408
|
+
function foldSessionDuration(
|
|
2409
|
+
aggregateDeltas: AggregateDeltas,
|
|
2410
|
+
siteId: Id<"sites">,
|
|
2411
|
+
event: TrackerEvent,
|
|
2412
|
+
durationDeltaMs: number,
|
|
2413
|
+
timezone?: string,
|
|
2414
|
+
) {
|
|
2415
|
+
if (durationDeltaMs <= 0) return;
|
|
2416
|
+
for (const granularity of aggregateGranularities(timezone)) {
|
|
2417
|
+
const shard =
|
|
2418
|
+
stableHash(event.sessionId) %
|
|
2419
|
+
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
2420
|
+
const bucketStart = aggregateBucketStart(
|
|
2421
|
+
granularity,
|
|
2422
|
+
event.timestamp,
|
|
2423
|
+
timezone,
|
|
2424
|
+
);
|
|
2425
|
+
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
2426
|
+
const delta = aggregateDeltas.get(key) ?? {
|
|
2427
|
+
siteId,
|
|
2428
|
+
granularity,
|
|
2429
|
+
bucketStart,
|
|
2430
|
+
shard,
|
|
2431
|
+
events: 0,
|
|
2432
|
+
pageviews: 0,
|
|
2433
|
+
clicks: 0,
|
|
2434
|
+
customEvents: 0,
|
|
2435
|
+
outboundClicks: 0,
|
|
2436
|
+
sessions: 0,
|
|
2437
|
+
visitors: 0,
|
|
2438
|
+
visitorSketch: createVisitorSketch(),
|
|
2439
|
+
conversions: 0,
|
|
2440
|
+
revenueCents: 0,
|
|
2441
|
+
dimensions: [],
|
|
2442
|
+
durationMs: 0,
|
|
2443
|
+
continuedSessions: 0,
|
|
2444
|
+
newVisitors: 0,
|
|
2445
|
+
};
|
|
2446
|
+
delta.durationMs += durationDeltaMs;
|
|
2447
|
+
aggregateDeltas.set(key, delta);
|
|
2531
2448
|
}
|
|
2532
2449
|
}
|
|
2533
2450
|
|
|
@@ -2550,7 +2467,11 @@ async function flushAggregates(
|
|
|
2550
2467
|
.unique();
|
|
2551
2468
|
const dimensions = mergeDimensions(
|
|
2552
2469
|
bucket?.dimensions ?? [],
|
|
2553
|
-
delta.dimensions
|
|
2470
|
+
delta.dimensions.map((slot) => ({
|
|
2471
|
+
...slot,
|
|
2472
|
+
pageviews: slot.pageviews ?? 0,
|
|
2473
|
+
sessions: slot.sessions ?? 0,
|
|
2474
|
+
})),
|
|
2554
2475
|
);
|
|
2555
2476
|
const priorSketch = bucket?.visitorSketch;
|
|
2556
2477
|
const visitorSketch = priorSketch
|
|
@@ -2573,6 +2494,10 @@ async function flushAggregates(
|
|
|
2573
2494
|
conversions: bucket.conversions + delta.conversions,
|
|
2574
2495
|
revenueCents: bucket.revenueCents + delta.revenueCents,
|
|
2575
2496
|
dimensions,
|
|
2497
|
+
durationMs: (bucket.durationMs ?? 0) + delta.durationMs,
|
|
2498
|
+
continuedSessions:
|
|
2499
|
+
(bucket.continuedSessions ?? 0) + delta.continuedSessions,
|
|
2500
|
+
newVisitors: (bucket.newVisitors ?? 0) + delta.newVisitors,
|
|
2576
2501
|
});
|
|
2577
2502
|
} else {
|
|
2578
2503
|
await ctx.db.insert("aggregateBuckets", {
|
|
@@ -2592,6 +2517,9 @@ async function flushAggregates(
|
|
|
2592
2517
|
conversions: delta.conversions,
|
|
2593
2518
|
revenueCents: delta.revenueCents,
|
|
2594
2519
|
dimensions,
|
|
2520
|
+
durationMs: delta.durationMs,
|
|
2521
|
+
continuedSessions: delta.continuedSessions,
|
|
2522
|
+
newVisitors: delta.newVisitors,
|
|
2595
2523
|
});
|
|
2596
2524
|
}
|
|
2597
2525
|
}
|
|
@@ -2610,6 +2538,8 @@ function mergeDimensions(
|
|
|
2610
2538
|
if (exact) {
|
|
2611
2539
|
exact.count += addition.count;
|
|
2612
2540
|
exact.revenueCents += addition.revenueCents;
|
|
2541
|
+
exact.pageviews = (exact.pageviews ?? 0) + addition.pageviews;
|
|
2542
|
+
exact.sessions = (exact.sessions ?? 0) + addition.sessions;
|
|
2613
2543
|
continue;
|
|
2614
2544
|
}
|
|
2615
2545
|
|
|
@@ -2627,6 +2557,8 @@ function mergeDimensions(
|
|
|
2627
2557
|
value: addition.value,
|
|
2628
2558
|
count: addition.count,
|
|
2629
2559
|
revenueCents: addition.revenueCents,
|
|
2560
|
+
pageviews: addition.pageviews,
|
|
2561
|
+
sessions: addition.sessions,
|
|
2630
2562
|
});
|
|
2631
2563
|
continue;
|
|
2632
2564
|
}
|
|
@@ -2636,6 +2568,8 @@ function mergeDimensions(
|
|
|
2636
2568
|
if (overflow) {
|
|
2637
2569
|
overflow.count += addition.count;
|
|
2638
2570
|
overflow.revenueCents += addition.revenueCents;
|
|
2571
|
+
overflow.pageviews = (overflow.pageviews ?? 0) + addition.pageviews;
|
|
2572
|
+
overflow.sessions = (overflow.sessions ?? 0) + addition.sessions;
|
|
2639
2573
|
continue;
|
|
2640
2574
|
}
|
|
2641
2575
|
if (addition.count === 0 && addition.revenueCents === 0) continue;
|
|
@@ -2649,6 +2583,8 @@ function mergeDimensions(
|
|
|
2649
2583
|
value: "(other)",
|
|
2650
2584
|
count: addition.count,
|
|
2651
2585
|
revenueCents: addition.revenueCents,
|
|
2586
|
+
pageviews: addition.pageviews,
|
|
2587
|
+
sessions: addition.sessions,
|
|
2652
2588
|
});
|
|
2653
2589
|
continue;
|
|
2654
2590
|
}
|
|
@@ -2660,6 +2596,8 @@ function mergeDimensions(
|
|
|
2660
2596
|
replacement.value = "(other)";
|
|
2661
2597
|
replacement.count += addition.count;
|
|
2662
2598
|
replacement.revenueCents += addition.revenueCents;
|
|
2599
|
+
replacement.pageviews = (replacement.pageviews ?? 0) + addition.pageviews;
|
|
2600
|
+
replacement.sessions = (replacement.sessions ?? 0) + addition.sessions;
|
|
2663
2601
|
}
|
|
2664
2602
|
return result;
|
|
2665
2603
|
}
|