The uri tag on http.client.requests is causing a metrics cardinality explosion. How do the built-in meters get their tags, and how do you control/limit cardinality across the registry?
answer
- tags from Observation conventions (server template vs client template)
- client uri explodes when URL is interpolated, not templated
- MeterFilter: deny / commonTags / replaceTagValues / maximumAllowableTags
- max-uri-tags default 100; management.metrics.enable.*
- filters apply before creation, in order, not retroactive
basics
~20 sBuilt-in meters get tags from Observation conventions (server/client). To limit cardinality, ensure client calls use URI templates (not interpolated URLs), and add a MeterFilter to deny high-cardinality tags, cap max tag values, rename, or drop whole meters registry-wide.
solid answer
~40 s`http.server.requests` uses the matched route template so cardinality is naturally bounded, but `http.client.requests` derives its `uri` tag from how you build the outbound request: if you interpolate values into the URL string, every distinct URL becomes a new series. The fix is to pass a **URI template + variables** (RestClient/RestTemplate/WebClient) so Micrometer records the template, not the expanded path. For registry-wide governance you register **`MeterFilter`** beans (Micrometer's tag/meter transformer). MeterFilters can: deny meters (`MeterFilter.deny(...)` / `denyNameStartsWith`), replace/normalize tag values (`replaceTagValues`), cap the number of distinct values with `MeterFilter.maximumAllowableTags(name, tag, max, onMaxReached)`, cap total meters with `maximumAllowableMetrics`, and add common tags. Boot also exposes config-based limits like `management.metrics.web.client.max-uri-tags`. Filters run in registration order and apply before meters are created, so they're the correct guardrail against a cardinality blow-up.
code
java · 30 lines@Configuration
class MetricsGovernance {
// Registry-wide guardrails via MeterFilter beans (Boot auto-applies them).
@Bean
MeterFilter cardinalityGuards() {
return new MeterFilter() {}; // placeholder to show bean shape
}
@Bean
MeterFilter capClientUriTags() {
// After 100 distinct uri values on http.client.requests, drop new ones.
return MeterFilter.maximumAllowableTags(
"http.client.requests", "uri", 100, MeterFilter.deny());
}
@Bean
MeterFilter normalizeNumericIds() {
return MeterFilter.replaceTagValues("uri",
uri -> uri.replaceAll("/\\d+", "/{id}"));
}
@Bean
MeterFilter commonTags() {
return MeterFilter.commonTags(Tags.of("application", "orders"));
}
}
// The real fix: give the client a template, not an interpolated URL.
User u = restClient.get().uri("/users/{id}", id).retrieve().body(User.class);go deeper
Know tags can explode cardinality and that templated URIs help.
Use uri templates on clients and know max-uri-tags exists.
Apply MeterFilter to deny/rename/cap tags and explain server-vs-client tag derivation.
Design a fleet-wide cardinality budget: source-level templating + normalization + hard caps as backstop, aware filters aren't retroactive.
## Where built-in tags come from Modern Boot instruments HTTP via the **Micrometer Observation API**. Each observation has an `ObservationConvention` that produces low- and high-cardinality `KeyValues` (tags): - Server: `DefaultServerRequestObservationConvention` -> `http.server.requests` with `uri` = matched route template, plus method/status/outcome/exception. - Client: `DefaultClientRequestObservationConvention` (for `RestClient`/`RestTemplate`) and the WebClient equivalent -> `http.client.requests` with `uri` derived from the **request's URI template**, plus `method`, `status`, `outcome`, `client.name` (host). ## Why the client side explodes Server-side `uri` is bounded because Spring MVC knows the handler's template. Client-side, Micrometer only knows the template **if you gave it one**. Two ways to call: ```java // BAD: interpolated -> uri tag = /users/1, /users/2, ... (unbounded) restClient.get().uri("/users/" + id).retrieve()... // GOOD: template + var -> uri tag = /users/{id} (one series) restClient.get().uri("/users/{id}", id).retrieve()... ``` With `WebClient`, use `uri(uriBuilder -> uriBuilder.path("/users/{id}").build(id))` or the template form. If you hand a fully-expanded `URI`, the tag becomes `none` or the raw path depending on version — either loses value or explodes. ## The governance tool: MeterFilter `io.micrometer.core.instrument.config.MeterFilter` is a registry-level hook applied to every meter *before creation*. Register `MeterFilter` beans (Boot auto-applies them; or `registry.config().meterFilter(...)`). Capabilities: - **Deny/accept**: `MeterFilter.deny(id -> ...)`, `MeterFilter.denyNameStartsWith("jvm.buffer")`, `MeterFilter.acceptNameStartsWith(...)`. - **Common tags**: `MeterFilter.commonTags(Tags.of("application","orders","region","eu"))`. - **Rename/normalize tag values**: `MeterFilter.replaceTagValues("uri", v -> collapseIds(v), "/actuator")` — e.g. squash numeric IDs. - **Cap distinct values (the cardinality cap)**: `MeterFilter.maximumAllowableTags("http.client.requests", "uri", 100, MeterFilter.deny())` — after 100 distinct uri values, drop further ones. - **Cap total meters**: `MeterFilter.maximumAllowableMetrics(n)`. - **Distribution config**: a filter can also set histogram/percentile settings via `configure(...)`. Order matters: filters run in the order added; first deny wins. ## Boot config shortcuts - `management.metrics.web.client.max-uri-tags` (default 100) caps client uri tag values. - `management.metrics.enable.<prefix>=false` disables a whole meter family (e.g. `management.metrics.enable.jvm=false`). - `management.metrics.tags.<key>=<value>` adds common tags declaratively. ## Gotchas - MeterFilters do NOT retroactively remove already-created series in the current process — apply them from the start. - Capping with `deny()` after max means late-appearing legitimate endpoints get dropped; prefer fixing the template. - `replaceTagValues` runs on every meter registration — keep it cheap. - Common-tag filters must be registered before the meters (Boot handles this for its own binders via `MeterRegistryCustomizer`). ## When to use what - First choice: **use URI templates** so the tag is bounded at the source. - Second: a targeted `MeterFilter.replaceTagValues` to normalize unavoidable dynamic segments. - Backstop: `maximumAllowableTags`/`max-uri-tags` so a bug can never take down your metrics backend. - Nuclear: `deny`/`management.metrics.enable.*` to drop noisy families entirely.
- Why does the server-side uri tag rarely explode but the client-side one does?Spring MVC/WebFlux knows the matched handler's route template, so the server observation convention tags with /users/{id} automatically. The client only records a template if you pass one; interpolating the id into the URL string yields a distinct uri per id. Use uri("/users/{id}", id).
- If a MeterFilter with maximumAllowableTags is added after some series already exist, what happens?MeterFilters aren't retroactive within a running process — series already registered stay. The cap only governs new registrations from that point. So filters must be configured at startup, and the durable fix is templating the URI at the source.
saying these in an interview costs you the question
- Believing MeterFilter retroactively deletes existing high-cardinality series
- Solving client cardinality by adding tags instead of using a URI template
- Confusing MeterFilter (registry-wide tag/meter transform) with per-request Observation conventions
- Thinking http.client.requests auto-templates any URL regardless of how the request was built