@mparticle/web-sdk 3.22.0 → 3.24.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/dist/mparticle.common.js +1 -1669
- package/dist/mparticle.esm.js +1 -1669
- package/dist/mparticle.js +4827 -4184
- package/dist/types/src/buttonTracking/buttonClickEvent.d.ts +4 -0
- package/dist/types/src/buttonTracking/buttonDescription.d.ts +2 -2
- package/dist/types/src/buttonTracking/buttonTracker.d.ts +2 -0
- package/dist/types/src/buttonTracking/checkoutIntent.d.ts +2 -2
- package/dist/types/src/buttonTracking/controls.d.ts +6 -3
- package/dist/types/src/buttonTracking/labelMasking.d.ts +1 -0
- package/dist/types/src/configAPIClient.d.ts +1 -0
- package/dist/types/src/forwarder-utils.d.ts +3 -0
- package/dist/types/src/pageUrl.d.ts +10 -0
- package/dist/types/src/pageViewTracker.d.ts +21 -3
- package/dist/types/src/roktManager.d.ts +8 -2
- package/dist/types/src/sdkRuntimeModels.d.ts +3 -0
- package/dist/types/src/store.d.ts +2 -0
- package/package.json +21 -14
- package/src/batchUploader.ts +3 -2
- package/src/buttonTracking/buttonClickEvent.ts +76 -1
- package/src/buttonTracking/buttonDescription.ts +236 -46
- package/src/buttonTracking/buttonTracker.ts +122 -7
- package/src/buttonTracking/checkoutIntent.ts +51 -9
- package/src/buttonTracking/controls.ts +160 -50
- package/src/buttonTracking/labelMasking.ts +4 -1
- package/src/configAPIClient.ts +1 -0
- package/src/events.ts +9 -3
- package/src/forwarder-utils.ts +38 -0
- package/src/forwarders.ts +2 -28
- package/src/mp-instance.ts +23 -10
- package/src/mparticle-instance-manager.ts +3 -0
- package/src/pageUrl.ts +191 -0
- package/src/pageViewTracker.ts +206 -78
- package/src/roktManager.ts +53 -20
- package/src/sdkRuntimeModels.ts +3 -0
- package/src/serverModel.ts +3 -3
- package/src/store.ts +14 -0
- package/src/utils.ts +1 -1
package/src/pageUrl.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { isAllowedQueryParam } from './pageViewTracker';
|
|
2
|
+
import { getHref, valueof } from './utils';
|
|
3
|
+
|
|
4
|
+
// The one rule for a URL that leaves the SDK, shared with kits through
|
|
5
|
+
// `mParticle._cleanUrl`. `path` keeps scheme, host and path. `full` adds the
|
|
6
|
+
// query params an auto page view captures by default and the campaign
|
|
7
|
+
// attribution params, then the fragment.
|
|
8
|
+
export const UrlMode = {
|
|
9
|
+
Path: 'path',
|
|
10
|
+
Full: 'full',
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export type UrlMode = valueof<typeof UrlMode>;
|
|
14
|
+
|
|
15
|
+
// Campaign attribution and ad-network click ids. URLs keep them; an auto page view
|
|
16
|
+
// carries one as an attribute only when an input configures it.
|
|
17
|
+
const URL_ATTRIBUTION_QUERY_PARAMS: string[] = [
|
|
18
|
+
'utm_source',
|
|
19
|
+
'utm_medium',
|
|
20
|
+
'utm_campaign',
|
|
21
|
+
'utm_term',
|
|
22
|
+
'utm_content',
|
|
23
|
+
'utm_id',
|
|
24
|
+
'utm_source_platform',
|
|
25
|
+
'utm_creative_format',
|
|
26
|
+
'utm_marketing_tactic',
|
|
27
|
+
'gad_source',
|
|
28
|
+
'gad_campaignid',
|
|
29
|
+
'mc_cid',
|
|
30
|
+
'gclid',
|
|
31
|
+
'gbraid',
|
|
32
|
+
'wbraid',
|
|
33
|
+
'fbclid',
|
|
34
|
+
'msclkid',
|
|
35
|
+
'ttclid',
|
|
36
|
+
'twclid',
|
|
37
|
+
'li_fat_id',
|
|
38
|
+
'dclid',
|
|
39
|
+
'srsltid',
|
|
40
|
+
'epik',
|
|
41
|
+
'ScCid',
|
|
42
|
+
'irclickid',
|
|
43
|
+
'rtid',
|
|
44
|
+
'rclid',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const URL_ATTRIBUTION_QUERY_PARAMS_LOWER: string[] = URL_ATTRIBUTION_QUERY_PARAMS.map(
|
|
48
|
+
name => name.toLowerCase()
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const isUrlQueryParam = (name: string): boolean =>
|
|
52
|
+
isAllowedQueryParam(name) ||
|
|
53
|
+
URL_ATTRIBUTION_QUERY_PARAMS_LOWER.indexOf(name.toLowerCase()) !== -1;
|
|
54
|
+
|
|
55
|
+
// OAuth 2.0 / OIDC authorization-flow params. They stay off URLs even where the
|
|
56
|
+
// page view allowlist admits them, because a URL travels on every event.
|
|
57
|
+
export const EXCLUDED_URL_QUERY_PARAMS: string[] = [
|
|
58
|
+
'client_id',
|
|
59
|
+
'redirect_uri',
|
|
60
|
+
'response_type',
|
|
61
|
+
'scope',
|
|
62
|
+
'state',
|
|
63
|
+
'code',
|
|
64
|
+
'nonce',
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// An OAuth implicit or OIDC fragment response puts its tokens in `#k=v&...`.
|
|
68
|
+
export const EXCLUDED_URL_FRAGMENT_PARAMS: string[] = EXCLUDED_URL_QUERY_PARAMS.concat(
|
|
69
|
+
[
|
|
70
|
+
'access_token',
|
|
71
|
+
'id_token',
|
|
72
|
+
'refresh_token',
|
|
73
|
+
'provider_token',
|
|
74
|
+
'provider_refresh_token',
|
|
75
|
+
]
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Allowlisted params whose value is itself a URL, so it can carry a query.
|
|
79
|
+
const URL_VALUED_QUERY_PARAMS: string[] = ['ref', 'referrer'];
|
|
80
|
+
|
|
81
|
+
const USERINFO = /^((?:[a-z][a-z0-9+.-]*:)?\/\/)[^/]*@/i;
|
|
82
|
+
|
|
83
|
+
const decodeQueryComponent = (component: string): string =>
|
|
84
|
+
decodeURIComponent(component.replace(/\+/g, ' '));
|
|
85
|
+
|
|
86
|
+
const cleanQueryPair = (pair: string): string | null => {
|
|
87
|
+
const separator = pair.indexOf('=');
|
|
88
|
+
const rawName = separator === -1 ? pair : pair.slice(0, separator);
|
|
89
|
+
let name: string;
|
|
90
|
+
try {
|
|
91
|
+
name = decodeQueryComponent(rawName);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const lowerName = name.toLowerCase();
|
|
97
|
+
if (
|
|
98
|
+
!name ||
|
|
99
|
+
!isUrlQueryParam(name) ||
|
|
100
|
+
EXCLUDED_URL_QUERY_PARAMS.indexOf(lowerName) !== -1
|
|
101
|
+
) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (separator === -1 || URL_VALUED_QUERY_PARAMS.indexOf(lowerName) === -1) {
|
|
106
|
+
return pair;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let value: string;
|
|
110
|
+
try {
|
|
111
|
+
value = decodeQueryComponent(pair.slice(separator + 1));
|
|
112
|
+
} catch (e) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const cleanedValue = cleanUrl(value, UrlMode.Path);
|
|
116
|
+
return cleanedValue === value
|
|
117
|
+
? pair
|
|
118
|
+
: `${rawName}=${encodeURIComponent(cleanedValue)}`;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const cleanQuery = (query: string): string =>
|
|
122
|
+
query
|
|
123
|
+
.split('&')
|
|
124
|
+
.map(cleanQueryPair)
|
|
125
|
+
.filter((pair): pair is string => pair !== null)
|
|
126
|
+
.join('&');
|
|
127
|
+
|
|
128
|
+
const keepsFragmentPair = (pair: string): boolean => {
|
|
129
|
+
try {
|
|
130
|
+
const name = decodeQueryComponent(pair.split('=')[0]);
|
|
131
|
+
return EXCLUDED_URL_FRAGMENT_PARAMS.indexOf(name.toLowerCase()) === -1;
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// A hash route (`#/...`, `#!...`, or a path with no `=` before its `?`) keeps
|
|
138
|
+
// its path; a query inside it gets the same allowlist as the URL's own query.
|
|
139
|
+
// A fragment of bare pairs loses the OAuth/OIDC ones.
|
|
140
|
+
const cleanFragment = (hash: string): string => {
|
|
141
|
+
const queryStart = hash.indexOf('?');
|
|
142
|
+
const route = queryStart === -1 ? hash : hash.slice(0, queryStart);
|
|
143
|
+
const isRoute =
|
|
144
|
+
/^#[/!]/.test(hash) || (queryStart !== -1 && route.indexOf('=') === -1);
|
|
145
|
+
|
|
146
|
+
if (isRoute && queryStart !== -1) {
|
|
147
|
+
const query = cleanQuery(hash.slice(queryStart + 1));
|
|
148
|
+
return route + (query ? `?${query}` : '');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (hash.indexOf('=') === -1) {
|
|
152
|
+
return hash;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const pairs = hash
|
|
156
|
+
.slice(1)
|
|
157
|
+
.split('&')
|
|
158
|
+
.filter(keepsFragmentPair)
|
|
159
|
+
.join('&');
|
|
160
|
+
return pairs ? `#${pairs}` : '';
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// String-based rather than `new URL`, so kept pairs and the fragment go out
|
|
164
|
+
// exactly as the browser reported them and no environment can parse differently.
|
|
165
|
+
// An unknown mode is treated as `path`.
|
|
166
|
+
export const cleanUrl = (href: string, mode: UrlMode): string => {
|
|
167
|
+
if (typeof href !== 'string' || !href) {
|
|
168
|
+
return '';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const hashStart = href.indexOf('#');
|
|
172
|
+
const hash = hashStart === -1 ? '' : href.slice(hashStart);
|
|
173
|
+
const beforeHash = hashStart === -1 ? href : href.slice(0, hashStart);
|
|
174
|
+
const queryStart = beforeHash.indexOf('?');
|
|
175
|
+
const path = (queryStart === -1
|
|
176
|
+
? beforeHash
|
|
177
|
+
: beforeHash.slice(0, queryStart)
|
|
178
|
+
).replace(USERINFO, '$1');
|
|
179
|
+
|
|
180
|
+
if (mode !== UrlMode.Full) {
|
|
181
|
+
return path;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const query =
|
|
185
|
+
queryStart === -1 ? '' : cleanQuery(beforeHash.slice(queryStart + 1));
|
|
186
|
+
|
|
187
|
+
return path + (query ? `?${query}` : '') + cleanFragment(hash);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// The current page as it may appear on an event.
|
|
191
|
+
export const cleanPageHref = (): string => cleanUrl(getHref(), UrlMode.Full);
|
package/src/pageViewTracker.ts
CHANGED
|
@@ -37,45 +37,9 @@ type WindowWithApv = Window & {
|
|
|
37
37
|
[WIN_APV_KEY]?: IApvState;
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// none of those should reach the event stream because someone forgot to exclude
|
|
43
|
-
// them. Anything absent from this list is dropped.
|
|
44
|
-
//
|
|
45
|
-
// SECURITY: `code`, `state` and `nonce` are the OAuth 2.0 / OIDC authorization
|
|
46
|
-
// code and the CSRF/replay tokens. They are credentials until redeemed, and
|
|
47
|
-
// attaching them here persists them in the event store and forwards them to
|
|
48
|
-
// every configured kit. They are on the list by explicit product decision —
|
|
49
|
-
// deleting that line is the whole of the fix if that decision is revisited.
|
|
40
|
+
// Parameters that identify which page a view is of. Authorization-flow and campaign
|
|
41
|
+
// attribution parameters are deliberately absent; an input can add them explicitly.
|
|
50
42
|
export const ALLOWED_QUERY_PARAMS: string[] = [
|
|
51
|
-
// Campaign attribution
|
|
52
|
-
'utm_source',
|
|
53
|
-
'utm_medium',
|
|
54
|
-
'utm_campaign',
|
|
55
|
-
'utm_term',
|
|
56
|
-
'utm_content',
|
|
57
|
-
'utm_id',
|
|
58
|
-
|
|
59
|
-
// Ad-network click ids
|
|
60
|
-
'gclid',
|
|
61
|
-
'gbraid',
|
|
62
|
-
'wbraid',
|
|
63
|
-
'fbclid',
|
|
64
|
-
'msclkid',
|
|
65
|
-
'ttclid',
|
|
66
|
-
'twclid',
|
|
67
|
-
'li_fat_id',
|
|
68
|
-
'dclid',
|
|
69
|
-
|
|
70
|
-
// OAuth / OIDC — see the SECURITY note above
|
|
71
|
-
'client_id',
|
|
72
|
-
'redirect_uri',
|
|
73
|
-
'response_type',
|
|
74
|
-
'scope',
|
|
75
|
-
'state',
|
|
76
|
-
'code',
|
|
77
|
-
'nonce',
|
|
78
|
-
|
|
79
43
|
// Pagination
|
|
80
44
|
'page',
|
|
81
45
|
'limit',
|
|
@@ -120,9 +84,62 @@ export const ALLOWED_QUERY_PARAMS: string[] = [
|
|
|
120
84
|
'referrer',
|
|
121
85
|
];
|
|
122
86
|
|
|
87
|
+
export const AUTO_PAGE_VIEW_ATTRIBUTE = 'is_auto_page_view';
|
|
88
|
+
|
|
89
|
+
const CORE_EVENT_FIELDS = ['hostname', 'title', 'path', AUTO_PAGE_VIEW_ATTRIBUTE];
|
|
90
|
+
|
|
91
|
+
const PROTOTYPE_MEMBER_NAMES = ['constructor', '__proto__', 'prototype'];
|
|
92
|
+
|
|
93
|
+
// Server flag values are the strings 'True' and 'False', so these get typed here by habit.
|
|
94
|
+
const FLAG_VALUE_NAMES = ['true', 'false'];
|
|
95
|
+
|
|
96
|
+
const RESERVED_QUERY_PARAMS = CORE_EVENT_FIELDS.concat(
|
|
97
|
+
PROTOTYPE_MEMBER_NAMES,
|
|
98
|
+
FLAG_VALUE_NAMES
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const BUILT_IN_NAMES = ALLOWED_QUERY_PARAMS.map(name => name.toLowerCase());
|
|
102
|
+
|
|
103
|
+
const isBuiltIn = (name: string): boolean =>
|
|
104
|
+
BUILT_IN_NAMES.indexOf(name.toLowerCase()) !== -1;
|
|
105
|
+
|
|
106
|
+
// Excludes `&`, `=`, `%` and whitespace, which would distort the dedup key.
|
|
107
|
+
const QUERY_PARAM_NAME = /^[a-z0-9_][a-z0-9_.-]{0,63}$/;
|
|
108
|
+
|
|
109
|
+
export const MAX_CUSTOM_QUERY_PARAMS = 25;
|
|
110
|
+
|
|
111
|
+
export interface IQueryParamAllowlist {
|
|
112
|
+
allowed: string[];
|
|
113
|
+
|
|
114
|
+
// Positions, never names: a rejected entry may be a secret pasted into the wrong field.
|
|
115
|
+
rejectedPositions: number[];
|
|
116
|
+
|
|
117
|
+
overLimit: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const MAX_CUSTOM_QUERY_PARAM_VALUE_LENGTH = 512;
|
|
121
|
+
|
|
122
|
+
// A list, not a dictionary: its order is the order the dedup key is built in.
|
|
123
|
+
export interface ICapturedParam {
|
|
124
|
+
name: string;
|
|
125
|
+
value: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Params matched by name prefix, for platforms that put a per-product id in the
|
|
129
|
+
// name. Salesforce Commerce Cloud spells a variant choice
|
|
130
|
+
// `dwvar_<productId>_<attribute>`, e.g. `dwvar_12345_color=navy`.
|
|
131
|
+
export const ALLOWED_QUERY_PARAM_PREFIXES: string[] = ['dwvar_'];
|
|
132
|
+
|
|
133
|
+
const hasAllowedPrefix = (name: string): boolean =>
|
|
134
|
+
ALLOWED_QUERY_PARAM_PREFIXES.some(prefix => name.indexOf(prefix) === 0);
|
|
135
|
+
|
|
136
|
+
// Whether a query param name, in any case, is one an auto page view captures by default.
|
|
137
|
+
export const isAllowedQueryParam = (name: string): boolean =>
|
|
138
|
+
isBuiltIn(name) || hasAllowedPrefix(name.toLowerCase());
|
|
139
|
+
|
|
123
140
|
interface IPageSnapshot {
|
|
124
141
|
path: string;
|
|
125
|
-
params:
|
|
142
|
+
params: ICapturedParam[];
|
|
126
143
|
}
|
|
127
144
|
|
|
128
145
|
interface IPageViewData extends IPageSnapshot {
|
|
@@ -147,15 +164,114 @@ interface IPendingNavigation {
|
|
|
147
164
|
// on their own, which is where the interesting rules live.
|
|
148
165
|
// ---------------------------------------------------------------------------
|
|
149
166
|
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
167
|
+
// Validated here as well as in the dashboard, because remote config is untrusted input.
|
|
168
|
+
export const parseQueryParamAllowlist = (
|
|
169
|
+
configured: unknown
|
|
170
|
+
): IQueryParamAllowlist => {
|
|
171
|
+
const allowed: string[] = [];
|
|
172
|
+
const rejectedPositions: number[] = [];
|
|
173
|
+
let overLimit = 0;
|
|
157
174
|
|
|
158
|
-
|
|
175
|
+
if (!configured) {
|
|
176
|
+
return { allowed, rejectedPositions, overLimit };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const entries: unknown[] = Array.isArray(configured)
|
|
180
|
+
? configured
|
|
181
|
+
: String(configured).split(',');
|
|
182
|
+
|
|
183
|
+
entries.forEach((entry, index) => {
|
|
184
|
+
const position = index + 1;
|
|
185
|
+
const name = String(entry)
|
|
186
|
+
.trim()
|
|
187
|
+
.toLowerCase();
|
|
188
|
+
|
|
189
|
+
if (!name) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (
|
|
194
|
+
!QUERY_PARAM_NAME.test(name) ||
|
|
195
|
+
RESERVED_QUERY_PARAMS.indexOf(name) !== -1
|
|
196
|
+
) {
|
|
197
|
+
rejectedPositions.push(position);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (isBuiltIn(name) || allowed.indexOf(name) !== -1) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (allowed.length >= MAX_CUSTOM_QUERY_PARAMS) {
|
|
206
|
+
overLimit++;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
allowed.push(name);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return { allowed, rejectedPositions, overLimit };
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Not localeCompare: this order feeds the dedup key, so it must be identical in every
|
|
217
|
+
// browser, and localeCompare is locale-dependent.
|
|
218
|
+
const byName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
219
|
+
|
|
220
|
+
// Built-ins keep their positions and additions sort after them, so a dedup key depends on
|
|
221
|
+
// which params are configured, not the order they were typed.
|
|
222
|
+
export const effectiveAllowlist = (extras: string[] = []): string[] =>
|
|
223
|
+
ALLOWED_QUERY_PARAMS.concat(
|
|
224
|
+
extras.filter(name => !isBuiltIn(name)).sort(byName)
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// Over-long custom values are dropped rather than truncated: two truncated values could
|
|
228
|
+
// share a dedup key and swallow a real page view.
|
|
229
|
+
const isWithinValueLimit = (name: string, value: string): boolean =>
|
|
230
|
+
isBuiltIn(name) || value.length <= MAX_CUSTOM_QUERY_PARAM_VALUE_LENGTH;
|
|
231
|
+
|
|
232
|
+
// A prefix match is a platform name, not a configured extra, so the custom
|
|
233
|
+
// value limit does not apply. A name already on the allowlist keeps that
|
|
234
|
+
// position instead of being appended again.
|
|
235
|
+
const prefixMatchedParams = (
|
|
236
|
+
href: string,
|
|
237
|
+
allowlist: string[]
|
|
238
|
+
): ICapturedParam[] => {
|
|
239
|
+
const allParams = queryStringParser(href);
|
|
240
|
+
|
|
241
|
+
return Object.keys(allParams)
|
|
242
|
+
.filter(
|
|
243
|
+
name =>
|
|
244
|
+
hasAllowedPrefix(name) &&
|
|
245
|
+
allParams[name] &&
|
|
246
|
+
allowlist.indexOf(name) === -1
|
|
247
|
+
)
|
|
248
|
+
.sort(byName)
|
|
249
|
+
.map(name => ({ name, value: allParams[name] }));
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export const allowedQueryParams = (
|
|
253
|
+
href: string,
|
|
254
|
+
extras: string[] = []
|
|
255
|
+
): ICapturedParam[] => {
|
|
256
|
+
const allowlist = effectiveAllowlist(extras);
|
|
257
|
+
const found = queryStringParser(href, allowlist);
|
|
258
|
+
const listed = allowlist
|
|
259
|
+
.filter(name => hasOwnProp(found, name))
|
|
260
|
+
.filter(name => isWithinValueLimit(name, found[name]))
|
|
261
|
+
.map(name => ({ name, value: found[name] }));
|
|
262
|
+
|
|
263
|
+
return listed.concat(prefixMatchedParams(href, allowlist));
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export const paramsToAttributes = (
|
|
267
|
+
params: ICapturedParam[]
|
|
268
|
+
): Dictionary<string> => {
|
|
269
|
+
const attributes: Dictionary<string> = {};
|
|
270
|
+
params.forEach(({ name, value }) => {
|
|
271
|
+
attributes[name] = value;
|
|
272
|
+
});
|
|
273
|
+
return attributes;
|
|
274
|
+
};
|
|
159
275
|
|
|
160
276
|
// Absent rather than false on a manual page view, so the attribute is only ever
|
|
161
277
|
// added by the automatic emitters. Shared by both of them so the key and that
|
|
@@ -165,32 +281,11 @@ export const autoPageViewAttribute = (
|
|
|
165
281
|
): Dictionary<boolean> =>
|
|
166
282
|
isAutoPageView ? { [AUTO_PAGE_VIEW_ATTRIBUTE]: true } : {};
|
|
167
283
|
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// does not depend on the object's insertion order, so reordering the query string
|
|
171
|
-
// cannot produce a different key.
|
|
172
|
-
//
|
|
173
|
-
// Membership is an own-property check, not `in`. `in` walks the prototype
|
|
174
|
-
// chain, so a name matching an Object.prototype member reports as present on
|
|
175
|
-
// any plain object. ALLOWED_QUERY_PARAMS contains no such name, which was the
|
|
176
|
-
// only thing making `in` safe here — and it stops being a safe assumption the
|
|
177
|
-
// moment this list can be extended from configuration.
|
|
178
|
-
const capturedNames = (params: Dictionary<string>): string[] =>
|
|
179
|
-
ALLOWED_QUERY_PARAMS.filter(name => hasOwnProp(params, name));
|
|
180
|
-
|
|
181
|
-
// The dedup key: pathname plus the allowlisted params in a fixed order, so that
|
|
182
|
-
// reordering the query string is not a new page. Params outside the allowlist
|
|
183
|
-
// never make it into `page.params` and so cannot key a view — nor can the hash.
|
|
184
|
-
//
|
|
185
|
-
// Values are re-encoded because queryStringParser hands them back DECODED. A
|
|
186
|
-
// value holding the pair delimiters would otherwise serialize exactly like two
|
|
187
|
-
// separate params — `{q: 'a&search=b'}` and `{q: 'a', search: 'b'}` both becoming
|
|
188
|
-
// `q=a&search=b` — and dedup would treat a real navigation between them as the
|
|
189
|
-
// same page and drop the view. `q`, `search` and `redirect_uri` carry `&` and `=`
|
|
190
|
-
// routinely, so this is reachable rather than theoretical.
|
|
284
|
+
// Values are re-encoded because they arrive decoded: `{q: 'a&search=b'}` would otherwise
|
|
285
|
+
// key the same as `{q: 'a', search: 'b'}` and dedup a real navigation away.
|
|
191
286
|
export const pageKey = (page: IPageSnapshot): string => {
|
|
192
|
-
const query =
|
|
193
|
-
.map(name => `${name}=${encodeURIComponent(
|
|
287
|
+
const query = page.params
|
|
288
|
+
.map(({ name, value }) => `${name}=${encodeURIComponent(value)}`)
|
|
194
289
|
.join('&');
|
|
195
290
|
|
|
196
291
|
return query ? `${page.path}?${query}` : page.path;
|
|
@@ -220,7 +315,7 @@ export const buildPageViewEvent = ({
|
|
|
220
315
|
// wins. No allowlist entry collides with hostname/title/path today; naming
|
|
221
316
|
// them here is what keeps a later addition from silently overwriting one.
|
|
222
317
|
data: {
|
|
223
|
-
...params,
|
|
318
|
+
...paramsToAttributes(params),
|
|
224
319
|
hostname,
|
|
225
320
|
title,
|
|
226
321
|
path,
|
|
@@ -310,9 +405,9 @@ const clearActiveTracker = (tracker: PageViewTracker): void => {
|
|
|
310
405
|
}
|
|
311
406
|
};
|
|
312
407
|
|
|
313
|
-
const currentPage = (): IPageSnapshot => ({
|
|
408
|
+
const currentPage = (extras: string[]): IPageSnapshot => ({
|
|
314
409
|
path: window.location.pathname,
|
|
315
|
-
params: allowedQueryParams(getHref()),
|
|
410
|
+
params: allowedQueryParams(getHref(), extras),
|
|
316
411
|
});
|
|
317
412
|
|
|
318
413
|
// Log-safe description of a page: the path, plus the NAMES of the captured
|
|
@@ -326,7 +421,7 @@ const describePage = (page: IPageSnapshot | null): string => {
|
|
|
326
421
|
return '';
|
|
327
422
|
}
|
|
328
423
|
|
|
329
|
-
const names =
|
|
424
|
+
const names = page.params.map(({ name }) => name);
|
|
330
425
|
return names.length ? `${page.path} (params: ${names.join()})` : page.path;
|
|
331
426
|
};
|
|
332
427
|
|
|
@@ -344,6 +439,8 @@ export class PageViewTracker {
|
|
|
344
439
|
|
|
345
440
|
private lastPage: IPageSnapshot | null = null;
|
|
346
441
|
private active = false;
|
|
442
|
+
|
|
443
|
+
private customQueryParams: string[] = [];
|
|
347
444
|
private pendingNavigations: IPendingNavigation[] = [];
|
|
348
445
|
|
|
349
446
|
private undoHistoryPatch: (() => void) | null = null;
|
|
@@ -388,7 +485,8 @@ export class PageViewTracker {
|
|
|
388
485
|
}
|
|
389
486
|
|
|
390
487
|
this.active = true;
|
|
391
|
-
this.
|
|
488
|
+
this.customQueryParams = this.readCustomQueryParams();
|
|
489
|
+
this.lastPage = currentPage(this.customQueryParams);
|
|
392
490
|
this.log(`[init] seeded lastPage: ${describePage(this.lastPage)}`);
|
|
393
491
|
|
|
394
492
|
this.undoHistoryPatch = subscribeToRouteChange(
|
|
@@ -421,6 +519,36 @@ export class PageViewTracker {
|
|
|
421
519
|
clearActiveTracker(this);
|
|
422
520
|
}
|
|
423
521
|
|
|
522
|
+
private readCustomQueryParams(): string[] {
|
|
523
|
+
const {
|
|
524
|
+
allowed = [],
|
|
525
|
+
rejectedPositions = [],
|
|
526
|
+
overLimit = 0,
|
|
527
|
+
} = (this.mpInstance._Store.SDKConfig.autoLogPageViewQueryParams ||
|
|
528
|
+
{}) as IQueryParamAllowlist;
|
|
529
|
+
|
|
530
|
+
if (rejectedPositions.length) {
|
|
531
|
+
this.mpInstance.Logger.warning(
|
|
532
|
+
'mParticle APV: ignoring invalid additional page view query ' +
|
|
533
|
+
`parameters at positions ${rejectedPositions.join(', ')}`
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (overLimit) {
|
|
538
|
+
this.mpInstance.Logger.warning(
|
|
539
|
+
`mParticle APV: ignoring ${overLimit} additional page view ` +
|
|
540
|
+
`query parameters beyond the limit of ` +
|
|
541
|
+
`${MAX_CUSTOM_QUERY_PARAMS}`
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (allowed.length) {
|
|
546
|
+
this.log(`[init] additional query params: ${allowed.join()}`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return allowed;
|
|
550
|
+
}
|
|
551
|
+
|
|
424
552
|
// Stops the outgoing tracker and returns the pages it had queued so this
|
|
425
553
|
// tracker can re-schedule them once it is active. Private, but reachable
|
|
426
554
|
// across instances: the handoff protocol stays internal to the class.
|
|
@@ -468,7 +596,7 @@ export class PageViewTracker {
|
|
|
468
596
|
}
|
|
469
597
|
|
|
470
598
|
private handleNavigation(source: NavigationSource): void {
|
|
471
|
-
const candidate = currentPage();
|
|
599
|
+
const candidate = currentPage(this.customQueryParams);
|
|
472
600
|
const lastKey = this.lastPage ? pageKey(this.lastPage) : null;
|
|
473
601
|
|
|
474
602
|
this.log(
|