Parcourir la source

JavaScript Snippet Injection (#7650)

Co-authored-by: Trask Stalnaker <trask.stalnaker@gmail.com>
siyuniu-ms il y a 1 an
Parent
commit
c7bd3e4d6a
29 fichiers modifiés avec 1192 ajouts et 2 suppressions
  1. 8 0
      instrumentation/servlet/servlet-3.0/javaagent-unit-tests/build.gradle.kts
  2. 158 0
      instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetPrintWriterTest.java
  3. 139 0
      instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetServletOutputStreamTest.java
  4. 33 0
      instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/TestUtil.java
  5. 11 0
      instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/afterSnippetInjection.html
  6. 11 0
      instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/afterSnippetInjectionChinese.html
  7. 10 0
      instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/beforeSnippetInjection.html
  8. 10 0
      instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/beforeSnippetInjectionChinese.html
  9. 7 0
      instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/htmlWithoutHeadTag.html
  10. 1 0
      instrumentation/servlet/servlet-3.0/javaagent/build.gradle.kts
  11. 8 0
      instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3Advice.java
  12. 6 0
      instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3InstrumentationModule.java
  13. 34 0
      instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3OutputStreamWriteBytesAdvice.java
  14. 35 0
      instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3OutputStreamWriteBytesAndOffsetAdvice.java
  15. 33 0
      instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3OutputStreamWriteIntAdvice.java
  16. 9 0
      instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3Singletons.java
  17. 76 0
      instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/InjectionState.java
  18. 93 0
      instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/OutputStreamSnippetInjectionHelper.java
  19. 36 0
      instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/ServletOutputStreamInjectionState.java
  20. 60 0
      instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetInjectingPrintWriter.java
  21. 186 0
      instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/snippet/SnippetInjectingResponseWrapper.java
  22. 76 0
      instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/AbstractServlet3Test.groovy
  23. 8 2
      instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/JettyServlet3Test.groovy
  24. 39 0
      instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/TestServlet3.groovy
  25. 8 0
      instrumentation/servlet/servlet-3.0/javaagent/src/test/groovy/TomcatServlet3Test.groovy
  26. 8 0
      instrumentation/servlet/servlet-3.0/javaagent/src/test/java/RequestDispatcherServlet.java
  27. 24 0
      instrumentation/servlet/servlet-common/bootstrap/src/main/java/io/opentelemetry/javaagent/bootstrap/servlet/ExperimentalSnippetHolder.java
  28. 64 0
      instrumentation/servlet/servlet-common/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/common/service/ServletOutputStreamInstrumentation.java
  29. 1 0
      settings.gradle.kts

+ 8 - 0
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"))
+}

+ 158 - 0
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  <script type=\"text/javascript\"> Test </script>";
+    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  <script type=\"text/javascript\"> Test </script>";
+    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  <script type=\"text/javascript\"> Test </script>";
+    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  <script type=\"text/javascript\"> Test </script>";
+    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  <script type=\"text/javascript\"> Test </script>";
+    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  <script type=\"text/javascript\"> Test </script>";
+    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();
+    }
+  }
+}

+ 139 - 0
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  <script type=\"text/javascript\"> Test </script>";
+    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  <script type=\"text/javascript\"> Test </script>";
+    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  <script type=\"text/javascript\"> Test </script>";
+    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  <script type=\"text/javascript\"> Test </script>";
+    String htmlFirstPart = "<!DOCTYPE html>\n<html lang=\"en\">\n<he";
+    byte[] htmlFirstPartBytes = htmlFirstPart.getBytes(UTF_8);
+
+    InjectionState obj = createInjectionStateForTesting(snippet, UTF_8);
+    InMemoryServletOutputStream out = new InMemoryServletOutputStream();
+
+    OutputStreamSnippetInjectionHelper helper = new OutputStreamSnippetInjectionHelper(snippet);
+    boolean injected =
+        helper.handleWrite(obj, out, htmlFirstPartBytes, 0, htmlFirstPartBytes.length);
+
+    assertThat(injected).isFalse();
+    assertThat(obj.getHeadTagBytesSeen()).isEqualTo(3);
+    assertThat(out.getBytes()).isEmpty();
+
+    String htmlSecondPart =
+        "ad>\n"
+            + "  <meta charset=\"UTF-8\">\n"
+            + "  <title>Title</title>\n"
+            + "</head>\n"
+            + "<body>\n"
+            + "\n"
+            + "</body>\n"
+            + "</html>";
+    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"
+            + "  <script type=\"text/javascript\"> Test </script>\n"
+            + "  <meta charset=\"UTF-8\">\n"
+            + "  <title>Title</title>\n"
+            + "</head>\n"
+            + "<body>\n"
+            + "\n"
+            + "</body>\n"
+            + "</html>";
+    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();
+    }
+  }
+}

