@atlaskit/editor-synced-block-provider 8.2.0 → 8.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cjs/hooks/useFetchSyncBlockData.js +1 -1
  3. package/dist/cjs/providers/block-service/blockServiceAPI.js +7 -4
  4. package/dist/cjs/store-manager/referenceSyncBlockStoreManager.js +9 -4
  5. package/dist/cjs/store-manager/syncBlockBatchFetcher.js +2 -1
  6. package/dist/cjs/store-manager/syncBlockProviderFactoryManager.js +4 -3
  7. package/dist/cjs/store-manager/syncBlockSubscriptionManager.js +11 -4
  8. package/dist/cjs/utils/errorHandling.js +152 -6
  9. package/dist/es2019/hooks/useFetchSyncBlockData.js +2 -2
  10. package/dist/es2019/providers/block-service/blockServiceAPI.js +11 -3
  11. package/dist/es2019/store-manager/referenceSyncBlockStoreManager.js +10 -5
  12. package/dist/es2019/store-manager/syncBlockBatchFetcher.js +3 -2
  13. package/dist/es2019/store-manager/syncBlockProviderFactoryManager.js +5 -4
  14. package/dist/es2019/store-manager/syncBlockSubscriptionManager.js +12 -5
  15. package/dist/es2019/utils/errorHandling.js +167 -22
  16. package/dist/esm/hooks/useFetchSyncBlockData.js +2 -2
  17. package/dist/esm/providers/block-service/blockServiceAPI.js +7 -4
  18. package/dist/esm/store-manager/referenceSyncBlockStoreManager.js +10 -5
  19. package/dist/esm/store-manager/syncBlockBatchFetcher.js +3 -2
  20. package/dist/esm/store-manager/syncBlockProviderFactoryManager.js +5 -4
  21. package/dist/esm/store-manager/syncBlockSubscriptionManager.js +12 -5
  22. package/dist/esm/utils/errorHandling.js +149 -5
  23. package/dist/types/providers/types.d.ts +8 -0
  24. package/dist/types/utils/errorHandling.d.ts +94 -2
  25. package/package.json +3 -3
@@ -58,34 +58,179 @@ export const buildErrorAttribution = (gateEnabled, error, statusCode) => {
58
58
  };
59
59
  };
60
60
 
