From f01b92a34d4d913963a5578ba2fc535d023552da Mon Sep 17 00:00:00 2001 From: croway Date: Wed, 2 Sep 2026 15:01:40 +0200 Subject: [PATCH] CAMEL-24505: camel-micrometer-starter - bound the uri metric tag The uri low cardinality tag of the http.server.requests metrics was set to request.getServletPath() + getPathInfo() whenever the request did not resolve to a Camel HTTP consumer. Micrometer registers a meter per distinct tag value and keeps it for the lifetime of the process, so the meters followed the number of distinct paths that clients requested, instead of the number of routes, and the memory they use grows with the traffic a deployment receives. Requests that do not resolve to a Camel consumer now keep the uri computed by Spring's own DefaultServerRequestObservationConvention: the mapped pattern for a Spring MVC endpoint, and a constant (UNKNOWN, NOT_FOUND, REDIRECTION) otherwise. That is also what the uriTagEnabled javadoc already documents, that an unresolved request "will be marked as UNKNOWN". Requests that do resolve to a Camel consumer are unchanged and keep the static consumer path. With uriTagDynamic the requested path is still used, as that is the documented purpose of the option, but only for requests that resolve to a Camel consumer, and the value is capped and stripped of control characters. The auto-configuration was also conditional on camel.metrics.uriTagEnabled, a spelling that Spring Boot cannot resolve from a relaxed binding source, so the camel.metrics.uri-tag-enabled property listed in the starter documentation never enabled the uri tag. The condition now uses the kebab-case name, and both spellings work. Co-Authored-By: Claude Opus 5 --- .../camel-micrometer-starter/pom.xml | 11 +++ .../src/main/docs/micrometer.json | 2 +- .../MicrometerTagsAutoConfiguration.java | 90 +++++++++++++----- .../metrics/CamelMetricsConfiguration.java | 3 +- .../MicrometerUriTagDynamicTest.java | 92 ++++++++++++++++++ .../springboot/MicrometerUriTagTest.java | 76 +++++++++++++++ .../MicrometerUriTagTestSupport.java | 93 +++++++++++++++++++ .../ROOT/pages/starters/micrometer.adoc | 2 +- 8 files changed, 342 insertions(+), 27 deletions(-) create mode 100644 components-starter/camel-micrometer-starter/src/test/java/org/apache/camel/component/micrometer/springboot/MicrometerUriTagDynamicTest.java create mode 100644 components-starter/camel-micrometer-starter/src/test/java/org/apache/camel/component/micrometer/springboot/MicrometerUriTagTest.java create mode 100644 components-starter/camel-micrometer-starter/src/test/java/org/apache/camel/component/micrometer/springboot/MicrometerUriTagTestSupport.java diff --git a/components-starter/camel-micrometer-starter/pom.xml b/components-starter/camel-micrometer-starter/pom.xml index 2f3880a97919..86f042fffdb0 100644 --- a/components-starter/camel-micrometer-starter/pom.xml +++ b/components-starter/camel-micrometer-starter/pom.xml @@ -54,6 +54,17 @@ camel-http-common ${camel-version} + + + org.apache.camel.springboot + camel-servlet-starter + test + + + org.awaitility + awaitility + test + org.apache.camel.springboot diff --git a/components-starter/camel-micrometer-starter/src/main/docs/micrometer.json b/components-starter/camel-micrometer-starter/src/main/docs/micrometer.json index f2ffa87891d5..f6073d988e52 100644 --- a/components-starter/camel-micrometer-starter/src/main/docs/micrometer.json +++ b/components-starter/camel-micrometer-starter/src/main/docs/micrometer.json @@ -133,7 +133,7 @@ { "name": "camel.metrics.uri-tag-dynamic", "type": "java.lang.Boolean", - "description": "Whether to use static or dynamic values for HTTP uri tags in captured metrics. When using dynamic tags, then a REST service with base URL: \/users\/{id} will capture metrics with uri tag with the actual dynamic value such as: \/users\/123. However, this can lead to many tags as the URI is dynamic, so use this with care.", + "description": "Whether to use static or dynamic values for HTTP uri tags in captured metrics. When using dynamic tags, then a REST service with base URL: \/users\/{id} will capture metrics with uri tag with the actual dynamic value such as: \/users\/123. However, this can lead to many tags as the URI is dynamic, so use this with care. The dynamic value is only used for requests that are resolved to a Camel HTTP consumer, any other request is tagged by the default Spring convention.", "sourceType": "org.apache.camel.component.micrometer.springboot.metrics.CamelMetricsConfiguration", "defaultValue": false }, diff --git a/components-starter/camel-micrometer-starter/src/main/java/org/apache/camel/component/micrometer/springboot/MicrometerTagsAutoConfiguration.java b/components-starter/camel-micrometer-starter/src/main/java/org/apache/camel/component/micrometer/springboot/MicrometerTagsAutoConfiguration.java index ecb5f3ac5298..8ffa746bdc7f 100644 --- a/components-starter/camel-micrometer-starter/src/main/java/org/apache/camel/component/micrometer/springboot/MicrometerTagsAutoConfiguration.java +++ b/components-starter/camel-micrometer-starter/src/main/java/org/apache/camel/component/micrometer/springboot/MicrometerTagsAutoConfiguration.java @@ -36,9 +36,19 @@ @AutoConfiguration(after = CamelAutoConfiguration.class) @Conditional({ ConditionalOnCamelContextAndAutoConfigurationBeans.class }) -@ConditionalOnProperty(prefix = "camel.metrics", name = "uriTagEnabled", havingValue = "true") +@ConditionalOnProperty(prefix = "camel.metrics", name = "uri-tag-enabled", havingValue = "true") public class MicrometerTagsAutoConfiguration { + /** + * Name of the low cardinality key holding the http uri. + */ + private static final String URI = "uri"; + + /** + * Maximum length of the uri tag value when using dynamic uri tags, to keep the tag value bounded. + */ + private static final int MAX_URI_LENGTH = 200; + /** * To integrate with micrometer to include expanded uri in tags when for example using camel rest-dsl with servlet. */ @@ -50,39 +60,71 @@ ServerRequestObservationConvention serverRequestObservationConvention(Optional MAX_URI_LENGTH ? uri.substring(0, MAX_URI_LENGTH) : uri; + StringBuilder sb = new StringBuilder(answer.length()); + for (int i = 0; i < answer.length(); i++) { + char ch = answer.charAt(i); + sb.append(Character.isISOControl(ch) ? '_' : ch); + } + return sb.toString(); + } } diff --git a/components-starter/camel-micrometer-starter/src/main/java/org/apache/camel/component/micrometer/springboot/metrics/CamelMetricsConfiguration.java b/components-starter/camel-micrometer-starter/src/main/java/org/apache/camel/component/micrometer/springboot/metrics/CamelMetricsConfiguration.java index 787adee4876e..888edd5601eb 100644 --- a/components-starter/camel-micrometer-starter/src/main/java/org/apache/camel/component/micrometer/springboot/metrics/CamelMetricsConfiguration.java +++ b/components-starter/camel-micrometer-starter/src/main/java/org/apache/camel/component/micrometer/springboot/metrics/CamelMetricsConfiguration.java @@ -32,7 +32,8 @@ public class CamelMetricsConfiguration { * * When using dynamic tags, then a REST service with base URL: /users/{id} will capture metrics with uri tag with * the actual dynamic value such as: /users/123. However, this can lead to many tags as the URI is dynamic, so use - * this with care. + * this with care. The dynamic value is only used for requests that are resolved to a Camel HTTP consumer, any + * other request is tagged by the default Spring convention. */ private boolean uriTagDynamic; diff --git a/components-starter/camel-micrometer-starter/src/test/java/org/apache/camel/component/micrometer/springboot/MicrometerUriTagDynamicTest.java b/components-starter/camel-micrometer-starter/src/test/java/org/apache/camel/component/micrometer/springboot/MicrometerUriTagDynamicTest.java new file mode 100644 index 000000000000..2b346bfa4491 --- /dev/null +++ b/components-starter/camel-micrometer-starter/src/test/java/org/apache/camel/component/micrometer/springboot/MicrometerUriTagDynamicTest.java @@ -0,0 +1,92 @@ +/* + * 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.camel.component.micrometer.springboot; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.apache.camel.spring.boot.CamelAutoConfiguration; +import org.apache.camel.test.spring.junit6.CamelSpringBootTest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * With dynamic uri tags, the requested path is used as uri tag, but only for requests that are resolved to a Camel + * consumer, and the tag value is kept bounded in length. + */ +@DirtiesContext +@CamelSpringBootTest +@EnableAutoConfiguration +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = { CamelAutoConfiguration.class, MicrometerUriTagTestSupport.TestConfiguration.class }, + // the legacy camelCase spelling of the properties must keep working as well + properties = { "camel.metrics.uriTagEnabled=true", "camel.metrics.uriTagDynamic=true" }) +public class MicrometerUriTagDynamicTest extends MicrometerUriTagTestSupport { + + private static final int REQUESTS = 10; + private static final int MAX_URI_LENGTH = 200; + + @Order(1) + @Test + void unmatchedRequestsShareASingleMeter() throws Exception { + for (int i = 0; i < REQUESTS; i++) { + assertEquals(404, get("/camel/no-such-path-" + i)); + } + + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + Map tags = uriTags(); + assertEquals(1, tags.size(), "Expected a single uri tag value but got " + tags); + assertEquals(REQUESTS, tags.values().iterator().next()); + assertFalse(tags.keySet().stream().anyMatch(uri -> uri.contains("no-such-path")), + "The requested path must not be used as uri tag but got " + tags); + }); + } + + @Order(2) + @Test + void matchedRequestsUseTheRequestedPath() throws Exception { + assertEquals(200, get("/camel/users/123")); + + await().atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> assertEquals(1, count("/camel/users/123"), "Got uri tags " + uriTags())); + } + + @Order(3) + @Test + void longRequestedPathIsCapped() throws Exception { + assertEquals(200, get("/camel/users/" + "a".repeat(300))); + + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + Map tags = uriTags(); + assertTrue(tags.keySet().stream().anyMatch(uri -> uri.length() == MAX_URI_LENGTH + && uri.startsWith("/camel/users/aaa")), "Expected a capped uri tag value but got " + tags); + assertFalse(tags.keySet().stream().anyMatch(uri -> uri.length() > MAX_URI_LENGTH), + "No uri tag value must be longer than " + MAX_URI_LENGTH + " but got " + tags); + }); + } +} diff --git a/components-starter/camel-micrometer-starter/src/test/java/org/apache/camel/component/micrometer/springboot/MicrometerUriTagTest.java b/components-starter/camel-micrometer-starter/src/test/java/org/apache/camel/component/micrometer/springboot/MicrometerUriTagTest.java new file mode 100644 index 000000000000..a87ad86dd5a1 --- /dev/null +++ b/components-starter/camel-micrometer-starter/src/test/java/org/apache/camel/component/micrometer/springboot/MicrometerUriTagTest.java @@ -0,0 +1,76 @@ +/* + * 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.camel.component.micrometer.springboot; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.apache.camel.spring.boot.CamelAutoConfiguration; +import org.apache.camel.test.spring.junit6.CamelSpringBootTest; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * The uri tag must be the static path of the Camel consumer, and requests that are not for a Camel consumer must not + * add a meter per requested path. + */ +@DirtiesContext +@CamelSpringBootTest +@EnableAutoConfiguration +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = { CamelAutoConfiguration.class, MicrometerUriTagTestSupport.TestConfiguration.class }, + properties = { "camel.metrics.uri-tag-enabled=true" }) +public class MicrometerUriTagTest extends MicrometerUriTagTestSupport { + + private static final int REQUESTS = 10; + + @Order(1) + @Test + void unmatchedRequestsShareASingleMeter() throws Exception { + for (int i = 0; i < REQUESTS; i++) { + assertEquals(404, get("/camel/no-such-path-" + i)); + } + + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + Map tags = uriTags(); + assertEquals(1, tags.size(), "Expected a single uri tag value but got " + tags); + assertEquals(REQUESTS, tags.values().iterator().next()); + assertFalse(tags.keySet().stream().anyMatch(uri -> uri.contains("no-such-path")), + "The requested path must not be used as uri tag but got " + tags); + }); + } + + @Order(2) + @Test + void matchedRequestsUseTheConsumerPath() throws Exception { + assertEquals(200, get("/camel/users/123")); + assertEquals(200, get("/camel/users/456")); + + await().atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> assertEquals(2, count("/users/{id}"), "Got uri tags " + uriTags())); + } +} diff --git a/components-starter/camel-micrometer-starter/src/test/java/org/apache/camel/component/micrometer/springboot/MicrometerUriTagTestSupport.java b/components-starter/camel-micrometer-starter/src/test/java/org/apache/camel/component/micrometer/springboot/MicrometerUriTagTestSupport.java new file mode 100644 index 000000000000..7768916f6bcf --- /dev/null +++ b/components-starter/camel-micrometer-starter/src/test/java/org/apache/camel/component/micrometer/springboot/MicrometerUriTagTestSupport.java @@ -0,0 +1,93 @@ +/* + * 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.camel.component.micrometer.springboot; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import org.apache.camel.builder.RouteBuilder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +/** + * Base class for the tests capturing the uri tag of the {@code http.server.requests} meters. + */ +public abstract class MicrometerUriTagTestSupport { + + protected static final String HTTP_SERVER_REQUESTS = "http.server.requests"; + protected static final String URI_TAG = "uri"; + + @Autowired + protected Environment env; + + @Autowired + protected MeterRegistry meterRegistry; + + /** + * Performs a HTTP GET on the given path, and returns the http status code. + */ + protected int get(String path) throws Exception { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + env.getRequiredProperty("local.server.port") + path)) + .GET() + .build(); + return client.send(request, HttpResponse.BodyHandlers.ofString()).statusCode(); + } + + /** + * The uri tag values of the captured http server request meters, and how many requests each of them counted. + */ + protected Map uriTags() { + Map answer = new LinkedHashMap<>(); + for (Timer timer : meterRegistry.find(HTTP_SERVER_REQUESTS).timers()) { + answer.merge(timer.getId().getTag(URI_TAG), timer.count(), Long::sum); + } + return answer; + } + + /** + * Number of requests counted for the given uri tag value. + */ + protected long count(String uri) { + Timer timer = meterRegistry.find(HTTP_SERVER_REQUESTS).tag(URI_TAG, uri).timer(); + return timer != null ? timer.count() : 0; + } + + @Configuration + public static class TestConfiguration { + + @Bean + public RouteBuilder routeBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("servlet:/users/{id}") + .setBody().constant("Hello"); + } + }; + } + } +} diff --git a/docs/spring-boot/modules/ROOT/pages/starters/micrometer.adoc b/docs/spring-boot/modules/ROOT/pages/starters/micrometer.adoc index 2f2521654593..54d74e42a2e7 100644 --- a/docs/spring-boot/modules/ROOT/pages/starters/micrometer.adoc +++ b/docs/spring-boot/modules/ROOT/pages/starters/micrometer.adoc @@ -44,6 +44,6 @@ The starter supports 18 options, which are listed below. | camel.metrics.naming-strategy | Controls the name style to use for metrics. Default = uses micrometer naming convention. Legacy = uses the classic naming style (camelCase) | default | String | camel.metrics.route-policy-exclude-pattern | Pattern to exclude routes (by id) to capture. Multiple route ids can be separated by comma. | | String | camel.metrics.route-policy-level | Sets the level of information to capture. Possible values are all,route,context. all = both context and routes. route = routes only. context = camel context only. | all | String -| camel.metrics.uri-tag-dynamic | Whether to use static or dynamic values for HTTP uri tags in captured metrics. When using dynamic tags, then a REST service with base URL: /users/\{id} will capture metrics with uri tag with the actual dynamic value such as: /users/123. However, this can lead to many tags as the URI is dynamic, so use this with care. | false | Boolean +| camel.metrics.uri-tag-dynamic | Whether to use static or dynamic values for HTTP uri tags in captured metrics. When using dynamic tags, then a REST service with base URL: /users/\{id} will capture metrics with uri tag with the actual dynamic value such as: /users/123. However, this can lead to many tags as the URI is dynamic, so use this with care. The dynamic value is only used for requests that are resolved to a Camel HTTP consumer, any other request is tagged by the default Spring convention. | false | Boolean | camel.metrics.uri-tag-enabled | Whether HTTP uri tags should be enabled or not in captured metrics. If disabled then the uri tag, is likely not able to be resolved and will be marked as UNKNOWN. | true | Boolean |===