-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathbeacon.ts
More file actions
316 lines (263 loc) · 9.05 KB
/
Copy pathbeacon.ts
File metadata and controls
316 lines (263 loc) · 9.05 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
311
312
313
314
315
316
import type { ConfigObject, UserConfig } from "./config";
import { wasPrerendered } from "./document";
import * as Events from "./events";
import Flags, { addFlag } from "./flags";
import { addListener } from "./listeners";
import Logger, { LogEvent } from "./logger";
import type { LoAFScriptSummary, LoAFSummary } from "./metric/LoAF";
import type { NavigationTimingData } from "./metric/navigation-timing";
import * as PROPS from "./minification";
import now from "./now";
import { getPageRestoreTime, getZeroTime, msSincePageInit } from "./timing";
import { postJson } from "./transport";
import { VERSION } from "./version";
type BeaconOptions = {
config: ConfigObject;
logger: Logger;
customerId: string;
sessionId: string;
pageId: string;
startTime?: number;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CollectorFunction = (config: UserConfig) => any;
/**
* Some values should only be reported if they are non-zero. The exception to this is when the page
* was prerendered or restored from BF cache
*/
export function shouldReportValue(value: number) {
if (getPageRestoreTime() || wasPrerendered()) {
return value >= 0;
}
return value > 0;
}
/**
* Fit an array of user timing delimited strings into a URL and return both the entries that fit and
* the remaining entries that didn't fit.
*/
export function fitUserTimingEntries(utValues: string[], config: ConfigObject, url: string) {
// Start with the maximum allowed UT entries per beacon
const beaconUtValues = utValues.slice(0, config.maxBeaconUTEntries);
const remainingUtValues = utValues.slice(config.maxBeaconUTEntries);
// Trim UT entries until they fit within the maximum URL length, ensuring at least one UT entry
// is included.
while (
(url + "&UT=" + beaconUtValues.join(","))[PROPS.length] > config.maxBeaconUrlLength &&
beaconUtValues[PROPS.length] > 1
) {
remainingUtValues.unshift(beaconUtValues.pop()!);
}
return [beaconUtValues, remainingUtValues];
}
export class Beacon {
config: ConfigObject;
logger: Logger;
isRecording = true;
isSent = false;
sendRetries = 0;
maxMeasureTimeout = 0;
customerId: string;
pageId: string;
sessionId: string;
flags = 0;
startTime: number;
metricCollectors: { [k in BeaconMetricKey]?: CollectorFunction } = {};
onBeforeSendCbs: Array<() => void> = [];
constructor(opts: BeaconOptions) {
this.startTime = opts.startTime || getZeroTime();
this.config = opts.config;
this.logger = opts.logger;
this.customerId = opts.customerId;
this.sessionId = opts.sessionId;
this.pageId = opts.pageId;
this.maxMeasureTimeout = window.setTimeout(() => {
this.logger.logEvent(LogEvent.PostBeaconTimeoutReached);
this.stopRecording();
this.send();
}, this.config.maxMeasureTime - msSincePageInit());
addListener("securitypolicyviolation", (e: SecurityPolicyViolationEvent) => {
if (e.disposition !== "report" && e.blockedURI === this.config.beaconUrlV2 && "URL" in self) {
// Some websites might have CSP rules that allow the GET beacon, but not the POST beacon.
// We can detect this here and attempt to send the beacon to a fallback endpoint.
//
// If the fallback endpoint has not been provided in the config, we will fall back to using
// the POST beacon pathname on the GET beacon origin.
if (!this.config.beaconUrlFallback) {
const getOrigin = new URL(this.config.beaconUrl).origin;
const postPathname = new URL(this.config.beaconUrlV2).pathname;
this.config.beaconUrlFallback = getOrigin + postPathname;
}
// Update the V2 beacon URL
this.config.beaconUrlV2 = this.config.beaconUrlFallback!;
this.logger.logEvent(LogEvent.PostBeaconCSPViolation, [this.config.beaconUrlV2]);
this.addFlag(Flags.BeaconBlockedByCsp);
// Not all browsers return false if sendBeacon fails. In this case, `this.isSent` will be
// true, even though the beacon wasn't sent. We need to reset this flag to ensure we can
// retry sending the beacon.
this.isSent = false;
// Try to send the beacon again
if (this.sendRetries < 1) {
this.sendRetries++;
this.send();
}
}
});
this.logger.logEvent(LogEvent.PostBeaconInitialised);
}
isBeingSampled() {
const bucket = parseInt(String(this.sessionId).slice(-2));
return bucket < this.config.samplerate;
}
stopRecording() {
this.isRecording = false;
this.logger.logEvent(LogEvent.PostBeaconStopRecording);
}
addCollector<K extends BeaconMetricKey>(metric: K, collector: CollectorFunction) {
this.metricCollectors[metric] = collector;
}
addFlag(flag: number) {
this.flags = addFlag(this.flags, flag);
}
beaconUrl() {
return this.config.beaconUrlV2;
}
onBeforeSend(cb: () => void) {
this.onBeforeSendCbs[PROPS.push](cb);
}
send() {
if (this.isSent) {
return;
}
this.logger.logEvent(LogEvent.PostBeaconSendCalled);
for (const cb of this.onBeforeSendCbs) {
cb();
}
if (!this.isBeingSampled()) {
return;
}
const collectionStart = now();
const metricData: Partial<BeaconMetricData> = {};
for (const metric in this.metricCollectors) {
const data = this.metricCollectors[metric as BeaconMetricKey]!(this.config);
this.logger.logEvent(LogEvent.PostBeaconCollector, [metric, !!data]);
if (data) {
metricData[metric as BeaconMetricKey] = data;
}
}
if (!Object.keys(metricData)[PROPS.length] && !this.config.allowEmptyPostBeacon) {
// TODO: This is only required while the new beacon is supplementary. Once it's the primary
// beacon, we should send it regardless of how much metric data it has.
this.logger.logEvent(LogEvent.PostBeaconCancelled);
return;
}
// Only clear the max measure timeout if there's data to send.
clearTimeout(this.maxMeasureTimeout);
const beaconUrl = this.beaconUrl();
const payload: BeaconPayload = Object.assign(
{
customerId: this.customerId,
flags: this.flags,
measureDuration: msSincePageInit(),
collectionDuration: now() - collectionStart,
pageId: this.pageId,
scriptVersion: VERSION,
snippetVersion: this.config.snippetVersion,
sessionId: this.sessionId,
startTime: this.startTime,
},
metricData,
);
try {
if (postJson(beaconUrl, JSON.stringify(payload))) {
this.isSent = true;
this.logger.logEvent(LogEvent.PostBeaconSent, [beaconUrl, payload]);
Events.emit("beacon", payload);
}
} catch {
// Intentionally empty; handled below
}
if (!this.isSent) {
this.logger.logEvent(LogEvent.PostBeaconSendFailed, [beaconUrl, payload]);
}
}
}
export type BeaconPayload = BeaconMetaData & Partial<BeaconMetricData>;
export type BeaconMetaData = {
customerId: string;
pageId: string;
sessionId: string;
flags: number;
/** When this beacon started measuring */
startTime: number;
/** The lux.js version that sent the beacon */
scriptVersion: string;
/** The lux.js snippet version that sent the beacon */
snippetVersion?: string;
/** How long in milliseconds did this beacon capture page data for */
measureDuration: number;
/** How long in milliseconds did the collection process take */
collectionDuration: number;
};
export enum BeaconMetricKey {
CLS = "cls",
INP = "inp",
FCP = "fcp",
LCP = "lcp",
LoAF = "loaf",
RageClick = "rage",
NavigationTiming = "nt",
}
export type BeaconMetricData = {
[BeaconMetricKey.NavigationTiming]: NavigationTimingData;
[BeaconMetricKey.FCP]: MetricWithValue;
[BeaconMetricKey.LCP]: MetricWithValue & {
attribution: MetricAttribution | null;
/** LCP sub-parts can be null if the LCP element was not loaded by a resource */
subParts: {
resourceLoadDelay: number;
resourceLoadTime: number;
elementRenderDelay: number;
} | null;
};
[BeaconMetricKey.LoAF]: LoAFSummary;
[BeaconMetricKey.INP]: MetricWithValue & {
startTime: number;
duration: number;
attribution: INPAttribution | null;
subParts: {
inputDelay: number;
processingTime: number;
presentationDelay: number;
processingStart: number;
processingEnd: number;
};
};
[BeaconMetricKey.CLS]: MetricWithValue & {
startTime: number | null;
/** Largest entry can be null if there were no layout shifts (value will be 0) */
largestEntry: {
value: number;
startTime: number;
} | null;
sources: CLSAttribution[] | null;
};
[BeaconMetricKey.RageClick]: MetricWithValue & {
startTime: number;
attribution: MetricAttribution;
};
};
export type CLSAttribution = MetricAttribution & {
value: number;
startTime: number;
};
export type INPAttribution = MetricAttribution & {
eventType: string;
loafScripts: LoAFScriptSummary[];
};
type MetricWithValue = {
value: number;
};
export type MetricAttribution = {
elementSelector: string | null;
elementType: string | null;
};