61
+ /**
62
+ * The set of categorical failure reasons emitted on synced-block fetch/subscribe
63
+ * operational error events (EDITOR-7862). Extends the write-path
64
+ * {@link SyncBlockErrorReason} set with read-path-specific buckets so the analytics
65
+ * dashboard can break fetch failures down by cause instead of regex-matching the
66
+ * free-text `error` blob.
67
+ *
68
+ * The read path surfaces several causes the write-path `SyncBlockError` enum does not
69
+ * model (benign source-state transitions, permission denials, WebSocket lifecycle, and
70
+ * client-side readiness errors), so those are added here. Known `SyncBlockError` enum
71
+ * values (`not_found`, `forbidden`, ...) still pass through unchanged.
72
+ */
73
+
74
+ /**
75
+ * Fetch reasons that represent benign (working-as-designed) outcomes rather than genuine
76
+ * system failures. The dashboard uses this to compute a "true error rate" by excluding
77
+ * benign reasons via the structured `reason` attribute instead of brittle free-text regex
78
+ * (EDITOR-7862).
79
+ */
80
+ export const FETCH_BENIGN_REASONS = new Set(['source_deleted', 'source_unpublished', 'source_unsynced', 'source_not_found', 'permission_denied', 'unauthenticated',
81
+ // Mirror the benign write-path enum equivalents so already-classified errors are
82
+ // treated consistently.
83
+ 'not_found', 'forbidden', 'unpublished'
84
+ // NOTE: `entity_not_found` and `offline` are intentionally NOT benign.
85
+ // - `entity_not_found` is retried up to ENTITY_NOT_FOUND_MAX_RETRIES (analytics are
86
+ // suppressed during retries); by the time the error event fires, retries are
87
+ // exhausted and it is a genuine failure.
88
+ // - `offline` is genuine client connectivity loss, not a working-as-designed outcome.
89
+ // Both still emit `reason` (so they can be inspected) but are counted as true errors.
90
+ ]);
91
+
92
+ /**
93
+ * Ordered list of substring matchers mapping known free-text fetch/subscribe error
94
+ * strings to a categorical {@link SyncBlockFetchErrorReason}. Each entry is a list of
95
+ * lowercase needles; if ANY needle is found (case-insensitively) in the raw `error`
96
+ * string, the entry's reason is used. Order matters: more specific entries must precede
97
+ * more general ones.
98
+ *
99
+ * Plain `String.includes` substring matching is used deliberately instead of `RegExp`:
100
+ * it sidesteps the `require-unicode-regexp` lint rule AND the declaration build's lower
101
+ * compile target (which rejects the regex `u` flag with TS1501), while being faster and
102
+ * easier to read. Doing this classification in code (rather than in dashboard SQL) keeps
103
+ * the buckets versioned with the source strings that produce them (EDITOR-7862).
104
+ */
105
+ const FETCH_REASON_MATCHERS = [
106
+ // Benign: source-state transitions (deletionReason values + free text).
107
+ [['source-document-deleted'], 'source_deleted'], [['source-block-deleted'], 'source_deleted'], [['source-block-unpublished'], 'source_unpublished'], [['source-block-unsynced'], 'source_unsynced'], [['does not exist'], 'source_not_found'], [['unpublished'], 'source_unpublished'],
108
+ // Working-as-designed permission outcomes.
109
+ [['unauthenticated'], 'unauthenticated'], [['not permitted to read synced block'], 'permission_denied'], [['bulk permission check failed'], 'permission_denied'], [['permission denied'], 'permission_denied'],
110
+ // Genuine system failures.
111
+ [['reconnection failed after'], 'websocket_exhausted'],
112
+ // `reconnect to the subscription` (not a bare `reconnect`) avoids false positives on
113
+ // unrelated DB/GraphQL "reconnect" messages while still matching the WS-drop string.
114
+ [['websocket', 'reconnect to the subscription', 'subscription terminated'], 'websocket_drop'], [['429', 'rate limit', 'rate-limit', 'ratelimit'], 'rate_limited'],
115
+ // `econnrefused`/`econnreset` (not a bare `econn`, which would also match `reconnect`).
116
+ [['network', 'failed to fetch', 'econnrefused', 'econnreset', 'timed out', 'timeout'], 'network'], [['data provider not set'], 'data_provider_not_ready']];
117
+
118
+ /**
119
+ * Maps a fetch/subscribe `error` field — which may be a {@link SyncBlockError} enum
120
+ * value, a {@link DeletionReason} value, or an arbitrary free-text/JSON blob — to a
121
+ * stable categorical {@link SyncBlockFetchErrorReason} for analytics grouping.
122
+ *
123
+ * Resolution order:
124
+ * 1. Known `SyncBlockError` enum value → passed through (via {@link classifyErrorReason}).
125
+ * 2. Known free-text substring → mapped to a fetch-specific bucket.
126
+ * 3. Anything else (including opaque blobs like `errored`-only payloads or
127
+ * `ErrorEvent: "undefined"`) → `'unknown'`, so the dashboard never groups on free text.
128
+ *
129
+ * Note: the bare string `'errored'` IS a `SyncBlockError` enum value and therefore
130
+ * classifies as `'errored'` (not `'unknown'`); only genuinely unrecognised strings
131
+ * collapse to `'unknown'`.
132
+ */
133
+ export const classifyFetchErrorReason = error => {
134
+ const enumReason = classifyErrorReason(error);
135
+ if (enumReason !== 'unknown') {
136
+ return enumReason;
137
+ }
138
+ if (error) {
139
+ const haystack = error.toLowerCase();
140
+ for (const [needles, reason] of FETCH_REASON_MATCHERS) {
141
+ if (needles.some(needle => haystack.includes(needle))) {
142
+ return reason;
143
+ }
144
+ }
145
+ }
146
+ return 'unknown';
147
+ };
148
+
149
+ /**
150
+ * Extra, optional analytics attributes describing WHY a fetch/subscribe synced-block
151
+ * action failed. Spread conditionally so we never emit `undefined` keys (EDITOR-7862).
152
+ *
153
+ * Adds `benign` on top of the shared {@link ErrorAttributionAttributes} so the dashboard
154
+ * can compute a true error rate (`genuine / total`) without any free-text regex.
155
+ */
156
+
157
+ /**
158
+ * Builds the {@link FetchErrorAttributionAttributes} for a failed fetch/subscribe
159
+ * synced-block operation from the raw `error` field and optional backend `statusCode`.
160
+ * Returns `undefined` when the `platform_editor_blocks_patch_3` gate is OFF, so the new
161
+ * `reason`/`statusCode`/`benign` attributes are only emitted once the gate is rolled out
162
+ * (EDITOR-7862). The existing free-text `error` attribute is always left unchanged.
163
+ *
164
+ * `gateEnabled` is injected by the caller (the store managers evaluate `fg(...)`) so this
165
+ * helper stays pure and trivially unit-testable for both gate states.
166
+ */
167
+ export const buildFetchErrorAttribution = (gateEnabled, error, statusCode) => {
168
+ if (!gateEnabled) {
169
+ return undefined;
170
+ }
171
+ const reason = classifyFetchErrorReason(error);
172
+ return {
173
+ reason,
174
+ benign: FETCH_BENIGN_REASONS.has(reason),
175
+ ...(statusCode !== undefined && {
176
+ statusCode
177
+ })
178
+ };
179
+ };
180
+
61
181
  // `sourceProduct` is threaded through every helper so analytics
62
182
  // events can be attributed to the source product (`confluence-page` vs `jira-work-item`).
63
183
  // All helpers accept it as an optional trailing argument; the spread-only-when-truthy
64
184
  // pattern below ensures we never emit `sourceProduct: undefined` (which would dirty the
65
185
  // event schema with empty keys).
66
186
 
67
- export const getErrorPayload = (actionSubjectId, error, resourceId, sourceProduct, attribution) => ({
68
- action: ACTION.ERROR,
69
- actionSubject: ACTION_SUBJECT.SYNCED_BLOCK,
70
- actionSubjectId,
71
- eventType: EVENT_TYPE.OPERATIONAL,
72
- attributes: {
73
- error,
74
- ...(resourceId && {
75
- resourceId
76
- }),
77
- ...(sourceProduct && {
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
85
- })
86
- }
87
- });
88
- export const fetchErrorPayload = (error, resourceId, sourceProduct) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_FETCH, error, resourceId, sourceProduct);
187
+ /**
188
+ * Shared operational ERROR payload builder for synced-block events.
189
+ *
190
+ * Overloaded so the emitted `reason` type stays accurate per path (raised in review on
191
+ * EDITOR-7862): fetch/subscribe callers pass a {@link FetchErrorAttributionAttributes}
192
+ * (discriminated by its required `benign` field) and get the wider
193
+ * {@link SyncBlockFetchErrorReason}; write-path callers pass an
194
+ * {@link ErrorAttributionAttributes} (or nothing) and keep the narrower
195
+ * {@link SyncBlockErrorReason}, so write-path payloads never claim to carry fetch-only
196
+ * buckets like `source_deleted` they never emit.
197
+ */
198
+
199
+ export function getErrorPayload(actionSubjectId, error, resourceId, sourceProduct, attribution) {
200
+ return {
201
+ action: ACTION.ERROR,
202
+ actionSubject: ACTION_SUBJECT.SYNCED_BLOCK,
203
+ actionSubjectId,
204
+ eventType: EVENT_TYPE.OPERATIONAL,
205
+ attributes: {
206
+ error,
207
+ ...(resourceId && {
208
+ resourceId
209
+ }),
210
+ ...(sourceProduct && {
211
+ sourceProduct
212
+ }),
213
+ ...((attribution === null || attribution === void 0 ? void 0 : attribution.reason) && {
214
+ reason: attribution.reason
215
+ }),
216
+ ...((attribution === null || attribution === void 0 ? void 0 : attribution.statusCode) !== undefined && {
217
+ statusCode: attribution.statusCode
218
+ }),
219
+ // `benign` is only present on fetch/subscribe attribution (EDITOR-7862). It is a
220
+ // boolean (never UGC); the `'benign' in attribution` check both guards the
221
+ // write-path attribution (which has no `benign`) and narrows the union so
222
+ // `attribution.benign` is accessible.
223
+ ...(attribution && 'benign' in attribution && {
224
+ benign: attribution.benign
225
+ })
226
+ }
227
+ };
228
+ }
229
+ export const fetchErrorPayload = (error, resourceId, sourceProduct, attribution) =>
230
+ // Branch on attribution presence so each call resolves to a concrete overload: with
231
+ // attribution it hits the fetch overload (wider `reason`); without it (gate OFF) it
232
+ // hits the no-attribution overload. Both produce a fetch event regardless.
233
+ attribution ? getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_FETCH, error, resourceId, sourceProduct, attribution) : getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_FETCH, error, resourceId, sourceProduct);
89
234
  export const getSourceInfoErrorPayload = (error, resourceId, sourceProduct) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_GET_SOURCE_INFO, error, resourceId, sourceProduct);
