Repository: webex/webex-js-sdk
Pull request #4234: fix(plugin-meetings): metrics on reachability subnet
Author: login: k-wasniowski
Labels: name: validated
description: If the pull request is validated for automation.
Created: 2025-05-05T15:50:16Z | Merged: 2025-05-12T14:00:05Z

<!--
Hey there,\
Thank you for taking the time to improve our code! 🙂\
Please let us know why this change is necessary and what testing you have performed. \
This ensures our reviewers understand the impact of your change. \

**IMPORTANT**
FAILING TO FILL OUT THIS TEMPLATE WILL RESULT IN REJECTION OF YOUR PULL REQUEST
This is for compliance purposes with FedRAMP program.
-->

# COMPLETES #[SPARK-660018](https://jira-eng-gpk2.cisco.com/jira/browse/SPARK-660018)

## This pull request addresses

Add metrics on servers subnet reachability.

## by making the following changes

### Change Type

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation update
- [ ] Tooling change
- [ ] Internal code refactor

## The following scenarios were tested

Tested with JS-SDK sample app

### I certified that

- [x] I have read and followed [contributing guidelines](https://github.com/webex/webex-js-sdk/blob/master/CONTRIBUTING.md#submitting-a-pull-request)
- [x] I discussed changes with code owners prior to submitting this pull request

- [x] I have not skipped any automated checks
- [x] All existing and new tests passed
- [x] I have updated the documentation accordingly

---

Make sure to have followed the [contributing guidelines](https://github.com/webex/webex-js-sdk/blob/master/CONTRIBUTING.md#submitting-a-pull-request) before submitting.



Diff:
diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts
index 76914428b95..4f81fc5b20d 100644
--- a/packages/@webex/plugin-meetings/src/meeting/index.ts
+++ b/packages/@webex/plugin-meetings/src/meeting/index.ts
@@ -722,6 +722,7 @@ export default class Meeting extends StatelessWebexPlugin {
   private rtcMetrics?: RtcMetrics;
   private uploadLogsTimer?: ReturnType<typeof setTimeout>;
   private logUploadIntervalIndex: number;
+  private mediaServerIp: string;
 
   /**
    * @param {Object} attrs
@@ -1598,6 +1599,15 @@ export default class Meeting extends StatelessWebexPlugin {
      * @memberof Meeting
      */
     this.#isoLocalClientMeetingJoinTime = undefined;
+
+    /**
+     * IP Address of the remote media server
+     * @instance
+     * @type {string}
+     * @private
+     * @memberof Meeting
+     */
+    this.mediaServerIp = undefined;
   }
 
   /**
@@ -6333,6 +6343,11 @@ export default class Meeting extends StatelessWebexPlugin {
         ? MeetingsUtil.getMediaServer(roapMessage.sdp)
         : undefined;
 
+    const mediaServerIp =
+      roapMessage.messageType === 'ANSWER'
+        ? MeetingsUtil.getMediaServerIp(roapMessage.sdp)
+        : undefined;
+
     if (this.isMultistream && mediaServer && mediaServer !== 'homer') {
       throw new MultistreamNotSupportedError(
         `Client asked for multistream backend (Homer), but got ${mediaServer} instead`
@@ -6343,6 +6358,10 @@ export default class Meeting extends StatelessWebexPlugin {
     if (mediaServer) {
       this.mediaProperties.webrtcMediaConnection.mediaServer = mediaServer;
     }
+
+    if (this.isMultistream && mediaServerIp) {
+      this.mediaServerIp = mediaServerIp;
+    }
   };
 
   /**
@@ -7768,6 +7787,11 @@ export default class Meeting extends StatelessWebexPlugin {
       const reachabilityStats = await this.webex.meetings.reachability.getReachabilityMetrics();
       const iceCandidateErrors = Object.fromEntries(this.iceCandidateErrors);
 
+      // @ts-ignore
+      const isSubnetReachable = this.webex.meetings.reachability.isSubnetReachable(
+        this.mediaServerIp
+      );
+
       Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.ADD_MEDIA_SUCCESS, {
         correlation_id: this.correlationId,
         locus_id: this.locusUrl.split('/').pop(),
@@ -7777,6 +7801,7 @@ export default class Meeting extends StatelessWebexPlugin {
         isMultistream: this.isMultistream,
         retriedWithTurnServer: this.addMediaData.retriedWithTurnServer,
         isJoinWithMediaRetry: this.joinWithMediaRetryInfo.isRetry,
+        isSubnetReachable,
         ...reachabilityStats,
         ...iceCandidateErrors,
         iceCandidatesCount: this.iceCandidatesCount,
@@ -7806,6 +7831,11 @@ export default class Meeting extends StatelessWebexPlugin {
 
       const iceCandidateErrors = Object.fromEntries(this.iceCandidateErrors);
 
+      // @ts-ignore
+      const isSubnetReachable = this.webex.meetings.reachability.isSubnetReachable(
+        this.mediaServerIp
+      );
+
       Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.ADD_MEDIA_FAILURE, {
         correlation_id: this.correlationId,
         locus_id: this.locusUrl.split('/').pop(),
@@ -7835,6 +7865,7 @@ export default class Meeting extends StatelessWebexPlugin {
           this.mediaProperties.webrtcMediaConnection?.mediaConnection?.pc?.iceConnectionState ||
           'unknown',
         ...reachabilityMetrics,
+        isSubnetReachable,
         ...iceCandidateErrors,
         iceCandidatesCount: this.iceCandidatesCount,
       });
diff --git a/packages/@webex/plugin-meetings/src/meetings/util.ts b/packages/@webex/plugin-meetings/src/meetings/util.ts
index 47c38d6d713..06abea9bde9 100644
--- a/packages/@webex/plugin-meetings/src/meetings/util.ts
+++ b/packages/@webex/plugin-meetings/src/meetings/util.ts
@@ -99,6 +99,24 @@ MeetingsUtil.getMediaServer = (sdp) => {
   return mediaServer;
 };
 
+MeetingsUtil.getMediaServerIp = (sdp) => {
+  let mediaServerIp;
+
+  // Attempt to collect the media server from the roap message.
+  try {
+    mediaServerIp = sdp
+      .split('\r\n')
+      .find((line) => line.startsWith('o='))
+      .match(/o=\S+ \d+ \d+ IN IP4 ([\d.]+)/)?.[1]
+      .toLowerCase()
+      .trim();
+  } catch {
+    mediaServerIp = undefined;
+  }
+
+  return mediaServerIp;
+};
+
 MeetingsUtil.checkForCorrelationId = (deviceUrl, locus) => {
   let devices = [];
 
diff --git a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
index 8e2fda20388..d91de905588 100644
--- a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
+++ b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
@@ -49,6 +49,7 @@ export class ClusterReachability extends EventsScope {
   private srflxIceCandidates: RTCIceCandidate[] = [];
   public readonly isVideoMesh: boolean;
   public readonly name;
+  public readonly reachedSubnets: Set<string> = new Set();
 
   /**
    * Constructor for ClusterReachability
@@ -234,27 +235,13 @@ export class ClusterReachability extends EventsScope {
    */
   private registerIceGatheringStateChangeListener() {
     this.pc.onicegatheringstatechange = () => {
-      const {COMPLETE} = ICE_GATHERING_STATE;
-
-      if (this.pc.iceConnectionState === COMPLETE) {
+      if (this.pc.iceGatheringState === ICE_GATHERING_STATE.COMPLETE) {
         this.closePeerConnection();
         this.finishReachabilityCheck();
       }
     };
   }
 
-  /**
-   * Checks if we have the results for all the protocols (UDP and TCP)
-   *
-   * @returns {boolean} true if we have all results, false otherwise
-   */
-  private haveWeGotAllResults(): boolean {
-    return ['udp', 'tcp', 'xtls'].every(
-      (protocol) =>
-        this.result[protocol].result === 'reachable' || this.result[protocol].result === 'untested'
-    );
-  }
-
   /**
    * Saves the latency in the result for the given protocol and marks it as reachable,
    * emits the "resultReady" event if this is the first result for that protocol,
@@ -264,9 +251,15 @@ export class ClusterReachability extends EventsScope {
    * @param {string} protocol
    * @param {number} latency
    * @param {string|null} [publicIp]
+   * @param {string|null} [serverIp]
    * @returns {void}
    */
-  private saveResult(protocol: 'udp' | 'tcp' | 'xtls', latency: number, publicIp?: string | null) {
+  private saveResult(
+    protocol: 'udp' | 'tcp' | 'xtls',
+    latency: number,
+    publicIp?: string | null,
+    serverIp?: string | null
+  ) {
     const result = this.result[protocol];
 
     if (result.latencyInMilliseconds === undefined) {
@@ -294,6 +287,10 @@ export class ClusterReachability extends EventsScope {
     } else {
       this.addPublicIP(protocol, publicIp);
     }
+
+    if (serverIp) {
+      this.reachedSubnets.add(serverIp);
+    }
   }
 
   /**
@@ -351,21 +348,25 @@ export class ClusterReachability extends EventsScope {
 
       if (e.candidate) {
         if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) {
-          this.saveResult('udp', latencyInMilliseconds, e.candidate.address);
+          let serverIp = null;
+          if ('url' in e.candidate) {
+            const stunServerUrlRegex = /stun:([\d.]+):\d+/;
+
+            const match = (e.candidate as any).url.match(stunServerUrlRegex);
+            if (match) {
+              // eslint-disable-next-line prefer-destructuring
+              serverIp = match[1];
+            }
+          }
+
+          this.saveResult('udp', latencyInMilliseconds, e.candidate.address, serverIp);
 
           this.determineNatType(e.candidate);
         }
 
         if (e.candidate.type === CANDIDATE_TYPES.RELAY) {
           const protocol = e.candidate.port === TURN_TLS_PORT ? 'xtls' : 'tcp';
-          this.saveResult(protocol, latencyInMilliseconds);
-          // we don't add public IP for TCP, because in the case of relay candidates
-          // e.candidate.address is the TURN server address, not the client's public IP
-        }
-
-        if (this.haveWeGotAllResults()) {
-          this.closePeerConnection();
-          this.finishReachabilityCheck();
+          this.saveResult(protocol, latencyInMilliseconds, null, e.candidate.address);
         }
       }
     };
diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts
index 5d6ff32f17c..0a6b1cd93a2 100644
--- a/packages/@webex/plugin-meetings/src/reachability/index.ts
+++ b/packages/@webex/plugin-meetings/src/reachability/index.ts
@@ -138,6 +138,60 @@ export default class Reachability extends EventsScope {
     }
   }
 
+  /**
+   * Checks if the given subnet is reachable
+   * @param {string} mediaServerIp - media server ip
+   * @returns {boolean | null} true if reachable, false if not reachable, null if mediaServerIp is not provided
+   * @public
+   * @memberof Reachability
+   */
+  public isSubnetReachable(mediaServerIp?: string): boolean | null {
+    if (!mediaServerIp) {
+      LoggerProxy.logger.error(`Reachability:index#isSubnetReachable --> mediaServerIp is null`);
+
+      return null;
+    }
+
+    const subnetFirstOctet = mediaServerIp.split('.')[0];
+
+    LoggerProxy.logger.info(
+      `Reachability:index#isSubnetReachable --> Looking for subnet: ${subnetFirstOctet}.X.X.X`
+    );
+
+    const matchingReachedClusters = Object.values(this.clusterReachability).reduce(
+      (acc, cluster) => {
+        const reachedSubnetsArray = Array.from(cluster.reachedSubnets);
+
+        let logMessage = `Reachability:index#isSubnetReachable --> Cluster ${cluster.name} reached [`;
+        for (let i = 0; i < reachedSubnetsArray.length; i += 1) {
+          const subnet = reachedSubnetsArray[i];
+          const reachedSubnetFirstOctet = subnet.split('.')[0];
+
+          if (subnetFirstOctet === reachedSubnetFirstOctet) {
+            acc.add(cluster.name);
+          }
+
+          logMessage += `${subnet}`;
+          if (i < reachedSubnetsArray.length - 1) {
+            logMessage += ',';
+          }
+        }
+        logMessage += `]`;
+
+        LoggerProxy.logger.info(logMessage);
+
+        return acc;
+      },
+      new Set<string>()
+    );
+
+    LoggerProxy.logger.info(
+      `Reachability:index#isSubnetReachable --> Found ${matchingReachedClusters.size} clusters that use the subnet ${subnetFirstOctet}.X.X.X`
+    );
+
+    return matchingReachedClusters.size > 0;
+  }
+
   /**
    * Gets a list of media clusters from the backend and performs reachability checks on all the clusters
    * @param {string} trigger - explains the reason for starting reachability
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js
index 2eea8811b52..de847f6f76e 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js
@@ -253,6 +253,7 @@ describe('plugin-meetings', () => {
       getReachabilityResults: sinon.stub().resolves(undefined),
       getReachabilityMetrics: sinon.stub().resolves({}),
       stopReachability: sinon.stub(),
+      isSubnetReachable: sinon.stub().returns(true),
     };
     webex.internal.llm.on = sinon.stub();
     webex.internal.newMetrics.callDiagnosticLatencies = new CallDiagnosticLatencies(
@@ -2128,6 +2129,7 @@ describe('plugin-meetings', () => {
               someReachabilityMetric2: 'some value2',
             }),
             stopReachability: sinon.stub(),
+            isSubnetReachable: sinon.stub().returns(false),
           };
 
           const forceRtcMetricsSend = sinon.stub().resolves();
@@ -2183,6 +2185,7 @@ describe('plugin-meetings', () => {
               someReachabilityMetric1: 'some value1',
               someReachabilityMetric2: 'some value2',
               selectedCandidatePairChanges: 2,
+              isSubnetReachable: false,
               numTransports: 1,
               iceCandidatesCount: 0,
             }
@@ -2229,6 +2232,7 @@ describe('plugin-meetings', () => {
               signalingState: 'unknown',
               connectionState: 'unknown',
               iceConnectionState: 'unknown',
+              isSubnetReachable: true,
             })
           );
 
@@ -2243,6 +2247,7 @@ describe('plugin-meetings', () => {
               someReachabilityMetric1: 'some value1',
               someReachabilityMetric2: 'some value2',
             }),
+            isSubnetReachable: sinon.stub().returns(true),
           };
 
           meeting.waitForRemoteSDPAnswer = sinon.stub().rejects();
@@ -2293,6 +2298,7 @@ describe('plugin-meetings', () => {
               selectedCandidatePairChanges: 2,
               numTransports: 1,
               iceCandidatesCount: 0,
+              isSubnetReachable: true,
             }
           );
         });
@@ -2350,6 +2356,7 @@ describe('plugin-meetings', () => {
               signalingState: 'have-local-offer',
               connectionState: 'connecting',
               iceConnectionState: 'checking',
+              isSubnetReachable: true,
             })
           );
 
@@ -2407,6 +2414,7 @@ describe('plugin-meetings', () => {
               signalingState: 'have-local-offer',
               connectionState: 'connecting',
               iceConnectionState: 'checking',
+              isSubnetReachable: true,
             })
           );
 
@@ -2744,6 +2752,7 @@ describe('plugin-meetings', () => {
             isWebexMediaBackendUnreachable: sinon.stub().resolves(false),
             getReachabilityMetrics: sinon.stub().resolves(),
             stopReachability: sinon.stub(),
+            isSubnetReachable: sinon.stub().returns(true),
           };
           const MOCK_CLIENT_ERROR_CODE = 2004;
           const generateClientErrorCodeForIceFailureStub = sinon
@@ -2923,6 +2932,7 @@ describe('plugin-meetings', () => {
               selectedCandidatePairChanges: 2,
               numTransports: 1,
               iceCandidatesCount: 0,
+              isSubnetReachable: true,
             },
           ]);
 
@@ -2953,6 +2963,7 @@ describe('plugin-meetings', () => {
               .resolves(false),
             getReachabilityMetrics: sinon.stub().resolves({}),
             stopReachability: sinon.stub(),
+            isSubnetReachable: sinon.stub().returns(true),
           };
           const getErrorPayloadForClientErrorCodeStub =
             (webex.internal.newMetrics.callDiagnosticMetrics.getErrorPayloadForClientErrorCode =
@@ -3120,6 +3131,7 @@ describe('plugin-meetings', () => {
               retriedWithTurnServer: true,
               isJoinWithMediaRetry: false,
               iceCandidatesCount: 0,
+              isSubnetReachable: true,
             },
           ]);
           meeting.roap.doTurnDiscovery;
@@ -3248,6 +3260,7 @@ describe('plugin-meetings', () => {
               someReachabilityMetric2: 'some value2',
             }),
             stopReachability: sinon.stub(),
+            isSubnetReachable: sinon.stub().returns(true),
           };
           meeting.iceCandidatesCount = 3;
           meeting.iceCandidateErrors.set('701_error', 3);
@@ -3275,6 +3288,7 @@ describe('plugin-meetings', () => {
               iceCandidatesCount: 3,
               '701_error': 3,
               '701_turn_host_lookup_received_error': 1,
+              isSubnetReachable: true,
             }
           );
 
@@ -3337,6 +3351,7 @@ describe('plugin-meetings', () => {
               iceConnectionState: 'unknown',
               selectedCandidatePairChanges: 2,
               numTransports: 1,
+              isSubnetReachable: true,
               iceCandidatesCount: 0,
             }
           );
@@ -3398,6 +3413,7 @@ describe('plugin-meetings', () => {
               numTransports: 1,
               '701_error': 2,
               '701_turn_host_lookup_received_error': 1,
+              isSubnetReachable: true,
               iceCandidatesCount: 0,
             }
           );
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
index 5ef732e2a99..6fdb0066f60 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
+++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
@@ -174,59 +174,6 @@ describe('ClusterReachability', () => {
       assert.deepEqual(emittedEvents[Events.clientMediaIpsUpdated], []);
     });
 
-    it('resolves and has correct result as soon as it finds that all udp, tcp and tls are reachable', async () => {
-      const promise = clusterReachability.start();
-
-      await clock.tickAsync(100);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp'}});
-
-      // check the right events were emitted
-      assert.equal(emittedEvents[Events.resultReady].length, 1);
-      assert.deepEqual(emittedEvents[Events.resultReady][0], {
-        protocol: 'udp',
-        result: 'reachable',
-        latencyInMilliseconds: 100,
-        clientMediaIPs: ['somePublicIp'],
-      });
-
-      // clientMediaIpsUpdated shouldn't be emitted, because the IP is already passed in the resultReady event
-      assert.equal(emittedEvents[Events.clientMediaIpsUpdated].length, 0);
-
-      await clock.tickAsync(100);
-      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
-
-      // check the right event was emitted
-      assert.equal(emittedEvents[Events.resultReady].length, 2);
-      assert.deepEqual(emittedEvents[Events.resultReady][1], {
-        protocol: 'tcp',
-        result: 'reachable',
-        latencyInMilliseconds: 200,
-      });
-      assert.equal(emittedEvents[Events.clientMediaIpsUpdated].length, 0);
-
-      await clock.tickAsync(100);
-      fakePeerConnection.onicecandidate({
-        candidate: {type: 'relay', address: 'someTurnRelayIp', port: 443},
-      });
-
-      // check the right event was emitted
-      assert.equal(emittedEvents[Events.resultReady].length, 3);
-      assert.deepEqual(emittedEvents[Events.resultReady][2], {
-        protocol: 'xtls',
-        result: 'reachable',
-        latencyInMilliseconds: 300,
-      });
-      assert.equal(emittedEvents[Events.clientMediaIpsUpdated].length, 0);
-
-      await promise;
-
-      assert.deepEqual(clusterReachability.getResult(), {
-        udp: {result: 'reachable', latencyInMilliseconds: 100, clientMediaIPs: ['somePublicIp']},
-        tcp: {result: 'reachable', latencyInMilliseconds: 200},
-        xtls: {result: 'reachable', latencyInMilliseconds: 300},
-      });
-    });
-
     it('resolves and returns correct results when aborted before it gets any candidates', async () => {
       const promise = clusterReachability.start();
 
@@ -275,7 +222,7 @@ describe('ClusterReachability', () => {
 
       await testUtils.flushPromises();
 
-      fakePeerConnection.iceConnectionState = 'complete';
+      fakePeerConnection.iceGatheringState = 'complete';
       fakePeerConnection.onicegatheringstatechange();
       await promise;
 
@@ -293,7 +240,7 @@ describe('ClusterReachability', () => {
       await clock.tickAsync(30);
       fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1'}});
 
-      fakePeerConnection.iceConnectionState = 'complete';
+      fakePeerConnection.iceGatheringState = 'complete';
       fakePeerConnection.onicegatheringstatechange();
       await promise;
 
@@ -436,6 +383,9 @@ describe('ClusterReachability', () => {
         candidate: {type: 'relay', address: 'someTurnRelayIp', port: 443},
       });
 
+      fakePeerConnection.iceGatheringState = 'complete';
+      fakePeerConnection.onicegatheringstatechange();
+
       await promise;
 
       assert.deepEqual(clusterReachability.getResult(), {
@@ -474,6 +424,10 @@ describe('ClusterReachability', () => {
         candidate: {type: 'relay', address: 'someTurnRelayIp', port: 443},
       });
 
+      fakePeerConnection.iceGatheringState = 'complete';
+      fakePeerConnection.onicegatheringstatechange();
+      await clock.tickAsync(10);
+
       await promise;
 
       assert.deepEqual(clusterReachability.getResult(), {
@@ -486,5 +440,37 @@ describe('ClusterReachability', () => {
         xtls: {result: 'reachable', latencyInMilliseconds: 20},
       });
     });
+
+    it('should gather correctly reached subnets', async () => {
+      const promise = clusterReachability.start();
+
+      await clock.tickAsync(10);
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:4.3.2.1:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
+
+      clusterReachability.abort();
+      await promise;
+
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), [
+        '1.2.3.4',
+        '4.3.2.1',
+        'someTurnRelayIp'
+      ]);
+    });
+
+    it('should store only unique subnet address', async () => {
+      const promise = clusterReachability.start();
+
+      await clock.tickAsync(10);
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:9000'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: '1.2.3.4'}});
+
+      clusterReachability.abort();
+      await promise;
+
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['1.2.3.4']);
+    });
   });
 });
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts
index 89eff269843..7515118a01c 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts
+++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts
@@ -2686,3 +2686,38 @@ describe('sendMetric', () => {
     });
   });
 });
+
+describe('isSubnetReachable', () => {
+  let webex;
+  let reachability;
+
+  beforeEach(() => {
+    webex = new MockWebex();
+    reachability = new TestReachability(webex);
+
+    reachability.setFakeClusterReachability({
+      cluster1: {
+        reachedSubnets: new Set(['1.2.3.4', '2.3.4.5']),
+      },
+      cluster2: {
+        reachedSubnets: new Set(['3.4.5.6', '4.5.6.7']),
+      },
+    });
+  });
+
+  afterEach(() => {
+    sinon.restore();
+  });
+
+  it('returns true if the subnet is reachable', () => {
+    assert(reachability.isSubnetReachable('1.2.3.4'));
+  });
+
+  it(`returns false if the subnet is unreachable`, () => {
+    assert(!reachability.isSubnetReachable('11.2.3.4'));
+  });
+
+  it('returns null if the subnet is not provided', () => {
+    assert.isNull(reachability.isSubnetReachable(undefined));
+  });
+});
\ No newline at end of file


Reviews:
user: login: marcin-bazyl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T10:14:03Z
user: login: marcin-bazyl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T10:17:32Z
user: login: marcin-bazyl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T10:22:40Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T11:08:26Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T11:09:12Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T11:10:03Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 6**

<details>
<summary>♻️ Duplicate comments (2)</summary><blockquote>

<details>
<summary>packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts (2)</summary><blockquote>

`365-375`: **Consider browser compatibility with URL extraction**

There's a browser compatibility concern with accessing the `url` property on ICE candidates.



According to the previous comments, the `url` property is only available in Chrome (possibly Edge/Opera too). Consider adding a fallback mechanism for other browsers or confirming that this meets your browser support requirements.

```shell
#!/bin/bash
# Check browser compatibility for RTCIceCandidate.url property
rg -A 5 -B 5 "browser.*compatibility|browser.*support" --type js
```

---

`386-389`: **Consider not stopping after first result for a protocol**

Early termination when protocol results are available might miss additional useful data.



Based on previous comments, there was a concern about stopping when getting the first result for a protocol. While you indicated TCP and TLS are more time-consuming than STUN, consider whether gathering additional candidates could provide more comprehensive subnet data.

```shell
#!/bin/bash
# Check if there are other places in the codebase where ice candidates continue to be collected
rg -A 5 "onicecandidate.*\{" --type js
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (3)</summary><blockquote>

<details>
<summary>packages/@webex/plugin-meetings/src/meeting/index.ts (1)</summary><blockquote>

`725-725`: **Property type could be more precise with `string | null`**

Since this property is initialized as `null` and later set to a string, consider changing its type to `string | null` for better type safety.

```diff
-  private mediaServerIp: string;
+  private mediaServerIp: string | null;
```

</blockquote></details>
<details>
<summary>packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts (1)</summary><blockquote>

`460-464`: **Improve assertion readability with sorting**

For more reliable test assertions on Set data, consider sorting the array to ensure consistent order across test runs.

```diff
  assert.deepEqual(Array.from(clusterReachability.reachedSubnets), [
-    '1.2.3.4',
-    '4.3.2.1',
-    'someTurnRelayIp'
+    '1.2.3.4',
+    '4.3.2.1',
+    'someTurnRelayIp'
+  ].sort());
```

Alternatively, you could use a Set-specific assertion pattern to avoid order dependency:

```javascript
const expected = new Set(['1.2.3.4', '4.3.2.1', 'someTurnRelayIp']);
assert.deepEqual(clusterReachability.reachedSubnets, expected);
```

</blockquote></details>
<details>
<summary>packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts (1)</summary><blockquote>

`369-369`: **Consider using proper type checking instead of type assertion**

The `as any` type assertion bypasses TypeScript's type checking. Consider using proper type checking instead.


```diff
-          const match = (e.candidate as any).url.match(regex);
+          // Check if url property exists before accessing
+          const candidateUrl = 'url' in e.candidate ? (e.candidate as any).url : undefined;
+          const match = candidateUrl ? candidateUrl.match(regex) : null;
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: .coderabbit.yaml**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between f11ee15a8f56c633702b88aed32de4f3a44ba16e and 29d89f0fca908657c52cd31599cd2bad12f8008d.

</details>

<details>
<summary>📒 Files selected for processing (7)</summary>

* `packages/@webex/plugin-meetings/src/meeting/index.ts` (8 hunks)
* `packages/@webex/plugin-meetings/src/meetings/util.ts` (1 hunks)
* `packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts` (4 hunks)
* `packages/@webex/plugin-meetings/src/reachability/index.ts` (1 hunks)
* `packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js` (16 hunks)
* `packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts` (1 hunks)
* `packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>packages/@webex/plugin-meetings/src/meeting/index.ts (1)</summary><blockquote>

<details>
<summary>packages/@webex/plugin-meetings/src/reachability/index.ts (1)</summary>

* `isSubnetReachable` (148-184)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (2)</summary>

* GitHub Check: Build Packages
* GitHub Check: AWS Amplify Console Web Preview

</details>

<details>
<summary>🔇 Additional comments (27)</summary><blockquote>

<details>
<summary>packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts (1)</summary>

`2689-2719`: **Well-structured tests for the new `isSubnetReachable` method.**

The tests effectively verify the subnet matching logic by mocking the necessary data and asserting expected behavior for both positive and negative cases.

</details>
<details>
<summary>packages/@webex/plugin-meetings/src/meeting/index.ts (5)</summary>

`2353-2371`: **LGTM: Media server IP extraction from SDP for metrics**

This code properly extracts the media server IP address from the SDP for multistream meetings, which will be used for subnet reachability metrics reporting.

---

`7774-7777`: **Integration with reachability subsystem for metrics**

Good integration with the reachability subsystem to check if the media server's subnet was previously reachable. This information will be valuable for diagnosing connection issues.

---

`7788-7788`: **LGTM: Including subnet reachability in success metrics**

Including the subnet reachability status in success metrics will help correlate successful connections with network reachability.

---

`7818-7821`: **LGTM: Including subnet reachability in failure metrics**

Including the subnet reachability status in failure metrics will help identify if connection failures are related to network subnet reachability issues.

---

`1610-1610`: **LGTM: Initializing mediaServerIp in constructor**

The mediaServerIp property is correctly initialized as null in the constructor.

</details>
<details>
<summary>packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js (16)</summary>

`253-256`: **LGTM: Good test coverage for new reachability feature**

The addition of the `isSubnetReachable` stub in the reachability mock is consistent with the PR objective of adding metrics for server subnet reachability. This ensures the new functionality is properly stubbed in tests.

---

`2123-2126`: **Consistent mocking of new reachability method**

The stub implementation for `isSubnetReachable` matches other test setup patterns in this file and ensures tests have coverage for the new subnet reachability functionality.

---

`2179-2182`: **Successfully integrating subnet reachability in metrics assertions**

The addition of `isSubnetReachable: true` to the metrics object correctly validates that the subnet reachability information is being included in metrics submissions.

---

`2226-2229`: **Good coverage of connection state metrics**

The inclusion of `isSubnetReachable` status in the connection state metrics provides valuable diagnostic information for network troubleshooting, which aligns with the PR objectives.

---

`2241-2244`: **Consistent mocking of subnet reachability**

Proper stubbing of the `isSubnetReachable` method ensures test consistency for scenarios involving media connection failures.

---

`2292-2295`: **Validating metrics payload structure**

Inclusion of `isSubnetReachable` status in assertions ensures the property is correctly included in metrics submissions during failure scenarios.

---

`2350-2353`: **Comprehensive ICE connection state testing**

The addition of subnet reachability status enriches the connection state metrics, providing better visibility into media connection issues.

---

`2408-2411`: **Thorough test coverage for connection scenarios**

Consistent inclusion of subnet reachability in metrics validation ensures comprehensive test coverage across different connection states.

---

`2746-2749`: **Good setup for ICE failure scenarios**

Proper stubbing of `isSubnetReachable` for testing ICE failure scenarios ensures that subnet reachability information is available during error handling flows.

---

`2926-2929`: **Comprehensive metrics validation**

The test properly validates that subnet reachability data is included in metrics submissions during network connection scenarios.

---

`2957-2960`: **Consistent test patterns across error scenarios**

The addition of `isSubnetReachable` stub is consistent with other reachability mocks in the test file, ensuring comprehensive test coverage.

---

`3125-3128`: **Validating metrics during TURN discovery**

Including subnet reachability status in TURN discovery metrics provides valuable network diagnostics for troubleshooting connectivity issues.

---

`3254-3257`: **Thorough mock setup for ICE candidate errors**

The `isSubnetReachable` stub is properly added to the reachability mock object, ensuring consistent test behavior for ICE candidate error scenarios.

---

`3282-3285`: **Comprehensive validation of ICE error metrics**

The test properly validates that the subnet reachability status is included in metrics submissions alongside ICE candidate error information.

---

`3345-3348`: **Thorough connection state metrics testing**

Inclusion of subnet reachability status in the metrics assertions ensures proper validation of the metrics structure during ICE connection state transitions.

---

`3407-3410`: **Complete ICE error metrics validation**

The test properly validates that subnet reachability information is included alongside ICE error metrics, providing comprehensive test coverage for the new feature.

</details>
<details>
<summary>packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts (5)</summary>

`52-52`: **LGTM: Addition of reachedSubnets property**

This new Set will efficiently track unique server subnet IP addresses reached during ICE candidate gathering.

---

`268-276`: **LGTM: Added serverIp parameter to saveResult method**

The method signature is appropriately extended to accept and process server IP addresses.

---

`304-307`: **LGTM: Logic to store reached server IPs**

This implementation properly adds server IPs to the reachedSubnets set when provided.

---

`376-376`: **LGTM: Saving server IP for srflx candidates**

Properly passing the extracted server IP to the saveResult method.

---

`383-383`: **LGTM: Saving server IP for relay candidates**

Correctly passing the candidate's address as the server IP for relay candidates.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T12:12:04Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T13:02:26Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T13:02:33Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T13:25:49Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T06:15:17Z
user: login: chrisadubois
body: I do not fully understand everythiung in this PR and did not validate it with a "liar" subnet but I get what is happening here, and the code appears safe. We could prioritize getting this in quickly on monday for a prod deploy cc @Coread @marcin-bazyl 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-09T18:56:42Z
user: login: antsukanova
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T09:35:12Z
user: login: edvujic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T10:10:17Z
user: login: marcin-bazyl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T11:22:30Z
user: login: marcin-bazyl
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T12:04:17Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T12:40:20Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T12:53:19Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T12:53:25Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T12:53:30Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T12:54:16Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T13:00:57Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T13:03:24Z
user: login: k-wasniowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T13:03:34Z
user: login: marcin-bazyl
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-12T13:07:52Z
user: login: antsukanova
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:01:10Z

Review comments:
diff_hunk: @@ -351,16 +362,25 @@ export class ClusterReachability extends EventsScope {
 
       if (e.candidate) {
         if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) {
-          this.saveResult('udp', latencyInMilliseconds, e.candidate.address);
+          let serverIp = null;
+          if ('url' in e.candidate) {
path: packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
user: login: marcin-bazyl
body: according to https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate url is available only in Chrome
created_at: 2025-05-06T10:14:03Z
author_association: COLLABORATOR
diff_hunk: @@ -99,6 +99,24 @@ MeetingsUtil.getMediaServer = (sdp) => {
   return mediaServer;
 };
 
+MeetingsUtil.getMediaServerIp = (sdp) => {
+  let mediaServerIp;
+
+  // Attempt to collect the media server from the roap message.
+  try {
+    mediaServerIp = sdp
+      .split('\r\n')
+      .find((line) => line.startsWith('o='))
+      .match(/o=\S+ \d+ \d+ IN IP4 ([\d.]+)/)?.[1]
path: packages/@webex/plugin-meetings/src/meetings/util.ts
user: login: marcin-bazyl
body: we're soon going to have ipv6 support rolled out..... but maybe for now we can do this just for ipv4
created_at: 2025-05-06T10:17:32Z
author_association: COLLABORATOR
diff_hunk: @@ -351,16 +362,25 @@ export class ClusterReachability extends EventsScope {
 
       if (e.candidate) {
         if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) {
-          this.saveResult('udp', latencyInMilliseconds, e.candidate.address);
+          let serverIp = null;
+          if ('url' in e.candidate) {
+            const regex = /stun:([\d.]+):\d+/;
+
+            const match = (e.candidate as any).url.match(regex);
+            if (match) {
+              // eslint-disable-next-line prefer-destructuring
+              serverIp = match[1];
+            }
+          }
+
+          this.saveResult('udp', latencyInMilliseconds, e.candidate.address, serverIp);
 
           this.determineNatType(e.candidate);
         }
 
         if (e.candidate.type === CANDIDATE_TYPES.RELAY) {
           const protocol = e.candidate.port === TURN_TLS_PORT ? 'xtls' : 'tcp';
-          this.saveResult(protocol, latencyInMilliseconds);
-          // we don't add public IP for TCP, because in the case of relay candidates
-          // e.candidate.address is the TURN server address, not the client's public IP
+          this.saveResult(protocol, latencyInMilliseconds, null, e.candidate.address);
         }
 
         if (this.haveWeGotAllResults()) {
path: packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
user: login: marcin-bazyl
body: we will also need to change this code to not stop when we get the 1st result for a protocol
created_at: 2025-05-06T10:22:40Z
author_association: COLLABORATOR
diff_hunk: @@ -351,16 +362,25 @@ export class ClusterReachability extends EventsScope {
 
       if (e.candidate) {
         if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) {
-          this.saveResult('udp', latencyInMilliseconds, e.candidate.address);
+          let serverIp = null;
+          if ('url' in e.candidate) {
path: packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
user: login: k-wasniowski
body: Chrome/Edge/Opera? If anyone cares :D
created_at: 2025-05-06T11:08:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +99,24 @@ MeetingsUtil.getMediaServer = (sdp) => {
   return mediaServer;
 };
 
+MeetingsUtil.getMediaServerIp = (sdp) => {
+  let mediaServerIp;
+
+  // Attempt to collect the media server from the roap message.
+  try {
+    mediaServerIp = sdp
+      .split('\r\n')
+      .find((line) => line.startsWith('o='))
+      .match(/o=\S+ \d+ \d+ IN IP4 ([\d.]+)/)?.[1]
path: packages/@webex/plugin-meetings/src/meetings/util.ts
user: login: k-wasniowski
body: Yes, it crossed my mind. Would be good to keep those things only for short period of time.
created_at: 2025-05-06T11:09:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -351,16 +362,25 @@ export class ClusterReachability extends EventsScope {
 
       if (e.candidate) {
         if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) {
-          this.saveResult('udp', latencyInMilliseconds, e.candidate.address);
+          let serverIp = null;
+          if ('url' in e.candidate) {
+            const regex = /stun:([\d.]+):\d+/;
+
+            const match = (e.candidate as any).url.match(regex);
+            if (match) {
+              // eslint-disable-next-line prefer-destructuring
+              serverIp = match[1];
+            }
+          }
+
+          this.saveResult('udp', latencyInMilliseconds, e.candidate.address, serverIp);
 
           this.determineNatType(e.candidate);
         }
 
         if (e.candidate.type === CANDIDATE_TYPES.RELAY) {
           const protocol = e.candidate.port === TURN_TLS_PORT ? 'xtls' : 'tcp';
-          this.saveResult(protocol, latencyInMilliseconds);
-          // we don't add public IP for TCP, because in the case of relay candidates
-          // e.candidate.address is the TURN server address, not the client's public IP
+          this.saveResult(protocol, latencyInMilliseconds, null, e.candidate.address);
         }
 
         if (this.haveWeGotAllResults()) {
path: packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
user: login: k-wasniowski
body: Do we need it?
We are currently doing TCP and TLS, which is more time consuming than STUN. So STUN checks should finalize before TCP and TLS ends.
created_at: 2025-05-06T11:10:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +99,24 @@ MeetingsUtil.getMediaServer = (sdp) => {
   return mediaServer;
 };
 
+MeetingsUtil.getMediaServerIp = (sdp) => {
+  let mediaServerIp;
+
+  // Attempt to collect the media server from the roap message.
+  try {
+    mediaServerIp = sdp
+      .split('\r\n')
+      .find((line) => line.startsWith('o='))
+      .match(/o=\S+ \d+ \d+ IN IP4 ([\d.]+)/)?.[1]
+      .toLowerCase()
+      .trim();
+  } catch {
+    mediaServerIp = undefined;
+  }
+
+  return mediaServerIp;
+};
path: packages/@webex/plugin-meetings/src/meetings/util.ts
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Consider future IPv6 support.**

The implementation correctly extracts IPv4 addresses from SDP, but it's limited to IPv4 only. As mentioned in previous comments, IPv6 support will be needed in the future.


---

🏁 Script executed:

```shell
#!/bin/bash
# Check for any IPv6 addresses in SDP examples in codebase to understand potential format requirements

echo "Searching for IPv6 examples in SDP files..."
rg "o=.+IN IP6" --type-add "sdp:*.sdp" --glob "**/*.{sdp,js,ts,json}"
```

Length of output: 67925

---



</details>

**Add IPv6 parsing support to `getMediaServerIp`**

The current implementation only matches IPv4 addresses (`IN IP4`). We already have IPv6 examples in SDP elsewhere (e.g. in `packages/calling/src/common/Utils.test.ts` with lines like `o=- … IN IP6 2001:db8::1`). To future-proof this utility, update the regex to accept both IPv4 and IPv6.

– File: `packages/@webex/plugin-meetings/src/meetings/util.ts`  
– Lines: 102–118  

Suggested change:
```diff
   try {
     mediaServerIp = sdp
       .split('\r\n')
       .find((line) => line.startsWith('o='))
-      .match(/o=\S+ \d+ \d+ IN IP4 ([\d.]+)/)?.[1]
+      // match either IN IP4 or IN IP6 and capture IPv4 or IPv6 address
+      .match(/o=\S+ \d+ \d+ IN IP(?:4|6) ([\dA-Fa-f:.]+)/)?.[1]
       .toLowerCase()
       .trim();
   } catch {
     mediaServerIp = undefined;
   }
```

This will allow the utility to extract both IPv4 (e.g. `192.168.1.1`) and IPv6 (e.g. `2001:db8::1`) addresses from the SDP “o=” line.  


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
MeetingsUtil.getMediaServerIp = (sdp) => {
  let mediaServerIp;

  // Attempt to collect the media server from the roap message.
  try {
    mediaServerIp = sdp
      .split('\r\n')
      .find((line) => line.startsWith('o='))
      .match(/o=\S+ \d+ \d+ IN IP(?:4|6) ([\dA-Fa-f:.]+)/)?.[1]
      .toLowerCase()
      .trim();
  } catch {
    mediaServerIp = undefined;
  }

  return mediaServerIp;
};
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-08T12:12:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -138,6 +138,51 @@ export default class Reachability extends EventsScope {
     }
   }
 
+  /**
+   * Checks if the given subnet is reachable
+   * @param {string} mediaServerIp - media server ip
+   * @returns {boolean} true if reachable, false otherwise
+   * @public
+   * @memberof Reachability
+   */
+  public isSubnetReachable(mediaServerIp: string): boolean {
+    const subnetFirstOctet = mediaServerIp.split('.')[0];
+
+    LoggerProxy.logger.info(
+      `Reachability:index#isSubnetReachable --> Looking for subnet: ${subnetFirstOctet}.X.X.X`
+    );
+
+    const reachedClusters = Object.values(this.clusterReachability).reduce((acc, cluster) => {
+      const reachedSubnetsArray = Array.from(cluster.reachedSubnets);
+
+      let logMessage = `Reachability:index#isSubnetReachable --> Cluster ${cluster.name} reached [`;
+      for (let i = 0; i < reachedSubnetsArray.length; i += 1) {
+        const subnet = reachedSubnetsArray[i];
+        const reachedSubnetFirstOctet = subnet.split('.')[0];
+
+        if (subnetFirstOctet === reachedSubnetFirstOctet) {
+          acc.add(cluster.name);
+        }
+
+        logMessage += `${subnet}`;
+        if (i < reachedSubnetsArray.length - 1) {
+          logMessage += ',';
+        }
+      }
+      logMessage += `]`;
+
+      LoggerProxy.logger.info(logMessage);
+
+      return acc;
+    }, new Set<string>());
+
+    LoggerProxy.logger.info(
+      `Reachability:index#isSubnetReachable --> Found ${reachedClusters.size} clusters that reached the subnet ${subnetFirstOctet}.X.X.X`
+    );
+
+    return reachedClusters.size > 0;
+  }
path: packages/@webex/plugin-meetings/src/reachability/index.ts
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Simplistic subnet matching might need future enhancement.**

The implementation only checks the first octet of IP addresses to determine subnet matches. While this works for basic cases, real network subnets often require more precise matching with proper subnet masks.

The current approach is straightforward but consider enhancing it in the future to handle more complex subnet scenarios, especially when IPv6 support is added.

---

🌐 Web query:

```web
What is the recommended way to check if an IP address belongs to a specific subnet in JavaScript?
```

💡 Result:

The recommended way to check if an IP address belongs to a specific subnet in JavaScript generally involves the following steps:

1. Convert the IP address and the subnet address from their dot-decimal notation (e.g., "192.168.1.5") into their numeric equivalent (usually a 32-bit number).
2. Convert the subnet mask or CIDR prefix length into a numeric subnet mask.
3. Perform a bitwise AND operation between the IP address and the subnet mask.
4. Compare the result with the subnet address (also masked with the subnet mask). If they match, the IP belongs to the subnet.

Here's a simple example for IPv4:

```js
function ipToInt(ip) {
  return ip.split('.').reduce((int, oct) => (int << 8) + parseInt(oct, 10), 0) >>> 0;
}

function checkIpInSubnet(ip, subnet, mask) {
  const ipInt = ipToInt(ip);
  const subnetInt = ipToInt(subnet);
  const maskInt = ipToInt(mask);
  return (ipInt & maskInt) === (subnetInt & maskInt);
}

// Example usage:
const ip = "192.168.1.5";
const subnet = "192.168.1.0";
const mask = "255.255.255.0";

console.log(checkIpInSubnet(ip, subnet, mask)); // true
```

If you want to handle CIDR notation (like "192.168.1.0/24"), you can convert the prefix length to a subnet mask first.

Alternatively, you can use libraries like `ip` (https://www.npmjs.com/package/ip) or `ip-address` for more robust handling of IP addresses and subnets.

If you want, I can provide examples using those libraries too. Would you like that?

---



</details>

**Enhance subnet matching logic for accurate reachability checks**

The current `isSubnetReachable` implementation only compares the first octet of IPv4 addresses, which can lead to false positives across different subnets (e.g., `10.*.*.*` matching all `10.x.x.x`). To ensure correctness:

• File:  
  packages/@webex/plugin-meetings/src/reachability/index.ts  
  Method: `isSubnetReachable` (≈ lines 148–184)

• Replace the simplistic first‐octet check with proper subnet‐mask (or CIDR) based matching:  
  – Convert IPs and masks to 32-bit integers and perform a bitwise AND comparison.  
  – Or leverage a battle-tested library (e.g., `ip`, `ip-address`) to handle IPv4 masks, CIDR notation, and future IPv6 support.

Example using the `ip` npm package:
```ts
import { toLong } from 'ip';

function ipInSubnet(ip: string, subnet: string, mask: string): boolean {
  const ipNum     = toLong(ip);
  const subnetNum = toLong(subnet);
  const maskNum   = toLong(mask);
  return (ipNum & maskNum) === (subnetNum & maskNum);
}
```

Plan for supporting CIDR strings (e.g., `"192.168.1.0/24"`) and IPv6 extensions in upcoming iterations.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-08T12:12:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7752,6 +7771,11 @@ export default class Meeting extends StatelessWebexPlugin {
       const reachabilityStats = await this.webex.meetings.reachability.getReachabilityMetrics();
       const iceCandidateErrors = Object.fromEntries(this.iceCandidateErrors);
 
+      // @ts-ignore
+      const isSubnetReachable = this.webex.meetings.reachability.isSubnetReachable(
+        this.mediaServerIp
+      );
path: packages/@webex/plugin-meetings/src/meeting/index.ts
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Verify null handling in isSubnetReachable**

The code doesn't explicitly check if `this.mediaServerIp` is null before passing it to `isSubnetReachable`. Verify that this method handles null values appropriately.

---

🏁 Script executed:

```shell
#!/bin/bash
# Description: Check how isSubnetReachable handles null values
ast-grep --pattern 'isSubnetReachable(mediaServerIp: $type) {
  $$$
}'
```

Length of output: 72

---

I couldn’t locate the implementation with the initial pattern. Let’s search for all occurrences and definitions of `isSubnetReachable` so we can inspect how it handles nulls:

---

🏁 Script executed:

```shell
#!/bin/bash
# Find all references and the definition of isSubnetReachable with context
rg -n -C3 "isSubnetReachable" packages/@webex/plugin-meetings/src
```

Length of output: 5205

---



</details>

**Handle null `mediaServerIp` before calling `isSubnetReachable`**

The `Reachability.isSubnetReachable` implementation assumes a non‐null string (it immediately does `mediaServerIp.split('.')`), so passing `null` or `undefined` will throw at runtime. You must guard against a missing IP at the call sites or make the method null‐safe.

Locations to update:
- packages/@webex/plugin-meetings/src/meeting/index.ts: lines 7774–7777 and 7819–7822
- (Alternatively) packages/@webex/plugin-meetings/src/reachability/index.ts

Suggested fixes:

1. Add a guard at each call site in `meeting/index.ts`:

```diff
- // @ts-ignore
- const isSubnetReachable = this.webex.meetings.reachability.isSubnetReachable(
-   this.mediaServerIp
- );
+ const isSubnetReachable = this.mediaServerIp
+   ? this.webex.meetings.reachability.isSubnetReachable(this.mediaServerIp)
+   : false;
```

2. Or update the signature in `reachability/index.ts` and early‐return on a falsy IP:

```ts
-public isSubnetReachable(mediaServerIp: string): boolean {
-  const subnetFirstOctet = mediaServerIp.split('.')[0];
+public isSubnetReachable(mediaServerIp?: string): boolean {
+  if (!mediaServerIp) {
+    return false;
+  }
+  const subnetFirstOctet = mediaServerIp.split('.')[0];
  // …rest of existing logic…
}
```

Implement one of these to prevent potential runtime errors.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-08T12:12:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -449,41 +449,53 @@ describe('ClusterReachability', () => {
       });
     });
 
-    it('determines correctly if symmetric-nat is detected', async () => {
-      const promise = clusterReachability.start();
+    it('should gather correctly reached subnets', async () => {
+      clusterReachability.start();
 
-      // generate candidates with duplicate addresses
       await clock.tickAsync(10);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', relatedPort: 3478, port: 1000}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:4.3.2.1:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
+
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), [
+        '1.2.3.4',
+        '4.3.2.1',
+        'someTurnRelayIp'
+      ]);
+    });
 
-      // check events emitted: there shouldn't be any natTypeUpdated emitted
-      assert.equal(emittedEvents[Events.natTypeUpdated].length, 0);
+    it('should store only unique subnet address', async () => {
+      clusterReachability.start();
 
       await clock.tickAsync(10);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', relatedPort: 3478, port: 2000}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:9000'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: '1.2.3.4'}});
 
-      // should emit natTypeUpdated event
-      assert.equal(emittedEvents[Events.natTypeUpdated].length, 1);
-      assert.deepEqual(emittedEvents[Events.natTypeUpdated][0], {
-        natType: 'symmetric-nat',
-      });
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['1.2.3.4']);
+    });
path: packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add completion and cleanup for the unique subnet test**

