@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
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
|
2
|
+
import { getDocument } from '@atlaskit/browser-apis';
|
|
3
|
+
import { InteractionObserver } from './interaction-observer';
|
|
4
|
+
import { InteractivitySession } from './interactivity-session';
|
|
5
|
+
import { SCHEMA_VERSION } from './bucket-boundaries';
|
|
6
|
+
import { LifecycleObserver } from './lifecycle-observer';
|
|
7
|
+
import { SnapshotScheduler } from './snapshot-scheduler';
|
|
8
|
+
/**
|
|
9
|
+
* Collects interaction latencies for one editor mount and emits session-to-date snapshots.
|
|
10
|
+
*
|
|
11
|
+
* `start` and `stop` bound the collecting, which happens once. Within it there can be several
|
|
12
|
+
* sessions, because a session covers one document in one mode: a Confluence live page
|
|
13
|
+
* navigates and switches between reading and editing without ever remounting the editor, and
|
|
14
|
+
* each of those closes the current session and opens the next.
|
|
15
|
+
*/
|
|
16
|
+
export class InteractivityCollector {
|
|
17
|
+
constructor(options) {
|
|
18
|
+
_defineProperty(this, "started", false);
|
|
19
|
+
_defineProperty(this, "stopped", false);
|
|
20
|
+
this.emit = options.emit;
|
|
21
|
+
this.getEditorDomSize = options.getEditorDomSize;
|
|
22
|
+
this.getNodeSize = options.getNodeSize;
|
|
23
|
+
this.getObjectId = options.getObjectId;
|
|
24
|
+
this.getSessionMode = options.getSessionMode;
|
|
25
|
+
this.interactionObserver = new InteractionObserver(entries => this.recordEntries(entries));
|
|
26
|
+
this.snapshotScheduler = new SnapshotScheduler(() => this.onTimer());
|
|
27
|
+
this.lifecycleObserver = new LifecycleObserver({
|
|
28
|
+
onHidden: () => this.onHidden(),
|
|
29
|
+
onVisible: () => this.onVisible(),
|
|
30
|
+
onPageHide: () => this.onPageHide(),
|
|
31
|
+
onPageShow: () => this.onPageShow()
|
|
32
|
+
});
|
|
33
|
+
this.session = this.createSession(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Starts collecting. Calling it again while collecting changes nothing.
|
|
38
|
+
*
|
|
39
|
+
* @returns whether collecting is running; `false` when the browser cannot support it, or
|
|
40
|
+
* when it has already been stopped.
|
|
41
|
+
*/
|
|
42
|
+
start() {
|
|
43
|
+
// Collecting is over for good once stopped: the session it covered has been reported.
|
|
44
|
+
if (this.stopped) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (this.started) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
if (!InteractionObserver.isSupported()) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Opened again so the session's timings start with the collecting, not with whenever
|
|
55
|
+
// this object was constructed.
|
|
56
|
+
this.session = this.createSession(0);
|
|
57
|
+
this.interactionObserver.start();
|
|
58
|
+
this.snapshotScheduler.start();
|
|
59
|
+
this.lifecycleObserver.start();
|
|
60
|
+
this.started = true;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Reports the session as ended by the editor unmounting, and stops collecting. */
|
|
65
|
+
stop() {
|
|
66
|
+
if (this.stopped) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.takeSnapshot('unmount');
|
|
70
|
+
this.stopped = true;
|
|
71
|
+
this.interactionObserver.stop();
|
|
72
|
+
this.snapshotScheduler.stop();
|
|
73
|
+
this.lifecycleObserver.stop();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Rotates the session when the editor is pointed at different content.
|
|
78
|
+
*
|
|
79
|
+
* An unknown object id is no information, never a change. The provider resolves
|
|
80
|
+
* asynchronously after mount, and `contextIdentifierPlugin` resets its state to the
|
|
81
|
+
* configured provider on transactions that do not carry a new one, so the id read here goes
|
|
82
|
+
* missing for a moment on a document that never changed.
|
|
83
|
+
*/
|
|
84
|
+
onObjectIdChanged() {
|
|
85
|
+
if (this.stopped) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const objectId = this.getObjectId();
|
|
89
|
+
if (objectId === undefined || objectId === this.session.objectId) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (this.session.objectId === undefined) {
|
|
93
|
+
this.session.objectId = objectId;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.rotateSession('navigation');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Rotates the session when the editor switches between reading and editing. Interactions
|
|
101
|
+
* with a read-only page are a different population from interactions while editing, so one
|
|
102
|
+
* session never covers both.
|
|
103
|
+
*/
|
|
104
|
+
onViewModeChanged() {
|
|
105
|
+
if (this.stopped) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const mode = this.getSessionMode();
|
|
109
|
+
if (mode === undefined || mode === this.session.mode) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (this.session.mode === undefined) {
|
|
113
|
+
this.session.mode = mode;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.rotateSession('modeChange');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param startsAfterInteractionId the highest interaction the previous session saw, so
|
|
121
|
+
* entries still arriving for it are not counted here as well.
|
|
122
|
+
*/
|
|
123
|
+
createSession(startsAfterInteractionId) {
|
|
124
|
+
var _getDocument;
|
|
125
|
+
return new InteractivitySession({
|
|
126
|
+
objectId: this.getObjectId(),
|
|
127
|
+
mode: this.getSessionMode(),
|
|
128
|
+
hidden: ((_getDocument = getDocument()) === null || _getDocument === void 0 ? void 0 : _getDocument.visibilityState) === 'hidden',
|
|
129
|
+
startsAfterInteractionId
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Reports the current session as ended by `reason` and puts a new one in its place. */
|
|
134
|
+
rotateSession(reason) {
|
|
135
|
+
this.takeSnapshot(reason);
|
|
136
|
+
this.session = this.createSession(this.session.tracker.lastInteractionId);
|
|
137
|
+
this.snapshotScheduler.restart();
|
|
138
|
+
}
|
|
139
|
+
onTimer() {
|
|
140
|
+
this.takeSnapshot('timer');
|
|
141
|
+
}
|
|
142
|
+
onHidden() {
|
|
143
|
+
if (this.session.hiddenSince === undefined) {
|
|
144
|
+
this.session.hiddenSince = performance.now();
|
|
145
|
+
}
|
|
146
|
+
this.takeSnapshot('hidden');
|
|
147
|
+
}
|
|
148
|
+
onVisible() {
|
|
149
|
+
if (this.session.hiddenSince !== undefined) {
|
|
150
|
+
this.session.hiddenMs += performance.now() - this.session.hiddenSince;
|
|
151
|
+
this.session.hiddenSince = undefined;
|
|
152
|
+
}
|
|
153
|
+
this.session.lifecycleSnapshotEmitted = false;
|
|
154
|
+
}
|
|
155
|
+
onPageHide() {
|
|
156
|
+
this.takeSnapshot('pagehide');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The page came back from the back/forward cache, which never raises a visibility change.
|
|
161
|
+
* The session continues, and the signal that suspended it has been reported, so the next
|
|
162
|
+
* one is due.
|
|
163
|
+
*/
|
|
164
|
+
onPageShow() {
|
|
165
|
+
this.session.lifecycleSnapshotEmitted = false;
|
|
166
|
+
}
|
|
167
|
+
recordEntries(entries) {
|
|
168
|
+
const {
|
|
169
|
+
tracker,
|
|
170
|
+
page
|
|
171
|
+
} = this.session;
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
const update = tracker.merge(entry);
|
|
174
|
+
if (!update) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (update.type === 'new') {
|
|
178
|
+
page.add(update.latencyMs);
|
|
179
|
+
} else {
|
|
180
|
+
page.remeasure(update.fromMs, update.toMs);
|
|
181
|
+
}
|
|
182
|
+
this.session.revision += 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
takeSnapshot(reason) {
|
|
186
|
+
if (this.stopped) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const session = this.session;
|
|
190
|
+
|
|
191
|
+
// Checked before anything is consumed: `visibilitychange` and `pagehide` fire back to
|
|
192
|
+
// back on the same transition and it is reported once, and draining first would take
|
|
193
|
+
// entries out of the browser's queue only to suppress the snapshot carrying them.
|
|
194
|
+
const lifecycleSignal = reason === 'hidden' || reason === 'pagehide';
|
|
195
|
+
if (lifecycleSignal && session.lifecycleSnapshotEmitted) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Must run before the change check: the browser may be holding entries that have not
|
|
200
|
+
// reached the observer callback yet, and on `pagehide` there is no later chance to
|
|
201
|
+
// pick them up.
|
|
202
|
+
this.interactionObserver.drain();
|
|
203
|
+
if (session.revision === session.emittedRevision) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (lifecycleSignal) {
|
|
207
|
+
session.lifecycleSnapshotEmitted = true;
|
|
208
|
+
}
|
|
209
|
+
session.seq += 1;
|
|
210
|
+
session.emittedRevision = session.revision;
|
|
211
|
+
const now = performance.now();
|
|
212
|
+
const hiddenMs = session.hiddenMs + (session.hiddenSince === undefined ? 0 : now - session.hiddenSince);
|
|
213
|
+
const totalCount = InteractionObserver.readPageInteractionCount() - session.interactionCountAtStart;
|
|
214
|
+
this.emit({
|
|
215
|
+
schema: SCHEMA_VERSION,
|
|
216
|
+
interactivitySessionId: session.id,
|
|
217
|
+
objectId: session.objectId,
|
|
218
|
+
seq: session.seq,
|
|
219
|
+
reason,
|
|
220
|
+
sessionMode: session.mode,
|
|
221
|
+
activeMs: Math.round(Math.max(0, now - session.startedAt - hiddenMs)),
|
|
222
|
+
hiddenMs: Math.round(hiddenMs),
|
|
223
|
+
nodeSize: this.getNodeSize(),
|
|
224
|
+
editorDomSize: this.getEditorDomSize(),
|
|
225
|
+
page: session.page.snapshot(totalCount)
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
/** Increments per snapshot; a query takes the highest one per session. */
|
|
27
|
+
_defineProperty(this, "seq", 0);
|
|
28
|
+
_defineProperty(this, "hiddenMs", 0);
|
|
29
|
+
/**
|
|
30
|
+
* Bumped on every change to the accumulated data. A snapshot is emitted only when this
|
|
31
|
+
* has moved past `emittedRevision`, so identical snapshots are never sent twice.
|
|
32
|
+
*/
|
|
33
|
+
_defineProperty(this, "revision", 0);
|
|
34
|
+
_defineProperty(this, "emittedRevision", 0);
|
|
35
|
+
/** One lifecycle snapshot per hidden episode; cleared when the page comes back. */
|
|
36
|
+
_defineProperty(this, "lifecycleSnapshotEmitted", false);
|
|
37
|
+
this.objectId = start.objectId;
|
|
38
|
+
this.mode = start.mode;
|
|
39
|
+
this.hiddenSince = start.hidden ? this.startedAt : undefined;
|
|
40
|
+
this.tracker = new InteractionTracker(start.startsAfterInteractionId);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { bind } from 'bind-event-listener';
|
|
2
|
+
import { getDocument } from '@atlaskit/browser-apis';
|
|
3
|
+
/**
|
|
4
|
+
* Reports the page lifecycle signals that force an extra snapshot: the tab being hidden or
|
|
5
|
+
* shown again, and the page being unloaded or restored from the back/forward cache.
|
|
6
|
+
*
|
|
7
|
+
* None of them is guaranteed to arrive — a mobile browser being killed raises nothing at all.
|
|
8
|
+
* Snapshots carry session-to-date values, so losing the last one costs only the tail of that
|
|
9
|
+
* session.
|
|
10
|
+
*/
|
|
11
|
+
export class LifecycleObserver {
|
|
12
|
+
constructor(handlers) {
|
|
13
|
+
this.handlers = handlers;
|
|
14
|
+
}
|
|
15
|
+
start() {
|
|
16
|
+
const doc = getDocument();
|
|
17
|
+
const unbindVisibilityChange = doc ? bind(doc, {
|
|
18
|
+
type: 'visibilitychange',
|
|
19
|
+
listener: () => {
|
|
20
|
+
if (doc.visibilityState === 'hidden') {
|
|
21
|
+
this.handlers.onHidden();
|
|
22
|
+
} else {
|
|
23
|
+
this.handlers.onVisible();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}) : undefined;
|
|
27
|
+
const unbindPageHide = bind(window, {
|
|
28
|
+
type: 'pagehide',
|
|
29
|
+
listener: () => this.handlers.onPageHide()
|
|
30
|
+
});
|
|
31
|
+
const unbindPageShow = bind(window, {
|
|
32
|
+
type: 'pageshow',
|
|
33
|
+
listener: () => this.handlers.onPageShow()
|
|
34
|
+
});
|
|
35
|
+
this.unbind = () => {
|
|
36
|
+
unbindVisibilityChange === null || unbindVisibilityChange === void 0 ? void 0 : unbindVisibilityChange();
|
|
37
|
+
unbindPageHide();
|
|
38
|
+
unbindPageShow();
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
stop() {
|
|
42
|
+
var _this$unbind;
|
|
43
|
+
(_this$unbind = this.unbind) === null || _this$unbind === void 0 ? void 0 : _this$unbind.call(this);
|
|
44
|
+
this.unbind = undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
|
2
|
+
/**
|
|
3
|
+
* Snapshots are taken 10 s, 30 s and 60 s after the session starts, then every 60 s. The
|
|
4
|
+
* first three come close together so that short sessions, which are the common case, are
|
|
5
|
+
* still reported.
|
|
6
|
+
*/
|
|
7
|
+
const INITIAL_OFFSETS_MS = [10_000, 30_000, 60_000];
|
|
8
|
+
const INTERVAL_MS = 60_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fires `onTick` on the snapshot cadence. Timings are offsets from `start()`, so a
|
|
12
|
+
* restart (a new session in the same editor) restarts the cadence too.
|
|
13
|
+
*/
|
|
14
|
+
export class SnapshotScheduler {
|
|
15
|
+
constructor(onTick) {
|
|
16
|
+
_defineProperty(this, "tickIndex", 0);
|
|
17
|
+
this.onTick = onTick;
|
|
18
|
+
}
|
|
19
|
+
start() {
|
|
20
|
+
this.tickIndex = 0;
|
|
21
|
+
this.scheduleNext();
|
|
22
|
+
}
|
|
23
|
+
restart() {
|
|
24
|
+
this.stop();
|
|
25
|
+
this.start();
|
|
26
|
+
}
|
|
27
|
+
stop() {
|
|
28
|
+
if (this.timeoutId !== undefined) {
|
|
29
|
+
window.clearTimeout(this.timeoutId);
|
|
30
|
+
this.timeoutId = undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
scheduleNext() {
|
|
34
|
+
this.timeoutId = window.setTimeout(() => {
|
|
35
|
+
this.tickIndex += 1;
|
|
36
|
+
this.onTick();
|
|
37
|
+
this.scheduleNext();
|
|
38
|
+
}, this.delayForNextTick());
|
|
39
|
+
}
|
|
40
|
+
delayForNextTick() {
|
|
41
|
+
const offset = INITIAL_OFFSETS_MS[this.tickIndex];
|
|
42
|
+
if (offset === undefined) {
|
|
43
|
+
return INTERVAL_MS;
|
|
44
|
+
}
|
|
45
|
+
const previousOffset = this.tickIndex === 0 ? 0 : INITIAL_OFFSETS_MS[this.tickIndex - 1];
|
|
46
|
+
return offset - previousOffset;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,10 +1,79 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { logException } from '@atlaskit/editor-common/monitoring';
|
|
3
|
+
import { fireInteractivityEvent } from './analytics/fire-interactivity-event';
|
|
4
|
+
import { InteractivityCollector } from './collector/interactivity-collector';
|
|
1
5
|
/**
|
|
2
|
-
* Reports interaction latency distributions for full page editor sessions
|
|
3
|
-
* `editor interactivity` operational event.
|
|
6
|
+
* Reports session-to-date interaction latency distributions for full page editor sessions
|
|
7
|
+
* as the `editor interactivity` operational event.
|
|
4
8
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
9
|
+
* The session runs for as long as the plugin's hook stays mounted, which is one editor
|
|
10
|
+
* mount: `MountPluginHooks` in editor-core keys hook fibers by plugin name, so a preset
|
|
11
|
+
* reconfigure — which destroys and recreates ProseMirror plugin views — leaves this hook,
|
|
12
|
+
* and the session, in place.
|
|
7
13
|
*/
|
|
8
|
-
export const interactivityPlugin = (
|
|
9
|
-
|
|
14
|
+
export const interactivityPlugin = ({
|
|
15
|
+
api
|
|
16
|
+
}) => ({
|
|
17
|
+
name: 'interactivity',
|
|
18
|
+
usePluginHook({
|
|
19
|
+
editorView
|
|
20
|
+
}) {
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
// Nothing measured here is worth an editor. This effect runs inside the plugin slot's
|
|
23
|
+
// error boundary, which also renders every plugin's content components, so a throw
|
|
24
|
+
// from instrumentation would take that UI down with it.
|
|
25
|
+
try {
|
|
26
|
+
var _api$contextIdentifie4, _api$editorViewMode2;
|
|
27
|
+
const collector = new InteractivityCollector({
|
|
28
|
+
emit: snapshot => {
|
|
29
|
+
var _api$analytics;
|
|
30
|
+
return fireInteractivityEvent(api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions, snapshot);
|
|
31
|
+
},
|
|
32
|
+
getObjectId: () => {
|
|
33
|
+
var _api$contextIdentifie, _api$contextIdentifie2, _api$contextIdentifie3;
|
|
34
|
+
return api === null || api === void 0 ? void 0 : (_api$contextIdentifie = api.contextIdentifier) === null || _api$contextIdentifie === void 0 ? void 0 : (_api$contextIdentifie2 = _api$contextIdentifie.sharedState.currentState()) === null || _api$contextIdentifie2 === void 0 ? void 0 : (_api$contextIdentifie3 = _api$contextIdentifie2.contextIdentifierProvider) === null || _api$contextIdentifie3 === void 0 ? void 0 : _api$contextIdentifie3.objectId;
|
|
35
|
+
},
|
|
36
|
+
getSessionMode: () => {
|
|
37
|
+
var _api$editorViewMode, _api$editorViewMode$s;
|
|
38
|
+
const mode = api === null || api === void 0 ? void 0 : (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 ? void 0 : (_api$editorViewMode$s = _api$editorViewMode.sharedState.currentState()) === null || _api$editorViewMode$s === void 0 ? void 0 : _api$editorViewMode$s.mode;
|
|
39
|
+
if (mode === undefined) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return mode === 'view' ? 'reading' : 'editing';
|
|
43
|
+
},
|
|
44
|
+
// A destroyed view still answers, with the document it was destroyed with, so
|
|
45
|
+
// both report nothing rather than a size the editor no longer has.
|
|
46
|
+
getNodeSize: () => editorView.isDestroyed ? undefined : editorView.state.doc.nodeSize,
|
|
47
|
+
// `getElementsByTagName` counts descendants in the browser engine, so this
|
|
48
|
+
// cannot overflow the stack on very large documents.
|
|
49
|
+
getEditorDomSize: () => editorView.isDestroyed ? undefined : editorView.dom.getElementsByTagName('*').length
|
|
50
|
+
});
|
|
51
|
+
if (!collector.start()) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Confluence live pages navigate and switch between reading and editing without
|
|
56
|
+
// remounting the editor. A session covers one document in one mode, so either
|
|
57
|
+
// change ends it and starts the next.
|
|
58
|
+
const unsubscribeFromObjectId = api === null || api === void 0 ? void 0 : (_api$contextIdentifie4 = api.contextIdentifier) === null || _api$contextIdentifie4 === void 0 ? void 0 : _api$contextIdentifie4.sharedState.onChange(() => collector.onObjectIdChanged());
|
|
59
|
+
const unsubscribeFromViewMode = api === null || api === void 0 ? void 0 : (_api$editorViewMode2 = api.editorViewMode) === null || _api$editorViewMode2 === void 0 ? void 0 : _api$editorViewMode2.sharedState.onChange(() => collector.onViewModeChanged());
|
|
60
|
+
return () => {
|
|
61
|
+
unsubscribeFromObjectId === null || unsubscribeFromObjectId === void 0 ? void 0 : unsubscribeFromObjectId();
|
|
62
|
+
unsubscribeFromViewMode === null || unsubscribeFromViewMode === void 0 ? void 0 : unsubscribeFromViewMode();
|
|
63
|
+
collector.stop();
|
|
64
|
+
};
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// Instrumentation must not fail the editor around it, but a failure that hits every
|
|
67
|
+
// browser of one engine would be invisible without this.
|
|
68
|
+
void logException(error, {
|
|
69
|
+
location: 'editor-plugin-interactivity/start'
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// `api` is deliberately not a dependency: a preset reconfigure hands out a new
|
|
74
|
+
// proxy object, but the one captured here keeps resolving plugins from the same
|
|
75
|
+
// live registry, and restarting the session on a reconfigure would split one
|
|
76
|
+
// editor session in two.
|
|
77
|
+
}, [editorView]);
|
|
78
|
+
}
|
|
10
79
|
});
|
|
@@ -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
|
+
var 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 || 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,130 @@
|
|
|
1
|
+
function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t.return || t.return(); } finally { if (u) throw o; } } }; }
|
|
2
|
+
function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
|
|
3
|
+
function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
|
|
4
|
+
/**
|
|
5
|
+
* Bucket boundaries for the interaction latency buckets, version 1.
|
|
6
|
+
*
|
|
7
|
+
* Two ranges:
|
|
8
|
+
* - 16 ms to 200 ms — a boundary every 8 ms: 16, 24, 32, … 200. Event Timing rounds
|
|
9
|
+
* durations to 8 ms, so nothing finer is measurable.
|
|
10
|
+
* - above 200 ms — each boundary ~15% above the previous one, five of them per doubling:
|
|
11
|
+
* 222, 256, 294, 337, 388, 445, 512, … A 40 ms difference matters at 100 ms and is
|
|
12
|
+
* noise at 4 seconds, so buckets grow with the latency instead of staying 8 ms wide.
|
|
13
|
+
*
|
|
14
|
+
* 500 ms — the Google INP "poor" threshold — falls inside the 445…512 bucket, so that one
|
|
15
|
+
* bucket is split at 500 to count the threshold instead of interpolating it. 200 ms, the
|
|
16
|
+
* "good" threshold, is already a boundary.
|
|
17
|
+
*
|
|
18
|
+
* Bump SCHEMA_VERSION whenever any boundary moves; queries group by it.
|
|
19
|
+
*/
|
|
20
|
+
export var SCHEMA_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Event Timing reporting threshold. Faster interactions are never delivered to the
|
|
24
|
+
* observer, so they reach no bucket at all — `performance.interactionCount` is what counts
|
|
25
|
+
* them, as `totalCount - observedCount`. This bucket holds the interactions reported at
|
|
26
|
+
* exactly the threshold.
|
|
27
|
+
*/
|
|
28
|
+
export var REPORTING_THRESHOLD_MS = 16;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Exceptions to the grid: latencies that must be a boundary of their own so that they are
|
|
32
|
+
* counted exactly rather than read off a bucket that spans them. Each one splits the bucket it
|
|
33
|
+
* falls inside. 500 ms is the Google INP "poor" threshold; the "good" one, 200 ms, needs no
|
|
34
|
+
* exception because the evenly spaced range already ends there.
|
|
35
|
+
*
|
|
36
|
+
* Every entry has to sit above that range, and adding one changes the reported keys, so bump
|
|
37
|
+
* SCHEMA_VERSION with it.
|
|
38
|
+
*/
|
|
39
|
+
var EXACT_THRESHOLDS_MS = [500];
|
|
40
|
+
var EVENLY_SPACED_MAX_MS = 200;
|
|
41
|
+
/**
|
|
42
|
+
* Keep this a multiple of 8 (8, 16, 24, …). Event Timing reports durations in 8 ms steps, so a
|
|
43
|
+
* step that is not a multiple of 8 leaves buckets no interaction can ever land in.
|
|
44
|
+
*/
|
|
45
|
+
var EVENLY_SPACED_STEP_MS = 8;
|
|
46
|
+
var BOUNDARIES_PER_DOUBLING = 5;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The first boundary at or above `latencyMs`, for the range above 200 ms.
|
|
50
|
+
*
|
|
51
|
+
* Five boundaries per doubling is the same as saying the nth boundary sits at `2^(n/5)` ms —
|
|
52
|
+
* boundary 40 at 256 ms, 45 at 512 ms, 50 at 1024 ms. So this turns the latency into a
|
|
53
|
+
* boundary number, rounds that up, and turns it back into milliseconds.
|
|
54
|
+
*
|
|
55
|
+
* Boundaries are floored to whole milliseconds, which keeps each one at or below the exact
|
|
56
|
+
* value it stands for. That is what makes `bucketKeyForMs(boundary) === boundary` hold.
|
|
57
|
+
*/
|
|
58
|
+
function firstBoundaryAtOrAbove(latencyMs) {
|
|
59
|
+
// `Math.log2(latencyMs) * 5` is the boundary number: log2 answers how many doublings of
|
|
60
|
+
// 1 ms reach this latency, and five boundaries cover each doubling.
|
|
61
|
+
//
|
|
62
|
+
// A latency sitting on a boundary makes that a whole number, which `Math.ceil` has to
|
|
63
|
+
// keep. ECMA-262 only requires `Math.log2` to be approximate, so 9.0000000000000002 for
|
|
64
|
+
// `log2(512)` would round up to boundary 46 and report 512 ms as 588 ms. EPSILON is
|
|
65
|
+
// larger than such imprecision and far smaller than the gap between two boundaries.
|
|
66
|
+
var EPSILON = 1e-9;
|
|
67
|
+
var boundaryNumber = Math.ceil(BOUNDARIES_PER_DOUBLING * Math.log2(latencyMs) - EPSILON);
|
|
68
|
+
|
|
69
|
+
// One `Math.pow` over the whole exponent, so the result is rounded once. Multiplying the
|
|
70
|
+
// ratio between neighbouring boundaries (2^(1/5) ≈ 1.1487) by itself instead accumulates
|
|
71
|
+
// the rounding of every step, reaching 512.0000000000018 by boundary 45.
|
|
72
|
+
return Math.floor(Math.pow(2, boundaryNumber / BOUNDARIES_PER_DOUBLING));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The bucket each exact threshold splits — 500 ms splits the one ending at 512 ms. */
|
|
76
|
+
var EXACT_THRESHOLD_BUCKETS = EXACT_THRESHOLDS_MS.map(function (thresholdMs) {
|
|
77
|
+
return {
|
|
78
|
+
thresholdMs: thresholdMs,
|
|
79
|
+
bucketMs: firstBoundaryAtOrAbove(thresholdMs)
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The bucket a latency belongs to, identified by the bucket's upper boundary in whole
|
|
85
|
+
* milliseconds — which is also its key in the reported buckets.
|
|
86
|
+
*
|
|
87
|
+
* Every latency gets a bucket, however slow: the boundaries continue upwards, so there is no
|
|
88
|
+
* overflow bucket. Expects the whole-millisecond durations Event Timing reports; boundaries
|
|
89
|
+
* are floored, so a fractional latency can land in a bucket whose key reads up to a
|
|
90
|
+
* millisecond below it. Non-finite latencies never reach here — `InteractionTracker` drops
|
|
91
|
+
* them as it reads the entry.
|
|
92
|
+
*/
|
|
93
|
+
export function bucketKeyForMs(latencyMs) {
|
|
94
|
+
if (latencyMs <= REPORTING_THRESHOLD_MS) {
|
|
95
|
+
return REPORTING_THRESHOLD_MS;
|
|
96
|
+
}
|
|
97
|
+
if (latencyMs <= EVENLY_SPACED_MAX_MS) {
|
|
98
|
+
// How many 8 ms steps above the threshold the latency is, rounded up: 17 ms is 0.125
|
|
99
|
+
// steps up and lands on the boundary one step up, 24 ms. Rounding up is what keeps the
|
|
100
|
+
// set of keys fixed when a latency is not a multiple of 8 ms.
|
|
101
|
+
//
|
|
102
|
+
// No EPSILON here, unlike the branch below: subtracting whole numbers gives a whole
|
|
103
|
+
// number, and dividing by a power of two shifts a binary float's exponent without
|
|
104
|
+
// touching its digits, so a latency on a boundary cannot come out just above a whole
|
|
105
|
+
// number of steps.
|
|
106
|
+
var steps = Math.ceil((latencyMs - REPORTING_THRESHOLD_MS) / EVENLY_SPACED_STEP_MS);
|
|
107
|
+
return REPORTING_THRESHOLD_MS + steps * EVENLY_SPACED_STEP_MS;
|
|
108
|
+
}
|
|
109
|
+
var boundary = firstBoundaryAtOrAbove(latencyMs);
|
|
110
|
+
|
|
111
|
+
// A latency in the lower part of a split bucket is reported as the threshold itself, so the
|
|
112
|
+
// threshold is counted exactly.
|
|
113
|
+
var _iterator = _createForOfIteratorHelper(EXACT_THRESHOLD_BUCKETS),
|
|
114
|
+
_step;
|
|
115
|
+
try {
|
|
116
|
+
for (_iterator.s(); !(_step = _iterator.n()).done;) {
|
|
117
|
+
var _step$value = _step.value,
|
|
118
|
+
thresholdMs = _step$value.thresholdMs,
|
|
119
|
+
bucketMs = _step$value.bucketMs;
|
|
120
|
+
if (boundary === bucketMs && latencyMs <= thresholdMs) {
|
|
121
|
+
return thresholdMs;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
_iterator.e(err);
|
|
126
|
+
} finally {
|
|
127
|
+
_iterator.f();
|
|
128
|
+
}
|
|
129
|
+
return boundary;
|
|
130
|
+
}
|