90
235
  export const updateErrorPayload = (error, resourceId, sourceProduct, attribution) => getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_UPDATE, error, resourceId, sourceProduct, attribution);
91
236
  export const updateReferenceErrorPayload = (error, resourceId, sourceProduct, attribution) => getErrorPayload(ACTION_SUBJECT_ID.REFERENCE_SYNCED_BLOCK_UPDATE, error, resourceId, sourceProduct, attribution);
@@ -9,7 +9,7 @@ import { isSSR } from '@atlaskit/editor-common/core-utils';
9
9
  import { logException } from '@atlaskit/editor-common/monitoring';
10
10
  import { fg } from '@atlaskit/platform-feature-flags';
11
11
  import { isProviderNotReadyError, SyncBlockError } from '../common/types';
12
- import { fetchErrorPayload } from '../utils/errorHandling';
12
+ import { buildFetchErrorAttribution, fetchErrorPayload } from '../utils/errorHandling';
13
13
  import { createSyncBlockNode, getSourceProductFromResourceIdSafe } from '../utils/utils';
14
14
  export var useFetchSyncBlockData = function useFetchSyncBlockData(manager, resourceId, localId, fireAnalyticsEvent) {
15
15
  var _manager$referenceMan2, _manager$referenceMan3, _manager$referenceMan4;
@@ -98,7 +98,7 @@ export var useFetchSyncBlockData = function useFetchSyncBlockData(manager, resou
98
98
  logException(_t, {
99
99
  location: 'editor-synced-block-provider/useFetchSyncBlockData'
100
100
  });
101
- fireAnalyticsEvent === null || fireAnalyticsEvent === void 0 || fireAnalyticsEvent(fetchErrorPayload(_t.message, resourceId, getSourceProductFromResourceIdSafe(resourceId)));
101
+ fireAnalyticsEvent === null || fireAnalyticsEvent === void 0 || fireAnalyticsEvent(fetchErrorPayload(_t.message, resourceId, getSourceProductFromResourceIdSafe(resourceId), buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), _t.message)));
102
102
 
103
103
  // Set error state if fetching fails
