@salesforce/lds-runtime-mobile 1.454.0 → 1.456.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/dist/main.js CHANGED
@@ -1973,16 +1973,18 @@ class LdsAbortController {
1973
1973
  }
1974
1974
 
1975
1975
  class AsyncWorkerPool {
1976
- constructor(concurrency) {
1976
+ constructor(concurrency, onWorkTiming) {
1977
1977
  this.queue = [];
1978
1978
  this.activeWork = [];
1979
1979
  this.concurrency = concurrency;
1980
+ this.onWorkTiming = onWorkTiming;
1980
1981
  }
1981
1982
  push(work) {
1982
1983
  return new Promise((resolve, reject) => {
1983
1984
  this.queue.push({
1984
1985
  ...work,
1985
1986
  workFn: (abortController) => work.workFn(abortController).then(resolve).catch(reject),
1987
+ enqueuedAt: Date.now(),
1986
1988
  });
1987
1989
  this.doWork();
1988
1990
  });
@@ -2025,10 +2027,30 @@ class AsyncWorkerPool {
2025
2027
  const abortController = new LdsAbortController();
2026
2028
  const newWork = { ...work, abortController };
2027
2029
  this.activeWork.push(newWork);
2028
- const { workFn } = work;
2030
+ const { workFn, enqueuedAt } = work;
2031
+ const onWorkTiming = this.onWorkTiming;
2032
+ let startedAt = 0;
2033
+ let waitMs = 0;
2034
+ let queueDepthAtStart = 0;
2035
+ if (onWorkTiming !== undefined) {
2036
+ startedAt = Date.now();
2037
+ waitMs = startedAt - enqueuedAt;
2038
+ // depth AFTER this unit was shifted off — units still waiting behind it.
2039
+ queueDepthAtStart = this.queue.length;
2040
+ }
2029
2041
  workFn(abortController).finally(() => {
2030
- this.activeWork = this.activeWork.filter((w) => w !== newWork);
2031
- this.doWork();
2042
+ try {
2043
+ if (onWorkTiming !== undefined) {
2044
+ onWorkTiming(waitMs, Date.now() - startedAt, queueDepthAtStart);
2045
+ }
2046
+ }
2047
+ catch {
2048
+ // Instrumentation must not prevent the pool from releasing the worker slot.
2049
+ }
2050
+ finally {
2051
+ this.activeWork = this.activeWork.filter((w) => w !== newWork);
2052
+ this.doWork();
2053
+ }
2032
2054
  });
2033
2055
  }
2034
2056
  }
@@ -2358,6 +2380,25 @@ const DRAFT_SEGMENT = 'DRAFT';
2358
2380
  */
2359
2381
  const DRAFT_ACTION_RETRY_COUNT_METADATA_KEY = 'retryCount';
2360
2382
  class DurableDraftQueue {
2383
+ // Instrumentation is strictly measure-only: a broken sink must never change queue behavior.
2384
+ // Pass the sink into the callback so methods are invoked through their owning object and retain
2385
+ // their receiver (some sinks use `this` to reach their metric registry).
2386
+ emitInstrumentation(emit) {
2387
+ const { instrumentation } = this;
2388
+ if (instrumentation === undefined) {
2389
+ return;
2390
+ }
2391
+ try {
2392
+ emit(instrumentation);
2393
+ }
2394
+ catch {
2395
+ // Telemetry failures must not interrupt draft processing.
2396
+ }
2397
+ }
2398
+ resetDrainCycle() {
2399
+ this.drainCycleStart = undefined;
2400
+ this.drainCycleBatchCount = 0;
2401
+ }
2361
2402
  getHandler(id) {
2362
2403
  const handler = this.handlers[id];
2363
2404
  if (handler === undefined) {
@@ -2380,9 +2421,19 @@ class DurableDraftQueue {
2380
2421
  ? __nimbus.plugins.JSLoggerPlugin
2381
2422
  : undefined;
2382
2423
  this.handlers = {};
2424
+ // Drain-wait attribution state. uploadStartTimes stamps when each action first began
2425
+ // uploading (keyed by action id) so actionCompleted/failed can emit its end-to-end drain time,
2426
+ // including automatic retries/backoff. actionQueueWaitEmittedIds ensures those retries do not
2427
+ // report the original enqueue wait more than once. drainCycleStart/BatchCount track one full
2428
+ // idle->empty sweep. All measure-only; when no instrumentation is injected these stay untouched.
2429
+ this.uploadStartTimes = new Map();
2430
+ this.actionQueueWaitEmittedIds = new Set();
2431
+ this.drainCycleBatchCount = 0;
2383
2432
  this.draftStore = draftStore;
2384
- this.workerPool = new AsyncWorkerPool(1);
2385
2433
  this.instrumentation = instrumentation;
2434
+ this.workerPool = new AsyncWorkerPool(1, instrumentation?.onWorkerTiming !== undefined
2435
+ ? (waitMs, holdMs, queueDepth) => this.emitInstrumentation((sink) => sink.onWorkerTiming?.(waitMs, holdMs, queueDepth))
2436
+ : undefined);
2386
2437
  }
2387
2438
  addHandler(handler) {
2388
2439
  const id = handler.handlerId;
@@ -2470,6 +2521,9 @@ class DurableDraftQueue {
2470
2521
  clearTimeout(this.timeoutHandler);
2471
2522
  this.timeoutHandler = undefined;
2472
2523
  }
2524
+ // A stop/user edit breaks the current sweep. A later restart starts a fresh cycle rather
2525
+ // than attributing stopped time to an offline drain.
2526
+ this.resetDrainCycle();
2473
2527
  this.state = DraftQueueState.Stopped;
2474
2528
  }
2475
2529
  async getQueueActions() {
@@ -2500,7 +2554,7 @@ class DurableDraftQueue {
2500
2554
  }
2501
2555
  return aTime - bTime;
2502
2556
  });
2503
- instrumentation?.onGetQueueActions?.(Date.now() - start, sorted.length);
2557
+ this.emitInstrumentation((sink) => sink.onGetQueueActions?.(Date.now() - start, sorted.length));
2504
2558
  return sorted;
2505
2559
  }
2506
2560
  async enqueue(handlerId, data, observabilityContext) {
@@ -2544,6 +2598,13 @@ class DurableDraftQueue {
2544
2598
  await handler.handleActionCompleted(action, queueOperations, values$2(this.handlers));
2545
2599
  this.retryIntervalMilliseconds = 0;
2546
2600
  this.uploadingActionId = undefined;
2601
+ // The action finished draining — emit its end-to-end drain time and
2602
+ // count it toward the current drain-cycle batch. Measure-only.
2603
+ this.emitActionDrainDuration(action.id, action.handler);
2604
+ this.actionQueueWaitEmittedIds.delete(action.id);
2605
+ if (this.drainCycleStart !== undefined) {
2606
+ this.drainCycleBatchCount += 1;
2607
+ }
2547
2608
  await this.notifyChangedListeners({
2548
2609
  type: DraftQueueEventType.ActionCompleted,
2549
2610
  action,
@@ -2555,6 +2616,16 @@ class DurableDraftQueue {
2555
2616
  },
2556
2617
  });
2557
2618
  }
2619
+ // Read+clear the action's upload-start stamp and emit its drain duration. Safe to
2620
+ // call when instrumentation is absent or the id was never stamped (no-op). Never emits the id.
2621
+ emitActionDrainDuration(id, handlerId) {
2622
+ const start = this.uploadStartTimes.get(id);
2623
+ if (start === undefined) {
2624
+ return;
2625
+ }
2626
+ this.uploadStartTimes.delete(id);
2627
+ this.emitInstrumentation((sink) => sink.onActionDrainDuration?.(handlerId, Date.now() - start));
2628
+ }
2558
2629
  async actionFailed(action, retry, retryDelayInMs, actionDataChanged) {
2559
2630
  if (actionDataChanged === true) {
2560
2631
  await this.draftStore.writeAction({
@@ -2564,14 +2635,24 @@ class DurableDraftQueue {
2564
2635
  }
2565
2636
  this.uploadingActionId = undefined;
2566
2637
  if (retry && this.state !== DraftQueueState.Stopped) {
2638
+ // A retry means this action is NOT done draining. Keep its first-upload stamp and the
2639
+ // open cycle so the eventual outcome includes network time and retry/backoff.
2567
2640
  this.state = DraftQueueState.Waiting;
2568
2641
  return retryDelayInMs !== undefined
2569
2642
  ? this.scheduleRetryWithSpecifiedDelay(retryDelayInMs)
2570
2643
  : this.scheduleRetry();
2571
2644
  }
2572
2645
  else if (isDraftError(action)) {
2646
+ // Terminal failure — this action is done draining. Emit its drain time.
2647
+ this.emitActionDrainDuration(action.id, action.handler);
2648
+ // This was not a clean drain-to-empty. Discard the sweep before user intervention so a
2649
+ // later manual retry/removal cannot inherit its elapsed time or completed batch count.
2650
+ this.resetDrainCycle();
2573
2651
  return this.handleServerError(action, action.error);
2574
2652
  }
2653
+ // Any other terminal path — ensure no stale start stamp lingers.
2654
+ this.emitActionDrainDuration(action.id, action.handler);
2655
+ this.resetDrainCycle();
2575
2656
  }
2576
2657
  handle(action) {
2577
2658
  const handler = this.getHandler(action.handler);
@@ -2591,12 +2672,22 @@ class DurableDraftQueue {
2591
2672
  this.state = DraftQueueState.Started;
2592
2673
  }
2593
2674
  this.processingAction = undefined;
2675
+ // The queue drained empty — close the drain-cycle window if one was open.
2676
+ if (this.drainCycleStart !== undefined) {
2677
+ const durationMs = Date.now() - this.drainCycleStart;
2678
+ const batchSize = this.drainCycleBatchCount;
2679
+ this.resetDrainCycle();
2680
+ this.emitInstrumentation((sink) => sink.onDrainCycle?.(durationMs, batchSize));
2681
+ }
2594
2682
  return ProcessActionResult.NO_ACTION_TO_PROCESS;
2595
2683
  }
2596
2684
  const { status, id } = action;
2597
2685
  if (status === DraftActionStatus.Error) {
2598
2686
  this.state = DraftQueueState.Error;
2599
2687
  this.processingAction = undefined;
2688
+ // The sweep is blocked on an errored action, not a clean drain-to-empty —
2689
+ // discard the open drain-cycle window so it doesn't inflate a later cycle's duration.
2690
+ this.resetDrainCycle();
2600
2691
  await this.notifyChangedListeners({
2601
2692
  type: DraftQueueEventType.ActionFailed,
2602
2693
  action: action,
@@ -2614,6 +2705,31 @@ class DurableDraftQueue {
2614
2705
  if (this.state === DraftQueueState.Waiting) {
2615
2706
  this.state = DraftQueueState.Started;
2616
2707
  }
2708
+ // The action is now leaving the queue to upload. Emit head-of-line FIFO wait
2709
+ // (enqueue -> upload start), stamp its drain-start for end-to-end timing on completion, and
2710
+ // open a drain-cycle window if the queue was previously idle. All measure-only.
2711
+ const { instrumentation } = this;
2712
+ if (instrumentation !== undefined) {
2713
+ const now = Date.now();
2714
+ const { timestamp } = action;
2715
+ if (instrumentation.onActionQueueWait !== undefined &&
2716
+ !this.actionQueueWaitEmittedIds.has(id) &&
2717
+ typeof timestamp === 'number' &&
2718
+ timestamp > 0) {
2719
+ // Mark before invoking the sink: even a throwing callback must not make a retry
2720
+ // report the same action's enqueue wait again.
2721
+ this.actionQueueWaitEmittedIds.add(id);
2722
+ this.emitInstrumentation((sink) => sink.onActionQueueWait?.(action.handler, now - timestamp));
2723
+ }
2724
+ if (instrumentation.onActionDrainDuration !== undefined &&
2725
+ !this.uploadStartTimes.has(id)) {
2726
+ this.uploadStartTimes.set(id, now);
2727
+ }
2728
+ if (instrumentation.onDrainCycle !== undefined && this.drainCycleStart === undefined) {
2729
+ this.drainCycleStart = now;
2730
+ this.drainCycleBatchCount = 0;
2731
+ }
2732
+ }
2617
2733
  await this.notifyChangedListeners({
2618
2734
  type: DraftQueueEventType.ActionUploading,
2619
2735
  action: { ...action, status: DraftActionStatus.Uploading },
@@ -2651,7 +2767,7 @@ class DurableDraftQueue {
2651
2767
  results.push(listener(event));
2652
2768
  }
2653
2769
  // W-23515682: listener fan-out per event type. Measure-only.
2654
- this.instrumentation?.onNotifyChangedListeners?.(event.type, draftQueueLen);
2770
+ this.emitInstrumentation((sink) => sink.onNotifyChangedListeners?.(event.type, draftQueueLen));
2655
2771
  await Promise.all(results);
2656
2772
  }
2657
2773
  /**
@@ -2680,9 +2796,17 @@ class DurableDraftQueue {
2680
2796
  const shouldDeleteRelated = handler.shouldDeleteActionByTagOnRemoval(action);
2681
2797
  if (shouldDeleteRelated) {
2682
2798
  await this.draftStore.deleteByTag(action.tag);
2799
+ queue.forEach((queuedAction) => {
2800
+ if (queuedAction.tag === action.tag) {
2801
+ this.uploadStartTimes.delete(queuedAction.id);
2802
+ this.actionQueueWaitEmittedIds.delete(queuedAction.id);
2803
+ }
2804
+ });
2683
2805
  }
2684
2806
  else {
2685
2807
  await this.draftStore.deleteDraft(action.id);
2808
+ this.uploadStartTimes.delete(action.id);
2809
+ this.actionQueueWaitEmittedIds.delete(action.id);
2686
2810
  }
2687
2811
  await handler.handleActionRemoved(action, queue.filter((x) => x.id !== actionId));
2688
2812
  await this.notifyChangedListeners({
@@ -42336,6 +42460,20 @@ const DRAFT_QUEUE_GET_QUEUE_ACTIONS_DURATION = 'draft-queue-get-queue-actions-du
42336
42460
  const DRAFT_QUEUE_NOTIFY_CHANGED_LISTENERS_COUNT = 'draft-queue-notify-changed-listeners-count';
42337
42461
  const DRAFT_QUEUE_DURABLE_OP_COUNT = 'draft-queue-durable-op-count';
42338
42462
  const DRAFT_QUEUE_DURABLE_OP_DURATION = 'draft-queue-durable-op-duration';
42463
+ // Drain-wait attribution telemetry: the wall-clock spine of where an offline drain actually spends
42464
+ // WAITING. Operational counts/durations/depths only — no draft content, field values, or server
42465
+ // record ids (matching the existing draft-queue privacy stance).
42466
+ const DRAFT_QUEUE_WORKER_WAIT_DURATION = 'draft-queue-worker-wait-duration';
42467
+ const DRAFT_QUEUE_WORKER_HOLD_DURATION = 'draft-queue-worker-hold-duration';
42468
+ const DRAFT_QUEUE_ACTION_QUEUE_WAIT_DURATION = 'draft-queue-action-queue-wait-duration';
42469
+ const DRAFT_QUEUE_ACTION_DRAIN_DURATION = 'draft-queue-action-drain-duration';
42470
+ const DRAFT_QUEUE_DRAIN_CYCLE_DURATION = 'draft-queue-drain-cycle-duration';
42471
+ const DRAFT_QUEUE_DRAIN_CYCLE_BATCH_SIZE = 'draft-queue-drain-cycle-batch-size';
42472
+ const DRAFT_QUEUE_ACTION_NETWORK_DURATION = 'draft-queue-action-network-duration';
42473
+ const DRAFT_QUEUE_ACTION_RETRY_ATTEMPT = 'draft-queue-action-retry-attempt';
42474
+ const DRAFT_QUEUE_SIDEEFFECT_OP_DURATION = 'draft-queue-sideeffect-op-duration';
42475
+ const DRAFT_QUEUE_SIDEEFFECT_MAP_SIZE = 'draft-queue-sideeffect-map-size';
42476
+ const DRAFT_QUEUE_SIDEEFFECT_REGION_DURATION = 'draft-queue-sideeffect-region-duration';
42339
42477
  /** Content Document */
42340
42478
  const CREATE_CONTENT_DOCUMENT_AND_VERSION_TOTAL_SYNTHESIZE_CALLS = 'content-document-version-total-synthesize-calls';
42341
42479
  const CREATE_CONTENT_DOCUMENT_AND_VERSION_DRAFT_SYNTHESIZE_ERROR = 'create-content-document-version-draft-synthesize-error';
@@ -42455,6 +42593,134 @@ function reportDraftQueueDurableOperation(op, segment, durationMs) {
42455
42593
  });
42456
42594
  ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_DURABLE_OP_DURATION, durationMs);
42457
42595
  }
42596
+ const DRAIN_DURATION_BUCKETS = [50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000];
42597
+ const WORKER_DEPTH_BUCKETS = [1, 2, 5, 10, 20, 50, 100, 250, 500];
42598
+ // Handler ids are extensible through DraftManager, so only retain the fixed mobile handlers in
42599
+ // metric tags. Everything else shares one bounded bucket rather than creating an unbounded series.
42600
+ function draftQueueHandlerTagOf(handlerId) {
42601
+ switch (handlerId) {
42602
+ case 'LDS_ACTION_HANDLER':
42603
+ case 'ContentDocumentCompositeRepresentationActionHandler':
42604
+ case 'QUICK_ACTION_HANDLER':
42605
+ case 'UPDATE_RECORD_QUICK_ACTION_HANDLER':
42606
+ return handlerId;
42607
+ default:
42608
+ return 'custom';
42609
+ }
42610
+ }
42611
+ // Coarse, bounded-cardinality label for the pool depth a unit saw when it began, so the
42612
+ // wait/hold durations can be sliced BY backlog size without emitting depth as a free integer tag.
42613
+ function workerDepthBucketOf(queueDepth) {
42614
+ if (queueDepth <= 0) {
42615
+ return '0';
42616
+ }
42617
+ if (queueDepth <= 2) {
42618
+ return '1-2';
42619
+ }
42620
+ if (queueDepth <= 10) {
42621
+ return '3-10';
42622
+ }
42623
+ if (queueDepth <= 50) {
42624
+ return '11-50';
42625
+ }
42626
+ if (queueDepth <= 250) {
42627
+ return '51-250';
42628
+ }
42629
+ return '250+';
42630
+ }
42631
+ /**
42632
+ * Per-unit lifecycle split for the AsyncWorkerPool(1) serial queue, emitted once when a unit settles
42633
+ * so the two halves are a JOINED event (rather than shipping wait and depth as unrelated aggregates).
42634
+ * Isolates WHERE the time goes:
42635
+ * waitMs — enqueue -> work began (waiting on the serial queue before any logic runs).
42636
+ * holdMs — work began -> work settled (the critical section's own O(N) service time).
42637
+ * Both are additionally tagged by a depthBucket so "does per-unit cost scale with backlog depth"
42638
+ * (the O(N^2) signature) survives aggregation. queueDepth itself is also tracked. Operational only.
42639
+ */
42640
+ function reportDraftQueueWorkerTiming(waitMs, holdMs, queueDepth) {
42641
+ const depthBucket = workerDepthBucketOf(queueDepth);
42642
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_WORKER_WAIT_DURATION, waitMs, undefined, {
42643
+ depthBucket,
42644
+ });
42645
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_WORKER_HOLD_DURATION, holdMs, undefined, {
42646
+ depthBucket,
42647
+ });
42648
+ ldsMobileInstrumentation.bucketValue(DRAFT_QUEUE_WORKER_WAIT_DURATION + '-depth', queueDepth, WORKER_DEPTH_BUCKETS);
42649
+ }
42650
+ /**
42651
+ * Head-of-line FIFO wait for one draft (enqueue -> upload start), bucketed so the
42652
+ * "does wait grow with backlog depth" (O(N^2)) signal is visible per handler type. Custom handler
42653
+ * ids are normalized to one bounded tag so caller-defined values never become metric dimensions.
42654
+ */
42655
+ function reportDraftQueueActionQueueWait(handlerId, waitMs) {
42656
+ ldsMobileInstrumentation.bucketValue(DRAFT_QUEUE_ACTION_QUEUE_WAIT_DURATION, waitMs, DRAIN_DURATION_BUCKETS, undefined, { handlerId: draftQueueHandlerTagOf(handlerId) });
42657
+ }
42658
+ /**
42659
+ * End-to-end drain time for one action (first upload attempt -> completed/failed), including
42660
+ * retry/backoff. Compare it with network-duration and retry-attempt signals to understand the
42661
+ * non-request portion of the drain interval.
42662
+ */
42663
+ function reportDraftQueueActionDrainDuration(handlerId, durationMs) {
42664
+ ldsMobileInstrumentation.bucketValue(DRAFT_QUEUE_ACTION_DRAIN_DURATION, durationMs, DRAIN_DURATION_BUCKETS, undefined, { handlerId: draftQueueHandlerTagOf(handlerId) });
42665
+ }
42666
+ /**
42667
+ * One full drain sweep — first action starts uploading -> queue empties. Both raw values share a
42668
+ * bounded batch-size cohort so aggregate duration remains sliceable by the number of actions drained.
42669
+ */
42670
+ function reportDraftQueueDrainCycle(durationMs, batchSize) {
42671
+ const batchSizeBucket = workerDepthBucketOf(batchSize);
42672
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_DRAIN_CYCLE_DURATION, durationMs, undefined, {
42673
+ batchSizeBucket,
42674
+ });
42675
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_DRAIN_CYCLE_BATCH_SIZE, batchSize, undefined, {
42676
+ batchSizeBucket,
42677
+ });
42678
+ }
42679
+ /**
42680
+ * Server round-trip duration for one upload. Isolates slow-network from CPU-bound
42681
+ * quadratic work — today only a THROWN error is captured, so a slow-but-successful (or slow 4xx/5xx)
42682
+ * response is invisible. Tags: handler type, HTTP method, and a bucketed status class ('2xx'/'4xx'/
42683
+ * '5xx'/'0'). No URL, body, or record id.
42684
+ */
42685
+ function reportDraftQueueActionNetworkDuration(handlerId, method, statusBucket, durationMs) {
42686
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_ACTION_NETWORK_DURATION, durationMs, undefined, {
42687
+ handlerId: draftQueueHandlerTagOf(handlerId),
42688
+ method,
42689
+ statusBucket,
42690
+ });
42691
+ }
42692
+ /**
42693
+ * Retry event count. Repeated events identify actions cycling through the drain even when the retry
42694
+ * cap is disabled and an exact attempt number is not persisted. cappedOut marks cap exhaustion.
42695
+ */
42696
+ function reportDraftQueueActionRetryAttempt(handlerId, cappedOut) {
42697
+ ldsMobileInstrumentation.incrementCounter(DRAFT_QUEUE_ACTION_RETRY_ATTEMPT, 1, undefined, {
42698
+ handlerId: draftQueueHandlerTagOf(handlerId),
42699
+ cappedOut: String(cappedOut),
42700
+ });
42701
+ }
42702
+ /**
42703
+ * SideEffectStore operation duration + map size. The incremental map sped up the by-KEY lookup but
42704
+ * getEffectsByTag still linearly scans all effects — this measures whether that residual scan is
42705
+ * silently quadratic during churn. op is 'add'|'remove'|'getByTag'; incremental flags the fast path;
42706
+ * mapSize is an integer effect count. No record keys, field names, or field values.
42707
+ */
42708
+ function reportDraftQueueSideEffectOp(op, incremental, durationMs, mapSize) {
42709
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_SIDEEFFECT_OP_DURATION, durationMs, undefined, {
42710
+ op,
42711
+ incremental: String(incremental),
42712
+ });
42713
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_SIDEEFFECT_MAP_SIZE, mapSize, undefined, {
42714
+ op,
42715
+ });
42716
+ }
42717
+ /**
42718
+ * Broad side-effect lifecycle duration. The fixed region and lifecycle tags identify which part of
42719
+ * serialized draft processing is slow without emitting record keys, action ids, or field data.
42720
+ */
42721
+ function reportDraftQueueSideEffectRegionDuration(region, lifecycle, durationMs) {
42722
+ ldsMobileInstrumentation.trackValue(DRAFT_QUEUE_SIDEEFFECT_REGION_DURATION, durationMs, undefined, { region, lifecycle });
42723
+ }
42458
42724
  /**
42459
42725
  * Reports an exception thrown while uploading a draft action. The thrown error is
42460
42726
  * otherwise swallowed by the upload handler's retry path, leaving the failure
@@ -42662,8 +42928,16 @@ class AbstractResourceRequestActionHandler {
42662
42928
  const dispatchOptions = action.observabilityContext !== undefined
42663
42929
  ? { requestCorrelator: { observabilityContext: action.observabilityContext } }
42664
42930
  : {};
42931
+ const method = request?.method?.toUpperCase() ?? 'UNKNOWN';
42932
+ const networkStart = Date.now();
42933
+ let responseReceived = false;
42665
42934
  try {
42666
42935
  const response = await this.networkAdapter(request, dispatchOptions);
42936
+ responseReceived = true;
42937
+ // Time the round-trip regardless of outcome (ok, retryable HTTP error, or non-ok
42938
+ // terminal error) — today only a THROWN error is visible; a slow-but-successful or
42939
+ // slow 4xx/5xx response is not.
42940
+ this.reportNetworkDuration(method, statusBucketOf(response.status), networkStart);
42667
42941
  if (response.ok) {
42668
42942
  await actionCompleted({
42669
42943
  ...action,
@@ -42819,6 +43093,11 @@ class AbstractResourceRequestActionHandler {
42819
43093
  const error = normalizeError$1(e);
42820
43094
  const attempt = this.getUploadRetryCount(action) + 1;
42821
43095
  reportDraftActionUploadError(error, this.handlerId, attempt);
43096
+ // A thrown network call still has a round-trip duration worth surfacing — a slow
43097
+ // FAILING call is otherwise as invisible as a slow successful one.
43098
+ if (!responseReceived) {
43099
+ this.reportNetworkDuration(method, 'error', networkStart);
43100
+ }
42822
43101
  await this.retryOrCap(action, error, actionErrored);
42823
43102
  return ProcessActionResult.NETWORK_ERROR;
42824
43103
  }
@@ -42854,12 +43133,17 @@ class AbstractResourceRequestActionHandler {
42854
43133
  */
42855
43134
  async retryOrCap(action, lastFailure, actionErrored, retryDelayInMs, actionDataChanged) {
42856
43135
  if (!draftQueueMaxRetryAttemptsGate.isOpen({ fallback: false })) {
42857
- // Gate closed: preserve the pre-existing unbounded-retry behavior exactly.
43136
+ // Gate closed: preserve the pre-existing unbounded-retry behavior exactly. The
43137
+ // metric is still emitted (measure-only) so retry attempts are visible even with
43138
+ // the cap gate closed — cappedOut is always false here since nothing ever caps
43139
+ // out on this path.
43140
+ this.reportRetryAttempt(false);
42858
43141
  await actionErrored(action, true, retryDelayInMs, actionDataChanged);
42859
43142
  return;
42860
43143
  }
42861
43144
  const attempt = this.getUploadRetryCount(action) + 1;
42862
43145
  if (attempt >= MAX_RETRY_ATTEMPTS) {
43146
+ this.reportRetryAttempt(true);
42863
43147
  await actionErrored({
42864
43148
  ...action,
42865
43149
  error: buildMaxRetryError(lastFailure),
@@ -42867,6 +43151,7 @@ class AbstractResourceRequestActionHandler {
42867
43151
  }, false);
42868
43152
  return;
42869
43153
  }
43154
+ this.reportRetryAttempt(false);
42870
43155
  // Persist the incremented attempt count so it survives restarts. Forcing
42871
43156
  // actionDataChanged ensures the queue writes the updated metadata before the
42872
43157
  // action is rescheduled for retry.
@@ -42880,12 +43165,12 @@ class AbstractResourceRequestActionHandler {
42880
43165
  await actionErrored(retryingAction, true, retryDelayInMs, true);
42881
43166
  }
42882
43167
  async handleActionEnqueued(action) {
42883
- const impactedKeys = await this.recordService.setSideEffectsForActions([action]);
42884
- await this.recordService.reapplyRecordSideEffects(impactedKeys);
43168
+ const impactedKeys = await this.recordService.setSideEffectsForActions([action], 'enqueued');
43169
+ await this.recordService.reapplyRecordSideEffects(impactedKeys, 'enqueued');
42885
43170
  }
42886
43171
  async handleActionRemoved(action) {
42887
43172
  const impactedKeys = await this.recordService.removeSideEffectsForAction(action);
42888
- await this.recordService.reapplyRecordSideEffects(impactedKeys);
43173
+ await this.recordService.reapplyRecordSideEffects(impactedKeys, 'removed');
42889
43174
  }
42890
43175
  async handleActionReplaced(target, source) {
42891
43176
  // remove both the siden effects from the actions being merged before we
@@ -42893,10 +43178,10 @@ class AbstractResourceRequestActionHandler {
42893
43178
  const impactedKeysFromRemove = await this.recordService.removeSideEffectsForAction(source);
42894
43179
  const removedSideEffectFromSet = await this.recordService.removeSideEffectsForAction(target);
42895
43180
  // revert the values in the DEFAULT store back to what it was before the removed side effects
42896
- await this.recordService.reapplyRecordSideEffects(new Set([...impactedKeysFromRemove, ...removedSideEffectFromSet]));
43181
+ await this.recordService.reapplyRecordSideEffects(new Set([...impactedKeysFromRemove, ...removedSideEffectFromSet]), 'replaced-remove');
42897
43182
  // set the new side effect values for the merged action
42898
- const impactedKeysFromSet = await this.recordService.setSideEffectsForActions([target]);
42899
- await this.recordService.reapplyRecordSideEffects(new Set([...impactedKeysFromSet]));
43183
+ const impactedKeysFromSet = await this.recordService.setSideEffectsForActions([target], 'replaced-set');
43184
+ await this.recordService.reapplyRecordSideEffects(new Set([...impactedKeysFromSet]), 'replaced-set');
42900
43185
  }
42901
43186
  async handleActionCompleted(action, queueOperations) {
42902
43187
  const { data: request, tag } = action;
@@ -42930,8 +43215,8 @@ class AbstractResourceRequestActionHandler {
42930
43215
  actionsNeedingReplay.push(qo.action);
42931
43216
  }
42932
43217
  });
42933
- const impactedKeys = await this.recordService.setSideEffectsForActions(actionsNeedingReplay);
42934
- await this.recordService.reapplyRecordSideEffects(impactedKeys);
43218
+ const impactedKeys = await this.recordService.setSideEffectsForActions(actionsNeedingReplay, 'completed-replay');
43219
+ await this.recordService.reapplyRecordSideEffects(impactedKeys, 'completed-replay');
42935
43220
  }
42936
43221
  getQueueOperationsForCompletingDrafts(queue, action) {
42937
43222
  const queueOperations = [];
@@ -43257,6 +43542,39 @@ class AbstractResourceRequestActionHandler {
43257
43542
  return new StoreKeyMap();
43258
43543
  });
43259
43544
  }
43545
+ reportNetworkDuration(method, statusBucket, startedAt) {
43546
+ try {
43547
+ reportDraftQueueActionNetworkDuration(this.handlerId, method, statusBucket, Date.now() - startedAt);
43548
+ }
43549
+ catch {
43550
+ // Telemetry must not change upload processing behavior.
43551
+ }
43552
+ }
43553
+ reportRetryAttempt(cappedOut) {
43554
+ try {
43555
+ reportDraftQueueActionRetryAttempt(this.handlerId, cappedOut);
43556
+ }
43557
+ catch {
43558
+ // Telemetry must not change retry or cap behavior.
43559
+ }
43560
+ }
43561
+ }
43562
+ // Classifies an HTTP status into a small, greppable tag set for the network-duration metric —
43563
+ // never the raw status, to keep the metric's tag cardinality bounded.
43564
+ function statusBucketOf(status) {
43565
+ if (status === 0) {
43566
+ return '0';
43567
+ }
43568
+ if (status >= 200 && status <= 299) {
43569
+ return '2xx';
43570
+ }
43571
+ if (status >= 400 && status <= 499) {
43572
+ return '4xx';
43573
+ }
43574
+ if (status >= 500) {
43575
+ return '5xx';
43576
+ }
43577
+ return 'other';
43260
43578
  }
43261
43579
  function actionsForTag(tag, queue) {
43262
43580
  return queue.filter((action) => action.tag === tag);
@@ -43620,10 +43938,16 @@ class UiApiDraftRecordService {
43620
43938
  }
43621
43939
  // calculates the side effects for a given set of actions
43622
43940
  // and returns a set of impacted record keys
43623
- async setSideEffectsForActions(changedActions) {
43624
- const operations = await this.recalculateForChangedActions(changedActions);
43625
- await this.sideEffectStore.addEffects(operations);
43626
- return new Set(operations.map((o) => o.key));
43941
+ async setSideEffectsForActions(changedActions, lifecycle) {
43942
+ const startedAt = Date.now();
43943
+ try {
43944
+ const operations = await this.recalculateForChangedActions(changedActions);
43945
+ await this.sideEffectStore.addEffects(operations);
43946
+ return new Set(operations.map((o) => o.key));
43947
+ }
43948
+ finally {
43949
+ this.reportSideEffectRegionDuration('set', lifecycle, startedAt);
43950
+ }
43627
43951
  }
43628
43952
  // removes the associated side effects for a given action and returns
43629
43953
  // a set of impacted record keys
@@ -43634,31 +43958,45 @@ class UiApiDraftRecordService {
43634
43958
  }
43635
43959
  // reads a set of records out of the durable store, re-applies current side
43636
43960
  // effects and persists the changed record
43637
- async reapplyRecordSideEffects(recordKeys) {
43638
- const luvio = this.getLuvio();
43639
- const keys = Array.from(recordKeys);
43640
- const recordEntries = await this.durableRecordStore.getRecordsWithMetadata(keys);
43641
- keys.forEach((key) => {
43642
- let record;
43643
- const metadata = {
43644
- namespace: 'UiApi',
43645
- representationName: 'RecordRepresentation',
43646
- version: VERSION$1v,
43647
- ingestionTimestamp: Date.now(),
43648
- ttl: Number.MAX_SAFE_INTEGER,
43649
- };
43650
- const recordEntry = recordEntries.get(key);
43651
- if (recordEntry) {
43652
- record = recordEntry.record;
43653
- if (recordEntry.metadata) {
43654
- metadata.ingestionTimestamp = recordEntry.metadata.ingestionTimestamp;
43655
- metadata.ttl = RecordRepresentationTTL;
43961
+ async reapplyRecordSideEffects(recordKeys, lifecycle) {
43962
+ const startedAt = Date.now();
43963
+ try {
43964
+ const luvio = this.getLuvio();
43965
+ const keys = Array.from(recordKeys);
43966
+ const recordEntries = await this.durableRecordStore.getRecordsWithMetadata(keys);
43967
+ keys.forEach((key) => {
43968
+ let record;
43969
+ const metadata = {
43970
+ namespace: 'UiApi',
43971
+ representationName: 'RecordRepresentation',
43972
+ version: VERSION$1v,
43973
+ ingestionTimestamp: Date.now(),
43974
+ ttl: Number.MAX_SAFE_INTEGER,
43975
+ };
43976
+ const recordEntry = recordEntries.get(key);
43977
+ if (recordEntry) {
43978
+ record = recordEntry.record;
43979
+ if (recordEntry.metadata) {
43980
+ metadata.ingestionTimestamp = recordEntry.metadata.ingestionTimestamp;
43981
+ metadata.ttl = RecordRepresentationTTL;
43982
+ }
43656
43983
  }
43657
- }
43658
- luvio.storePublish(key, record);
43659
- luvio.publishStoreMetadata(key, metadata);
43660
- });
43661
- await luvio.storeBroadcast();
43984
+ luvio.storePublish(key, record);
43985
+ luvio.publishStoreMetadata(key, metadata);
43986
+ });
43987
+ await luvio.storeBroadcast();
43988
+ }
43989
+ finally {
43990
+ this.reportSideEffectRegionDuration('reapply', lifecycle, startedAt);
43991
+ }
43992
+ }
43993
+ reportSideEffectRegionDuration(region, lifecycle, startedAt) {
43994
+ try {
43995
+ reportDraftQueueSideEffectRegionDuration(region, lifecycle, Date.now() - startedAt);
43996
+ }
43997
+ catch {
43998
+ // Telemetry must not change draft processing behavior.
43999
+ }
43662
44000
  }
43663
44001
  async synthesizeId(apiName) {
43664
44002
  const objectInfo = await this.objectInfoService.getObjectInfo(apiName);
@@ -43894,7 +44232,7 @@ class UpdateRecordQuickActionExecutionRepresentationHandler extends AbstractQuic
43894
44232
  // since we don't ingest a record from the response, we need to manually trigger a re-apply here to ensure we
43895
44233
  // re-ingest the record without effects applied
43896
44234
  await this.recordService.removeSideEffectsForAction(action);
43897
- await this.recordService.reapplyRecordSideEffects(new Set([action.tag]));
44235
+ await this.recordService.reapplyRecordSideEffects(new Set([action.tag]), 'quick-action-completed');
43898
44236
  // notify luvio that record is now changed and needs to be refreshed
43899
44237
  await luvio.notifyStoreUpdateAvailable([action.tag]);
43900
44238
  }
@@ -52580,6 +52918,18 @@ const draftQueueInstrumentation = {
52580
52918
  onDurableOperation(op, segment, durationMs) {
52581
52919
  reportDraftQueueDurableOperation(op, segment, durationMs);
52582
52920
  },
52921
+ onWorkerTiming(waitMs, holdMs, queueDepth) {
52922
+ reportDraftQueueWorkerTiming(waitMs, holdMs, queueDepth);
52923
+ },
52924
+ onActionQueueWait(handlerId, waitMs) {
52925
+ reportDraftQueueActionQueueWait(handlerId, waitMs);
52926
+ },
52927
+ onActionDrainDuration(handlerId, durationMs) {
52928
+ reportDraftQueueActionDrainDuration(handlerId, durationMs);
52929
+ },
52930
+ onDrainCycle(durationMs, batchSize) {
52931
+ reportDraftQueueDrainCycle(durationMs, batchSize);
52932
+ },
52583
52933
  };
52584
52934
 
52585
52935
  // so eslint doesn't complain about nimbus
@@ -56070,6 +56420,9 @@ class SideEffectStore {
56070
56420
  this.initialize();
56071
56421
  }
56072
56422
  async addEffects(effects) {
56423
+ // Wall-clock across the whole method (including the durable write), matching
56424
+ // how the equivalent removeEffects/getEffectsByTag windows are measured.
56425
+ const startTime = Date.now();
56073
56426
  const incremental = draftQueueSideEffectMapGate.isOpen({ fallback: true });
56074
56427
  const durableEntries = {};
56075
56428
  effects.forEach((effect) => {
@@ -56084,8 +56437,11 @@ class SideEffectStore {
56084
56437
  this.regenerateKeyToEffectMap();
56085
56438
  }
56086
56439
  await this.durableStore.setEntries(durableEntries, SIDE_EFFECT_SEGMENT);
56440
+ this.reportOperation('add', incremental, startTime);
56087
56441
  }
56088
56442
  async removeEffects(effects) {
56443
+ // See addEffects — same wall-clock-across-the-method measurement.
56444
+ const startTime = Date.now();
56089
56445
  const incremental = draftQueueSideEffectMapGate.isOpen({ fallback: true });
56090
56446
  const effectKeys = effects.map((e) => e.uniqueId);
56091
56447
  effects.forEach((effect) => {
@@ -56099,6 +56455,7 @@ class SideEffectStore {
56099
56455
  this.regenerateKeyToEffectMap();
56100
56456
  }
56101
56457
  await this.durableStore.evictEntries(effectKeys, SIDE_EFFECT_SEGMENT);
56458
+ this.reportOperation('remove', incremental, startTime);
56102
56459
  }
56103
56460
  getEffects(key) {
56104
56461
  const effects = this.keyToEffectMap.get(key);
@@ -56108,6 +56465,10 @@ class SideEffectStore {
56108
56465
  return effects;
56109
56466
  }
56110
56467
  getEffectsByTag(tag) {
56468
+ // This scan is still O(N) over every effect regardless of the incremental-map
56469
+ // gate — timing it tells us whether that residual scan is the quadratic hotspot during churn.
56470
+ const startTime = Date.now();
56471
+ const incremental = draftQueueSideEffectMapGate.isOpen({ fallback: true });
56111
56472
  const filteredEffects = [];
56112
56473
  const { keyToEffectMap } = this;
56113
56474
  // Loop through every entry in the map
@@ -56117,7 +56478,17 @@ class SideEffectStore {
56117
56478
  // Add the matching effects to the filtered list
56118
56479
  filteredEffects.push(...matchingEffects);
56119
56480
  }
56120
- return filteredEffects.sort((a, b) => a.timestamp - b.timestamp);
56481
+ const result = filteredEffects.sort((a, b) => a.timestamp - b.timestamp);
56482
+ this.reportOperation('getByTag', incremental, startTime);
56483
+ return result;
56484
+ }
56485
+ reportOperation(op, incremental, startedAt) {
56486
+ try {
56487
+ reportDraftQueueSideEffectOp(op, incremental, Date.now() - startedAt, this.allEffects.size);
56488
+ }
56489
+ catch {
56490
+ // Telemetry must not change side-effect store behavior.
56491
+ }
56121
56492
  }
56122
56493
  async initialize() {
56123
56494
  const effects = await this.durableStore.getAllEntries(SIDE_EFFECT_SEGMENT);
@@ -59331,7 +59702,7 @@ function buildServiceDescriptor$b(luvio) {
59331
59702
  },
59332
59703
  };
59333
59704
  }
59334
- // version: 1.454.0-4223ab4365
59705
+ // version: 1.456.0-cef836b978
59335
59706
 
59336
59707
  /**
59337
59708
  * Copyright (c) 2022, Salesforce, Inc.,
@@ -59357,7 +59728,7 @@ function buildServiceDescriptor$a(notifyRecordUpdateAvailable, getNormalizedLuvi
59357
59728
  },
59358
59729
  };
59359
59730
  }
59360
- // version: 1.454.0-4223ab4365
59731
+ // version: 1.456.0-cef836b978
59361
59732
 
59362
59733
  function findExecutableOperation(input) {
59363
59734
  const operations = input.query.definitions.filter(
@@ -62069,4 +62440,4 @@ register({
62069
62440
  });
62070
62441
 
62071
62442
  export { O11Y_NAMESPACE_LDS_MOBILE, getRuntime, ingest$1o as ingestDenormalizedRecordRepresentation, initializeOneStore, registerReportObserver, reportGraphqlQueryParseError };
62072
- // version: 1.454.0-4223ab4365
62443
+ // version: 1.456.0-cef836b978