Skip to content

Commit ab1227c

Browse files
authored
Http client instrumentation TCK (#3258)
Introduce test suite for HTTP client instrumentations to check that all implementations have the naming and tags expected. This can also help instrumentors ensure backwards compatibility of the HTTP client instrumentation across changes to it. Introduced as incubating until we get feedback from instrumentation outside of Micrometer on the usability and usefulness of the TCK in this form.
1 parent 8348b60 commit ab1227c

File tree

10 files changed

+402
-9
lines changed

10 files changed

+402
-9
lines changed

micrometer-core/gradle.lockfile

Lines changed: 8 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

micrometer-test/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,6 @@ dependencies {
2222
testImplementation 'com.hazelcast:hazelcast'
2323
testImplementation 'com.squareup.okhttp3:okhttp'
2424
testImplementation 'io.projectreactor.netty:reactor-netty-http'
25+
testImplementation 'org.apache.httpcomponents:httpclient'
26+
testImplementation 'org.eclipse.jetty:jetty-client'
2527
}

micrometer-test/gradle.lockfile

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
* Copyright 2022 VMware, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.micrometer.core.instrument;
17+
18+
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
19+
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
20+
import io.micrometer.core.annotation.Incubating;
21+
import io.micrometer.core.lang.Nullable;
22+
import org.junit.jupiter.api.Disabled;
23+
import org.junit.jupiter.api.Test;
24+
25+
import java.io.IOException;
26+
import java.net.ServerSocket;
27+
import java.net.URI;
28+
import java.util.concurrent.TimeUnit;
29+
30+
import static com.github.tomakehurst.wiremock.client.WireMock.*;
31+
import static org.assertj.core.api.Assertions.assertThat;
32+
33+
/**
34+
* Test suite for HTTP client timing instrumentation that verifies the expected metrics
35+
* are registered and recorded after different scenarios. Use this suite to ensure that
36+
* your instrumentation has the expected naming and tags. A locally running server is used
37+
* to receive real requests from an instrumented HTTP client.
38+
*/
39+
@WireMockTest
40+
@Incubating(since = "1.9.2")
41+
public abstract class HttpClientTimingInstrumentationVerificationTests extends InstrumentationVerificationTests {
42+
43+
enum HttpMethod {
44+
45+
GET, POST;
46+
47+
}
48+
49+
/**
50+
* A default is provided that should be preferred by new instrumentations. Existing
51+
* instrumentations that use a different value to maintain backwards compatibility may
52+
* override this method to run tests with a different name used in assertions.
53+
* @return name of the meter timing http client requests
54+
*/
55+
protected String timerName() {
56+
return "http.client.requests";
57+
}
58+
59+
/**
60+
* Send an HTTP request using the instrumented HTTP client to the given base URL and
61+
* path on the locally running server. The HTTP client instrumentation must be
62+
* configured to tag the templated path to pass this test suite. The templated path
63+
* will contain path variables surrounded by curly brackets to be substituted. For
64+
* example, for the full templated URL {@literal http://localhost:8080/cart/{cartId}}
65+
* the baseUrl would be {@literal http://localhost:8080}, the templatedPath would be
66+
* {@literal /cart/{cartId}}. One string pathVariables argument is expected for
67+
* substituting the cartId path variable. The number of pathVariables arguments SHOULD
68+
* exactly match the number of path variables in the templatedPath.
69+
* @param method http method to use to send the request
70+
* @param baseUrl portion of the URL before the path where to send the request
71+
* @param templatedPath the path portion of the URL after the baseUrl, starting with a
72+
* forward slash, and optionally containing path variable placeholders
73+
* @param pathVariables optional variables to substitute into the templatedPath
74+
*/
75+
abstract void sendHttpRequest(HttpMethod method, @Nullable byte[] body, URI baseUrl, String templatedPath,
76+
String... pathVariables);
77+
78+
/**
79+
* Convenience method provided to substitute the template placeholders for the
80+
* provided path variables. The number of pathVariables argument SHOULD match the
81+
* number of placeholders in the templatedPath. Substitutions will be made in order.
82+
* @param templatedPath a URL path optionally containing placeholders in curly
83+
* brackets
84+
* @param pathVariables path variable values for which placeholders should be
85+
* substituted
86+
* @return path string with substitutions, if any, performed
87+
*/
88+
protected String substitutePathVariables(String templatedPath, String... pathVariables) {
89+
if (pathVariables.length == 0) {
90+
return templatedPath;
91+
}
92+
String substituted = templatedPath;
93+
for (String substitution : pathVariables) {
94+
substituted = substituted.replaceFirst("\\{.*?}", substitution);
95+
}
96+
return substituted;
97+
}
98+
99+
@Test
100+
void getTemplatedPathForUri(WireMockRuntimeInfo wmRuntimeInfo) {
101+
stubFor(get(anyUrl()).willReturn(ok()));
102+
103+
String templatedPath = "/customers/{customerId}/carts/{cartId}";
104+
sendHttpRequest(HttpMethod.GET, null, URI.create(wmRuntimeInfo.getHttpBaseUrl()), templatedPath, "112", "5");
105+
106+
Timer timer = getRegistry().get(timerName()).tags("method", "GET", "status", "200", "uri", templatedPath)
107+
.timer();
108+
assertThat(timer.count()).isEqualTo(1);
109+
assertThat(timer.totalTime(TimeUnit.NANOSECONDS)).isPositive();
110+
}
111+
112+
@Test
113+
@Disabled("apache/jetty http client instrumentation currently fails this test")
114+
void timedWhenServerIsMissing() throws IOException {
115+
int unusedPort = 0;
116+
try (ServerSocket server = new ServerSocket(0)) {
117+
unusedPort = server.getLocalPort();
118+
}
119+
120+
try {
121+
sendHttpRequest(HttpMethod.GET, null, URI.create("http://localhost:" + unusedPort), "/anything");
122+
}
123+
catch (Throwable ignore) {
124+
}
125+
126+
Timer timer = getRegistry().get(timerName()).tags("method", "GET").timer();
127+
128+
assertThat(timer.count()).isEqualTo(1);
129+
assertThat(timer.totalTime(TimeUnit.NANOSECONDS)).isPositive();
130+
}
131+
132+
@Test
133+
void serverException(WireMockRuntimeInfo wmRuntimeInfo) {
134+
stubFor(get(anyUrl()).willReturn(serverError()));
135+
136+
sendHttpRequest(HttpMethod.GET, null, URI.create(wmRuntimeInfo.getHttpBaseUrl()), "/socks");
137+
138+
Timer timer = getRegistry().get(timerName()).tags("method", "GET", "status", "500").timer();
139+
assertThat(timer.count()).isEqualTo(1);
140+
assertThat(timer.totalTime(TimeUnit.NANOSECONDS)).isPositive();
141+
}
142+
143+
@Test
144+
void clientException(WireMockRuntimeInfo wmRuntimeInfo) {
145+
stubFor(post(anyUrl()).willReturn(badRequest()));
146+
147+
sendHttpRequest(HttpMethod.POST, new byte[0], URI.create(wmRuntimeInfo.getHttpBaseUrl()), "/socks");
148+
149+
Timer timer = getRegistry().get(timerName()).tags("method", "POST", "status", "400").timer();
150+
assertThat(timer.count()).isEqualTo(1);
151+
assertThat(timer.totalTime(TimeUnit.NANOSECONDS)).isPositive();
152+
}
153+
154+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright 2022 VMware, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.micrometer.core.instrument;
17+
18+
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
19+
20+
abstract class InstrumentationVerificationTests {
21+
22+
private final MeterRegistry registry = new SimpleMeterRegistry();
23+
24+
MeterRegistry getRegistry() {
25+
return registry;
26+
}
27+
28+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright 2022 VMware, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.micrometer.core.instrument;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright 2022 VMware, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.micrometer.core.instrument;
17+
18+
import io.micrometer.core.instrument.binder.httpcomponents.DefaultUriMapper;
19+
import io.micrometer.core.instrument.binder.httpcomponents.MicrometerHttpRequestExecutor;
20+
import io.micrometer.core.lang.Nullable;
21+
import org.apache.http.client.HttpClient;
22+
import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
23+
import org.apache.http.client.methods.HttpUriRequest;
24+
import org.apache.http.entity.BasicHttpEntity;
25+
import org.apache.http.impl.client.HttpClientBuilder;
26+
import org.apache.http.util.EntityUtils;
27+
28+
import java.io.ByteArrayInputStream;
29+
import java.io.IOException;
30+
import java.net.URI;
31+
32+
class ApacheHttpClientTimingInstrumentationVerificationTests extends HttpClientTimingInstrumentationVerificationTests {
33+
34+
private final HttpClient httpClient = HttpClientBuilder.create()
35+
.setRequestExecutor(MicrometerHttpRequestExecutor.builder(getRegistry()).build()).build();
36+
37+
@Override
38+
protected String timerName() {
39+
return "httpcomponents.httpclient.request";
40+
}
41+
42+
@Override
43+
void sendHttpRequest(HttpMethod method, @Nullable byte[] body, URI baseUri, String templatedPath,
44+
String... pathVariables) {
45+
try {
46+
EntityUtils.consume(
47+
httpClient.execute(makeRequest(method, body, baseUri, templatedPath, pathVariables)).getEntity());
48+
}
49+
catch (IOException e) {
50+
throw new RuntimeException(e);
51+
}
52+
}
53+
54+
private HttpUriRequest makeRequest(HttpMethod method, @Nullable byte[] body, URI baseUri, String templatedPath,
55+
String... pathVariables) {
56+
HttpEntityEnclosingRequestBase request = new HttpEntityEnclosingRequestBase() {
57+
@Override
58+
public String getMethod() {
59+
return method.name();
60+
}
61+
};
62+
if (body != null) {
63+
BasicHttpEntity entity = new BasicHttpEntity();
64+
entity.setContent(new ByteArrayInputStream(body));
65+
request.setEntity(entity);
66+
}
67+
request.setURI(URI.create(baseUri + substitutePathVariables(templatedPath, pathVariables)));
68+
request.setHeader(DefaultUriMapper.URI_PATTERN_HEADER, templatedPath);
69+
return request;
70+
}
71+
72+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright 2022 VMware, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.micrometer.core.instrument;
17+
18+
import io.micrometer.core.instrument.binder.jetty.JettyClientMetrics;
19+
import io.micrometer.core.lang.Nullable;
20+
import org.eclipse.jetty.client.HttpClient;
21+
import org.eclipse.jetty.client.api.Request;
22+
import org.eclipse.jetty.client.util.BytesContentProvider;
23+
import org.junit.jupiter.api.BeforeEach;
24+
25+
import java.net.URI;
26+
27+
class JettyClientTimingInstrumentationVerificationTests extends HttpClientTimingInstrumentationVerificationTests {
28+
29+
private final HttpClient httpClient = new HttpClient();
30+
31+
@Override
32+
protected String timerName() {
33+
return "jetty.client.requests";
34+
}
35+
36+
@BeforeEach
37+
void setup() throws Exception {
38+
httpClient.getRequestListeners().add(JettyClientMetrics
39+
.builder(getRegistry(), result -> result.getRequest().getHeaders().get("URI_PATTERN")).build());
40+
httpClient.start();
41+
}
42+
43+
@Override
44+
void sendHttpRequest(HttpMethod method, @Nullable byte[] body, URI baseUri, String templatedPath,
45+
String... pathVariables) {
46+
try {
47+
Request request = httpClient.newRequest(baseUri + substitutePathVariables(templatedPath, pathVariables))
48+
.method(method.name()).header("URI_PATTERN", templatedPath);
49+
if (body != null) {
50+
request.content(new BytesContentProvider(body));
51+
}
52+
request.send();
53+
httpClient.stop();
54+
}
55+
catch (Exception e) {
56+
throw new RuntimeException(e);
57+
}
58+
}
59+
60+
}

0 commit comments

Comments
 (0)