diff --git a/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamBenchmark.java b/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamBenchmark.java
index 8b90b4678a7..8f52f13ee86 100644
--- a/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamBenchmark.java
+++ b/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamBenchmark.java
@@ -51,7 +51,7 @@ public class InjectingPipeOutputStreamBenchmark {
public void withPipe() throws Exception {
try (final PrintWriter out =
new PrintWriter(
- new InjectingPipeOutputStream(new ByteArrayOutputStream(), marker, content, null))) {
+ new InjectingPipeOutputStream(new ByteArrayOutputStream(), marker, content))) {
htmlContent.forEach(out::println);
}
}
diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java
index 8f91a8e38cd..686170ea380 100644
--- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java
+++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java
@@ -2,6 +2,7 @@
import java.io.IOException;
import java.io.OutputStream;
+import java.util.function.LongConsumer;
import javax.annotation.concurrent.NotThreadSafe;
/**
@@ -23,18 +24,37 @@ public class InjectingPipeOutputStream extends OutputStream {
private final Runnable onContentInjected;
private final int bulkWriteThreshold;
private final OutputStream downstream;
+ private final LongConsumer onBytesWritten;
+ private long bytesWritten = 0;
/**
+ * This constructor is typically used for testing where we care about the logic and not the
+ * telemetry.
+ *
+ * @param downstream the delegate output stream
+ * @param marker the marker to find in the stream. Must at least be one byte.
+ * @param contentToInject the content to inject once before the marker if found.
+ */
+ public InjectingPipeOutputStream(
+ final OutputStream downstream, final byte[] marker, final byte[] contentToInject) {
+ this(downstream, marker, contentToInject, null, null);
+ }
+
+ /**
+ * This constructor contains the full set of parameters.
+ *
* @param downstream the delegate output stream
* @param marker the marker to find in the stream. Must at least be one byte.
* @param contentToInject the content to inject once before the marker if found.
* @param onContentInjected callback called when and if the content is injected.
+ * @param onBytesWritten callback called when stream is closed to report total bytes written.
*/
public InjectingPipeOutputStream(
final OutputStream downstream,
final byte[] marker,
final byte[] contentToInject,
- final Runnable onContentInjected) {
+ final Runnable onContentInjected,
+ final LongConsumer onBytesWritten) {
this.downstream = downstream;
this.marker = marker;
this.lookbehind = new byte[marker.length];
@@ -46,6 +66,7 @@ public InjectingPipeOutputStream(
this.filter = true;
this.contentToInject = contentToInject;
this.onContentInjected = onContentInjected;
+ this.onBytesWritten = onBytesWritten;
this.bulkWriteThreshold = marker.length * 2 - 2;
}
@@ -57,11 +78,13 @@ public void write(int b) throws IOException {
drain();
}
downstream.write(b);
+ bytesWritten++;
return;
}
if (count == lookbehind.length) {
downstream.write(lookbehind[pos]);
+ bytesWritten++;
} else {
count++;
}
@@ -91,6 +114,7 @@ public void write(byte[] array, int off, int len) throws IOException {
drain();
}
downstream.write(array, off, len);
+ bytesWritten += len;
return;
}
@@ -103,12 +127,16 @@ public void write(byte[] array, int off, int len) throws IOException {
// we have a full match. just write everything
filter = false;
drain();
- downstream.write(array, off, idx);
+ int bytesToWrite = idx;
+ downstream.write(array, off, bytesToWrite);
+ bytesWritten += bytesToWrite;
downstream.write(contentToInject);
if (onContentInjected != null) {
onContentInjected.run();
}
- downstream.write(array, off + idx, len - idx);
+ bytesToWrite = len - idx;
+ downstream.write(array, off + idx, bytesToWrite);
+ bytesWritten += bytesToWrite;
} else {
// we don't have a full match. write everything in a bulk except the lookbehind buffer
// sequentially
@@ -120,7 +148,9 @@ public void write(byte[] array, int off, int len) throws IOException {
// will be reset if no errors after the following write
filter = false;
- downstream.write(array, off + marker.length - 1, len - bulkWriteThreshold);
+ int bytesToWrite = len - bulkWriteThreshold;
+ downstream.write(array, off + marker.length - 1, bytesToWrite);
+ bytesWritten += bytesToWrite;
filter = wasFiltering;
for (int i = len - marker.length + 1; i < len; i++) {
write(array[i]);
@@ -163,6 +193,7 @@ private void drain() throws IOException {
int cnt = count;
for (int i = 0; i < cnt; i++) {
downstream.write(lookbehind[(start + i) % lookbehind.length]);
+ bytesWritten++;
count--;
}
filter = wasFiltering;
@@ -185,6 +216,11 @@ public void flush() throws IOException {
public void close() throws IOException {
try {
commit();
+ // report the size of the original HTTP response before injecting via callback
+ if (onBytesWritten != null) {
+ onBytesWritten.accept(bytesWritten);
+ }
+ bytesWritten = 0;
} finally {
downstream.close();
}
diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeWriter.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeWriter.java
index 7a3d4a75f19..83969617a84 100644
--- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeWriter.java
+++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeWriter.java
@@ -2,6 +2,7 @@
import java.io.IOException;
import java.io.Writer;
+import java.util.function.LongConsumer;
import javax.annotation.concurrent.NotThreadSafe;
/**
@@ -23,18 +24,37 @@ public class InjectingPipeWriter extends Writer {
private final Runnable onContentInjected;
private final int bulkWriteThreshold;
private final Writer downstream;
+ private final LongConsumer onBytesWritten;
+ private long bytesWritten = 0;
/**
+ * This constructor is typically used for testing where we care about the logic and not the
+ * telemetry.
+ *
+ * @param downstream the delegate writer
+ * @param marker the marker to find in the stream. Must at least be one char.
+ * @param contentToInject the content to inject once before the marker if found.
+ */
+ public InjectingPipeWriter(
+ final Writer downstream, final char[] marker, final char[] contentToInject) {
+ this(downstream, marker, contentToInject, null, null);
+ }
+
+ /**
+ * This constructor contains the full set of parameters.
+ *
* @param downstream the delegate writer
* @param marker the marker to find in the stream. Must at least be one char.
* @param contentToInject the content to inject once before the marker if found.
* @param onContentInjected callback called when and if the content is injected.
+ * @param onBytesWritten callback called when writer is closed to report total bytes written.
*/
public InjectingPipeWriter(
final Writer downstream,
final char[] marker,
final char[] contentToInject,
- final Runnable onContentInjected) {
+ final Runnable onContentInjected,
+ final LongConsumer onBytesWritten) {
this.downstream = downstream;
this.marker = marker;
this.lookbehind = new char[marker.length];
@@ -46,6 +66,7 @@ public InjectingPipeWriter(
this.filter = true;
this.contentToInject = contentToInject;
this.onContentInjected = onContentInjected;
+ this.onBytesWritten = onBytesWritten;
this.bulkWriteThreshold = marker.length * 2 - 2;
}
@@ -57,11 +78,13 @@ public void write(int c) throws IOException {
drain();
}
downstream.write(c);
+ bytesWritten++;
return;
}
if (count == lookbehind.length) {
downstream.write(lookbehind[pos]);
+ bytesWritten++;
} else {
count++;
}
@@ -91,6 +114,7 @@ public void write(char[] array, int off, int len) throws IOException {
drain();
}
downstream.write(array, off, len);
+ bytesWritten += len;
return;
}
@@ -103,12 +127,16 @@ public void write(char[] array, int off, int len) throws IOException {
// we have a full match. just write everything
filter = false;
drain();
- downstream.write(array, off, idx);
+ int bytesToWrite = idx;
+ downstream.write(array, off, bytesToWrite);
+ bytesWritten += bytesToWrite;
downstream.write(contentToInject);
if (onContentInjected != null) {
onContentInjected.run();
}
- downstream.write(array, off + idx, len - idx);
+ bytesToWrite = len - idx;
+ downstream.write(array, off + idx, bytesToWrite);
+ bytesWritten += bytesToWrite;
} else {
// we don't have a full match. write everything in a bulk except the lookbehind buffer
// sequentially
@@ -120,7 +148,9 @@ public void write(char[] array, int off, int len) throws IOException {
// will be reset if no errors after the following write
filter = false;
- downstream.write(array, off + marker.length - 1, len - bulkWriteThreshold);
+ int bytesToWrite = len - bulkWriteThreshold;
+ downstream.write(array, off + marker.length - 1, bytesToWrite);
+ bytesWritten += bytesToWrite;
filter = wasFiltering;
for (int i = len - marker.length + 1; i < len; i++) {
@@ -164,6 +194,7 @@ private void drain() throws IOException {
int cnt = count;
for (int i = 0; i < cnt; i++) {
downstream.write(lookbehind[(start + i) % lookbehind.length]);
+ bytesWritten++;
count--;
}
filter = wasFiltering;
@@ -188,6 +219,11 @@ public void close() throws IOException {
commit();
} finally {
downstream.close();
+ // report the size of the original HTTP response before injecting via callback
+ if (onBytesWritten != null) {
+ onBytesWritten.accept(bytesWritten);
+ }
+ bytesWritten = 0;
}
}
diff --git a/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamTest.groovy b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamTest.groovy
index 9b04234ad3d..41d275d37c0 100644
--- a/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamTest.groovy
+++ b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamTest.groovy
@@ -3,6 +3,9 @@ package datadog.trace.bootstrap.instrumentation.buffer
import datadog.trace.test.util.DDSpecification
class InjectingPipeOutputStreamTest extends DDSpecification {
+ static final byte[] MARKER_BYTES = "".getBytes("UTF-8")
+ static final byte[] CONTEXT_BYTES = "".getBytes("UTF-8")
+
static class GlitchedOutputStream extends FilterOutputStream {
int glitchesPos
int count
@@ -33,10 +36,18 @@ class InjectingPipeOutputStreamTest extends DDSpecification {
}
}
+ static class Counter {
+ int value = 0
+
+ def incr(long count) {
+ this.value += count
+ }
+ }
+
def 'should filter a buffer and inject if found #found'() {
setup:
def downstream = new ByteArrayOutputStream()
- def piped = new OutputStreamWriter(new InjectingPipeOutputStream(downstream, marker.getBytes("UTF-8"), contentToInject.getBytes("UTF-8"), null),
+ def piped = new OutputStreamWriter(new InjectingPipeOutputStream(downstream, marker.getBytes("UTF-8"), contentToInject.getBytes("UTF-8")),
"UTF-8")
when:
try (def closeme = piped) {
@@ -55,7 +66,7 @@ class InjectingPipeOutputStreamTest extends DDSpecification {
setup:
def baos = new ByteArrayOutputStream()
def downstream = new GlitchedOutputStream(baos, glichesAt)
- def piped = new InjectingPipeOutputStream(downstream, marker.getBytes("UTF-8"), contentToInject.getBytes("UTF-8"), null)
+ def piped = new InjectingPipeOutputStream(downstream, marker.getBytes("UTF-8"), contentToInject.getBytes("UTF-8"))
when:
try {
for (String line : body) {
@@ -87,4 +98,74 @@ class InjectingPipeOutputStreamTest extends DDSpecification {
// expected broken since the real write happens at close (drain) being the content smaller than the buffer. And retry on close is not a common practice. Hence, we suppose loosing content
[""] | "" | "" | 3 | " counter.incr(bytes) })
+
+ when:
+ piped.write(testBytes)
+ piped.close()
+
+ then:
+ counter.value == 12
+ downstream.toByteArray() == testBytes
+ }
+
+ def 'should count bytes correctly when writing bytes individually'() {
+ setup:
+ def testBytes = "test".getBytes("UTF-8")
+ def downstream = new ByteArrayOutputStream()
+ def counter = new Counter()
+ def piped = new InjectingPipeOutputStream(downstream, MARKER_BYTES, CONTEXT_BYTES, null, { long bytes -> counter.incr(bytes) })
+
+ when:
+ for (int i = 0; i < testBytes.length; i++) {
+ piped.write((int) testBytes[i])
+ }
+ piped.close()
+
+ then:
+ counter.value == 4
+ downstream.toByteArray() == testBytes
+ }
+
+ def 'should count bytes correctly with multiple writes'() {
+ setup:
+ def part1 = "test".getBytes("UTF-8")
+ def part2 = " ".getBytes("UTF-8")
+ def part3 = "content".getBytes("UTF-8")
+ def testBytes = "test content".getBytes("UTF-8")
+ def downstream = new ByteArrayOutputStream()
+ def counter = new Counter()
+ def piped = new InjectingPipeOutputStream(downstream, MARKER_BYTES, CONTEXT_BYTES, null, { long bytes -> counter.incr(bytes) })
+
+ when:
+ piped.write(part1)
+ piped.write(part2)
+ piped.write(part3)
+ piped.close()
+
+ then:
+ counter.value == 12
+ downstream.toByteArray() == testBytes
+ }
+
+ def 'should be resilient to exceptions when onBytesWritten callback is null'() {
+ setup:
+ def testBytes = "test content".getBytes("UTF-8")
+ def downstream = new ByteArrayOutputStream()
+ def piped = new InjectingPipeOutputStream(downstream, MARKER_BYTES, CONTEXT_BYTES)
+
+ when:
+ piped.write(testBytes)
+ piped.close()
+
+ then:
+ noExceptionThrown()
+ downstream.toByteArray() == testBytes
+ }
}
diff --git a/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeWriterTest.groovy b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeWriterTest.groovy
index d115f81a403..049922045f0 100644
--- a/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeWriterTest.groovy
+++ b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeWriterTest.groovy
@@ -3,6 +3,9 @@ package datadog.trace.bootstrap.instrumentation.buffer
import datadog.trace.test.util.DDSpecification
class InjectingPipeWriterTest extends DDSpecification {
+ static final char[] MARKER_CHARS = "".toCharArray()
+ static final char[] CONTEXT_CHARS = "".toCharArray()
+
static class GlitchedWriter extends FilterWriter {
int glitchesPos
int count
@@ -33,10 +36,18 @@ class InjectingPipeWriterTest extends DDSpecification {
}
}
+ static class Counter {
+ int value = 0
+
+ def incr(long count) {
+ this.value += count
+ }
+ }
+
def 'should filter a buffer and inject if found #found using write'() {
setup:
def downstream = new StringWriter()
- def piped = new PrintWriter(new InjectingPipeWriter(downstream, marker.toCharArray(), contentToInject.toCharArray(), null))
+ def piped = new PrintWriter(new InjectingPipeWriter(downstream, marker.toCharArray(), contentToInject.toCharArray()))
when:
try (def closeme = piped) {
piped.write(body)
@@ -53,7 +64,7 @@ class InjectingPipeWriterTest extends DDSpecification {
def 'should filter a buffer and inject if found #found using append'() {
setup:
def downstream = new StringWriter()
- def piped = new PrintWriter(new InjectingPipeWriter(downstream, marker.toCharArray(), contentToInject.toCharArray(), null))
+ def piped = new PrintWriter(new InjectingPipeWriter(downstream, marker.toCharArray(), contentToInject.toCharArray()))
when:
try (def closeme = piped) {
piped.append(body)
@@ -71,7 +82,7 @@ class InjectingPipeWriterTest extends DDSpecification {
setup:
def writer = new StringWriter()
def downstream = new GlitchedWriter(writer, glichesAt)
- def piped = new InjectingPipeWriter(downstream, marker.toCharArray(), contentToInject.toCharArray(), null)
+ def piped = new InjectingPipeWriter(downstream, marker.toCharArray(), contentToInject.toCharArray())
when:
try {
for (String line : body) {
@@ -103,4 +114,68 @@ class InjectingPipeWriterTest extends DDSpecification {
// expected broken since the real write happens at close (drain) being the content smaller than the buffer. And retry on close is not a common practice. Hence, we suppose loosing content
[""] | "" | "" | 3 | " counter.incr(bytes) })
+
+ when:
+ piped.write("test content".toCharArray())
+ piped.close()
+
+ then:
+ counter.value == 12
+ downstream.toString() == "test content"
+ }
+
+ def 'should count bytes correctly when writing characters individually'() {
+ setup:
+ def downstream = new StringWriter()
+ def counter = new Counter()
+ def piped = new InjectingPipeWriter(downstream, MARKER_CHARS, CONTEXT_CHARS, null, { long bytes -> counter.incr(bytes) })
+
+ when:
+ def content = "test"
+ for (int i = 0; i < content.length(); i++) {
+ piped.write((int) content.charAt(i))
+ }
+ piped.close()
+
+ then:
+ counter.value == 4
+ downstream.toString() == "test"
+ }
+
+ def 'should count bytes correctly with multiple writes'() {
+ setup:
+ def downstream = new StringWriter()
+ def counter = new Counter()
+ def piped = new InjectingPipeWriter(downstream, MARKER_CHARS, CONTEXT_CHARS, null, { long bytes -> counter.incr(bytes) })
+
+ when:
+ piped.write("test".toCharArray())
+ piped.write(" ".toCharArray())
+ piped.write("content".toCharArray())
+ piped.close()
+
+ then:
+ counter.value == 12
+ downstream.toString() == "test content"
+ }
+
+ def 'should be resilient to exceptions when onBytesWritten callback is null'() {
+ setup:
+ def downstream = new StringWriter()
+ def piped = new InjectingPipeWriter(downstream, MARKER_CHARS, CONTEXT_CHARS)
+
+ when:
+ piped.write("test content".toCharArray())
+ piped.close()
+
+ then:
+ noExceptionThrown()
+ downstream.toString() == "test content"
+ }
}
diff --git a/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/RumHttpServletResponseWrapper.java b/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/RumHttpServletResponseWrapper.java
index a5defc56dfe..f61fbdb77e7 100644
--- a/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/RumHttpServletResponseWrapper.java
+++ b/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/RumHttpServletResponseWrapper.java
@@ -18,8 +18,11 @@ public class RumHttpServletResponseWrapper extends HttpServletResponseWrapper {
private PrintWriter printWriter;
private InjectingPipeWriter wrappedPipeWriter;
private boolean shouldInject = true;
+ private long injectionStartTime = -1;
+ private String contentEncoding = null;
private static final MethodHandle SET_CONTENT_LENGTH_LONG = getMh("setContentLengthLong");
+ private static final String SERVLET_VERSION = "3";
private static MethodHandle getMh(final String name) {
try {
@@ -45,18 +48,32 @@ public ServletOutputStream getOutputStream() throws IOException {
return outputStream;
}
if (!shouldInject) {
+ RumInjector.getTelemetryCollector().onInjectionSkipped(SERVLET_VERSION);
return super.getOutputStream();
}
- String encoding = getCharacterEncoding();
- if (encoding == null) {
- encoding = Charset.defaultCharset().name();
+ // start timing injection
+ if (injectionStartTime == -1) {
+ injectionStartTime = System.nanoTime();
+ }
+ try {
+ String encoding = getCharacterEncoding();
+ if (encoding == null) {
+ encoding = Charset.defaultCharset().name();
+ }
+ outputStream =
+ new WrappedServletOutputStream(
+ super.getOutputStream(),
+ rumInjector.getMarkerBytes(encoding),
+ rumInjector.getSnippetBytes(encoding),
+ this::onInjected,
+ bytes ->
+ RumInjector.getTelemetryCollector()
+ .onInjectionResponseSize(SERVLET_VERSION, bytes));
+ } catch (Exception e) {
+ injectionStartTime = -1;
+ RumInjector.getTelemetryCollector().onInjectionFailed(SERVLET_VERSION, contentEncoding);
+ throw e;
}
- outputStream =
- new WrappedServletOutputStream(
- super.getOutputStream(),
- rumInjector.getMarkerBytes(encoding),
- rumInjector.getSnippetBytes(encoding),
- this::onInjected);
return outputStream;
}
@@ -67,19 +84,53 @@ public PrintWriter getWriter() throws IOException {
return printWriter;
}
if (!shouldInject) {
+ RumInjector.getTelemetryCollector().onInjectionSkipped(SERVLET_VERSION);
return super.getWriter();
}
- wrappedPipeWriter =
- new InjectingPipeWriter(
- super.getWriter(),
- rumInjector.getMarkerChars(),
- rumInjector.getSnippetChars(),
- this::onInjected);
- printWriter = new PrintWriter(wrappedPipeWriter);
+ // start timing injection
+ if (injectionStartTime == -1) {
+ injectionStartTime = System.nanoTime();
+ }
+ try {
+ wrappedPipeWriter =
+ new InjectingPipeWriter(
+ super.getWriter(),
+ rumInjector.getMarkerChars(),
+ rumInjector.getSnippetChars(),
+ this::onInjected,
+ bytes ->
+ RumInjector.getTelemetryCollector()
+ .onInjectionResponseSize(SERVLET_VERSION, bytes));
+ printWriter = new PrintWriter(wrappedPipeWriter);
+ } catch (Exception e) {
+ injectionStartTime = -1;
+ RumInjector.getTelemetryCollector().onInjectionFailed(SERVLET_VERSION, contentEncoding);
+ throw e;
+ }
return printWriter;
}
+ @Override
+ public void setHeader(String name, String value) {
+ checkForContentSecurityPolicy(name);
+ super.setHeader(name, value);
+ }
+
+ @Override
+ public void addHeader(String name, String value) {
+ checkForContentSecurityPolicy(name);
+ super.addHeader(name, value);
+ }
+
+ private void checkForContentSecurityPolicy(String name) {
+ if (name != null && rumInjector.isEnabled()) {
+ if (name.startsWith("Content-Security-Policy")) {
+ RumInjector.getTelemetryCollector().onContentSecurityPolicyDetected(SERVLET_VERSION);
+ }
+ }
+ }
+
@Override
public void setContentLength(int len) {
// don't set it since we don't know if we will inject
@@ -99,12 +150,21 @@ public void setContentLengthLong(long len) {
}
}
+ @Override
+ public void setCharacterEncoding(String charset) {
+ if (charset != null && rumInjector.isEnabled()) {
+ this.contentEncoding = charset;
+ }
+ super.setCharacterEncoding(charset);
+ }
+
@Override
public void reset() {
this.outputStream = null;
this.wrappedPipeWriter = null;
this.printWriter = null;
this.shouldInject = false;
+ this.injectionStartTime = -1;
super.reset();
}
@@ -117,6 +177,16 @@ public void resetBuffer() {
}
public void onInjected() {
+ RumInjector.getTelemetryCollector().onInjectionSucceed(SERVLET_VERSION);
+
+ // calculate total injection time
+ if (injectionStartTime != -1) {
+ long nanoseconds = System.nanoTime() - injectionStartTime;
+ long milliseconds = nanoseconds / 1_000_000L;
+ RumInjector.getTelemetryCollector().onInjectionTime(SERVLET_VERSION, milliseconds);
+ injectionStartTime = -1;
+ }
+
try {
setHeader("x-datadog-rum-injected", "1");
} catch (Throwable ignored) {
diff --git a/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/WrappedServletOutputStream.java b/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/WrappedServletOutputStream.java
index 109a55491d8..a539adadfec 100644
--- a/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/WrappedServletOutputStream.java
+++ b/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/WrappedServletOutputStream.java
@@ -4,6 +4,7 @@
import datadog.trace.util.MethodHandles;
import java.io.IOException;
import java.lang.invoke.MethodHandle;
+import java.util.function.LongConsumer;
import javax.servlet.ServletOutputStream;
import javax.servlet.WriteListener;
@@ -29,8 +30,14 @@ private static void sneakyThrow(Throwable e) throws E {
}
public WrappedServletOutputStream(
- ServletOutputStream delegate, byte[] marker, byte[] contentToInject, Runnable onInjected) {
- this.filtered = new InjectingPipeOutputStream(delegate, marker, contentToInject, onInjected);
+ ServletOutputStream delegate,
+ byte[] marker,
+ byte[] contentToInject,
+ Runnable onInjected,
+ LongConsumer onBytesWritten) {
+ this.filtered =
+ new InjectingPipeOutputStream(
+ delegate, marker, contentToInject, onInjected, onBytesWritten);
this.delegate = delegate;
}
diff --git a/dd-java-agent/instrumentation/servlet/request-3/src/test/groovy/RumHttpServletResponseWrapperTest.groovy b/dd-java-agent/instrumentation/servlet/request-3/src/test/groovy/RumHttpServletResponseWrapperTest.groovy
new file mode 100644
index 00000000000..94d8e07552c
--- /dev/null
+++ b/dd-java-agent/instrumentation/servlet/request-3/src/test/groovy/RumHttpServletResponseWrapperTest.groovy
@@ -0,0 +1,240 @@
+import datadog.trace.agent.test.AgentTestRunner
+import datadog.trace.api.rum.RumInjector
+import datadog.trace.api.rum.RumTelemetryCollector
+import datadog.trace.bootstrap.instrumentation.buffer.InjectingPipeOutputStream
+import datadog.trace.bootstrap.instrumentation.buffer.InjectingPipeWriter
+import datadog.trace.instrumentation.servlet3.RumHttpServletResponseWrapper
+import datadog.trace.instrumentation.servlet3.WrappedServletOutputStream
+import spock.lang.Subject
+
+import java.util.function.LongConsumer
+import javax.servlet.http.HttpServletResponse
+
+class RumHttpServletResponseWrapperTest extends AgentTestRunner {
+ private static final String SERVLET_VERSION = "3"
+
+ def mockResponse = Mock(HttpServletResponse)
+ def mockTelemetryCollector = Mock(RumTelemetryCollector)
+
+ // injector needs to be enabled in order to check headers
+ @Override
+ protected void configurePreAgent() {
+ super.configurePreAgent()
+ injectSysConfig("rum.enabled", "true")
+ injectSysConfig("rum.application.id", "test")
+ injectSysConfig("rum.client.token", "secret")
+ injectSysConfig("rum.remote.configuration.id", "12345")
+ }
+
+ @Subject
+ RumHttpServletResponseWrapper wrapper
+
+ void setup() {
+ wrapper = new RumHttpServletResponseWrapper(mockResponse)
+ RumInjector.setTelemetryCollector(mockTelemetryCollector)
+ }
+
+ void cleanup() {
+ RumInjector.setTelemetryCollector(RumTelemetryCollector.NO_OP)
+ }
+
+ void 'onInjected calls telemetry collector onInjectionSucceed'() {
+ when:
+ wrapper.onInjected()
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionSucceed(SERVLET_VERSION)
+ }
+
+ void 'getOutputStream with non-HTML content reports skipped'() {
+ setup:
+ wrapper.setContentType("text/plain")
+
+ when:
+ wrapper.getOutputStream()
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionSkipped(SERVLET_VERSION)
+ 1 * mockResponse.getOutputStream()
+ }
+
+ void 'getWriter with non-HTML content reports skipped'() {
+ setup:
+ wrapper.setContentType("text/plain")
+
+ when:
+ wrapper.getWriter()
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionSkipped(SERVLET_VERSION)
+ 1 * mockResponse.getWriter()
+ }
+
+ void 'getOutputStream exception reports failure'() {
+ setup:
+ wrapper.setContentType("text/html")
+ mockResponse.getOutputStream() >> { throw new IOException("stream error") }
+
+ when:
+ try {
+ wrapper.getOutputStream()
+ } catch (IOException ignored) {}
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionFailed(SERVLET_VERSION, null)
+ }
+
+ void 'getWriter exception reports failure'() {
+ setup:
+ wrapper.setContentType("text/html")
+ mockResponse.getWriter() >> { throw new IOException("writer error") }
+
+ when:
+ try {
+ wrapper.getWriter()
+ } catch (IOException ignored) {}
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionFailed(SERVLET_VERSION, null)
+ }
+
+ void 'setHeader with Content-Security-Policy reports CSP detected'() {
+ when:
+ wrapper.setHeader("Content-Security-Policy", "test")
+
+ then:
+ 1 * mockTelemetryCollector.onContentSecurityPolicyDetected(SERVLET_VERSION)
+ 1 * mockResponse.setHeader("Content-Security-Policy", "test")
+ }
+
+ void 'addHeader with Content-Security-Policy reports CSP detected'() {
+ when:
+ wrapper.addHeader("Content-Security-Policy", "test")
+
+ then:
+ 1 * mockTelemetryCollector.onContentSecurityPolicyDetected(SERVLET_VERSION)
+ 1 * mockResponse.addHeader("Content-Security-Policy", "test")
+ }
+
+ void 'setHeader with non-CSP header does not report CSP detected'() {
+ when:
+ wrapper.setHeader("X-Content-Security-Policy", "test")
+
+ then:
+ 0 * mockTelemetryCollector.onContentSecurityPolicyDetected(SERVLET_VERSION)
+ 1 * mockResponse.setHeader("X-Content-Security-Policy", "test")
+ }
+
+ void 'addHeader with non-CSP header does not report CSP detected'() {
+ when:
+ wrapper.addHeader("X-Content-Security-Policy", "test")
+
+ then:
+ 0 * mockTelemetryCollector.onContentSecurityPolicyDetected(SERVLET_VERSION)
+ 1 * mockResponse.addHeader("X-Content-Security-Policy", "test")
+ }
+
+ void 'setCharacterEncoding reports the content-encoding tag with value when injection fails'() {
+ setup:
+ wrapper.setContentType("text/html")
+ wrapper.setCharacterEncoding("UTF-8")
+ mockResponse.getOutputStream() >> { throw new IOException("stream error") }
+
+ when:
+ try {
+ wrapper.getOutputStream()
+ } catch (IOException ignored) {}
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionFailed(SERVLET_VERSION, "UTF-8")
+ }
+
+ void 'setCharacterEncoding reports the content-encoding tag with null when injection fails'() {
+ setup:
+ wrapper.setContentType("text/html")
+ wrapper.setCharacterEncoding(null)
+ mockResponse.getOutputStream() >> { throw new IOException("stream error") }
+
+ when:
+ try {
+ wrapper.getOutputStream()
+ } catch (IOException ignored) {}
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionFailed(SERVLET_VERSION, null)
+ }
+
+ // Callback is created in the RumHttpServletResponseWrapper and passed to InjectingPipeOutputStream via WrappedServletOutputStream.
+ // When the stream is closed, the callback is called with the total number of bytes written to the stream.
+ void 'response sizes are reported to the telemetry collector via the WrappedServletOutputStream callback'() {
+ setup:
+ def downstream = Mock(javax.servlet.ServletOutputStream)
+ def marker = "".getBytes("UTF-8")
+ def contentToInject = "".getBytes("UTF-8")
+ def onBytesWritten = { bytes ->
+ mockTelemetryCollector.onInjectionResponseSize(SERVLET_VERSION, bytes)
+ }
+ def wrappedStream = new WrappedServletOutputStream(
+ downstream, marker, contentToInject, null, onBytesWritten)
+
+ when:
+ wrappedStream.write("test".getBytes("UTF-8"))
+ wrappedStream.write("content".getBytes("UTF-8"))
+ wrappedStream.close()
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionResponseSize(SERVLET_VERSION, 11)
+ }
+
+ void 'response sizes are reported by the InjectingPipeOutputStream callback'() {
+ setup:
+ def downstream = Mock(java.io.OutputStream)
+ def marker = "".getBytes("UTF-8")
+ def contentToInject = "".getBytes("UTF-8")
+ def onBytesWritten = Mock(LongConsumer)
+ def stream = new InjectingPipeOutputStream(
+ downstream, marker, contentToInject, null, onBytesWritten)
+
+ when:
+ stream.write("test".getBytes("UTF-8"))
+ stream.write("content".getBytes("UTF-8"))
+ stream.close()
+
+ then:
+ 1 * onBytesWritten.accept(11)
+ }
+
+ void 'response sizes are reported by the InjectingPipeWriter callback'() {
+ setup:
+ def downstream = Mock(java.io.Writer)
+ def marker = "".toCharArray()
+ def contentToInject = "".toCharArray()
+ def onBytesWritten = Mock(LongConsumer)
+ def writer = new InjectingPipeWriter(
+ downstream, marker, contentToInject, null, onBytesWritten)
+
+ when:
+ writer.write("test".toCharArray())
+ writer.write("content".toCharArray())
+ writer.close()
+
+ then:
+ 1 * onBytesWritten.accept(11)
+ }
+
+ void 'injection timing is reported when injection is successful'() {
+ setup:
+ wrapper.setContentType("text/html")
+ def mockWriter = Mock(java.io.PrintWriter)
+ mockResponse.getWriter() >> mockWriter
+
+ when:
+ wrapper.getWriter()
+ Thread.sleep(1) // ensure measurable time passes
+ wrapper.onInjected()
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionSucceed(SERVLET_VERSION)
+ 1 * mockTelemetryCollector.onInjectionTime(SERVLET_VERSION, { it > 0 })
+ }
+}
diff --git a/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java
index 4b91afd3890..3e65164514e 100644
--- a/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java
+++ b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java
@@ -15,6 +15,10 @@ public class RumHttpServletResponseWrapper extends HttpServletResponseWrapper {
private InjectingPipeWriter wrappedPipeWriter;
private PrintWriter printWriter;
private boolean shouldInject = true;
+ private long injectionStartTime = -1;
+ private String contentEncoding = null;
+
+ private static final String SERVLET_VERSION = "5";
public RumHttpServletResponseWrapper(HttpServletResponse response) {
super(response);
@@ -27,18 +31,32 @@ public ServletOutputStream getOutputStream() throws IOException {
return outputStream;
}
if (!shouldInject) {
+ RumInjector.getTelemetryCollector().onInjectionSkipped(SERVLET_VERSION);
return super.getOutputStream();
}
- String encoding = getCharacterEncoding();
- if (encoding == null) {
- encoding = Charset.defaultCharset().name();
+ // start timing injection
+ if (injectionStartTime == -1) {
+ injectionStartTime = System.nanoTime();
+ }
+ try {
+ String encoding = getCharacterEncoding();
+ if (encoding == null) {
+ encoding = Charset.defaultCharset().name();
+ }
+ outputStream =
+ new WrappedServletOutputStream(
+ super.getOutputStream(),
+ rumInjector.getMarkerBytes(encoding),
+ rumInjector.getSnippetBytes(encoding),
+ this::onInjected,
+ bytes ->
+ RumInjector.getTelemetryCollector()
+ .onInjectionResponseSize(SERVLET_VERSION, bytes));
+ } catch (Exception e) {
+ injectionStartTime = -1;
+ RumInjector.getTelemetryCollector().onInjectionFailed(SERVLET_VERSION, contentEncoding);
+ throw e;
}
- outputStream =
- new WrappedServletOutputStream(
- super.getOutputStream(),
- rumInjector.getMarkerBytes(encoding),
- rumInjector.getSnippetBytes(encoding),
- this::onInjected);
return outputStream;
}
@@ -48,19 +66,53 @@ public PrintWriter getWriter() throws IOException {
return printWriter;
}
if (!shouldInject) {
+ RumInjector.getTelemetryCollector().onInjectionSkipped(SERVLET_VERSION);
return super.getWriter();
}
- wrappedPipeWriter =
- new InjectingPipeWriter(
- super.getWriter(),
- rumInjector.getMarkerChars(),
- rumInjector.getSnippetChars(),
- this::onInjected);
- printWriter = new PrintWriter(wrappedPipeWriter);
+ // start timing injection
+ if (injectionStartTime == -1) {
+ injectionStartTime = System.nanoTime();
+ }
+ try {
+ wrappedPipeWriter =
+ new InjectingPipeWriter(
+ super.getWriter(),
+ rumInjector.getMarkerChars(),
+ rumInjector.getSnippetChars(),
+ this::onInjected,
+ bytes ->
+ RumInjector.getTelemetryCollector()
+ .onInjectionResponseSize(SERVLET_VERSION, bytes));
+ printWriter = new PrintWriter(wrappedPipeWriter);
+ } catch (Exception e) {
+ injectionStartTime = -1;
+ RumInjector.getTelemetryCollector().onInjectionFailed(SERVLET_VERSION, contentEncoding);
+ throw e;
+ }
return printWriter;
}
+ @Override
+ public void setHeader(String name, String value) {
+ checkForContentSecurityPolicy(name);
+ super.setHeader(name, value);
+ }
+
+ @Override
+ public void addHeader(String name, String value) {
+ checkForContentSecurityPolicy(name);
+ super.addHeader(name, value);
+ }
+
+ private void checkForContentSecurityPolicy(String name) {
+ if (name != null && rumInjector.isEnabled()) {
+ if (name.startsWith("Content-Security-Policy")) {
+ RumInjector.getTelemetryCollector().onContentSecurityPolicyDetected(SERVLET_VERSION);
+ }
+ }
+ }
+
@Override
public void setContentLength(int len) {
// don't set it since we don't know if we will inject
@@ -76,12 +128,21 @@ public void setContentLengthLong(long len) {
}
}
+ @Override
+ public void setCharacterEncoding(String charset) {
+ if (charset != null && rumInjector.isEnabled()) {
+ this.contentEncoding = charset;
+ }
+ super.setCharacterEncoding(charset);
+ }
+
@Override
public void reset() {
this.outputStream = null;
this.wrappedPipeWriter = null;
this.printWriter = null;
this.shouldInject = false;
+ this.injectionStartTime = -1;
super.reset();
}
@@ -94,6 +155,16 @@ public void resetBuffer() {
}
public void onInjected() {
+ RumInjector.getTelemetryCollector().onInjectionSucceed(SERVLET_VERSION);
+
+ // calculate total injection time
+ if (injectionStartTime != -1) {
+ long nanoseconds = System.nanoTime() - injectionStartTime;
+ long milliseconds = nanoseconds / 1_000_000L;
+ RumInjector.getTelemetryCollector().onInjectionTime(SERVLET_VERSION, milliseconds);
+ injectionStartTime = -1;
+ }
+
try {
setHeader("x-datadog-rum-injected", "1");
} catch (Throwable ignored) {
diff --git a/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/WrappedServletOutputStream.java b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/WrappedServletOutputStream.java
index db956377708..daf6dcaaafa 100644
--- a/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/WrappedServletOutputStream.java
+++ b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/WrappedServletOutputStream.java
@@ -4,14 +4,21 @@
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.WriteListener;
import java.io.IOException;
+import java.util.function.LongConsumer;
public class WrappedServletOutputStream extends ServletOutputStream {
private final InjectingPipeOutputStream filtered;
private final ServletOutputStream delegate;
public WrappedServletOutputStream(
- ServletOutputStream delegate, byte[] marker, byte[] contentToInject, Runnable onInjected) {
- this.filtered = new InjectingPipeOutputStream(delegate, marker, contentToInject, onInjected);
+ ServletOutputStream delegate,
+ byte[] marker,
+ byte[] contentToInject,
+ Runnable onInjected,
+ LongConsumer onBytesWritten) {
+ this.filtered =
+ new InjectingPipeOutputStream(
+ delegate, marker, contentToInject, onInjected, onBytesWritten);
this.delegate = delegate;
}
diff --git a/dd-java-agent/instrumentation/servlet/request-5/src/test/groovy/RumHttpServletResponseWrapperTest.groovy b/dd-java-agent/instrumentation/servlet/request-5/src/test/groovy/RumHttpServletResponseWrapperTest.groovy
new file mode 100644
index 00000000000..d0a33eb5c11
--- /dev/null
+++ b/dd-java-agent/instrumentation/servlet/request-5/src/test/groovy/RumHttpServletResponseWrapperTest.groovy
@@ -0,0 +1,240 @@
+import datadog.trace.agent.test.AgentTestRunner
+import datadog.trace.api.rum.RumInjector
+import datadog.trace.api.rum.RumTelemetryCollector
+import datadog.trace.bootstrap.instrumentation.buffer.InjectingPipeOutputStream
+import datadog.trace.bootstrap.instrumentation.buffer.InjectingPipeWriter
+import datadog.trace.instrumentation.servlet5.RumHttpServletResponseWrapper
+import datadog.trace.instrumentation.servlet5.WrappedServletOutputStream
+import spock.lang.Subject
+
+import java.util.function.LongConsumer
+import jakarta.servlet.http.HttpServletResponse
+
+class RumHttpServletResponseWrapperTest extends AgentTestRunner {
+ private static final String SERVLET_VERSION = "5"
+
+ def mockResponse = Mock(HttpServletResponse)
+ def mockTelemetryCollector = Mock(RumTelemetryCollector)
+
+ // injector needs to be enabled in order to check headers
+ @Override
+ protected void configurePreAgent() {
+ super.configurePreAgent()
+ injectSysConfig("rum.enabled", "true")
+ injectSysConfig("rum.application.id", "test")
+ injectSysConfig("rum.client.token", "secret")
+ injectSysConfig("rum.remote.configuration.id", "12345")
+ }
+
+ @Subject
+ RumHttpServletResponseWrapper wrapper
+
+ void setup() {
+ wrapper = new RumHttpServletResponseWrapper(mockResponse)
+ RumInjector.setTelemetryCollector(mockTelemetryCollector)
+ }
+
+ void cleanup() {
+ RumInjector.setTelemetryCollector(RumTelemetryCollector.NO_OP)
+ }
+
+ void 'onInjected calls telemetry collector onInjectionSucceed'() {
+ when:
+ wrapper.onInjected()
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionSucceed(SERVLET_VERSION)
+ }
+
+ void 'getOutputStream with non-HTML content reports skipped'() {
+ setup:
+ wrapper.setContentType("text/plain")
+
+ when:
+ wrapper.getOutputStream()
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionSkipped(SERVLET_VERSION)
+ 1 * mockResponse.getOutputStream()
+ }
+
+ void 'getWriter with non-HTML content reports skipped'() {
+ setup:
+ wrapper.setContentType("text/plain")
+
+ when:
+ wrapper.getWriter()
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionSkipped(SERVLET_VERSION)
+ 1 * mockResponse.getWriter()
+ }
+
+ void 'getOutputStream exception reports failure'() {
+ setup:
+ wrapper.setContentType("text/html")
+ mockResponse.getOutputStream() >> { throw new IOException("stream error") }
+
+ when:
+ try {
+ wrapper.getOutputStream()
+ } catch (IOException ignored) {}
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionFailed(SERVLET_VERSION, null)
+ }
+
+ void 'getWriter exception reports failure'() {
+ setup:
+ wrapper.setContentType("text/html")
+ mockResponse.getWriter() >> { throw new IOException("writer error") }
+
+ when:
+ try {
+ wrapper.getWriter()
+ } catch (IOException ignored) {}
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionFailed(SERVLET_VERSION, null)
+ }
+
+ void 'setHeader with Content-Security-Policy reports CSP detected'() {
+ when:
+ wrapper.setHeader("Content-Security-Policy", "test")
+
+ then:
+ 1 * mockTelemetryCollector.onContentSecurityPolicyDetected(SERVLET_VERSION)
+ 1 * mockResponse.setHeader("Content-Security-Policy", "test")
+ }
+
+ void 'addHeader with Content-Security-Policy reports CSP detected'() {
+ when:
+ wrapper.addHeader("Content-Security-Policy", "test")
+
+ then:
+ 1 * mockTelemetryCollector.onContentSecurityPolicyDetected(SERVLET_VERSION)
+ 1 * mockResponse.addHeader("Content-Security-Policy", "test")
+ }
+
+ void 'setHeader with non-CSP header does not report CSP detected'() {
+ when:
+ wrapper.setHeader("X-Content-Security-Policy", "test")
+
+ then:
+ 0 * mockTelemetryCollector.onContentSecurityPolicyDetected(SERVLET_VERSION)
+ 1 * mockResponse.setHeader("X-Content-Security-Policy", "test")
+ }
+
+ void 'addHeader with non-CSP header does not report CSP detected'() {
+ when:
+ wrapper.addHeader("X-Content-Security-Policy", "test")
+
+ then:
+ 0 * mockTelemetryCollector.onContentSecurityPolicyDetected(SERVLET_VERSION)
+ 1 * mockResponse.addHeader("X-Content-Security-Policy", "test")
+ }
+
+ void 'setCharacterEncoding reports the content-encoding tag with value when injection fails'() {
+ setup:
+ wrapper.setContentType("text/html")
+ wrapper.setCharacterEncoding("UTF-8")
+ mockResponse.getOutputStream() >> { throw new IOException("stream error") }
+
+ when:
+ try {
+ wrapper.getOutputStream()
+ } catch (IOException ignored) {}
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionFailed(SERVLET_VERSION, "UTF-8")
+ }
+
+ void 'setCharacterEncoding reports the content-encoding tag with null when injection fails'() {
+ setup:
+ wrapper.setContentType("text/html")
+ wrapper.setCharacterEncoding(null)
+ mockResponse.getOutputStream() >> { throw new IOException("stream error") }
+
+ when:
+ try {
+ wrapper.getOutputStream()
+ } catch (IOException ignored) {}
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionFailed(SERVLET_VERSION, null)
+ }
+
+ // Callback is created in the RumHttpServletResponseWrapper and passed to InjectingPipeOutputStream via WrappedServletOutputStream.
+ // When the stream is closed, the callback is called with the total number of bytes written to the stream.
+ void 'response sizes are reported to the telemetry collector via the WrappedServletOutputStream callback'() {
+ setup:
+ def downstream = Mock(jakarta.servlet.ServletOutputStream)
+ def marker = "".getBytes("UTF-8")
+ def contentToInject = "".getBytes("UTF-8")
+ def onBytesWritten = { bytes ->
+ mockTelemetryCollector.onInjectionResponseSize(SERVLET_VERSION, bytes)
+ }
+ def wrappedStream = new WrappedServletOutputStream(
+ downstream, marker, contentToInject, null, onBytesWritten)
+
+ when:
+ wrappedStream.write("test".getBytes("UTF-8"))
+ wrappedStream.write("content".getBytes("UTF-8"))
+ wrappedStream.close()
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionResponseSize(SERVLET_VERSION, 11)
+ }
+
+ void 'response sizes are reported by the InjectingPipeOutputStream callback'() {
+ setup:
+ def downstream = Mock(java.io.OutputStream)
+ def marker = "".getBytes("UTF-8")
+ def contentToInject = "".getBytes("UTF-8")
+ def onBytesWritten = Mock(LongConsumer)
+ def stream = new InjectingPipeOutputStream(
+ downstream, marker, contentToInject, null, onBytesWritten)
+
+ when:
+ stream.write("test".getBytes("UTF-8"))
+ stream.write("content".getBytes("UTF-8"))
+ stream.close()
+
+ then:
+ 1 * onBytesWritten.accept(11)
+ }
+
+ void 'response sizes are reported by the InjectingPipeWriter callback'() {
+ setup:
+ def downstream = Mock(java.io.Writer)
+ def marker = "".toCharArray()
+ def contentToInject = "".toCharArray()
+ def onBytesWritten = Mock(LongConsumer)
+ def writer = new InjectingPipeWriter(
+ downstream, marker, contentToInject, null, onBytesWritten)
+
+ when:
+ writer.write("test".toCharArray())
+ writer.write("content".toCharArray())
+ writer.close()
+
+ then:
+ 1 * onBytesWritten.accept(11)
+ }
+
+ void 'injection timing is reported when injection is successful'() {
+ setup:
+ wrapper.setContentType("text/html")
+ def mockWriter = Mock(java.io.PrintWriter)
+ mockResponse.getWriter() >> mockWriter
+
+ when:
+ wrapper.getWriter()
+ Thread.sleep(1) // ensure measurable time passes
+ wrapper.onInjected()
+
+ then:
+ 1 * mockTelemetryCollector.onInjectionSucceed(SERVLET_VERSION)
+ 1 * mockTelemetryCollector.onInjectionTime(SERVLET_VERSION, { it > 0 })
+ }
+}
diff --git a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java
index 6b5ea0b5f10..d68127abf63 100644
--- a/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java
+++ b/dd-trace-core/src/main/java/datadog/trace/core/CoreTracer.java
@@ -30,6 +30,7 @@
import datadog.trace.api.DynamicConfig;
import datadog.trace.api.EndpointTracker;
import datadog.trace.api.IdGenerationStrategy;
+import datadog.trace.api.InstrumenterConfig;
import datadog.trace.api.StatsDClient;
import datadog.trace.api.TagMap;
import datadog.trace.api.TraceConfig;
@@ -49,6 +50,7 @@
import datadog.trace.api.metrics.SpanMetricRegistry;
import datadog.trace.api.naming.SpanNaming;
import datadog.trace.api.remoteconfig.ServiceNameCollector;
+import datadog.trace.api.rum.RumInjector;
import datadog.trace.api.sampling.PrioritySampling;
import datadog.trace.api.scopemanager.ScopeListener;
import datadog.trace.api.time.SystemTimeSource;
@@ -703,6 +705,12 @@ private CoreTracer(
? new TracerHealthMetrics(this.statsDClient)
: HealthMetrics.NO_OP;
healthMetrics.start();
+
+ // Start RUM injector telemetry
+ if (InstrumenterConfig.get().isRumEnabled()) {
+ RumInjector.enableTelemetry(this.statsDClient);
+ }
+
performanceMonitoring =
config.isPerfMetricsEnabled()
? new MonitoringImpl(this.statsDClient, 10, SECONDS)
@@ -1248,6 +1256,7 @@ public void close() {
tracingConfigPoller.stop();
pendingTraceBuffer.close();
writer.close();
+ RumInjector.shutdownTelemetry();
statsDClient.close();
metricsAggregator.close();
dataStreamsMonitoring.close();
diff --git a/internal-api/build.gradle.kts b/internal-api/build.gradle.kts
index eb25ebd9ac8..6b29c32d3a7 100644
--- a/internal-api/build.gradle.kts
+++ b/internal-api/build.gradle.kts
@@ -250,6 +250,8 @@ val excludedClassesBranchCoverage by extra(
"datadog.trace.api.env.CapturedEnvironment.ProcessInfo",
"datadog.trace.util.TempLocationManager",
"datadog.trace.util.TempLocationManager.*",
+ // Branches depend on RUM injector state that cannot be reliably controlled in unit tests
+ "datadog.trace.api.rum.RumInjectorMetrics",
)
)
diff --git a/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java b/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java
index 5501c80e62c..343c9450aa6 100644
--- a/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java
+++ b/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java
@@ -29,6 +29,8 @@ public final class RumInjector {
private final DDCache markerCache;
private final Function snippetBytes;
+ private static volatile RumTelemetryCollector telemetryCollector = RumTelemetryCollector.NO_OP;
+
RumInjector(Config config, InstrumenterConfig instrumenterConfig) {
boolean rumEnabled = instrumenterConfig.isRumEnabled();
RumInjectorConfig injectorConfig = config.getRumInjectorConfig();
@@ -122,4 +124,46 @@ public byte[] getMarkerBytes(String encoding) {
}
return this.markerCache.computeIfAbsent(encoding, MARKER_BYTES);
}
+
+ /**
+ * Starts telemetry collection and reports metrics via StatsDClient.
+ *
+ * @param statsDClient The StatsDClient to report metrics to.
+ */
+ public static void enableTelemetry(datadog.trace.api.StatsDClient statsDClient) {
+ if (statsDClient != null) {
+ RumInjectorMetrics metrics = new RumInjectorMetrics(statsDClient);
+ telemetryCollector = metrics;
+
+ if (INSTANCE.isEnabled()) {
+ telemetryCollector.onInitializationSucceed();
+ }
+ } else {
+ telemetryCollector = RumTelemetryCollector.NO_OP;
+ }
+ }
+
+ /** Shuts down telemetry collection and resets the telemetry collector to NO_OP. */
+ public static void shutdownTelemetry() {
+ telemetryCollector.close();
+ telemetryCollector = RumTelemetryCollector.NO_OP;
+ }
+
+ /**
+ * Sets the telemetry collector. This is used for testing purposes only.
+ *
+ * @param collector The telemetry collector to set or {@code null} to reset to NO_OP.
+ */
+ public static void setTelemetryCollector(RumTelemetryCollector collector) {
+ telemetryCollector = collector != null ? collector : RumTelemetryCollector.NO_OP;
+ }
+
+ /**
+ * Gets the telemetry collector.
+ *
+ * @return The telemetry collector used to report telemetry.
+ */
+ public static RumTelemetryCollector getTelemetryCollector() {
+ return telemetryCollector;
+ }
}
diff --git a/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorMetrics.java b/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorMetrics.java
new file mode 100644
index 00000000000..7b67dff6bfd
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorMetrics.java
@@ -0,0 +1,185 @@
+package datadog.trace.api.rum;
+
+import datadog.trace.api.Config;
+import datadog.trace.api.StatsDClient;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * This class implements the RumTelemetryCollector interface, which is used to collect telemetry
+ * from the RumInjector. Metrics are then reported via StatsDClient with tagging.
+ *
+ * @see common
+ * metrics and tags
+ */
+public class RumInjectorMetrics implements RumTelemetryCollector {
+ // Use static tags for common combinations so that we don't have to build them for each metric
+ // Note that injector_version tags are not included because we do not use the rust injector
+ private static final String[] CSP_SERVLET3_TAGS =
+ new String[] {
+ "integration_name:servlet",
+ "integration_version:3",
+ "kind:header",
+ "reason:csp_header_found",
+ "status:seen"
+ };
+
+ private static final String[] CSP_SERVLET5_TAGS =
+ new String[] {
+ "integration_name:servlet",
+ "integration_version:5",
+ "kind:header",
+ "reason:csp_header_found",
+ "status:seen"
+ };
+
+ private static final String[] INIT_TAGS =
+ new String[] {"integration_name:servlet", "integration_version:3,5"};
+
+ private static final String[] TIME_SERVLET3_TAGS =
+ new String[] {"integration_name:servlet", "integration_version:3"};
+
+ private static final String[] TIME_SERVLET5_TAGS =
+ new String[] {"integration_name:servlet", "integration_version:5"};
+
+ private static final String[] RESPONSE_SERVLET3_TAGS =
+ new String[] {"integration_name:servlet", "integration_version:3", "response_kind:header"};
+
+ private static final String[] RESPONSE_SERVLET5_TAGS =
+ new String[] {"integration_name:servlet", "integration_version:5", "response_kind:header"};
+
+ private final AtomicLong injectionSucceed = new AtomicLong();
+ private final AtomicLong injectionFailed = new AtomicLong();
+ private final AtomicLong injectionSkipped = new AtomicLong();
+ private final AtomicLong contentSecurityPolicyDetected = new AtomicLong();
+ private final AtomicLong initializationSucceed = new AtomicLong();
+
+ private final StatsDClient statsd;
+
+ private final String applicationId;
+ private final String remoteConfigUsed;
+
+ public RumInjectorMetrics(final StatsDClient statsd) {
+ this.statsd = statsd;
+
+ // Get RUM config values (applicationId and remoteConfigUsed) for tagging
+ RumInjector rumInjector = RumInjector.get();
+ RumInjectorConfig injectorConfig = Config.get().getRumInjectorConfig();
+ if (rumInjector.isEnabled() && injectorConfig != null) {
+ this.applicationId = injectorConfig.applicationId;
+ this.remoteConfigUsed = injectorConfig.remoteConfigurationId != null ? "true" : "false";
+ } else {
+ this.applicationId = "unknown";
+ this.remoteConfigUsed = "false";
+ }
+ }
+
+ @Override
+ public void onInjectionSucceed(String integrationVersion) {
+ injectionSucceed.incrementAndGet();
+
+ String[] tags =
+ new String[] {
+ "application_id:" + applicationId,
+ "integration_name:servlet",
+ "integration_version:" + integrationVersion,
+ "remote_config_used:" + remoteConfigUsed
+ };
+
+ statsd.count("rum.injection.succeed", 1, tags);
+ }
+
+ @Override
+ public void onInjectionFailed(String integrationVersion, String contentEncoding) {
+ injectionFailed.incrementAndGet();
+
+ String[] tags;
+ if (contentEncoding != null) {
+ tags =
+ new String[] {
+ "application_id:" + applicationId,
+ "content_encoding:" + contentEncoding,
+ "integration_name:servlet",
+ "integration_version:" + integrationVersion,
+ "reason:failed_to_return_response_wrapper",
+ "remote_config_used:" + remoteConfigUsed
+ };
+ } else {
+ tags =
+ new String[] {
+ "application_id:" + applicationId,
+ "integration_name:servlet",
+ "integration_version:" + integrationVersion,
+ "reason:failed_to_return_response_wrapper",
+ "remote_config_used:" + remoteConfigUsed
+ };
+ }
+
+ statsd.count("rum.injection.failed", 1, tags);
+ }
+
+ @Override
+ public void onInjectionSkipped(String integrationVersion) {
+ injectionSkipped.incrementAndGet();
+
+ String[] tags =
+ new String[] {
+ "application_id:" + applicationId,
+ "integration_name:servlet",
+ "integration_version:" + integrationVersion,
+ "reason:should_not_inject",
+ "remote_config_used:" + remoteConfigUsed
+ };
+
+ statsd.count("rum.injection.skipped", 1, tags);
+ }
+
+ @Override
+ public void onInitializationSucceed() {
+ initializationSucceed.incrementAndGet();
+ statsd.count("rum.injection.initialization.succeed", 1, INIT_TAGS);
+ }
+
+ @Override
+ public void onContentSecurityPolicyDetected(String integrationVersion) {
+ contentSecurityPolicyDetected.incrementAndGet();
+
+ String[] tags = "5".equals(integrationVersion) ? CSP_SERVLET5_TAGS : CSP_SERVLET3_TAGS;
+ statsd.count("rum.injection.content_security_policy", 1, tags);
+ }
+
+ @Override
+ public void onInjectionResponseSize(String integrationVersion, long bytes) {
+ String[] tags =
+ "5".equals(integrationVersion) ? RESPONSE_SERVLET5_TAGS : RESPONSE_SERVLET3_TAGS;
+ statsd.distribution("rum.injection.response.bytes", bytes, tags);
+ }
+
+ @Override
+ public void onInjectionTime(String integrationVersion, long milliseconds) {
+ String[] tags = "5".equals(integrationVersion) ? TIME_SERVLET5_TAGS : TIME_SERVLET3_TAGS;
+ statsd.distribution("rum.injection.ms", milliseconds, tags);
+ }
+
+ @Override
+ public void close() {
+ injectionSucceed.set(0);
+ injectionFailed.set(0);
+ injectionSkipped.set(0);
+ contentSecurityPolicyDetected.set(0);
+ initializationSucceed.set(0);
+ }
+
+ public String summary() {
+ return "\ninitializationSucceed="
+ + initializationSucceed.get()
+ + "\ninjectionSucceed="
+ + injectionSucceed.get()
+ + "\ninjectionFailed="
+ + injectionFailed.get()
+ + "\ninjectionSkipped="
+ + injectionSkipped.get()
+ + "\ncontentSecurityPolicyDetected="
+ + contentSecurityPolicyDetected.get();
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/api/rum/RumTelemetryCollector.java b/internal-api/src/main/java/datadog/trace/api/rum/RumTelemetryCollector.java
new file mode 100644
index 00000000000..74638bdffec
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/rum/RumTelemetryCollector.java
@@ -0,0 +1,96 @@
+package datadog.trace.api.rum;
+
+/**
+ * Collect RUM injection telemetry from the RumInjector This is implemented by the
+ * RumInjectorMetrics class
+ */
+public interface RumTelemetryCollector {
+
+ RumTelemetryCollector NO_OP =
+ new RumTelemetryCollector() {
+ @Override
+ public void onInjectionSucceed(String integrationVersion) {}
+
+ @Override
+ public void onInjectionFailed(String integrationVersion, String contentEncoding) {}
+
+ @Override
+ public void onInjectionSkipped(String integrationVersion) {}
+
+ @Override
+ public void onInitializationSucceed() {}
+
+ @Override
+ public void onContentSecurityPolicyDetected(String integrationVersion) {}
+
+ @Override
+ public void onInjectionResponseSize(String integrationVersion, long bytes) {}
+
+ @Override
+ public void onInjectionTime(String integrationVersion, long milliseconds) {}
+
+ @Override
+ public void close() {}
+
+ @Override
+ public String summary() {
+ return "";
+ }
+ };
+
+ /**
+ * Reports successful RUM injection.
+ *
+ * @param integrationVersion The version of the integration that was injected.
+ */
+ void onInjectionSucceed(String integrationVersion);
+
+ /**
+ * Reports failed RUM injection.
+ *
+ * @param integrationVersion The version of the integration that was injected.
+ * @param contentEncoding The content encoding of the response that was injected.
+ */
+ void onInjectionFailed(String integrationVersion, String contentEncoding);
+
+ /**
+ * Reports skipped RUM injection.
+ *
+ * @param integrationVersion The version of the integration that was injected.
+ */
+ void onInjectionSkipped(String integrationVersion);
+
+ /** Reports successful RUM injector initialization. */
+ void onInitializationSucceed();
+
+ /**
+ * Reports content security policy detected in the response header to be injected.
+ *
+ * @param integrationVersion The version of the integration that was injected.
+ */
+ void onContentSecurityPolicyDetected(String integrationVersion);
+
+ /**
+ * Reports the size of the response before injection.
+ *
+ * @param integrationVersion The version of the integration that was injected.
+ * @param bytes The size of the response before injection.
+ */
+ void onInjectionResponseSize(String integrationVersion, long bytes);
+
+ /**
+ * Reports the time taken to inject the RUM SDK.
+ *
+ * @param integrationVersion The version of the integration that was injected.
+ * @param milliseconds The time taken to inject the RUM SDK.
+ */
+ void onInjectionTime(String integrationVersion, long milliseconds);
+
+ /** Closes the telemetry collector. */
+ default void close() {}
+
+ /** Returns a human-readable summary of the telemetry collected. */
+ default String summary() {
+ return "";
+ }
+}
diff --git a/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorMetricsTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorMetricsTest.groovy
new file mode 100644
index 00000000000..286cfcfb4db
--- /dev/null
+++ b/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorMetricsTest.groovy
@@ -0,0 +1,212 @@
+package datadog.trace.api.rum
+
+import datadog.trace.api.StatsDClient
+import spock.lang.Specification
+import spock.lang.Subject
+
+class RumInjectorMetricsTest extends Specification {
+ def statsD = Mock(StatsDClient)
+
+ @Subject
+ def metrics = new RumInjectorMetrics(statsD)
+
+ void assertTags(String[] args, String... expectedTags) {
+ expectedTags.each { expectedTag ->
+ assert args.contains(expectedTag), "Expected tag '$expectedTag' not found in tags: ${args as List}"
+ }
+ }
+
+ // Note: application_id and remote_config_used are dynamic runtime values that depend on
+ // the RUM configuration state, so we do not test them here.
+ def "test onInjectionSucceed"() {
+ when:
+ metrics.onInjectionSucceed("3")
+ metrics.onInjectionSucceed("5")
+
+ then:
+ 1 * statsD.count('rum.injection.succeed', 1, _) >> { args ->
+ def tags = args[2] as String[]
+ assertTags(tags, "integration_name:servlet", "integration_version:3")
+ }
+ 1 * statsD.count('rum.injection.succeed', 1, _) >> { args ->
+ def tags = args[2] as String[]
+ assertTags(tags, "integration_name:servlet", "integration_version:5")
+ }
+ 0 * _
+ }
+
+ def "test onInjectionFailed"() {
+ when:
+ metrics.onInjectionFailed("3", "gzip")
+ metrics.onInjectionFailed("5", null)
+
+ then:
+ 1 * statsD.count('rum.injection.failed', 1, _) >> { args ->
+ def tags = args[2] as String[]
+ assertTags(tags, "content_encoding:gzip", "integration_name:servlet", "integration_version:3", "reason:failed_to_return_response_wrapper")
+ }
+ 1 * statsD.count('rum.injection.failed', 1, _) >> { args ->
+ def tags = args[2] as String[]
+ assert !tags.any { it.startsWith("content_encoding:") }
+ assertTags(tags, "integration_name:servlet", "integration_version:5", "reason:failed_to_return_response_wrapper")
+ }
+ 0 * _
+ }
+
+ def "test onInjectionSkipped"() {
+ when:
+ metrics.onInjectionSkipped("3")
+ metrics.onInjectionSkipped("5")
+
+ then:
+ 1 * statsD.count('rum.injection.skipped', 1, _) >> { args ->
+ def tags = args[2] as String[]
+ assertTags(tags, "integration_name:servlet", "integration_version:3", "reason:should_not_inject")
+ }
+ 1 * statsD.count('rum.injection.skipped', 1, _) >> { args ->
+ def tags = args[2] as String[]
+ assertTags(tags, "integration_name:servlet", "integration_version:5", "reason:should_not_inject")
+ }
+ 0 * _
+ }
+
+ def "test onContentSecurityPolicyDetected"() {
+ when:
+ metrics.onContentSecurityPolicyDetected("3")
+ metrics.onContentSecurityPolicyDetected("5")
+
+ then:
+ 1 * statsD.count('rum.injection.content_security_policy', 1, _) >> { args ->
+ def tags = args[2] as String[]
+ assertTags(tags, "integration_name:servlet", "integration_version:3", "kind:header", "reason:csp_header_found", "status:seen")
+ }
+ 1 * statsD.count('rum.injection.content_security_policy', 1, _) >> { args ->
+ def tags = args[2] as String[]
+ assertTags(tags, "integration_name:servlet", "integration_version:5", "kind:header", "reason:csp_header_found", "status:seen")
+ }
+ 0 * _
+ }
+
+ def "test onInitializationSucceed"() {
+ when:
+ metrics.onInitializationSucceed()
+
+ then:
+ 1 * statsD.count('rum.injection.initialization.succeed', 1, _) >> { args ->
+ def tags = args[2] as String[]
+ assertTags(tags, "integration_name:servlet", "integration_version:3,5")
+ }
+ 0 * _
+ }
+
+ def "test onInjectionResponseSize with multiple sizes"() {
+ when:
+ metrics.onInjectionResponseSize("3", 256)
+ metrics.onInjectionResponseSize("5", 512)
+
+ then:
+ 1 * statsD.distribution('rum.injection.response.bytes', 256, _) >> { args ->
+ def tags = args[2] as String[]
+ assertTags(tags, "integration_name:servlet", "integration_version:3", "response_kind:header")
+ }
+ 1 * statsD.distribution('rum.injection.response.bytes', 512, _) >> { args ->
+ def tags = args[2] as String[]
+ assertTags(tags, "integration_name:servlet", "integration_version:5", "response_kind:header")
+ }
+ 0 * _
+ }
+
+ def "test onInjectionTime with multiple durations"() {
+ when:
+ metrics.onInjectionTime("5", 5L)
+ metrics.onInjectionTime("3", 10L)
+
+ then:
+ 1 * statsD.distribution('rum.injection.ms', 5L, _) >> { args ->
+ def tags = args[2] as String[]
+ assertTags(tags, "integration_name:servlet", "integration_version:5")
+ }
+ 1 * statsD.distribution('rum.injection.ms', 10L, _) >> { args ->
+ def tags = args[2] as String[]
+ assertTags(tags, "integration_name:servlet", "integration_version:3")
+ }
+ 0 * _
+ }
+
+ def "test summary with multiple events in different order"() {
+ when:
+ metrics.onInitializationSucceed()
+ metrics.onContentSecurityPolicyDetected("3")
+ metrics.onInjectionSkipped("5")
+ metrics.onInjectionFailed("3", "gzip")
+ metrics.onInjectionSucceed("3")
+ metrics.onInjectionFailed("5", null)
+ metrics.onInjectionSucceed("3")
+ metrics.onInjectionSkipped("3")
+ metrics.onContentSecurityPolicyDetected("5")
+ metrics.onInjectionResponseSize("3", 256)
+ metrics.onInjectionTime("5", 5L)
+ def summary = metrics.summary()
+
+ then:
+ summary.contains("initializationSucceed=1")
+ summary.contains("injectionSucceed=2")
+ summary.contains("injectionFailed=2")
+ summary.contains("injectionSkipped=2")
+ summary.contains("contentSecurityPolicyDetected=2")
+ 1 * statsD.count('rum.injection.initialization.succeed', 1, _)
+ 2 * statsD.count('rum.injection.succeed', 1, _)
+ 2 * statsD.count('rum.injection.failed', 1, _)
+ 2 * statsD.count('rum.injection.skipped', 1, _)
+ 2 * statsD.count('rum.injection.content_security_policy', 1, _)
+ 1 * statsD.distribution('rum.injection.response.bytes', 256, _)
+ 1 * statsD.distribution('rum.injection.ms', 5L, _)
+ 0 * _
+ }
+
+ def "test metrics start at zero in summary"() {
+ when:
+ def summary = metrics.summary()
+
+ then:
+ summary.contains("initializationSucceed=0")
+ summary.contains("injectionSucceed=0")
+ summary.contains("injectionFailed=0")
+ summary.contains("injectionSkipped=0")
+ summary.contains("contentSecurityPolicyDetected=0")
+ 0 * _
+ }
+
+ def "test close resets counters in summary"() {
+ when:
+ metrics.onInitializationSucceed()
+ metrics.onInjectionSucceed("3")
+ metrics.onInjectionFailed("3", "gzip")
+ metrics.onInjectionSkipped("3")
+ metrics.onContentSecurityPolicyDetected("3")
+
+ def summaryBeforeClose = metrics.summary()
+ metrics.close()
+ def summaryAfterClose = metrics.summary()
+
+ then:
+ summaryBeforeClose.contains("initializationSucceed=1")
+ summaryBeforeClose.contains("injectionSucceed=1")
+ summaryBeforeClose.contains("injectionFailed=1")
+ summaryBeforeClose.contains("injectionSkipped=1")
+ summaryBeforeClose.contains("contentSecurityPolicyDetected=1")
+
+ summaryAfterClose.contains("initializationSucceed=0")
+ summaryAfterClose.contains("injectionSucceed=0")
+ summaryAfterClose.contains("injectionFailed=0")
+ summaryAfterClose.contains("injectionSkipped=0")
+ summaryAfterClose.contains("contentSecurityPolicyDetected=0")
+
+ 1 * statsD.count('rum.injection.initialization.succeed', 1, _)
+ 1 * statsD.count('rum.injection.succeed', 1, _)
+ 1 * statsD.count('rum.injection.failed', 1, _)
+ 1 * statsD.count('rum.injection.skipped', 1, _)
+ 1 * statsD.count('rum.injection.content_security_policy', 1, _)
+ 0 * _
+ }
+}
diff --git a/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorTest.groovy
index 98988e40242..e16939adf8b 100644
--- a/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorTest.groovy
+++ b/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorTest.groovy
@@ -65,4 +65,167 @@ class RumInjectorTest extends DDSpecification {
injector.getSnippetChars() != null
injector.getMarkerChars() != null
}
+
+ void 'set telemetry collector'() {
+ setup:
+ def telemetryCollector = mock(RumTelemetryCollector)
+
+ when:
+ RumInjector.setTelemetryCollector(telemetryCollector)
+
+ then:
+ RumInjector.getTelemetryCollector() == telemetryCollector
+
+ cleanup:
+ RumInjector.setTelemetryCollector(RumTelemetryCollector.NO_OP)
+ }
+
+ void 'return NO_OP when telemetry collector is not set'() {
+ when:
+ RumInjector.setTelemetryCollector(null)
+
+ then:
+ RumInjector.getTelemetryCollector() == RumTelemetryCollector.NO_OP
+ }
+
+ void 'enable telemetry with StatsDClient'() {
+ when:
+ RumInjector.enableTelemetry(mock(datadog.trace.api.StatsDClient))
+
+ then:
+ RumInjector.getTelemetryCollector() instanceof datadog.trace.api.rum.RumInjectorMetrics
+
+ cleanup:
+ RumInjector.shutdownTelemetry()
+ }
+
+ void 'enabling telemetry with a null StatsDClient sets the telemetry collector to NO_OP'() {
+ when:
+ RumInjector.enableTelemetry(null)
+
+ then:
+ RumInjector.getTelemetryCollector() == RumTelemetryCollector.NO_OP
+ }
+
+ void 'shutdown telemetry'() {
+ setup:
+ RumInjector.enableTelemetry(mock(datadog.trace.api.StatsDClient))
+
+ when:
+ RumInjector.shutdownTelemetry()
+
+ then:
+ RumInjector.getTelemetryCollector() == RumTelemetryCollector.NO_OP
+ }
+
+ void 'initialize rum injector'() {
+ when:
+ RumInjector.enableTelemetry(mock(datadog.trace.api.StatsDClient))
+ def telemetryCollector = RumInjector.getTelemetryCollector()
+ telemetryCollector.onInitializationSucceed()
+ def summary = telemetryCollector.summary()
+
+ then:
+ summary.contains("initializationSucceed=1")
+
+ cleanup:
+ RumInjector.shutdownTelemetry()
+ }
+
+ void 'telemetry integration works end-to-end'() {
+ when:
+ // simulate CoreTracer enabling telemetry
+ RumInjector.enableTelemetry(mock(datadog.trace.api.StatsDClient))
+
+ // simulate reporting injection telemetry
+ def telemetryCollector = RumInjector.getTelemetryCollector()
+ telemetryCollector.onInjectionSucceed("3")
+ telemetryCollector.onInjectionFailed("3", "gzip")
+ telemetryCollector.onInjectionSkipped("3")
+ telemetryCollector.onContentSecurityPolicyDetected("3")
+ telemetryCollector.onInjectionResponseSize("3", 256)
+ telemetryCollector.onInjectionTime("3", 5L)
+
+ // verify metrics are collected
+ def summary = telemetryCollector.summary()
+
+ then:
+ summary.contains("injectionSucceed=1")
+ summary.contains("injectionFailed=1")
+ summary.contains("injectionSkipped=1")
+ summary.contains("contentSecurityPolicyDetected=1")
+
+ cleanup:
+ RumInjector.shutdownTelemetry()
+ }
+
+ void 'response size telemetry does not throw an exception'() {
+ setup:
+ def mockStatsDClient = mock(datadog.trace.api.StatsDClient)
+
+ when:
+ RumInjector.enableTelemetry(mockStatsDClient)
+
+ def telemetryCollector = RumInjector.getTelemetryCollector()
+ telemetryCollector.onInjectionResponseSize("3", 256)
+ telemetryCollector.onInjectionResponseSize("3", 512)
+ telemetryCollector.onInjectionResponseSize("5", 2048)
+
+ then:
+ noExceptionThrown()
+
+ cleanup:
+ RumInjector.shutdownTelemetry()
+ }
+
+ void 'injection time telemetry does not throw an exception'() {
+ setup:
+ def mockStatsDClient = mock(datadog.trace.api.StatsDClient)
+
+ when:
+ RumInjector.enableTelemetry(mockStatsDClient)
+
+ def telemetryCollector = RumInjector.getTelemetryCollector()
+ telemetryCollector.onInjectionTime("5", 5L)
+ telemetryCollector.onInjectionTime("5", 10L)
+ telemetryCollector.onInjectionTime("3", 20L)
+
+ then:
+ noExceptionThrown()
+
+ cleanup:
+ RumInjector.shutdownTelemetry()
+ }
+
+ void 'concurrent telemetry calls return an accurate summary'() {
+ setup:
+ RumInjector.enableTelemetry(mock(datadog.trace.api.StatsDClient))
+ def telemetryCollector = RumInjector.getTelemetryCollector()
+ def threads = []
+
+ when:
+ // simulate multiple threads calling telemetry methods
+ (1..50).each { i ->
+ threads << Thread.start {
+ telemetryCollector.onInjectionSucceed("3")
+ telemetryCollector.onInjectionFailed("3", "gzip")
+ telemetryCollector.onInjectionSkipped("3")
+ telemetryCollector.onContentSecurityPolicyDetected("3")
+ telemetryCollector.onInjectionResponseSize("3", 256)
+ telemetryCollector.onInjectionTime("3", 5L)
+ }
+ }
+ threads*.join()
+
+ def summary = telemetryCollector.summary()
+
+ then:
+ summary.contains("injectionSucceed=50")
+ summary.contains("injectionFailed=50")
+ summary.contains("injectionSkipped=50")
+ summary.contains("contentSecurityPolicyDetected=50")
+
+ cleanup:
+ RumInjector.shutdownTelemetry()
+ }
}
diff --git a/internal-api/src/test/groovy/datadog/trace/api/rum/RumTelemetryCollectorTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/rum/RumTelemetryCollectorTest.groovy
new file mode 100644
index 00000000000..19c423635a7
--- /dev/null
+++ b/internal-api/src/test/groovy/datadog/trace/api/rum/RumTelemetryCollectorTest.groovy
@@ -0,0 +1,106 @@
+package datadog.trace.api.rum
+
+import spock.lang.Specification
+
+class RumTelemetryCollectorTest extends Specification {
+
+ def "test default NO_OP does not throw exception"() {
+ when:
+ RumTelemetryCollector.NO_OP.onInjectionSucceed("3")
+ RumTelemetryCollector.NO_OP.onInjectionSucceed("5")
+ RumTelemetryCollector.NO_OP.onInjectionFailed("3", "gzip")
+ RumTelemetryCollector.NO_OP.onInjectionFailed("5", null)
+ RumTelemetryCollector.NO_OP.onInjectionSkipped("3")
+ RumTelemetryCollector.NO_OP.onInjectionSkipped("5")
+ RumTelemetryCollector.NO_OP.onInitializationSucceed()
+ RumTelemetryCollector.NO_OP.onContentSecurityPolicyDetected("3")
+ RumTelemetryCollector.NO_OP.onContentSecurityPolicyDetected("5")
+ RumTelemetryCollector.NO_OP.onInjectionResponseSize("3", 256L)
+ RumTelemetryCollector.NO_OP.onInjectionResponseSize("5", 512L)
+ RumTelemetryCollector.NO_OP.onInjectionTime("3", 10L)
+ RumTelemetryCollector.NO_OP.onInjectionTime("5", 20L)
+ RumTelemetryCollector.NO_OP.close()
+
+ then:
+ noExceptionThrown()
+ }
+
+ def "test default NO_OP summary returns an empty string"() {
+ when:
+ def summary = RumTelemetryCollector.NO_OP.summary()
+
+ then:
+ summary == ""
+ }
+
+ def "test default NO_OP close method does not throw exception"() {
+ when:
+ RumTelemetryCollector.NO_OP.close()
+
+ then:
+ noExceptionThrown()
+ }
+
+ def "test defining a custom implementation does not throw exception"() {
+ setup:
+ def customCollector = new RumTelemetryCollector() {
+ @Override
+ void onInjectionSucceed(String integrationVersion) {
+ }
+
+ @Override
+ void onInjectionFailed(String integrationVersion, String contentEncoding) {
+ }
+
+ @Override
+ void onInjectionSkipped(String integrationVersion) {
+ }
+
+ @Override
+ void onInitializationSucceed() {
+ }
+
+ @Override
+ void onContentSecurityPolicyDetected(String integrationVersion) {
+ }
+
+ @Override
+ void onInjectionResponseSize(String integrationVersion, long bytes) {
+ }
+
+ @Override
+ void onInjectionTime(String integrationVersion, long milliseconds) {
+ }
+ }
+
+ when:
+ customCollector.close()
+ def summary = customCollector.summary()
+
+ then:
+ noExceptionThrown()
+ summary == ""
+ }
+
+ def "test multiple close calls do not throw exception"() {
+ when:
+ RumTelemetryCollector.NO_OP.close()
+ RumTelemetryCollector.NO_OP.close()
+ RumTelemetryCollector.NO_OP.close()
+
+ then:
+ noExceptionThrown()
+ }
+
+ def "test multiple summary calls return the same empty string"() {
+ when:
+ def summary1 = RumTelemetryCollector.NO_OP.summary()
+ def summary2 = RumTelemetryCollector.NO_OP.summary()
+ def summary3 = RumTelemetryCollector.NO_OP.summary()
+
+ then:
+ summary1 == ""
+ summary1 == summary2
+ summary2 == summary3
+ }
+}