@atlaskit/editor-plugin-interactivity 0.3.0 → 1.1.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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # @atlaskit/editor-plugin-interactivity
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`ccd1b52723728`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/ccd1b52723728) -
8
+ Report the slowest interactions of the session in the `editor interactivity` event — their group,
9
+ event type, latency, timing phases and a sanitised target — so a regression the histograms show
10
+ can be traced to what was interacted with. Behind the platform_editor_editor_interactivity_slowest
11
+ experiment.
12
+
13
+ ## 1.0.0
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies
18
+
3
19
  ## 0.3.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -31,6 +31,40 @@ interactions were slow, so this plugin keeps bucketed counts instead.
31
31
  bucket means zero. The boundaries are versioned by `schema`.
32
32
  - Snapshots are session-to-date, so a cohort query takes the highest `seq` per
33
33
  `interactivitySessionId` and then sums bucket counts.
34
+ - `slowest` — up to five of the slowest interactions of the session, slowest first. See below.
35
+
36
+ ## The slowest interactions
37
+
38
+ The histograms count how many interactions were slow. They cannot say what was interacted with, so
39
+ a regression they show cannot be diagnosed from telemetry alone. Each record carries the group and
40
+ the event type, the latency, the phases it divides into — input delay, processing, presentation
41
+ delay — and a name for the target.
42
+
43
+ Only interactions above 200 ms, the Google INP "good" threshold, are candidates, and `slowest` is
44
+ left out of the event when none crossed it, which is the common case. That is also what keeps the
45
+ cost down: a target is named only for the few interactions slow enough to be recorded, never for
46
+ every one the browser reports.
47
+
48
+ Records are kept per interaction, because an interaction's latency grows as later entries arrive:
49
+ one that is already recorded is replaced by its slower self instead of taking a second place. Its
50
+ attribution comes from the entry that measured it at its slowest, read as that entry arrives —
51
+ `entry.target` is `null` once the element has left the document, so there is no reading it later.
52
+
53
+ The target is a short path of tag names plus the first allow-listed attribute above it, such as
54
+ `div[data-vc="editor"] > p > span`. Nothing in it is document content: a tag name comes from the
55
+ schema, and the only attributes read are ones we put there ourselves. Ids, roles, class names, text
56
+ and accessibility labels are all left out, and the path is bounded in both depth and length.
57
+
58
+ An interaction that is not the editor's is recorded as `group: 'outsideEditor'`, so the records also
59
+ answer whether the slow interactions of a session were the editor's at all. That group is not the
60
+ `page` histogram: `page` counts the editor's interactions as well, `outsideEditor` counts only the
61
+ ones that are not.
62
+
63
+ Collecting them is its own experiment, `platform_editor_editor_interactivity_slowest`, read where a
64
+ session decides whether to keep them rather than where the plugin is added to a preset: the plugin
65
+ only exists when the experiment reporting the histograms is on, so the exposure lands on the
66
+ sessions that already send the event, and the cost of the records stays separable from the cost of
67
+ the event carrying them. Nothing is collected, named or sent when it is off.
34
68
 
35
69
  ## The editor groups
36
70
 
@@ -30,6 +30,9 @@
30
30
  {
31
31
  "path": "../../editor-plugin-editor-viewmode/afm-cc/tsconfig.json"
32
32
  },
33
+ {
34
+ "path": "../../../platform/feature-experiments/afm-cc/tsconfig.json"
35
+ },
33
36
  {
34
37
  "path": "../../editor-common/afm-cc/tsconfig.json"
35
38
  }
@@ -30,6 +30,9 @@
30
30
  {
31
31
  "path": "../../editor-plugin-editor-viewmode/afm-products/tsconfig.json"
32
32
  },
33
+ {
34
+ "path": "../../../platform/feature-experiments/afm-products/tsconfig.json"
35
+ },
33
36
  {
34
37
  "path": "../../editor-common/afm-products/tsconfig.json"
35
38
  }
@@ -5,13 +5,6 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.INTERACTION_EVENT_TYPES = void 0;
7
7
  exports.interactionEventKind = interactionEventKind;
8
- /**
9
- * The editor group an interaction belongs to, which is also the field the event reports it in.
10
- *
11
- * `editorOther` is the remainder — an interaction the browser counts that is neither typing nor
12
- * pointing — so the three groups together cover whatever the browser calls an interaction.
13
- */
14
-
15
8
  /**
16
9
  * The events an interaction is made of, and the group they belong to. Both sides of the collector
17
10
  * read this, so counting and grouping cannot disagree.
@@ -13,9 +13,9 @@ function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol
13
13
  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; } }
14
14
  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; }
15
15
  /**
16
- * The Event Timing fields this package reads. `interactionId` is missing from the DOM
17
- * typings' `PerformanceEventTiming`, so it is declared here as optional, which also makes
18
- * a `PerformanceEntry` from `getEntries()` assignable without a cast.
16
+ * The Event Timing fields this package reads, declared optional so that a `PerformanceEntry` from
17
+ * `getEntries()` is assignable without a cast `interactionId` is missing from the DOM typings'
18
+ * `PerformanceEventTiming` altogether, and the rest are only on it.
19
19
  */
20
20
 
21
21
  /**
@@ -122,6 +122,7 @@ var InteractionTracker = exports.InteractionTracker = /*#__PURE__*/function () {
122
122
  this.prune(this.interactions);
123
123
  return {
124
124
  type: 'new',
125
+ interactionId: interactionId,
125
126
  latencyMs: entry.duration,
126
127
  group: group
127
128
  };
@@ -133,6 +134,7 @@ var InteractionTracker = exports.InteractionTracker = /*#__PURE__*/function () {
133
134
  tracked.latencyMs = entry.duration;
134
135
  return {
135
136
  type: 'remeasured',
137
+ interactionId: interactionId,
136
138
  fromMs: fromMs,
137
139
  toMs: entry.duration,
138
140
  group: tracked.group
@@ -15,6 +15,8 @@ var _interactivitySession = require("./interactivity-session");
15
15
  var _bucketBoundaries = require("./bucket-boundaries");
16
16
  var _lifecycleObserver = require("./lifecycle-observer");
17
17
  var _snapshotScheduler = require("./snapshot-scheduler");
18
+ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
19
+ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
18
20
  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; } } }; }
19
21
  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; } }
20
22
  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; }
