@atlaskit/editor-plugin-interactivity 0.1.0 → 0.2.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/CHANGELOG.md +13 -0
- package/README.md +34 -6
- package/afm-cc/tsconfig.json +12 -0
- package/afm-products/tsconfig.json +12 -0
- package/dist/cjs/analytics/fire-interactivity-event.js +39 -0
- package/dist/cjs/analytics/interactivity-snapshot.js +1 -0
- package/dist/cjs/collector/bucket-boundaries.js +137 -0
- package/dist/cjs/collector/interaction-group.js +167 -0
- package/dist/cjs/collector/interaction-observer.js +114 -0
- package/dist/cjs/collector/interaction-tracker.js +144 -0
- package/dist/cjs/collector/interactivity-collector.js +289 -0
- package/dist/cjs/collector/interactivity-session.js +52 -0
- package/dist/cjs/collector/lifecycle-observer.js +66 -0
- package/dist/cjs/collector/snapshot-scheduler.js +70 -0
- package/dist/cjs/interactivityPlugin.js +81 -6
- package/dist/es2019/analytics/fire-interactivity-event.js +33 -0
- package/dist/es2019/analytics/interactivity-snapshot.js +0 -0
- package/dist/es2019/collector/bucket-boundaries.js +117 -0
- package/dist/es2019/collector/interaction-group.js +110 -0
- package/dist/es2019/collector/interaction-observer.js +90 -0
- package/dist/es2019/collector/interaction-tracker.js +116 -0
- package/dist/es2019/collector/interactivity-collector.js +228 -0
- package/dist/es2019/collector/interactivity-session.js +42 -0
- package/dist/es2019/collector/lifecycle-observer.js +46 -0
- package/dist/es2019/collector/snapshot-scheduler.js +48 -0
- package/dist/es2019/interactivityPlugin.js +75 -6
- package/dist/esm/analytics/fire-interactivity-event.js +33 -0
- package/dist/esm/analytics/interactivity-snapshot.js +0 -0
- package/dist/esm/collector/bucket-boundaries.js +130 -0
- package/dist/esm/collector/interaction-group.js +161 -0
- package/dist/esm/collector/interaction-observer.js +108 -0
- package/dist/esm/collector/interaction-tracker.js +137 -0
- package/dist/esm/collector/interactivity-collector.js +282 -0
- package/dist/esm/collector/interactivity-session.js +45 -0
- package/dist/esm/collector/lifecycle-observer.js +59 -0
- package/dist/esm/collector/snapshot-scheduler.js +63 -0
- package/dist/esm/interactivityPlugin.js +81 -6
- package/dist/types/analytics/fire-interactivity-event.d.ts +9 -0
- package/dist/types/analytics/interactivity-snapshot.d.ts +61 -0
- package/dist/types/collector/bucket-boundaries.d.ts +35 -0
- package/dist/types/collector/interaction-group.d.ts +25 -0
- package/dist/types/collector/interaction-observer.d.ts +39 -0
- package/dist/types/collector/interaction-tracker.d.ts +56 -0
- package/dist/types/collector/interactivity-collector.d.ts +75 -0
- package/dist/types/collector/interactivity-session.d.ts +39 -0
- package/dist/types/collector/lifecycle-observer.d.ts +25 -0
- package/dist/types/collector/snapshot-scheduler.d.ts +15 -0
- package/dist/types/interactivityPlugin.d.ts +6 -4
- package/dist/types/interactivityPluginType.d.ts +11 -2
- package/docs/0-intro.tsx +25 -7
- package/package.json +8 -3
|
@@ -4,15 +4,90 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
6
|
exports.interactivityPlugin = void 0;
|
|
7
|
+
var _react = require("react");
|
|
8
|
+
var _monitoring = require("@atlaskit/editor-common/monitoring");
|
|
9
|
+
var _fireInteractivityEvent = require("./analytics/fire-interactivity-event");
|
|
10
|
+
var _interactivityCollector = require("./collector/interactivity-collector");
|
|
7
11
|
/**
|
|
8
|
-
* Reports interaction latency distributions for full page editor sessions
|
|
9
|
-
* `editor interactivity` operational event.
|
|
12
|
+
* Reports session-to-date interaction latency distributions for full page editor sessions
|
|
13
|
+
* as the `editor interactivity` operational event.
|
|
10
14
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
15
|
+
* The session runs for as long as the plugin's hook stays mounted, which is one editor
|
|
16
|
+
* mount: `MountPluginHooks` in editor-core keys hook fibers by plugin name, so a preset
|
|
17
|
+
* reconfigure — which destroys and recreates ProseMirror plugin views — leaves this hook,
|
|
18
|
+
* and the session, in place.
|
|
13
19
|
*/
|
|
14
|
-
var interactivityPlugin = exports.interactivityPlugin = function interactivityPlugin() {
|
|
20
|
+
var interactivityPlugin = exports.interactivityPlugin = function interactivityPlugin(_ref) {
|
|
21
|
+
var api = _ref.api;
|
|
15
22
|
return {
|
|
16
|
-
name: 'interactivity'
|
|
23
|
+
name: 'interactivity',
|
|
24
|
+
usePluginHook: function usePluginHook(_ref2) {
|
|
25
|
+
var editorView = _ref2.editorView;
|
|
26
|
+
(0, _react.useEffect)(function () {
|
|
27
|
+
// Nothing measured here is worth an editor. This effect runs inside the plugin slot's
|
|
28
|
+
// error boundary, which also renders every plugin's content components, so a throw
|
|
29
|
+
// from instrumentation would take that UI down with it.
|
|
30
|
+
try {
|
|
31
|
+
var _api$contextIdentifie2, _api$editorViewMode2;
|
|
32
|
+
var collector = new _interactivityCollector.InteractivityCollector({
|
|
33
|
+
emit: function emit(snapshot) {
|
|
34
|
+
var _api$analytics;
|
|
35
|
+
return (0, _fireInteractivityEvent.fireInteractivityEvent)(api === null || api === void 0 || (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions, snapshot);
|
|
36
|
+
},
|
|
37
|
+
getObjectId: function getObjectId() {
|
|
38
|
+
var _api$contextIdentifie;
|
|
39
|
+
return api === null || api === void 0 || (_api$contextIdentifie = api.contextIdentifier) === null || _api$contextIdentifie === void 0 || (_api$contextIdentifie = _api$contextIdentifie.sharedState.currentState()) === null || _api$contextIdentifie === void 0 || (_api$contextIdentifie = _api$contextIdentifie.contextIdentifierProvider) === null || _api$contextIdentifie === void 0 ? void 0 : _api$contextIdentifie.objectId;
|
|
40
|
+
},
|
|
41
|
+
getSessionMode: function getSessionMode() {
|
|
42
|
+
var _api$editorViewMode;
|
|
43
|
+
var mode = api === null || api === void 0 || (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 || (_api$editorViewMode = _api$editorViewMode.sharedState.currentState()) === null || _api$editorViewMode === void 0 ? void 0 : _api$editorViewMode.mode;
|
|
44
|
+
if (mode === undefined) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return mode === 'view' ? 'reading' : 'editing';
|
|
48
|
+
},
|
|
49
|
+
// A destroyed view still answers, with the document it was destroyed with, so
|
|
50
|
+
// both report nothing rather than a size the editor no longer has.
|
|
51
|
+
getNodeSize: function getNodeSize() {
|
|
52
|
+
return editorView.isDestroyed ? undefined : editorView.state.doc.nodeSize;
|
|
53
|
+
},
|
|
54
|
+
// `getElementsByTagName` counts descendants in the browser engine, so this
|
|
55
|
+
// cannot overflow the stack on very large documents.
|
|
56
|
+
getEditorDomSize: function getEditorDomSize() {
|
|
57
|
+
return editorView.isDestroyed ? undefined : editorView.dom.getElementsByTagName('*').length;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
if (!collector.start()) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Confluence live pages navigate and switch between reading and editing without
|
|
65
|
+
// remounting the editor. A session covers one document in one mode, so either
|
|
66
|
+
// change ends it and starts the next.
|
|
67
|
+
var unsubscribeFromObjectId = api === null || api === void 0 || (_api$contextIdentifie2 = api.contextIdentifier) === null || _api$contextIdentifie2 === void 0 ? void 0 : _api$contextIdentifie2.sharedState.onChange(function () {
|
|
68
|
+
return collector.onObjectIdChanged();
|
|
69
|
+
});
|
|
70
|
+
var unsubscribeFromViewMode = api === null || api === void 0 || (_api$editorViewMode2 = api.editorViewMode) === null || _api$editorViewMode2 === void 0 ? void 0 : _api$editorViewMode2.sharedState.onChange(function () {
|
|
71
|
+
return collector.onViewModeChanged();
|
|
72
|
+
});
|
|
73
|
+
return function () {
|
|
74
|
+
unsubscribeFromObjectId === null || unsubscribeFromObjectId === void 0 || unsubscribeFromObjectId();
|
|
75
|
+
unsubscribeFromViewMode === null || unsubscribeFromViewMode === void 0 || unsubscribeFromViewMode();
|
|
76
|
+
collector.stop();
|
|
77
|
+
};
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// Instrumentation must not fail the editor around it, but a failure that hits every
|
|
80
|
+
// browser of one engine would be invisible without this.
|
|
81
|
+
void (0, _monitoring.logException)(error, {
|
|
82
|
+
location: 'editor-plugin-interactivity/start'
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// `api` is deliberately not a dependency: a preset reconfigure hands out a new
|
|
87
|
+
// proxy object, but the one captured here keeps resolving plugins from the same
|
|
88
|
+
// live registry, and restarting the session on a reconfigure would split one
|
|
89
|
+
// editor session in two.
|
|
90
|
+
}, [editorView]);
|
|
91
|
+
}
|
|
17
92
|
};
|
|
18
93
|
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics/types/enums';
|
|
2
|
+
/** The reasons that mean the page may not be around long enough to deliver a queued event. */
|
|
3
|
+
const REASONS_THE_PAGE_MAY_NOT_OUTLIVE = ['hidden', 'pagehide', 'unmount'];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `editor interactivity` operational event.
|
|
7
|
+
*
|
|
8
|
+
* `editorSessionId` arrives without being set here: `@atlaskit/analytics-listeners` merges
|
|
9
|
+
* the editor analytics context into the attributes of every event on the editor channel.
|
|
10
|
+
* `objectId` comes both from that context and from the snapshot, which carries the value
|
|
11
|
+
* the session was collected against.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sends a snapshot as the `editor interactivity` event.
|
|
16
|
+
*
|
|
17
|
+
* Does nothing without the analytics plugin: an editor without it has nowhere to send events,
|
|
18
|
+
* and the collector keeps measuring either way.
|
|
19
|
+
*/
|
|
20
|
+
export function fireInteractivityEvent(analytics, snapshot) {
|
|
21
|
+
analytics === null || analytics === void 0 ? void 0 : analytics.fireAnalyticsEvent({
|
|
22
|
+
action: 'interactivity',
|
|
23
|
+
actionSubject: ACTION_SUBJECT.EDITOR,
|
|
24
|
+
eventType: EVENT_TYPE.OPERATIONAL,
|
|
25
|
+
attributes: snapshot
|
|
26
|
+
}, undefined,
|
|
27
|
+
// Events are queued into an idle callback by default, which a page being unloaded never
|
|
28
|
+
// runs and a backgrounded tab throttles. The snapshots taken because the page is going
|
|
29
|
+
// away go immediately; the rest can wait their turn.
|
|
30
|
+
{
|
|
31
|
+
immediate: REASONS_THE_PAGE_MAY_NOT_OUTLIVE.includes(snapshot.reason)
|
|
32
|
+
});
|
|
33
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bucket boundaries for the interaction latency buckets, version 1.
|
|
3
|
+
*
|
|
4
|
+
* Two ranges:
|
|
5
|
+
* - 16 ms to 200 ms — a boundary every 8 ms: 16, 24, 32, … 200. Event Timing rounds
|
|
6
|
+
* durations to 8 ms, so nothing finer is measurable.
|
|
7
|
+
* - above 200 ms — each boundary ~15% above the previous one, five of them per doubling:
|
|
8
|
+
* 222, 256, 294, 337, 388, 445, 512, … A 40 ms difference matters at 100 ms and is
|
|
9
|
+
* noise at 4 seconds, so buckets grow with the latency instead of staying 8 ms wide.
|
|
10
|
+
*
|
|
11
|
+
* 500 ms — the Google INP "poor" threshold — falls inside the 445…512 bucket, so that one
|
|
12
|
+
* bucket is split at 500 to count the threshold instead of interpolating it. 200 ms, the
|
|
13
|
+
* "good" threshold, is already a boundary.
|
|
14
|
+
*
|
|
15
|
+
* Bump SCHEMA_VERSION whenever any boundary moves; queries group by it.
|
|
16
|
+
*/
|
|
17
|
+
export const SCHEMA_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Event Timing reporting threshold. Faster interactions are never delivered to the
|
|
21
|
+
* observer, so they reach no bucket at all — `performance.interactionCount` is what counts
|
|
22
|
+
* them, as `totalCount - observedCount`. This bucket holds the interactions reported at
|
|
23
|
+
* exactly the threshold.
|
|
24
|
+
*/
|
|
25
|
+
export const REPORTING_THRESHOLD_MS = 16;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Exceptions to the grid: latencies that must be a boundary of their own so that they are
|
|
29
|
+
* counted exactly rather than read off a bucket that spans them. Each one splits the bucket it
|
|
30
|
+
* falls inside. 500 ms is the Google INP "poor" threshold; the "good" one, 200 ms, needs no
|
|
31
|
+
* exception because the evenly spaced range already ends there.
|
|
32
|
+
*
|
|
33
|
+
* Every entry has to sit above that range, and adding one changes the reported keys, so bump
|
|
34
|
+
* SCHEMA_VERSION with it.
|
|
35
|
+
*/
|
|
36
|
+
const EXACT_THRESHOLDS_MS = [500];
|
|
37
|
+
const EVENLY_SPACED_MAX_MS = 200;
|
|
38
|
+
/**
|
|
39
|
+
* Keep this a multiple of 8 (8, 16, 24, …). Event Timing reports durations in 8 ms steps, so a
|
|
40
|
+
* step that is not a multiple of 8 leaves buckets no interaction can ever land in.
|
|
41
|
+
*/
|
|
42
|
+
const EVENLY_SPACED_STEP_MS = 8;
|
|
43
|
+
const BOUNDARIES_PER_DOUBLING = 5;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The first boundary at or above `latencyMs`, for the range above 200 ms.
|
|
47
|
+
*
|
|
48
|
+
* Five boundaries per doubling is the same as saying the nth boundary sits at `2^(n/5)` ms —
|
|
49
|
+
* boundary 40 at 256 ms, 45 at 512 ms, 50 at 1024 ms. So this turns the latency into a
|
|
50
|
+
* boundary number, rounds that up, and turns it back into milliseconds.
|
|
51
|
+
*
|
|
52
|
+
* Boundaries are floored to whole milliseconds, which keeps each one at or below the exact
|
|
53
|
+
* value it stands for. That is what makes `bucketKeyForMs(boundary) === boundary` hold.
|
|
54
|
+
*/
|
|
55
|
+
function firstBoundaryAtOrAbove(latencyMs) {
|
|
56
|
+
// `Math.log2(latencyMs) * 5` is the boundary number: log2 answers how many doublings of
|
|
57
|
+
// 1 ms reach this latency, and five boundaries cover each doubling.
|
|
58
|
+
//
|
|
59
|
+
// A latency sitting on a boundary makes that a whole number, which `Math.ceil` has to
|
|
60
|
+
// keep. ECMA-262 only requires `Math.log2` to be approximate, so 9.0000000000000002 for
|
|
61
|
+
// `log2(512)` would round up to boundary 46 and report 512 ms as 588 ms. EPSILON is
|
|
62
|
+
// larger than such imprecision and far smaller than the gap between two boundaries.
|
|
63
|
+
const EPSILON = 1e-9;
|
|
64
|
+
const boundaryNumber = Math.ceil(BOUNDARIES_PER_DOUBLING * Math.log2(latencyMs) - EPSILON);
|
|
65
|
+
|
|
66
|
+
// One `Math.pow` over the whole exponent, so the result is rounded once. Multiplying the
|
|
67
|
+
// ratio between neighbouring boundaries (2^(1/5) ≈ 1.1487) by itself instead accumulates
|
|
68
|
+
// the rounding of every step, reaching 512.0000000000018 by boundary 45.
|
|
69
|
+
return Math.floor(Math.pow(2, boundaryNumber / BOUNDARIES_PER_DOUBLING));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The bucket each exact threshold splits — 500 ms splits the one ending at 512 ms. */
|
|
73
|
+
const EXACT_THRESHOLD_BUCKETS = EXACT_THRESHOLDS_MS.map(thresholdMs => ({
|
|
74
|
+
thresholdMs,
|
|
75
|
+
bucketMs: firstBoundaryAtOrAbove(thresholdMs)
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The bucket a latency belongs to, identified by the bucket's upper boundary in whole
|
|
80
|
+
* milliseconds — which is also its key in the reported buckets.
|
|
81
|
+
*
|
|
82
|
+
* Every latency gets a bucket, however slow: the boundaries continue upwards, so there is no
|
|
83
|
+
* overflow bucket. Expects the whole-millisecond durations Event Timing reports; boundaries
|
|
84
|
+
* are floored, so a fractional latency can land in a bucket whose key reads up to a
|
|
85
|
+
* millisecond below it. Non-finite latencies never reach here — `InteractionTracker` drops
|
|
86
|
+
* them as it reads the entry.
|
|
87
|
+
*/
|
|
88
|
+
export function bucketKeyForMs(latencyMs) {
|
|
89
|
+
if (latencyMs <= REPORTING_THRESHOLD_MS) {
|
|
90
|
+
return REPORTING_THRESHOLD_MS;
|
|
91
|
+
}
|
|
92
|
+
if (latencyMs <= EVENLY_SPACED_MAX_MS) {
|
|
93
|
+
// How many 8 ms steps above the threshold the latency is, rounded up: 17 ms is 0.125
|
|
94
|
+
// steps up and lands on the boundary one step up, 24 ms. Rounding up is what keeps the
|
|
95
|
+
// set of keys fixed when a latency is not a multiple of 8 ms.
|
|
96
|
+
//
|
|
97
|
+
// No EPSILON here, unlike the branch below: subtracting whole numbers gives a whole
|
|
98
|
+
// number, and dividing by a power of two shifts a binary float's exponent without
|
|
99
|
+
// touching its digits, so a latency on a boundary cannot come out just above a whole
|
|
100
|
+
// number of steps.
|
|
101
|
+
const steps = Math.ceil((latencyMs - REPORTING_THRESHOLD_MS) / EVENLY_SPACED_STEP_MS);
|
|
102
|
+
return REPORTING_THRESHOLD_MS + steps * EVENLY_SPACED_STEP_MS;
|
|
103
|
+
}
|
|
104
|
+
const boundary = firstBoundaryAtOrAbove(latencyMs);
|
|
105
|
+
|
|
106
|
+
// A latency in the lower part of a split bucket is reported as the threshold itself, so the
|
|
107
|
+
// threshold is counted exactly.
|
|
108
|
+
for (const {
|
|
109
|
+
thresholdMs,
|
|
110
|
+
bucketMs
|
|
111
|
+
} of EXACT_THRESHOLD_BUCKETS) {
|
|
112
|
+
if (boundary === bucketMs && latencyMs <= thresholdMs) {
|
|
113
|
+
return thresholdMs;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return boundary;
|
|
117
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
|
2
|
+
import { bucketKeyForMs } from './bucket-boundaries';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Latencies are counted per 8 ms, the resolution Event Timing reports durations at. Every
|
|
6
|
+
* latency is rounded up to this step on the way in, which caps the number of distinct values
|
|
7
|
+
* a group can hold whatever the latency was derived from.
|
|
8
|
+
*/
|
|
9
|
+
const RESOLUTION_MS = 8;
|
|
10
|
+
|
|
11
|
+
/** Which percentiles are reported, as quantiles. */
|
|
12
|
+
const REPORTED_QUANTILES = [0.9, 0.98];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The latencies of one set of interactions — every interaction on the page, or only the ones
|
|
16
|
+
* inside the editor — reported as one object in the event.
|
|
17
|
+
*
|
|
18
|
+
* The whole state is a count per distinct latency, so an interaction only costs a counter
|
|
19
|
+
* whatever its latency was, and everything the event carries — the reported buckets, the
|
|
20
|
+
* count, the sum, the maximum and the percentiles — is derived from that map when a snapshot
|
|
21
|
+
* is taken. Nothing is computed while interactions arrive.
|
|
22
|
+
*/
|
|
23
|
+
export class InteractionGroup {
|
|
24
|
+
constructor() {
|
|
25
|
+
_defineProperty(this, "countByLatency", new Map());
|
|
26
|
+
}
|
|
27
|
+
add(latencyMs) {
|
|
28
|
+
this.increment(latencyMs);
|
|
29
|
+
}
|
|
30
|
+
remeasure(previousLatencyMs, latencyMs) {
|
|
31
|
+
// Moved rather than counted again: the count belongs to the same interaction.
|
|
32
|
+
this.decrement(previousLatencyMs);
|
|
33
|
+
this.increment(latencyMs);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param totalCount every interaction of the group, including those below the Event Timing
|
|
38
|
+
* reporting threshold, which this group never sees.
|
|
39
|
+
*/
|
|
40
|
+
snapshot(totalCount) {
|
|
41
|
+
var _latencies;
|
|
42
|
+
// Ascending, so the reported buckets come out in order and the last latency is the
|
|
43
|
+
// maximum. Sorted once for everything below.
|
|
44
|
+
const latencies = Array.from(this.countByLatency.keys()).sort((a, b) => a - b);
|
|
45
|
+
const observedCount = this.observedCount();
|
|
46
|
+
const percentileRanks = REPORTED_QUANTILES.map(quantile => ({
|
|
47
|
+
key: String(Math.round(quantile * 100)),
|
|
48
|
+
rank: Math.max(1, Math.ceil(quantile * observedCount))
|
|
49
|
+
}));
|
|
50
|
+
const buckets = {};
|
|
51
|
+
const percentilesMs = {};
|
|
52
|
+
let sumMs = 0;
|
|
53
|
+
let counted = 0;
|
|
54
|
+
for (const latencyMs of latencies) {
|
|
55
|
+
var _this$countByLatency$, _buckets$bucket;
|
|
56
|
+
const count = (_this$countByLatency$ = this.countByLatency.get(latencyMs)) !== null && _this$countByLatency$ !== void 0 ? _this$countByLatency$ : 0;
|
|
57
|
+
sumMs += latencyMs * count;
|
|
58
|
+
const bucket = String(bucketKeyForMs(latencyMs));
|
|
59
|
+
buckets[bucket] = ((_buckets$bucket = buckets[bucket]) !== null && _buckets$bucket !== void 0 ? _buckets$bucket : 0) + count;
|
|
60
|
+
|
|
61
|
+
// A percentile is the latency the group's interactions reach counting up from the
|
|
62
|
+
// fastest, so it is answered as soon as this many of them have been passed.
|
|
63
|
+
counted += count;
|
|
64
|
+
for (const {
|
|
65
|
+
key,
|
|
66
|
+
rank
|
|
67
|
+
} of percentileRanks) {
|
|
68
|
+
if (percentilesMs[key] === undefined && counted >= rank) {
|
|
69
|
+
percentilesMs[key] = latencyMs;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
// `performance.interactionCount` can lag the entries the observer has delivered.
|
|
75
|
+
totalCount: Math.max(totalCount, observedCount),
|
|
76
|
+
observedCount,
|
|
77
|
+
sumMs,
|
|
78
|
+
maxMs: (_latencies = latencies[latencies.length - 1]) !== null && _latencies !== void 0 ? _latencies : 0,
|
|
79
|
+
buckets,
|
|
80
|
+
percentilesMs
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
observedCount() {
|
|
84
|
+
let total = 0;
|
|
85
|
+
for (const count of this.countByLatency.values()) {
|
|
86
|
+
total += count;
|
|
87
|
+
}
|
|
88
|
+
return total;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Rounding lives here so that every count goes through the same step, in or out. */
|
|
92
|
+
roundLatencyUp(latencyMs) {
|
|
93
|
+
return Math.ceil(latencyMs / RESOLUTION_MS) * RESOLUTION_MS;
|
|
94
|
+
}
|
|
95
|
+
increment(latencyMs) {
|
|
96
|
+
var _this$countByLatency$2;
|
|
97
|
+
const step = this.roundLatencyUp(latencyMs);
|
|
98
|
+
this.countByLatency.set(step, ((_this$countByLatency$2 = this.countByLatency.get(step)) !== null && _this$countByLatency$2 !== void 0 ? _this$countByLatency$2 : 0) + 1);
|
|
99
|
+
}
|
|
100
|
+
decrement(latencyMs) {
|
|
101
|
+
var _this$countByLatency$3;
|
|
102
|
+
const step = this.roundLatencyUp(latencyMs);
|
|
103
|
+
const next = ((_this$countByLatency$3 = this.countByLatency.get(step)) !== null && _this$countByLatency$3 !== void 0 ? _this$countByLatency$3 : 0) - 1;
|
|
104
|
+
if (next > 0) {
|
|
105
|
+
this.countByLatency.set(step, next);
|
|
106
|
+
} else {
|
|
107
|
+
this.countByLatency.delete(step);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { REPORTING_THRESHOLD_MS } from './bucket-boundaries';
|
|
2
|
+
/** `performance.interactionCount` is Chromium-only and absent from the DOM typings. */
|
|
3
|
+
const interactionCount = () => performance.interactionCount;
|
|
4
|
+
|
|
5
|
+
/** `durationThreshold` is absent from the DOM typings for `PerformanceObserverInit`. */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reports the interactions the browser observes to `onEntries`.
|
|
9
|
+
*
|
|
10
|
+
* `drain` and `stop` are safe to call before `start` and after each other, so a session
|
|
11
|
+
* that never started collecting needs no special handling.
|
|
12
|
+
*/
|
|
13
|
+
export class InteractionObserver {
|
|
14
|
+
/**
|
|
15
|
+
* Whether the browser reports both things a session needs: `interactionId`, which groups
|
|
16
|
+
* entries into interactions, and `performance.interactionCount`, which counts the ones
|
|
17
|
+
* below the reporting threshold. Both are Chromium-only, and without the count
|
|
18
|
+
* `totalCount` would be indistinguishable from `observedCount`.
|
|
19
|
+
*/
|
|
20
|
+
static isSupported() {
|
|
21
|
+
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (!('PerformanceEventTiming' in window) || !('interactionId' in PerformanceEventTiming.prototype)) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (!PerformanceObserver.supportedEntryTypes.includes('event')) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return typeof interactionCount() === 'number';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Total interactions on the page since page load, including those below the reporting
|
|
35
|
+
* threshold that no observer ever sees. A property of the page, not of an observer.
|
|
36
|
+
*/
|
|
37
|
+
static readPageInteractionCount() {
|
|
38
|
+
var _interactionCount;
|
|
39
|
+
return (_interactionCount = interactionCount()) !== null && _interactionCount !== void 0 ? _interactionCount : 0;
|
|
40
|
+
}
|
|
41
|
+
constructor(onEntries) {
|
|
42
|
+
this.onEntries = onEntries;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Starts reporting interactions from this point on. Does nothing when already started, so
|
|
47
|
+
* a second call cannot leave an observer running with nobody to disconnect it.
|
|
48
|
+
*
|
|
49
|
+
* `buffered` is `false`: the entries the browser collected earlier are interactions with
|
|
50
|
+
* the page while the editor was still loading, and they belong to no session of ours.
|
|
51
|
+
*/
|
|
52
|
+
start() {
|
|
53
|
+
if (this.observer) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this.observer = new PerformanceObserver(list => {
|
|
57
|
+
// Delay by a microtask to work around a Safari bug where the callback is
|
|
58
|
+
// invoked synchronously rather than in a separate task.
|
|
59
|
+
// See: https://github.com/GoogleChrome/web-vitals/issues/277
|
|
60
|
+
Promise.resolve().then(() => {
|
|
61
|
+
this.onEntries(list.getEntries());
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
const init = {
|
|
65
|
+
type: 'event',
|
|
66
|
+
buffered: false,
|
|
67
|
+
// 16 ms is also the smallest value the spec honours; lower values are clamped.
|
|
68
|
+
durationThreshold: REPORTING_THRESHOLD_MS
|
|
69
|
+
};
|
|
70
|
+
this.observer.observe(init);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Synchronously reports the entries the browser has produced but not yet dispatched to
|
|
75
|
+
* the callback. A snapshot taken because the page is going away has to include them,
|
|
76
|
+
* because there is no later chance to.
|
|
77
|
+
*/
|
|
78
|
+
drain() {
|
|
79
|
+
var _this$observer;
|
|
80
|
+
const entries = (_this$observer = this.observer) === null || _this$observer === void 0 ? void 0 : _this$observer.takeRecords();
|
|
81
|
+
if (entries) {
|
|
82
|
+
this.onEntries(entries);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
stop() {
|
|
86
|
+
var _this$observer2;
|
|
87
|
+
(_this$observer2 = this.observer) === null || _this$observer2 === void 0 ? void 0 : _this$observer2.disconnect();
|
|
88
|
+
this.observer = undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
|
2
|
+
/**
|
|
3
|
+
* The Event Timing fields this package reads. `interactionId` is missing from the DOM
|
|
4
|
+
* typings' `PerformanceEventTiming`, so it is declared here as optional, which also makes
|
|
5
|
+
* a `PerformanceEntry` from `getEntries()` assignable without a cast.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What an entry did to the interaction it belongs to: either it is the first entry of a new
|
|
10
|
+
* interaction, or it measured an interaction that was already counted as slower than it was
|
|
11
|
+
* known to be.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How many interactions are remembered, so their growth can still be applied. Entries of one
|
|
16
|
+
* interaction arrive within the interaction itself, so anything older is not needed.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_TRACKED = 256;
|
|
19
|
+
/**
|
|
20
|
+
* How many are dropped per cleanup. Dropping one at a time would run a cleanup on every new
|
|
21
|
+
* interaction once the limit is reached; a batch makes it one cleanup per 64 of them.
|
|
22
|
+
*/
|
|
23
|
+
const PRUNE_BATCH = 64;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Groups Event Timing entries into interactions.
|
|
27
|
+
*
|
|
28
|
+
* Entries sharing a non-zero `interactionId` are one interaction whose latency is the
|
|
29
|
+
* maximum `duration` among them. Entries arrive incrementally, so an interaction's latency
|
|
30
|
+
* can grow after it was first reported — callers apply that to what they already counted
|
|
31
|
+
* rather than counting the interaction twice.
|
|
32
|
+
*
|
|
33
|
+
* A non-zero `interactionId` is the browser's own definition of an interaction, which is
|
|
34
|
+
* also what INP filters on: it is assigned to the pointer and keyboard events that make one
|
|
35
|
+
* up, and never to scrolling or pointer movement.
|
|
36
|
+
*/
|
|
37
|
+
export class InteractionTracker {
|
|
38
|
+
/**
|
|
39
|
+
* @param startsAfterInteractionId interactions up to and including this one belong to the
|
|
40
|
+
* previous tracker and are ignored. `interactionId` counts up over the life of the page, so
|
|
41
|
+
* a session opening mid-page passes the highest id the one before it saw; without that, an
|
|
42
|
+
* entry still arriving for an interaction from the previous session would look new here and
|
|
43
|
+
* be counted in both.
|
|
44
|
+
*/
|
|
45
|
+
constructor(startsAfterInteractionId = 0) {
|
|
46
|
+
_defineProperty(this, "latencies", new Map());
|
|
47
|
+
_defineProperty(this, "highestInteractionId", 0);
|
|
48
|
+
this.startsAfterInteractionId = startsAfterInteractionId;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The highest `interactionId` this tracker has seen. */
|
|
52
|
+
get lastInteractionId() {
|
|
53
|
+
return Math.max(this.highestInteractionId, this.startsAfterInteractionId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Merges an entry into the interaction it belongs to.
|
|
58
|
+
*
|
|
59
|
+
* @returns what that did to the interaction's latency, or nothing when the entry is not
|
|
60
|
+
* part of an interaction, belongs to a previous tracker, or does not change one.
|
|
61
|
+
*/
|
|
62
|
+
merge(entry) {
|
|
63
|
+
const {
|
|
64
|
+
interactionId
|
|
65
|
+
} = entry;
|
|
66
|
+
// `first-input` and non-interaction events report `interactionId` 0.
|
|
67
|
+
if (!interactionId) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
if (interactionId <= this.startsAfterInteractionId) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
this.highestInteractionId = Math.max(this.highestInteractionId, interactionId);
|
|
74
|
+
|
|
75
|
+
// Every latency is counted through here, so this is where one that cannot be measured
|
|
76
|
+
// is rejected: a `NaN` getting through becomes a `NaN` bucket key and a `NaN` `sumMs`
|
|
77
|
+
// for the rest of the session.
|
|
78
|
+
if (!Number.isFinite(entry.duration) || entry.duration < 0) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const previousLatencyMs = this.latencies.get(interactionId);
|
|
82
|
+
if (previousLatencyMs === undefined) {
|
|
83
|
+
this.latencies.set(interactionId, entry.duration);
|
|
84
|
+
this.prune();
|
|
85
|
+
return {
|
|
86
|
+
type: 'new',
|
|
87
|
+
latencyMs: entry.duration
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (entry.duration <= previousLatencyMs) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
this.latencies.set(interactionId, entry.duration);
|
|
94
|
+
return {
|
|
95
|
+
type: 'remeasured',
|
|
96
|
+
fromMs: previousLatencyMs,
|
|
97
|
+
toMs: entry.duration
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
prune() {
|
|
101
|
+
if (this.latencies.size <= MAX_TRACKED) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// `Map` preserves insertion order and `interactionId` increases monotonically,
|
|
106
|
+
// so the entries inserted first are the least likely to see another entry.
|
|
107
|
+
let remaining = PRUNE_BATCH;
|
|
108
|
+
for (const interactionId of this.latencies.keys()) {
|
|
109
|
+
this.latencies.delete(interactionId);
|
|
110
|
+
remaining -= 1;
|
|
111
|
+
if (remaining === 0) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|