@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/dist/component/ingest.js
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { mutation } from "./_generated/server.js";
|
|
3
3
|
import { internal } from "./_generated/api.js";
|
|
4
|
-
import { AGGREGATE_SHARDS, MAX_BATCH_EVENT_GROUPS, DAY_MS, HOURLY_AGGREGATE_SHARDS, DIMENSION_SLOTS, HOUR_MS, LIVE_SESSION_TTL_MS, LIVE_SWEEP_INTERVAL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE,
|
|
4
|
+
import { AGGREGATE_SHARDS, MAX_BATCH_EVENT_GROUPS, DAY_MS, HOURLY_AGGREGATE_SHARDS, DIMENSION_SLOTS, HOUR_MS, LIVE_LEAVE_GRACE_MS, LIVE_SESSION_TTL_MS, LIVE_SWEEP_INTERVAL_MS, MAX_BATCH_EVENTS, MAX_SITE_BYTES_PER_SHARD_WINDOW, MAX_SITE_EVENTS_PER_SHARD_WINDOW, MAX_BYTES_PER_SESSION_WINDOW, MAX_EVENTS_PER_SESSION_WINDOW, MAX_FUNNELS_PER_SITE, MAX_GOALS_PER_SITE, RATE_LIMIT_WINDOW_MS, REPORT_ROLLUP_SHARDS, SECONDARY_AGGREGATE_SHARDS, SITE_INGEST_SHARDS, } from "./constants.js";
|
|
5
5
|
import { addVisitor, createVisitorSketch, unionVisitorSketches, } from "./cardinality.js";
|
|
6
|
-
import { addToVitalHistogram, emptyVitalHistogram,
|
|
6
|
+
import { addToVitalHistogram, emptyVitalHistogram, vitalDevice, VITAL_ALL, } from "./vitals.js";
|
|
7
7
|
import { fail } from "./errors.js";
|
|
8
8
|
import { ensureAnalyticsControl } from "./coverage.js";
|
|
9
9
|
import { bufferEventBatch, eventBatchKey, eventBucketStart, findTelemetry, flushEventBatches, insertEventBatch, inspectTelemetryMinute, } from "./eventStore.js";
|
|
10
|
-
import { cleanString, isValidMoney, measureBatchBytes, normalizeRoute, originAllowed, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
|
|
10
|
+
import { cleanString, isValidMoney, measureBatchBytes, normalizeRoute, originAllowed, publicUrlHost, sanitizeContext, sanitizeCurrency, sanitizeEvent, sanitizeOpaqueId, sanitizePath, sanitizeProperties, sanitizeSlug, sourceFromReferrer, stableHash, } from "./sanitize.js";
|
|
11
11
|
import { ingestContextValidator, ingestResultValidator, trackerEventValidator, trustedConversionValidator, } from "./validators.js";
|
|
12
|
+
import { createReportRollupDeltas, enqueueReportRollups, reportRollupCoordinates, } from "./rollupStore.js";
|
|
13
|
+
import { localDayStart, localDayTimezone } from "./localTime.js";
|
|
14
|
+
function furtherReach(current, next) {
|
|
15
|
+
if (!next)
|
|
16
|
+
return current;
|
|
17
|
+
if (!current)
|
|
18
|
+
return next;
|
|
19
|
+
return next.step / next.steps >= current.step / current.steps
|
|
20
|
+
? next
|
|
21
|
+
: current;
|
|
22
|
+
}
|
|
12
23
|
export const ingestBatch = mutation({
|
|
13
24
|
args: {
|
|
14
25
|
siteId: v.id("sites"),
|
|
@@ -42,6 +53,7 @@ export const ingestBatch = mutation({
|
|
|
42
53
|
await ensureAnalyticsControl(ctx, args.siteId, now);
|
|
43
54
|
await consumeSiteRateLimit(ctx, args.siteId, args.events, eventByteCounts, now);
|
|
44
55
|
const context = sanitizeContext(args.context);
|
|
56
|
+
const timezone = localDayTimezone(site.timezone);
|
|
45
57
|
const aggregateDeltas = new Map();
|
|
46
58
|
const reportRollupDeltas = createReportRollupDeltas();
|
|
47
59
|
const sessionRateWindows = new Map();
|
|
@@ -77,7 +89,9 @@ export const ingestBatch = mutation({
|
|
|
77
89
|
const bucketStart = eventBucketStart(event.timestamp);
|
|
78
90
|
const groupKey = eventBatchKey(event.sessionId, bucketStart);
|
|
79
91
|
requestGroups.add(groupKey);
|
|
80
|
-
if (event.type === "heartbeat" ||
|
|
92
|
+
if (event.type === "heartbeat" ||
|
|
93
|
+
event.type === "vital" ||
|
|
94
|
+
event.type === "leave")
|
|
81
95
|
continue;
|
|
82
96
|
const eventIds = eventIdsByGroup.get(groupKey) ?? new Set();
|
|
83
97
|
if (eventIds.has(event.eventId)) {
|
|
@@ -141,7 +155,37 @@ export const ingestBatch = mutation({
|
|
|
141
155
|
rejected += 1;
|
|
142
156
|
continue;
|
|
143
157
|
}
|
|
144
|
-
await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now, batchState);
|
|
158
|
+
const durationDeltaMs = await accountHeartbeat(ctx, args.siteId, existingSession, event, context, now, batchState);
|
|
159
|
+
// A heartbeat is presence, not telemetry, but it is what keeps a
|
|
160
|
+
// session's duration honest while the visitor reads: the growth is
|
|
161
|
+
// folded into the buckets the heartbeat landed in.
|
|
162
|
+
foldSessionDuration(aggregateDeltas, args.siteId, event, durationDeltaMs, timezone);
|
|
163
|
+
accepted += 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (event.type === "leave") {
|
|
167
|
+
// A departure only shortens live presence: it stores no telemetry,
|
|
168
|
+
// touches no aggregates, and never extends anything, so a replayed
|
|
169
|
+
// or late leave is inert. The read side filters by expiresAt and the
|
|
170
|
+
// sweep deletes the row, so the dashboard sees the exit in seconds.
|
|
171
|
+
if (!existingSession) {
|
|
172
|
+
rejected += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!(await consumeRateLimit(ctx, args.siteId, event.sessionId, eventByteCounts[eventIndex], now, sessionRateWindows))) {
|
|
176
|
+
rejected += 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const liveState = await loadLiveSession(ctx, args.siteId, event.sessionId, batchState);
|
|
180
|
+
const liveSession = liveState.current;
|
|
181
|
+
if (liveSession) {
|
|
182
|
+
const expiresAt = Math.min(liveSession.expiresAt, now + LIVE_LEAVE_GRACE_MS);
|
|
183
|
+
if (expiresAt < liveSession.expiresAt) {
|
|
184
|
+
await ctx.db.patch("liveSessions", liveSession._id, { expiresAt });
|
|
185
|
+
liveState.current = { ...liveSession, expiresAt };
|
|
186
|
+
}
|
|
187
|
+
await ensureLiveSweep(ctx, args.siteId, now, batchState);
|
|
188
|
+
}
|
|
145
189
|
accepted += 1;
|
|
146
190
|
continue;
|
|
147
191
|
}
|
|
@@ -194,6 +238,7 @@ export const ingestBatch = mutation({
|
|
|
194
238
|
aggregateDeltas,
|
|
195
239
|
reportRollupDeltas,
|
|
196
240
|
recordTelemetry: true,
|
|
241
|
+
timezone,
|
|
197
242
|
financialConversion: false,
|
|
198
243
|
trustedGoalKey: undefined,
|
|
199
244
|
now,
|
|
@@ -205,7 +250,7 @@ export const ingestBatch = mutation({
|
|
|
205
250
|
await flushBatchState(ctx, batchState);
|
|
206
251
|
await flushSessionRateLimits(ctx, sessionRateWindows, now);
|
|
207
252
|
await flushAggregates(ctx, aggregateDeltas);
|
|
208
|
-
await
|
|
253
|
+
await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
|
|
209
254
|
return { accepted, duplicates, rejected };
|
|
210
255
|
},
|
|
211
256
|
});
|
|
@@ -294,6 +339,7 @@ export const trackConversion = mutation({
|
|
|
294
339
|
aggregateDeltas,
|
|
295
340
|
reportRollupDeltas,
|
|
296
341
|
recordTelemetry: existingTelemetry === null && !telemetry.saturated,
|
|
342
|
+
timezone: localDayTimezone(site.timezone),
|
|
297
343
|
financialConversion: true,
|
|
298
344
|
trustedGoalKey,
|
|
299
345
|
now: Date.now(),
|
|
@@ -302,8 +348,9 @@ export const trackConversion = mutation({
|
|
|
302
348
|
await insertEventBatch(ctx, args.siteId, event, session.country ?? "unknown");
|
|
303
349
|
}
|
|
304
350
|
await flushAggregates(ctx, aggregateDeltas);
|
|
305
|
-
|
|
306
|
-
await
|
|
351
|
+
const now = Date.now();
|
|
352
|
+
await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
|
|
353
|
+
await ensureAnalyticsControl(ctx, args.siteId, now);
|
|
307
354
|
return { accepted: 1, duplicates: 0, rejected: 0 };
|
|
308
355
|
},
|
|
309
356
|
});
|
|
@@ -426,20 +473,34 @@ async function accountEvent(ctx, input) {
|
|
|
426
473
|
foldGoalRollup(input.reportRollupDeltas, input.siteId, event, goal);
|
|
427
474
|
matchedGoalKeys.push(goal.key);
|
|
428
475
|
}
|
|
476
|
+
let reach;
|
|
429
477
|
for (const funnel of input.funnels) {
|
|
430
478
|
if (funnel.active) {
|
|
431
|
-
await advanceFunnel(ctx, input.siteId, funnel, event, input.reportRollupDeltas, input.batchState);
|
|
479
|
+
reach = furtherReach(reach, await advanceFunnel(ctx, input.siteId, funnel, event, input.reportRollupDeltas, input.batchState));
|
|
432
480
|
}
|
|
433
481
|
}
|
|
434
482
|
if (input.financialConversion) {
|
|
435
483
|
await accountConversion(ctx, input.siteId, session, event, input.trustedGoalKey, input.reportRollupDeltas);
|
|
436
484
|
}
|
|
437
|
-
const campaignDimensions =
|
|
438
|
-
|
|
439
|
-
:
|
|
485
|
+
const campaignDimensions = [];
|
|
486
|
+
if (session.utmCampaign) {
|
|
487
|
+
campaignDimensions.push({ type: "campaign", value: session.utmCampaign });
|
|
488
|
+
}
|
|
489
|
+
if (session.utmMedium) {
|
|
490
|
+
// A medium is a taxonomy ("social", "cpc", "email"), so its case is noise.
|
|
491
|
+
campaignDimensions.push({
|
|
492
|
+
type: "medium",
|
|
493
|
+
value: session.utmMedium.toLowerCase(),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
// An outbound click ranks by where it went: the host alone, never the path.
|
|
497
|
+
const outboundHost = event.type === "outbound" ? publicUrlHost(event.href) : undefined;
|
|
440
498
|
const dimensions = [
|
|
441
499
|
{ type: "source", value: session.source },
|
|
442
500
|
...campaignDimensions,
|
|
501
|
+
...(outboundHost
|
|
502
|
+
? [{ type: "outbound", value: outboundHost }]
|
|
503
|
+
: []),
|
|
443
504
|
{ type: "page", value: event.path },
|
|
444
505
|
{ type: "country", value: session.country ?? "unknown" },
|
|
445
506
|
{ type: "device", value: input.context.device ?? "unknown" },
|
|
@@ -450,10 +511,10 @@ async function accountEvent(ctx, input) {
|
|
|
450
511
|
},
|
|
451
512
|
...matchedGoalKeys.map((value) => ({ type: "goal", value })),
|
|
452
513
|
];
|
|
453
|
-
foldAggregates(input.aggregateDeltas, input.siteId, event, session, dimensions, input.recordTelemetry, input.financialConversion);
|
|
514
|
+
foldAggregates(input.aggregateDeltas, input.siteId, event, session, dimensions, input.recordTelemetry, input.financialConversion, input.timezone);
|
|
454
515
|
if (input.recordTelemetry &&
|
|
455
516
|
event.timestamp >= input.now - LIVE_SESSION_TTL_MS) {
|
|
456
|
-
await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now, input.batchState);
|
|
517
|
+
await updateLiveSession(ctx, input.siteId, event, input.context, session, input.now, input.batchState, reach);
|
|
457
518
|
}
|
|
458
519
|
return session;
|
|
459
520
|
}
|
|
@@ -471,9 +532,16 @@ async function accountTrustedSession(ctx, existing, event) {
|
|
|
471
532
|
isNewSession: false,
|
|
472
533
|
isNewVisitorHour: false,
|
|
473
534
|
isNewVisitorDay: false,
|
|
535
|
+
isNewVisitor: false,
|
|
536
|
+
durationDeltaMs: 0,
|
|
537
|
+
continued: false,
|
|
474
538
|
startedAt: existing.startedAt,
|
|
475
539
|
source: existing.source,
|
|
476
540
|
utmCampaign: existing.utmCampaign,
|
|
541
|
+
utmMedium: existing.utmMedium,
|
|
542
|
+
entryPath: existing.entryPath,
|
|
543
|
+
conversionCount: existing.conversionCount + 1,
|
|
544
|
+
newVisitor: existing.newVisitor,
|
|
477
545
|
visitorKey: existing.visitorKey ?? existing.visitorId,
|
|
478
546
|
country: existing.country,
|
|
479
547
|
city: existing.city,
|
|
@@ -555,9 +623,16 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
|
|
|
555
623
|
Math.floor(event.timestamp / HOUR_MS),
|
|
556
624
|
isNewVisitorDay: Math.floor(existing.lastSeenAt / DAY_MS) <
|
|
557
625
|
Math.floor(event.timestamp / DAY_MS),
|
|
626
|
+
isNewVisitor: false,
|
|
627
|
+
durationDeltaMs: Math.max(0, updates.durationMs - existing.durationMs),
|
|
628
|
+
continued: event.type === "pageview" && existing.pageviewCount === 1,
|
|
558
629
|
startedAt,
|
|
559
630
|
source: existing.source,
|
|
560
631
|
utmCampaign: existing.utmCampaign,
|
|
632
|
+
utmMedium: existing.utmMedium,
|
|
633
|
+
entryPath: existing.entryPath,
|
|
634
|
+
conversionCount: updates.conversionCount,
|
|
635
|
+
newVisitor: existing.newVisitor,
|
|
561
636
|
visitorKey,
|
|
562
637
|
country: context.country ?? existing.country,
|
|
563
638
|
city: context.city ?? existing.city,
|
|
@@ -584,6 +659,18 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
|
|
|
584
659
|
}
|
|
585
660
|
}
|
|
586
661
|
}
|
|
662
|
+
// A visitor is new when nothing was ever recorded under their id. A host
|
|
663
|
+
// id whose anonymous history was linked at sign-up is a return, not a new
|
|
664
|
+
// person; a browser without a persistent id is new every session.
|
|
665
|
+
let isNewVisitor = priorVisit === null;
|
|
666
|
+
if (isNewVisitor && event.visitorId !== event.sessionId) {
|
|
667
|
+
const alias = await ctx.db
|
|
668
|
+
.query("visitorAliases")
|
|
669
|
+
.withIndex("by_siteId_and_visitorId", (range) => range.eq("siteId", siteId).eq("visitorId", event.visitorId))
|
|
670
|
+
.first();
|
|
671
|
+
if (alias)
|
|
672
|
+
isNewVisitor = false;
|
|
673
|
+
}
|
|
587
674
|
const fields = {
|
|
588
675
|
siteId,
|
|
589
676
|
sessionId: event.sessionId,
|
|
@@ -593,6 +680,7 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
|
|
|
593
680
|
lastSeenAt: event.timestamp,
|
|
594
681
|
entryPath: event.path,
|
|
595
682
|
exitPath: event.path,
|
|
683
|
+
newVisitor: isNewVisitor,
|
|
596
684
|
...(event.referrer ? { referrer: event.referrer } : {}),
|
|
597
685
|
source,
|
|
598
686
|
...(utmSource ? { utmSource } : {}),
|
|
@@ -639,9 +727,16 @@ async function upsertSession(ctx, siteId, event, context, existing, financialCon
|
|
|
639
727
|
isNewVisitorDay: priorVisit === null ||
|
|
640
728
|
Math.floor(priorVisit.lastSeenAt / DAY_MS) <
|
|
641
729
|
Math.floor(event.timestamp / DAY_MS),
|
|
730
|
+
isNewVisitor,
|
|
731
|
+
durationDeltaMs: 0,
|
|
732
|
+
continued: false,
|
|
642
733
|
startedAt: event.timestamp,
|
|
643
734
|
source,
|
|
644
735
|
utmCampaign,
|
|
736
|
+
utmMedium,
|
|
737
|
+
entryPath: event.path,
|
|
738
|
+
conversionCount: Number(financialConversion),
|
|
739
|
+
newVisitor: isNewVisitor,
|
|
645
740
|
visitorKey,
|
|
646
741
|
country: context.country,
|
|
647
742
|
city: context.city,
|
|
@@ -788,10 +883,14 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
|
|
|
788
883
|
else {
|
|
789
884
|
await ctx.db.patch("sessions", session._id, updates);
|
|
790
885
|
}
|
|
886
|
+
const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
|
|
791
887
|
await updateLiveSession(ctx, siteId, event, context, {
|
|
792
888
|
isNewSession: false,
|
|
793
889
|
isNewVisitorHour: false,
|
|
794
890
|
isNewVisitorDay: false,
|
|
891
|
+
isNewVisitor: false,
|
|
892
|
+
durationDeltaMs: 0,
|
|
893
|
+
continued: false,
|
|
795
894
|
startedAt,
|
|
796
895
|
source: session.source,
|
|
797
896
|
visitorKey: session.visitorKey ?? session.visitorId,
|
|
@@ -802,7 +901,11 @@ async function accountHeartbeat(ctx, siteId, session, event, context, now, batch
|
|
|
802
901
|
pageviewCount: session.pageviewCount,
|
|
803
902
|
eventCount: session.eventCount,
|
|
804
903
|
affiliateSlug: session.affiliateSlug,
|
|
904
|
+
entryPath: session.entryPath,
|
|
905
|
+
conversionCount: session.conversionCount,
|
|
906
|
+
newVisitor: session.newVisitor,
|
|
805
907
|
}, now, batchState);
|
|
908
|
+
return durationDeltaMs;
|
|
806
909
|
}
|
|
807
910
|
async function consumeSiteRateLimit(ctx, siteId, events, eventByteCounts, now) {
|
|
808
911
|
const windowStart = Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
@@ -919,7 +1022,26 @@ async function flushSessionRateLimits(ctx, states, now) {
|
|
|
919
1022
|
}
|
|
920
1023
|
}
|
|
921
1024
|
}
|
|
922
|
-
|
|
1025
|
+
/**
|
|
1026
|
+
* The intent facts a live row carries for the dashboard's heat score. A
|
|
1027
|
+
* session row from before 0.1.0 has no first-visit flag, so `returning` stays
|
|
1028
|
+
* unknown rather than guessed.
|
|
1029
|
+
*/
|
|
1030
|
+
function liveIntentFields(session, funnel) {
|
|
1031
|
+
return {
|
|
1032
|
+
...(session.entryPath ? { entryPath: session.entryPath } : {}),
|
|
1033
|
+
...(session.newVisitor !== undefined
|
|
1034
|
+
? { returning: !session.newVisitor }
|
|
1035
|
+
: {}),
|
|
1036
|
+
...(session.utmCampaign ? { utmCampaign: session.utmCampaign } : {}),
|
|
1037
|
+
...(session.affiliateSlug ? { affiliateSlug: session.affiliateSlug } : {}),
|
|
1038
|
+
...(session.conversionCount !== undefined
|
|
1039
|
+
? { conversionCount: session.conversionCount }
|
|
1040
|
+
: {}),
|
|
1041
|
+
...(funnel ? { funnel } : {}),
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
async function updateLiveSession(ctx, siteId, event, context, session, now, batchState, reach) {
|
|
923
1045
|
await ensureLiveSweep(ctx, siteId, now, batchState);
|
|
924
1046
|
const liveState = batchState
|
|
925
1047
|
? await loadLiveSession(ctx, siteId, event.sessionId, batchState)
|
|
@@ -960,6 +1082,7 @@ async function updateLiveSession(ctx, siteId, event, context, session, now, batc
|
|
|
960
1082
|
device: context.device ?? existing.device,
|
|
961
1083
|
pageviewCount: session.pageviewCount,
|
|
962
1084
|
eventCount: session.eventCount,
|
|
1085
|
+
...liveIntentFields(session, furtherReach(existing.funnel, reach)),
|
|
963
1086
|
// The live map animates a visitor from the route they left to the one
|
|
964
1087
|
// they reached; a heartbeat or click on the same page is not a move.
|
|
965
1088
|
...(event.type === "pageview" && event.path !== existing.path
|
|
@@ -999,6 +1122,7 @@ async function updateLiveSession(ctx, siteId, event, context, session, now, batc
|
|
|
999
1122
|
device: context.device ?? "unknown",
|
|
1000
1123
|
pageviewCount: session.pageviewCount,
|
|
1001
1124
|
eventCount: session.eventCount,
|
|
1125
|
+
...liveIntentFields(session, reach),
|
|
1002
1126
|
...(event.type === "heartbeat"
|
|
1003
1127
|
? {
|
|
1004
1128
|
lastHeartbeatEventId: event.eventId,
|
|
@@ -1086,9 +1210,9 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
|
|
|
1086
1210
|
_id: progressId,
|
|
1087
1211
|
};
|
|
1088
1212
|
}
|
|
1089
|
-
|
|
1213
|
+
foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1090
1214
|
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1091
|
-
return;
|
|
1215
|
+
return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
|
|
1092
1216
|
}
|
|
1093
1217
|
if (progress.completedAt !== undefined ||
|
|
1094
1218
|
event.timestamp < progress.lastStepAt)
|
|
@@ -1117,9 +1241,9 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
|
|
|
1117
1241
|
completedAt: undefined,
|
|
1118
1242
|
});
|
|
1119
1243
|
}
|
|
1120
|
-
|
|
1244
|
+
foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1121
1245
|
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1122
|
-
return;
|
|
1246
|
+
return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
|
|
1123
1247
|
}
|
|
1124
1248
|
const nextStep = funnel.steps[progress.currentStep];
|
|
1125
1249
|
if (!nextStep || !stepMatches(nextStep, event))
|
|
@@ -1139,44 +1263,19 @@ async function advanceFunnel(ctx, siteId, funnel, event, reportRollupDeltas, bat
|
|
|
1139
1263
|
else {
|
|
1140
1264
|
await ctx.db.patch("funnelProgress", progress._id, updates);
|
|
1141
1265
|
}
|
|
1142
|
-
|
|
1266
|
+
foldFunnelStepDelta(reportRollupDeltas, siteId, funnel._id, event, progress.currentStep, completed);
|
|
1143
1267
|
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, progress.currentStep, completed);
|
|
1268
|
+
return {
|
|
1269
|
+
funnelId: funnel._id,
|
|
1270
|
+
step: currentStep,
|
|
1271
|
+
steps: funnel.steps.length,
|
|
1272
|
+
};
|
|
1144
1273
|
}
|
|
1145
1274
|
function stepMatches(step, event) {
|
|
1146
1275
|
return step.type === "pageview"
|
|
1147
1276
|
? event.type === "pageview" && event.path === step.value
|
|
1148
1277
|
: event.type === "custom" && event.name === step.value;
|
|
1149
1278
|
}
|
|
1150
|
-
async function incrementFunnelStep(ctx, siteId, funnelId, event, step, completed) {
|
|
1151
|
-
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
1152
|
-
const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
|
|
1153
|
-
const bucket = await ctx.db
|
|
1154
|
-
.query("funnelStepBuckets")
|
|
1155
|
-
.withIndex("by_siteId_and_funnelId_and_bucketStart_and_step_and_shard", (range) => range
|
|
1156
|
-
.eq("siteId", siteId)
|
|
1157
|
-
.eq("funnelId", funnelId)
|
|
1158
|
-
.eq("bucketStart", bucketStart)
|
|
1159
|
-
.eq("step", step)
|
|
1160
|
-
.eq("shard", shard))
|
|
1161
|
-
.unique();
|
|
1162
|
-
if (bucket) {
|
|
1163
|
-
await ctx.db.patch("funnelStepBuckets", bucket._id, {
|
|
1164
|
-
entrants: bucket.entrants + 1,
|
|
1165
|
-
completions: bucket.completions + (completed ? 1 : 0),
|
|
1166
|
-
});
|
|
1167
|
-
}
|
|
1168
|
-
else {
|
|
1169
|
-
await ctx.db.insert("funnelStepBuckets", {
|
|
1170
|
-
siteId,
|
|
1171
|
-
funnelId,
|
|
1172
|
-
bucketStart,
|
|
1173
|
-
step,
|
|
1174
|
-
shard,
|
|
1175
|
-
entrants: 1,
|
|
1176
|
-
completions: completed ? 1 : 0,
|
|
1177
|
-
});
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
1279
|
async function accountConversion(ctx, siteId, session, event, trustedGoalKey, reportRollupDeltas) {
|
|
1181
1280
|
const revenueCents = event.revenueCents ?? 0;
|
|
1182
1281
|
const currency = event.currency ?? "USD";
|
|
@@ -1238,24 +1337,6 @@ async function accountConversion(ctx, siteId, session, event, trustedGoalKey, re
|
|
|
1238
1337
|
}
|
|
1239
1338
|
}
|
|
1240
1339
|
}
|
|
1241
|
-
function createReportRollupDeltas() {
|
|
1242
|
-
return {
|
|
1243
|
-
goals: new Map(),
|
|
1244
|
-
funnels: new Map(),
|
|
1245
|
-
affiliates: new Map(),
|
|
1246
|
-
vitals: new Map(),
|
|
1247
|
-
routes: new Map(),
|
|
1248
|
-
};
|
|
1249
|
-
}
|
|
1250
|
-
function reportRollupCoordinates(siteId, definitionId, timestamp, visitorId) {
|
|
1251
|
-
const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
|
|
1252
|
-
const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
|
|
1253
|
-
return {
|
|
1254
|
-
bucketStart,
|
|
1255
|
-
shard,
|
|
1256
|
-
key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
|
|
1257
|
-
};
|
|
1258
|
-
}
|
|
1259
1340
|
function foldGoalRollup(deltas, siteId, event, goal) {
|
|
1260
1341
|
const coordinates = reportRollupCoordinates(siteId, String(goal._id), event.timestamp, event.visitorId);
|
|
1261
1342
|
const current = deltas.goals.get(coordinates.key) ?? {
|
|
@@ -1290,6 +1371,23 @@ function foldFunnelRollup(deltas, siteId, funnelId, event, step, completed) {
|
|
|
1290
1371
|
rollup.steps.set(step, current);
|
|
1291
1372
|
deltas.funnels.set(coordinates.key, rollup);
|
|
1292
1373
|
}
|
|
1374
|
+
function foldFunnelStepDelta(deltas, siteId, funnelId, event, step, completed) {
|
|
1375
|
+
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
1376
|
+
const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
|
|
1377
|
+
const key = `${siteId}:${funnelId}:${bucketStart}:${step}:${shard}`;
|
|
1378
|
+
const current = deltas.funnelSteps.get(key) ?? {
|
|
1379
|
+
siteId,
|
|
1380
|
+
funnelId,
|
|
1381
|
+
bucketStart,
|
|
1382
|
+
step,
|
|
1383
|
+
shard,
|
|
1384
|
+
entrants: 0,
|
|
1385
|
+
completions: 0,
|
|
1386
|
+
};
|
|
1387
|
+
current.entrants += 1;
|
|
1388
|
+
current.completions += Number(completed);
|
|
1389
|
+
deltas.funnelSteps.set(key, current);
|
|
1390
|
+
}
|
|
1293
1391
|
function foldAffiliateVisitRollup(deltas, siteId, event, attribution, visitorKey) {
|
|
1294
1392
|
const coordinates = reportRollupCoordinates(siteId, String(attribution.affiliateId), event.timestamp, event.visitorId);
|
|
1295
1393
|
const current = deltas.affiliates.get(coordinates.key) ?? {
|
|
@@ -1299,13 +1397,13 @@ function foldAffiliateVisitRollup(deltas, siteId, event, attribution, visitorKey
|
|
|
1299
1397
|
shard: coordinates.shard,
|
|
1300
1398
|
affiliateSlug: attribution.affiliateSlug,
|
|
1301
1399
|
visits: 0,
|
|
1302
|
-
|
|
1400
|
+
visitorKeys: new Set(),
|
|
1303
1401
|
conversions: 0,
|
|
1304
1402
|
revenueCents: 0,
|
|
1305
1403
|
commissionCents: 0,
|
|
1306
1404
|
};
|
|
1307
1405
|
current.visits += 1;
|
|
1308
|
-
current.
|
|
1406
|
+
current.visitorKeys.add(visitorKey);
|
|
1309
1407
|
deltas.affiliates.set(coordinates.key, current);
|
|
1310
1408
|
}
|
|
1311
1409
|
function foldAffiliateConversionRollup(deltas, siteId, event, affiliate, affiliateSlug, revenueCents, commissionCents) {
|
|
@@ -1317,7 +1415,7 @@ function foldAffiliateConversionRollup(deltas, siteId, event, affiliate, affilia
|
|
|
1317
1415
|
shard: coordinates.shard,
|
|
1318
1416
|
affiliateSlug,
|
|
1319
1417
|
visits: 0,
|
|
1320
|
-
|
|
1418
|
+
visitorKeys: new Set(),
|
|
1321
1419
|
conversions: 0,
|
|
1322
1420
|
revenueCents: 0,
|
|
1323
1421
|
commissionCents: 0,
|
|
@@ -1377,7 +1475,7 @@ function foldRouteDelta(deltas, siteId, route, timestamp, visitorId, apply) {
|
|
|
1377
1475
|
continued: 0,
|
|
1378
1476
|
dwellMs: 0,
|
|
1379
1477
|
dwellSamples: 0,
|
|
1380
|
-
|
|
1478
|
+
visitorKeys: new Set(),
|
|
1381
1479
|
outgoing: new Map(),
|
|
1382
1480
|
};
|
|
1383
1481
|
apply(current);
|
|
@@ -1398,7 +1496,7 @@ function foldRouteRollups(deltas, siteId, event, session, existing) {
|
|
|
1398
1496
|
delta.pageviews += 1;
|
|
1399
1497
|
if (firstPageview)
|
|
1400
1498
|
delta.entries += 1;
|
|
1401
|
-
delta.
|
|
1499
|
+
delta.visitorKeys.add(session.visitorKey);
|
|
1402
1500
|
});
|
|
1403
1501
|
// Out-of-order telemetry keeps its view but cannot say where it came from.
|
|
1404
1502
|
if (existing === null || firstPageview)
|
|
@@ -1420,245 +1518,19 @@ function foldRouteRollups(deltas, siteId, event, session, existing) {
|
|
|
1420
1518
|
});
|
|
1421
1519
|
}
|
|
1422
1520
|
}
|
|
1423
|
-
|
|
1424
|
-
const existing = await ctx.db
|
|
1425
|
-
.query("routeDailyRollups")
|
|
1426
|
-
.withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) => range
|
|
1427
|
-
.eq("siteId", delta.siteId)
|
|
1428
|
-
.eq("route", delta.route)
|
|
1429
|
-
.eq("bucketStart", delta.bucketStart)
|
|
1430
|
-
.eq("shard", delta.shard))
|
|
1431
|
-
.unique();
|
|
1432
|
-
if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
|
|
1433
|
-
// A new route row spends one bounded read per batch to respect the daily
|
|
1434
|
-
// cap; past it, the day's remaining routes fold into "(other)".
|
|
1435
|
-
const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
|
|
1436
|
-
const dayKey = `${delta.siteId}:${delta.bucketStart}`;
|
|
1437
|
-
let dayRows = dayRowCounts.get(dayKey);
|
|
1438
|
-
if (dayRows === undefined) {
|
|
1439
|
-
dayRows = (await ctx.db
|
|
1440
|
-
.query("routeDailyRollups")
|
|
1441
|
-
.withIndex("by_siteId_and_bucketStart", (range) => range
|
|
1442
|
-
.eq("siteId", delta.siteId)
|
|
1443
|
-
.eq("bucketStart", delta.bucketStart))
|
|
1444
|
-
.take(capRows + 1)).length;
|
|
1445
|
-
}
|
|
1446
|
-
if (dayRows >= capRows) {
|
|
1447
|
-
await upsertRouteRollup(ctx, { ...delta, route: SITE_MAP_OTHER_ROUTE }, dayRowCounts);
|
|
1448
|
-
return;
|
|
1449
|
-
}
|
|
1450
|
-
dayRowCounts.set(dayKey, dayRows + 1);
|
|
1451
|
-
}
|
|
1452
|
-
const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
|
|
1453
|
-
for (const [target, count] of delta.outgoing) {
|
|
1454
|
-
let entry = outgoing.find((candidate) => candidate.route === target);
|
|
1455
|
-
if (!entry) {
|
|
1456
|
-
const named = outgoing.filter((candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE).length;
|
|
1457
|
-
const route = target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
|
|
1458
|
-
? SITE_MAP_OTHER_ROUTE
|
|
1459
|
-
: target;
|
|
1460
|
-
entry = outgoing.find((candidate) => candidate.route === route);
|
|
1461
|
-
if (!entry) {
|
|
1462
|
-
entry = { route, count: 0 };
|
|
1463
|
-
outgoing.push(entry);
|
|
1464
|
-
}
|
|
1465
|
-
}
|
|
1466
|
-
entry.count += count;
|
|
1467
|
-
}
|
|
1468
|
-
const fields = {
|
|
1469
|
-
siteId: delta.siteId,
|
|
1470
|
-
bucketStart: delta.bucketStart,
|
|
1471
|
-
shard: delta.shard,
|
|
1472
|
-
generation: 1,
|
|
1473
|
-
route: delta.route,
|
|
1474
|
-
pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
|
|
1475
|
-
entries: (existing?.entries ?? 0) + delta.entries,
|
|
1476
|
-
continued: (existing?.continued ?? 0) + delta.continued,
|
|
1477
|
-
dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
|
|
1478
|
-
dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
|
|
1479
|
-
visitorSketch: unionVisitorSketches(existing?.visitorSketch ?? createVisitorSketch(), delta.visitorSketch),
|
|
1480
|
-
outgoing,
|
|
1481
|
-
};
|
|
1482
|
-
if (existing) {
|
|
1483
|
-
await ctx.db.replace("routeDailyRollups", existing._id, fields);
|
|
1484
|
-
}
|
|
1485
|
-
else {
|
|
1486
|
-
await ctx.db.insert("routeDailyRollups", fields);
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
async function upsertVitalRollup(ctx, delta) {
|
|
1490
|
-
const existing = await ctx.db
|
|
1491
|
-
.query("vitalRollups")
|
|
1492
|
-
.withIndex("by_key", (range) => range
|
|
1493
|
-
.eq("siteId", delta.siteId)
|
|
1494
|
-
.eq("bucketStart", delta.bucketStart)
|
|
1495
|
-
.eq("device", delta.device)
|
|
1496
|
-
.eq("page", delta.page)
|
|
1497
|
-
.eq("shard", delta.shard))
|
|
1498
|
-
.unique();
|
|
1499
|
-
if (!existing &&
|
|
1500
|
-
delta.device === VITAL_ALL &&
|
|
1501
|
-
delta.page !== VITAL_OTHER_PAGES) {
|
|
1502
|
-
// A new page row spends one bounded read to respect the daily page slots;
|
|
1503
|
-
// past the cap, the day's remaining pages fold into "(other)".
|
|
1504
|
-
const pageRows = await ctx.db
|
|
1505
|
-
.query("vitalRollups")
|
|
1506
|
-
.withIndex("by_key", (range) => range
|
|
1507
|
-
.eq("siteId", delta.siteId)
|
|
1508
|
-
.eq("bucketStart", delta.bucketStart)
|
|
1509
|
-
.eq("device", VITAL_ALL))
|
|
1510
|
-
.take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
|
|
1511
|
-
if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
|
|
1512
|
-
await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
|
|
1513
|
-
return;
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
|
|
1517
|
-
for (const [metric, addition] of delta.metrics) {
|
|
1518
|
-
const entry = metrics.find((candidate) => candidate.metric === metric);
|
|
1519
|
-
if (entry) {
|
|
1520
|
-
entry.count += addition.count;
|
|
1521
|
-
entry.sum += addition.sum;
|
|
1522
|
-
entry.histogram = mergeVitalHistograms(entry.histogram, addition.histogram);
|
|
1523
|
-
}
|
|
1524
|
-
else {
|
|
1525
|
-
metrics.push({
|
|
1526
|
-
metric,
|
|
1527
|
-
count: addition.count,
|
|
1528
|
-
sum: addition.sum,
|
|
1529
|
-
histogram: [...addition.histogram],
|
|
1530
|
-
});
|
|
1531
|
-
}
|
|
1532
|
-
}
|
|
1533
|
-
const fields = {
|
|
1534
|
-
siteId: delta.siteId,
|
|
1535
|
-
bucketStart: delta.bucketStart,
|
|
1536
|
-
device: delta.device,
|
|
1537
|
-
page: delta.page,
|
|
1538
|
-
shard: delta.shard,
|
|
1539
|
-
metrics,
|
|
1540
|
-
};
|
|
1541
|
-
if (existing) {
|
|
1542
|
-
await ctx.db.replace("vitalRollups", existing._id, fields);
|
|
1543
|
-
}
|
|
1544
|
-
else {
|
|
1545
|
-
await ctx.db.insert("vitalRollups", fields);
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
async function flushReportRollups(ctx, deltas) {
|
|
1549
|
-
for (const delta of deltas.goals.values()) {
|
|
1550
|
-
const existing = await ctx.db
|
|
1551
|
-
.query("goalDailyRollups")
|
|
1552
|
-
.withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) => range
|
|
1553
|
-
.eq("siteId", delta.siteId)
|
|
1554
|
-
.eq("goalId", delta.goalId)
|
|
1555
|
-
.eq("bucketStart", delta.bucketStart)
|
|
1556
|
-
.eq("shard", delta.shard))
|
|
1557
|
-
.unique();
|
|
1558
|
-
const fields = {
|
|
1559
|
-
siteId: delta.siteId,
|
|
1560
|
-
goalId: delta.goalId,
|
|
1561
|
-
bucketStart: delta.bucketStart,
|
|
1562
|
-
shard: delta.shard,
|
|
1563
|
-
generation: 1,
|
|
1564
|
-
goalKey: delta.goalKey,
|
|
1565
|
-
completions: (existing?.completions ?? 0) + delta.completions,
|
|
1566
|
-
valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
|
|
1567
|
-
};
|
|
1568
|
-
if (existing) {
|
|
1569
|
-
await ctx.db.replace("goalDailyRollups", existing._id, fields);
|
|
1570
|
-
}
|
|
1571
|
-
else {
|
|
1572
|
-
await ctx.db.insert("goalDailyRollups", fields);
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
for (const delta of deltas.funnels.values()) {
|
|
1576
|
-
const existing = await ctx.db
|
|
1577
|
-
.query("funnelDailyRollups")
|
|
1578
|
-
.withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) => range
|
|
1579
|
-
.eq("siteId", delta.siteId)
|
|
1580
|
-
.eq("funnelId", delta.funnelId)
|
|
1581
|
-
.eq("bucketStart", delta.bucketStart)
|
|
1582
|
-
.eq("shard", delta.shard))
|
|
1583
|
-
.unique();
|
|
1584
|
-
const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
|
|
1585
|
-
for (const addition of delta.steps.values()) {
|
|
1586
|
-
const current = steps.find((step) => step.step === addition.step);
|
|
1587
|
-
if (current) {
|
|
1588
|
-
current.entrants += addition.entrants;
|
|
1589
|
-
current.completions += addition.completions;
|
|
1590
|
-
}
|
|
1591
|
-
else {
|
|
1592
|
-
if (steps.length >= MAX_FUNNEL_STEPS) {
|
|
1593
|
-
fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
|
|
1594
|
-
}
|
|
1595
|
-
steps.push(addition);
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
const fields = {
|
|
1599
|
-
siteId: delta.siteId,
|
|
1600
|
-
funnelId: delta.funnelId,
|
|
1601
|
-
bucketStart: delta.bucketStart,
|
|
1602
|
-
shard: delta.shard,
|
|
1603
|
-
generation: 1,
|
|
1604
|
-
steps,
|
|
1605
|
-
};
|
|
1606
|
-
if (existing) {
|
|
1607
|
-
await ctx.db.replace("funnelDailyRollups", existing._id, fields);
|
|
1608
|
-
}
|
|
1609
|
-
else {
|
|
1610
|
-
await ctx.db.insert("funnelDailyRollups", fields);
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
for (const delta of deltas.affiliates.values()) {
|
|
1614
|
-
const existing = await ctx.db
|
|
1615
|
-
.query("affiliateDailyRollups")
|
|
1616
|
-
.withIndex("by_siteId_and_affiliateId_and_bucketStart_and_shard", (range) => range
|
|
1617
|
-
.eq("siteId", delta.siteId)
|
|
1618
|
-
.eq("affiliateId", delta.affiliateId)
|
|
1619
|
-
.eq("bucketStart", delta.bucketStart)
|
|
1620
|
-
.eq("shard", delta.shard))
|
|
1621
|
-
.unique();
|
|
1622
|
-
const fields = {
|
|
1623
|
-
siteId: delta.siteId,
|
|
1624
|
-
affiliateId: delta.affiliateId,
|
|
1625
|
-
bucketStart: delta.bucketStart,
|
|
1626
|
-
shard: delta.shard,
|
|
1627
|
-
generation: 1,
|
|
1628
|
-
affiliateSlug: delta.affiliateSlug,
|
|
1629
|
-
visits: (existing?.visits ?? 0) + delta.visits,
|
|
1630
|
-
visitorSketch: unionVisitorSketches(existing?.visitorSketch ?? createVisitorSketch(), delta.visitorSketch),
|
|
1631
|
-
conversions: (existing?.conversions ?? 0) + delta.conversions,
|
|
1632
|
-
revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
|
|
1633
|
-
commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
|
|
1634
|
-
};
|
|
1635
|
-
if (existing) {
|
|
1636
|
-
await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
|
|
1637
|
-
}
|
|
1638
|
-
else {
|
|
1639
|
-
await ctx.db.insert("affiliateDailyRollups", fields);
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
const routeDayRowCounts = new Map();
|
|
1643
|
-
for (const delta of deltas.routes.values()) {
|
|
1644
|
-
await upsertRouteRollup(ctx, delta, routeDayRowCounts);
|
|
1645
|
-
}
|
|
1646
|
-
for (const delta of deltas.vitals.values()) {
|
|
1647
|
-
await upsertVitalRollup(ctx, delta);
|
|
1648
|
-
}
|
|
1649
|
-
}
|
|
1650
|
-
function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion) {
|
|
1521
|
+
function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, recordTelemetry, financialConversion, timezone) {
|
|
1651
1522
|
const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
|
|
1652
1523
|
const dimensionDeltas = dimensions.map((dimension) => ({
|
|
1653
1524
|
...dimension,
|
|
1654
1525
|
count: Number(recordTelemetry),
|
|
1655
1526
|
revenueCents,
|
|
1527
|
+
pageviews: Number(recordTelemetry && event.type === "pageview"),
|
|
1528
|
+
sessions: Number(recordTelemetry && session.isNewSession),
|
|
1656
1529
|
}));
|
|
1657
|
-
for (const granularity of
|
|
1530
|
+
for (const granularity of aggregateGranularities(timezone)) {
|
|
1658
1531
|
const shard = stableHash(event.sessionId) %
|
|
1659
1532
|
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
1660
|
-
const
|
|
1661
|
-
const bucketStart = Math.floor(event.timestamp / interval) * interval;
|
|
1533
|
+
const bucketStart = aggregateBucketStart(granularity, event.timestamp, timezone);
|
|
1662
1534
|
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
1663
1535
|
let delta = aggregateDeltas.get(key);
|
|
1664
1536
|
if (!delta) {
|
|
@@ -1678,6 +1550,9 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
|
|
|
1678
1550
|
conversions: 0,
|
|
1679
1551
|
revenueCents: 0,
|
|
1680
1552
|
dimensions: [],
|
|
1553
|
+
durationMs: 0,
|
|
1554
|
+
continuedSessions: 0,
|
|
1555
|
+
newVisitors: 0,
|
|
1681
1556
|
};
|
|
1682
1557
|
aggregateDeltas.set(key, delta);
|
|
1683
1558
|
}
|
|
@@ -1699,6 +1574,58 @@ function foldAggregates(aggregateDeltas, siteId, event, session, dimensions, rec
|
|
|
1699
1574
|
delta.conversions += Number(financialConversion);
|
|
1700
1575
|
delta.revenueCents += revenueCents;
|
|
1701
1576
|
delta.dimensions = mergeDimensions(delta.dimensions, dimensionDeltas);
|
|
1577
|
+
if (recordTelemetry) {
|
|
1578
|
+
delta.durationMs += session.durationDeltaMs;
|
|
1579
|
+
delta.continuedSessions += Number(session.continued);
|
|
1580
|
+
delta.newVisitors += Number(session.isNewSession && session.isNewVisitor);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
/**
|
|
1585
|
+
* The buckets an event lands in: UTC hour and UTC day always, plus the
|
|
1586
|
+
* calendar day in the site's timezone when the site keeps one.
|
|
1587
|
+
*/
|
|
1588
|
+
function aggregateGranularities(timezone) {
|
|
1589
|
+
return timezone ? ["hour", "day", "localDay"] : ["hour", "day"];
|
|
1590
|
+
}
|
|
1591
|
+
function aggregateBucketStart(granularity, timestamp, timezone) {
|
|
1592
|
+
if (granularity === "localDay" && timezone) {
|
|
1593
|
+
return localDayStart(timestamp, timezone);
|
|
1594
|
+
}
|
|
1595
|
+
const interval = granularity === "hour" ? HOUR_MS : DAY_MS;
|
|
1596
|
+
return Math.floor(timestamp / interval) * interval;
|
|
1597
|
+
}
|
|
1598
|
+
/** Folds only session duration growth, for heartbeats. */
|
|
1599
|
+
function foldSessionDuration(aggregateDeltas, siteId, event, durationDeltaMs, timezone) {
|
|
1600
|
+
if (durationDeltaMs <= 0)
|
|
1601
|
+
return;
|
|
1602
|
+
for (const granularity of aggregateGranularities(timezone)) {
|
|
1603
|
+
const shard = stableHash(event.sessionId) %
|
|
1604
|
+
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
1605
|
+
const bucketStart = aggregateBucketStart(granularity, event.timestamp, timezone);
|
|
1606
|
+
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
1607
|
+
const delta = aggregateDeltas.get(key) ?? {
|
|
1608
|
+
siteId,
|
|
1609
|
+
granularity,
|
|
1610
|
+
bucketStart,
|
|
1611
|
+
shard,
|
|
1612
|
+
events: 0,
|
|
1613
|
+
pageviews: 0,
|
|
1614
|
+
clicks: 0,
|
|
1615
|
+
customEvents: 0,
|
|
1616
|
+
outboundClicks: 0,
|
|
1617
|
+
sessions: 0,
|
|
1618
|
+
visitors: 0,
|
|
1619
|
+
visitorSketch: createVisitorSketch(),
|
|
1620
|
+
conversions: 0,
|
|
1621
|
+
revenueCents: 0,
|
|
1622
|
+
dimensions: [],
|
|
1623
|
+
durationMs: 0,
|
|
1624
|
+
continuedSessions: 0,
|
|
1625
|
+
newVisitors: 0,
|
|
1626
|
+
};
|
|
1627
|
+
delta.durationMs += durationDeltaMs;
|
|
1628
|
+
aggregateDeltas.set(key, delta);
|
|
1702
1629
|
}
|
|
1703
1630
|
}
|
|
1704
1631
|
async function flushAggregates(ctx, aggregateDeltas) {
|
|
@@ -1711,7 +1638,11 @@ async function flushAggregates(ctx, aggregateDeltas) {
|
|
|
1711
1638
|
.eq("bucketStart", delta.bucketStart)
|
|
1712
1639
|
.eq("shard", delta.shard))
|
|
1713
1640
|
.unique();
|
|
1714
|
-
const dimensions = mergeDimensions(bucket?.dimensions ?? [], delta.dimensions)
|
|
1641
|
+
const dimensions = mergeDimensions(bucket?.dimensions ?? [], delta.dimensions.map((slot) => ({
|
|
1642
|
+
...slot,
|
|
1643
|
+
pageviews: slot.pageviews ?? 0,
|
|
1644
|
+
sessions: slot.sessions ?? 0,
|
|
1645
|
+
})));
|
|
1715
1646
|
const priorSketch = bucket?.visitorSketch;
|
|
1716
1647
|
const visitorSketch = priorSketch
|
|
1717
1648
|
? unionVisitorSketches(priorSketch, delta.visitorSketch)
|
|
@@ -1733,6 +1664,9 @@ async function flushAggregates(ctx, aggregateDeltas) {
|
|
|
1733
1664
|
conversions: bucket.conversions + delta.conversions,
|
|
1734
1665
|
revenueCents: bucket.revenueCents + delta.revenueCents,
|
|
1735
1666
|
dimensions,
|
|
1667
|
+
durationMs: (bucket.durationMs ?? 0) + delta.durationMs,
|
|
1668
|
+
continuedSessions: (bucket.continuedSessions ?? 0) + delta.continuedSessions,
|
|
1669
|
+
newVisitors: (bucket.newVisitors ?? 0) + delta.newVisitors,
|
|
1736
1670
|
});
|
|
1737
1671
|
}
|
|
1738
1672
|
else {
|
|
@@ -1753,6 +1687,9 @@ async function flushAggregates(ctx, aggregateDeltas) {
|
|
|
1753
1687
|
conversions: delta.conversions,
|
|
1754
1688
|
revenueCents: delta.revenueCents,
|
|
1755
1689
|
dimensions,
|
|
1690
|
+
durationMs: delta.durationMs,
|
|
1691
|
+
continuedSessions: delta.continuedSessions,
|
|
1692
|
+
newVisitors: delta.newVisitors,
|
|
1756
1693
|
});
|
|
1757
1694
|
}
|
|
1758
1695
|
}
|
|
@@ -1764,6 +1701,8 @@ function mergeDimensions(current, additions) {
|
|
|
1764
1701
|
if (exact) {
|
|
1765
1702
|
exact.count += addition.count;
|
|
1766
1703
|
exact.revenueCents += addition.revenueCents;
|
|
1704
|
+
exact.pageviews = (exact.pageviews ?? 0) + addition.pageviews;
|
|
1705
|
+
exact.sessions = (exact.sessions ?? 0) + addition.sessions;
|
|
1767
1706
|
continue;
|
|
1768
1707
|
}
|
|
1769
1708
|
const typeSlots = result.filter((candidate) => candidate.type === addition.type);
|
|
@@ -1780,6 +1719,8 @@ function mergeDimensions(current, additions) {
|
|
|
1780
1719
|
value: addition.value,
|
|
1781
1720
|
count: addition.count,
|
|
1782
1721
|
revenueCents: addition.revenueCents,
|
|
1722
|
+
pageviews: addition.pageviews,
|
|
1723
|
+
sessions: addition.sessions,
|
|
1783
1724
|
});
|
|
1784
1725
|
continue;
|
|
1785
1726
|
}
|
|
@@ -1787,6 +1728,8 @@ function mergeDimensions(current, additions) {
|
|
|
1787
1728
|
if (overflow) {
|
|
1788
1729
|
overflow.count += addition.count;
|
|
1789
1730
|
overflow.revenueCents += addition.revenueCents;
|
|
1731
|
+
overflow.pageviews = (overflow.pageviews ?? 0) + addition.pageviews;
|
|
1732
|
+
overflow.sessions = (overflow.sessions ?? 0) + addition.sessions;
|
|
1790
1733
|
continue;
|
|
1791
1734
|
}
|
|
1792
1735
|
if (addition.count === 0 && addition.revenueCents === 0)
|
|
@@ -1802,6 +1745,8 @@ function mergeDimensions(current, additions) {
|
|
|
1802
1745
|
value: "(other)",
|
|
1803
1746
|
count: addition.count,
|
|
1804
1747
|
revenueCents: addition.revenueCents,
|
|
1748
|
+
pageviews: addition.pageviews,
|
|
1749
|
+
sessions: addition.sessions,
|
|
1805
1750
|
});
|
|
1806
1751
|
continue;
|
|
1807
1752
|
}
|
|
@@ -1813,6 +1758,8 @@ function mergeDimensions(current, additions) {
|
|
|
1813
1758
|
replacement.value = "(other)";
|
|
1814
1759
|
replacement.count += addition.count;
|
|
1815
1760
|
replacement.revenueCents += addition.revenueCents;
|
|
1761
|
+
replacement.pageviews = (replacement.pageviews ?? 0) + addition.pageviews;
|
|
1762
|
+
replacement.sessions = (replacement.sessions ?? 0) + addition.sessions;
|
|
1816
1763
|
}
|
|
1817
1764
|
return result;
|
|
1818
1765
|
}
|