Skip to content
Draft
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### 🌟 New instrumentation

- The activity instrumentation now measures Time To Initial Display (TTID) for the app's cold
start, emitted as an `AppStartDisplay` span parented to `AppStart`.

## Version 1.5.0 (2026-06-23)

### 📣 Migration notes
Expand Down
10 changes: 10 additions & 0 deletions instrumentation/activity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ This instrumentation produces the following telemetry:
* Attributes:
* `start.type`: { `cold` | `hot` | `warm` }

### App Start Display (TTID)

* Type: Span
* Name: `AppStartDisplay`
* Description: Measures Time To Initial Display (TTID) for the initial (cold start) activity only.
This span is a child of the `AppStart` span, sharing the same start timestamp. It ends when the
first frame is drawn after the initial activity resumes, detected via a double
`Choreographer.postFrameCallback`. If the activity is destroyed before the first frame is
observed, the span is never emitted.

### Activity state change

* Type: Span
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.google.auto.service.AutoService
import io.opentelemetry.android.OpenTelemetryRum
import io.opentelemetry.android.instrumentation.AndroidInstrumentation
import io.opentelemetry.android.instrumentation.activity.startup.AppStartupTimer
import io.opentelemetry.android.instrumentation.activity.startup.TtidTimer
import io.opentelemetry.android.instrumentation.common.Constants.INSTRUMENTATION_SCOPE
import io.opentelemetry.android.instrumentation.common.DefaultScreenNameExtractor
import io.opentelemetry.android.instrumentation.common.ScreenNameExtractor
Expand All @@ -27,6 +28,7 @@ class ActivityLifecycleInstrumentation : AndroidInstrumentation {
private var tracerCustomizer: (Tracer) -> Tracer = { it }
private var startupLifecycle: Application.ActivityLifecycleCallbacks? = null
private var activityLifecycle: Application.ActivityLifecycleCallbacks? = null
private var ttidTimer: TtidTimer? = null

override val name: String = "activity"

Expand Down Expand Up @@ -62,17 +64,21 @@ class ActivityLifecycleInstrumentation : AndroidInstrumentation {
it.unregisterActivityLifecycleCallbacks(activityLifecycle)
activityLifecycle = null
}
ttidTimer?.cancel()
ttidTimer = null
}
}