+ 33 - 0
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() {}
+}

+ 11 - 0
instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/afterSnippetInjection.html

@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <script type="text/javascript"> Test </script>
+  <meta charset="UTF-8">
+  <title>Title</title>
+</head>
+<body>
+
+</body>
+</html>

+ 11 - 0
instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/afterSnippetInjectionChinese.html

@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <script type="text/javascript"> Test </script>
+  <meta charset="UTF-8">
+  <title>Title</title>
+</head>
+<body>
+<p>欢迎光临</p>
+</body>
+</html>

+ 10 - 0
instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/beforeSnippetInjection.html

@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <title>Title</title>
+</head>
+<body>
+
+</body>
+</html>

+ 10 - 0
instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/beforeSnippetInjectionChinese.html

@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <title>Title</title>
+</head>
+<body>
+<p>欢迎光临</p>
+</body>
+</html>

+ 7 - 0
instrumentation/servlet/servlet-3.0/javaagent-unit-tests/src/test/resources/htmlWithoutHeadTag.html

@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<body>
+<p> without head tag </p>
+</body>
+</html>

+ 1 - 0
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")

+ 8 - 0
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.CallDepth;
 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 class Servlet3Advice {
     }
     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();
 

+ 6 - 0
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.AsyncCont
 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 @@ public class Servlet3InstrumentationModule extends InstrumentationModule {
             adviceClassName(".Servlet3Advice"),
             adviceClassName(".Servlet3InitAdvice"),
             adviceClassName(".Servlet3FilterInitAdvice")),
+        new ServletOutputStreamInstrumentation(
+            BASE_PACKAGE,
+            adviceClassName(".Servlet3OutputStreamWriteBytesAndOffsetAdvice"),
+            adviceClassName(".Servlet3OutputStreamWriteBytesAdvice"),
+            adviceClassName(".Servlet3OutputStreamWriteIntAdvice")),
         new HttpServletResponseInstrumentation(
             BASE_PACKAGE, adviceClassName(".Servlet3ResponseSendAdvice")));
   }

+ 34 - 0
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);
+  }
+}

+ 35 - 0
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);
+  }
+}

+ 33 - 0
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);
+  }
+}

+ 9 - 0
instrumentation/servlet/servlet-3.0/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/servlet/v3_0/Servlet3Singletons.java

@@ -8,12 +8,14 @@ package io.opentelemetry.javaagent.instrumentation.servlet.v3_0;
 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<ClassAndMethod, Void> RESPONSE_INSTRUMENTER =
       ResponseInstrumenterFactory.createInstrumenter(INSTRUMENTATION_NAME);
 
+  private static final OutputStreamSnippetInjectionHelper SNIPPET_INJECTION_HELPER =
+      new OutputStreamSnippetInjectionHelper(ExperimentalSnippetHolder.getSnippet());
+
   public static ServletHelper<HttpServletRequest, HttpServletResponse> helper() {
     return HELPER;
   }
@@ -55,6 +60,10 @@ public final class Servlet3Singletons {
     return null;
   }
 
+  public static OutputStreamSnippetInjectionHelper getSnippetInjectionHelper() {
+    return SNIPPET_INJECTION_HELPER;
+  }
+
   private static MappingResolver.Factory getMappingResolverFactory(Object servletOrFilter) {
     boolean servlet = servletOrFilter instanceof Servlet;
     if (servlet) {

+ 76 - 0
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 = "<head>".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 "<head>" 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;
+  }
+}

+ 93 - 0
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;
+  }
+}

+ 36 - 0
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<ServletOutputStream, InjectionState> 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() {}
+}

+ 60 - 0
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]);
+    }
+  }
+}

+ 186 - 0
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.
+ *
+ * <p>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();
+  }
+}