@@ -259,27 +261,41 @@ var InteractivityCollector = exports.InteractivityCollector = /*#__PURE__*/funct
259
261
  value: function recordEntries(entries) {
260
262
  var _this$session = this.session,
261
263
  tracker = _this$session.tracker,
262
- page = _this$session.page;
264
+ page = _this$session.page,
265
+ slowest = _this$session.slowest;
263
266
  var _iterator = _createForOfIteratorHelper(entries),
264
267
  _step;
265
268
  try {
266
269
  for (_iterator.s(); !(_step = _iterator.n()).done;) {
270
+ var _update$group;
267
271
  var entry = _step.value;
268
272
  var update = tracker.merge(entry);
269
273
  if (!update) {
270
274
  continue;
271
275
  }
276
+ var latencyMs = void 0;
272
277
  if (update.type === 'new') {
273
- page.add(update.latencyMs);
278
+ latencyMs = update.latencyMs;
279
+ page.add(latencyMs);
274
280
  if (update.group) {
275
- this.session[update.group].add(update.latencyMs);
281
+ this.session[update.group].add(latencyMs);
276
282
  }
277
283
  } else {
278
- page.remeasure(update.fromMs, update.toMs);
284
+ latencyMs = update.toMs;
285
+ page.remeasure(update.fromMs, latencyMs);
279
286
  if (update.group) {
280
- this.session[update.group].remeasure(update.fromMs, update.toMs);
287
+ this.session[update.group].remeasure(update.fromMs, latencyMs);
281
288
  }
282
289
  }
290
+
291
+ // An interaction the editor never reported an event for is not the editor's as far as we
292
+ // know.
293
+ slowest === null || slowest === void 0 || slowest.track({
294
+ entry: entry,
295
+ interactionId: update.interactionId,
296
+ latencyMs: latencyMs,
297
+ group: (_update$group = update.group) !== null && _update$group !== void 0 ? _update$group : 'outsideEditor'
298
+ });
283
299
  this.session.revision += 1;
284
300
  }
285
301
  } catch (err) {
@@ -291,6 +307,7 @@ var InteractivityCollector = exports.InteractivityCollector = /*#__PURE__*/funct
291
307
  }, {
292
308
  key: "takeSnapshot",
293
309
  value: function takeSnapshot(reason) {
310
+ var _session$slowest;
294
311
  if (this.stopped) {
295
312
  return;
296
313
  }
@@ -319,7 +336,8 @@ var InteractivityCollector = exports.InteractivityCollector = /*#__PURE__*/funct
319
336
  var now = performance.now();
320
337
  var hiddenMs = session.hiddenMs + (session.hiddenSince === undefined ? 0 : now - session.hiddenSince);
321
338
  var pageTotalCount = _interactionObserver.InteractionObserver.readPageInteractionCount() - session.interactionCountAtStart;
322
- this.emit({
339
+ var slowest = (_session$slowest = session.slowest) === null || _session$slowest === void 0 ? void 0 : _session$slowest.snapshot();
340
+ this.emit(_objectSpread({
323
341
  schema: _bucketBoundaries.SCHEMA_VERSION,
324
342
  interactivitySessionId: session.id,
325
343
  objectId: session.objectId,
@@ -335,7 +353,9 @@ var InteractivityCollector = exports.InteractivityCollector = /*#__PURE__*/funct
335
353
  editorTyping: session.editorTyping.snapshot(),
336
354
  editorPointer: session.editorPointer.snapshot(),
337
355
  editorOther: session.editorOther.snapshot()
338
- });
356
+ }, slowest && {
357
+ slowest: slowest
358
+ }));
339
359
  }
340
360
  }]);
341
361
  }();
@@ -8,9 +8,11 @@ exports.InteractivitySession = void 0;
8
8
  var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));
9
9
  var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
10
10
  var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
11
+ var _isExperimentEnabled = require("@atlaskit/platform-feature-experiments/is-experiment-enabled");
11
12
  var _interactionObserver = require("./interaction-observer");
12
13
  var _interactionTracker = require("./interaction-tracker");
13
14
  var _interactionGroup = require("./interaction-group");
