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
3 changes: 3 additions & 0 deletions common/api/common.api
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ object RumConstants {
@JvmField
val BATTERY_PERCENT_KEY: AttributeKey<Double> = AttributeKey.doubleKey("battery.percent")

@JvmField
val CPU_AVERAGE_KEY: AttributeKey<Double> = AttributeKey.doubleKey("process.cpu.avg_utilization")

@JvmField
val CPU_ELAPSED_TIME_START_KEY: AttributeKey<Long> = AttributeKey.longKey("process.cpu.elapsed_time_start")

@JvmField
val CPU_ELAPSED_TIME_END_KEY: AttributeKey<Long> = AttributeKey.longKey("process.cpu.elapsed_time_end")

const val APP_START_SPAN_NAME: String = "AppStart"

object Events {
Expand Down
14 changes: 14 additions & 0 deletions core/api/core.api
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ public final class io/opentelemetry/android/BuildConfig {
public fun <init> ()V
}

public final class io/opentelemetry/android/CpuAttributesSpanAppender : io/opentelemetry/sdk/trace/internal/ExtendedSpanProcessor {
public fun <init> ()V
public fun <init> (I)V
public synthetic fun <init> (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;
Expand Down Expand Up @@ -57,6 +69,7 @@ public final class io/opentelemetry/android/SessionIdRatioBasedSampler : io/open

public class io/opentelemetry/android/config/OtelRumConfig {
public fun <init> ()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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guess what? This value is not guaranteed to stay the same for the life of the runtime! I know, it's wild. https://developer.android.com/reference/java/lang/Runtime#availableProcessors()

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And yeah, unfortunately, this uncertainty can make the data harder to interpret without also sending along how many cores were used in the average calculation. For example, if the app is running along for some time with 8 cores, and then the number of cores drops to 4, the per-core reported "cpu average" reported value will double considerably and the user will have no indication about why.

To address this, one could imagine also sending the number of cores at span end and then the computation isn't required (it can be done in the backend, and the computed average is redundant).

This also opens up some other slightly stupider edge cases which are probably not worth thinking too deeply about....like what if the core count changes mid-span (hint: there's no trivial way of knowing!)...or the fact that you can't guarantee that every cpu/core is running with the same performance/frequency...etc.

This makes me think of an interesting feature possibility tho -- polling the cpu count and generating an event when that number changes. 🤔

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guess what? This value is not guaranteed to stay the same for the life of the runtime! I know, it's wild. developer.android.com/reference/java/lang/Runtime#availableProcessors()

TIL

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we had to dig into the native layer to get consistent info about cores. It's arguably not worth it...

) : 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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tend to favor existing fluent utility classes instead of manually doing the math with conversion factors.

Suggested change
val spanDurationMs = (span.latencyNanos / 1_000_000).toDouble()
val spanDurationMs = TimeUnit.NANOSECONDS.toMillis(span.latencyNanos).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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<String> suppressedInstrumentations = new ArrayList<>();

Expand Down Expand Up @@ -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;
}
Comment on lines +71 to +78
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Until we can establish that there is a broad(er) desire for these attributes, I think it's safer for this to be opt-in (default disabled). Can we flip this around?


/** 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AttributeKey<Double>>(), any<Double>()) } returns mockSpan
every { mockSpan.setAttribute(any<AttributeKey<Long>>(), any<Long>()) } 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<Double>())
}

verify {
mockSpan.setAttribute(RumConstants.CPU_ELAPSED_TIME_END_KEY, 50L)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<SpanData> spans = spanExporter.getFinishedSpanItems();
assertThat(spans).hasSize(1);
assertThat(spans.get(0).getAttributes().asMap())
.containsKey(longKey("process.cpu.elapsed_time_start"));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would also help to

  • verify that the other 2 attributes are also present
  • verify that the end time is greater than or equal the start time

});
}

private static Services createAndSetServiceManager() {
Services services = mock(Services.class);
when(services.getAppLifecycle()).thenReturn(mock(AppLifecycle.class));
Expand Down
Loading