diff --git a/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/build.gradle.kts b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/build.gradle.kts new file mode 100644 index 000000000000..c6f646595e21 --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("otel.java-conventions") +} + +dependencies { + testImplementation("javax.servlet:javax.servlet-api:3.0.1") + testImplementation(project(":instrumentation:servlet:servlet-3.0:javaagent")) +} diff --git a/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetPrintWriterTest.java b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetPrintWriterTest.java new file mode 100644 index 000000000000..eedc842a5792 --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetPrintWriterTest.java @@ -0,0 +1,158 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet; + +import static io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet.TestUtil.readFileAsString; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.Charset; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; +import org.junit.jupiter.api.Test; + +class SnippetPrintWriterTest { + + @Test + void testInjectToTextHtml() throws IOException { + String snippet = "\n "; + String html = readFileAsString("beforeSnippetInjection.html"); + + InMemoryHttpServletResponse response = createInMemoryHttpServletResponse("text/html"); + SnippetInjectingResponseWrapper responseWrapper = + new SnippetInjectingResponseWrapper(response, snippet); + + responseWrapper.getWriter().write(html); + responseWrapper.getWriter().flush(); + + String expectedHtml = readFileAsString("afterSnippetInjection.html"); + assertThat(response.getStringContent()).isEqualTo(expectedHtml); + } + + @Test + void testInjectToChineseTextHtml() throws IOException { + String snippet = "\n "; + String html = readFileAsString("beforeSnippetInjectionChinese.html"); + + InMemoryHttpServletResponse response = createInMemoryHttpServletResponse("text/html"); + SnippetInjectingResponseWrapper responseWrapper = + new SnippetInjectingResponseWrapper(response, snippet); + + responseWrapper.getWriter().write(html); + responseWrapper.getWriter().flush(); + + String expectedHtml = readFileAsString("afterSnippetInjectionChinese.html"); + assertThat(response.getStringContent()).isEqualTo(expectedHtml); + } + + @Test + void shouldNotInjectToTextHtml() throws IOException { + String snippet = "\n "; + String html = readFileAsString("beforeSnippetInjection.html"); + + InMemoryHttpServletResponse response = createInMemoryHttpServletResponse("not/text"); + + SnippetInjectingResponseWrapper responseWrapper = + new SnippetInjectingResponseWrapper(response, snippet); + + responseWrapper.getWriter().write(html); + responseWrapper.getWriter().flush(); + + assertThat(response.getStringContent()).isEqualTo(html); + } + + @Test + void testWriteInt() throws IOException { + String snippet = "\n "; + String html = readFileAsString("beforeSnippetInjection.html"); + + InMemoryHttpServletResponse response = createInMemoryHttpServletResponse("text/html"); + SnippetInjectingResponseWrapper responseWrapper = + new SnippetInjectingResponseWrapper(response, snippet); + + byte[] originalBytes = html.getBytes(Charset.defaultCharset()); + for (byte originalByte : originalBytes) { + responseWrapper.getWriter().write(originalByte); + } + responseWrapper.getWriter().flush(); + + String expectedHtml = readFileAsString("afterSnippetInjection.html"); + assertThat(response.getStringContent()).isEqualTo(expectedHtml); + } + + @Test + void testWriteCharArray() throws IOException { + String snippet = "\n "; + String html = readFileAsString("beforeSnippetInjectionChinese.html"); + + InMemoryHttpServletResponse response = createInMemoryHttpServletResponse("text/html"); + SnippetInjectingResponseWrapper responseWrapper = + new SnippetInjectingResponseWrapper(response, snippet); + + char[] originalChars = html.toCharArray(); + responseWrapper.getWriter().write(originalChars, 0, originalChars.length); + responseWrapper.getWriter().flush(); + + String expectedHtml = readFileAsString("afterSnippetInjectionChinese.html"); + assertThat(response.getStringContent()).isEqualTo(expectedHtml); + } + + @Test + void testWriteWithOffset() throws IOException { + String snippet = "\n "; + String html = readFileAsString("beforeSnippetInjectionChinese.html"); + String extraBuffer = "this buffer should not be print out"; + html = extraBuffer + html; + + InMemoryHttpServletResponse response = createInMemoryHttpServletResponse("text/html"); + SnippetInjectingResponseWrapper responseWrapper = + new SnippetInjectingResponseWrapper(response, snippet); + + responseWrapper + .getWriter() + .write(html, extraBuffer.length(), html.length() - extraBuffer.length()); + responseWrapper.getWriter().flush(); + + String expectedHtml = readFileAsString("afterSnippetInjectionChinese.html"); + assertThat(response.getStringContent()).isEqualTo(expectedHtml); + } + + private static InMemoryHttpServletResponse createInMemoryHttpServletResponse(String contentType) { + HttpServletResponse response = mock(HttpServletResponse.class); + when(response.getContentType()).thenReturn(contentType); + when(response.getStatus()).thenReturn(200); + when(response.containsHeader("content-type")).thenReturn(true); + return new InMemoryHttpServletResponse(response); + } + + private static class InMemoryHttpServletResponse extends HttpServletResponseWrapper { + + private PrintWriter printWriter; + private StringWriter stringWriter; + + InMemoryHttpServletResponse(HttpServletResponse delegate) { + super(delegate); + } + + @Override + public PrintWriter getWriter() { + if (printWriter == null) { + stringWriter = new StringWriter(); + printWriter = new PrintWriter(stringWriter); + } + return printWriter; + } + + String getStringContent() { + printWriter.flush(); + return stringWriter.toString(); + } + } +} diff --git a/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetServletOutputStreamTest.java b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetServletOutputStreamTest.java new file mode 100644 index 000000000000..46018baba9eb --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetServletOutputStreamTest.java @@ -0,0 +1,139 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet; + +import static io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet.TestUtil.readFileAsBytes; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.Charset; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; + +class SnippetServletOutputStreamTest { + + @Test + void testInjectionForStringContainHeadTag() throws IOException { + String snippet = "\n "; + byte[] html = readFileAsBytes("beforeSnippetInjection.html"); + + InjectionState obj = createInjectionStateForTesting(snippet, UTF_8); + InMemoryServletOutputStream out = new InMemoryServletOutputStream(); + + OutputStreamSnippetInjectionHelper helper = new OutputStreamSnippetInjectionHelper(snippet); + boolean injected = helper.handleWrite(obj, out, html, 0, html.length); + assertThat(obj.getHeadTagBytesSeen()).isEqualTo(-1); + assertThat(injected).isEqualTo(true); + + byte[] expectedHtml = readFileAsBytes("afterSnippetInjection.html"); + assertThat(out.getBytes()).isEqualTo(expectedHtml); + } + + @Test + void testInjectionForChinese() throws IOException { + String snippet = "\n "; + byte[] html = readFileAsBytes("beforeSnippetInjectionChinese.html"); + + InjectionState obj = createInjectionStateForTesting(snippet, UTF_8); + InMemoryServletOutputStream out = new InMemoryServletOutputStream(); + + OutputStreamSnippetInjectionHelper helper = new OutputStreamSnippetInjectionHelper(snippet); + boolean injected = helper.handleWrite(obj, out, html, 0, html.length); + + byte[] expectedHtml = readFileAsBytes("afterSnippetInjectionChinese.html"); + assertThat(injected).isTrue(); + assertThat(obj.getHeadTagBytesSeen()).isEqualTo(-1); + assertThat(out.getBytes()).isEqualTo(expectedHtml); + } + + @Test + void testInjectionForStringWithoutHeadTag() throws IOException { + String snippet = "\n "; + byte[] html = readFileAsBytes("htmlWithoutHeadTag.html"); + + InjectionState obj = createInjectionStateForTesting(snippet, UTF_8); + InMemoryServletOutputStream out = new InMemoryServletOutputStream(); + + OutputStreamSnippetInjectionHelper helper = new OutputStreamSnippetInjectionHelper(snippet); + boolean injected = helper.handleWrite(obj, out, html, 0, html.length); + + assertThat(injected).isFalse(); + assertThat(obj.getHeadTagBytesSeen()).isEqualTo(0); + assertThat(out.getBytes()).isEmpty(); + } + + @Test + void testHeadTagSplitAcrossTwoWrites() throws IOException { + String snippet = "\n "; + String htmlFirstPart = "\n\n\n" + + " \n" + + " Title\n" + + "\n" + + "\n" + + "\n" + + "\n" + + ""; + byte[] htmlSecondPartBytes = htmlSecondPart.getBytes(UTF_8); + injected = helper.handleWrite(obj, out, htmlSecondPartBytes, 0, htmlSecondPartBytes.length); + + assertThat(injected).isTrue(); + assertThat(obj.getHeadTagBytesSeen()).isEqualTo(-1); + + String expectedSecondPart = + "ad>\n" + + " \n" + + " \n" + + " Title\n" + + "\n" + + "\n" + + "\n" + + "\n" + + ""; + assertThat(out.getBytes()).isEqualTo(expectedSecondPart.getBytes(UTF_8)); + } + + private static InjectionState createInjectionStateForTesting(String snippet, Charset charset) { + HttpServletResponse response = mock(HttpServletResponse.class); + when(response.isCommitted()).thenReturn(false); + when(response.getCharacterEncoding()).thenReturn(charset.name()); + + return new InjectionState(new SnippetInjectingResponseWrapper(response, snippet)); + } + + private static class InMemoryServletOutputStream extends ServletOutputStream { + + private final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + @Override + public void write(int b) { + baos.write(b); + } + + public byte[] getBytes() { + return baos.toByteArray(); + } + } +} diff --git a/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/TestUtil.java b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/TestUtil.java new file mode 100644 index 000000000000..9095afd2d3da --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/TestUtil.java @@ -0,0 +1,33 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +public class TestUtil { + + protected static byte[] readFileAsBytes(String resourceName) throws IOException { + InputStream in = + SnippetPrintWriterTest.class.getClassLoader().getResourceAsStream(resourceName); + ByteArrayOutputStream result = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int length; + while ((length = in.read(buffer)) != -1) { + result.write(buffer, 0, length); + } + return result.toByteArray(); + } + + protected static String readFileAsString(String resourceName) throws IOException { + return new String(readFileAsBytes(resourceName), UTF_8); + } + + private TestUtil() {} +} diff --git a/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/afterSnippetInjection.html b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/afterSnippetInjection.html new file mode 100644 index 000000000000..ba5ff4ad3ac1 --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/afterSnippetInjection.html @@ -0,0 +1,11 @@ + + + + + + Title + + + + + \ No newline at end of file diff --git a/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/afterSnippetInjectionChinese.html b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/afterSnippetInjectionChinese.html new file mode 100644 index 000000000000..81166f54dd69 --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/afterSnippetInjectionChinese.html @@ -0,0 +1,11 @@ + + + + + + Title + + +

