diff --git a/docs/src/main/asciidoc/spring-cloud-commons.adoc b/docs/src/main/asciidoc/spring-cloud-commons.adoc index 7dc718995..64eaed379 100644 --- a/docs/src/main/asciidoc/spring-cloud-commons.adoc +++ b/docs/src/main/asciidoc/spring-cloud-commons.adoc @@ -1118,15 +1118,38 @@ public class MyConfiguration { One type of bean that it may be useful to register using <> is `LoadBalancerLifecycle`. -The LoadBalancerLifecycle beans provide callback methods, named `onStart(Request request)` and `onComplete(CompletionContext completionContext)`, that you should implement to specify what actions should take place before and after load-balancing. +The `LoadBalancerLifecycle` beans provide callback methods, named `onStart(Request request)`, `onStartRequest(Request request, Response lbResponse)` and `onComplete(CompletionContext completionContext)`, that you should implement to specify what actions should take place before and after load-balancing. -`onStart(Request request)` takes a `Request` object as a parameter. It contains data that is used to select an appropriate instance, including the downstream client request and <>. On the other hand, a `CompletionContext` object is provided to the `onComplete(CompletionContext completionContext)` method. It contains the LoadBalancer `Response`, including the selected service instance, the `Status` of the request executed against that service instance and (if available) the response returned to the downstream client, and (if an exception has occurred) the corresponding `Throwable`. +`onStart(Request request)` takes a `Request` object as a parameter. It contains data that is used to select an appropriate instance, including the downstream client request and <>. `onStartRequest` also takes the `Request` object and, additionally, the `Response` object as parameters. On the other hand, a `CompletionContext` object is provided to the `onComplete(CompletionContext completionContext)` method. It contains the LoadBalancer `Response`, including the selected service instance, the `Status` of the request executed against that service instance and (if available) the response returned to the downstream client, and (if an exception has occurred) the corresponding `Throwable`. The `supports(Class requestContextClass, Class responseClass, Class serverTypeClass)` method can be used to determine whether the processor in question handles objects of provided types. If not overridden by the user, it returns `true`. NOTE: In the preceding method calls, `RC` means `RequestContext` type, `RES` means client response type, and `T` means returned server type. +[[loadbalancer-micrometer-stats-lifecycle]] +=== Spring Cloud LoadBalancer Statistics + +We provide a `LoadBalancerLifecycle` bean called `MicrometerStatsLoadBalancerLifecycle`, which uses Micrometer to provide statistics for load-balanced calls. + +In order to get this bean added to your application context, +set the value of the `spring.cloud.loadbalancer.stats.micrometer.enabled` to `true` and have a `MeterRegistry` available (for example, by adding https://docs.spring.io/spring-boot/docs/current/reference/html/production-ready-features.html[Spring Boot Actuator] to your project). + +`MicrometerStatsLoadBalancerLifecycle` registers the following meters in `MeterRegistry`: + +* `loadbalancer.requests.active`: A gauge that allows you to monitor the number of currently active requests for any service instance (service instance data available via tags); +* `loadbalancer.requests.success`: A timer that measures the time of execution of any load-balanced requests that have ended in passing a response on to the underlying client; +* `loadbalancer.requests.failed`: A timer that measures the time of execution of any load-balanced requests that have ended with an exception; +* `loadbalancer.requests.discard`: A counter that measures the number of discarded load-balanced requests, i.e. requests where a service instance to run the request on has not been retrieved by the LoadBalancer. + +Additional information regarding the service instances, request data, and response data is added to metrics via tags whenever available. + +NOTE: For some implementations, such as `BlockingLoadBalancerClient`, request and response data might not be available, as we establish generic types from arguments and might not be able to determine the types and read the data. + +NOTE: The meters are registered in the registry when at least one record is added for a given meter. + +TIP: You can further configure the behavior of those metrics (for example, add https://micrometer.io/docs/concepts#_histograms_and_percentiles[publishing percentiles and histograms]) by https://docs.spring.io/spring-boot/docs/current/reference/html/production-ready-features.html#production-ready-metrics-per-meter-properties[adding `MeterFilters`]. + == Spring Cloud Circuit Breaker include::spring-cloud-circuitbreaker.adoc[leveloffset=+1] diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/AsyncLoadBalancerInterceptor.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/AsyncLoadBalancerInterceptor.java index 2be38a1f0..6d00965df 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/AsyncLoadBalancerInterceptor.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/AsyncLoadBalancerInterceptor.java @@ -19,7 +19,6 @@ import java.io.IOException; import java.net.URI; -import org.springframework.cloud.client.ServiceInstance; import org.springframework.http.HttpRequest; import org.springframework.http.client.AsyncClientHttpRequestExecution; import org.springframework.http.client.AsyncClientHttpRequestInterceptor; @@ -42,14 +41,10 @@ public ListenableFuture intercept(final HttpRequest request, final AsyncClientHttpRequestExecution execution) throws IOException { final URI originalUri = request.getURI(); String serviceName = originalUri.getHost(); - return this.loadBalancer.execute(serviceName, new LoadBalancerRequest>() { - @Override - public ListenableFuture apply(final ServiceInstance instance) throws Exception { - HttpRequest serviceRequest = new ServiceRequestWrapper(request, instance, - AsyncLoadBalancerInterceptor.this.loadBalancer); - return execution.executeAsync(serviceRequest, body); - } - + return this.loadBalancer.execute(serviceName, instance -> { + HttpRequest serviceRequest = new ServiceRequestWrapper(request, instance, + AsyncLoadBalancerInterceptor.this.loadBalancer); + return execution.executeAsync(serviceRequest, body); }); } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/CompletionContext.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/CompletionContext.java index 975e63a1e..dfbdb7c36 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/CompletionContext.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/CompletionContext.java @@ -25,7 +25,7 @@ * @author Olga Maciaszek-Sharma * @since 3.0.0 */ -public class CompletionContext { +public class CompletionContext { private final Status status; @@ -35,25 +35,31 @@ public class CompletionContext { private final RES clientResponse; - public CompletionContext(Status status) { - this(status, null, null, null); + private final Request loadBalancerRequest; + + public CompletionContext(Status status, Request loadBalancerRequest) { + this(status, null, loadBalancerRequest, null, null); } - public CompletionContext(Status status, Response response) { - this(status, null, response, null); + public CompletionContext(Status status, Request loadBalancerRequest, Response response) { + this(status, null, loadBalancerRequest, response, null); } - public CompletionContext(Status status, Throwable throwable, Response loadBalancerResponse) { - this(status, throwable, loadBalancerResponse, null); + public CompletionContext(Status status, Throwable throwable, Request loadBalancerRequest, + Response loadBalancerResponse) { + this(status, throwable, loadBalancerRequest, loadBalancerResponse, null); } - public CompletionContext(Status status, Response loadBalancerResponse, RES clientResponse) { - this(status, null, loadBalancerResponse, clientResponse); + public CompletionContext(Status status, Request loadBalancerRequest, Response loadBalancerResponse, + RES clientResponse) { + this(status, null, loadBalancerRequest, loadBalancerResponse, clientResponse); } - public CompletionContext(Status status, Throwable throwable, Response loadBalancerResponse, RES clientResponse) { + public CompletionContext(Status status, Throwable throwable, Request loadBalancerRequest, + Response loadBalancerResponse, RES clientResponse) { this.status = status; this.throwable = throwable; + this.loadBalancerRequest = loadBalancerRequest; this.loadBalancerResponse = loadBalancerResponse; this.clientResponse = clientResponse; } @@ -74,6 +80,10 @@ public RES getClientResponse() { return clientResponse; } + public Request getLoadBalancerRequest() { + return loadBalancerRequest; + } + @Override public String toString() { ToStringCreator to = new ToStringCreator(this); diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/HintRequestContext.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/HintRequestContext.java index 0ef6b1cc1..df02d9d0a 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/HintRequestContext.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/HintRequestContext.java @@ -25,7 +25,7 @@ * * @author Olga Maciaszek-Sharma */ -public class HintRequestContext { +public class HintRequestContext implements TimedRequestContext { /** * A {@link String} value of hint that can be used to choose the correct service @@ -33,6 +33,8 @@ public class HintRequestContext { */ private String hint = "default"; + private long requestStartTime; + public HintRequestContext() { } @@ -48,6 +50,16 @@ public void setHint(String hint) { this.hint = hint; } + @Override + public long getRequestStartTime() { + return requestStartTime; + } + + @Override + public void setRequestStartTime(long requestStartTime) { + this.requestStartTime = requestStartTime; + } + @Override public String toString() { ToStringCreator to = new ToStringCreator(this); diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerLifecycle.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerLifecycle.java index 4db50ed60..4ba9240b5 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerLifecycle.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerLifecycle.java @@ -45,11 +45,20 @@ default boolean supports(Class requestContextClass, Class responseClass, Class s */ void onStart(Request request); + /** + * A callback method executed after a service instance has been selected, before + * executing the actual load-balanced request. + * @param request the {@link Request} that has been used by the LoadBalancer to select + * a service instance + * @param lbResponse the {@link Response} returned by the LoadBalancer + */ + void onStartRequest(Request request, Response lbResponse); + /** * A callback method executed after load-balancing. * @param completionContext the {@link CompletionContext} containing data relevant to * the load-balancing and the response returned from the selected service instance */ - void onComplete(CompletionContext completionContext); + void onComplete(CompletionContext completionContext); } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerRequestAdapter.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerRequestAdapter.java new file mode 100644 index 000000000..038ffa759 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/LoadBalancerRequestAdapter.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.client.loadbalancer; + +import org.springframework.cloud.client.ServiceInstance; + +/** + * An adapter class that allows creating {@link Request} objects from previously + * {@link LoadBalancerRequest} objects. + * + * @author Olga Maciaszek-Sharma + * @since 3.0.0 + */ +public class LoadBalancerRequestAdapter extends DefaultRequest implements LoadBalancerRequest { + + private final LoadBalancerRequest delegate; + + public LoadBalancerRequestAdapter(LoadBalancerRequest delegate) { + this.delegate = delegate; + } + + public LoadBalancerRequestAdapter(LoadBalancerRequest delegate, RC context) { + super(context); + this.delegate = delegate; + } + + @Override + public T apply(ServiceInstance instance) throws Exception { + return delegate.apply(instance); + } + +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/RequestDataContext.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/RequestDataContext.java index 003aa9f67..6bb23deee 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/RequestDataContext.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/RequestDataContext.java @@ -26,6 +26,10 @@ */ public class RequestDataContext extends DefaultRequestContext { + public RequestDataContext() { + super(); + } + public RequestDataContext(RequestData requestData) { this(requestData, "default"); } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/ResponseData.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/ResponseData.java index f6583cf6e..740685e4f 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/ResponseData.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/ResponseData.java @@ -16,13 +16,18 @@ package org.springframework.cloud.client.loadbalancer; +import java.io.IOException; +import java.util.Collections; +import java.util.List; import java.util.Objects; import org.springframework.core.style.ToStringCreator; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.client.ClientResponse; @@ -59,6 +64,11 @@ public ResponseData(ServerHttpResponse response, RequestData requestData) { this(response.getStatusCode(), response.getHeaders(), response.getCookies(), requestData); } + public ResponseData(ClientHttpResponse clientHttpResponse, RequestData requestData) throws IOException { + this(clientHttpResponse.getStatusCode(), clientHttpResponse.getHeaders(), + buildCookiesFromHeaders(clientHttpResponse.getHeaders()), requestData); + } + public HttpStatus getHttpStatus() { return httpStatus; } @@ -82,6 +92,30 @@ public String toString() { return to.toString(); } + static MultiValueMap buildCookiesFromHeaders(HttpHeaders headers) { + LinkedMultiValueMap newCookies = new LinkedMultiValueMap<>(); + if (headers == null) { + return newCookies; + } + List cookiesFromHeaders = headers.get(HttpHeaders.COOKIE); + if (cookiesFromHeaders != null) { + cookiesFromHeaders.forEach(cookie -> { + String[] splitCookie = cookie.split("="); + if (splitCookie.length < 2) { + return; + } + newCookies.put(splitCookie[0], + Collections.singletonList(ResponseCookie.from(splitCookie[0], splitCookie[1]).build())); + }); + } + return newCookies; + } + + @Override + public int hashCode() { + return Objects.hash(httpStatus, headers, cookies, requestData); + } + @Override public boolean equals(Object o) { if (this == o) { @@ -95,9 +129,4 @@ public boolean equals(Object o) { && Objects.equals(cookies, that.cookies) && Objects.equals(requestData, that.requestData); } - @Override - public int hashCode() { - return Objects.hash(httpStatus, headers, cookies, requestData); - } - } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/RetryLoadBalancerInterceptor.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/RetryLoadBalancerInterceptor.java index 56d267976..e56922b72 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/RetryLoadBalancerInterceptor.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/RetryLoadBalancerInterceptor.java @@ -89,7 +89,8 @@ public ClientHttpResponse intercept(final HttpRequest request, final byte[] body Set supportedLifecycleProcessors = LoadBalancerLifecycleValidator .getSupportedLifecycleProcessors( loadBalancerFactory.getInstances(serviceName, LoadBalancerLifecycle.class), - RequestDataContext.class, ResponseData.class, ServiceInstance.class); + RetryableRequestContext.class, ResponseData.class, ServiceInstance.class); + String hint = getHint(serviceName); if (serviceInstance == null) { if (LOG.isDebugEnabled()) { LOG.debug("Service instance retrieved from LoadBalancedRetryContext: was null. " @@ -100,7 +101,6 @@ public ClientHttpResponse intercept(final HttpRequest request, final byte[] body LoadBalancedRetryContext lbContext = (LoadBalancedRetryContext) context; previousServiceInstance = lbContext.getPreviousServiceInstance(); } - String hint = getHint(serviceName); DefaultRequest lbRequest = new DefaultRequest<>( new RetryableRequestContext(previousServiceInstance, new RequestData(request), hint)); supportedLifecycleProcessors.forEach(lifecycle -> lifecycle.onStart(lbRequest)); @@ -112,15 +112,22 @@ public ClientHttpResponse intercept(final HttpRequest request, final byte[] body LoadBalancedRetryContext lbContext = (LoadBalancedRetryContext) context; lbContext.setServiceInstance(serviceInstance); } + Response lbResponse = new DefaultResponse(serviceInstance); + if (serviceInstance == null) { + supportedLifecycleProcessors.forEach(lifecycle -> lifecycle + .onComplete(new CompletionContext( + CompletionContext.Status.DISCARD, + new DefaultRequest<>( + new RetryableRequestContext(null, new RequestData(request), hint)), + lbResponse))); + } } - Response lbResponse = new DefaultResponse(serviceInstance); - if (serviceInstance == null) { - supportedLifecycleProcessors - .forEach(lifecycle -> lifecycle.onComplete(new CompletionContext( - CompletionContext.Status.DISCARD, lbResponse))); - } + LoadBalancerRequestAdapter lbRequest = new LoadBalancerRequestAdapter<>( + requestFactory.createRequest(request, body, execution), + new RetryableRequestContext(null, new RequestData(request), hint)); + ServiceInstance finalServiceInstance = serviceInstance; ClientHttpResponse response = RetryLoadBalancerInterceptor.this.loadBalancer.execute(serviceName, - serviceInstance, requestFactory.createRequest(request, body, execution)); + finalServiceInstance, lbRequest); int statusCode = response.getRawStatusCode(); if (retryPolicy != null && retryPolicy.retryableStatusCode(statusCode)) { if (LOG.isDebugEnabled()) { diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/RetryableRequestContext.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/RetryableRequestContext.java index ef8b32ec3..a2ad0c0cb 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/RetryableRequestContext.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/RetryableRequestContext.java @@ -27,21 +27,22 @@ * * @author Olga Maciaszek-Sharma */ -public class RetryableRequestContext extends DefaultRequestContext { +public class RetryableRequestContext extends RequestDataContext { - private final ServiceInstance previousServiceInstance; + private ServiceInstance previousServiceInstance; public RetryableRequestContext(ServiceInstance previousServiceInstance) { this.previousServiceInstance = previousServiceInstance; } - public RetryableRequestContext(ServiceInstance previousServiceInstance, Object clientRequest) { - super(clientRequest); + public RetryableRequestContext(ServiceInstance previousServiceInstance, RequestData clientRequestData) { + super(clientRequestData); this.previousServiceInstance = previousServiceInstance; } - public RetryableRequestContext(ServiceInstance previousServiceInstance, Object clientRequest, String hint) { - super(clientRequest, hint); + public RetryableRequestContext(ServiceInstance previousServiceInstance, RequestData clientRequestData, + String hint) { + super(clientRequestData, hint); this.previousServiceInstance = previousServiceInstance; } @@ -49,6 +50,10 @@ public ServiceInstance getPreviousServiceInstance() { return previousServiceInstance; } + public void setPreviousServiceInstance(ServiceInstance previousServiceInstance) { + this.previousServiceInstance = previousServiceInstance; + } + @Override public String toString() { ToStringCreator to = new ToStringCreator(this); diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/TimedRequestContext.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/TimedRequestContext.java new file mode 100644 index 000000000..13f8eb90c --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/TimedRequestContext.java @@ -0,0 +1,31 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.client.loadbalancer; + +/** + * Allows setting and retrieving request start time. + * + * @author Olga Maciaszek-Sharma + * @since 3.0.0 + */ +public interface TimedRequestContext { + + long getRequestStartTime(); + + void setRequestStartTime(long requestStartTime); + +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunction.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunction.java index de703f933..256f0bb66 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunction.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunction.java @@ -94,7 +94,7 @@ public Mono filter(ClientRequest clientRequest, ExchangeFunction LOG.warn(message); } supportedLifecycleProcessors.forEach(lifecycle -> lifecycle - .onComplete(new CompletionContext<>(CompletionContext.Status.DISCARD, lbResponse))); + .onComplete(new CompletionContext<>(CompletionContext.Status.DISCARD, lbRequest, lbResponse))); return Mono.just(ClientResponse.create(HttpStatus.SERVICE_UNAVAILABLE) .body(serviceInstanceUnavailableMessage(serviceId)).build()); } @@ -107,13 +107,14 @@ public Mono filter(ClientRequest clientRequest, ExchangeFunction ClientRequest newRequest = buildClientRequest(clientRequest, instance, stickySessionProperties.getInstanceIdCookieName(), stickySessionProperties.isAddServiceInstanceCookie()); + supportedLifecycleProcessors.forEach(lifecycle -> lifecycle.onStartRequest(lbRequest, lbResponse)); return next.exchange(newRequest) - .doOnError(throwable -> supportedLifecycleProcessors.forEach( - lifecycle -> lifecycle.onComplete(new CompletionContext( - CompletionContext.Status.FAILED, throwable, lbResponse)))) + .doOnError(throwable -> supportedLifecycleProcessors.forEach(lifecycle -> lifecycle + .onComplete(new CompletionContext( + CompletionContext.Status.FAILED, throwable, lbRequest, lbResponse)))) .doOnSuccess(clientResponse -> supportedLifecycleProcessors.forEach( lifecycle -> lifecycle.onComplete(new CompletionContext<>(CompletionContext.Status.SUCCESS, - lbResponse, new ResponseData(clientResponse, requestData))))); + lbRequest, lbResponse, new ResponseData(clientResponse, requestData))))); }); } diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/RetryableLoadBalancerExchangeFilterFunction.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/RetryableLoadBalancerExchangeFilterFunction.java index aa5fb8803..4d4d7233e 100644 --- a/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/RetryableLoadBalancerExchangeFilterFunction.java +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/client/loadbalancer/reactive/RetryableLoadBalancerExchangeFilterFunction.java @@ -39,7 +39,6 @@ import org.springframework.cloud.client.loadbalancer.LoadBalancerProperties; import org.springframework.cloud.client.loadbalancer.Request; import org.springframework.cloud.client.loadbalancer.RequestData; -import org.springframework.cloud.client.loadbalancer.RequestDataContext; import org.springframework.cloud.client.loadbalancer.Response; import org.springframework.cloud.client.loadbalancer.ResponseData; import org.springframework.cloud.client.loadbalancer.RetryableRequestContext; @@ -102,7 +101,7 @@ public Mono filter(ClientRequest clientRequest, ExchangeFunction Set supportedLifecycleProcessors = LoadBalancerLifecycleValidator .getSupportedLifecycleProcessors( loadBalancerFactory.getInstances(serviceId, LoadBalancerLifecycle.class), - RequestDataContext.class, ResponseData.class, ServiceInstance.class); + RetryableRequestContext.class, ResponseData.class, ServiceInstance.class); String hint = getHint(serviceId, properties.getHint()); RequestData requestData = new RequestData(clientRequest); DefaultRequest lbRequest = new DefaultRequest<>( @@ -110,14 +109,15 @@ public Mono filter(ClientRequest clientRequest, ExchangeFunction supportedLifecycleProcessors.forEach(lifecycle -> lifecycle.onStart(lbRequest)); return Mono.defer(() -> choose(serviceId, lbRequest).flatMap(lbResponse -> { ServiceInstance instance = lbResponse.getServer(); - lbRequest.setContext(new RetryableRequestContext(instance, clientRequest, hint)); + lbRequest.setContext(new RetryableRequestContext(instance, requestData, hint)); if (instance == null) { String message = serviceInstanceUnavailableMessage(serviceId); if (LOG.isWarnEnabled()) { LOG.warn(message); } supportedLifecycleProcessors.forEach(lifecycle -> lifecycle - .onComplete(new CompletionContext<>(CompletionContext.Status.DISCARD, lbResponse))); + .onComplete(new CompletionContext( + CompletionContext.Status.DISCARD, lbRequest, lbResponse))); return Mono.just(ClientResponse.create(HttpStatus.SERVICE_UNAVAILABLE) .body(serviceInstanceUnavailableMessage(serviceId)).build()); } @@ -130,13 +130,14 @@ public Mono filter(ClientRequest clientRequest, ExchangeFunction ClientRequest newRequest = buildClientRequest(clientRequest, instance, stickySessionProperties.getInstanceIdCookieName(), stickySessionProperties.isAddServiceInstanceCookie()); + supportedLifecycleProcessors.forEach(lifecycle -> lifecycle.onStartRequest(lbRequest, lbResponse)); return next.exchange(newRequest) - .doOnError(throwable -> supportedLifecycleProcessors.forEach( - lifecycle -> lifecycle.onComplete(new CompletionContext( - CompletionContext.Status.FAILED, throwable, lbResponse)))) + .doOnError(throwable -> supportedLifecycleProcessors.forEach(lifecycle -> lifecycle + .onComplete(new CompletionContext( + CompletionContext.Status.FAILED, throwable, lbRequest, lbResponse)))) .doOnSuccess(clientResponse -> supportedLifecycleProcessors.forEach( lifecycle -> lifecycle.onComplete(new CompletionContext<>(CompletionContext.Status.SUCCESS, - lbResponse, new ResponseData(clientResponse, requestData))))) + lbRequest, lbResponse, new ResponseData(clientResponse, requestData))))) .map(clientResponse -> { loadBalancerRetryContext.setClientResponse(clientResponse); if (shouldRetrySameServiceInstance(loadBalancerRetryContext)) { diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/RetryLoadBalancerInterceptorTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/RetryLoadBalancerInterceptorTests.java index 55f906258..3a8126c9b 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/RetryLoadBalancerInterceptorTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/RetryLoadBalancerInterceptorTests.java @@ -373,16 +373,26 @@ public void shouldNotDuplicateLifecycleCalls() throws IOException, URISyntaxExce interceptor.intercept(request, new byte[] {}, mock(ClientHttpRequestExecution.class)); assertThat(((TestLoadBalancerLifecycle) lifecycleProcessors.get("testLifecycle")).getStartLog()).hasSize(1); + assertThat(((TestLoadBalancerLifecycle) lifecycleProcessors.get("testLifecycle")).getStartRequestLog()) + .hasSize(0); assertThat(((TestLoadBalancerLifecycle) lifecycleProcessors.get("testLifecycle")).getCompleteLog()).hasSize(0); assertThat(((TestLoadBalancerLifecycle) lifecycleProcessors.get("anotherLifecycle")).getStartLog()).hasSize(1); + assertThat(((TestLoadBalancerLifecycle) lifecycleProcessors.get("anotherLifecycle")).getStartRequestLog()) + .hasSize(0); assertThat(((TestLoadBalancerLifecycle) lifecycleProcessors.get("anotherLifecycle")).getCompleteLog()) .hasSize(0); assertThat(((TestLoadBalancerLifecycle) client.getLifecycleProcessors().get("testLifecycle")).getStartLog()) .hasSize(0); + assertThat( + ((TestLoadBalancerLifecycle) client.getLifecycleProcessors().get("testLifecycle")).getStartRequestLog()) + .hasSize(1); assertThat(((TestLoadBalancerLifecycle) client.getLifecycleProcessors().get("testLifecycle")).getCompleteLog()) .hasSize(1); assertThat(((TestLoadBalancerLifecycle) client.getLifecycleProcessors().get("anotherLifecycle")).getStartLog()) .hasSize(0); + assertThat( + ((TestLoadBalancerLifecycle) client.getLifecycleProcessors().get("testLifecycle")).getStartRequestLog()) + .hasSize(1); assertThat( ((TestLoadBalancerLifecycle) client.getLifecycleProcessors().get("anotherLifecycle")).getCompleteLog()) .hasSize(1); @@ -499,11 +509,12 @@ public T execute(String serviceId, ServiceInstance serviceInstance, LoadBala Set supportedLoadBalancerProcessors = LoadBalancerLifecycleValidator .getSupportedLifecycleProcessors(lifecycleProcessors, DefaultRequestContext.class, Object.class, ServiceInstance.class); - + supportedLoadBalancerProcessors.forEach(lifecycle -> lifecycle.onStartRequest(new DefaultRequest<>(), + new DefaultResponse(serviceInstance))); T response = (T) new MockClientHttpResponse(new byte[] {}, HttpStatus.OK); supportedLoadBalancerProcessors .forEach(lifecycle -> lifecycle.onComplete(new CompletionContext(CompletionContext.Status.SUCCESS, - new DefaultResponse(defaultServiceInstance())))); + new DefaultRequest<>(), new DefaultResponse(defaultServiceInstance())))); return response; } @@ -532,7 +543,9 @@ protected static class TestLoadBalancerLifecycle implements LoadBalancerLifecycl final ConcurrentHashMap> startLog = new ConcurrentHashMap<>(); - final ConcurrentHashMap> completeLog = new ConcurrentHashMap<>(); + final ConcurrentHashMap> startRequestLog = new ConcurrentHashMap<>(); + + final ConcurrentHashMap> completeLog = new ConcurrentHashMap<>(); @Override public boolean supports(Class requestContextClass, Class responseClass, Class serverTypeClass) { @@ -547,7 +560,12 @@ public void onStart(Request request) { } @Override - public void onComplete(CompletionContext completionContext) { + public void onStartRequest(Request request, Response lbResponse) { + startRequestLog.put(getName() + UUID.randomUUID(), request); + } + + @Override + public void onComplete(CompletionContext completionContext) { completeLog.put(getName() + UUID.randomUUID(), completionContext); } @@ -555,10 +573,14 @@ ConcurrentHashMap> getStartLog() { return startLog; } - ConcurrentHashMap> getCompleteLog() { + ConcurrentHashMap> getCompleteLog() { return completeLog; } + ConcurrentHashMap> getStartRequestLog() { + return startRequestLog; + } + protected String getName() { return getClass().getSimpleName(); } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunctionTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunctionTests.java index 4c40b88d9..5ddcf7a84 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunctionTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/ReactorLoadBalancerExchangeFilterFunctionTests.java @@ -43,6 +43,7 @@ import org.springframework.cloud.client.loadbalancer.LoadBalancerLifecycle; import org.springframework.cloud.client.loadbalancer.LoadBalancerProperties; import org.springframework.cloud.client.loadbalancer.Request; +import org.springframework.cloud.client.loadbalancer.Response; import org.springframework.cloud.client.loadbalancer.ResponseData; import org.springframework.context.annotation.Bean; import org.springframework.http.HttpStatus; @@ -128,19 +129,24 @@ void exceptionNotThrownWhenFactoryReturnsNullLifecycleProcessorsMap() { void loadBalancerLifecycleCallbacksExecuted() { final String callbackTestHint = "callbackTestHint"; loadBalancerProperties.getHint().put("testservice", "callbackTestHint"); - final String result = "callbackTestResult"; ClientResponse clientResponse = WebClient.builder().baseUrl("http://testservice").filter(loadBalancerFunction) .build().get().uri("/callback").exchange().block(); Collection> lifecycleLogRequests = ((TestLoadBalancerLifecycle) factory .getInstances("testservice", LoadBalancerLifecycle.class).get("loadBalancerLifecycle")).getStartLog() .values(); - Collection> anotherLifecycleLogRequests = ((AnotherLoadBalancerLifecycle) factory + Collection> lifecycleStartedLogRequests = ((TestLoadBalancerLifecycle) factory + .getInstances("testservice", LoadBalancerLifecycle.class).get("loadBalancerLifecycle")) + .getStartRequestLog().values(); + Collection> anotherLifecycleLogRequests = ((AnotherLoadBalancerLifecycle) factory .getInstances("testservice", LoadBalancerLifecycle.class).get("anotherLoadBalancerLifecycle")) .getCompleteLog().values(); then(clientResponse.statusCode()).isEqualTo(HttpStatus.OK); assertThat(lifecycleLogRequests).extracting(request -> ((DefaultRequestContext) request.getContext()).getHint()) .contains(callbackTestHint); + assertThat(lifecycleStartedLogRequests) + .extracting(request -> ((DefaultRequestContext) request.getContext()).getHint()) + .contains(callbackTestHint); assertThat(anotherLifecycleLogRequests) .extracting(completionContext -> ((ResponseData) completionContext.getClientResponse()).getRequestData() .getUrl().toString()) @@ -204,9 +210,11 @@ LoadBalancerProperties loadBalancerProperties() { protected static class TestLoadBalancerLifecycle implements LoadBalancerLifecycle { - ConcurrentHashMap> startLog = new ConcurrentHashMap<>(); + final Map> startLog = new ConcurrentHashMap<>(); + + final Map> startRequestLog = new ConcurrentHashMap<>(); - ConcurrentHashMap> completeLog = new ConcurrentHashMap<>(); + final Map> completeLog = new ConcurrentHashMap<>(); @Override public void onStart(Request request) { @@ -214,18 +222,27 @@ public void onStart(Request request) { } @Override - public void onComplete(CompletionContext completionContext) { + public void onStartRequest(Request request, Response lbResponse) { + startRequestLog.put(getName() + UUID.randomUUID(), request); + } + + @Override + public void onComplete(CompletionContext completionContext) { completeLog.put(getName() + UUID.randomUUID(), completionContext); } - ConcurrentHashMap> getStartLog() { + Map> getStartLog() { return startLog; } - ConcurrentHashMap> getCompleteLog() { + Map> getCompleteLog() { return completeLog; } + Map> getStartRequestLog() { + return startRequestLog; + } + protected String getName() { return this.getClass().getSimpleName(); } diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/RetryableLoadBalancerExchangeFilterFunctionIntegrationTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/RetryableLoadBalancerExchangeFilterFunctionIntegrationTests.java index ad44571e3..45b6c2ccd 100644 --- a/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/RetryableLoadBalancerExchangeFilterFunctionIntegrationTests.java +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/client/loadbalancer/reactive/RetryableLoadBalancerExchangeFilterFunctionIntegrationTests.java @@ -45,6 +45,7 @@ import org.springframework.cloud.client.loadbalancer.LoadBalancerLifecycle; import org.springframework.cloud.client.loadbalancer.LoadBalancerProperties; import org.springframework.cloud.client.loadbalancer.Request; +import org.springframework.cloud.client.loadbalancer.Response; import org.springframework.cloud.client.loadbalancer.ResponseData; import org.springframework.context.annotation.Bean; import org.springframework.http.HttpMethod; @@ -108,12 +109,18 @@ void loadBalancerLifecycleCallbacksExecuted() { Collection> lifecycleLogRequests = ((TestLoadBalancerLifecycle) factory .getInstances("testservice", LoadBalancerLifecycle.class).get("loadBalancerLifecycle")).getStartLog() .values(); - Collection> anotherLifecycleLogRequests = ((AnotherLoadBalancerLifecycle) factory + Collection> lifecycleLogStartRequests = ((TestLoadBalancerLifecycle) factory + .getInstances("testservice", LoadBalancerLifecycle.class).get("loadBalancerLifecycle")) + .getStartRequestLog().values(); + Collection> anotherLifecycleLogRequests = ((AnotherLoadBalancerLifecycle) factory .getInstances("testservice", LoadBalancerLifecycle.class).get("anotherLoadBalancerLifecycle")) .getCompleteLog().values(); then(clientResponse.statusCode()).isEqualTo(HttpStatus.OK); assertThat(lifecycleLogRequests).extracting(request -> ((DefaultRequestContext) request.getContext()).getHint()) .contains(callbackTestHint); + assertThat(lifecycleLogStartRequests) + .extracting(request -> ((DefaultRequestContext) request.getContext()).getHint()) + .contains(callbackTestHint); assertThat(anotherLifecycleLogRequests) .extracting(completionContext -> ((ResponseData) completionContext.getClientResponse()).getRequestData() .getHttpMethod()) @@ -270,7 +277,9 @@ protected static class TestLoadBalancerLifecycle implements LoadBalancerLifecycl Map> startLog = new ConcurrentHashMap<>(); - Map> completeLog = new ConcurrentHashMap<>(); + Map> startRequestLog = new ConcurrentHashMap<>(); + + Map> completeLog = new ConcurrentHashMap<>(); @Override public void onStart(Request request) { @@ -278,7 +287,12 @@ public void onStart(Request request) { } @Override - public void onComplete(CompletionContext completionContext) { + public void onStartRequest(Request request, Response lbResponse) { + startRequestLog.put(getName() + UUID.randomUUID(), request); + } + + @Override + public void onComplete(CompletionContext completionContext) { completeLog.clear(); completeLog.put(getName() + UUID.randomUUID(), completionContext); } @@ -287,10 +301,14 @@ Map> getStartLog() { return startLog; } - Map> getCompleteLog() { + Map> getCompleteLog() { return completeLog; } + Map> getStartRequestLog() { + return startRequestLog; + } + protected String getName() { return this.getClass().getSimpleName(); } diff --git a/spring-cloud-loadbalancer/pom.xml b/spring-cloud-loadbalancer/pom.xml index fccc31493..ecd38385b 100644 --- a/spring-cloud-loadbalancer/pom.xml +++ b/spring-cloud-loadbalancer/pom.xml @@ -87,6 +87,11 @@ spring-security-oauth2-autoconfigure true + + io.micrometer + micrometer-core + true + org.springframework.boot spring-boot-starter-test diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClient.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClient.java index 681318856..2119b4025 100644 --- a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClient.java +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClient.java @@ -33,11 +33,14 @@ import org.springframework.cloud.client.loadbalancer.LoadBalancerLifecycleValidator; import org.springframework.cloud.client.loadbalancer.LoadBalancerProperties; import org.springframework.cloud.client.loadbalancer.LoadBalancerRequest; +import org.springframework.cloud.client.loadbalancer.LoadBalancerRequestAdapter; import org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools; import org.springframework.cloud.client.loadbalancer.Request; import org.springframework.cloud.client.loadbalancer.Response; +import org.springframework.cloud.client.loadbalancer.ResponseData; import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.util.ReflectionUtils; import static org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer.REQUEST; @@ -65,7 +68,7 @@ public BlockingLoadBalancerClient(LoadBalancerClientFactory loadBalancerClientFa @Override public T execute(String serviceId, LoadBalancerRequest request) throws IOException { String hint = getHint(serviceId); - DefaultRequest lbRequest = new DefaultRequest<>( + LoadBalancerRequestAdapter lbRequest = new LoadBalancerRequestAdapter<>(request, new DefaultRequestContext(request, hint)); Set supportedLifecycleProcessors = LoadBalancerLifecycleValidator .getSupportedLifecycleProcessors( @@ -74,11 +77,11 @@ public T execute(String serviceId, LoadBalancerRequest request) throws IO supportedLifecycleProcessors.forEach(lifecycle -> lifecycle.onStart(lbRequest)); ServiceInstance serviceInstance = choose(serviceId, lbRequest); if (serviceInstance == null) { - supportedLifecycleProcessors.forEach(lifecycle -> lifecycle - .onComplete(new CompletionContext<>(CompletionContext.Status.DISCARD, new EmptyResponse()))); + supportedLifecycleProcessors.forEach(lifecycle -> lifecycle.onComplete( + new CompletionContext<>(CompletionContext.Status.DISCARD, lbRequest, new EmptyResponse()))); throw new IllegalStateException("No instances available for " + serviceId); } - return execute(serviceId, serviceInstance, request); + return execute(serviceId, serviceInstance, lbRequest); } @Override @@ -89,25 +92,45 @@ public T execute(String serviceId, ServiceInstance serviceInstance, LoadBala .getSupportedLifecycleProcessors( loadBalancerClientFactory.getInstances(serviceId, LoadBalancerLifecycle.class), DefaultRequestContext.class, Object.class, ServiceInstance.class); + Request lbRequest = request instanceof Request ? (Request) request : new DefaultRequest<>(); + supportedLifecycleProcessors + .forEach(lifecycle -> lifecycle.onStartRequest(lbRequest, new DefaultResponse(serviceInstance))); try { T response = request.apply(serviceInstance); - supportedLifecycleProcessors.forEach(lifecycle -> lifecycle - .onComplete(new CompletionContext<>(CompletionContext.Status.SUCCESS, defaultResponse, response))); + Object clientResponse = getClientResponse(response); + supportedLifecycleProcessors + .forEach(lifecycle -> lifecycle.onComplete(new CompletionContext<>(CompletionContext.Status.SUCCESS, + lbRequest, defaultResponse, clientResponse))); return response; } catch (IOException iOException) { supportedLifecycleProcessors.forEach(lifecycle -> lifecycle.onComplete( - new CompletionContext<>(CompletionContext.Status.FAILED, iOException, defaultResponse))); + new CompletionContext<>(CompletionContext.Status.FAILED, iOException, lbRequest, defaultResponse))); throw iOException; } catch (Exception exception) { - supportedLifecycleProcessors.forEach(lifecycle -> lifecycle - .onComplete(new CompletionContext<>(CompletionContext.Status.FAILED, exception, defaultResponse))); + supportedLifecycleProcessors.forEach(lifecycle -> lifecycle.onComplete( + new CompletionContext<>(CompletionContext.Status.FAILED, exception, lbRequest, defaultResponse))); ReflectionUtils.rethrowRuntimeException(exception); } return null; } + private Object getClientResponse(T response) { + ClientHttpResponse clientHttpResponse = null; + if (response instanceof ClientHttpResponse) { + clientHttpResponse = (ClientHttpResponse) response; + } + if (clientHttpResponse != null) { + try { + return new ResponseData(clientHttpResponse, null); + } + catch (IOException ignored) { + } + } + return response; + } + @Override public URI reconstructURI(ServiceInstance serviceInstance, URI original) { return LoadBalancerUriTools.reconstructURI(serviceInstance, original); diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/config/LoadBalancerStatsAutoConfiguration.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/config/LoadBalancerStatsAutoConfiguration.java new file mode 100644 index 000000000..1e6092847 --- /dev/null +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/config/LoadBalancerStatsAutoConfiguration.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.loadbalancer.config; + +import io.micrometer.core.instrument.MeterRegistry; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cloud.loadbalancer.stats.MicrometerStatsLoadBalancerLifecycle; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Autoconfiguration that provides a {@link MicrometerStatsLoadBalancerLifecycle} bean. + * + * @author Olga Maciaszek-Sharma + * @since 3.0.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(MeterRegistry.class) +@ConditionalOnProperty(value = "spring.cloud.loadbalancer.stats.micrometer.enabled", havingValue = "true") +public class LoadBalancerStatsAutoConfiguration { + + @Bean + @ConditionalOnBean(MeterRegistry.class) + public MicrometerStatsLoadBalancerLifecycle micrometerStatsLifecycle(MeterRegistry meterRegistry) { + return new MicrometerStatsLoadBalancerLifecycle(meterRegistry); + } + +} diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/stats/LoadBalancerTags.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/stats/LoadBalancerTags.java new file mode 100644 index 000000000..7679f2868 --- /dev/null +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/stats/LoadBalancerTags.java @@ -0,0 +1,160 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.loadbalancer.stats; + +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.CompletionContext; +import org.springframework.cloud.client.loadbalancer.RequestData; +import org.springframework.cloud.client.loadbalancer.RequestDataContext; +import org.springframework.cloud.client.loadbalancer.ResponseData; +import org.springframework.util.StringUtils; + +/** + * Utility class for building metrics tags for load-balanced calls. + * + * @author Olga Maciaszek-Sharma + * @since 3.0.0 + */ +final class LoadBalancerTags { + + static final String UNKNOWN = "UNKNOWN"; + + private LoadBalancerTags() { + throw new UnsupportedOperationException("Cannot instantiate utility class"); + } + + static Iterable buildSuccessRequestTags(CompletionContext completionContext) { + ServiceInstance serviceInstance = completionContext.getLoadBalancerResponse().getServer(); + Tags tags = Tags.of(buildServiceInstanceTags(serviceInstance)); + Object clientResponse = completionContext.getClientResponse(); + if (clientResponse instanceof ResponseData) { + ResponseData responseData = (ResponseData) clientResponse; + RequestData requestData = responseData.getRequestData(); + if (requestData != null) { + tags = tags.and(valueOrUnknown("method", requestData.getHttpMethod()), + valueOrUnknown("uri", getPath(requestData))); + } + else { + tags = tags.and(Tag.of("method", UNKNOWN), Tag.of("uri", UNKNOWN)); + } + + tags = tags.and(Tag.of("outcome", forStatus(statusValue(responseData))), + valueOrUnknown("status", statusValue(responseData))); + } + else { + tags = tags.and(Tag.of("method", UNKNOWN), Tag.of("uri", UNKNOWN), Tag.of("outcome", UNKNOWN), + Tag.of("status", UNKNOWN)); + } + return tags; + } + + // In keeping with the way null HttpStatus is handled in Actuator + private static int statusValue(ResponseData responseData) { + return responseData.getHttpStatus() != null ? responseData.getHttpStatus().value() : 200; + } + + private static String getPath(RequestData requestData) { + return requestData.getUrl() != null ? requestData.getUrl().getPath() : UNKNOWN; + } + + static Iterable buildDiscardedRequestTags( + CompletionContext completionContext) { + if (completionContext.getLoadBalancerRequest().getContext() instanceof RequestDataContext) { + RequestData requestData = ((RequestDataContext) completionContext.getLoadBalancerRequest().getContext()) + .getClientRequest(); + if (requestData != null) { + return Tags.of(valueOrUnknown("method", requestData.getHttpMethod()), + valueOrUnknown("uri", getPath(requestData)), valueOrUnknown("serviceId", getHost(requestData))); + } + } + return Tags.of(valueOrUnknown("method", UNKNOWN), valueOrUnknown("uri", UNKNOWN), + valueOrUnknown("serviceId", UNKNOWN)); + + } + + private static String getHost(RequestData requestData) { + return requestData.getUrl() != null ? requestData.getUrl().getHost() : UNKNOWN; + } + + static Iterable buildFailedRequestTags(CompletionContext completionContext) { + ServiceInstance serviceInstance = completionContext.getLoadBalancerResponse().getServer(); + Tags tags = Tags.of(buildServiceInstanceTags(serviceInstance)).and(exception(completionContext.getThrowable())); + if (completionContext.getLoadBalancerRequest().getContext() instanceof RequestDataContext) { + RequestData requestData = ((RequestDataContext) completionContext.getLoadBalancerRequest().getContext()) + .getClientRequest(); + if (requestData != null) { + return tags.and(Tags.of(valueOrUnknown("method", requestData.getHttpMethod()), + valueOrUnknown("uri", getPath(requestData)))); + } + } + return tags.and(Tags.of(valueOrUnknown("method", UNKNOWN), valueOrUnknown("uri", UNKNOWN))); + } + + static Iterable buildServiceInstanceTags(ServiceInstance serviceInstance) { + return Tags.of(valueOrUnknown("serviceId", serviceInstance.getServiceId()), + valueOrUnknown("serviceInstance.instanceId", serviceInstance.getInstanceId()), + valueOrUnknown("serviceInstance.host", serviceInstance.getHost()), + valueOrUnknown("serviceInstance.port", String.valueOf(serviceInstance.getPort()))); + } + + private static Tag valueOrUnknown(String key, String value) { + if (value != null) { + return Tag.of(key, value); + } + return Tag.of(key, UNKNOWN); + } + + private static Tag valueOrUnknown(String key, Object value) { + if (value != null) { + return Tag.of(key, String.valueOf(value)); + } + return Tag.of(key, UNKNOWN); + } + + private static Tag exception(Throwable exception) { + if (exception != null) { + String simpleName = exception.getClass().getSimpleName(); + return Tag.of("exception", StringUtils.hasText(simpleName) ? simpleName : exception.getClass().getName()); + } + return Tag.of("exception", "None"); + } + + // Logic from Actuator's `Outcome` class. Copied in here to avoid adding Actuator + // dependency. + public static String forStatus(int status) { + if (status >= 100 && status < 200) { + return "INFORMATIONAL"; + } + else if (status >= 200 && status < 300) { + return "SUCCESS"; + } + else if (status >= 300 && status < 400) { + return "REDIRECTION"; + } + else if (status >= 400 && status < 500) { + return "CLIENT_ERROR"; + } + else if (status >= 500 && status < 600) { + return "SERVER_ERROR"; + } + return UNKNOWN; + } + +} diff --git a/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/stats/MicrometerStatsLoadBalancerLifecycle.java b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/stats/MicrometerStatsLoadBalancerLifecycle.java new file mode 100644 index 000000000..fe7daf0e9 --- /dev/null +++ b/spring-cloud-loadbalancer/src/main/java/org/springframework/cloud/loadbalancer/stats/MicrometerStatsLoadBalancerLifecycle.java @@ -0,0 +1,121 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.loadbalancer.stats; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.CompletionContext; +import org.springframework.cloud.client.loadbalancer.LoadBalancerLifecycle; +import org.springframework.cloud.client.loadbalancer.Request; +import org.springframework.cloud.client.loadbalancer.Response; +import org.springframework.cloud.client.loadbalancer.TimedRequestContext; + +import static org.springframework.cloud.loadbalancer.stats.LoadBalancerTags.buildDiscardedRequestTags; +import static org.springframework.cloud.loadbalancer.stats.LoadBalancerTags.buildFailedRequestTags; +import static org.springframework.cloud.loadbalancer.stats.LoadBalancerTags.buildServiceInstanceTags; +import static org.springframework.cloud.loadbalancer.stats.LoadBalancerTags.buildSuccessRequestTags; + +/** + * An implementation of {@link LoadBalancerLifecycle} that records metrics for + * load-balanced calls. + * + * @author Olga Maciaszek-Sharma + * @since 3.0.0 + */ +public class MicrometerStatsLoadBalancerLifecycle implements LoadBalancerLifecycle { + + private final MeterRegistry meterRegistry; + + private final ConcurrentHashMap activeRequestsPerInstance = new ConcurrentHashMap<>(); + + public MicrometerStatsLoadBalancerLifecycle(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + @Override + public boolean supports(Class requestContextClass, Class responseClass, Class serverTypeClass) { + return ServiceInstance.class.isAssignableFrom(serverTypeClass); + } + + @Override + public void onStart(Request request) { + // do nothing + } + + @Override + public void onStartRequest(Request request, Response lbResponse) { + if (request.getContext() instanceof TimedRequestContext) { + ((TimedRequestContext) request.getContext()).setRequestStartTime(System.nanoTime()); + } + if (!lbResponse.hasServer()) { + return; + } + ServiceInstance serviceInstance = lbResponse.getServer(); + AtomicLong activeRequestsCounter = activeRequestsPerInstance.computeIfAbsent(serviceInstance, instance -> { + AtomicLong createdCounter = new AtomicLong(); + Gauge.builder("loadbalancer.requests.active", () -> createdCounter) + .tags(buildServiceInstanceTags(serviceInstance)).register(meterRegistry); + return createdCounter; + }); + activeRequestsCounter.incrementAndGet(); + } + + @Override + public void onComplete(CompletionContext completionContext) { + long requestFinishedTimestamp = System.nanoTime(); + if (CompletionContext.Status.DISCARD.equals(completionContext.status())) { + Counter.builder("loadbalancer.requests.discard").tags(buildDiscardedRequestTags(completionContext)) + .register(meterRegistry).increment(); + return; + } + ServiceInstance serviceInstance = completionContext.getLoadBalancerResponse().getServer(); + AtomicLong activeRequestsCounter = activeRequestsPerInstance.get(serviceInstance); + if (activeRequestsCounter != null) { + activeRequestsCounter.decrementAndGet(); + } + Object loadBalancerRequestContext = completionContext.getLoadBalancerRequest().getContext(); + if (requestHasBeenTimed(loadBalancerRequestContext)) { + if (CompletionContext.Status.FAILED.equals(completionContext.status())) { + Timer.builder("loadbalancer.requests.failed").tags(buildFailedRequestTags(completionContext)) + .register(meterRegistry) + .record(requestFinishedTimestamp + - ((TimedRequestContext) loadBalancerRequestContext).getRequestStartTime(), + TimeUnit.NANOSECONDS); + return; + } + Timer.builder("loadbalancer.requests.success").tags(buildSuccessRequestTags(completionContext)) + .register(meterRegistry) + .record(requestFinishedTimestamp + - ((TimedRequestContext) loadBalancerRequestContext).getRequestStartTime(), + TimeUnit.NANOSECONDS); + } + } + + private boolean requestHasBeenTimed(Object loadBalancerRequestContext) { + return loadBalancerRequestContext instanceof TimedRequestContext + && (((TimedRequestContext) loadBalancerRequestContext).getRequestStartTime() != 0L); + } + +} diff --git a/spring-cloud-loadbalancer/src/main/resources/META-INF/spring.factories b/spring-cloud-loadbalancer/src/main/resources/META-INF/spring.factories index da847ea32..03d4e739f 100644 --- a/spring-cloud-loadbalancer/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-loadbalancer/src/main/resources/META-INF/spring.factories @@ -3,4 +3,5 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ org.springframework.cloud.loadbalancer.config.LoadBalancerAutoConfiguration,\ org.springframework.cloud.loadbalancer.config.BlockingLoadBalancerClientAutoConfiguration,\ org.springframework.cloud.loadbalancer.config.LoadBalancerCacheAutoConfiguration,\ -org.springframework.cloud.loadbalancer.security.OAuth2LoadBalancerClientAutoConfiguration \ No newline at end of file +org.springframework.cloud.loadbalancer.security.OAuth2LoadBalancerClientAutoConfiguration,\ +org.springframework.cloud.loadbalancer.config.LoadBalancerStatsAutoConfiguration \ No newline at end of file diff --git a/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClientTests.java b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClientTests.java index 92e343165..8d0e623c1 100644 --- a/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClientTests.java +++ b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/blocking/client/BlockingLoadBalancerClientTests.java @@ -20,6 +20,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Random; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -173,12 +174,18 @@ void loadBalancerLifecycleCallbacksExecuted() throws IOException { Collection> lifecycleLogRequests = ((TestLoadBalancerLifecycle) factory .getInstances("myservice", LoadBalancerLifecycle.class).get("loadBalancerLifecycle")).getStartLog() .values(); - Collection> anotherLifecycleLogRequests = ((AnotherLoadBalancerLifecycle) factory + Collection> lifecycleLogStartedRequests = ((TestLoadBalancerLifecycle) factory + .getInstances("myservice", LoadBalancerLifecycle.class).get("loadBalancerLifecycle")) + .getStartRequestLog().values(); + Collection> anotherLifecycleLogRequests = ((AnotherLoadBalancerLifecycle) factory .getInstances("myservice", LoadBalancerLifecycle.class).get("anotherLoadBalancerLifecycle")) .getCompleteLog().values(); assertThat(actualResult).isEqualTo(result); assertThat(lifecycleLogRequests).extracting(request -> ((DefaultRequestContext) request.getContext()).getHint()) .contains(callbackTestHint); + assertThat(lifecycleLogStartedRequests) + .extracting(request -> ((DefaultRequestContext) request.getContext()).getHint()) + .contains(callbackTestHint); assertThat(anotherLifecycleLogRequests).extracting(CompletionContext::getClientResponse).contains(result); } @@ -231,9 +238,11 @@ ReactorLoadBalancer reactiveLoadBalancer(DiscoveryClient discov protected static class TestLoadBalancerLifecycle implements LoadBalancerLifecycle { - final ConcurrentHashMap> startLog = new ConcurrentHashMap<>(); + final Map> startLog = new ConcurrentHashMap<>(); + + final Map> startRequestLog = new ConcurrentHashMap<>(); - final ConcurrentHashMap> completeLog = new ConcurrentHashMap<>(); + final Map> completeLog = new ConcurrentHashMap<>(); @Override public void onStart(Request request) { @@ -241,18 +250,27 @@ public void onStart(Request request) { } @Override - public void onComplete(CompletionContext completionContext) { + public void onStartRequest(Request request, Response lbResponse) { + startRequestLog.put(getName() + UUID.randomUUID(), request); + } + + @Override + public void onComplete(CompletionContext completionContext) { completeLog.put(getName() + UUID.randomUUID(), completionContext); } - ConcurrentHashMap> getStartLog() { + Map> getStartLog() { return startLog; } - ConcurrentHashMap> getCompleteLog() { + Map> getCompleteLog() { return completeLog; } + Map> getStartRequestLog() { + return startRequestLog; + } + protected String getName() { return this.getClass().getSimpleName(); } diff --git a/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/stats/MicrometerStatsLoadBalancerLifecycleTests.java b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/stats/MicrometerStatsLoadBalancerLifecycleTests.java new file mode 100644 index 000000000..49d8714c7 --- /dev/null +++ b/spring-cloud-loadbalancer/src/test/java/org/springframework/cloud/loadbalancer/stats/MicrometerStatsLoadBalancerLifecycleTests.java @@ -0,0 +1,182 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.loadbalancer.stats; + +import java.net.URI; +import java.util.HashMap; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.cloud.client.DefaultServiceInstance; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.CompletionContext; +import org.springframework.cloud.client.loadbalancer.DefaultRequest; +import org.springframework.cloud.client.loadbalancer.DefaultRequestContext; +import org.springframework.cloud.client.loadbalancer.DefaultResponse; +import org.springframework.cloud.client.loadbalancer.EmptyResponse; +import org.springframework.cloud.client.loadbalancer.Request; +import org.springframework.cloud.client.loadbalancer.RequestData; +import org.springframework.cloud.client.loadbalancer.RequestDataContext; +import org.springframework.cloud.client.loadbalancer.Response; +import org.springframework.cloud.client.loadbalancer.ResponseData; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.util.MultiValueMapAdapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.cloud.loadbalancer.stats.LoadBalancerTags.UNKNOWN; + +/** + * Tests for {@link MicrometerStatsLoadBalancerLifecycle}. + * + * @author Olga Maciaszek-Sharma + */ +class MicrometerStatsLoadBalancerLifecycleTests { + + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + + MicrometerStatsLoadBalancerLifecycle statsLifecycle = new MicrometerStatsLoadBalancerLifecycle(meterRegistry); + + @Test + void shouldRecordSuccessfulTimedRequest() { + RequestData requestData = new RequestData(HttpMethod.GET, URI.create("http://test.org/test"), new HttpHeaders(), + new HttpHeaders(), new HashMap<>()); + Request lbRequest = new DefaultRequest<>(new RequestDataContext(requestData)); + Response lbResponse = new DefaultResponse( + new DefaultServiceInstance("test-1", "test", "test.org", 8080, false, new HashMap<>())); + ResponseData responseData = new ResponseData(HttpStatus.OK, new HttpHeaders(), + new MultiValueMapAdapter<>(new HashMap<>()), requestData); + statsLifecycle.onStartRequest(lbRequest, lbResponse); + assertThat(meterRegistry.get("loadbalancer.requests.active").gauge().value()).isEqualTo(1); + + statsLifecycle.onComplete( + new CompletionContext<>(CompletionContext.Status.SUCCESS, lbRequest, lbResponse, responseData)); + + assertThat(meterRegistry.getMeters()).hasSize(2); + assertThat(meterRegistry.get("loadbalancer.requests.active").gauge().value()).isEqualTo(0); + assertThat(meterRegistry.get("loadbalancer.requests.success").timers()).hasSize(1); + assertThat(meterRegistry.get("loadbalancer.requests.success").timer().count()).isEqualTo(1); + assertThat(meterRegistry.get("loadbalancer.requests.success").timer().getId().getTags()).contains( + Tag.of("method", "GET"), Tag.of("outcome", "SUCCESS"), Tag.of("serviceId", "test"), + Tag.of("serviceInstance.host", "test.org"), Tag.of("serviceInstance.instanceId", "test-1"), + Tag.of("serviceInstance.port", "8080"), Tag.of("status", "200"), Tag.of("uri", "/test")); + } + + @Test + void shouldRecordFailedTimedRequest() { + RequestData requestData = new RequestData(HttpMethod.GET, URI.create("http://test.org/test"), new HttpHeaders(), + new HttpHeaders(), new HashMap<>()); + Request lbRequest = new DefaultRequest<>(new RequestDataContext(requestData)); + Response lbResponse = new DefaultResponse( + new DefaultServiceInstance("test-1", "test", "test.org", 8080, false, new HashMap<>())); + statsLifecycle.onStartRequest(lbRequest, lbResponse); + assertThat(meterRegistry.get("loadbalancer.requests.active").gauge().value()).isEqualTo(1); + + statsLifecycle.onComplete(new CompletionContext<>(CompletionContext.Status.FAILED, new IllegalStateException(), + lbRequest, lbResponse)); + + assertThat(meterRegistry.getMeters()).hasSize(2); + assertThat(meterRegistry.get("loadbalancer.requests.active").gauge().value()).isEqualTo(0); + assertThat(meterRegistry.get("loadbalancer.requests.failed").timers()).hasSize(1); + assertThat(meterRegistry.get("loadbalancer.requests.failed").timer().count()).isEqualTo(1); + assertThat(meterRegistry.get("loadbalancer.requests.failed").timer().getId().getTags()).contains( + Tag.of("exception", "IllegalStateException"), Tag.of("method", "GET"), Tag.of("serviceId", "test"), + Tag.of("serviceInstance.host", "test.org"), Tag.of("serviceInstance.instanceId", "test-1"), + Tag.of("serviceInstance.port", "8080"), Tag.of("uri", "/test")); + } + + @Test + void shouldNotRecordDiscardedRequest() { + RequestData requestData = new RequestData(HttpMethod.GET, URI.create("http://test.org/test"), new HttpHeaders(), + new HttpHeaders(), new HashMap<>()); + Request lbRequest = new DefaultRequest<>(new RequestDataContext(requestData)); + Response lbResponse = new EmptyResponse(); + statsLifecycle.onStartRequest(lbRequest, lbResponse); + + statsLifecycle.onComplete(new CompletionContext<>(CompletionContext.Status.DISCARD, lbRequest, lbResponse)); + assertThat(meterRegistry.getMeters()).hasSize(1); + assertThat(meterRegistry.get("loadbalancer.requests.discard").counter().count()).isEqualTo(1); + } + + @Test + void shouldNotRecordUnTimedRequest() { + Request lbRequest = new DefaultRequest<>(new StatsTestContext()); + Response lbResponse = new DefaultResponse( + new DefaultServiceInstance("test-1", "test", "test.org", 8080, false, new HashMap<>())); + ResponseData responseData = new ResponseData(HttpStatus.OK, new HttpHeaders(), + new MultiValueMapAdapter<>(new HashMap<>()), null); + statsLifecycle.onStartRequest(lbRequest, lbResponse); + assertThat(meterRegistry.get("loadbalancer.requests.active").gauge().value()).isEqualTo(1); + + statsLifecycle.onComplete( + new CompletionContext<>(CompletionContext.Status.SUCCESS, lbRequest, lbResponse, responseData)); + + assertThat(meterRegistry.getMeters()).hasSize(1); + assertThat(meterRegistry.get("loadbalancer.requests.active").gauge().value()).isEqualTo(0); + } + + @Test + void shouldNotCreateNullTagsWhenNullDataObjects() { + Request lbRequest = new DefaultRequest<>(new DefaultRequestContext()); + Response lbResponse = new DefaultResponse(new DefaultServiceInstance()); + statsLifecycle.onStartRequest(lbRequest, lbResponse); + assertThat(meterRegistry.get("loadbalancer.requests.active").gauge().value()).isEqualTo(1); + + statsLifecycle + .onComplete(new CompletionContext<>(CompletionContext.Status.SUCCESS, lbRequest, lbResponse, null)); + + assertThat(meterRegistry.getMeters()).hasSize(2); + assertThat(meterRegistry.get("loadbalancer.requests.active").gauge().value()).isEqualTo(0); + assertThat(meterRegistry.get("loadbalancer.requests.success").timers()).hasSize(1); + assertThat(meterRegistry.get("loadbalancer.requests.success").timer().count()).isEqualTo(1); + assertThat(meterRegistry.get("loadbalancer.requests.success").timer().getId().getTags()).contains( + Tag.of("method", UNKNOWN), Tag.of("outcome", UNKNOWN), Tag.of("serviceId", UNKNOWN), + Tag.of("serviceInstance.host", UNKNOWN), Tag.of("serviceInstance.instanceId", UNKNOWN), + Tag.of("serviceInstance.port", "0"), Tag.of("status", UNKNOWN), Tag.of("uri", UNKNOWN)); + } + + @Test + void shouldNotCreateNullTagsWhenEmptyDataObjects() { + RequestData requestData = new RequestData(null, null, null, null, null); + Request lbRequest = new DefaultRequest<>(new RequestDataContext()); + Response lbResponse = new DefaultResponse(new DefaultServiceInstance()); + ResponseData responseData = new ResponseData(null, null, null, requestData); + statsLifecycle.onStartRequest(lbRequest, lbResponse); + assertThat(meterRegistry.get("loadbalancer.requests.active").gauge().value()).isEqualTo(1); + + statsLifecycle.onComplete( + new CompletionContext<>(CompletionContext.Status.SUCCESS, lbRequest, lbResponse, responseData)); + + assertThat(meterRegistry.getMeters()).hasSize(2); + assertThat(meterRegistry.get("loadbalancer.requests.active").gauge().value()).isEqualTo(0); + assertThat(meterRegistry.get("loadbalancer.requests.success").timers()).hasSize(1); + assertThat(meterRegistry.get("loadbalancer.requests.success").timer().count()).isEqualTo(1); + assertThat(meterRegistry.get("loadbalancer.requests.success").timer().getId().getTags()).contains( + Tag.of("method", UNKNOWN), Tag.of("outcome", "SUCCESS"), Tag.of("serviceId", UNKNOWN), + Tag.of("serviceInstance.host", UNKNOWN), Tag.of("serviceInstance.instanceId", UNKNOWN), + Tag.of("serviceInstance.port", "0"), Tag.of("status", "200"), Tag.of("uri", UNKNOWN)); + } + + private static class StatsTestContext { + + } + +}