This test correctly verifies subnet deduplication, but like the previous test, it doesn't properly wait for the start promise to complete or clean up resources with `abort()`.

```diff
it('should store only unique subnet address', async () => {
-  clusterReachability.start();
+  const promise = clusterReachability.start();

  await clock.tickAsync(10);
  fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
  fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:9000'}});
  fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: '1.2.3.4'}});

  assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['1.2.3.4']);
+  
+  clusterReachability.abort();
+  await promise;
});
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
it('should store only unique subnet address', async () => {
  const promise = clusterReachability.start();

  await clock.tickAsync(10);
  fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
  fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:9000'}});
  fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: '1.2.3.4'}});

  assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['1.2.3.4']);

  clusterReachability.abort();
  await promise;
});
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-08T12:12:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -449,41 +449,53 @@
       });
     });
 
-    it('determines correctly if symmetric-nat is detected', async () => {
-      const promise = clusterReachability.start();
+    it('should gather correctly reached subnets', async () => {
+      clusterReachability.start();
 
-      // generate candidates with duplicate addresses
       await clock.tickAsync(10);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', relatedPort: 3478, port: 1000}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:4.3.2.1:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
+
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), [
+        '1.2.3.4',
+        '4.3.2.1',
+        'someTurnRelayIp'
+      ]);
+    });
 
-      // check events emitted: there shouldn't be any natTypeUpdated emitted
-      assert.equal(emittedEvents[Events.natTypeUpdated].length, 0);
+    it('should store only unique subnet address', async () => {
+      clusterReachability.start();
 
       await clock.tickAsync(10);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', relatedPort: 3478, port: 2000}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:9000'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: '1.2.3.4'}});
 
-      // should emit natTypeUpdated event
-      assert.equal(emittedEvents[Events.natTypeUpdated].length, 1);
-      assert.deepEqual(emittedEvents[Events.natTypeUpdated][0], {
-        natType: 'symmetric-nat',
-      });
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['1.2.3.4']);
+    });
 
-      // send also a relay candidate so that the reachability check finishes
-      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
-      fakePeerConnection.onicecandidate({
-        candidate: {type: 'relay', address: 'someTurnRelayIp', port: 443},
-      });
+    it('should store latency only for the first relay candidate', async () => {
+      const promise = clusterReachability.start();
+
+      await clock.tickAsync(10);
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp1'}});
 
+      // generate more candidates
+      await clock.tickAsync(10);
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp2'}});
+
+      await clock.tickAsync(10);
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp3'}});
+
+      clusterReachability.abort();
       await promise;
 
+      // latency should be from only the first candidates, but the clientMediaIps should be from only from UDP candidates
       assert.deepEqual(clusterReachability.getResult(), {
-        udp: {
-          result: 'reachable',
-          latencyInMilliseconds: 10,
-          clientMediaIPs: ['somePublicIp1'],
-        },
-        tcp: {result: 'reachable', latencyInMilliseconds: 20},
-        xtls: {result: 'reachable', latencyInMilliseconds: 20},
+        udp: {result: 'unreachable'},
+        tcp: {result: 'reachable', latencyInMilliseconds: 10},
+        xtls: {result: 'unreachable'},
       });
     });
path: packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Remove duplicated test**

This test is a duplicate of the identical test at lines 335-357, which checks the same functionality (that latency is stored only for the first relay candidate).

```diff
-it('should store latency only for the first relay candidate', async () => {
-  const promise = clusterReachability.start();
-
-  await clock.tickAsync(10);
-  fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp1'}});
-
-  // generate more candidates
-  await clock.tickAsync(10);
-  fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp2'}});
-
-  await clock.tickAsync(10);
-  fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp3'}});
-
-  clusterReachability.abort();
-  await promise;
-
-  // latency should be from only the first candidates, but the clientMediaIps should be from only from UDP candidates
-  assert.deepEqual(clusterReachability.getResult(), {
-    udp: {result: 'unreachable'},
-    tcp: {result: 'reachable', latencyInMilliseconds: 10},
-    xtls: {result: 'unreachable'},
-  });
-});
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion

