forked from snowplow/snowplow-javascript-tracker
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathconfiguration.ts
More file actions
310 lines (281 loc) · 12.3 KB
/
configuration.ts
File metadata and controls
310 lines (281 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
import type { Logger, SelfDescribingJson } from '@snowplow/tracker-core';
import { isDataSelector } from './data';
import { type DataSelector, Frequency, type OneOrMany, type RequiredExcept } from './types';
export enum ConfigurationState {
INITIAL,
CONFIGURED,
}
/**
* A dynamic context provider for events generated from the plugin.
*
* Can be a static list of Self Describing JSON entities, or a function that returns the same.
* The function will receive the matching element, and matching element configuration as parameters.
*/
export type ContextProvider =
| SelfDescribingJson[]
| ((element: Element | HTMLElement | undefined, match: Configuration) => SelfDescribingJson[]);
/**
* Options controlling when this type of event should occur.
*/
type BaseOptions = {
/**
* Frequency cap options for how often this should be tracked over the lifetime of the plugin.
*/
when: `${Frequency}`;
/**
* A custom DataSelector defining if an element should trigger the event or not. If the DataSelector returns no result triplets, the event does not trigger. The `match` operation can be used here to do some logic against the other types of operators.
*/
condition?: DataSelector;
};
/**
* Additional options for controlling when an EXPOSE event should occur.
*/
type ExposeOptions = BaseOptions & {
/**
* For larger elements, only trigger if at least this proportion of the element is visible on screen; expects: 0.0 - 1.0; default: 0
*/
minPercentage?: number;
/**
* Only trigger once the element has been in view for at least this many milliseconds. The time is measured cumulatively. After the threshold is met it will re-fire immediately.
*/
minTimeMillis?: number;
/**
* Don't count this element as visible unless its area (height * width) is at least this many pixels. Useful to prohibit empty container elements being tracked as visible.
*/
minSize?: number;
/**
* Add these dimensions (in pixels) to the element size when calculating minPercentage. Used to increase/decrease the size of the actual element before considering it visible.
*/
boundaryPixels?: [number, number, number, number] | [number, number] | number;
};
/**
* Input configuration format for describing a set of elements to be tracked with this plugin.
*/
export type ElementConfiguration = {
/**
* Logical name for elements matched by this configuration. This name will be used to describe any matching elements in event payloads and entities, and to associate the data between them.
* If not provided, the `selector` is used as `name`.
*/
name?: string;
/**
* Required. CSS selector to determine the set of elements that match this configuration.
*/
selector: string;
/**
* If `selector` is intended for matching elements within custom elements or shadow DOM hosts, specify a selector for the shadow hosts here; this will be used to identify shadow-elements that match `selector` that would otherwise not be visible.
*/
shadowSelector?: string;
/**
* If using `shadowSelector` to indicate `selector` matches elements in shadow hosts; use this to specify that only elements within shadow hosts matching `shadowSelector` should match; if `false` (default), elements outside shadow hosts (that are not necessarily children of `shadowSelector` hosts) will match the configuration also.
*/
shadowOnly?: boolean;
/**
* Configure when, if ever, element create events should be triggered when detected for elements matching this configuration.
* Defaults to `false`, which is shorthand for `{ when: 'never' }`.
*/
create?: boolean | BaseOptions;
/**
* Configure when, if ever, element destroy events should be triggered when detected for elements matching this configuration.
* Defaults to `false`, which is shorthand for `{ when: 'never' }`.
*/
destroy?: boolean | BaseOptions;
/**
* Configure when, if ever, element expose events should be triggered when detected for elements matching this configuration.
* Also specify additional criteria on relevant for expose events like minimum size or visibility time.
* Defaults to `true`, which is shorthand for `{ when: 'always' }` and `0` for all other options.
*/
expose?: boolean | ExposeOptions;
/**
* Configure when, if ever, element obscure events should be triggered when detected for elements matching this configuration.
* Defaults to `false`, which is shorthand for `{ when: 'never' }`.
*/
obscure?: boolean | BaseOptions;
/**
* Indicate that elements matching this configuration are "components"; their ancestry of other elements will be identified in the component_parents entity if this is set (using this configuration's `name`).
*/
component?: boolean;
/**
* When events occur to elements matching this configuration, extract data from one or more DataSelectors and include them in `attributes` in the `element` entity that describes that element.
*/
details?: OneOrMany<DataSelector>;
/**
* When events occur to elements matching this configuration, evaluate one or more nested configurations using the matching element as a root; the `name`, `selector`, `details`, and `contents` will be processed, with other options ignored. The resulting elements (and optionally their `details` will be included as entities on events for this element.)
*/
contents?: OneOrMany<ElementConfiguration>;
/**
* Types of events that statistics for the element should be included on, if any. Should be the `event_name` value for events that should have the entity attached. E.g. `page_view`, `page_ping`, `expose_element`, `my_custom_event`.
*/
includeStats?: OneOrMany<string>;
/**
* Provide custom context entities for events generated from this configuration.
*/
context?: ContextProvider;
/**
* An optional ID for this configuration.
* Calls to track configurations with a specific ID will override previous configurations with the same ID.
* No impact on actual tracking or payloads.
*/
id?: string;
};
/**
* Parsed valid version of `ElementConfiguration`.
* Removes some ambiguities allowed in that type that are there for a more pleasant configuration API.
*/
export type Configuration = Omit<
RequiredExcept<ElementConfiguration, 'id' | 'shadowSelector'>,
'create' | 'destroy' | 'expose' | 'obscure' | 'details' | 'includeStats' | 'contents'
> & {
trackers?: string[];
create: BaseOptions;
destroy: BaseOptions;
expose: RequiredExcept<ExposeOptions, 'condition'>;
obscure: BaseOptions;
state: ConfigurationState;
details: DataSelector[];
includeStats: string[];
contents: Configuration[];
context: Extract<ContextProvider, Function>;
};
const DEFAULT_FREQUENCY_OPTIONS: BaseOptions = { when: 'always' };
const emptyProvider: ContextProvider = () => [];
/**
* Create a new ContextProvider that will merge the given `context` into that generated by the plugin or other configuration itself.
* @param context An existing ContextProvider to merge with future unknown context.
* @returns New ContextProvider function that will produce the results of merging its own context with the provided `context`.
*/
export function createContextMerger(batchContext?: ContextProvider, configContext?: ContextProvider): ContextProvider {
return function contextMerger(element, config) {
const result: SelfDescribingJson[] = [];
for (const contextSrc of [batchContext, configContext]) {
if (contextSrc) {
if (typeof contextSrc === 'function') {
if (contextSrc !== contextMerger) result.push(...contextSrc(element, config));
} else {
result.push(...contextSrc);
}
}
}
return result;
};
}
/**
* Parse and validate a given `ElementConfiguration`, returning a more concrete `Configuration` if successful.
* @param config Input configuration to evaluate.
* @param contextProvider The context provider to embed into the configuration; this will handle merging any batch-level context into configuration-level context.
* @param trackers A list of trackers the resulting configuration should send events to, if specified.
* @returns Validated Configuration.
*/
export function checkConfig(
config: ElementConfiguration,
contextProvider: ContextProvider,
intersectionPossible: boolean,
mutationPossible: boolean,
logger?: Logger,
trackers?: string[]
): Configuration {
const { selector, name = selector, shadowSelector, shadowOnly = false, id, component = false } = config;
// essential configs
if (typeof name !== 'string' || !name) throw new Error(`Invalid element name value: ${name}`);
if (typeof selector !== 'string' || !selector) throw new Error(`Invalid element selector value: ${selector}`);
// these will throw if selectors invalid
document.querySelector(selector);
if (shadowSelector) document.querySelector(shadowSelector);
// event type frequencies & options
const { create = false, destroy = false, expose = true, obscure = false } = config;
// simple event configs
const [validCreate, validDestroy, validObscure] = [create, destroy, obscure].map((input) => {
if (!input) return { when: Frequency.NEVER };
if (typeof input === 'object') {
const { when = 'always', condition } = input;
if (condition && !isDataSelector(condition)) throw new Error('Invalid data selector provided for condition');
if (when.toUpperCase() in Frequency) {
return {
when: when.toLowerCase() as Frequency,
condition,
};
} else {
throw new Error(`Unknown tracking frequency: ${when}`);
}
}
return DEFAULT_FREQUENCY_OPTIONS;
});
if ((validCreate.when !== Frequency.NEVER || validDestroy.when !== Frequency.NEVER) && !mutationPossible)
logger?.warn('MutationObserver API unavailable but required for events in configuration:', config);
let validExpose: RequiredExcept<ExposeOptions, 'condition'> | null = null;
// expose has custom options and is more complex
if (expose && typeof expose === 'object') {
const {
when = 'always',
condition,
boundaryPixels = 0,
minPercentage = 0,
minSize = 0,
minTimeMillis = 0,
} = expose;
if (condition && !isDataSelector(condition)) throw new Error('Invalid data selector provided for condition');
if (
(typeof boundaryPixels !== 'number' && !Array.isArray(boundaryPixels)) ||
typeof minPercentage !== 'number' ||
typeof minSize !== 'number' ||
typeof minTimeMillis !== 'number'
)
throw new Error('Invalid expose options provided');
if (when.toUpperCase() in Frequency) {
validExpose = {
when: when.toLowerCase() as Frequency,
condition,
boundaryPixels,
minPercentage,
minSize,
minTimeMillis,
};
} else {
throw new Error(`Unknown tracking frequency: ${when}`);
}
} else if (expose) {
validExpose = {
...DEFAULT_FREQUENCY_OPTIONS,
boundaryPixels: 0,
minPercentage: 0,
minSize: 0,
minTimeMillis: 0,
};
} else {
validExpose = {
when: Frequency.NEVER,
boundaryPixels: 0,
minPercentage: 0,
minSize: 0,
minTimeMillis: 0,
};
}
if ((validExpose.when !== Frequency.NEVER || validObscure.when !== Frequency.NEVER) && !intersectionPossible)
logger?.warn('IntersectionObserver API unavailable but required for events in configuration:', config);
// normalize to arrays (scalars allowed in input for convenience)
let { details = [], contents = [], includeStats = [] } = config;
if (!Array.isArray(details)) details = details == null ? [] : [details];
if (!Array.isArray(contents)) contents = contents == null ? [] : [contents];
if (!Array.isArray(includeStats)) includeStats = includeStats == null ? [] : [includeStats];
if (details.length !== details.filter(isDataSelector).length)
throw new Error('Invalid DataSelector given for details');
return {
name,
selector,
id,
shadowSelector,
shadowOnly,
create: validCreate,
destroy: validDestroy,
expose: validExpose,
obscure: validObscure,
component: !!component,
details,
includeStats,
contents: contents.map((inner) =>
checkConfig(inner, inner.context ?? emptyProvider, intersectionPossible, mutationPossible, logger, trackers)
),
context: typeof contextProvider === 'function' ? contextProvider : () => contextProvider,
trackers,
state: ConfigurationState.INITIAL,
};
}