diff --git a/common/api/common.api b/common/api/common.api index a3179d0a5..01085a0a2 100644 --- a/common/api/common.api +++ b/common/api/common.api @@ -1,6 +1,9 @@ public final class io/opentelemetry/android/common/RumConstants { public static final field APP_START_SPAN_NAME Ljava/lang/String; public static final field BATTERY_PERCENT_KEY Lio/opentelemetry/api/common/AttributeKey; + public static final field CPU_AVERAGE_KEY Lio/opentelemetry/api/common/AttributeKey; + public static final field CPU_ELAPSED_TIME_END_KEY Lio/opentelemetry/api/common/AttributeKey; + public static final field CPU_ELAPSED_TIME_START_KEY Lio/opentelemetry/api/common/AttributeKey; public static final field HEAP_FREE_KEY Lio/opentelemetry/api/common/AttributeKey; public static final field INSTANCE Lio/opentelemetry/android/common/RumConstants; public static final field LAST_SCREEN_NAME_KEY Lio/opentelemetry/api/common/AttributeKey; diff --git a/common/src/main/java/io/opentelemetry/android/common/RumConstants.kt b/common/src/main/java/io/opentelemetry/android/common/RumConstants.kt index 1c23cbfc6..c27792353 100644 --- a/common/src/main/java/io/opentelemetry/android/common/RumConstants.kt +++ b/common/src/main/java/io/opentelemetry/android/common/RumConstants.kt @@ -31,6 +31,15 @@ object RumConstants { @JvmField val BATTERY_PERCENT_KEY: AttributeKey = AttributeKey.doubleKey("battery.percent") + @JvmField + val CPU_AVERAGE_KEY: AttributeKey = AttributeKey.doubleKey("process.cpu.avg_utilization") + + @JvmField + val CPU_ELAPSED_TIME_START_KEY: AttributeKey = AttributeKey.longKey("process.cpu.elapsed_time_start") + + @JvmField + val CPU_ELAPSED_TIME_END_KEY: AttributeKey = AttributeKey.longKey("process.cpu.elapsed_time_end") + const val APP_START_SPAN_NAME: String = "AppStart" object Events { diff --git a/core/api/core.api b/core/api/core.api index cbad72887..5559972e7 100644 --- a/core/api/core.api +++ b/core/api/core.api @@ -11,6 +11,18 @@ public final class io/opentelemetry/android/BuildConfig { public fun ()V } +public final class io/opentelemetry/android/CpuAttributesSpanAppender : io/opentelemetry/sdk/trace/internal/ExtendedSpanProcessor { + public fun ()V + public fun (I)V + public synthetic fun (IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun isEndRequired ()Z + public fun isOnEndingRequired ()Z + public fun isStartRequired ()Z + public fun onEnd (Lio/opentelemetry/sdk/trace/ReadableSpan;)V + public fun onEnding (Lio/opentelemetry/sdk/trace/ReadWriteSpan;)V + public fun onStart (Lio/opentelemetry/context/Context;Lio/opentelemetry/sdk/trace/ReadWriteSpan;)V +} + public abstract interface class io/opentelemetry/android/OpenTelemetryRum { public static fun builder (Landroid/app/Application;)Lio/opentelemetry/android/OpenTelemetryRumBuilder; public static fun builder (Landroid/app/Application;Lio/opentelemetry/android/config/OtelRumConfig;)Lio/opentelemetry/android/OpenTelemetryRumBuilder; @@ -57,6 +69,7 @@ public final class io/opentelemetry/android/SessionIdRatioBasedSampler : io/open public class io/opentelemetry/android/config/OtelRumConfig { public fun ()V + public fun disableCpuAttributes ()Lio/opentelemetry/android/config/OtelRumConfig; public fun disableInstrumentationDiscovery ()Lio/opentelemetry/android/config/OtelRumConfig; public fun disableNetworkAttributes ()Lio/opentelemetry/android/config/OtelRumConfig; public fun disableScreenAttributes ()Lio/opentelemetry/android/config/OtelRumConfig; @@ -70,6 +83,7 @@ public class io/opentelemetry/android/config/OtelRumConfig { public fun setGlobalAttributes (Ljava/util/function/Supplier;)Lio/opentelemetry/android/config/OtelRumConfig; public fun shouldDiscoverInstrumentations ()Z public fun shouldGenerateSdkInitializationEvents ()Z + public fun shouldIncludeCpuAttributes ()Z public fun shouldIncludeNetworkAttributes ()Z public fun shouldIncludeScreenAttributes ()Z public fun suppressInstrumentation (Ljava/lang/String;)Lio/opentelemetry/android/config/OtelRumConfig; diff --git a/core/src/main/java/io/opentelemetry/android/CpuAttributesSpanAppender.kt b/core/src/main/java/io/opentelemetry/android/CpuAttributesSpanAppender.kt new file mode 100644 index 000000000..d11fdb492 --- /dev/null +++ b/core/src/main/java/io/opentelemetry/android/CpuAttributesSpanAppender.kt @@ -0,0 +1,57 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android + +import android.os.Process +import io.opentelemetry.android.common.RumConstants +import io.opentelemetry.context.Context +import io.opentelemetry.sdk.trace.ReadWriteSpan +import io.opentelemetry.sdk.trace.ReadableSpan +import io.opentelemetry.sdk.trace.internal.ExtendedSpanProcessor + +/** + * A [SpanProcessor] that uses the experimental ExtendedSpanProcessor API to append OS process + * cpu statistics into span attributes. We establish 'cpu utilization average' to be: + * + * cpuUtilizationAvg = 100 * (cpuTimeMs / spanDurationMs) / number of CPU cores + * * cpuTimeMs is the time in milliseconds that the app process has taken in active CPU + * time + * * spanDurationMs is the total running time in milliseconds that the span has been active + * for + */ +class CpuAttributesSpanAppender( + private val cpuCores: Int = Runtime.getRuntime().availableProcessors(), +) : ExtendedSpanProcessor { + override fun isStartRequired(): Boolean = true + + override fun onEnd(span: ReadableSpan) {} + + override fun isEndRequired(): Boolean = false + + override fun isOnEndingRequired(): Boolean = true + + override fun onStart( + parentContext: Context, + span: ReadWriteSpan, + ) { + val cputime = Process.getElapsedCpuTime() + span.setAttribute(RumConstants.CPU_ELAPSED_TIME_START_KEY, Process.getElapsedCpuTime()) + } + + override fun onEnding(span: ReadWriteSpan) { + val startCpuTime = + span.getAttribute(RumConstants.CPU_ELAPSED_TIME_START_KEY) ?: return + val endCpuTime = Process.getElapsedCpuTime() + val cpuTimeMs = (endCpuTime - startCpuTime).toDouble() + val spanDurationMs = (span.latencyNanos / 1_000_000).toDouble() + + if (spanDurationMs > 0) { + val cpuUtilization = (cpuTimeMs / spanDurationMs) * 100.0 / cpuCores.toDouble() + span.setAttribute(RumConstants.CPU_AVERAGE_KEY, cpuUtilization) + } + span.setAttribute(RumConstants.CPU_ELAPSED_TIME_END_KEY, endCpuTime) + } +} diff --git a/core/src/main/java/io/opentelemetry/android/OpenTelemetryRumBuilder.java b/core/src/main/java/io/opentelemetry/android/OpenTelemetryRumBuilder.java index 0d92953e5..e331b1368 100644 --- a/core/src/main/java/io/opentelemetry/android/OpenTelemetryRumBuilder.java +++ b/core/src/main/java/io/opentelemetry/android/OpenTelemetryRumBuilder.java @@ -531,6 +531,12 @@ private void applyConfiguration(Services services, InitializationEvents initiali new ScreenAttributesLogRecordProcessor( services.getVisibleScreenTracker()))); } + + // Add processor that appends CPU attributes + if (config.shouldIncludeCpuAttributes()) { + tracerProviderCustomizers.add( + 0, (builder, app) -> builder.addSpanProcessor(new CpuAttributesSpanAppender())); + } } private SdkTracerProvider buildTracerProvider( diff --git a/core/src/main/java/io/opentelemetry/android/config/OtelRumConfig.java b/core/src/main/java/io/opentelemetry/android/config/OtelRumConfig.java index f679409a1..d9928544d 100644 --- a/core/src/main/java/io/opentelemetry/android/config/OtelRumConfig.java +++ b/core/src/main/java/io/opentelemetry/android/config/OtelRumConfig.java @@ -7,6 +7,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import io.opentelemetry.android.CpuAttributesSpanAppender; import io.opentelemetry.android.ScreenAttributesSpanProcessor; import io.opentelemetry.android.features.diskbuffering.DiskBufferingConfig; import io.opentelemetry.android.internal.services.network.CurrentNetworkProvider; @@ -27,6 +28,7 @@ public class OtelRumConfig { private boolean generateSdkInitializationEvents = true; private boolean includeScreenAttributes = true; private boolean discoverInstrumentations = true; + private boolean includeCpuAttributes = true; private DiskBufferingConfig diskBufferingConfig = DiskBufferingConfig.create(); private final List suppressedInstrumentations = new ArrayList<>(); @@ -66,11 +68,25 @@ public OtelRumConfig disableNetworkAttributes() { return this; } + /** + * Disables the cpu attributes for spans. See {@link CpuAttributesSpanAppender} for more + * information. Default = true. + */ + public OtelRumConfig disableCpuAttributes() { + includeCpuAttributes = false; + return this; + } + /** Returns true if runtime network attributes are enabled, false otherwise. */ public boolean shouldIncludeNetworkAttributes() { return includeNetworkAttributes; } + /** Returns true if cpu attributes are enabled, false otherwise */ + public boolean shouldIncludeCpuAttributes() { + return includeCpuAttributes; + } + /** * Disables the collection of events related to the initialization of the OTel Android SDK * itself. Default = true. diff --git a/core/src/test/java/io/opentelemetry/android/CpuAttributesSpanAppenderTest.kt b/core/src/test/java/io/opentelemetry/android/CpuAttributesSpanAppenderTest.kt new file mode 100644 index 000000000..2a977446a --- /dev/null +++ b/core/src/test/java/io/opentelemetry/android/CpuAttributesSpanAppenderTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.android + +import android.os.Process +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockkStatic +import io.mockk.verify +import io.opentelemetry.android.common.RumConstants +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.context.Context +import io.opentelemetry.sdk.trace.ReadWriteSpan +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(MockKExtension::class) +class CpuAttributesSpanAppenderTest { + @MockK + private lateinit var mockSpan: ReadWriteSpan + + @MockK + private lateinit var context: Context + + val processor = CpuAttributesSpanAppender(cpuCores = 1) + + @BeforeEach + fun setup() { + mockkStatic(Process::class) + every { mockSpan.setAttribute(any>(), any()) } returns mockSpan + every { mockSpan.setAttribute(any>(), any()) } returns mockSpan + } + + @Test + fun `onStart should set the right attribute`() { + every { Process.getElapsedCpuTime() } returns 5L + + processor.onStart(context, mockSpan) + + verify { + mockSpan.setAttribute(RumConstants.CPU_ELAPSED_TIME_START_KEY, 5L) + } + } + + @Test + fun `onEnding should set the right attributes if span has duration`() { + every { Process.getElapsedCpuTime() } returns 50L + every { + mockSpan.getAttribute(RumConstants.CPU_ELAPSED_TIME_START_KEY) + } returns 5L + // cpuTime = 45 + + every { mockSpan.latencyNanos } returns 100L * 1_000_000 + + // Span took 100ms, process was active for 45ms of that time. Therefore, expect 45% cpu + processor.onEnding(mockSpan) + + verify { + mockSpan.setAttribute(RumConstants.CPU_AVERAGE_KEY, 45.0) + mockSpan.setAttribute(RumConstants.CPU_ELAPSED_TIME_END_KEY, 50L) + } + + // With multiple cores, divide CPU average, expect 22.5% cpu + val moreCoresProcessor = CpuAttributesSpanAppender(cpuCores = 2) + moreCoresProcessor.onEnding(mockSpan) + + verify { + mockSpan.setAttribute(RumConstants.CPU_AVERAGE_KEY, 22.5) + mockSpan.setAttribute(RumConstants.CPU_ELAPSED_TIME_END_KEY, 50L) + } + } + + @Test + fun `onEnding should not set CPU average attribute if span has zero duration`() { + every { Process.getElapsedCpuTime() } returns 50L + every { + mockSpan.getAttribute(RumConstants.CPU_ELAPSED_TIME_START_KEY) + } returns 5L + + every { mockSpan.latencyNanos } returns 0 + + processor.onEnding(mockSpan) + + verify(exactly = 0) { + mockSpan.setAttribute(RumConstants.CPU_AVERAGE_KEY, any()) + } + + verify { + mockSpan.setAttribute(RumConstants.CPU_ELAPSED_TIME_END_KEY, 50L) + } + } +} diff --git a/core/src/test/java/io/opentelemetry/android/OpenTelemetryRumBuilderTest.java b/core/src/test/java/io/opentelemetry/android/OpenTelemetryRumBuilderTest.java index e1d9afce2..81e1ef3a0 100644 --- a/core/src/test/java/io/opentelemetry/android/OpenTelemetryRumBuilderTest.java +++ b/core/src/test/java/io/opentelemetry/android/OpenTelemetryRumBuilderTest.java @@ -508,6 +508,31 @@ public void verifyGlobalAttrsForLogs() { .build()); } + @Test + public void verifyCpuAttributesSpanProcessor() { + createAndSetServiceManager(); + OtelRumConfig config = buildConfig(); + + OpenTelemetryRum rum = + OpenTelemetryRum.builder(application, config) + .addTracerProviderCustomizer( + (tracerProviderBuilder, app) -> + tracerProviderBuilder.addSpanProcessor( + SimpleSpanProcessor.create(spanExporter))) + .build(); + + rum.getOpenTelemetry().getTracer("test").spanBuilder("test span").startSpan().end(); + + await().atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).hasSize(1); + assertThat(spans.get(0).getAttributes().asMap()) + .containsKey(longKey("process.cpu.elapsed_time_start")); + }); + } + private static Services createAndSetServiceManager() { Services services = mock(Services.class); when(services.getAppLifecycle()).thenReturn(mock(AppLifecycle.class));