This is an automated email from the ASF dual-hosted git repository.
Similarityoung pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go-pixiu.git
The following commit(s) were added to refs/heads/develop by this push:
new e7691370f feat(cluster): add metrics for endpoint snapshot publication
(#962)
e7691370f is described below
commit e7691370f89fc2f394546dfdc9a79f1a53f573d0
Author: 承潜 <[email protected]>
AuthorDate: Thu Jul 23 21:02:08 2026 +0800
feat(cluster): add metrics for endpoint snapshot publication (#962)
* feat(cluster): add metrics for endpoint snapshot publication
Add observability for endpoint snapshot publication so operators can
diagnose registry churn, unexpected cluster size, or excessive health
update publication.
Emit three OpenTelemetry instruments, labeled only by cluster name, on
each successful snapshot CompareAndSwap:
- pixiu_cluster_snapshot_publish_total (counter)
- pixiu_cluster_snapshot_endpoint_count (gauge)
- pixiu_cluster_snapshot_healthy_endpoint_count (gauge)
Instruments bind lazily to the global MeterProvider, so they stay no-op
when metrics are disabled and do not alter snapshot publication behavior.
Closes #944
* fix(cluster): ensure snapshot metrics recorded at static cluster startup
Move registerOtelMetricMeter before cluster construction so static clusters'
initial snapshot publish lands on the real meter provider rather than the
no-op default. Without this fix, static clusters with no health transitions
show empty counter/gauges in steady state — defeating the PR's goal of
diagnosing unexpected cluster sizes.
Changes:
- Move registerOtelMetricMeter call from (*Server).Start() to Start(bs),
positioned before server.initialize(bs) which constructs clusters
- Add TestStaticClusterSnapshotMetricsRecordedAtStartup in pkg/server to
guard against ordering regression (models production: provider → clusters)
- Expand installSnapshotMetricsReader doc to note all cluster construction
triggers global instrument init (t.Parallel constraint)
Issue: #944
* docs(cluster): fix installClusterSnapshotMetricsReader comment accuracy
Update the helper's documentation to accurately reflect its behavior: it
installs a ManualReader provider and restores the previous provider on
cleanup, but does not reset pkg/cluster's instrument variables (which are
in a different package and not accessible from pkg/server tests).
The comment previously claimed it "resets the global instrument state" and
mentioned "pkg/cluster instrument variables," but the implementation only
swaps otel.MeterProvider without touching snapshotPublishTotal,
snapshotEndpointCount, or snapshotHealthyCount.
Addresses review feedback on PR #962.
---
pkg/cluster/cluster.go | 3 +
pkg/cluster/metrics.go | 87 +++++++++++++++++++
pkg/cluster/metrics_test.go | 174 +++++++++++++++++++++++++++++++++++++
pkg/server/cluster_manager_test.go | 122 ++++++++++++++++++++++++++
pkg/server/pixiu_start.go | 10 ++-
5 files changed, 395 insertions(+), 1 deletion(-)
diff --git a/pkg/cluster/cluster.go b/pkg/cluster/cluster.go
index c6c27a70f..4868107e6 100644
--- a/pkg/cluster/cluster.go
+++ b/pkg/cluster/cluster.go
@@ -101,6 +101,7 @@ func (c *Cluster) RefreshEndpointsFrom(previous
*EndpointSnapshot) {
}
next := newEndpointSnapshot(c.Config, source,
len(c.Config.HealthChecks) != 0)
if c.endpoints.CompareAndSwap(current, next) {
+ recordSnapshotPublish(c.clusterName(), next)
return
}
}
@@ -128,6 +129,7 @@ func (c *Cluster) UpdateEndpointHealth(endpointID,
endpointAddress string, healt
return true
}
if c.endpoints.CompareAndSwap(current, next) {
+ recordSnapshotPublish(c.clusterName(), next)
return true
}
}
@@ -158,6 +160,7 @@ func (c *Cluster)
UpdateEndpointAddressHealth(endpointAddress string, healthy bo
return true
}
if c.endpoints.CompareAndSwap(current, next) {
+ recordSnapshotPublish(c.clusterName(), next)
return true
}
}
diff --git a/pkg/cluster/metrics.go b/pkg/cluster/metrics.go
new file mode 100644
index 000000000..7701f5b92
--- /dev/null
+++ b/pkg/cluster/metrics.go
@@ -0,0 +1,87 @@
+/*
+ * 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 cluster
+
+import (
+ "context"
+ "sync"
+)
+
+import (
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/metric"
+)
+
+import (
+ "github.com/apache/dubbo-go-pixiu/pkg/logger"
+)
+
+// Endpoint snapshot publication metrics. Instruments are bound lazily to the
+// global MeterProvider on first publish; when metrics are disabled the
provider
+// is a no-op, so recording stays a cheap no-op and never blocks publication.
+var (
+ snapshotMetricsOnce sync.Once
+
+ snapshotPublishTotal metric.Int64Counter
+ snapshotEndpointCount metric.Int64Gauge
+ snapshotHealthyCount metric.Int64Gauge
+)
+
+func initSnapshotMetrics() {
+ snapshotMetricsOnce.Do(func() {
+ meter := otel.GetMeterProvider().Meter("pixiu")
+
+ var err error
+ if snapshotPublishTotal, err =
meter.Int64Counter("pixiu_cluster_snapshot_publish_total",
+ metric.WithDescription("Total number of cluster
endpoint snapshots successfully published.")); err != nil {
+ logger.Errorf("[dubbo-go-pixiu] register
pixiu_cluster_snapshot_publish_total failed: %v", err)
+ }
+ if snapshotEndpointCount, err =
meter.Int64Gauge("pixiu_cluster_snapshot_endpoint_count",
+ metric.WithDescription("Total endpoints in the latest
published cluster snapshot.")); err != nil {
+ logger.Errorf("[dubbo-go-pixiu] register
pixiu_cluster_snapshot_endpoint_count failed: %v", err)
+ }
+ if snapshotHealthyCount, err =
meter.Int64Gauge("pixiu_cluster_snapshot_healthy_endpoint_count",
+ metric.WithDescription("Healthy endpoints in the latest
published cluster snapshot.")); err != nil {
+ logger.Errorf("[dubbo-go-pixiu] register
pixiu_cluster_snapshot_healthy_endpoint_count failed: %v", err)
+ }
+ })
+}
+
+// recordSnapshotPublish reports a successful snapshot publication for a
cluster.
+// Call exactly once per successful CompareAndSwap of the published snapshot.
The
+// only label is the cluster name, which is bounded by configuration rather
than
+// request traffic, so cardinality stays low. The health-update callers invoke
+// this while holding the cluster's healthMu, so the body must stay
+// allocation-light and must not block.
+func recordSnapshotPublish(clusterName string, snapshot *EndpointSnapshot) {
+ initSnapshotMetrics()
+
+ attrs := metric.WithAttributes(attribute.String("cluster", clusterName))
+ ctx := context.Background()
+
+ if snapshotPublishTotal != nil {
+ snapshotPublishTotal.Add(ctx, 1, attrs)
+ }
+ if snapshotEndpointCount != nil {
+ snapshotEndpointCount.Record(ctx,
int64(snapshot.EndpointCount()), attrs)
+ }
+ if snapshotHealthyCount != nil {
+ snapshotHealthyCount.Record(ctx,
int64(snapshot.HealthyEndpointCount()), attrs)
+ }
+}
diff --git a/pkg/cluster/metrics_test.go b/pkg/cluster/metrics_test.go
new file mode 100644
index 000000000..c03dd33f9
--- /dev/null
+++ b/pkg/cluster/metrics_test.go
@@ -0,0 +1,174 @@
+/*
+ * 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 cluster
+
+import (
+ "context"
+ "sync"
+ "testing"
+)
+
+import (
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
+ "go.opentelemetry.io/otel/sdk/metric/metricdata"
+)
+
+// installSnapshotMetricsReader binds the snapshot instruments to a fresh
+// ManualReader so a test can inspect the recorded measurements, and resets the
+// global provider and instrument state on cleanup so later callers rebind.
+//
+// It mutates package-global instruments and the process-global MeterProvider,
+// so tests that use it must not call t.Parallel(). Additionally, any test in
+// this package that constructs a cluster (even without calling this helper)
+// will trigger recordSnapshotPublish → initSnapshotMetrics, so tests that
+// construct clusters must also not call t.Parallel() to avoid racing on the
+// sync.Once and global instrument variables.
+func installSnapshotMetricsReader(t *testing.T) *sdkmetric.ManualReader {
+ t.Helper()
+
+ reader := sdkmetric.NewManualReader()
+ provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
+
+ prevProvider := otel.GetMeterProvider()
+
+ otel.SetMeterProvider(provider)
+ snapshotMetricsOnce = sync.Once{}
+ snapshotPublishTotal, snapshotEndpointCount, snapshotHealthyCount =
nil, nil, nil
+
+ t.Cleanup(func() {
+ otel.SetMeterProvider(prevProvider)
+ snapshotMetricsOnce = sync.Once{}
+ snapshotPublishTotal, snapshotEndpointCount,
snapshotHealthyCount = nil, nil, nil
+ })
+
+ return reader
+}
+
+func collectSnapshotMetrics(t *testing.T, reader *sdkmetric.ManualReader)
map[string]metricdata.Metrics {
+ t.Helper()
+
+ var rm metricdata.ResourceMetrics
+ require.NoError(t, reader.Collect(context.Background(), &rm))
+
+ out := make(map[string]metricdata.Metrics)
+ for _, sm := range rm.ScopeMetrics {
+ for _, m := range sm.Metrics {
+ out[m.Name] = m
+ }
+ }
+ return out
+}
+
+func sumForCluster(t *testing.T, m metricdata.Metrics, cluster string) int64 {
+ t.Helper()
+ data, ok := m.Data.(metricdata.Sum[int64])
+ require.True(t, ok, "metric %s is not an int64 Sum", m.Name)
+ for _, dp := range data.DataPoints {
+ if v, ok := dp.Attributes.Value(attribute.Key("cluster")); ok
&& v.AsString() == cluster {
+ return dp.Value
+ }
+ }
+ t.Fatalf("no data point for cluster %q in metric %s", cluster, m.Name)
+ return 0
+}
+
+func gaugeForCluster(t *testing.T, m metricdata.Metrics, cluster string) int64
{
+ t.Helper()
+ data, ok := m.Data.(metricdata.Gauge[int64])
+ require.True(t, ok, "metric %s is not an int64 Gauge", m.Name)
+ for _, dp := range data.DataPoints {
+ if v, ok := dp.Attributes.Value(attribute.Key("cluster")); ok
&& v.AsString() == cluster {
+ return dp.Value
+ }
+ }
+ t.Fatalf("no data point for cluster %q in metric %s", cluster, m.Name)
+ return 0
+}
+
+func TestSnapshotMetricsRecordPublishCountAndSizes(t *testing.T) {
+ reader := installSnapshotMetricsReader(t)
+
+ healthy := testEndpoint("ep-1", "127.0.0.1", 18080)
+ unhealthy := testEndpoint("ep-2", "127.0.0.2", 18081)
+ runtimeCluster := NewCluster(testCluster("snapshot-metrics", healthy,
unhealthy))
+
+ // One publish from NewCluster's initial RefreshEndpointsFrom.
+ require.True(t, runtimeCluster.UpdateEndpointHealth(unhealthy.ID,
unhealthy.Address.GetAddress(), false))
+
+ metrics := collectSnapshotMetrics(t, reader)
+
+ publish, ok := metrics["pixiu_cluster_snapshot_publish_total"]
+ require.True(t, ok, "publish total metric missing")
+ assert.Equal(t, int64(2), sumForCluster(t, publish, "snapshot-metrics"))
+
+ count, ok := metrics["pixiu_cluster_snapshot_endpoint_count"]
+ require.True(t, ok, "endpoint count metric missing")
+ assert.Equal(t, int64(2), gaugeForCluster(t, count, "snapshot-metrics"))
+
+ healthyCount, ok :=
metrics["pixiu_cluster_snapshot_healthy_endpoint_count"]
+ require.True(t, ok, "healthy endpoint count metric missing")
+ assert.Equal(t, int64(1), gaugeForCluster(t, healthyCount,
"snapshot-metrics"))
+}
+
+func TestSnapshotMetricsDoNotCountNoOpHealthUpdate(t *testing.T) {
+ reader := installSnapshotMetricsReader(t)
+
+ endpoint := testEndpoint("ep-1", "127.0.0.1", 18082)
+ runtimeCluster := NewCluster(testCluster("snapshot-metrics-noop",
endpoint))
+
+ // Endpoint already healthy; setting it healthy again is a no-op that
does
+ // not swap the snapshot and so must not increment the publish counter.
+ require.True(t, runtimeCluster.UpdateEndpointHealth(endpoint.ID,
endpoint.Address.GetAddress(), true))
+
+ metrics := collectSnapshotMetrics(t, reader)
+ publish, ok := metrics["pixiu_cluster_snapshot_publish_total"]
+ require.True(t, ok, "publish total metric missing")
+ assert.Equal(t, int64(1), sumForCluster(t, publish,
"snapshot-metrics-noop"))
+}
+
+func TestSnapshotMetricsRecordAddressHealthPublish(t *testing.T) {
+ reader := installSnapshotMetricsReader(t)
+
+ // Two endpoints share one address, so an address-keyed health flip
marks
+ // both unhealthy in a single publish.
+ first := testEndpoint("ep-1", "127.0.0.1", 18083)
+ second := testEndpoint("ep-2", "127.0.0.1", 18083)
+ runtimeCluster := NewCluster(testCluster("snapshot-metrics-address",
first, second))
+
+ // One publish from NewCluster's initial RefreshEndpointsFrom.
+ require.True(t,
runtimeCluster.UpdateEndpointAddressHealth(first.Address.GetAddress(), false))
+
+ metrics := collectSnapshotMetrics(t, reader)
+
+ publish, ok := metrics["pixiu_cluster_snapshot_publish_total"]
+ require.True(t, ok, "publish total metric missing")
+ assert.Equal(t, int64(2), sumForCluster(t, publish,
"snapshot-metrics-address"))
+
+ count, ok := metrics["pixiu_cluster_snapshot_endpoint_count"]
+ require.True(t, ok, "endpoint count metric missing")
+ assert.Equal(t, int64(2), gaugeForCluster(t, count,
"snapshot-metrics-address"))
+
+ healthyCount, ok :=
metrics["pixiu_cluster_snapshot_healthy_endpoint_count"]
+ require.True(t, ok, "healthy endpoint count metric missing")
+ assert.Equal(t, int64(0), gaugeForCluster(t, healthyCount,
"snapshot-metrics-address"))
+}
diff --git a/pkg/server/cluster_manager_test.go
b/pkg/server/cluster_manager_test.go
index b8cef223b..67f038c60 100644
--- a/pkg/server/cluster_manager_test.go
+++ b/pkg/server/cluster_manager_test.go
@@ -18,6 +18,7 @@
package server
import (
+ "context"
"fmt"
"reflect"
"sync"
@@ -27,6 +28,12 @@ import (
import (
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
+ "go.opentelemetry.io/otel/sdk/metric/metricdata"
)
import (
@@ -1111,3 +1118,118 @@ func healthCheckerAddresses(runtime *cluster.Cluster)
[]string {
}
return addrs
}
+
+// TestStaticClusterSnapshotMetricsRecordedAtStartup verifies that snapshot
+// publication metrics emitted during static cluster initialization land on the
+// real meter provider. This test models production startup ordering: clusters
+// are created during CreateDefaultClusterManager (called from initialize), and
+// the OTel provider must be installed BEFORE that point so the initial
snapshot
+// publish (the only guaranteed emission for steady-state clusters) is
recorded.
+//
+// Regression guard for: if registerOtelMetricMeter is moved back after cluster
+// construction, static clusters' initial publish lands on the no-op delegating
+// provider, and the counter/gauges remain empty in steady state.
+func TestStaticClusterSnapshotMetricsRecordedAtStartup(t *testing.T) {
+ // Step 1: Install a ManualReader meter provider BEFORE cluster
construction.
+ // This models the corrected startup order:
registerOtelMetricMeter(bs.Metric)
+ // is called in Start(bs) before server.initialize(bs).
+ reader := installClusterSnapshotMetricsReader(t)
+
+ // Step 2: Construct a cluster manager with static clusters, simulating
what
+ // happens during initialize → CreateDefaultClusterManager.
+ staticCluster := testCluster("static-metrics-test",
model.LoadBalancerRoundRobin, []*model.Endpoint{
+ testEndpoint("ep-1", "127.0.0.1", 19001),
+ testEndpoint("ep-2", "127.0.0.1", 19002),
+ })
+ _ = CreateDefaultClusterManager(&model.Bootstrap{
+ StaticResources: model.StaticResources{
+ Clusters: []*model.ClusterConfig{staticCluster},
+ },
+ })
+
+ // Step 3: Verify the initial snapshot publish was recorded. NewCluster
calls
+ // RefreshEndpointsFrom, which publishes once. That single emission
must land
+ // on the real provider for steady-state clusters (those with no health
flips
+ // or registry churn) to have any recorded metrics at all.
+ metrics := collectClusterSnapshotMetrics(t, reader)
+
+ publishTotal, ok := metrics["pixiu_cluster_snapshot_publish_total"]
+ assert.True(t, ok, "publish total metric missing")
+ assert.Equal(t, int64(1), sumForClusterMetric(t, publishTotal,
"static-metrics-test"),
+ "static cluster initial publish must increment the counter")
+
+ endpointCount, ok := metrics["pixiu_cluster_snapshot_endpoint_count"]
+ assert.True(t, ok, "endpoint count gauge missing")
+ assert.Equal(t, int64(2), gaugeForClusterMetric(t, endpointCount,
"static-metrics-test"),
+ "endpoint count gauge must reflect the initial snapshot size")
+
+ healthyCount, ok :=
metrics["pixiu_cluster_snapshot_healthy_endpoint_count"]
+ assert.True(t, ok, "healthy endpoint count gauge missing")
+ assert.Equal(t, int64(2), gaugeForClusterMetric(t, healthyCount,
"static-metrics-test"),
+ "healthy endpoint count gauge must reflect the initial snapshot
size")
+}
+
+// installClusterSnapshotMetricsReader installs a ManualReader meter provider
+// for snapshot metrics testing and restores the previous provider on cleanup.
+// This helper mutates the process-global MeterProvider, so tests using it must
+// not call t.Parallel().
+func installClusterSnapshotMetricsReader(t *testing.T) *sdkmetric.ManualReader
{
+ t.Helper()
+
+ reader := sdkmetric.NewManualReader()
+ provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
+ prevProvider := otel.GetMeterProvider()
+
+ otel.SetMeterProvider(provider)
+
+ t.Cleanup(func() {
+ otel.SetMeterProvider(prevProvider)
+ })
+
+ return reader
+}
+
+// collectClusterSnapshotMetrics collects metrics from the reader and returns
+// them as a map keyed by metric name.
+func collectClusterSnapshotMetrics(t *testing.T, reader
*sdkmetric.ManualReader) map[string]metricdata.Metrics {
+ t.Helper()
+
+ var rm metricdata.ResourceMetrics
+ require.NoError(t, reader.Collect(context.Background(), &rm))
+
+ out := make(map[string]metricdata.Metrics)
+ for _, sm := range rm.ScopeMetrics {
+ for _, m := range sm.Metrics {
+ out[m.Name] = m
+ }
+ }
+ return out
+}
+
+// sumForClusterMetric extracts the counter value for the given cluster label.
+func sumForClusterMetric(t *testing.T, m metricdata.Metrics, clusterName
string) int64 {
+ t.Helper()
+ data, ok := m.Data.(metricdata.Sum[int64])
+ require.True(t, ok, "metric %s is not an int64 Sum", m.Name)
+ for _, dp := range data.DataPoints {
+ if v, ok := dp.Attributes.Value(attribute.Key("cluster")); ok
&& v.AsString() == clusterName {
+ return dp.Value
+ }
+ }
+ t.Fatalf("no data point for cluster %q in metric %s", clusterName,
m.Name)
+ return 0
+}
+
+// gaugeForClusterMetric extracts the gauge value for the given cluster label.
+func gaugeForClusterMetric(t *testing.T, m metricdata.Metrics, clusterName
string) int64 {
+ t.Helper()
+ data, ok := m.Data.(metricdata.Gauge[int64])
+ require.True(t, ok, "metric %s is not an int64 Gauge", m.Name)
+ for _, dp := range data.DataPoints {
+ if v, ok := dp.Attributes.Value(attribute.Key("cluster")); ok
&& v.AsString() == clusterName {
+ return dp.Value
+ }
+ }
+ t.Fatalf("no data point for cluster %q in metric %s", clusterName,
m.Name)
+ return 0
+}
diff --git a/pkg/server/pixiu_start.go b/pkg/server/pixiu_start.go
index 3f04746d7..38b882e57 100644
--- a/pkg/server/pixiu_start.go
+++ b/pkg/server/pixiu_start.go
@@ -93,7 +93,6 @@ func (s *Server) Start() {
}
}()
- registerOtelMetricMeter(conf.Metric)
s.listenerManager.StartListen()
s.adapterManager.Start()
@@ -127,6 +126,15 @@ func Start(bs *model.Bootstrap) {
logger.Infof("[dubbo-go-pixiu] start by config : %+v", bs)
// global variable
server = NewServer()
+
+ // Register the OTel meter provider BEFORE cluster construction so that
+ // snapshot publication metrics emitted during cluster initialization
land
+ // on the real provider rather than the no-op default. Static clusters
+ // publish their initial snapshot in initialize →
CreateDefaultClusterManager,
+ // and that emission is the only guaranteed recording for steady-state
+ // clusters with no health transitions.
+ registerOtelMetricMeter(bs.Metric)
+
server.initialize(bs)
server.Start()
server.startWG.Wait()