private fun buildActivityLifecycleTracer(context: Context, openTelemetry: OpenTelemetry): DefaultingActivityLifecycleCallbacks {
val visibleScreenService = Services.get(context).visibleScreenTracker
val delegateTracer: Tracer = openTelemetry.getTracer(INSTRUMENTATION_SCOPE)
val ttid = TtidTimer(delegateTracer).also { ttidTimer = it }
val tracers =
ActivityTracerCache(
tracerCustomizer.invoke(delegateTracer),
visibleScreenService,
startupTimer,
ttid,
screenNameExtractor,
)
return if (Build.VERSION.SDK_INT < 29) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package io.opentelemetry.android.instrumentation.activity
import android.app.Activity
import io.opentelemetry.android.common.internal.SemconvCompat.Companion.map
import io.opentelemetry.android.instrumentation.activity.startup.AppStartupTimer
import io.opentelemetry.android.instrumentation.activity.startup.TtidTimer
import io.opentelemetry.android.instrumentation.common.ActiveSpan
import io.opentelemetry.api.common.AttributeKey
import io.opentelemetry.api.trace.Span
Expand All @@ -21,6 +22,7 @@ internal class ActivityTracer(
private val activeSpan: ActiveSpan,
private val tracer: Tracer,
private val appStartupTimer: AppStartupTimer,
private val ttidTimer: TtidTimer,
screenName: String? = null,
private var initialAppActivity: String? = null,
) {
Expand Down Expand Up @@ -100,6 +102,9 @@ internal class ActivityTracer(
fun endSpanForActivityResumed() {
if (initialAppActivity == null) {
initialAppActivity = activityName
appStartupTimer.startupSpan?.let { startupSpan ->
ttidTimer.start(startupSpan, appStartupTimer.startTimestampNanos, appStartupTimer.anchoredClock)
}
}
endActiveSpan()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package io.opentelemetry.android.instrumentation.activity
import android.app.Activity
import androidx.annotation.VisibleForTesting
import io.opentelemetry.android.instrumentation.activity.startup.AppStartupTimer
import io.opentelemetry.android.instrumentation.activity.startup.TtidTimer
import io.opentelemetry.android.instrumentation.common.ActiveSpan
import io.opentelemetry.android.instrumentation.common.ScreenNameExtractor
import io.opentelemetry.android.internal.services.visiblescreen.VisibleScreenTracker
Expand All @@ -28,13 +29,15 @@ internal class ActivityTracerCache
tracer: Tracer,
visibleScreenTracker: VisibleScreenTracker,
startupTimer: AppStartupTimer,
ttidTimer: TtidTimer,
screenNameExtractor: ScreenNameExtractor,
) : this({ activity: Activity ->
ActivityTracer(
activity = activity,
activeSpan = ActiveSpan(visibleScreenTracker::previouslyVisibleScreen),
tracer = tracer,
appStartupTimer = startupTimer,
ttidTimer = ttidTimer,
screenName = screenNameExtractor.extract(activity),
)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,14 @@ internal class AnchoredClock(
val deltaNanos = clock.nanoTime() - nanoTime
return epochNanos + deltaNanos
}

/**
* Converts a timestamp taken from the same [System.nanoTime] timebase as this clock's anchor
* (for example, a [android.view.Choreographer.FrameCallback] frameTimeNanos value) into an
* epoch-nanos timestamp comparable to [now].
*/
fun toEpochNanos(nanoTimeValue: Long): Long {
val deltaNanos = nanoTimeValue - nanoTime
return epochNanos + deltaNanos
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ internal class AppStartupTimer {
var startupSpan: Span? = null
private set

/** The [AnchoredClock] used to time the startup span, valid once [start] has been called. */
internal val anchoredClock: AnchoredClock
get() = startupClock

/** The epoch-nanos timestamp the startup span (and any span sharing its anchor) started at. */
internal val startTimestampNanos: Long
get() = firstPossibleTimestamp

// whether activity has been created
// accessed only from UI thread
private var uiInitStarted = false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.android.instrumentation.activity.startup

import android.view.Choreographer
import io.opentelemetry.api.trace.Span
import io.opentelemetry.api.trace.Tracer
import io.opentelemetry.context.Context
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean

/**
* Measures Time To Initial Display (TTID): the time from app startup until the first frame is
* drawn after the initial activity resumes. Produced as a child span of the "AppStart" span so it
* shares the same start timestamp but reflects when content actually became visible rather than
* when [android.app.Activity.onResume] returned.
*
* A frame is confirmed drawn using the standard double [Choreographer.postFrameCallback] trick: a
* callback posted immediately after resume can still fire for a frame that was already in flight,
* so the first callback does nothing but schedule a second one, whose frameTimeNanos is used as
* the "first frame drawn" timestamp.
*/
internal class TtidTimer(
private val tracer: Tracer,
private val choreographerProvider: () -> Choreographer = { Choreographer.getInstance() },
) {
private val started = AtomicBoolean(false)

@Volatile
private var pendingCallback: Choreographer.FrameCallback? = null

/** Starts timing TTID, parented to [parentSpan] and anchored to [startTimestampNanos]. */
fun start(
parentSpan: Span,
startTimestampNanos: Long,
anchoredClock: AnchoredClock,
) {
if (!started.compareAndSet(false, true)) {
return
}
val span =
tracer
.spanBuilder(SPAN_NAME)
.setParent(parentSpan.storeInContext(Context.current()))
.setStartTimestamp(startTimestampNanos, TimeUnit.NANOSECONDS)
.startSpan()
postDoubleFrameCallback { frameTimeNanos ->
pendingCallback = null
span.end(anchoredClock.toEpochNanos(frameTimeNanos), TimeUnit.NANOSECONDS)
}
}

/** Cancels a pending frame callback if the first frame was never observed (e.g. on uninstall). */
fun cancel() {
val choreographer = choreographerProvider()
pendingCallback?.let { choreographer.removeFrameCallback(it) }
pendingCallback = null
}

private fun postDoubleFrameCallback(onSecondFrame: (Long) -> Unit) {
val choreographer = choreographerProvider()
val secondCallback =
Choreographer.FrameCallback { frameTimeNanos -> onSecondFrame(frameTimeNanos) }
val firstCallback =
Choreographer.FrameCallback {
pendingCallback = secondCallback
choreographer.postFrameCallback(secondCallback)
}
pendingCallback = firstCallback
choreographer.postFrameCallback(firstCallback)
}

internal companion object {
const val SPAN_NAME: String = "AppStartDisplay"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import io.mockk.mockk
import io.opentelemetry.android.common.RumConstants
import io.opentelemetry.android.instrumentation.activity.ActivityTracer.Companion.START_TYPE_KEY
import io.opentelemetry.android.instrumentation.activity.startup.AppStartupTimer
import io.opentelemetry.android.instrumentation.activity.startup.TtidTimer
import io.opentelemetry.android.instrumentation.common.ScreenNameExtractor
import io.opentelemetry.android.internal.services.visiblescreen.VisibleScreenTracker
import io.opentelemetry.api.common.AttributeKey.stringKey
Expand Down Expand Up @@ -39,11 +40,12 @@ internal class ActivityCallbacksTest {
fun setup() {
val tracer = otelTesting.openTelemetry.getTracer("testTracer")
val startupTimer = AppStartupTimer()
val ttidTimer = TtidTimer(tracer)
visibleScreenTracker = mockk<VisibleScreenTracker>(relaxed = true)
every { visibleScreenTracker.previouslyVisibleScreen } returns null
val extractor = mockk<ScreenNameExtractor>(relaxed = true)
every { extractor.extract(any<Activity>()) } returns "Activity"
tracers = ActivityTracerCache(tracer, visibleScreenTracker, startupTimer, extractor)
tracers = ActivityTracerCache(tracer, visibleScreenTracker, startupTimer, ttidTimer, extractor)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import io.mockk.mockk
import io.opentelemetry.android.common.RumConstants
import io.opentelemetry.android.instrumentation.activity.ActivityTracer.Companion.START_TYPE_KEY
import io.opentelemetry.android.instrumentation.activity.startup.AppStartupTimer
import io.opentelemetry.android.instrumentation.activity.startup.TtidTimer
import io.opentelemetry.android.instrumentation.common.ActiveSpan
import io.opentelemetry.android.internal.services.visiblescreen.VisibleScreenTracker
import io.opentelemetry.api.common.AttributeKey.stringKey
Expand Down Expand Up @@ -42,11 +43,13 @@ class ActivityTracerTest {
@RelaxedMockK
private lateinit var visibleScreenTracker: VisibleScreenTracker
private val appStartupTimer = AppStartupTimer()
private lateinit var ttidTimer: TtidTimer
private lateinit var activeSpan: ActiveSpan

@BeforeEach
fun setup() {
tracer = otelTesting.openTelemetry.getTracer("testTracer")
ttidTimer = TtidTimer(tracer)
activeSpan = ActiveSpan(visibleScreenTracker::previouslyVisibleScreen)
every { visibleScreenTracker.previouslyVisibleScreen } returns null
}
Expand All @@ -59,6 +62,7 @@ class ActivityTracerTest {
activeSpan = activeSpan,
tracer = tracer,
appStartupTimer = appStartupTimer,
ttidTimer = ttidTimer,
initialAppActivity = "FirstActivity",
)
trackableTracer.initiateRestartSpanIfNecessary(false)
Expand All @@ -76,6 +80,7 @@ class ActivityTracerTest {
activeSpan = activeSpan,
tracer = tracer,
appStartupTimer = appStartupTimer,
ttidTimer = ttidTimer,
initialAppActivity = "Activity",
)
trackableTracer.initiateRestartSpanIfNecessary(false)
Expand All @@ -93,6 +98,7 @@ class ActivityTracerTest {
activeSpan = activeSpan,
tracer = tracer,
appStartupTimer = appStartupTimer,
ttidTimer = ttidTimer,
initialAppActivity = "Activity",
)
trackableTracer.initiateRestartSpanIfNecessary(true)
Expand All @@ -110,6 +116,7 @@ class ActivityTracerTest {
activeSpan = activeSpan,
tracer = tracer,
appStartupTimer = appStartupTimer,
ttidTimer = ttidTimer,
initialAppActivity = "FirstActivity",
)

Expand All @@ -128,6 +135,7 @@ class ActivityTracerTest {
activeSpan = activeSpan,
tracer = tracer,
appStartupTimer = appStartupTimer,
ttidTimer = ttidTimer,
initialAppActivity = "Activity",
)
trackableTracer.startActivityCreation()
Expand All @@ -146,6 +154,7 @@ class ActivityTracerTest {
activeSpan = activeSpan,
tracer = tracer,
appStartupTimer = appStartupTimer,
ttidTimer = ttidTimer,
)
trackableTracer.startActivityCreation()
trackableTracer.endActiveSpan()
Expand All @@ -170,6 +179,7 @@ class ActivityTracerTest {
activeSpan = activeSpan,
tracer = tracer,
appStartupTimer = appStartupTimer,
ttidTimer = ttidTimer,
)

trackableTracer.startSpanIfNoneInProgress("starting")
Expand All @@ -191,6 +201,7 @@ class ActivityTracerTest {
activeSpan = activeSpan,
tracer = tracer,
appStartupTimer = appStartupTimer,
ttidTimer = ttidTimer,
)

trackableTracer.startSpanIfNoneInProgress("starting")
Expand All @@ -211,6 +222,7 @@ class ActivityTracerTest {
activeSpan = activeSpan,
tracer = tracer,
appStartupTimer = appStartupTimer,
ttidTimer = ttidTimer,
)

trackableTracer.startSpanIfNoneInProgress("starting")
Expand All @@ -232,6 +244,7 @@ class ActivityTracerTest {
activeSpan = activeSpan,
tracer = tracer,
appStartupTimer = appStartupTimer,
ttidTimer = ttidTimer,
screenName = "squarely",
)
activityTracer.startActivityCreation()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import io.mockk.mockk
import io.opentelemetry.android.common.RumConstants
import io.opentelemetry.android.instrumentation.activity.ActivityTracer.Companion.START_TYPE_KEY
import io.opentelemetry.android.instrumentation.activity.startup.AppStartupTimer
import io.opentelemetry.android.instrumentation.activity.startup.TtidTimer
import io.opentelemetry.android.instrumentation.common.ScreenNameExtractor
import io.opentelemetry.android.internal.services.visiblescreen.VisibleScreenTracker
import io.opentelemetry.api.common.AttributeKey.stringKey
Expand Down Expand Up @@ -45,9 +46,10 @@ internal class Pre29ActivityLifecycleCallbacksTest {
fun setup() {
val appStartupTimer = AppStartupTimer()
val tracer = otelTesting.openTelemetry.getTracer("testTracer")
val ttidTimer = TtidTimer(tracer)
val extractor = mockk<ScreenNameExtractor>(relaxed = true)
every { extractor.extract(any<Activity>()) } returns "Activity"
tracers = ActivityTracerCache(tracer, visibleScreenTracker, appStartupTimer, extractor)
tracers = ActivityTracerCache(tracer, visibleScreenTracker, appStartupTimer, ttidTimer, extractor)
every { visibleScreenTracker.previouslyVisibleScreen } returns null
}

Expand Down
Loading
Loading