Skip to content

Commit 860a603

Browse files
Add RUM SDK injection for servlet based web servers (#9110)
* feat(rum): Add initial config and API * feat(rum): Iterating on initial config and API * fix(rum): Fix config print * fix(rum): Remove content type support as only "text/html" is supported * feat(rum): Add smoke tests * feat(rum): Add smoke tests * feat(rum): Add smoke tests * feat(rum): Add smoke tests * Add RUM injection for servlet 3 * fix rum injection smoke test * fix javadoc * Add benchmark * avoid linkage issues with earlier servlet specs * feat(rum): Improve smoke test to add more cases * feat(rum): Add remote config and fix json encoding * feat(rum): Add more smoke tests * fix(rum): Fix config * fix(rum): Fix smoke tests * feat(rum): Add more smoke tests * feat(rum): Simplify config to remove dynamic init RUM injection remote config is not dynamic as other products * Use runnable for callback * improve pipe perfs * feat(rum): Add injector and config unit tests * Add rum injection for jakarta servlet * codenarc * fix(rum): Fix smoke test merge * exclude spring virtual filter chain * fix(rum): Fix SDK snippet * final fixes * fix more tests * fix(rum): Fix privacy level encoding * Apply suggestions * feat(rum): Improve config related to PR review feedback * Improve and fix circular buffer * review --------- Co-authored-by: Andrea Marziali <[email protected]>
1 parent 8aa9607 commit 860a603

File tree

46 files changed

+2010
-16
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+2010
-16
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package datadog.trace.bootstrap.instrumentation.buffer;
2+
3+
import static java.util.concurrent.TimeUnit.MICROSECONDS;
4+
import static java.util.concurrent.TimeUnit.SECONDS;
5+
6+
import java.io.ByteArrayOutputStream;
7+
import java.io.IOException;
8+
import java.io.InputStream;
9+
import java.io.PrintWriter;
10+
import java.net.URL;
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.List;
13+
import org.apache.commons.io.IOUtils;
14+
import org.openjdk.jmh.annotations.Benchmark;
15+
import org.openjdk.jmh.annotations.BenchmarkMode;
16+
import org.openjdk.jmh.annotations.Fork;
17+
import org.openjdk.jmh.annotations.Measurement;
18+
import org.openjdk.jmh.annotations.Mode;
19+
import org.openjdk.jmh.annotations.OutputTimeUnit;
20+
import org.openjdk.jmh.annotations.Scope;
21+
import org.openjdk.jmh.annotations.State;
22+
import org.openjdk.jmh.annotations.Warmup;
23+
24+
/*
25+
* Benchmark Mode Cnt Score Error Units
26+
* InjectingPipeOutputStreamBenchmark.withPipe avgt 2 15.515 us/op
27+
* InjectingPipeOutputStreamBenchmark.withoutPipe avgt 2 12.861 us/op
28+
*/
29+
@State(Scope.Benchmark)
30+
@Warmup(iterations = 1, time = 30, timeUnit = SECONDS)
31+
@Measurement(iterations = 2, time = 30, timeUnit = SECONDS)
32+
@BenchmarkMode(Mode.AverageTime)
33+
@OutputTimeUnit(MICROSECONDS)
34+
@Fork(value = 1)
35+
public class InjectingPipeOutputStreamBenchmark {
36+
private static final List<String> htmlContent;
37+
private static final byte[] marker;
38+
private static final byte[] content;
39+
40+
static {
41+
try (InputStream is = new URL("https://www.google.com").openStream()) {
42+
htmlContent = IOUtils.readLines(is, StandardCharsets.UTF_8);
43+
} catch (IOException ioe) {
44+
throw new RuntimeException(ioe);
45+
}
46+
marker = "</head>".getBytes(StandardCharsets.UTF_8);
47+
content = "<script/>".getBytes(StandardCharsets.UTF_8);
48+
}
49+
50+
@Benchmark
51+
public void withPipe() throws Exception {
52+
try (final PrintWriter out =
53+
new PrintWriter(
54+
new InjectingPipeOutputStream(new ByteArrayOutputStream(), marker, content, null))) {
55+
htmlContent.forEach(out::println);
56+
}
57+
}
58+
59+
@Benchmark
60+
public void withoutPipe() throws Exception {
61+
try (final PrintWriter out = new PrintWriter(new ByteArrayOutputStream())) {
62+
htmlContent.forEach(out::println);
63+
}
64+
}
65+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package datadog.trace.bootstrap.instrumentation.buffer;
2+
3+
import java.io.FilterOutputStream;
4+
import java.io.IOException;
5+
import java.io.OutputStream;
6+
7+
/**
8+
* A circular buffer with a lookbehind buffer of n bytes. The first time that the latest n bytes
9+
* matches the marker, a content is injected before.
10+
*/
11+
public class InjectingPipeOutputStream extends FilterOutputStream {
12+
private final byte[] lookbehind;
13+
private int pos;
14+
private boolean bufferFilled;
15+
private final byte[] marker;
16+
private final byte[] contentToInject;
17+
private boolean found = false;
18+
private int matchingPos = 0;
19+
private final Runnable onContentInjected;
20+
private final int bulkWriteThreshold;
21+
22+
/**
23+
* @param downstream the delegate output stream
24+
* @param marker the marker to find in the stream
25+
* @param contentToInject the content to inject once before the marker if found.
26+
* @param onContentInjected callback called when and if the content is injected.
27+
*/
28+
public InjectingPipeOutputStream(
29+
final OutputStream downstream,
30+
final byte[] marker,
31+
final byte[] contentToInject,
32+
final Runnable onContentInjected) {
33+
super(downstream);
34+
this.marker = marker;
35+
this.lookbehind = new byte[marker.length];
36+
this.pos = 0;
37+
this.contentToInject = contentToInject;
38+
this.onContentInjected = onContentInjected;
39+
this.bulkWriteThreshold = marker.length * 2 - 2;
40+
}
41+
42+
@Override
43+
public void write(int b) throws IOException {
44+
if (found) {
45+
out.write(b);
46+
return;
47+
}
48+
49+
if (bufferFilled) {
50+
out.write(lookbehind[pos]);
51+
}
52+
53+
lookbehind[pos] = (byte) b;
54+
pos = (pos + 1) % lookbehind.length;
55+
56+
if (!bufferFilled) {
57+
bufferFilled = pos == 0;
58+
}
59+
60+
if (marker[matchingPos++] == b) {
61+
if (matchingPos == marker.length) {
62+
found = true;
63+
out.write(contentToInject);
64+
if (onContentInjected != null) {
65+
onContentInjected.run();
66+
}
67+
drain();
68+
}
69+
} else {
70+
matchingPos = 0;
71+
}
72+
}
73+
74+
@Override
75+
public void write(byte[] b, int off, int len) throws IOException {
76+
if (found) {
77+
out.write(b, off, len);
78+
return;
79+
}
80+
if (len > bulkWriteThreshold) {
81+
// if the content is large enough, we can bulk write everything but the N trail and tail.
82+
// This because the buffer can already contain some byte from a previous single write.
83+
// Also we need to fill the buffer with the tail since we don't know about the next write.
84+
int idx = arrayContains(b, marker);
85+
if (idx >= 0) {
86+
// we have a full match. just write everything
87+
found = true;
88+
drain();
89+
out.write(b, off, idx);
90+
out.write(contentToInject);
91+
if (onContentInjected != null) {
92+
onContentInjected.run();
93+
}
94+
out.write(b, off + idx, len - idx);
95+
} else {
96+
// we don't have a full match. write everything in a bulk except the lookbehind buffer
97+
// sequentially
98+
for (int i = off; i < off + marker.length - 1; i++) {
99+
write(b[i]);
100+
}
101+
drain();
102+
out.write(b, off + marker.length - 1, len - bulkWriteThreshold);
103+
for (int i = len - marker.length + 1; i < len; i++) {
104+
write(b[i]);
105+
}
106+
}
107+
} else {
108+
// use slow path because the length to write is small and within the lookbehind buffer size
109+
super.write(b, off, len);
110+
}
111+
}
112+
113+
private int arrayContains(byte[] array, byte[] search) {
114+
for (int i = 0; i < array.length - search.length; i++) {
115+
if (array[i] == search[0]) {
116+
boolean found = true;
117+
int k = i;
118+
for (int j = 1; j < search.length; j++) {
119+
k++;
120+
if (array[k] != search[j]) {
121+
found = false;
122+
break;
123+
}
124+
}
125+
if (found) {
126+
return i;
127+
}
128+
}
129+
}
130+
return -1;
131+
}
132+
133+
private void drain() throws IOException {
134+
if (bufferFilled) {
135+
for (int i = 0; i < lookbehind.length; i++) {
136+
out.write(lookbehind[(pos + i) % lookbehind.length]);
137+
}
138+
} else {
139+
out.write(this.lookbehind, 0, pos);
140+
}
141+
pos = 0;
142+
matchingPos = 0;
143+
bufferFilled = false;
144+
}
145+
146+
@Override
147+
public void close() throws IOException {
148+
if (!found) {
149+
drain();
150+
}
151+
super.close();
152+
}
153+
}

dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ public abstract class HttpServerDecorator<REQUEST, CONNECTION, RESPONSE, REQUEST
5757

5858
public static final String DD_SPAN_ATTRIBUTE = "datadog.span";
5959
public static final String DD_DISPATCH_SPAN_ATTRIBUTE = "datadog.span.dispatch";
60+
public static final String DD_RUM_INJECTED = "datadog.rum.injected";
6061
public static final String DD_FIN_DISP_LIST_SPAN_ATTRIBUTE =
6162
"datadog.span.finish_dispatch_listener";
6263
public static final String DD_RESPONSE_ATTRIBUTE = "datadog.response";
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package datadog.trace.bootstrap.instrumentation.buffer
2+
3+
import datadog.trace.test.util.DDSpecification
4+
5+
class InjectingPipeOutputStreamTest extends DDSpecification {
6+
7+
static class ExceptionControlledOutputStream extends FilterOutputStream {
8+
9+
boolean failWrite = false
10+
11+
ExceptionControlledOutputStream(OutputStream out) {
12+
super(out)
13+
}
14+
15+
@Override
16+
void write(int b) throws IOException {
17+
if (failWrite) {
18+
throw new IOException("Failed")
19+
}
20+
super.write(b)
21+
}
22+
}
23+
24+
def 'should filter a buffer and inject if found #found'() {
25+
setup:
26+
def downstream = new ByteArrayOutputStream()
27+
def piped = new OutputStreamWriter(new InjectingPipeOutputStream(downstream, marker.getBytes("UTF-8"), contentToInject.getBytes("UTF-8"), null),
28+
"UTF-8")
29+
when:
30+
try (def closeme = piped) {
31+
piped.write(body)
32+
}
33+
then:
34+
assert downstream.toByteArray() == expected.getBytes("UTF-8")
35+
where:
36+
body | marker | contentToInject | found | expected
37+
"<html><head><foo/></head><body/></html>" | "</head>" | "<script>true</script>" | true | "<html><head><foo/><script>true</script></head><body/></html>"
38+
"<html><body/></html>" | "</head>" | "<something/>" | false | "<html><body/></html>"
39+
"<foo/>" | "<longerThanFoo>" | "<nothing>" | false | "<foo/>"
40+
}
41+
}

dd-java-agent/agent-tooling/src/main/resources/datadog/trace/agent/tooling/bytebuddy/matcher/ignored_class_name.trie

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@
350350
0 org.springframework.web.context.support.AbstractRefreshableWebApplicationContext
351351
0 org.springframework.web.context.support.GenericWebApplicationContext
352352
0 org.springframework.web.context.support.XmlWebApplicationContext
353+
1 org.springframework.web.filter.CompositeFilter$VirtualFilterChain
353354
0 org.springframework.web.reactive.*
354355
0 org.springframework.web.servlet.*
355356
0 org.springframework.web.socket.*

dd-java-agent/instrumentation/jetty-11/src/test/groovy/Jetty11Test.groovy

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import datadog.trace.agent.test.base.HttpServer
22
import datadog.trace.agent.test.base.HttpServerTest
33
import datadog.trace.agent.test.naming.TestingGenericHttpNamingConventions
4+
import datadog.trace.instrumentation.servlet5.HtmlRumServlet
45
import datadog.trace.instrumentation.servlet5.TestServlet5
6+
import datadog.trace.instrumentation.servlet5.XmlRumServlet
57
import org.eclipse.jetty.server.Handler
68
import org.eclipse.jetty.server.Server
79

@@ -103,3 +105,18 @@ class Jetty11V1ForkedTest extends Jetty11Test implements TestingGenericHttpNamin
103105
true
104106
}
105107
}
108+
109+
class JettyRumInjectionForkedTest extends Jetty11V0ForkedTest {
110+
@Override
111+
boolean testRumInjection() {
112+
true
113+
}
114+
115+
@Override
116+
protected Handler handler() {
117+
def handler = JettyServer.servletHandler(TestServlet5)
118+
handler.addServlet(HtmlRumServlet, "/gimme-html")
119+
handler.addServlet(XmlRumServlet, "/gimme-xml")
120+
handler
121+
}
122+
}

