@iann29/rastro 0.3.0 → 0.5.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 +199 -50
- package/agent/integration.md +26 -10
- package/agent/manifest.json +10 -5
- package/agent/manifest.schema.json +14 -5
- package/dist/client/federation.d.ts +28 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +12 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +262 -9
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +119 -2
- 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 +72 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/_generated/server.d.ts +1 -0
- package/dist/component/_generated/server.d.ts.map +1 -1
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/constants.d.ts +9 -2
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +31 -3
- package/dist/component/constants.js.map +1 -1
- package/dist/component/convex.config.d.ts +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/coverage.d.ts +1 -0
- package/dist/component/coverage.d.ts.map +1 -1
- package/dist/component/coverage.js +6 -1
- package/dist/component/coverage.js.map +1 -1
- package/dist/component/eventStore.d.ts +2 -0
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/geo.d.ts +1 -0
- package/dist/component/geo.d.ts.map +1 -1
- package/dist/component/geo.js +1 -1
- package/dist/component/geo.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +65 -2
- package/dist/component/http.js.map +1 -1
- package/dist/component/identity.d.ts +12 -0
- package/dist/component/identity.d.ts.map +1 -1
- package/dist/component/identity.js +30 -2
- package/dist/component/identity.js.map +1 -1
- package/dist/component/ingest.d.ts +36 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +111 -20
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +34 -4
- package/dist/component/live.js.map +1 -1
- package/dist/component/origin.d.ts +70 -0
- package/dist/component/origin.d.ts.map +1 -0
- package/dist/component/origin.js +230 -0
- package/dist/component/origin.js.map +1 -0
- package/dist/component/platforms.d.ts +33 -0
- package/dist/component/platforms.d.ts.map +1 -0
- package/dist/component/platforms.js +328 -0
- package/dist/component/platforms.js.map +1 -0
- package/dist/component/reports.d.ts +27 -72
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +72 -16
- package/dist/component/reports.js.map +1 -1
- package/dist/component/rollupStore.d.ts +7 -4
- package/dist/component/rollupStore.d.ts.map +1 -1
- package/dist/component/rollupStore.js +37 -17
- package/dist/component/rollupStore.js.map +1 -1
- package/dist/component/rollups.d.ts +27 -8
- package/dist/component/rollups.d.ts.map +1 -1
- package/dist/component/rollups.js +110 -32
- package/dist/component/rollups.js.map +1 -1
- package/dist/component/sanitize.d.ts +5 -0
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +33 -1
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +68 -9
- package/dist/component/schema.js +21 -3
- package/dist/component/schema.js.map +1 -1
- package/dist/component/trackedLinks.d.ts +91 -0
- package/dist/component/trackedLinks.d.ts.map +1 -0
- package/dist/component/trackedLinks.js +314 -0
- package/dist/component/trackedLinks.js.map +1 -0
- package/dist/component/useragent.d.ts +6 -0
- package/dist/component/useragent.d.ts.map +1 -1
- package/dist/component/useragent.js +9 -0
- package/dist/component/useragent.js.map +1 -1
- package/dist/component/validators.d.ts +51 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +35 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/tracker/generated.d.ts +11 -11
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +11 -11
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts +1 -0
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +104 -47
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker/vitals.d.ts.map +1 -1
- package/dist/tracker/vitals.js +22 -4
- package/dist/tracker/vitals.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/dist/vitals.min.js +1 -1
- package/docs/upgrading.md +234 -45
- package/llms.txt +9 -4
- package/package.json +2 -2
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +153 -1
- package/src/component/_generated/server.ts +1 -0
- package/src/component/constants.ts +31 -3
- package/src/component/convex.config.ts +3 -0
- package/src/component/coverage.ts +7 -1
- package/src/component/geo.ts +1 -1
- package/src/component/http.ts +71 -2
- package/src/component/identity.ts +41 -2
- package/src/component/ingest.ts +146 -33
- package/src/component/live.ts +51 -4
- package/src/component/origin.ts +273 -0
- package/src/component/platforms.ts +359 -0
- package/src/component/reports.ts +98 -18
- package/src/component/rollupStore.ts +51 -20
- package/src/component/rollups.ts +141 -32
- package/src/component/sanitize.ts +39 -1
- package/src/component/schema.ts +24 -3
- package/src/component/trackedLinks.ts +384 -0
- package/src/component/useragent.ts +11 -0
- package/src/component/validators.ts +71 -0
- package/src/tracker/generated.ts +11 -11
package/src/component/ingest.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
LIVE_SESSION_TTL_MS,
|
|
15
15
|
LIVE_SWEEP_INTERVAL_MS,
|
|
16
16
|
MAX_BATCH_EVENTS,
|
|
17
|
+
MAX_BYTES_PER_CLIENT_WINDOW,
|
|
18
|
+
MAX_EVENTS_PER_CLIENT_WINDOW,
|
|
17
19
|
MAX_SITE_BYTES_PER_SHARD_WINDOW,
|
|
18
20
|
MAX_SITE_EVENTS_PER_SHARD_WINDOW,
|
|
19
21
|
MAX_BYTES_PER_SESSION_WINDOW,
|
|
@@ -52,6 +54,7 @@ import {
|
|
|
52
54
|
} from "./eventStore.js";
|
|
53
55
|
import {
|
|
54
56
|
cleanString,
|
|
57
|
+
externalReferrer,
|
|
55
58
|
isValidMoney,
|
|
56
59
|
measureBatchBytes,
|
|
57
60
|
normalizeRoute,
|
|
@@ -72,6 +75,8 @@ import {
|
|
|
72
75
|
ingestResultValidator,
|
|
73
76
|
trackerEventValidator,
|
|
74
77
|
trustedConversionValidator,
|
|
78
|
+
type Channel,
|
|
79
|
+
type Evidence,
|
|
75
80
|
type IngestContext,
|
|
76
81
|
type TrackerEvent,
|
|
77
82
|
} from "./validators.js";
|
|
@@ -83,6 +88,7 @@ import {
|
|
|
83
88
|
type RouteRollupDelta,
|
|
84
89
|
} from "./rollupStore.js";
|
|
85
90
|
import { localDayStart, localDayTimezone } from "./localTime.js";
|
|
91
|
+
import { classifyOrigin, platformDimensionValue } from "./origin.js";
|
|
86
92
|
|
|
87
93
|
type DimensionType =
|
|
88
94
|
| "source"
|
|
@@ -94,7 +100,10 @@ type DimensionType =
|
|
|
94
100
|
| "event"
|
|
95
101
|
| "goal"
|
|
96
102
|
| "medium"
|
|
97
|
-
| "outbound"
|
|
103
|
+
| "outbound"
|
|
104
|
+
| "platform"
|
|
105
|
+
| "channel"
|
|
106
|
+
| "evidence";
|
|
98
107
|
|
|
99
108
|
type Dimension = { type: DimensionType; value: string };
|
|
100
109
|
type DimensionDelta = Dimension & {
|
|
@@ -123,7 +132,7 @@ type AggregateDelta = {
|
|
|
123
132
|
continuedSessions: number;
|
|
124
133
|
newVisitors: number;
|
|
125
134
|
};
|
|
126
|
-
type AggregateDeltas = Map<string, AggregateDelta>;
|
|
135
|
+
export type AggregateDeltas = Map<string, AggregateDelta>;
|
|
127
136
|
type AttributionSnapshot = {
|
|
128
137
|
affiliateId: Id<"affiliates">;
|
|
129
138
|
affiliateSlug: string;
|
|
@@ -143,6 +152,10 @@ type SessionState = {
|
|
|
143
152
|
continued: boolean;
|
|
144
153
|
startedAt: number;
|
|
145
154
|
source: string;
|
|
155
|
+
/** The first touch's origin; absent on sessions recorded before it. */
|
|
156
|
+
platform?: string;
|
|
157
|
+
channel?: Channel;
|
|
158
|
+
evidence?: Evidence;
|
|
146
159
|
visitorKey: string;
|
|
147
160
|
utmCampaign?: string;
|
|
148
161
|
utmMedium?: string;
|
|
@@ -214,6 +227,8 @@ export const ingestBatch = mutation({
|
|
|
214
227
|
origin: v.string(),
|
|
215
228
|
events: v.array(trackerEventValidator),
|
|
216
229
|
context: v.optional(ingestContextValidator),
|
|
230
|
+
// The HTTP action's salted daily client-network key (see identity.ts).
|
|
231
|
+
clientKey: v.optional(v.string()),
|
|
217
232
|
},
|
|
218
233
|
returns: ingestResultValidator,
|
|
219
234
|
handler: async (ctx, args) => {
|
|
@@ -245,6 +260,15 @@ export const ingestBatch = mutation({
|
|
|
245
260
|
|
|
246
261
|
const now = Date.now();
|
|
247
262
|
await ensureAnalyticsControl(ctx, args.siteId, now);
|
|
263
|
+
if (args.clientKey !== undefined) {
|
|
264
|
+
await consumeClientRateLimit(
|
|
265
|
+
ctx,
|
|
266
|
+
args.siteId,
|
|
267
|
+
args.clientKey,
|
|
268
|
+
eventByteCounts,
|
|
269
|
+
now,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
248
272
|
await consumeSiteRateLimit(
|
|
249
273
|
ctx,
|
|
250
274
|
args.siteId,
|
|
@@ -389,7 +413,10 @@ export const ingestBatch = mutation({
|
|
|
389
413
|
rejected += 1;
|
|
390
414
|
continue;
|
|
391
415
|
}
|
|
392
|
-
|
|
416
|
+
// A heartbeat is presence, not telemetry: it moves the session's
|
|
417
|
+
// lastSeenAt and live row only. The time it proves reaches the
|
|
418
|
+
// duration counters once, when the visitor departs (live.ts).
|
|
419
|
+
await accountHeartbeat(
|
|
393
420
|
ctx,
|
|
394
421
|
args.siteId,
|
|
395
422
|
existingSession,
|
|
@@ -398,16 +425,6 @@ export const ingestBatch = mutation({
|
|
|
398
425
|
now,
|
|
399
426
|
batchState,
|
|
400
427
|
);
|
|
401
|
-
// A heartbeat is presence, not telemetry, but it is what keeps a
|
|
402
|
-
// session's duration honest while the visitor reads: the growth is
|
|
403
|
-
// folded into the buckets the heartbeat landed in.
|
|
404
|
-
foldSessionDuration(
|
|
405
|
-
aggregateDeltas,
|
|
406
|
-
args.siteId,
|
|
407
|
-
event,
|
|
408
|
-
durationDeltaMs,
|
|
409
|
-
timezone,
|
|
410
|
-
);
|
|
411
428
|
accepted += 1;
|
|
412
429
|
continue;
|
|
413
430
|
}
|
|
@@ -461,7 +478,8 @@ export const ingestBatch = mutation({
|
|
|
461
478
|
foldSessionDuration(
|
|
462
479
|
aggregateDeltas,
|
|
463
480
|
args.siteId,
|
|
464
|
-
event,
|
|
481
|
+
event.sessionId,
|
|
482
|
+
event.timestamp,
|
|
465
483
|
durationMs - existingSession.durationMs,
|
|
466
484
|
timezone,
|
|
467
485
|
);
|
|
@@ -560,6 +578,7 @@ export const ingestBatch = mutation({
|
|
|
560
578
|
reportRollupDeltas,
|
|
561
579
|
recordTelemetry: true,
|
|
562
580
|
timezone,
|
|
581
|
+
siteDomains: site.domains,
|
|
563
582
|
financialConversion: false,
|
|
564
583
|
trustedGoalKey: undefined,
|
|
565
584
|
now,
|
|
@@ -690,6 +709,7 @@ export const trackConversion = mutation({
|
|
|
690
709
|
reportRollupDeltas,
|
|
691
710
|
recordTelemetry: existingTelemetry === null && !telemetry.saturated,
|
|
692
711
|
timezone: localDayTimezone(site.timezone),
|
|
712
|
+
siteDomains: site.domains,
|
|
693
713
|
financialConversion: true,
|
|
694
714
|
trustedGoalKey,
|
|
695
715
|
now: Date.now(),
|
|
@@ -828,6 +848,8 @@ async function accountEvent(
|
|
|
828
848
|
recordTelemetry: boolean;
|
|
829
849
|
/** The site's timezone when it keeps local-day buckets. */
|
|
830
850
|
timezone?: string;
|
|
851
|
+
/** The site's own domains: a referrer on them is not an origin. */
|
|
852
|
+
siteDomains: string[];
|
|
831
853
|
financialConversion: boolean;
|
|
832
854
|
trustedGoalKey: string | undefined;
|
|
833
855
|
now: number;
|
|
@@ -841,6 +863,7 @@ async function accountEvent(
|
|
|
841
863
|
input.siteId,
|
|
842
864
|
event,
|
|
843
865
|
input.context,
|
|
866
|
+
input.siteDomains,
|
|
844
867
|
input.existingSession,
|
|
845
868
|
input.financialConversion,
|
|
846
869
|
input.reportRollupDeltas,
|
|
@@ -962,6 +985,23 @@ async function accountEvent(
|
|
|
962
985
|
event.type === "outbound" ? publicUrlHost(event.href) : undefined;
|
|
963
986
|
const dimensions: Dimension[] = [
|
|
964
987
|
{ type: "source", value: session.source },
|
|
988
|
+
// A session recorded before origins carries none, and fills no slot.
|
|
989
|
+
...(session.platform && session.channel && session.evidence
|
|
990
|
+
? ([
|
|
991
|
+
{
|
|
992
|
+
type: "platform",
|
|
993
|
+
// The value carries the channel too when it is not the
|
|
994
|
+
// platform's default, so the pair survives without its own
|
|
995
|
+
// dimension (splitPlatformDimension reads it back).
|
|
996
|
+
value: platformDimensionValue({
|
|
997
|
+
platform: session.platform,
|
|
998
|
+
channel: session.channel,
|
|
999
|
+
}),
|
|
1000
|
+
},
|
|
1001
|
+
{ type: "channel", value: session.channel },
|
|
1002
|
+
{ type: "evidence", value: session.evidence },
|
|
1003
|
+
] satisfies Dimension[])
|
|
1004
|
+
: []),
|
|
965
1005
|
...campaignDimensions,
|
|
966
1006
|
...(outboundHost
|
|
967
1007
|
? [{ type: "outbound", value: outboundHost } satisfies Dimension]
|
|
@@ -1028,6 +1068,9 @@ async function accountTrustedSession(
|
|
|
1028
1068
|
continued: false,
|
|
1029
1069
|
startedAt: existing.startedAt,
|
|
1030
1070
|
source: existing.source,
|
|
1071
|
+
platform: existing.platform,
|
|
1072
|
+
channel: existing.channel,
|
|
1073
|
+
evidence: existing.evidence,
|
|
1031
1074
|
utmCampaign: existing.utmCampaign,
|
|
1032
1075
|
utmMedium: existing.utmMedium,
|
|
1033
1076
|
entryPath: existing.entryPath,
|
|
@@ -1052,6 +1095,7 @@ async function upsertSession(
|
|
|
1052
1095
|
siteId: Id<"sites">,
|
|
1053
1096
|
event: TrackerEvent,
|
|
1054
1097
|
context: IngestContext,
|
|
1098
|
+
siteDomains: string[],
|
|
1055
1099
|
existing: CachedDocument<"sessions"> | null,
|
|
1056
1100
|
financialConversion: boolean,
|
|
1057
1101
|
reportRollupDeltas: ReportRollupDeltas,
|
|
@@ -1149,6 +1193,9 @@ async function upsertSession(
|
|
|
1149
1193
|
continued: event.type === "pageview" && existing.pageviewCount === 1,
|
|
1150
1194
|
startedAt,
|
|
1151
1195
|
source: existing.source,
|
|
1196
|
+
platform: existing.platform,
|
|
1197
|
+
channel: existing.channel,
|
|
1198
|
+
evidence: existing.evidence,
|
|
1152
1199
|
utmCampaign: existing.utmCampaign,
|
|
1153
1200
|
utmMedium: existing.utmMedium,
|
|
1154
1201
|
entryPath: existing.entryPath,
|
|
@@ -1199,6 +1246,17 @@ async function upsertSession(
|
|
|
1199
1246
|
.first();
|
|
1200
1247
|
if (alias) isNewVisitor = false;
|
|
1201
1248
|
}
|
|
1249
|
+
// First touch only, like `source`: the session keeps where it came from.
|
|
1250
|
+
// An affiliate channel needs this landing's own `ref` to have resolved; a
|
|
1251
|
+
// visitor's standing attribution alone is not how this visit arrived.
|
|
1252
|
+
const origin = classifyOrigin({
|
|
1253
|
+
utmSource,
|
|
1254
|
+
utmMedium,
|
|
1255
|
+
clickIds: event.clid,
|
|
1256
|
+
referrer: externalReferrer(event.referrer, siteDomains),
|
|
1257
|
+
inApp: context.inApp,
|
|
1258
|
+
affiliate: event.affiliateSlug !== undefined && attribution !== undefined,
|
|
1259
|
+
});
|
|
1202
1260
|
const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
|
|
1203
1261
|
siteId,
|
|
1204
1262
|
sessionId: event.sessionId,
|
|
@@ -1211,6 +1269,7 @@ async function upsertSession(
|
|
|
1211
1269
|
newVisitor: isNewVisitor,
|
|
1212
1270
|
...(event.referrer ? { referrer: event.referrer } : {}),
|
|
1213
1271
|
source,
|
|
1272
|
+
...origin,
|
|
1214
1273
|
...(utmSource ? { utmSource } : {}),
|
|
1215
1274
|
...(utmMedium ? { utmMedium } : {}),
|
|
1216
1275
|
...(utmCampaign ? { utmCampaign } : {}),
|
|
@@ -1267,6 +1326,7 @@ async function upsertSession(
|
|
|
1267
1326
|
continued: false,
|
|
1268
1327
|
startedAt: event.timestamp,
|
|
1269
1328
|
source,
|
|
1329
|
+
...origin,
|
|
1270
1330
|
utmCampaign,
|
|
1271
1331
|
utmMedium,
|
|
1272
1332
|
entryPath: event.path,
|
|
@@ -1421,6 +1481,12 @@ async function attributionForEvent(
|
|
|
1421
1481
|
};
|
|
1422
1482
|
}
|
|
1423
1483
|
|
|
1484
|
+
/**
|
|
1485
|
+
* Moves the session's lastSeenAt and its live row. `durationMs` stays: it is
|
|
1486
|
+
* what the duration counters already hold, and the time a heartbeat proves
|
|
1487
|
+
* is counted when the visitor departs (live.ts), so an idle tab never
|
|
1488
|
+
* rewrites an aggregate bucket.
|
|
1489
|
+
*/
|
|
1424
1490
|
async function accountHeartbeat(
|
|
1425
1491
|
ctx: MutationCtx,
|
|
1426
1492
|
siteId: Id<"sites">,
|
|
@@ -1429,7 +1495,7 @@ async function accountHeartbeat(
|
|
|
1429
1495
|
context: IngestContext,
|
|
1430
1496
|
now: number,
|
|
1431
1497
|
batchState?: BatchState,
|
|
1432
|
-
): Promise<
|
|
1498
|
+
): Promise<void> {
|
|
1433
1499
|
const startedAt = Math.min(session.startedAt, event.timestamp);
|
|
1434
1500
|
const lastSeenAt = Math.max(session.lastSeenAt, event.timestamp);
|
|
1435
1501
|
const updates = {
|
|
@@ -1437,7 +1503,6 @@ async function accountHeartbeat(
|
|
|
1437
1503
|
lastSeenAt,
|
|
1438
1504
|
exitPath:
|
|
1439
1505
|
event.timestamp >= session.lastSeenAt ? event.path : session.exitPath,
|
|
1440
|
-
durationMs: Math.max(0, lastSeenAt - startedAt),
|
|
1441
1506
|
...(context.country ? { country: context.country } : {}),
|
|
1442
1507
|
...(context.city ? { city: context.city } : {}),
|
|
1443
1508
|
...(context.latitude !== undefined ? { latitude: context.latitude } : {}),
|
|
@@ -1456,7 +1521,6 @@ async function accountHeartbeat(
|
|
|
1456
1521
|
} else {
|
|
1457
1522
|
await ctx.db.patch("sessions", session._id, updates);
|
|
1458
1523
|
}
|
|
1459
|
-
const durationDeltaMs = Math.max(0, updates.durationMs - session.durationMs);
|
|
1460
1524
|
await updateLiveSession(
|
|
1461
1525
|
ctx,
|
|
1462
1526
|
siteId,
|
|
@@ -1486,7 +1550,54 @@ async function accountHeartbeat(
|
|
|
1486
1550
|
now,
|
|
1487
1551
|
batchState,
|
|
1488
1552
|
);
|
|
1489
|
-
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* One client network's budget per site and minute, kept in the session
|
|
1557
|
+
* windows' table under `~client:<key>`, a key no sanitized session ID can take.
|
|
1558
|
+
* The key is the salted daily hash the HTTP action derived; the address never
|
|
1559
|
+
* reaches this mutation. It is one row per client, so only that client's own
|
|
1560
|
+
* concurrent requests contend on it.
|
|
1561
|
+
*/
|
|
1562
|
+
async function consumeClientRateLimit(
|
|
1563
|
+
ctx: MutationCtx,
|
|
1564
|
+
siteId: Id<"sites">,
|
|
1565
|
+
clientKey: string,
|
|
1566
|
+
eventByteCounts: number[],
|
|
1567
|
+
now: number,
|
|
1568
|
+
): Promise<void> {
|
|
1569
|
+
const windowStart =
|
|
1570
|
+
Math.floor(now / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
|
|
1571
|
+
const sessionId = `~client:${clientKey}`;
|
|
1572
|
+
const rows = await ctx.db
|
|
1573
|
+
.query("ingestWindows")
|
|
1574
|
+
.withIndex("by_siteId_and_sessionId_and_windowStart", (range) =>
|
|
1575
|
+
range.eq("siteId", siteId).eq("sessionId", sessionId),
|
|
1576
|
+
)
|
|
1577
|
+
.order("desc")
|
|
1578
|
+
.take(1);
|
|
1579
|
+
const window = rows[0] ?? null;
|
|
1580
|
+
const current = window?.windowStart === windowStart ? window : null;
|
|
1581
|
+
const eventCount = (current?.eventCount ?? 0) + eventByteCounts.length;
|
|
1582
|
+
const byteCount =
|
|
1583
|
+
(current?.byteCount ?? 0) +
|
|
1584
|
+
eventByteCounts.reduce((total, bytes) => total + bytes, 0);
|
|
1585
|
+
if (
|
|
1586
|
+
eventCount > MAX_EVENTS_PER_CLIENT_WINDOW ||
|
|
1587
|
+
byteCount > MAX_BYTES_PER_CLIENT_WINDOW
|
|
1588
|
+
) {
|
|
1589
|
+
fail("RATE_LIMITED", "client ingest budget exceeded", {
|
|
1590
|
+
windowMs: RATE_LIMIT_WINDOW_MS,
|
|
1591
|
+
eventLimitPerClient: MAX_EVENTS_PER_CLIENT_WINDOW,
|
|
1592
|
+
byteLimitPerClient: MAX_BYTES_PER_CLIENT_WINDOW,
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
const fields = { windowStart, eventCount, byteCount, updatedAt: now };
|
|
1596
|
+
if (window) {
|
|
1597
|
+
await ctx.db.patch("ingestWindows", window._id, fields);
|
|
1598
|
+
} else {
|
|
1599
|
+
await ctx.db.insert("ingestWindows", { siteId, sessionId, ...fields });
|
|
1600
|
+
}
|
|
1490
1601
|
}
|
|
1491
1602
|
|
|
1492
1603
|
async function consumeSiteRateLimit(
|
|
@@ -1872,11 +1983,10 @@ async function advanceFunnel(
|
|
|
1872
1983
|
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1873
1984
|
return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
|
|
1874
1985
|
}
|
|
1875
|
-
if (
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
return;
|
|
1986
|
+
if (event.timestamp < progress.lastStepAt) return;
|
|
1987
|
+
// An expired window restarts on a first-step event even after a completion,
|
|
1988
|
+
// so a returning buyer can complete again; inside the window a completed
|
|
1989
|
+
// funnel stays done.
|
|
1880
1990
|
if (event.timestamp - progress.startedAt > funnel.conversionWindowMs) {
|
|
1881
1991
|
if (!matchesFirst) return;
|
|
1882
1992
|
const updated = {
|
|
@@ -1910,6 +2020,7 @@ async function advanceFunnel(
|
|
|
1910
2020
|
foldFunnelRollup(reportRollupDeltas, siteId, funnel._id, event, 0, false);
|
|
1911
2021
|
return { funnelId: funnel._id, step: 1, steps: funnel.steps.length };
|
|
1912
2022
|
}
|
|
2023
|
+
if (progress.completedAt !== undefined) return;
|
|
1913
2024
|
const nextStep = funnel.steps[progress.currentStep];
|
|
1914
2025
|
if (!nextStep || !stepMatches(nextStep, event)) return;
|
|
1915
2026
|
const currentStep = progress.currentStep + 1;
|
|
@@ -2428,24 +2539,26 @@ function aggregateBucketStart(
|
|
|
2428
2539
|
return Math.floor(timestamp / interval) * interval;
|
|
2429
2540
|
}
|
|
2430
2541
|
|
|
2431
|
-
/**
|
|
2432
|
-
|
|
2542
|
+
/**
|
|
2543
|
+
* Folds session duration growth without telemetry: a departure's final
|
|
2544
|
+
* interval, or the heartbeat time of a visitor the live sweep retired. The
|
|
2545
|
+
* shard follows `shardKey`: the session for a departure, the site for a
|
|
2546
|
+
* sweep.
|
|
2547
|
+
*/
|
|
2548
|
+
export function foldSessionDuration(
|
|
2433
2549
|
aggregateDeltas: AggregateDeltas,
|
|
2434
2550
|
siteId: Id<"sites">,
|
|
2435
|
-
|
|
2551
|
+
shardKey: string,
|
|
2552
|
+
timestamp: number,
|
|
2436
2553
|
durationDeltaMs: number,
|
|
2437
2554
|
timezone?: string,
|
|
2438
2555
|
) {
|
|
2439
2556
|
if (durationDeltaMs <= 0) return;
|
|
2440
2557
|
for (const granularity of aggregateGranularities(timezone)) {
|
|
2441
2558
|
const shard =
|
|
2442
|
-
stableHash(
|
|
2559
|
+
stableHash(shardKey) %
|
|
2443
2560
|
(granularity === "hour" ? HOURLY_AGGREGATE_SHARDS : AGGREGATE_SHARDS);
|
|
2444
|
-
const bucketStart = aggregateBucketStart(
|
|
2445
|
-
granularity,
|
|
2446
|
-
event.timestamp,
|
|
2447
|
-
timezone,
|
|
2448
|
-
);
|
|
2561
|
+
const bucketStart = aggregateBucketStart(granularity, timestamp, timezone);
|
|
2449
2562
|
const key = `${siteId}:${granularity}:${bucketStart}:${shard}`;
|
|
2450
2563
|
const delta = aggregateDeltas.get(key) ?? {
|
|
2451
2564
|
siteId,
|
|
@@ -2472,7 +2585,7 @@ function foldSessionDuration(
|
|
|
2472
2585
|
}
|
|
2473
2586
|
}
|
|
2474
2587
|
|
|
2475
|
-
async function flushAggregates(
|
|
2588
|
+
export async function flushAggregates(
|
|
2476
2589
|
ctx: MutationCtx,
|
|
2477
2590
|
aggregateDeltas: AggregateDeltas,
|
|
2478
2591
|
) {
|
package/src/component/live.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { internal } from "./_generated/api.js";
|
|
3
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
4
|
+
import type { MutationCtx } from "./_generated/server.js";
|
|
3
5
|
import { internalMutation } from "./_generated/server.js";
|
|
4
6
|
import { LIVE_SWEEP_INTERVAL_MS } from "./constants.js";
|
|
7
|
+
import {
|
|
8
|
+
flushAggregates,
|
|
9
|
+
foldSessionDuration,
|
|
10
|
+
type AggregateDeltas,
|
|
11
|
+
} from "./ingest.js";
|
|
12
|
+
import { localDayTimezone } from "./localTime.js";
|
|
5
13
|
|
|
6
14
|
const SWEEP_BATCH_SIZE = 500;
|
|
7
15
|
|
|
@@ -58,7 +66,7 @@ export const expire = internalMutation({
|
|
|
58
66
|
}
|
|
59
67
|
return { expired: false, rescheduled: false, stale: false };
|
|
60
68
|
}
|
|
61
|
-
await ctx.
|
|
69
|
+
await retireLiveSessions(ctx, liveSession.siteId, [liveSession]);
|
|
62
70
|
return { expired: true, rescheduled: false, stale: false };
|
|
63
71
|
},
|
|
64
72
|
});
|
|
@@ -89,9 +97,7 @@ export const sweep = internalMutation({
|
|
|
89
97
|
range.eq("siteId", args.siteId).lte("expiresAt", now),
|
|
90
98
|
)
|
|
91
99
|
.take(SWEEP_BATCH_SIZE);
|
|
92
|
-
|
|
93
|
-
await ctx.db.delete("liveSessions", session._id);
|
|
94
|
-
}
|
|
100
|
+
await retireLiveSessions(ctx, args.siteId, expired);
|
|
95
101
|
|
|
96
102
|
const next = await ctx.db
|
|
97
103
|
.query("liveSessions")
|
|
@@ -115,3 +121,44 @@ export const sweep = internalMutation({
|
|
|
115
121
|
return { expired: expired.length, rescheduled: true, stale: false };
|
|
116
122
|
},
|
|
117
123
|
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Deletes departed live rows and counts the time their heartbeats proved. A
|
|
127
|
+
* heartbeat only moves its session's `lastSeenAt`, and `durationMs` is what
|
|
128
|
+
* the duration counters already hold, so the difference reaches the buckets
|
|
129
|
+
* here, once, in the hour the visitor was last seen. It lands in one shard
|
|
130
|
+
* per site, so a sweep rewrites a few buckets however many visitors it
|
|
131
|
+
* retires.
|
|
132
|
+
*/
|
|
133
|
+
async function retireLiveSessions(
|
|
134
|
+
ctx: MutationCtx,
|
|
135
|
+
siteId: Id<"sites">,
|
|
136
|
+
rows: Doc<"liveSessions">[],
|
|
137
|
+
) {
|
|
138
|
+
if (rows.length === 0) return;
|
|
139
|
+
const site = await ctx.db.get("sites", siteId);
|
|
140
|
+
const timezone = localDayTimezone(site?.timezone);
|
|
141
|
+
const aggregateDeltas: AggregateDeltas = new Map();
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
await ctx.db.delete("liveSessions", row._id);
|
|
144
|
+
const session = await ctx.db
|
|
145
|
+
.query("sessions")
|
|
146
|
+
.withIndex("by_siteId_and_sessionId", (range) =>
|
|
147
|
+
range.eq("siteId", siteId).eq("sessionId", row.sessionId),
|
|
148
|
+
)
|
|
149
|
+
.unique();
|
|
150
|
+
if (!session) continue;
|
|
151
|
+
const durationMs = session.lastSeenAt - session.startedAt;
|
|
152
|
+
if (durationMs <= session.durationMs) continue;
|
|
153
|
+
await ctx.db.patch("sessions", session._id, { durationMs });
|
|
154
|
+
foldSessionDuration(
|
|
155
|
+
aggregateDeltas,
|
|
156
|
+
siteId,
|
|
157
|
+
siteId,
|
|
158
|
+
session.lastSeenAt,
|
|
159
|
+
durationMs - session.durationMs,
|
|
160
|
+
timezone,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
await flushAggregates(ctx, aggregateDeltas);
|
|
164
|
+
}
|