104
104
  setFetchState({
@@ -1,6 +1,6 @@
1
- import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
1
  import _classCallCheck from "@babel/runtime/helpers/classCallCheck";
3
2
  import _createClass from "@babel/runtime/helpers/createClass";
3
+ import _defineProperty from "@babel/runtime/helpers/defineProperty";
4
4
  import _toConsumableArray from "@babel/runtime/helpers/toConsumableArray";
5
5
  import _asyncToGenerator from "@babel/runtime/helpers/asyncToGenerator";
6
6
  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; }
@@ -405,10 +405,12 @@ var _batchFetchData = /*#__PURE__*/function () {
405
405
  case 21:
406
406
  return _context2.abrupt("return", blockNodeIdentifiers.map(function (blockNodeIdentifier) {
407
407
  return {
408
- error: {
408
+ error: _objectSpread({
409
409
  type: _t4 instanceof BlockError ? mapBlockError(_t4) : SyncBlockError.Errored,
410
410
  reason: _t4.message
411
- },
411
+ }, _t4 instanceof BlockError && {
412
+ statusCode: _t4.status
413
+ }),
412
414
  resourceId: blockNodeIdentifier.resourceId
413
415
  };
414
416
  }));
@@ -661,7 +663,8 @@ var BlockServiceADFFetchProvider = /*#__PURE__*/function () {
661
663
  return _context5.abrupt("return", {
662
664
  error: {
663
665
  type: mapBlockError(_t7),
664
- reason: _t7.message
666
+ reason: _t7.message,
667
+ statusCode: _t7.status
665
668
  },
666
669
  resourceId: resourceId
667
670
  });
@@ -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 { isProviderNotReadyError, ProviderNotReadyError, SyncBlockError } from '../common/types';
16
- import { buildErrorAttribution, cacheDeletionForcedPayload, fetchErrorPayload, fetchSuccessPayload, getSourceInfoErrorPayload, sourceInfoOrphanedPayload, updateReferenceErrorPayload } from '../utils/errorHandling';
16
+ import { buildErrorAttribution, buildFetchErrorAttribution, 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';
@@ -602,10 +602,12 @@ export var ReferenceSyncBlockStoreManager = /*#__PURE__*/function () {
602
602
  data.forEach(function (syncBlockInstance) {
603
603
  var _resolvedSyncBlockIns;
604
604
  if (!syncBlockInstance.resourceId) {
605
- var _syncBlockInstance$er, _syncBlockInstance$er2, _this5$fireAnalyticsE;
605
+ var _syncBlockInstance$er, _syncBlockInstance$er2, _this5$fireAnalyticsE, _syncBlockInstance$er3, _syncBlockInstance$er4, _syncBlockInstance$er5;
606
606
  var payload = ((_syncBlockInstance$er = syncBlockInstance.error) === null || _syncBlockInstance$er === void 0 ? void 0 : _syncBlockInstance$er.reason) || ((_syncBlockInstance$er2 = syncBlockInstance.error) === null || _syncBlockInstance$er2 === void 0 ? void 0 : _syncBlockInstance$er2.type) || 'Returned sync block instance does not have resource id';
607
607
  // No resourceId means we cannot derive a sourceProduct here; intentionally omit.
608
- (_this5$fireAnalyticsE = _this5.fireAnalyticsEvent) === null || _this5$fireAnalyticsE === void 0 || _this5$fireAnalyticsE.call(_this5, fetchErrorPayload(payload));
608
+ // Classify on the structured `type` first, falling back to the free-text
609
+ // `reason`/payload (EDITOR-7862).
610
+ (_this5$fireAnalyticsE = _this5.fireAnalyticsEvent) === null || _this5$fireAnalyticsE === void 0 || _this5$fireAnalyticsE.call(_this5, fetchErrorPayload(payload, undefined, undefined, buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), ((_syncBlockInstance$er3 = syncBlockInstance.error) === null || _syncBlockInstance$er3 === void 0 ? void 0 : _syncBlockInstance$er3.type) || ((_syncBlockInstance$er4 = syncBlockInstance.error) === null || _syncBlockInstance$er4 === void 0 ? void 0 : _syncBlockInstance$er4.reason) || payload, (_syncBlockInstance$er5 = syncBlockInstance.error) === null || _syncBlockInstance$er5 === void 0 ? void 0 : _syncBlockInstance$er5.statusCode)));
609
611
  return;
610
612
  }
611
613
  var existingSyncBlock = _this5.getFromCache(syncBlockInstance.resourceId);
@@ -639,7 +641,10 @@ export var ReferenceSyncBlockStoreManager = /*#__PURE__*/function () {
639
641
  var isRetryingEntityNotFound = syncBlockInstance.error.type === SyncBlockError.EntityNotFound && ((_this5$entityNotFound = _this5.entityNotFoundRetryCount.get(syncBlockInstance.resourceId)) !== null && _this5$entityNotFound !== void 0 ? _this5$entityNotFound : 0) < ENTITY_NOT_FOUND_MAX_RETRIES && fg('platform_synced_block_patch_13');
640
642
  if (!isRetryingEntityNotFound) {
641
643
  var _this5$fireAnalyticsE2, _syncBlockInstance$da, _syncBlockInstance$da2;
642
- (_this5$fireAnalyticsE2 = _this5.fireAnalyticsEvent) === null || _this5$fireAnalyticsE2 === void 0 || _this5$fireAnalyticsE2.call(_this5, fetchErrorPayload(syncBlockInstance.error.reason || syncBlockInstance.error.type, syncBlockInstance.resourceId, (_syncBlockInstance$da = (_syncBlockInstance$da2 = syncBlockInstance.data) === null || _syncBlockInstance$da2 === void 0 ? void 0 : _syncBlockInstance$da2.product) !== null && _syncBlockInstance$da !== void 0 ? _syncBlockInstance$da : getSourceProductFromResourceIdSafe(syncBlockInstance.resourceId)));
644
+ // Classify on the structured `type` (a `SyncBlockError` enum value) first,
645
+ // falling back to the free-text `reason` so source-state/permission strings
646
+ // are still bucketed (EDITOR-7862). The emitted `error` attribute is unchanged.
647
+ (_this5$fireAnalyticsE2 = _this5.fireAnalyticsEvent) === null || _this5$fireAnalyticsE2 === void 0 || _this5$fireAnalyticsE2.call(_this5, fetchErrorPayload(syncBlockInstance.error.reason || syncBlockInstance.error.type, syncBlockInstance.resourceId, (_syncBlockInstance$da = (_syncBlockInstance$da2 = syncBlockInstance.data) === null || _syncBlockInstance$da2 === void 0 ? void 0 : _syncBlockInstance$da2.product) !== null && _syncBlockInstance$da !== void 0 ? _syncBlockInstance$da : getSourceProductFromResourceIdSafe(syncBlockInstance.resourceId), buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), syncBlockInstance.error.type || syncBlockInstance.error.reason, syncBlockInstance.error.statusCode)));
643
648
  }
644
649
  if (syncBlockInstance.error.type === SyncBlockError.NotFound || syncBlockInstance.error.type === SyncBlockError.Forbidden) {
645
650
  hasExpectedError = true;
@@ -963,7 +968,7 @@ export var ReferenceSyncBlockStoreManager = /*#__PURE__*/function () {
963
968
  logException(error, {
964
969
  location: 'editor-synced-block-provider/referenceSyncBlockStoreManager'
965
970
  });
966
- (_this$fireAnalyticsEv7 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv7 === void 0 || _this$fireAnalyticsEv7.call(this, fetchErrorPayload(error.message));
971
+ (_this$fireAnalyticsEv7 = this.fireAnalyticsEvent) === null || _this$fireAnalyticsEv7 === void 0 || _this$fireAnalyticsEv7.call(this, fetchErrorPayload(error.message, undefined, undefined, buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), error.message)));
967
972
  return function () {};
968
973
  }
969
974
  }
@@ -5,7 +5,7 @@ import rafSchedule from 'raf-schd';
5
5
  import { logException } from '@atlaskit/editor-common/monitoring';
6
6
  import { fg } from '@atlaskit/platform-feature-flags';
7
7
  import { isProviderNotReadyError } from '../common/types';
8
- import { fetchErrorPayload } from '../utils/errorHandling';
8
+ import { buildFetchErrorAttribution, fetchErrorPayload } from '../utils/errorHandling';
9
9
  import { createSyncBlockNode, getSourceProductFromResourceIdSafe } from '../utils/utils';
10
10
  /**
11
11
  * Handles debounced batch-fetching of sync block data via `raf-schd`.
@@ -68,9 +68,10 @@ export var SyncBlockBatchFetcher = /*#__PURE__*/function () {
68
68
  logException(error, {
69
69
  location: 'editor-synced-block-provider/syncBlockBatchFetcher/batchedFetchSyncBlocks'
70
70
  });
71
+ var attribution = buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), error.message);
71
72
  resourceIds.forEach(function (resId) {
72
73
  var _this$deps$getFireAna;
73
- (_this$deps$getFireAna = _this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna === void 0 || _this$deps$getFireAna(fetchErrorPayload(error.message, resId, getSourceProductFromResourceIdSafe(resId)));
74
+ (_this$deps$getFireAna = _this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna === void 0 || _this$deps$getFireAna(fetchErrorPayload(error.message, resId, getSourceProductFromResourceIdSafe(resId), attribution));
74
75
  });
75
76
  }).finally(function () {
76
77
  // If the fetcher was destroyed while the request was in flight,
@@ -3,7 +3,8 @@ import _createClass from "@babel/runtime/helpers/createClass";
3
3
  import _defineProperty from "@babel/runtime/helpers/defineProperty";
4
4
  import { logException } from '@atlaskit/editor-common/monitoring';
5
5
  import { ProviderFactory } from '@atlaskit/editor-common/provider-factory';
6
- import { fetchErrorPayload } from '../utils/errorHandling';
6
+ import { fg } from '@atlaskit/platform-feature-flags';
7
+ import { buildFetchErrorAttribution, fetchErrorPayload } from '../utils/errorHandling';
7
8
  import { parseResourceId } from '../utils/resourceId';
8
9
  import { getSourceProductFromResourceIdSafe } from '../utils/utils';
9
10
  /**
@@ -26,7 +27,7 @@ export var SyncBlockProviderFactoryManager = /*#__PURE__*/function () {
26
27
  logException(error, {
27
28
  location: 'editor-synced-block-provider/syncBlockProviderFactoryManager'
28
29
  });
29
- (_this$deps$getFireAna = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna === void 0 || _this$deps$getFireAna(fetchErrorPayload(error.message, resourceId, getSourceProductFromResourceIdSafe(resourceId)));
30
+ (_this$deps$getFireAna = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna === void 0 || _this$deps$getFireAna(fetchErrorPayload(error.message, resourceId, getSourceProductFromResourceIdSafe(resourceId), buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), error.message)));
30
31
  return undefined;
31
32
  }
32
33
  var _dataProvider$getSync = dataProvider.getSyncedBlockRendererProviderOptions(),
@@ -59,7 +60,7 @@ export var SyncBlockProviderFactoryManager = /*#__PURE__*/function () {
59
60
  logException(error, {
60
61
  location: 'editor-synced-block-provider/syncBlockProviderFactoryManager'
61
62
  });
62
- (_this$deps$getFireAna2 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna2 === void 0 || _this$deps$getFireAna2(fetchErrorPayload(error.message, resourceId, getSourceProductFromResourceIdSafe(resourceId)));
63
+ (_this$deps$getFireAna2 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna2 === void 0 || _this$deps$getFireAna2(fetchErrorPayload(error.message, resourceId, getSourceProductFromResourceIdSafe(resourceId), buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), error.message)));
63
64
  }
64
65
  }
65
66
  return providerFactory;
@@ -144,7 +145,7 @@ export var SyncBlockProviderFactoryManager = /*#__PURE__*/function () {
144
145
  if (!syncBlock.data.sourceAri || !syncBlock.data.product) {
145
146
  var _this$deps$getFireAna3, _syncBlock$data$produ;
146
147
  (_this$deps$getFireAna3 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna3 === void 0 || _this$deps$getFireAna3(fetchErrorPayload('Sync block source ari or product not found', resourceId, // Prefer cached product when available; fall back to parsing resourceId.
147
- (_syncBlock$data$produ = syncBlock.data.product) !== null && _syncBlock$data$produ !== void 0 ? _syncBlock$data$produ : getSourceProductFromResourceIdSafe(resourceId)));
148
+ (_syncBlock$data$produ = syncBlock.data.product) !== null && _syncBlock$data$produ !== void 0 ? _syncBlock$data$produ : getSourceProductFromResourceIdSafe(resourceId), buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), 'Sync block source ari or product not found')));
148
149
  return;
149
150
  }
150
151
  var parentInfo = dataProvider.retrieveSyncBlockParentInfo(syncBlock.data.sourceAri, syncBlock.data.product);
@@ -8,7 +8,7 @@ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbol
8
8
  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; }
