Skip to content

Wrap servlet original PrintWriter on rum injector #9146

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
package datadog.trace.bootstrap.instrumentation.buffer;

import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.OutputStream;

/**
* A circular buffer with a lookbehind buffer of n bytes. The first time that the latest n bytes
* matches the marker, a content is injected before.
* An OutputStream containing a circular buffer with a lookbehind buffer of n bytes. The first time
* that the latest n bytes matches the marker, a content is injected before.
*/
public class InjectingPipeOutputStream extends FilterOutputStream {
public class InjectingPipeOutputStream extends OutputStream {
private final byte[] lookbehind;
private int pos;
private boolean bufferFilled;
Expand All @@ -18,10 +17,11 @@ public class InjectingPipeOutputStream extends FilterOutputStream {
private int matchingPos = 0;
private final Runnable onContentInjected;
private final int bulkWriteThreshold;
private final OutputStream downstream;

/**
* @param downstream the delegate output stream
* @param marker the marker to find in the 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.
*/
Expand All @@ -30,7 +30,7 @@ public InjectingPipeOutputStream(
final byte[] marker,
final byte[] contentToInject,
final Runnable onContentInjected) {
super(downstream);
this.downstream = downstream;
this.marker = marker;
this.lookbehind = new byte[marker.length];
this.pos = 0;
Expand All @@ -42,12 +42,12 @@ public InjectingPipeOutputStream(
@Override
public void write(int b) throws IOException {
if (found) {
out.write(b);
downstream.write(b);
return;
}

if (bufferFilled) {
out.write(lookbehind[pos]);
downstream.write(lookbehind[pos]);
}

lookbehind[pos] = (byte) b;
Expand All @@ -60,7 +60,7 @@ public void write(int b) throws IOException {
if (marker[matchingPos++] == b) {
if (matchingPos == marker.length) {
found = true;
out.write(contentToInject);
downstream.write(contentToInject);
if (onContentInjected != null) {
onContentInjected.run();
}
Expand All @@ -72,46 +72,48 @@ public void write(int b) throws IOException {
}

@Override
public void write(byte[] b, int off, int len) throws IOException {
public void write(byte[] array, int off, int len) throws IOException {
if (found) {
out.write(b, off, len);
downstream.write(array, off, len);
return;
}
if (len > bulkWriteThreshold) {
// if the content is large enough, we can bulk write everything but the N trail and tail.
// This because the buffer can already contain some byte from a previous single write.
// Also we need to fill the buffer with the tail since we don't know about the next write.
int idx = arrayContains(b, marker);
int idx = arrayContains(array, off, len, marker);
if (idx >= 0) {
// we have a full match. just write everything
found = true;
drain();
out.write(b, off, idx);
out.write(contentToInject);
downstream.write(array, off, idx);
downstream.write(contentToInject);
if (onContentInjected != null) {
onContentInjected.run();
}
out.write(b, off + idx, len - idx);
downstream.write(array, off + idx, len - idx);
} else {
// we don't have a full match. write everything in a bulk except the lookbehind buffer
// sequentially
for (int i = off; i < off + marker.length - 1; i++) {
write(b[i]);
write(array[i]);
}
drain();
out.write(b, off + marker.length - 1, len - bulkWriteThreshold);
downstream.write(array, off + marker.length - 1, len - bulkWriteThreshold);
for (int i = len - marker.length + 1; i < len; i++) {
write(b[i]);
write(array[i]);
}
}
} else {
// use slow path because the length to write is small and within the lookbehind buffer size
super.write(b, off, len);
for (int i = off; i < off + len; i++) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mcculls added the loop not to delegate to super

write(array[i]);
}
}
}

private int arrayContains(byte[] array, byte[] search) {
for (int i = 0; i < array.length - search.length; i++) {
private int arrayContains(byte[] array, int off, int len, byte[] search) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed the signature to take into account potential offset and len

for (int i = off; i < len - search.length; i++) {
if (array[i] == search[0]) {
boolean found = true;
int k = i;
Expand All @@ -133,10 +135,10 @@ private int arrayContains(byte[] array, byte[] search) {
private void drain() throws IOException {
if (bufferFilled) {
for (int i = 0; i < lookbehind.length; i++) {
out.write(lookbehind[(pos + i) % lookbehind.length]);
downstream.write(lookbehind[(pos + i) % lookbehind.length]);
}
} else {
out.write(this.lookbehind, 0, pos);
downstream.write(this.lookbehind, 0, pos);
}
pos = 0;
matchingPos = 0;
Expand All @@ -148,6 +150,6 @@ public void close() throws IOException {
if (!found) {
drain();
}
super.close();
downstream.close();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package datadog.trace.bootstrap.instrumentation.buffer;

import java.io.IOException;
import java.io.Writer;

/**
* An Writer containing a circular buffer with a lookbehind buffer of n bytes. The first time that
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* An Writer containing a circular buffer with a lookbehind buffer of n bytes. The first time that
* A Writer containing a circular buffer with a lookbehind buffer of n bytes. The first time that

* the latest n bytes matches the marker, a content is injected before.
*/
public class InjectingPipeWriter extends Writer {
private final char[] lookbehind;
private int pos;
private boolean bufferFilled;
private final char[] marker;
private final char[] contentToInject;
private boolean found = false;
private int matchingPos = 0;
private final Runnable onContentInjected;
private final int bulkWriteThreshold;
private final Writer downstream;

/**
* @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.
*/
public InjectingPipeWriter(
final Writer downstream,
final char[] marker,
final char[] contentToInject,
final Runnable onContentInjected) {
this.downstream = downstream;
this.marker = marker;
this.lookbehind = new char[marker.length];
this.pos = 0;
this.contentToInject = contentToInject;
this.onContentInjected = onContentInjected;
this.bulkWriteThreshold = marker.length * 2 - 2;
}

@Override
public void write(int b) throws IOException {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public void write(int b) throws IOException {
public void write(int c) throws IOException {

matches the Writer javadoc

if (found) {
downstream.write(b);
return;
}

if (bufferFilled) {
downstream.write(lookbehind[pos]);
}

lookbehind[pos] = (char) b;
pos = (pos + 1) % lookbehind.length;

if (!bufferFilled) {
bufferFilled = pos == 0;
}

if (marker[matchingPos++] == b) {
if (matchingPos == marker.length) {
found = true;
downstream.write(contentToInject);
if (onContentInjected != null) {
onContentInjected.run();
}
drain();
}
} else {
matchingPos = 0;
}
}

@Override
public void flush() throws IOException {
downstream.flush();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we also need this in the updated InjectingPipeOutputStream

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes in the outputstream I didn't implement the first time since extending FilterOutputStream but now is not the case anymore thanks

}

@Override
public void write(char[] array, int off, int len) throws IOException {
if (found) {
downstream.write(array, off, len);
return;
}
if (len > bulkWriteThreshold) {
// if the content is large enough, we can bulk write everything but the N trail and tail.
// This because the buffer can already contain some byte from a previous single write.
// Also we need to fill the buffer with the tail since we don't know about the next write.
int idx = arrayContains(array, off, len, marker);
if (idx >= 0) {
// we have a full match. just write everything
found = true;
drain();
downstream.write(array, off, idx);
downstream.write(contentToInject);
if (onContentInjected != null) {
onContentInjected.run();
}
downstream.write(array, off + idx, len - idx);
} else {
// we don't have a full match. write everything in a bulk except the lookbehind buffer
// sequentially
for (int i = off; i < off + marker.length - 1; i++) {
write(array[i]);
}
drain();
downstream.write(array, off + marker.length - 1, len - bulkWriteThreshold);
for (int i = len - marker.length + 1; i < len; i++) {
write(array[i]);
}
}
} else {
// use slow path because the length to write is small and within the lookbehind buffer size
for (int i = off; i < off + len; i++) {
write(array[i]);
}
}
}

private int arrayContains(char[] array, int off, int len, char[] search) {
for (int i = off; i < len - search.length; i++) {
if (array[i] == search[0]) {
boolean found = true;
int k = i;
for (int j = 1; j < search.length; j++) {
k++;
if (array[k] != search[j]) {
found = false;
break;
}
}
if (found) {
return i;
}
}
}
return -1;
}

private void drain() throws IOException {
if (bufferFilled) {
for (int i = 0; i < lookbehind.length; i++) {
downstream.write(lookbehind[(pos + i) % lookbehind.length]);
}
} else {
downstream.write(this.lookbehind, 0, pos);
}
pos = 0;
matchingPos = 0;
bufferFilled = false;
}

@Override
public void close() throws IOException {
if (!found) {
drain();
}
downstream.close();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,6 @@ package datadog.trace.bootstrap.instrumentation.buffer
import datadog.trace.test.util.DDSpecification

class InjectingPipeOutputStreamTest extends DDSpecification {

static class ExceptionControlledOutputStream extends FilterOutputStream {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was a leftover. I need to implement that when I will test the reliability in case of exception (next PR)


boolean failWrite = false

ExceptionControlledOutputStream(OutputStream out) {
super(out)
}

@Override
void write(int b) throws IOException {
if (failWrite) {
throw new IOException("Failed")
}
super.write(b)
}
}

def 'should filter a buffer and inject if found #found'() {
setup:
def downstream = new ByteArrayOutputStream()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package datadog.trace.bootstrap.instrumentation.buffer

import datadog.trace.test.util.DDSpecification

class InjectingPipeWriterTest extends DDSpecification {
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))
when:
try (def closeme = piped) {
piped.write(body)
}
then:
assert downstream.toString() == expected
where:
body | marker | contentToInject | found | expected
"<html><head><foo/></head><body/></html>" | "</head>" | "<script>true</script>" | true | "<html><head><foo/><script>true</script></head><body/></html>"
"<html><body/></html>" | "</head>" | "<something/>" | false | "<html><body/></html>"
"<foo/>" | "<longerThanFoo>" | "<nothing>" | false | "<foo/>"
}

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))
when:
try (def closeme = piped) {
piped.append(body)
}
then:
assert downstream.toString() == expected
where:
body | marker | contentToInject | found | expected
"<html><head><foo/></head><body/></html>" | "</head>" | "<script>true</script>" | true | "<html><head><foo/><script>true</script></head><body/></html>"
"<html><body/></html>" | "</head>" | "<something/>" | false | "<html><body/></html>"
"<foo/>" | "<longerThanFoo>" | "<nothing>" | false | "<foo/>"
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package datadog.trace.instrumentation.servlet3;

import datadog.trace.api.rum.RumInjector;
import datadog.trace.bootstrap.instrumentation.buffer.InjectingPipeWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.Charset;
Expand Down Expand Up @@ -41,11 +42,15 @@ public ServletOutputStream getOutputStream() throws IOException {

@Override
public PrintWriter getWriter() throws IOException {
final PrintWriter delegate = super.getWriter();
if (!shouldInject) {
return super.getWriter();
return delegate;
}
if (printWriter == null) {
printWriter = new PrintWriter(getOutputStream());
printWriter =
new PrintWriter(
new InjectingPipeWriter(
delegate, rumInjector.getMarker(), rumInjector.getSnippet(), this::onInjected));
}
return printWriter;
}
Expand Down
Loading