dd-java-agent/instrumentation/jetty-12/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ addTestSuiteForDir('ee9Test', 'test/ee9')
2929
addTestSuiteExtendingForDir('ee9LatestDepTest', 'latestDepTest', 'test/ee9')
3030
// ee10
3131
addTestSuiteForDir('ee10Test', 'test/ee10')
32+
addTestSuiteExtendingForDir('ee10ForkedTest', 'ee10Test', 'test/ee10')
3233
addTestSuiteExtendingForDir('ee10LatestDepTest', 'latestDepTest', 'test/ee10')
34+
addTestSuiteExtendingForDir('ee10LatestDepForkedTest', 'ee10LatestDepTest', 'test/ee10')
3335

3436
[compileMain_java17Java, compileTestJava].each {
3537
it.configure {

dd-java-agent/instrumentation/jetty-12/src/test/ee10/groovy/Jetty12Test.groovy

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import datadog.trace.agent.test.base.HttpServer
22
import datadog.trace.agent.test.base.HttpServerTest
33
import datadog.trace.agent.test.naming.TestingGenericHttpNamingConventions
4+
import datadog.trace.instrumentation.servlet5.HtmlRumServlet
45
import datadog.trace.instrumentation.servlet5.TestServlet5
6+
import datadog.trace.instrumentation.servlet5.XmlRumServlet
7+
import org.eclipse.jetty.ee10.servlet.ServletContextHandler
58
import org.eclipse.jetty.server.Server
69

710
class Jetty12Test extends HttpServerTest<Server> implements TestingGenericHttpNamingConventions.ServerV0 {
@@ -61,3 +64,18 @@ class Jetty12PojoWebsocketTest extends Jetty12Test {
6164
!isLatestDepTest
6265
}
6366
}
67+
68+
class Jetty12RumInjectionForkedTest extends Jetty12Test {
69+
@Override
70+
boolean testRumInjection() {
71+
true
72+
}
73+
74+
@Override
75+
HttpServer server() {
76+
ServletContextHandler handler = JettyServer.servletHandler(TestServlet5)
77+
handler.addServlet(HtmlRumServlet, "/gimme-html")
78+
handler.addServlet(XmlRumServlet, "/gimme-xml")
79+
new JettyServer(handler, useWebsocketPojoEndpoint())
80+
}
81+
}

0 commit comments

Comments
 (0)