9
9
  import { logException } from '@atlaskit/editor-common/monitoring';
10
10
  import { fg } from '@atlaskit/platform-feature-flags';
11
- import { fetchErrorPayload, fetchSuccessPayload } from '../utils/errorHandling';
11
+ import { buildFetchErrorAttribution, fetchErrorPayload, fetchSuccessPayload } from '../utils/errorHandling';
12
12
  import { resolveSyncBlockInstance } from '../utils/resolveSyncBlockInstance';
13
13
  import { getSourceProductFromResourceIdSafe } from '../utils/utils';
14
14
  /**
@@ -114,7 +114,7 @@ export var SyncBlockSubscriptionManager = /*#__PURE__*/function () {
114
114
  logException(error, {
115
115
  location: 'editor-synced-block-provider/syncBlockSubscriptionManager/notifySubscriptionChangeListeners'
116
116
  });
117
- (_this2$deps$getFireAn = _this2.deps.getFireAnalyticsEvent()) === null || _this2$deps$getFireAn === void 0 || _this2$deps$getFireAn(fetchErrorPayload(error.message));
117
+ (_this2$deps$getFireAn = _this2.deps.getFireAnalyticsEvent()) === null || _this2$deps$getFireAn === void 0 || _this2$deps$getFireAn(fetchErrorPayload(error.message, undefined, undefined, buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), error.message)));
118
118
  }
