Собираем метрики с RestClient
Предположим у нас уже есть веб-приложение на Spring boot 4 с подключенным стартером spring-boot-starter-webmvc.
А значит у нас уже есть возможность добавить в наш контекст бин RestClient и использовать его в нашем приложении.
Сделать это можно, например, так:
@Bean
public RestClient restClient() {
return RestClient.builder().baseUrl("https://echo.free.beeceptor.com").build();
}
Теперь подключить его в наше приложения и писать бизнес логику можно, например, так:
@RestController
public final class TestController {
// импорты, поля и конструктор опущены для краткости
@GetMapping("/echo")
public EchoResponse echo(@RequestParam(required = false) String echo) {
if (echo == null) {
return restClient.get().uri("/sample-request").retrieve().body(EchoResponse.class);
}
return restClient.get().uri("/sample-request?echo=" + echo).retrieve().body(EchoResponse.class);
}
}
Теперь предположим что мы хотим мониторить как работает наш RestClient,
для мониторинга у нас уже есть Prometheus + Grafana.
И нам осталось только вытащить метрики с нашего клиента в соответствующий маршрут актуатора.
Чуда не произошло
Так как считаем что наше приложение уже мониторится, то появляется ощущение что тут должна сработать магия Spring,
и метрики клиента должны сразу появится по аналогии с метриками HTTP сервера.
Идем проверять и с огорчением обнаруживаем что чуда не произошло.
А все потому что мы создаем наш RestClient вручную, и Spring-контекст попросту о нем ничего не знает,
кроме того что это бин. И чтобы это исправить, для создания нашего бина RestClient нужно использовать
преднастроенный бин RestClient.Builder.
Чудо надо поднастроить
Для этого нам нужно подключить spring-boot-starter-restclient и немного переписать метод инициализации нашего бина:
@Bean
public RestClient restClient(RestClient.Builder restClientBuilder) {
return restClientBuilder.baseUrl("https://echo.free.beeceptor.com").build();
}
Теперь в наших метриках появляются долгожданные секции http_client_requests_
# HELP http_client_requests_active_seconds
# TYPE http_client_requests_active_seconds summary
http_client_requests_active_seconds_count{client_name="echo.free.beeceptor.com",exception="none",method="GET",outcome="UNKNOWN",status="CLIENT_ERROR",uri="/sample-request"} 0
http_client_requests_active_seconds_sum{client_name="echo.free.beeceptor.com",exception="none",method="GET",outcome="UNKNOWN",status="CLIENT_ERROR",uri="/sample-request"} 0.0
# HELP http_client_requests_active_seconds_max
# TYPE http_client_requests_active_seconds_max gauge
http_client_requests_active_seconds_max{client_name="echo.free.beeceptor.com",exception="none",method="GET",outcome="UNKNOWN",status="CLIENT_ERROR",uri="/sample-request"} 0.0
# HELP http_client_requests_seconds
# TYPE http_client_requests_seconds summary
http_client_requests_seconds_count{client_name="echo.free.beeceptor.com",error="none",exception="none",method="GET",outcome="SUCCESS",status="200",uri="/sample-request"} 1
http_client_requests_seconds_sum{client_name="echo.free.beeceptor.com",error="none",exception="none",method="GET",outcome="SUCCESS",status="200",uri="/sample-request"} 0.917163708
# HELP http_client_requests_seconds_max
# TYPE http_client_requests_seconds_max gauge
http_client_requests_seconds_max{client_name="echo.free.beeceptor.com",error="none",exception="none",method="GET",outcome="SUCCESS",status="200",uri="/sample-request"} 0.917163708
Победа, но не совсем
Теперь представим что у нас в приложении используется несколько клиентов RestClient.
При этом мы не хотим делать их частью Spring-контекста и объявлять как бины,
а в место этого сделать их частью других бинов.
Так же мы хотим сделать настройку наших клиентов максимально гибкой
используя файлы конфигурации (properties или yaml), которые можно будет смаппить на объект
public final class HttpClientProperties {
private String baseUrl;
private boolean logRequests;
private Duration connectTimeout;
private Auth auth;
private Proxy proxy;
private ClientCredentials clientCredentials;
public record Auth(AuthType type, Token token, String apikeyPrefix) {
public enum AuthType {
NONE,
BASIC,
PASSWORD,
TOKEN,
CLIENT_CREDENTIALS,
APIKEY
}
}
public record ClientCredentials(String username, String password) {
}
public record Proxy(boolean enabled, String host, Integer port, String username, String password) {
}
public record Token(String header, String prefix) {
}
}
И в зависимости от того как этот объект заполняется через файл конфигурации могут использоваться разные реализации
ClientHttpRequestFactory. При этом для конфигурации у нас будет использоваться отдельный компонент.
Клиент же будет конфигурироваться непосредственно в конструкторе сервиса примерно так:
@Service
public class RestService {
private final RestClient restClient;
public RestService(HttpClientProperties props, HttpClientConfigurer httpClientConfigurer) {
this.restClient = httpClientConfigurer.buildClient(props);
}
}
И если наш бин HttpClientConfigurer это стандартный для Spring boot синглтон, тут возникает проблема -
мы не можем использовать один экземпляр RestClient.Builder для настройки наших клиентов.
И соответственно не можем переиспользовать один и тот же бин RestClient.Builder
добавив его в поле нашего HttpClientConfigurer.
Варианты решения
Инжектим бины RestClient.Builder в конструкторы сервисов
После чего также в конструкторе инициализируем наш клиент, но уже передав в HttpClientConfigurer вместе с пропсами
еще и бин RestClient.Builder. Это будет работать потому что бин RestClient.Builder это прототип,
и соответственно в каждую точку инъекции будет попадать новый экземпляр билдера.
Код инициализации нашего клиента в конструкторе сервиса теперь будет выглядеть так:
@Service
public class RestService {
private final RestClient restClient;
public RestService(HttpClientProperties props,
HttpClientConfigurer httpClientConfigurer,
RestClient.Builder clientBuilder) {
this.restClient = httpClientConfigurer.buildClient(clientBuilder, props);
}
}
Инициализируем и настраиваем билдер вручную
Этот вариант позволяет нам отказаться от использования бина RestClient.Builder,
вместо этого мы можем скопировать часть конфигурации, которая отвечает за настройку метрик клиента.
За это отвечает ObservationRestClientCustomizer, который доступен в spring-boot-starter-restclient.
В конструкторе нашего HttpClientConfigurer инициализируем наш ObservationRestClientCustomizer,
для чего нам понадобиться бин ObservationRegistry.
@Component
public final class HttpClientConfigurer {
// импорты и другие поля опущены для краткости
private final ObservationRestClientCustomizer observationRestClientCustomizer;
public HttpClientConfigurer(ObservationRegistry observationRegistry) {
this.observationRestClientCustomizer =
new ObservationRestClientCustomizer(observationRegistry,
new DefaultClientRequestObservationConvention());
}
}
После чего мы можем использовать ObservationRestClientCustomizer
для настройки всех наших клиентов в методе buildClient():
public RestClient buildClient(HttpClientProperties props) {
try {
// создаем билдер вручную
RestClient.Builder builder = RestClient.builder();
// настраиваем его для вывода метрик
observationRestClientCustomizer.customize(builder);
// проводим всю остальную конфигурацию
builder.requestFactory(createRequestFactory(props));
configureDefaultHeaders(builder);
configureAuth(builder, props);
configureBaseUrl(builder, props.getBaseUrl());
configureLogger(builder, props);
// отдаем готовый клиент
return builder.build();
} catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) {
throw new IllegalStateException("Failed to initialize RestClient for " + props.getBaseUrl(), e);
}
}
Вернемся к метрикам
Казалось бы все хорошо, наш клиент настроен и метрики собираются и отдаются.
Но есть нюанс, часть метрик выглядит странно.
У некоторых метрик отсутствует uri, точнее значение тэга установлено в none:
http_client_requests_seconds_count{client_name="echo.free.beeceptor.com",error="none",exception="none",method="GET",outcome="SUCCESS",status="200",uri="/sample-request"} 1
http_client_requests_seconds_count{client_name="echo.free.beeceptor.com",error="none",exception="none",method="GET",outcome="SUCCESS",status="200",uri="none"} 1
Происходит это потому что начиная с версии Spring boot 3 изменился механизм извлечения шаблона URI из запросов
клиента когда для формирования URI используется UriBuilder. Теперь если использовать вот такой вариант конфигурации:
restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/sample-request")
.queryParam("echo", echo)
.build())
.retrieve()
// остальное опущено для краткости
Оно по прежнему будет работать и выполнять запросы, но URI для метрик извлекаться не будет.
Правильный вариант использования UriBuilder теперь выглядит так:
restClient.get()
.uri("/sample-request",
uriBuilder -> uriBuilder
.queryParam("echo", echo)
.build())
.retrieve()
// остальное опущено для краткости
А для случая когда переменные передаются в uri:
restClient.get()
.uri("/sample-request/{someId}",
uriBuilder -> uriBuilder
.queryParam("echo", echo)
.build(123))
.retrieve()
// остальное опущено для краткости
Итого
Важно не только правильно сконфигурировать клиент для фиксации метрик, но и использовать правильный механизм для формирования запросов, чтобы метрики полноценно заполнялись.