diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f47af9d1..424ab1d889 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Add new threshold parameters to monitor config ([#3181](https://github.com/getsentry/sentry-java/pull/3181)) - Report process init time as a span for app start performance ([#3159](https://github.com/getsentry/sentry-java/pull/3159)) +- (perf-v2): Calculate frame delay on a span level ([#3197](https://github.com/getsentry/sentry-java/pull/3197)) ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 4a62f5009d..8eb017346d 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -349,7 +349,7 @@ public final class io/sentry/android/core/SentryPerformanceProvider { } public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerformanceContinuousCollector, io/sentry/android/core/internal/util/SentryFrameMetricsCollector$FrameMetricsCollectorListener { - public fun (Lio/sentry/android/core/SentryAndroidOptions;)V + public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V public fun clear ()V public fun onFrameMetricCollected (JJJJZZF)V public fun onSpanFinished (Lio/sentry/ISpan;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 41d0dec6b2..e777db4add 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -212,7 +212,12 @@ static void initializeIntegrationsAndProcessors( new AndroidCpuCollector(options.getLogger(), buildInfoProvider)); if (options.isEnablePerformanceV2()) { - options.addPerformanceCollector(new SpanFrameMetricsCollector(options)); + options.addPerformanceCollector( + new SpanFrameMetricsCollector( + options, + Objects.requireNonNull( + options.getFrameMetricsCollector(), + "options.getFrameMetricsCollector is required"))); } } options.setTransactionPerformanceCollector(new DefaultTransactionPerformanceCollector(options)); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetrics.java index 54ed5b6b1f..23409eadea 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryFrameMetrics.java @@ -24,6 +24,7 @@ public SentryFrameMetrics( final int frozenFrameCount, final long frozenFrameDelayNanos, final long totalDurationNanos) { + this.normalFrameCount = normalFrameCount; this.slowFrameCount = slowFrameCount; @@ -34,21 +35,21 @@ public SentryFrameMetrics( this.totalDurationNanos = totalDurationNanos; } - public void addSlowFrame(final long durationNanos, final long delayNanos) { - totalDurationNanos += durationNanos; - slowFrameDelayNanos += delayNanos; - slowFrameCount++; - } - - public void addFrozenFrame(final long durationNanos, final long delayNanos) { - totalDurationNanos += durationNanos; - frozenFrameDelayNanos += delayNanos; - frozenFrameCount++; - } - - public void addNormalFrame(final long durationNanos) { + public void addFrame( + final long durationNanos, + final long delayNanos, + final boolean isSlow, + final boolean isFrozen) { totalDurationNanos += durationNanos; - normalFrameCount++; + if (isFrozen) { + frozenFrameDelayNanos += delayNanos; + frozenFrameCount += 1; + } else if (isSlow) { + slowFrameDelayNanos += delayNanos; + slowFrameCount += 1; + } else { + normalFrameCount += 1; + } } public int getNormalFrameCount() { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java index c96c0d6b7b..b5f0332c67 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java @@ -6,12 +6,15 @@ import io.sentry.NoOpSpan; import io.sentry.NoOpTransaction; import io.sentry.SentryDate; +import io.sentry.SentryNanotimeDate; import io.sentry.SpanDataConvention; -import io.sentry.SpanId; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.protocol.MeasurementValue; -import java.util.HashMap; -import java.util.Map; +import java.util.Date; +import java.util.Iterator; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentSkipListSet; import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -22,22 +25,51 @@ public class SpanFrameMetricsCollector implements IPerformanceContinuousCollector, SentryFrameMetricsCollector.FrameMetricsCollectorListener { - private @NotNull final Object lock = new Object(); - private @Nullable final SentryFrameMetricsCollector frameMetricsCollector; - private @Nullable volatile String listenerId; - private @NotNull final Map metricsAtSpanStart; + // 30s span duration at 120fps = 3600 frames + // this is just an upper limit for frames.size, ensuring that the buffer does not + // grow indefinitely in case of a long running span + private static final int MAX_FRAMES_COUNT = 3600; + private static final long ONE_SECOND_NANOS = TimeUnit.SECONDS.toNanos(1); + private static final SentryNanotimeDate UNIX_START_DATE = new SentryNanotimeDate(new Date(0), 0); - private @NotNull final SentryFrameMetrics currentFrameMetrics; private final boolean enabled; + private final @NotNull Object lock = new Object(); + private final @NotNull SentryFrameMetricsCollector frameMetricsCollector; - private float lastRefreshRate = 60.0f; + private volatile @Nullable String listenerId; - public SpanFrameMetricsCollector(final @NotNull SentryAndroidOptions options) { - frameMetricsCollector = options.getFrameMetricsCollector(); - enabled = options.isEnablePerformanceV2() && options.isEnableFramesTracking(); + // all running spans, sorted by span start nano time + private final @NotNull SortedSet runningSpans = + new TreeSet<>( + (o1, o2) -> { + int timeDiff = o1.getStartDate().compareTo(o2.getStartDate()); + if (timeDiff != 0) { + return timeDiff; + } else { + // TreeSet uses compareTo to check for duplicates, so ensure that + // two non-equal spans with the same start date are not considered equal + return o1.getSpanContext() + .getSpanId() + .toString() + .compareTo(o2.getSpanContext().getSpanId().toString()); + } + }); + + // all collected frames, sorted by frame end time + // this is a concurrent set, as the frames are added on the main thread, + // but span starts/finish may happen on any thread + // the list only holds Frames, but in order to query for a specific span NanoTimeStamp is used + private final @NotNull ConcurrentSkipListSet frames = new ConcurrentSkipListSet<>(); + + // assume 60fps until we get a value reported by the system + private long lastKnownFrameDurationNanos = 16_666_666L; + + public SpanFrameMetricsCollector( + final @NotNull SentryAndroidOptions options, + final @NotNull SentryFrameMetricsCollector frameMetricsCollector) { + this.frameMetricsCollector = frameMetricsCollector; - metricsAtSpanStart = new HashMap<>(); - currentFrameMetrics = new SentryFrameMetrics(); + enabled = options.isEnablePerformanceV2() && options.isEnableFramesTracking(); } @Override @@ -53,12 +85,10 @@ public void onSpanStarted(final @NotNull ISpan span) { } synchronized (lock) { - metricsAtSpanStart.put(span.getSpanContext().getSpanId(), currentFrameMetrics.duplicate()); + runningSpans.add(span); if (listenerId == null) { - if (frameMetricsCollector != null) { - listenerId = frameMetricsCollector.startCollection(this); - } + listenerId = frameMetricsCollector.startCollection(this); } } } @@ -68,63 +98,121 @@ public void onSpanFinished(final @NotNull ISpan span) { if (!enabled) { return; } + if (span instanceof NoOpSpan) { return; } + if (span instanceof NoOpTransaction) { return; } - @Nullable SentryFrameMetrics diff = null; + // ignore span if onSpanStarted was never called for it synchronized (lock) { - final @Nullable SentryFrameMetrics metricsAtStart = - metricsAtSpanStart.remove(span.getSpanContext().getSpanId()); - if (metricsAtStart != null) { - diff = currentFrameMetrics.diffTo(metricsAtStart); + if (!runningSpans.contains(span)) { + return; } } - if (diff != null && diff.containsValidData()) { - int nonRenderedFrameCount = 0; + captureFrameMetrics(span); + + synchronized (lock) { + if (runningSpans.isEmpty()) { + clear(); + } else { + // otherwise only remove old/irrelevant frames + final @NotNull ISpan oldestSpan = runningSpans.first(); + frames.headSet(new Frame(realNanos(oldestSpan.getStartDate()))).clear(); + } + } + } - // if there are no content changes on Android, also no frames are rendered - // thus no frame metrics are provided - // in order to match the span duration with the total frame count, - // we simply interpolate the total number of frames based on the span duration - // this way the data is more sound and we also match the output of the cocoa SDK + private void captureFrameMetrics(@NotNull final ISpan span) { + // TODO lock still required? + synchronized (lock) { + boolean removed = runningSpans.remove(span); + if (!removed) { + return; + } + + // ignore spans with no finish date final @Nullable SentryDate spanFinishDate = span.getFinishDate(); - if (spanFinishDate != null) { - final long spanDurationNanos = spanFinishDate.diff(span.getStartDate()); + if (spanFinishDate == null) { + return; + } + final long spanEndNanos = realNanos(spanFinishDate); + + final @NotNull SentryFrameMetrics frameMetrics = new SentryFrameMetrics(); + final long spanStartNanos = realNanos(span.getStartDate()); + if (spanStartNanos >= spanEndNanos) { + return; + } + + final long spanDurationNanos = spanEndNanos - spanStartNanos; + long frameDurationNanos = lastKnownFrameDurationNanos; - final long frameMetricsDurationNanos = diff.getTotalDurationNanos(); - final long nonRenderedDuration = spanDurationNanos - frameMetricsDurationNanos; - final double refreshRate = lastRefreshRate; + if (!frames.isEmpty()) { + // determine relevant start in frames list + final Iterator iterator = frames.tailSet(new Frame(spanStartNanos)).iterator(); - if (nonRenderedDuration > 0 && refreshRate > 0.0d) { - // e.g. at 60fps we would have 16.6ms per frame - final long normalFrameDurationNanos = - (long) ((double) TimeUnit.SECONDS.toNanos(1) / refreshRate); + //noinspection WhileLoopReplaceableByForEach + while (iterator.hasNext()) { + final @NotNull Frame frame = iterator.next(); - nonRenderedFrameCount = (int) (nonRenderedDuration / normalFrameDurationNanos); + if (frame.startNanos > spanEndNanos) { + break; + } + + if (frame.startNanos >= spanStartNanos && frame.endNanos <= spanEndNanos) { + // if the frame is contained within the span, add it 1:1 to the span metrics + frameMetrics.addFrame( + frame.durationNanos, frame.delayNanos, frame.isSlow, frame.isFrozen); + } else if ((spanStartNanos > frame.startNanos && spanStartNanos < frame.endNanos) + || (spanEndNanos > frame.startNanos && spanEndNanos < frame.endNanos)) { + // span start or end are within frame + // calculate the intersection + final long durationBeforeSpan = Math.max(0, spanStartNanos - frame.startNanos); + final long delayBeforeSpan = + Math.max(0, durationBeforeSpan - frame.expectedDurationNanos); + final long delayWithinSpan = + Math.min(frame.delayNanos - delayBeforeSpan, spanDurationNanos); + + final long frameStart = Math.max(spanStartNanos, frame.startNanos); + final long frameEnd = Math.min(spanEndNanos, frame.endNanos); + final long frameDuration = frameEnd - frameStart; + frameMetrics.addFrame( + frameDuration, + delayWithinSpan, + SentryFrameMetricsCollector.isSlow(frameDuration, frame.expectedDurationNanos), + SentryFrameMetricsCollector.isFrozen(frameDuration)); + } + + frameDurationNanos = frame.expectedDurationNanos; } } - final int totalFrameCount = diff.getTotalFrameCount() + nonRenderedFrameCount; + int totalFrameCount = frameMetrics.getTotalFrameCount(); + + final long nextScheduledFrameNanos = frameMetricsCollector.getLastKnownFrameStartTimeNanos(); + totalFrameCount += + addPendingFrameDelay( + frameMetrics, frameDurationNanos, spanEndNanos, nextScheduledFrameNanos); + totalFrameCount += interpolateFrameCount(frameMetrics, frameDurationNanos, spanDurationNanos); + + final long frameDelayNanos = + frameMetrics.getSlowFrameDelayNanos() + frameMetrics.getFrozenFrameDelayNanos(); + final double frameDelayInSeconds = frameDelayNanos / 1e9d; span.setData(SpanDataConvention.FRAMES_TOTAL, totalFrameCount); - span.setData(SpanDataConvention.FRAMES_SLOW, diff.getSlowFrameCount()); - span.setData(SpanDataConvention.FRAMES_FROZEN, diff.getFrozenFrameCount()); + span.setData(SpanDataConvention.FRAMES_SLOW, frameMetrics.getSlowFrameCount()); + span.setData(SpanDataConvention.FRAMES_FROZEN, frameMetrics.getFrozenFrameCount()); + span.setData(SpanDataConvention.FRAMES_DELAY, frameDelayInSeconds); if (span instanceof ITransaction) { span.setMeasurement(MeasurementValue.KEY_FRAMES_TOTAL, totalFrameCount); - span.setMeasurement(MeasurementValue.KEY_FRAMES_SLOW, diff.getSlowFrameCount()); - span.setMeasurement(MeasurementValue.KEY_FRAMES_FROZEN, diff.getFrozenFrameCount()); - } - } - - synchronized (lock) { - if (metricsAtSpanStart.isEmpty()) { - clear(); + span.setMeasurement(MeasurementValue.KEY_FRAMES_SLOW, frameMetrics.getSlowFrameCount()); + span.setMeasurement(MeasurementValue.KEY_FRAMES_FROZEN, frameMetrics.getFrozenFrameCount()); + span.setMeasurement(MeasurementValue.KEY_FRAMES_DELAY, frameDelayInSeconds); } } } @@ -133,34 +221,124 @@ public void onSpanFinished(final @NotNull ISpan span) { public void clear() { synchronized (lock) { if (listenerId != null) { - if (frameMetricsCollector != null) { - frameMetricsCollector.stopCollection(listenerId); - } + frameMetricsCollector.stopCollection(listenerId); listenerId = null; } - metricsAtSpanStart.clear(); - currentFrameMetrics.clear(); + frames.clear(); + runningSpans.clear(); } } @Override public void onFrameMetricCollected( - final long frameStartNanos, - final long frameEndNanos, - final long durationNanos, - final long delayNanos, - final boolean isSlow, - final boolean isFrozen, - final float refreshRate) { - - if (isFrozen) { - currentFrameMetrics.addFrozenFrame(durationNanos, delayNanos); - } else if (isSlow) { - currentFrameMetrics.addSlowFrame(durationNanos, delayNanos); - } else { - currentFrameMetrics.addNormalFrame(durationNanos); - } - - lastRefreshRate = refreshRate; + long frameStartNanos, + long frameEndNanos, + long durationNanos, + long delayNanos, + boolean isSlow, + boolean isFrozen, + float refreshRate) { + + // buffer is full, skip adding new frames for now + // once a span finishes, the buffer will trimmed + if (frames.size() > MAX_FRAMES_COUNT) { + return; + } + + final long expectedFrameDurationNanos = + (long) ((double) ONE_SECOND_NANOS / (double) refreshRate); + lastKnownFrameDurationNanos = expectedFrameDurationNanos; + + frames.add( + new Frame( + frameStartNanos, + frameEndNanos, + durationNanos, + delayNanos, + isSlow, + isFrozen, + expectedFrameDurationNanos)); + } + + private static int interpolateFrameCount( + final @NotNull SentryFrameMetrics frameMetrics, + final long frameDurationNanos, + final long spanDurationNanos) { + // if there are no content changes on Android, also no new frame metrics are provided by the + // system + // in order to match the span duration with the total frame count, + // we simply interpolate the total number of frames based on the span duration + // this way the data is more sound and we also match the output of the cocoa SDK + final long frameMetricsDurationNanos = frameMetrics.getTotalDurationNanos(); + final long nonRenderedDuration = spanDurationNanos - frameMetricsDurationNanos; + if (nonRenderedDuration > 0) { + return (int) (nonRenderedDuration / frameDurationNanos); + } + return 0; + } + + private static int addPendingFrameDelay( + @NotNull final SentryFrameMetrics frameMetrics, + final long frameDurationNanos, + final long spanEndNanos, + final long nextScheduledFrameNanos) { + final long pendingDurationNanos = Math.max(0, spanEndNanos - nextScheduledFrameNanos); + final boolean isSlow = + SentryFrameMetricsCollector.isSlow(pendingDurationNanos, frameDurationNanos); + if (isSlow) { + // add a single slow/frozen frame + final boolean isFrozen = SentryFrameMetricsCollector.isFrozen(pendingDurationNanos); + final long pendingDelayNanos = Math.max(0, pendingDurationNanos - frameDurationNanos); + frameMetrics.addFrame(pendingDurationNanos, pendingDelayNanos, true, isFrozen); + return 1; + } + return 0; + } + + /** + * Because {@link SentryNanotimeDate#nanoTimestamp()} only gives you millisecond precision, but + * diff does ¯\_(ツ)_/¯ + * + * @param date the input date + * @return a timestamp in nano precision + */ + private static long realNanos(final @NotNull SentryDate date) { + return date.diff(UNIX_START_DATE); + } + + private static class Frame implements Comparable { + private final long startNanos; + private final long endNanos; + private final long durationNanos; + private final long delayNanos; + private final boolean isSlow; + private final boolean isFrozen; + private final long expectedDurationNanos; + + Frame(final long timestampNanos) { + this(timestampNanos, timestampNanos, 0, 0, false, false, 0); + } + + Frame( + final long startNanos, + final long endNanos, + final long durationNanos, + final long delayNanos, + final boolean isSlow, + final boolean isFrozen, + final long expectedFrameDurationNanos) { + this.startNanos = startNanos; + this.endNanos = endNanos; + this.durationNanos = durationNanos; + this.delayNanos = delayNanos; + this.isSlow = isSlow; + this.isFrozen = isFrozen; + this.expectedDurationNanos = expectedFrameDurationNanos; + } + + @Override + public int compareTo(final @NotNull Frame o) { + return Long.compare(this.endNanos, o.endNanos); + } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java index 062084842c..27731e48cf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java @@ -170,8 +170,9 @@ public SentryFrameMetricsCollector( // Most frames take just a few nanoseconds longer than the optimal calculated // duration. // Therefore we subtract one, because otherwise almost all frames would be slow. - final boolean isSlow = cpuDuration > oneSecondInNanos / (refreshRate - 1); - final boolean isFrozen = isSlow && cpuDuration > frozenFrameThresholdNanos; + final boolean isSlow = + isSlow(cpuDuration, (long) ((float) oneSecondInNanos / (refreshRate - 1.0f))); + final boolean isFrozen = isSlow && isFrozen(cpuDuration); for (FrameMetricsCollectorListener l : listenerMap.values()) { l.onFrameMetricCollected( @@ -186,6 +187,14 @@ public SentryFrameMetricsCollector( }; } + public static boolean isFrozen(long frameDuration) { + return frameDuration > frozenFrameThresholdNanos; + } + + public static boolean isSlow(long frameDuration, final long expectedFrameDuration) { + return frameDuration > expectedFrameDuration; + } + /** * Return the internal timestamp in the choreographer of the last frame start timestamp through * reflection. On Android O the value is read from the frameMetrics itself. @@ -197,19 +206,7 @@ private long getFrameStartTimestamp(final @NotNull FrameMetrics frameMetrics) { return frameMetrics.getMetric(FrameMetrics.INTENDED_VSYNC_TIMESTAMP); } - // Let's read the choreographer private field to get start timestamp of the frame, which - // uses System.nanoTime() under the hood - if (choreographer != null && choreographerLastFrameTimeField != null) { - try { - Long choreographerFrameStartTime = - (Long) choreographerLastFrameTimeField.get(choreographer); - if (choreographerFrameStartTime != null) { - return choreographerFrameStartTime; - } - } catch (IllegalAccessException ignored) { - } - } - return -1; + return getLastKnownFrameStartTimeNanos(); } /** @@ -321,6 +318,25 @@ private void trackCurrentWindow() { } } + /** + * @return the last known time a frame was started, according to the Choreographer + */ + public long getLastKnownFrameStartTimeNanos() { + // Let's read the choreographer private field to get start timestamp of the frame, which + // uses System.nanoTime() under the hood + if (choreographer != null && choreographerLastFrameTimeField != null) { + try { + Long choreographerFrameStartTime = + (Long) choreographerLastFrameTimeField.get(choreographer); + if (choreographerFrameStartTime != null) { + return choreographerFrameStartTime; + } + } catch (IllegalAccessException ignored) { + } + } + return -1; + } + @ApiStatus.Internal public interface FrameMetricsCollectorListener { /** diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryFrameMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryFrameMetricsTest.kt index b6c2e7b2f9..1e992041b0 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryFrameMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryFrameMetricsTest.kt @@ -9,21 +9,21 @@ class SentryFrameMetricsTest { @Test fun addFastFrame() { val frameMetrics = SentryFrameMetrics() - frameMetrics.addNormalFrame(10) + frameMetrics.addFrame(10, 0, false, false) assertEquals(1, frameMetrics.normalFrameCount) - frameMetrics.addNormalFrame(10) + frameMetrics.addFrame(10, 0, false, false) assertEquals(2, frameMetrics.normalFrameCount) } @Test fun addSlowFrame() { val frameMetrics = SentryFrameMetrics() - frameMetrics.addSlowFrame(116, 100) + frameMetrics.addFrame(116, 100, true, false) assertEquals(1, frameMetrics.slowFrameCount) assertEquals(100, frameMetrics.slowFrameDelayNanos) - frameMetrics.addSlowFrame(116, 100) + frameMetrics.addFrame(116, 100, true, false) assertEquals(2, frameMetrics.slowFrameCount) assertEquals(200, frameMetrics.slowFrameDelayNanos) } @@ -31,11 +31,11 @@ class SentryFrameMetricsTest { @Test fun addFrozenFrame() { val frameMetrics = SentryFrameMetrics() - frameMetrics.addFrozenFrame(1016, 1000) + frameMetrics.addFrame(1016, 1000, true, true) assertEquals(1, frameMetrics.frozenFrameCount) assertEquals(1000, frameMetrics.frozenFrameDelayNanos) - frameMetrics.addFrozenFrame(1016, 1000) + frameMetrics.addFrame(1016, 1000, true, true) assertEquals(2, frameMetrics.frozenFrameCount) assertEquals(2000, frameMetrics.frozenFrameDelayNanos) } @@ -43,18 +43,18 @@ class SentryFrameMetricsTest { @Test fun totalFrameCount() { val frameMetrics = SentryFrameMetrics() - frameMetrics.addNormalFrame(10) - frameMetrics.addSlowFrame(116, 100) - frameMetrics.addFrozenFrame(1016, 1000) + frameMetrics.addFrame(10, 0, false, false) + frameMetrics.addFrame(116, 100, true, false) + frameMetrics.addFrame(1016, 1000, true, true) assertEquals(3, frameMetrics.totalFrameCount) } @Test fun duplicate() { val frameMetrics = SentryFrameMetrics() - frameMetrics.addNormalFrame(10) - frameMetrics.addSlowFrame(116, 100) - frameMetrics.addFrozenFrame(1016, 1000) + frameMetrics.addFrame(10, 0, false, false) + frameMetrics.addFrame(116, 100, true, false) + frameMetrics.addFrame(1016, 1000, true, true) val dup = frameMetrics.duplicate() assertEquals(1, dup.normalFrameCount) @@ -69,17 +69,17 @@ class SentryFrameMetricsTest { fun diffTo() { // given one fast, 2 slow and 3 frozen frame val frameMetricsA = SentryFrameMetrics() - frameMetricsA.addNormalFrame(10) - frameMetricsA.addSlowFrame(116, 100) - frameMetricsA.addSlowFrame(116, 100) - frameMetricsA.addFrozenFrame(1016, 1000) - frameMetricsA.addFrozenFrame(1016, 1000) - frameMetricsA.addFrozenFrame(1016, 1000) + frameMetricsA.addFrame(10, 0, false, false) + frameMetricsA.addFrame(116, 100, true, false) + frameMetricsA.addFrame(116, 100, true, false) + frameMetricsA.addFrame(1016, 1000, true, true) + frameMetricsA.addFrame(1016, 1000, true, true) + frameMetricsA.addFrame(1016, 1000, true, true) // when 1 more slow and frozen frame is happening val frameMetricsB = frameMetricsA.duplicate() - frameMetricsB.addSlowFrame(116, 100) - frameMetricsB.addFrozenFrame(1016, 1000) + frameMetricsB.addFrame(116, 100, true, false) + frameMetricsB.addFrame(1016, 1000, true, true) // then the diff only contains the new data val diff = frameMetricsB.diffTo(frameMetricsA) @@ -95,9 +95,9 @@ class SentryFrameMetricsTest { @Test fun clear() { val frameMetrics = SentryFrameMetrics().apply { - addNormalFrame(10) - addSlowFrame(116, 100) - addFrozenFrame(1016, 1000) + addFrame(10, 0, false, false) + addFrame(116, 100, true, false) + addFrame(1016, 1000, true, true) } frameMetrics.clear() @@ -117,7 +117,7 @@ class SentryFrameMetricsTest { assertTrue(frameMetrics.containsValidData()) // when a normal frame is added, it's still valid - frameMetrics.addNormalFrame(10) + frameMetrics.addFrame(10, 0, false, false) assertTrue(frameMetrics.containsValidData()) // when frame metrics are negative, it's invalid diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt index 859118eb3e..0d34a8fb07 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt @@ -5,33 +5,46 @@ import io.sentry.ITransaction import io.sentry.NoOpSpan import io.sentry.NoOpTransaction import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate import io.sentry.SpanContext import io.sentry.android.core.internal.util.SentryFrameMetricsCollector import io.sentry.protocol.MeasurementValue +import org.mockito.AdditionalMatchers import org.mockito.kotlin.any +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import java.util.Date import java.util.UUID import java.util.concurrent.TimeUnit import kotlin.test.Test +import kotlin.test.assertEquals class SpanFrameMetricsCollectorTest { private class Fixture { val options = SentryAndroidOptions() val frameMetricsCollector = mock() + var timeNanos = 0L + var lastKnownChoreographerFrameTimeNanos = 0L fun getSut(enabled: Boolean = true): SpanFrameMetricsCollector { whenever(frameMetricsCollector.startCollection(any())).thenReturn( UUID.randomUUID().toString() ) + whenever(frameMetricsCollector.getLastKnownFrameStartTimeNanos()).thenAnswer { + return@thenAnswer lastKnownChoreographerFrameTimeNanos + } options.frameMetricsCollector = frameMetricsCollector options.isEnableFramesTracking = enabled options.isEnablePerformanceV2 = enabled + options.setDateProvider { + SentryLongDate(timeNanos) + } - return SpanFrameMetricsCollector(options) + return SpanFrameMetricsCollector(options, frameMetricsCollector) } } @@ -43,14 +56,35 @@ class SpanFrameMetricsCollectorTest { val spanContext = SpanContext("op.fake") whenever(span.spanContext).thenReturn(spanContext) whenever(span.startDate).thenReturn(SentryLongDate(startTimeStampNanos)) - whenever(span.finishDate).thenReturn(if (endTimeStampNanos != null) SentryLongDate(endTimeStampNanos) else null) + whenever(span.finishDate).thenReturn( + if (endTimeStampNanos != null) { + SentryLongDate( + endTimeStampNanos + ) + } else { + null + } + ) return span } - private fun createFakeTxn(): ITransaction { + private fun createFakeTxn( + startTimeStampNanos: Long = 1000, + endTimeStampNanos: Long? = 2000 + ): ITransaction { val span = mock() val spanContext = SpanContext("op.fake") whenever(span.spanContext).thenReturn(spanContext) + whenever(span.startDate).thenReturn(SentryLongDate(startTimeStampNanos)) + whenever(span.finishDate).thenReturn( + if (endTimeStampNanos != null) { + SentryLongDate( + endTimeStampNanos + ) + } else { + null + } + ) return span } @@ -98,13 +132,15 @@ class SpanFrameMetricsCollectorTest { val sut = fixture.getSut() // when a span is started - val span = createFakeSpan() + fixture.timeNanos = 1000 + val span = createFakeSpan(1000, 2000) sut.onSpanStarted(span) // then it registers for frame metrics verify(fixture.frameMetricsCollector).startCollection(any()) // when the span is finished + fixture.timeNanos = 2000 sut.onSpanFinished(span) // then it unregisters from frame metrics @@ -139,42 +175,114 @@ class SpanFrameMetricsCollectorTest { val sut = fixture.getSut() // when the first span starts - val span0 = createFakeSpan() + fixture.timeNanos = 0 + val span0 = createFakeSpan(0, 800) sut.onSpanStarted(span0) // and one fast, two slow frames and one frozen is are recorded sut.onFrameMetricCollected(0, 10, 10, 0, false, false, 60.0f) - sut.onFrameMetricCollected(0, 20, 20, 4, true, false, 60.0f) - sut.onFrameMetricCollected(0, 20, 20, 4, true, false, 60.0f) - sut.onFrameMetricCollected(0, 800, 800, 784, true, true, 60.0f) + sut.onFrameMetricCollected(16, 48, 32, 16, true, false, 60.0f) + sut.onFrameMetricCollected(60, 92, 32, 16, true, false, 60.0f) + sut.onFrameMetricCollected(100, 800, 800, 784, true, true, 60.0f) // then a second span starts - val span1 = createFakeSpan() + fixture.timeNanos = 800 + sut.onSpanFinished(span0) + + fixture.timeNanos = 820 + val span1 = createFakeSpan(820, 840) sut.onSpanStarted(span1) // and another slow frame is recorded - sut.onFrameMetricCollected(0, 20, 20, 4, true, false, 60.0f) - - sut.onSpanFinished(span0) + fixture.timeNanos = 840 + sut.onFrameMetricCollected(820, 840, 20, 4, true, false, 60.0f) sut.onSpanFinished(span1) // then the metrics are set on the spans - verify(span0).setData("frames.slow", 3) + verify(span0).setData("frames.total", 4) + verify(span0).setData("frames.slow", 2) verify(span0).setData("frames.frozen", 1) - verify(span0).setData("frames.total", 5) + verify(span1).setData("frames.total", 1) verify(span1).setData("frames.slow", 1) verify(span1).setData("frames.frozen", 0) - verify(span1).setData("frames.total", 1) + } + + @Test + fun `slow and frozen frames are calculated even when spans overlap`() { + val sut = fixture.getSut() + + // when 4 spans are running at the same time + fixture.timeNanos = 0 + val span0 = createFakeSpan(0, 2000) + val span1 = createFakeSpan(200, 2200) + val span2 = createFakeSpan(400, 2400) + val span3 = createFakeSpan(600, 2600) + + fixture.timeNanos = 0 + sut.onSpanStarted(span0) + + fixture.timeNanos = 200 + sut.onSpanStarted(span1) + + fixture.timeNanos = 400 + sut.onSpanStarted(span2) + + fixture.timeNanos = 600 + sut.onSpanStarted(span3) + + // and one frozen frame is captured right when all spans are running + fixture.timeNanos = 620 + sut.onFrameMetricCollected(620, 1620, 1000, 984, true, true, 60.0f) + + fixture.timeNanos = 2000 + fixture.lastKnownChoreographerFrameTimeNanos = 2000 + sut.onSpanFinished(span0) + + fixture.timeNanos = 2200 + fixture.lastKnownChoreographerFrameTimeNanos = 2200 + sut.onSpanFinished(span1) + + fixture.timeNanos = 2400 + fixture.lastKnownChoreographerFrameTimeNanos = 2400 + sut.onSpanFinished(span2) + + fixture.timeNanos = 2600 + fixture.lastKnownChoreographerFrameTimeNanos = 2600 + sut.onSpanFinished(span3) + + // every span should contain the frozen frame information + verify(span0).setData("frames.frozen", 1) + verify(span1).setData("frames.frozen", 1) + verify(span2).setData("frames.frozen", 1) + verify(span3).setData("frames.frozen", 1) + } + + @Test + fun `when a span finishes which was never started no-op`() { + val sut = fixture.getSut() + + // when 4 spans are running at the same time + fixture.timeNanos = 0 + val span = createFakeSpan(0, 2000) + + sut.onFrameMetricCollected(0, 100, 1000, 984, true, true, 60.0f) + + sut.onSpanFinished(span) + verify(span, never()).setData(any(), any()) } @Test fun `measurements are not set on spans`() { val sut = fixture.getSut() - val span = createFakeSpan() + fixture.timeNanos = 900 + val span = createFakeSpan(900, 1110) + sut.onSpanStarted(span) - sut.onFrameMetricCollected(0, 10, 10, 0, false, false, 60.0f) + sut.onFrameMetricCollected(1000, 1010, 10, 0, false, false, 60.0f) + + fixture.timeNanos = 1020 sut.onSpanFinished(span) // then the metrics are set on the spans @@ -186,15 +294,18 @@ class SpanFrameMetricsCollectorTest { fun `measurements are set on transactions`() { val sut = fixture.getSut() - // when the first span starts - val txn = createFakeTxn() - sut.onSpanStarted(txn) - sut.onFrameMetricCollected(0, 10, 10, 0, false, false, 60.0f) - sut.onSpanFinished(txn) + fixture.timeNanos = 900 + val span = createFakeTxn(900, 1110) + + sut.onSpanStarted(span) + sut.onFrameMetricCollected(1000, 1010, 10, 0, false, false, 60.0f) + + fixture.timeNanos = 1020 + sut.onSpanFinished(span) // then the metrics are set on the spans - verify(txn).setData("frames.total", 1) - verify(txn).setMeasurement(MeasurementValue.KEY_FRAMES_TOTAL, 1) + verify(span).setData("frames.total", 1) + verify(span).setMeasurement(MeasurementValue.KEY_FRAMES_TOTAL, 1) } @Test @@ -202,6 +313,7 @@ class SpanFrameMetricsCollectorTest { val sut = fixture.getSut() // given a span which lasts for 1 second + fixture.timeNanos = TimeUnit.SECONDS.toNanos(1) val span = createFakeSpan( TimeUnit.SECONDS.toNanos(1), TimeUnit.SECONDS.toNanos(2) @@ -211,14 +323,46 @@ class SpanFrameMetricsCollectorTest { // but no frames are drawn // and the span finishes + // and the choreographer reports a recent update + fixture.lastKnownChoreographerFrameTimeNanos = TimeUnit.SECONDS.toNanos(2) + fixture.timeNanos = TimeUnit.SECONDS.toNanos(2) sut.onSpanFinished(span) - // then still 60 fps should be reported (1 second at 60fps) + // then still 60 frames should be reported (1 second at 60fps) verify(span).setData("frames.total", 60) verify(span).setData("frames.slow", 0) verify(span).setData("frames.frozen", 0) } + @Test + fun `when no frame data is collected the total count is interpolated and frame delay is added`() { + val sut = fixture.getSut() + + // given a span which lasts for 2 seconds + fixture.timeNanos = TimeUnit.SECONDS.toNanos(1) + val span = createFakeSpan( + TimeUnit.SECONDS.toNanos(1), + TimeUnit.SECONDS.toNanos(3) + ) + + sut.onSpanStarted(span) + // but no frames are drawn + + // and the span finishes + // but the choreographer has no update for the last second + fixture.lastKnownChoreographerFrameTimeNanos = TimeUnit.SECONDS.toNanos(2) + fixture.timeNanos = TimeUnit.SECONDS.toNanos(3) + sut.onSpanFinished(span) + + // then + // still 60 fps should be reported for 1 seconds + // and one frame with frame delay should be reported (1s - 16ms) + verify(span).setData("frames.total", 61) + verify(span).setData("frames.slow", 0) + verify(span).setData("frames.frozen", 1) + verify(span).setData(eq("frames.delay"), AdditionalMatchers.eq(0.983333334, 0.01)) + } + @Test fun `when frame data is only partially collected the total count is still interpolated`() { val sut = fixture.getSut() @@ -229,6 +373,7 @@ class SpanFrameMetricsCollectorTest { endTimeStampNanos = TimeUnit.SECONDS.toNanos(2) ) + fixture.timeNanos = TimeUnit.SECONDS.toNanos(1) sut.onSpanStarted(span) // when one frozen frame is recorded @@ -243,20 +388,22 @@ class SpanFrameMetricsCollectorTest { ) // and the span finishes + fixture.timeNanos = TimeUnit.SECONDS.toNanos(2) sut.onSpanFinished(span) // then 13 frames should be reported - // 1 frame at 800ms + 12 frames at 16ms = 992ms - verify(span).setData("frames.total", 13) + // 1 frame at 800ms + 1 frames at 16ms = 992ms + verify(span).setData("frames.total", 2) verify(span).setData("frames.slow", 0) - verify(span).setData("frames.frozen", 1) + verify(span).setData("frames.frozen", 2) } @Test fun `when frame data is only partially collected the total count is not interpolated in case the span didn't finish`() { val sut = fixture.getSut() - // given a span which lasts for 1 second + // given a span has no end date + fixture.timeNanos = TimeUnit.SECONDS.toNanos(1) val span = createFakeSpan( startTimeStampNanos = TimeUnit.SECONDS.toNanos(1), endTimeStampNanos = null @@ -275,13 +422,12 @@ class SpanFrameMetricsCollectorTest { 60.0f ) - // and the span finishes + // and the span finishes without a finish date + fixture.timeNanos = TimeUnit.MILLISECONDS.toNanos(1800) sut.onSpanFinished(span) - // then only 1 total frame should be reported, as the span has no finish date - verify(span).setData("frames.total", 1) - verify(span).setData("frames.slow", 0) - verify(span).setData("frames.frozen", 1) + // then no frame stats should be reported + verify(span, never()).setData(any(), any()) } @Test @@ -301,4 +447,13 @@ class SpanFrameMetricsCollectorTest { // and no span data should be attached verify(span0, never()).setData(any(), any()) } + + @Test + fun `SentryNanoDate diff does nano precision`() { + // having this in here, as SpanFrameMetricsCollector relies on this behavior + val a = SentryNanotimeDate(Date(1234), 567) + val b = SentryNanotimeDate(Date(1234), 0) + + assertEquals(567, a.diff(b)) + } } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index e4fd0aefd9..f8609c1c29 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -62,6 +62,9 @@ android:name=".compose.ComposeActivity" android:exported="false" /> + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/FrameDataForSpansActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/FrameDataForSpansActivity.kt new file mode 100644 index 0000000000..5b753cf98e --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/FrameDataForSpansActivity.kt @@ -0,0 +1,253 @@ +package io.sentry.samples.android + +import android.annotation.SuppressLint +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import io.sentry.ISpan +import io.sentry.ITransaction +import io.sentry.Sentry +import io.sentry.SpanDataConvention +import io.sentry.TransactionOptions + +class FrameDataForSpansActivity : ComponentActivity() { + + private val model = ViewModel() + private var txn: ITransaction? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface { + val infiniteTransition = rememberInfiniteTransition( + label = "infiniteTransition" + ) + val progress = infiniteTransition.animateFloat( + label = "progress", + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1000), + repeatMode = RepeatMode.Reverse + ) + ) + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = "Frame Data for Spans", + style = MaterialTheme.typography.headlineMedium + ) + LinearProgressIndicator( + progress = progress.value, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.size(24.dp)) + Text(text = "Tap to trigger a new frame render") + FrameControls(model) + Spacer(modifier = Modifier.size(24.dp)) + Text(text = "Span Control") + SpanControls(model) + } + } + } + } + } + + override fun onStart() { + super.onStart() + // ensure we have a top level txn to attach our spans to + Sentry.getSpan()?.finish() + val txnOpts = TransactionOptions().apply { + idleTimeout = 100000 + deadlineTimeout = 100000 + isBindToScope = true + } + txn = Sentry.startTransaction("SlowAndFrozenFramesActivity", "ui.render", txnOpts) + } + + override fun onStop() { + super.onStop() + txn?.finish() + } +} + +@Composable +fun FrameControls(viewModel: ViewModel) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + JankyButton(name = "Normal", delay = 5, viewModel.normalCount) + JankyButton(name = "Slow", delay = 500, viewModel.slowCount) + JankyButton(name = "Frozen", delay = 4000, viewModel.frozenCount) + } +} + +@Composable +fun ButtonWithoutIndication( + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: () -> Unit, + content: @Composable () -> Unit +) { + val m = if (enabled) { + modifier + .background( + MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(25) + ) + .pointerInput(Unit) { + detectTapGestures { + onClick() + } + } + .padding(horizontal = 12.dp, vertical = 8.dp) + } else { + modifier + .background( + Color.LightGray, + shape = RoundedCornerShape(25) + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + } + + Box( + modifier = m + ) { + ProvideTextStyle(value = MaterialTheme.typography.labelLarge) { + content() + } + } +} + +@Composable +fun JankyButton(name: String, delay: Long, counter: MutableIntState) { + Box( + modifier = Modifier + .background( + MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(25) + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + .pointerInput(Unit) { + detectTapGestures { + counter.intValue += 1 + Thread.sleep(delay) + } + } + ) { + Text( + color = MaterialTheme.colorScheme.onPrimary, + text = "$name: ${counter.intValue}" + ) + } +} + +@Composable +fun SpanControls(viewModel: ViewModel) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ButtonWithoutIndication( + enabled = viewModel.lastSpan.value == null, + onClick = { + viewModel.onStartSpanClicked() + } + ) { + Text("Start", color = MaterialTheme.colorScheme.onPrimary) + } + + ButtonWithoutIndication( + enabled = viewModel.lastSpan.value != null, + onClick = { + viewModel.onStopSpanClicked() + } + ) { + Text("Stop", color = MaterialTheme.colorScheme.onPrimary) + } + + ButtonWithoutIndication( + enabled = viewModel.lastSpan.value != null, + onClick = { + viewModel.onStopDelayedSpanClicked() + } + ) { + Text("Stop in 3s", color = MaterialTheme.colorScheme.onPrimary) + } + } + + viewModel.lastSpanSummary.value?.let { + Spacer(modifier = Modifier.size(12.dp)) + Text(text = "Last Span Summary", style = MaterialTheme.typography.headlineSmall) + Text(it) + } + } +} + +class ViewModel { + val lastSpan = mutableStateOf(null) + val lastSpanSummary = mutableStateOf(null) + val normalCount = mutableIntStateOf(0) + val slowCount = mutableIntStateOf(0) + val frozenCount = mutableIntStateOf(0) + + fun onStartSpanClicked() { + normalCount.intValue = 0 + slowCount.intValue = 0 + frozenCount.intValue = 0 + + lastSpan.value = Sentry.getSpan()?.startChild("op.span") + lastSpanSummary.value = null + } + + fun onStopDelayedSpanClicked() { + Thread { + Thread.sleep(3000) + onStopSpanClicked() + }.start() + } + + @SuppressLint("PrivateApi") + fun onStopSpanClicked() { + lastSpan.value?.finish() + + lastSpanSummary.value = lastSpan.value?.let { span -> + "${SpanDataConvention.FRAMES_SLOW}: ${span.getData(SpanDataConvention.FRAMES_SLOW)}\n" + + "${SpanDataConvention.FRAMES_FROZEN}: ${span.getData(SpanDataConvention.FRAMES_FROZEN)}\n" + + "${SpanDataConvention.FRAMES_TOTAL}: ${span.getData(SpanDataConvention.FRAMES_TOTAL)}\n" + + "${SpanDataConvention.FRAMES_DELAY}: ${span.getData(SpanDataConvention.FRAMES_DELAY)}\n" + } + lastSpan.value = null + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 5a467957f3..93d859449c 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -251,6 +251,9 @@ public void run() { startActivity(new Intent(this, ProfilingActivity.class)); }); + binding.openFrameDataForSpans.setOnClickListener( + view -> startActivity(new Intent(this, FrameDataForSpansActivity.class))); + setContentView(binding.getRoot()); } diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index 27b333255d..49b8de3f33 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -135,6 +135,12 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/open_profiling_activity"/> + +