diff --git a/pom.xml b/pom.xml index 614687e..368b8d8 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ 8 8 UTF-8 + 5.9.0 @@ -199,18 +200,30 @@ io.micrometer micrometer-registry-prometheus - 1.9.3 + 1.9.5 com.github.tomakehurst wiremock-jre8 - 2.33.2 + 2.34.0 provided org.junit.jupiter junit-jupiter-engine - 5.9.0 + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + + + org.assertj + assertj-core + 3.23.1 test diff --git a/src/main/java/com/rasklaad/wiremock/metrics/MetricsConfiguration.java b/src/main/java/com/rasklaad/wiremock/metrics/MetricsConfiguration.java new file mode 100644 index 0000000..64af1bc --- /dev/null +++ b/src/main/java/com/rasklaad/wiremock/metrics/MetricsConfiguration.java @@ -0,0 +1,72 @@ +package com.rasklaad.wiremock.metrics; + +public class MetricsConfiguration { + + private boolean useMappingUrlPattern; + private boolean useRequestUrl; + private boolean registerNotMatchedRequests; + + private boolean ignoreQueryParams; + + private boolean registerAnyUrlMappingAsRequestUrl; + + MetricsConfiguration() { + + } + public MetricsConfiguration useRequestUrl() { + useRequestUrl = true; + return this; + } + + public MetricsConfiguration useMappingUrlPattern() { + useMappingUrlPattern = true; + return this; + } + + public MetricsConfiguration registerNotMatchedRequests() { + registerNotMatchedRequests = true; + return this; + } + + public MetricsConfiguration registerAnyUrlMappingAsRequestUrl() { + registerAnyUrlMappingAsRequestUrl = true; + return this; + } + + public MetricsConfiguration ignoreQueryParams() { + ignoreQueryParams = true; + return this; + } + + static MetricsConfiguration defaultConfiguration() { + return new MetricsConfiguration().useRequestUrl(); + } + + void validate() { + if (useMappingUrlPattern && useRequestUrl) { + throw new IllegalStateException("You can't use both url path and url pattern"); + } + if (!useMappingUrlPattern && !useRequestUrl) { + throw new IllegalStateException("You must use either url path or url pattern"); + } + } + boolean shouldUseMappingUrlPattern() { + return useMappingUrlPattern; + } + + boolean shouldUseRequestUrl() { + return useRequestUrl; + } + + boolean shouldRegisterNotMatchedRequests() { + return registerNotMatchedRequests; + } + + boolean shouldRegisterAnyUrlMappingAsRequestUrl() { + return registerAnyUrlMappingAsRequestUrl; + } + + boolean shouldIgnoreQueryParams() { + return ignoreQueryParams; + } +} diff --git a/src/main/java/com/rasklaad/wiremock/metrics/MetricsEndpointExtension.java b/src/main/java/com/rasklaad/wiremock/metrics/MetricsEndpointExtension.java index b734845..d67365c 100644 --- a/src/main/java/com/rasklaad/wiremock/metrics/MetricsEndpointExtension.java +++ b/src/main/java/com/rasklaad/wiremock/metrics/MetricsEndpointExtension.java @@ -8,16 +8,25 @@ import com.github.tomakehurst.wiremock.http.Request; import com.github.tomakehurst.wiremock.http.RequestMethod; import com.github.tomakehurst.wiremock.http.ResponseDefinition; +import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Metrics; import io.micrometer.prometheus.PrometheusMeterRegistry; import java.net.HttpURLConnection; +import java.util.List; +import java.util.stream.Collectors; public class MetricsEndpointExtension implements AdminApiExtension { public static final String EXTENSION_NAME = "metrics-endpoint-extension"; + + private final AdminTask adminTask; + + public MetricsEndpointExtension() { + adminTask = new PrometheusEndpointAdminTask(); + } @Override public void contributeAdminApiRoutes(Router router) { - router.add(RequestMethod.GET, "/prometheus-metrics", new PrometheusEndpointAdminTask()); + router.add(RequestMethod.GET, "/prometheus-metrics", adminTask); } @Override @@ -30,13 +39,14 @@ private final static class PrometheusEndpointAdminTask implements AdminTask { private final PrometheusMeterRegistry prometheusMeterRegistry; private PrometheusEndpointAdminTask() { - prometheusMeterRegistry = (PrometheusMeterRegistry) Metrics.globalRegistry.getRegistries() + List registries = Metrics.globalRegistry.getRegistries() .stream() .filter(registry -> registry instanceof PrometheusMeterRegistry) - .findFirst() - .orElseThrow(() -> new IllegalStateException("PrometheusMeterRegistry not found," + - " you should define com.rasklaad.wiremock.metrics.PrometheusMetricsExtension" + - " in your wiremock extensions")); + .collect(Collectors.toList()); + if (registries.size() != 1) { + throw new IllegalStateException("Expected exactly one PrometheusMeterRegistry, found " + registries.size()); + } + prometheusMeterRegistry = (PrometheusMeterRegistry) registries.get(0); } @Override diff --git a/src/main/java/com/rasklaad/wiremock/metrics/PrometheusMetricsExtension.java b/src/main/java/com/rasklaad/wiremock/metrics/PrometheusMetricsExtension.java index c9db637..fa5a546 100644 --- a/src/main/java/com/rasklaad/wiremock/metrics/PrometheusMetricsExtension.java +++ b/src/main/java/com/rasklaad/wiremock/metrics/PrometheusMetricsExtension.java @@ -4,8 +4,8 @@ import com.github.tomakehurst.wiremock.core.Admin; import com.github.tomakehurst.wiremock.extension.PostServeAction; import com.github.tomakehurst.wiremock.http.LoggedResponse; +import com.github.tomakehurst.wiremock.matching.UrlPattern; import com.github.tomakehurst.wiremock.stubbing.ServeEvent; -import com.github.tomakehurst.wiremock.stubbing.StubMapping; import com.github.tomakehurst.wiremock.verification.LoggedRequest; import io.micrometer.core.instrument.DistributionSummary; import io.micrometer.core.instrument.Metrics; @@ -16,9 +16,12 @@ import io.micrometer.prometheus.PrometheusConfig; import io.micrometer.prometheus.PrometheusMeterRegistry; +import java.net.URI; + public class PrometheusMetricsExtension extends PostServeAction { public static final String EXTENSION_NAME = "prometheus-metrics-extension"; private final PrometheusMeterRegistry registry; + private MetricsConfiguration configuration; public PrometheusMetricsExtension() { registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); @@ -27,58 +30,108 @@ public PrometheusMetricsExtension() { new ProcessorMetrics().bindTo(registry); new JvmGcMetrics().bindTo(registry); new JvmMemoryMetrics().bindTo(registry); + configuration = MetricsConfiguration.defaultConfiguration(); + } + + public PrometheusMetricsExtension(MetricsConfiguration configuration) { + this(); + configuration.validate(); + this.configuration = configuration; } @Override public void doGlobalAction(ServeEvent serveEvent, Admin admin) { super.doGlobalAction(serveEvent, admin); - StubMapping mapping = serveEvent.getStubMapping(); - if (mapping != null) { - Timing timing = serveEvent.getTiming(); - LoggedRequest request = serveEvent.getRequest(); - LoggedResponse response = serveEvent.getResponse(); - String urlPath = request.getUrl(); - String method = request.getMethod().getName(); - String status = String.valueOf(response.getStatus()); - - DistributionSummary totalTimeSummary = DistributionSummary.builder("wiremock.request.totalTime") - .baseUnit("ms") - .publishPercentiles(0.5, 0.9, 0.95, 0.99) - .description("Request time latency") - .tags("path", urlPath,"method", method, "status", status) - .register(Metrics.globalRegistry); - - DistributionSummary processingTimeSummary = DistributionSummary.builder("wiremock.request.processingTime") - .baseUnit("ms") - .publishPercentiles(0.5, 0.9, 0.95, 0.99) - .description("Processing time latency") - .tags("path", urlPath,"method", method, "status", status) - .register(registry); - - - DistributionSummary serveTimeSummary = DistributionSummary.builder("wiremock.request.serveTime") - .baseUnit("ms") - .publishPercentiles(0.5, 0.9, 0.95, 0.99) - .description("Serve time latency") - .tags("path", urlPath,"method", method, "status", status) - .register(registry); - - DistributionSummary responseSendTime = DistributionSummary.builder("wiremock.request.responseSendTime") - .baseUnit("ms") - .publishPercentiles(0.5, 0.9, 0.95, 0.99) - .description("Response send time latency") - .tags("path", urlPath,"method", method, "status", status) - .register(registry); - - totalTimeSummary.record(timing.getTotalTime()); - processingTimeSummary.record(timing.getProcessTime()); - serveTimeSummary.record(timing.getServeTime()); - responseSendTime.record(timing.getResponseSendTime()); + Timing timing = serveEvent.getTiming(); + LoggedRequest request = serveEvent.getRequest(); + LoggedResponse response = serveEvent.getResponse(); + + String requestUrlPath = request.getUrl(); + String method = request.getMethod().getName(); + String status = String.valueOf(response.getStatus()); + + if (!serveEvent.getWasMatched()) { + if (configuration.shouldRegisterNotMatchedRequests()) { + registerByUrlPath(timing, requestUrlPath, method, status); + } + return; + } + + if (configuration.shouldUseRequestUrl()) { + registerByUrlPath(timing, requestUrlPath, method, status); + return; + } + + UrlPattern urlPattern = serveEvent.getStubMapping().getRequest().getUrlMatcher(); + if (configuration.shouldUseMappingUrlPattern()) { + if (urlPattern == UrlPattern.ANY && configuration.shouldRegisterAnyUrlMappingAsRequestUrl()) { + registerByUrlPath(timing, requestUrlPath, method, status); + } else { + registerByUrlMapping(timing, urlPattern, method, status); + } + } + + } + + private void registerByUrlMapping(Timing timing, UrlPattern urlPattern, String method, String status) { + String mappingUrlPath = urlPattern.getExpected(); + if (configuration.shouldIgnoreQueryParams()) { + mappingUrlPath = URI.create(mappingUrlPath).getPath(); } + register(timing, mappingUrlPath, method, status); + + } + + private void registerByUrlPath(Timing timing, String urlPath, String method, String statusCode) { + String path = urlPath; + if (configuration.shouldIgnoreQueryParams()) { + path = URI.create(urlPath).getPath(); + } + register(timing, path, method, statusCode); + } + + private void register(Timing timing, String path, String method, String statusCode) { + DistributionSummary totalTimeSummary = DistributionSummary.builder("wiremock.request.totalTime") + .baseUnit("ms") + .publishPercentiles(0.5, 0.9, 0.95, 0.99) + .description("Request time latency") + .tags("path", path, "method", method, "status", statusCode) + .register(Metrics.globalRegistry); + + DistributionSummary processingTimeSummary = DistributionSummary.builder("wiremock.request.processingTime") + .baseUnit("ms") + .publishPercentiles(0.5, 0.9, 0.95, 0.99) + .description("Processing time latency") + .tags("path", path, "method", method, "status", statusCode) + .register(registry); + + + DistributionSummary serveTimeSummary = DistributionSummary.builder("wiremock.request.serveTime") + .baseUnit("ms") + .publishPercentiles(0.5, 0.9, 0.95, 0.99) + .description("Serve time latency") + .tags("path", path, "method", method, "status", statusCode) + .register(registry); + + DistributionSummary responseSendTime = DistributionSummary.builder("wiremock.request.responseSendTime") + .baseUnit("ms") + .publishPercentiles(0.5, 0.9, 0.95, 0.99) + .description("Response send time latency") + .tags("path", path, "method", method, "status", statusCode) + .register(registry); + + totalTimeSummary.record(timing.getTotalTime()); + processingTimeSummary.record(timing.getProcessTime()); + serveTimeSummary.record(timing.getServeTime()); + responseSendTime.record(timing.getResponseSendTime()); } @Override public String getName() { return EXTENSION_NAME; } + + public static MetricsConfiguration options() { + return new MetricsConfiguration(); + } } diff --git a/src/test/java/com/rasklaad/wiremock/metrics/MetricsExtensionTest.java b/src/test/java/com/rasklaad/wiremock/metrics/MetricsExtensionTest.java index 7cfa49f..2a92880 100644 --- a/src/test/java/com/rasklaad/wiremock/metrics/MetricsExtensionTest.java +++ b/src/test/java/com/rasklaad/wiremock/metrics/MetricsExtensionTest.java @@ -3,56 +3,236 @@ import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.client.WireMock; import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.matching.UrlPattern; import com.github.tomakehurst.wiremock.stubbing.StubMapping; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Metrics; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeAll; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStreamReader; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; public class MetricsExtensionTest { private static final OkHttpClient client = new OkHttpClient.Builder().build(); - private static WireMockServer server; - @BeforeAll - static void createServer() { - WireMockConfiguration config = new WireMockConfiguration(); - config.extensions(new PrometheusMetricsExtension(), new MetricsEndpointExtension()); - server = new WireMockServer(config); + private WireMockServer server; + + + private WireMockServer startServer(MetricsConfiguration configuration) { + WireMockConfiguration config = new WireMockConfiguration() + .dynamicPort() + .extensions(new PrometheusMetricsExtension(configuration), new MetricsEndpointExtension()); + WireMockServer server = new WireMockServer(config); server.start(); + this.server = server; + // Metrics.add add values from previous tests + Metrics.globalRegistry.getRegistries().forEach(MeterRegistry::clear); + + return server; + } + + private WireMockServer startServer() { + WireMockConfiguration config = new WireMockConfiguration() + .dynamicPort() + .extensions(new PrometheusMetricsExtension(), new MetricsEndpointExtension()); + WireMockServer server = new WireMockServer(config); + server.start(); + this.server = server; + // Metrics.add add values from previous tests + Metrics.globalRegistry.getRegistries().forEach(MeterRegistry::clear); + + return server; } - @AfterAll - static void close() { - server.stop(); + @AfterEach + public void stopServers() { + if (server != null) { + server.stop(); + } + // Metrics.add add values from previous tests + Metrics.globalRegistry.getRegistries().forEach(registry -> { + registry.clear(); + registry.close(); + Metrics.removeRegistry(registry); + }); } + private List scrape(WireMockServer server) throws IOException { + Response response = client.newCall(new Request.Builder() + .url(server.baseUrl() + "/__admin/prometheus-metrics") + .build()).execute(); + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body().byteStream()))) { + return reader.lines().collect(Collectors.toList()); + } + } + + private void httpCall(WireMockServer server, String endpoint) throws IOException { + Request request = new Request.Builder() + .url(server.baseUrl() + endpoint) + .build(); + client.newCall(request).execute().close(); + } + + private StubMapping createDefaultMapping() { + return WireMock.get(WireMock.urlPathEqualTo("/test")) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withBody("test")).build(); + } @Test void wiremockShouldRegisterMetrics() throws IOException, InterruptedException { - StubMapping mapping = WireMock.get(WireMock.urlPathEqualTo("/test")) + WireMockServer server = startServer(); + + server.addStubMapping(WireMock.get(WireMock.urlPathEqualTo("/simple-test")) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withBody("test")).build()); + + httpCall(server, "/simple-test"); + httpCall(server, "/simple-test"); + Thread.sleep(1_000L); + + List metrics = scrape(server); + Assertions.assertThat(metrics).contains("wiremock_request_totalTime_ms_count{method=\"GET\",path=\"/simple-test\",status=\"200\",} 2.0"); + } + + @Test + void shouldThrowExceptionWhenBothMappingAndUrlOptionsAreAbsent() { + Assertions.assertThatThrownBy(() -> startServer(new MetricsConfiguration())) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void shouldThrowExceptionWhenBothMappingAndUrlOptionsArePresent() { + Assertions.assertThatThrownBy(() -> + startServer(new MetricsConfiguration().useMappingUrlPattern().useRequestUrl()) + ).isInstanceOf(IllegalStateException.class); + } + + + private static Stream urlPathProvider() { + return Stream.of( + Arguments.of(WireMock.urlPathEqualTo("/some-test"), "/some-test"), + Arguments.of(WireMock.urlPathMatching("/some-t.+"), "/some-t.+"), + Arguments.of(WireMock.urlEqualTo("/some-test?withQueryParam=true"), "/some-test?withQueryParam=true"), + Arguments.of(WireMock.urlMatching("/some-test.+"), "/some-test.+") + ); + } + @ParameterizedTest + @MethodSource("urlPathProvider") + void shouldRegisterMetricsByMapping(UrlPattern pattern, String expectedMetricPath) throws IOException, InterruptedException { + WireMockServer server = startServer( + new MetricsConfiguration().useMappingUrlPattern() + ); + + StubMapping mapping = WireMock.get(pattern) .willReturn(WireMock.aResponse() .withStatus(200) - .withFixedDelay(200) .withBody("test")).build(); server.addStubMapping(mapping); - Request request = new Request.Builder() - .url(server.baseUrl() + "/test") - .build(); + httpCall(server, "/some-test?withQueryParam=true"); + Thread.sleep(1000L); - client.newCall(request).execute(); - client.newCall(request).execute(); - Thread.sleep(1_000L); + List metrics = scrape(server); + Assertions.assertThat(metrics).contains("wiremock_request_totalTime_ms_count{method=\"GET\",path=\"" + expectedMetricPath + "\",status=\"200\",} 1.0"); + } + + @Test + void shouldIgnoreQueryParamUsingMappingUrl() throws IOException, InterruptedException { + WireMockServer server = startServer( + new MetricsConfiguration() + .useMappingUrlPattern() + .ignoreQueryParams() + ); + server.addStubMapping(WireMock.get(WireMock.urlPathEqualTo("/test-something-with-param")) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withBody("test")).build()); + + httpCall(server, "/test-something-with-param?withQueryParam=true"); + Thread.sleep(1000L); - Response res = client.newCall(new Request.Builder() - .url(server.baseUrl() + "/__admin/prometheus-metrics") - .build()).execute(); + List metrics = scrape(server); + Assertions.assertThat(metrics).contains("wiremock_request_totalTime_ms_count{method=\"GET\",path=\"/test-something-with-param\",status=\"200\",} 1.0"); + } + @Test + void shouldIgnoreQueryParamUsingRequestUrl() throws IOException, InterruptedException { + WireMockServer server = startServer( + new MetricsConfiguration() + .useRequestUrl() + .ignoreQueryParams() + ); + server.addStubMapping(createDefaultMapping()); + + httpCall(server, "/test?withQueryParam=true"); + Thread.sleep(1000L); + + List metrics = scrape(server); + Assertions.assertThat(metrics).contains("wiremock_request_totalTime_ms_count{method=\"GET\",path=\"/test\",status=\"200\",} 1.0"); + } + + @Test + void shouldRegisterNonMatchedRequestsByRequestUrl() throws IOException, InterruptedException { + WireMockServer server = startServer( + new MetricsConfiguration() + .useMappingUrlPattern() + .registerNotMatchedRequests() + ); - String responseBody = res.body().string(); - Assertions.assertTrue(responseBody.contains("wiremock_request_totalTime_ms_count{method=\"GET\",path=\"/test\",status=\"200\",} 2.0")); + httpCall(server, "/test?withQueryParam=true"); + Thread.sleep(1000L); + List metrics = scrape(server); + Assertions.assertThat(metrics).contains("wiremock_request_totalTime_ms_count{method=\"GET\",path=\"/test?withQueryParam=true\",status=\"404\",} 1.0"); } + + @Test + void shouldNotRegisterNonMatchedRequests() throws IOException, InterruptedException { + WireMockServer server = startServer( + new MetricsConfiguration() + .useRequestUrl() + ); + + httpCall(server, "/some-url-that-should-not-be-matched"); + Thread.sleep(1000L); + + List metrics = scrape(server); + List filtered = metrics.stream().filter(s -> s.startsWith("wiremock")).collect(Collectors.toList()); + Assertions.assertThat(filtered).isEmpty(); + + } + + @Test + void shouldRegisterAnyUrlMappingAsRequestUrl() throws IOException, InterruptedException { + WireMockServer server = startServer( + new MetricsConfiguration() + .useMappingUrlPattern() + .registerAnyUrlMappingAsRequestUrl() + ); + + server.addStubMapping(WireMock.any(WireMock.anyUrl()) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withBody("test")).build()); + + httpCall(server, "/any-url?param=true"); + Thread.sleep(1000L); + + List metrics = scrape(server); + Assertions.assertThat(metrics).contains("wiremock_request_totalTime_ms_count{method=\"GET\",path=\"/any-url?param=true\",status=\"200\",} 1.0"); + } + }