@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +65 -6
  3. package/afm-cc/tsconfig.json +12 -0
  4. package/afm-products/tsconfig.json +12 -0
  5. package/dist/cjs/analytics/fire-interactivity-event.js +39 -0
  6. package/dist/cjs/analytics/interactivity-snapshot.js +1 -0
  7. package/dist/cjs/collector/bucket-boundaries.js +137 -0
  8. package/dist/cjs/collector/editor-event-observer.js +63 -0
  9. package/dist/cjs/collector/interaction-events.js +62 -0
  10. package/dist/cjs/collector/interaction-group.js +185 -0
  11. package/dist/cjs/collector/interaction-observer.js +114 -0
  12. package/dist/cjs/collector/interaction-tracker.js +185 -0
  13. package/dist/cjs/collector/interactivity-collector.js +341 -0
  14. package/dist/cjs/collector/interactivity-session.js +55 -0
  15. package/dist/cjs/collector/lifecycle-observer.js +66 -0
  16. package/dist/cjs/collector/snapshot-scheduler.js +70 -0
  17. package/dist/cjs/interactivityPlugin.js +93 -6
  18. package/dist/es2019/analytics/fire-interactivity-event.js +33 -0
  19. package/dist/es2019/analytics/interactivity-snapshot.js +0 -0
  20. package/dist/es2019/collector/bucket-boundaries.js +117 -0
  21. package/dist/es2019/collector/editor-event-observer.js +46 -0
  22. package/dist/es2019/collector/interaction-events.js +55 -0
  23. package/dist/es2019/collector/interaction-group.js +125 -0
  24. package/dist/es2019/collector/interaction-observer.js +90 -0
  25. package/dist/es2019/collector/interaction-tracker.js +156 -0
  26. package/dist/es2019/collector/interactivity-collector.js +274 -0
  27. package/dist/es2019/collector/interactivity-session.js +45 -0
  28. package/dist/es2019/collector/lifecycle-observer.js +46 -0
  29. package/dist/es2019/collector/snapshot-scheduler.js +48 -0
  30. package/dist/es2019/interactivityPlugin.js +87 -6
  31. package/dist/esm/analytics/fire-interactivity-event.js +33 -0
  32. package/dist/esm/analytics/interactivity-snapshot.js +0 -0
  33. package/dist/esm/collector/bucket-boundaries.js +130 -0
  34. package/dist/esm/collector/editor-event-observer.js +57 -0
  35. package/dist/esm/collector/interaction-events.js +55 -0
  36. package/dist/esm/collector/interaction-group.js +179 -0
  37. package/dist/esm/collector/interaction-observer.js +108 -0
  38. package/dist/esm/collector/interaction-tracker.js +179 -0
  39. package/dist/esm/collector/interactivity-collector.js +334 -0
  40. package/dist/esm/collector/interactivity-session.js +48 -0
  41. package/dist/esm/collector/lifecycle-observer.js +59 -0
  42. package/dist/esm/collector/snapshot-scheduler.js +63 -0
  43. package/dist/esm/interactivityPlugin.js +93 -6
  44. package/dist/types/analytics/fire-interactivity-event.d.ts +9 -0
  45. package/dist/types/analytics/interactivity-snapshot.d.ts +66 -0
  46. package/dist/types/collector/bucket-boundaries.d.ts +35 -0
  47. package/dist/types/collector/editor-event-observer.d.ts +19 -0
  48. package/dist/types/collector/interaction-events.d.ts +19 -0
  49. package/dist/types/collector/interaction-group.d.ts +37 -0
  50. package/dist/types/collector/interaction-observer.d.ts +39 -0
  51. package/dist/types/collector/interaction-tracker.d.ts +65 -0
  52. package/dist/types/collector/interactivity-collector.d.ts +90 -0
  53. package/dist/types/collector/interactivity-session.d.ts +42 -0
  54. package/dist/types/collector/lifecycle-observer.d.ts +25 -0
  55. package/dist/types/collector/snapshot-scheduler.d.ts +15 -0
  56. package/dist/types/interactivityPlugin.d.ts +6 -4
  57. package/dist/types/interactivityPluginType.d.ts +11 -2
  58. package/docs/0-intro.tsx +26 -7
  59. package/package.json +8 -3
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.InteractivitySession = void 0;
8
+ var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));
9
+ var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
10
+ var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
11
+ var _interactionObserver = require("./interaction-observer");
12
+ var _interactionTracker = require("./interaction-tracker");
13
+ var _interactionGroup = require("./interaction-group");
14
+ function createSessionId() {
15
+ if (typeof crypto.randomUUID === 'function') {
16
+ return crypto.randomUUID();
17
+ }
18
+ var bytes = new Uint8Array(16);
19
+ crypto.getRandomValues(bytes);
20
+ return Array.from(bytes, function (byte) {
21
+ return byte.toString(16).padStart(2, '0');
22
+ }).join('');
23
+ }
24
+ /**
25
+ * Everything that belongs to one session.
26
+ *
27
+ * Nothing here is reset — the next session is a new instance — so a field added here cannot
28
+ * be left carrying the previous session's value.
29
+ */
30
+ var InteractivitySession = exports.InteractivitySession = /*#__PURE__*/(0, _createClass2.default)(function InteractivitySession(start) {
31
+ (0, _classCallCheck2.default)(this, InteractivitySession);
32
+ (0, _defineProperty2.default)(this, "id", createSessionId());
33
+ (0, _defineProperty2.default)(this, "startedAt", performance.now());
34
+ /** Page interaction count when the session opened, subtracted to get its own total. */
35
+ (0, _defineProperty2.default)(this, "interactionCountAtStart", _interactionObserver.InteractionObserver.readPageInteractionCount());
36
+ (0, _defineProperty2.default)(this, "page", new _interactionGroup.InteractionGroup());
37
+ (0, _defineProperty2.default)(this, "editorTyping", new _interactionGroup.InteractionGroup());
38
+ (0, _defineProperty2.default)(this, "editorPointer", new _interactionGroup.InteractionGroup());
39
+ (0, _defineProperty2.default)(this, "editorOther", new _interactionGroup.InteractionGroup());
40
+ /** Increments per snapshot; a query takes the highest one per session. */
41
+ (0, _defineProperty2.default)(this, "seq", 0);
42
+ (0, _defineProperty2.default)(this, "hiddenMs", 0);
43
+ /**
44
+ * Bumped on every change to the accumulated data. A snapshot is emitted only when this
45
+ * has moved past `emittedRevision`, so identical snapshots are never sent twice.
46
+ */
47
+ (0, _defineProperty2.default)(this, "revision", 0);
48
+ (0, _defineProperty2.default)(this, "emittedRevision", 0);
49
+ /** One lifecycle snapshot per hidden episode; cleared when the page comes back. */
50
+ (0, _defineProperty2.default)(this, "lifecycleSnapshotEmitted", false);
51
+ this.objectId = start.objectId;
52
+ this.mode = start.mode;
53
+ this.hiddenSince = start.hidden ? this.startedAt : undefined;
54
+ this.tracker = new _interactionTracker.InteractionTracker(start.startsAfterInteractionId);
55
+ });
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.LifecycleObserver = void 0;
8
+ var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
9
+ var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));
10
+ var _bindEventListener = require("bind-event-listener");
11
+ var _browserApis = require("@atlaskit/browser-apis");
12
+ /**
13
+ * Reports the page lifecycle signals that force an extra snapshot: the tab being hidden or
14
+ * shown again, and the page being unloaded or restored from the back/forward cache.
15
+ *
16
+ * None of them is guaranteed to arrive — a mobile browser being killed raises nothing at all.
17
+ * Snapshots carry session-to-date values, so losing the last one costs only the tail of that
18
+ * session.
19
+ */
20
+ var LifecycleObserver = exports.LifecycleObserver = /*#__PURE__*/function () {
21
+ function LifecycleObserver(handlers) {
22
+ (0, _classCallCheck2.default)(this, LifecycleObserver);
23
+ this.handlers = handlers;
24
+ }
25
+ return (0, _createClass2.default)(LifecycleObserver, [{
26
+ key: "start",
27
+ value: function start() {
28
+ var _this = this;
29
+ var doc = (0, _browserApis.getDocument)();
30
+ var unbindVisibilityChange = doc ? (0, _bindEventListener.bind)(doc, {
31
+ type: 'visibilitychange',
32
+ listener: function listener() {
33
+ if (doc.visibilityState === 'hidden') {
34
+ _this.handlers.onHidden();
35
+ } else {
36
+ _this.handlers.onVisible();
37
+ }
38
+ }
39
+ }) : undefined;
40
+ var unbindPageHide = (0, _bindEventListener.bind)(window, {
41
+ type: 'pagehide',
42
+ listener: function listener() {
43
+ return _this.handlers.onPageHide();
44
+ }
45
+ });
46
+ var unbindPageShow = (0, _bindEventListener.bind)(window, {
47
+ type: 'pageshow',
48
+ listener: function listener() {
49
+ return _this.handlers.onPageShow();
50
+ }
51
+ });
52
+ this.unbind = function () {
53
+ unbindVisibilityChange === null || unbindVisibilityChange === void 0 || unbindVisibilityChange();
54
+ unbindPageHide();
55
+ unbindPageShow();
56
+ };
57
+ }
58
+ }, {
59
+ key: "stop",
60
+ value: function stop() {
61
+ var _this$unbind;
62
+ (_this$unbind = this.unbind) === null || _this$unbind === void 0 || _this$unbind.call(this);
63
+ this.unbind = undefined;
64
+ }
65
+ }]);
66
+ }();
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.SnapshotScheduler = void 0;
8
+ var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
9
+ var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));
10
+ var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
11
+ /**
12
+ * Snapshots are taken 10 s, 30 s and 60 s after the session starts, then every 60 s. The
13
+ * first three come close together so that short sessions, which are the common case, are
14
+ * still reported.
15
+ */
16
+ var INITIAL_OFFSETS_MS = [10000, 30000, 60000];
17
+ var INTERVAL_MS = 60000;
18
+
19
+ /**
20
+ * Fires `onTick` on the snapshot cadence. Timings are offsets from `start()`, so a
21
+ * restart (a new session in the same editor) restarts the cadence too.
22
+ */
23
+ var SnapshotScheduler = exports.SnapshotScheduler = /*#__PURE__*/function () {
24
+ function SnapshotScheduler(onTick) {
25
+ (0, _classCallCheck2.default)(this, SnapshotScheduler);
26
+ (0, _defineProperty2.default)(this, "tickIndex", 0);
27
+ this.onTick = onTick;
28
+ }
29
+ return (0, _createClass2.default)(SnapshotScheduler, [{
30
+ key: "start",
31
+ value: function start() {
32
+ this.tickIndex = 0;
33
+ this.scheduleNext();
34
+ }
35
+ }, {
36
+ key: "restart",
37
+ value: function restart() {
38
+ this.stop();
39
+ this.start();
40
+ }
41
+ }, {
42
+ key: "stop",
43
+ value: function stop() {
44
+ if (this.timeoutId !== undefined) {
45
+ window.clearTimeout(this.timeoutId);
46
+ this.timeoutId = undefined;
47
+ }
48
+ }
49
+ }, {
50
+ key: "scheduleNext",
51
+ value: function scheduleNext() {
52
+ var _this = this;
53
+ this.timeoutId = window.setTimeout(function () {
54
+ _this.tickIndex += 1;
55
+ _this.onTick();
56
+ _this.scheduleNext();
57
+ }, this.delayForNextTick());
58
+ }
59
+ }, {
60
+ key: "delayForNextTick",
61
+ value: function delayForNextTick() {
62
+ var offset = INITIAL_OFFSETS_MS[this.tickIndex];
63
+ if (offset === undefined) {
64
+ return INTERVAL_MS;
65
+ }
66
+ var previousOffset = this.tickIndex === 0 ? 0 : INITIAL_OFFSETS_MS[this.tickIndex - 1];
67
+ return offset - previousOffset;
68
+ }
69
+ }]);
70
+ }();
@@ -4,15 +4,102 @@ 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 as the
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
- * A stub for now: the plugin only registers itself. The collector and the event land in
12
- * EDITOR-8553.
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
+ wrapperElement = _ref2.wrapperElement;
27
+ var collectorRef = (0, _react.useRef)(undefined);
28
+ (0, _react.useEffect)(function () {
29
+ try {
30
+ var _api$contextIdentifie2, _api$editorViewMode2;
31
+ var collector = new _interactivityCollector.InteractivityCollector({
32
+ emit: function emit(snapshot) {
33
+ var _api$analytics;
34
+ return (0, _fireInteractivityEvent.fireInteractivityEvent)(api === null || api === void 0 || (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions, snapshot);
35
+ },
36
+ getObjectId: function getObjectId() {
37
+ var _api$contextIdentifie;
38
+ 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;
39
+ },
40
+ getSessionMode: function getSessionMode() {
41
+ var _api$editorViewMode;
42
+ 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;
43
+ if (mode === undefined) {
44
+ return undefined;
45
+ }
46
+ return mode === 'view' ? 'reading' : 'editing';
47
+ },
48
+ // A destroyed view still answers, with the document it was destroyed with, so
49
+ // both report nothing rather than a size the editor no longer has.
50
+ getNodeSize: function getNodeSize() {
51
+ return editorView.isDestroyed ? undefined : editorView.state.doc.nodeSize;
52
+ },
53
+ // `getElementsByTagName` counts descendants in the browser engine, so this
54
+ // cannot overflow the stack on very large documents.
55
+ getEditorDomSize: function getEditorDomSize() {
56
+ return editorView.isDestroyed ? undefined : editorView.dom.getElementsByTagName('*').length;
57
+ }
58
+ });
59
+
60
+ // Effects run in declaration order, so the one below always finds it.
61
+ collectorRef.current = collector;
62
+ if (!collector.start()) {
63
+ return;
64
+ }
65
+
66
+ // Confluence live pages navigate and switch between reading and editing without
67
+ // remounting the editor. A session covers one document in one mode, so either
68
+ // change ends it and starts the next.
69
+ var unsubscribeFromObjectId = api === null || api === void 0 || (_api$contextIdentifie2 = api.contextIdentifier) === null || _api$contextIdentifie2 === void 0 ? void 0 : _api$contextIdentifie2.sharedState.onChange(function () {
70
+ return collector.onObjectIdChanged();
71
+ });
72
+ var unsubscribeFromViewMode = api === null || api === void 0 || (_api$editorViewMode2 = api.editorViewMode) === null || _api$editorViewMode2 === void 0 ? void 0 : _api$editorViewMode2.sharedState.onChange(function () {
73
+ return collector.onViewModeChanged();
74
+ });
75
+ return function () {
76
+ unsubscribeFromObjectId === null || unsubscribeFromObjectId === void 0 || unsubscribeFromObjectId();
77
+ unsubscribeFromViewMode === null || unsubscribeFromViewMode === void 0 || unsubscribeFromViewMode();
78
+ collector.stop();
79
+ };
80
+ } catch (error) {
81
+ // Instrumentation must not fail the editor around it, but a failure that hits every
82
+ // browser of one engine would be invisible without this.
83
+ void (0, _monitoring.logException)(error, {
84
+ location: 'editor-plugin-interactivity/start'
85
+ });
86
+ return;
87
+ }
88
+ // `api` is deliberately not a dependency: a preset reconfigure hands out a new
89
+ // proxy object, but the one captured here keeps resolving plugins from the same
90
+ // live registry, and restarting the session on a reconfigure would split one
91
+ // editor session in two.
92
+ }, [editorView]);
93
+ (0, _react.useEffect)(function () {
94
+ try {
95
+ var _collectorRef$current;
96
+ (_collectorRef$current = collectorRef.current) === null || _collectorRef$current === void 0 || _collectorRef$current.setEditorRoot(wrapperElement);
97
+ } catch (error) {
98
+ void (0, _monitoring.logException)(error, {
99
+ location: 'editor-plugin-interactivity/observe'
100
+ });
101
+ }
102
+ }, [wrapperElement]);
103
+ }
17
104
  };
