From 55e90cb5727b2d7fd5bbe3e25016e0deddf13701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20=C4=8Ctvrtka?= Date: Sun, 26 Jul 2026 09:40:34 +0200 Subject: [PATCH 01/11] PMM Fix duplicate opLatencies histogram metrics. --- exporter/metrics.go | 31 +++++++++++++-- exporter/metrics_test.go | 83 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/exporter/metrics.go b/exporter/metrics.go index 3ff63a90f..bf9c0a633 100644 --- a/exporter/metrics.go +++ b/exporter/metrics.go @@ -433,18 +433,39 @@ func processHistogramSlice(prefix string, v []any, commonLabels map[string]strin continue } + boundKey, boundLabel, ok := histogramBound(bucket) + if !ok { + continue + } + labels := make(map[string]string, len(commonLabels)+1) for name, value := range commonLabels { labels[name] = value } - labels["lower_bound"] = fmt.Sprint(bucket["lowerBound"]) + labels[boundLabel] = fmt.Sprint(bucket[boundKey]) metrics = appendMetricValue(metrics, prefix+".", "count", bucket["count"], labels, compatibleMode) } return metrics } +// histogramBound returns the field holding the bucket boundary and the label exposing it. +// getDiagnosticData uses two shapes: {lowerBound, count} under "histograms" nodes and +// {micros, count} under the "histogram" arrays of serverStatus.opLatencies. +func histogramBound(bucket map[string]any) (string, string, bool) { + for _, bound := range []struct{ key, label string }{ + {"lowerBound", "lower_bound"}, + {"micros", "micros"}, + } { + if _, ok := bucket[bound.key]; ok { + return bound.key, bound.label, true + } + } + + return "", "", false +} + func isHistogramBucketSlice(prefix string, v []any) bool { if len(v) == 0 { return false @@ -458,7 +479,7 @@ func isHistogramBucketSlice(prefix string, v []any) bool { if !ok { return false } - if _, ok := bucket["lowerBound"]; !ok { + if _, _, ok := histogramBound(bucket); !ok { return false } if _, ok := bucket["count"]; !ok { @@ -470,7 +491,11 @@ func isHistogramBucketSlice(prefix string, v []any) bool { } func isHistogramPath(prefix string) bool { - return prefix == "histograms" || strings.Contains(prefix, ".histograms.") || strings.HasSuffix(prefix, ".histograms") //nolint:goconst + return isPathNode(prefix, "histograms") || isPathNode(prefix, "histogram") +} + +func isPathNode(prefix, node string) bool { + return prefix == node || strings.Contains(prefix, "."+node+".") || strings.HasSuffix(prefix, "."+node) } func asMetricMap(item any) (map[string]any, bool) { diff --git a/exporter/metrics_test.go b/exporter/metrics_test.go index a9abdbff8..46dc912ef 100644 --- a/exporter/metrics_test.go +++ b/exporter/metrics_test.go @@ -305,6 +305,89 @@ func TestHistogramMetricsAreSkippedByDefault(t *testing.T) { assert.Empty(t, metrics) } +// serverStatus.opLatencies exposes its buckets under "histogram" with a "micros" boundary, +// which used to be flattened into one metric per bucket sharing the same name and labels. +func TestOpLatenciesHistogramMetricsDoNotCollide(t *testing.T) { + t.Parallel() + + metrics := makeMetricsWithHistograms("serverStatus", bson.M{ + "opLatencies": bson.M{ + "reads": bson.M{ + "latency": int64(120), + "ops": int64(4), + "histogram": primitive.A{ + bson.M{"micros": int64(1), "count": int64(3)}, + bson.M{"micros": int64(2048), "count": int64(7)}, + }, + }, + }, + }, nil, false, true) + + reg := prometheus.NewPedanticRegistry() + reg.MustRegister(staticCollector(metrics)) + + gatheredMetrics, err := reg.Gather() + assert.NoError(t, err, "metrics with the same name and labels must not be exported") + + metricsByName := make(map[string]*dto.MetricFamily, len(gatheredMetrics)) + for _, metric := range gatheredMetrics { + metricsByName[metric.GetName()] = metric + } + + assert.NotContains(t, metricsByName, "mongodb_ss_opLatencies_reads_histogram_micros") + + bucketCounts, ok := metricsByName["mongodb_ss_opLatencies_reads_histogram_count"] + if !assert.True(t, ok) { + return + } + + valuesByBound := make(map[string]float64, len(bucketCounts.GetMetric())) + for _, metric := range bucketCounts.GetMetric() { + for _, label := range metric.GetLabel() { + if label.GetName() == "micros" { + valuesByBound[label.GetValue()] = metric.GetCounter().GetValue() + } + } + } + + assert.Equal(t, map[string]float64{ + "1": 3, + "2048": 7, + }, valuesByBound) +} + +func TestOpLatenciesHistogramMetricsAreSkippedByDefault(t *testing.T) { + t.Parallel() + + metrics := makeMetrics("serverStatus.opLatencies.reads", bson.M{ + "latency": int64(120), + "histogram": primitive.A{ + bson.M{"micros": int64(1), "count": int64(3)}, + bson.M{"micros": int64(2048), "count": int64(7)}, + }, + }, nil, false) + + // The latency metric is renamed and labeled by specialConversions, the buckets are dropped. + assert.Equal(t, []string{"mongodb_ss_opLatencies_latency"}, gatheredMetricNames(t, metrics)) +} + +func gatheredMetricNames(t *testing.T, metrics []prometheus.Metric) []string { + t.Helper() + + reg := prometheus.NewPedanticRegistry() + reg.MustRegister(staticCollector(metrics)) + + gatheredMetrics, err := reg.Gather() + assert.NoError(t, err) + + names := make([]string, 0, len(gatheredMetrics)) + for _, metric := range gatheredMetrics { + names = append(names, metric.GetName()) + } + + return names +} + func TestAsMetricMapHandlesBSONM(t *testing.T) { t.Parallel() From 5bfaaa0a4f660911877ab9e757fea4dfb599cff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20=C4=8Ctvrtka?= Date: Mon, 27 Jul 2026 10:19:42 +0200 Subject: [PATCH 02/11] PMM Cover histogram buckets with a captured 8.3 reply. --- exporter/fixtures_test.go | 76 ++ exporter/metrics_histogram_fixture_test.go | 65 ++ .../testdata/get_diagnostic_data_8.3.json | 666 ++++++++++++++++++ 3 files changed, 807 insertions(+) create mode 100644 exporter/fixtures_test.go create mode 100644 exporter/metrics_histogram_fixture_test.go create mode 100644 exporter/testdata/get_diagnostic_data_8.3.json diff --git a/exporter/fixtures_test.go b/exporter/fixtures_test.go new file mode 100644 index 000000000..26caece04 --- /dev/null +++ b/exporter/fixtures_test.go @@ -0,0 +1,76 @@ +// mongodb_exporter +// Copyright (C) 2017 Percona LLC +// +// Licensed 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 exporter + +import ( + "os" + "path/filepath" + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson" +) + +// loadFixture reads a captured command reply. Extended JSON is used instead of +// encoding/json because the driver decodes arrays into primitive.A while encoding/json +// produces []any, which makeMetrics silently walks past. A fixture parsed the plain way +// hides everything array shaped, histogram buckets included. +func loadFixture(t *testing.T, name string) bson.M { + t.Helper() + + buf, err := os.ReadFile(filepath.Join("testdata/", name)) + require.NoError(t, err) + + var m bson.M + require.NoError(t, bson.UnmarshalExtJSON(buf, false, &m)) + + return m +} + +// gatherFixtureMetrics exports the metrics the way the registry does at scrape time, so +// duplicated series and inconsistent descriptors fail the test instead of the scrape. +func gatherFixtureMetrics(t *testing.T, metrics []prometheus.Metric) map[string]*dto.MetricFamily { + t.Helper() + + reg := prometheus.NewPedanticRegistry() + require.NoError(t, reg.Register(staticCollector(metrics)), "descriptors must be consistent") + + gatheredMetrics, err := reg.Gather() + require.NoError(t, err, "metrics must be exported only once") + + metricsByName := make(map[string]*dto.MetricFamily, len(gatheredMetrics)) + for _, metric := range gatheredMetrics { + metricsByName[metric.GetName()] = metric + } + + return metricsByName +} + +// labelValues returns the value of the given label for every series of a metric family. +func labelValues(family *dto.MetricFamily, name string) []string { + values := make([]string, 0, len(family.GetMetric())) + for _, metric := range family.GetMetric() { + for _, label := range metric.GetLabel() { + if label.GetName() == name { + values = append(values, label.GetValue()) + } + } + } + + return values +} diff --git a/exporter/metrics_histogram_fixture_test.go b/exporter/metrics_histogram_fixture_test.go new file mode 100644 index 000000000..227f29154 --- /dev/null +++ b/exporter/metrics_histogram_fixture_test.go @@ -0,0 +1,65 @@ +// mongodb_exporter +// Copyright (C) 2017 Percona LLC +// +// Licensed 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 exporter + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/bson" +) + +const diagnosticData83Fixture = "get_diagnostic_data_8.3.json" + +// A MongoDB 8.3 reply carries both bucket shapes: the "histogram" arrays of +// serverStatus.opLatencies and the "histograms" nodes under serverStatus.metrics.query. +func TestServerStatusHistogramsFromFixture(t *testing.T) { + t.Parallel() + + serverStatus, ok := loadFixture(t, diagnosticData83Fixture)["serverStatus"].(bson.M) + require.True(t, ok) + + labels := map[string]string{"cl_id": "", "cl_role": ""} + metricsByName := gatherFixtureMetrics(t, makeMetricsWithHistograms("serverStatus", serverStatus, labels, false, true)) + + opLatencyBuckets, ok := metricsByName["mongodb_ss_opLatencies_reads_histogram_count"] + require.True(t, ok) + assert.ElementsMatch(t, []string{"0", "8", "64", "512", "3072", "8192", "24576", "65536", "131072"}, + labelValues(opLatencyBuckets, "micros")) + assert.NotContains(t, metricsByName, "mongodb_ss_opLatencies_reads_histogram_micros") + + plannerBuckets, ok := metricsByName["mongodb_ss_metrics_query_multiPlanner_histograms_classicWorks_count"] + require.True(t, ok) + assert.Len(t, labelValues(plannerBuckets, "lower_bound"), 10) +} + +func TestServerStatusHistogramsFromFixtureAreSkippedByDefault(t *testing.T) { + t.Parallel() + + serverStatus, ok := loadFixture(t, diagnosticData83Fixture)["serverStatus"].(bson.M) + require.True(t, ok) + + labels := map[string]string{"cl_id": "", "cl_role": ""} + metricsByName := gatherFixtureMetrics(t, makeMetrics("serverStatus", serverStatus, labels, false)) + + for name := range metricsByName { + assert.NotContains(t, name, "histogram") + } + + // Fields next to the buckets are still collected. + assert.Contains(t, metricsByName, "mongodb_ss_opLatencies_latency") +} diff --git a/exporter/testdata/get_diagnostic_data_8.3.json b/exporter/testdata/get_diagnostic_data_8.3.json new file mode 100644 index 000000000..741d62f3b --- /dev/null +++ b/exporter/testdata/get_diagnostic_data_8.3.json @@ -0,0 +1,666 @@ +{ + "serverStatus": { + "opLatencies": { + "commands": { + "histogram": [ + { + "micros": 0, + "count": 0 + }, + { + "micros": 8, + "count": 37370 + }, + { + "micros": 64, + "count": 99126 + }, + { + "micros": 512, + "count": 9424 + }, + { + "micros": 3072, + "count": 474 + }, + { + "micros": 8192, + "count": 22 + }, + { + "micros": 24576, + "count": 6 + }, + { + "micros": 65536, + "count": 2 + }, + { + "micros": 131072, + "count": 0 + } + ], + "latency": 28664744, + "ops": 146424, + "queryableEncryptionLatencyMicros": 0 + }, + "reads": { + "histogram": [ + { + "micros": 0, + "count": 0 + }, + { + "micros": 8, + "count": 0 + }, + { + "micros": 64, + "count": 16737 + }, + { + "micros": 512, + "count": 947 + }, + { + "micros": 3072, + "count": 351 + }, + { + "micros": 8192, + "count": 77 + }, + { + "micros": 24576, + "count": 54 + }, + { + "micros": 65536, + "count": 13 + }, + { + "micros": 131072, + "count": 21 + } + ], + "latency": 16098564, + "ops": 18200, + "queryableEncryptionLatencyMicros": 0 + }, + "writes": { + "histogram": [ + { + "micros": 0, + "count": 0 + }, + { + "micros": 8, + "count": 0 + }, + { + "micros": 64, + "count": 1 + }, + { + "micros": 512, + "count": 6 + }, + { + "micros": 3072, + "count": 4 + }, + { + "micros": 8192, + "count": 1 + }, + { + "micros": 24576, + "count": 1 + }, + { + "micros": 65536, + "count": 0 + }, + { + "micros": 131072, + "count": 0 + } + ], + "latency": 118235, + "ops": 13, + "queryableEncryptionLatencyMicros": 0 + }, + "transactions": { + "histogram": [ + { + "micros": 0, + "count": 0 + }, + { + "micros": 8, + "count": 0 + }, + { + "micros": 64, + "count": 0 + }, + { + "micros": 512, + "count": 0 + }, + { + "micros": 3072, + "count": 0 + }, + { + "micros": 8192, + "count": 0 + }, + { + "micros": 24576, + "count": 0 + }, + { + "micros": 65536, + "count": 0 + }, + { + "micros": 131072, + "count": 0 + } + ], + "latency": 0, + "ops": 0, + "queryableEncryptionLatencyMicros": 0 + } + }, + "metrics": { + "query": { + "allowDiskUseFalse": 0, + "deleteManyCount": 1, + "deleteOneNonTargetedShardedCount": 0, + "deleteOneTargetedShardedCount": 0, + "deleteOneUnshardedCount": 0, + "deleteOneWithoutShardKeyWithIdCount": 0, + "deleteOneWithoutShardKeyWithIdRetryCount": 0, + "externalRetryableWriteCount": 4, + "findAndModifyNonTargetedShardedCount": 0, + "findAndModifyTargetedShardedCount": 0, + "findAndModifyUnshardedCount": 0, + "internalRetryableWriteCount": 0, + "nonRetryableDeleteOneWithoutShardKeyWithIdCount": 0, + "nonRetryableUpdateOneWithoutShardKeyWithIdCount": 0, + "recordIdDeduplicationSwitchedToRoaring": 0, + "retryableInternalTransactionCount": 0, + "totalSlowQueryLogs": 24, + "updateDeleteManyDocumentsMaxCount": 1, + "updateDeleteManyDocumentsTotalCount": 1, + "updateDeleteManyDurationMaxMs": 2, + "updateDeleteManyDurationTotalMs": 2, + "updateManyCount": 0, + "updateOneNonTargetedShardedCount": 0, + "updateOneOpStyleBroadcastWithExactIDCount": 0, + "updateOneTargetedShardedCount": 0, + "updateOneUnshardedCount": 0, + "updateOneWithoutShardKeyWithIdCount": 0, + "updateOneWithoutShardKeyWithIdRetryCount": 0, + "bucketAuto": { + "spilledBytes": 0, + "spilledDataStorageSize": 0, + "spilledRecords": 0, + "spills": 0 + }, + "cbr": { + "choseWinningPlan": 0, + "count": 0, + "micros": 0, + "numPlans": 0, + "numPlansFailedCostEstimation": 0, + "numPlansTiedCostEstimation": 0, + "samplingMicros": 0, + "histograms": { + "micros": [ + { + "lowerBound": 0, + "count": 0 + }, + { + "lowerBound": 1024, + "count": 0 + }, + { + "lowerBound": 4096, + "count": 0 + }, + { + "lowerBound": 16384, + "count": 0 + }, + { + "lowerBound": 65536, + "count": 0 + }, + { + "lowerBound": 262144, + "count": 0 + }, + { + "lowerBound": 1048576, + "count": 0 + }, + { + "lowerBound": 4194304, + "count": 0 + }, + { + "lowerBound": 16777216, + "count": 0 + }, + { + "lowerBound": 67108864, + "count": 0 + }, + { + "lowerBound": 268435456, + "count": 0 + }, + { + "lowerBound": 1073741824, + "count": 0 + } + ], + "numPlans": [ + { + "lowerBound": 0, + "count": 0 + }, + { + "lowerBound": 2, + "count": 0 + }, + { + "lowerBound": 4, + "count": 0 + }, + { + "lowerBound": 8, + "count": 0 + }, + { + "lowerBound": 16, + "count": 0 + }, + { + "lowerBound": 32, + "count": 0 + } + ], + "samplingMicros": [ + { + "lowerBound": 0, + "count": 0 + }, + { + "lowerBound": 1024, + "count": 0 + }, + { + "lowerBound": 4096, + "count": 0 + }, + { + "lowerBound": 16384, + "count": 0 + }, + { + "lowerBound": 65536, + "count": 0 + }, + { + "lowerBound": 262144, + "count": 0 + }, + { + "lowerBound": 1048576, + "count": 0 + }, + { + "lowerBound": 4194304, + "count": 0 + }, + { + "lowerBound": 16777216, + "count": 0 + }, + { + "lowerBound": 67108864, + "count": 0 + }, + { + "lowerBound": 268435456, + "count": 0 + }, + { + "lowerBound": 1073741824, + "count": 0 + } + ] + } + }, + "expressionSimplifier": { + "abortedTooLarge": 0, + "notSimplified": 3265, + "simplified": 0, + "trivial": 2636 + }, + "geoNear": { + "spilledBytes": 0, + "spilledDataStorageSize": 0, + "spilledRecords": 0, + "spills": 0 + }, + "graphLookup": { + "spilledBytes": 0, + "spilledDataStorageSize": 0, + "spilledRecords": 0, + "spills": 0 + }, + "group": { + "spilledBytes": 0, + "spilledDataStorageSize": 0, + "spilledRecords": 0, + "spills": 0 + }, + "hashJoin": { + "spilledBytes": 0, + "spilledDataStorageSize": 0, + "spilledRecords": 0, + "spills": 0 + }, + "lookup": { + "dynamicIndexedLoopJoin": 0, + "hashLookup": 0, + "hashLookupSpillToDisk": 0, + "hashLookupSpillToDiskBytes": 0, + "hashLookupSpilledBytes": 0, + "hashLookupSpilledDataStorageSize": 0, + "hashLookupSpilledRecords": 0, + "hashLookupSpills": 0, + "indexedLoopJoin": 0, + "nestedLoopJoin": 0 + }, + "multiPlanner": { + "allPlansHitMemoryLimit": 0, + "choseWinningPlan": 0, + "classicCount": 0, + "classicMicros": 0, + "classicNumPlans": 0, + "classicWorks": 0, + "histograms": { + "classicMicros": [ + { + "lowerBound": 0, + "count": 0 + }, + { + "lowerBound": 1024, + "count": 0 + }, + { + "lowerBound": 4096, + "count": 0 + }, + { + "lowerBound": 16384, + "count": 0 + }, + { + "lowerBound": 65536, + "count": 0 + }, + { + "lowerBound": 262144, + "count": 0 + }, + { + "lowerBound": 1048576, + "count": 0 + }, + { + "lowerBound": 4194304, + "count": 0 + }, + { + "lowerBound": 16777216, + "count": 0 + }, + { + "lowerBound": 67108864, + "count": 0 + }, + { + "lowerBound": 268435456, + "count": 0 + }, + { + "lowerBound": 1073741824, + "count": 0 + } + ], + "classicNumPlans": [ + { + "lowerBound": 0, + "count": 0 + }, + { + "lowerBound": 2, + "count": 0 + }, + { + "lowerBound": 4, + "count": 0 + }, + { + "lowerBound": 8, + "count": 0 + }, + { + "lowerBound": 16, + "count": 0 + }, + { + "lowerBound": 32, + "count": 0 + } + ], + "classicWorks": [ + { + "lowerBound": 0, + "count": 0 + }, + { + "lowerBound": 128, + "count": 0 + }, + { + "lowerBound": 256, + "count": 0 + }, + { + "lowerBound": 512, + "count": 0 + }, + { + "lowerBound": 1024, + "count": 0 + }, + { + "lowerBound": 2048, + "count": 0 + }, + { + "lowerBound": 4096, + "count": 0 + }, + { + "lowerBound": 8192, + "count": 0 + }, + { + "lowerBound": 16384, + "count": 0 + }, + { + "lowerBound": 32768, + "count": 0 + } + ] + }, + "rateLimiter": { + "allowed": 0, + "delayed": 0, + "released": 0 + }, + "stoppingCondition": { + "hitEof": 0, + "hitResultsLimit": 0, + "hitWorksLimit": 0 + } + }, + "planCache": { + "totalQueryShapes": 0, + "totalSizeEstimateBytes": 0, + "classic": { + "cached_plans_evicted": 0, + "hits": 0, + "inactive_cached_plans_replaced": 0, + "misses": 481, + "replanned": 0, + "replanned_plan_is_cached_plan": 0, + "skipped": 2292 + }, + "sbe": { + "cached_plans_evicted": 0, + "hits": 0, + "inactive_cached_plans_replaced": 0, + "misses": 0, + "replanned": 0, + "replanned_plan_is_cached_plan": 0, + "skipped": 0 + } + }, + "planning": { + "invocationCount": 505, + "fastPath": { + "express": 2409, + "idHack": 72 + } + }, + "queryFramework": { + "aggregate": { + "classicHybrid": 7, + "classicOnly": 1439, + "sbeHybrid": 2, + "sbeOnly": 11 + }, + "find": { + "classic": 2699, + "sbe": 0 + } + }, + "recordIdDeduplication": { + "IXSCAN": { + "deduplicatedBytes": 0, + "deduplicatedRecords": 0 + }, + "OR": { + "deduplicatedBytes": 0, + "deduplicatedRecords": 0 + }, + "SORT_MERGE": { + "deduplicatedBytes": 0, + "deduplicatedRecords": 0 + }, + "unique": { + "deduplicatedBytes": 0, + "deduplicatedRecords": 0 + }, + "unique_roaring": { + "deduplicatedBytes": 0, + "deduplicatedRecords": 0 + } + }, + "setWindowFields": { + "spilledBytes": 0, + "spilledDataStorageSize": 0, + "spilledRecords": 0, + "spills": 0 + }, + "sort": { + "spillToDisk": 0, + "spillToDiskBytes": 0, + "totalBytesSorted": 17274146, + "totalKeysSorted": 3440 + }, + "subPlanner": { + "classicChoseWinningPlan": 0 + }, + "textOr": { + "spilledBytes": 0, + "spilledDataStorageSize": 0, + "spilledRecords": 0, + "spills": 0 + } + } + } + }, + "systemMetrics": { + "ethtool": { + "ens192": { + "Tx Queue#": 0, + " TSO pkts tx": 0, + " TSO bytes tx": -102, + " ucast pkts tx": -102, + " ucast bytes tx": 78, + " mcast pkts tx": 0, + " mcast bytes tx": 0, + " bcast pkts tx": 0, + " bcast bytes tx": 0, + " pkts tx err": 0, + " pkts tx discard": -10, + " drv dropped tx total": -59, + " too many frags": -89, + " giant hdr": 104, + " hdr err": 0, + " tso": 0, + " ring full": 0, + " pkts linearized": 0, + " hdr cloned": 0, + " giant hdr": 0, + " xdp xmit": 0, + " xdp xmit err": 0, + "Rx Queue#": -113, + " LRO pkts rx": 4, + " LRO byte rx": 0, + " ucast pkts rx": 0, + " ucast bytes rx": 0, + " mcast pkts rx": 0, + " mcast bytes rx": 0, + " bcast pkts rx": 0, + " bcast bytes rx": 0, + " pkts rx OOB": 0, + " pkts rx err": 0, + " drv dropped rx total": 0, + " err": 0, + " fcs": 0, + " rx buf alloc fail": 0, + " xdp packets": 0, + " xdp tx": 0, + " xdp redirects": 0, + " xdp drops": 0, + " xdp aborted": 0, + "tx timeout count": 0 + } + } + } +} From 7913155463ea1ea18d46635d543de102b543511f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20=C4=8Ctvrtka?= Date: Mon, 27 Jul 2026 10:23:06 +0200 Subject: [PATCH 03/11] PMM Move the fixture name to the shared test helper. --- exporter/fixtures_test.go | 5 +++++ exporter/metrics_histogram_fixture_test.go | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/exporter/fixtures_test.go b/exporter/fixtures_test.go index 26caece04..51a63a9be 100644 --- a/exporter/fixtures_test.go +++ b/exporter/fixtures_test.go @@ -26,6 +26,11 @@ import ( "go.mongodb.org/mongo-driver/bson" ) +// diagnosticData83Fixture is a getDiagnosticData reply captured from the MongoDB 8.3.2 +// instance of https://github.com/percona/mongodb_exporter/issues/1285, trimmed to the +// subtrees the tests below need. +const diagnosticData83Fixture = "get_diagnostic_data_8.3.json" + // loadFixture reads a captured command reply. Extended JSON is used instead of // encoding/json because the driver decodes arrays into primitive.A while encoding/json // produces []any, which makeMetrics silently walks past. A fixture parsed the plain way diff --git a/exporter/metrics_histogram_fixture_test.go b/exporter/metrics_histogram_fixture_test.go index 227f29154..6734b7c45 100644 --- a/exporter/metrics_histogram_fixture_test.go +++ b/exporter/metrics_histogram_fixture_test.go @@ -23,8 +23,6 @@ import ( "go.mongodb.org/mongo-driver/bson" ) -const diagnosticData83Fixture = "get_diagnostic_data_8.3.json" - // A MongoDB 8.3 reply carries both bucket shapes: the "histogram" arrays of // serverStatus.opLatencies and the "histograms" nodes under serverStatus.metrics.query. func TestServerStatusHistogramsFromFixture(t *testing.T) { From 0fe3c29c7f0b53198202bbe06d0a8394fe6085ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20=C4=8Ctvrtka?= Date: Mon, 27 Jul 2026 11:49:20 +0200 Subject: [PATCH 04/11] PMM-15248 Lint. --- exporter/fixtures_test.go | 11 ++++---- exporter/metrics.go | 8 ++++-- exporter/metrics_histogram_fixture_test.go | 6 ++-- exporter/metrics_test.go | 33 +++++++++++----------- 4 files changed, 30 insertions(+), 28 deletions(-) diff --git a/exporter/fixtures_test.go b/exporter/fixtures_test.go index 51a63a9be..641c7c3d7 100644 --- a/exporter/fixtures_test.go +++ b/exporter/fixtures_test.go @@ -17,7 +17,6 @@ package exporter import ( "os" - "path/filepath" "testing" "github.com/prometheus/client_golang/prometheus" @@ -26,19 +25,19 @@ import ( "go.mongodb.org/mongo-driver/bson" ) -// diagnosticData83Fixture is a getDiagnosticData reply captured from the MongoDB 8.3.2 +// diagnosticData83FixturePath is a getDiagnosticData reply captured from the MongoDB 8.3.2 // instance of https://github.com/percona/mongodb_exporter/issues/1285, trimmed to the // subtrees the tests below need. -const diagnosticData83Fixture = "get_diagnostic_data_8.3.json" +const diagnosticData83FixturePath = "testdata/get_diagnostic_data_8.3.json" -// loadFixture reads a captured command reply. Extended JSON is used instead of +// loadDiagnosticData83Fixture reads the captured command reply. Extended JSON is used instead of // encoding/json because the driver decodes arrays into primitive.A while encoding/json // produces []any, which makeMetrics silently walks past. A fixture parsed the plain way // hides everything array shaped, histogram buckets included. -func loadFixture(t *testing.T, name string) bson.M { +func loadDiagnosticData83Fixture(t *testing.T) bson.M { t.Helper() - buf, err := os.ReadFile(filepath.Join("testdata/", name)) + buf, err := os.ReadFile(diagnosticData83FixturePath) require.NoError(t, err) var m bson.M diff --git a/exporter/metrics.go b/exporter/metrics.go index bf9c0a633..31141757d 100644 --- a/exporter/metrics.go +++ b/exporter/metrics.go @@ -31,7 +31,9 @@ import ( ) const ( - exporterPrefix = "mongodb_" + exporterPrefix = "mongodb_" + histogramLowerBoundKey = "lowerBound" + histogramMicrosKey = "micros" ) type rawMetric struct { @@ -455,8 +457,8 @@ func processHistogramSlice(prefix string, v []any, commonLabels map[string]strin // {micros, count} under the "histogram" arrays of serverStatus.opLatencies. func histogramBound(bucket map[string]any) (string, string, bool) { for _, bound := range []struct{ key, label string }{ - {"lowerBound", "lower_bound"}, - {"micros", "micros"}, + {histogramLowerBoundKey, "lower_bound"}, + {histogramMicrosKey, histogramMicrosKey}, } { if _, ok := bucket[bound.key]; ok { return bound.key, bound.label, true diff --git a/exporter/metrics_histogram_fixture_test.go b/exporter/metrics_histogram_fixture_test.go index 6734b7c45..4d8940a95 100644 --- a/exporter/metrics_histogram_fixture_test.go +++ b/exporter/metrics_histogram_fixture_test.go @@ -28,7 +28,7 @@ import ( func TestServerStatusHistogramsFromFixture(t *testing.T) { t.Parallel() - serverStatus, ok := loadFixture(t, diagnosticData83Fixture)["serverStatus"].(bson.M) + serverStatus, ok := loadDiagnosticData83Fixture(t)["serverStatus"].(bson.M) require.True(t, ok) labels := map[string]string{"cl_id": "", "cl_role": ""} @@ -37,7 +37,7 @@ func TestServerStatusHistogramsFromFixture(t *testing.T) { opLatencyBuckets, ok := metricsByName["mongodb_ss_opLatencies_reads_histogram_count"] require.True(t, ok) assert.ElementsMatch(t, []string{"0", "8", "64", "512", "3072", "8192", "24576", "65536", "131072"}, - labelValues(opLatencyBuckets, "micros")) + labelValues(opLatencyBuckets, histogramMicrosKey)) assert.NotContains(t, metricsByName, "mongodb_ss_opLatencies_reads_histogram_micros") plannerBuckets, ok := metricsByName["mongodb_ss_metrics_query_multiPlanner_histograms_classicWorks_count"] @@ -48,7 +48,7 @@ func TestServerStatusHistogramsFromFixture(t *testing.T) { func TestServerStatusHistogramsFromFixtureAreSkippedByDefault(t *testing.T) { t.Parallel() - serverStatus, ok := loadFixture(t, diagnosticData83Fixture)["serverStatus"].(bson.M) + serverStatus, ok := loadDiagnosticData83Fixture(t)["serverStatus"].(bson.M) require.True(t, ok) labels := map[string]string{"cl_id": "", "cl_role": ""} diff --git a/exporter/metrics_test.go b/exporter/metrics_test.go index 46dc912ef..2e64910ec 100644 --- a/exporter/metrics_test.go +++ b/exporter/metrics_test.go @@ -23,6 +23,7 @@ import ( "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" ) @@ -238,8 +239,8 @@ func TestHistogramMetricsDoNotCollide(t *testing.T) { metrics := makeMetricsWithHistograms("serverStatus.metrics.query.multiPlanner.histograms", bson.M{ "sbeMicros": primitive.A{ - bson.M{"lowerBound": int64(0), "count": int64(3)}, - bson.M{"lowerBound": int64(1024), "count": int64(7)}, + bson.M{histogramLowerBoundKey: int64(0), "count": int64(3)}, + bson.M{histogramLowerBoundKey: int64(1024), "count": int64(7)}, }, }, nil, true, true) @@ -247,7 +248,7 @@ func TestHistogramMetricsDoNotCollide(t *testing.T) { reg.MustRegister(staticCollector(metrics)) gatheredMetrics, err := reg.Gather() - assert.NoError(t, err, "metrics with the same name and labels must not be exported") + require.NoError(t, err, "metrics with the same name and labels must not be exported") metricsByName := make(map[string]*dto.MetricFamily, len(gatheredMetrics)) for _, metric := range gatheredMetrics { @@ -287,8 +288,8 @@ func TestHistogramMetricsAreSkippedByDefault(t *testing.T) { metrics := makeMetrics("serverStatus.metrics.query.multiPlanner", bson.M{ "histograms": bson.M{ "sbeMicros": primitive.A{ - bson.M{"lowerBound": int64(0), "count": int64(3)}, - bson.M{"lowerBound": int64(1024), "count": int64(7)}, + bson.M{histogramLowerBoundKey: int64(0), "count": int64(3)}, + bson.M{histogramLowerBoundKey: int64(1024), "count": int64(7)}, }, }, }, nil, true) @@ -297,8 +298,8 @@ func TestHistogramMetricsAreSkippedByDefault(t *testing.T) { metrics = makeMetrics("serverStatus.metrics.query.multiPlanner.histograms", bson.M{ "sbeMicros": primitive.A{ - bson.M{"lowerBound": int64(0), "count": int64(3)}, - bson.M{"lowerBound": int64(1024), "count": int64(7)}, + bson.M{histogramLowerBoundKey: int64(0), "count": int64(3)}, + bson.M{histogramLowerBoundKey: int64(1024), "count": int64(7)}, }, }, nil, true) @@ -316,8 +317,8 @@ func TestOpLatenciesHistogramMetricsDoNotCollide(t *testing.T) { "latency": int64(120), "ops": int64(4), "histogram": primitive.A{ - bson.M{"micros": int64(1), "count": int64(3)}, - bson.M{"micros": int64(2048), "count": int64(7)}, + bson.M{histogramMicrosKey: int64(1), "count": int64(3)}, + bson.M{histogramMicrosKey: int64(2048), "count": int64(7)}, }, }, }, @@ -327,7 +328,7 @@ func TestOpLatenciesHistogramMetricsDoNotCollide(t *testing.T) { reg.MustRegister(staticCollector(metrics)) gatheredMetrics, err := reg.Gather() - assert.NoError(t, err, "metrics with the same name and labels must not be exported") + require.NoError(t, err, "metrics with the same name and labels must not be exported") metricsByName := make(map[string]*dto.MetricFamily, len(gatheredMetrics)) for _, metric := range gatheredMetrics { @@ -344,7 +345,7 @@ func TestOpLatenciesHistogramMetricsDoNotCollide(t *testing.T) { valuesByBound := make(map[string]float64, len(bucketCounts.GetMetric())) for _, metric := range bucketCounts.GetMetric() { for _, label := range metric.GetLabel() { - if label.GetName() == "micros" { + if label.GetName() == histogramMicrosKey { valuesByBound[label.GetValue()] = metric.GetCounter().GetValue() } } @@ -362,8 +363,8 @@ func TestOpLatenciesHistogramMetricsAreSkippedByDefault(t *testing.T) { metrics := makeMetrics("serverStatus.opLatencies.reads", bson.M{ "latency": int64(120), "histogram": primitive.A{ - bson.M{"micros": int64(1), "count": int64(3)}, - bson.M{"micros": int64(2048), "count": int64(7)}, + bson.M{histogramMicrosKey: int64(1), "count": int64(3)}, + bson.M{histogramMicrosKey: int64(2048), "count": int64(7)}, }, }, nil, false) @@ -378,7 +379,7 @@ func gatheredMetricNames(t *testing.T, metrics []prometheus.Metric) []string { reg.MustRegister(staticCollector(metrics)) gatheredMetrics, err := reg.Gather() - assert.NoError(t, err) + require.NoError(t, err) names := make([]string, 0, len(gatheredMetrics)) for _, metric := range gatheredMetrics { @@ -391,9 +392,9 @@ func gatheredMetricNames(t *testing.T, metrics []prometheus.Metric) []string { func TestAsMetricMapHandlesBSONM(t *testing.T) { t.Parallel() - bucket, ok := asMetricMap(bson.M{"lowerBound": int64(1024), "count": int64(7)}) + bucket, ok := asMetricMap(bson.M{histogramLowerBoundKey: int64(1024), "count": int64(7)}) assert.True(t, ok) - assert.Equal(t, int64(1024), bucket["lowerBound"]) + assert.Equal(t, int64(1024), bucket[histogramLowerBoundKey]) assert.Equal(t, int64(7), bucket["count"]) } From e949635f1a2f23a54da657c6ae8a4ddaa218f39c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20=C4=8Ctvrtka?= Date: Thu, 30 Jul 2026 14:09:57 +0200 Subject: [PATCH 05/11] PMM-15248 Gate histograms on the bucket shape, not on the node name. --- exporter/metrics.go | 18 +++++++++++++++++- exporter/metrics_test.go | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/exporter/metrics.go b/exporter/metrics.go index 31141757d..7a3192195 100644 --- a/exporter/metrics.go +++ b/exporter/metrics.go @@ -322,7 +322,7 @@ func makeMetricsWithHistograms(prefix string, m bson.M, labels map[string]string for k, val := range m { nextPrefix := prefix + k - if !includeHistograms && isHistogramPath(nextPrefix) { + if !includeHistograms && isHistogramBucketValue(nextPrefix, val) { continue } @@ -468,6 +468,22 @@ func histogramBound(bucket map[string]any) (string, string, bool) { return "", "", false } +// isHistogramBucketValue reports whether the value holds histogram buckets, so that +// includeHistograms gates the buckets themselves rather than every node that happens to be +// named "histogram" or "histograms". Only the diagnostic data collector can enable them, +// every other collector passes includeHistograms=false; see the "latencyStats" request of +// the collstats collector and PMM-9568. +func isHistogramBucketValue(prefix string, val any) bool { + switch v := val.(type) { + case primitive.A: + return isHistogramBucketSlice(prefix, v) + case []any: + return isHistogramBucketSlice(prefix, v) + default: + return false + } +} + func isHistogramBucketSlice(prefix string, v []any) bool { if len(v) == 0 { return false diff --git a/exporter/metrics_test.go b/exporter/metrics_test.go index 2e64910ec..e1dfd2113 100644 --- a/exporter/metrics_test.go +++ b/exporter/metrics_test.go @@ -306,6 +306,24 @@ func TestHistogramMetricsAreSkippedByDefault(t *testing.T) { assert.Empty(t, metrics) } +// Only the buckets are gated by includeHistograms. A node named "histogram" that holds +// something else must be collected like any other node. +func TestNonBucketHistogramNodeIsNotSkipped(t *testing.T) { + t.Parallel() + + metrics := makeMetrics("serverStatus.someFeature", bson.M{ + "histogram": bson.M{ + "enabled": int64(1), + "sizeBytes": int64(42), + }, + }, nil, false) + + assert.ElementsMatch(t, []string{ + "mongodb_ss_someFeature_histogram_enabled", + "mongodb_ss_someFeature_histogram_sizeBytes", + }, gatheredMetricNames(t, metrics)) +} + // serverStatus.opLatencies exposes its buckets under "histogram" with a "micros" boundary, // which used to be flattened into one metric per bucket sharing the same name and labels. func TestOpLatenciesHistogramMetricsDoNotCollide(t *testing.T) { From a2659c0cfff2028afe1bb509f26354c593f6aef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20=C4=8Ctvrtka?= Date: Thu, 30 Jul 2026 14:11:27 +0200 Subject: [PATCH 06/11] PMM-15248 Expose both bucket shapes under one lower_bound label. --- exporter/metrics.go | 33 +++++++++++++--------- exporter/metrics_histogram_fixture_test.go | 5 ++-- exporter/metrics_test.go | 2 +- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/exporter/metrics.go b/exporter/metrics.go index 7a3192195..de84307cf 100644 --- a/exporter/metrics.go +++ b/exporter/metrics.go @@ -34,8 +34,14 @@ const ( exporterPrefix = "mongodb_" histogramLowerBoundKey = "lowerBound" histogramMicrosKey = "micros" + histogramBoundLabel = "lower_bound" ) +// Fields holding a histogram bucket boundary, sorted by precedence. See histogramBoundKey. +// +//nolint:gochecknoglobals +var histogramBoundKeys = []string{histogramLowerBoundKey, histogramMicrosKey} + type rawMetric struct { // Full Qualified Name fqName string @@ -435,7 +441,7 @@ func processHistogramSlice(prefix string, v []any, commonLabels map[string]strin continue } - boundKey, boundLabel, ok := histogramBound(bucket) + boundKey, ok := histogramBoundKey(bucket) if !ok { continue } @@ -445,27 +451,26 @@ func processHistogramSlice(prefix string, v []any, commonLabels map[string]strin labels[name] = value } - labels[boundLabel] = fmt.Sprint(bucket[boundKey]) + labels[histogramBoundLabel] = fmt.Sprint(bucket[boundKey]) metrics = appendMetricValue(metrics, prefix+".", "count", bucket["count"], labels, compatibleMode) } return metrics } -// histogramBound returns the field holding the bucket boundary and the label exposing it. -// getDiagnosticData uses two shapes: {lowerBound, count} under "histograms" nodes and -// {micros, count} under the "histogram" arrays of serverStatus.opLatencies. -func histogramBound(bucket map[string]any) (string, string, bool) { - for _, bound := range []struct{ key, label string }{ - {histogramLowerBoundKey, "lower_bound"}, - {histogramMicrosKey, histogramMicrosKey}, - } { - if _, ok := bucket[bound.key]; ok { - return bound.key, bound.label, true +// histogramBoundKey returns the field holding the bucket boundary. getDiagnosticData names it +// two ways, both documented as the lower bound of the bucket: "lowerBound" under "histograms" +// nodes and "micros" under the "histogram" arrays of serverStatus.opLatencies. Both are exposed +// under histogramBoundLabel so that a query does not have to know which shape it came from. +// The order decides which one wins should a bucket ever carry both. +func histogramBoundKey(bucket map[string]any) (string, bool) { + for _, key := range histogramBoundKeys { + if _, ok := bucket[key]; ok { + return key, true } } - return "", "", false + return "", false } // isHistogramBucketValue reports whether the value holds histogram buckets, so that @@ -497,7 +502,7 @@ func isHistogramBucketSlice(prefix string, v []any) bool { if !ok { return false } - if _, _, ok := histogramBound(bucket); !ok { + if _, ok := histogramBoundKey(bucket); !ok { return false } if _, ok := bucket["count"]; !ok { diff --git a/exporter/metrics_histogram_fixture_test.go b/exporter/metrics_histogram_fixture_test.go index 4d8940a95..e295d2b94 100644 --- a/exporter/metrics_histogram_fixture_test.go +++ b/exporter/metrics_histogram_fixture_test.go @@ -37,12 +37,13 @@ func TestServerStatusHistogramsFromFixture(t *testing.T) { opLatencyBuckets, ok := metricsByName["mongodb_ss_opLatencies_reads_histogram_count"] require.True(t, ok) assert.ElementsMatch(t, []string{"0", "8", "64", "512", "3072", "8192", "24576", "65536", "131072"}, - labelValues(opLatencyBuckets, histogramMicrosKey)) + labelValues(opLatencyBuckets, histogramBoundLabel)) assert.NotContains(t, metricsByName, "mongodb_ss_opLatencies_reads_histogram_micros") plannerBuckets, ok := metricsByName["mongodb_ss_metrics_query_multiPlanner_histograms_classicWorks_count"] require.True(t, ok) - assert.Len(t, labelValues(plannerBuckets, "lower_bound"), 10) + assert.ElementsMatch(t, []string{"0", "128", "256", "512", "1024", "2048", "4096", "8192", "16384", "32768"}, + labelValues(plannerBuckets, histogramBoundLabel)) } func TestServerStatusHistogramsFromFixtureAreSkippedByDefault(t *testing.T) { diff --git a/exporter/metrics_test.go b/exporter/metrics_test.go index e1dfd2113..ecb8673d1 100644 --- a/exporter/metrics_test.go +++ b/exporter/metrics_test.go @@ -363,7 +363,7 @@ func TestOpLatenciesHistogramMetricsDoNotCollide(t *testing.T) { valuesByBound := make(map[string]float64, len(bucketCounts.GetMetric())) for _, metric := range bucketCounts.GetMetric() { for _, label := range metric.GetLabel() { - if label.GetName() == histogramMicrosKey { + if label.GetName() == histogramBoundLabel { valuesByBound[label.GetValue()] = metric.GetCounter().GetValue() } } From 562504b3be84f1923491a84537ffab8243e42227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20=C4=8Ctvrtka?= Date: Thu, 30 Jul 2026 14:11:56 +0200 Subject: [PATCH 07/11] PMM-15248 Assert MongoDB field and label names with literals in tests. --- exporter/metrics_histogram_fixture_test.go | 4 ++-- exporter/metrics_test.go | 26 +++++++++++----------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/exporter/metrics_histogram_fixture_test.go b/exporter/metrics_histogram_fixture_test.go index e295d2b94..b7d49885f 100644 --- a/exporter/metrics_histogram_fixture_test.go +++ b/exporter/metrics_histogram_fixture_test.go @@ -37,13 +37,13 @@ func TestServerStatusHistogramsFromFixture(t *testing.T) { opLatencyBuckets, ok := metricsByName["mongodb_ss_opLatencies_reads_histogram_count"] require.True(t, ok) assert.ElementsMatch(t, []string{"0", "8", "64", "512", "3072", "8192", "24576", "65536", "131072"}, - labelValues(opLatencyBuckets, histogramBoundLabel)) + labelValues(opLatencyBuckets, "lower_bound")) assert.NotContains(t, metricsByName, "mongodb_ss_opLatencies_reads_histogram_micros") plannerBuckets, ok := metricsByName["mongodb_ss_metrics_query_multiPlanner_histograms_classicWorks_count"] require.True(t, ok) assert.ElementsMatch(t, []string{"0", "128", "256", "512", "1024", "2048", "4096", "8192", "16384", "32768"}, - labelValues(plannerBuckets, histogramBoundLabel)) + labelValues(plannerBuckets, "lower_bound")) } func TestServerStatusHistogramsFromFixtureAreSkippedByDefault(t *testing.T) { diff --git a/exporter/metrics_test.go b/exporter/metrics_test.go index ecb8673d1..7382d0ef5 100644 --- a/exporter/metrics_test.go +++ b/exporter/metrics_test.go @@ -239,8 +239,8 @@ func TestHistogramMetricsDoNotCollide(t *testing.T) { metrics := makeMetricsWithHistograms("serverStatus.metrics.query.multiPlanner.histograms", bson.M{ "sbeMicros": primitive.A{ - bson.M{histogramLowerBoundKey: int64(0), "count": int64(3)}, - bson.M{histogramLowerBoundKey: int64(1024), "count": int64(7)}, + bson.M{"lowerBound": int64(0), "count": int64(3)}, + bson.M{"lowerBound": int64(1024), "count": int64(7)}, }, }, nil, true, true) @@ -288,8 +288,8 @@ func TestHistogramMetricsAreSkippedByDefault(t *testing.T) { metrics := makeMetrics("serverStatus.metrics.query.multiPlanner", bson.M{ "histograms": bson.M{ "sbeMicros": primitive.A{ - bson.M{histogramLowerBoundKey: int64(0), "count": int64(3)}, - bson.M{histogramLowerBoundKey: int64(1024), "count": int64(7)}, + bson.M{"lowerBound": int64(0), "count": int64(3)}, + bson.M{"lowerBound": int64(1024), "count": int64(7)}, }, }, }, nil, true) @@ -298,8 +298,8 @@ func TestHistogramMetricsAreSkippedByDefault(t *testing.T) { metrics = makeMetrics("serverStatus.metrics.query.multiPlanner.histograms", bson.M{ "sbeMicros": primitive.A{ - bson.M{histogramLowerBoundKey: int64(0), "count": int64(3)}, - bson.M{histogramLowerBoundKey: int64(1024), "count": int64(7)}, + bson.M{"lowerBound": int64(0), "count": int64(3)}, + bson.M{"lowerBound": int64(1024), "count": int64(7)}, }, }, nil, true) @@ -335,8 +335,8 @@ func TestOpLatenciesHistogramMetricsDoNotCollide(t *testing.T) { "latency": int64(120), "ops": int64(4), "histogram": primitive.A{ - bson.M{histogramMicrosKey: int64(1), "count": int64(3)}, - bson.M{histogramMicrosKey: int64(2048), "count": int64(7)}, + bson.M{"micros": int64(1), "count": int64(3)}, + bson.M{"micros": int64(2048), "count": int64(7)}, }, }, }, @@ -363,7 +363,7 @@ func TestOpLatenciesHistogramMetricsDoNotCollide(t *testing.T) { valuesByBound := make(map[string]float64, len(bucketCounts.GetMetric())) for _, metric := range bucketCounts.GetMetric() { for _, label := range metric.GetLabel() { - if label.GetName() == histogramBoundLabel { + if label.GetName() == "lower_bound" { valuesByBound[label.GetValue()] = metric.GetCounter().GetValue() } } @@ -381,8 +381,8 @@ func TestOpLatenciesHistogramMetricsAreSkippedByDefault(t *testing.T) { metrics := makeMetrics("serverStatus.opLatencies.reads", bson.M{ "latency": int64(120), "histogram": primitive.A{ - bson.M{histogramMicrosKey: int64(1), "count": int64(3)}, - bson.M{histogramMicrosKey: int64(2048), "count": int64(7)}, + bson.M{"micros": int64(1), "count": int64(3)}, + bson.M{"micros": int64(2048), "count": int64(7)}, }, }, nil, false) @@ -410,9 +410,9 @@ func gatheredMetricNames(t *testing.T, metrics []prometheus.Metric) []string { func TestAsMetricMapHandlesBSONM(t *testing.T) { t.Parallel() - bucket, ok := asMetricMap(bson.M{histogramLowerBoundKey: int64(1024), "count": int64(7)}) + bucket, ok := asMetricMap(bson.M{"lowerBound": int64(1024), "count": int64(7)}) assert.True(t, ok) - assert.Equal(t, int64(1024), bucket[histogramLowerBoundKey]) + assert.Equal(t, int64(1024), bucket["lowerBound"]) assert.Equal(t, int64(7), bucket["count"]) } From db0cd80a8fa4f5182f47cc282cea8d37696510b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20=C4=8Ctvrtka?= Date: Thu, 30 Jul 2026 14:12:55 +0200 Subject: [PATCH 08/11] PMM-15248 Drop the unused systemMetrics block from the 8.3 fixture. --- exporter/fixtures_test.go | 3 +- .../testdata/get_diagnostic_data_8.3.json | 49 ------------------- 2 files changed, 2 insertions(+), 50 deletions(-) diff --git a/exporter/fixtures_test.go b/exporter/fixtures_test.go index 641c7c3d7..5f2f1bbbe 100644 --- a/exporter/fixtures_test.go +++ b/exporter/fixtures_test.go @@ -27,7 +27,8 @@ import ( // diagnosticData83FixturePath is a getDiagnosticData reply captured from the MongoDB 8.3.2 // instance of https://github.com/percona/mongodb_exporter/issues/1285, trimmed to the -// subtrees the tests below need. +// subtrees the tests below need: serverStatus.opLatencies for the "histogram" bucket arrays +// and serverStatus.metrics.query for the "histograms" nodes. const diagnosticData83FixturePath = "testdata/get_diagnostic_data_8.3.json" // loadDiagnosticData83Fixture reads the captured command reply. Extended JSON is used instead of diff --git a/exporter/testdata/get_diagnostic_data_8.3.json b/exporter/testdata/get_diagnostic_data_8.3.json index 741d62f3b..ce84c8f49 100644 --- a/exporter/testdata/get_diagnostic_data_8.3.json +++ b/exporter/testdata/get_diagnostic_data_8.3.json @@ -613,54 +613,5 @@ } } } - }, - "systemMetrics": { - "ethtool": { - "ens192": { - "Tx Queue#": 0, - " TSO pkts tx": 0, - " TSO bytes tx": -102, - " ucast pkts tx": -102, - " ucast bytes tx": 78, - " mcast pkts tx": 0, - " mcast bytes tx": 0, - " bcast pkts tx": 0, - " bcast bytes tx": 0, - " pkts tx err": 0, - " pkts tx discard": -10, - " drv dropped tx total": -59, - " too many frags": -89, - " giant hdr": 104, - " hdr err": 0, - " tso": 0, - " ring full": 0, - " pkts linearized": 0, - " hdr cloned": 0, - " giant hdr": 0, - " xdp xmit": 0, - " xdp xmit err": 0, - "Rx Queue#": -113, - " LRO pkts rx": 4, - " LRO byte rx": 0, - " ucast pkts rx": 0, - " ucast bytes rx": 0, - " mcast pkts rx": 0, - " mcast bytes rx": 0, - " bcast pkts rx": 0, - " bcast bytes rx": 0, - " pkts rx OOB": 0, - " pkts rx err": 0, - " drv dropped rx total": 0, - " err": 0, - " fcs": 0, - " rx buf alloc fail": 0, - " xdp packets": 0, - " xdp tx": 0, - " xdp redirects": 0, - " xdp drops": 0, - " xdp aborted": 0, - "tx timeout count": 0 - } - } } } From 7cded85fa29b3ded23006dfc0001bc9b26a30afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20=C4=8Ctvrtka?= Date: Thu, 30 Jul 2026 14:14:58 +0200 Subject: [PATCH 09/11] PMM-15248 Reuse one gather helper across the histogram tests. --- exporter/fixtures_test.go | 49 ++++++++++++-- exporter/metrics_histogram_fixture_test.go | 4 +- exporter/metrics_test.go | 77 +++------------------- 3 files changed, 53 insertions(+), 77 deletions(-) diff --git a/exporter/fixtures_test.go b/exporter/fixtures_test.go index 5f2f1bbbe..af0187470 100644 --- a/exporter/fixtures_test.go +++ b/exporter/fixtures_test.go @@ -47,25 +47,48 @@ func loadDiagnosticData83Fixture(t *testing.T) bson.M { return m } -// gatherFixtureMetrics exports the metrics the way the registry does at scrape time, so +// gatherFamilies exports the metrics the way the registry does at scrape time, so // duplicated series and inconsistent descriptors fail the test instead of the scrape. -func gatherFixtureMetrics(t *testing.T, metrics []prometheus.Metric) map[string]*dto.MetricFamily { +func gatherFamilies(t *testing.T, metrics []prometheus.Metric) []*dto.MetricFamily { t.Helper() reg := prometheus.NewPedanticRegistry() require.NoError(t, reg.Register(staticCollector(metrics)), "descriptors must be consistent") - gatheredMetrics, err := reg.Gather() + families, err := reg.Gather() require.NoError(t, err, "metrics must be exported only once") - metricsByName := make(map[string]*dto.MetricFamily, len(gatheredMetrics)) - for _, metric := range gatheredMetrics { - metricsByName[metric.GetName()] = metric + return families +} + +// gatherMetrics exports the metrics and indexes the families by name. +func gatherMetrics(t *testing.T, metrics []prometheus.Metric) map[string]*dto.MetricFamily { + t.Helper() + + families := gatherFamilies(t, metrics) + + metricsByName := make(map[string]*dto.MetricFamily, len(families)) + for _, family := range families { + metricsByName[family.GetName()] = family } return metricsByName } +// gatheredMetricNames returns the exported metric names, in the order Gather sorts them. +func gatheredMetricNames(t *testing.T, metrics []prometheus.Metric) []string { + t.Helper() + + families := gatherFamilies(t, metrics) + + names := make([]string, 0, len(families)) + for _, family := range families { + names = append(names, family.GetName()) + } + + return names +} + // labelValues returns the value of the given label for every series of a metric family. func labelValues(family *dto.MetricFamily, name string) []string { values := make([]string, 0, len(family.GetMetric())) @@ -79,3 +102,17 @@ func labelValues(family *dto.MetricFamily, name string) []string { return values } + +// counterValuesByLabel maps each value of the given label to the counter value of its series. +func counterValuesByLabel(family *dto.MetricFamily, name string) map[string]float64 { + values := make(map[string]float64, len(family.GetMetric())) + for _, metric := range family.GetMetric() { + for _, label := range metric.GetLabel() { + if label.GetName() == name { + values[label.GetValue()] = metric.GetCounter().GetValue() + } + } + } + + return values +} diff --git a/exporter/metrics_histogram_fixture_test.go b/exporter/metrics_histogram_fixture_test.go index b7d49885f..240272a9b 100644 --- a/exporter/metrics_histogram_fixture_test.go +++ b/exporter/metrics_histogram_fixture_test.go @@ -32,7 +32,7 @@ func TestServerStatusHistogramsFromFixture(t *testing.T) { require.True(t, ok) labels := map[string]string{"cl_id": "", "cl_role": ""} - metricsByName := gatherFixtureMetrics(t, makeMetricsWithHistograms("serverStatus", serverStatus, labels, false, true)) + metricsByName := gatherMetrics(t, makeMetricsWithHistograms("serverStatus", serverStatus, labels, false, true)) opLatencyBuckets, ok := metricsByName["mongodb_ss_opLatencies_reads_histogram_count"] require.True(t, ok) @@ -53,7 +53,7 @@ func TestServerStatusHistogramsFromFixtureAreSkippedByDefault(t *testing.T) { require.True(t, ok) labels := map[string]string{"cl_id": "", "cl_role": ""} - metricsByName := gatherFixtureMetrics(t, makeMetrics("serverStatus", serverStatus, labels, false)) + metricsByName := gatherMetrics(t, makeMetrics("serverStatus", serverStatus, labels, false)) for name := range metricsByName { assert.NotContains(t, name, "histogram") diff --git a/exporter/metrics_test.go b/exporter/metrics_test.go index 7382d0ef5..c88d03212 100644 --- a/exporter/metrics_test.go +++ b/exporter/metrics_test.go @@ -21,7 +21,6 @@ import ( "github.com/AlekSi/pointer" "github.com/prometheus/client_golang/prometheus" - dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/bson" @@ -244,42 +243,18 @@ func TestHistogramMetricsDoNotCollide(t *testing.T) { }, }, nil, true, true) - reg := prometheus.NewPedanticRegistry() - reg.MustRegister(staticCollector(metrics)) - - gatheredMetrics, err := reg.Gather() - require.NoError(t, err, "metrics with the same name and labels must not be exported") - - metricsByName := make(map[string]*dto.MetricFamily, len(gatheredMetrics)) - for _, metric := range gatheredMetrics { - metricsByName[metric.GetName()] = metric - } + metricsByName := gatherMetrics(t, metrics) assert.NotContains(t, metricsByName, "mongodb_ss_metrics_query_multiPlanner_histograms_sbeMicros_lowerBound") bucketCounts, ok := metricsByName["mongodb_ss_metrics_query_multiPlanner_histograms_sbeMicros_count"] - if !assert.True(t, ok) { - return - } - - bucketCountMetrics := bucketCounts.GetMetric() - if !assert.Len(t, bucketCountMetrics, 2) { - return - } - - valuesByBound := make(map[string]float64, len(bucketCountMetrics)) - for _, metric := range bucketCountMetrics { - labels := make(map[string]string, len(metric.GetLabel())) - for _, label := range metric.GetLabel() { - labels[label.GetName()] = label.GetValue() - } - valuesByBound[labels["lower_bound"]] = metric.GetCounter().GetValue() - } + require.True(t, ok) + assert.Len(t, bucketCounts.GetMetric(), 2) assert.Equal(t, map[string]float64{ "0": 3, "1024": 7, - }, valuesByBound) + }, counterValuesByLabel(bucketCounts, "lower_bound")) } func TestHistogramMetricsAreSkippedByDefault(t *testing.T) { @@ -342,37 +317,18 @@ func TestOpLatenciesHistogramMetricsDoNotCollide(t *testing.T) { }, }, nil, false, true) - reg := prometheus.NewPedanticRegistry() - reg.MustRegister(staticCollector(metrics)) - - gatheredMetrics, err := reg.Gather() - require.NoError(t, err, "metrics with the same name and labels must not be exported") - - metricsByName := make(map[string]*dto.MetricFamily, len(gatheredMetrics)) - for _, metric := range gatheredMetrics { - metricsByName[metric.GetName()] = metric - } + metricsByName := gatherMetrics(t, metrics) assert.NotContains(t, metricsByName, "mongodb_ss_opLatencies_reads_histogram_micros") bucketCounts, ok := metricsByName["mongodb_ss_opLatencies_reads_histogram_count"] - if !assert.True(t, ok) { - return - } - - valuesByBound := make(map[string]float64, len(bucketCounts.GetMetric())) - for _, metric := range bucketCounts.GetMetric() { - for _, label := range metric.GetLabel() { - if label.GetName() == "lower_bound" { - valuesByBound[label.GetValue()] = metric.GetCounter().GetValue() - } - } - } + require.True(t, ok) + assert.Len(t, bucketCounts.GetMetric(), 2) assert.Equal(t, map[string]float64{ "1": 3, "2048": 7, - }, valuesByBound) + }, counterValuesByLabel(bucketCounts, "lower_bound")) } func TestOpLatenciesHistogramMetricsAreSkippedByDefault(t *testing.T) { @@ -390,23 +346,6 @@ func TestOpLatenciesHistogramMetricsAreSkippedByDefault(t *testing.T) { assert.Equal(t, []string{"mongodb_ss_opLatencies_latency"}, gatheredMetricNames(t, metrics)) } -func gatheredMetricNames(t *testing.T, metrics []prometheus.Metric) []string { - t.Helper() - - reg := prometheus.NewPedanticRegistry() - reg.MustRegister(staticCollector(metrics)) - - gatheredMetrics, err := reg.Gather() - require.NoError(t, err) - - names := make([]string, 0, len(gatheredMetrics)) - for _, metric := range gatheredMetrics { - names = append(names, metric.GetName()) - } - - return names -} - func TestAsMetricMapHandlesBSONM(t *testing.T) { t.Parallel() From 1169f707c196b8e4c08f962e35693e64429470d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20=C4=8Ctvrtka?= Date: Thu, 30 Jul 2026 14:16:19 +0200 Subject: [PATCH 10/11] PMM-15248 Cover compatibleMode and the captured bucket counts. --- exporter/metrics_histogram_fixture_test.go | 13 +++++++-- exporter/metrics_test.go | 34 ++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/exporter/metrics_histogram_fixture_test.go b/exporter/metrics_histogram_fixture_test.go index 240272a9b..22b03f7de 100644 --- a/exporter/metrics_histogram_fixture_test.go +++ b/exporter/metrics_histogram_fixture_test.go @@ -34,10 +34,19 @@ func TestServerStatusHistogramsFromFixture(t *testing.T) { labels := map[string]string{"cl_id": "", "cl_role": ""} metricsByName := gatherMetrics(t, makeMetricsWithHistograms("serverStatus", serverStatus, labels, false, true)) + // Every op type keeps its own bucket family. + for _, opType := range []string{"reads", "writes", "commands", "transactions"} { + assert.Contains(t, metricsByName, "mongodb_ss_opLatencies_"+opType+"_histogram_count", opType) + } + + // Each bound keeps the count it was captured with, so a bound paired with the wrong count + // fails here rather than showing up as a plausible looking heatmap. opLatencyBuckets, ok := metricsByName["mongodb_ss_opLatencies_reads_histogram_count"] require.True(t, ok) - assert.ElementsMatch(t, []string{"0", "8", "64", "512", "3072", "8192", "24576", "65536", "131072"}, - labelValues(opLatencyBuckets, "lower_bound")) + assert.Equal(t, map[string]float64{ + "0": 0, "8": 0, "64": 16737, "512": 947, "3072": 351, + "8192": 77, "24576": 54, "65536": 13, "131072": 21, + }, counterValuesByLabel(opLatencyBuckets, "lower_bound")) assert.NotContains(t, metricsByName, "mongodb_ss_opLatencies_reads_histogram_micros") plannerBuckets, ok := metricsByName["mongodb_ss_metrics_query_multiPlanner_histograms_classicWorks_count"] diff --git a/exporter/metrics_test.go b/exporter/metrics_test.go index c88d03212..b2749729b 100644 --- a/exporter/metrics_test.go +++ b/exporter/metrics_test.go @@ -331,6 +331,40 @@ func TestOpLatenciesHistogramMetricsDoNotCollide(t *testing.T) { }, counterValuesByLabel(bucketCounts, "lower_bound")) } +// The bucket metrics carry the "mongodb_ss_opLatencies" prefix that specialConversions and the +// v1 conversions match on, and compatibleMode exports a second series set from the same raw +// metric. Neither may rename the buckets onto the op_type series. +func TestOpLatenciesHistogramMetricsInCompatibleMode(t *testing.T) { + t.Parallel() + + metrics := makeMetricsWithHistograms("serverStatus", bson.M{ + "opLatencies": bson.M{ + "reads": bson.M{ + "latency": int64(120), + "ops": int64(4), + "histogram": primitive.A{ + bson.M{"micros": int64(1), "count": int64(3)}, + bson.M{"micros": int64(2048), "count": int64(7)}, + }, + }, + }, + }, nil, true, true) + + metricsByName := gatherMetrics(t, metrics) + + bucketCounts, ok := metricsByName["mongodb_ss_opLatencies_reads_histogram_count"] + require.True(t, ok) + assert.Equal(t, map[string]float64{ + "1": 3, + "2048": 7, + }, counterValuesByLabel(bucketCounts, "lower_bound")) + + // The buckets get no op_type series and no v1 compatible twin of their own. + assert.Empty(t, labelValues(bucketCounts, "op_type")) + assert.Equal(t, []string{"reads"}, labelValues(metricsByName["mongodb_ss_opLatencies_latency"], "op_type")) + assert.Len(t, metricsByName["mongodb_mongod_op_latencies_latency_total"].GetMetric(), 1) +} + func TestOpLatenciesHistogramMetricsAreSkippedByDefault(t *testing.T) { t.Parallel() From 92ef01712be72e4ad89f0b6db2fca2d64b2a2b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20=C4=8Ctvrtka?= Date: Thu, 30 Jul 2026 15:46:57 +0200 Subject: [PATCH 11/11] PMM-15248 Lint. --- exporter/fixtures_test.go | 6 +++--- exporter/metrics_histogram_fixture_test.go | 2 +- exporter/metrics_test.go | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/exporter/fixtures_test.go b/exporter/fixtures_test.go index af0187470..6e7058180 100644 --- a/exporter/fixtures_test.go +++ b/exporter/fixtures_test.go @@ -103,12 +103,12 @@ func labelValues(family *dto.MetricFamily, name string) []string { return values } -// counterValuesByLabel maps each value of the given label to the counter value of its series. -func counterValuesByLabel(family *dto.MetricFamily, name string) map[string]float64 { +// countsByLowerBound maps each bucket bound of a histogram family to the count of its series. +func countsByLowerBound(family *dto.MetricFamily) map[string]float64 { values := make(map[string]float64, len(family.GetMetric())) for _, metric := range family.GetMetric() { for _, label := range metric.GetLabel() { - if label.GetName() == name { + if label.GetName() == histogramBoundLabel { values[label.GetValue()] = metric.GetCounter().GetValue() } } diff --git a/exporter/metrics_histogram_fixture_test.go b/exporter/metrics_histogram_fixture_test.go index 22b03f7de..a5dc73be9 100644 --- a/exporter/metrics_histogram_fixture_test.go +++ b/exporter/metrics_histogram_fixture_test.go @@ -46,7 +46,7 @@ func TestServerStatusHistogramsFromFixture(t *testing.T) { assert.Equal(t, map[string]float64{ "0": 0, "8": 0, "64": 16737, "512": 947, "3072": 351, "8192": 77, "24576": 54, "65536": 13, "131072": 21, - }, counterValuesByLabel(opLatencyBuckets, "lower_bound")) + }, countsByLowerBound(opLatencyBuckets)) assert.NotContains(t, metricsByName, "mongodb_ss_opLatencies_reads_histogram_micros") plannerBuckets, ok := metricsByName["mongodb_ss_metrics_query_multiPlanner_histograms_classicWorks_count"] diff --git a/exporter/metrics_test.go b/exporter/metrics_test.go index b2749729b..090e0e9d8 100644 --- a/exporter/metrics_test.go +++ b/exporter/metrics_test.go @@ -254,7 +254,7 @@ func TestHistogramMetricsDoNotCollide(t *testing.T) { assert.Equal(t, map[string]float64{ "0": 3, "1024": 7, - }, counterValuesByLabel(bucketCounts, "lower_bound")) + }, countsByLowerBound(bucketCounts)) } func TestHistogramMetricsAreSkippedByDefault(t *testing.T) { @@ -328,7 +328,7 @@ func TestOpLatenciesHistogramMetricsDoNotCollide(t *testing.T) { assert.Equal(t, map[string]float64{ "1": 3, "2048": 7, - }, counterValuesByLabel(bucketCounts, "lower_bound")) + }, countsByLowerBound(bucketCounts)) } // The bucket metrics carry the "mongodb_ss_opLatencies" prefix that specialConversions and the @@ -357,7 +357,7 @@ func TestOpLatenciesHistogramMetricsInCompatibleMode(t *testing.T) { assert.Equal(t, map[string]float64{ "1": 3, "2048": 7, - }, counterValuesByLabel(bucketCounts, "lower_bound")) + }, countsByLowerBound(bucketCounts)) // The buckets get no op_type series and no v1 compatible twin of their own. assert.Empty(t, labelValues(bucketCounts, "op_type"))