+ 76 - 0
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<SERVER, CONTEXT> extends HttpServerTest<SERV
 
   abstract void addServlet(CONTEXT context, String path, Class<Servlet> servlet)
 
+  public static final ServerEndpoint HTML_PRINT_WRITER =
+    new ServerEndpoint("HTML_PRINT_WRITER",  "htmlPrintWriter",
+      200,
+      "<!DOCTYPE html>\n"
+        + "<html lang=\"en\">\n"
+        + "<head>\n"
+        + "  <meta charset=\"UTF-8\">\n"
+        + "  <title>Title</title>\n"
+        + "</head>\n"
+        + "<body>\n"
+        + "<p>test works</p>\n"
+        + "</body>\n"
+        + "</html>")
+  public static final ServerEndpoint HTML_SERVLET_OUTPUT_STREAM =
+    new ServerEndpoint("HTML_SERVLET_OUTPUT_STREAM", "htmlServletOutputStream",
+      200,
+      "<!DOCTYPE html>\n"
+        + "<html lang=\"en\">\n"
+        + "<head>\n"
+        + "  <meta charset=\"UTF-8\">\n"
+        + "  <title>Title</title>\n"
+        + "</head>\n"
+        + "<body>\n"
+        + "<p>test works</p>\n"
+        + "</body>\n"
+        + "</html>")
   protected void setupServlets(CONTEXT context) {
     def servlet = servlet()
 
@@ -50,6 +77,8 @@ abstract class AbstractServlet3Test<SERVER, CONTEXT> extends HttpServerTest<SERV
     addServlet(context, INDEXED_CHILD.path, servlet)
     addServlet(context, CAPTURE_HEADERS.path, servlet)
     addServlet(context, CAPTURE_PARAMETERS.path, servlet)
+    addServlet(context, HTML_PRINT_WRITER.path, servlet)
+    addServlet(context, HTML_SERVLET_OUTPUT_STREAM.path, servlet)
   }
 
   protected ServerEndpoint lastRequest
@@ -100,4 +129,51 @@ abstract class AbstractServlet3Test<SERVER, CONTEXT> extends HttpServerTest<SERV
         break
     }
   }
+
+  def "snippet injection with ServletOutputStream"() {
+    setup:
+    ExperimentalSnippetHolder.setSnippet("\n  <script type=\"text/javascript\"> Test </script>")
+    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 = "<!DOCTYPE html>\n" +
+      "<html lang=\"en\">\n" +
+      "<head>\n" +
+      "  <script type=\"text/javascript\"> Test </script>\n" +
+      "  <meta charset=\"UTF-8\">\n" +
+      "  <title>Title</title>\n" +
+      "</head>\n" +
+      "<body>\n" +
+      "<p>test works</p>\n" +
+      "</body>\n" +
+      "</html>"
+    response.contentUtf8() == result
+    response.headers().contentLength() == result.length()
+  }
+
+  def "snippet injection with PrintWriter"() {
+    setup:
+    ExperimentalSnippetHolder.setSnippet("\n  <script type=\"text/javascript\"> Test </script>")
+    def request = request(HTML_PRINT_WRITER, "GET")
+    def response = client.execute(request).aggregate().join()
+
+    expect:
+    response.status().code() == HTML_PRINT_WRITER.status
+    String result = "<!DOCTYPE html>\n" +
+      "<html lang=\"en\">\n" +
+      "<head>\n" +
+      "  <script type=\"text/javascript\"> Test </script>\n" +
+      "  <meta charset=\"UTF-8\">\n" +
+      "  <title>Title</title>\n" +
+      "</head>\n" +
+      "<body>\n" +
+      "<p>test works</p>\n" +
+      "</body>\n" +
+      "</html>"
+
+    response.contentUtf8() == result
+    response.headers().contentLength() == result.length()
+  }
 }

+ 8 - 2
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)

+ 39 - 0
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 {

+ 8 - 0
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)
   }
 

+ 8 - 0
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 @@ public class RequestDispatcherServlet {
       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);
     }
   }

+ 24 - 0
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<String> 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() {}
+}

+ 64 - 0
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<ClassLoader> classLoaderOptimization() {
+    return hasClassesNamed(basePackageName + ".ServletOutputStream");
+  }
+
+  @Override
+  public ElementMatcher<TypeDescription> 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);
+  }
+}

+ 1 - 0
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")