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

46 files changed

Lines changed: 2010 additions & 16 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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";
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)