Skip to content

Commit a2333cf

Browse files
authored
✨ Capture HTTP bodies on Undertow (#321)
- Handle the case where the Undertow servlet implemeentation does not access the request body via the Servlet API
1 parent 0a62233 commit a2333cf

File tree

17 files changed

+855
-6
lines changed

17 files changed

+855
-6
lines changed

instrumentation/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ dependencies{
4040
implementation(project(":instrumentation:apache-httpasyncclient-4.1"))
4141
implementation(project(":instrumentation:netty:netty-4.0"))
4242
implementation(project(":instrumentation:netty:netty-4.1"))
43+
implementation(project(":instrumentation:undertow:undertow-1.4"))
44+
implementation(project(":instrumentation:undertow:undertow-servlet-1.4"))
4345
implementation(project(":otel-extensions"))
4446
}
4547

instrumentation/java-streams/src/test/java/io/opentelemetry/javaagent/instrumentation/hypertrace/java/inputstream/InputStreamInstrumentationModuleTest.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@
2020
import io.opentelemetry.api.trace.Span;
2121
import io.opentelemetry.sdk.trace.data.SpanData;
2222
import java.io.ByteArrayInputStream;
23-
import java.io.ByteArrayOutputStream;
2423
import java.io.IOException;
2524
import java.io.InputStream;
2625
import java.nio.charset.StandardCharsets;
2726
import java.util.List;
2827
import org.ContextAccessor;
2928
import org.hypertrace.agent.core.instrumentation.SpanAndBuffer;
29+
import org.hypertrace.agent.core.instrumentation.buffer.BoundedBuffersFactory;
30+
import org.hypertrace.agent.core.instrumentation.buffer.BoundedByteArrayOutputStream;
3031
import org.hypertrace.agent.testing.AbstractInstrumenterTest;
3132
import org.junit.jupiter.api.Assertions;
3233
import org.junit.jupiter.api.Test;
@@ -94,7 +95,8 @@ public void readBytesOffset() {
9495
private void read(InputStream inputStream, Runnable read, String expected) {
9596
Span span = TEST_TRACER.spanBuilder("test-span").startSpan();
9697

97-
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
98+
BoundedByteArrayOutputStream buffer =
99+
BoundedBuffersFactory.createStream(StandardCharsets.ISO_8859_1);
98100
ContextAccessor.addToInputStreamContext(
99101
inputStream, new SpanAndBuffer(span, buffer, ATTRIBUTE_KEY, StandardCharsets.ISO_8859_1));
100102

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
plugins {
2+
`java-library`
3+
id("net.bytebuddy.byte-buddy")
4+
id("io.opentelemetry.instrumentation.auto-instrumentation")
5+
muzzle
6+
}
7+
8+
muzzle {
9+
pass {
10+
group = "io.undertow"
11+
module = "undertow-core"
12+
versions = "[1.4.0.Final,)"
13+
assertInverse = true
14+
}
15+
}
16+
17+
afterEvaluate{
18+
io.opentelemetry.instrumentation.gradle.bytebuddy.ByteBuddyPluginConfigurator(project,
19+
sourceSets.main.get(),
20+
"io.opentelemetry.javaagent.tooling.muzzle.collector.MuzzleCodeGenerationPlugin",
21+
project(":javaagent-tooling").configurations["instrumentationMuzzle"] + configurations.runtimeClasspath
22+
).configure()
23+
}
24+
25+
val versions: Map<String, String> by extra
26+
27+
dependencies {
28+
implementation("io.opentelemetry.javaagent.instrumentation:opentelemetry-javaagent-undertow-1.4:${versions["opentelemetry_java_agent"]}")
29+
library("io.undertow:undertow-core:1.4.0.Final")
30+
implementation(project(":instrumentation:undertow:undertow-common"))
31+
testImplementation(testFixtures(project(":testing-common")))
32+
testImplementation("javax.servlet:javax.servlet-api:3.1.0")
33+
testImplementation("io.undertow:undertow-servlet:2.0.0.Final")
34+
testRuntimeOnly(project(":instrumentation:servlet:servlet-3.0"))
35+
testRuntimeOnly(project(":instrumentation:undertow:undertow-servlet-1.4"))
36+
}
37+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Copyright The Hypertrace Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.opentelemetry.javaagent.instrumentation.hypertrace.undertow.v1_4;
18+
19+
import static net.bytebuddy.matcher.ElementMatchers.isPublic;
20+
import static net.bytebuddy.matcher.ElementMatchers.named;
21+
import static net.bytebuddy.matcher.ElementMatchers.returns;
22+
import static net.bytebuddy.matcher.ElementMatchers.takesArgument;
23+
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
24+
25+
import io.opentelemetry.javaagent.instrumentation.api.ContextStore;
26+
import io.opentelemetry.javaagent.instrumentation.api.InstrumentationContext;
27+
import io.opentelemetry.javaagent.instrumentation.hypertrace.undertow.v1_4.utils.Utils;
28+
import io.opentelemetry.javaagent.tooling.TypeInstrumentation;
29+
import io.opentelemetry.javaagent.tooling.bytebuddy.matcher.AgentElementMatchers;
30+
import io.undertow.server.HttpServerExchange;
31+
import java.nio.ByteBuffer;
32+
import java.util.Collections;
33+
import java.util.Map;
34+
import net.bytebuddy.asm.Advice;
35+
import net.bytebuddy.description.method.MethodDescription;
36+
import net.bytebuddy.description.type.TypeDescription;
37+
import net.bytebuddy.matcher.ElementMatcher;
38+
import org.hypertrace.agent.core.instrumentation.HypertraceCallDepthThreadLocalMap;
39+
import org.hypertrace.agent.core.instrumentation.SpanAndBuffer;
40+
import org.xnio.channels.StreamSourceChannel;
41+
42+
/** Instrumentation for {@link StreamSourceChannel} implementations */
43+
public final class StreamSourceChannelInstrumentation implements TypeInstrumentation {
44+
45+
@Override
46+
public ElementMatcher<TypeDescription> typeMatcher() {
47+
return AgentElementMatchers.safeHasSuperType(named("org.xnio.channels.StreamSourceChannel"));
48+
}
49+
50+
@Override
51+
public Map<? extends ElementMatcher<? super MethodDescription>, String> transformers() {
52+
return Collections.singletonMap(
53+
named("read")
54+
.and(takesArguments(1))
55+
.and(takesArgument(0, ByteBuffer.class))
56+
.and(returns(int.class))
57+
.and(isPublic()),
58+
StreamSourceChannelInstrumentation.class.getName() + "$Read_advice");
59+
}
60+
61+
/**
62+
* Decorates the {@link StreamSourceChannel#read(ByteBuffer)} implementations with logic to
63+
* capture data read into the request body {@link ByteBuffer} and report it. This instrumentation
64+
* short-circuits if:
65+
*
66+
* <ul>
67+
* <li>We're in a nested {@link StreamSourceChannel#read(ByteBuffer)} call
68+
* <li>A {@link Throwable} was thrown in the context of the {@link
69+
* StreamSourceChannel#read(ByteBuffer)}, causing the method to exit
70+
* <li>The instrumented {@link StreamSourceChannel} was never put in the {@link
71+
* InstrumentationContext} by {@link
72+
* UndertowHttpServerExchangeInstrumentation.GetRequestChannel_advice#exit(HttpServerExchange,
73+
* StreamSourceChannel)}
74+
* </ul>
75+
*/
76+
static final class Read_advice {
77+
78+
@Advice.OnMethodEnter
79+
public static void trackCallDepth() {
80+
HypertraceCallDepthThreadLocalMap.incrementCallDepth(StreamSourceChannel.class);
81+
}
82+
83+
@Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class)
84+
public static void exit(
85+
@Advice.Return final int numBytesRead,
86+
@Advice.This final StreamSourceChannel streamSourceChannel,
87+
@Advice.Thrown final Throwable thrown,
88+
@Advice.Argument(0) final ByteBuffer byteBuffer) {
89+
if (HypertraceCallDepthThreadLocalMap.decrementCallDepth(StreamSourceChannel.class) > 0
90+
|| thrown != null) {
91+
return;
92+
}
93+
final ContextStore<StreamSourceChannel, SpanAndBuffer> contextStore =
94+
InstrumentationContext.get(StreamSourceChannel.class, SpanAndBuffer.class);
95+
final SpanAndBuffer spanAndBuffer = contextStore.get(streamSourceChannel);
96+
if (spanAndBuffer != null) {
97+
Utils.handleRead(byteBuffer.asReadOnlyBuffer(), numBytesRead, spanAndBuffer);
98+
}
99+
}
100+
}
101+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright The Hypertrace Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.opentelemetry.javaagent.instrumentation.hypertrace.undertow.v1_4;
18+
19+
import static net.bytebuddy.matcher.ElementMatchers.failSafe;
20+
import static net.bytebuddy.matcher.ElementMatchers.isPublic;
21+
import static net.bytebuddy.matcher.ElementMatchers.named;
22+
import static net.bytebuddy.matcher.ElementMatchers.returns;
23+
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
24+
25+
import io.opentelemetry.javaagent.instrumentation.api.ContextStore;
26+
import io.opentelemetry.javaagent.instrumentation.api.InstrumentationContext;
27+
import io.opentelemetry.javaagent.instrumentation.hypertrace.undertow.common.RequestBodyCaptureMethod;
28+
import io.opentelemetry.javaagent.instrumentation.hypertrace.undertow.v1_4.utils.Utils;
29+
import io.opentelemetry.javaagent.tooling.TypeInstrumentation;
30+
import io.undertow.server.HttpServerExchange;
31+
import java.util.Collections;
32+
import java.util.Map;
33+
import net.bytebuddy.asm.Advice;
34+
import net.bytebuddy.description.method.MethodDescription;
35+
import net.bytebuddy.description.type.TypeDescription;
36+
import net.bytebuddy.matcher.ElementMatcher;
37+
import org.hypertrace.agent.core.instrumentation.SpanAndBuffer;
38+
import org.xnio.channels.StreamSourceChannel;
39+
40+
/** Instrumentation for {@link HttpServerExchange} to capture request bodies */
41+
public final class UndertowHttpServerExchangeInstrumentation implements TypeInstrumentation {
42+
43+
@Override
44+
public ElementMatcher<TypeDescription> typeMatcher() {
45+
return failSafe(named("io.undertow.server.HttpServerExchange"));
46+
}
47+
48+
@Override
49+
public Map<? extends ElementMatcher<? super MethodDescription>, String> transformers() {
50+
return Collections.singletonMap(
51+
named("getRequestChannel")
52+
.and(takesArguments(0))
53+
.and(returns(named("org.xnio.channels.StreamSourceChannel")))
54+
.and(isPublic()),
55+
UndertowHttpServerExchangeInstrumentation.class.getName() + "$GetRequestChannel_advice");
56+
}
57+
58+
/**
59+
* Decorates {@link HttpServerExchange#getRequestChannel()} with instrumentation to store a {@link
60+
* SpanAndBuffer} in the {@link InstrumentationContext}
61+
*/
62+
static final class GetRequestChannel_advice {
63+
64+
@Advice.OnMethodExit(suppress = Throwable.class, onThrowable = Throwable.class)
65+
public static void exit(
66+
@Advice.This final HttpServerExchange thizz,
67+
@Advice.Return final StreamSourceChannel returnedChannel) {
68+
final RequestBodyCaptureMethod requestBodyCaptureMethod =
69+
InstrumentationContext.get(HttpServerExchange.class, RequestBodyCaptureMethod.class)
70+
.get(thizz);
71+
if (RequestBodyCaptureMethod.SERVLET.equals(requestBodyCaptureMethod)) {
72+
// short circuit if we detect that we can capture the request body with servlet
73+
// instrumentation
74+
return;
75+
}
76+
final ContextStore<StreamSourceChannel, SpanAndBuffer> contextStore =
77+
InstrumentationContext.get(StreamSourceChannel.class, SpanAndBuffer.class);
78+
if (contextStore.get(returnedChannel) != null) {
79+
// HttpServerExchange.getRequestChannel only creates a new channel the first time it is
80+
// invoked.on subsequent invocations, we do not want to create a new buffer and put it in
81+
// the context, as that would reset the state and could potential result in lost request
82+
// bodies
83+
return;
84+
}
85+
Utils.createAndStoreBufferForSpan(thizz, returnedChannel, contextStore);
86+
}
87+
}
88+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright The Hypertrace Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.opentelemetry.javaagent.instrumentation.hypertrace.undertow.v1_4;
18+
19+
import com.google.auto.service.AutoService;
20+
import io.opentelemetry.javaagent.tooling.InstrumentationModule;
21+
import io.opentelemetry.javaagent.tooling.TypeInstrumentation;
22+
import java.util.Arrays;
23+
import java.util.List;
24+
25+
@AutoService(InstrumentationModule.class)
26+
public final class UndertowInstrumentationModule extends InstrumentationModule {
27+
28+
public UndertowInstrumentationModule() {
29+
super("undertow", "undertow-1.4-ht", "ht", "undertow-ht");
30+
}
31+
32+
@Override
33+
public List<TypeInstrumentation> typeInstrumentations() {
34+
return Arrays.asList(
35+
new UndertowHttpServerExchangeInstrumentation(), new StreamSourceChannelInstrumentation());
36+
}
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright The Hypertrace Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.opentelemetry.javaagent.instrumentation.hypertrace.undertow.v1_4.utils;
18+
19+
import io.undertow.server.ExchangeCompletionListener;
20+
import io.undertow.server.HttpServerExchange;
21+
import java.io.UnsupportedEncodingException;
22+
import org.hypertrace.agent.core.instrumentation.HypertraceSemanticAttributes;
23+
import org.hypertrace.agent.core.instrumentation.SpanAndBuffer;
24+
import org.slf4j.Logger;
25+
import org.slf4j.LoggerFactory;
26+
27+
public final class BodyCapturingExchangeCompletionListener implements ExchangeCompletionListener {
28+
29+
private static final Logger log =
30+
LoggerFactory.getLogger(BodyCapturingExchangeCompletionListener.class);
31+
32+
private final SpanAndBuffer spanAndBuffer;
33+
34+
public BodyCapturingExchangeCompletionListener(final SpanAndBuffer spanAndBuffer) {
35+
this.spanAndBuffer = spanAndBuffer;
36+
}
37+
38+
@Override
39+
public void exchangeEvent(HttpServerExchange exchange, NextListener nextListener) {
40+
final String body;
41+
try {
42+
body = spanAndBuffer.byteArrayBuffer.toStringWithSuppliedCharset();
43+
} catch (UnsupportedEncodingException e) {
44+
log.error("illegal encoding", e);
45+
return;
46+
}
47+
spanAndBuffer.span.setAttribute(HypertraceSemanticAttributes.HTTP_REQUEST_BODY, body);
48+
nextListener.proceed();
49+
}
50+
}

0 commit comments

Comments
 (0)