diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3895c5b2e..b06ff69d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,15 +20,16 @@ androidx-core = "androidx.core:core:1.13.1" findbugs-jsr305 = "com.google.code.findbugs:jsr305:3.0.2" byteBuddy = { module = "net.bytebuddy:byte-buddy", version.ref = "byteBuddy" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } -opentelemetry-instrumentation-api = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-api" } -opentelemetry-instrumentation-apiSemconv = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-api-incubator" } +opentelemetry-instrumentation-api = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-api"} +opentelemetry-instrumentation-apiSemconv = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-api-incubator", version.ref = "opentelemetry-instrumentation-alpha"} opentelemetry-instrumentation-okhttp = { module = "io.opentelemetry.instrumentation:opentelemetry-okhttp-3.0", version.ref = "opentelemetry-instrumentation-alpha" } opentelemetry-semconv = { module = "io.opentelemetry.semconv:opentelemetry-semconv", version.ref = "opentelemetry-semconv" } opentelemetry-semconv-incubating = { module = "io.opentelemetry.semconv:opentelemetry-semconv-incubating", version.ref = "opentelemetry-semconv" } -opentelemetry-api = { module = "io.opentelemetry:opentelemetry-api" } +opentelemetry-api = { module = "io.opentelemetry:opentelemetry-api"} opentelemetry-api-incubator = { module = "io.opentelemetry:opentelemetry-api-incubator" } opentelemetry-sdk-extension-incubator = { module = "io.opentelemetry:opentelemetry-sdk-extension-incubator", version.ref = "opentelemetry-alpha" } -opentelemetry-sdk = { module = "io.opentelemetry:opentelemetry-sdk" } +opentelemetry-sdk = { module = "io.opentelemetry:opentelemetry-sdk"} +opentelemetry-context = { module = "io.opentelemetry:opentelemetry-context", version.ref = "opentelemetry"} opentelemetry-exporter-logging = { module = "io.opentelemetry:opentelemetry-exporter-logging" } opentelemetry-diskBuffering = { module = "io.opentelemetry.contrib:opentelemetry-disk-buffering", version.ref = "opentelemetry-contrib" } opentelemetry-exporter-otlp = { module = "io.opentelemetry:opentelemetry-exporter-otlp", version.ref = "opentelemetry" } diff --git a/instrumentation/httpurlconnection/README.md b/instrumentation/httpurlconnection/README.md new file mode 100644 index 000000000..c1fee0c60 --- /dev/null +++ b/instrumentation/httpurlconnection/README.md @@ -0,0 +1,75 @@ +# Android Instrumentation for URLConnection, HttpURLConnection and HttpsURLConnection + +## Status : Experimental + +Provides OpenTelemetry instrumentation for: +- [URLConnection](https://developer.android.com/reference/java/net/URLConnection) +- [HttpURLConnection](https://developer.android.com/reference/java/net/HttpURLConnection) +- [HttpsURLConnection](https://developer.android.com/reference/javax/net/ssl/HttpsURLConnection) + +## Quickstart + +### Overview + +This plugin enhances the Android application host code by instrumenting all critical APIs (specifically those that initiate a connection). It intercepts calls to these APIs to ensure the following actions are performed: +- Context is added for distributed tracing before actual API is called (i.e before connection is initiated). +- Traces and spans are generated and properly closed. +- Any exceptions thrown are recorded. + +A span associated with a given request is concluded in the following scenarios: +- When the getInputStream()/getErrorStream() APIs are called, the span concludes after the stream is fully read, an IOException is encountered, or the stream is closed. +- When the disconnect API is called. + +Spans won't be automatically ended and reported otherwise. If any of your URLConnection requests do not call the span concluding APIs mentioned above, refer the section entitled ["Scheduling Harvester Thread"](#scheduling-harvester-thread). This section provides guidance on setting up a recurring thread that identifies unreported, idle connections (those that have been read from but have been inactive for more than 10 seconds) and concludes any open spans associated with them. + +> The minimum supported Android SDK version is 21, though it will also instrument APIs added in the Android SDK version 24 when running on devices with API level 24 and above. + +> If your project's minSdk is lower than 26, then you must enable +> [corelib desugaring](https://developer.android.com/studio/write/java8-support#library-desugaring). +> +> If your project's minSdk is lower than 24, in order to run the app built on debug, you need to add the following property in `gradle.properties` file: +> - If AGP <= 8.3.0, set `android.enableDexingArtifactTransform=false` +> - if AGP > 8.3.0, set `android.useFullClasspathForDexingTransform=true` +> +> For the full context for these workaround, please see +> [this issue](https://issuetracker.google.com/issues/334281968) for AGP <= 8.3.0 +> or [this issue](https://issuetracker.google.com/issues/230454566#comment18) for AGP > 8.3.0. + +### Add these dependencies to your project + +Replace `AUTO_HTTP_URL_INSTRUMENTATION_VERSION` with the [latest release](https://central.sonatype.com/search?q=g%3Aio.opentelemetry.android++a%3Ahttpurlconnection-library&smo=true). + +Replace `BYTEBUDDY_VERSION` with the [latest release](https://search.maven.org/search?q=g:net.bytebuddy%20AND%20a:byte-buddy). + +#### Byte buddy compilation plugin + +This plugin leverages Android's [Transform API](https://developer.android.com/reference/tools/gradle-api/current/com/android/build/api/variant/ScopedArtifactsOperation#toTransform(com.android.build.api.artifact.ScopedArtifact,kotlin.Function1,kotlin.Function1,kotlin.Function1)) to instrument bytecode at compile time. You can find more info on its [repo page](https://github.com/raphw/byte-buddy/tree/master/byte-buddy-gradle-plugin/android-plugin). + +```groovy +plugins { + id 'net.bytebuddy.byte-buddy-gradle-plugin' version 'BYTEBUDDY_VERSION' +} +``` + +#### Project dependencies + +```kotlin +implementation("io.opentelemetry.android:httpurlconnection-library:AUTO_HTTP_URL_INSTRUMENTATION_VERSION") +byteBuddy("io.opentelemetry.android:httpurlconnection-agent:AUTO_HTTP_URL_INSTRUMENTATION_VERSION") +``` + +### Configurations + +#### Scheduling Harvester Thread + +To schedule a periodically running thread to conclude spans on any unreported, idle connections, add the below code in the function where your application starts ( that could be onCreate() method of first Activity/Fragment/Service): +```Java +Executors.newSingleThreadScheduledExecutor().scheduleWithFixedDelay(HttpUrlInstrumentationConfig.getReportIdleConnectionRunnable(), 0, HttpUrlInstrumentationConfig.getReportIdleConnectionInterval(), TimeUnit.MILLISECONDS); +``` + +`HttpUrlInstrumentationConfig.getReportIdleConnectionRunnable()` is the API to get the runnable. `HttpUrlInstrumentationConfig.getReportIdleConnectionInterval()` is the API to get the fixed interval (10s) in milli seconds. + +#### Other Optional Configurations +You can optionally configure the automatic instrumentation by using the setters in [HttpUrlInstrumentationConfig](library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/HttpUrlInstrumentationConfig.java). + +After adding the plugin and the dependencies to your project, and after doing the required configurations, your requests will be traced automatically. diff --git a/instrumentation/httpurlconnection/agent/build.gradle.kts b/instrumentation/httpurlconnection/agent/build.gradle.kts new file mode 100644 index 000000000..ce81adc2d --- /dev/null +++ b/instrumentation/httpurlconnection/agent/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("otel.android-library-conventions") + id("otel.publish-conventions") +} + +description = "OpenTelemetry build-time auto-instrumentation for HttpURLConnection on Android" + +android { + namespace = "io.opentelemetry.android.httpurlconnection.agent" +} + +dependencies { + implementation(project(":instrumentation:httpurlconnection:library")) + implementation(libs.byteBuddy) +} diff --git a/instrumentation/httpurlconnection/agent/src/main/java/io/opentelemetry/instrumentation/agent/httpurlconnection/HttpUrlConnectionPlugin.java b/instrumentation/httpurlconnection/agent/src/main/java/io/opentelemetry/instrumentation/agent/httpurlconnection/HttpUrlConnectionPlugin.java new file mode 100644 index 000000000..b8f818b29 --- /dev/null +++ b/instrumentation/httpurlconnection/agent/src/main/java/io/opentelemetry/instrumentation/agent/httpurlconnection/HttpUrlConnectionPlugin.java @@ -0,0 +1,226 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.agent.httpurlconnection; + +import static net.bytebuddy.matcher.ElementMatchers.is; +import static net.bytebuddy.matcher.ElementMatchers.isMethod; + +import io.opentelemetry.instrumentation.library.httpurlconnection.HttpUrlReplacements; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URLConnection; +import net.bytebuddy.asm.MemberSubstitution; +import net.bytebuddy.build.AndroidDescriptor; +import net.bytebuddy.build.Plugin; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.dynamic.ClassFileLocator; +import net.bytebuddy.dynamic.DynamicType; + +public class HttpUrlConnectionPlugin implements Plugin { + + private final AndroidDescriptor androidDescriptor; + + public HttpUrlConnectionPlugin(AndroidDescriptor androidDescriptor) { + this.androidDescriptor = androidDescriptor; + } + + @Override + public DynamicType.Builder apply( + DynamicType.Builder builder, + TypeDescription typeDescription, + ClassFileLocator classFileLocator) { + + try { + return builder.visit( + MemberSubstitution.relaxed() + .method(is(URLConnection.class.getDeclaredMethod("connect"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForConnect", URLConnection.class)) + .method(is(URLConnection.class.getDeclaredMethod("getContent"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForContent", URLConnection.class)) + .method( + is( + URLConnection.class.getDeclaredMethod( + "getContent", Class[].class))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForContent", + URLConnection.class, + Class[].class)) + .method(is(URLConnection.class.getDeclaredMethod("getContentType"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForContentType", URLConnection.class)) + .method(is(URLConnection.class.getDeclaredMethod("getContentEncoding"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForContentEncoding", URLConnection.class)) + .method(is(URLConnection.class.getDeclaredMethod("getContentLength"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForContentLength", URLConnection.class)) + .method( + is( + URLConnection.class.getDeclaredMethod( + "getContentLengthLong"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForContentLengthLong", URLConnection.class)) + .method(is(URLConnection.class.getDeclaredMethod("getExpiration"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForExpiration", URLConnection.class)) + .method(is(URLConnection.class.getDeclaredMethod("getDate"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForDate", URLConnection.class)) + .method(is(URLConnection.class.getDeclaredMethod("getLastModified"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForLastModified", URLConnection.class)) + .method( + is( + URLConnection.class.getDeclaredMethod( + "getHeaderField", String.class))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForHeaderField", + URLConnection.class, + String.class)) + .method(is(URLConnection.class.getDeclaredMethod("getHeaderFields"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForHeaderFields", URLConnection.class)) + .method( + is( + URLConnection.class.getDeclaredMethod( + "getHeaderFieldInt", + String.class, + Integer.TYPE))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForHeaderFieldInt", + URLConnection.class, + String.class, + Integer.TYPE)) + .method( + is( + URLConnection.class.getDeclaredMethod( + "getHeaderFieldLong", String.class, Long.TYPE))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForHeaderFieldLong", + URLConnection.class, + String.class, + Long.TYPE)) + .method( + is( + URLConnection.class.getDeclaredMethod( + "getHeaderField", Integer.TYPE))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForHeaderField", + URLConnection.class, + Integer.TYPE)) + .method( + is( + HttpURLConnection.class.getDeclaredMethod( + "getHeaderField", Integer.TYPE))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForHttpHeaderField", + HttpURLConnection.class, + Integer.TYPE)) + .method( + is( + URLConnection.class.getDeclaredMethod( + "getHeaderFieldKey", Integer.TYPE))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForHeaderFieldKey", + URLConnection.class, + Integer.TYPE)) + .method( + is( + HttpURLConnection.class.getDeclaredMethod( + "getHeaderFieldKey", Integer.TYPE))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForHttpHeaderFieldKey", + HttpURLConnection.class, + Integer.TYPE)) + .method( + is( + URLConnection.class.getDeclaredMethod( + "getHeaderFieldDate", String.class, Long.TYPE))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForHeaderFieldDate", + URLConnection.class, + String.class, + Long.TYPE)) + .method( + is( + HttpURLConnection.class.getDeclaredMethod( + "getHeaderFieldDate", String.class, Long.TYPE))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForHttpHeaderFieldDate", + HttpURLConnection.class, + String.class, + Long.TYPE)) + .method( + is( + HttpURLConnection.class.getDeclaredMethod( + "getResponseCode"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForResponseCode", URLConnection.class)) + .method( + is( + HttpURLConnection.class.getDeclaredMethod( + "getResponseMessage"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForResponseMessage", URLConnection.class)) + .method(is(URLConnection.class.getDeclaredMethod("getOutputStream"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForOutputStream", URLConnection.class)) + .method(is(URLConnection.class.getDeclaredMethod("getInputStream"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForInputStream", URLConnection.class)) + .method(is(HttpURLConnection.class.getDeclaredMethod("getErrorStream"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForErrorStream", HttpURLConnection.class)) + .method(is(HttpURLConnection.class.getDeclaredMethod("disconnect"))) + .replaceWith( + HttpUrlReplacements.class.getDeclaredMethod( + "replacementForDisconnect", HttpURLConnection.class)) + .on(isMethod())); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + @Override + public void close() throws IOException { + // No operation. + } + + @Override + public boolean matches(TypeDescription target) { + if (androidDescriptor.getTypeScope(target) == AndroidDescriptor.TypeScope.EXTERNAL) { + return false; + } + return true; + } +} diff --git a/instrumentation/httpurlconnection/agent/src/main/resources/META-INF/net.bytebuddy/build.plugins b/instrumentation/httpurlconnection/agent/src/main/resources/META-INF/net.bytebuddy/build.plugins new file mode 100644 index 000000000..f4be8526d --- /dev/null +++ b/instrumentation/httpurlconnection/agent/src/main/resources/META-INF/net.bytebuddy/build.plugins @@ -0,0 +1 @@ +io.opentelemetry.instrumentation.agent.httpurlconnection.HttpUrlConnectionPlugin diff --git a/instrumentation/httpurlconnection/library/build.gradle.kts b/instrumentation/httpurlconnection/library/build.gradle.kts new file mode 100644 index 000000000..e1204c772 --- /dev/null +++ b/instrumentation/httpurlconnection/library/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("otel.android-library-conventions") + id("otel.publish-conventions") +} + +description = "OpenTelemetry HttpURLConnection library instrumentation for Android" + +android { + namespace = "io.opentelemetry.android.httpurlconnection.library" +} + +dependencies { + api(platform(libs.opentelemetry.platform)) + api(libs.opentelemetry.api) + api(libs.opentelemetry.context) + implementation(libs.opentelemetry.instrumentation.apiSemconv) + implementation(libs.opentelemetry.instrumentation.api) +} diff --git a/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/HttpUrlInstrumentationConfig.java b/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/HttpUrlInstrumentationConfig.java new file mode 100644 index 000000000..8db4eb95c --- /dev/null +++ b/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/HttpUrlInstrumentationConfig.java @@ -0,0 +1,154 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.library.httpurlconnection; + +import io.opentelemetry.instrumentation.api.incubator.semconv.net.PeerServiceResolver; +import io.opentelemetry.instrumentation.api.internal.HttpConstants; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Configuration for automatic instrumentation of HttpURLConnection/HttpsURLConnection requests. */ +public final class HttpUrlInstrumentationConfig { + private static List capturedRequestHeaders = new ArrayList<>(); + private static List capturedResponseHeaders = new ArrayList<>(); + private static Set knownMethods = HttpConstants.KNOWN_METHODS; + private static Map peerServiceMapping = new HashMap<>(); + private static boolean emitExperimentalHttpClientMetrics; + + // Time (ms) to wait before assuming that an idle connection is no longer + // in use and should be reported. + private static final long CONNECTION_INACTIVITY_TIMEOUT_MS = 10000; + + private HttpUrlInstrumentationConfig() {} + + /** + * Configures the HTTP request headers that will be captured as span attributes as described in + * HTTP + * semantic conventions. + * + *

