@atlaskit/editor-plugin-interactivity 0.1.0 → 0.3.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 +26 -0
- package/README.md +65 -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/editor-event-observer.js +63 -0
- package/dist/cjs/collector/interaction-events.js +62 -0
- package/dist/cjs/collector/interaction-group.js +185 -0
- package/dist/cjs/collector/interaction-observer.js +114 -0
- package/dist/cjs/collector/interaction-tracker.js +185 -0
- package/dist/cjs/collector/interactivity-collector.js +341 -0
- package/dist/cjs/collector/interactivity-session.js +55 -0
- package/dist/cjs/collector/lifecycle-observer.js +66 -0
- package/dist/cjs/collector/snapshot-scheduler.js +70 -0
- package/dist/cjs/interactivityPlugin.js +93 -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/editor-event-observer.js +46 -0
- package/dist/es2019/collector/interaction-events.js +55 -0
- package/dist/es2019/collector/interaction-group.js +125 -0
- package/dist/es2019/collector/interaction-observer.js +90 -0
- package/dist/es2019/collector/interaction-tracker.js +156 -0
- package/dist/es2019/collector/interactivity-collector.js +274 -0
- package/dist/es2019/collector/interactivity-session.js +45 -0
- package/dist/es2019/collector/lifecycle-observer.js +46 -0
- package/dist/es2019/collector/snapshot-scheduler.js +48 -0
- package/dist/es2019/interactivityPlugin.js +87 -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/editor-event-observer.js +57 -0
- package/dist/esm/collector/interaction-events.js +55 -0
- package/dist/esm/collector/interaction-group.js +179 -0
- package/dist/esm/collector/interaction-observer.js +108 -0
- package/dist/esm/collector/interaction-tracker.js +179 -0
- package/dist/esm/collector/interactivity-collector.js +334 -0
- package/dist/esm/collector/interactivity-session.js +48 -0
- package/dist/esm/collector/lifecycle-observer.js +59 -0
- package/dist/esm/collector/snapshot-scheduler.js +63 -0
- package/dist/esm/interactivityPlugin.js +93 -6
- package/dist/types/analytics/fire-interactivity-event.d.ts +9 -0
- package/dist/types/analytics/interactivity-snapshot.d.ts +66 -0
- package/dist/types/collector/bucket-boundaries.d.ts +35 -0
- package/dist/types/collector/editor-event-observer.d.ts +19 -0
- package/dist/types/collector/interaction-events.d.ts +19 -0
- package/dist/types/collector/interaction-group.d.ts +37 -0
- package/dist/types/collector/interaction-observer.d.ts +39 -0
- package/dist/types/collector/interaction-tracker.d.ts +65 -0
- package/dist/types/collector/interactivity-collector.d.ts +90 -0
- package/dist/types/collector/interactivity-session.d.ts +42 -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 +26 -7
- package/package.json +8 -3
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
* The two counters count different populations: `add` takes the interactions Event Timing
|
|
24
|
+
* measured, `countTotal` takes all of them, including the ones below the 16 ms reporting threshold
|
|
25
|
+
* it never delivers. So `totalCount >= observedCount`, and the difference is how many were too
|
|
26
|
+
* fast to be measured.
|
|
27
|
+
*/
|
|
28
|
+
export class InteractionGroup {
|
|
29
|
+
constructor() {
|
|
30
|
+
_defineProperty(this, "countByLatency", new Map());
|
|
31
|
+
_defineProperty(this, "totalCount", 0);
|
|
32
|
+
}
|
|
33
|
+
add(latencyMs) {
|
|
34
|
+
this.increment(latencyMs);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Counts an interaction towards the group's total, measured or not. `page` has no use for it:
|
|
39
|
+
* `performance.interactionCount` counts the page's interactions.
|
|
40
|
+
*/
|
|
41
|
+
countTotal() {
|
|
42
|
+
this.totalCount += 1;
|
|
43
|
+
}
|
|
44
|
+
remeasure(previousLatencyMs, latencyMs) {
|
|
45
|
+
// Moved rather than counted again: the count belongs to the same interaction.
|
|
46
|
+
this.decrement(previousLatencyMs);
|
|
47
|
+
this.increment(latencyMs);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param totalCount every interaction of the group, including those below the Event Timing
|
|
52
|
+
* reporting threshold. Defaults to what `countTotal` was told, which is where an editor
|
|
53
|
+
* group's total comes from; `page` passes `performance.interactionCount` instead.
|
|
54
|
+
*/
|
|
55
|
+
snapshot(totalCount = this.totalCount) {
|
|
56
|
+
var _latencies;
|
|
57
|
+
// Ascending, so the reported buckets come out in order and the last latency is the
|
|
58
|
+
// maximum. Sorted once for everything below.
|
|
59
|
+
const latencies = Array.from(this.countByLatency.keys()).sort((a, b) => a - b);
|
|
60
|
+
const observedCount = this.observedCount();
|
|
61
|
+
const percentileRanks = REPORTED_QUANTILES.map(quantile => ({
|
|
62
|
+
key: String(Math.round(quantile * 100)),
|
|
63
|
+
rank: Math.max(1, Math.ceil(quantile * observedCount))
|
|
64
|
+
}));
|
|
65
|
+
const buckets = {};
|
|
66
|
+
const percentilesMs = {};
|
|
67
|
+
let sumMs = 0;
|
|
68
|
+
let counted = 0;
|
|
69
|
+
for (const latencyMs of latencies) {
|
|
70
|
+
var _this$countByLatency$, _buckets$bucket;
|
|
71
|
+
const count = (_this$countByLatency$ = this.countByLatency.get(latencyMs)) !== null && _this$countByLatency$ !== void 0 ? _this$countByLatency$ : 0;
|
|
72
|
+
sumMs += latencyMs * count;
|
|
73
|
+
const bucket = String(bucketKeyForMs(latencyMs));
|
|
74
|
+
buckets[bucket] = ((_buckets$bucket = buckets[bucket]) !== null && _buckets$bucket !== void 0 ? _buckets$bucket : 0) + count;
|
|
75
|
+
|
|
76
|
+
// A percentile is the latency the group's interactions reach counting up from the
|
|
77
|
+
// fastest, so it is answered as soon as this many of them have been passed.
|
|
78
|
+
counted += count;
|
|
79
|
+
for (const {
|
|
80
|
+
key,
|
|
81
|
+
rank
|
|
82
|
+
} of percentileRanks) {
|
|
83
|
+
if (percentilesMs[key] === undefined && counted >= rank) {
|
|
84
|
+
percentilesMs[key] = latencyMs;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
// `performance.interactionCount` can lag the entries the observer has delivered.
|
|
90
|
+
totalCount: Math.max(totalCount, observedCount),
|
|
91
|
+
observedCount,
|
|
92
|
+
sumMs,
|
|
93
|
+
maxMs: (_latencies = latencies[latencies.length - 1]) !== null && _latencies !== void 0 ? _latencies : 0,
|
|
94
|
+
buckets,
|
|
95
|
+
percentilesMs
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
observedCount() {
|
|
99
|
+
let total = 0;
|
|
100
|
+
for (const count of this.countByLatency.values()) {
|
|
101
|
+
total += count;
|
|
102
|
+
}
|
|
103
|
+
return total;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Rounding lives here so that every count goes through the same step, in or out. */
|
|
107
|
+
roundLatencyUp(latencyMs) {
|
|
108
|
+
return Math.ceil(latencyMs / RESOLUTION_MS) * RESOLUTION_MS;
|
|
109
|
+
}
|
|
110
|
+
increment(latencyMs) {
|
|
111
|
+
var _this$countByLatency$2;
|
|
112
|
+
const step = this.roundLatencyUp(latencyMs);
|
|
113
|
+
this.countByLatency.set(step, ((_this$countByLatency$2 = this.countByLatency.get(step)) !== null && _this$countByLatency$2 !== void 0 ? _this$countByLatency$2 : 0) + 1);
|
|
114
|
+
}
|
|
115
|
+
decrement(latencyMs) {
|
|
116
|
+
var _this$countByLatency$3;
|
|
117
|
+
const step = this.roundLatencyUp(latencyMs);
|
|
118
|
+
const next = ((_this$countByLatency$3 = this.countByLatency.get(step)) !== null && _this$countByLatency$3 !== void 0 ? _this$countByLatency$3 : 0) - 1;
|
|
119
|
+
if (next > 0) {
|
|
120
|
+
this.countByLatency.set(step, next);
|
|
121
|
+
} else {
|
|
122
|
+
this.countByLatency.delete(step);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -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,156 @@
|
|
|
1
|
+
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
|
2
|
+
import { interactionEventKind } from './interaction-events';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Event Timing fields this package reads. `interactionId` is missing from the DOM
|
|
6
|
+
* typings' `PerformanceEventTiming`, so it is declared here as optional, which also makes
|
|
7
|
+
* a `PerformanceEntry` from `getEntries()` assignable without a cast.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What an entry did to the interaction it belongs to: either it is the first entry of a new
|
|
12
|
+
* interaction, or it measured an interaction that was already counted as slower than it was
|
|
13
|
+
* known to be. Both carry the editor group of the interaction, if it is one of the editor's.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How many interactions are remembered, so their growth can still be applied, and how many of
|
|
18
|
+
* the editor's events. Entries of one interaction arrive within the interaction itself, so
|
|
19
|
+
* anything older than the last few hundred is not needed.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_TRACKED = 256;
|
|
22
|
+
/**
|
|
23
|
+
* How many are dropped per cleanup. Dropping one at a time would run a cleanup on every new
|
|
24
|
+
* interaction once the limit is reached; a batch makes it one cleanup per 64 of them.
|
|
25
|
+
*/
|
|
26
|
+
const PRUNE_BATCH = 64;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Identifies the event an entry measured: an entry's `startTime` is that event's timestamp and its
|
|
30
|
+
* `name` is its type. The type is in the key because browsers coarsen the timestamp, so two events
|
|
31
|
+
* of one task can share it — and events of one type are always of one group, so a collision cannot
|
|
32
|
+
* move an interaction into another group.
|
|
33
|
+
*/
|
|
34
|
+
function eventKey(type, timeStamp) {
|
|
35
|
+
return `${type}|${timeStamp}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Makes interactions out of what the two observers report, for one session.
|
|
40
|
+
*
|
|
41
|
+
* Entries sharing a non-zero `interactionId` are one interaction whose latency is the
|
|
42
|
+
* maximum `duration` among them. Entries arrive incrementally, so an interaction's latency
|
|
43
|
+
* can grow after it was first reported — callers apply that to what they already counted
|
|
44
|
+
* rather than counting the interaction twice.
|
|
45
|
+
*
|
|
46
|
+
* A non-zero `interactionId` is the browser's own definition of an interaction, which is
|
|
47
|
+
* also what INP filters on: it is assigned to the pointer and keyboard events that make one
|
|
48
|
+
* up, and never to scrolling or pointer movement.
|
|
49
|
+
*
|
|
50
|
+
* The editor's events answer what an entry cannot: which interactions were with the editor, and
|
|
51
|
+
* how many there were, including the ones below the Event Timing reporting threshold.
|
|
52
|
+
*/
|
|
53
|
+
export class InteractionTracker {
|
|
54
|
+
/**
|
|
55
|
+
* @param startsAfterInteractionId interactions up to and including this one belong to the
|
|
56
|
+
* previous tracker and are ignored. `interactionId` counts up over the life of the page, so
|
|
57
|
+
* a session opening mid-page passes the highest id the one before it saw; without that, an
|
|
58
|
+
* entry still arriving for an interaction from the previous session would look new here and
|
|
59
|
+
* be counted in both.
|
|
60
|
+
*/
|
|
61
|
+
constructor(startsAfterInteractionId = 0) {
|
|
62
|
+
_defineProperty(this, "interactions", new Map());
|
|
63
|
+
_defineProperty(this, "groupByEvent", new Map());
|
|
64
|
+
_defineProperty(this, "highestInteractionId", 0);
|
|
65
|
+
this.startsAfterInteractionId = startsAfterInteractionId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The highest `interactionId` this tracker has seen. */
|
|
69
|
+
get lastInteractionId() {
|
|
70
|
+
return Math.max(this.highestInteractionId, this.startsAfterInteractionId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Merges an entry into the interaction it belongs to.
|
|
75
|
+
*
|
|
76
|
+
* @returns what that did to the interaction's latency, or nothing when the entry is not
|
|
77
|
+
* part of an interaction, belongs to a previous tracker, or does not change one.
|
|
78
|
+
*/
|
|
79
|
+
merge(entry) {
|
|
80
|
+
const {
|
|
81
|
+
interactionId
|
|
82
|
+
} = entry;
|
|
83
|
+
// `first-input` and non-interaction events report `interactionId` 0.
|
|
84
|
+
if (!interactionId) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
if (interactionId <= this.startsAfterInteractionId) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
this.highestInteractionId = Math.max(this.highestInteractionId, interactionId);
|
|
91
|
+
|
|
92
|
+
// Every latency is counted through here, so this is where one that cannot be measured
|
|
93
|
+
// is rejected: a `NaN` getting through becomes a `NaN` bucket key and a `NaN` `sumMs`
|
|
94
|
+
// for the rest of the session.
|
|
95
|
+
if (!Number.isFinite(entry.duration) || entry.duration < 0) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const tracked = this.interactions.get(interactionId);
|
|
99
|
+
if (tracked === undefined) {
|
|
100
|
+
// Taken once: an entry that only makes the interaction slower has to move its count
|
|
101
|
+
// within the group it was counted in, not into another one.
|
|
102
|
+
const group = this.groupByEvent.get(eventKey(entry.name, entry.startTime));
|
|
103
|
+
this.interactions.set(interactionId, {
|
|
104
|
+
latencyMs: entry.duration,
|
|
105
|
+
group
|
|
106
|
+
});
|
|
107
|
+
this.prune(this.interactions);
|
|
108
|
+
return {
|
|
109
|
+
type: 'new',
|
|
110
|
+
latencyMs: entry.duration,
|
|
111
|
+
group
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (entry.duration <= tracked.latencyMs) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
const fromMs = tracked.latencyMs;
|
|
118
|
+
tracked.latencyMs = entry.duration;
|
|
119
|
+
return {
|
|
120
|
+
type: 'remeasured',
|
|
121
|
+
fromMs,
|
|
122
|
+
toMs: entry.duration,
|
|
123
|
+
group: tracked.group
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @returns the group of an interaction to count, when this is the event its group counts on. */
|
|
128
|
+
recordEditorEvent(event) {
|
|
129
|
+
const kind = interactionEventKind(event.type);
|
|
130
|
+
if (!kind) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Every event of the interaction, because any of them can be the one Event Timing reports
|
|
135
|
+
// as the slowest: for a pointer press that is usually the click.
|
|
136
|
+
this.groupByEvent.set(eventKey(event.type, event.timeStamp), kind.group);
|
|
137
|
+
this.prune(this.groupByEvent);
|
|
138
|
+
return kind.counts ? kind.group : undefined;
|
|
139
|
+
}
|
|
140
|
+
prune(entries) {
|
|
141
|
+
if (entries.size <= MAX_TRACKED) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// `Map` preserves insertion order, so the entries inserted first are the least likely to
|
|
146
|
+
// see another entry or another event.
|
|
147
|
+
let remaining = PRUNE_BATCH;
|
|
148
|
+
for (const key of entries.keys()) {
|
|
149
|
+
entries.delete(key);
|
|
150
|
+
remaining -= 1;
|
|
151
|
+
if (remaining === 0) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
|
2
|
+
import { getDocument } from '@atlaskit/browser-apis';
|
|
3
|
+
import { EditorEventObserver } from './editor-event-observer';
|
|
4
|
+
import { InteractionObserver } from './interaction-observer';
|
|
5
|
+
import { InteractivitySession } from './interactivity-session';
|
|
6
|
+
import { SCHEMA_VERSION } from './bucket-boundaries';
|
|
7
|
+
import { LifecycleObserver } from './lifecycle-observer';
|
|
8
|
+
import { SnapshotScheduler } from './snapshot-scheduler';
|
|
9
|
+
/**
|
|
10
|
+
* Collects interaction latencies for one editor mount and emits session-to-date snapshots.
|
|
11
|
+
*
|
|
12
|
+
* Every interaction is counted in `page`, and the ones with the editor again in one of the editor
|
|
13
|
+
* groups, which is what tells a slow editor apart from a slow page around it.
|
|
14
|
+
*
|
|
15
|
+
* `start` and `stop` bound the collecting, which happens once. Within it there can be several
|
|
16
|
+
* sessions, because a session covers one document in one mode: a Confluence live page
|
|
17
|
+
* navigates and switches between reading and editing without ever remounting the editor, and
|
|
18
|
+
* each of those closes the current session and opens the next.
|
|
19
|
+
*/
|
|
20
|
+
export class InteractivityCollector {
|
|
21
|
+
constructor(options) {
|
|
22
|
+
_defineProperty(this, "started", false);
|
|
23
|
+
_defineProperty(this, "stopped", false);
|
|
24
|
+
this.emit = options.emit;
|
|
25
|
+
this.getEditorDomSize = options.getEditorDomSize;
|
|
26
|
+
this.getNodeSize = options.getNodeSize;
|
|
27
|
+
this.getObjectId = options.getObjectId;
|
|
28
|
+
this.getSessionMode = options.getSessionMode;
|
|
29
|
+
this.interactionObserver = new InteractionObserver(entries => this.recordEntries(entries));
|
|
30
|
+
this.editorEvents = new EditorEventObserver(event => this.recordEditorEvent(event));
|
|
31
|
+
this.snapshotScheduler = new SnapshotScheduler(() => this.onTimer());
|
|
32
|
+
this.lifecycleObserver = new LifecycleObserver({
|
|
33
|
+
onHidden: () => this.onHidden(),
|
|
34
|
+
onVisible: () => this.onVisible(),
|
|
35
|
+
onPageHide: () => this.onPageHide(),
|
|
36
|
+
onPageShow: () => this.onPageShow()
|
|
37
|
+
});
|
|
38
|
+
this.session = this.createSession(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Starts collecting. Calling it again while collecting changes nothing.
|
|
43
|
+
*
|
|
44
|
+
* @returns whether collecting is running; `false` when the browser cannot support it, or
|
|
45
|
+
* when it has already been stopped.
|
|
46
|
+
*/
|
|
47
|
+
start() {
|
|
48
|
+
// Collecting is over for good once stopped: the session it covered has been reported.
|
|
49
|
+
if (this.stopped) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if (this.started) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
if (!InteractionObserver.isSupported()) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Opened again so the session's timings start with the collecting, not with whenever
|
|
60
|
+
// this object was constructed.
|
|
61
|
+
this.session = this.createSession(0);
|
|
62
|
+
this.interactionObserver.start();
|
|
63
|
+
this.editorEvents.observe(this.editorRoot);
|
|
64
|
+
this.snapshotScheduler.start();
|
|
65
|
+
this.lifecycleObserver.start();
|
|
66
|
+
this.started = true;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Reports the session as ended by the editor unmounting, and stops collecting. */
|
|
71
|
+
stop() {
|
|
72
|
+
if (this.stopped) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.takeSnapshot('unmount');
|
|
76
|
+
this.stopped = true;
|
|
77
|
+
this.interactionObserver.stop();
|
|
78
|
+
this.editorEvents.stop();
|
|
79
|
+
this.snapshotScheduler.stop();
|
|
80
|
+
this.lifecycleObserver.stop();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The element the editor renders itself into is what makes an interaction one of the editor's.
|
|
85
|
+
* The session is not tied to it: interactions from before it arrives are counted in `page`.
|
|
86
|
+
*/
|
|
87
|
+
setEditorRoot(root) {
|
|
88
|
+
this.editorRoot = root !== null && root !== void 0 ? root : undefined;
|
|
89
|
+
if (this.started && !this.stopped) {
|
|
90
|
+
this.editorEvents.observe(this.editorRoot);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rotates the session when the editor is pointed at different content.
|
|
96
|
+
*
|
|
97
|
+
* An unknown object id is no information, never a change. The provider resolves
|
|
98
|
+
* asynchronously after mount, and `contextIdentifierPlugin` resets its state to the
|
|
99
|
+
* configured provider on transactions that do not carry a new one, so the id read here goes
|
|
100
|
+
* missing for a moment on a document that never changed.
|
|
101
|
+
*/
|
|
102
|
+
onObjectIdChanged() {
|
|
103
|
+
if (this.stopped) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const objectId = this.getObjectId();
|
|
107
|
+
if (objectId === undefined || objectId === this.session.objectId) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (this.session.objectId === undefined) {
|
|
111
|
+
this.session.objectId = objectId;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
this.rotateSession('navigation');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Rotates the session when the editor switches between reading and editing. Interactions
|
|
119
|
+
* with a read-only page are a different population from interactions while editing, so one
|
|
120
|
+
* session never covers both.
|
|
121
|
+
*/
|
|
122
|
+
onViewModeChanged() {
|
|
123
|
+
if (this.stopped) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const mode = this.getSessionMode();
|
|
127
|
+
if (mode === undefined || mode === this.session.mode) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (this.session.mode === undefined) {
|
|
131
|
+
this.session.mode = mode;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.rotateSession('modeChange');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param startsAfterInteractionId the highest interaction the previous session saw, so
|
|
139
|
+
* entries still arriving for it are not counted here as well.
|
|
140
|
+
*/
|
|
141
|
+
createSession(startsAfterInteractionId) {
|
|
142
|
+
var _getDocument;
|
|
143
|
+
return new InteractivitySession({
|
|
144
|
+
objectId: this.getObjectId(),
|
|
145
|
+
mode: this.getSessionMode(),
|
|
146
|
+
hidden: ((_getDocument = getDocument()) === null || _getDocument === void 0 ? void 0 : _getDocument.visibilityState) === 'hidden',
|
|
147
|
+
startsAfterInteractionId
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Reports the current session as ended by `reason` and puts a new one in its place. */
|
|
152
|
+
rotateSession(reason) {
|
|
153
|
+
this.takeSnapshot(reason);
|
|
154
|
+
this.session = this.createSession(this.session.tracker.lastInteractionId);
|
|
155
|
+
this.snapshotScheduler.restart();
|
|
156
|
+
}
|
|
157
|
+
onTimer() {
|
|
158
|
+
this.takeSnapshot('timer');
|
|
159
|
+
}
|
|
160
|
+
onHidden() {
|
|
161
|
+
if (this.session.hiddenSince === undefined) {
|
|
162
|
+
this.session.hiddenSince = performance.now();
|
|
163
|
+
}
|
|
164
|
+
this.takeSnapshot('hidden');
|
|
165
|
+
}
|
|
166
|
+
onVisible() {
|
|
167
|
+
if (this.session.hiddenSince !== undefined) {
|
|
168
|
+
this.session.hiddenMs += performance.now() - this.session.hiddenSince;
|
|
169
|
+
this.session.hiddenSince = undefined;
|
|
170
|
+
}
|
|
171
|
+
this.session.lifecycleSnapshotEmitted = false;
|
|
172
|
+
}
|
|
173
|
+
onPageHide() {
|
|
174
|
+
this.takeSnapshot('pagehide');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The page came back from the back/forward cache, which never raises a visibility change.
|
|
179
|
+
* The session continues, and the signal that suspended it has been reported, so the next
|
|
180
|
+
* one is due.
|
|
181
|
+
*/
|
|
182
|
+
onPageShow() {
|
|
183
|
+
this.session.lifecycleSnapshotEmitted = false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Counts an interaction with the editor, including the ones Event Timing never reports because
|
|
188
|
+
* they were faster than its threshold — which is why a count alone moves the session on.
|
|
189
|
+
*/
|
|
190
|
+
recordEditorEvent(event) {
|
|
191
|
+
if (this.stopped) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const group = this.session.tracker.recordEditorEvent(event);
|
|
195
|
+
if (!group) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The session names its editor groups after the fields they are reported in.
|
|
200
|
+
this.session[group].countTotal();
|
|
201
|
+
this.session.revision += 1;
|
|
202
|
+
}
|
|
203
|
+
recordEntries(entries) {
|
|
204
|
+
const {
|
|
205
|
+
tracker,
|
|
206
|
+
page
|
|
207
|
+
} = this.session;
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
const update = tracker.merge(entry);
|
|
210
|
+
if (!update) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (update.type === 'new') {
|
|
214
|
+
page.add(update.latencyMs);
|
|
215
|
+
if (update.group) {
|
|
216
|
+
this.session[update.group].add(update.latencyMs);
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
page.remeasure(update.fromMs, update.toMs);
|
|
220
|
+
if (update.group) {
|
|
221
|
+
this.session[update.group].remeasure(update.fromMs, update.toMs);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
this.session.revision += 1;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
takeSnapshot(reason) {
|
|
228
|
+
if (this.stopped) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const session = this.session;
|
|
232
|
+
|
|
233
|
+
// Checked before anything is consumed: `visibilitychange` and `pagehide` fire back to
|
|
234
|
+
// back on the same transition and it is reported once, and draining first would take
|
|
235
|
+
// entries out of the browser's queue only to suppress the snapshot carrying them.
|
|
236
|
+
const lifecycleSignal = reason === 'hidden' || reason === 'pagehide';
|
|
237
|
+
if (lifecycleSignal && session.lifecycleSnapshotEmitted) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Must run before the change check: the browser may be holding entries that have not
|
|
242
|
+
// reached the observer callback yet, and on `pagehide` there is no later chance to
|
|
243
|
+
// pick them up.
|
|
244
|
+
this.interactionObserver.drain();
|
|
245
|
+
if (session.revision === session.emittedRevision) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (lifecycleSignal) {
|
|
249
|
+
session.lifecycleSnapshotEmitted = true;
|
|
250
|
+
}
|
|
251
|
+
session.seq += 1;
|
|
252
|
+
session.emittedRevision = session.revision;
|
|
253
|
+
const now = performance.now();
|
|
254
|
+
const hiddenMs = session.hiddenMs + (session.hiddenSince === undefined ? 0 : now - session.hiddenSince);
|
|
255
|
+
const pageTotalCount = InteractionObserver.readPageInteractionCount() - session.interactionCountAtStart;
|
|
256
|
+
this.emit({
|
|
257
|
+
schema: SCHEMA_VERSION,
|
|
258
|
+
interactivitySessionId: session.id,
|
|
259
|
+
objectId: session.objectId,
|
|
260
|
+
seq: session.seq,
|
|
261
|
+
reason,
|
|
262
|
+
sessionMode: session.mode,
|
|
263
|
+
activeMs: Math.round(Math.max(0, now - session.startedAt - hiddenMs)),
|
|
264
|
+
hiddenMs: Math.round(hiddenMs),
|
|
265
|
+
nodeSize: this.getNodeSize(),
|
|
266
|
+
editorDomSize: this.getEditorDomSize(),
|
|
267
|
+
// Only `page` is told its total; each editor group has counted its own.
|
|
268
|
+
page: session.page.snapshot(pageTotalCount),
|
|
269
|
+
editorTyping: session.editorTyping.snapshot(),
|
|
270
|
+
editorPointer: session.editorPointer.snapshot(),
|
|
271
|
+
editorOther: session.editorOther.snapshot()
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
|
2
|
+
import { InteractionObserver } from './interaction-observer';
|
|
3
|
+
import { InteractionTracker } from './interaction-tracker';
|
|
4
|
+
import { InteractionGroup } from './interaction-group';
|
|
5
|
+
function createSessionId() {
|
|
6
|
+
if (typeof crypto.randomUUID === 'function') {
|
|
7
|
+
return crypto.randomUUID();
|
|
8
|
+
}
|
|
9
|
+
const bytes = new Uint8Array(16);
|
|
10
|
+
crypto.getRandomValues(bytes);
|
|
11
|
+
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Everything that belongs to one session.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here is reset — the next session is a new instance — so a field added here cannot
|
|
17
|
+
* be left carrying the previous session's value.
|
|
18
|
+
*/
|
|
19
|
+
export class InteractivitySession {
|
|
20
|
+
constructor(start) {
|
|
21
|
+
_defineProperty(this, "id", createSessionId());
|
|
22
|
+
_defineProperty(this, "startedAt", performance.now());
|
|
23
|
+
/** Page interaction count when the session opened, subtracted to get its own total. */
|
|
24
|
+
_defineProperty(this, "interactionCountAtStart", InteractionObserver.readPageInteractionCount());
|
|
25
|
+
_defineProperty(this, "page", new InteractionGroup());
|
|
26
|
+
_defineProperty(this, "editorTyping", new InteractionGroup());
|
|
27
|
+
_defineProperty(this, "editorPointer", new InteractionGroup());
|
|
28
|
+
_defineProperty(this, "editorOther", new InteractionGroup());
|
|
29
|
+
/** Increments per snapshot; a query takes the highest one per session. */
|
|
30
|
+
_defineProperty(this, "seq", 0);
|
|
31
|
+
_defineProperty(this, "hiddenMs", 0);
|
|
32
|
+
/**
|
|
33
|
+
* Bumped on every change to the accumulated data. A snapshot is emitted only when this
|
|
34
|
+
* has moved past `emittedRevision`, so identical snapshots are never sent twice.
|
|
35
|
+
*/
|
|
36
|
+
_defineProperty(this, "revision", 0);
|
|
37
|
+
_defineProperty(this, "emittedRevision", 0);
|
|
38
|
+
/** One lifecycle snapshot per hidden episode; cleared when the page comes back. */
|
|
39
|
+
_defineProperty(this, "lifecycleSnapshotEmitted", false);
|
|
40
|
+
this.objectId = start.objectId;
|
|
41
|
+
this.mode = start.mode;
|
|
42
|
+
this.hiddenSince = start.hidden ? this.startedAt : undefined;
|
|
43
|
+
this.tracker = new InteractionTracker(start.startsAfterInteractionId);
|
|
44
|
+
}
|
|
45
|
+
}
|