-
Notifications
You must be signed in to change notification settings - Fork 112
Coroutines context propagation instrumentation #1935
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| # Kotlin coroutines | ||
|
|
||
| Status: development | ||
|
|
||
| Automatically propagates the current OpenTelemetry `Context` into Kotlin coroutines started with | ||
| `CoroutineScope.launch`. It does **not** create coroutine spans or emit any other telemetry. | ||
|
|
||
| ## What this does in practice | ||
|
|
||
| Given: | ||
|
|
||
| ```kotlin | ||
| val span = tracer.spanBuilder("work").startSpan() | ||
| span.makeCurrent().use { | ||
| scope.launch { | ||
| val inner = tracer.spanBuilder("coroutine-work").startSpan() | ||
| // ... | ||
| inner.end() | ||
| } | ||
| } | ||
| span.end() | ||
| ``` | ||
|
|
||
| **Without** this instrumentation, `coroutine-work` does not inherit the active context and is recorded | ||
| as a separate root span: | ||
|
|
||
| ``` | ||
| work | ||
| coroutine-work | ||
| ``` | ||
|
|
||
| **With** this instrumentation, the active context is carried into the coroutine automatically, and | ||
| `coroutine-work` is recorded as a child of `work`: | ||
|
|
||
| ``` | ||
| work | ||
| └── coroutine-work | ||
| ``` | ||
|
|
||
| ## Supported API | ||
|
|
||
| - `CoroutineScope.launch` (both the default-parameter form and the explicit form) | ||
|
|
||
| ## Transformation scope | ||
|
|
||
| Only classes local to the module that applies the ByteBuddy plugin are transformed. External | ||
| library and dependency classes are left unchanged. | ||
|
|
||
| ## Explicit exclusions | ||
|
|
||
| The following are out of scope for this instrumentation: | ||
|
|
||
| - External dependencies and sibling library modules consumed as dependencies | ||
| - `async`, `withContext`, `runBlocking`, Flow, and all other coroutine builders | ||
|
|
||
| ## Installation | ||
|
|
||
| Add the ByteBuddy Gradle plugin to your application module: | ||
|
|
||
| ```kotlin | ||
| plugins { | ||
| id("net.bytebuddy.byte-buddy-gradle-plugin") | ||
| } | ||
|
|
||
| dependencies { | ||
| implementation("io.opentelemetry.android.instrumentation:coroutines-library:<version>") | ||
| byteBuddy("io.opentelemetry.android.instrumentation:coroutines-agent:<version>") | ||
| } | ||
| ``` | ||
|
|
||
| ## Context precedence | ||
|
|
||
| If `CoroutineScope.launch` is called with an explicit, non-root OpenTelemetry coroutine context | ||
| element already in the supplied `CoroutineContext`, that user-supplied context takes precedence and | ||
| automatic capture is skipped. | ||
|
|
||
| Propagation is one-directional: context flows from the calling thread into the coroutine at launch | ||
| time. Work dispatched from within a coroutine to a plain Java `Executor` or thread starts with a | ||
| fresh context, as it would without this instrumentation. | ||
|
|
||
| ## Suppression | ||
|
|
||
| This instrumentation can be suppressed by its stable name `"coroutines"` using the standard | ||
| OpenTelemetry Android suppression mechanism. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| plugins { | ||
| id("otel.android-library-conventions") | ||
| id("otel.publish-conventions") | ||
| } | ||
|
|
||
| description = "OpenTelemetry Android coroutines context propagation agent" | ||
|
|
||
| android { | ||
| namespace = "io.opentelemetry.android.instrumentation.coroutines" | ||
| } | ||
|
|
||
| dependencies { | ||
| implementation(project(":instrumentation:coroutines:library")) | ||
| implementation(libs.byteBuddy) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package io.opentelemetry.instrumentation.agent.coroutines | ||
|
|
||
| import io.opentelemetry.instrumentation.library.coroutines.internal.CoroutinesLaunchBridge | ||
| import net.bytebuddy.asm.MemberSubstitution | ||
| import net.bytebuddy.build.AndroidDescriptor | ||
| import net.bytebuddy.build.Plugin | ||
| import net.bytebuddy.description.method.MethodDescription | ||
| import net.bytebuddy.description.type.TypeDescription | ||
| import net.bytebuddy.dynamic.ClassFileLocator | ||
| import net.bytebuddy.dynamic.DynamicType | ||
| import net.bytebuddy.matcher.ElementMatchers | ||
|
|
||
| internal class CoroutinesPlugin( | ||
| private val androidDescriptor: AndroidDescriptor, | ||
| ) : Plugin { | ||
| override fun matches(target: TypeDescription): Boolean = androidDescriptor.getTypeScope(target) == AndroidDescriptor.TypeScope.LOCAL | ||
|
|
||
| override fun apply( | ||
| builder: DynamicType.Builder<*>, | ||
| typeDescription: TypeDescription, | ||
| classFileLocator: ClassFileLocator, | ||
| ): DynamicType.Builder<*> = | ||
| builder.visit( | ||
| MemberSubstitution | ||
| .relaxed() | ||
| .method( | ||
| ElementMatchers | ||
| .named<MethodDescription>("launch") | ||
| .and(ElementMatchers.isDeclaredBy(ElementMatchers.named("kotlinx.coroutines.BuildersKt"))), | ||
| ).replaceWith(BRIDGE_LAUNCH) | ||
| .method( | ||
| ElementMatchers | ||
| .named<MethodDescription>("launch\$default") | ||
| .and(ElementMatchers.isDeclaredBy(ElementMatchers.named("kotlinx.coroutines.BuildersKt"))), | ||
| ).replaceWith(BRIDGE_LAUNCH_DEFAULT) | ||
| .on(ElementMatchers.any()), | ||
| ) | ||
|
|
||
| override fun close() {} | ||
|
|
||
| private companion object { | ||
| private val BRIDGE_TYPE = TypeDescription.ForLoadedType.of(CoroutinesLaunchBridge::class.java) | ||
| private val BRIDGE_LAUNCH: MethodDescription = | ||
| BRIDGE_TYPE.declaredMethods | ||
| .filter( | ||
| ElementMatchers | ||
| .named<MethodDescription>("launch") | ||
| .and(ElementMatchers.isStatic()) | ||
| .and(ElementMatchers.takesArguments(4)), | ||
| ).getOnly() | ||
| private val BRIDGE_LAUNCH_DEFAULT: MethodDescription = | ||
| BRIDGE_TYPE.declaredMethods | ||
| .filter( | ||
| ElementMatchers | ||
| .named<MethodDescription>("launch\$default") | ||
| .and(ElementMatchers.isStatic()), | ||
| ).getOnly() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| io.opentelemetry.instrumentation.agent.coroutines.CoroutinesPlugin |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package io.opentelemetry.instrumentation.agent.coroutines | ||
|
|
||
| import net.bytebuddy.build.AndroidDescriptor | ||
| import net.bytebuddy.description.type.TypeDescription | ||
| import org.assertj.core.api.Assertions.assertThat | ||
| import org.junit.jupiter.api.Test | ||
|
|
||
| class CoroutinesPluginTest { | ||
| private val sampleType: TypeDescription = TypeDescription.ForLoadedType.of(Any::class.java) | ||
|
|
||
| @Test | ||
| fun `LOCAL scope matches`() { | ||
| val plugin = CoroutinesPlugin(AndroidDescriptor.Trivial.LOCAL) | ||
| assertThat(plugin.matches(sampleType)).isTrue() | ||
| } | ||
|
|
||
| @Test | ||
| fun `EXTERNAL scope does not match`() { | ||
| val plugin = CoroutinesPlugin(AndroidDescriptor.Trivial.EXTERNAL) | ||
| assertThat(plugin.matches(sampleType)).isFalse() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| public final class io/opentelemetry/instrumentation/library/coroutines/CoroutinesInstrumentation : io/opentelemetry/android/instrumentation/AndroidInstrumentation { | ||
| public fun <init> ()V | ||
| public fun getName ()Ljava/lang/String; | ||
| public fun install (Landroid/content/Context;Lio/opentelemetry/android/OpenTelemetryRum;)V | ||
| public fun uninstall (Landroid/content/Context;Lio/opentelemetry/android/OpenTelemetryRum;)V | ||
| } | ||
|
|
||
| public final class io/opentelemetry/instrumentation/library/coroutines/internal/CoroutinesContextHelper { | ||
| public static final field INSTANCE Lio/opentelemetry/instrumentation/library/coroutines/internal/CoroutinesContextHelper; | ||
| public static final fun addCurrentContextIfNeeded (Lkotlin/coroutines/CoroutineContext;)Lkotlin/coroutines/CoroutineContext; | ||
| public static final fun setEnabled (Z)V | ||
| } | ||
|
|
||
| public final class io/opentelemetry/instrumentation/library/coroutines/internal/CoroutinesLaunchBridge { | ||
| public static final field INSTANCE Lio/opentelemetry/instrumentation/library/coroutines/internal/CoroutinesLaunchBridge; | ||
| public static final fun launch (Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/CoroutineContext;Lkotlinx/coroutines/CoroutineStart;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/Job; | ||
| public static final fun launch$default (Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/CoroutineContext;Lkotlinx/coroutines/CoroutineStart;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/Job; | ||
| } | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| plugins { | ||
| id("otel.android-library-conventions") | ||
| id("otel.publish-conventions") | ||
| } | ||
|
|
||
| description = "OpenTelemetry Android coroutines context propagation library" | ||
|
|
||
| android { | ||
| namespace = "io.opentelemetry.android.coroutines.library" | ||
| } | ||
|
|
||
| dependencies { | ||
| api(platform(libs.opentelemetry.platform.alpha)) | ||
| implementation(project(":instrumentation:android-instrumentation")) | ||
| implementation(libs.opentelemetry.extension.kotlin) | ||
| compileOnly(libs.kotlinx.coroutines) | ||
| testImplementation(libs.kotlinx.coroutines) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package io.opentelemetry.instrumentation.library.coroutines | ||
|
|
||
| import android.content.Context | ||
| import com.google.auto.service.AutoService | ||
| import io.opentelemetry.android.OpenTelemetryRum | ||
| import io.opentelemetry.android.instrumentation.AndroidInstrumentation | ||
| import io.opentelemetry.instrumentation.library.coroutines.internal.CoroutinesContextHelper | ||
|
|
||
| @AutoService(AndroidInstrumentation::class) | ||
| class CoroutinesInstrumentation : AndroidInstrumentation { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know whether this belongs as instrumentation. It doesn't actually record telemetry & exists solely to manage context. My instinct is that it should be in a separate opt-in module like it is now, but I'm not convinced we need to implement
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see what you mean; it is a strange use case for calling it an "AndroidInstrumentation". I did it like this mostly to keep some consistency around opt-in tools that do code on behalf of users. If we strictly define "instrumentations" as telemetry-generating code, then it doesn't fit, but if we instead define them as "automatic code generation" in general, it could fit. That being said, I'm open to ideas to avoid making this an "AndroidInstrumentation" implementation. If we're fine with leaving it all as is and just removing the explicit implementation of |
||
| override val name: String = "coroutines" | ||
|
|
||
| override fun install( | ||
| context: Context, | ||
| openTelemetryRum: OpenTelemetryRum, | ||
| ) { | ||
| CoroutinesContextHelper.setEnabled(true) | ||
| } | ||
|
|
||
| override fun uninstall( | ||
| context: Context, | ||
| openTelemetryRum: OpenTelemetryRum, | ||
| ) { | ||
| CoroutinesContextHelper.setEnabled(false) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package io.opentelemetry.instrumentation.library.coroutines.internal | ||
|
|
||
| import io.opentelemetry.context.Context | ||
| import io.opentelemetry.extension.kotlin.asContextElement | ||
| import io.opentelemetry.extension.kotlin.getOpenTelemetryContext | ||
| import kotlin.coroutines.CoroutineContext | ||
|
|
||
| /** | ||
| * Decides whether the current OpenTelemetry [Context] should be added to an about-to-launch | ||
| * coroutine. Not intended as public API; public JVM visibility is required so that the runtime | ||
| * instrumentation in the parent package can configure it. | ||
| */ | ||
| object CoroutinesContextHelper { | ||
| @Volatile | ||
| private var enabled = false | ||
|
|
||
| @JvmStatic | ||
| fun setEnabled(value: Boolean) { | ||
| enabled = value | ||
| } | ||
|
|
||
| /** | ||
| * Returns the supplied [coroutineContext] enriched with the current OpenTelemetry context | ||
| * when all of the following are true: | ||
| * | ||
| * - Runtime instrumentation is enabled. | ||
| * - The current OpenTelemetry context is not root. | ||
| * - The coroutine context does not already carry a non-root OpenTelemetry context (user | ||
| * supplied context takes precedence). | ||
| */ | ||
| @JvmStatic | ||
| fun addCurrentContextIfNeeded(coroutineContext: CoroutineContext): CoroutineContext { | ||
| if (!enabled) { | ||
| return coroutineContext | ||
| } | ||
|
|
||
| val current = Context.current() | ||
| if (current === Context.root()) { | ||
| return coroutineContext | ||
| } | ||
|
|
||
| val existing = coroutineContext.getOpenTelemetryContext() | ||
| if (existing !== Context.root()) { | ||
| return coroutineContext | ||
| } | ||
|
|
||
| return coroutineContext + current.asContextElement() | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will folks want this included in every
scope.launch, i.e. is there a possibility folks might want to opt-out of certain coroutines or specify which ones they want manually via a helper/extension function instead?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's no way to choose which coroutines would get the OTel context or not when using this plugin. If people wanted to do so, I think they should not use this plugin and instead directly use the Kotlin extension whenever they need it.
The only thing that they could do, even when applying this plugin, is to set their own coroutine OTel context manually, in case they want to use a custom one (the plugin won't override it).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using the extension manually instead sounds reasonable 👍