Skip to content

Commit f265d60

Browse files
authored
Capture text-based request bodies (#498)
1 parent 9379f5f commit f265d60

File tree

23 files changed

+930
-72
lines changed

23 files changed

+930
-72
lines changed

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

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
public class Request implements Recyclable {
4343

4444

45-
private final ObjectPool<CharBuffer> charBufferPool = QueueBasedObjectPool.of(new MpmcAtomicArrayQueue<CharBuffer>(128), false,
45+
private static final ObjectPool<CharBuffer> charBufferPool = QueueBasedObjectPool.of(new MpmcAtomicArrayQueue<CharBuffer>(128), false,
4646
new Allocator<CharBuffer>() {
4747
@Override
4848
public CharBuffer createInstance() {
@@ -71,6 +71,11 @@ public void recycle(CharBuffer object) {
7171
* A parsed key-value object of cookies
7272
*/
7373
private final PotentiallyMultiValuedMap cookies = new PotentiallyMultiValuedMap();
74+
/**
75+
* Data should only contain the request body (not the query string). It can either be a dictionary (for standard HTTP requests) or a raw request body.
76+
*/
77+
@Nullable
78+
private String rawBody;
7479
/**
7580
* HTTP version.
7681
*/
@@ -84,6 +89,7 @@ public void recycle(CharBuffer object) {
8489
private String method;
8590
@Nullable
8691
private CharBuffer bodyBuffer;
92+
private boolean bodyBufferFinished = false;
8793

8894
/**
8995
* Data should only contain the request body (not the query string). It can either be a dictionary (for standard HTTP requests) or a raw request body.
@@ -92,16 +98,34 @@ public void recycle(CharBuffer object) {
9298
public Object getBody() {
9399
if (!postParams.isEmpty()) {
94100
return postParams;
101+
} else if (rawBody != null) {
102+
return rawBody;
95103
} else {
96104
return bodyBuffer;
97105
}
98106
}
99107

100-
public void redactBody() {
108+
@Nullable
109+
public String getRawBody() {
110+
return rawBody;
111+
}
112+
113+
/**
114+
* Sets the body as a raw string and removes any previously set {@link #postParams} or {@link #bodyBuffer}.
115+
*
116+
* @param rawBody the body as a raw string
117+
*/
118+
public void setRawBody(String rawBody) {
101119
postParams.resetState();
102120
if (bodyBuffer != null) {
103-
bodyBuffer.clear().append("[REDACTED]").flip();
121+
charBufferPool.recycle(bodyBuffer);
122+
bodyBuffer = null;
104123
}
124+
this.rawBody = rawBody;
125+
}
126+
127+
public void redactBody() {
128+
setRawBody("[REDACTED]");
105129
}
106130

107131
public Request addFormUrlEncodedParameter(String key, String value) {
@@ -132,6 +156,13 @@ public CharBuffer withBodyBuffer() {
132156
return this.bodyBuffer;
133157
}
134158

159+
public void endOfBufferInput() {
160+
if (bodyBuffer != null && !bodyBufferFinished) {
161+
bodyBufferFinished = true;
162+
((Buffer) bodyBuffer).flip();
163+
}
164+
}
165+
135166
/**
136167
* Returns the associated pooled {@link CharBuffer} to record the request body.
137168
* <p>
@@ -142,6 +173,15 @@ public CharBuffer withBodyBuffer() {
142173
*/
143174
@Nullable
144175
public CharBuffer getBodyBuffer() {
176+
if (!bodyBufferFinished) {
177+
return bodyBuffer;
178+
} else {
179+
return null;
180+
}
181+
}
182+
183+
@Nullable
184+
public CharBuffer getBodyBufferForSerialization() {
145185
return bodyBuffer;
146186
}
147187

@@ -231,6 +271,10 @@ public PotentiallyMultiValuedMap getCookies() {
231271
return cookies;
232272
}
233273

274+
void onTransactionEnd() {
275+
endOfBufferInput();
276+
}
277+
234278
@Override
235279
public void resetState() {
236280
postParams.resetState();
@@ -240,10 +284,11 @@ public void resetState() {
240284
socket.resetState();
241285
url.resetState();
242286
cookies.resetState();
287+
bodyBufferFinished = false;
243288
if (bodyBuffer != null) {
244289
charBufferPool.recycle(bodyBuffer);
290+
bodyBuffer = null;
245291
}
246-
bodyBuffer = null;
247292
}
248293

249294
public void copyFrom(Request other) {
@@ -255,12 +300,12 @@ public void copyFrom(Request other) {
255300
this.url.copyFrom(other.url);
256301
this.cookies.copyFrom(other.cookies);
257302
if (other.bodyBuffer != null) {
258-
final CharBuffer otherBuffer = other.getBodyBuffer();
303+
final CharBuffer otherBuffer = other.bodyBuffer;
259304
final CharBuffer thisBuffer = this.withBodyBuffer();
260305
for (int i = 0; i < otherBuffer.length(); i++) {
261306
thisBuffer.append(otherBuffer.charAt(i));
262307
}
263-
thisBuffer.flip();
308+
((Buffer) thisBuffer).flip();
264309
}
265310
}
266311

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,8 @@ public void resetState() {
9292
request.resetState();
9393
user.resetState();
9494
}
95+
96+
public void onTransactionEnd() {
97+
request.onTransactionEnd();
98+
}
9599
}

apm-agent-core/src/main/java/co/elastic/apm/agent/impl/transaction/Db.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
*/
3939
public class Db implements Recyclable {
4040

41-
private final ObjectPool<CharBuffer> charBufferPool = QueueBasedObjectPool.of(new MpmcAtomicArrayQueue<CharBuffer>(128), false,
41+
private static final ObjectPool<CharBuffer> charBufferPool = QueueBasedObjectPool.of(new MpmcAtomicArrayQueue<CharBuffer>(128), false,
4242
new Allocator<CharBuffer>() {
4343
@Override
4444
public CharBuffer createInstance() {

apm-agent-core/src/main/java/co/elastic/apm/agent/impl/transaction/Transaction.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ public void doEnd(long epochMicros) {
171171
if (type == null) {
172172
type = "custom";
173173
}
174+
context.onTransactionEnd();
174175
this.tracer.endTransaction(this);
175176
}
176177

apm-agent-core/src/main/java/co/elastic/apm/agent/matcher/WildcardMatcher.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,18 @@ public static WildcardMatcher valueOf(final String wildcardString) {
128128
return new CompoundWildcardMatcher(wildcardString, matcher, matchers);
129129
}
130130

131+
/**
132+
* Returns the first {@link WildcardMatcher} {@linkplain WildcardMatcher#matches(String) matching} the provided string.
133+
*
134+
* @param matchers the matchers which should be used to match the provided string
135+
* @param s the string to match against
136+
* @return the first matching {@link WildcardMatcher}, or {@code null} if none match.
137+
*/
138+
@Nullable
139+
public static boolean isAnyMatch(List<WildcardMatcher> matchers, @Nullable String s) {
140+
return anyMatch(matchers, s) != null;
141+
}
142+
131143
/**
132144
* Returns {@code true}, if any of the matchers match the provided string.
133145
*
@@ -136,17 +148,20 @@ public static WildcardMatcher valueOf(final String wildcardString) {
136148
* @return {@code true}, if any of the matchers match the provided string
137149
*/
138150
@Nullable
139-
public static WildcardMatcher anyMatch(List<WildcardMatcher> matchers, String s) {
151+
public static WildcardMatcher anyMatch(List<WildcardMatcher> matchers, @Nullable String s) {
152+
if (s == null) {
153+
return null;
154+
}
140155
return anyMatch(matchers, s, null);
141156
}
142157

143158
/**
144-
* Returns {@code true}, if any of the matchers match the provided partitioned string.
159+
* Returns the first {@link WildcardMatcher} {@linkplain WildcardMatcher#matches(String) matching} the provided partitioned string.
145160
*
146161
* @param matchers the matchers which should be used to match the provided string
147162
* @param firstPart The first part of the string to match against.
148163
* @param secondPart The second part of the string to match against.
149-
* @return {@code true}, if any of the matchers match the provided partitioned string
164+
* @return the first matching {@link WildcardMatcher}, or {@code null} if none match.
150165
* @see #matches(String, String)
151166
*/
152167
@Nullable

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -816,10 +816,15 @@ private void serializeRequest(final Request request) {
816816
// only one of those can be non-empty
817817
if (!request.getFormUrlEncodedParameters().isEmpty()) {
818818
writeField("body", request.getFormUrlEncodedParameters());
819-
} else if (request.getBodyBuffer() != null && request.getBodyBuffer().length() > 0) {
820-
writeFieldName("body");
821-
jw.writeString(request.getBodyBuffer());
822-
jw.writeByte(COMMA);
819+
} else if (request.getRawBody() != null) {
820+
writeField("body", request.getRawBody());
821+
} else {
822+
final CharBuffer bodyBuffer = request.getBodyBufferForSerialization();
823+
if (bodyBuffer != null && bodyBuffer.length() > 0) {
824+
writeFieldName("body");
825+
jw.writeString(bodyBuffer);
826+
jw.writeByte(COMMA);
827+
}
823828
}
824829
if (request.getUrl().hasContent()) {
825830
serializeUrl(request.getUrl());
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*-
2+
* #%L
3+
* Elastic APM Java agent
4+
* %%
5+
* Copyright (C) 2018 - 2019 Elastic and contributors
6+
* %%
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
* #L%
19+
*/
20+
package co.elastic.apm.agent.servlet;
21+
22+
import co.elastic.apm.agent.bci.ElasticApmInstrumentation;
23+
import co.elastic.apm.agent.bci.HelperClassManager;
24+
import co.elastic.apm.agent.bci.VisibleForAdvice;
25+
import co.elastic.apm.agent.impl.ElasticApmTracer;
26+
import co.elastic.apm.agent.impl.context.Request;
27+
import co.elastic.apm.agent.impl.transaction.Transaction;
28+
import net.bytebuddy.asm.Advice;
29+
import net.bytebuddy.description.NamedElement;
30+
import net.bytebuddy.description.method.MethodDescription;
31+
import net.bytebuddy.description.type.TypeDescription;
32+
import net.bytebuddy.matcher.ElementMatcher;
33+
34+
import javax.annotation.Nullable;
35+
import javax.servlet.ServletInputStream;
36+
import java.util.Arrays;
37+
import java.util.Collection;
38+
39+
import static co.elastic.apm.agent.servlet.ServletInstrumentation.SERVLET_API;
40+
import static net.bytebuddy.matcher.ElementMatchers.hasSuperType;
41+
import static net.bytebuddy.matcher.ElementMatchers.isInterface;
42+
import static net.bytebuddy.matcher.ElementMatchers.nameContains;
43+
import static net.bytebuddy.matcher.ElementMatchers.named;
44+
import static net.bytebuddy.matcher.ElementMatchers.not;
45+
import static net.bytebuddy.matcher.ElementMatchers.returns;
46+
47+
public class RequestStreamRecordingInstrumentation extends ElasticApmInstrumentation {
48+
49+
@Nullable
50+
@VisibleForAdvice
51+
// referring to InputStreamWrapperFactory is legal because of type erasure
52+
public static HelperClassManager<InputStreamWrapperFactory> wrapperHelperClassManager;
53+
54+
@Override
55+
public void init(ElasticApmTracer tracer) {
56+
wrapperHelperClassManager = HelperClassManager.ForSingleClassLoader.of(tracer,
57+
"co.elastic.apm.agent.servlet.helper.InputStreamFactoryHelperImpl",
58+
"co.elastic.apm.agent.servlet.helper.RecordingServletInputStreamWrapper");
59+
}
60+
61+
@Override
62+
public ElementMatcher<? super NamedElement> getTypeMatcherPreFilter() {
63+
return nameContains("Request");
64+
}
65+
66+
@Override
67+
public ElementMatcher<? super TypeDescription> getTypeMatcher() {
68+
return hasSuperType(named("javax.servlet.ServletRequest")).and(not(isInterface()));
69+
}
70+
71+
@Override
72+
public ElementMatcher<? super MethodDescription> getMethodMatcher() {
73+
return named("getInputStream").and(returns(named("javax.servlet.ServletInputStream")));
74+
}
75+
76+
@Override
77+
public Collection<String> getInstrumentationGroupNames() {
78+
return Arrays.asList(SERVLET_API, "servlet-input-stream");
79+
}
80+
81+
@Override
82+
public Class<?> getAdviceClass() {
83+
return GetInputStreamAdvice.class;
84+
}
85+
86+
public interface InputStreamWrapperFactory {
87+
ServletInputStream wrap(Request request, ServletInputStream servletInputStream);
88+
}
89+
90+
public static class GetInputStreamAdvice {
91+
92+
@VisibleForAdvice
93+
public static final ThreadLocal<Boolean> nestedThreadLocal = new ThreadLocal<Boolean>() {
94+
@Override
95+
protected Boolean initialValue() {
96+
return Boolean.FALSE;
97+
}
98+
};
99+
100+
@Advice.OnMethodEnter(suppress = Throwable.class)
101+
public static void onReadEnter(@Advice.This Object thiz,
102+
@Advice.Local("transaction") Transaction transaction,
103+
@Advice.Local("nested") boolean nested) {
104+
nested = nestedThreadLocal.get();
105+
nestedThreadLocal.set(Boolean.TRUE);
106+
}
107+
108+
@Advice.OnMethodExit(suppress = Throwable.class)
109+
public static void afterGetInputStream(@Advice.Return(readOnly = false) ServletInputStream inputStream,
110+
@Advice.Local("nested") boolean nested) {
111+
if (nested || tracer == null || wrapperHelperClassManager == null) {
112+
return;
113+
}
114+
try {
115+
final Transaction transaction = tracer.currentTransaction();
116+
// only wrap if the body buffer has been initialized via ServletTransactionHelper.startCaptureBody
117+
if (transaction != null && transaction.getContext().getRequest().getBodyBuffer() != null) {
118+
inputStream = wrapperHelperClassManager.getForClassLoaderOfClass(inputStream.getClass()).wrap(transaction.getContext().getRequest(), inputStream);
119+
}
120+
} finally {
121+
nestedThreadLocal.set(Boolean.FALSE);
122+
}
123+
}
124+
}
125+
}

apm-agent-plugins/apm-servlet-plugin/src/main/java/co/elastic/apm/agent/servlet/ServletApiAdvice.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ public static void onEnterServletService(@Advice.Argument(0) ServletRequest serv
117117

118118
servletTransactionHelper.fillRequestContext(transaction, request.getProtocol(), request.getMethod(), request.isSecure(),
119119
request.getScheme(), request.getServerName(), request.getServerPort(), request.getRequestURI(), request.getQueryString(),
120-
request.getRemoteAddr());
120+
request.getRemoteAddr(), request.getHeader("Content-Type"));
121121
}
122122
}
123123

0 commit comments

Comments
 (0)