Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,24 @@
package io.opentelemetry.javaagent.instrumentation.okhttp.v3_0;

import static java.util.Collections.singletonList;
import static java.util.Collections.singletonMap;
import static net.bytebuddy.matcher.ElementMatchers.isConstructor;
import static net.bytebuddy.matcher.ElementMatchers.isMethod;
import static net.bytebuddy.matcher.ElementMatchers.named;
import static net.bytebuddy.matcher.ElementMatchers.returns;
import static net.bytebuddy.matcher.ElementMatchers.takesArgument;

import com.google.auto.service.AutoService;
import io.opentelemetry.javaagent.tooling.InstrumentationModule;
import io.opentelemetry.javaagent.tooling.TypeInstrumentation;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.description.method.MethodDescription;
import net.bytebuddy.description.type.TypeDescription;
import net.bytebuddy.matcher.ElementMatcher;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;

@AutoService(InstrumentationModule.class)
Expand All @@ -42,16 +46,65 @@ public ElementMatcher<TypeDescription> typeMatcher() {

@Override
public Map<? extends ElementMatcher<? super MethodDescription>, String> transformers() {
return singletonMap(
Map<ElementMatcher.Junction<MethodDescription>, String> transformers = new HashMap<>();
transformers.put(
isConstructor().and(takesArgument(0, named("okhttp3.OkHttpClient$Builder"))),
OkHttp3InstrumentationModule.class.getName() + "$OkHttp3Advice");
OkHttp3InstrumentationModule.class.getName() + "$OkHttp3ClientConstructorAdvice");
transformers.put(
isMethod().and(named("newBuilder").and(returns(named("okhttp3.OkHttpClient$Builder")))),
OkHttp3InstrumentationModule.class.getName() + "$OkHttp3ClientNewBuilderAdvice");
return transformers;
}
}

