Skip to content
Draft
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
Expand Up @@ -6,6 +6,7 @@
import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes;
import datadog.trace.bootstrap.instrumentation.api.Tags;
import datadog.trace.bootstrap.instrumentation.api.URIUtils;
import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
import java.net.URI;
import java.net.URISyntaxException;
Expand Down Expand Up @@ -56,7 +57,7 @@ public void onURI(@Nonnull AgentSpan span, @Nonnull URI uri) {

public void onURL(@Nonnull final AgentSpan span, @Nonnull final URL url) {
try {
onURI(span, url.toURI());
onURI(span, URIUtils.toURI(url));
} catch (URISyntaxException e) {
LOGGER.debug("Error tagging url", e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static datadog.trace.api.cache.RadixTreeCache.UNSET_STATUS;

import datadog.trace.bootstrap.instrumentation.api.URIUtils;
import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
import datadog.trace.bootstrap.instrumentation.decorator.HttpClientDecorator;
import java.net.HttpURLConnection;
Expand Down Expand Up @@ -36,7 +37,7 @@ protected String method(final HttpURLConnection connection) {

@Override
protected URI url(final HttpURLConnection connection) throws URISyntaxException {
return connection.getURL().toURI();
return URIUtils.toURI(connection.getURL());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package datadog.trace.bootstrap.instrumentation.api;

import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Threads;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;

/**
* What an HTTP client decorator pays to get the URL parts it tags from a {@link URL}, comparing
* conversions to {@link URI} against reading the {@code URL} directly.
*
* <p>Every arm extracts the same values {@code HttpClientDecorator.onRequest} reads: scheme, host,
* port, and the decoded path, query and fragment. {@code URI}'s getters decode percent-escapes and
* {@code URL}'s do not, so the {@code URL} arm decodes them itself.
*
* <ul>
* <li>{@link #strict}: {@code url.toURI()}, as on master; a bad URL throws and is caught.
* <li>{@link #latched}: {@link URIUtils#toURI} through its shared {@link URIUtils.ToURI} latch,
* as in production. Well-formed input keeps it disengaged; bad input keeps it on the repair
* path.
* <li>{@link #repairPath}: the latch's repairing conversion on every call, which is what a
* well-formed URL costs while the latch is engaged.
* <li>{@link #urlFields}: no {@code URI} at all; the parts come from the {@code URL}, the
* structural alternative of a client-side {@code URIDataAdapter}.
* </ul>
*
* <p>{@code depth} runs each call under that many extra frames, because a throw's cost grows with
* the stack it captures and a real HTTP client call is deep. Only {@link #strict} on bad input
* throws; the frames cost the other arms the same small constant.
*
* <p>The latch is shared across all threads, as the production {@code static final} is, so its
* plain counter is written concurrently on the repair path. Run with {@code -prof gc}, and read the
* allocation modes across forks rather than their mean.
*/
@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 5, time = 2)
@Measurement(iterations = 5, time = 2)
@Fork(3)
@Threads(8)
public class URIUtilsToURIBenchmark {

@Param({"0", "50"})
int depth;

/** Shared like the production latch, which is a {@code static final}. */
URIUtils.ToURI latch;

/** Per thread, so each thread reads its own {@code URL}. */
@State(Scope.Thread)
public static class Url {
@Param({"wellFormed", "bad"})
String input;

URL url;

@Setup(Level.Trial)
@SuppressWarnings("deprecation") // new URL(String) is what applications use to build these
public void setup() throws MalformedURLException {
// same length and shape; the bad one has the unencoded space URI rejects and URL accepts
url =
new URL(
"wellFormed".equals(input)
? "http://example.com/api/v1/search?q=hello%20world&page=2#top"
: "http://example.com/api/v1/search?q=hello world&page=2#top");
}
}

@Setup(Level.Trial)
public void setup() {
latch = new URIUtils.ToURI();
}

@Benchmark
public void strict(Url state, Blackhole bh) {
consume(bh, strict(state.url, depth));
}

@Benchmark
public void latched(Url state, Blackhole bh) {
consume(bh, latched(latch, state.url, depth));
}

@Benchmark
public void repairPath(Url state, Blackhole bh) {
consume(bh, repairPath(latch, state.url, depth));
}

@Benchmark
public void urlFields(Url state, Blackhole bh) {
urlFields(bh, state.url, depth);
}

private static URI strict(URL url, int depth) {
if (depth > 0) {
return strict(url, depth - 1);
}
try {
return url.toURI();
} catch (URISyntaxException e) {
return null; // HttpClientDecorator logs at debug and tags nothing
}
}

private static URI latched(URIUtils.ToURI latch, URL url, int depth) {
if (depth > 0) {
return latched(latch, url, depth - 1);
}
final URI uri = latch.tryApply(url);
if (uri != null) {
return uri;
}
try {
return url.toURI(); // what URIUtils.toURI does for an unrepairable URL
} catch (URISyntaxException e) {
return null;
}
}

private static URI repairPath(URIUtils.ToURI latch, URL url, int depth) {
if (depth > 0) {
return repairPath(latch, url, depth - 1);
}
return latch.applySafely(url);
}

private static void urlFields(Blackhole bh, URL url, int depth) {
if (depth > 0) {
urlFields(bh, url, depth - 1);
return;
}
bh.consume(url.getProtocol());
bh.consume(url.getHost());
bh.consume(url.getPort());
bh.consume(URIUtils.decode(url.getPath()));
bh.consume(URIUtils.decode(url.getQuery()));
bh.consume(URIUtils.decode(url.getRef()));
}

private static void consume(Blackhole bh, URI uri) {
if (uri == null) {
bh.consume((Object) null);
return;
}
bh.consume(uri.getScheme());
bh.consume(uri.getHost());
bh.consume(uri.getPort());
bh.consume(uri.getPath());
bh.consume(uri.getQuery());
bh.consume(uri.getFragment());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import static datadog.trace.api.telemetry.LogCollector.EXCLUDE_TELEMETRY;

import datadog.trace.api.iast.util.PropagationUtils;
import datadog.trace.util.AdaptiveLatch;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
Expand Down Expand Up @@ -145,6 +147,162 @@ public static URI safeParse(@Nullable final String unparsed) {
}
}

/**
* Converts a {@link URL} to a {@link URI} like {@link URL#toURI()}, but first percent-encodes the
* characters that {@link URI} rejects even though {@link URL} accepts them, so that a common kind
* of bad input does not cost a thrown and caught {@link URISyntaxException} on every request.
*
* <p>These are: a space or control character, any of {@code " < > \ ^ ` { | }}, a {@code [} or
* {@code ]} in the path, a {@code %} that is not followed by two hex digits, and a second {@code
* #}. Well-formed URLs are converted exactly as {@code url.toURI()} would, with no extra
* allocation. Other problems, such as an invalid scheme, still throw {@link URISyntaxException}.
*
* <p>Bad URLs tend to come from the same application code again and again, so the repair runs
* only while they are arriving (see {@link ToURI}); otherwise this is just {@code url.toURI()}.
*/
public static URI toURI(final URL url) throws URISyntaxException {
final URI uri = TO_URI.tryApply(url);
// null only when the repair could not help: let URI report why
return uri != null ? uri : url.toURI();
}

private static final ToURI TO_URI = new ToURI();

/**
* Chooses between {@code url.toURI()} and the repairing conversion: strict while URLs are
* well-formed, so they skip the scan; repairing once a bad one arrives, until {@link
* #CLOSE_AFTER} well-formed ones in a row.
*/
static final class ToURI extends AdaptiveLatch<URL, URI, URISyntaxException> {
/**
* The rent-or-buy break-even (see {@link AdaptiveLatch}): a thrown and caught {@link
* URISyntaxException} under an HTTP client's stack, against one extra scan of a well-formed
* URL. An estimate, not yet measured.
*/
static final int CLOSE_AFTER = 64;

ToURI() {
super(URISyntaxException.class, CLOSE_AFTER);
}

@Override
protected URI apply(final URL url) throws URISyntaxException {
return url.toURI();
}

@Override
protected URI applySafely(final URL url) {
final String s = url.toString();
final String escaped = escapeIllegalURIChars(s);
try {
return escaped == null ? new URI(s) : repaired(new URI(escaped));
} catch (URISyntaxException e) {
return reject(url);
}
}
}

/** Percent-encodes the characters {@link URI} rejects; returns {@code null} if there are none. */
@Nullable
private static String escapeIllegalURIChars(final String s) {
final int length = s.length();
final int pathStart = pathStart(s);
int pathEnd = length;
for (int i = pathStart; i < length; i++) {
final char c = s.charAt(i);
if (c == '?' || c == '#') {
pathEnd = i;
break;
}
}

StringBuilder escaped = null;
boolean inFragment = false;
for (int i = 0; i < length; i++) {
final char c = s.charAt(i);
final boolean illegal;
if (c == '%') {
illegal = !isPercentEscape(s, i);
} else if (c == '#') {
illegal = inFragment; // only the first '#' starts the fragment
inFragment = true;
} else if (c == '[' || c == ']') {
illegal = i >= pathStart && i < pathEnd; // allowed in an IPv6 host, the query and fragment
} else {
illegal = isIllegalURIChar(c);
}
if (illegal) {
if (escaped == null) {
escaped = new StringBuilder(length + 16).append(s, 0, i);
}
appendPercentEncoded(escaped, c);
} else if (escaped != null) {
escaped.append(c);
}
}
return escaped == null ? null : escaped.toString();
}

/** Index where the path starts: after the authority of {@code scheme://authority}, or 0. */
private static int pathStart(final String s) {
final int schemeEnd = s.indexOf("://");
if (schemeEnd < 0) {
return 0;
}
for (int i = schemeEnd + 3; i < s.length(); i++) {
final char c = s.charAt(i);
if (c == '/' || c == '?' || c == '#') {
return i;
}
}
return s.length();
}

private static boolean isPercentEscape(final String s, final int i) {
return i + 2 < s.length() && isHexDigit(s.charAt(i + 1)) && isHexDigit(s.charAt(i + 2));
}

private static boolean isHexDigit(final char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}

private static boolean isIllegalURIChar(final char c) {
if (c <= ' ' || c == 0x7F) {
return true;
}
switch (c) {
case '"':
case '<':
case '>':
case '\\':
case '^':
case '`':
case '{':
case '|':
case '}':
return true;
default:
// URI accepts other non-ASCII characters, but not spaces or control characters
return c >= 0x80 && (Character.isSpaceChar(c) || Character.isISOControl(c));
}
}

private static void appendPercentEncoded(final StringBuilder sb, final char c) {
if (c < 0x80) {
appendPercentEncoded(sb, (byte) c);
} else {
for (final byte b : String.valueOf(c).getBytes(StandardCharsets.UTF_8)) {
appendPercentEncoded(sb, b);
}
}
}

private static void appendPercentEncoded(final StringBuilder sb, final byte b) {
sb.append('%')
.append(Character.toUpperCase(Character.forDigit((b >> 4) & 0xF, 16)))
.append(Character.toUpperCase(Character.forDigit(b & 0xF, 16)));
}

/**
* Builds a lazily evaluated valid URL based on the scheme, host, port and path.
*
Expand Down
Loading
Loading