Skip to content

Commit cf3d0fc

Browse files
Capture request body for Apache HttpClient 4.x (#3692)
Co-authored-by: jackshirazi <[email protected]>
1 parent 54fee67 commit cf3d0fc

File tree

27 files changed

+1046
-23
lines changed

27 files changed

+1046
-23
lines changed

CHANGELOG.asciidoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Use subheadings with the "=====" level for adding notes for unreleased changes:
4141
[float]
4242
===== Features
4343
* Added option to make routing-key part of RabbitMQ transaction/span names - {pull}3636[#3636]
44+
* Added internal option for capturing request bodies for apache httpclient v4 - {pull}3692[#3692]
4445
4546
[[release-notes-1.x]]
4647
=== Java Agent version 1.x
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package co.elastic.apm.agent.impl.context;
2+
3+
import co.elastic.apm.agent.objectpool.Resetter;
4+
import co.elastic.apm.agent.objectpool.impl.QueueBasedObjectPool;
5+
import co.elastic.apm.agent.tracer.configuration.WebConfiguration;
6+
import co.elastic.apm.agent.tracer.metadata.BodyCapture;
7+
import co.elastic.apm.agent.tracer.pooling.Allocator;
8+
import co.elastic.apm.agent.tracer.pooling.ObjectPool;
9+
import co.elastic.apm.agent.tracer.pooling.Recyclable;
10+
import org.jctools.queues.atomic.MpmcAtomicArrayQueue;
11+
12+
import javax.annotation.Nullable;
13+
import java.nio.ByteBuffer;
14+
15+
public class BodyCaptureImpl implements BodyCapture, Recyclable {
16+
private static final ObjectPool<ByteBuffer> BYTE_BUFFER_POOL = QueueBasedObjectPool.of(new MpmcAtomicArrayQueue<ByteBuffer>(128), false,
17+
new Allocator<ByteBuffer>() {
18+
@Override
19+
public ByteBuffer createInstance() {
20+
return ByteBuffer.allocate(WebConfiguration.MAX_BODY_CAPTURE_BYTES);
21+
}
22+
},
23+
new Resetter<ByteBuffer>() {
24+
@Override
25+
public void recycle(ByteBuffer object) {
26+
object.clear();
27+
}
28+
});
29+
30+
private enum CaptureState {
31+
NOT_ELIGIBLE,
32+
ELIGIBLE,
33+
STARTED
34+
}
35+
36+
private volatile CaptureState state;
37+
38+
private final StringBuilder charset;
39+
40+
/**
41+
* The maximum number of bytes to capture, if the body is longer remaining bytes will be dropped.
42+
*/
43+
private int numBytesToCapture;
44+
45+
@Nullable
46+
private ByteBuffer bodyBuffer;
47+
48+
BodyCaptureImpl() {
49+
charset = new StringBuilder();
50+
resetState();
51+
}
52+
53+
@Override
54+
public void resetState() {
55+
state = CaptureState.NOT_ELIGIBLE;
56+
charset.setLength(0);
57+
if (bodyBuffer != null) {
58+
BYTE_BUFFER_POOL.recycle(bodyBuffer);
59+
}
60+
}
61+
62+
@Override
63+
public void markEligibleForCapturing() {
64+
if (state == CaptureState.NOT_ELIGIBLE) {
65+
synchronized (this) {
66+
if (state == CaptureState.NOT_ELIGIBLE) {
67+
state = CaptureState.ELIGIBLE;
68+
}
69+
}
70+
}
71+
}
72+
73+
@Override
74+
public boolean isEligibleForCapturing() {
75+
return state != CaptureState.NOT_ELIGIBLE;
76+
}
77+
78+
@Override
79+
public boolean startCapture(@Nullable String requestCharset, int numBytesToCapture) {
80+
if (numBytesToCapture > WebConfiguration.MAX_BODY_CAPTURE_BYTES) {
81+
throw new IllegalArgumentException("Capturing " + numBytesToCapture + " bytes is not supported, maximum is " + WebConfiguration.MAX_BODY_CAPTURE_BYTES + " bytes");
82+
}
83+
if (state == CaptureState.ELIGIBLE) {
84+
synchronized (this) {
85+
if (state == CaptureState.ELIGIBLE) {
86+
if (requestCharset != null) {
87+
this.charset.append(requestCharset);
88+
}
89+
this.numBytesToCapture = numBytesToCapture;
90+
state = CaptureState.STARTED;
91+
return true;
92+
}
93+
}
94+
}
95+
return false;
96+
}
97+
98+
private void acquireBodyBufferIfRequired() {
99+
if (state != CaptureState.STARTED) {
100+
throw new IllegalStateException("Capturing has not been started!");
101+
}
102+
if (bodyBuffer == null) {
103+
bodyBuffer = BYTE_BUFFER_POOL.createInstance();
104+
}
105+
}
106+
107+
@Override
108+
public void append(byte b) {
109+
acquireBodyBufferIfRequired();
110+
if (!isFull()) {
111+
bodyBuffer.put(b);
112+
}
113+
}
114+
115+
@Override
116+
public void append(byte[] b, int offset, int len) {
117+
acquireBodyBufferIfRequired();
118+
int remaining = numBytesToCapture - bodyBuffer.position();
119+
if (remaining > 0) {
120+
bodyBuffer.put(b, offset, Math.min(len, remaining));
121+
}
122+
}
123+
124+
@Override
125+
public boolean isFull() {
126+
if (bodyBuffer == null) {
127+
return false;
128+
}
129+
return bodyBuffer.position() >= numBytesToCapture;
130+
}
131+
132+
@Nullable
133+
public CharSequence getCharset() {
134+
if (charset.length() == 0) {
135+
return null;
136+
}
137+
return charset;
138+
}
139+
140+
@Nullable
141+
public ByteBuffer getBody() {
142+
return bodyBuffer;
143+
}
144+
}

apm-agent-core/src/main/java/co/elastic/apm/agent/impl/context/HttpImpl.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ public class HttpImpl implements Recyclable, Http {
3030
*/
3131
private final UrlImpl url = new UrlImpl();
3232

33+
private final BodyCaptureImpl requestBody = new BodyCaptureImpl();
34+
3335
/**
3436
* HTTP method used by this HTTP outgoing span
3537
*/
@@ -65,6 +67,11 @@ public int getStatusCode() {
6567
return statusCode;
6668
}
6769

70+
@Override
71+
public BodyCaptureImpl getRequestBody() {
72+
return requestBody;
73+
}
74+
6875
@Override
6976
public HttpImpl withUrl(@Nullable String url) {
7077
if (url != null) {
@@ -88,6 +95,7 @@ public HttpImpl withStatusCode(int statusCode) {
8895
@Override
8996
public void resetState() {
9097
url.resetState();
98+
requestBody.resetState();
9199
method = null;
92100
statusCode = 0;
93101
}
@@ -98,9 +106,4 @@ public boolean hasContent() {
98106
statusCode > 0;
99107
}
100108

101-
public void copyFrom(HttpImpl other) {
102-
url.copyFrom(other.url);
103-
method = other.method;
104-
statusCode = other.statusCode;
105-
}
106109
}

apm-agent-core/src/main/java/co/elastic/apm/agent/report/serialize/DslJsonSerializer.java

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,23 @@
1818
*/
1919
package co.elastic.apm.agent.report.serialize;
2020

21-
import co.elastic.apm.agent.impl.context.*;
21+
import co.elastic.apm.agent.impl.context.AbstractContextImpl;
22+
import co.elastic.apm.agent.impl.context.BodyCaptureImpl;
23+
import co.elastic.apm.agent.impl.context.CloudOriginImpl;
24+
import co.elastic.apm.agent.impl.context.DbImpl;
25+
import co.elastic.apm.agent.impl.context.DestinationImpl;
26+
import co.elastic.apm.agent.impl.context.Headers;
2227
import co.elastic.apm.agent.impl.context.HttpImpl;
28+
import co.elastic.apm.agent.impl.context.MessageImpl;
29+
import co.elastic.apm.agent.impl.context.RequestImpl;
30+
import co.elastic.apm.agent.impl.context.ResponseImpl;
31+
import co.elastic.apm.agent.impl.context.ServiceOriginImpl;
32+
import co.elastic.apm.agent.impl.context.ServiceTargetImpl;
33+
import co.elastic.apm.agent.impl.context.SocketImpl;
34+
import co.elastic.apm.agent.impl.context.SpanContextImpl;
35+
import co.elastic.apm.agent.impl.context.TransactionContextImpl;
36+
import co.elastic.apm.agent.impl.context.UrlImpl;
37+
import co.elastic.apm.agent.impl.context.UserImpl;
2338
import co.elastic.apm.agent.impl.error.ErrorCaptureImpl;
2439
import co.elastic.apm.agent.impl.metadata.Agent;
2540
import co.elastic.apm.agent.impl.metadata.CloudProviderInfo;
@@ -33,16 +48,26 @@
3348
import co.elastic.apm.agent.impl.metadata.ServiceImpl;
3449
import co.elastic.apm.agent.impl.metadata.SystemInfo;
3550
import co.elastic.apm.agent.impl.stacktrace.StacktraceConfigurationImpl;
36-
import co.elastic.apm.agent.impl.transaction.*;
51+
import co.elastic.apm.agent.impl.transaction.AbstractSpanImpl;
52+
import co.elastic.apm.agent.impl.transaction.Composite;
53+
import co.elastic.apm.agent.impl.transaction.DroppedSpanStats;
54+
import co.elastic.apm.agent.impl.transaction.FaasImpl;
55+
import co.elastic.apm.agent.impl.transaction.FaasTriggerImpl;
56+
import co.elastic.apm.agent.impl.transaction.IdImpl;
57+
import co.elastic.apm.agent.impl.transaction.OTelSpanKind;
58+
import co.elastic.apm.agent.impl.transaction.SpanCount;
3759
import co.elastic.apm.agent.impl.transaction.SpanImpl;
38-
import co.elastic.apm.agent.tracer.metrics.Labels;
60+
import co.elastic.apm.agent.impl.transaction.StackFrame;
61+
import co.elastic.apm.agent.impl.transaction.TraceContextImpl;
62+
import co.elastic.apm.agent.impl.transaction.TransactionImpl;
3963
import co.elastic.apm.agent.report.ApmServerClient;
4064
import co.elastic.apm.agent.sdk.internal.collections.LongList;
4165
import co.elastic.apm.agent.sdk.logging.Logger;
4266
import co.elastic.apm.agent.sdk.logging.LoggerFactory;
4367
import co.elastic.apm.agent.tracer.metadata.PotentiallyMultiValuedMap;
44-
import co.elastic.apm.agent.tracer.pooling.Recyclable;
4568
import co.elastic.apm.agent.tracer.metrics.DslJsonUtil;
69+
import co.elastic.apm.agent.tracer.metrics.Labels;
70+
import co.elastic.apm.agent.tracer.pooling.Recyclable;
4671
import com.dslplatform.json.BoolConverter;
4772
import com.dslplatform.json.DslJson;
4873
import com.dslplatform.json.JsonWriter;
@@ -54,6 +79,7 @@
5479
import java.io.File;
5580
import java.io.IOException;
5681
import java.io.OutputStream;
82+
import java.nio.ByteBuffer;
5783
import java.nio.CharBuffer;
5884
import java.nio.charset.StandardCharsets;
5985
import java.util.ArrayList;
@@ -1028,21 +1054,36 @@ private void serializeSpanLinks(List<TraceContextImpl> spanLinks) {
10281054
}
10291055

10301056
private void serializeOTel(SpanImpl span) {
1031-
serializeOtel(span, Collections.<IdImpl>emptyList());
1057+
serializeOtel(span, Collections.<IdImpl>emptyList(), requestBodyToString(span.getContext().getHttp().getRequestBody()));
1058+
}
1059+
1060+
@Nullable
1061+
private CharSequence requestBodyToString(BodyCaptureImpl requestBody) {
1062+
//TODO: perform proper, charset aware conversion to string
1063+
ByteBuffer buffer = requestBody.getBody();
1064+
if (buffer == null || buffer.position() == 0) {
1065+
return null;
1066+
}
1067+
buffer.flip();
1068+
StringBuilder result = new StringBuilder();
1069+
while (buffer.hasRemaining()) {
1070+
result.append((char) buffer.get());
1071+
}
1072+
return result;
10321073
}
10331074

10341075
private void serializeOTel(TransactionImpl transaction) {
10351076
List<IdImpl> profilingCorrelationStackTraceIds = transaction.getProfilingCorrelationStackTraceIds();
10361077
synchronized (profilingCorrelationStackTraceIds) {
1037-
serializeOtel(transaction, profilingCorrelationStackTraceIds);
1078+
serializeOtel(transaction, profilingCorrelationStackTraceIds, null);
10381079
}
10391080
}
10401081

1041-
private void serializeOtel(AbstractSpanImpl<?> span, List<IdImpl> profilingStackTraceIds) {
1082+
private void serializeOtel(AbstractSpanImpl<?> span, List<IdImpl> profilingStackTraceIds, @Nullable CharSequence httpRequestBody) {
10421083
OTelSpanKind kind = span.getOtelKind();
10431084
Map<String, Object> attributes = span.getOtelAttributes();
10441085

1045-
boolean hasAttributes = !attributes.isEmpty() || !profilingStackTraceIds.isEmpty();
1086+
boolean hasAttributes = !attributes.isEmpty() || !profilingStackTraceIds.isEmpty() || httpRequestBody != null;
10461087
boolean hasKind = kind != null;
10471088
if (hasKind || hasAttributes) {
10481089
writeFieldName("otel");
@@ -1092,6 +1133,13 @@ private void serializeOtel(AbstractSpanImpl<?> span, List<IdImpl> profilingStack
10921133
}
10931134
jw.writeByte(ARRAY_END);
10941135
}
1136+
if (httpRequestBody != null) {
1137+
if (!isFirstAttrib) {
1138+
jw.writeByte(COMMA);
1139+
}
1140+
writeFieldName("http.request.body.content");
1141+
jw.writeString(httpRequestBody);
1142+
}
10951143
jw.writeByte(OBJECT_END);
10961144
}
10971145

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package co.elastic.apm.agent.impl.context;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.nio.ByteBuffer;
6+
import java.nio.charset.StandardCharsets;
7+
8+
import static co.elastic.apm.agent.testutils.assertions.Assertions.assertThat;
9+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
10+
11+
public class BodyCaptureTest {
12+
13+
@Test
14+
public void testAppendTruncation() {
15+
BodyCaptureImpl capture = new BodyCaptureImpl();
16+
capture.markEligibleForCapturing();
17+
capture.startCapture("foobar", 10);
18+
assertThat(capture.isFull()).isFalse();
19+
20+
capture.append("123Hello World!".getBytes(StandardCharsets.UTF_8), 3, 5);
21+
assertThat(capture.isFull()).isFalse();
22+
23+
capture.append(" from the other side".getBytes(StandardCharsets.UTF_8), 0, 20);
24+
assertThat(capture.isFull()).isTrue();
25+
26+
ByteBuffer content = capture.getBody();
27+
int size = content.position();
28+
byte[] contentBytes = new byte[size];
29+
content.position(0);
30+
content.get(contentBytes);
31+
32+
assertThat(contentBytes).isEqualTo("Hello from".getBytes(StandardCharsets.UTF_8));
33+
}
34+
35+
@Test
36+
public void testLifecycle() {
37+
BodyCaptureImpl capture = new BodyCaptureImpl();
38+
39+
assertThat(capture.isEligibleForCapturing()).isFalse();
40+
assertThat(capture.startCapture("foobar", 42))
41+
.isFalse();
42+
assertThatThrownBy(() -> capture.append((byte) 42)).isInstanceOf(IllegalStateException.class);
43+
44+
capture.markEligibleForCapturing();
45+
assertThat(capture.isEligibleForCapturing()).isTrue();
46+
assertThatThrownBy(() -> capture.append((byte) 42)).isInstanceOf(IllegalStateException.class);
47+
48+
assertThat(capture.startCapture("foobar", 42))
49+
.isTrue();
50+
capture.append((byte) 42); //ensure no exception thrown
51+
52+
// startCapture should return true only once
53+
assertThat(capture.startCapture("foobar", 42))
54+
.isFalse();
55+
}
56+
}

0 commit comments

Comments
 (0)