Skip to content

Commit b5b855d

Browse files
authored
Add ttid span to ActivityLifecycleIntegration (#2369)
* added time-to-initial-display span to ActivityLifecycleIntegration * added FirstDrawDoneListener * added reference to Firebase sdk
1 parent d00c464 commit b5b855d

File tree

9 files changed

+392
-23
lines changed

9 files changed

+392
-23
lines changed

CHANGELOG.md

+5-4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Features
66

7+
- Add time-to-initial-display span to Activity transactions ([#2369](https://github.com/getsentry/sentry-java/pull/2369))
78
- Start a session after init if AutoSessionTracking is enabled ([#2356](https://github.com/getsentry/sentry-java/pull/2356))
89

910
### Dependencies
@@ -59,15 +60,15 @@
5960

6061
## 6.8.0
6162

63+
### Features
64+
65+
- Add FrameMetrics to Android profiling data ([#2342](https://github.com/getsentry/sentry-java/pull/2342))
66+
6267
### Fixes
6368

6469
- Remove profiler main thread io ([#2348](https://github.com/getsentry/sentry-java/pull/2348))
6570
- Fix ensure all options are processed before integrations are loaded ([#2377](https://github.com/getsentry/sentry-java/pull/2377))
6671

67-
### Features
68-
69-
- Add FrameMetrics to Android profiling data ([#2342](https://github.com/getsentry/sentry-java/pull/2342))
70-
7172
## 6.7.1
7273

7374
### Fixes

sentry-android-core/api/sentry-android-core.api

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public final class io/sentry/android/core/ActivityLifecycleIntegration : android
1515
public fun onActivityDestroyed (Landroid/app/Activity;)V
1616
public fun onActivityPaused (Landroid/app/Activity;)V
1717
public fun onActivityPostResumed (Landroid/app/Activity;)V
18+
public fun onActivityPrePaused (Landroid/app/Activity;)V
1819
public fun onActivityResumed (Landroid/app/Activity;)V
1920
public fun onActivitySaveInstanceState (Landroid/app/Activity;Landroid/os/Bundle;)V
2021
public fun onActivityStarted (Landroid/app/Activity;)V

sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java

+86-7
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@
33
import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND;
44
import static io.sentry.TypeCheckHint.ANDROID_ACTIVITY;
55

6+
import android.annotation.SuppressLint;
67
import android.app.Activity;
78
import android.app.ActivityManager;
89
import android.app.Application;
910
import android.content.Context;
1011
import android.os.Build;
1112
import android.os.Bundle;
13+
import android.os.Handler;
14+
import android.os.Looper;
1215
import android.os.Process;
16+
import android.view.View;
17+
import androidx.annotation.NonNull;
1318
import io.sentry.Breadcrumb;
19+
import io.sentry.DateUtils;
1420
import io.sentry.Hint;
1521
import io.sentry.IHub;
1622
import io.sentry.ISpan;
@@ -23,6 +29,7 @@
2329
import io.sentry.SpanStatus;
2430
import io.sentry.TransactionContext;
2531
import io.sentry.TransactionOptions;
32+
import io.sentry.android.core.internal.util.FirstDrawDoneListener;
2633
import io.sentry.protocol.TransactionNameSource;
2734
import io.sentry.util.Objects;
2835
import java.io.Closeable;
@@ -43,8 +50,10 @@ public final class ActivityLifecycleIntegration
4350
static final String UI_LOAD_OP = "ui.load";
4451
static final String APP_START_WARM = "app.start.warm";
4552
static final String APP_START_COLD = "app.start.cold";
53+
static final String TTID_OP = "ui.load.initial_display";
4654

4755
private final @NotNull Application application;
56+
private final @NotNull BuildInfoProvider buildInfoProvider;
4857
private @Nullable IHub hub;
4958
private @Nullable SentryAndroidOptions options;
5059

@@ -57,6 +66,9 @@ public final class ActivityLifecycleIntegration
5766
private boolean foregroundImportance = false;
5867

5968
private @Nullable ISpan appStartSpan;
69+
private final @NotNull WeakHashMap<Activity, ISpan> ttidSpanMap = new WeakHashMap<>();
70+
private @NotNull Date lastPausedTime = DateUtils.getCurrentDateTime();
71+
private final @NotNull Handler mainHandler = new Handler(Looper.getMainLooper());
6072

6173
// WeakHashMap isn't thread safe but ActivityLifecycleCallbacks is only called from the
6274
// main-thread
@@ -70,7 +82,8 @@ public ActivityLifecycleIntegration(
7082
final @NotNull BuildInfoProvider buildInfoProvider,
7183
final @NotNull ActivityFramesTracker activityFramesTracker) {
7284
this.application = Objects.requireNonNull(application, "Application is required");
73-
Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required");
85+
this.buildInfoProvider =
86+
Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required");
7487
this.activityFramesTracker =
7588
Objects.requireNonNull(activityFramesTracker, "ActivityFramesTracker is required");
7689

@@ -146,7 +159,8 @@ private void stopPreviousTransactions() {
146159
for (final Map.Entry<Activity, ITransaction> entry :
147160
activitiesWithOngoingTransactions.entrySet()) {
148161
final ITransaction transaction = entry.getValue();
149-
finishTransaction(transaction);
162+
final ISpan ttidSpan = ttidSpanMap.get(entry.getKey());
163+
finishTransaction(transaction, ttidSpan);
150164
}
151165
}
152166

@@ -202,6 +216,18 @@ private void startTracing(final @NotNull Activity activity) {
202216
getAppStartDesc(coldStart),
203217
appStartTime,
204218
Instrumenter.SENTRY);
219+
// The first activity ttidSpan should start at the same time as the app start time
220+
ttidSpanMap.put(
221+
activity,
222+
transaction.startChild(
223+
TTID_OP, getTtidDesc(activityName), appStartTime, Instrumenter.SENTRY));
224+
} else {
225+
// Other activities (or in case appStartTime is not available) the ttid span should
226+
// start when the previous activity called its onPause method.
227+
ttidSpanMap.put(
228+
activity,
229+
transaction.startChild(
230+
TTID_OP, getTtidDesc(activityName), lastPausedTime, Instrumenter.SENTRY));
205231
}
206232

207233
// lets bind to the scope so other integrations can pick it up
@@ -250,18 +276,22 @@ private boolean isRunningTransaction(final @NotNull Activity activity) {
250276
private void stopTracing(final @NotNull Activity activity, final boolean shouldFinishTracing) {
251277
if (performanceEnabled && shouldFinishTracing) {
252278
final ITransaction transaction = activitiesWithOngoingTransactions.get(activity);
253-
finishTransaction(transaction);
279+
finishTransaction(transaction, null);
254280
}
255281
}
256282

257-
private void finishTransaction(final @Nullable ITransaction transaction) {
283+
private void finishTransaction(
284+
final @Nullable ITransaction transaction, final @Nullable ISpan ttidSpan) {
258285
if (transaction != null) {
259286
// if io.sentry.traces.activity.auto-finish.enable is disabled, transaction may be already
260287
// finished manually when this method is called.
261288
if (transaction.isFinished()) {
262289
return;
263290
}
264291

292+
// in case the ttidSpan isn't completed yet, we finish it as cancelled to avoid memory leak
293+
finishSpan(ttidSpan, SpanStatus.CANCELLED);
294+
265295
SpanStatus status = transaction.getStatus();
266296
// status might be set by other integrations, let's not overwrite it
267297
if (status == null) {
@@ -301,6 +331,7 @@ public synchronized void onActivityStarted(final @NotNull Activity activity) {
301331
addBreadcrumb(activity, "started");
302332
}
303333

334+
@SuppressLint("NewApi")
304335
@Override
305336
public synchronized void onActivityResumed(final @NotNull Activity activity) {
306337
if (!firstActivityResumed) {
@@ -326,6 +357,17 @@ public synchronized void onActivityResumed(final @NotNull Activity activity) {
326357
firstActivityResumed = true;
327358
}
328359

360+
final ISpan ttidSpan = ttidSpanMap.get(activity);
361+
final View rootView = activity.findViewById(android.R.id.content);
362+
if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN
363+
&& rootView != null) {
364+
FirstDrawDoneListener.registerForNextDraw(
365+
rootView, () -> finishSpan(ttidSpan), buildInfoProvider);
366+
} else {
367+
// Posting a task to the main thread's handler will make it executed after it finished
368+
// its current job. That is, right after the activity draws the layout.
369+
mainHandler.post(() -> finishSpan(ttidSpan));
370+
}
329371
addBreadcrumb(activity, "resumed");
330372

331373
// fallback call for API < 29 compatibility, otherwise it happens on onActivityPostResumed
@@ -344,8 +386,20 @@ public synchronized void onActivityPostResumed(final @NotNull Activity activity)
344386
}
345387
}
346388

389+
@Override
390+
public void onActivityPrePaused(@NonNull Activity activity) {
391+
// only executed if API >= 29 otherwise it happens on onActivityPaused
392+
if (isAllActivityCallbacksAvailable) {
393+
lastPausedTime = DateUtils.getCurrentDateTime();
394+
}
395+
}
396+
347397
@Override
348398
public synchronized void onActivityPaused(final @NotNull Activity activity) {
399+
// only executed if API < 29 otherwise it happens on onActivityPrePaused
400+
if (!isAllActivityCallbacksAvailable) {
401+
lastPausedTime = DateUtils.getCurrentDateTime();
402+
}
349403
addBreadcrumb(activity, "paused");
350404
}
351405

@@ -366,16 +420,19 @@ public synchronized void onActivityDestroyed(final @NotNull Activity activity) {
366420

367421
// in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid
368422
// memory leak
369-
if (appStartSpan != null && !appStartSpan.isFinished()) {
370-
appStartSpan.finish(SpanStatus.CANCELLED);
371-
}
423+
finishSpan(appStartSpan, SpanStatus.CANCELLED);
424+
425+
// we finish the ttidSpan as cancelled in case it isn't completed yet
426+
final ISpan ttidSpan = ttidSpanMap.get(activity);
427+
finishSpan(ttidSpan, SpanStatus.CANCELLED);
372428

373429
// in case people opt-out enableActivityLifecycleTracingAutoFinish and forgot to finish it,
374430
// we make sure to finish it when the activity gets destroyed.
375431
stopTracing(activity, true);
376432

377433
// set it to null in case its been just finished as cancelled
378434
appStartSpan = null;
435+
ttidSpanMap.remove(activity);
379436

380437
// clear it up, so we don't start again for the same activity if the activity is in the activity
381438
// stack still.
@@ -385,6 +442,18 @@ public synchronized void onActivityDestroyed(final @NotNull Activity activity) {
385442
}
386443
}
387444

445+
private void finishSpan(@Nullable ISpan span) {
446+
if (span != null && !span.isFinished()) {
447+
span.finish();
448+
}
449+
}
450+
451+
private void finishSpan(@Nullable ISpan span, @NotNull SpanStatus status) {
452+
if (span != null && !span.isFinished()) {
453+
span.finish(status);
454+
}
455+
}
456+
388457
@TestOnly
389458
@NotNull
390459
WeakHashMap<Activity, ITransaction> getActivitiesWithOngoingTransactions() {
@@ -403,6 +472,12 @@ ISpan getAppStartSpan() {
403472
return appStartSpan;
404473
}
405474

475+
@TestOnly
476+
@NotNull
477+
WeakHashMap<Activity, ISpan> getTtidSpanMap() {
478+
return ttidSpanMap;
479+
}
480+
406481
private void setColdStart(final @Nullable Bundle savedInstanceState) {
407482
if (!firstActivityCreated) {
408483
// if Activity has savedInstanceState then its a warm start
@@ -411,6 +486,10 @@ private void setColdStart(final @Nullable Bundle savedInstanceState) {
411486
}
412487
}
413488

489+
private @NotNull String getTtidDesc(final @NotNull String activityName) {
490+
return activityName + " initial display";
491+
}
492+
414493
private @NotNull String getAppStartDesc(final boolean coldStart) {
415494
if (coldStart) {
416495
return "Cold Start";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package io.sentry.android.core.internal.util;
2+
3+
import android.annotation.SuppressLint;
4+
import android.os.Build;
5+
import android.os.Handler;
6+
import android.os.Looper;
7+
import android.view.View;
8+
import android.view.ViewTreeObserver;
9+
import androidx.annotation.RequiresApi;
10+
import io.sentry.android.core.BuildInfoProvider;
11+
import java.util.concurrent.atomic.AtomicReference;
12+
import org.jetbrains.annotations.NotNull;
13+
14+
/**
15+
* OnDrawListener that unregisters itself and invokes callback when the next draw is done. This API
16+
* 16+ implementation is an approximation of the initial-display-time defined by Android Vitals.
17+
*
18+
* <p>Adapted from <a
19+
* href="https://github.com/firebase/firebase-android-sdk/blob/master/firebase-perf/src/main/java/com/google/firebase/perf/util/FirstDrawDoneListener.java">Firebase</a>
20+
* under the Apache License, Version 2.0.
21+
*/
22+
@SuppressLint("ObsoleteSdkInt")
23+
@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
24+
public class FirstDrawDoneListener implements ViewTreeObserver.OnDrawListener {
25+
private final @NotNull Handler mainThreadHandler = new Handler(Looper.getMainLooper());
26+
private final @NotNull AtomicReference<View> viewReference;
27+
private final @NotNull Runnable callback;
28+
29+
/** Registers a post-draw callback for the next draw of a view. */
30+
public static void registerForNextDraw(
31+
final @NotNull View view,
32+
final @NotNull Runnable drawDoneCallback,
33+
final @NotNull BuildInfoProvider buildInfoProvider) {
34+
final FirstDrawDoneListener listener = new FirstDrawDoneListener(view, drawDoneCallback);
35+
// Handle bug prior to API 26 where OnDrawListener from the floating ViewTreeObserver is not
36+
// merged into the real ViewTreeObserver.
37+
// https://android.googlesource.com/platform/frameworks/base/+/9f8ec54244a5e0343b9748db3329733f259604f3
38+
if (buildInfoProvider.getSdkInfoVersion() < 26
39+
&& !isAliveAndAttached(view, buildInfoProvider)) {
40+
view.addOnAttachStateChangeListener(
41+
new View.OnAttachStateChangeListener() {
42+
@Override
43+
public void onViewAttachedToWindow(View view) {
44+
view.getViewTreeObserver().addOnDrawListener(listener);
45+
view.removeOnAttachStateChangeListener(this);
46+
}
47+
48+
@Override
49+
public void onViewDetachedFromWindow(View view) {
50+
view.removeOnAttachStateChangeListener(this);
51+
}
52+
});
53+
} else {
54+
view.getViewTreeObserver().addOnDrawListener(listener);
55+
}
56+
}
57+
58+
private FirstDrawDoneListener(final @NotNull View view, final @NotNull Runnable callback) {
59+
this.viewReference = new AtomicReference<>(view);
60+
this.callback = callback;
61+
}
62+
63+
@Override
64+
public void onDraw() {
65+
// Set viewReference to null so any onDraw past the first is a no-op
66+
final View view = viewReference.getAndSet(null);
67+
if (view == null) {
68+
return;
69+
}
70+
// OnDrawListeners cannot be removed within onDraw, so we remove it with a
71+
// GlobalLayoutListener
72+
view.getViewTreeObserver()
73+
.addOnGlobalLayoutListener(() -> view.getViewTreeObserver().removeOnDrawListener(this));
74+
mainThreadHandler.postAtFrontOfQueue(callback);
75+
}
76+
77+
/**
78+
* Helper to avoid <a
79+
* href="https://android.googlesource.com/platform/frameworks/base/+/9f8ec54244a5e0343b9748db3329733f259604f3">bug
80+
* prior to API 26</a>, where the floating ViewTreeObserver's OnDrawListeners are not merged into
81+
* the real ViewTreeObserver during attach.
82+
*
83+
* @return true if the View is already attached and the ViewTreeObserver is not a floating
84+
* placeholder.
85+
*/
86+
private static boolean isAliveAndAttached(
87+
final @NotNull View view, final @NotNull BuildInfoProvider buildInfoProvider) {
88+
return view.getViewTreeObserver().isAlive() && isAttachedToWindow(view, buildInfoProvider);
89+
}
90+
91+
@SuppressLint("NewApi")
92+
private static boolean isAttachedToWindow(
93+
final @NotNull View view, final @NotNull BuildInfoProvider buildInfoProvider) {
94+
if (buildInfoProvider.getSdkInfoVersion() >= 19) {
95+
return view.isAttachedToWindow();
96+
}
97+
return view.getWindowToken() != null;
98+
}
99+
}

0 commit comments

Comments
 (0)