The HTTP request header values will be captured under the {@code + * http.request.header.} attribute key. The {@code } part in the attribute key is + * the normalized header name: lowercase, with dashes replaced by underscores. + * + * @param requestHeaders A list of HTTP header names. + */ + public static void setCapturedRequestHeaders(List requestHeaders) { + HttpUrlInstrumentationConfig.capturedRequestHeaders = new ArrayList<>(requestHeaders); + } + + public static List getCapturedRequestHeaders() { + return capturedRequestHeaders; + } + + /** + * Configures the HTTP response headers that will be captured as span attributes as described in + * HTTP + * semantic conventions. + * + *

The HTTP response header values will be captured under the {@code + * http.response.header.} attribute key. The {@code } part in the attribute key is + * the normalized header name: lowercase, with dashes replaced by underscores. + * + * @param responseHeaders A list of HTTP header names. + */ + public static void setCapturedResponseHeaders(List responseHeaders) { + HttpUrlInstrumentationConfig.capturedResponseHeaders = new ArrayList<>(responseHeaders); + } + + public static List getCapturedResponseHeaders() { + return capturedResponseHeaders; + } + + /** + * Configures the attrs extractor to recognize an alternative set of HTTP request methods. + * + *

By default, the extractor defines "known" methods as the ones listed in RFC9110 and the PATCH + * method defined in RFC5789. If an + * unknown method is encountered, the extractor will use the value {@value HttpConstants#_OTHER} + * instead of it and put the original value in an extra {@code http.request.method_original} + * attribute. + * + *

Note: calling this method overrides the default known method sets completely; it + * does not supplement it. + * + * @param knownMethods A set of recognized HTTP request methods. + */ + public static void setKnownMethods(Set knownMethods) { + HttpUrlInstrumentationConfig.knownMethods = new HashSet<>(knownMethods); + } + + public static Set getKnownMethods() { + return knownMethods; + } + + /** + * Configures the extractor of the {@code peer.service} span attribute, described in the + * specification. + */ + public static void setPeerServiceMapping(Map peerServiceMapping) { + HttpUrlInstrumentationConfig.peerServiceMapping = new HashMap<>(peerServiceMapping); + } + + public static PeerServiceResolver newPeerServiceResolver() { + return PeerServiceResolver.create(peerServiceMapping); + } + + /** + * When enabled keeps track of the + * experimental HTTP client metrics. + */ + public static void setEmitExperimentalHttpClientMetrics( + boolean emitExperimentalHttpClientMetrics) { + HttpUrlInstrumentationConfig.emitExperimentalHttpClientMetrics = + emitExperimentalHttpClientMetrics; + } + + public static boolean emitExperimentalHttpClientMetrics() { + return emitExperimentalHttpClientMetrics; + } + + /** + * Returns a runnable that can be scheduled to run periodically at a fixed interval to close + * open spans if connection is left idle for CONNECTION_INACTIVITY_TIMEOUT duration. Runnable + * interval is same as CONNECTION_INACTIVITY_TIMEOUT. CONNECTION_INACTIVITY_TIMEOUT in milli + * seconds can be obtained from getReportIdleConnectionInterval() API. + * + * @return The idle connection reporting runnable + */ + public static Runnable getReportIdleConnectionRunnable() { + return new Runnable() { + @Override + public void run() { + HttpUrlReplacements.reportIdleConnectionsOlderThan( + CONNECTION_INACTIVITY_TIMEOUT_MS); + } + + @Override + public String toString() { + return "ReportIdleConnectionsRunnable"; + } + }; + } + + /** + * The fixed interval duration in milli seconds that the runnable from + * getReportIdleConnectionRunnable() API should be scheduled to periodically run at. + * + * @return The fixed interval duration in ms + */ + public static long getReportIdleConnectionInterval() { + return CONNECTION_INACTIVITY_TIMEOUT_MS; + } +} diff --git a/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/HttpUrlReplacements.java b/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/HttpUrlReplacements.java new file mode 100644 index 000000000..3b7305664 --- /dev/null +++ b/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/HttpUrlReplacements.java @@ -0,0 +1,414 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.library.httpurlconnection; + +import static io.opentelemetry.instrumentation.library.httpurlconnection.internal.HttpUrlConnectionSingletons.instrumenter; + +import android.annotation.SuppressLint; +import android.os.SystemClock; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.library.httpurlconnection.internal.RequestPropertySetter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URLConnection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class HttpUrlReplacements { + + private static final Map activeURLConnections = + new ConcurrentHashMap<>(); + private static final Logger logger = Logger.getLogger("HttpUrlReplacements"); + public static final int UNKNOWN_RESPONSE_CODE = -1; + + public static void replacementForDisconnect(HttpURLConnection connection) { + // Ensure ending of un-ended spans while connection is still alive + // If disconnect is not called, harvester thread if scheduled, takes care of ending any + // un-ended spans. + final HttpURLConnectionInfo info = activeURLConnections.get(connection); + if (info != null && !info.reported) { + reportWithResponseCode(connection); + } + + connection.disconnect(); + } + + public static void replacementForConnect(URLConnection connection) throws IOException { + startTracingAtFirstConnection(connection); + + try { + connection.connect(); + } catch (IOException exception) { + reportWithThrowable(connection, exception); + throw exception; + } + + updateLastSeenTime(connection); + // connect() does not read anything from connection so request not harvestable yet (to be + // reported if left idle). + } + + public static Object replacementForContent(URLConnection connection) throws IOException { + return replaceThrowable(connection, connection::getContent); + } + + public static Object replacementForContent(URLConnection connection, Class[] classes) + throws IOException { + return replaceThrowable(connection, () -> connection.getContent(classes)); + } + + public static String replacementForContentType(URLConnection connection) { + return replace(connection, () -> connection.getContentType()); + } + + public static String replacementForContentEncoding(URLConnection connection) { + return replace(connection, () -> connection.getContentEncoding()); + } + + public static int replacementForContentLength(URLConnection connection) { + return replace(connection, () -> connection.getContentLength()); + } + + @SuppressLint("NewApi") + public static long replacementForContentLengthLong(URLConnection connection) { + return replace(connection, () -> connection.getContentLengthLong()); + } + + public static long replacementForExpiration(URLConnection connection) { + return replace(connection, () -> connection.getExpiration()); + } + + public static long replacementForDate(URLConnection connection) { + return replace(connection, () -> connection.getDate()); + } + + public static long replacementForLastModified(URLConnection connection) { + return replace(connection, () -> connection.getLastModified()); + } + + public static String replacementForHeaderField(URLConnection connection, String name) { + return replace(connection, () -> connection.getHeaderField(name)); + } + + public static Map> replacementForHeaderFields(URLConnection connection) { + return replace(connection, () -> connection.getHeaderFields()); + } + + public static int replacementForHeaderFieldInt( + URLConnection connection, String name, int Default) { + return replace(connection, () -> connection.getHeaderFieldInt(name, Default)); + } + + @SuppressLint("NewApi") + public static long replacementForHeaderFieldLong( + URLConnection connection, String name, long Default) { + return replace(connection, () -> connection.getHeaderFieldLong(name, Default)); + } + + public static long replacementForHeaderFieldDate( + URLConnection connection, String name, long Default) { + // HttpURLConnection also overrides this and that is covered in + // replacementForHttpHeaderFieldDate method. + return replace(connection, () -> connection.getHeaderFieldDate(name, Default)); + } + + public static long replacementForHttpHeaderFieldDate( + HttpURLConnection connection, String name, long Default) { + // URLConnection also overrides this and that is covered in replacementForHeaderFieldDate + // method. + return replace(connection, () -> connection.getHeaderFieldDate(name, Default)); + } + + public static String replacementForHeaderFieldKey(URLConnection connection, int index) { + // HttpURLConnection also overrides this and that is covered in + // replacementForHttpHeaderFieldKey method. + return replace(connection, () -> connection.getHeaderFieldKey(index)); + } + + public static String replacementForHttpHeaderFieldKey(HttpURLConnection connection, int index) { + // URLConnection also overrides this and that is covered in replacementForHeaderFieldKey + // method. + return replace(connection, () -> connection.getHeaderFieldKey(index)); + } + + public static String replacementForHeaderField(URLConnection connection, int index) { + // HttpURLConnection also overrides this and that is covered in + // replacementForHttpHeaderField method. + return replace(connection, () -> connection.getHeaderField(index)); + } + + public static String replacementForHttpHeaderField(HttpURLConnection connection, int index) { + // URLConnection also overrides this and that is covered in replacementForHeaderField + // method. + return replace(connection, () -> connection.getHeaderField(index)); + } + + public static int replacementForResponseCode(URLConnection connection) throws IOException { + HttpURLConnection httpURLConnection = (HttpURLConnection) connection; + return replaceThrowable(connection, httpURLConnection::getResponseCode); + } + + public static String replacementForResponseMessage(URLConnection connection) + throws IOException { + HttpURLConnection httpURLConnection = (HttpURLConnection) connection; + return replaceThrowable(connection, httpURLConnection::getResponseMessage); + } + + public static OutputStream replacementForOutputStream(URLConnection connection) + throws IOException { + return replaceThrowable(connection, connection::getOutputStream, false); + } + + public static InputStream replacementForInputStream(URLConnection connection) + throws IOException { + startTracingAtFirstConnection(connection); + + InputStream inputStream; + try { + inputStream = connection.getInputStream(); + } catch (IOException exception) { + reportWithThrowable(connection, exception); + throw exception; + } + + if (inputStream == null) { + return inputStream; + } + + return new InstrumentedInputStream(connection, inputStream); + } + + public static InputStream replacementForErrorStream(HttpURLConnection connection) { + startTracingAtFirstConnection(connection); + + InputStream errorStream = connection.getErrorStream(); + + if (errorStream == null) { + return errorStream; + } + + return new InstrumentedInputStream(connection, errorStream); + } + + private static T replace(URLConnection connection, ResultProvider resultProvider) { + startTracingAtFirstConnection(connection); + + T result = resultProvider.get(); + + updateLastSeenTime(connection); + markHarvestable(connection); + + return result; + } + + private static T replaceThrowable( + URLConnection connection, ThrowableResultProvider resultProvider) + throws IOException { + return replaceThrowable(connection, resultProvider, true); + } + + private static T replaceThrowable( + URLConnection connection, + ThrowableResultProvider resultProvider, + boolean shouldMarkHarvestable) + throws IOException { + startTracingAtFirstConnection(connection); + + T result; + try { + result = resultProvider.get(); + } catch (IOException exception) { + reportWithThrowable(connection, exception); + throw exception; + } + + updateLastSeenTime(connection); + if (shouldMarkHarvestable) { + markHarvestable(connection); + } + + return result; + } + + interface ResultProvider { + T get(); + } + + interface ThrowableResultProvider { + T get() throws IOException; + } + + private static void reportWithThrowable(URLConnection connection, IOException exception) { + endTracing(connection, UNKNOWN_RESPONSE_CODE, exception); + } + + private static void reportWithResponseCode(HttpURLConnection connection) { + try { + endTracing(connection, connection.getResponseCode(), null); + } catch (IOException exception) { + logger.log( + Level.FINE, + "Exception " + + exception.getMessage() + + " was thrown while ending span for connection " + + connection); + } + } + + private static void endTracing(URLConnection connection, int responseCode, Throwable error) { + HttpURLConnectionInfo info = activeURLConnections.get(connection); + if (info != null && !info.reported) { + Context context = info.context; + instrumenter().end(context, connection, responseCode, error); + info.reported = true; + activeURLConnections.remove(connection); + } + } + + private static void startTracingAtFirstConnection(URLConnection connection) { + Context parentContext = Context.current(); + if (!instrumenter().shouldStart(parentContext, connection)) { + return; + } + + if (!activeURLConnections.containsKey(connection)) { + Context context = instrumenter().start(parentContext, connection); + activeURLConnections.put(connection, new HttpURLConnectionInfo(context)); + try { + injectContextToRequest(connection, context); + } catch (Exception exception) { + // If connection was already made prior to setting this request property, + // (which should not happen as we've instrumented all methods that connect) + // above call would throw IllegalStateException. + logger.log( + Level.FINE, + "Exception " + + exception.getMessage() + + " was thrown while adding distributed tracing context for connection " + + connection, + exception); + } + } + } + + private static void injectContextToRequest(URLConnection connection, Context context) { + GlobalOpenTelemetry.getPropagators() + .getTextMapPropagator() + .inject(context, connection, RequestPropertySetter.INSTANCE); + } + + private static void updateLastSeenTime(URLConnection connection) { + final HttpURLConnectionInfo info = activeURLConnections.get(connection); + if (info != null && !info.reported) { + info.lastSeenTime = SystemClock.uptimeMillis(); + } + } + + private static void markHarvestable(URLConnection connection) { + final HttpURLConnectionInfo info = activeURLConnections.get(connection); + if (info != null && !info.reported) { + info.harvestable = true; + } + } + + static void reportIdleConnectionsOlderThan(long timeInterval) { + final long timeNow = SystemClock.uptimeMillis(); + for (URLConnection connection : activeURLConnections.keySet()) { + final HttpURLConnectionInfo info = activeURLConnections.get(connection); + if (info != null + && info.harvestable + && !info.reported + && (info.lastSeenTime + timeInterval) < timeNow) { + HttpURLConnection httpURLConnection = (HttpURLConnection) connection; + reportWithResponseCode(httpURLConnection); + } + } + } + + private static class HttpURLConnectionInfo { + private long lastSeenTime; + private boolean reported; + private boolean harvestable; + private Context context; + + private HttpURLConnectionInfo(Context context) { + this.context = context; + lastSeenTime = SystemClock.uptimeMillis(); + } + } + + private static class InstrumentedInputStream extends InputStream { + private final URLConnection connection; + + private final InputStream inputStream; + + public InstrumentedInputStream(URLConnection connection, InputStream inputStream) { + this.connection = connection; + this.inputStream = inputStream; + } + + @Override + public int read() throws IOException { + int res; + try { + res = inputStream.read(); + } catch (IOException exception) { + reportWithThrowable(connection, exception); + throw exception; + } + reportIfDoneOrMarkHarvestable(res); + return res; + } + + @Override + public int read(byte[] b) throws IOException { + int res; + try { + res = inputStream.read(b); + } catch (IOException exception) { + reportWithThrowable(connection, exception); + throw exception; + } + reportIfDoneOrMarkHarvestable(res); + return res; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int res; + try { + res = inputStream.read(b, off, len); + } catch (IOException exception) { + reportWithThrowable(connection, exception); + throw exception; + } + reportIfDoneOrMarkHarvestable(res); + return res; + } + + @Override + public void close() throws IOException { + HttpURLConnection httpURLConnection = (HttpURLConnection) connection; + reportWithResponseCode(httpURLConnection); + inputStream.close(); + } + + private void reportIfDoneOrMarkHarvestable(int result) { + if (result == -1) { + HttpURLConnection httpURLConnection = (HttpURLConnection) connection; + reportWithResponseCode(httpURLConnection); + } else { + markHarvestable(connection); + } + } + } +} diff --git a/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/internal/HttpUrlConnectionSingletons.java b/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/internal/HttpUrlConnectionSingletons.java new file mode 100644 index 000000000..adb61b0fb --- /dev/null +++ b/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/internal/HttpUrlConnectionSingletons.java @@ -0,0 +1,76 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.library.httpurlconnection.internal; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.instrumentation.api.incubator.semconv.http.HttpClientExperimentalMetrics; +import io.opentelemetry.instrumentation.api.incubator.semconv.http.HttpClientPeerServiceAttributesExtractor; +import io.opentelemetry.instrumentation.api.incubator.semconv.http.HttpExperimentalAttributesExtractor; +import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.opentelemetry.instrumentation.api.instrumenter.InstrumenterBuilder; +import io.opentelemetry.instrumentation.api.semconv.http.HttpClientAttributesExtractor; +import io.opentelemetry.instrumentation.api.semconv.http.HttpClientAttributesExtractorBuilder; +import io.opentelemetry.instrumentation.api.semconv.http.HttpClientMetrics; +import io.opentelemetry.instrumentation.api.semconv.http.HttpSpanNameExtractor; +import io.opentelemetry.instrumentation.api.semconv.http.HttpSpanNameExtractorBuilder; +import io.opentelemetry.instrumentation.api.semconv.http.HttpSpanStatusExtractor; +import io.opentelemetry.instrumentation.library.httpurlconnection.HttpUrlInstrumentationConfig; +import java.net.URLConnection; + +public final class HttpUrlConnectionSingletons { + + private static final Instrumenter INSTRUMENTER; + private static final String INSTRUMENTATION_NAME = + "io.opentelemetry.android.http-url-connection"; + + static { + HttpUrlHttpAttributesGetter httpAttributesGetter = new HttpUrlHttpAttributesGetter(); + + HttpSpanNameExtractorBuilder httpSpanNameExtractorBuilder = + HttpSpanNameExtractor.builder(httpAttributesGetter) + .setKnownMethods(HttpUrlInstrumentationConfig.getKnownMethods()); + + HttpClientAttributesExtractorBuilder + httpClientAttributesExtractorBuilder = + HttpClientAttributesExtractor.builder(httpAttributesGetter) + .setCapturedRequestHeaders( + HttpUrlInstrumentationConfig.getCapturedRequestHeaders()) + .setCapturedResponseHeaders( + HttpUrlInstrumentationConfig.getCapturedResponseHeaders()) + .setKnownMethods(HttpUrlInstrumentationConfig.getKnownMethods()); + + HttpClientPeerServiceAttributesExtractor + httpClientPeerServiceAttributesExtractor = + HttpClientPeerServiceAttributesExtractor.create( + httpAttributesGetter, + HttpUrlInstrumentationConfig.newPeerServiceResolver()); + + InstrumenterBuilder builder = + Instrumenter.builder( + GlobalOpenTelemetry.get(), + INSTRUMENTATION_NAME, + httpSpanNameExtractorBuilder.build()) + .setSpanStatusExtractor( + HttpSpanStatusExtractor.create(httpAttributesGetter)) + .addAttributesExtractor(httpClientAttributesExtractorBuilder.build()) + .addAttributesExtractor(httpClientPeerServiceAttributesExtractor) + .addOperationMetrics(HttpClientMetrics.get()); + + if (HttpUrlInstrumentationConfig.emitExperimentalHttpClientMetrics()) { + builder.addAttributesExtractor( + HttpExperimentalAttributesExtractor.create(httpAttributesGetter)) + .addOperationMetrics(HttpClientExperimentalMetrics.get()); + } + + INSTRUMENTER = builder.buildClientInstrumenter(RequestPropertySetter.INSTANCE); + } + + public static Instrumenter instrumenter() { + return INSTRUMENTER; + } + + private HttpUrlConnectionSingletons() {} +} diff --git a/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/internal/HttpUrlHttpAttributesGetter.java b/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/internal/HttpUrlHttpAttributesGetter.java new file mode 100644 index 000000000..63b09fe3d --- /dev/null +++ b/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/internal/HttpUrlHttpAttributesGetter.java @@ -0,0 +1,73 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.library.httpurlconnection.internal; + +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; + +import io.opentelemetry.instrumentation.api.semconv.http.HttpClientAttributesGetter; +import java.net.HttpURLConnection; +import java.net.URLConnection; +import java.util.List; +import javax.annotation.Nullable; + +public class HttpUrlHttpAttributesGetter + implements HttpClientAttributesGetter { + + @Override + public String getHttpRequestMethod(URLConnection connection) { + HttpURLConnection httpURLConnection = (HttpURLConnection) connection; + return httpURLConnection.getRequestMethod(); + } + + @Override + public String getUrlFull(URLConnection connection) { + return connection.getURL().toExternalForm(); + } + + @Override + public List getHttpRequestHeader(URLConnection connection, String name) { + String value = connection.getRequestProperty(name); + return value == null ? emptyList() : singletonList(value); + } + + @Override + public Integer getHttpResponseStatusCode( + URLConnection connection, Integer statusCode, @Nullable Throwable error) { + return statusCode; + } + + @Override + public List getHttpResponseHeader( + URLConnection connection, Integer statusCode, String name) { + String value = connection.getHeaderField(name); + return value == null ? emptyList() : singletonList(value); + } + + @Nullable + @Override + public String getNetworkProtocolName(URLConnection connection, @Nullable Integer integer) { + // HttpURLConnection hardcodes the protocol name&version + return "http"; + } + + @Nullable + @Override + public String getNetworkProtocolVersion(URLConnection connection, @Nullable Integer integer) { + // HttpURLConnection hardcodes the protocol name&version + return "1.1"; + } + + @Override + public String getServerAddress(URLConnection connection) { + return connection.getURL().getHost(); + } + + @Override + public Integer getServerPort(URLConnection connection) { + return connection.getURL().getPort(); + } +} diff --git a/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/internal/RequestPropertySetter.java b/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/internal/RequestPropertySetter.java new file mode 100644 index 000000000..44260e4db --- /dev/null +++ b/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/internal/RequestPropertySetter.java @@ -0,0 +1,18 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.library.httpurlconnection.internal; + +import io.opentelemetry.context.propagation.TextMapSetter; +import java.net.URLConnection; + +public enum RequestPropertySetter implements TextMapSetter { + INSTANCE; + + @Override + public void set(URLConnection carrier, String key, String value) { + carrier.setRequestProperty(key, value); + } +} diff --git a/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/package-info.java b/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/package-info.java new file mode 100644 index 000000000..b5c226b36 --- /dev/null +++ b/instrumentation/httpurlconnection/library/src/main/java/io/opentelemetry/instrumentation/library/httpurlconnection/package-info.java @@ -0,0 +1,5 @@ +/** HttpUrlConnection auto instrumentation runtime. */ +@ParametersAreNonnullByDefault +package io.opentelemetry.instrumentation.library.httpurlconnection; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/settings.gradle.kts b/settings.gradle.kts index 6ad7204c7..9c5ad9378 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,3 +15,5 @@ include(":instrumentation:network") include(":instrumentation:slowrendering") include(":instrumentation:startup") include(":instrumentation:volley:library") +include(":instrumentation:httpurlconnection:agent") +include(":instrumentation:httpurlconnection:library")