Beware of using spring WebClient(Episode5)

Metric

We have to know that It is different between WebClient level metric and reactor.netty level metric which is implementation of WebClient.

At first, WebClient level metric is made by DefaultWebClientExchangeTagsProvider and is tagged in units of uri template.

public class DefaultWebClientExchangeTagsProvider 
  implements WebClientExchangeTagsProvider {

  @Override
  public Iterable<Tag> tags(
    ClientRequest request,
    ClientResponse response,
    Throwable throwable
  ) {
    Tag method = WebClientExchangeTags.method(request);
    Tag uri = WebClientExchangeTags.uri(request);
    Tag clientName = WebClientExchangeTags.clientName(request);
    Tag status = WebClientExchangeTags.status(response, throwable);
    Tag outcome = WebClientExchangeTags.outcome(response);
    return Arrays.asList(method, uri, clientName, status, outcome);
  }
}

Next, We can find reactor.netty level metric information in this link.

1. reactor.netty metric issue

Let's make ClientHttpConnector implementation for making WebClient.

/**
 * Configure the {@link ClientHttpConnector} to use. This is useful for
 * plugging in and/or customizing options of the underlying HTTP client
 * library (e.g. SSL).
 * <p>By default this is set to
 * {@link org.springframework.http.client.reactive.ReactorClientHttpConnector
 * ReactorClientHttpConnector}.
 * @param connector the connector to use
 */
Builder clientConnector(ClientHttpConnector connector);

We can make like this.

new ReactorClientHttpConnector(
    reactorResourceFactory,
    httpClient -> httpClient
        .metrics(true, uriTagValue)
        ...

However, Suppose that the uriTagValue value is changed according to the path parameter. ex) /update/user/1, update/user/2, update/user/3 When the userid value is received as a path parameter, a uri tag is generated for each parameter. Therefore, the amount of metric increases abnormally fast, resulting in memory and CPU overhead(document).

cf ) query string is not involved with this issue.

To solve this problem, we can first make a filter that limit on the number of tag generation.

@Bean
MeterRegistryCustomizer<MeterRegistry> metricCustomizer() {
  return registry -> registry.config()
    .meterFilter(
      MeterFilter.maximumAllowableTags(HTTP_CLIENT_PREFIX, URI, 100, MeterFilter.deny())
    );
}

But it is not a fundamental solution to the problem. The document guides you to template using uriTagValue as below code.

Metrics.globalRegistry 
  .config()
  .meterFilter(MeterFilter.maximumAllowableTags("reactor.netty.http.client", "URI", 100, MeterFilter.deny()));

HttpClient client =
  HttpClient.create()
    .metrics(true, s -> {
      if (s.startsWith("/stream/")) { 
        return "/stream/{n}";
      }
      else if (s.startsWith("/bytes/")) {
        return "/bytes/{n}";
      }
      return s;
    });              
...

{n} doesn't have an actual value, it just tells us in brackets that it's a path parameter. However, Our team judged that it is easy to make mistakes to change every problematic API like this. And meaningful information in reactor.netty metric is low level information such as connection time and active/idle/pending connection so we decided to unify the uri tag to "/".

new ReactorClientHttpConnector(
    reactorResourceFactory,
    httpClient -> httpClient
        .metrics(true, uri -> "/")
        ...

2. WebClient uri metric issue

When calling .uri() in WebClient, if the uriTemplate is passed as a parameter, it becomes template.

@Override
public RequestBodySpec uri(String uriTemplate, Function<UriBuilder, URI> uriFunction) {
  attribute(URI_TEMPLATE_ATTRIBUTE, uriTemplate);
  return uri(uriFunction.apply(uriBuilderFactory.uriString(uriTemplate)));
}

However, if only the uriFunction is delivered, it is not templating. The actual calling uri is put into the tag.

@Override
public RequestBodySpec uri(Function<UriBuilder, URI> uriFunction) {
  return uri(uriFunction.apply(uriBuilderFactory.builder()));
}

Here, the uri explosion issue may occur. To prevent that, we can use uriTemplate, but our team used a different method. First, a new attribute was added to the webClient as below code.

return this.webClient.get()
  .uri("/api")
  .attribute(METHOD_ATTRIBUTE_NAME, XXXApiClient.class.getSimpleName() + "xxxMethodName")
  ...

And we implemented a new WebClientExchangeTagsProvider. Name is MyWebClientExchangeTagsProvider. Next, We made a tag using the newly created attribute above and erased the existing uri tag.

// config file
public static final String METHOD_ATTRIBUTE_NAME = "method";

@Bean
WebClientExchangeTagsProvider myWebClientExchangeTagsProvider() {
    return new MyWebClientExchangeTagsProvider();
}

private static class MyWebClientExchangeTagsProvider implements WebClientExchangeTagsProvider {
  @Override
  public Iterable<Tag> tags(ClientRequest request, ClientResponse response, Throwable throwable) {

    // We kept the existing code intact.
    Tag method = WebClientExchangeTags.method(request);
    Tag clientName = WebClientExchangeTags.clientName(request);
    Tag status = WebClientExchangeTags.status(response, throwable);
    Tag outcome = WebClientExchangeTags.outcome(response);

    // remove the uri tag and make a new tag
    Tag methodAttributeTag = request.attribute(METHOD_ATTRIBUTE_NAME)
      .map(object -> Tag.of(METHOD_ATTRIBUTE_NAME, object.toString()))
      .orElse(Tag.of(METHOD_ATTRIBUTE_NAME, "nothing"));

    return Arrays.asList(method, clientName, status, outcome, methodAttributeTag);
  }
}