@atlaskit/editor-synced-block-provider 8.1.8 → 8.1.10

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.
@@ -12,6 +12,25 @@ import { getSourceProductFromResourceIdSafe } from '../utils/utils';
12
12
  */
13
13
 
14
14
  export class SyncBlockSubscriptionManager {
15
+ // EDITOR-7861: higher ceiling lets transient WS-gateway drops self-heal
16
+ // before a terminal failure is surfaced.
17
+ getMaxRetryAttempts() {
18
+ return fg('platform_editor_blocks_patch_3') ? SyncBlockSubscriptionManager.MAX_RETRY_ATTEMPTS_HARDENED : SyncBlockSubscriptionManager.MAX_RETRY_ATTEMPTS;
19
+ }
20
+
21
+ // Backoff delay for the given attempt.
22
+ // Gate OFF: pure exponential (1s, 2s, 4s, 8s, 16s).
23
+ // Gate ON (EDITOR-7861): exponential capped at MAX_RETRY_DELAY_MS with equal
24
+ // jitter (capped/2 + random*capped/2) — de-synchronises simultaneous
25
+ // reconnects while guaranteeing a non-zero delay (full jitter could hit 0).
26
+ getReconnectionDelay(attempts) {
27
+ const exponential = SyncBlockSubscriptionManager.INITIAL_RETRY_DELAY_MS * Math.pow(SyncBlockSubscriptionManager.RETRY_BACKOFF_MULTIPLIER, attempts);
28
+ if (!fg('platform_editor_blocks_patch_3')) {
29
+ return exponential;
30
+ }
31
+ const half = Math.min(exponential, SyncBlockSubscriptionManager.MAX_RETRY_DELAY_MS) / 2;
32
+ return Math.round(half + Math.random() * half);
33
+ }
15
34
  constructor(deps) {
16
35
  _defineProperty(this, "subscriptions", new Map());
17
36
  _defineProperty(this, "titleSubscriptions", new Map());
@@ -23,6 +42,7 @@ export class SyncBlockSubscriptionManager {
23
42
  // causing the cache to be deleted prematurely. We delay deletion to allow
24
43
  // the new component to subscribe and cancel the pending deletion.
25
44
  _defineProperty(this, "pendingCacheDeletions", new Map());
45
+ // backoff cap (gate ON)
26
46
  _defineProperty(this, "retryAttempts", new Map());
27
47
  _defineProperty(this, "pendingRetries", new Map());
28
48
  this.deps = deps;
@@ -237,11 +257,17 @@ export class SyncBlockSubscriptionManager {
237
257
  const unsubscribe = dataProvider.subscribeToBlockUpdates(resourceId, syncBlockInstance => {
238
258
  this.handleGraphQLUpdate(syncBlockInstance);
239
259
  }, error => {
240
- var _this$deps$getFireAna2;
241
260
  logException(error, {
242
261
  location: 'editor-synced-block-provider/syncBlockSubscriptionManager/graphql-subscription'
243
262
  });
244
- (_this$deps$getFireAna2 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna2 === void 0 ? void 0 : _this$deps$getFireAna2(fetchErrorPayload(error.message, resourceId, getSourceProductFromResourceIdSafe(resourceId)));
263
+ // EDITOR-7861: a single socket drop is usually transient and
264
+ // recovers on reconnect, so under the gate we don't fire a
265
+ // user-facing error here — it's only surfaced on exhaustion (see
266
+ // scheduleReconnection). Gate OFF keeps the legacy fire-on-drop.
267
+ if (!fg('platform_editor_blocks_patch_3')) {
268
+ var _this$deps$getFireAna2;
269
+ (_this$deps$getFireAna2 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna2 === void 0 ? void 0 : _this$deps$getFireAna2(fetchErrorPayload(error.message, resourceId, getSourceProductFromResourceIdSafe(resourceId)));
270
+ }
245
271
  this.handleSubscriptionTerminated(resourceId);
246
272
  }, () => {
247
273
  this.handleSubscriptionTerminated(resourceId);
@@ -270,16 +296,16 @@ export class SyncBlockSubscriptionManager {
270
296
  }
271
297
  }
272
298
 
273
- /**
274
- * Schedules a reconnection attempt with exponential backoff.
275
- * Delay = INITIAL_RETRY_DELAY_MS * (RETRY_BACKOFF_MULTIPLIER ^ attempts)
276
- * e.g. 1s, 2s, 4s, 8s, 16s
277
- */
299
+ // Schedules a reconnection with backoff (see getMaxRetryAttempts /
300
+ // getReconnectionDelay for the gated behaviour).
278
301
  scheduleReconnection(resourceId) {
279
302
  var _this$retryAttempts$g;
280
303
  const attempts = (_this$retryAttempts$g = this.retryAttempts.get(resourceId)) !== null && _this$retryAttempts$g !== void 0 ? _this$retryAttempts$g : 0;
281
- if (attempts >= SyncBlockSubscriptionManager.MAX_RETRY_ATTEMPTS) {
304
+ const maxAttempts = this.getMaxRetryAttempts();
305
+ if (attempts >= maxAttempts) {
282
306
  var _this$deps$getFireAna3;
307
+ // Exhausted all attempts — the only place a WS drop surfaces as a
308
+ // fetch error under the gate (EDITOR-7861).
283
309
  const errorMessage = `Subscription reconnection failed after ${attempts} attempts`;
284
310
  logException(new Error(errorMessage), {
285
311
  location: 'editor-synced-block-provider/syncBlockSubscriptionManager/max-retries-exhausted'
@@ -287,7 +313,7 @@ export class SyncBlockSubscriptionManager {
287
313
  (_this$deps$getFireAna3 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna3 === void 0 ? void 0 : _this$deps$getFireAna3(fetchErrorPayload(errorMessage, resourceId, getSourceProductFromResourceIdSafe(resourceId)));
288
314
  return;
289
315
  }
290
- const delay = SyncBlockSubscriptionManager.INITIAL_RETRY_DELAY_MS * Math.pow(SyncBlockSubscriptionManager.RETRY_BACKOFF_MULTIPLIER, attempts);
316
+ const delay = this.getReconnectionDelay(attempts);
291
317
  const timer = setTimeout(() => {
292
318
  this.pendingRetries.delete(resourceId);
293
319
 
@@ -389,7 +415,11 @@ export class SyncBlockSubscriptionManager {
389
415
  }
390
416
  }
391
417
  }
392
- // Reconnection with exponential backoff
418
+ // Reconnection with exponential backoff.
393
419
  _defineProperty(SyncBlockSubscriptionManager, "INITIAL_RETRY_DELAY_MS", 1000);
394
420
  _defineProperty(SyncBlockSubscriptionManager, "RETRY_BACKOFF_MULTIPLIER", 2);
395
- _defineProperty(SyncBlockSubscriptionManager, "MAX_RETRY_ATTEMPTS", 5);
421
+ _defineProperty(SyncBlockSubscriptionManager, "MAX_RETRY_ATTEMPTS", 5);
422
+ // legacy (gate OFF)
423
+ _defineProperty(SyncBlockSubscriptionManager, "MAX_RETRY_ATTEMPTS_HARDENED", 8);
424
+ // gate ON (EDITOR-7861)
425
+ _defineProperty(SyncBlockSubscriptionManager, "MAX_RETRY_DELAY_MS", 30000);
@@ -1,4 +1,5 @@
1
1
  import { ACTION, ACTION_SUBJECT, EVENT_TYPE, ACTION_SUBJECT_ID } from '@atlaskit/editor-common/analytics';
2
+ import { SyncBlockError } from '../common/types';
2
3
  export const stringifyError = error => {
3
4
  try {
4
5
  return JSON.stringify(error);
@@ -7,13 +8,63 @@ export const stringifyError = error => {
7
8
  }
8
9
  };
9
10
 
11
+ /**
12
+ * The set of categorical failure reasons emitted on synced-block operational error
13
+ * events (EDITOR-7796). These let the analytics dashboard break delete/update/create
14
+ * failures down by cause (Block Service / HG / Relay) instead of relying on the
15
+ * free-text `error` blob. Mirrors the {@link SyncBlockError} enum values, plus an
16
+ * `unknown` fallback for unclassifiable errors.
17
+ */
18
+
19
+ const SYNC_BLOCK_ERROR_REASONS = new Set(Object.values(SyncBlockError));
20
+
21
+ /**
22
+ * Maps a result `error` field (which may be a {@link SyncBlockError} enum value such as
23
+ * `'not_found'`, or an arbitrary stringified blob) to a stable categorical
24
+ * {@link SyncBlockErrorReason} for analytics grouping. Anything that is not a known
25
+ * `SyncBlockError` value collapses to `'unknown'` so the dashboard never has to group on
26
+ * free-text error blobs.
27
+ */
28
+ export const classifyErrorReason = error => {
29
+ if (error && SYNC_BLOCK_ERROR_REASONS.has(error)) {
30
+ return error;
31
+ }
32
+ return 'unknown';
33
+ };
34
+
35
+ /**
36
+ * Extra, optional analytics attributes describing WHY an operational synced-block
37
+ * action failed. Spread conditionally so we never emit `undefined` keys (EDITOR-7796).
38
+ */
39
+
40
+ /**
41
+ * Builds the {@link ErrorAttributionAttributes} for a failed synced-block operation from
42
+ * the raw result `error` field and optional backend `statusCode`. Returns `undefined`
43
+ * when the `platform_editor_blocks_patch_3` gate is OFF, so the new `reason`/`statusCode`
44
+ * attributes are only emitted once the gate is rolled out (EDITOR-7796).
45
+ *
46
+ * `gateEnabled` is injected by the caller (the store managers evaluate `fg(...)`) so this
47
+ * helper stays pure and trivially unit-testable for both gate states.
48
+ */
49
+ export const buildErrorAttribution = (gateEnabled, error, statusCode) => {
50
+ if (!gateEnabled) {
51
+ return undefined;
52
+ }
53
+ return {
54
+ reason: classifyErrorReason(error),
55
+ ...(statusCode !== undefined && {
56
+ statusCode
57
+ })
58
+ };
59
+ };
60
+
10
61
  // `sourceProduct` is threaded through every helper so analytics
11
62
  // events can be attributed to the source product (`confluence-page` vs `jira-work-item`).
12
63
  // All helpers accept it as an optional trailing argument; the spread-only-when-truthy
13
64
  // pattern below ensures we never emit `sourceProduct: undefined` (which would dirty the
14
65
  // event schema with empty keys).
15
66
 
16
- export const getErrorPayload = (actionSubjectId, error, resourceId, sourceProduct) => ({
67
+ export const getErrorPayload = (actionSubjectId, error, resourceId, sourceProduct, attribution) => ({
17
68
  action: ACTION.ERROR,
18
69
  actionSubject: ACTION_SUBJECT.SYNCED_BLOCK,
19
70
  actionSubjectId,
@@ -25,15 +76,21 @@ export const getErrorPayload = (actionSubjectId, error, resourceId, sourceProduc
25
76
  }),
26
77
  ...(sourceProduct && {
27
78
  sourceProduct
79
+ }),
80
+ ...((attribution === null || attribution === void 0 ? void 0 : attribution.reason) && {
81
+ reason: attribution.reason
82
+ }),
83
+ ...((attribution === null || attribution === void 0 ? void 0 : attribution.statusCode) !== undefined && {
84
+ statusCode: attribution.statusCode
28
85
  })
29
86
  }
30
87
  });
31
88
  export const fetchErrorPayload = (error, resourceId, sourceProduct) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_FETCH, error, resourceId, sourceProduct);
32
89
  export const getSourceInfoErrorPayload = (error, resourceId, sourceProduct) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_GET_SOURCE_INFO, error, resourceId, sourceProduct);
33
- export const updateErrorPayload = (error, resourceId, sourceProduct) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_UPDATE, error, resourceId, sourceProduct);
34
- export const updateReferenceErrorPayload = (error, resourceId, sourceProduct) => getErrorPayload(ACTION_SUBJECT_ID.REFERENCE_SYNCED_BLOCK_UPDATE, error, resourceId, sourceProduct);
35
- export const createErrorPayload = (error, resourceId, sourceProduct) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_CREATE, error, resourceId, sourceProduct);
36
- export const deleteErrorPayload = (error, resourceId, sourceProduct) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_DELETE, error, resourceId, sourceProduct);
90
+ export const updateErrorPayload = (error, resourceId, sourceProduct, attribution) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_UPDATE, error, resourceId, sourceProduct, attribution);
91
+ export const updateReferenceErrorPayload = (error, resourceId, sourceProduct, attribution) => getErrorPayload(ACTION_SUBJECT_ID.REFERENCE_SYNCED_BLOCK_UPDATE, error, resourceId, sourceProduct, attribution);
92
+ export const createErrorPayload = (error, resourceId, sourceProduct, attribution) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_CREATE, error, resourceId, sourceProduct, attribution);
93
+ export const deleteErrorPayload = (error, resourceId, sourceProduct, attribution) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_DELETE, error, resourceId, sourceProduct, attribution);
37
94
  export const updateCacheErrorPayload = (error, resourceId, sourceProduct) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_UPDATE_CACHE, error, resourceId, sourceProduct);
38
95
  /**
39
96
  * Payload for `SYNCED_BLOCK_SOURCE_INFO_ORPHANED`. Fired when source-info
@@ -567,7 +567,8 @@ export var writeDataBatch = /*#__PURE__*/function () {
567
567
  return _context4.abrupt("return", data.map(function (block) {
568
568
  return {
569
569
  error: mapBlockError(_t6),
570
- resourceId: block.resourceId
570
+ resourceId: block.resourceId,
571
+ statusCode: _t6.status
571
572
  };
572
573
  }));
573
574
  case 11:
@@ -913,7 +914,8 @@ var BlockServiceADFWriteProvider = /*#__PURE__*/function () {
913
914
  }
914
915
  return _context8.abrupt("return", {
915
916
  error: mapBlockError(_t0),
916
- resourceId: resourceId
917
+ resourceId: resourceId,
918
+ statusCode: _t0.status
917
919
  });
918
920
  case 8:
919
921
  return _context8.abrupt("return", {
@@ -993,7 +995,8 @@ var BlockServiceADFWriteProvider = /*#__PURE__*/function () {
993
995
  }
994
996
  return _context9.abrupt("return", {
995
997
  error: mapBlockError(_t10),
996
- resourceId: resourceId
998
+ resourceId: resourceId,
999
+ statusCode: _t10.status
997
1000
  });
998
1001
  case 8:
999
1002
  return _context9.abrupt("return", {
@@ -1077,7 +1080,8 @@ var BlockServiceADFWriteProvider = /*#__PURE__*/function () {
1077
1080
  return _context0.abrupt("return", {
1078
1081
  resourceId: resourceId,
1079
1082
  success: false,
1080
- error: mapBlockError(_t11)
1083
+ error: mapBlockError(_t11),
1084
+ statusCode: _t11.status
1081
1085
  });
1082
1086
  case 7:
1083
1087
  return _context0.abrupt("return", {
@@ -1152,7 +1156,8 @@ var BlockServiceADFWriteProvider = /*#__PURE__*/function () {
1152
1156
  return _context1.abrupt("return", {
1153
1157
  resourceId: resourceId,
1154
1158
  success: false,
1155
- error: mapBlockError(_t13)
1159
+ error: mapBlockError(_t13),
1160
+ statusCode: _t13.status
1156
1161
  });
1157
1162
  case 9:
1158
1163
  return _context1.abrupt("return", {
@@ -1182,7 +1187,8 @@ var BlockServiceADFWriteProvider = /*#__PURE__*/function () {
1182
1187
  return _context1.abrupt("return", {
1183
1188
  resourceId: resourceId,
1184
1189
  success: false,
1185
- error: mapBlockError(_t14)
1190
+ error: mapBlockError(_t14),
1191
+ statusCode: _t14.status
1186
1192
  });
1187
1193
  case 13:
1188
1194
  return _context1.abrupt("return", {
@@ -1267,7 +1273,8 @@ var BlockServiceADFWriteProvider = /*#__PURE__*/function () {
1267
1273
  }
1268
1274
  return _context10.abrupt("return", {
1269
1275
  success: false,
1270
- error: mapBlockError(_t15)
1276
+ error: mapBlockError(_t15),
1277
+ statusCode: _t15.status
1271
1278
  });
1272
1279
  case 4:
1273
1280
  return _context10.abrupt("return", {
@@ -1434,7 +1441,8 @@ var BlockServiceADFWriteProvider = /*#__PURE__*/function () {
1434
1441
  return _context12.abrupt("return", data.map(function (block) {
1435
1442
  return {
1436
1443
  error: mapBlockError(_t18),
1437
- resourceId: block.resourceId
1444
+ resourceId: block.resourceId,
1445
+ statusCode: _t18.status
1438
1446
  };
1439
1447
  }));
1440
1448
  case 15:
@@ -13,7 +13,7 @@ import isEqual from 'lodash/isEqual';
13
13
  import { logException } from '@atlaskit/editor-common/monitoring';
14
14
  import { fg } from '@atlaskit/platform-feature-flags';
15
15
  import { SyncBlockError } from '../common/types';
16
- import { cacheDeletionForcedPayload, fetchErrorPayload, fetchSuccessPayload, getSourceInfoErrorPayload, sourceInfoOrphanedPayload, updateReferenceErrorPayload } from '../utils/errorHandling';
16
+ import { buildErrorAttribution, cacheDeletionForcedPayload, fetchErrorPayload, fetchSuccessPayload, getSourceInfoErrorPayload, sourceInfoOrphanedPayload, updateReferenceErrorPayload } from '../utils/errorHandling';
17
17
  import { getFetchExperience, getFetchSourceInfoExperience, getSaveReferenceExperience } from '../utils/experienceTracking';
18
18
  import { resolveSyncBlockInstance } from '../utils/resolveSyncBlockInstance';
19
19
  import { createSyncBlockNode, getSourceProductFromResourceIdSafe, normalizeSyncBlockJSONContent } from '../utils/utils';
@@ -1077,7 +1077,7 @@ export var ReferenceSyncBlockStoreManager = /*#__PURE__*/function () {
1077
1077
  (_this$saveExperience2 = this.saveExperience) === null || _this$saveExperience2 === void 0 || _this$saveExperience2.failure({
1078
1078
  reason: updateResult.error || 'Failed to update reference synced blocks on the document'
1079
1079
  });
1080
- (_this$fireAnalyticsEv8 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv8 === void 0 || _this$fireAnalyticsEv8.call(this, updateReferenceErrorPayload(updateResult.error || 'Failed to update reference synced blocks on the document'));
1080
+ (_this$fireAnalyticsEv8 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv8 === void 0 || _this$fireAnalyticsEv8.call(this, updateReferenceErrorPayload(updateResult.error || 'Failed to update reference synced blocks on the document', undefined, undefined, buildErrorAttribution(fg('platform_editor_blocks_patch_3'), updateResult.error, updateResult.statusCode)));
1081
1081
  }
1082
1082
  _context4.next = 17;
1083
1083
  break;
@@ -1092,7 +1092,9 @@ export var ReferenceSyncBlockStoreManager = /*#__PURE__*/function () {
1092
1092
  reason: _t3.message
1093
1093
  });
1094
1094
  // No `resourceId` available in this catch — sourceProduct is intentionally omitted.
1095
- (_this$fireAnalyticsEv9 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv9 === void 0 || _this$fireAnalyticsEv9.call(this, updateReferenceErrorPayload(_t3.message));
1095
+ // No structured SyncBlockError/status here, so the attribution `reason` falls back
1096
+ // to `unknown` when the gate is on.
1097
+ (_this$fireAnalyticsEv9 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv9 === void 0 || _this$fireAnalyticsEv9.call(this, updateReferenceErrorPayload(_t3.message, undefined, undefined, buildErrorAttribution(fg('platform_editor_blocks_patch_3'))));
1096
1098
  case 17:
1097
1099
  _context4.prev = 17;
1098
1100
  if (!success) {
@@ -10,8 +10,9 @@ function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length)
10
10
  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; }
11
11
  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; }
12
12
  import { logException } from '@atlaskit/editor-common/monitoring';
13
+ import { fg } from '@atlaskit/platform-feature-flags';
13
14
  import { SyncBlockError } from '../common/types';
14
- import { updateErrorPayload, createErrorPayload, deleteErrorPayload, updateCacheErrorPayload, getSourceInfoErrorPayload, updateSuccessPayload, createSuccessPayload, deleteSuccessPayload, fetchReferencesErrorPayload } from '../utils/errorHandling';
15
+ import { updateErrorPayload, createErrorPayload, deleteErrorPayload, updateCacheErrorPayload, getSourceInfoErrorPayload, updateSuccessPayload, createSuccessPayload, deleteSuccessPayload, fetchReferencesErrorPayload, buildErrorAttribution } from '../utils/errorHandling';
15
16
  import { getCreateSourceExperience, getDeleteSourceExperience, getSaveSourceExperience, getFetchSourceInfoExperience } from '../utils/experienceTracking';
16
17
  import { convertSyncBlockPMNodeToSyncBlockData, getSourceProductFromResourceIdSafe } from '../utils/utils';
17
18
  /** Maximum time (ms) flush() will wait for in-flight block creations before proceeding. */
@@ -158,7 +159,7 @@ export var SourceSyncBlockStoreManager = /*#__PURE__*/function () {
158
159
  value: (function () {
159
160
  var _flush = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee() {
160
161
  var _this2 = this;
161
- var _this$saveExperience, timedOut, timeoutId, timeout, _iterator, _step, resourceId, bodiedSyncBlockNodes, bodiedSyncBlockData, writeResults, _this$saveExperience2, _this$saveExperience3, _this$fireAnalyticsEv2, _this$flushCompletion, _t;
162
+ var _this$saveExperience, timedOut, timeoutId, timeout, _iterator, _step, resourceId, bodiedSyncBlockNodes, bodiedSyncBlockData, writeResults, _this$saveExperience2, _this$saveExperience3, attributionEnabled, _this$fireAnalyticsEv2, _this$flushCompletion, _t;
162
163
  return _regeneratorRuntime.wrap(function (_context) {
163
164
  while (1) switch (_context.prev = _context.next) {
164
165
  case 0:
@@ -277,11 +278,12 @@ export var SourceSyncBlockStoreManager = /*#__PURE__*/function () {
277
278
  return _context.abrupt("return", true);
278
279
  case 7:
279
280
  (_this$saveExperience3 = this.saveExperience) === null || _this$saveExperience3 === void 0 || _this$saveExperience3.failure();
281
+ attributionEnabled = fg('platform_editor_blocks_patch_3');
280
282
  writeResults.filter(function (result) {
281
283
  return !result.resourceId || result.error;
282
284
  }).forEach(function (result) {
283
285
  var _this2$fireAnalyticsE2;
284
- (_this2$fireAnalyticsE2 = _this2.fireAnalyticsEvent) === null || _this2$fireAnalyticsE2 === void 0 || _this2$fireAnalyticsE2.call(_this2, updateErrorPayload(result.error || 'Failed to write data', result.resourceId, getSourceProductFromResourceIdSafe(result.resourceId)));
286
+ (_this2$fireAnalyticsE2 = _this2.fireAnalyticsEvent) === null || _this2$fireAnalyticsE2 === void 0 || _this2$fireAnalyticsE2.call(_this2, updateErrorPayload(result.error || 'Failed to write data', result.resourceId, getSourceProductFromResourceIdSafe(result.resourceId), buildErrorAttribution(attributionEnabled, result.error, result.statusCode)));
285
287
  });
286
288
  return _context.abrupt("return", false);
287
289
  case 8:
@@ -293,8 +295,9 @@ export var SourceSyncBlockStoreManager = /*#__PURE__*/function () {
293
295
  logException(_t, {
294
296
  location: 'editor-synced-block-provider/sourceSyncBlockStoreManager'
295
297
  });
296
- // Top-level flush failure is not tied to a single resourceId.
297
- (_this$fireAnalyticsEv2 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv2 === void 0 || _this$fireAnalyticsEv2.call(this, updateErrorPayload(_t.message));
298
+ // Top-level flush failure is not tied to a single resourceId. There is no structured
299
+ // SyncBlockError/status here, so the attribution `reason` falls back to `unknown`.
300
+ (_this$fireAnalyticsEv2 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv2 === void 0 || _this$fireAnalyticsEv2.call(this, updateErrorPayload(_t.message, undefined, undefined, buildErrorAttribution(fg('platform_editor_blocks_patch_3'))));
298
301
  return _context.abrupt("return", false);
299
302
  case 10:
300
303
  _context.prev = 10;
@@ -469,7 +472,7 @@ export var SourceSyncBlockStoreManager = /*#__PURE__*/function () {
469
472
  (_this4$createExperien2 = _this4.createExperience) === null || _this4$createExperien2 === void 0 || _this4$createExperien2.failure({
470
473
  reason: result.error || 'Failed to create bodied sync block'
471
474
  });
472
- (_this4$fireAnalyticsE = _this4.fireAnalyticsEvent) === null || _this4$fireAnalyticsE === void 0 || _this4$fireAnalyticsE.call(_this4, createErrorPayload(result.error || 'Failed to create bodied sync block', resourceId, getSourceProductFromResourceIdSafe(resourceId)));
475
+ (_this4$fireAnalyticsE = _this4.fireAnalyticsEvent) === null || _this4$fireAnalyticsE === void 0 || _this4$fireAnalyticsE.call(_this4, createErrorPayload(result.error || 'Failed to create bodied sync block', resourceId, getSourceProductFromResourceIdSafe(resourceId), buildErrorAttribution(fg('platform_editor_blocks_patch_3'), result.error, result.statusCode)));
473
476
  }
474
477
  }).catch(function (error) {
475
478
  var _this4$createExperien3, _this4$fireAnalyticsE2;
@@ -501,7 +504,7 @@ export var SourceSyncBlockStoreManager = /*#__PURE__*/function () {
501
504
  value: function () {
502
505
  var _delete2 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee2(syncBlockIds, onDelete, onDeleteCompleted, reason) {
503
506
  var _this5 = this;
504
- var _this$deleteExperienc, results, callback, isDeleteSuccessful, _this$deleteExperienc2, _this$deleteExperienc3, _t2;
507
+ var _this$deleteExperienc, results, callback, isDeleteSuccessful, _this$deleteExperienc2, _this$deleteExperienc3, attributionEnabled, attribution, _t2;
505
508
  return _regeneratorRuntime.wrap(function (_context2) {
506
509
  while (1) switch (_context2.prev = _context2.next) {
507
510
  case 0:
@@ -548,13 +551,14 @@ export var SourceSyncBlockStoreManager = /*#__PURE__*/function () {
548
551
  _this5.setPendingDeletion(Ids, false);
549
552
  };
550
553
  (_this$deleteExperienc3 = this.deleteExperience) === null || _this$deleteExperienc3 === void 0 || _this$deleteExperienc3.failure();
554
+ attributionEnabled = fg('platform_editor_blocks_patch_3');
551
555
  results.forEach(function (result) {
552
556
  if (result.success) {
553
557
  var _this5$fireAnalyticsE2;
554
558
  (_this5$fireAnalyticsE2 = _this5.fireAnalyticsEvent) === null || _this5$fireAnalyticsE2 === void 0 || _this5$fireAnalyticsE2.call(_this5, deleteSuccessPayload(result.resourceId, getSourceProductFromResourceIdSafe(result.resourceId)));
555
559
  } else {
556
560
  var _this5$fireAnalyticsE3;
557
- (_this5$fireAnalyticsE3 = _this5.fireAnalyticsEvent) === null || _this5$fireAnalyticsE3 === void 0 || _this5$fireAnalyticsE3.call(_this5, deleteErrorPayload(result.error || 'Failed to delete synced block', result.resourceId, getSourceProductFromResourceIdSafe(result.resourceId)));
561
+ (_this5$fireAnalyticsE3 = _this5.fireAnalyticsEvent) === null || _this5$fireAnalyticsE3 === void 0 || _this5$fireAnalyticsE3.call(_this5, deleteErrorPayload(result.error || 'Failed to delete synced block', result.resourceId, getSourceProductFromResourceIdSafe(result.resourceId), buildErrorAttribution(attributionEnabled, result.error, result.statusCode)));
558
562
  }
559
563
  });
560
564
  }
@@ -563,10 +567,13 @@ export var SourceSyncBlockStoreManager = /*#__PURE__*/function () {
563
567
  case 4:
564
568
  _context2.prev = 4;
565
569
  _t2 = _context2["catch"](0);
570
+ // Thrown (non-result) failure — no structured SyncBlockError/status is available,
571
+ // so the attribution `reason` falls back to `unknown` when the gate is on.
572
+ attribution = buildErrorAttribution(fg('platform_editor_blocks_patch_3'));
566
573
  syncBlockIds.forEach(function (Ids) {
567
574
  var _this5$fireAnalyticsE4;
568
575
  _this5.setPendingDeletion(Ids, false);
569
- (_this5$fireAnalyticsE4 = _this5.fireAnalyticsEvent) === null || _this5$fireAnalyticsE4 === void 0 || _this5$fireAnalyticsE4.call(_this5, deleteErrorPayload(_t2.message, Ids.resourceId, getSourceProductFromResourceIdSafe(Ids.resourceId)));
576
+ (_this5$fireAnalyticsE4 = _this5.fireAnalyticsEvent) === null || _this5$fireAnalyticsE4 === void 0 || _this5$fireAnalyticsE4.call(_this5, deleteErrorPayload(_t2.message, Ids.resourceId, getSourceProductFromResourceIdSafe(Ids.resourceId), attribution));
570
577
  });
571
578
  logException(_t2, {
572
579
  location: 'editor-synced-block-provider/sourceSyncBlockStoreManager'
@@ -31,6 +31,7 @@ export var SyncBlockSubscriptionManager = /*#__PURE__*/function () {
31
31
  // causing the cache to be deleted prematurely. We delay deletion to allow
32
32
  // the new component to subscribe and cancel the pending deletion.
33
33
  _defineProperty(this, "pendingCacheDeletions", new Map());
34
+ // backoff cap (gate ON)
34
35
  _defineProperty(this, "retryAttempts", new Map());
35
36
  _defineProperty(this, "pendingRetries", new Map());
36
37
  this.deps = deps;
@@ -41,6 +42,30 @@ export var SyncBlockSubscriptionManager = /*#__PURE__*/function () {
41
42
  * that need to read the current subscription state.
42
43
  */
43
44
  return _createClass(SyncBlockSubscriptionManager, [{
45
+ key: "getMaxRetryAttempts",
46
+ value:
47
+ // EDITOR-7861: higher ceiling lets transient WS-gateway drops self-heal
48
+ // before a terminal failure is surfaced.
49
+ function getMaxRetryAttempts() {
50
+ return fg('platform_editor_blocks_patch_3') ? SyncBlockSubscriptionManager.MAX_RETRY_ATTEMPTS_HARDENED : SyncBlockSubscriptionManager.MAX_RETRY_ATTEMPTS;
51
+ }
52
+
53
+ // Backoff delay for the given attempt.
54
+ // Gate OFF: pure exponential (1s, 2s, 4s, 8s, 16s).
55
+ // Gate ON (EDITOR-7861): exponential capped at MAX_RETRY_DELAY_MS with equal
56
+ // jitter (capped/2 + random*capped/2) — de-synchronises simultaneous
57
+ // reconnects while guaranteeing a non-zero delay (full jitter could hit 0).
58
+ }, {
59
+ key: "getReconnectionDelay",
60
+ value: function getReconnectionDelay(attempts) {
61
+ var exponential = SyncBlockSubscriptionManager.INITIAL_RETRY_DELAY_MS * Math.pow(SyncBlockSubscriptionManager.RETRY_BACKOFF_MULTIPLIER, attempts);
62
+ if (!fg('platform_editor_blocks_patch_3')) {
63
+ return exponential;
64
+ }
65
+ var half = Math.min(exponential, SyncBlockSubscriptionManager.MAX_RETRY_DELAY_MS) / 2;
66
+ return Math.round(half + Math.random() * half);
67
+ }
68
+ }, {
44
69
  key: "getSubscriptions",
45
70
  value: function getSubscriptions() {
46
71
  return this.subscriptions;
@@ -267,11 +292,17 @@ export var SyncBlockSubscriptionManager = /*#__PURE__*/function () {
267
292
  var unsubscribe = dataProvider.subscribeToBlockUpdates(resourceId, function (syncBlockInstance) {
268
293
  _this5.handleGraphQLUpdate(syncBlockInstance);
269
294
  }, function (error) {
270
- var _this5$deps$getFireAn;
271
295
  logException(error, {
272
296
  location: 'editor-synced-block-provider/syncBlockSubscriptionManager/graphql-subscription'
273
297
  });
274
- (_this5$deps$getFireAn = _this5.deps.getFireAnalyticsEvent()) === null || _this5$deps$getFireAn === void 0 || _this5$deps$getFireAn(fetchErrorPayload(error.message, resourceId, getSourceProductFromResourceIdSafe(resourceId)));
298
+ // EDITOR-7861: a single socket drop is usually transient and
299
+ // recovers on reconnect, so under the gate we don't fire a
300
+ // user-facing error here — it's only surfaced on exhaustion (see
301
+ // scheduleReconnection). Gate OFF keeps the legacy fire-on-drop.
302
+ if (!fg('platform_editor_blocks_patch_3')) {
303
+ var _this5$deps$getFireAn;
304
+ (_this5$deps$getFireAn = _this5.deps.getFireAnalyticsEvent()) === null || _this5$deps$getFireAn === void 0 || _this5$deps$getFireAn(fetchErrorPayload(error.message, resourceId, getSourceProductFromResourceIdSafe(resourceId)));
305
+ }
275
306
  _this5.handleSubscriptionTerminated(resourceId);
276
307
  }, function () {
277
308
  _this5.handleSubscriptionTerminated(resourceId);
@@ -302,19 +333,19 @@ export var SyncBlockSubscriptionManager = /*#__PURE__*/function () {
302
333
  }
303
334
  }
304
335
 
305
- /**
306
- * Schedules a reconnection attempt with exponential backoff.
307
- * Delay = INITIAL_RETRY_DELAY_MS * (RETRY_BACKOFF_MULTIPLIER ^ attempts)
308
- * e.g. 1s, 2s, 4s, 8s, 16s
309
- */
336
+ // Schedules a reconnection with backoff (see getMaxRetryAttempts /
337
+ // getReconnectionDelay for the gated behaviour).
310
338
  }, {
311
339
  key: "scheduleReconnection",
312
340
  value: function scheduleReconnection(resourceId) {
313
341
  var _this$retryAttempts$g,
314
342
  _this6 = this;
315
343
  var attempts = (_this$retryAttempts$g = this.retryAttempts.get(resourceId)) !== null && _this$retryAttempts$g !== void 0 ? _this$retryAttempts$g : 0;
316
- if (attempts >= SyncBlockSubscriptionManager.MAX_RETRY_ATTEMPTS) {
344
+ var maxAttempts = this.getMaxRetryAttempts();
345
+ if (attempts >= maxAttempts) {
317
346
  var _this$deps$getFireAna;
347
+ // Exhausted all attempts — the only place a WS drop surfaces as a
348
+ // fetch error under the gate (EDITOR-7861).
318
349
  var errorMessage = "Subscription reconnection failed after ".concat(attempts, " attempts");
319
350
  logException(new Error(errorMessage), {
320
351
  location: 'editor-synced-block-provider/syncBlockSubscriptionManager/max-retries-exhausted'
@@ -322,7 +353,7 @@ export var SyncBlockSubscriptionManager = /*#__PURE__*/function () {
322
353
  (_this$deps$getFireAna = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna === void 0 || _this$deps$getFireAna(fetchErrorPayload(errorMessage, resourceId, getSourceProductFromResourceIdSafe(resourceId)));
323
354
  return;
324
355
  }
325
- var delay = SyncBlockSubscriptionManager.INITIAL_RETRY_DELAY_MS * Math.pow(SyncBlockSubscriptionManager.RETRY_BACKOFF_MULTIPLIER, attempts);
356
+ var delay = this.getReconnectionDelay(attempts);
326
357
  var timer = setTimeout(function () {
327
358
  _this6.pendingRetries.delete(resourceId);
328
359
 
@@ -480,7 +511,11 @@ export var SyncBlockSubscriptionManager = /*#__PURE__*/function () {
480
511
  }
481
512
  }]);
482
513
  }();
483
- // Reconnection with exponential backoff
514
+ // Reconnection with exponential backoff.
484
515
  _defineProperty(SyncBlockSubscriptionManager, "INITIAL_RETRY_DELAY_MS", 1000);
485
516
  _defineProperty(SyncBlockSubscriptionManager, "RETRY_BACKOFF_MULTIPLIER", 2);
486
- _defineProperty(SyncBlockSubscriptionManager, "MAX_RETRY_ATTEMPTS", 5);
517
+ _defineProperty(SyncBlockSubscriptionManager, "MAX_RETRY_ATTEMPTS", 5);
518
+ // legacy (gate OFF)
519
+ _defineProperty(SyncBlockSubscriptionManager, "MAX_RETRY_ATTEMPTS_HARDENED", 8);
520
+ // gate ON (EDITOR-7861)
521
+ _defineProperty(SyncBlockSubscriptionManager, "MAX_RETRY_DELAY_MS", 30000);
@@ -2,6 +2,7 @@ import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
2
  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; }
3
3
  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
4
  import { ACTION, ACTION_SUBJECT, EVENT_TYPE, ACTION_SUBJECT_ID } from '@atlaskit/editor-common/analytics';
5
+ import { SyncBlockError } from '../common/types';
5
6
  export var stringifyError = function stringifyError(error) {
6
7
  try {
7
8
  return JSON.stringify(error);
@@ -10,24 +11,77 @@ export var stringifyError = function stringifyError(error) {
10
11
  }
11
12
  };
12
13
 
14
+ /**
15
+ * The set of categorical failure reasons emitted on synced-block operational error
16
+ * events (EDITOR-7796). These let the analytics dashboard break delete/update/create
17
+ * failures down by cause (Block Service / HG / Relay) instead of relying on the
18
+ * free-text `error` blob. Mirrors the {@link SyncBlockError} enum values, plus an
19
+ * `unknown` fallback for unclassifiable errors.
20
+ */
21
+
22
+ var SYNC_BLOCK_ERROR_REASONS = new Set(Object.values(SyncBlockError));
23
+
24
+ /**
25
+ * Maps a result `error` field (which may be a {@link SyncBlockError} enum value such as
26
+ * `'not_found'`, or an arbitrary stringified blob) to a stable categorical
27
+ * {@link SyncBlockErrorReason} for analytics grouping. Anything that is not a known
28
+ * `SyncBlockError` value collapses to `'unknown'` so the dashboard never has to group on
29
+ * free-text error blobs.
30
+ */
31
+ export var classifyErrorReason = function classifyErrorReason(error) {
32
+ if (error && SYNC_BLOCK_ERROR_REASONS.has(error)) {
33
+ return error;
34
+ }
35
+ return 'unknown';
36
+ };
37
+
38
+ /**
39
+ * Extra, optional analytics attributes describing WHY an operational synced-block
40
+ * action failed. Spread conditionally so we never emit `undefined` keys (EDITOR-7796).
41
+ */
42
+
43
+ /**
44
+ * Builds the {@link ErrorAttributionAttributes} for a failed synced-block operation from
45
+ * the raw result `error` field and optional backend `statusCode`. Returns `undefined`
46
+ * when the `platform_editor_blocks_patch_3` gate is OFF, so the new `reason`/`statusCode`
47
+ * attributes are only emitted once the gate is rolled out (EDITOR-7796).
48
+ *
49
+ * `gateEnabled` is injected by the caller (the store managers evaluate `fg(...)`) so this
50
+ * helper stays pure and trivially unit-testable for both gate states.
51
+ */
52
+ export var buildErrorAttribution = function buildErrorAttribution(gateEnabled, error, statusCode) {
53
+ if (!gateEnabled) {
54
+ return undefined;
55
+ }
56
+ return _objectSpread({
57
+ reason: classifyErrorReason(error)
58
+ }, statusCode !== undefined && {
59
+ statusCode: statusCode
60
+ });
61
+ };
62
+
13
63
  // `sourceProduct` is threaded through every helper so analytics
14
64
  // events can be attributed to the source product (`confluence-page` vs `jira-work-item`).
15
65
  // All helpers accept it as an optional trailing argument; the spread-only-when-truthy
16
66
  // pattern below ensures we never emit `sourceProduct: undefined` (which would dirty the
17
67
  // event schema with empty keys).
18
68
 
19
- export var getErrorPayload = function getErrorPayload(actionSubjectId, error, resourceId, sourceProduct) {
69
+ export var getErrorPayload = function getErrorPayload(actionSubjectId, error, resourceId, sourceProduct, attribution) {
20
70
  return {
21
71
  action: ACTION.ERROR,
22
72
  actionSubject: ACTION_SUBJECT.SYNCED_BLOCK,
23
73
  actionSubjectId: actionSubjectId,
24
74
  eventType: EVENT_TYPE.OPERATIONAL,
25
- attributes: _objectSpread(_objectSpread({
75
+ attributes: _objectSpread(_objectSpread(_objectSpread(_objectSpread({
26
76
  error: error
27
77
  }, resourceId && {
28
78
  resourceId: resourceId
29
79
  }), sourceProduct && {
30
80
  sourceProduct: sourceProduct
81
+ }), (attribution === null || attribution === void 0 ? void 0 : attribution.reason) && {
82
+ reason: attribution.reason
83
+ }), (attribution === null || attribution === void 0 ? void 0 : attribution.statusCode) !== undefined && {
84
+ statusCode: attribution.statusCode
31
85
  })
32
86
  };
33
87
  };
@@ -37,17 +91,17 @@ export var fetchErrorPayload = function fetchErrorPayload(error, resourceId, sou
37
91
  export var getSourceInfoErrorPayload = function getSourceInfoErrorPayload(error, resourceId, sourceProduct) {
38
92
  return getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_GET_SOURCE_INFO, error, resourceId, sourceProduct);
39
93
  };
40
- export var updateErrorPayload = function updateErrorPayload(error, resourceId, sourceProduct) {
41
- return getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_UPDATE, error, resourceId, sourceProduct);
94
+ export var updateErrorPayload = function updateErrorPayload(error, resourceId, sourceProduct, attribution) {
95
+ return getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_UPDATE, error, resourceId, sourceProduct, attribution);
42
96
  };
43
- export var updateReferenceErrorPayload = function updateReferenceErrorPayload(error, resourceId, sourceProduct) {
44
- return getErrorPayload(ACTION_SUBJECT_ID.REFERENCE_SYNCED_BLOCK_UPDATE, error, resourceId, sourceProduct);
97
+ export var updateReferenceErrorPayload = function updateReferenceErrorPayload(error, resourceId, sourceProduct, attribution) {
98
+ return getErrorPayload(ACTION_SUBJECT_ID.REFERENCE_SYNCED_BLOCK_UPDATE, error, resourceId, sourceProduct, attribution);
45
99
  };
46
- export var createErrorPayload = function createErrorPayload(error, resourceId, sourceProduct) {
47
- return getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_CREATE, error, resourceId, sourceProduct);
100
+ export var createErrorPayload = function createErrorPayload(error, resourceId, sourceProduct, attribution) {
101
+ return getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_CREATE, error, resourceId, sourceProduct, attribution);
48
102
  };
49
- export var deleteErrorPayload = function deleteErrorPayload(error, resourceId, sourceProduct) {
50
- return getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_DELETE, error, resourceId, sourceProduct);
103
+ export var deleteErrorPayload = function deleteErrorPayload(error, resourceId, sourceProduct, attribution) {
104
+ return getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_DELETE, error, resourceId, sourceProduct, attribution);
51
105
  };
52
106
  export var updateCacheErrorPayload = function updateCacheErrorPayload(error, resourceId, sourceProduct) {
53
107
  return getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_UPDATE_CACHE, error, resourceId, sourceProduct);