Spring Boot: 3.1.5 Spring Framework: 6.0.13

In Spring 3 micrometer integration has been reworked to use Micrometer 1.10 Observation. It works in the default setup but in case there is a filter (ExchangeFilterFunction) that changes the underlining request, metric is reported with incorrect tags.

It happens because current implementation is initializing ClientRequestObservationContext using the original ClientRequest

https://github.com/spring-projects/spring-framework/blob/e12269ef1ac332e192a02ac58eab8e6b62f3b7f3/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java#L455

and any changes to the request after this point are not visible to the observation context https://github.com/spring-projects/spring-framework/blob/e12269ef1ac332e192a02ac58eab8e6b62f3b7f3/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientRequestObservationConvention.java#L92

Similar scenario was supported in the previous version by adding custom filters before MetricsWebClientFilterFunction that reported the same metric before.

Here is a test to reproduce the above issue. In this test original request uses POST http method but filter is changing is to GET. The metric is still reported with the original POST method.

iterable contents differ at index [3], expected: <tag(method=GET)> but was: <tag(method=POST)>
Expected :tag(method=GET)
Actual   :tag(method=POST)
@SpringBootTest(
        properties = {"management.metrics.tags.service-name=test"}
)
class WebClientObservationTest {
    @Autowired
    private WebClient.Builder webClientBuilder;

    @Autowired
    private MeterRegistry meterRegistry;

    @Test
    void httpClientRequestsMetrics() {
        var webClient = webClientBuilder
                .filter(new ClientRequestExchangeFilter())
                .build();

        var req = webClient.post()
                .uri("http://api.zippopotam.us/us/{zip}", 98121)
                .retrieve()
                .bodyToMono(String.class);

        StepVerifier.create(req)
                .assertNext(res -> {
                    assertNotNull(res);
                })
                .verifyComplete();

        Meter meter = meterRegistry.find("http.client.requests")
                .meter();
        assertNotNull(meter);

        List<Tag> expectedTags = Arrays.asList(
                Tag.of("client.name", "api.zippopotam.us"),
                Tag.of("error", "none"),
                Tag.of("exception", "none"),
                Tag.of("method", "GET"),
                Tag.of("outcome", "SUCCESS"),
                Tag.of("service-name", "test"),
                Tag.of("status", "200"),
                Tag.of("uri", "/us/{zip}")
        );
        assertIterableEquals(expectedTags, meter.getId().getTags());
    }

    static class ClientRequestExchangeFilter implements ExchangeFilterFunction {
        @Override
        public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
            ClientRequest newRequest = ClientRequest.from(request)
                    .method(HttpMethod.GET)
                    .build();
            return next.exchange(newRequest);
        }
    }

    @TestConfiguration
    public static class MockConfiguration {
        @Bean
        public MeterRegistry simpleMeterRegistry() {
            return new SimpleMeterRegistry();
        }
    }
}

Comment From: bclozel

Thanks for reaching out.

I realize that this provides less flexibility than the former MetricsWebClientFilterFunction, but this instrumentation is now built-in and this fixes numerous issues we had in the past.

Now we could consider moving the instrumentation after the ExchangeFilterFunction processing, but this would cause other problems:

  • the processing time of filter functions would not be measured - this time can be often not negligible for the application
  • filter functions can perform error handling operations and retries transparently for the user. This time should be measured for the metric
  • filter functions can do logging and send authentication requests to fetch tokens; the observation should be active at that point otherwise this won't be part of the global trace.
  • the instrumentation here is meant to be applied at the client level, not the underlying HTTP library. In this context I think that the request given to the client better expresses the intent (even if the request can be later mutated or if multiple requests can happen).

This instrumentation is similar to the current arrangement in RestTemplate since the processing of ClientHttpRequestInterceptor is included in the observation. We've already improved the situation there in #30247 by ensuring that the observation wraps all functions and still get the response result.

In short, I don't know how to make this work without breaking those assumptions or somehow going back to a more complex setup where the observation belongs to a separate filter function that you need to configure and order yourself relative to other filters.

Am I missing something?

Comment From: amrynsky

@bclozel I agree with the above points, but the limitation here is that observation context for the request is immutable and, as result, it's impossible to use changes made by filters in reported metrics.

I'm still trying to understand the new observation lifecycle but here are several scenarios from our system that are not working after migration: - use http request headers added by filters as tags in metrics - use key/values from the reactive context as tags in metrics - add attributes to the WebClient request using filters and then use as tags in metrics

From what I see https://github.com/spring-projects/spring-framework/blob/e12269ef1ac332e192a02ac58eab8e6b62f3b7f3/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultClientRequestObservationConvention.java#L96 is called after filters (observation.stop()) but it relies on the request that was initialized before. Probably we can update request in the ObservationFilterFunction together with response in ClientRequestObservationConvention.

Comment From: bclozel

I'm still trying to understand the new observation lifecycle but here are several scenarios from our system that are not working after migration

Reading the use cases you've described, I think it would make more sense to get the current observation from your filter function and then add keyvalues directly to it. You should get it from the reactor Context or better, from a request attribute.

I see we're not exposing the current observation right now to the filter functions and I would consider this a bug.

Ideally, something like this:

class CustomFilterFunction implements ExchangeFilterFunction {

    @Override
    public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
        ClientRequestObservationContext.findCurrent(request)
                .ifPresent(context -> context.addLowCardinalityKeyValue(KeyValue.of("key", "value")));
        return return next.exchange(request);
    }
}

is called after filters (observation.stop()) but it relies on the request that was initialized before.

