@iann29/rastro 0.4.0 → 0.6.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 +146 -24
- package/agent/integration.md +66 -29
- package/agent/manifest.json +19 -8
- package/agent/manifest.schema.json +28 -9
- package/dist/client/federation.d.ts +38 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +18 -1
- package/dist/client/federation.js.map +1 -1
- package/dist/client/identity.d.ts +11 -0
- package/dist/client/identity.d.ts.map +1 -0
- package/dist/client/identity.js +123 -0
- package/dist/client/identity.js.map +1 -0
- package/dist/client/index.d.ts +501 -11
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +220 -4
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +8 -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 +158 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/constants.d.ts +2 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +7 -0
- package/dist/component/constants.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/http.d.ts.map +1 -1
- package/dist/component/http.js +51 -1
- package/dist/component/http.js.map +1 -1
- package/dist/component/ingest.d.ts +2 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +72 -22
- package/dist/component/ingest.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/people.d.ts +79 -0
- package/dist/component/people.d.ts.map +1 -0
- package/dist/component/people.js +249 -0
- package/dist/component/people.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 +35 -74
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +59 -14
- package/dist/component/reports.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 +101 -7
- package/dist/component/schema.js +45 -1
- 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 +101 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +62 -1
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts +38 -2
- package/dist/component/visitors.d.ts.map +1 -1
- package/dist/component/visitors.js +162 -42
- package/dist/component/visitors.js.map +1 -1
- package/dist/react/index.d.ts +9 -5
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +36 -5
- package/dist/react/index.js.map +1 -1
- package/dist/tracker/generated.d.ts +6 -6
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +6 -6
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts +3 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +56 -5
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/federation.md +24 -0
- package/docs/identity.md +307 -0
- package/docs/upgrading.md +190 -19
- package/llms.txt +11 -2
- package/package.json +5 -3
- package/src/component/_generated/api.ts +8 -0
- package/src/component/_generated/component.ts +251 -1
- package/src/component/constants.ts +7 -0
- package/src/component/coverage.ts +7 -1
- package/src/component/http.ts +56 -1
- package/src/component/ingest.ts +100 -28
- package/src/component/origin.ts +273 -0
- package/src/component/people.ts +321 -0
- package/src/component/platforms.ts +359 -0
- package/src/component/reports.ts +84 -13
- package/src/component/sanitize.ts +39 -1
- package/src/component/schema.ts +53 -0
- package/src/component/trackedLinks.ts +384 -0
- package/src/component/useragent.ts +11 -0
- package/src/component/validators.ts +120 -0
- package/src/component/visitors.ts +232 -55
- package/src/tracker/generated.ts +6 -6
package/src/component/ingest.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
} from "./eventStore.js";
|
|
55
55
|
import {
|
|
56
56
|
cleanString,
|
|
57
|
+
externalReferrer,
|
|
57
58
|
isValidMoney,
|
|
58
59
|
measureBatchBytes,
|
|
59
60
|
normalizeRoute,
|
|
@@ -74,6 +75,8 @@ import {
|
|
|
74
75
|
ingestResultValidator,
|
|
75
76
|
trackerEventValidator,
|
|
76
77
|
trustedConversionValidator,
|
|
78
|
+
type Channel,
|
|
79
|
+
type Evidence,
|
|
77
80
|
type IngestContext,
|
|
78
81
|
type TrackerEvent,
|
|
79
82
|
} from "./validators.js";
|
|
@@ -85,6 +88,8 @@ import {
|
|
|
85
88
|
type RouteRollupDelta,
|
|
86
89
|
} from "./rollupStore.js";
|
|
87
90
|
import { localDayStart, localDayTimezone } from "./localTime.js";
|
|
91
|
+
import { classifyOrigin, platformDimensionValue } from "./origin.js";
|
|
92
|
+
import { resolveVisitorIdentities } from "./visitors.js";
|
|
88
93
|
|
|
89
94
|
type DimensionType =
|
|
90
95
|
| "source"
|
|
@@ -96,7 +101,10 @@ type DimensionType =
|
|
|
96
101
|
| "event"
|
|
97
102
|
| "goal"
|
|
98
103
|
| "medium"
|
|
99
|
-
| "outbound"
|
|
104
|
+
| "outbound"
|
|
105
|
+
| "platform"
|
|
106
|
+
| "channel"
|
|
107
|
+
| "evidence";
|
|
100
108
|
|
|
101
109
|
type Dimension = { type: DimensionType; value: string };
|
|
102
110
|
type DimensionDelta = Dimension & {
|
|
@@ -145,6 +153,10 @@ type SessionState = {
|
|
|
145
153
|
continued: boolean;
|
|
146
154
|
startedAt: number;
|
|
147
155
|
source: string;
|
|
156
|
+
/** The first touch's origin; absent on sessions recorded before it. */
|
|
157
|
+
platform?: string;
|
|
158
|
+
channel?: Channel;
|
|
159
|
+
evidence?: Evidence;
|
|
148
160
|
visitorKey: string;
|
|
149
161
|
utmCampaign?: string;
|
|
150
162
|
utmMedium?: string;
|
|
@@ -206,6 +218,7 @@ type BatchState = {
|
|
|
206
218
|
CachedRow<CachedDocument<"visitorAttributions">>
|
|
207
219
|
>;
|
|
208
220
|
funnelProgress: Map<string, CachedRow<CachedDocument<"funnelProgress">>>;
|
|
221
|
+
funnelIdentities: Map<string, string[]>;
|
|
209
222
|
eventBatches: EventBatchBuffer;
|
|
210
223
|
liveSweepSites: Set<string>;
|
|
211
224
|
};
|
|
@@ -275,6 +288,7 @@ export const ingestBatch = mutation({
|
|
|
275
288
|
liveSessions: new Map(),
|
|
276
289
|
visitorAttributions: new Map(),
|
|
277
290
|
funnelProgress: new Map(),
|
|
291
|
+
funnelIdentities: new Map(),
|
|
278
292
|
eventBatches: new Map(),
|
|
279
293
|
liveSweepSites: new Set(),
|
|
280
294
|
};
|
|
@@ -567,6 +581,7 @@ export const ingestBatch = mutation({
|
|
|
567
581
|
reportRollupDeltas,
|
|
568
582
|
recordTelemetry: true,
|
|
569
583
|
timezone,
|
|
584
|
+
siteDomains: site.domains,
|
|
570
585
|
financialConversion: false,
|
|
571
586
|
trustedGoalKey: undefined,
|
|
572
587
|
now,
|
|
@@ -697,6 +712,7 @@ export const trackConversion = mutation({
|
|
|
697
712
|
reportRollupDeltas,
|
|
698
713
|
recordTelemetry: existingTelemetry === null && !telemetry.saturated,
|
|
699
714
|
timezone: localDayTimezone(site.timezone),
|
|
715
|
+
siteDomains: site.domains,
|
|
700
716
|
financialConversion: true,
|
|
701
717
|
trustedGoalKey,
|
|
702
718
|
now: Date.now(),
|
|
@@ -835,6 +851,8 @@ async function accountEvent(
|
|
|
835
851
|
recordTelemetry: boolean;
|
|
836
852
|
/** The site's timezone when it keeps local-day buckets. */
|
|
837
853
|
timezone?: string;
|
|
854
|
+
/** The site's own domains: a referrer on them is not an origin. */
|
|
855
|
+
siteDomains: string[];
|
|
838
856
|
financialConversion: boolean;
|
|
839
857
|
trustedGoalKey: string | undefined;
|
|
840
858
|
now: number;
|
|
@@ -848,6 +866,7 @@ async function accountEvent(
|
|
|
848
866
|
input.siteId,
|
|
849
867
|
event,
|
|
850
868
|
input.context,
|
|
869
|
+
input.siteDomains,
|
|
851
870
|
input.existingSession,
|
|
852
871
|
input.financialConversion,
|
|
853
872
|
input.reportRollupDeltas,
|
|
@@ -969,6 +988,23 @@ async function accountEvent(
|
|
|
969
988
|
event.type === "outbound" ? publicUrlHost(event.href) : undefined;
|
|
970
989
|
const dimensions: Dimension[] = [
|
|
971
990
|
{ type: "source", value: session.source },
|
|
991
|
+
// A session recorded before origins carries none, and fills no slot.
|
|
992
|
+
...(session.platform && session.channel && session.evidence
|
|
993
|
+
? ([
|
|
994
|
+
{
|
|
995
|
+
type: "platform",
|
|
996
|
+
// The value carries the channel too when it is not the
|
|
997
|
+
// platform's default, so the pair survives without its own
|
|
998
|
+
// dimension (splitPlatformDimension reads it back).
|
|
999
|
+
value: platformDimensionValue({
|
|
1000
|
+
platform: session.platform,
|
|
1001
|
+
channel: session.channel,
|
|
1002
|
+
}),
|
|
1003
|
+
},
|
|
1004
|
+
{ type: "channel", value: session.channel },
|
|
1005
|
+
{ type: "evidence", value: session.evidence },
|
|
1006
|
+
] satisfies Dimension[])
|
|
1007
|
+
: []),
|
|
972
1008
|
...campaignDimensions,
|
|
973
1009
|
...(outboundHost
|
|
974
1010
|
? [{ type: "outbound", value: outboundHost } satisfies Dimension]
|
|
@@ -1035,6 +1071,9 @@ async function accountTrustedSession(
|
|
|
1035
1071
|
continued: false,
|
|
1036
1072
|
startedAt: existing.startedAt,
|
|
1037
1073
|
source: existing.source,
|
|
1074
|
+
platform: existing.platform,
|
|
1075
|
+
channel: existing.channel,
|
|
1076
|
+
evidence: existing.evidence,
|
|
1038
1077
|
utmCampaign: existing.utmCampaign,
|
|
1039
1078
|
utmMedium: existing.utmMedium,
|
|
1040
1079
|
entryPath: existing.entryPath,
|
|
@@ -1059,6 +1098,7 @@ async function upsertSession(
|
|
|
1059
1098
|
siteId: Id<"sites">,
|
|
1060
1099
|
event: TrackerEvent,
|
|
1061
1100
|
context: IngestContext,
|
|
1101
|
+
siteDomains: string[],
|
|
1062
1102
|
existing: CachedDocument<"sessions"> | null,
|
|
1063
1103
|
financialConversion: boolean,
|
|
1064
1104
|
reportRollupDeltas: ReportRollupDeltas,
|
|
@@ -1156,6 +1196,9 @@ async function upsertSession(
|
|
|
1156
1196
|
continued: event.type === "pageview" && existing.pageviewCount === 1,
|
|
1157
1197
|
startedAt,
|
|
1158
1198
|
source: existing.source,
|
|
1199
|
+
platform: existing.platform,
|
|
1200
|
+
channel: existing.channel,
|
|
1201
|
+
evidence: existing.evidence,
|
|
1159
1202
|
utmCampaign: existing.utmCampaign,
|
|
1160
1203
|
utmMedium: existing.utmMedium,
|
|
1161
1204
|
entryPath: existing.entryPath,
|
|
@@ -1206,6 +1249,17 @@ async function upsertSession(
|
|
|
1206
1249
|
.first();
|
|
1207
1250
|
if (alias) isNewVisitor = false;
|
|
1208
1251
|
}
|
|
1252
|
+
// First touch only, like `source`: the session keeps where it came from.
|
|
1253
|
+
// An affiliate channel needs this landing's own `ref` to have resolved; a
|
|
1254
|
+
// visitor's standing attribution alone is not how this visit arrived.
|
|
1255
|
+
const origin = classifyOrigin({
|
|
1256
|
+
utmSource,
|
|
1257
|
+
utmMedium,
|
|
1258
|
+
clickIds: event.clid,
|
|
1259
|
+
referrer: externalReferrer(event.referrer, siteDomains),
|
|
1260
|
+
inApp: context.inApp,
|
|
1261
|
+
affiliate: event.affiliateSlug !== undefined && attribution !== undefined,
|
|
1262
|
+
});
|
|
1209
1263
|
const fields: Omit<Doc<"sessions">, "_id" | "_creationTime"> = {
|
|
1210
1264
|
siteId,
|
|
1211
1265
|
sessionId: event.sessionId,
|
|
@@ -1218,6 +1272,7 @@ async function upsertSession(
|
|
|
1218
1272
|
newVisitor: isNewVisitor,
|
|
1219
1273
|
...(event.referrer ? { referrer: event.referrer } : {}),
|
|
1220
1274
|
source,
|
|
1275
|
+
...origin,
|
|
1221
1276
|
...(utmSource ? { utmSource } : {}),
|
|
1222
1277
|
...(utmMedium ? { utmMedium } : {}),
|
|
1223
1278
|
...(utmCampaign ? { utmCampaign } : {}),
|
|
@@ -1274,6 +1329,7 @@ async function upsertSession(
|
|
|
1274
1329
|
continued: false,
|
|
1275
1330
|
startedAt: event.timestamp,
|
|
1276
1331
|
source,
|
|
1332
|
+
...origin,
|
|
1277
1333
|
utmCampaign,
|
|
1278
1334
|
utmMedium,
|
|
1279
1335
|
entryPath: event.path,
|
|
@@ -1874,46 +1930,62 @@ async function advanceFunnel(
|
|
|
1874
1930
|
reportRollupDeltas: ReportRollupDeltas,
|
|
1875
1931
|
batchState?: BatchState,
|
|
1876
1932
|
): Promise<FunnelReach | undefined> {
|
|
1877
|
-
|
|
1933
|
+
let identities = batchState?.funnelIdentities.get(event.visitorId);
|
|
1934
|
+
if (!identities) {
|
|
1935
|
+
identities = await resolveVisitorIdentities(ctx, siteId, event.visitorId);
|
|
1936
|
+
for (const id of identities)
|
|
1937
|
+
batchState?.funnelIdentities.set(id, identities);
|
|
1938
|
+
}
|
|
1939
|
+
const cacheKey = JSON.stringify([funnel._id, identities[0]]);
|
|
1878
1940
|
let progressState = batchState?.funnelProgress.get(cacheKey);
|
|
1879
|
-
if (
|
|
1880
|
-
const
|
|
1881
|
-
.
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
.
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1941
|
+
if (!progressState) {
|
|
1942
|
+
const candidates = await Promise.all(
|
|
1943
|
+
identities.map((visitorId) =>
|
|
1944
|
+
ctx.db
|
|
1945
|
+
.query("funnelProgress")
|
|
1946
|
+
.withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
|
|
1947
|
+
range
|
|
1948
|
+
.eq("siteId", siteId)
|
|
1949
|
+
.eq("funnelId", funnel._id)
|
|
1950
|
+
.eq("visitorId", visitorId),
|
|
1951
|
+
)
|
|
1952
|
+
.unique(),
|
|
1953
|
+
),
|
|
1954
|
+
);
|
|
1955
|
+
// Reuse the furthest attempt still inside its window. Already-counted
|
|
1956
|
+
// attempts are not replayed or merged into historical aggregates.
|
|
1957
|
+
const current =
|
|
1958
|
+
candidates
|
|
1959
|
+
.filter((row) => row !== null)
|
|
1960
|
+
.sort((a, b) => {
|
|
1961
|
+
const activeA =
|
|
1962
|
+
event.timestamp - a.startedAt <= funnel.conversionWindowMs;
|
|
1963
|
+
const activeB =
|
|
1964
|
+
event.timestamp - b.startedAt <= funnel.conversionWindowMs;
|
|
1965
|
+
return (
|
|
1966
|
+
Number(activeB) - Number(activeA) ||
|
|
1967
|
+
(activeA ? b.currentStep - a.currentStep : 0) ||
|
|
1968
|
+
b.lastStepAt - a.lastStepAt
|
|
1969
|
+
);
|
|
1970
|
+
})[0] ?? null;
|
|
1889
1971
|
progressState = { current, dirty: false };
|
|
1890
|
-
batchState
|
|
1972
|
+
batchState?.funnelProgress.set(cacheKey, progressState);
|
|
1891
1973
|
}
|
|
1892
|
-
const progress = progressState
|
|
1893
|
-
? progressState.current
|
|
1894
|
-
: await ctx.db
|
|
1895
|
-
.query("funnelProgress")
|
|
1896
|
-
.withIndex("by_siteId_and_funnelId_and_visitorId", (range) =>
|
|
1897
|
-
range
|
|
1898
|
-
.eq("siteId", siteId)
|
|
1899
|
-
.eq("funnelId", funnel._id)
|
|
1900
|
-
.eq("visitorId", event.visitorId),
|
|
1901
|
-
)
|
|
1902
|
-
.unique();
|
|
1974
|
+
const progress = progressState.current;
|
|
1903
1975
|
const matchesFirst = stepMatches(funnel.steps[0], event);
|
|
1904
1976
|
if (!progress) {
|
|
1905
1977
|
if (!matchesFirst) return;
|
|
1906
1978
|
const fields: Omit<Doc<"funnelProgress">, "_id" | "_creationTime"> = {
|
|
1907
1979
|
siteId,
|
|
1908
1980
|
funnelId: funnel._id,
|
|
1909
|
-
visitorId:
|
|
1981
|
+
visitorId: identities[0],
|
|
1910
1982
|
sessionId: event.sessionId,
|
|
1911
1983
|
currentStep: 1,
|
|
1912
1984
|
startedAt: event.timestamp,
|
|
1913
1985
|
lastStepAt: event.timestamp,
|
|
1914
1986
|
};
|
|
1915
1987
|
const progressId = await ctx.db.insert("funnelProgress", fields);
|
|
1916
|
-
if (
|
|
1988
|
+
if (batchState) {
|
|
1917
1989
|
progressState.current = {
|
|
1918
1990
|
...fields,
|
|
1919
1991
|
_id: progressId,
|
|
@@ -1944,7 +2016,7 @@ async function advanceFunnel(
|
|
|
1944
2016
|
lastStepAt: event.timestamp,
|
|
1945
2017
|
};
|
|
1946
2018
|
delete updated.completedAt;
|
|
1947
|
-
if (
|
|
2019
|
+
if (batchState) {
|
|
1948
2020
|
progressState.current = updated;
|
|
1949
2021
|
progressState.dirty = true;
|
|
1950
2022
|
} else {
|
|
@@ -1978,7 +2050,7 @@ async function advanceFunnel(
|
|
|
1978
2050
|
lastStepAt: event.timestamp,
|
|
1979
2051
|
...(completed ? { completedAt: event.timestamp } : {}),
|
|
1980
2052
|
};
|
|
1981
|
-
if (
|
|
2053
|
+
if (batchState) {
|
|
1982
2054
|
progressState.current = { ...progress, ...updates };
|
|
1983
2055
|
progressState.dirty = true;
|
|
1984
2056
|
} else {
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { CLICK_IDS, PLATFORMS, type PlatformRow } from "./platforms.js";
|
|
2
|
+
import { channelValidator, type Channel, type Evidence } from "./validators.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a session came from, with the rule that proved it. `platform` is an
|
|
6
|
+
* id from platforms.ts, `unknown` for a referrer or utm_source outside the
|
|
7
|
+
* table (the raw value stays in the session's `source`), or `none`.
|
|
8
|
+
*/
|
|
9
|
+
export type Origin = { platform: string; channel: Channel; evidence: Evidence };
|
|
10
|
+
|
|
11
|
+
export type OriginInput = {
|
|
12
|
+
utmSource?: string;
|
|
13
|
+
utmMedium?: string;
|
|
14
|
+
/** Click-ID key names from the landing URL, never their values. */
|
|
15
|
+
clickIds?: string[];
|
|
16
|
+
/** An external referrer: callers drop the site's own (externalReferrer). */
|
|
17
|
+
referrer?: string;
|
|
18
|
+
/** The platform whose in-app browser opened the page (inAppPlatform). */
|
|
19
|
+
inApp?: string;
|
|
20
|
+
/** The landing carried a `ref` that resolved to an affiliate. */
|
|
21
|
+
affiliate?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const MAX_CLICK_IDS = 8;
|
|
25
|
+
const DIRECT: Origin = {
|
|
26
|
+
platform: "none",
|
|
27
|
+
channel: "direct",
|
|
28
|
+
evidence: "none",
|
|
29
|
+
};
|
|
30
|
+
const UNKNOWN_REFERRAL = { platform: "unknown", channel: "referral" } as const;
|
|
31
|
+
// Surfaces that add `fbclid` to their outbound links: a referrer or in-app
|
|
32
|
+
// browser naming one of them says which Meta app the click came from.
|
|
33
|
+
const META_FAMILY = new Set(["facebook", "instagram", "messenger", "threads"]);
|
|
34
|
+
const CHANNELS = new Set<string>(
|
|
35
|
+
channelValidator.members.map((member) => member.value),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
type Hit = { row: PlatformRow; channel: Channel };
|
|
39
|
+
const BY_ID = new Map(PLATFORMS.map((row) => [row.id, row]));
|
|
40
|
+
const BY_HOST = new Map<string, Hit>();
|
|
41
|
+
const BY_ALIAS = new Map<string, Hit>();
|
|
42
|
+
for (const row of PLATFORMS) {
|
|
43
|
+
const paid = { row, channel: paidChannel(row.channel) ?? row.channel };
|
|
44
|
+
for (const host of [...(row.domains ?? []), ...(row.packages ?? [])]) {
|
|
45
|
+
BY_HOST.set(host, { row, channel: row.channel });
|
|
46
|
+
}
|
|
47
|
+
for (const host of row.paidDomains ?? []) BY_HOST.set(host, paid);
|
|
48
|
+
for (const alias of [row.id, ...(row.aliases ?? [])]) {
|
|
49
|
+
BY_ALIAS.set(alias, { row, channel: row.channel });
|
|
50
|
+
}
|
|
51
|
+
for (const alias of row.paidAliases ?? []) BY_ALIAS.set(alias, paid);
|
|
52
|
+
}
|
|
53
|
+
const CLICK_ID_RANK = new Map(CLICK_IDS.map(([key], rank) => [key, rank]));
|
|
54
|
+
|
|
55
|
+
// utm_medium spellings besides the channel ids, which are accepted as they
|
|
56
|
+
// are (tracked links send them). "paid" and "organic" follow the platform:
|
|
57
|
+
// search or social, whichever it is. A Map, so no utm_medium can reach
|
|
58
|
+
// Object.prototype.
|
|
59
|
+
const MEDIUMS = new Map<string, Channel | "paid" | "organic">([
|
|
60
|
+
["cpc", "paid"],
|
|
61
|
+
["ppc", "paid"],
|
|
62
|
+
["paid", "paid"],
|
|
63
|
+
["paidsearch", "paid_search"],
|
|
64
|
+
["sem", "paid_search"],
|
|
65
|
+
["paidsocial", "paid_social"],
|
|
66
|
+
["social", "organic_social"],
|
|
67
|
+
["social-media", "organic_social"],
|
|
68
|
+
["social_media", "organic_social"],
|
|
69
|
+
["e-mail", "email"],
|
|
70
|
+
["newsletter", "email"],
|
|
71
|
+
["mail", "email"],
|
|
72
|
+
["affiliates", "affiliate"],
|
|
73
|
+
["partner", "affiliate"],
|
|
74
|
+
["banner", "display"],
|
|
75
|
+
["video", "display"],
|
|
76
|
+
["cpm", "display"],
|
|
77
|
+
["whatsapp", "messaging"],
|
|
78
|
+
["sms", "messaging"],
|
|
79
|
+
["print", "offline"],
|
|
80
|
+
["qr", "offline"],
|
|
81
|
+
["organic", "organic"],
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Classifies a session's first touch. One rule decides and the next only
|
|
86
|
+
* runs when it did not: utm_source, then click IDs, then the referrer host,
|
|
87
|
+
* then the in-app browser; an affiliate `ref` then sets the channel, keeping
|
|
88
|
+
* the platform and evidence an earlier rule found. Nothing at all is direct,
|
|
89
|
+
* the only way to be direct. Matching is exact, never by substring.
|
|
90
|
+
*/
|
|
91
|
+
export function classifyOrigin(input: OriginInput): Origin {
|
|
92
|
+
const referred = referrerPlatform(input.referrer);
|
|
93
|
+
const inApp = input.inApp === undefined ? undefined : BY_ID.get(input.inApp);
|
|
94
|
+
const origin =
|
|
95
|
+
fromUtm(input.utmSource, input.utmMedium) ??
|
|
96
|
+
fromClickIds(input.clickIds, referred?.platform, inApp?.id) ??
|
|
97
|
+
(referred && { ...referred, evidence: "referrer" as const }) ??
|
|
98
|
+
(inApp && {
|
|
99
|
+
platform: inApp.id,
|
|
100
|
+
channel: inApp.channel,
|
|
101
|
+
evidence: "inApp" as const,
|
|
102
|
+
});
|
|
103
|
+
if (input.affiliate) {
|
|
104
|
+
return origin
|
|
105
|
+
? { ...origin, channel: "affiliate" }
|
|
106
|
+
: { platform: "none", channel: "affiliate", evidence: "affiliate" };
|
|
107
|
+
}
|
|
108
|
+
return origin ?? DIRECT;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The known click-ID keys of a list, deduplicated and bounded. */
|
|
112
|
+
export function knownClickIds(keys: string[]): string[] | undefined {
|
|
113
|
+
const known = [...new Set(keys.filter((key) => CLICK_ID_RANK.has(key)))];
|
|
114
|
+
return known.length > 0 ? known.slice(0, MAX_CLICK_IDS) : undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isPlatform(id: string): boolean {
|
|
118
|
+
return BY_ID.has(id);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The platform behind a stored `source` — a `utm_source` alias, a referrer
|
|
123
|
+
* host, or an Android package, as sessions and the overview keep them.
|
|
124
|
+
* `unknown` when the table has no row for it, `none` when the session had no
|
|
125
|
+
* source at all. Matching is `classifyOrigin`'s, never by substring, so a
|
|
126
|
+
* dashboard can group raw hosts the same way the classifier does.
|
|
127
|
+
*/
|
|
128
|
+
export function platformForSource(source: string): string {
|
|
129
|
+
const value = source.trim().toLowerCase();
|
|
130
|
+
if (!value || value === "direct" || value === "internal") return "none";
|
|
131
|
+
return (BY_ALIAS.get(value) ?? hostHit(value))?.row.id ?? "unknown";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Where a platform's traffic lands when nothing says paid or organic:
|
|
136
|
+
* `direct` for `none`, `referral` for an id outside the table.
|
|
137
|
+
*/
|
|
138
|
+
export function channelForPlatform(platform: string): Channel {
|
|
139
|
+
if (platform === "none") return DIRECT.channel;
|
|
140
|
+
return BY_ID.get(platform)?.channel ?? UNKNOWN_REFERRAL.channel;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The value the `platform` aggregate dimension carries. A session that
|
|
145
|
+
* arrived by its platform's default channel keeps the bare id; one that
|
|
146
|
+
* deviated carries `platform@channel` (`google@paid_search` for a `gclid`,
|
|
147
|
+
* `unknown@email` for a newsletter nobody's table knows), so the aggregates
|
|
148
|
+
* hold the pair without a dimension of their own. `none` is always `none`:
|
|
149
|
+
* a session with no platform is the direct row, whatever its channel.
|
|
150
|
+
*/
|
|
151
|
+
export function platformDimensionValue(origin: {
|
|
152
|
+
platform: string;
|
|
153
|
+
channel: Channel;
|
|
154
|
+
}): string {
|
|
155
|
+
if (origin.platform === "none") return "none";
|
|
156
|
+
return origin.channel === channelForPlatform(origin.platform)
|
|
157
|
+
? origin.platform
|
|
158
|
+
: `${origin.platform}@${origin.channel}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The inverse: the platform and the channel behind a `platform` dimension
|
|
163
|
+
* value. A bare id — every session recorded before the pair was encoded
|
|
164
|
+
* included — reads as that platform's default channel, which is what it was
|
|
165
|
+
* classified with.
|
|
166
|
+
*/
|
|
167
|
+
export function splitPlatformDimension(value: string): {
|
|
168
|
+
platform: string;
|
|
169
|
+
channel: Channel;
|
|
170
|
+
} {
|
|
171
|
+
const at = value.indexOf("@");
|
|
172
|
+
const platform = at < 0 ? value : value.slice(0, at);
|
|
173
|
+
const channel = at < 0 ? "" : value.slice(at + 1);
|
|
174
|
+
return {
|
|
175
|
+
platform,
|
|
176
|
+
channel: CHANNELS.has(channel)
|
|
177
|
+
? (channel as Channel)
|
|
178
|
+
: channelForPlatform(platform),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function fromUtm(
|
|
183
|
+
utmSource: string | undefined,
|
|
184
|
+
utmMedium: string | undefined,
|
|
185
|
+
): Origin | undefined {
|
|
186
|
+
const source = utmSource?.trim().toLowerCase();
|
|
187
|
+
if (!source) return undefined;
|
|
188
|
+
// A host-shaped value (utm_source=chatgpt.com) matches like a referrer.
|
|
189
|
+
const hit =
|
|
190
|
+
BY_ALIAS.get(source) ?? (source.includes(".") ? hostHit(source) : null);
|
|
191
|
+
const declared = hit?.channel ?? UNKNOWN_REFERRAL.channel;
|
|
192
|
+
const mediumKey = utmMedium?.trim().toLowerCase() ?? "";
|
|
193
|
+
const medium = CHANNELS.has(mediumKey)
|
|
194
|
+
? (mediumKey as Channel)
|
|
195
|
+
: MEDIUMS.get(mediumKey);
|
|
196
|
+
const channel: Channel =
|
|
197
|
+
medium === "paid"
|
|
198
|
+
? (paidChannel(hit?.row.channel) ?? declared)
|
|
199
|
+
: medium === "organic"
|
|
200
|
+
? (hit?.row.channel ?? declared)
|
|
201
|
+
: (medium ?? declared);
|
|
202
|
+
return {
|
|
203
|
+
platform: hit?.row.id ?? UNKNOWN_REFERRAL.platform,
|
|
204
|
+
channel,
|
|
205
|
+
evidence: "utm",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function fromClickIds(
|
|
210
|
+
keys: string[] | undefined,
|
|
211
|
+
referred: string | undefined,
|
|
212
|
+
inApp: string | undefined,
|
|
213
|
+
): Origin | undefined {
|
|
214
|
+
let best: (typeof CLICK_IDS)[number] | undefined;
|
|
215
|
+
for (const key of keys ?? []) {
|
|
216
|
+
const rank = CLICK_ID_RANK.get(key);
|
|
217
|
+
if (rank !== undefined && (!best || rank < CLICK_ID_RANK.get(best[0])!)) {
|
|
218
|
+
best = CLICK_IDS[rank];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!best) return undefined;
|
|
222
|
+
const [, platform, channel] = best;
|
|
223
|
+
// The click ID names who generated the link. A Meta id defers to the Meta
|
|
224
|
+
// app the referrer or the in-app browser names, channel included (a
|
|
225
|
+
// Messenger link is messaging); an e-mail id takes the referrer's platform.
|
|
226
|
+
if (platform === "meta") {
|
|
227
|
+
const app = [referred, inApp].find((id) => id && META_FAMILY.has(id));
|
|
228
|
+
const row = app === undefined ? undefined : BY_ID.get(app);
|
|
229
|
+
return row
|
|
230
|
+
? { platform: row.id, channel: row.channel, evidence: "clickId" }
|
|
231
|
+
: { platform, channel, evidence: "clickId" };
|
|
232
|
+
}
|
|
233
|
+
const resolved =
|
|
234
|
+
platform === "unknown" && referred && referred !== "unknown"
|
|
235
|
+
? referred
|
|
236
|
+
: platform;
|
|
237
|
+
return { platform: resolved, channel, evidence: "clickId" };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function referrerPlatform(
|
|
241
|
+
referrer: string | undefined,
|
|
242
|
+
): { platform: string; channel: Channel } | undefined {
|
|
243
|
+
if (!referrer) return undefined;
|
|
244
|
+
let host: string;
|
|
245
|
+
try {
|
|
246
|
+
host = new URL(referrer).hostname.toLowerCase().replace(/\.$/, "");
|
|
247
|
+
} catch {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
if (!host) return undefined;
|
|
251
|
+
const hit = hostHit(host);
|
|
252
|
+
return hit
|
|
253
|
+
? { platform: hit.row.id, channel: hit.channel }
|
|
254
|
+
: UNKNOWN_REFERRAL;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The longest listed suffix of a host (a whole label at a time), or null. */
|
|
258
|
+
function hostHit(host: string): Hit | null {
|
|
259
|
+
for (let suffix = host; suffix;) {
|
|
260
|
+
const hit = BY_HOST.get(suffix);
|
|
261
|
+
if (hit) return hit;
|
|
262
|
+
const dot = suffix.indexOf(".");
|
|
263
|
+
suffix = dot < 0 ? "" : suffix.slice(dot + 1);
|
|
264
|
+
}
|
|
265
|
+
const row = PLATFORMS.find((candidate) => candidate.hostPattern?.test(host));
|
|
266
|
+
return row ? { row, channel: row.channel } : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function paidChannel(channel: Channel | undefined): Channel | undefined {
|
|
270
|
+
if (channel === "organic_search") return "paid_search";
|
|
271
|
+
if (channel === "organic_social") return "paid_social";
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|