This is an automated email from the ASF dual-hosted git repository.

jianbin pushed a commit to branch 2.x
in repository https://gitbox.apache.org/repos/asf/incubator-seata.git


The following commit(s) were added to refs/heads/2.x by this push:
     new b9a94c0b1b feature: add metrics support for NamingServer (#7882)
b9a94c0b1b is described below

commit b9a94c0b1b008314db528ff6c685d19593b7afb9
Author: contrueCT <[email protected]>
AuthorDate: Mon Jan 19 09:47:20 2026 +0800

    feature: add metrics support for NamingServer (#7882)
---
 changes/en-us/2.x.md                               |   3 +-
 changes/zh-cn/2.x.md                               |   2 +
 namingserver/pom.xml                               |   9 +
 .../namingserver/listener/ClusterChangeEvent.java  |  28 ++-
 ...hangeEvent.java => ClusterChangePushEvent.java} |  46 ++--
 .../manager/ClusterWatcherManager.java             |  28 ++-
 .../seata/namingserver/manager/NamingManager.java  |  28 ++-
 .../metrics/NamingServerMetricsManager.java        |  63 ++++++
 .../metrics/NamingServerTagsContributor.java       |  64 ++++++
 .../metrics/NoOpNamingMetricsManager.java          |  53 +++++
 .../metrics/PrometheusNamingMetricsManager.java    | 181 ++++++++++++++++
 namingserver/src/main/resources/application.yml    |  16 +-
 .../namingserver/ClusterWatcherManagerTest.java    |  26 ++-
 .../seata/namingserver/NamingControllerV2Test.java |  32 ++-
 .../seata/namingserver/NamingManagerTest.java      |  10 +-
 .../NamingServerMetricsManagerTest.java            | 233 +++++++++++++++++++++
 namingserver/src/test/resources/application.yml    |  14 ++
 17 files changed, 781 insertions(+), 55 deletions(-)

diff --git a/changes/en-us/2.x.md b/changes/en-us/2.x.md
index 92e9d15aa4..a577c31d8a 100644
--- a/changes/en-us/2.x.md
+++ b/changes/en-us/2.x.md
@@ -19,7 +19,7 @@ Add changes here for all PR submitted to the 2.x branch.
 <!-- Please add the `changes` to the following 
location(feature/bugfix/optimize/test) based on the type of PR -->
 
 ### feature:
-
+- [[#7882](https://github.com/apache/incubator-seata/pull/7882)] add metrics 
for NamingServer
 - [[#7760](https://github.com/apache/incubator-seata/pull/7760)] unify 
Jackson/fastjson serialization
 
 
@@ -53,6 +53,7 @@ Thanks to these contributors for their code commits. Please 
report an unintended
 <!-- Please make sure your Github ID is in the list below -->
 
 - [slievrly](https://github.com/slievrly)
+- [contrueCT](https://github.com/contrueCT)
 - [lokidundun](https://github.com/lokidundun)
 - [LegendPei](https://github.com/LegendPei)
 - [funky-eyes](https://github.com/funky-eyes)
diff --git a/changes/zh-cn/2.x.md b/changes/zh-cn/2.x.md
index 609b123bb8..399ad9317d 100644
--- a/changes/zh-cn/2.x.md
+++ b/changes/zh-cn/2.x.md
@@ -20,6 +20,7 @@
 
 ### feature:
 
+- [[#7882](https://github.com/apache/incubator-seata/pull/7882)] 
为NamingServer增加Metrics监控
 - [[#7760](https://github.com/apache/incubator-seata/pull/7760)] 
统一Jackson/fastjson序列化器
 
 
@@ -53,6 +54,7 @@
 <!-- 请确保您的 GitHub ID 在以下列表中 -->
 
 - [slievrly](https://github.com/slievrly)
+- [contrueCT](https://github.com/contrueCT)
 - [lokidundun](https://github.com/lokidundun)
 - [LegendPei](https://github.com/LegendPei)
 - [funky-eyes](https://github.com/funky-eyes)
diff --git a/namingserver/pom.xml b/namingserver/pom.xml
index 0d2e9ff161..46268dc064 100644
--- a/namingserver/pom.xml
+++ b/namingserver/pom.xml
@@ -104,6 +104,15 @@
             <artifactId>spring-boot-starter</artifactId>
         </dependency>
 
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-actuator</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.micrometer</groupId>
+            <artifactId>micrometer-registry-prometheus</artifactId>
+        </dependency>
+
         <dependency>
             <groupId>com.github.ben-manes.caffeine</groupId>
             <artifactId>caffeine</artifactId>
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/listener/ClusterChangeEvent.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/listener/ClusterChangeEvent.java
index 0564a9b057..7d81f5dc6a 100644
--- 
a/namingserver/src/main/java/org/apache/seata/namingserver/listener/ClusterChangeEvent.java
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/listener/ClusterChangeEvent.java
@@ -24,17 +24,25 @@ public class ClusterChangeEvent extends ApplicationEvent {
 
     private String group;
 
+    private String namespace;
+
+    private String clusterName;
+
     private long term;
 
-    public ClusterChangeEvent(Object source, String group, long term) {
+    public ClusterChangeEvent(Object source, String group, String namespace, 
String clusterName, long term) {
         super(source);
         this.group = group;
+        this.namespace = namespace;
+        this.clusterName = clusterName;
         this.term = term;
     }
 
-    public ClusterChangeEvent(Object source, String group) {
+    public ClusterChangeEvent(Object source, String group, String namespace, 
String clusterName) {
         super(source);
         this.group = group;
+        this.namespace = namespace;
+        this.clusterName = clusterName;
     }
 
     public ClusterChangeEvent(Object source, Clock clock) {
@@ -49,6 +57,22 @@ public class ClusterChangeEvent extends ApplicationEvent {
         this.group = group;
     }
 
+    public String getNamespace() {
+        return namespace;
+    }
+
+    public void setNamespace(String namespace) {
+        this.namespace = namespace;
+    }
+
+    public String getClusterName() {
+        return clusterName;
+    }
+
+    public void setClusterName(String clusterName) {
+        this.clusterName = clusterName;
+    }
+
     public long getTerm() {
         return term;
     }
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/listener/ClusterChangeEvent.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/listener/ClusterChangePushEvent.java
similarity index 57%
copy from 
namingserver/src/main/java/org/apache/seata/namingserver/listener/ClusterChangeEvent.java
copy to 
namingserver/src/main/java/org/apache/seata/namingserver/listener/ClusterChangePushEvent.java
index 0564a9b057..ed20689fe3 100644
--- 
a/namingserver/src/main/java/org/apache/seata/namingserver/listener/ClusterChangeEvent.java
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/listener/ClusterChangePushEvent.java
@@ -18,42 +18,32 @@ package org.apache.seata.namingserver.listener;
 
 import org.springframework.context.ApplicationEvent;
 
-import java.time.Clock;
-
-public class ClusterChangeEvent extends ApplicationEvent {
-
-    private String group;
-
-    private long term;
+/**
+ * Event published when cluster change notifications are pushed to watchers.
+ * Used for metrics tracking via Spring's event mechanism.
+ */
+public class ClusterChangePushEvent extends ApplicationEvent {
 
-    public ClusterChangeEvent(Object source, String group, long term) {
-        super(source);
-        this.group = group;
-        this.term = term;
-    }
+    private final String namespace;
+    private final String clusterName;
+    private final String vgroup;
 
-    public ClusterChangeEvent(Object source, String group) {
+    public ClusterChangePushEvent(Object source, String namespace, String 
clusterName, String vgroup) {
         super(source);
-        this.group = group;
-    }
-
-    public ClusterChangeEvent(Object source, Clock clock) {
-        super(source, clock);
-    }
-
-    public String getGroup() {
-        return group;
+        this.namespace = namespace;
+        this.clusterName = clusterName;
+        this.vgroup = vgroup;
     }
 
-    public void setGroup(String group) {
-        this.group = group;
+    public String getNamespace() {
+        return namespace;
     }
 
-    public long getTerm() {
-        return term;
+    public String getClusterName() {
+        return clusterName;
     }
 
-    public void setTerm(long term) {
-        this.term = term;
+    public String getVgroup() {
+        return vgroup;
     }
 }
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/manager/ClusterWatcherManager.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/manager/ClusterWatcherManager.java
index 86d40b5faf..1f6f9e3094 100644
--- 
a/namingserver/src/main/java/org/apache/seata/namingserver/manager/ClusterWatcherManager.java
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/manager/ClusterWatcherManager.java
@@ -21,9 +21,13 @@ import jakarta.servlet.AsyncContext;
 import jakarta.servlet.http.HttpServletResponse;
 import org.apache.seata.namingserver.listener.ClusterChangeEvent;
 import org.apache.seata.namingserver.listener.ClusterChangeListener;
+import org.apache.seata.namingserver.listener.ClusterChangePushEvent;
 import org.apache.seata.namingserver.listener.Watcher;
+import org.apache.seata.namingserver.metrics.NamingServerMetricsManager;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.context.event.EventListener;
 import org.springframework.http.HttpStatus;
 import org.springframework.scheduling.annotation.Async;
@@ -54,8 +58,17 @@ public class ClusterWatcherManager implements 
ClusterChangeListener {
     private final ScheduledThreadPoolExecutor scheduledThreadPoolExecutor =
             new ScheduledThreadPoolExecutor(1, new 
CustomizableThreadFactory("long-polling"));
 
+    @Autowired
+    private NamingServerMetricsManager metricsManager;
+
+    @Autowired
+    private ApplicationEventPublisher eventPublisher;
+
     @PostConstruct
     public void init() {
+        // Register metrics data supplier
+        metricsManager.setWatchersSupplier(() -> WATCHERS);
+
         // Responds to monitors that time out
         scheduledThreadPoolExecutor.scheduleAtFixedRate(
                 () -> {
@@ -85,8 +98,16 @@ public class ClusterWatcherManager implements 
ClusterChangeListener {
             GROUP_UPDATE_TERM.put(event.getGroup(), event.getTerm());
             // Notifications are made of changes in cluster information
 
-            Optional.ofNullable(WATCHERS.remove(event.getGroup()))
-                    .ifPresent(watchers -> 
watchers.parallelStream().forEach(this::notify));
+            
Optional.ofNullable(WATCHERS.remove(event.getGroup())).ifPresent(watchers -> {
+                watchers.parallelStream().forEach(this::notify);
+                // Publish event for metrics tracking
+                if (!watchers.isEmpty()) {
+                    eventPublisher.publishEvent(new ClusterChangePushEvent(
+                            this, event.getNamespace(), 
event.getClusterName(), event.getGroup()));
+                    // Refresh watcher count metrics after notification
+                    metricsManager.refreshWatcherCountMetrics();
+                }
+            });
         }
     }
 
@@ -113,6 +134,9 @@ public class ClusterWatcherManager implements 
ClusterChangeListener {
         if (term == null || watcher.getTerm() >= term) {
             WATCHERS.computeIfAbsent(group, value -> new 
ConcurrentLinkedQueue<>())
                     .add(watcher);
+
+            // Immediately refresh watcher count metrics
+            metricsManager.refreshWatcherCountMetrics();
         } else {
             notify(watcher);
         }
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/manager/NamingManager.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/manager/NamingManager.java
index 13a6e42cdf..895b2a1e9d 100644
--- 
a/namingserver/src/main/java/org/apache/seata/namingserver/manager/NamingManager.java
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/manager/NamingManager.java
@@ -41,6 +41,7 @@ import org.apache.seata.namingserver.entity.pojo.ClusterData;
 import org.apache.seata.namingserver.entity.vo.NamespaceVO;
 import org.apache.seata.namingserver.entity.vo.monitor.ClusterVO;
 import org.apache.seata.namingserver.listener.ClusterChangeEvent;
+import org.apache.seata.namingserver.metrics.NamingServerMetricsManager;
 import org.checkerframework.checker.nullness.qual.NonNull;
 import org.checkerframework.checker.nullness.qual.Nullable;
 import org.slf4j.Logger;
@@ -92,6 +93,9 @@ public class NamingManager {
     @Autowired
     private ApplicationContext applicationContext;
 
+    @Autowired
+    private NamingServerMetricsManager metricsManager;
+
     public NamingManager() {
         this.instanceLiveTable = new ConcurrentHashMap<>();
         this.namespaceClusterDataMap = new ConcurrentHashMap<>();
@@ -123,6 +127,9 @@ public class NamingManager {
                 heartbeatCheckTimePeriod,
                 heartbeatCheckTimePeriod,
                 TimeUnit.MILLISECONDS);
+
+        // Register metrics data supplier
+        metricsManager.setNamespaceClusterDataSupplier(() -> 
namespaceClusterDataMap);
     }
 
     public List<ClusterVO> monitorCluster(String namespace) {
@@ -299,7 +306,7 @@ public class NamingManager {
                 .flatMap(map -> Optional.ofNullable(map.get(namespace))
                         .flatMap(namespaceBO -> 
Optional.ofNullable(namespaceBO.getCluster(clusterName))))
                 .ifPresent(clusterBO -> {
-                    applicationContext.publishEvent(new 
ClusterChangeEvent(this, vGroup, term));
+                    applicationContext.publishEvent(new 
ClusterChangeEvent(this, vGroup, namespace, clusterName, term));
                 });
     }
 
@@ -327,12 +334,15 @@ public class NamingManager {
                             clusterName, (String) 
node.getMetadata().get("cluster-type")));
             boolean hasChanged = clusterData.registerInstance(node, unitName);
             Object mappingObj = node.getMetadata().get(CONSTANT_GROUP);
-            // if extended metadata includes vgroup mapping relationship, add 
it in clusterData
+            // if extended metadata includes vgroup mapping relationship, add 
it in
+            // clusterData
             if (mappingObj instanceof Map) {
                 Map<String, String> vGroups = (Map<String, String>) mappingObj;
                 vGroups.forEach((k, v) -> {
-                    // In non-raft mode, a unit is one-to-one with a node, and 
the unitName is stored on the node.
-                    // In raft mode, the unitName is equal to the raft-group, 
so the node's unitName cannot be used.
+                    // In non-raft mode, a unit is one-to-one with a node, and 
the unitName is
+                    // stored on the node.
+                    // In raft mode, the unitName is equal to the raft-group, 
so the node's unitName
+                    // cannot be used.
                     boolean changed = addGroup(namespace, clusterName, 
StringUtils.isBlank(v) ? unitName : v, k);
                     if (hasChanged || changed) {
                         notifyClusterChange(k, namespace, clusterName, 
unitName, node.getTerm());
@@ -344,6 +354,9 @@ public class NamingManager {
                             node.getTransaction().getHost(),
                             node.getTransaction().getPort()),
                     System.currentTimeMillis());
+
+            // Immediately refresh cluster node count metrics
+            metricsManager.refreshClusterNodeCountMetrics();
         } catch (Exception e) {
             LOGGER.error("Instance registered failed:{}", e.getMessage(), e);
             return false;
@@ -376,6 +389,9 @@ public class NamingManager {
                             node.getTransaction().getPort()));
                 }
             }
+
+            // Immediately refresh cluster node count metrics
+            metricsManager.refreshClusterNodeCountMetrics();
         } catch (Exception e) {
             LOGGER.error("Instance unregistered failed:{}", e.getMessage(), e);
             return false;
@@ -472,6 +488,10 @@ public class NamingManager {
                                     instance.getTransaction().getHost() + ":"
                                             + 
instance.getTransaction().getPort());
                         }
+
+                        // Immediately refresh cluster node count metrics 
after removing offline
+                        // instances
+                        metricsManager.refreshClusterNodeCountMetrics();
                     }
                 }
             }
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/metrics/NamingServerMetricsManager.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/metrics/NamingServerMetricsManager.java
new file mode 100644
index 0000000000..119debdfa2
--- /dev/null
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/metrics/NamingServerMetricsManager.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.namingserver.metrics;
+
+import org.apache.seata.namingserver.entity.pojo.ClusterData;
+import org.apache.seata.namingserver.listener.Watcher;
+
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentMap;
+import java.util.function.Supplier;
+
+/**
+ * Interface for NamingServer metrics management.
+ * Provides methods to track cluster nodes, watchers, and push notifications.
+ */
+public interface NamingServerMetricsManager {
+
+    // Metric names
+    String METRIC_CLUSTER_NODE_COUNT = "seata_namingserver_cluster_node_count";
+    String METRIC_WATCHER_COUNT = "seata_namingserver_watcher_count";
+    String METRIC_CLUSTER_CHANGE_PUSH_TOTAL = 
"seata_namingserver_cluster_change_push_total";
+
+    // Tag names
+    String TAG_NAMESPACE = "namespace";
+    String TAG_CLUSTER = "cluster";
+    String TAG_UNIT = "unit";
+    String TAG_VGROUP = "vgroup";
+
+    /**
+     * Sets the supplier for namespace-cluster data used by cluster node count 
metrics.
+     */
+    void setNamespaceClusterDataSupplier(Supplier<ConcurrentMap<String, 
ConcurrentMap<String, ClusterData>>> supplier);
+
+    /**
+     * Sets the supplier for watchers data used by watcher count metrics.
+     */
+    void setWatchersSupplier(Supplier<Map<String, Queue<Watcher<?>>>> 
supplier);
+
+    /**
+     * Refreshes the cluster node count metrics based on current registry data.
+     */
+    void refreshClusterNodeCountMetrics();
+
+    /**
+     * Refreshes the watcher count metrics based on current long-polling 
connections.
+     */
+    void refreshWatcherCountMetrics();
+}
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/metrics/NamingServerTagsContributor.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/metrics/NamingServerTagsContributor.java
new file mode 100644
index 0000000000..7231317f21
--- /dev/null
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/metrics/NamingServerTagsContributor.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.namingserver.metrics;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.common.KeyValues;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import 
org.springframework.http.server.observation.DefaultServerRequestObservationConvention;
+import 
org.springframework.http.server.observation.ServerRequestObservationContext;
+import org.springframework.stereotype.Component;
+
+/**
+ * Custom tags contributor for HTTP server request metrics.
+ * Adds Seata business dimensions (namespace, cluster, vgroup) to
+ * http.server.requests metrics.
+ */
+@Component
+@ConditionalOnProperty(name = "seata.namingserver.metrics.enabled", 
havingValue = "true")
+public class NamingServerTagsContributor extends 
DefaultServerRequestObservationConvention {
+
+    private static final String TAG_NAMESPACE = "namespace";
+    private static final String TAG_CLUSTER = "cluster";
+    private static final String TAG_VGROUP = "vgroup";
+    private static final String UNKNOWN = "unknown";
+
+    @Override
+    public KeyValues 
getLowCardinalityKeyValues(ServerRequestObservationContext context) {
+        KeyValues keyValues = super.getLowCardinalityKeyValues(context);
+
+        // Add namespace tag
+        String namespace = context.getCarrier().getParameter(TAG_NAMESPACE);
+        keyValues = keyValues.and(KeyValue.of(TAG_NAMESPACE, namespace != null 
? namespace : UNKNOWN));
+
+        // Add cluster tag
+        String cluster = context.getCarrier().getParameter(TAG_CLUSTER);
+        if (cluster == null) {
+            cluster = context.getCarrier().getParameter("clusterName");
+        }
+        keyValues = keyValues.and(KeyValue.of(TAG_CLUSTER, cluster != null ? 
cluster : UNKNOWN));
+
+        // Add vgroup tag
+        String vgroup = context.getCarrier().getParameter(TAG_VGROUP);
+        if (vgroup == null) {
+            vgroup = context.getCarrier().getParameter("group");
+        }
+        keyValues = keyValues.and(KeyValue.of(TAG_VGROUP, vgroup != null ? 
vgroup : UNKNOWN));
+
+        return keyValues;
+    }
+}
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/metrics/NoOpNamingMetricsManager.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/metrics/NoOpNamingMetricsManager.java
new file mode 100644
index 0000000000..69754ce549
--- /dev/null
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/metrics/NoOpNamingMetricsManager.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.namingserver.metrics;
+
+import org.apache.seata.namingserver.entity.pojo.ClusterData;
+import org.apache.seata.namingserver.listener.Watcher;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentMap;
+import java.util.function.Supplier;
+
+@Component
+@ConditionalOnProperty(name = "seata.namingserver.metrics.enabled", 
havingValue = "false", matchIfMissing = true)
+public class NoOpNamingMetricsManager implements NamingServerMetricsManager {
+
+    @Override
+    public void setNamespaceClusterDataSupplier(
+            Supplier<ConcurrentMap<String, ConcurrentMap<String, 
ClusterData>>> supplier) {
+        // No-op
+    }
+
+    @Override
+    public void setWatchersSupplier(Supplier<Map<String, Queue<Watcher<?>>>> 
supplier) {
+        // No-op
+    }
+
+    @Override
+    public void refreshClusterNodeCountMetrics() {
+        // No-op
+    }
+
+    @Override
+    public void refreshWatcherCountMetrics() {
+        // No-op
+    }
+}
diff --git 
a/namingserver/src/main/java/org/apache/seata/namingserver/metrics/PrometheusNamingMetricsManager.java
 
b/namingserver/src/main/java/org/apache/seata/namingserver/metrics/PrometheusNamingMetricsManager.java
new file mode 100644
index 0000000000..4954f27c0a
--- /dev/null
+++ 
b/namingserver/src/main/java/org/apache/seata/namingserver/metrics/PrometheusNamingMetricsManager.java
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.namingserver.metrics;
+
+import io.micrometer.core.instrument.Counter;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.MultiGauge;
+import io.micrometer.core.instrument.Tags;
+import jakarta.annotation.PostConstruct;
+import org.apache.seata.common.metadata.namingserver.Unit;
+import org.apache.seata.namingserver.entity.pojo.ClusterData;
+import org.apache.seata.namingserver.listener.ClusterChangePushEvent;
+import org.apache.seata.namingserver.listener.Watcher;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.function.Supplier;
+
+/**
+ * Prometheus-based implementation of NamingServerMetricsManager.
+ * Uses Micrometer MultiGauge for dynamic tag management and automatic cleanup 
of stale metrics.
+ */
+@Component
+@ConditionalOnProperty(name = "seata.namingserver.metrics.enabled", 
havingValue = "true")
+public class PrometheusNamingMetricsManager implements 
NamingServerMetricsManager {
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(PrometheusNamingMetricsManager.class);
+
+    private final MeterRegistry meterRegistry;
+
+    private MultiGauge clusterNodeCountGauge;
+
+    private MultiGauge watcherCountGauge;
+
+    private final ConcurrentMap<String, Counter> clusterChangePushCounters = 
new ConcurrentHashMap<>();
+
+    private Supplier<ConcurrentMap<String, ConcurrentMap<String, 
ClusterData>>> namespaceClusterDataSupplier;
+    private Supplier<Map<String, Queue<Watcher<?>>>> watchersSupplier;
+
+    public PrometheusNamingMetricsManager(MeterRegistry meterRegistry) {
+        this.meterRegistry = meterRegistry;
+    }
+
+    @PostConstruct
+    public void init() {
+        // Initialize MultiGauge for cluster node count
+        this.clusterNodeCountGauge = 
MultiGauge.builder(METRIC_CLUSTER_NODE_COUNT)
+                .description("Number of alive Seata Server nodes in the 
registry")
+                .register(meterRegistry);
+
+        // Initialize MultiGauge for watcher count
+        this.watcherCountGauge = MultiGauge.builder(METRIC_WATCHER_COUNT)
+                .description("Number of HTTP connections waiting for change 
notifications (long polling)")
+                .register(meterRegistry);
+
+        LOGGER.info("NamingServer Prometheus metrics manager initialized with 
event-driven refresh");
+    }
+
+    /**
+     * Listens for ClusterChangePushEvent and increments the push counter.
+     */
+    @EventListener
+    public void onClusterChangePush(ClusterChangePushEvent event) {
+        incrementClusterChangePushCount(event.getNamespace(), 
event.getClusterName(), event.getVgroup());
+    }
+
+    @Override
+    public void setNamespaceClusterDataSupplier(
+            Supplier<ConcurrentMap<String, ConcurrentMap<String, 
ClusterData>>> supplier) {
+        this.namespaceClusterDataSupplier = supplier;
+    }
+
+    @Override
+    public void setWatchersSupplier(Supplier<Map<String, Queue<Watcher<?>>>> 
supplier) {
+        this.watchersSupplier = supplier;
+    }
+
+    @Override
+    public void refreshClusterNodeCountMetrics() {
+        if (namespaceClusterDataSupplier == null) {
+            return;
+        }
+
+        List<MultiGauge.Row<?>> rows = new ArrayList<>();
+        ConcurrentMap<String, ConcurrentMap<String, ClusterData>> 
namespaceClusterDataMap =
+                namespaceClusterDataSupplier.get();
+
+        if (namespaceClusterDataMap != null) {
+            namespaceClusterDataMap.forEach((namespace, clusterDataMap) -> {
+                if (clusterDataMap != null) {
+                    clusterDataMap.forEach((clusterName, clusterData) -> {
+                        if (clusterData != null && clusterData.getUnitData() 
!= null) {
+                            Map<String, Unit> unitData = 
clusterData.getUnitData();
+                            unitData.forEach((unitName, unit) -> {
+                                int nodeCount = 0;
+                                if (unit != null && 
unit.getNamingInstanceList() != null) {
+                                    nodeCount = 
unit.getNamingInstanceList().size();
+                                }
+                                rows.add(MultiGauge.Row.of(
+                                        Tags.of(
+                                                TAG_NAMESPACE, namespace,
+                                                TAG_CLUSTER, clusterName,
+                                                TAG_UNIT, unitName),
+                                        nodeCount));
+                            });
+                        }
+                    });
+                }
+            });
+        }
+
+        clusterNodeCountGauge.register(rows, true);
+    }
+
+    @Override
+    public void refreshWatcherCountMetrics() {
+        if (watchersSupplier == null) {
+            return;
+        }
+
+        List<MultiGauge.Row<?>> rows = new ArrayList<>();
+        Map<String, Queue<Watcher<?>>> watchers = watchersSupplier.get();
+
+        if (watchers != null) {
+            watchers.forEach((vgroup, watcherQueue) -> {
+                int count = 0;
+                if (watcherQueue != null) {
+                    count = watcherQueue.size();
+                }
+                rows.add(MultiGauge.Row.of(Tags.of(TAG_VGROUP, vgroup), 
count));
+            });
+        }
+
+        watcherCountGauge.register(rows, true);
+    }
+
+    private void incrementClusterChangePushCount(String namespace, String 
cluster, String vgroup) {
+        String key = namespace + "|" + cluster + "|" + vgroup;
+        Counter counter =
+                clusterChangePushCounters.computeIfAbsent(key, k -> 
Counter.builder(METRIC_CLUSTER_CHANGE_PUSH_TOTAL)
+                        .description("Total number of cluster change push 
notifications to watchers")
+                        .tag(TAG_NAMESPACE, namespace)
+                        .tag(TAG_CLUSTER, cluster)
+                        .tag(TAG_VGROUP, vgroup)
+                        .register(meterRegistry));
+        counter.increment();
+    }
+
+    /**
+     * Gets the current count of cluster change push notifications.
+     * Exposed for testing purposes.
+     */
+    public double getClusterChangePushCount(String namespace, String cluster, 
String vgroup) {
+        String key = namespace + "|" + cluster + "|" + vgroup;
+        Counter counter = clusterChangePushCounters.get(key);
+        return counter != null ? counter.count() : 0;
+    }
+}
diff --git a/namingserver/src/main/resources/application.yml 
b/namingserver/src/main/resources/application.yml
index e8ba1939c3..892bfe4b66 100644
--- a/namingserver/src/main/resources/application.yml
+++ b/namingserver/src/main/resources/application.yml
@@ -51,14 +51,28 @@ console:
 #    addr:
 #      - 127.0.0.1:8081
 seata:
+  namingserver:
+    metrics:
+      enabled: true
   security:
     secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017
     tokenValidityInMilliseconds: 1800000
     csrf-ignore-urls: /naming/v1/**,/api/v1/naming/**
     ignore:
       urls: 
/,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.jpeg,/**/*.ico,/api/v1/auth/login,/version.json,/naming/v1/health,/error
+management:
+  endpoints:
+    web:
+      exposure:
+        include: "prometheus,health,info,metrics"
+  metrics:
+    tags:
+      application: ${spring.application.name}
+    distribution:
+      percentiles:
+        http.server.requests: 0.5, 0.99, 0.999
   # MCP Server Configuration
   mcp:
     # Maximum query time interval, The unit is milliseconds, Default one day
     query:
-      max-query-duration: 86400000
\ No newline at end of file
+      max-query-duration: 86400000
diff --git 
a/namingserver/src/test/java/org/apache/seata/namingserver/ClusterWatcherManagerTest.java
 
b/namingserver/src/test/java/org/apache/seata/namingserver/ClusterWatcherManagerTest.java
index 6a1addff7a..e32a3b47e5 100644
--- 
a/namingserver/src/test/java/org/apache/seata/namingserver/ClusterWatcherManagerTest.java
+++ 
b/namingserver/src/test/java/org/apache/seata/namingserver/ClusterWatcherManagerTest.java
@@ -22,11 +22,16 @@ import jakarta.servlet.http.HttpServletResponse;
 import org.apache.seata.namingserver.listener.ClusterChangeEvent;
 import org.apache.seata.namingserver.listener.Watcher;
 import org.apache.seata.namingserver.manager.ClusterWatcherManager;
+import org.apache.seata.namingserver.metrics.NoOpNamingMetricsManager;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.Mockito;
-import org.springframework.boot.test.context.SpringBootTest;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.http.HttpStatus;
 import org.springframework.test.util.ReflectionTestUtils;
 
@@ -42,7 +47,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
-@SpringBootTest
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
 public class ClusterWatcherManagerTest {
 
     private ClusterWatcherManager clusterWatcherManager;
@@ -56,7 +62,12 @@ public class ClusterWatcherManagerTest {
     @Mock
     private HttpServletRequest request;
 
+    @Mock
+    private ApplicationEventPublisher eventPublisher;
+
     private final String TEST_GROUP = "testGroup";
+    private final String TEST_NAMESPACE = "testNamespace";
+    private final String TEST_CLUSTER = "testCluster";
     private final int TEST_TIMEOUT = 5000;
     private final Long TEST_TERM = 1000L;
     private final String TEST_CLIENT_ENDPOINT = "127.0.0.1";
@@ -64,6 +75,10 @@ public class ClusterWatcherManagerTest {
     @BeforeEach
     void setUp() {
         clusterWatcherManager = new ClusterWatcherManager();
+        // Inject dependencies to avoid null pointer
+        ReflectionTestUtils.setField(clusterWatcherManager, "metricsManager", 
new NoOpNamingMetricsManager());
+        ReflectionTestUtils.setField(clusterWatcherManager, "eventPublisher", 
eventPublisher);
+
         Mockito.when(asyncContext.getResponse()).thenReturn(response);
         Mockito.when(asyncContext.getRequest()).thenReturn(request);
         Mockito.when(request.getRemoteAddr()).thenReturn(TEST_CLIENT_ENDPOINT);
@@ -136,7 +151,7 @@ public class ClusterWatcherManagerTest {
         assertNotNull(watchers);
         assertNotNull(updateTime);
 
-        ClusterChangeEvent zeroTermEvent = new ClusterChangeEvent(this, 
TEST_GROUP, 0);
+        ClusterChangeEvent zeroTermEvent = new ClusterChangeEvent(this, 
TEST_GROUP, TEST_NAMESPACE, TEST_CLUSTER, 0);
         clusterWatcherManager.onChangeEvent(zeroTermEvent);
 
         assertEquals(0, updateTime.size());
@@ -145,7 +160,8 @@ public class ClusterWatcherManagerTest {
         assertNotNull(watchers.get(TEST_GROUP));
         assertEquals(1, watchers.get(TEST_GROUP).size());
 
-        ClusterChangeEvent event = new ClusterChangeEvent(this, TEST_GROUP, 
TEST_TERM + 1);
+        ClusterChangeEvent event =
+                new ClusterChangeEvent(this, TEST_GROUP, TEST_NAMESPACE, 
TEST_CLUSTER, TEST_TERM + 1);
         clusterWatcherManager.onChangeEvent(event);
 
         Mockito.verify(response).setStatus(HttpServletResponse.SC_OK);
@@ -246,7 +262,7 @@ public class ClusterWatcherManagerTest {
                 new Watcher<>(TEST_GROUP, asyncContext, TEST_TIMEOUT, 
TEST_TERM, TEST_CLIENT_ENDPOINT);
         clusterWatcherManager.registryWatcher(watcher);
 
-        ClusterChangeEvent minus1TermEvent = new ClusterChangeEvent(this, 
TEST_GROUP, -1);
+        ClusterChangeEvent minus1TermEvent = new ClusterChangeEvent(this, 
TEST_GROUP, TEST_NAMESPACE, TEST_CLUSTER, -1);
         clusterWatcherManager.onChangeEvent(minus1TermEvent);
 
         Map<String, Long> updateTime =
diff --git 
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingControllerV2Test.java
 
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingControllerV2Test.java
index 81b1985a8d..13c6f3faf1 100644
--- 
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingControllerV2Test.java
+++ 
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingControllerV2Test.java
@@ -22,11 +22,14 @@ import org.apache.seata.common.result.SingleResult;
 import org.apache.seata.namingserver.controller.NamingControllerV2;
 import org.apache.seata.namingserver.entity.vo.v2.NamespaceVO;
 import org.apache.seata.namingserver.manager.NamingManager;
+import org.apache.seata.namingserver.metrics.NoOpNamingMetricsManager;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.junit.runner.RunWith;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.test.context.junit4.SpringRunner;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.test.util.ReflectionTestUtils;
 
 import java.util.Map;
 import java.util.UUID;
@@ -36,15 +39,24 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
-@RunWith(SpringRunner.class)
-@SpringBootTest
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
 class NamingControllerV2Test {
 
-    @Autowired
-    NamingControllerV2 namingControllerV2;
+    private NamingControllerV2 namingControllerV2;
 
-    @Autowired
-    NamingManager namingManager;
+    private NamingManager namingManager;
+
+    @BeforeEach
+    void setUp() {
+        namingManager = new NamingManager();
+        ReflectionTestUtils.setField(namingManager, "metricsManager", new 
NoOpNamingMetricsManager());
+        ReflectionTestUtils.setField(namingManager, "heartbeatTimeThreshold", 
500000);
+        ReflectionTestUtils.setField(namingManager, 
"heartbeatCheckTimePeriod", 10000000);
+        namingManager.init();
+        namingControllerV2 = new NamingControllerV2();
+        ReflectionTestUtils.setField(namingControllerV2, "namingManager", 
namingManager);
+    }
 
     @Test
     void testNamespaces() {
diff --git 
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
 
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
index 10169707b3..36eaccca81 100644
--- 
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
+++ 
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
@@ -31,13 +31,17 @@ import 
org.apache.seata.namingserver.entity.vo.monitor.ClusterVO;
 import org.apache.seata.namingserver.entity.vo.v2.NamespaceVO;
 import org.apache.seata.namingserver.listener.ClusterChangeEvent;
 import org.apache.seata.namingserver.manager.NamingManager;
+import org.apache.seata.namingserver.metrics.NoOpNamingMetricsManager;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
-import org.springframework.boot.test.context.SpringBootTest;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
 import org.springframework.context.ApplicationContext;
 import org.springframework.test.util.ReflectionTestUtils;
 
@@ -55,7 +59,8 @@ import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyMap;
 import static org.mockito.ArgumentMatchers.anyString;
 
-@SpringBootTest
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
 class NamingManagerTest {
 
     private NamingManager namingManager;
@@ -77,6 +82,7 @@ class NamingManagerTest {
         ReflectionTestUtils.setField(namingManager, "applicationContext", 
applicationContext);
         ReflectionTestUtils.setField(namingManager, "heartbeatTimeThreshold", 
500000);
         ReflectionTestUtils.setField(namingManager, 
"heartbeatCheckTimePeriod", 10000000);
+        ReflectionTestUtils.setField(namingManager, "metricsManager", new 
NoOpNamingMetricsManager());
 
         Mockito.when(httpResponse.code()).thenReturn(200);
         Mockito.when(httpResponse.body()).thenReturn(responseBody);
diff --git 
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingServerMetricsManagerTest.java
 
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingServerMetricsManagerTest.java
new file mode 100644
index 0000000000..d2e2bdd030
--- /dev/null
+++ 
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingServerMetricsManagerTest.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.seata.namingserver;
+
+import io.micrometer.core.instrument.Meter;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import org.apache.seata.common.metadata.namingserver.NamingServerNode;
+import org.apache.seata.common.metadata.namingserver.Unit;
+import org.apache.seata.namingserver.entity.pojo.ClusterData;
+import org.apache.seata.namingserver.listener.ClusterChangePushEvent;
+import org.apache.seata.namingserver.listener.Watcher;
+import org.apache.seata.namingserver.metrics.PrometheusNamingMetricsManager;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import static 
org.apache.seata.namingserver.metrics.NamingServerMetricsManager.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Unit tests for PrometheusNamingMetricsManager.
+ */
+class NamingServerMetricsManagerTest {
+
+    private MeterRegistry meterRegistry;
+    private PrometheusNamingMetricsManager metricsManager;
+
+    @BeforeEach
+    void setUp() {
+        meterRegistry = new SimpleMeterRegistry();
+        metricsManager = new PrometheusNamingMetricsManager(meterRegistry);
+        metricsManager.init();
+    }
+
+    @Test
+    void testClusterNodeCountMetrics() {
+        // Prepare test data
+        ConcurrentMap<String, ConcurrentMap<String, ClusterData>> 
namespaceClusterDataMap = new ConcurrentHashMap<>();
+
+        // Create namespace -> cluster -> unit structure
+        String namespace = "test-namespace";
+        String clusterName = "test-cluster";
+        String unitName = "test-unit";
+
+        ClusterData clusterData = new ClusterData(clusterName, "default");
+        Unit unit = new Unit();
+        unit.setUnitName(unitName);
+        unit.setNamingInstanceList(new CopyOnWriteArrayList<>());
+
+        // Add some nodes
+        NamingServerNode node1 = new NamingServerNode();
+        NamingServerNode node2 = new NamingServerNode();
+        unit.getNamingInstanceList().add(node1);
+        unit.getNamingInstanceList().add(node2);
+
+        clusterData.getUnitData().put(unitName, unit);
+
+        ConcurrentMap<String, ClusterData> clusterDataMap = new 
ConcurrentHashMap<>();
+        clusterDataMap.put(clusterName, clusterData);
+        namespaceClusterDataMap.put(namespace, clusterDataMap);
+
+        // Set the supplier
+        metricsManager.setNamespaceClusterDataSupplier(() -> 
namespaceClusterDataMap);
+
+        // Manually trigger metrics refresh
+        metricsManager.refreshClusterNodeCountMetrics();
+
+        // Verify the metric is registered
+        Meter meter = meterRegistry
+                .find(METRIC_CLUSTER_NODE_COUNT)
+                .tag(TAG_NAMESPACE, namespace)
+                .tag(TAG_CLUSTER, clusterName)
+                .tag(TAG_UNIT, unitName)
+                .meter();
+
+        assertNotNull(meter, "Cluster node count metric should be registered");
+    }
+
+    @Test
+    void testWatcherCountMetrics() {
+        // Prepare test data
+        Map<String, Queue<Watcher<?>>> watchers = new ConcurrentHashMap<>();
+        String vgroup = "test-vgroup";
+
+        Queue<Watcher<?>> watcherQueue = new ConcurrentLinkedQueue<>();
+        watcherQueue.add(new Watcher<>(vgroup, null, 5000, 1L, "127.0.0.1"));
+        watcherQueue.add(new Watcher<>(vgroup, null, 5000, 1L, "127.0.0.2"));
+        watchers.put(vgroup, watcherQueue);
+
+        // Set the supplier
+        metricsManager.setWatchersSupplier(() -> watchers);
+
+        // Manually trigger metrics refresh
+        metricsManager.refreshWatcherCountMetrics();
+
+        // Verify the metric is registered
+        Meter meter =
+                meterRegistry.find(METRIC_WATCHER_COUNT).tag(TAG_VGROUP, 
vgroup).meter();
+
+        assertNotNull(meter, "Watcher count metric should be registered");
+    }
+
+    @Test
+    void testClusterChangePushCounter() {
+        String namespace = "test-namespace";
+        String cluster = "test-cluster";
+        String vgroup1 = "vgroup1";
+        String vgroup2 = "vgroup2";
+
+        // Simulate events via event listener
+        metricsManager.onClusterChangePush(new ClusterChangePushEvent(this, 
namespace, cluster, vgroup1));
+        metricsManager.onClusterChangePush(new ClusterChangePushEvent(this, 
namespace, cluster, vgroup1));
+        metricsManager.onClusterChangePush(new ClusterChangePushEvent(this, 
namespace, cluster, vgroup2));
+
+        // Verify counts
+        assertEquals(2.0, metricsManager.getClusterChangePushCount(namespace, 
cluster, vgroup1));
+        assertEquals(1.0, metricsManager.getClusterChangePushCount(namespace, 
cluster, vgroup2));
+
+        // Verify metrics are registered in registry
+        assertNotNull(meterRegistry
+                .find(METRIC_CLUSTER_CHANGE_PUSH_TOTAL)
+                .tag(TAG_NAMESPACE, namespace)
+                .tag(TAG_CLUSTER, cluster)
+                .tag(TAG_VGROUP, vgroup1)
+                .counter());
+        assertNotNull(meterRegistry
+                .find(METRIC_CLUSTER_CHANGE_PUSH_TOTAL)
+                .tag(TAG_NAMESPACE, namespace)
+                .tag(TAG_CLUSTER, cluster)
+                .tag(TAG_VGROUP, vgroup2)
+                .counter());
+    }
+
+    @Test
+    void testMultiGaugeCleanupStaleTags() {
+        // Prepare initial test data with two namespaces
+        ConcurrentMap<String, ConcurrentMap<String, ClusterData>> 
namespaceClusterDataMap = new ConcurrentHashMap<>();
+
+        String namespace1 = "namespace1";
+        String namespace2 = "namespace2";
+        String clusterName = "cluster1";
+        String unitName = "unit1";
+
+        // Create two clusters in different namespaces
+        for (String namespace : new String[] {namespace1, namespace2}) {
+            ClusterData clusterData = new ClusterData(clusterName, "default");
+            Unit unit = new Unit();
+            unit.setUnitName(unitName);
+            unit.setNamingInstanceList(new CopyOnWriteArrayList<>());
+            unit.getNamingInstanceList().add(new NamingServerNode());
+            clusterData.getUnitData().put(unitName, unit);
+
+            ConcurrentMap<String, ClusterData> clusterDataMap = new 
ConcurrentHashMap<>();
+            clusterDataMap.put(clusterName, clusterData);
+            namespaceClusterDataMap.put(namespace, clusterDataMap);
+        }
+
+        metricsManager.setNamespaceClusterDataSupplier(() -> 
namespaceClusterDataMap);
+
+        // Manually trigger metrics refresh
+        metricsManager.refreshClusterNodeCountMetrics();
+
+        // Verify both metrics exist
+        assertNotNull(meterRegistry
+                .find(METRIC_CLUSTER_NODE_COUNT)
+                .tag(TAG_NAMESPACE, namespace1)
+                .meter());
+        assertNotNull(meterRegistry
+                .find(METRIC_CLUSTER_NODE_COUNT)
+                .tag(TAG_NAMESPACE, namespace2)
+                .meter());
+
+        // Remove namespace2
+        namespaceClusterDataMap.remove(namespace2);
+
+        // Manually trigger metrics refresh
+        metricsManager.refreshClusterNodeCountMetrics();
+
+        // Verify namespace1 still exists but namespace2 is cleaned up
+        assertNotNull(meterRegistry
+                .find(METRIC_CLUSTER_NODE_COUNT)
+                .tag(TAG_NAMESPACE, namespace1)
+                .meter());
+        // Note: MultiGauge with overwrite=true will remove stale entries
+    }
+
+    @Test
+    void testNullSupplierHandling() {
+        // Don't set any suppliers
+        // Manually trigger refresh - should not throw exception
+        metricsManager.refreshClusterNodeCountMetrics();
+        metricsManager.refreshWatcherCountMetrics();
+
+        // No exception means test passed
+        assertTrue(true);
+    }
+
+    @Test
+    void testEmptyDataHandling() {
+        // Set empty suppliers
+        metricsManager.setNamespaceClusterDataSupplier(ConcurrentHashMap::new);
+        metricsManager.setWatchersSupplier(ConcurrentHashMap::new);
+
+        // Manually trigger metrics refresh
+        metricsManager.refreshClusterNodeCountMetrics();
+        metricsManager.refreshWatcherCountMetrics();
+
+        // Verify no exception and no metrics registered
+        assertEquals(0, 
meterRegistry.find(METRIC_CLUSTER_NODE_COUNT).meters().size());
+        assertEquals(0, 
meterRegistry.find(METRIC_WATCHER_COUNT).meters().size());
+    }
+}
diff --git a/namingserver/src/test/resources/application.yml 
b/namingserver/src/test/resources/application.yml
index 8ffcfe613b..3a333437b8 100644
--- a/namingserver/src/test/resources/application.yml
+++ b/namingserver/src/test/resources/application.yml
@@ -29,8 +29,22 @@ heartbeat:
   threshold: 5000
   period: 5000
 seata:
+  namingserver:
+    metrics:
+      enabled: true
   security:
     secretKey: SeataSecretKey0c382ef121d778043159209298fd40bf3850a017
     tokenValidityInMilliseconds: 1800000
     ignore:
       urls: 
/,/**/*.css,/**/*.js,/**/*.html,/**/*.map,/**/*.svg,/**/*.png,/**/*.jpeg,/**/*.ico,/api/v1/auth/login,/version.json,/health,/error,/naming/v1/**
+management:
+  endpoints:
+    web:
+      exposure:
+        include: "prometheus,health,info,metrics"
+  metrics:
+    tags:
+      application: ${spring.application.name}
+    distribution:
+      percentiles:
+        http.server.requests: 0.5, 0.99, 0.999


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to