public static class OkHttp3Advice {
// This advice makes two guarantees:
// 1) The state of the builder (specifically interceptor list) is the same before and after the
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this relying on an implementation detail that the interceptor is applied directly to a client? Otherwise I don't see how it can work otherwise if we remove it right away. But I think we shouldn't rely on such a special implementation detail, it seems correct for the builder to have our interceptor when it's constructed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing the builder not affecting any objects that have already been built in the past by this builder definitely does not sound like an implementation detail to me, but a very logical part of how the builder pattern works. Unless I misunderstood what you meant by the interceptor being applied to the client directly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I think it is highly unexpected that a call to build mutates the builder itself. This is not usually done by builders and therefore adding such a side effect to that method would not be good instrumentation etiquette.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah if this was the build method I'd get it but it's the builder constructor isn't it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor is effectively filling the role of what is usually done by a build method (in cases where an instance can also be created without a builder), and is private and only called from the build method and the zero-parameter constructor. However, being a constructor does not change that it should leave the builder in the same state, and that the builder can be modified and reused later to build a different client instance.

Copy link
Contributor

@anuraaga anuraaga May 4, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh shoot I had been misreading the constructor to be of the builder, not the client. Sorry for the confusion makes sense now. Is it possible to simplify by changing to instrument the builder constructor to prepopulate interceptors if it doesn't already have it and not instrument the client itself at all?

// OkHttpClient constructor invocation
// 2) The interceptor list of the created OkHttpClient has exactly one instance of the tracing
// interceptor and it is in the end (assuming no other instrumentations)
public static class OkHttp3ClientConstructorAdvice {
@Advice.OnMethodEnter(suppress = Throwable.class)
public static void addTracingInterceptor(@Advice.Argument(0) OkHttpClient.Builder builder) {
public static void addTracingInterceptor(
@Advice.Argument(0) OkHttpClient.Builder builder,
@Advice.Local("otelOriginalInterceptors") List<Interceptor> originalInterceptors) {

if (builder.interceptors().contains(OkHttp3Interceptors.TRACING_INTERCEPTOR)) {
// Potential corner case - the tracing interceptor may be in the builder due to the builder
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the difference vs just calling return without the state management?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A possible case is that the tracing interceptor may be in the middle, therefore it captures a "request" that may not be the actual request at all due to some later interceptor deciding to do something different.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it can be in the middle if we always add it in the constructor (unless already present which is only when constructing a builder from another client, where it again couldn't be in the middle).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible - the third test in OkHttp3Test produces this scenario.

// being manually constructed by adding interceptors from an existing client. In this case,
// save the original interceptors so we can restore them after the constructor call, and
// then remove all tracing interceptors before adding one as the last.
originalInterceptors = new ArrayList<>(builder.interceptors());

while (builder.interceptors().remove(OkHttp3Interceptors.TRACING_INTERCEPTOR)) {}
}

builder.addInterceptor(OkHttp3Interceptors.TRACING_INTERCEPTOR);
}

@Advice.OnMethodExit(suppress = Throwable.class)
public static void removeTracingInterceptor(
@Advice.Argument(0) OkHttpClient.Builder builder,
@Advice.Local("otelOriginalInterceptors") List<Interceptor> originalInterceptors) {

// Restore the interceptor list to what it was before the constructor call. In the common
// case, a single instance of the tracing interceptor was appended to the end, so it will be
// removed. For the corner case where builder already contained the tracing interceptor, the
// original interceptor list was saved to a local and will be restored from there.
if (originalInterceptors != null) {
builder.interceptors().clear();
builder.interceptors().addAll(originalInterceptors);
} else {
builder.interceptors().remove(OkHttp3Interceptors.TRACING_INTERCEPTOR);
}
}
}

public static class OkHttp3ClientNewBuilderAdvice {
@Advice.OnMethodExit(suppress = Throwable.class)
public static void removeTracingInterceptor(@Advice.Return OkHttpClient.Builder builder) {
// Remove the interceptor from the builder returned by newBuilder, as it should be added only
// by the constructor instrumentation to guarantee that it is the last one in the chain.
builder.interceptors().remove(OkHttp3Interceptors.TRACING_INTERCEPTOR);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,97 @@ package io.opentelemetry.javaagent.instrumentation.okhttp.v3_0

import io.opentelemetry.instrumentation.okhttp.v3_0.AbstractOkHttp3Test
import io.opentelemetry.instrumentation.test.AgentTestTrait
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import org.jetbrains.annotations.NotNull

class OkHttp3Test extends AbstractOkHttp3Test implements AgentTestTrait {
@Override
OkHttpClient.Builder configureClient(OkHttpClient.Builder clientBuilder) {
return clientBuilder
}

/**
* Makes sure that the builder interceptors are the same before and after invoking
* {@link OkHttpClient.Builder#build}. This guarantees that there will be no duplicate tracing
* interceptors and that the tracing interceptor is not in the middle of the chain.
*/
def "builder only contains the original interceptors after build"() {
setup:
def customInterceptor = new TestInterceptor()
def clientBuilder = new OkHttpClient.Builder().addInterceptor(customInterceptor)
def originalInterceptors = new ArrayList<>(clientBuilder.interceptors())

when:
clientBuilder.build()

then:
clientBuilder.interceptors() == originalInterceptors
}

/**
* Makes sure that the tracing interceptor is not present in the builder returned by
* {@link OkHttpClient#newBuilder()}. This reduces the chance of the corner case where when
* building a client, the builder already contains the tracing interceptor.
*/
def "builder created from client does not contain tracing interceptor"() {
setup:
def customInterceptor = new TestInterceptor()
def clientBuilder = new OkHttpClient.Builder().addInterceptor(customInterceptor)
def originalInterceptors = new ArrayList<>(clientBuilder.interceptors())

when:
def builderFromClient = clientBuilder.build().newBuilder()

then:
builderFromClient.interceptors() == originalInterceptors
}

/**
* Tests the corner case where the tracing interceptor has been manually added to the builder by
* accessing it from the interceptors list of an existing client. In this case, a client created
* from that builder should only have one tracing interceptor and it must be in the end, but the
* builder state should still be the same before and after building.
*/
def "tracing interceptor in the end for client even if it is in the middle for builder"() {
setup:
def customInterceptor = new TestInterceptor()
def clientBuilder = new OkHttpClient.Builder().addInterceptor(customInterceptor)

when:
def client = clientBuilder.build()

then:
clientBuilder.interceptors().size() == 1
clientBuilder.interceptors().get(0).is(customInterceptor)
client.interceptors().size() == 2
client.interceptors().get(0).is(customInterceptor)
client.interceptors().get(1).is(OkHttp3Interceptors.TRACING_INTERCEPTOR)

when:
def otherInterceptor = new TestInterceptor()
def newClientBuilder = new OkHttpClient.Builder()
.addInterceptor(client.interceptors().get(0))
.addInterceptor(client.interceptors().get(1))
.addInterceptor(client.interceptors().get(1))
.addInterceptor(otherInterceptor)
def originalNewInterceptors = newClientBuilder.interceptors()
def newClient = newClientBuilder.build()

then:
newClientBuilder.interceptors() == originalNewInterceptors
newClient.interceptors().size() == 3
newClient.interceptors().get(0).is(customInterceptor)
newClient.interceptors().get(1).is(otherInterceptor)
newClient.interceptors().get(2).is(OkHttp3Interceptors.TRACING_INTERCEPTOR)
}

private static class TestInterceptor implements Interceptor {

@Override
Response intercept(@NotNull Chain chain) throws IOException {
return chain.proceed(chain.request())
}
}
}