`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-08T12:12:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -449,41 +449,53 @@
       });
     });
 
-    it('determines correctly if symmetric-nat is detected', async () => {
-      const promise = clusterReachability.start();
+    it('should gather correctly reached subnets', async () => {
+      clusterReachability.start();
 
-      // generate candidates with duplicate addresses
       await clock.tickAsync(10);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', relatedPort: 3478, port: 1000}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:4.3.2.1:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
+
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), [
+        '1.2.3.4',
+        '4.3.2.1',
+        'someTurnRelayIp'
+      ]);
+    });
path: packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add completion and cleanup for the reachedSubnets test**

The test verifies subnet collection from ICE candidates correctly, but it doesn't properly complete the promise returned by `clusterReachability.start()` or clean up resources by calling `abort()`.

```diff
it('should gather correctly reached subnets', async () => {
-  clusterReachability.start();
+  const promise = clusterReachability.start();

  await clock.tickAsync(10);
  fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
  fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:4.3.2.1:5004'}});
  fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});

  assert.deepEqual(Array.from(clusterReachability.reachedSubnets), [
    '1.2.3.4',
    '4.3.2.1',
    'someTurnRelayIp'
  ]);
+  
+  clusterReachability.abort();
+  await promise;
});
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
    it('should gather correctly reached subnets', async () => {
      const promise = clusterReachability.start();

      await clock.tickAsync(10);
      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:4.3.2.1:5004'}});
      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});

      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), [
        '1.2.3.4',
        '4.3.2.1',
        'someTurnRelayIp'
      ]);
      
      clusterReachability.abort();
      await promise;
    });
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-08T12:12:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +99,24 @@ MeetingsUtil.getMediaServer = (sdp) => {
   return mediaServer;
 };
 