It's not entirely true. The lifecycle is a bit more complex. Micrometer uses io.micrometer.observation.ObservationHandler implementations to bridge from the Observation API to the metrics and traces. For metrics, the timer is started at the beginning and the keyvalues are collected on stop. But for tracing, the keyvalues can be collected early and we also add a tracing header to the original request. In short, even if we change the request mid-flight, it might not affect the tracing information. This is why we need to set the request in the observation context as early as possible - without that, we cannot add the tracing information to it.

Let me know what you think about this.

Comment From: amrynsky

Getting access to the current observation context from the filter is a good idea and definitely make sense, but adding metrics directly to the context will create unnecessary coupling. Currently context is just request/response holder and has no knowledge on the underlining metrics. In addition, in our case, we adding an additional tag to the built-in metric http.client.requests . To do it we just define custom ObservationConvention that extends DefaultClientRequestObservationConvention

       @Override
    public KeyValues getLowCardinalityKeyValues(ClientRequestObservationContext context) {
        return super.getLowCardinalityKeyValues(context)
                .and(
                        fromHeader(context.getRequest(), HTTP_HEADER_TENANT, "tenant")
                );
    }

    private static KeyValue fromHeader(ClientRequest request, String header, String key) {
        if (request == null) {
            return KeyValue.of(key, KeyValue.NONE_VALUE);
        }

        return Optional.ofNullable(request.headers().getFirst(header))
                .map(value -> KeyValue.of(key, value))
                .orElse(KeyValue.of(key, KeyValue.NONE_VALUE));
    }

Basically we just need to pass some addition context from the filter to the ObservationConvention. Something like this should work

static class ClientRequestExchangeFilter implements ExchangeFilterFunction {
    @Override
    public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
        return Mono.deferContextual(context -> {
            ClientRequestObservationContext.findCurrent(request)
                    .ifPresent(observation -> observation.attributes().put("tenant", context.get("tenant")));

            return next.exchange(request);
        });
    }
}

and then access attributes() downstream (similar to WebClient attributes).

Another option is to update the request in the ObservationFilterFunction https://github.com/spring-projects/spring-framework/blob/7b14606d92bfe36fea048137e794cfff18e9e42a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/DefaultWebClient.java#L746C43-L746C43

@Override
public Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next) {
    return next.exchange(request)
            .doOnNext(response -> {
                observationContext.setRequest(request);
                observationContext.setResponse(response);
            });
}

Comment From: bclozel

Getting access to the current observation context from the filter is a good idea and definitely make sense, but adding metrics directly to the context will create unnecessary coupling. Currently context is just request/response holder and has no knowledge on the underlining metrics. In addition, in our case, we adding an additional tag to the built-in metric http.client.requests.

ClientRequestObservationContext extends Observation.Context, so you are in fact coupled to low and high cardinality KeyValues. The Observation context is never coupled to the metrics themselves as those are extracted from the context by Observation handlers.

Basically we just need to pass some addition context from the filter to the ObservationConvention. Something like this should work

There is no attributes method on the observation itself, this is really the goal of the Observation.Context. If this is an acceptable solution to you, why not use the ClientRequest#attributes in the first place? The request is itself immutable, but the attributes are not.

Another option is to update the request in the ObservationFilterFunction

I think we should eliminate this solution completely. We've seen how this can lead to inconsistent observations, leading to metrics being very different from traces. Implementations can do that through the context if they chose so, but I don't think we should make this easier and lead developers to this situation.

At this stage, I'm considering putting the ClientRequestObservationContext as a request attribute and/or putting the current observation in the reactor context. Those are acceptable changes but I'm not sure they're actually required for your use case since a request attribute might be enough.

Comment From: amrynsky

There is no attributes method on the observation itself, this is really the goal of the Observation.Context.

I know that attributes is not supported on the observation context. It was just a proposal following suggestion to get current context from the filter

If this is an acceptable solution to you, why not use the ClientRequest#attributes in the first place? The request is itself immutable, but the attributes are not.

This is exactly the issue. ClientRequest represents a typed, immutable, client-side HTTP request and, as far as I see, we can't add any additional state to it without cloning the original request ClientRequest newRequest = ClientRequest.from(request) in filter. Just tried and underlining map is UnmodifiableMap

Suppressed: java.lang.UnsupportedOperationException
        at java.base/java.util.Collections$UnmodifiableMap.put(Collections.java:1505)
        at com.example.demo.WebClientObservationTest$ClientRequestExchangeFilter.filter(WebClientObservationTest.java:72)

Comment From: bclozel

Right, I forgot that the attributes map was also made immutable. We'll add the client Observation.Context as a request attribute then, this is the most sensible and straightforward solution.

Comment From: amrynsky

@bclozel thx for the update but still not sure how to pass additional state to ClientRequestObservationContext. do you plan to add custom observation KeyValues in a separate PR?

Comment From: bclozel

No this change should be enough - you can add key values like this: https://github.com/spring-projects/spring-framework/issues/31609#issuecomment-1814111143

Comment From: amrynsky

let me check what is the lifecycle of the lowCardinalityKeyValue. The original question was how to add additional tags to the built-in http.client.requests metric and not how to create a new one

Comment From: bclozel

I have described the lifecycle in https://github.com/spring-projects/spring-framework/issues/31609#issuecomment-1814111143

Adding low cardinality KeyValues to the observation will add tags to the metric.

Comment From: bclozel

Unfortunately we had to remove this feature in #32198 because it was linked with reported memory leaks.