119
119
  });
120
120
  }
@@ -299,6 +299,9 @@ export var SyncBlockSubscriptionManager = /*#__PURE__*/function () {
299
299
  // recovers on reconnect, so under the gate we don't fire a
300
300
  // user-facing error here — it's only surfaced on exhaustion (see
301
301
  // scheduleReconnection). Gate OFF keeps the legacy fire-on-drop.
302
+ // This branch only runs when the gate is OFF, so buildFetchErrorAttribution
303
+ // would return undefined; the structured attribution (EDITOR-7862) is therefore
304
+ // applied at the gate-ON exhaustion site in scheduleReconnection instead.
302
305
  if (!fg('platform_editor_blocks_patch_3')) {
303
306
  var _this5$deps$getFireAn;
304
307
  (_this5$deps$getFireAn = _this5.deps.getFireAnalyticsEvent()) === null || _this5$deps$getFireAn === void 0 || _this5$deps$getFireAn(fetchErrorPayload(error.message, resourceId, getSourceProductFromResourceIdSafe(resourceId)));
@@ -350,7 +353,7 @@ export var SyncBlockSubscriptionManager = /*#__PURE__*/function () {
350
353
  logException(new Error(errorMessage), {
351
354
  location: 'editor-synced-block-provider/syncBlockSubscriptionManager/max-retries-exhausted'
352
355
  });
353
- (_this$deps$getFireAna = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna === void 0 || _this$deps$getFireAna(fetchErrorPayload(errorMessage, resourceId, getSourceProductFromResourceIdSafe(resourceId)));
356
+ (_this$deps$getFireAna = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna === void 0 || _this$deps$getFireAna(fetchErrorPayload(errorMessage, resourceId, getSourceProductFromResourceIdSafe(resourceId), buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), errorMessage)));
354
357
  return;
355
358
  }
356
359
  var delay = this.getReconnectionDelay(attempts);
@@ -504,9 +507,13 @@ export var SyncBlockSubscriptionManager = /*#__PURE__*/function () {
504
507
  });
505
508
  this.deps.fetchSyncBlockSourceInfo(resolved.resourceId);
506
509
  } else {
507
- var _syncBlockInstance$er, _syncBlockInstance$er2, _this$deps$getFireAna2, _syncBlockInstance$da3, _syncBlockInstance$da4;
510
+ var _syncBlockInstance$er, _syncBlockInstance$er2, _this$deps$getFireAna2, _syncBlockInstance$da3, _syncBlockInstance$da4, _syncBlockInstance$er3, _syncBlockInstance$er4, _syncBlockInstance$er5;
508
511
  var errorMessage = ((_syncBlockInstance$er = syncBlockInstance.error) === null || _syncBlockInstance$er === void 0 ? void 0 : _syncBlockInstance$er.reason) || ((_syncBlockInstance$er2 = syncBlockInstance.error) === null || _syncBlockInstance$er2 === void 0 ? void 0 : _syncBlockInstance$er2.type);
509
- (_this$deps$getFireAna2 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna2 === void 0 || _this$deps$getFireAna2(fetchErrorPayload(errorMessage, syncBlockInstance.resourceId, (_syncBlockInstance$da3 = (_syncBlockInstance$da4 = syncBlockInstance.data) === null || _syncBlockInstance$da4 === void 0 ? void 0 : _syncBlockInstance$da4.product) !== null && _syncBlockInstance$da3 !== void 0 ? _syncBlockInstance$da3 : getSourceProductFromResourceIdSafe(syncBlockInstance.resourceId)));
512
+
513
+ // Prefer the structured `type` (a `SyncBlockError` enum value) for classification
514
+ // and fall back to the free-text `reason` so source-state/permission strings are
515
+ // still bucketed (EDITOR-7862). The emitted free-text `error` attribute is unchanged.
516
+ (_this$deps$getFireAna2 = this.deps.getFireAnalyticsEvent()) === null || _this$deps$getFireAna2 === void 0 || _this$deps$getFireAna2(fetchErrorPayload(errorMessage, syncBlockInstance.resourceId, (_syncBlockInstance$da3 = (_syncBlockInstance$da4 = syncBlockInstance.data) === null || _syncBlockInstance$da4 === void 0 ? void 0 : _syncBlockInstance$da4.product) !== null && _syncBlockInstance$da3 !== void 0 ? _syncBlockInstance$da3 : getSourceProductFromResourceIdSafe(syncBlockInstance.resourceId), buildFetchErrorAttribution(fg('platform_editor_blocks_patch_3'), ((_syncBlockInstance$er3 = syncBlockInstance.error) === null || _syncBlockInstance$er3 === void 0 ? void 0 : _syncBlockInstance$er3.type) || ((_syncBlockInstance$er4 = syncBlockInstance.error) === null || _syncBlockInstance$er4 === void 0 ? void 0 : _syncBlockInstance$er4.reason), (_syncBlockInstance$er5 = syncBlockInstance.error) === null || _syncBlockInstance$er5 === void 0 ? void 0 : _syncBlockInstance$er5.statusCode)));
510
517
  }
511
518
  }
512
519
  }]);
@@ -1,3 +1,4 @@
1
+ import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
1
2
  import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
3
  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
4
  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; }
@@ -60,19 +61,155 @@ export var buildErrorAttribution = function buildErrorAttribution(gateEnabled, e
60
61
  });
61
62
  };
62
63
 