15
+ var _slowInteractionList = require("./slow-interaction-list");
14
16
  function createSessionId() {
15
17
  if (typeof crypto.randomUUID === 'function') {
16
18
  return crypto.randomUUID();
@@ -37,6 +39,7 @@ var InteractivitySession = exports.InteractivitySession = /*#__PURE__*/(0, _crea
37
39
  (0, _defineProperty2.default)(this, "editorTyping", new _interactionGroup.InteractionGroup());
38
40
  (0, _defineProperty2.default)(this, "editorPointer", new _interactionGroup.InteractionGroup());
39
41
  (0, _defineProperty2.default)(this, "editorOther", new _interactionGroup.InteractionGroup());
42
+ (0, _defineProperty2.default)(this, "slowest", (0, _isExperimentEnabled.isExperimentEnabled)('platform_editor_editor_interactivity_slowest') ? new _slowInteractionList.SlowInteractionList() : undefined);
40
43
  /** Increments per snapshot; a query takes the highest one per session. */
41
44
  (0, _defineProperty2.default)(this, "seq", 0);
42
45
  (0, _defineProperty2.default)(this, "hiddenMs", 0);
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.SlowInteractionList = 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
+ /** Fixed by the schema, so the event stays a bounded size. */
12
+ var MAX_RECORDS = 5;
13
+
14
+ /**
15
+ * The latency an interaction has to beat to be recorded. 200 ms is the Google INP "good" threshold,
16
+ * so anything below it is an interaction the user was not waiting for.
17
+ */
18
+ var MIN_LATENCY_MS = 200;
19
+
20
+ /**
21
+ * The attributes a target may be named by, all of them ours: `data-vc` for visual completion, the
22
+ * test ids for tests. Ids, roles, class names, text and accessibility labels are left out because
23
+ * they can carry what the user wrote.
24
+ */
25
+ var ALLOWED_TARGET_ATTRIBUTES = ['data-vc', 'data-testid', 'data-test-id'];
26
+ var MAX_TARGET_ELEMENTS = 4;
27
+ var MAX_ATTRIBUTE_VALUE_LENGTH = 32;
28
+ var MAX_TARGET_LENGTH = 120;
29
+
30
+ /** The id identifies an interaction across the entries measuring it. It is not reported. */
31
+
32
+ /** An interaction as the collector's two observers saw it. */
33
+ /**
34
+ * The slowest interactions of one session, which is what the event's `slowest` records are.
35
+ *
36
+ * A record is built from the entry that measured the interaction, as that entry arrives:
37
+ * `entry.target` is `null` once the element has left the document.
38
+ */
39
+ var SlowInteractionList = exports.SlowInteractionList = /*#__PURE__*/function () {
40
+ function SlowInteractionList() {
41
+ (0, _classCallCheck2.default)(this, SlowInteractionList);
42
+ /** Slowest first. */
43
+ (0, _defineProperty2.default)(this, "records", []);
44
+ }
45
+ return (0, _createClass2.default)(SlowInteractionList, [{
46
+ key: "track",
47
+ value: function track(interaction) {
48
+ var interactionId = interaction.interactionId,
49
+ latencyMs = interaction.latencyMs;
50
+ var previousIndex = this.records.findIndex(function (existing) {
51
+ return existing.interactionId === interactionId;
52
+ });
53
+ var toBeatMs = MIN_LATENCY_MS;
54
+ if (previousIndex !== -1) {
55
+ // An interaction's latency grows as later entries arrive, and it replaces itself rather
56
+ // than taking a second place.
57
+ toBeatMs = this.records[previousIndex].durationMs;
58
+ } else if (this.records.length === MAX_RECORDS) {
59
+ toBeatMs = this.records[MAX_RECORDS - 1].durationMs;
60
+ }
61
+ if (latencyMs <= toBeatMs) {
62
+ return;
63
+ }
64
+
65
+ // Read only once the interaction has earned a place: naming its target walks the DOM, and
66
+ // this runs while the page is already slow.
67
+ var record = this.toRecord(interaction);
68
+ if (previousIndex === -1) {
69
+ this.records.push(record);
70
+ } else {
71
+ this.records[previousIndex] = record;
72
+ }
73
+ this.records.sort(function (a, b) {
74
+ return b.durationMs - a.durationMs;
75
+ });
76
+ this.records.splice(MAX_RECORDS);
77
+ }
78
+
79
+ /** @returns the records, slowest first, or nothing when no interaction was slow enough. */
80
+ }, {
81
+ key: "snapshot",
82
+ value: function snapshot() {
83
+ if (this.records.length === 0) {
84
+ return undefined;
85
+ }
86
+ return this.records.map(function (record) {
87
+ return {
88
+ group: record.group,
89
+ name: record.name,
90
+ durationMs: record.durationMs,
91
+ inputDelayMs: record.inputDelayMs,
92
+ processingMs: record.processingMs,
93
+ presentationDelayMs: record.presentationDelayMs,
94
+ target: record.target
95
+ };
96
+ });
97
+ }
98
+
99
+ /**
100
+ * The phases are reported together or not at all: a browser that reports one reports all three,
101
+ * and zeroes standing in for values we never had would read as an interaction that spent no time
102
+ * anywhere. Each is clamped, because they come from timestamps the browser coarsens
103
+ * independently, so a phase can come out just below zero.
104
+ */
105
+ }, {
106
+ key: "toRecord",
107
+ value: function toRecord(_ref) {
108
+ var entry = _ref.entry,
109
+ group = _ref.group,
110
+ interactionId = _ref.interactionId,
111
+ latencyMs = _ref.latencyMs;
112
+ var processingStart = entry.processingStart,
113
+ processingEnd = entry.processingEnd,
114
+ startTime = entry.startTime;
115
+ var measured = typeof processingStart === 'number' && typeof processingEnd === 'number';
116
+ var phaseMs = function phaseMs(durationMs) {
117
+ return Math.max(0, Math.round(durationMs));
118
+ };
119
+ return {
120
+ interactionId: interactionId,
121
+ group: group,
122
+ name: entry.name,
123
+ durationMs: Math.round(latencyMs),
124
+ inputDelayMs: measured ? phaseMs(processingStart - startTime) : undefined,
125
+ processingMs: measured ? phaseMs(processingEnd - processingStart) : undefined,
126
+ presentationDelayMs: measured ? phaseMs(startTime + latencyMs - processingEnd) : undefined,
127
+ target: this.describeTarget(entry.target)
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Names the element an interaction happened on — `div[data-vc="x"] > p > span`, outermost first.
133
+ * The path climbs until an element carries an allow-listed attribute, because that is what says
134
+ * which part of the page this was.
135
+ */
136
+ }, {
137
+ key: "describeTarget",
138
+ value: function describeTarget(node) {
139
+ var _node$parentElement;
140
+ // An event's target can be a text node, and the element around it is the answer for it.
141
+ var element = node instanceof Element ? node : (_node$parentElement = node === null || node === void 0 ? void 0 : node.parentElement) !== null && _node$parentElement !== void 0 ? _node$parentElement : null;
142
+ var path = [];
143
+ for (var climbed = 0; element && climbed < MAX_TARGET_ELEMENTS; climbed += 1) {
144
+ var attribute = this.identifyingAttribute(element);
145
+ path.unshift("".concat(element.localName).concat(attribute !== null && attribute !== void 0 ? attribute : ''));
146
+ if (attribute) {
147
+ break;
148
+ }
149
+ element = element.parentElement;
150
+ }
151
+ if (path.length === 0) {
152
+ return undefined;
153
+ }
154
+ return path.join(' > ').slice(0, MAX_TARGET_LENGTH);
155
+ }
156
+ }, {
157
+ key: "identifyingAttribute",
158
+ value: function identifyingAttribute(element) {
159
+ for (var _i = 0, _ALLOWED_TARGET_ATTRI = ALLOWED_TARGET_ATTRIBUTES; _i < _ALLOWED_TARGET_ATTRI.length; _i++) {
160
+ var attribute = _ALLOWED_TARGET_ATTRI[_i];
161
+ var value = element.getAttribute(attribute);
162
+ if (value) {
163
+ // Encoded and cut: a value we did not write cannot bring quotes or a paragraph of
164
+ // text into the event.
165
+ var safeValue = encodeURIComponent(value).slice(0, MAX_ATTRIBUTE_VALUE_LENGTH);
166
+ return "[".concat(attribute, "=\"").concat(safeValue, "\"]");
167
+ }
168
+ }
169
+ return undefined;
170
+ }
171
+ }]);
172
+ }();
@@ -1,10 +1,3 @@
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
1
  /**
9
2
  * The events an interaction is made of, and the group they belong to. Both sides of the collector
10
3
  * read this, so counting and grouping cannot disagree.
@@ -2,9 +2,9 @@ import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
2
  import { interactionEventKind } from './interaction-events';
3
3
 
4
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.
5
+ * The Event Timing fields this package reads, declared optional so that a `PerformanceEntry` from
6
+ * `getEntries()` is assignable without a cast `interactionId` is missing from the DOM typings'
7
+ * `PerformanceEventTiming` altogether, and the rest are only on it.
8
8
  */
9
9
 
10
10
  /**
@@ -107,6 +107,7 @@ export class InteractionTracker {
107
107
  this.prune(this.interactions);
108
108
  return {
109
109
  type: 'new',
110
+ interactionId,
110
111
  latencyMs: entry.duration,
111
112
  group
112
113
  };
@@ -118,6 +119,7 @@ export class InteractionTracker {
118
119
  tracked.latencyMs = entry.duration;
119
120
  return {
120
121
  type: 'remeasured',
122
+ interactionId,
121
123
  fromMs,
122
124
  toMs: entry.duration,
123
125
  group: tracked.group
@@ -203,28 +203,43 @@ export class InteractivityCollector {
203
203
  recordEntries(entries) {
204
204
  const {
205
205
  tracker,
206
- page
206
+ page,
207
+ slowest
207
208
  } = this.session;
208
209
  for (const entry of entries) {
210
+ var _update$group;
209
211
  const update = tracker.merge(entry);
210
212
  if (!update) {
211
213
  continue;
212
214
  }
215
+ let latencyMs;
213
216
  if (update.type === 'new') {
214
- page.add(update.latencyMs);
217
+ latencyMs = update.latencyMs;
218
+ page.add(latencyMs);
215
219
  if (update.group) {
216
- this.session[update.group].add(update.latencyMs);
220
+ this.session[update.group].add(latencyMs);
217
221
  }
218
222
  } else {
219
- page.remeasure(update.fromMs, update.toMs);
223
+ latencyMs = update.toMs;
224
+ page.remeasure(update.fromMs, latencyMs);
220
225
  if (update.group) {
221
- this.session[update.group].remeasure(update.fromMs, update.toMs);
226
+ this.session[update.group].remeasure(update.fromMs, latencyMs);
222
227
  }
223
228
  }
229
+
230
+ // An interaction the editor never reported an event for is not the editor's as far as we
231
+ // know.
232
+ slowest === null || slowest === void 0 ? void 0 : slowest.track({
233
+ entry,
234
+ interactionId: update.interactionId,
235
+ latencyMs,
236
+ group: (_update$group = update.group) !== null && _update$group !== void 0 ? _update$group : 'outsideEditor'
237
+ });
224
238
  this.session.revision += 1;
225
239
  }
226
240
  }
227
241
  takeSnapshot(reason) {
242
+ var _session$slowest;
228
243
  if (this.stopped) {
229
244
  return;
230
245
  }
@@ -253,6 +268,7 @@ export class InteractivityCollector {
253
268
  const now = performance.now();
254
269
  const hiddenMs = session.hiddenMs + (session.hiddenSince === undefined ? 0 : now - session.hiddenSince);
255
270
  const pageTotalCount = InteractionObserver.readPageInteractionCount() - session.interactionCountAtStart;
271
+ const slowest = (_session$slowest = session.slowest) === null || _session$slowest === void 0 ? void 0 : _session$slowest.snapshot();
256
272
  this.emit({
257
273
  schema: SCHEMA_VERSION,
258
274
  interactivitySessionId: session.id,
@@ -268,7 +284,10 @@ export class InteractivityCollector {
268
284
  page: session.page.snapshot(pageTotalCount),
269
285
  editorTyping: session.editorTyping.snapshot(),
270
286
  editorPointer: session.editorPointer.snapshot(),
271
- editorOther: session.editorOther.snapshot()
287
+ editorOther: session.editorOther.snapshot(),
288
+ ...(slowest && {
289
+ slowest
290
+ })
272
291
  });
273
292
  }
274
293
  }
@@ -1,7 +1,9 @@
1
1
  import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
+ import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
2
3
  import { InteractionObserver } from './interaction-observer';
3
4
  import { InteractionTracker } from './interaction-tracker';
4
5
  import { InteractionGroup } from './interaction-group';
6
+ import { SlowInteractionList } from './slow-interaction-list';
5
7
  function createSessionId() {
6
8
  if (typeof crypto.randomUUID === 'function') {
7
9
  return crypto.randomUUID();
@@ -26,6 +28,7 @@ export class InteractivitySession {
26
28
  _defineProperty(this, "editorTyping", new InteractionGroup());
27
29
  _defineProperty(this, "editorPointer", new InteractionGroup());
28
30
  _defineProperty(this, "editorOther", new InteractionGroup());
31
+ _defineProperty(this, "slowest", isExperimentEnabled('platform_editor_editor_interactivity_slowest') ? new SlowInteractionList() : undefined);
29
32
  /** Increments per snapshot; a query takes the highest one per session. */
30
33
  _defineProperty(this, "seq", 0);
31
34
  _defineProperty(this, "hiddenMs", 0);
@@ -0,0 +1,148 @@
1
+ import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
+ /** Fixed by the schema, so the event stays a bounded size. */
3
+ const MAX_RECORDS = 5;
4
+
5
+ /**
6
+ * The latency an interaction has to beat to be recorded. 200 ms is the Google INP "good" threshold,
7
+ * so anything below it is an interaction the user was not waiting for.
8
+ */
9
+ const MIN_LATENCY_MS = 200;
10
+
11
+ /**
12
+ * The attributes a target may be named by, all of them ours: `data-vc` for visual completion, the
13
+ * test ids for tests. Ids, roles, class names, text and accessibility labels are left out because
14
+ * they can carry what the user wrote.
15
+ */
16
+ const ALLOWED_TARGET_ATTRIBUTES = ['data-vc', 'data-testid', 'data-test-id'];
17
+ const MAX_TARGET_ELEMENTS = 4;
18
+ const MAX_ATTRIBUTE_VALUE_LENGTH = 32;
19
+ const MAX_TARGET_LENGTH = 120;
20
+
21
+ /** The id identifies an interaction across the entries measuring it. It is not reported. */
22
+
23
+ /** An interaction as the collector's two observers saw it. */
24
+
25
+ /**
26
+ * The slowest interactions of one session, which is what the event's `slowest` records are.
27
+ *
28
+ * A record is built from the entry that measured the interaction, as that entry arrives:
29
+ * `entry.target` is `null` once the element has left the document.
30
+ */
31
+ export class SlowInteractionList {
32
+ constructor() {
33
+ /** Slowest first. */
34
+ _defineProperty(this, "records", []);
35
+ }
36
+ track(interaction) {
37
+ const {
38
+ interactionId,
39
+ latencyMs
40
+ } = interaction;
41
+ const previousIndex = this.records.findIndex(existing => existing.interactionId === interactionId);
42
+ let toBeatMs = MIN_LATENCY_MS;
43
+ if (previousIndex !== -1) {
44
+ // An interaction's latency grows as later entries arrive, and it replaces itself rather
45
+ // than taking a second place.
46
+ toBeatMs = this.records[previousIndex].durationMs;
47
+ } else if (this.records.length === MAX_RECORDS) {
48
+ toBeatMs = this.records[MAX_RECORDS - 1].durationMs;
49
+ }
50
+ if (latencyMs <= toBeatMs) {
51
+ return;
52
+ }
53
+
54
+ // Read only once the interaction has earned a place: naming its target walks the DOM, and
55
+ // this runs while the page is already slow.
56
+ const record = this.toRecord(interaction);
57
+ if (previousIndex === -1) {
58
+ this.records.push(record);
59
+ } else {
60
+ this.records[previousIndex] = record;
61
+ }
62
+ this.records.sort((a, b) => b.durationMs - a.durationMs);
63
+ this.records.splice(MAX_RECORDS);
64
+ }
65
+
66
+ /** @returns the records, slowest first, or nothing when no interaction was slow enough. */
67
+ snapshot() {
68
+ if (this.records.length === 0) {
69
+ return undefined;
70
+ }
71
+ return this.records.map(record => ({
72
+ group: record.group,
73
+ name: record.name,
74
+ durationMs: record.durationMs,
75
+ inputDelayMs: record.inputDelayMs,
76
+ processingMs: record.processingMs,
77
+ presentationDelayMs: record.presentationDelayMs,
78
+ target: record.target
79
+ }));
80
+ }
81
+
82
+ /**
83
+ * The phases are reported together or not at all: a browser that reports one reports all three,
84
+ * and zeroes standing in for values we never had would read as an interaction that spent no time
85
+ * anywhere. Each is clamped, because they come from timestamps the browser coarsens
86
+ * independently, so a phase can come out just below zero.
87
+ */
88
+ toRecord({
89
+ entry,
90
+ group,
91
+ interactionId,
92
+ latencyMs
93
+ }) {
94
+ const {
95
+ processingStart,
96
+ processingEnd,
97
+ startTime
98
+ } = entry;
99
+ const measured = typeof processingStart === 'number' && typeof processingEnd === 'number';
100
+ const phaseMs = durationMs => Math.max(0, Math.round(durationMs));
101
+ return {
102
+ interactionId,
103
+ group,
104
+ name: entry.name,
105
+ durationMs: Math.round(latencyMs),
106
+ inputDelayMs: measured ? phaseMs(processingStart - startTime) : undefined,
107
+ processingMs: measured ? phaseMs(processingEnd - processingStart) : undefined,
108
+ presentationDelayMs: measured ? phaseMs(startTime + latencyMs - processingEnd) : undefined,
109
+ target: this.describeTarget(entry.target)
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Names the element an interaction happened on — `div[data-vc="x"] > p > span`, outermost first.
115
+ * The path climbs until an element carries an allow-listed attribute, because that is what says
116
+ * which part of the page this was.
117
+ */
118
+ describeTarget(node) {
119
+ var _node$parentElement;
120
+ // An event's target can be a text node, and the element around it is the answer for it.
121
+ let element = node instanceof Element ? node : (_node$parentElement = node === null || node === void 0 ? void 0 : node.parentElement) !== null && _node$parentElement !== void 0 ? _node$parentElement : null;
122
+ const path = [];
123
+ for (let climbed = 0; element && climbed < MAX_TARGET_ELEMENTS; climbed += 1) {
124
+ const attribute = this.identifyingAttribute(element);
125
+ path.unshift(`${element.localName}${attribute !== null && attribute !== void 0 ? attribute : ''}`);
126
+ if (attribute) {
127
+ break;
128
+ }
129
+ element = element.parentElement;
130
+ }
131
+ if (path.length === 0) {
132
+ return undefined;
133
+ }
134
+ return path.join(' > ').slice(0, MAX_TARGET_LENGTH);
135
+ }
136
+ identifyingAttribute(element) {
137
+ for (const attribute of ALLOWED_TARGET_ATTRIBUTES) {
138
+ const value = element.getAttribute(attribute);
139
+ if (value) {
140
+ // Encoded and cut: a value we did not write cannot bring quotes or a paragraph of
141
+ // text into the event.
142
+ const safeValue = encodeURIComponent(value).slice(0, MAX_ATTRIBUTE_VALUE_LENGTH);
143
+ return `[${attribute}="${safeValue}"]`;
144
+ }
145
+ }
146
+ return undefined;
147
+ }
148
+ }
@@ -1,10 +1,3 @@
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
1
  /**
9
2
  * The events an interaction is made of, and the group they belong to. Both sides of the collector
10
3
  * read this, so counting and grouping cannot disagree.
@@ -7,9 +7,9 @@ function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length)
7
7
  import { interactionEventKind } from './interaction-events';
8
8
 
9
9
  /**
10
- * The Event Timing fields this package reads. `interactionId` is missing from the DOM
11
- * typings' `PerformanceEventTiming`, so it is declared here as optional, which also makes
12
- * a `PerformanceEntry` from `getEntries()` assignable without a cast.
10
+ * The Event Timing fields this package reads, declared optional so that a `PerformanceEntry` from
11
+ * `getEntries()` is assignable without a cast `interactionId` is missing from the DOM typings'
12
+ * `PerformanceEventTiming` altogether, and the rest are only on it.
13
13
  */
14
14
 
15
15
  /**
@@ -116,6 +116,7 @@ export var InteractionTracker = /*#__PURE__*/function () {
116
116
  this.prune(this.interactions);
117
117
  return {
118
118
  type: 'new',
119
+ interactionId: interactionId,
119
120
  latencyMs: entry.duration,
120
121
  group: group
121
122
  };
@@ -127,6 +128,7 @@ export var InteractionTracker = /*#__PURE__*/function () {
127
128
  tracked.latencyMs = entry.duration;
128
129
  return {
129
130
  type: 'remeasured',
131
+ interactionId: interactionId,
130
132
  fromMs: fromMs,
131
133
  toMs: entry.duration,
132
134
  group: tracked.group
@@ -1,6 +1,8 @@
1
1
  import _classCallCheck from "@babel/runtime/helpers/classCallCheck";
2
2
  import _createClass from "@babel/runtime/helpers/createClass";
3
3
  import _defineProperty from "@babel/runtime/helpers/defineProperty";
4
+ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
5
+ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
4
6
  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; } } }; }
5
7
  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; } }
6
8
  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; }
@@ -252,27 +254,41 @@ export var InteractivityCollector = /*#__PURE__*/function () {
252
254
  value: function recordEntries(entries) {
253
255
  var _this$session = this.session,
254
256
  tracker = _this$session.tracker,
255
- page = _this$session.page;
257
+ page = _this$session.page,
258
+ slowest = _this$session.slowest;
256
259
  var _iterator = _createForOfIteratorHelper(entries),
257
260
  _step;
258
261
  try {
259
262
  for (_iterator.s(); !(_step = _iterator.n()).done;) {
263
+ var _update$group;
260
264
  var entry = _step.value;
261
265
  var update = tracker.merge(entry);
262
266
  if (!update) {
263
267
  continue;
264
268
  }
269
+ var latencyMs = void 0;
265
270
  if (update.type === 'new') {
266
- page.add(update.latencyMs);
271
+ latencyMs = update.latencyMs;
272
+ page.add(latencyMs);
267
273
  if (update.group) {
268
- this.session[update.group].add(update.latencyMs);
274
+ this.session[update.group].add(latencyMs);
269
275
  }
270
276
  } else {
271
- page.remeasure(update.fromMs, update.toMs);
277
+ latencyMs = update.toMs;
278
+ page.remeasure(update.fromMs, latencyMs);
272
279
  if (update.group) {
273
- this.session[update.group].remeasure(update.fromMs, update.toMs);
280
+ this.session[update.group].remeasure(update.fromMs, latencyMs);
274
281
  }
275
282
  }
283
+
284
+ // An interaction the editor never reported an event for is not the editor's as far as we
285
+ // know.
286
+ slowest === null || slowest === void 0 || slowest.track({
287
+ entry: entry,
288
+ interactionId: update.interactionId,
289
+ latencyMs: latencyMs,
290
+ group: (_update$group = update.group) !== null && _update$group !== void 0 ? _update$group : 'outsideEditor'
291
+ });
276
292
  this.session.revision += 1;
277
293
  }
278
294
  } catch (err) {
@@ -284,6 +300,7 @@ export var InteractivityCollector = /*#__PURE__*/function () {
284
300
  }, {
285
301
  key: "takeSnapshot",
286
302
  value: function takeSnapshot(reason) {
303
+ var _session$slowest;
287
304
  if (this.stopped) {
288
305
  return;
289
306
  }
@@ -312,7 +329,8 @@ export var InteractivityCollector = /*#__PURE__*/function () {
312
329
  var now = performance.now();
313
330
  var hiddenMs = session.hiddenMs + (session.hiddenSince === undefined ? 0 : now - session.hiddenSince);
314
331
  var pageTotalCount = InteractionObserver.readPageInteractionCount() - session.interactionCountAtStart;
315
- this.emit({
332
+ var slowest = (_session$slowest = session.slowest) === null || _session$slowest === void 0 ? void 0 : _session$slowest.snapshot();
333
+ this.emit(_objectSpread({
316
334
  schema: SCHEMA_VERSION,
317
335
  interactivitySessionId: session.id,
318
336
  objectId: session.objectId,
@@ -328,7 +346,9 @@ export var InteractivityCollector = /*#__PURE__*/function () {
328
346
  editorTyping: session.editorTyping.snapshot(),
329
347
  editorPointer: session.editorPointer.snapshot(),
330
348
  editorOther: session.editorOther.snapshot()
331
- });
349
+ }, slowest && {
350
+ slowest: slowest
351
+ }));
332
352
  }
333
353
  }]);
334
354
  }();
@@ -1,9 +1,11 @@
1
1
  import _createClass from "@babel/runtime/helpers/createClass";
2
2
  import _classCallCheck from "@babel/runtime/helpers/classCallCheck";
3
3
  import _defineProperty from "@babel/runtime/helpers/defineProperty";
4
+ import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
4
5
  import { InteractionObserver } from './interaction-observer';
5
6
  import { InteractionTracker } from './interaction-tracker';
6
7
  import { InteractionGroup } from './interaction-group';
8
+ import { SlowInteractionList } from './slow-interaction-list';
7
9
  function createSessionId() {
8
10
  if (typeof crypto.randomUUID === 'function') {
9
11
  return crypto.randomUUID();
@@ -30,6 +32,7 @@ export var InteractivitySession = /*#__PURE__*/_createClass(function Interactivi
30
32
  _defineProperty(this, "editorTyping", new InteractionGroup());
31
33
  _defineProperty(this, "editorPointer", new InteractionGroup());
32
34
  _defineProperty(this, "editorOther", new InteractionGroup());
35
+ _defineProperty(this, "slowest", isExperimentEnabled('platform_editor_editor_interactivity_slowest') ? new SlowInteractionList() : undefined);
33
36
  /** Increments per snapshot; a query takes the highest one per session. */
34
37
  _defineProperty(this, "seq", 0);
35
38
  _defineProperty(this, "hiddenMs", 0);
@@ -0,0 +1,166 @@
1
+ import _classCallCheck from "@babel/runtime/helpers/classCallCheck";
2
+ import _createClass from "@babel/runtime/helpers/createClass";
3
+ import _defineProperty from "@babel/runtime/helpers/defineProperty";
4
+ /** Fixed by the schema, so the event stays a bounded size. */
5
+ var MAX_RECORDS = 5;
6
+
7
+ /**
8
+ * The latency an interaction has to beat to be recorded. 200 ms is the Google INP "good" threshold,
9
+ * so anything below it is an interaction the user was not waiting for.
10
+ */
11
+ var MIN_LATENCY_MS = 200;
12
+
13
+ /**
14
+ * The attributes a target may be named by, all of them ours: `data-vc` for visual completion, the
15
+ * test ids for tests. Ids, roles, class names, text and accessibility labels are left out because
16
+ * they can carry what the user wrote.
17
+ */
18
+ var ALLOWED_TARGET_ATTRIBUTES = ['data-vc', 'data-testid', 'data-test-id'];
19
+ var MAX_TARGET_ELEMENTS = 4;
20
+ var MAX_ATTRIBUTE_VALUE_LENGTH = 32;
21
+ var MAX_TARGET_LENGTH = 120;
22
+
23
+ /** The id identifies an interaction across the entries measuring it. It is not reported. */
24
+
25
+ /** An interaction as the collector's two observers saw it. */
26
+
27
+ /**
28
+ * The slowest interactions of one session, which is what the event's `slowest` records are.
29
+ *
30
+ * A record is built from the entry that measured the interaction, as that entry arrives:
31
+ * `entry.target` is `null` once the element has left the document.
32
+ */
33
+ export var SlowInteractionList = /*#__PURE__*/function () {
34
+ function SlowInteractionList() {
35
+ _classCallCheck(this, SlowInteractionList);
36
+ /** Slowest first. */
37
+ _defineProperty(this, "records", []);
38
+ }
39
+ return _createClass(SlowInteractionList, [{
40
+ key: "track",
41
+ value: function track(interaction) {
42
+ var interactionId = interaction.interactionId,
43
+ latencyMs = interaction.latencyMs;
44
+ var previousIndex = this.records.findIndex(function (existing) {
45
+ return existing.interactionId === interactionId;
46
+ });
47
+ var toBeatMs = MIN_LATENCY_MS;
48
+ if (previousIndex !== -1) {
49
+ // An interaction's latency grows as later entries arrive, and it replaces itself rather
50
+ // than taking a second place.
51
+ toBeatMs = this.records[previousIndex].durationMs;
52
+ } else if (this.records.length === MAX_RECORDS) {
53
+ toBeatMs = this.records[MAX_RECORDS - 1].durationMs;
54
+ }
55
+ if (latencyMs <= toBeatMs) {
56
+ return;
57
+ }
58
+
59
+ // Read only once the interaction has earned a place: naming its target walks the DOM, and
60
+ // this runs while the page is already slow.
61
+ var record = this.toRecord(interaction);
62
+ if (previousIndex === -1) {
63
+ this.records.push(record);
64
+ } else {
65
+ this.records[previousIndex] = record;
66
+ }
67
+ this.records.sort(function (a, b) {
68
+ return b.durationMs - a.durationMs;
69
+ });
70
+ this.records.splice(MAX_RECORDS);
71
+ }
72
+
73
+ /** @returns the records, slowest first, or nothing when no interaction was slow enough. */
74
+ }, {
75
+ key: "snapshot",
76
+ value: function snapshot() {
77
+ if (this.records.length === 0) {
78
+ return undefined;
79
+ }
80
+ return this.records.map(function (record) {
81
+ return {
82
+ group: record.group,
83
+ name: record.name,
84
+ durationMs: record.durationMs,
85
+ inputDelayMs: record.inputDelayMs,
86
+ processingMs: record.processingMs,
87
+ presentationDelayMs: record.presentationDelayMs,
88
+ target: record.target
89
+ };
90
+ });
91
+ }
92
+
93
+ /**
94
+ * The phases are reported together or not at all: a browser that reports one reports all three,
95
+ * and zeroes standing in for values we never had would read as an interaction that spent no time
96
+ * anywhere. Each is clamped, because they come from timestamps the browser coarsens
97
+ * independently, so a phase can come out just below zero.
98
+ */
99
+ }, {
100
+ key: "toRecord",
101
+ value: function toRecord(_ref) {
102
+ var entry = _ref.entry,
103
+ group = _ref.group,
104
+ interactionId = _ref.interactionId,
105
+ latencyMs = _ref.latencyMs;
106
+ var processingStart = entry.processingStart,
107
+ processingEnd = entry.processingEnd,
108
+ startTime = entry.startTime;
109
+ var measured = typeof processingStart === 'number' && typeof processingEnd === 'number';
110
+ var phaseMs = function phaseMs(durationMs) {
111
+ return Math.max(0, Math.round(durationMs));
112
+ };
113
+ return {
114
+ interactionId: interactionId,
115
+ group: group,
116
+ name: entry.name,
117
+ durationMs: Math.round(latencyMs),
118
+ inputDelayMs: measured ? phaseMs(processingStart - startTime) : undefined,
119
+ processingMs: measured ? phaseMs(processingEnd - processingStart) : undefined,
120
+ presentationDelayMs: measured ? phaseMs(startTime + latencyMs - processingEnd) : undefined,
121
+ target: this.describeTarget(entry.target)
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Names the element an interaction happened on — `div[data-vc="x"] > p > span`, outermost first.
127
+ * The path climbs until an element carries an allow-listed attribute, because that is what says
128
+ * which part of the page this was.
129
+ */
130
+ }, {
131
+ key: "describeTarget",
132
+ value: function describeTarget(node) {
133
+ var _node$parentElement;
134
+ // An event's target can be a text node, and the element around it is the answer for it.
135
+ var element = node instanceof Element ? node : (_node$parentElement = node === null || node === void 0 ? void 0 : node.parentElement) !== null && _node$parentElement !== void 0 ? _node$parentElement : null;
136
+ var path = [];
137
+ for (var climbed = 0; element && climbed < MAX_TARGET_ELEMENTS; climbed += 1) {
138
+ var attribute = this.identifyingAttribute(element);
139
+ path.unshift("".concat(element.localName).concat(attribute !== null && attribute !== void 0 ? attribute : ''));
140
+ if (attribute) {
141
+ break;
142
+ }
143
+ element = element.parentElement;
144
+ }
145
+ if (path.length === 0) {
146
+ return undefined;
147
+ }
148
+ return path.join(' > ').slice(0, MAX_TARGET_LENGTH);
149
+ }
150
+ }, {
151
+ key: "identifyingAttribute",
152
+ value: function identifyingAttribute(element) {
153
+ for (var _i = 0, _ALLOWED_TARGET_ATTRI = ALLOWED_TARGET_ATTRIBUTES; _i < _ALLOWED_TARGET_ATTRI.length; _i++) {
154
+ var attribute = _ALLOWED_TARGET_ATTRI[_i];
155
+ var value = element.getAttribute(attribute);
156
+ if (value) {
157
+ // Encoded and cut: a value we did not write cannot bring quotes or a paragraph of
158
+ // text into the event.
159
+ var safeValue = encodeURIComponent(value).slice(0, MAX_ATTRIBUTE_VALUE_LENGTH);
160
+ return "[".concat(attribute, "=\"").concat(safeValue, "\"]");
161
+ }
162
+ }
163
+ return undefined;
164
+ }
165
+ }]);
166
+ }();
@@ -20,6 +20,38 @@ export type SnapshotReason = 'timer' | 'hidden' | 'pagehide' | 'unmount' | 'navi
20
20
  * editable editor, `reading` is a live page being viewed with the editor still mounted.
21
21
  */
22
22
  export type SessionMode = 'editing' | 'reading';
23
+ /**
24
+ * The editor groups, which are also the fields their histograms are reported in.
25
+ *
26
+ * `editorOther` is the remainder — an interaction the browser counts that is neither typing nor
27
+ * pointing — so the three together cover whatever the browser calls an interaction.
28
+ */
29
+ export type EditorInteractionGroupName = 'editorOther' | 'editorPointer' | 'editorTyping';
30
+ /**
31
+ * The group a record is attributed to. Unlike the `page` histogram, which counts the editor's
32
+ * interactions as well, `outsideEditor` is only the interactions that are not the editor's.
33
+ */
34
+ export type SlowInteractionGroup = EditorInteractionGroupName | 'outsideEditor';
35
+ /**
36
+ * One of the slowest interactions of the session: which target was slow, and where the time went,
37
+ * neither of which the histograms can answer.
38
+ *
39
+ * The three phases divide `durationMs` and add up to it within rounding.
40
+ */
41
+ export type SlowInteraction = {
42
+ durationMs: number;
43
+ group: SlowInteractionGroup;
44
+ /** Time between the event arriving and its handlers starting to run. */
45
+ inputDelayMs?: number;
46
+ /** Type of the event the latency was measured on, which for a pointer press is usually `click`. */
47
+ name: string;
48
+ /** Time between the handlers finishing and the next frame being presented. */
49
+ presentationDelayMs?: number;
50
+ /** Time spent running the event's handlers. */
51
+ processingMs?: number;
52
+ /** A short DOM path. Absent once the element has been removed from the document. */
53
+ target?: string;
54
+ };
23
55
  /**
24
56
  * Session-to-date latency distribution for one group of interactions.
25
57
  *
@@ -63,4 +95,6 @@ export type InteractivitySnapshot = {
63
95
  seq: number;
64
96
  /** Fixed for the whole session: a mode change closes it and opens the next. */
65
97
  sessionMode?: SessionMode;
98
+ /** Slowest first. Absent when nothing was slow enough to record. */
99
+ slowest?: SlowInteraction[];
66
100
  };
@@ -1,10 +1,4 @@
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
- export type EditorInteractionGroupName = 'editorOther' | 'editorPointer' | 'editorTyping';
1
+ import type { EditorInteractionGroupName } from '../analytics/interactivity-snapshot';
8
2
  export type InteractionEventKind = {
9
3
  /**
10
4
  * Whether an interaction is counted when this event arrives. One event of each group has it,
@@ -1,11 +1,14 @@
1
- import type { EditorInteractionGroupName } from './interaction-events';
1
+ import type { EditorInteractionGroupName } from '../analytics/interactivity-snapshot';
2
2
  /**
3
- * The Event Timing fields this package reads. `interactionId` is missing from the DOM
4
- * typings' `PerformanceEventTiming`, so it is declared here as optional, which also makes
5
- * a `PerformanceEntry` from `getEntries()` assignable without a cast.
3
+ * The Event Timing fields this package reads, declared optional so that a `PerformanceEntry` from
4
+ * `getEntries()` is assignable without a cast `interactionId` is missing from the DOM typings'
5
+ * `PerformanceEventTiming` altogether, and the rest are only on it.
6
6
  */
7
7
  export type InteractionEntry = PerformanceEntry & {
8
8
  interactionId?: number;
9
+ processingEnd?: number;
10
+ processingStart?: number;
11
+ target?: Node | null;
9
12
  };
10
13
  /**
11
14
  * What an entry did to the interaction it belongs to: either it is the first entry of a new
@@ -14,11 +17,13 @@ export type InteractionEntry = PerformanceEntry & {
14
17
  */
15
18
  export type InteractionUpdate = {
16
19
  group: EditorInteractionGroupName | undefined;
20
+ interactionId: number;
17
21
  latencyMs: number;
18
22
  type: 'new';
19
23
  } | {
20
24
  fromMs: number;
21
25
  group: EditorInteractionGroupName | undefined;
26
+ interactionId: number;
22
27
  toMs: number;
23
28
  type: 'remeasured';
24
29
  };
@@ -1,6 +1,7 @@
1
1
  import type { SessionMode } from '../analytics/interactivity-snapshot';
2
2
  import { InteractionTracker } from './interaction-tracker';
3
3
  import { InteractionGroup } from './interaction-group';
4
+ import { SlowInteractionList } from './slow-interaction-list';
4
5
  export type InteractivitySessionStart = {
5
6
  hidden: boolean;
6
7
  mode: SessionMode | undefined;
@@ -23,6 +24,7 @@ export declare class InteractivitySession {
23
24
  readonly editorTyping: InteractionGroup;
24
25
  readonly editorPointer: InteractionGroup;
25
26
  readonly editorOther: InteractionGroup;
27
+ readonly slowest: SlowInteractionList | undefined;
26
28
  /** Increments per snapshot; a query takes the highest one per session. */
27
29
  seq: number;
28
30
  /** Both fixed for the session: a change to either closes it and opens the next. */
@@ -0,0 +1,38 @@
1
+ import type { SlowInteraction, SlowInteractionGroup } from '../analytics/interactivity-snapshot';
2
+ import type { InteractionEntry } from './interaction-tracker';
3
+ /** An interaction as the collector's two observers saw it. */
4
+ type ObservedInteraction = {
5
+ entry: InteractionEntry;
6
+ group: SlowInteractionGroup;
7
+ interactionId: number;
8
+ /** The duration of the entry measuring the interaction at its slowest. */
9
+ latencyMs: number;
10
+ };
11
+ /**
12
+ * The slowest interactions of one session, which is what the event's `slowest` records are.
13
+ *
14
+ * A record is built from the entry that measured the interaction, as that entry arrives:
15
+ * `entry.target` is `null` once the element has left the document.
16
+ */
17
+ export declare class SlowInteractionList {
18
+ /** Slowest first. */
19
+ private readonly records;
20
+ track(interaction: ObservedInteraction): void;
21
+ /** @returns the records, slowest first, or nothing when no interaction was slow enough. */
22
+ snapshot(): SlowInteraction[] | undefined;
23
+ /**
24
+ * The phases are reported together or not at all: a browser that reports one reports all three,
25
+ * and zeroes standing in for values we never had would read as an interaction that spent no time
26
+ * anywhere. Each is clamped, because they come from timestamps the browser coarsens
27
+ * independently, so a phase can come out just below zero.
28
+ */
29
+ private toRecord;
30
+ /**
31
+ * Names the element an interaction happened on — `div[data-vc="x"] > p > span`, outermost first.
32
+ * The path climbs until an element carries an allow-listed attribute, because that is what says
33
+ * which part of the page this was.
34
+ */
35
+ private describeTarget;
36
+ private identifyingAttribute;
37
+ }
38
+ export {};
package/docs/0-intro.tsx CHANGED
@@ -25,7 +25,8 @@ ${createEditorUseOnlyNotice('Editor Plugin Interactivity', [
25
25
 
26
26
  It reports the \`editor interactivity\` operational event: session-to-date interaction latency
27
27
  histograms for full page editor sessions — for the page as a whole, and for the editor's typing,
28
- pointer and other interactions — per [RFC 095](https://hello.atlassian.net/wiki/spaces/EDITOR/pages/7527607488/Editor+RFC+095+Confluence+editor+responsiveness+bucketed+INP+telemetry).
28
+ pointer and other interactions — along with the slowest interactions of the session, per
29
+ [RFC 095](https://hello.atlassian.net/wiki/spaces/EDITOR/pages/7527607488/Editor+RFC+095+Confluence+editor+responsiveness+bucketed+INP+telemetry).
29
30
  See the package README for the event shape, the snapshot cadence and what ends a session.
30
31
 
31
32
  The plugin has no configuration, state, actions or commands — adding it to a preset is what turns
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-interactivity",
3
- "version": "0.3.0",
3
+ "version": "1.1.0",
4
4
  "description": "Interactivity plugin for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -20,14 +20,15 @@
20
20
  "sideEffects": false,
21
21
  "dependencies": {
22
22
  "@atlaskit/browser-apis": "^1.2.0",
23
- "@atlaskit/editor-plugin-analytics": "^15.0.0",
24
- "@atlaskit/editor-plugin-context-identifier": "^15.0.0",
25
- "@atlaskit/editor-plugin-editor-viewmode": "^17.0.0",
23
+ "@atlaskit/editor-plugin-analytics": "^16.0.0",
24
+ "@atlaskit/editor-plugin-context-identifier": "^16.0.0",
25
+ "@atlaskit/editor-plugin-editor-viewmode": "^18.0.0",
26
+ "@atlaskit/platform-feature-experiments": "^0.3.0",
26
27
  "@babel/runtime": "^7.0.0",
27
28
  "bind-event-listener": "^3.0.0"
28
29
  },
29
30
  "peerDependencies": {
30
- "@atlaskit/editor-common": "^119.10.0",
31
+ "@atlaskit/editor-common": "^120.1.0",
31
32
  "react": "^18.2.0 || ^19.2.0"
32
33
  },
33
34
  "devDependencies": {