diff --git a/splunk-otel-android/src/main/java/com/splunk/rum/RumInitializer.java b/splunk-otel-android/src/main/java/com/splunk/rum/RumInitializer.java index aeca1000f..99594fcbd 100644 --- a/splunk-otel-android/src/main/java/com/splunk/rum/RumInitializer.java +++ b/splunk-otel-android/src/main/java/com/splunk/rum/RumInitializer.java @@ -178,19 +178,16 @@ private SlowRenderingDetector buildSlowRenderingDetector(Tracer tracer) { Log.w(LOG_TAG, "Slow/frozen rendering detection has been disabled by user."); return NoOpSlowRenderingDetector.INSTANCE; } - try { - initializationEvents.add( - new RumInitializer.InitializationEvent( - "slowRenderingDetectorInitialized", timingClock.now())); - Class.forName("androidx.core.app.FrameMetricsAggregator"); - return new SlowRenderingDetectorImpl( - tracer, builder.slowRenderingDetectionPollInterval); - } catch (ClassNotFoundException e) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { Log.w( LOG_TAG, - "FrameMetricsAggregator is not available on this platform - slow/frozen rendering detection is disabled."); + "Slow/frozen rendering detection is not supported on platforms older than Android N (SDK version 24)."); return NoOpSlowRenderingDetector.INSTANCE; } + initializationEvents.add( + new RumInitializer.InitializationEvent( + "slowRenderingDetectorInitialized", timingClock.now())); + return new SlowRenderingDetectorImpl(tracer, builder.slowRenderingDetectionPollInterval); } private AppStateListener initializeAnrReporting(Looper mainLooper) { diff --git a/splunk-otel-android/src/main/java/com/splunk/rum/SlowRenderingDetectorImpl.java b/splunk-otel-android/src/main/java/com/splunk/rum/SlowRenderingDetectorImpl.java index e7b538a6f..b4880f6e6 100644 --- a/splunk-otel-android/src/main/java/com/splunk/rum/SlowRenderingDetectorImpl.java +++ b/splunk-otel-android/src/main/java/com/splunk/rum/SlowRenderingDetectorImpl.java @@ -16,14 +16,19 @@ package com.splunk.rum; -import static androidx.core.app.FrameMetricsAggregator.DRAW_DURATION; -import static androidx.core.app.FrameMetricsAggregator.DRAW_INDEX; import static com.splunk.rum.SplunkRum.LOG_TAG; import android.app.Activity; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; import android.util.Log; import android.util.SparseIntArray; -import androidx.core.app.FrameMetricsAggregator; +import android.view.FrameMetrics; +import android.view.Window; +import androidx.annotation.GuardedBy; +import androidx.annotation.RequiresApi; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.Tracer; import java.time.Duration; @@ -34,56 +39,83 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -class SlowRenderingDetectorImpl implements SlowRenderingDetector { +@RequiresApi(api = Build.VERSION_CODES.N) +class SlowRenderingDetectorImpl + implements SlowRenderingDetector, Window.OnFrameMetricsAvailableListener { static final int SLOW_THRESHOLD_MS = 16; static final int FROZEN_THRESHOLD_MS = 700; - private final FrameMetricsAggregator frameMetrics; - private final ScheduledExecutorService executorService; - private final Set activities = new HashSet<>(); + private static final int NANOS_PER_MS = (int) TimeUnit.MILLISECONDS.toNanos(1); + // rounding value adds half a millisecond, for rounding to nearest ms + private static final int NANOS_ROUNDING_VALUE = NANOS_PER_MS / 2; + + private static final HandlerThread frameMetricsThread = + new HandlerThread("FrameMetricsCollector"); + private final Tracer tracer; + private final ScheduledExecutorService executorService; + private final Handler frameMetricsHandler; private final Duration pollInterval; + private final Object lock = new Object(); + @GuardedBy("lock") + private final Set activities = new HashSet<>(); + + @GuardedBy("lock") + private SparseIntArray drawDurationHistogram = new SparseIntArray(); + SlowRenderingDetectorImpl(Tracer tracer, Duration pollInterval) { this( tracer, - new FrameMetricsAggregator(DRAW_DURATION), Executors.newScheduledThreadPool(1), + new Handler(startFrameMetricsLoop()), pollInterval); } // Exists for testing SlowRenderingDetectorImpl( Tracer tracer, - FrameMetricsAggregator frameMetricsAggregator, ScheduledExecutorService executorService, + Handler frameMetricsHandler, Duration pollInterval) { this.tracer = tracer; - this.frameMetrics = frameMetricsAggregator; this.executorService = executorService; + this.frameMetricsHandler = frameMetricsHandler; this.pollInterval = pollInterval; } + private static Looper startFrameMetricsLoop() { + // just a precaution: this is supposed to be called only once, and the thread should always + // be not started here + if (!frameMetricsThread.isAlive()) { + frameMetricsThread.start(); + } + return frameMetricsThread.getLooper(); + } + @Override public void add(Activity activity) { + boolean added; synchronized (lock) { - activities.add(activity); - frameMetrics.add(activity); + added = activities.add(activity); + } + if (added) { + activity.getWindow().addOnFrameMetricsAvailableListener(this, frameMetricsHandler); } } @Override public void stop(Activity activity) { - SparseIntArray[] arrays; + boolean removed; synchronized (lock) { - arrays = frameMetrics.remove(activity); - activities.remove(activity); + removed = activities.remove(activity); } - if (arrays != null) { - reportSlow(arrays[DRAW_INDEX]); + if (removed) { + activity.getWindow().removeOnFrameMetricsAvailableListener(this); } + reportSlow(getMetrics()); } // the returned future is very unlikely to fail @@ -97,34 +129,44 @@ public void start() { TimeUnit.MILLISECONDS); } - private void reportSlowRenders() { - try { - SparseIntArray[] metrics; + @Override + public void onFrameMetricsAvailable( + Window window, FrameMetrics frameMetrics, int dropCountSinceLastInvocation) { + long drawDurationsNs = frameMetrics.getMetric(FrameMetrics.DRAW_DURATION); + // ignore values < 0; something must have gone wrong + if (drawDurationsNs >= 0) { synchronized (lock) { - metrics = frameMetrics.reset(); - } - if (metrics != null) { - reportSlow(metrics[DRAW_INDEX]); + // calculation copied from FrameMetricsAggregator + int durationMs = (int) ((drawDurationsNs + NANOS_ROUNDING_VALUE) / NANOS_PER_MS); + int oldValue = drawDurationHistogram.get(durationMs); + drawDurationHistogram.put(durationMs, (oldValue + 1)); } - } catch (Exception e) { - Log.w(LOG_TAG, "Exception while processing frame metrics", e); } + } + + private SparseIntArray getMetrics() { synchronized (lock) { - try { - for (Activity activity : activities) { - frameMetrics.remove(activity); - frameMetrics.add(activity); - } - } catch (Exception e) { - Log.w(LOG_TAG, "Exception updating observed activities", e); - } + return drawDurationHistogram.clone(); } } - private void reportSlow(SparseIntArray durationToCountHistogram) { - if (durationToCountHistogram == null) { - return; + private SparseIntArray resetMetrics() { + synchronized (lock) { + SparseIntArray metrics = drawDurationHistogram; + drawDurationHistogram = new SparseIntArray(); + return metrics; } + } + + private void reportSlowRenders() { + try { + reportSlow(resetMetrics()); + } catch (Exception e) { + Log.w(LOG_TAG, "Exception while processing frame metrics", e); + } + } + + private void reportSlow(SparseIntArray durationToCountHistogram) { int slowCount = 0; int frozenCount = 0; for (int i = 0; i < durationToCountHistogram.size(); i++) { diff --git a/splunk-otel-android/src/test/java/com/splunk/rum/SlowRenderingDetectorImplTest.java b/splunk-otel-android/src/test/java/com/splunk/rum/SlowRenderingDetectorImplTest.java index 43afd8294..608b97bac 100644 --- a/splunk-otel-android/src/test/java/com/splunk/rum/SlowRenderingDetectorImplTest.java +++ b/splunk-otel-android/src/test/java/com/splunk/rum/SlowRenderingDetectorImplTest.java @@ -16,20 +16,19 @@ package com.splunk.rum; -import static androidx.core.app.FrameMetricsAggregator.DRAW_INDEX; -import static org.junit.Assert.assertEquals; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import android.app.Activity; -import android.util.SparseIntArray; -import androidx.annotation.NonNull; -import androidx.core.app.FrameMetricsAggregator; +import android.os.Build; +import android.os.Handler; +import android.view.FrameMetrics; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.trace.Tracer; import io.opentelemetry.sdk.testing.junit4.OpenTelemetryRule; @@ -38,21 +37,34 @@ import java.util.List; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.InOrder; +import org.mockito.Answers; import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; -@RunWith(MockitoJUnitRunner.class) +@RunWith(RobolectricTestRunner.class) +@Config(sdk = Build.VERSION_CODES.N) public class SlowRenderingDetectorImplTest { - public static final AttributeKey COUNT_KEY = AttributeKey.longKey("count"); + private static final AttributeKey COUNT_KEY = AttributeKey.longKey("count"); + @Rule public OpenTelemetryRule otelTesting = OpenTelemetryRule.create(); - @Mock FrameMetricsAggregator frameMetrics; - @Mock Activity activity; + @Rule public MockitoRule mocks = MockitoJUnit.rule(); + + @Mock Handler frameMetricsHandler; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + Activity activity; + + @Mock FrameMetrics frameMetrics; Tracer tracer; @Before @@ -63,40 +75,61 @@ public void setup() { @Test public void add() { SlowRenderingDetectorImpl testInstance = - new SlowRenderingDetectorImpl(tracer, frameMetrics, null, Duration.ofSeconds(0)); + new SlowRenderingDetectorImpl(tracer, null, frameMetricsHandler, Duration.ZERO); + testInstance.add(activity); - verify(frameMetrics).add(activity); + + verify(activity.getWindow()) + .addOnFrameMetricsAvailableListener(testInstance, frameMetricsHandler); } @Test - public void stopBeforeAddOk() { + public void removeBeforeAddOk() { SlowRenderingDetectorImpl testInstance = - new SlowRenderingDetectorImpl(tracer, frameMetrics, null, Duration.ofSeconds(0)); + new SlowRenderingDetectorImpl(tracer, null, frameMetricsHandler, Duration.ZERO); + testInstance.stop(activity); + + verifyNoInteractions(activity); + assertThat(otelTesting.getSpans()).hasSize(0); } @Test - public void stop() { + public void addAndRemove() { + SlowRenderingDetectorImpl testInstance = + new SlowRenderingDetectorImpl(tracer, null, frameMetricsHandler, Duration.ZERO); - SparseIntArray[] metricsArray = makeSomeMetrics(); + testInstance.add(activity); + testInstance.stop(activity); - when(frameMetrics.remove(activity)).thenReturn(metricsArray); + verify(activity.getWindow()) + .addOnFrameMetricsAvailableListener(testInstance, frameMetricsHandler); + verify(activity.getWindow()).removeOnFrameMetricsAvailableListener(testInstance); + assertThat(otelTesting.getSpans()).hasSize(0); + } + @Test + public void removeWithMetrics() { SlowRenderingDetectorImpl testInstance = - new SlowRenderingDetectorImpl(tracer, frameMetrics, null, Duration.ofMillis(1001)); + new SlowRenderingDetectorImpl(tracer, null, frameMetricsHandler, Duration.ZERO); testInstance.add(activity); + + for (long duration : makeSomeDurations()) { + when(frameMetrics.getMetric(FrameMetrics.DRAW_DURATION)).thenReturn(duration); + testInstance.onFrameMetricsAvailable(null, frameMetrics, 0); + } + testInstance.stop(activity); + List spans = otelTesting.getSpans(); assertSpanContent(spans); } @Test public void start() { - SparseIntArray[] metricsArray = makeSomeMetrics(); ScheduledExecutorService exec = mock(ScheduledExecutorService.class); - when(frameMetrics.reset()).thenReturn(metricsArray); doAnswer( invocation -> { Runnable runnable = invocation.getArgument(0); @@ -107,50 +140,46 @@ public void start() { .scheduleAtFixedRate(any(), eq(1001L), eq(1001L), eq(TimeUnit.MILLISECONDS)); SlowRenderingDetectorImpl testInstance = - new SlowRenderingDetectorImpl(tracer, frameMetrics, exec, Duration.ofMillis(1001)); + new SlowRenderingDetectorImpl( + tracer, exec, frameMetricsHandler, Duration.ofMillis(1001)); + testInstance.add(activity); - testInstance.start(); - List spans = otelTesting.getSpans(); - assertEquals(2, spans.size()); - assertSpanContent(spans); - InOrder inOrder = inOrder(frameMetrics); - inOrder.verify(frameMetrics).add(activity); - inOrder.verify(frameMetrics).remove(activity); - inOrder.verify(frameMetrics).add(activity); - inOrder.verifyNoMoreInteractions(); - } - private void assertSpanContent(List spans) { - assertEquals(2, spans.size()); - SpanData slowRenders = spans.get(0); - SpanData frozenRenders = spans.get(1); + for (long duration : makeSomeDurations()) { + when(frameMetrics.getMetric(FrameMetrics.DRAW_DURATION)).thenReturn(duration); + testInstance.onFrameMetricsAvailable(null, frameMetrics, 0); + } - assertEquals("slowRenders", slowRenders.getName()); - assertEquals(0, slowRenders.getStartEpochNanos() - slowRenders.getEndEpochNanos()); - long slowCount = slowRenders.getAttributes().get(COUNT_KEY); - assertEquals(2, slowCount); + testInstance.start(); - assertEquals("frozenRenders", frozenRenders.getName()); - assertEquals(0, frozenRenders.getStartEpochNanos() - frozenRenders.getEndEpochNanos()); - long frozenCount = frozenRenders.getAttributes().get(COUNT_KEY); - assertEquals(1, frozenCount); + List spans = otelTesting.getSpans(); + assertSpanContent(spans); } - @NonNull - private SparseIntArray[] makeSomeMetrics() { - SparseIntArray[] metricsArray = new SparseIntArray[DRAW_INDEX + 1]; - SparseIntArray drawMetrics = mock(SparseIntArray.class); - when(drawMetrics.size()).thenReturn(3); - addFrameMetric(drawMetrics, 0, 12, 17); - addFrameMetric(drawMetrics, 1, 100, 2); - addFrameMetric(drawMetrics, 2, 701, 1); - - metricsArray[DRAW_INDEX] = drawMetrics; - return metricsArray; + private static void assertSpanContent(List spans) { + assertThat(spans) + .hasSize(2) + .satisfiesExactly( + span -> + assertThat(span) + .hasName("slowRenders") + .endsAt(span.getStartEpochNanos()) + .hasAttribute(COUNT_KEY, 3L), + span -> + assertThat(span) + .hasName("frozenRenders") + .endsAt(span.getStartEpochNanos()) + .hasAttribute(COUNT_KEY, 1L)); } - private void addFrameMetric(SparseIntArray drawMetrics, int index, int key, int value) { - when(drawMetrics.keyAt(index)).thenReturn(key); - when(drawMetrics.get(key)).thenReturn(value); + private List makeSomeDurations() { + return Stream.of( + 5L, 11L, 101L, // slow + 701L, // frozen + 17L, // slow + 17L, // slow + 16L, 11L) + .map(TimeUnit.MILLISECONDS::toNanos) + .collect(Collectors.toList()); } }