64
+ /**
65
+ * The set of categorical failure reasons emitted on synced-block fetch/subscribe
66
+ * operational error events (EDITOR-7862). Extends the write-path
67
+ * {@link SyncBlockErrorReason} set with read-path-specific buckets so the analytics
68
+ * dashboard can break fetch failures down by cause instead of regex-matching the
69
+ * free-text `error` blob.
70
+ *
71
+ * The read path surfaces several causes the write-path `SyncBlockError` enum does not
72
+ * model (benign source-state transitions, permission denials, WebSocket lifecycle, and
73
+ * client-side readiness errors), so those are added here. Known `SyncBlockError` enum
74
+ * values (`not_found`, `forbidden`, ...) still pass through unchanged.
75
+ */
76
+
77
+ /**
78
+ * Fetch reasons that represent benign (working-as-designed) outcomes rather than genuine
79
+ * system failures. The dashboard uses this to compute a "true error rate" by excluding
80
+ * benign reasons via the structured `reason` attribute instead of brittle free-text regex
81
+ * (EDITOR-7862).
82
+ */
83
+ export var FETCH_BENIGN_REASONS = new Set(['source_deleted', 'source_unpublished', 'source_unsynced', 'source_not_found', 'permission_denied', 'unauthenticated',
84
+ // Mirror the benign write-path enum equivalents so already-classified errors are
85
+ // treated consistently.
86
+ 'not_found', 'forbidden', 'unpublished'
87
+ // NOTE: `entity_not_found` and `offline` are intentionally NOT benign.
88
+ // - `entity_not_found` is retried up to ENTITY_NOT_FOUND_MAX_RETRIES (analytics are
89
+ // suppressed during retries); by the time the error event fires, retries are
90
+ // exhausted and it is a genuine failure.
91
+ // - `offline` is genuine client connectivity loss, not a working-as-designed outcome.
92
+ // Both still emit `reason` (so they can be inspected) but are counted as true errors.
93
+ ]);
94
+
95
+ /**
96
+ * Ordered list of substring matchers mapping known free-text fetch/subscribe error
97
+ * strings to a categorical {@link SyncBlockFetchErrorReason}. Each entry is a list of
98
+ * lowercase needles; if ANY needle is found (case-insensitively) in the raw `error`
99
+ * string, the entry's reason is used. Order matters: more specific entries must precede
100
+ * more general ones.
101
+ *
102
+ * Plain `String.includes` substring matching is used deliberately instead of `RegExp`:
103
+ * it sidesteps the `require-unicode-regexp` lint rule AND the declaration build's lower
104
+ * compile target (which rejects the regex `u` flag with TS1501), while being faster and
105
+ * easier to read. Doing this classification in code (rather than in dashboard SQL) keeps
106
+ * the buckets versioned with the source strings that produce them (EDITOR-7862).
107
+ */
108
+ var FETCH_REASON_MATCHERS = [
109
+ // Benign: source-state transitions (deletionReason values + free text).
110
+ [['source-document-deleted'], 'source_deleted'], [['source-block-deleted'], 'source_deleted'], [['source-block-unpublished'], 'source_unpublished'], [['source-block-unsynced'], 'source_unsynced'], [['does not exist'], 'source_not_found'], [['unpublished'], 'source_unpublished'],
111
+ // Working-as-designed permission outcomes.
112
+ [['unauthenticated'], 'unauthenticated'], [['not permitted to read synced block'], 'permission_denied'], [['bulk permission check failed'], 'permission_denied'], [['permission denied'], 'permission_denied'],
113
+ // Genuine system failures.
114
+ [['reconnection failed after'], 'websocket_exhausted'],
115
+ // `reconnect to the subscription` (not a bare `reconnect`) avoids false positives on
116
+ // unrelated DB/GraphQL "reconnect" messages while still matching the WS-drop string.
117
+ [['websocket', 'reconnect to the subscription', 'subscription terminated'], 'websocket_drop'], [['429', 'rate limit', 'rate-limit', 'ratelimit'], 'rate_limited'],
118
+ // `econnrefused`/`econnreset` (not a bare `econn`, which would also match `reconnect`).
119
+ [['network', 'failed to fetch', 'econnrefused', 'econnreset', 'timed out', 'timeout'], 'network'], [['data provider not set'], 'data_provider_not_ready']];
120
+
121
+ /**
122
+ * Maps a fetch/subscribe `error` field — which may be a {@link SyncBlockError} enum
123
+ * value, a {@link DeletionReason} value, or an arbitrary free-text/JSON blob — to a
124
+ * stable categorical {@link SyncBlockFetchErrorReason} for analytics grouping.
125
+ *
126
+ * Resolution order:
127
+ * 1. Known `SyncBlockError` enum value → passed through (via {@link classifyErrorReason}).
128
+ * 2. Known free-text substring → mapped to a fetch-specific bucket.
129
+ * 3. Anything else (including opaque blobs like `errored`-only payloads or
130
+ * `ErrorEvent: "undefined"`) → `'unknown'`, so the dashboard never groups on free text.
131
+ *
132
+ * Note: the bare string `'errored'` IS a `SyncBlockError` enum value and therefore
133
+ * classifies as `'errored'` (not `'unknown'`); only genuinely unrecognised strings
134
+ * collapse to `'unknown'`.
135
+ */
136
+ export var classifyFetchErrorReason = function classifyFetchErrorReason(error) {
137
+ var enumReason = classifyErrorReason(error);
138
+ if (enumReason !== 'unknown') {
139
+ return enumReason;
140
+ }
141
+ if (error) {
142
+ var haystack = error.toLowerCase();
143
+ for (var _i = 0, _FETCH_REASON_MATCHER = FETCH_REASON_MATCHERS; _i < _FETCH_REASON_MATCHER.length; _i++) {
144
+ var _FETCH_REASON_MATCHER2 = _slicedToArray(_FETCH_REASON_MATCHER[_i], 2),
145
+ needles = _FETCH_REASON_MATCHER2[0],
146
+ reason = _FETCH_REASON_MATCHER2[1];
147
+ if (needles.some(function (needle) {
148
+ return haystack.includes(needle);
149
+ })) {
150
+ return reason;
151
+ }
152
+ }
153
+ }
154
+ return 'unknown';
155
+ };
156
+
157
+ /**
158
+ * Extra, optional analytics attributes describing WHY a fetch/subscribe synced-block
159
+ * action failed. Spread conditionally so we never emit `undefined` keys (EDITOR-7862).
160
+ *
161
+ * Adds `benign` on top of the shared {@link ErrorAttributionAttributes} so the dashboard
162
+ * can compute a true error rate (`genuine / total`) without any free-text regex.
163
+ */
164
+
165
+ /**
166
+ * Builds the {@link FetchErrorAttributionAttributes} for a failed fetch/subscribe
167
+ * synced-block operation from the raw `error` field and optional backend `statusCode`.
168
+ * Returns `undefined` when the `platform_editor_blocks_patch_3` gate is OFF, so the new
169
+ * `reason`/`statusCode`/`benign` attributes are only emitted once the gate is rolled out
170
+ * (EDITOR-7862). The existing free-text `error` attribute is always left unchanged.
171
+ *
172
+ * `gateEnabled` is injected by the caller (the store managers evaluate `fg(...)`) so this
173
+ * helper stays pure and trivially unit-testable for both gate states.
174
+ */
175
+ export var buildFetchErrorAttribution = function buildFetchErrorAttribution(gateEnabled, error, statusCode) {
176
+ if (!gateEnabled) {
177
+ return undefined;
178
+ }
179
+ var reason = classifyFetchErrorReason(error);
180
+ return _objectSpread({
181
+ reason: reason,
182
+ benign: FETCH_BENIGN_REASONS.has(reason)
183
+ }, statusCode !== undefined && {
184
+ statusCode: statusCode
185
+ });
186
+ };
187
+
63
188
  // `sourceProduct` is threaded through every helper so analytics