+MeetingsUtil.getMediaServerIp = (sdp) => {
+  let mediaServerIp;
+
+  // Attempt to collect the media server from the roap message.
+  try {
+    mediaServerIp = sdp
+      .split('\r\n')
+      .find((line) => line.startsWith('o='))
+      .match(/o=\S+ \d+ \d+ IN IP4 ([\d.]+)/)?.[1]
+      .toLowerCase()
+      .trim();
+  } catch {
+    mediaServerIp = undefined;
+  }
+
+  return mediaServerIp;
+};
path: packages/@webex/plugin-meetings/src/meetings/util.ts
user: login: k-wasniowski
body: @marcin-bazyl Should we listen to a rabbit here?
created_at: 2025-05-08T13:02:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +99,24 @@ MeetingsUtil.getMediaServer = (sdp) => {
   return mediaServer;
 };
 
+MeetingsUtil.getMediaServerIp = (sdp) => {
+  let mediaServerIp;
+
+  // Attempt to collect the media server from the roap message.
+  try {
+    mediaServerIp = sdp
+      .split('\r\n')
+      .find((line) => line.startsWith('o='))
+      .match(/o=\S+ \d+ \d+ IN IP4 ([\d.]+)/)?.[1]
+      .toLowerCase()
+      .trim();
+  } catch {
+    mediaServerIp = undefined;
+  }
+
+  return mediaServerIp;
+};
path: packages/@webex/plugin-meetings/src/meetings/util.ts
user: login: coderabbitai[bot]
body: > Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