18
105
  };
@@ -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
+ }
@@ -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,46 @@
1
+ import { bindAll } from 'bind-event-listener';
2
+ import { INTERACTION_EVENT_TYPES } from './interaction-events';
3
+
4
+ /**
5
+ * Reports the events the interactions with the editor are made of, as the browser dispatched
6
+ * them. What they mean is the tracker's business.
7
+ *
8
+ * Event Timing cannot answer which interactions were with the editor, because it observes the
9
+ * whole document, nor how many there were, because it reports none below 16 ms.
10
+ */
11
+ export class EditorEventObserver {
12
+ constructor(onEvent) {
13
+ this.onEvent = onEvent;
14
+ }
15
+
16
+ /**
17
+ * Called as the element the editor renders itself into changes: the editor only knows it after
18
+ * its first render, and can replace it.
19
+ */
20
+ observe(root) {
21
+ const next = root !== null && root !== void 0 ? root : undefined;
22
+ if (next === this.root) {
23
+ return;
24
+ }
25
+ this.stop();
26
+ this.root = next;
27
+ if (!next) {
28
+ return;
29
+ }
30
+ this.unbind = bindAll(next, INTERACTION_EVENT_TYPES.map(type => ({
31
+ type,
32
+ listener: this.onEvent
33
+ })),
34
+ // Capture, so a handler in the editor cannot stop an interaction from being reported.
35
+ {
36
+ capture: true,
37
+ passive: true
38
+ });
39
+ }
40
+ stop() {
41
+ var _this$unbind;
42
+ (_this$unbind = this.unbind) === null || _this$unbind === void 0 ? void 0 : _this$unbind.call(this);
43
+ this.unbind = undefined;
44
+ this.root = undefined;
45
+ }
46
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The editor group an interaction belongs to, which is also the field the event reports it in.
3
+ *
4
+ * `editorOther` is the remainder — an interaction the browser counts that is neither typing nor
5
+ * pointing — so the three groups together cover whatever the browser calls an interaction.
6
+ */
7
+
8
+ /**
9
+ * The events an interaction is made of, and the group they belong to. Both sides of the collector
10
+ * read this, so counting and grouping cannot disagree.
11
+ *
12
+ * These are the events Event Timing can report with a non-zero `interactionId`, so any of them can
13
+ * be the one an entry measured — and it is usually not the one the interaction is counted on: for a
14
+ * pointer press the slow part is normally the `click` handler.
15
+ *
16
+ * The counted event is at a different end of the interaction in each group, because what can go
17
+ * wrong differs. Typing is counted on `keydown`, since the browser opens a new interaction for
18
+ * every repeat of a held key, and nothing cancels a key press. Pointing is counted on `pointerup`,
19
+ * since a press cannot repeat but can be taken over by a scroll — which the browser does not count
20
+ * either, and in that case `pointerup` never arrives.
21
+ */
22
+ const INTERACTION_EVENTS = {
23
+ keydown: {
24
+ group: 'editorTyping',
25
+ counts: true
26
+ },
27
+ keyup: {
28
+ group: 'editorTyping'
29
+ },
30
+ // Only while an IME composes: the browser counts the text it commits in the interaction of the
31
+ // key that caused it.
32
+ input: {
33
+ group: 'editorTyping'
34
+ },
35
+ pointerdown: {
36
+ group: 'editorPointer'
37
+ },
38
+ pointerup: {
39
+ group: 'editorPointer',
40
+ counts: true
41
+ },
42
+ click: {
43
+ group: 'editorPointer'
44
+ },
45
+ // Ends a pointer press the way `pointerup` does, so the browser counts it as an interaction.
46
+ contextmenu: {
47
+ group: 'editorPointer'
48
+ }
49
+ };
50
+
51
+ /** Derived, so the types listened for cannot drift from the table. */
52
+ export const INTERACTION_EVENT_TYPES = Object.keys(INTERACTION_EVENTS);
53
+ export function interactionEventKind(type) {
54
+ return INTERACTION_EVENTS[type];
55
+ }