64
189
  // events can be attributed to the source product (`confluence-page` vs `jira-work-item`).
65
190
  // All helpers accept it as an optional trailing argument; the spread-only-when-truthy
66
191
  // pattern below ensures we never emit `sourceProduct: undefined` (which would dirty the
67
192
  // event schema with empty keys).
68
193
 
69
- export var getErrorPayload = function getErrorPayload(actionSubjectId, error, resourceId, sourceProduct, attribution) {
194
+ /**
195
+ * Shared operational ERROR payload builder for synced-block events.
196
+ *
197
+ * Overloaded so the emitted `reason` type stays accurate per path (raised in review on
198
+ * EDITOR-7862): fetch/subscribe callers pass a {@link FetchErrorAttributionAttributes}
199
+ * (discriminated by its required `benign` field) and get the wider
200
+ * {@link SyncBlockFetchErrorReason}; write-path callers pass an
201
+ * {@link ErrorAttributionAttributes} (or nothing) and keep the narrower
202
+ * {@link SyncBlockErrorReason}, so write-path payloads never claim to carry fetch-only
203
+ * buckets like `source_deleted` they never emit.
204
+ */
205
+
206
+ export function getErrorPayload(actionSubjectId, error, resourceId, sourceProduct, attribution) {
70
207
  return {
71
208
  action: ACTION.ERROR,
72
209
  actionSubject: ACTION_SUBJECT.SYNCED_BLOCK,
73
210
  actionSubjectId: actionSubjectId,
74
211
  eventType: EVENT_TYPE.OPERATIONAL,
75
- attributes: _objectSpread(_objectSpread(_objectSpread(_objectSpread({
212
+ attributes: _objectSpread(_objectSpread(_objectSpread(_objectSpread(_objectSpread({
76
213
  error: error
77
214
  }, resourceId && {
78
215
  resourceId: resourceId
@@ -82,11 +219,18 @@ export var getErrorPayload = function getErrorPayload(actionSubjectId, error, re
82
219
  reason: attribution.reason
83
220
  }), (attribution === null || attribution === void 0 ? void 0 : attribution.statusCode) !== undefined && {
84
221
  statusCode: attribution.statusCode
222
+ }), attribution && 'benign' in attribution && {
223
+ benign: attribution.benign
85
224
  })
86
225
  };
87
- };
88
- export var fetchErrorPayload = function fetchErrorPayload(error, resourceId, sourceProduct) {
89
- return getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_FETCH, error, resourceId, sourceProduct);
226
+ }
227
+ export var fetchErrorPayload = function fetchErrorPayload(error, resourceId, sourceProduct, attribution) {
228
+ return (
229
+ // Branch on attribution presence so each call resolves to a concrete overload: with
230
+ // attribution it hits the fetch overload (wider `reason`); without it (gate OFF) it
231
+ // hits the no-attribution overload. Both produce a fetch event regardless.
232
+ attribution ? getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_FETCH, error, resourceId, sourceProduct, attribution) : getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_FETCH, error, resourceId, sourceProduct)
233
+ );
90
234
  };
91
235
  export var getSourceInfoErrorPayload = function getSourceInfoErrorPayload(error, resourceId, sourceProduct) {
92
236
  return getErrorPayload(ACTION_SUBJECT_ID.SYNCED_BLOCK_GET_SOURCE_INFO, error, resourceId, sourceProduct);
@@ -7,6 +7,14 @@ import type { SyncBlockData, SyncBlockStatus, ResourceId, SyncBlockError, SyncBl
7
7
  type SyncBlockErrorInfo = {
8
8
  reason?: string;
9
9
  sourceAri?: string;
10
+ /**
11
+ * HTTP status code from the backend (Block Service) when a fetch/subscribe failure
12
+ * originated from a `BlockError`. Surfaced so fetch failure analytics can break down
13
+ * read-path failures by status code (EDITOR-7862). Undefined for non-HTTP failures
14
+ * (e.g. JSON parse errors, missing-content NotFound, or backend error responses that
15
+ * only carry a string `code`).
16
+ */
17
+ statusCode?: number;
10
18
  type: SyncBlockError;
11
19
  };
12
20
  /**