From 02754101f52c8c5aae5ad5c1aaa1a699cff68a6e Mon Sep 17 00:00:00 2001 From: Mateusz Rzeszutek Date: Fri, 5 Feb 2021 13:19:06 +0100 Subject: [PATCH] Instrument Netty 4.0 to add Server-Timing header --- instrumentation/netty-3.8/build.gradle | 3 + ...va => ChannelPipelineInstrumentation.java} | 2 +- .../v3_8/NettyInstrumentationModule.java | 13 +- instrumentation/netty-4.0/build.gradle | 19 +++ .../v4_0/ChannelPipelineInstrumentation.java | 84 +++++++++++ .../v4_0/NettyInstrumentationModule.java | 68 +++++++++ .../netty/v4_0/ServerTimingHandler.java | 52 +++++++ .../netty/v4_0/NettyInstrumentationTest.java | 133 ++++++++++++++++++ settings.gradle | 1 + 9 files changed, 371 insertions(+), 4 deletions(-) rename instrumentation/netty-3.8/src/main/java/com/splunk/opentelemetry/netty/v3_8/{NettyChannelPipelineInstrumentation.java => ChannelPipelineInstrumentation.java} (98%) create mode 100644 instrumentation/netty-4.0/build.gradle create mode 100644 instrumentation/netty-4.0/src/main/java/com/splunk/opentelemetry/netty/v4_0/ChannelPipelineInstrumentation.java create mode 100644 instrumentation/netty-4.0/src/main/java/com/splunk/opentelemetry/netty/v4_0/NettyInstrumentationModule.java create mode 100644 instrumentation/netty-4.0/src/main/java/com/splunk/opentelemetry/netty/v4_0/ServerTimingHandler.java create mode 100644 instrumentation/netty-4.0/src/test/java/com/splunk/opentelemetry/netty/v4_0/NettyInstrumentationTest.java diff --git a/instrumentation/netty-3.8/build.gradle b/instrumentation/netty-3.8/build.gradle index 232c6eec4..c5f64c0c4 100644 --- a/instrumentation/netty-3.8/build.gradle +++ b/instrumentation/netty-3.8/build.gradle @@ -7,6 +7,9 @@ dependencies { implementation project(':instrumentation:common') testInstrumentation group: 'io.opentelemetry.javaagent.instrumentation', name: 'opentelemetry-javaagent-netty-3.8', version: versions.opentelemetryJavaagent + testInstrumentation group: 'io.opentelemetry.javaagent.instrumentation', name: 'opentelemetry-javaagent-netty-4.0', version: versions.opentelemetryJavaagent + testInstrumentation group: 'io.opentelemetry.javaagent.instrumentation', name: 'opentelemetry-javaagent-netty-4.1', version: versions.opentelemetryJavaagent + testInstrumentation project(':instrumentation:netty-4.0') testImplementation group: 'io.netty', name: 'netty', version: '3.8.0.Final' } diff --git a/instrumentation/netty-3.8/src/main/java/com/splunk/opentelemetry/netty/v3_8/NettyChannelPipelineInstrumentation.java b/instrumentation/netty-3.8/src/main/java/com/splunk/opentelemetry/netty/v3_8/ChannelPipelineInstrumentation.java similarity index 98% rename from instrumentation/netty-3.8/src/main/java/com/splunk/opentelemetry/netty/v3_8/NettyChannelPipelineInstrumentation.java rename to instrumentation/netty-3.8/src/main/java/com/splunk/opentelemetry/netty/v3_8/ChannelPipelineInstrumentation.java index 90477f19c..76d4b377b 100644 --- a/instrumentation/netty-3.8/src/main/java/com/splunk/opentelemetry/netty/v3_8/NettyChannelPipelineInstrumentation.java +++ b/instrumentation/netty-3.8/src/main/java/com/splunk/opentelemetry/netty/v3_8/ChannelPipelineInstrumentation.java @@ -40,7 +40,7 @@ import org.jboss.netty.handler.codec.http.HttpResponseEncoder; import org.jboss.netty.handler.codec.http.HttpServerCodec; -public class NettyChannelPipelineInstrumentation implements TypeInstrumentation { +public class ChannelPipelineInstrumentation implements TypeInstrumentation { @Override public ElementMatcher classLoaderOptimization() { diff --git a/instrumentation/netty-3.8/src/main/java/com/splunk/opentelemetry/netty/v3_8/NettyInstrumentationModule.java b/instrumentation/netty-3.8/src/main/java/com/splunk/opentelemetry/netty/v3_8/NettyInstrumentationModule.java index 7de003829..611faf306 100644 --- a/instrumentation/netty-3.8/src/main/java/com/splunk/opentelemetry/netty/v3_8/NettyInstrumentationModule.java +++ b/instrumentation/netty-3.8/src/main/java/com/splunk/opentelemetry/netty/v3_8/NettyInstrumentationModule.java @@ -16,6 +16,8 @@ package com.splunk.opentelemetry.netty.v3_8; +import static io.opentelemetry.javaagent.tooling.bytebuddy.matcher.ClassLoaderMatcher.hasClassesNamed; + import com.google.auto.service.AutoService; import com.splunk.opentelemetry.servertiming.ServerTimingHeader; import io.opentelemetry.javaagent.instrumentation.netty.v3_8.ChannelTraceContext; @@ -24,6 +26,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import net.bytebuddy.matcher.ElementMatcher; @AutoService(InstrumentationModule.class) public class NettyInstrumentationModule extends InstrumentationModule { @@ -31,14 +34,18 @@ public NettyInstrumentationModule() { super("netty", "netty-3.8"); } + @Override + public ElementMatcher.Junction classLoaderMatcher() { + return hasClassesNamed("org.jboss.netty.channel.ChannelPipeline"); + } + @Override protected String[] additionalHelperClassNames() { return new String[] { ServerTimingHeader.class.getName(), getClass().getPackage().getName() + ".ServerTimingHandler", getClass().getPackage().getName() + ".ServerTimingHandler$HeadersSetter", - getClass().getPackage().getName() - + ".NettyChannelPipelineInstrumentation$ChannelPipelineUtil", + getClass().getPackage().getName() + ".ChannelPipelineInstrumentation$ChannelPipelineUtil", }; } @@ -62,6 +69,6 @@ public Map contextStore() { @Override public List typeInstrumentations() { - return Collections.singletonList(new NettyChannelPipelineInstrumentation()); + return Collections.singletonList(new ChannelPipelineInstrumentation()); } } diff --git a/instrumentation/netty-4.0/build.gradle b/instrumentation/netty-4.0/build.gradle new file mode 100644 index 000000000..3cda408a6 --- /dev/null +++ b/instrumentation/netty-4.0/build.gradle @@ -0,0 +1,19 @@ +apply from: "$rootDir/gradle/instrumentation.gradle" + +dependencies { + compileOnly group: 'io.netty', name: 'netty-codec-http', version: '4.0.0.Final' + compileOnly group: 'io.opentelemetry.javaagent.instrumentation', name: 'opentelemetry-javaagent-netty-4.0', version: versions.opentelemetryJavaagent + + implementation project(':instrumentation:common') + + testInstrumentation group: 'io.opentelemetry.javaagent.instrumentation', name: 'opentelemetry-javaagent-netty-3.8', version: versions.opentelemetryJavaagent + testInstrumentation group: 'io.opentelemetry.javaagent.instrumentation', name: 'opentelemetry-javaagent-netty-4.0', version: versions.opentelemetryJavaagent + testInstrumentation group: 'io.opentelemetry.javaagent.instrumentation', name: 'opentelemetry-javaagent-netty-4.1', version: versions.opentelemetryJavaagent + testInstrumentation project(':instrumentation:netty-3.8') + + testImplementation group: 'io.netty', name: 'netty-codec-http', version: '4.0.0.Final' +} + +tasks.withType(Test) { + jvmArgs '-Dsplunk.context.server-timing.enabled=true' +} diff --git a/instrumentation/netty-4.0/src/main/java/com/splunk/opentelemetry/netty/v4_0/ChannelPipelineInstrumentation.java b/instrumentation/netty-4.0/src/main/java/com/splunk/opentelemetry/netty/v4_0/ChannelPipelineInstrumentation.java new file mode 100644 index 000000000..5de45a7b0 --- /dev/null +++ b/instrumentation/netty-4.0/src/main/java/com/splunk/opentelemetry/netty/v4_0/ChannelPipelineInstrumentation.java @@ -0,0 +1,84 @@ +/* + * Copyright Splunk Inc. + * + * 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 com.splunk.opentelemetry.netty.v4_0; + +import static io.opentelemetry.javaagent.tooling.bytebuddy.matcher.AgentElementMatchers.implementsInterface; +import static io.opentelemetry.javaagent.tooling.bytebuddy.matcher.ClassLoaderMatcher.hasClassesNamed; +import static net.bytebuddy.matcher.ElementMatchers.isMethod; +import static net.bytebuddy.matcher.ElementMatchers.nameStartsWith; +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; + +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelPipeline; +import io.netty.handler.codec.http.HttpResponseEncoder; +import io.netty.handler.codec.http.HttpServerCodec; +import io.opentelemetry.javaagent.instrumentation.api.CallDepthThreadLocalMap; +import io.opentelemetry.javaagent.tooling.TypeInstrumentation; +import java.util.Collections; +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; + +public class ChannelPipelineInstrumentation implements TypeInstrumentation { + @Override + public ElementMatcher classLoaderOptimization() { + return hasClassesNamed("io.netty.channel.ChannelPipeline"); + } + + @Override + public ElementMatcher typeMatcher() { + return implementsInterface(named("io.netty.channel.ChannelPipeline")); + } + + @Override + public Map, String> transformers() { + return Collections.singletonMap( + isMethod() + .and(nameStartsWith("add")) + .and(takesArgument(2, named("io.netty.channel.ChannelHandler"))), + this.getClass().getName() + "$ChannelPipelineAddAdvice"); + } + + public static class ChannelPipelineAddAdvice { + @Advice.OnMethodEnter + public static int trackCallDepth() { + return CallDepthThreadLocalMap.incrementCallDepth(ServerTimingHandler.class); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void addHandler( + @Advice.Enter int callDepth, + @Advice.This ChannelPipeline pipeline, + @Advice.Argument(2) ChannelHandler handler) { + if (callDepth > 0) { + return; + } + CallDepthThreadLocalMap.reset(ServerTimingHandler.class); + + try { + if (handler instanceof HttpServerCodec || handler instanceof HttpResponseEncoder) { + pipeline.addLast(ServerTimingHandler.class.getName(), new ServerTimingHandler()); + } + } catch (IllegalArgumentException e) { + // Prevented adding duplicate handlers. + } + } + } +} diff --git a/instrumentation/netty-4.0/src/main/java/com/splunk/opentelemetry/netty/v4_0/NettyInstrumentationModule.java b/instrumentation/netty-4.0/src/main/java/com/splunk/opentelemetry/netty/v4_0/NettyInstrumentationModule.java new file mode 100644 index 000000000..56a15891d --- /dev/null +++ b/instrumentation/netty-4.0/src/main/java/com/splunk/opentelemetry/netty/v4_0/NettyInstrumentationModule.java @@ -0,0 +1,68 @@ +/* + * Copyright Splunk Inc. + * + * 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 com.splunk.opentelemetry.netty.v4_0; + +import static io.opentelemetry.javaagent.tooling.bytebuddy.matcher.ClassLoaderMatcher.hasClassesNamed; +import static net.bytebuddy.matcher.ElementMatchers.not; + +import com.google.auto.service.AutoService; +import com.splunk.opentelemetry.servertiming.ServerTimingHeader; +import io.opentelemetry.javaagent.tooling.InstrumentationModule; +import io.opentelemetry.javaagent.tooling.TypeInstrumentation; +import java.util.Collections; +import java.util.List; +import net.bytebuddy.matcher.ElementMatcher; + +@AutoService(InstrumentationModule.class) +public class NettyInstrumentationModule extends InstrumentationModule { + public NettyInstrumentationModule() { + super("netty", "netty-4.0"); + } + + @Override + public ElementMatcher.Junction classLoaderMatcher() { + // Class added in 4.1.0 and not in 4.0.56 to avoid resolving this instrumentation completely + // when using 4.1. + return not(hasClassesNamed("io.netty.handler.codec.http.CombinedHttpHeaders")); + } + + @Override + protected String[] additionalHelperClassNames() { + return new String[] { + ServerTimingHeader.class.getName(), + this.getClass().getPackage().getName() + ".ServerTimingHandler", + this.getClass().getPackage().getName() + ".ServerTimingHandler$HeadersSetter" + }; + } + + // run after the upstream netty instrumentation + @Override + public int getOrder() { + return 1; + } + + // enable the instrumentation only if the server-timing header flag is on + @Override + protected boolean defaultEnabled() { + return super.defaultEnabled() && ServerTimingHeader.shouldEmitServerTimingHeader(); + } + + @Override + public List typeInstrumentations() { + return Collections.singletonList(new ChannelPipelineInstrumentation()); + } +} diff --git a/instrumentation/netty-4.0/src/main/java/com/splunk/opentelemetry/netty/v4_0/ServerTimingHandler.java b/instrumentation/netty-4.0/src/main/java/com/splunk/opentelemetry/netty/v4_0/ServerTimingHandler.java new file mode 100644 index 000000000..9e90d7083 --- /dev/null +++ b/instrumentation/netty-4.0/src/main/java/com/splunk/opentelemetry/netty/v4_0/ServerTimingHandler.java @@ -0,0 +1,52 @@ +/* + * Copyright Splunk Inc. + * + * 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 com.splunk.opentelemetry.netty.v4_0; + +import static io.opentelemetry.javaagent.instrumentation.netty.v4_0.server.NettyHttpServerTracer.tracer; + +import com.splunk.opentelemetry.servertiming.ServerTimingHeader; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelOutboundHandlerAdapter; +import io.netty.channel.ChannelPromise; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpResponse; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapPropagator; + +public class ServerTimingHandler extends ChannelOutboundHandlerAdapter { + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise prm) { + Context context = tracer().getServerContext(ctx.channel()); + if (context == null || !(msg instanceof HttpResponse)) { + ctx.write(msg, prm); + return; + } + + HttpResponse response = (HttpResponse) msg; + ServerTimingHeader.setHeaders(context, response.headers(), HeadersSetter.INSTANCE); + ctx.write(msg, prm); + } + + public static final class HeadersSetter implements TextMapPropagator.Setter { + private static final HeadersSetter INSTANCE = new HeadersSetter(); + + @Override + public void set(HttpHeaders carrier, String key, String value) { + carrier.add(key, value); + } + } +} diff --git a/instrumentation/netty-4.0/src/test/java/com/splunk/opentelemetry/netty/v4_0/NettyInstrumentationTest.java b/instrumentation/netty-4.0/src/test/java/com/splunk/opentelemetry/netty/v4_0/NettyInstrumentationTest.java new file mode 100644 index 000000000..d36047a7b --- /dev/null +++ b/instrumentation/netty-4.0/src/test/java/com/splunk/opentelemetry/netty/v4_0/NettyInstrumentationTest.java @@ -0,0 +1,133 @@ +/* + * Copyright Splunk Inc. + * + * 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 com.splunk.opentelemetry.netty.v4_0; + +import static io.netty.handler.codec.http.HttpHeaders.Names.CONTENT_LENGTH; +import static io.netty.handler.codec.http.HttpHeaders.Names.CONTENT_TYPE; +import static io.netty.handler.codec.http.HttpResponseStatus.OK; +import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.splunk.opentelemetry.servertiming.ServerTimingHeader; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpServerCodec; +import io.opentelemetry.instrumentation.test.AgentTestRunner; +import io.opentelemetry.instrumentation.test.utils.OkHttpUtils; +import io.opentelemetry.instrumentation.test.utils.PortUtils; +import java.util.concurrent.TimeoutException; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class NettyInstrumentationTest extends AgentTestRunner { + private static final OkHttpClient httpClient = OkHttpUtils.client(); + + private static int port; + private static EventLoopGroup server; + + @BeforeAll + static void startServer() throws InterruptedException { + port = PortUtils.randomOpenPort(); + server = new NioEventLoopGroup(); + + new ServerBootstrap() + .group(server) + .childHandler( + new ChannelInitializer<>() { + @Override + protected void initChannel(@NotNull Channel ch) { + var pipeline = ch.pipeline(); + pipeline.addLast(new HttpServerCodec()); + pipeline.addLast( + new SimpleChannelInboundHandler() { + @Override + protected void channelRead0(ChannelHandlerContext ctx, HttpRequest msg) { + var responseBody = Unpooled.copiedBuffer("result", UTF_8); + var response = new DefaultFullHttpResponse(HTTP_1_1, OK, responseBody); + response.headers().set(CONTENT_TYPE, "text/plain"); + response.headers().set(CONTENT_LENGTH, responseBody.readableBytes()); + ctx.write(response); + ctx.flush(); + } + }); + } + }) + .channel(NioServerSocketChannel.class) + .bind(port) + .sync(); + } + + @AfterAll + static void stopServer() { + server.shutdownGracefully(); + } + + @Test + void shouldAddServerTimingHeaders() throws Exception { + // given + var request = new Request.Builder().url(HttpUrl.get("http://localhost:" + port)).get().build(); + + // when + var response = httpClient.newCall(request).execute(); + + // then + assertEquals(200, response.code()); + assertEquals("result", response.body().string()); + + var serverTimingHeader = response.header(ServerTimingHeader.SERVER_TIMING); + assertHeaders(response, serverTimingHeader); + assertServerTimingHeaderContainsTraceId(serverTimingHeader); + } + + private static void assertHeaders(Response response, String serverTimingHeader) { + assertNotNull(serverTimingHeader); + assertEquals( + ServerTimingHeader.SERVER_TIMING, response.header(ServerTimingHeader.EXPOSE_HEADERS)); + } + + private static void assertServerTimingHeaderContainsTraceId(String serverTimingHeader) + throws InterruptedException, TimeoutException { + TEST_WRITER.waitForTraces(1); + + var traces = TEST_WRITER.getTraces(); + assertEquals(1, traces.size()); + + var spans = traces.get(0); + assertEquals(1, spans.size()); + + var serverSpan = spans.get(0); + assertTrue(serverTimingHeader.contains(serverSpan.getTraceId())); + } +} diff --git a/settings.gradle b/settings.gradle index 301d88d48..e4051013d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,6 +19,7 @@ include("agent", "instrumentation:jetty", "instrumentation:liberty", "instrumentation:netty-3.8", + "instrumentation:netty-4.0", "instrumentation:servlet", "instrumentation:servlet-3-testing", "instrumentation:tomcat",