<!-- This is an auto-generated mute comment by CodeRabbit -->
created_at: 2025-05-08T13:02:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -234,27 +235,13 @@ export class ClusterReachability extends EventsScope {
    */
   private registerIceGatheringStateChangeListener() {
     this.pc.onicegatheringstatechange = () => {
-      const {COMPLETE} = ICE_GATHERING_STATE;
-
-      if (this.pc.iceConnectionState === COMPLETE) {
path: packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
user: login: k-wasniowski
body: iceConnectionState will never be complete...
created_at: 2025-05-08T13:25:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -351,16 +362,25 @@ export class ClusterReachability extends EventsScope {
 
       if (e.candidate) {
         if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) {
-          this.saveResult('udp', latencyInMilliseconds, e.candidate.address);
+          let serverIp = null;
+          if ('url' in e.candidate) {
+            const regex = /stun:([\d.]+):\d+/;
+
+            const match = (e.candidate as any).url.match(regex);
+            if (match) {
+              // eslint-disable-next-line prefer-destructuring
+              serverIp = match[1];
+            }
+          }
+
+          this.saveResult('udp', latencyInMilliseconds, e.candidate.address, serverIp);
 
           this.determineNatType(e.candidate);
         }
 
         if (e.candidate.type === CANDIDATE_TYPES.RELAY) {
           const protocol = e.candidate.port === TURN_TLS_PORT ? 'xtls' : 'tcp';
-          this.saveResult(protocol, latencyInMilliseconds);
-          // we don't add public IP for TCP, because in the case of relay candidates
-          // e.candidate.address is the TURN server address, not the client's public IP
+          this.saveResult(protocol, latencyInMilliseconds, null, e.candidate.address);
         }
 
         if (this.haveWeGotAllResults()) {
path: packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
user: login: k-wasniowski
body: Done
created_at: 2025-05-09T06:15:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -351,21 +348,25 @@ export class ClusterReachability extends EventsScope {
 
       if (e.candidate) {
         if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) {
-          this.saveResult('udp', latencyInMilliseconds, e.candidate.address);
+          let serverIp = null;
+          if ('url' in e.candidate) {
+            const regex = /stun:([\d.]+):\d+/;
path: packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
user: login: antsukanova
body: It feels good to add some comment what this regex is about or even renaming `regex` to some more specific name to clear its logic 
created_at: 2025-05-12T09:35:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1598,6 +1599,15 @@ export default class Meeting extends StatelessWebexPlugin {
      * @memberof Meeting
      */
     this.#isoLocalClientMeetingJoinTime = undefined;
+
+    /**
+     * IP Address of remote media server
path: packages/@webex/plugin-meetings/src/meeting/index.ts
user: login: edvujic
body: Nit: 
```suggestion
     * IP Address of the remote media server
```
created_at: 2025-05-12T09:52:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -351,21 +348,25 @@ export class ClusterReachability extends EventsScope {
 
       if (e.candidate) {
         if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) {
-          this.saveResult('udp', latencyInMilliseconds, e.candidate.address);
+          let serverIp = null;
+          if ('url' in e.candidate) {
+            const regex = /stun:([\d.]+):\d+/;
+
+            const match = (e.candidate as any).url.match(regex);
+            if (match) {
+              // eslint-disable-next-line prefer-destructuring
+              serverIp = match[1];
+            }
+          }
+
+          this.saveResult('udp', latencyInMilliseconds, e.candidate.address, serverIp);
path: packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
user: login: edvujic
body: Suggestion: use ternary operator

```suggestion
const match = (e.candidate as any).url.match(regex);
match && ([, serverIp] = match);

this.saveResult('udp', latencyInMilliseconds, e.candidate.address, serverIp);
```
created_at: 2025-05-12T09:59:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -138,6 +138,57 @@ export default class Reachability extends EventsScope {
     }
   }
 
+  /**
+   * Checks if the given subnet is reachable
+   * @param {string} mediaServerIp - media server ip
+   * @returns {boolean} true if reachable, false otherwise
+   * @public
+   * @memberof Reachability
+   */
+  public isSubnetReachable(mediaServerIp?: string): boolean | null {
+    if (!mediaServerIp) {
+      LoggerProxy.logger.error(`Reachability:index#isSubnetReachable --> mediaServerIp is null`);
+
+      return null;
+    }
+
+    const subnetFirstOctet = mediaServerIp.split('.')[0];
+
+    LoggerProxy.logger.info(
+      `Reachability:index#isSubnetReachable --> Looking for subnet: ${subnetFirstOctet}.X.X.X`
+    );
+
+    const reachedClusters = Object.values(this.clusterReachability).reduce((acc, cluster) => {
+      const reachedSubnetsArray = Array.from(cluster.reachedSubnets);
+
+      let logMessage = `Reachability:index#isSubnetReachable --> Cluster ${cluster.name} reached [`;
+      for (let i = 0; i < reachedSubnetsArray.length; i += 1) {
+        const subnet = reachedSubnetsArray[i];
+        const reachedSubnetFirstOctet = subnet.split('.')[0];
+
+        if (subnetFirstOctet === reachedSubnetFirstOctet) {
+          acc.add(cluster.name);
+        }
+
+        logMessage += `${subnet}`;
+        if (i < reachedSubnetsArray.length - 1) {
+          logMessage += ',';
+        }
+      }
+      logMessage += `]`;
path: packages/@webex/plugin-meetings/src/reachability/index.ts
user: login: edvujic
body: (1)
Suggestion:
you can use arrow function here

```
const logMessage = reachedSubnetsArray
  .map((subnet) => {
    const reachedSubnetFirstOctet = subnet.split('.')[0];
    if (subnetFirstOctet === reachedSubnetFirstOctet) {
      acc.add(cluster.name);
    }
    return subnet;
  })
  .join(',');
```
created_at: 2025-05-12T10:04:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -138,6 +138,57 @@ export default class Reachability extends EventsScope {
     }
   }
 
+  /**
+   * Checks if the given subnet is reachable
+   * @param {string} mediaServerIp - media server ip
+   * @returns {boolean} true if reachable, false otherwise
+   * @public
+   * @memberof Reachability
+   */
+  public isSubnetReachable(mediaServerIp?: string): boolean | null {
+    if (!mediaServerIp) {
+      LoggerProxy.logger.error(`Reachability:index#isSubnetReachable --> mediaServerIp is null`);
+
+      return null;
+    }
+
+    const subnetFirstOctet = mediaServerIp.split('.')[0];
+
+    LoggerProxy.logger.info(
+      `Reachability:index#isSubnetReachable --> Looking for subnet: ${subnetFirstOctet}.X.X.X`
+    );
+
+    const reachedClusters = Object.values(this.clusterReachability).reduce((acc, cluster) => {
+      const reachedSubnetsArray = Array.from(cluster.reachedSubnets);
+
+      let logMessage = `Reachability:index#isSubnetReachable --> Cluster ${cluster.name} reached [`;
+      for (let i = 0; i < reachedSubnetsArray.length; i += 1) {
+        const subnet = reachedSubnetsArray[i];
+        const reachedSubnetFirstOctet = subnet.split('.')[0];
+
+        if (subnetFirstOctet === reachedSubnetFirstOctet) {
+          acc.add(cluster.name);
+        }
+
+        logMessage += `${subnet}`;
+        if (i < reachedSubnetsArray.length - 1) {
+          logMessage += ',';
+        }
+      }
+      logMessage += `]`;
+
+      LoggerProxy.logger.info(logMessage);
path: packages/@webex/plugin-meetings/src/reachability/index.ts
user: login: edvujic
body: (2) and then use it here:
```suggestion
      LoggerProxy.logger.info(
  `Reachability:index#isSubnetReachable --> Cluster ${cluster.name} reached [${logMessage}]`
);
```
created_at: 2025-05-12T10:05:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -449,41 +399,54 @@ describe('ClusterReachability', () => {
       });
     });
 
-    it('determines correctly if symmetric-nat is detected', async () => {
+    it('should gather correctly reached subnets', async () => {
       const promise = clusterReachability.start();
 
-      // generate candidates with duplicate addresses
       await clock.tickAsync(10);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', relatedPort: 3478, port: 1000}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:4.3.2.1:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
 
-      // check events emitted: there shouldn't be any natTypeUpdated emitted
-      assert.equal(emittedEvents[Events.natTypeUpdated].length, 0);
+      clusterReachability.abort();
+      await promise;
+
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), [
+        '1.2.3.4',
+        '4.3.2.1',
+        'someTurnRelayIp'
+      ]);
+    });
+
+    it('should store only unique subnet address', async () => {
+      const promise = clusterReachability.start();
 
       await clock.tickAsync(10);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', relatedPort: 3478, port: 2000}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:9000'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: '1.2.3.4'}});
 
-      // should emit natTypeUpdated event
-      assert.equal(emittedEvents[Events.natTypeUpdated].length, 1);
-      assert.deepEqual(emittedEvents[Events.natTypeUpdated][0], {
-        natType: 'symmetric-nat',
-      });
+      clusterReachability.abort();
+      await promise;
 
-      // send also a relay candidate so that the reachability check finishes
-      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
-      fakePeerConnection.onicecandidate({
-        candidate: {type: 'relay', address: 'someTurnRelayIp', port: 443},
-      });
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['1.2.3.4']);
+    });
+
+    it('should store latency only for the first relay candidate', async () => {
+      const promise = clusterReachability.start();
+
+      await clock.tickAsync(10);
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp1'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp2'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp3'}});
 
+      clusterReachability.abort();
       await promise;
 
+      // latency should be from only the first candidates, but the clientMediaIps should be from only from UDP candidates
path: packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
user: login: edvujic
body: nit:
```suggestion
      // latency should be measured only from the first candidates, but clientMediaIps should be taken only from UDP candidates
```
created_at: 2025-05-12T10:08:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -138,6 +138,57 @@ export default class Reachability extends EventsScope {
     }
   }
 
+  /**
+   * Checks if the given subnet is reachable
+   * @param {string} mediaServerIp - media server ip
+   * @returns {boolean} true if reachable, false otherwise
path: packages/@webex/plugin-meetings/src/reachability/index.ts
user: login: edvujic
body: is this correct? you also return `null`.
created_at: 2025-05-12T10:09:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +99,24 @@ MeetingsUtil.getMediaServer = (sdp) => {
   return mediaServer;
 };
 
+MeetingsUtil.getMediaServerIp = (sdp) => {
+  let mediaServerIp;
+
+  // Attempt to collect the media server from the roap message.
+  try {
+    mediaServerIp = sdp
+      .split('\r\n')
+      .find((line) => line.startsWith('o='))
+      .match(/o=\S+ \d+ \d+ IN IP4 ([\d.]+)/)?.[1]
+      .toLowerCase()
+      .trim();
+  } catch {
+    mediaServerIp = undefined;
+  }
+
+  return mediaServerIp;
+};
path: packages/@webex/plugin-meetings/src/meetings/util.ts
user: login: marcin-bazyl
body: for now we can ignore it, let's add this metric only for ipv4 cases, because Orpheus only will give us ipv4 addresses for reachability checks in the first call to /clusters anyway, so we would have a lot of false metric results for ipv6 
created_at: 2025-05-12T11:22:30Z
author_association: COLLABORATOR
diff_hunk: @@ -138,6 +138,57 @@ export default class Reachability extends EventsScope {
     }
   }
 
+  /**
+   * Checks if the given subnet is reachable
+   * @param {string} mediaServerIp - media server ip
+   * @returns {boolean} true if reachable, false otherwise
+   * @public
+   * @memberof Reachability
+   */
+  public isSubnetReachable(mediaServerIp?: string): boolean | null {
+    if (!mediaServerIp) {
+      LoggerProxy.logger.error(`Reachability:index#isSubnetReachable --> mediaServerIp is null`);
+
+      return null;
+    }
+
+    const subnetFirstOctet = mediaServerIp.split('.')[0];
+
+    LoggerProxy.logger.info(
+      `Reachability:index#isSubnetReachable --> Looking for subnet: ${subnetFirstOctet}.X.X.X`
+    );
+
+    const reachedClusters = Object.values(this.clusterReachability).reduce((acc, cluster) => {
path: packages/@webex/plugin-meetings/src/reachability/index.ts
user: login: marcin-bazyl
body: nitpick: the name `reachedClusters` is not entirely correct, because these are only the reached clusters for which the first octet matches the `subnetFirstOctet`, so maybe `matchingReachedClusters` would be a better name? 
created_at: 2025-05-12T11:42:37Z
author_association: COLLABORATOR
diff_hunk: @@ -138,6 +138,57 @@ export default class Reachability extends EventsScope {
     }
   }
 
+  /**
+   * Checks if the given subnet is reachable
+   * @param {string} mediaServerIp - media server ip
+   * @returns {boolean} true if reachable, false otherwise
+   * @public
+   * @memberof Reachability
+   */
+  public isSubnetReachable(mediaServerIp?: string): boolean | null {
+    if (!mediaServerIp) {
+      LoggerProxy.logger.error(`Reachability:index#isSubnetReachable --> mediaServerIp is null`);
+
+      return null;
+    }
+
+    const subnetFirstOctet = mediaServerIp.split('.')[0];
+
+    LoggerProxy.logger.info(
+      `Reachability:index#isSubnetReachable --> Looking for subnet: ${subnetFirstOctet}.X.X.X`
+    );
+
+    const reachedClusters = Object.values(this.clusterReachability).reduce((acc, cluster) => {
+      const reachedSubnetsArray = Array.from(cluster.reachedSubnets);
+
+      let logMessage = `Reachability:index#isSubnetReachable --> Cluster ${cluster.name} reached [`;
+      for (let i = 0; i < reachedSubnetsArray.length; i += 1) {
+        const subnet = reachedSubnetsArray[i];
+        const reachedSubnetFirstOctet = subnet.split('.')[0];
+
+        if (subnetFirstOctet === reachedSubnetFirstOctet) {
+          acc.add(cluster.name);
+        }
+
+        logMessage += `${subnet}`;
+        if (i < reachedSubnetsArray.length - 1) {
+          logMessage += ',';
+        }
+      }
+      logMessage += `]`;
+
+      LoggerProxy.logger.info(logMessage);
+
+      return acc;
+    }, new Set<string>());
+
+    LoggerProxy.logger.info(
+      `Reachability:index#isSubnetReachable --> Found ${reachedClusters.size} clusters that reached the subnet ${subnetFirstOctet}.X.X.X`
path: packages/@webex/plugin-meetings/src/reachability/index.ts
user: login: marcin-bazyl
body: the log message is not entirely correct, it should say "found ${reachedClusters.size} reachable clusters that use the subnet ${subnetFirstOctet}.X.X.X" or something like that
created_at: 2025-05-12T11:45:05Z
author_association: COLLABORATOR
diff_hunk: @@ -449,41 +399,54 @@ describe('ClusterReachability', () => {
       });
     });
 
-    it('determines correctly if symmetric-nat is detected', async () => {
path: packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
user: login: marcin-bazyl
body: what happened to this test? why is it gone?
created_at: 2025-05-12T11:49:27Z
author_association: COLLABORATOR
diff_hunk: @@ -449,41 +399,54 @@ describe('ClusterReachability', () => {
       });
     });
 
-    it('determines correctly if symmetric-nat is detected', async () => {
+    it('should gather correctly reached subnets', async () => {
       const promise = clusterReachability.start();
 
-      // generate candidates with duplicate addresses
       await clock.tickAsync(10);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', relatedPort: 3478, port: 1000}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:4.3.2.1:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
 
-      // check events emitted: there shouldn't be any natTypeUpdated emitted
-      assert.equal(emittedEvents[Events.natTypeUpdated].length, 0);
+      clusterReachability.abort();
+      await promise;
+
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), [
+        '1.2.3.4',
+        '4.3.2.1',
+        'someTurnRelayIp'
+      ]);
+    });
+
+    it('should store only unique subnet address', async () => {
+      const promise = clusterReachability.start();
 
       await clock.tickAsync(10);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', relatedPort: 3478, port: 2000}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:9000'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: '1.2.3.4'}});
 
-      // should emit natTypeUpdated event
-      assert.equal(emittedEvents[Events.natTypeUpdated].length, 1);
-      assert.deepEqual(emittedEvents[Events.natTypeUpdated][0], {
-        natType: 'symmetric-nat',
-      });
+      clusterReachability.abort();
+      await promise;
 
-      // send also a relay candidate so that the reachability check finishes
-      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
-      fakePeerConnection.onicecandidate({
-        candidate: {type: 'relay', address: 'someTurnRelayIp', port: 443},
-      });
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['1.2.3.4']);
+    });
+
+    it('should store latency only for the first relay candidate', async () => {
path: packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
user: login: marcin-bazyl
body: the test with exactly the same title and almost the same body exists in line 282 already. Why are we adding it here?
created_at: 2025-05-12T11:54:06Z
author_association: COLLABORATOR
diff_hunk: @@ -253,6 +253,7 @@ describe('plugin-meetings', () => {
       getReachabilityResults: sinon.stub().resolves(undefined),
       getReachabilityMetrics: sinon.stub().resolves({}),
       stopReachability: sinon.stub(),
+      isSubnetReachable: sinon.stub().returns(true),
path: packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js
user: login: marcin-bazyl
body: nitpick: in all the tests the stub returns true, it would be nice to have at least one place returning false
created_at: 2025-05-12T12:03:26Z
author_association: COLLABORATOR
diff_hunk: @@ -449,41 +399,54 @@ describe('ClusterReachability', () => {
       });
     });
 
-    it('determines correctly if symmetric-nat is detected', async () => {
+    it('should gather correctly reached subnets', async () => {
       const promise = clusterReachability.start();
 
-      // generate candidates with duplicate addresses
       await clock.tickAsync(10);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', relatedPort: 3478, port: 1000}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:4.3.2.1:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
 
-      // check events emitted: there shouldn't be any natTypeUpdated emitted
-      assert.equal(emittedEvents[Events.natTypeUpdated].length, 0);
+      clusterReachability.abort();
+      await promise;
+
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), [
+        '1.2.3.4',
+        '4.3.2.1',
+        'someTurnRelayIp'
+      ]);
+    });
+
+    it('should store only unique subnet address', async () => {
+      const promise = clusterReachability.start();
 
       await clock.tickAsync(10);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', relatedPort: 3478, port: 2000}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:9000'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: '1.2.3.4'}});
 
-      // should emit natTypeUpdated event
-      assert.equal(emittedEvents[Events.natTypeUpdated].length, 1);
-      assert.deepEqual(emittedEvents[Events.natTypeUpdated][0], {
-        natType: 'symmetric-nat',
-      });
+      clusterReachability.abort();
+      await promise;
 
-      // send also a relay candidate so that the reachability check finishes
-      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
-      fakePeerConnection.onicecandidate({
-        candidate: {type: 'relay', address: 'someTurnRelayIp', port: 443},
-      });
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['1.2.3.4']);
+    });
+
+    it('should store latency only for the first relay candidate', async () => {
path: packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
user: login: k-wasniowski
body: Removed... Not sure how it was added...
created_at: 2025-05-12T12:40:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -138,6 +138,57 @@ export default class Reachability extends EventsScope {
     }
   }
 
+  /**
+   * Checks if the given subnet is reachable
+   * @param {string} mediaServerIp - media server ip
+   * @returns {boolean} true if reachable, false otherwise
path: packages/@webex/plugin-meetings/src/reachability/index.ts
user: login: k-wasniowski
body: Updated
created_at: 2025-05-12T12:53:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -138,6 +138,57 @@ export default class Reachability extends EventsScope {
     }
   }
 
+  /**
+   * Checks if the given subnet is reachable
+   * @param {string} mediaServerIp - media server ip
+   * @returns {boolean} true if reachable, false otherwise
+   * @public
+   * @memberof Reachability
+   */
+  public isSubnetReachable(mediaServerIp?: string): boolean | null {
+    if (!mediaServerIp) {
+      LoggerProxy.logger.error(`Reachability:index#isSubnetReachable --> mediaServerIp is null`);
+
+      return null;
+    }
+
+    const subnetFirstOctet = mediaServerIp.split('.')[0];
+
+    LoggerProxy.logger.info(
+      `Reachability:index#isSubnetReachable --> Looking for subnet: ${subnetFirstOctet}.X.X.X`
+    );
+
+    const reachedClusters = Object.values(this.clusterReachability).reduce((acc, cluster) => {
path: packages/@webex/plugin-meetings/src/reachability/index.ts
user: login: k-wasniowski
body: Updated
created_at: 2025-05-12T12:53:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -138,6 +138,57 @@ export default class Reachability extends EventsScope {
     }
   }
 
+  /**
+   * Checks if the given subnet is reachable
+   * @param {string} mediaServerIp - media server ip
+   * @returns {boolean} true if reachable, false otherwise
+   * @public
+   * @memberof Reachability
+   */
+  public isSubnetReachable(mediaServerIp?: string): boolean | null {
+    if (!mediaServerIp) {
+      LoggerProxy.logger.error(`Reachability:index#isSubnetReachable --> mediaServerIp is null`);
+
+      return null;
+    }
+
+    const subnetFirstOctet = mediaServerIp.split('.')[0];
+
+    LoggerProxy.logger.info(
+      `Reachability:index#isSubnetReachable --> Looking for subnet: ${subnetFirstOctet}.X.X.X`
+    );
+
+    const reachedClusters = Object.values(this.clusterReachability).reduce((acc, cluster) => {
+      const reachedSubnetsArray = Array.from(cluster.reachedSubnets);
+
+      let logMessage = `Reachability:index#isSubnetReachable --> Cluster ${cluster.name} reached [`;
+      for (let i = 0; i < reachedSubnetsArray.length; i += 1) {
+        const subnet = reachedSubnetsArray[i];
+        const reachedSubnetFirstOctet = subnet.split('.')[0];
+
+        if (subnetFirstOctet === reachedSubnetFirstOctet) {
+          acc.add(cluster.name);
+        }
+
+        logMessage += `${subnet}`;
+        if (i < reachedSubnetsArray.length - 1) {
+          logMessage += ',';
+        }
+      }
+      logMessage += `]`;
+
+      LoggerProxy.logger.info(logMessage);
+
+      return acc;
+    }, new Set<string>());
+
+    LoggerProxy.logger.info(
+      `Reachability:index#isSubnetReachable --> Found ${reachedClusters.size} clusters that reached the subnet ${subnetFirstOctet}.X.X.X`
path: packages/@webex/plugin-meetings/src/reachability/index.ts
user: login: k-wasniowski
body: Updated
created_at: 2025-05-12T12:53:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -449,41 +399,54 @@ describe('ClusterReachability', () => {
       });
     });
 
-    it('determines correctly if symmetric-nat is detected', async () => {
path: packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
user: login: k-wasniowski
body: No idea... Added it back...
created_at: 2025-05-12T12:54:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -449,41 +399,54 @@ describe('ClusterReachability', () => {
       });
     });
 
-    it('determines correctly if symmetric-nat is detected', async () => {
+    it('should gather correctly reached subnets', async () => {
       const promise = clusterReachability.start();
 
-      // generate candidates with duplicate addresses
       await clock.tickAsync(10);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', relatedPort: 3478, port: 1000}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:4.3.2.1:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
 
-      // check events emitted: there shouldn't be any natTypeUpdated emitted
-      assert.equal(emittedEvents[Events.natTypeUpdated].length, 0);
+      clusterReachability.abort();
+      await promise;
+
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), [
+        '1.2.3.4',
+        '4.3.2.1',
+        'someTurnRelayIp'
+      ]);
+    });
+
+    it('should store only unique subnet address', async () => {
+      const promise = clusterReachability.start();
 
       await clock.tickAsync(10);
-      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', relatedPort: 3478, port: 2000}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:9000'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: '1.2.3.4'}});
 
-      // should emit natTypeUpdated event
-      assert.equal(emittedEvents[Events.natTypeUpdated].length, 1);
-      assert.deepEqual(emittedEvents[Events.natTypeUpdated][0], {
-        natType: 'symmetric-nat',
-      });
+      clusterReachability.abort();
+      await promise;
 
-      // send also a relay candidate so that the reachability check finishes
-      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}});
-      fakePeerConnection.onicecandidate({
-        candidate: {type: 'relay', address: 'someTurnRelayIp', port: 443},
-      });
+      assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['1.2.3.4']);
+    });
+
+    it('should store latency only for the first relay candidate', async () => {
+      const promise = clusterReachability.start();
+
+      await clock.tickAsync(10);
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp1'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp2'}});
+      fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp3'}});
 
+      clusterReachability.abort();
       await promise;
 
+      // latency should be from only the first candidates, but the clientMediaIps should be from only from UDP candidates
path: packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts
user: login: k-wasniowski
body: Not sure how this test came up there, removed it.
created_at: 2025-05-12T13:00:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -351,21 +348,25 @@ export class ClusterReachability extends EventsScope {
 
       if (e.candidate) {
         if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) {
-          this.saveResult('udp', latencyInMilliseconds, e.candidate.address);
+          let serverIp = null;
+          if ('url' in e.candidate) {
+            const regex = /stun:([\d.]+):\d+/;
path: packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
user: login: k-wasniowski
body: Done
created_at: 2025-05-12T13:03:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -253,6 +253,7 @@ describe('plugin-meetings', () => {
       getReachabilityResults: sinon.stub().resolves(undefined),
       getReachabilityMetrics: sinon.stub().resolves({}),
       stopReachability: sinon.stub(),
+      isSubnetReachable: sinon.stub().returns(true),
path: packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js
user: login: k-wasniowski
body: Done
created_at: 2025-05-12T13:03:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -351,21 +348,25 @@ export class ClusterReachability extends EventsScope {
 
       if (e.candidate) {
         if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) {
-          this.saveResult('udp', latencyInMilliseconds, e.candidate.address);
+          let serverIp = null;
+          if ('url' in e.candidate) {
+            const regex = /stun:([\d.]+):\d+/;
+
+            const match = (e.candidate as any).url.match(regex);
+            if (match) {
+              // eslint-disable-next-line prefer-destructuring
+              serverIp = match[1];
+            }
+          }
+
+          this.saveResult('udp', latencyInMilliseconds, e.candidate.address, serverIp);
path: packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
user: login: antsukanova
body: I'm not sure if this line is good readable.. `match && ([, serverIp] = match);`
created_at: 2025-05-12T14:01:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-05-05T15:50:22Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

## Walkthrough

The changes introduce tracking and reporting of media server subnet reachability in the Webex Meetings plugin. A new utility method extracts the media server IP from SDP, and this IP is stored as a private property in the Meeting class. During media connection setup, the system checks if the subnet of the media server IP was previously reached using a new method in the Reachability class, which analyzes the subnets gathered during ICE candidate processing. This subnet reachability status is included in behavioral metrics. Related tests are updated and new tests are added to verify the subnet reachability logic and its integration with metrics and ICE candidate handling.

</details>

<!-- walkthrough_end -->
<!-- This is an auto-generated comment: all tool run failures by coderabbit.ai -->

> [!WARNING]
> There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.
> 
> <details>
> <summary>🔧 ESLint</summary>
> 
> > If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.
> 
> yarn install v1.22.22
> [1/4] Resolving packages...
> warning eslint@8.57.1: This version is no longer supported. Please see https://eslint.org/version-support for other options.
> warning eslint > @humanwhocodes/config-array@0.13.0: Use @eslint/config-array instead
> warning eslint > @humanwhocodes/config-array > @humanwhocodes/object-schema@2.0.3: Use @eslint/object-schema instead
> warning eslint > file-entry-cache > flat-cache > rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
> warning eslint > file-entry-cache > flat-cache > rimraf > glob@7.2.3: Glob versions prior to v9 are no longer supported
> warning eslint > file-entry-cache > flat-cache > rimraf > glob > inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
> warning eslint-import-resolver-typescript > glob@7.2.3: Glob versions prior to v9 are no longer supported
> warning glob@7.2.3: Glob versions prior to v9 are no longer supported
> warning intern > glob@7.1.7: Glob versions prior to v9 are no longer supported
> warning intern > glob > inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
> warning jasmine > glob@7.2.3: Glob versions prior to v9 are no longer supported
> warning jest > @jest/core > jest-runtime > glob@7.2.3: Glob versions prior to v9 are no longer supported
> warning jest > @jest/core > jest-config > glob@7.2.3: Glob versions prior to v9 are no longer supported
> warning jest > @jest/core > @jest/reporters > glob@7.2.3: Glob versions prior to v9 are no longer supported
> warning jest > @jest/core > @jest/transform > babel-plugin-istanbul > test-exclude > glob@7.2.3: Glob versions prior to v9 are no longer supported
> warning mocha > glob@7.2.0: Glob versions prior to v9 are no longer supported
> warning mocha > glob > inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
> warning sinon@9.2.4: 16.1.1
> warning sinon > @sinonjs/samsam > lodash.get@4.4.2: This package is deprecated. Use the optional chaining (?.) operator instead.
> warning wd > archiver > glob@7.2.3: Glob versions prior to v9 are no longer supported
> warning wd > q@1.5.1: You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.
> 
> (For a CapTP with native promises, see @endo/eventual-send and @endo/captp)
> warning wd > archiver > archiver-utils > glob@7.2.3: Glob versions prior to v9 are no longer supported
> warning wd > request@2.88.0: request has been deprecated, see https://github.com/request/request/issues/3142
> warning wd > request > uuid@3.4.0: Please upgrade  to version 7 or higher.  Older versions may use Math.random() in certain circumstances, which is known to be problematic.  See https://v8.dev/blog/math-random for details.
> warning wd > request > har-validator@5.1.5: this library is no longer supported
> warning @babel/cli > glob@7.2.3: Glob versions prior to v9 are no longer supported
> warning @babel/plugin-proposal-async-generator-functions@7.20.7: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-async-generator-functions instead.
> warning @babel/plugin-proposal-class-properties@7.18.6: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead.
> warning @babel/plugin-proposal-export-namespace-from@7.18.9: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-export-namespace-from instead.
> warning @babel/plugin-proposal-nullish-coalescing-operator@7.18.6: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead.
> warning @babel/plugin-proposal-object-rest-spread@7.20.7: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead.
> warning @babel/plugin-proposal-optional-chaining@7.21.0: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead.
> warning @babel/polyfill@7.12.1: 🚨 This package has been deprecated in favor of separate inclusion of a polyfill and regenerator-runtime (when needed). See the @babel/polyfill docs (https://babeljs.io/docs/en/babel-polyfill) for more information.
> warning @babel/polyfill > core-js@2.6.12: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.
> warning @babel/runtime-corejs2 > core-js@2.6.12: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.
> warning babel-plugin-lodash > glob@7.2.3: Glob versions prior to v9 are no longer supported
> warning workspace-aggregator-f754f5b1-a92f-4e66-9183-1a849044e1a2 > eslint@8.57.1: This version is no longer supported. Please see https://eslint.org/version-support for other options.
> warning workspace-aggregator-f754f5b1-a92f-4e66-9183-1a849044e1a2 > glob@7.2.3: Glob versions prior to v9 are no longer supported
> warning workspace-aggregator-f754f5b1-a92f-4e66-9183-1a849044e1a2 > sinon@9.2.4: 16.1.1
> warning workspace-aggregator-f754f5b1-a92f-4e66-9183-1a849044e1a2 > @babel/plugin-proposal-async-generator-functions@7.20.7: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-async-generator-functions instead.
> warning workspace-aggregator-f754f5b1-a92f-4e66-9183-1a849044e1a2 > @babel/plugin-proposal-class-properties@7.18.6: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead.
> warning workspace-aggregator-f754f5b1-a92f-4e66-9183-1a849044e1a2 > @babel/plugin-proposal-export-namespace-from@7.18.9: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-export-namespace-from instead.
> warning workspace-aggregator-f754f5b1-a92f-4e66-9183-1a849044e1a2 > @babel/plugin-proposal-nullish-coalescing-operator@7.18.6: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead.
> warning workspace-aggregator-f754f5b1-a92f-4e66-9183-1a849044e1a2 > @babel/plugin-proposal-object-rest-spread@7.20.7: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead.
> warning workspace-aggregator-f754f5b1-a92f-4e66-9183-1a849044e1a2 > @babel/plugin-proposal-optional-chaining@7.21.0: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead.
> warning workspace-aggregator-f754f5b1-a92f-4e66-9183-1a849044e1a2 > @babel/polyfill@7.12.1: 🚨 This package has been deprecated in favor of separate inclusion of a polyfill and regenerator-runtime (when needed). See the @babel/polyfill docs (https://babeljs.io/docs/en/babel-polyfill) for more information.
> [2/4] Fetching packages...
> error eslint-plugin-jsdoc@40.3.0: The engine "node" is incompatible with this module. Expected version "^14 || ^16 || ^17 || ^18 || ^19". Got "22.9.0"
> error Found incompatible module.
> info Visit https://yarnpkg.com/en/docs/cli/install for documentation about this command.
> 
> 
> 
> </details>

<!-- end of auto-generated comment: all tool run failures by coderabbit.ai -->

<!-- announcements_start -->

> [!TIP]
> <details>
> <summary>⚡️ Faster reviews with caching</summary>
> 
> - CodeRabbit now supports caching for code and dependencies, helping speed up reviews. This means quicker feedback, reduced wait times, and a smoother review experience overall. Cached data is encrypted and stored securely. This feature will be automatically enabled for all accounts on May 16th. To opt out, configure `Review - Disable Cache` at either the organization or repository level. If you prefer to disable all data retention across your organization, simply turn off the `Data Retention` setting under your Organization Settings.
> 
> Enjoy the performance boost—your workflow just got faster.
> 
> </details>

<!-- announcements_end -->

---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: .coderabbit.yaml**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between c2f033ca2cbd9d4db119f1d7f1a36450d0bd8904 and 878d8a132d8c29311c47e05809b40d5c34781efc.

</details>

<details>
<summary>📒 Files selected for processing (1)</summary>

* `packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts` (5 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (2)</summary>

* GitHub Check: Build Packages
* GitHub Check: AWS Amplify Console Web Preview

</details>

</details>
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=webex/webex-js-sdk&utm_content=4234):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: aws-amplify-us-east-2[bot]
created_at: 2025-05-05T16:00:30Z
body: This pull request is automatically being deployed by Amplify Hosting ([learn more](https://aws.amazon.com/amplify/hosting/)).<br/><br/>Access this pull request here: https://pr-4234.d3m3l2kee0btzx.amplifyapp.com
author_association: NONE