
io.opentelemetry.instrumentation.testing.junit.http.AbstractHttpClientTest Maven / Gradle / Ivy
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
package io.opentelemetry.instrumentation.testing.junit.http;
import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat;
import static io.opentelemetry.semconv.trace.attributes.SemanticAttributes.NetTransportValues.IP_TCP;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.junit.Assume.assumeFalse;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.instrumentation.test.utils.PortUtils;
import io.opentelemetry.instrumentation.testing.InstrumentationTestRunner;
import io.opentelemetry.sdk.testing.assertj.SpanDataAssert;
import io.opentelemetry.sdk.testing.assertj.TraceAssert;
import io.opentelemetry.sdk.trace.data.SpanData;
import io.opentelemetry.sdk.trace.data.StatusData;
import io.opentelemetry.semconv.trace.attributes.SemanticAttributes;
import java.net.URI;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.IntStream;
import javax.annotation.Nullable;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.condition.DisabledIfSystemProperty;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public abstract class AbstractHttpClientTest {
public static final Duration CONNECTION_TIMEOUT = Duration.ofSeconds(5);
public static final Duration READ_TIMEOUT = Duration.ofSeconds(2);
static final String BASIC_AUTH_KEY = "custom-authorization-header";
static final String BASIC_AUTH_VAL = "plain text auth token";
/**
* Build the request to be passed to {@link #sendRequest(java.lang.Object, java.lang.String,
* java.net.URI, java.util.Map)}.
*
* By splitting this step out separate from {@code sendRequest}, tests and re-execute the same
* request a second time to verify that the traceparent header is not added multiple times to the
* request, and that the last one wins. Tests will fail if the header shows multiple times.
*/
protected abstract REQUEST buildRequest(String method, URI uri, Map headers)
throws Exception;
/**
* Helper class for capturing result of asynchronous request and running a callback when result is
* received.
*/
public static class RequestResult {
private static final long timeout = 10_000;
private final CountDownLatch valueReady = new CountDownLatch(1);
private final Runnable callback;
private int status;
private Throwable throwable;
public RequestResult(Runnable callback) {
this.callback = callback;
}
public void complete(int status) {
complete(() -> status, null);
}
public void complete(Throwable throwable) {
complete(null, throwable);
}
public void complete(Supplier status, Throwable throwable) {
if (throwable != null) {
this.throwable = throwable;
} else {
this.status = status.get();
}
callback.run();
valueReady.countDown();
}
public int get() throws Throwable {
if (!valueReady.await(timeout, TimeUnit.MILLISECONDS)) {
throw new TimeoutException("Timed out waiting for response in " + timeout + "ms");
}
if (throwable != null) {
throw throwable;
}
return status;
}
}
/**
* Make the request and return the status code of the response synchronously. Some clients, e.g.,
* HTTPUrlConnection only support synchronous execution without callbacks, and many offer a
* dedicated API for invoking synchronously, such as OkHttp's execute method.
*/
protected abstract int sendRequest(
REQUEST request, String method, URI uri, Map headers) throws Exception;
protected void sendRequestWithCallback(
REQUEST request,
String method,
URI uri,
Map headers,
RequestResult requestResult)
throws Exception {
// Must be implemented if testAsync is true
throw new UnsupportedOperationException();
}
/** Returns the connection timeout that should be used when setting up tested clients. */
protected final Duration connectTimeout() {
return CONNECTION_TIMEOUT;
}
protected final Duration readTimeout() {
return READ_TIMEOUT;
}
private InstrumentationTestRunner testing;
private HttpClientTestServer server;
private final HttpClientTestOptions options = new HttpClientTestOptions();
@BeforeAll
void setupOptions() {
// TODO(anuraaga): Have subclasses configure options directly and remove mapping of legacy
// protected methods.
options.setHttpAttributes(this::httpAttributes);
options.setExpectedClientSpanNameMapper(this::expectedClientSpanName);
Integer responseCodeOnError = responseCodeOnRedirectError();
if (responseCodeOnError != null) {
options.setResponseCodeOnRedirectError(responseCodeOnError);
}
options.setUserAgent(userAgent());
options.setClientSpanErrorMapper(this::clientSpanError);
options.setSingleConnectionFactory(this::createSingleConnection);
if (!testWithClientParent()) {
options.disableTestWithClientParent();
}
if (!testRedirects()) {
options.disableTestRedirects();
}
if (!testCircularRedirects()) {
options.disableTestCircularRedirects();
}
options.setMaxRedirects(maxRedirects());
if (!testReusedRequest()) {
options.disableTestReusedRequest();
}
if (!testConnectionFailure()) {
options.disableTestConnectionFailure();
}
if (testReadTimeout()) {
options.enableTestReadTimeout();
}
if (!testRemoteConnection()) {
options.disableTestRemoteConnection();
}
if (!testHttps()) {
options.disableTestHttps();
}
if (!testCausality()) {
options.disableTestCausality();
}
if (!testCausalityWithCallback()) {
options.disableTestCausalityWithCallback();
}
if (!testCallback()) {
options.disableTestCallback();
}
if (!testCallbackWithParent()) {
options.disableTestCallbackWithParent();
}
if (!testErrorWithCallback()) {
options.disableTestErrorWithCallback();
}
if (testCallbackWithImplicitParent()) {
options.enableTestCallbackWithImplicitParent();
}
configure(options);
}
@BeforeEach
void verifyExtension() {
if (testing == null) {
throw new AssertionError(
"Subclasses of AbstractHttpClientTest must register HttpClientInstrumentationExtension");
}
}
@ParameterizedTest
@ValueSource(strings = {"/success", "/success?with=params"})
void successfulGetRequest(String path) throws Exception {
URI uri = resolveAddress(path);
String method = "GET";
int responseCode = doRequest(method, uri);
assertThat(responseCode).isEqualTo(200);
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span -> assertClientSpan(span, uri, method, responseCode).hasNoParent(),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)));
});
}
@ParameterizedTest
@ValueSource(strings = {"PUT", "POST"})
void successfulRequestWithParent(String method) throws Exception {
URI uri = resolveAddress("/success");
int responseCode = testing.runWithSpan("parent", () -> doRequest(method, uri));
assertThat(responseCode).isEqualTo(200);
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span -> span.hasName("parent").hasKind(SpanKind.INTERNAL).hasNoParent(),
span -> assertClientSpan(span, uri, method, responseCode).hasParent(trace.getSpan(0)),
span -> assertServerSpan(span).hasParent(trace.getSpan(1)));
});
}
@Test
void successfulRequestWithNotSampledParent() throws Exception {
String method = "GET";
URI uri = resolveAddress("/success");
int responseCode = testing.runWithNonRecordingSpan(() -> doRequest(method, uri));
assertThat(responseCode).isEqualTo(200);
// sleep to ensure no spans are emitted
Thread.sleep(200);
assertThat(testing.traces()).isEmpty();
}
@ParameterizedTest
@ValueSource(strings = {"PUT", "POST"})
void shouldSuppressNestedClientSpanIfAlreadyUnderParentClientSpan(String method)
throws Exception {
assumeTrue(options.testWithClientParent);
URI uri = resolveAddress("/success");
int responseCode =
testing.runWithHttpClientSpan("parent-client-span", () -> doRequest(method, uri));
assertThat(responseCode).isEqualTo(200);
testing.waitAndAssertTraces(
trace ->
trace.hasSpansSatisfyingExactly(
span -> span.hasName("parent-client-span").hasKind(SpanKind.CLIENT).hasNoParent()),
trace -> trace.hasSpansSatisfyingExactly(span -> assertServerSpan(span)));
}
// FIXME: add tests for POST with large/chunked data
@Test
void requestWithCallbackAndParent() throws Throwable {
assumeTrue(options.testCallback);
assumeTrue(options.testCallbackWithParent);
String method = "GET";
URI uri = resolveAddress("/success");
RequestResult result =
testing.runWithSpan(
"parent",
() -> doRequestWithCallback(method, uri, () -> testing.runWithSpan("child", () -> {})));
assertThat(result.get()).isEqualTo(200);
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span -> span.hasName("parent").hasKind(SpanKind.INTERNAL).hasNoParent(),
span -> assertClientSpan(span, uri, method, 200).hasParent(trace.getSpan(0)),
span -> assertServerSpan(span).hasParent(trace.getSpan(1)),
span -> span.hasName("child").hasKind(SpanKind.INTERNAL).hasParent(trace.getSpan(0)));
});
}
@Test
void requestWithCallbackAndNoParent() throws Throwable {
assumeTrue(options.testCallback);
assumeFalse(options.testCallbackWithImplicitParent);
String method = "GET";
URI uri = resolveAddress("/success");
RequestResult result =
doRequestWithCallback(method, uri, () -> testing.runWithSpan("callback", () -> {}));
assertThat(result.get()).isEqualTo(200);
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span -> assertClientSpan(span, uri, method, 200).hasNoParent(),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)));
},
trace ->
trace.hasSpansSatisfyingExactly(
span -> span.hasName("callback").hasKind(SpanKind.INTERNAL).hasNoParent()));
}
@Test
void requestWithCallbackAndImplicitParent() throws Throwable {
assumeTrue(options.testCallbackWithImplicitParent);
String method = "GET";
URI uri = resolveAddress("/success");
RequestResult result =
doRequestWithCallback(method, uri, () -> testing.runWithSpan("callback", () -> {}));
assertThat(result.get()).isEqualTo(200);
testing.waitAndAssertTraces(
trace ->
trace.hasSpansSatisfyingExactly(
span -> assertClientSpan(span, uri, method, 200).hasNoParent(),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)),
span ->
span.hasName("callback")
.hasKind(SpanKind.INTERNAL)
.hasParent(trace.getSpan(0))));
}
@Test
void basicRequestWith1Redirect() throws Exception {
// TODO quite a few clients create an extra span for the redirect
// This test should handle both types or we should unify how the clients work
assumeTrue(options.testRedirects);
String method = "GET";
URI uri = resolveAddress("/redirect");
int responseCode = doRequest(method, uri);
assertThat(responseCode).isEqualTo(200);
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span -> assertClientSpan(span, uri, method, responseCode).hasNoParent(),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)));
});
}
@Test
void basicRequestWith2Redirects() throws Exception {
// TODO quite a few clients create an extra span for the redirect
// This test should handle both types or we should unify how the clients work
assumeTrue(options.testRedirects);
String method = "GET";
URI uri = resolveAddress("/another-redirect");
int responseCode = doRequest(method, uri);
assertThat(responseCode).isEqualTo(200);
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span -> assertClientSpan(span, uri, method, responseCode).hasNoParent(),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)));
});
}
@Test
void circularRedirects() {
assumeTrue(options.testRedirects);
assumeTrue(options.testCircularRedirects);
String method = "GET";
URI uri = resolveAddress("/circular-redirect");
Throwable thrown = catchThrowable(() -> doRequest(method, uri));
Throwable ex;
if (thrown instanceof ExecutionException) {
ex = thrown.getCause();
} else {
ex = thrown;
}
Throwable clientError = options.clientSpanErrorMapper.apply(uri, ex);
testing.waitAndAssertTraces(
trace -> {
List> assertions = new ArrayList<>();
assertions.add(
span ->
assertClientSpan(span, uri, method, options.responseCodeOnRedirectError)
.hasNoParent()
.hasException(clientError));
for (int i = 0; i < options.maxRedirects; i++) {
assertions.add(span -> assertServerSpan(span).hasParent(trace.getSpan(0)));
}
trace.hasSpansSatisfyingExactly(assertions);
});
}
@Test
void redirectToSecuredCopiesAuthHeader() throws Exception {
assumeTrue(options.testRedirects);
String method = "GET";
URI uri = resolveAddress("/to-secured");
int responseCode =
doRequest(method, uri, Collections.singletonMap(BASIC_AUTH_KEY, BASIC_AUTH_VAL));
assertThat(responseCode).isEqualTo(200);
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span -> assertClientSpan(span, uri, method, 200).hasNoParent(),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)));
});
}
@Test
void errorSpan() {
String method = "GET";
URI uri = resolveAddress("/error");
testing.runWithSpan(
"parent",
() -> {
try {
doRequest(method, uri);
} catch (Throwable ignored) {
}
});
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span -> span.hasName("parent").hasKind(SpanKind.INTERNAL).hasNoParent(),
span -> assertClientSpan(span, uri, method, 500).hasParent(trace.getSpan(0)),
span -> assertServerSpan(span).hasParent(trace.getSpan(1)));
});
}
@Test
void reuseRequest() throws Exception {
assumeTrue(options.testReusedRequest);
String method = "GET";
URI uri = resolveAddress("/success");
int responseCode = doReusedRequest(method, uri);
assertThat(responseCode).isEqualTo(200);
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span -> assertClientSpan(span, uri, method, responseCode).hasNoParent(),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)));
},
trace -> {
trace.hasSpansSatisfyingExactly(
span -> assertClientSpan(span, uri, method, responseCode).hasNoParent(),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)));
});
}
// this test verifies two things:
// * the javaagent doesn't cause multiples of tracing headers to be added
// (TestHttpServer throws exception if there are multiples)
// * the javaagent overwrites the existing tracing headers
// (so that it propagates the same trace id / span id that it reports to the backend
// and the trace is not broken)
@Test
void requestWithExistingTracingHeaders() throws Exception {
String method = "GET";
URI uri = resolveAddress("/success");
int responseCode = doRequestWithExistingTracingHeaders(method, uri);
assertThat(responseCode).isEqualTo(200);
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span -> assertClientSpan(span, uri, method, responseCode).hasNoParent(),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)));
});
}
@Test
void connectionErrorUnopenedPort() {
assumeTrue(options.testConnectionFailure);
String method = "GET";
URI uri = URI.create("http://localhost:" + PortUtils.UNUSABLE_PORT + '/');
Throwable thrown =
catchThrowable(() -> testing.runWithSpan("parent", () -> doRequest(method, uri)));
Throwable ex;
if (thrown instanceof ExecutionException) {
ex = thrown.getCause();
} else {
ex = thrown;
}
Throwable clientError = options.clientSpanErrorMapper.apply(uri, ex);
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span ->
span.hasName("parent")
.hasKind(SpanKind.INTERNAL)
.hasNoParent()
.hasStatus(StatusData.error())
.hasException(ex),
span ->
assertClientSpan(span, uri, method, null)
.hasParent(trace.getSpan(0))
.hasException(clientError));
});
}
@Test
void connectionErrorUnopenedPortWithCallback() throws Exception {
assumeTrue(options.testConnectionFailure);
assumeTrue(options.testCallback);
assumeTrue(options.testErrorWithCallback);
String method = "GET";
URI uri = URI.create("http://localhost:" + PortUtils.UNUSABLE_PORT + '/');
RequestResult result =
testing.runWithSpan(
"parent",
() ->
doRequestWithCallback(
method, uri, () -> testing.runWithSpan("callback", () -> {})));
Throwable thrown = catchThrowable(result::get);
Throwable ex;
if (thrown instanceof ExecutionException) {
ex = thrown.getCause();
} else {
ex = thrown;
}
Throwable clientError = options.clientSpanErrorMapper.apply(uri, ex);
testing.waitAndAssertTraces(
trace -> {
List> spanAsserts =
Arrays.asList(
span -> span.hasName("parent").hasKind(SpanKind.INTERNAL).hasNoParent(),
span ->
assertClientSpan(span, uri, method, null)
.hasParent(trace.getSpan(0))
.hasException(clientError),
span ->
span.hasName("callback")
.hasKind(SpanKind.INTERNAL)
.hasParent(trace.getSpan(0)));
boolean jdk8 = "1.8".equals(System.getProperty("java.specification.version"));
if (jdk8) {
// on some netty based http clients order of `CONNECT` and `callback` spans isn't
// guaranteed when running on jdk8
trace.hasSpansSatisfyingExactlyInAnyOrder(spanAsserts);
} else {
trace.hasSpansSatisfyingExactly(spanAsserts);
}
});
}
@Test
void connectionErrorNonRoutableAddress() {
assumeTrue(options.testRemoteConnection);
String method = "HEAD";
URI uri = URI.create(options.testHttps ? "https://192.0.2.1/" : "http://192.0.2.1/");
Throwable thrown =
catchThrowable(() -> testing.runWithSpan("parent", () -> doRequest(method, uri)));
Throwable ex;
if (thrown instanceof ExecutionException) {
ex = thrown.getCause();
} else {
ex = thrown;
}
Throwable clientError = options.clientSpanErrorMapper.apply(uri, ex);
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span ->
span.hasName("parent")
.hasKind(SpanKind.INTERNAL)
.hasNoParent()
.hasStatus(StatusData.error())
.hasException(ex),
span ->
assertClientSpan(span, uri, method, null)
.hasParent(trace.getSpan(0))
.hasException(clientError));
});
}
@Test
void readTimedOut() {
assumeTrue(options.testReadTimeout);
String method = "GET";
URI uri = resolveAddress("/read-timeout");
Throwable thrown =
catchThrowable(() -> testing.runWithSpan("parent", () -> doRequest(method, uri)));
Throwable ex;
if (thrown instanceof ExecutionException) {
ex = thrown.getCause();
} else {
ex = thrown;
}
Throwable clientError = options.clientSpanErrorMapper.apply(uri, ex);
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span ->
span.hasName("parent")
.hasKind(SpanKind.INTERNAL)
.hasNoParent()
.hasStatus(StatusData.error())
.hasException(ex),
span ->
assertClientSpan(span, uri, method, null)
.hasParent(trace.getSpan(0))
.hasException(clientError),
span -> assertServerSpan(span).hasParent(trace.getSpan(1)));
});
}
@DisabledIfSystemProperty(
named = "java.vm.name",
matches = ".*IBM J9 VM.*",
disabledReason = "IBM JVM has different protocol support for TLS")
@Test
void httpsRequest() throws Exception {
assumeTrue(options.testRemoteConnection);
assumeTrue(options.testHttps);
String method = "GET";
URI uri = URI.create("https://localhost:" + server.httpsPort() + "/success");
int responseCode = doRequest(method, uri);
assertThat(responseCode).isEqualTo(200);
testing.waitAndAssertTraces(
trace -> {
trace.hasSpansSatisfyingExactly(
span -> assertClientSpan(span, uri, method, responseCode).hasNoParent(),
span -> assertServerSpan(span).hasParent(trace.getSpan(0)));
});
}
/**
* This test fires a large number of concurrent requests. Each request first hits a HTTP server
* and then makes another client request. The goal of this test is to verify that in highly
* concurrent environment our instrumentations for http clients (especially inherently concurrent
* ones, such as Netty or Reactor) correctly propagate trace context.
*/
@Test
void highConcurrency() {
assumeTrue(options.testCausality);
int count = 50;
String method = "GET";
URI uri = resolveAddress("/success");
CountDownLatch latch = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < count; i++) {
int index = i;
Runnable job =
() -> {
try {
latch.await();
} catch (InterruptedException e) {
throw new AssertionError(e);
}
try {
Integer result =
testing.runWithSpan(
"Parent span " + index,
() -> {
Span.current().setAttribute("test.request.id", index);
return doRequest(
method,
uri,
Collections.singletonMap("test-request-id", String.valueOf(index)));
});
assertThat(result).isEqualTo(200);
} catch (Throwable throwable) {
if (throwable instanceof AssertionError) {
throw (AssertionError) throwable;
}
throw new AssertionError(throwable);
}
};
pool.submit(job);
}
latch.countDown();
List> assertions = new ArrayList<>();
for (int i = 0; i < count; i++) {
assertions.add(
trace -> {
SpanData rootSpan = trace.getSpan(0);
// Traces can be in arbitrary order, let us find out the request id of the current one
int requestId = Integer.parseInt(rootSpan.getName().substring("Parent span ".length()));
trace.hasSpansSatisfyingExactly(
span ->
span.hasName(rootSpan.getName())
.hasKind(SpanKind.INTERNAL)
.hasNoParent()
.hasAttributesSatisfying(
attrs -> assertThat(attrs).containsEntry("test.request.id", requestId)),
span -> assertClientSpan(span, uri, method, 200).hasParent(rootSpan),
span ->
assertServerSpan(span)
.hasParent(trace.getSpan(1))
.hasAttributesSatisfying(
attrs ->
assertThat(attrs).containsEntry("test.request.id", requestId)));
});
}
testing.waitAndAssertTraces(assertions);
pool.shutdown();
}
@Test
void highConcurrencyWithCallback() {
assumeTrue(options.testCausality);
assumeTrue(options.testCausalityWithCallback);
assumeTrue(options.testCallback);
assumeTrue(options.testCallbackWithParent);
int count = 50;
String method = "GET";
URI uri = resolveAddress("/success");
CountDownLatch latch = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(4);
IntStream.range(0, count)
.forEach(
index -> {
Runnable job =
() -> {
try {
latch.await();
} catch (InterruptedException e) {
throw new AssertionError(e);
}
try {
RequestResult result =
testing.runWithSpan(
"Parent span " + index,
() -> {
Span.current().setAttribute("test.request.id", index);
return doRequestWithCallback(
method,
uri,
Collections.singletonMap(
"test-request-id", String.valueOf(index)),
() -> testing.runWithSpan("child", () -> {}));
});
assertThat(result.get()).isEqualTo(200);
} catch (Throwable throwable) {
if (throwable instanceof AssertionError) {
throw (AssertionError) throwable;
}
throw new AssertionError(throwable);
}
};
pool.submit(job);
});
latch.countDown();
List> assertions = new ArrayList<>();
for (int i = 0; i < count; i++) {
assertions.add(
trace -> {
SpanData rootSpan = trace.getSpan(0);
// Traces can be in arbitrary order, let us find out the request id of the current one
int requestId = Integer.parseInt(rootSpan.getName().substring("Parent span ".length()));
trace.hasSpansSatisfyingExactly(
span ->
span.hasName(rootSpan.getName())
.hasKind(SpanKind.INTERNAL)
.hasNoParent()
.hasAttributesSatisfying(
attrs -> assertThat(attrs).containsEntry("test.request.id", requestId)),
span -> assertClientSpan(span, uri, method, 200).hasParent(rootSpan),
span ->
assertServerSpan(span)
.hasParent(trace.getSpan(1))
.hasAttributesSatisfying(
attrs -> assertThat(attrs).containsEntry("test.request.id", requestId)),
span -> span.hasName("child").hasKind(SpanKind.INTERNAL).hasParent(rootSpan));
});
}
testing.waitAndAssertTraces(assertions);
pool.shutdown();
}
/**
* Almost similar to the "high concurrency test" test above, but all requests use the same single
* connection.
*/
@Test
void highConcurrencyOnSingleConnection() {
SingleConnection singleConnection =
options.singleConnectionFactory.apply("localhost", server.httpPort());
assumeTrue(singleConnection != null);
int count = 50;
String method = "GET";
String path = "/success";
URI uri = resolveAddress(path);
CountDownLatch latch = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < count; i++) {
int index = i;
Runnable job =
() -> {
try {
latch.await();
} catch (InterruptedException e) {
throw new AssertionError(e);
}
try {
Integer result =
testing.runWithSpan(
"Parent span " + index,
() -> {
Span.current().setAttribute("test.request.id", index);
return singleConnection.doRequest(
path,
Collections.singletonMap("test-request-id", String.valueOf(index)));
});
assertThat(result).isEqualTo(200);
} catch (Throwable throwable) {
if (throwable instanceof AssertionError) {
throw (AssertionError) throwable;
}
throw new AssertionError(throwable);
}
};
pool.submit(job);
}
latch.countDown();
List> assertions = new ArrayList<>();
for (int i = 0; i < count; i++) {
assertions.add(
trace -> {
SpanData rootSpan = trace.getSpan(0);
// Traces can be in arbitrary order, let us find out the request id of the current one
int requestId = Integer.parseInt(rootSpan.getName().substring("Parent span ".length()));
trace.hasSpansSatisfyingExactly(
span ->
span.hasName(rootSpan.getName())
.hasKind(SpanKind.INTERNAL)
.hasNoParent()
.hasAttributesSatisfying(
attrs -> assertThat(attrs).containsEntry("test.request.id", requestId)),
span -> assertClientSpan(span, uri, method, 200).hasParent(rootSpan),
span ->
assertServerSpan(span)
.hasParent(trace.getSpan(1))
.hasAttributesSatisfying(
attrs ->
assertThat(attrs).containsEntry("test.request.id", requestId)));
});
}
testing.waitAndAssertTraces(assertions);
pool.shutdown();
}
// Visible for spock bridge.
SpanDataAssert assertClientSpan(
SpanDataAssert span, URI uri, String method, Integer responseCode) {
Set> httpClientAttributes = options.httpAttributes.apply(uri);
return span.hasName(options.expectedClientSpanNameMapper.apply(uri, method))
.hasKind(SpanKind.CLIENT)
.hasAttributesSatisfying(
attrs -> {
// TODO: Move to test knob rather than always treating as optional
if (attrs.get(SemanticAttributes.NET_TRANSPORT) != null) {
assertThat(attrs).containsEntry(SemanticAttributes.NET_TRANSPORT, IP_TCP);
}
if (uri.getPort() == PortUtils.UNUSABLE_PORT || uri.getHost().equals("192.0.2.1")) {
// TODO: net.peer.name and net.peer.port should always be populated from the URI or
// the Host header, verify these assertions below
if (attrs.get(SemanticAttributes.NET_PEER_NAME) != null) {
assertThat(attrs).containsEntry(SemanticAttributes.NET_PEER_NAME, uri.getHost());
}
if (attrs.get(SemanticAttributes.NET_PEER_PORT) != null) {
if (uri.getPort() > 0) {
assertThat(attrs)
.containsEntry(SemanticAttributes.NET_PEER_PORT, (long) uri.getPort());
} else {
// https://192.0.2.1/ where some instrumentation may have set this to 443, but
// not all.
assertThat(attrs)
.hasEntrySatisfying(
SemanticAttributes.NET_PEER_PORT,
port -> {
// Some instrumentation seem to set NET_PEER_PORT to -1 incorrectly.
if (port > 0) {
assertThat(port).isEqualTo(options.testHttps ? 443 : 80);
}
});
}
}
// In these cases the peer connection is not established, so the HTTP client should
// not report any socket-level attributes
assertThat(attrs)
.doesNotContainKey("net.sock.family")
// TODO netty sometimes reports net.sock.peer.addr in connection error test
// .doesNotContainKey("net.sock.peer.addr")
.doesNotContainKey("net.sock.peer.name")
.doesNotContainKey("net.sock.peer.port");
} else {
if (httpClientAttributes.contains(SemanticAttributes.NET_PEER_NAME)) {
assertThat(attrs).containsEntry(SemanticAttributes.NET_PEER_NAME, uri.getHost());
}
if (httpClientAttributes.contains(SemanticAttributes.NET_PEER_PORT)) {
assertThat(attrs).containsEntry(SemanticAttributes.NET_PEER_PORT, uri.getPort());
}
// TODO: Move to test knob rather than always treating as optional
if (attrs.get(AttributeKey.stringKey("net.sock.peer.addr")) != null) {
assertThat(attrs)
.containsEntry(AttributeKey.stringKey("net.sock.peer.addr"), "127.0.0.1");
}
if (attrs.get(AttributeKey.stringKey("net.sock.peer.port")) != null) {
assertThat(attrs)
.containsEntry(
AttributeKey.longKey("net.sock.peer.port"),
"https".equals(uri.getScheme()) ? server.httpsPort() : server.httpPort());
}
}
if (httpClientAttributes.contains(SemanticAttributes.HTTP_URL)) {
assertThat(attrs).containsEntry(SemanticAttributes.HTTP_URL, uri.toString());
}
if (httpClientAttributes.contains(SemanticAttributes.HTTP_METHOD)) {
assertThat(attrs).containsEntry(SemanticAttributes.HTTP_METHOD, method);
}
if (httpClientAttributes.contains(SemanticAttributes.HTTP_FLAVOR)) {
// TODO(anuraaga): Support HTTP/2
assertThat(attrs)
.containsEntry(
SemanticAttributes.HTTP_FLAVOR,
SemanticAttributes.HttpFlavorValues.HTTP_1_1);
}
if (httpClientAttributes.contains(SemanticAttributes.HTTP_USER_AGENT)) {
String userAgent = options.userAgent;
if (userAgent != null) {
assertThat(attrs)
.hasEntrySatisfying(
SemanticAttributes.HTTP_USER_AGENT,
actual -> assertThat(actual).startsWith(userAgent));
}
}
if (attrs.get(SemanticAttributes.HTTP_REQUEST_CONTENT_LENGTH) != null) {
assertThat(attrs)
.hasEntrySatisfying(
SemanticAttributes.HTTP_REQUEST_CONTENT_LENGTH,
length -> assertThat(length).isNotNegative());
}
if (attrs.get(SemanticAttributes.HTTP_RESPONSE_CONTENT_LENGTH) != null) {
assertThat(attrs)
.hasEntrySatisfying(
SemanticAttributes.HTTP_RESPONSE_CONTENT_LENGTH,
length -> assertThat(length).isNotNegative());
}
if (responseCode != null) {
assertThat(attrs)
.containsEntry(SemanticAttributes.HTTP_STATUS_CODE, (long) responseCode);
} else {
// worth adding AttributesAssert.doesNotContainKey?
assertThat(attrs.get(SemanticAttributes.HTTP_STATUS_CODE)).isNull();
}
});
}
// Visible for spock bridge.
static SpanDataAssert assertServerSpan(SpanDataAssert span) {
return span.hasName("test-http-server").hasKind(SpanKind.SERVER);
}
protected Set> httpAttributes(URI uri) {
Set> attributes = new HashSet<>();
attributes.add(SemanticAttributes.HTTP_URL);
attributes.add(SemanticAttributes.HTTP_METHOD);
attributes.add(SemanticAttributes.HTTP_FLAVOR);
attributes.add(SemanticAttributes.HTTP_USER_AGENT);
return attributes;
}
protected String expectedClientSpanName(URI uri, String method) {
return method != null ? "HTTP " + method : "HTTP request";
}
@Nullable
protected Integer responseCodeOnRedirectError() {
return null;
}
@Nullable
protected String userAgent() {
return null;
}
protected Throwable clientSpanError(URI uri, Throwable exception) {
return exception;
}
// This method should create either a single connection to the target uri or a http client
// which is guaranteed to use the same connection for all requests
@Nullable
protected SingleConnection createSingleConnection(String host, int port) {
return null;
}
protected boolean testWithClientParent() {
return true;
}
protected boolean testRedirects() {
return true;
}
protected boolean testCircularRedirects() {
return true;
}
// maximum number of redirects that http client follows before giving up
protected int maxRedirects() {
return 2;
}
protected boolean testReusedRequest() {
return true;
}
protected boolean testConnectionFailure() {
return true;
}
protected boolean testReadTimeout() {
return false;
}
protected boolean testRemoteConnection() {
return true;
}
protected boolean testHttps() {
return true;
}
protected boolean testCausality() {
return true;
}
protected boolean testCausalityWithCallback() {
return true;
}
protected boolean testCallback() {
return true;
}
protected boolean testCallbackWithParent() {
// FIXME: this hack is here because callback with parent is broken in play-ws when the stream()
// function is used. There is no way to stop a test from a derived class hence the flag
return true;
}
protected boolean testCallbackWithImplicitParent() {
// depending on async behavior callback can be executed within
// parent span scope or outside of the scope, e.g. in reactor-netty or spring
// callback is correlated.
return false;
}
protected boolean testErrorWithCallback() {
return true;
}
protected void configure(HttpClientTestOptions options) {}
private int doRequest(String method, URI uri) throws Exception {
return doRequest(method, uri, Collections.emptyMap());
}
private int doRequest(String method, URI uri, Map headers) throws Exception {
REQUEST request = buildRequest(method, uri, headers);
return sendRequest(request, method, uri, headers);
}
private int doReusedRequest(String method, URI uri) throws Exception {
REQUEST request = buildRequest(method, uri, Collections.emptyMap());
sendRequest(request, method, uri, Collections.emptyMap());
return sendRequest(request, method, uri, Collections.emptyMap());
}
private int doRequestWithExistingTracingHeaders(String method, URI uri) throws Exception {
Map headers = new HashMap<>();
for (String field :
testing.getOpenTelemetry().getPropagators().getTextMapPropagator().fields()) {
headers.put(field, "12345789");
}
REQUEST request = buildRequest(method, uri, headers);
return sendRequest(request, method, uri, headers);
}
private RequestResult doRequestWithCallback(String method, URI uri, Runnable callback)
throws Exception {
return doRequestWithCallback(method, uri, Collections.emptyMap(), callback);
}
private RequestResult doRequestWithCallback(
String method, URI uri, Map headers, Runnable callback) throws Exception {
REQUEST request = buildRequest(method, uri, headers);
RequestResult requestResult = new RequestResult(callback);
sendRequestWithCallback(request, method, uri, headers, requestResult);
return requestResult;
}
protected URI resolveAddress(String path) {
return URI.create("http://localhost:" + server.httpPort() + path);
}
final void setTesting(InstrumentationTestRunner testing, HttpClientTestServer server) {
this.testing = testing;
this.server = server;
}
}