欢迎光临

+ + \ No newline at end of file diff --git a/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/beforeSnippetInjection.html b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/beforeSnippetInjection.html new file mode 100644 index 000000000000..de155ea51d7d --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/beforeSnippetInjection.html @@ -0,0 +1,10 @@ + + + + + Title + + + + + \ No newline at end of file diff --git a/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/beforeSnippetInjectionChinese.html b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/beforeSnippetInjectionChinese.html new file mode 100644 index 000000000000..5594942811a7 --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/beforeSnippetInjectionChinese.html @@ -0,0 +1,10 @@ + + + + + Title + + +

欢迎光临

+ + \ No newline at end of file diff --git a/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/htmlWithoutHeadTag.html b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/htmlWithoutHeadTag.html new file mode 100644 index 000000000000..e0a5f5d9d61c --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/htmlWithoutHeadTag.html @@ -0,0 +1,7 @@ + + + + +

without head tag

+ + \ No newline at end of file diff --git a/instrumentation/servlet/servlet-3.0/javaagent/build.gradle.kts b/instrumentation/servlet/servlet-3.0/javaagent/build.gradle.kts index 689d229e20fd..ee6fcecfeb37 100644 --- a/instrumentation/servlet/servlet-3.0/javaagent/build.gradle.kts +++ b/instrumentation/servlet/servlet-3.0/javaagent/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { compileOnly("javax.servlet:javax.servlet-api:3.0.1") testInstrumentation(project(":instrumentation:jetty:jetty-8.0:javaagent")) + testImplementation(project(":instrumentation:servlet:servlet-common:bootstrap")) testLibrary("org.eclipse.jetty:jetty-server:8.0.0.v20110901") testLibrary("org.eclipse.jetty:jetty-servlet:8.0.0.v20110901") diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3Advice.java b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3Advice.java index 25569ee6d1d0..5b9a115aa1c5 100644 --- a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3Advice.java +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3Advice.java @@ -13,8 +13,10 @@ import io.opentelemetry.javaagent.bootstrap.Java8BytecodeBridge; import io.opentelemetry.javaagent.bootstrap.http.HttpServerResponseCustomizerHolder; import io.opentelemetry.javaagent.bootstrap.servlet.AppServerBridge; +import io.opentelemetry.javaagent.bootstrap.servlet.ExperimentalSnippetHolder; import io.opentelemetry.javaagent.bootstrap.servlet.MappingResolver; import io.opentelemetry.javaagent.instrumentation.servlet.ServletRequestContext; +import io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet.SnippetInjectingResponseWrapper; import javax.servlet.Servlet; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; @@ -41,6 +43,12 @@ public static void onEnter( } HttpServletRequest httpServletRequest = (HttpServletRequest) request; + String snippet = ExperimentalSnippetHolder.getSnippet(); + if (!snippet.isEmpty() + && !((HttpServletResponse) response) + .containsHeader(SnippetInjectingResponseWrapper.FAKE_SNIPPET_HEADER)) { + response = new SnippetInjectingResponseWrapper((HttpServletResponse) response, snippet); + } callDepth = CallDepth.forClass(AppServerBridge.getCallDepthKey()); callDepth.getAndIncrement(); diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3InstrumentationModule.java b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3InstrumentationModule.java index 299efcdd1f5c..94935ade7b7a 100644 --- a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3InstrumentationModule.java +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3InstrumentationModule.java @@ -16,6 +16,7 @@ import io.opentelemetry.javaagent.instrumentation.servlet.common.async.AsyncStartInstrumentation; import io.opentelemetry.javaagent.instrumentation.servlet.common.response.HttpServletResponseInstrumentation; import io.opentelemetry.javaagent.instrumentation.servlet.common.service.ServletAndFilterInstrumentation; +import io.opentelemetry.javaagent.instrumentation.servlet.common.service.ServletOutputStreamInstrumentation; import java.util.List; import net.bytebuddy.matcher.ElementMatcher; @@ -44,6 +45,11 @@ BASE_PACKAGE, adviceClassName(".Servlet3AsyncContextStartAdvice")), adviceClassName(".Servlet3Advice"), adviceClassName(".Servlet3InitAdvice"), adviceClassName(".Servlet3FilterInitAdvice")), + new ServletOutputStreamInstrumentation( + BASE_PACKAGE, + adviceClassName(".Servlet3OutputStreamWriteBytesAndOffsetAdvice"), + adviceClassName(".Servlet3OutputStreamWriteBytesAdvice"), + adviceClassName(".Servlet3OutputStreamWriteIntAdvice")), new HttpServletResponseInstrumentation( BASE_PACKAGE, adviceClassName(".Servlet3ResponseSendAdvice"))); } diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3OutputStreamWriteBytesAdvice.java b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3OutputStreamWriteBytesAdvice.java new file mode 100644 index 000000000000..8cf67e5166ae --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3OutputStreamWriteBytesAdvice.java @@ -0,0 +1,34 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.servlet.v3_0; + +import static io.opentelemetry.javaagent.instrumentation.servlet.v3_0.Servlet3Singletons.getSnippetInjectionHelper; + +import io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet.InjectionState; +import io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet.ServletOutputStreamInjectionState; +import java.io.IOException; +import javax.servlet.ServletOutputStream; +import net.bytebuddy.asm.Advice; + +public class Servlet3OutputStreamWriteBytesAdvice { + + @Advice.OnMethodEnter(skipOn = Advice.OnDefaultValue.class, suppress = Throwable.class) + public static boolean methodEnter( + @Advice.This ServletOutputStream servletOutputStream, @Advice.Argument(0) byte[] write) + throws IOException { + InjectionState state = ServletOutputStreamInjectionState.getInjectionState(servletOutputStream); + if (state == null) { + return true; + } + // if handleWrite returns true, then it means the original bytes + the snippet were written + // to the servletOutputStream, and so we no longer need to execute the original method + // call (see skipOn above) + // if it returns false, then it means nothing was written to the servletOutputStream and the + // original method call should be executed + return !getSnippetInjectionHelper() + .handleWrite(state, servletOutputStream, write, 0, write.length); + } +} diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3OutputStreamWriteBytesAndOffsetAdvice.java b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3OutputStreamWriteBytesAndOffsetAdvice.java new file mode 100644 index 000000000000..563693e795ad --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3OutputStreamWriteBytesAndOffsetAdvice.java @@ -0,0 +1,35 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.servlet.v3_0; + +import static io.opentelemetry.javaagent.instrumentation.servlet.v3_0.Servlet3Singletons.getSnippetInjectionHelper; + +import io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet.InjectionState; +import io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet.ServletOutputStreamInjectionState; +import java.io.IOException; +import javax.servlet.ServletOutputStream; +import net.bytebuddy.asm.Advice; + +public class Servlet3OutputStreamWriteBytesAndOffsetAdvice { + @Advice.OnMethodEnter(skipOn = Advice.OnDefaultValue.class, suppress = Throwable.class) + public static boolean methodEnter( + @Advice.This ServletOutputStream servletOutputStream, + @Advice.Argument(value = 0) byte[] write, + @Advice.Argument(value = 1) int off, + @Advice.Argument(value = 2) int len) + throws IOException { + InjectionState state = ServletOutputStreamInjectionState.getInjectionState(servletOutputStream); + if (state == null) { + return true; + } + // if handleWrite returns true, then it means the original bytes + the snippet were written + // to the servletOutputStream, and so we no longer need to execute the original method + // call (see skipOn above) + // if it returns false, then it means nothing was written to the servletOutputStream and the + // original method call should be executed + return !getSnippetInjectionHelper().handleWrite(state, servletOutputStream, write, off, len); + } +} diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3OutputStreamWriteIntAdvice.java b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3OutputStreamWriteIntAdvice.java new file mode 100644 index 000000000000..a1e140376998 --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3OutputStreamWriteIntAdvice.java @@ -0,0 +1,33 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.servlet.v3_0; + +import static io.opentelemetry.javaagent.instrumentation.servlet.v3_0.Servlet3Singletons.getSnippetInjectionHelper; + +import io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet.InjectionState; +import io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet.ServletOutputStreamInjectionState; +import java.io.IOException; +import javax.servlet.ServletOutputStream; +import net.bytebuddy.asm.Advice; + +public class Servlet3OutputStreamWriteIntAdvice { + + @Advice.OnMethodEnter(skipOn = Advice.OnDefaultValue.class, suppress = Throwable.class) + public static boolean methodEnter( + @Advice.This ServletOutputStream servletOutputStream, @Advice.Argument(0) int write) + throws IOException { + InjectionState state = ServletOutputStreamInjectionState.getInjectionState(servletOutputStream); + if (state == null) { + return true; + } + // if handleWrite returns true, then it means the original bytes + the snippet were written + // to the servletOutputStream, and so we no longer need to execute the original method + // call (see skipOn above) + // if it returns false, then it means nothing was written to the servletOutputStream and the + // original method call should be executed + return !getSnippetInjectionHelper().handleWrite(state, servletOutputStream, write); + } +} diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3Singletons.java b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3Singletons.java index fe4d8a3ce055..6c59213a4619 100644 --- a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3Singletons.java +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3Singletons.java @@ -8,12 +8,14 @@ import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; import io.opentelemetry.instrumentation.api.instrumenter.util.ClassAndMethod; import io.opentelemetry.instrumentation.api.util.VirtualField; +import io.opentelemetry.javaagent.bootstrap.servlet.ExperimentalSnippetHolder; import io.opentelemetry.javaagent.bootstrap.servlet.MappingResolver; import io.opentelemetry.javaagent.instrumentation.servlet.ServletHelper; import io.opentelemetry.javaagent.instrumentation.servlet.ServletInstrumenterBuilder; import io.opentelemetry.javaagent.instrumentation.servlet.ServletRequestContext; import io.opentelemetry.javaagent.instrumentation.servlet.ServletResponseContext; import io.opentelemetry.javaagent.instrumentation.servlet.common.response.ResponseInstrumenterFactory; +import io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet.OutputStreamSnippetInjectionHelper; import javax.servlet.Filter; import javax.servlet.Servlet; import javax.servlet.http.HttpServletRequest; @@ -39,6 +41,9 @@ public final class Servlet3Singletons { private static final Instrumenter RESPONSE_INSTRUMENTER = ResponseInstrumenterFactory.createInstrumenter(INSTRUMENTATION_NAME); + private static final OutputStreamSnippetInjectionHelper SNIPPET_INJECTION_HELPER = + new OutputStreamSnippetInjectionHelper(ExperimentalSnippetHolder.getSnippet()); + public static ServletHelper helper() { return HELPER; } @@ -55,6 +60,10 @@ public static MappingResolver getMappingResolver(Object servletOrFilter) { return null; } + public static OutputStreamSnippetInjectionHelper getSnippetInjectionHelper() { + return SNIPPET_INJECTION_HELPER; + } + private static MappingResolver.Factory getMappingResolverFactory(Object servletOrFilter) { boolean servlet = servletOrFilter instanceof Servlet; if (servlet) { diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/InjectionState.java b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/InjectionState.java new file mode 100644 index 000000000000..a535d81fd152 --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/InjectionState.java @@ -0,0 +1,76 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet; + +// this is shared by both ServletOutputStream and PrintWriter injection +public class InjectionState { + private static final int HEAD_TAG_WRITTEN_FAKE_VALUE = -1; + private static final int HEAD_TAG_LENGTH = "".length(); + private final SnippetInjectingResponseWrapper wrapper; + private int headTagBytesSeen = 0; + + public InjectionState(SnippetInjectingResponseWrapper wrapper) { + this.wrapper = wrapper; + } + + public int getHeadTagBytesSeen() { + return headTagBytesSeen; + } + + public String getCharacterEncoding() { + return wrapper.getCharacterEncoding(); + } + + private void setHeadTagWritten() { + headTagBytesSeen = HEAD_TAG_WRITTEN_FAKE_VALUE; + } + + public boolean isHeadTagWritten() { + return headTagBytesSeen == HEAD_TAG_WRITTEN_FAKE_VALUE; + } + + /** + * Returns true when the byte is the last character of "" and now is the right time to + * inject. Otherwise, returns false. + */ + public boolean processByte(int b) { + if (isHeadTagWritten()) { + return false; + } + if (inHeadTag(b)) { + headTagBytesSeen++; + } else { + headTagBytesSeen = 0; + } + if (headTagBytesSeen == HEAD_TAG_LENGTH) { + setHeadTagWritten(); + return true; + } else { + return false; + } + } + + private boolean inHeadTag(int b) { + if (headTagBytesSeen == 0 && b == '<') { + return true; + } else if (headTagBytesSeen == 1 && b == 'h') { + return true; + } else if (headTagBytesSeen == 2 && b == 'e') { + return true; + } else if (headTagBytesSeen == 3 && b == 'a') { + return true; + } else if (headTagBytesSeen == 4 && b == 'd') { + return true; + } else if (headTagBytesSeen == 5 && b == '>') { + return true; + } + return false; + } + + public SnippetInjectingResponseWrapper getWrapper() { + return wrapper; + } +} diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/OutputStreamSnippetInjectionHelper.java b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/OutputStreamSnippetInjectionHelper.java new file mode 100644 index 000000000000..c75e2e28ee34 --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/OutputStreamSnippetInjectionHelper.java @@ -0,0 +1,93 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet; + +import static java.util.logging.Level.FINE; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.util.logging.Logger; + +public class OutputStreamSnippetInjectionHelper { + + private static final Logger logger = + Logger.getLogger(OutputStreamSnippetInjectionHelper.class.getName()); + + private final String snippet; + + public OutputStreamSnippetInjectionHelper(String snippet) { + this.snippet = snippet; + } + + /** + * return true means this method performed the injection, return false means it didn't inject + * anything Servlet3OutputStreamWriteAdvice would skip the write method when the return value is + * true, and would write the original bytes when the return value is false. + */ + public boolean handleWrite( + InjectionState state, OutputStream out, byte[] original, int off, int length) + throws IOException { + if (state.isHeadTagWritten()) { + return false; + } + int endOfHeadTagPosition; + boolean endOfHeadTagFound = false; + for (endOfHeadTagPosition = off; + endOfHeadTagPosition < length && endOfHeadTagPosition - off < length; + endOfHeadTagPosition++) { + if (state.processByte(original[endOfHeadTagPosition])) { + endOfHeadTagFound = true; + break; + } + } + if (!endOfHeadTagFound) { + return false; + } + + if (state.getWrapper().isNotSafeToInject()) { + return false; + } + byte[] snippetBytes; + try { + snippetBytes = snippet.getBytes(state.getCharacterEncoding()); + } catch (UnsupportedEncodingException e) { + logger.log(FINE, "UnsupportedEncodingException", e); + return false; + } + // updating Content-Length before any further writing in case that writing triggers a flush + state.getWrapper().updateContentLengthIfPreviouslySet(); + out.write(original, off, endOfHeadTagPosition + 1); + out.write(snippetBytes); + out.write(original, endOfHeadTagPosition + 1, length - endOfHeadTagPosition - 1); + return true; + } + + public boolean handleWrite(InjectionState state, OutputStream out, int b) throws IOException { + if (state.isHeadTagWritten()) { + return false; + } + if (!state.processByte(b)) { + return false; + } + + if (state.getWrapper().isNotSafeToInject()) { + return false; + } + byte[] snippetBytes; + try { + snippetBytes = snippet.getBytes(state.getCharacterEncoding()); + } catch (UnsupportedEncodingException e) { + logger.log(FINE, "UnsupportedEncodingException", e); + return false; + } + state.getWrapper().updateContentLengthIfPreviouslySet(); + out.write(b); + + out.write(snippetBytes); + return true; + } +} diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/ServletOutputStreamInjectionState.java b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/ServletOutputStreamInjectionState.java new file mode 100644 index 000000000000..75bda0c2dfdd --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/ServletOutputStreamInjectionState.java @@ -0,0 +1,36 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet; + +import io.opentelemetry.instrumentation.api.util.VirtualField; +import javax.annotation.Nullable; +import javax.servlet.ServletOutputStream; + +public class ServletOutputStreamInjectionState { + + private static final VirtualField virtualField = + VirtualField.find(ServletOutputStream.class, InjectionState.class); + + public static void initializeInjectionStateIfNeeded( + ServletOutputStream servletOutputStream, SnippetInjectingResponseWrapper wrapper) { + InjectionState state = virtualField.get(servletOutputStream); + if (!wrapper.isContentTypeTextHtml()) { + virtualField.set(servletOutputStream, null); + return; + } + if (state == null || state.getWrapper() != wrapper) { + state = new InjectionState(wrapper); + virtualField.set(servletOutputStream, state); + } + } + + @Nullable + public static InjectionState getInjectionState(ServletOutputStream servletOutputStream) { + return virtualField.get(servletOutputStream); + } + + private ServletOutputStreamInjectionState() {} +} diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetInjectingPrintWriter.java b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetInjectingPrintWriter.java new file mode 100644 index 000000000000..55d70d8d9568 --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetInjectingPrintWriter.java @@ -0,0 +1,60 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet; + +import java.io.PrintWriter; + +public class SnippetInjectingPrintWriter extends PrintWriter { + private final String snippet; + private final InjectionState state; + + public SnippetInjectingPrintWriter( + PrintWriter writer, String snippet, SnippetInjectingResponseWrapper wrapper) { + super(writer); + state = new InjectionState(wrapper); + this.snippet = snippet; + } + + @Override + public void write(String s, int off, int len) { + if (state.isHeadTagWritten()) { + super.write(s, off, len); + return; + } + for (int i = off; i < s.length() && i - off < len; i++) { + write(s.charAt(i)); + } + } + + @Override + public void write(int b) { + super.write(b); + if (state.isHeadTagWritten()) { + return; + } + boolean endOfHeadTagFound = state.processByte(b); + if (!endOfHeadTagFound) { + return; + } + + if (state.getWrapper().isNotSafeToInject()) { + return; + } + state.getWrapper().updateContentLengthIfPreviouslySet(); + super.write(snippet); + } + + @Override + public void write(char[] buf, int off, int len) { + if (state.isHeadTagWritten()) { + super.write(buf, off, len); + return; + } + for (int i = off; i < buf.length && i - off < len; i++) { + write(buf[i]); + } + } +} diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetInjectingResponseWrapper.java b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetInjectingResponseWrapper.java new file mode 100644 index 000000000000..f52ceb53369a --- /dev/null +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetInjectingResponseWrapper.java @@ -0,0 +1,186 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet; + +import static io.opentelemetry.javaagent.instrumentation.servlet.v3_0.snippet.ServletOutputStreamInjectionState.initializeInjectionStateIfNeeded; +import static java.util.logging.Level.FINE; + +import java.io.IOException; +import java.io.PrintWriter; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; + +/** + * Notes on Content-Length: the snippet length is only added to the content length when injection + * occurs and the content length was set previously. + * + *

If the Content-Length is set after snippet injection occurs (either for the first time or is + * set again for some reason), we intentionally do not add the snippet length, because the + * application server may be making that call at the end of a request when it sees the request has + * not been submitted, in which case it is likely using the real length of content that has been + * written, including the snippet length. + */ +public class SnippetInjectingResponseWrapper extends HttpServletResponseWrapper { + + private static final Logger logger = Logger.getLogger(HttpServletResponseWrapper.class.getName()); + + public static final String FAKE_SNIPPET_HEADER = "FAKE_SNIPPET_HEADER"; + + private static final int UNSET = -1; + + // this is for Servlet 3.1 support + @Nullable + private static final MethodHandle setContentLengthLongHandler = findSetContentLengthLongMethod(); + + private final String snippet; + private final int snippetLength; + + private long contentLength = UNSET; + + private SnippetInjectingPrintWriter snippetInjectingPrintWriter = null; + + public SnippetInjectingResponseWrapper(HttpServletResponse response, String snippet) { + super(response); + this.snippet = snippet; + snippetLength = snippet.length(); + } + + @Override + public boolean containsHeader(String name) { + // this function is overridden in order to make sure the response is wrapped + // but not wrapped twice + // we don't use req.setAttribute + // because async requests pass down their attributes, but don't pass down our wrapped response + // and so we would see the presence of the attribute and think the response was already wrapped + // when it really is not + // see also https://docs.oracle.com/javaee/7/api/javax/servlet/AsyncContext.html + if (name.equals(FAKE_SNIPPET_HEADER)) { + return true; + } + return super.containsHeader(name); + } + + @Override + public void setHeader(String name, String value) { + // checking content-type is just an optimization to avoid unnecessary parsing + if ("Content-Length".equalsIgnoreCase(name) && isContentTypeTextHtml()) { + try { + contentLength = Long.parseLong(value); + } catch (NumberFormatException ex) { + logger.log(FINE, "NumberFormatException", ex); + } + } + super.setHeader(name, value); + } + + @Override + public void addHeader(String name, String value) { + // checking content-type is just an optimization to avoid unnecessary parsing + if ("Content-Length".equalsIgnoreCase(name) && isContentTypeTextHtml()) { + try { + contentLength = Long.parseLong(value); + } catch (NumberFormatException ex) { + logger.log(FINE, "NumberFormatException", ex); + } + } + super.addHeader(name, value); + } + + @Override + public void setIntHeader(String name, int value) { + // checking content-type is just an optimization to avoid unnecessary parsing + if ("Content-Length".equalsIgnoreCase(name) && isContentTypeTextHtml()) { + contentLength = value; + } + super.setIntHeader(name, value); + } + + @Override + public void addIntHeader(String name, int value) { + // checking content-type is just an optimization to avoid unnecessary parsing + if ("Content-Length".equalsIgnoreCase(name) && isContentTypeTextHtml()) { + contentLength = value; + } + super.addIntHeader(name, value); + } + + @Override + public void setContentLength(int len) { + contentLength = len; + super.setContentLength(len); + } + + @Nullable + private static MethodHandle findSetContentLengthLongMethod() { + try { + return MethodHandles.lookup() + .findSpecial( + HttpServletResponseWrapper.class, + "setContentLengthLong", + MethodType.methodType(void.class), + SnippetInjectingResponseWrapper.class); + } catch (NoSuchMethodException | IllegalAccessException e) { + logger.log(FINE, "SnippetInjectingResponseWrapper setContentLengthLong", e); + return null; + } + } + + // this is for Servlet 3.1 support + public void setContentLengthLong(long length) throws Throwable { + contentLength = length; + if (setContentLengthLongHandler == null) { + super.setContentLength((int) length); + } else { + setContentLengthLongHandler.invokeWithArguments(this, length); + } + } + + boolean isContentTypeTextHtml() { + String contentType = super.getContentType(); + if (contentType == null) { + contentType = super.getHeader("content-type"); + } + return contentType != null && contentType.startsWith("text/html"); + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + ServletOutputStream output = super.getOutputStream(); + initializeInjectionStateIfNeeded(output, this); + return output; + } + + @Override + public PrintWriter getWriter() throws IOException { + if (!isContentTypeTextHtml()) { + return super.getWriter(); + } + if (snippetInjectingPrintWriter == null) { + snippetInjectingPrintWriter = + new SnippetInjectingPrintWriter(super.getWriter(), snippet, this); + } + return snippetInjectingPrintWriter; + } + + void updateContentLengthIfPreviouslySet() { + if (contentLength != UNSET) { + setContentLength((int) contentLength + snippetLength); + } + } + + boolean isNotSafeToInject() { + // if content-length was set and response was already committed (headers sent to the client), + // then it is not safe to inject because the content-length header cannot be updated to account + // for the snippet length + return contentLength != UNSET && isCommitted(); + } +} diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/AbstractServlet3Test.groovy b/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/AbstractServlet3Test.groovy index fd905ddc1f6a..5a9838a60a26 100644 --- a/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/AbstractServlet3Test.groovy +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/AbstractServlet3Test.groovy @@ -7,6 +7,7 @@ import io.opentelemetry.instrumentation.test.AgentTestTrait import io.opentelemetry.instrumentation.test.asserts.TraceAssert import io.opentelemetry.instrumentation.test.base.HttpServerTest import io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint +import io.opentelemetry.javaagent.bootstrap.servlet.ExperimentalSnippetHolder import io.opentelemetry.testing.internal.armeria.common.AggregatedHttpRequest import javax.servlet.Servlet @@ -38,6 +39,32 @@ abstract class AbstractServlet3Test extends HttpServerTest servlet) + public static final ServerEndpoint HTML_PRINT_WRITER = + new ServerEndpoint("HTML_PRINT_WRITER", "htmlPrintWriter", + 200, + "\n" + + "\n" + + "\n" + + " \n" + + " Title\n" + + "\n" + + "\n" + + "

test works

\n" + + "\n" + + "") + public static final ServerEndpoint HTML_SERVLET_OUTPUT_STREAM = + new ServerEndpoint("HTML_SERVLET_OUTPUT_STREAM", "htmlServletOutputStream", + 200, + "\n" + + "\n" + + "\n" + + " \n" + + " Title\n" + + "\n" + + "\n" + + "

test works

\n" + + "\n" + + "") protected void setupServlets(CONTEXT context) { def servlet = servlet() @@ -50,6 +77,8 @@ abstract class AbstractServlet3Test extends HttpServerTest extends HttpServerTest Test ") + def request = request(HTML_SERVLET_OUTPUT_STREAM, "GET") + def response = client.execute(request).aggregate().join() + + expect: + response.status().code() == HTML_SERVLET_OUTPUT_STREAM.status + String result = "\n" + + "\n" + + "\n" + + " \n" + + " \n" + + " Title\n" + + "\n" + + "\n" + + "

test works

\n" + + "\n" + + "" + response.contentUtf8() == result + response.headers().contentLength() == result.length() + } + + def "snippet injection with PrintWriter"() { + setup: + ExperimentalSnippetHolder.setSnippet("\n ") + def request = request(HTML_PRINT_WRITER, "GET") + def response = client.execute(request).aggregate().join() + + expect: + response.status().code() == HTML_PRINT_WRITER.status + String result = "\n" + + "\n" + + "\n" + + " \n" + + " \n" + + " Title\n" + + "\n" + + "\n" + + "

test works

\n" + + "\n" + + "" + + response.contentUtf8() == result + response.headers().contentLength() == result.length() + } } diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/JettyServlet3Test.groovy b/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/JettyServlet3Test.groovy index 8d1549ecf086..f2f93e4c2ef9 100644 --- a/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/JettyServlet3Test.groovy +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/JettyServlet3Test.groovy @@ -170,6 +170,8 @@ class JettyServlet3TestForward extends JettyDispatchTest { super.setupServlets(context) addServlet(context, "/dispatch" + SUCCESS.path, RequestDispatcherServlet.Forward) + addServlet(context, "/dispatch" + HTML_PRINT_WRITER.path, RequestDispatcherServlet.Forward) + addServlet(context, "/dispatch" + HTML_SERVLET_OUTPUT_STREAM.path, RequestDispatcherServlet.Forward) addServlet(context, "/dispatch" + QUERY_PARAM.path, RequestDispatcherServlet.Forward) addServlet(context, "/dispatch" + REDIRECT.path, RequestDispatcherServlet.Forward) addServlet(context, "/dispatch" + ERROR.path, RequestDispatcherServlet.Forward) @@ -207,6 +209,8 @@ class JettyServlet3TestInclude extends JettyDispatchTest { super.setupServlets(context) addServlet(context, "/dispatch" + SUCCESS.path, RequestDispatcherServlet.Include) + addServlet(context, "/dispatch" + HTML_PRINT_WRITER.path, RequestDispatcherServlet.Include) + addServlet(context, "/dispatch" + HTML_SERVLET_OUTPUT_STREAM.path, RequestDispatcherServlet.Include) addServlet(context, "/dispatch" + QUERY_PARAM.path, RequestDispatcherServlet.Include) addServlet(context, "/dispatch" + REDIRECT.path, RequestDispatcherServlet.Include) addServlet(context, "/dispatch" + ERROR.path, RequestDispatcherServlet.Include) @@ -232,7 +236,8 @@ class JettyServlet3TestDispatchImmediate extends JettyDispatchTest { @Override protected void setupServlets(ServletContextHandler context) { super.setupServlets(context) - + addServlet(context, "/dispatch" + HTML_PRINT_WRITER.path, TestServlet3.DispatchImmediate) + addServlet(context, "/dispatch" + HTML_SERVLET_OUTPUT_STREAM.path, TestServlet3.DispatchImmediate) addServlet(context, "/dispatch" + SUCCESS.path, TestServlet3.DispatchImmediate) addServlet(context, "/dispatch" + QUERY_PARAM.path, TestServlet3.DispatchImmediate) addServlet(context, "/dispatch" + ERROR.path, TestServlet3.DispatchImmediate) @@ -260,7 +265,8 @@ class JettyServlet3TestDispatchAsync extends JettyDispatchTest { @Override protected void setupServlets(ServletContextHandler context) { super.setupServlets(context) - + addServlet(context, "/dispatch" + HTML_PRINT_WRITER.path, TestServlet3.DispatchAsync) + addServlet(context, "/dispatch" + HTML_SERVLET_OUTPUT_STREAM.path, TestServlet3.DispatchAsync) addServlet(context, "/dispatch" + SUCCESS.path, TestServlet3.DispatchAsync) addServlet(context, "/dispatch" + QUERY_PARAM.path, TestServlet3.DispatchAsync) addServlet(context, "/dispatch" + ERROR.path, TestServlet3.DispatchAsync) diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/TestServlet3.groovy b/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/TestServlet3.groovy index 2014402497da..9048c157c27f 100644 --- a/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/TestServlet3.groovy +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/TestServlet3.groovy @@ -5,6 +5,7 @@ import io.opentelemetry.instrumentation.test.base.HttpServerTest import io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint + import javax.servlet.RequestDispatcher import javax.servlet.ServletException import javax.servlet.annotation.WebServlet @@ -70,6 +71,19 @@ class TestServlet3 { break case EXCEPTION: throw new ServletException(endpoint.body) + case AbstractServlet3Test.HTML_PRINT_WRITER: + resp.contentType = "text/html" + resp.status = endpoint.status + resp.setContentLengthLong(endpoint.body.length()) + resp.writer.print(endpoint.body) + break + case AbstractServlet3Test.HTML_SERVLET_OUTPUT_STREAM: + resp.contentType = "text/html" + resp.status = endpoint.status + resp.setContentLength(endpoint.body.length()) + byte[] body = endpoint.body.getBytes() + resp.getOutputStream().write(body, 0, body.length) + break } } } @@ -141,6 +155,20 @@ class TestServlet3 { writer.close() } throw new ServletException(endpoint.body) + break + case AbstractServlet3Test.HTML_PRINT_WRITER: + resp.contentType = "text/html" + resp.status = endpoint.status + resp.setContentLength(endpoint.body.length()) + resp.writer.print(endpoint.body) + context.complete() + break + case AbstractServlet3Test.HTML_SERVLET_OUTPUT_STREAM: + resp.contentType = "text/html" + resp.status = endpoint.status + resp.getOutputStream().print(endpoint.body) + context.complete() + break } } } finally { @@ -197,6 +225,17 @@ class TestServlet3 { resp.status = endpoint.status resp.writer.print(endpoint.body) throw new ServletException(endpoint.body) + case AbstractServlet3Test.HTML_PRINT_WRITER: + // intentionally testing setting status before contentType here to cover that case somewhere + resp.status = endpoint.status + resp.contentType = "text/html" + resp.writer.print(endpoint.body) + break + case AbstractServlet3Test.HTML_SERVLET_OUTPUT_STREAM: + resp.contentType = "text/html" + resp.status = endpoint.status + resp.getOutputStream().print(endpoint.body) + break } } } finally { diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/TomcatServlet3Test.groovy b/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/TomcatServlet3Test.groovy index 93cfeacd8e6b..1fc69364a0bc 100644 --- a/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/TomcatServlet3Test.groovy +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/TomcatServlet3Test.groovy @@ -343,6 +343,8 @@ class TomcatServlet3TestForward extends TomcatDispatchTest { addServlet(context, "/dispatch" + CAPTURE_HEADERS.path, RequestDispatcherServlet.Forward) addServlet(context, "/dispatch" + CAPTURE_PARAMETERS.path, RequestDispatcherServlet.Forward) addServlet(context, "/dispatch" + INDEXED_CHILD.path, RequestDispatcherServlet.Forward) + addServlet(context, "/dispatch" + HTML_PRINT_WRITER.path, RequestDispatcherServlet.Forward) + addServlet(context, "/dispatch" + HTML_SERVLET_OUTPUT_STREAM.path, RequestDispatcherServlet.Forward) } } @@ -384,6 +386,8 @@ class TomcatServlet3TestInclude extends TomcatDispatchTest { addServlet(context, "/dispatch" + AUTH_REQUIRED.path, RequestDispatcherServlet.Include) addServlet(context, "/dispatch" + CAPTURE_PARAMETERS.path, RequestDispatcherServlet.Include) addServlet(context, "/dispatch" + INDEXED_CHILD.path, RequestDispatcherServlet.Include) + addServlet(context, "/dispatch" + HTML_PRINT_WRITER.path, RequestDispatcherServlet.Include) + addServlet(context, "/dispatch" + HTML_SERVLET_OUTPUT_STREAM.path, RequestDispatcherServlet.Include) } } @@ -411,6 +415,8 @@ class TomcatServlet3TestDispatchImmediate extends TomcatDispatchTest { addServlet(context, "/dispatch" + CAPTURE_HEADERS.path, TestServlet3.DispatchImmediate) addServlet(context, "/dispatch" + CAPTURE_PARAMETERS.path, TestServlet3.DispatchImmediate) addServlet(context, "/dispatch" + INDEXED_CHILD.path, TestServlet3.DispatchImmediate) + addServlet(context, "/dispatch" + HTML_PRINT_WRITER.path, TestServlet3.DispatchImmediate) + addServlet(context, "/dispatch" + HTML_SERVLET_OUTPUT_STREAM.path, TestServlet3.DispatchImmediate) addServlet(context, "/dispatch/recursive", TestServlet3.DispatchRecursive) } } @@ -434,6 +440,8 @@ class TomcatServlet3TestDispatchAsync extends TomcatDispatchTest { addServlet(context, "/dispatch" + CAPTURE_HEADERS.path, TestServlet3.DispatchAsync) addServlet(context, "/dispatch" + CAPTURE_PARAMETERS.path, TestServlet3.DispatchAsync) addServlet(context, "/dispatch" + INDEXED_CHILD.path, TestServlet3.DispatchAsync) + addServlet(context, "/dispatch" + HTML_PRINT_WRITER.path, TestServlet3.DispatchAsync) + addServlet(context, "/dispatch" + HTML_SERVLET_OUTPUT_STREAM.path, TestServlet3.DispatchAsync) addServlet(context, "/dispatch/recursive", TestServlet3.DispatchRecursive) } diff --git a/instrumentation/servlet/servlet-3.0/javaagent/src/test/java/RequestDispatcherServlet.java b/instrumentation/servlet/servlet-3.0/javaagent/src/test/java/RequestDispatcherServlet.java index 25e2e68d379a..7d9b749a107b 100644 --- a/instrumentation/servlet/servlet-3.0/javaagent/src/test/java/RequestDispatcherServlet.java +++ b/instrumentation/servlet/servlet-3.0/javaagent/src/test/java/RequestDispatcherServlet.java @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint; import java.io.IOException; import javax.servlet.RequestDispatcher; import javax.servlet.ServletContext; @@ -37,6 +38,13 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) String target = req.getServletPath().replace("/dispatch", ""); ServletContext context = getServletContext(); RequestDispatcher dispatcher = context.getRequestDispatcher(target); + // for HTML test case, set the content type before calling include because + // setContentType will be rejected if called inside of include + // check https://statics.teams.cdn.office.net/evergreen-assets/safelinks/1/atp-safelinks.html + if (ServerEndpoint.forPath(target) == ServerEndpoint.forPath("/htmlPrintWriter") + || ServerEndpoint.forPath(target) == ServerEndpoint.forPath("/htmlServletOutputStream")) { + resp.setContentType("text/html"); + } dispatcher.include(req, resp); } } diff --git a/instrumentation/servlet/servlet-common/bootstrap/src/main/java/io/opentelemetry/javaagent/bootstrap/servlet/ExperimentalSnippetHolder.java b/instrumentation/servlet/servlet-common/bootstrap/src/main/java/io/opentelemetry/javaagent/bootstrap/servlet/ExperimentalSnippetHolder.java new file mode 100644 index 000000000000..c6a9a12d9f3e --- /dev/null +++ b/instrumentation/servlet/servlet-common/bootstrap/src/main/java/io/opentelemetry/javaagent/bootstrap/servlet/ExperimentalSnippetHolder.java @@ -0,0 +1,24 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.bootstrap.servlet; + +import java.util.concurrent.atomic.AtomicReference; + +public class ExperimentalSnippetHolder { + + private static final AtomicReference snippet = + new AtomicReference<>(System.getProperty("otel.experimental.javascript-snippet", "")); + + public static void setSnippet(String newValue) { + snippet.compareAndSet("", newValue); + } + + public static String getSnippet() { + return snippet.get(); + } + + private ExperimentalSnippetHolder() {} +} diff --git a/instrumentation/servlet/servlet-common/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/common/service/ServletOutputStreamInstrumentation.java b/instrumentation/servlet/servlet-common/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/common/service/ServletOutputStreamInstrumentation.java new file mode 100644 index 000000000000..631d4f372770 --- /dev/null +++ b/instrumentation/servlet/servlet-common/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/common/service/ServletOutputStreamInstrumentation.java @@ -0,0 +1,64 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.servlet.common.service; + +import static io.opentelemetry.javaagent.extension.matcher.AgentElementMatchers.hasClassesNamed; +import static io.opentelemetry.javaagent.extension.matcher.AgentElementMatchers.hasSuperType; +import static net.bytebuddy.matcher.ElementMatchers.isPublic; +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +import io.opentelemetry.javaagent.extension.instrumentation.TypeInstrumentation; +import io.opentelemetry.javaagent.extension.instrumentation.TypeTransformer; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +public class ServletOutputStreamInstrumentation implements TypeInstrumentation { + private final String basePackageName; + private final String writeBytesAndOffsetAdviceClassName; + private final String writeBytesAdviceClassName; + private final String writeIntAdviceClassName; + + public ServletOutputStreamInstrumentation( + String basePackageName, + String writeBytesAndOffsetAdviceClassName, + String writeBytesAdviceClassName, + String writeIntAdviceClassName) { + this.basePackageName = basePackageName; + this.writeBytesAndOffsetAdviceClassName = writeBytesAndOffsetAdviceClassName; + this.writeBytesAdviceClassName = writeBytesAdviceClassName; + this.writeIntAdviceClassName = writeIntAdviceClassName; + } + + @Override + public ElementMatcher classLoaderOptimization() { + return hasClassesNamed(basePackageName + ".ServletOutputStream"); + } + + @Override + public ElementMatcher typeMatcher() { + return hasSuperType(named(basePackageName + ".ServletOutputStream")); + } + + @Override + public void transform(TypeTransformer transformer) { + transformer.applyAdviceToMethod( + named("write") + .and(takesArguments(3)) + .and(takesArgument(0, byte[].class)) + .and(takesArgument(1, int.class)) + .and(takesArgument(2, int.class)) + .and(isPublic()), + writeBytesAndOffsetAdviceClassName); + transformer.applyAdviceToMethod( + named("write").and(takesArguments(1)).and(takesArgument(0, byte[].class)).and(isPublic()), + writeBytesAdviceClassName); + transformer.applyAdviceToMethod( + named("write").and(takesArguments(1)).and(takesArgument(0, int.class)).and(isPublic()), + writeIntAdviceClassName); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 624afed5475b..f05fd7948536 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -459,6 +459,7 @@ hideFromDependabot(":instrumentation:servlet:servlet-common:javaagent") hideFromDependabot(":instrumentation:servlet:servlet-javax-common:javaagent") hideFromDependabot(":instrumentation:servlet:servlet-2.2:javaagent") hideFromDependabot(":instrumentation:servlet:servlet-3.0:javaagent") +hideFromDependabot(":instrumentation:servlet:servlet-3.0:javaagent-unit-tests") hideFromDependabot(":instrumentation:servlet:servlet-5.0:javaagent") hideFromDependabot(":instrumentation:spark-2.3:javaagent") hideFromDependabot(":instrumentation:spring:spring-batch-3.0:javaagent")