From f0f4358125950a77445872e5bbfa86d874049f69 Mon Sep 17 00:00:00 2001 From: Isaac Levine Date: Tue, 17 Feb 2026 12:42:21 -0500 Subject: [PATCH] Bucket gql.loaderBatchSize tag to limit metric cardinality The gql.loaderBatchSize tag on the gql.dataLoader timer emits raw integer values (e.g. "152", "140", "10"), causing unbounded cardinality in Prometheus and other metrics backends. Every distinct batch size creates a separate time series. Bucket the batch size into predefined ranges [5, 10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000] using the same approach already used for gql.query.complexity in ComplexityUtils. This limits the tag to at most 12 distinct values. Fixes #1974 --- .../BatchLoaderWithContextInterceptor.kt | 18 +++- .../BatchLoaderWithContextInterceptorTest.kt | 100 ++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 graphql-dgs-spring-boot-micrometer/src/test/kotlin/com/netflix/graphql/dgs/metrics/micrometer/dataloader/BatchLoaderWithContextInterceptorTest.kt diff --git a/graphql-dgs-spring-boot-micrometer/src/main/kotlin/com/netflix/graphql/dgs/metrics/micrometer/dataloader/BatchLoaderWithContextInterceptor.kt b/graphql-dgs-spring-boot-micrometer/src/main/kotlin/com/netflix/graphql/dgs/metrics/micrometer/dataloader/BatchLoaderWithContextInterceptor.kt index 8fa141536..818cfabf6 100644 --- a/graphql-dgs-spring-boot-micrometer/src/main/kotlin/com/netflix/graphql/dgs/metrics/micrometer/dataloader/BatchLoaderWithContextInterceptor.kt +++ b/graphql-dgs-spring-boot-micrometer/src/main/kotlin/com/netflix/graphql/dgs/metrics/micrometer/dataloader/BatchLoaderWithContextInterceptor.kt @@ -46,7 +46,7 @@ internal class BatchLoaderWithContextInterceptor( .tags( listOf( Tag.of(GqlTag.LOADER_NAME.key, name), - Tag.of(GqlTag.LOADER_BATCH_SIZE.key, resultSize.toString()), + Tag.of(GqlTag.LOADER_BATCH_SIZE.key, bucketBatchSize(resultSize).toString()), ), ).register(registry), ) @@ -65,5 +65,21 @@ internal class BatchLoaderWithContextInterceptor( companion object { private val ID = GqlMetric.DATA_LOADER.key private val logger = LoggerFactory.getLogger(BatchLoaderWithContextInterceptor::class.java) + + private val BATCH_SIZE_BUCKETS = listOf(5, 10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000) + + /** + * Buckets the given batch size into a predefined range to limit metric cardinality. + * Uses the same bucketing approach as query complexity in [DgsGraphQLMetricsInstrumentation]. + * Returns the smallest bucket that the size falls below, or [Int.MAX_VALUE] if it exceeds all buckets. + */ + internal fun bucketBatchSize(size: Int): Int { + for (bucket in BATCH_SIZE_BUCKETS) { + if (size < bucket) { + return bucket + } + } + return Int.MAX_VALUE + } } } diff --git a/graphql-dgs-spring-boot-micrometer/src/test/kotlin/com/netflix/graphql/dgs/metrics/micrometer/dataloader/BatchLoaderWithContextInterceptorTest.kt b/graphql-dgs-spring-boot-micrometer/src/test/kotlin/com/netflix/graphql/dgs/metrics/micrometer/dataloader/BatchLoaderWithContextInterceptorTest.kt new file mode 100644 index 000000000..9c4cabba7 --- /dev/null +++ b/graphql-dgs-spring-boot-micrometer/src/test/kotlin/com/netflix/graphql/dgs/metrics/micrometer/dataloader/BatchLoaderWithContextInterceptorTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2025 Netflix, Inc. + * + * 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 com.netflix.graphql.dgs.metrics.micrometer.dataloader + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class BatchLoaderWithContextInterceptorTest { + @Test + fun `batch size 0 is bucketed to 5`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(0)).isEqualTo(5) + } + + @Test + fun `batch size 1 is bucketed to 5`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(1)).isEqualTo(5) + } + + @Test + fun `batch size 4 is bucketed to 5`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(4)).isEqualTo(5) + } + + @Test + fun `batch size 5 is bucketed to 10`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(5)).isEqualTo(10) + } + + @Test + fun `batch size 9 is bucketed to 10`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(9)).isEqualTo(10) + } + + @Test + fun `batch size 10 is bucketed to 25`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(10)).isEqualTo(25) + } + + @Test + fun `batch size 24 is bucketed to 25`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(24)).isEqualTo(25) + } + + @Test + fun `batch size 25 is bucketed to 50`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(25)).isEqualTo(50) + } + + @Test + fun `batch size 99 is bucketed to 100`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(99)).isEqualTo(100) + } + + @Test + fun `batch size 100 is bucketed to 200`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(100)).isEqualTo(200) + } + + @Test + fun `batch size 500 is bucketed to 1000`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(500)).isEqualTo(1000) + } + + @Test + fun `batch size 9999 is bucketed to 10000`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(9999)).isEqualTo(10000) + } + + @Test + fun `batch size 10000 exceeds all buckets`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(10000)).isEqualTo(Int.MAX_VALUE) + } + + @Test + fun `batch size 50000 exceeds all buckets`() { + assertThat(BatchLoaderWithContextInterceptor.bucketBatchSize(50000)).isEqualTo(Int.MAX_VALUE) + } + + @Test + fun `all bucket boundaries produce at most 12 distinct values`() { + val distinctValues = + (0..50000).map { BatchLoaderWithContextInterceptor.bucketBatchSize(it) }.distinct().sorted() + assertThat(distinctValues).hasSize(12) + assertThat(distinctValues).containsExactly(5, 10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000, Int.MAX_VALUE) + } +}