@iann29/rastro 0.1.0-alpha.9 → 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 +246 -57
- 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 +441 -98
- 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 +50 -13
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/constants.d.ts +3 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +6 -0
- 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/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 +48 -27
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +223 -304
- 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 +44 -13
- 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 +345 -11
- 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 +34 -13
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +23 -2
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +3 -0
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +3 -0
- package/dist/tracker/generated.js.map +1 -1
- package/docs/federation-setup.md +104 -27
- package/docs/federation.md +168 -31
- package/docs/upgrading.md +158 -21
- package/llms.txt +8 -6
- package/package.json +2 -1
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +47 -12
- package/src/component/constants.ts +6 -0
- package/src/component/coverage.ts +46 -6
- package/src/component/geo.ts +82 -28
- package/src/component/http.ts +61 -26
- package/src/component/ingest.ts +327 -439
- 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 +27 -1
- package/src/tracker/generated.ts +12 -0
package/src/component/ingest.ts
CHANGED
|
@@ -19,16 +19,11 @@ import {
|
|
|
19
19
|
MAX_BYTES_PER_SESSION_WINDOW,
|
|
20
20
|
MAX_EVENTS_PER_SESSION_WINDOW,
|
|
21
21
|
MAX_FUNNELS_PER_SITE,
|
|
22
|
-
MAX_FUNNEL_STEPS,
|
|
23
22
|
MAX_GOALS_PER_SITE,
|
|
24
|
-
MAX_SITE_MAP_ROUTES_PER_DAY,
|
|
25
23
|
RATE_LIMIT_WINDOW_MS,
|
|
26
24
|
REPORT_ROLLUP_SHARDS,
|
|
27
|
-
ROUTE_TRANSITION_SLOTS,
|
|
28
25
|
SECONDARY_AGGREGATE_SHARDS,
|
|
29
26
|
SITE_INGEST_SHARDS,
|
|
30
|
-
SITE_MAP_OTHER_ROUTE,
|
|
31
|
-
VITAL_PAGE_SLOTS,
|
|
32
27
|
} from "./constants.js";
|
|
33
28
|
import {
|
|
34
29
|
addVisitor,
|
|
@@ -39,10 +34,8 @@ import {
|
|
|
39
34
|
import {
|
|
40
35
|
addToVitalHistogram,
|
|
41
36
|
emptyVitalHistogram,
|
|
42
|
-
mergeVitalHistograms,
|
|
43
37
|
vitalDevice,
|
|
44
38
|
VITAL_ALL,
|
|
45
|
-
VITAL_OTHER_PAGES,
|
|
46
39
|
type VitalMetric,
|
|
47
40
|
} from "./vitals.js";
|
|
48
41
|
import { fail } from "./errors.js";
|
|
@@ -63,6 +56,7 @@ import {
|
|
|
63
56
|
measureBatchBytes,
|
|
64
57
|
normalizeRoute,
|
|
65
58
|
originAllowed,
|
|
59
|
+
publicUrlHost,
|
|
66
60
|
sanitizeContext,
|
|
67
61
|
sanitizeCurrency,
|
|
68
62
|
sanitizeEvent,
|
|
@@ -81,6 +75,14 @@ import {
|
|
|
81
75
|
type IngestContext,
|
|
82
76
|
type TrackerEvent,
|
|
83
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";
|
|
84
86
|
|
|
85
87
|
type DimensionType =
|
|
86
88
|
| "source"
|
|
@@ -90,13 +92,20 @@ type DimensionType =
|
|
|
90
92
|
| "device"
|
|
91
93
|
| "browser"
|
|
92
94
|
| "event"
|
|
93
|
-
| "goal"
|
|
95
|
+
| "goal"
|
|
96
|
+
| "medium"
|
|
97
|
+
| "outbound";
|
|
94
98
|
|
|
95
99
|
type Dimension = { type: DimensionType; value: string };
|
|
96
|
-
type DimensionDelta = Dimension & {
|
|
100
|
+
type DimensionDelta = Dimension & {
|
|
101
|
+
count: number;
|
|
102
|
+
revenueCents: number;
|
|
103
|
+
pageviews: number;
|
|
104
|
+
sessions: number;
|
|
105
|
+
};
|
|
97
106
|
type AggregateDelta = {
|
|
98
107
|
siteId: Id<"sites">;
|
|
99
|
-
granularity: "hour" | "day";
|
|
108
|
+
granularity: "hour" | "day" | "localDay";
|
|
100
109
|
bucketStart: number;
|
|
101
110
|
shard: number;
|
|
102
111
|
events: number;
|
|
@@ -110,74 +119,11 @@ type AggregateDelta = {
|
|
|
110
119
|
conversions: number;
|
|
111
120
|
revenueCents: number;
|
|
112
121
|
dimensions: Doc<"aggregateBuckets">["dimensions"];
|
|
122
|
+
durationMs: number;
|
|
123
|
+
continuedSessions: number;
|
|
124
|
+
newVisitors: number;
|
|
113
125
|
};
|
|
114
126
|
type AggregateDeltas = Map<string, AggregateDelta>;
|
|
115
|
-
type GoalRollupDelta = {
|
|
116
|
-
siteId: Id<"sites">;
|
|
117
|
-
goalId: Id<"goals">;
|
|
118
|
-
bucketStart: number;
|
|
119
|
-
shard: number;
|
|
120
|
-
goalKey: string;
|
|
121
|
-
completions: number;
|
|
122
|
-
valueCents: number;
|
|
123
|
-
};
|
|
124
|
-
type FunnelRollupDelta = {
|
|
125
|
-
siteId: Id<"sites">;
|
|
126
|
-
funnelId: Id<"funnels">;
|
|
127
|
-
bucketStart: number;
|
|
128
|
-
shard: number;
|
|
129
|
-
steps: Map<
|
|
130
|
-
number,
|
|
131
|
-
{
|
|
132
|
-
step: number;
|
|
133
|
-
entrants: number;
|
|
134
|
-
completions: number;
|
|
135
|
-
}
|
|
136
|
-
>;
|
|
137
|
-
};
|
|
138
|
-
type AffiliateRollupDelta = {
|
|
139
|
-
siteId: Id<"sites">;
|
|
140
|
-
affiliateId: Id<"affiliates">;
|
|
141
|
-
bucketStart: number;
|
|
142
|
-
shard: number;
|
|
143
|
-
affiliateSlug: string;
|
|
144
|
-
visits: number;
|
|
145
|
-
visitorSketch: VisitorSketch;
|
|
146
|
-
conversions: number;
|
|
147
|
-
revenueCents: number;
|
|
148
|
-
commissionCents: number;
|
|
149
|
-
};
|
|
150
|
-
type VitalRollupDelta = {
|
|
151
|
-
siteId: Id<"sites">;
|
|
152
|
-
bucketStart: number;
|
|
153
|
-
device: string;
|
|
154
|
-
page: string;
|
|
155
|
-
shard: number;
|
|
156
|
-
metrics: Map<
|
|
157
|
-
VitalMetric,
|
|
158
|
-
{ count: number; sum: number; histogram: number[] }
|
|
159
|
-
>;
|
|
160
|
-
};
|
|
161
|
-
type RouteRollupDelta = {
|
|
162
|
-
siteId: Id<"sites">;
|
|
163
|
-
bucketStart: number;
|
|
164
|
-
shard: number;
|
|
165
|
-
route: string;
|
|
166
|
-
pageviews: number;
|
|
167
|
-
entries: number;
|
|
168
|
-
continued: number;
|
|
169
|
-
dwellMs: number;
|
|
170
|
-
dwellSamples: number;
|
|
171
|
-
visitorSketch: VisitorSketch;
|
|
172
|
-
outgoing: Map<string, number>;
|
|
173
|
-
};
|
|
174
|
-
type ReportRollupDeltas = {
|
|
175
|
-
goals: Map<string, GoalRollupDelta>;
|
|
176
|
-
funnels: Map<string, FunnelRollupDelta>;
|
|
177
|
-
affiliates: Map<string, AffiliateRollupDelta>;
|
|
178
|
-
vitals: Map<string, VitalRollupDelta>;
|
|
179
|
-
routes: Map<string, RouteRollupDelta>;
|
|
180
|
-
};
|
|
181
127
|
type AttributionSnapshot = {
|
|
182
128
|
affiliateId: Id<"affiliates">;
|
|
183
129
|
affiliateSlug: string;
|
|
@@ -189,10 +135,17 @@ type SessionState = {
|
|
|
189
135
|
isNewSession: boolean;
|
|
190
136
|
isNewVisitorHour: boolean;
|
|
191
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;
|
|
192
144
|
startedAt: number;
|
|
193
145
|
source: string;
|
|
194
146
|
visitorKey: string;
|
|
195
147
|
utmCampaign?: string;
|
|
148
|
+
utmMedium?: string;
|
|
196
149
|
country?: string;
|
|
197
150
|
city?: string;
|
|
198
151
|
latitude?: number;
|
|
@@ -203,8 +156,28 @@ type SessionState = {
|
|
|
203
156
|
affiliateSlug?: string;
|
|
204
157
|
affiliateAttributedAt?: number;
|
|
205
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;
|
|
206
165
|
};
|
|
207
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
|
+
|
|
208
181
|
type SessionRateWindowState = {
|
|
209
182
|
siteId: Id<"sites">;
|
|
210
183
|
sessionId: string;
|
|
@@ -280,6 +253,7 @@ export const ingestBatch = mutation({
|
|
|
280
253
|
now,
|
|
281
254
|
);
|
|
282
255
|
const context = sanitizeContext(args.context);
|
|
256
|
+
const timezone = localDayTimezone(site.timezone);
|
|
283
257
|
const aggregateDeltas: AggregateDeltas = new Map();
|
|
284
258
|
const reportRollupDeltas = createReportRollupDeltas();
|
|
285
259
|
const sessionRateWindows = new Map<string, SessionRateWindowState>();
|
|
@@ -415,7 +389,7 @@ export const ingestBatch = mutation({
|
|
|
415
389
|
rejected += 1;
|
|
416
390
|
continue;
|
|
417
391
|
}
|
|
418
|
-
await accountHeartbeat(
|
|
392
|
+
const durationDeltaMs = await accountHeartbeat(
|
|
419
393
|
ctx,
|
|
420
394
|
args.siteId,
|
|
421
395
|
existingSession,
|
|
@@ -424,6 +398,16 @@ export const ingestBatch = mutation({
|
|
|
424
398
|
now,
|
|
425
399
|
batchState,
|
|
426
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
|
+
);
|
|
427
411
|
accepted += 1;
|
|
428
412
|
continue;
|
|
429
413
|
}
|
|
@@ -551,6 +535,7 @@ export const ingestBatch = mutation({
|
|
|
551
535
|
aggregateDeltas,
|
|
552
536
|
reportRollupDeltas,
|
|
553
537
|
recordTelemetry: true,
|
|
538
|
+
timezone,
|
|
554
539
|
financialConversion: false,
|
|
555
540
|
trustedGoalKey: undefined,
|
|
556
541
|
now,
|
|
@@ -567,7 +552,7 @@ export const ingestBatch = mutation({
|
|
|
567
552
|
await flushBatchState(ctx, batchState);
|
|
568
553
|
await flushSessionRateLimits(ctx, sessionRateWindows, now);
|
|
569
554
|
await flushAggregates(ctx, aggregateDeltas);
|
|
570
|
-
await
|
|
555
|
+
await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
|
|
571
556
|
return { accepted, duplicates, rejected };
|
|
572
557
|
},
|
|
573
558
|
});
|
|
@@ -680,6 +665,7 @@ export const trackConversion = mutation({
|
|
|
680
665
|
aggregateDeltas,
|
|
681
666
|
reportRollupDeltas,
|
|
682
667
|
recordTelemetry: existingTelemetry === null && !telemetry.saturated,
|
|
668
|
+
timezone: localDayTimezone(site.timezone),
|
|
683
669
|
financialConversion: true,
|
|
684
670
|
trustedGoalKey,
|
|
685
671
|
now: Date.now(),
|
|
@@ -693,8 +679,9 @@ export const trackConversion = mutation({
|
|
|
693
679
|
);
|
|
694
680
|
}
|
|
695
681
|
await flushAggregates(ctx, aggregateDeltas);
|
|
696
|
-
|
|
697
|
-
await
|
|
682
|
+
const now = Date.now();
|
|
683
|
+
await enqueueReportRollups(ctx, args.siteId, reportRollupDeltas, now);
|
|
684
|
+
await ensureAnalyticsControl(ctx, args.siteId, now);
|
|
698
685
|
return { accepted: 1, duplicates: 0, rejected: 0 };
|
|
699
686
|
},
|
|
700
687
|
});
|
|
@@ -815,6 +802,8 @@ async function accountEvent(
|
|
|
815
802
|
aggregateDeltas: AggregateDeltas;
|
|
816
803
|
reportRollupDeltas: ReportRollupDeltas;
|
|
817
804
|
recordTelemetry: boolean;
|
|
805
|
+
/** The site's timezone when it keeps local-day buckets. */
|
|
806
|
+
timezone?: string;
|
|
818
807
|
financialConversion: boolean;
|
|
819
808
|
trustedGoalKey: string | undefined;
|
|
820
809
|
now: number;
|
|
@@ -905,15 +894,19 @@ async function accountEvent(
|
|
|
905
894
|
matchedGoalKeys.push(goal.key);
|
|
906
895
|
}
|
|
907
896
|
|
|
897
|
+
let reach: FunnelReach | undefined;
|
|
908
898
|
for (const funnel of input.funnels) {
|
|
909
899
|
if (funnel.active) {
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
900
|
+
reach = furtherReach(
|
|
901
|
+
reach,
|
|
902
|
+
await advanceFunnel(
|
|
903
|
+
ctx,
|
|
904
|
+
input.siteId,
|
|
905
|
+
funnel,
|
|
906
|
+
event,
|
|
907
|
+
input.reportRollupDeltas,
|
|
908
|
+
input.batchState,
|
|
909
|
+
),
|
|
917
910
|
);
|
|
918
911
|
}
|
|
919
912
|
}
|
|
@@ -929,12 +922,26 @@ async function accountEvent(
|
|
|
929
922
|
);
|
|
930
923
|
}
|
|
931
924
|
|
|
932
|
-
const campaignDimensions: Dimension[] =
|
|
933
|
-
|
|
934
|
-
:
|
|
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;
|
|
935
939
|
const dimensions: Dimension[] = [
|
|
936
940
|
{ type: "source", value: session.source },
|
|
937
941
|
...campaignDimensions,
|
|
942
|
+
...(outboundHost
|
|
943
|
+
? [{ type: "outbound", value: outboundHost } satisfies Dimension]
|
|
944
|
+
: []),
|
|
938
945
|
{ type: "page", value: event.path },
|
|
939
946
|
{ type: "country", value: session.country ?? "unknown" },
|
|
940
947
|
{ type: "device", value: input.context.device ?? "unknown" },
|
|
@@ -953,6 +960,7 @@ async function accountEvent(
|
|
|
953
960
|
dimensions,
|
|
954
961
|
input.recordTelemetry,
|
|
955
962
|
input.financialConversion,
|
|
963
|
+
input.timezone,
|
|
956
964
|
);
|
|
957
965
|
|
|
958
966
|
if (
|
|
@@ -967,6 +975,7 @@ async function accountEvent(
|
|
|
967
975
|
session,
|
|
968
976
|
input.now,
|
|
969
977
|
input.batchState,
|
|
978
|
+
reach,
|
|
970
979
|
);
|
|
971
980
|
}
|
|
972
981
|
return session;
|
|
@@ -990,9 +999,16 @@ async function accountTrustedSession(
|
|
|
990
999
|
isNewSession: false,
|
|
991
1000
|
isNewVisitorHour: false,
|
|
992
1001
|
isNewVisitorDay: false,
|
|
1002
|
+
isNewVisitor: false,
|
|
1003
|
+
durationDeltaMs: 0,
|
|
1004
|
+
continued: false,
|
|
993
1005
|
startedAt: existing.startedAt,
|
|
994
1006
|
source: existing.source,
|
|
995
1007
|
utmCampaign: existing.utmCampaign,
|
|
1008
|
+
utmMedium: existing.utmMedium,
|
|
1009
|
+
entryPath: existing.entryPath,
|
|
1010
|
+
conversionCount: existing.conversionCount + 1,
|
|
1011
|
+
newVisitor: existing.newVisitor,
|
|
996
1012
|
visitorKey: existing.visitorKey ?? existing.visitorId,
|
|
997
1013
|
country: existing.country,
|
|
998
1014
|
city: existing.city,
|
|
@@ -1104,9 +1120,16 @@ async function upsertSession(
|
|
|
1104
1120
|
isNewVisitorDay:
|
|
1105
1121
|
Math.floor(existing.lastSeenAt / DAY_MS) <
|
|
1106
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,
|
|
1107
1126
|
startedAt,
|
|
1108
1127
|
source: existing.source,
|
|
1109
1128
|
utmCampaign: existing.utmCampaign,
|
|
1129
|
+
utmMedium: existing.utmMedium,
|
|
1130
|
+
entryPath: existing.entryPath,
|
|
1131
|
+
conversionCount: updates.conversionCount,
|
|
1132
|
+
newVisitor: existing.newVisitor,
|
|
1110
1133
|
visitorKey,
|
|
1111
1134
|
country: context.country ?? existing.country,
|
|
1112
1135
|
city: context.city ?? existing.city,
|
|
@@ -1139,6 +1162,19 @@ async function upsertSession(
|
|
|
1139
1162
|
}
|
|
1140
1163
|
}
|
|
1141
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
|
+
}
|
|
1142
1178
|
const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
|
|
1143
1179
|
siteId,
|
|
1144
1180
|
sessionId: event.sessionId,
|
|
@@ -1148,6 +1184,7 @@ async function upsertSession(
|
|
|
1148
1184
|
lastSeenAt: event.timestamp,
|
|
1149
1185
|
entryPath: event.path,
|
|
1150
1186
|
exitPath: event.path,
|
|
1187
|
+
newVisitor: isNewVisitor,
|
|
1151
1188
|
...(event.referrer ? { referrer: event.referrer } : {}),
|
|
1152
1189
|
source,
|
|
1153
1190
|
...(utmSource ? { utmSource } : {}),
|
|
@@ -1201,9 +1238,16 @@ async function upsertSession(
|
|
|
1201
1238
|
priorVisit === null ||
|
|
1202
1239
|
Math.floor(priorVisit.lastSeenAt / DAY_MS) <
|
|
1203
1240
|
Math.floor(event.timestamp / DAY_MS),
|
|
1241
|
+
isNewVisitor,
|
|
1242
|
+
durationDeltaMs: 0,
|
|
1243
|
+
continued: false,
|
|
1204
1244
|
startedAt: event.timestamp,
|
|
1205
1245
|
source,
|
|
1206
1246
|
utmCampaign,
|
|
1247
|
+
utmMedium,
|
|
1248
|
+
entryPath: event.path,
|
|
1249
|
+
conversionCount: Number(financialConversion),
|
|
1250
|
+
newVisitor: isNewVisitor,
|
|
1207
1251
|
visitorKey,
|
|
1208
1252
|
country: context.country,
|
|
1209
1253
|
city: context.city,
|
|
@@ -1361,7 +1405,7 @@ async function accountHeartbeat(
|
|
|
1361
1405
|
context: IngestContext,
|
|
1362
1406
|
now: number,
|
|
1363
1407
|
batchState?: BatchState,
|
|
1364
|
-
) {
|
|
1408
|
+
): Promise<number> {
|
|
1365
1409
|
const startedAt = Math.min(session.startedAt, event.timestamp);
|
|
1366
1410
|
const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
|
|
1367
1411
|
const updates = {
|
|
@@ -1388,6 +1432,7 @@ async function accountHeartbeat(
|
|
|
1388
1432
|
} else {
|
|
1389
1433
|
await ctx.db.patch("sessions", session._id, updates);
|
|
1390
1434
|
}
|
|
1435
|
+
const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
|
|
1391
1436
|
await updateLiveSession(
|
|
1392
1437
|
ctx,
|
|
1393
1438
|
siteId,
|
|
@@ -1397,6 +1442,9 @@ async function accountHeartbeat(
|
|
|
1397
1442
|
isNewSession: false,
|
|
1398
1443
|
isNewVisitorHour: false,
|
|
1399
1444
|
isNewVisitorDay: false,
|
|
1445
|
+
isNewVisitor: false,
|
|
1446
|
+
durationDeltaMs: 0,
|
|
1447
|
+
continued: false,
|
|
1400
1448
|
startedAt,
|
|
1401
1449
|
source: session.source,
|
|
1402
1450
|
visitorKey: session.visitorKey ?? session.visitorId,
|
|
@@ -1407,10 +1455,14 @@ async function accountHeartbeat(
|
|
|
1407
1455
|
pageviewCount: session.pageviewCount,
|
|
1408
1456
|
eventCount: session.eventCount,
|
|
1409
1457
|
affiliateSlug: session.affiliateSlug,
|
|
1458
|
+
entryPath: session.entryPath,
|
|
1459
|
+
conversionCount: session.conversionCount,
|
|
1460
|
+
newVisitor: session.newVisitor,
|
|
1410
1461
|
},
|
|
1411
1462
|
now,
|
|
1412
1463
|
batchState,
|
|
1413
1464
|
);
|
|
1465
|
+
return durationDeltaMs;
|
|
1414
1466
|
}
|
|
1415
1467
|
|
|
1416
1468
|
async function consumeSiteRateLimit(
|
|
@@ -1568,6 +1620,26 @@ async function flushSessionRateLimits(
|
|
|
1568
1620
|
}
|
|
1569
1621
|
}
|
|
1570
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
|
+
|
|
1571
1643
|
async function updateLiveSession(
|
|
1572
1644
|
ctx: MutationCtx,
|
|
1573
1645
|
siteId: Id<"sites">,
|
|
@@ -1576,6 +1648,7 @@ async function updateLiveSession(
|
|
|
1576
1648
|
session: SessionState,
|
|
1577
1649
|
now: number,
|
|
1578
1650
|
batchState?: BatchState,
|
|
1651
|
+
reach?: FunnelReach,
|
|
1579
1652
|
) {
|
|
1580
1653
|
await ensureLiveSweep(ctx, siteId, now, batchState);
|
|
1581
1654
|
const liveState = batchState
|
|
@@ -1620,6 +1693,7 @@ async function updateLiveSession(
|
|
|
1620
1693
|
device: context.device ?? existing.device,
|
|
1621
1694
|
pageviewCount: session.pageviewCount,
|
|
1622
1695
|
eventCount: session.eventCount,
|
|
1696
|
+
...liveIntentFields(session, furtherReach(existing.funnel, reach)),
|
|
1623
1697
|
// The live map animates a visitor from the route they left to the one
|
|
1624
1698
|
// they reached; a heartbeat or click on the same page is not a move.
|
|
1625
1699
|
...(event.type === "pageview" && event.path !== existing.path
|
|
@@ -1659,6 +1733,7 @@ async function updateLiveSession(
|
|
|
1659
1733
|
device: context.device ?? "unknown",
|
|
1660
1734
|
pageviewCount: session.pageviewCount,
|
|
1661
1735
|
eventCount: session.eventCount,
|
|
1736
|
+
...liveIntentFields(session, reach),
|
|
1662
1737
|
...(event.type === "heartbeat"
|
|
1663
1738
|
? {
|
|
1664
1739
|
lastHeartbeatEventId: event.eventId,
|
|
@@ -1716,7 +1791,7 @@ async function advanceFunnel(
|
|
|
1716
1791
|
event: TrackerEvent,
|
|
1717
1792
|
reportRollupDeltas: ReportRollupDeltas,
|
|
1718
1793
|
batchState?: BatchState,
|
|
1719
|
-
) {
|
|
1794
|
+
): Promise<FunnelReach | undefined> {
|
|
1720
1795
|
const cacheKey = JSON.stringify([funnel._id, event.visitorId]);
|
|
1721
1796
|
let progressState = batchState?.funnelProgress.get(cacheKey);
|
|
1722
1797
|
if (batchState && !progressState) {
|
|
@@ -1762,9 +1837,16 @@ async function advanceFunnel(
|
|
|
1762
1837
|
_id: progressId,
|
|
1763
1838
|
};
|
|
1764
1839
|
}
|
|
1765
|
-
|
|
1840
|
+
foldFunnelStepDelta(
|
|
1841
|
+
reportRollupDeltas,
|
|
1842
|
+
siteId,
|
|
1843
|
+
funnel._id,
|
|
1844
|
+
event,
|
|
1845
|
+
0,
|
|
1846
|
+
false,
|
|
1847
|
+
);
|
|
1766
1848
|
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1767
|
-
return;
|
|
1849
|
+
return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
|
|
1768
1850
|
}
|
|
1769
1851
|
if (
|
|
1770
1852
|
progress.completedAt !== undefined ||
|
|
@@ -1793,9 +1875,16 @@ async function advanceFunnel(
|
|
|
1793
1875
|
completedAt: undefined,
|
|
1794
1876
|
});
|
|
1795
1877
|
}
|
|
1796
|
-
|
|
1878
|
+
foldFunnelStepDelta(
|
|
1879
|
+
reportRollupDeltas,
|
|
1880
|
+
siteId,
|
|
1881
|
+
funnel._id,
|
|
1882
|
+
event,
|
|
1883
|
+
0,
|
|
1884
|
+
false,
|
|
1885
|
+
);
|
|
1797
1886
|
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1798
|
-
return;
|
|
1887
|
+
return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
|
|
1799
1888
|
}
|
|
1800
1889
|
const nextStep = funnel.steps[progress.currentStep];
|
|
1801
1890
|
if (!nextStep || !stepMatches(nextStep, event)) return;
|
|
@@ -1813,8 +1902,8 @@ async function advanceFunnel(
|
|
|
1813
1902
|
} else {
|
|
1814
1903
|
await ctx.db.patch("funnelProgress", progress._id, updates);
|
|
1815
1904
|
}
|
|
1816
|
-
|
|
1817
|
-
|
|
1905
|
+
foldFunnelStepDelta(
|
|
1906
|
+
reportRollupDeltas,
|
|
1818
1907
|
siteId,
|
|
1819
1908
|
funnel._id,
|
|
1820
1909
|
event,
|
|
@@ -1829,6 +1918,11 @@ async function advanceFunnel(
|
|
|
1829
1918
|
progress.currentStep,
|
|
1830
1919
|
completed,
|
|
1831
1920
|
);
|
|
1921
|
+
return {
|
|
1922
|
+
funnelId: funnel._id,
|
|
1923
|
+
step: currentStep,
|
|
1924
|
+
steps: funnel.steps.length,
|
|
1925
|
+
};
|
|
1832
1926
|
}
|
|
1833
1927
|
|
|
1834
1928
|
function stepMatches(
|
|
@@ -1840,47 +1934,6 @@ function stepMatches(
|
|
|
1840
1934
|
: event.type === "custom" && event.name === step.value;
|
|
1841
1935
|
}
|
|
1842
1936
|
|
|
1843
|
-
async function incrementFunnelStep(
|
|
1844
|
-
ctx: MutationCtx,
|
|
1845
|
-
siteId: Id<"sites">,
|
|
1846
|
-
funnelId: Id<"funnels">,
|
|
1847
|
-
event: TrackerEvent,
|
|
1848
|
-
step: number,
|
|
1849
|
-
completed: boolean,
|
|
1850
|
-
) {
|
|
1851
|
-
const bucketStart = Math.floor(event.timestamp / DAY_MS) * DAY_MS;
|
|
1852
|
-
const shard = stableHash(event.visitorId) % SECONDARY_AGGREGATE_SHARDS;
|
|
1853
|
-
const bucket = await ctx.db
|
|
1854
|
-
.query("funnelStepBuckets")
|
|
1855
|
-
.withIndex(
|
|
1856
|
-
"by_siteId_and_funnelId_and_bucketStart_and_step_and_shard",
|
|
1857
|
-
(range) =>
|
|
1858
|
-
range
|
|
1859
|
-
.eq("siteId", siteId)
|
|
1860
|
-
.eq("funnelId", funnelId)
|
|
1861
|
-
.eq("bucketStart", bucketStart)
|
|
1862
|
-
.eq("step", step)
|
|
1863
|
-
.eq("shard", shard),
|
|
1864
|
-
)
|
|
1865
|
-
.unique();
|
|
1866
|
-
if (bucket) {
|
|
1867
|
-
await ctx.db.patch("funnelStepBuckets", bucket._id, {
|
|
1868
|
-
entrants: bucket.entrants + 1,
|
|
1869
|
-
completions: bucket.completions + (completed ? 1 : 0),
|
|
1870
|
-
});
|
|
1871
|
-
} else {
|
|
1872
|
-
await ctx.db.insert("funnelStepBuckets", {
|
|
1873
|
-
siteId,
|
|
1874
|
-
funnelId,
|
|
1875
|
-
bucketStart,
|
|
1876
|
-
step,
|
|
1877
|
-
shard,
|
|
1878
|
-
entrants: 1,
|
|
1879
|
-
completions: completed ? 1 : 0,
|
|
1880
|
-
});
|
|
1881
|
-
}
|
|
1882
|
-
}
|
|
1883
|
-
|
|
1884
1937
|
async function accountConversion(
|
|
1885
1938
|
ctx: MutationCtx,
|
|
1886
1939
|
siteId: Id<"sites">,
|
|
@@ -1963,31 +2016,6 @@ async function accountConversion(
|
|
|
1963
2016
|
}
|
|
1964
2017
|
}
|
|
1965
2018
|
|
|
1966
|
-
function createReportRollupDeltas(): ReportRollupDeltas {
|
|
1967
|
-
return {
|
|
1968
|
-
goals: new Map(),
|
|
1969
|
-
funnels: new Map(),
|
|
1970
|
-
affiliates: new Map(),
|
|
1971
|
-
vitals: new Map(),
|
|
1972
|
-
routes: new Map(),
|
|
1973
|
-
};
|
|
1974
|
-
}
|
|
1975
|
-
|
|
1976
|
-
function reportRollupCoordinates(
|
|
1977
|
-
siteId: Id<"sites">,
|
|
1978
|
-
definitionId: string,
|
|
1979
|
-
timestamp: number,
|
|
1980
|
-
visitorId: string,
|
|
1981
|
-
) {
|
|
1982
|
-
const bucketStart = Math.floor(timestamp / DAY_MS) * DAY_MS;
|
|
1983
|
-
const shard = stableHash(visitorId) % REPORT_ROLLUP_SHARDS;
|
|
1984
|
-
return {
|
|
1985
|
-
bucketStart,
|
|
1986
|
-
shard,
|
|
1987
|
-
key: `${siteId}:${definitionId}:${bucketStart}:${shard}`,
|
|
1988
|
-
};
|
|
1989
|
-
}
|
|
1990
|
-
|
|
1991
2019
|
function foldGoalRollup(
|
|
1992
2020
|
deltas: ReportRollupDeltas,
|
|
1993
2021
|
siteId: Id<"sites">,
|
|
@@ -2046,6 +2074,31 @@ function foldFunnelRollup(
|
|
|
2046
2074
|
deltas.funnels.set(coordinates.key, rollup);
|
|
2047
2075
|
}
|
|
2048
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
|
+
|
|
2049
2102
|
function foldAffiliateVisitRollup(
|
|
2050
2103
|
deltas: ReportRollupDeltas,
|
|
2051
2104
|
siteId: Id<"sites">,
|
|
@@ -2066,13 +2119,13 @@ function foldAffiliateVisitRollup(
|
|
|
2066
2119
|
shard: coordinates.shard,
|
|
2067
2120
|
affiliateSlug: attribution.affiliateSlug,
|
|
2068
2121
|
visits: 0,
|
|
2069
|
-
|
|
2122
|
+
visitorKeys: new Set<string>(),
|
|
2070
2123
|
conversions: 0,
|
|
2071
2124
|
revenueCents: 0,
|
|
2072
2125
|
commissionCents: 0,
|
|
2073
2126
|
};
|
|
2074
2127
|
current.visits += 1;
|
|
2075
|
-
current.
|
|
2128
|
+
current.visitorKeys.add(visitorKey);
|
|
2076
2129
|
deltas.affiliates.set(coordinates.key, current);
|
|
2077
2130
|
}
|
|
2078
2131
|
|
|
@@ -2098,7 +2151,7 @@ function foldAffiliateConversionRollup(
|
|
|
2098
2151
|
shard: coordinates.shard,
|
|
2099
2152
|
affiliateSlug,
|
|
2100
2153
|
visits: 0,
|
|
2101
|
-
|
|
2154
|
+
visitorKeys: new Set<string>(),
|
|
2102
2155
|
conversions: 0,
|
|
2103
2156
|
revenueCents: 0,
|
|
2104
2157
|
commissionCents: 0,
|
|
@@ -2180,7 +2233,7 @@ function foldRouteDelta(
|
|
|
2180
2233
|
continued: 0,
|
|
2181
2234
|
dwellMs: 0,
|
|
2182
2235
|
dwellSamples: 0,
|
|
2183
|
-
|
|
2236
|
+
visitorKeys: new Set<string>(),
|
|
2184
2237
|
outgoing: new Map<string, number>(),
|
|
2185
2238
|
};
|
|
2186
2239
|
apply(current);
|
|
@@ -2213,7 +2266,7 @@ function foldRouteRollups(
|
|
|
2213
2266
|
(delta) => {
|
|
2214
2267
|
delta.pageviews += 1;
|
|
2215
2268
|
if (firstPageview) delta.entries += 1;
|
|
2216
|
-
delta.
|
|
2269
|
+
delta.visitorKeys.add(session.visitorKey);
|
|
2217
2270
|
},
|
|
2218
2271
|
);
|
|
2219
2272
|
// Out-of-order telemetry keeps its view but cannot say where it came from.
|
|
@@ -2249,272 +2302,6 @@ function foldRouteRollups(
|
|
|
2249
2302
|
}
|
|
2250
2303
|
}
|
|
2251
2304
|
|
|
2252
|
-
async function upsertRouteRollup(
|
|
2253
|
-
ctx: MutationCtx,
|
|
2254
|
-
delta: RouteRollupDelta,
|
|
2255
|
-
dayRowCounts: Map<string, number>,
|
|
2256
|
-
) {
|
|
2257
|
-
const existing = await ctx.db
|
|
2258
|
-
.query("routeDailyRollups")
|
|
2259
|
-
.withIndex("by_siteId_and_route_and_bucketStart_and_shard", (range) =>
|
|
2260
|
-
range
|
|
2261
|
-
.eq("siteId", delta.siteId)
|
|
2262
|
-
.eq("route", delta.route)
|
|
2263
|
-
.eq("bucketStart", delta.bucketStart)
|
|
2264
|
-
.eq("shard", delta.shard),
|
|
2265
|
-
)
|
|
2266
|
-
.unique();
|
|
2267
|
-
if (!existing && delta.route !== SITE_MAP_OTHER_ROUTE) {
|
|
2268
|
-
// A new route row spends one bounded read per batch to respect the daily
|
|
2269
|
-
// cap; past it, the day's remaining routes fold into "(other)".
|
|
2270
|
-
const capRows = MAX_SITE_MAP_ROUTES_PER_DAY * REPORT_ROLLUP_SHARDS;
|
|
2271
|
-
const dayKey = `${delta.siteId}:${delta.bucketStart}`;
|
|
2272
|
-
let dayRows = dayRowCounts.get(dayKey);
|
|
2273
|
-
if (dayRows === undefined) {
|
|
2274
|
-
dayRows = (
|
|
2275
|
-
await ctx.db
|
|
2276
|
-
.query("routeDailyRollups")
|
|
2277
|
-
.withIndex("by_siteId_and_bucketStart", (range) =>
|
|
2278
|
-
range
|
|
2279
|
-
.eq("siteId", delta.siteId)
|
|
2280
|
-
.eq("bucketStart", delta.bucketStart),
|
|
2281
|
-
)
|
|
2282
|
-
.take(capRows + 1)
|
|
2283
|
-
).length;
|
|
2284
|
-
}
|
|
2285
|
-
if (dayRows >= capRows) {
|
|
2286
|
-
await upsertRouteRollup(
|
|
2287
|
-
ctx,
|
|
2288
|
-
{ ...delta, route: SITE_MAP_OTHER_ROUTE },
|
|
2289
|
-
dayRowCounts,
|
|
2290
|
-
);
|
|
2291
|
-
return;
|
|
2292
|
-
}
|
|
2293
|
-
dayRowCounts.set(dayKey, dayRows + 1);
|
|
2294
|
-
}
|
|
2295
|
-
const outgoing = existing?.outgoing.map((entry) => ({ ...entry })) ?? [];
|
|
2296
|
-
for (const [target, count] of delta.outgoing) {
|
|
2297
|
-
let entry = outgoing.find((candidate) => candidate.route === target);
|
|
2298
|
-
if (!entry) {
|
|
2299
|
-
const named = outgoing.filter(
|
|
2300
|
-
(candidate) => candidate.route !== SITE_MAP_OTHER_ROUTE,
|
|
2301
|
-
).length;
|
|
2302
|
-
const route =
|
|
2303
|
-
target !== SITE_MAP_OTHER_ROUTE && named >= ROUTE_TRANSITION_SLOTS
|
|
2304
|
-
? SITE_MAP_OTHER_ROUTE
|
|
2305
|
-
: target;
|
|
2306
|
-
entry = outgoing.find((candidate) => candidate.route === route);
|
|
2307
|
-
if (!entry) {
|
|
2308
|
-
entry = { route, count: 0 };
|
|
2309
|
-
outgoing.push(entry);
|
|
2310
|
-
}
|
|
2311
|
-
}
|
|
2312
|
-
entry.count += count;
|
|
2313
|
-
}
|
|
2314
|
-
const fields = {
|
|
2315
|
-
siteId: delta.siteId,
|
|
2316
|
-
bucketStart: delta.bucketStart,
|
|
2317
|
-
shard: delta.shard,
|
|
2318
|
-
generation: 1 as const,
|
|
2319
|
-
route: delta.route,
|
|
2320
|
-
pageviews: (existing?.pageviews ?? 0) + delta.pageviews,
|
|
2321
|
-
entries: (existing?.entries ?? 0) + delta.entries,
|
|
2322
|
-
continued: (existing?.continued ?? 0) + delta.continued,
|
|
2323
|
-
dwellMs: (existing?.dwellMs ?? 0) + delta.dwellMs,
|
|
2324
|
-
dwellSamples: (existing?.dwellSamples ?? 0) + delta.dwellSamples,
|
|
2325
|
-
visitorSketch: unionVisitorSketches(
|
|
2326
|
-
existing?.visitorSketch ?? createVisitorSketch(),
|
|
2327
|
-
delta.visitorSketch,
|
|
2328
|
-
),
|
|
2329
|
-
outgoing,
|
|
2330
|
-
};
|
|
2331
|
-
if (existing) {
|
|
2332
|
-
await ctx.db.replace("routeDailyRollups", existing._id, fields);
|
|
2333
|
-
} else {
|
|
2334
|
-
await ctx.db.insert("routeDailyRollups", fields);
|
|
2335
|
-
}
|
|
2336
|
-
}
|
|
2337
|
-
|
|
2338
|
-
async function upsertVitalRollup(ctx: MutationCtx, delta: VitalRollupDelta) {
|
|
2339
|
-
const existing = await ctx.db
|
|
2340
|
-
.query("vitalRollups")
|
|
2341
|
-
.withIndex("by_key", (range) =>
|
|
2342
|
-
range
|
|
2343
|
-
.eq("siteId", delta.siteId)
|
|
2344
|
-
.eq("bucketStart", delta.bucketStart)
|
|
2345
|
-
.eq("device", delta.device)
|
|
2346
|
-
.eq("page", delta.page)
|
|
2347
|
-
.eq("shard", delta.shard),
|
|
2348
|
-
)
|
|
2349
|
-
.unique();
|
|
2350
|
-
if (
|
|
2351
|
-
!existing &&
|
|
2352
|
-
delta.device === VITAL_ALL &&
|
|
2353
|
-
delta.page !== VITAL_OTHER_PAGES
|
|
2354
|
-
) {
|
|
2355
|
-
// A new page row spends one bounded read to respect the daily page slots;
|
|
2356
|
-
// past the cap, the day's remaining pages fold into "(other)".
|
|
2357
|
-
const pageRows = await ctx.db
|
|
2358
|
-
.query("vitalRollups")
|
|
2359
|
-
.withIndex("by_key", (range) =>
|
|
2360
|
-
range
|
|
2361
|
-
.eq("siteId", delta.siteId)
|
|
2362
|
-
.eq("bucketStart", delta.bucketStart)
|
|
2363
|
-
.eq("device", VITAL_ALL),
|
|
2364
|
-
)
|
|
2365
|
-
.take(VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS + 1);
|
|
2366
|
-
if (pageRows.length > VITAL_PAGE_SLOTS * REPORT_ROLLUP_SHARDS) {
|
|
2367
|
-
await upsertVitalRollup(ctx, { ...delta, page: VITAL_OTHER_PAGES });
|
|
2368
|
-
return;
|
|
2369
|
-
}
|
|
2370
|
-
}
|
|
2371
|
-
const metrics = existing?.metrics.map((entry) => ({ ...entry })) ?? [];
|
|
2372
|
-
for (const [metric, addition] of delta.metrics) {
|
|
2373
|
-
const entry = metrics.find((candidate) => candidate.metric === metric);
|
|
2374
|
-
if (entry) {
|
|
2375
|
-
entry.count += addition.count;
|
|
2376
|
-
entry.sum += addition.sum;
|
|
2377
|
-
entry.histogram = mergeVitalHistograms(
|
|
2378
|
-
entry.histogram,
|
|
2379
|
-
addition.histogram,
|
|
2380
|
-
);
|
|
2381
|
-
} else {
|
|
2382
|
-
metrics.push({
|
|
2383
|
-
metric,
|
|
2384
|
-
count: addition.count,
|
|
2385
|
-
sum: addition.sum,
|
|
2386
|
-
histogram: [...addition.histogram],
|
|
2387
|
-
});
|
|
2388
|
-
}
|
|
2389
|
-
}
|
|
2390
|
-
const fields = {
|
|
2391
|
-
siteId: delta.siteId,
|
|
2392
|
-
bucketStart: delta.bucketStart,
|
|
2393
|
-
device: delta.device,
|
|
2394
|
-
page: delta.page,
|
|
2395
|
-
shard: delta.shard,
|
|
2396
|
-
metrics,
|
|
2397
|
-
};
|
|
2398
|
-
if (existing) {
|
|
2399
|
-
await ctx.db.replace("vitalRollups", existing._id, fields);
|
|
2400
|
-
} else {
|
|
2401
|
-
await ctx.db.insert("vitalRollups", fields);
|
|
2402
|
-
}
|
|
2403
|
-
}
|
|
2404
|
-
|
|
2405
|
-
async function flushReportRollups(
|
|
2406
|
-
ctx: MutationCtx,
|
|
2407
|
-
deltas: ReportRollupDeltas,
|
|
2408
|
-
) {
|
|
2409
|
-
for (const delta of deltas.goals.values()) {
|
|
2410
|
-
const existing = await ctx.db
|
|
2411
|
-
.query("goalDailyRollups")
|
|
2412
|
-
.withIndex("by_siteId_and_goalId_and_bucketStart_and_shard", (range) =>
|
|
2413
|
-
range
|
|
2414
|
-
.eq("siteId", delta.siteId)
|
|
2415
|
-
.eq("goalId", delta.goalId)
|
|
2416
|
-
.eq("bucketStart", delta.bucketStart)
|
|
2417
|
-
.eq("shard", delta.shard),
|
|
2418
|
-
)
|
|
2419
|
-
.unique();
|
|
2420
|
-
const fields = {
|
|
2421
|
-
siteId: delta.siteId,
|
|
2422
|
-
goalId: delta.goalId,
|
|
2423
|
-
bucketStart: delta.bucketStart,
|
|
2424
|
-
shard: delta.shard,
|
|
2425
|
-
generation: 1 as const,
|
|
2426
|
-
goalKey: delta.goalKey,
|
|
2427
|
-
completions: (existing?.completions ?? 0) + delta.completions,
|
|
2428
|
-
valueCents: (existing?.valueCents ?? 0) + delta.valueCents,
|
|
2429
|
-
};
|
|
2430
|
-
if (existing) {
|
|
2431
|
-
await ctx.db.replace("goalDailyRollups", existing._id, fields);
|
|
2432
|
-
} else {
|
|
2433
|
-
await ctx.db.insert("goalDailyRollups", fields);
|
|
2434
|
-
}
|
|
2435
|
-
}
|
|
2436
|
-
for (const delta of deltas.funnels.values()) {
|
|
2437
|
-
const existing = await ctx.db
|
|
2438
|
-
.query("funnelDailyRollups")
|
|
2439
|
-
.withIndex("by_siteId_and_funnelId_and_bucketStart_and_shard", (range) =>
|
|
2440
|
-
range
|
|
2441
|
-
.eq("siteId", delta.siteId)
|
|
2442
|
-
.eq("funnelId", delta.funnelId)
|
|
2443
|
-
.eq("bucketStart", delta.bucketStart)
|
|
2444
|
-
.eq("shard", delta.shard),
|
|
2445
|
-
)
|
|
2446
|
-
.unique();
|
|
2447
|
-
const steps = existing?.steps.map((step) => ({ ...step })) ?? [];
|
|
2448
|
-
for (const addition of delta.steps.values()) {
|
|
2449
|
-
const current = steps.find((step) => step.step === addition.step);
|
|
2450
|
-
if (current) {
|
|
2451
|
-
current.entrants += addition.entrants;
|
|
2452
|
-
current.completions += addition.completions;
|
|
2453
|
-
} else {
|
|
2454
|
-
if (steps.length >= MAX_FUNNEL_STEPS) {
|
|
2455
|
-
fail("LIMIT_EXCEEDED", "daily funnel rollup step limit exceeded");
|
|
2456
|
-
}
|
|
2457
|
-
steps.push(addition);
|
|
2458
|
-
}
|
|
2459
|
-
}
|
|
2460
|
-
const fields = {
|
|
2461
|
-
siteId: delta.siteId,
|
|
2462
|
-
funnelId: delta.funnelId,
|
|
2463
|
-
bucketStart: delta.bucketStart,
|
|
2464
|
-
shard: delta.shard,
|
|
2465
|
-
generation: 1 as const,
|
|
2466
|
-
steps,
|
|
2467
|
-
};
|
|
2468
|
-
if (existing) {
|
|
2469
|
-
await ctx.db.replace("funnelDailyRollups", existing._id, fields);
|
|
2470
|
-
} else {
|
|
2471
|
-
await ctx.db.insert("funnelDailyRollups", fields);
|
|
2472
|
-
}
|
|
2473
|
-
}
|
|
2474
|
-
for (const delta of deltas.affiliates.values()) {
|
|
2475
|
-
const existing = await ctx.db
|
|
2476
|
-
.query("affiliateDailyRollups")
|
|
2477
|
-
.withIndex(
|
|
2478
|
-
"by_siteId_and_affiliateId_and_bucketStart_and_shard",
|
|
2479
|
-
(range) =>
|
|
2480
|
-
range
|
|
2481
|
-
.eq("siteId", delta.siteId)
|
|
2482
|
-
.eq("affiliateId", delta.affiliateId)
|
|
2483
|
-
.eq("bucketStart", delta.bucketStart)
|
|
2484
|
-
.eq("shard", delta.shard),
|
|
2485
|
-
)
|
|
2486
|
-
.unique();
|
|
2487
|
-
const fields = {
|
|
2488
|
-
siteId: delta.siteId,
|
|
2489
|
-
affiliateId: delta.affiliateId,
|
|
2490
|
-
bucketStart: delta.bucketStart,
|
|
2491
|
-
shard: delta.shard,
|
|
2492
|
-
generation: 1 as const,
|
|
2493
|
-
affiliateSlug: delta.affiliateSlug,
|
|
2494
|
-
visits: (existing?.visits ?? 0) + delta.visits,
|
|
2495
|
-
visitorSketch: unionVisitorSketches(
|
|
2496
|
-
existing?.visitorSketch ?? createVisitorSketch(),
|
|
2497
|
-
delta.visitorSketch,
|
|
2498
|
-
),
|
|
2499
|
-
conversions: (existing?.conversions ?? 0) + delta.conversions,
|
|
2500
|
-
revenueCents: (existing?.revenueCents ?? 0) + delta.revenueCents,
|
|
2501
|
-
commissionCents: (existing?.commissionCents ?? 0) + delta.commissionCents,
|
|
2502
|
-
};
|
|
2503
|
-
if (existing) {
|
|
2504
|
-
await ctx.db.replace("affiliateDailyRollups", existing._id, fields);
|
|
2505
|
-
} else {
|
|
2506
|
-
await ctx.db.insert("affiliateDailyRollups", fields);
|
|
2507
|
-
}
|
|
2508
|
-
}
|
|
2509
|
-
const routeDayRowCounts = new Map<string, number>();
|
|
2510
|
-
for (const delta of deltas.routes.values()) {
|
|
2511
|
-
await upsertRouteRollup(ctx, delta, routeDayRowCounts);
|
|
2512
|
-
}
|
|
2513
|
-
for (const delta of deltas.vitals.values()) {
|
|
2514
|
-
await upsertVitalRollup(ctx, delta);
|
|
2515
|
-
}
|
|
2516
|
-
}
|
|
2517
|
-
|
|
2518
2305
|
function foldAggregates(
|
|
2519
2306
|
aggregateDeltas: AggregateDeltas,
|
|
2520
2307
|
siteId: Id<"sites">,
|
|
@@ -2523,19 +2310,25 @@ function foldAggregates(
|
|
|
2523
2310
|
dimensions: Dimension[],
|
|
2524
2311
|
recordTelemetry: boolean,
|
|
2525
2312
|
financialConversion: boolean,
|
|
2313
|
+
timezone?: string,
|
|
2526
2314
|
) {
|
|
2527
2315
|
const revenueCents = financialConversion ? (event.revenueCents ?? 0) : 0;
|
|
2528
2316
|
const dimensionDeltas = dimensions.map((dimension): DimensionDelta => ({
|
|
2529
2317
|
...dimension,
|
|
2530
2318
|
count: Number(recordTelemetry),
|
|
2531
2319
|
revenueCents,
|
|
2320
|
+
pageviews: Number(recordTelemetry && event.type === "pageview"),
|
|
2321
|
+
sessions: Number(recordTelemetry && session.isNewSession),
|
|
2532
2322
|
}));
|
|
2533
|
-
for (const granularity of
|
|
2323
|
+
for (const granularity of aggregateGranularities(timezone)) {
|
|
2534
2324
|
const shard =
|
|
2535
2325
|
stableHash(event.sessionId) %
|
|
2536
2326
|
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
2537
|
-
const
|
|
2538
|
-
|
|
2327
|
+
const bucketStart = aggregateBucketStart(
|
|
2328
|
+
granularity,
|
|
2329
|
+
event.timestamp,
|
|
2330
|
+
timezone,
|
|
2331
|
+
);
|
|
2539
2332
|
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
2540
2333
|
let delta = aggregateDeltas.get(key);
|
|
2541
2334
|
if (!delta) {
|
|
@@ -2555,6 +2348,9 @@ function foldAggregates(
|
|
|
2555
2348
|
conversions: 0,
|
|
2556
2349
|
revenueCents: 0,
|
|
2557
2350
|
dimensions: [],
|
|
2351
|
+
durationMs: 0,
|
|
2352
|
+
continuedSessions: 0,
|
|
2353
|
+
newVisitors: 0,
|
|
2558
2354
|
};
|
|
2559
2355
|
aggregateDeltas.set(key, delta);
|
|
2560
2356
|
}
|
|
@@ -2578,6 +2374,77 @@ function foldAggregates(
|
|
|
2578
2374
|
delta.conversions += Number(financialConversion);
|
|
2579
2375
|
delta.revenueCents += revenueCents;
|
|
2580
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);
|
|
2581
2448
|
}
|
|
2582
2449
|
}
|
|
2583
2450
|
|
|
@@ -2600,7 +2467,11 @@ async function flushAggregates(
|
|
|
2600
2467
|
.unique();
|
|
2601
2468
|
const dimensions = mergeDimensions(
|
|
2602
2469
|
bucket?.dimensions ?? [],
|
|
2603
|
-
delta.dimensions
|
|
2470
|
+
delta.dimensions.map((slot) => ({
|
|
2471
|
+
...slot,
|
|
2472
|
+
pageviews: slot.pageviews ?? 0,
|
|
2473
|
+
sessions: slot.sessions ?? 0,
|
|
2474
|
+
})),
|
|
2604
2475
|
);
|
|
2605
2476
|
const priorSketch = bucket?.visitorSketch;
|
|
2606
2477
|
const visitorSketch = priorSketch
|
|
@@ -2623,6 +2494,10 @@ async function flushAggregates(
|
|
|
2623
2494
|
conversions: bucket.conversions + delta.conversions,
|
|
2624
2495
|
revenueCents: bucket.revenueCents + delta.revenueCents,
|
|
2625
2496
|
dimensions,
|
|
2497
|
+
durationMs: (bucket.durationMs ?? 0) + delta.durationMs,
|
|
2498
|
+
continuedSessions:
|
|
2499
|
+
(bucket.continuedSessions ?? 0) + delta.continuedSessions,
|
|
2500
|
+
newVisitors: (bucket.newVisitors ?? 0) + delta.newVisitors,
|
|
2626
2501
|
});
|
|
2627
2502
|
} else {
|
|
2628
2503
|
await ctx.db.insert("aggregateBuckets", {
|
|
@@ -2642,6 +2517,9 @@ async function flushAggregates(
|
|
|
2642
2517
|
conversions: delta.conversions,
|
|
2643
2518
|
revenueCents: delta.revenueCents,
|
|
2644
2519
|
dimensions,
|
|
2520
|
+
durationMs: delta.durationMs,
|
|
2521
|
+
continuedSessions: delta.continuedSessions,
|
|
2522
|
+
newVisitors: delta.newVisitors,
|
|
2645
2523
|
});
|
|
2646
2524
|
}
|
|
2647
2525
|
}
|
|
@@ -2660,6 +2538,8 @@ function mergeDimensions(
|
|
|
2660
2538
|
if (exact) {
|
|
2661
2539
|
exact.count += addition.count;
|
|
2662
2540
|
exact.revenueCents += addition.revenueCents;
|
|
2541
|
+
exact.pageviews = (exact.pageviews ?? 0) + addition.pageviews;
|
|
2542
|
+
exact.sessions = (exact.sessions ?? 0) + addition.sessions;
|
|
2663
2543
|
continue;
|
|
2664
2544
|
}
|
|
2665
2545
|
|
|
@@ -2677,6 +2557,8 @@ function mergeDimensions(
|
|
|
2677
2557
|
value: addition.value,
|
|
2678
2558
|
count: addition.count,
|
|
2679
2559
|
revenueCents: addition.revenueCents,
|
|
2560
|
+
pageviews: addition.pageviews,
|
|
2561
|
+
sessions: addition.sessions,
|
|
2680
2562
|
});
|
|
2681
2563
|
continue;
|
|
2682
2564
|
}
|
|
@@ -2686,6 +2568,8 @@ function mergeDimensions(
|
|
|
2686
2568
|
if (overflow) {
|
|
2687
2569
|
overflow.count += addition.count;
|
|
2688
2570
|
overflow.revenueCents += addition.revenueCents;
|
|
2571
|
+
overflow.pageviews = (overflow.pageviews ?? 0) + addition.pageviews;
|
|
2572
|
+
overflow.sessions = (overflow.sessions ?? 0) + addition.sessions;
|
|
2689
2573
|
continue;
|
|
2690
2574
|
}
|
|
2691
2575
|
if (addition.count === 0 && addition.revenueCents === 0) continue;
|
|
@@ -2699,6 +2583,8 @@ function mergeDimensions(
|
|
|
2699
2583
|
value: "(other)",
|
|
2700
2584
|
count: addition.count,
|
|
2701
2585
|
revenueCents: addition.revenueCents,
|
|
2586
|
+
pageviews: addition.pageviews,
|
|
2587
|
+
sessions: addition.sessions,
|
|
2702
2588
|
});
|
|
2703
2589
|
continue;
|
|
2704
2590
|
}
|
|
@@ -2710,6 +2596,8 @@ function mergeDimensions(
|
|
|
2710
2596
|
replacement.value = "(other)";
|
|
2711
2597
|
replacement.count += addition.count;
|
|
2712
2598
|
replacement.revenueCents += addition.revenueCents;
|
|
2599
|
+
replacement.pageviews = (replacement.pageviews ?? 0) + addition.pageviews;
|
|
2600
|
+
replacement.sessions = (replacement.sessions ?? 0) + addition.sessions;
|
|
2713
2601
|
}
|
|
2714
2602
|
return result;
|
|
2715
2603
|
}
|