Skip to content
Open
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
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ opentelemetry-proto = { module = "io.opentelemetry.proto:opentelemetry-proto", v
protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobufKotlin" }
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.11.0" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.11.0" }
opentelemetry-extension-kotlin = { module = "io.opentelemetry:opentelemetry-extension-kotlin" }

#Compilation tools
desugarJdkLibs = "com.android.tools:desugar_jdk_libs:2.1.5"
Expand Down
84 changes: 84 additions & 0 deletions instrumentation/coroutines/README.md
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.
Empty file.
20 changes: 20 additions & 0 deletions instrumentation/coroutines/agent/build.gradle.kts
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(

Copy link
Copy Markdown
Member

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?

Copy link
Copy Markdown
Contributor Author

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).

Copy link
Copy Markdown
Member

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 👍

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()
}
}
19 changes: 19 additions & 0 deletions instrumentation/coroutines/library/api/library.api
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;
}

23 changes: 23 additions & 0 deletions instrumentation/coroutines/library/build.gradle.kts
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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 AndroidInstrumentation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 AndroidInstrumentation, I think it's fine. However, if we wanted to create a new category of opt-in tools for when they do bytecode weaving but don't create telemetry, I think we should wait for at least a second tool that would fit in there, because doing so for a single module seems a bit overkill.

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()
}
}
Loading