diff --git a/docs/src/main/asciidoc/gateway-grafana-dashboard.json b/docs/src/main/asciidoc/gateway-grafana-dashboard.json new file mode 100644 index 00000000..95defd61 --- /dev/null +++ b/docs/src/main/asciidoc/gateway-grafana-dashboard.json @@ -0,0 +1,760 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "4.4.1" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "singlestat", + "name": "Singlestat", + "version": "" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "hideControls": false, + "id": null, + "links": [], + "refresh": "1m", + "rows": [ + { + "collapse": false, + "height": 38, + "panels": [ + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(191, 148, 112, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "${DS_PROMETHEUS}", + "description": "", + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 28, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 4, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "Value", + "targets": [ + { + "expr": "sum(gateway_requests_seconds_count{job=~\"$gatewayService\"})", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "metric": "gateway_requests_seconds_count", + "refId": "A", + "step": 1200 + } + ], + "thresholds": "", + "timeFrom": null, + "timeShift": null, + "title": "Total Requests Served", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(191, 148, 112, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "${DS_PROMETHEUS}", + "description": "", + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 29, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 4, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "Value", + "targets": [ + { + "expr": "sum(gateway_requests_seconds_count{outcome=\"SUCCESSFUL\", job=~\"$gatewayService\"})", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "metric": "gateway_requests_seconds_count", + "refId": "A", + "step": 1200 + } + ], + "thresholds": "", + "timeFrom": null, + "timeShift": null, + "title": "Total Successful Requests Served", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(191, 148, 112, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "${DS_PROMETHEUS}", + "description": "", + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": false, + "id": 30, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 4, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "tableColumn": "Value", + "targets": [ + { + "expr": "sum(gateway_requests_seconds_count{outcome!=\"SUCCESSFUL\", job=~\"$gatewayService\"})", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "", + "metric": "gateway_requests_seconds_count", + "refId": "A", + "step": 1200 + } + ], + "thresholds": "", + "timeFrom": null, + "timeShift": null, + "title": "Total Unsuccessful Requests Served", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Total Throughput", + "titleSize": "h2" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "fill": 1, + "id": 49, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": "routeId", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "gateway_requests_seconds_sum{routeId=~\"$routeId\", job=~\"$gatewayService\"}/gateway_requests_seconds_count{routeId=~\"$routeId\", job=~\"$gatewayService\"}", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{ status }}", + "metric": "gateway_api_time_seconds_sum", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "$routeId", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "s", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Request Processing Time", + "titleSize": "h2" + }, + { + "collapse": true, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "description": "", + "fill": 1, + "id": 2, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "minSpan": null, + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": "routeId", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "gateway_requests_seconds_count{outcome=\"SUCCESSFUL\", routeId=~\"$routeId\", job=~\"$gatewayService\"}", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{ job }} / {{ routeUri }}", + "metric": "gateway_requests_seconds_count", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "$routeId Success API Gateway Calls", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "cumulative" + }, + "transparent": false, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Successful API Calls", + "titleSize": "h2" + }, + { + "collapse": true, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "description": "", + "fill": 1, + "id": 3, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": "routeId", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": true, + "steppedLine": false, + "targets": [ + { + "expr": "gateway_requests_seconds_count{outcome!=\"SUCCESSFUL\", routeId=~\"$routeId\", job=~\"$gatewayService\"}", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{ job }} / {{ routeUri }}", + "metric": "gateway_requests_seconds_count", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "$routeId Unsuccessful GW Calls", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "cumulative" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Unsuccessful API Calls", + "titleSize": "h2" + }, + { + "collapse": false, + "height": 250, + "panels": [ + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": "${DS_PROMETHEUS}", + "description": "", + "fill": 1, + "id": 35, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "null", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "repeat": "routeId", + "seriesOverrides": [], + "spaceLength": 10, + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "expr": "sum(gateway_requests_seconds_count{routeId=~\"$routeId\", job=~\"$gatewayService\"}) by (job, status)", + "format": "time_series", + "interval": "", + "intervalFactor": 2, + "legendFormat": "{{ job }} / {{ status }}", + "metric": "gateway_requests_seconds_count", + "refId": "A", + "step": 240 + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "$routeId", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "API / HTTP Status", + "titleSize": "h2" + } + ], + "schemaVersion": 14, + "style": "dark", + "tags": [], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "${DS_PROMETHEUS}", + "hide": 0, + "includeAll": true, + "label": "Gateway Service", + "multi": true, + "name": "gatewayService", + "options": [], + "query": "label_values(gateway_requests_seconds_count,job)", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "${DS_PROMETHEUS}", + "hide": 0, + "includeAll": true, + "label": "Route Id", + "multi": true, + "name": "routeId", + "options": [], + "query": "label_values(gateway_requests_seconds_count,routeId)", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now/d", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "", + "title": "Spring Cloud Gateway", + "version": 1 +} diff --git a/docs/src/main/asciidoc/images/gateway-grafana-dashboard.jpeg b/docs/src/main/asciidoc/images/gateway-grafana-dashboard.jpeg new file mode 100644 index 00000000..7ba7f88b Binary files /dev/null and b/docs/src/main/asciidoc/images/gateway-grafana-dashboard.jpeg differ diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index 94a78e0a..2ab6834a 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -884,6 +884,17 @@ spring: - Path=/websocket/** ---- +=== Gateway Metrics Filter + +The Gateway Metrics Filter runs as long as the property `spring.cloud.gateway.metrics.enabled` is not set to `false`. This filter adds a timer metric named "gateway.requests" with the following tags: + +* `routeId`: The route id +* `routeUri`: The URI that the API will be routed to +* `outcome` |Outcome as classified by link:https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/HttpStatus.Series.html[HttpStatus.Series] +* `status`: Http Status of the request returned to the client + +These metrics are then available to be scraped from ``/actuator/metrics/gateway.requests`` and can be easily integated with Prometheus to create a link:images/gateway-grafana-dashboard.jpeg[Grafana] link:gateway-grafana-dashboard.json[dashboard]. + === Making An Exchange As Routed After the Gateway has routed a `ServerWebExchange` it will mark that exchange as "routed" by adding `gatewayAlreadyRouted` diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index e61b79f7..cc0e2f68 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -21,6 +21,8 @@ import java.util.List; import java.util.function.Consumer; import com.netflix.hystrix.HystrixObservableCommand; + +import io.micrometer.core.instrument.MeterRegistry; import io.netty.channel.ChannelOption; import io.netty.handler.ssl.util.InsecureTrustManagerFactory; import reactor.core.publisher.Flux; @@ -48,6 +50,7 @@ import org.springframework.cloud.gateway.filter.AdaptCachedBodyGlobalFilter; import org.springframework.cloud.gateway.filter.ForwardPathFilter; import org.springframework.cloud.gateway.filter.ForwardRoutingFilter; import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.cloud.gateway.filter.GatewayMetricsFilter; import org.springframework.cloud.gateway.filter.NettyRoutingFilter; import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter; import org.springframework.cloud.gateway.filter.RouteToRequestUrlFilter; @@ -325,6 +328,12 @@ public class GatewayAutoConfiguration { // GlobalFilter beans + + @Bean + @ConditionalOnProperty(name = "spring.cloud.gateway.metrics.enabled", matchIfMissing = true) + public GatewayMetricsFilter gatewayMetricFilter(MeterRegistry meterRegistry) { + return new GatewayMetricsFilter(meterRegistry); + } @Bean public AdaptCachedBodyGlobalFilter adaptCachedBodyGlobalFilter() { diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/GatewayMetricsFilter.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/GatewayMetricsFilter.java new file mode 100644 index 00000000..11ac4981 --- /dev/null +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/GatewayMetricsFilter.java @@ -0,0 +1,98 @@ +/* + * Copyright 2013-2017 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 + * + * http://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.gateway.filter; + +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR; + +import org.springframework.cloud.gateway.route.Route; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.AbstractServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.Timer.Sample; +import reactor.core.publisher.Mono; + +public class GatewayMetricsFilter implements GlobalFilter, Ordered { + + private MeterRegistry meterRegistry; + + public GatewayMetricsFilter(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + @Override + public int getOrder() { + // start the timer as soon as possible and report the metric event before we write + // response to client + return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER + 1; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + Sample sample = Timer.start(meterRegistry); + + return chain.filter(exchange).doOnSuccessOrError((aVoid, ex) -> { + endTimerRespectingCommit(exchange, sample); + }); + } + + private void endTimerRespectingCommit(ServerWebExchange exchange, Sample sample) { + + ServerHttpResponse response = exchange.getResponse(); + if (response.isCommitted()) { + endTimerInner(exchange, sample); + } + else { + response.beforeCommit(() -> { + endTimerInner(exchange, sample); + return Mono.empty(); + }); + } + } + + private void endTimerInner(ServerWebExchange exchange, Sample sample) { + String outcome = "CUSTOM"; + String status = "CUSTOM"; + HttpStatus statusCode = exchange.getResponse().getStatusCode(); + if (statusCode != null) { + outcome = statusCode.series().name(); + status = statusCode.name(); + } + else { // a non standard HTTPS status could be used. Let's be defensive here + if (exchange.getResponse() instanceof AbstractServerHttpResponse) { + Integer statusInt = ((AbstractServerHttpResponse) exchange.getResponse()) + .getStatusCodeValue(); + if (statusInt != null) { + status = String.valueOf(statusInt); + } + else { + status = "NA"; + } + } + } + Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR); + Tags tags = Tags.of("outcome", outcome, "status", status, "routeId", + route.getId(), "routeUri", route.getUri().toString()); + sample.stop(meterRegistry.timer("gateway.requests", tags)); + } +} diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java index 94a1b429..af17a135 100644 --- a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/config/GatewayAutoConfigurationTests.java @@ -24,6 +24,8 @@ import reactor.ipc.netty.http.client.HttpClientOptions; import reactor.ipc.netty.options.ClientProxyOptions; import reactor.ipc.netty.resources.PoolResources; +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; @@ -36,6 +38,8 @@ public class GatewayAutoConfigurationTests { public void nettyHttpClientDefaults() { new ReactiveWebApplicationContextRunner() .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, + MetricsAutoConfiguration.class, + SimpleMetricsExportAutoConfiguration.class, GatewayAutoConfiguration.class)) .withPropertyValues("debug=true") .run(context -> { @@ -59,6 +63,8 @@ public class GatewayAutoConfigurationTests { public void nettyHttpClientConfigured() { new ReactiveWebApplicationContextRunner() .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, + MetricsAutoConfiguration.class, + SimpleMetricsExportAutoConfiguration.class, GatewayAutoConfiguration.class)) .withPropertyValues("spring.cloud.gateway.httpclient.ssl.use-insecure-trust-manager=true", "spring.cloud.gateway.httpclient.connect-timeout=10", diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/GatewayMetricFilterTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/GatewayMetricFilterTests.java new file mode 100644 index 00000000..46a957c9 --- /dev/null +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/GatewayMetricFilterTests.java @@ -0,0 +1,122 @@ +/* + * Copyright 2013-2017 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 + * + * http://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.gateway.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.cloud.gateway.test.BaseWebClientTests; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.micrometer.core.instrument.MeterRegistry; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +public class GatewayMetricFilterTests extends BaseWebClientTests { + + private static final String REQUEST_METRICS_NAME = "gateway.requests"; + + @Autowired + private MeterRegistry meterRegistry; + + @Value("${test.uri}") + private String testUri; + + @Test + public void gatewayRequestsMeterFilterHasTags() throws InterruptedException { + testClient.get().uri("/headers").exchange().expectStatus().isOk(); + assertMetricsContainsTag("outcome", HttpStatus.Series.SUCCESSFUL.name()); + assertMetricsContainsTag("status", HttpStatus.OK.name()); + assertMetricsContainsTag("routeId", "default_path_to_httpbin"); + assertMetricsContainsTag("routeUri", "lb://testservice"); + } + + @Test + public void gatewayRequestsMeterFilterHasTagsForBadTargetUri() + throws InterruptedException { + testClient.get().uri("/badtargeturi").exchange().expectStatus() + .is5xxServerError(); + assertMetricsContainsTag("outcome", HttpStatus.Series.SERVER_ERROR.name()); + assertMetricsContainsTag("status", HttpStatus.INTERNAL_SERVER_ERROR.name()); + assertMetricsContainsTag("routeId", "default_path_to_httpbin"); + assertMetricsContainsTag("routeUri", testUri); + } + + @Test + public void hasMetricsForSetStatusFilter() throws InterruptedException { + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.HOST, "www.setcustomstatus.org"); + // cannot use netty client since we cannot read custom http status + ResponseEntity response = new TestRestTemplate().exchange( + baseUri + "/headers", HttpMethod.GET, new HttpEntity<>(headers), + String.class); + assertThat(response.getStatusCodeValue()).isEqualTo(432); + assertMetricsContainsTag("outcome", "CUSTOM"); + assertMetricsContainsTag("status", "432"); + assertMetricsContainsTag("routeId", "test_custom_http_status"); + assertMetricsContainsTag("routeUri", testUri); + } + + private void assertMetricsContainsTag(String tagKey, String tagValue) { + assertThat(this.meterRegistry.get(REQUEST_METRICS_NAME).tag(tagKey, tagValue) + .timer().count()).isEqualTo(1); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @RestController + @Import(DefaultTestConfig.class) + public static class CustomConfig { + @Value("${test.uri}") + protected String testUri; + + @Bean + public RouteLocator myRouteLocator(RouteLocatorBuilder builder) { + return builder.routes() + .route("test_custom_http_status", r -> r.host("*.setcustomstatus.org") + .filters(f -> f.setStatus(432)).uri(testUri)) + .build(); + } + + @RequestMapping("/httpbin/badtargeturi") + public String exception() { + throw new RuntimeException("an error"); + } + } +} diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/BaseWebClientTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/BaseWebClientTests.java index 1473f00c..603e8bea 100644 --- a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/BaseWebClientTests.java +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/test/BaseWebClientTests.java @@ -22,7 +22,6 @@ import java.time.Duration; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.junit.Before; -import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.web.server.LocalServerPort; @@ -35,6 +34,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.core.annotation.Order; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.client.WebClient; @@ -63,9 +64,11 @@ public class BaseWebClientTests { @Before public void setup() { - //TODO: how to set new ReactorClientHttpConnector() + ClientHttpConnector httpConnector = new ReactorClientHttpConnector(); + baseUri = "http://localhost:" + port; - this.webClient = WebClient.create(baseUri); + this.webClient = WebClient.builder().clientConnector(httpConnector) + .baseUrl(baseUri).build(); this.testClient = WebTestClient.bindToServer().baseUrl(baseUri).build(); }