Skip to content

Commit c553c5f

Browse files
committed
feat: Add updateExternalRefreshRate to internal API
refs: RUM-8875
1 parent 208da2c commit c553c5f

File tree

8 files changed

+323
-1
lines changed

8 files changed

+323
-1
lines changed

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/_RumInternalProxy.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ class _RumInternalProxy internal constructor(private val rumMonitor: AdvancedRum
4545
rumMonitor.updatePerformanceMetric(metric, value)
4646
}
4747

48+
fun updateExternalRefreshRate(frameTimeNanos: Long) {
49+
rumMonitor.updateExternalRefreshRate(frameTimeNanos)
50+
}
51+
4852
fun setInternalViewAttribute(key: String, value: Any?) {
4953
rumMonitor.setInternalViewAttribute(key, value)
5054
}

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumRawEvent.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,11 @@ internal sealed class RumRawEvent {
217217
override val eventTime: Time = Time()
218218
) : RumRawEvent()
219219

220+
internal data class UpdateExternalRefreshRate(
221+
val frameTimeNanos: Long,
222+
override val eventTime: Time = Time()
223+
) : RumRawEvent()
224+
220225
internal data class SetInternalViewAttribute(
221226
val key: String,
222227
val value: Any?,

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScope.kt

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ internal open class RumViewScope(
146146

147147
private val performanceMetrics: MutableMap<RumPerformanceMetric, VitalInfo> = mutableMapOf()
148148

149+
private var externalRefreshRateInfo: VitalInfo? = null
150+
149151
// endregion
150152

151153
init {
@@ -205,6 +207,7 @@ internal open class RumViewScope(
205207
is RumRawEvent.StopSession -> onStopSession(event, writer)
206208

207209
is RumRawEvent.UpdatePerformanceMetric -> onUpdatePerformanceMetric(event)
210+
is RumRawEvent.UpdateExternalRefreshRate -> onUpdateExternalRefreshRate(event)
208211
is RumRawEvent.AddViewLoadingTime -> onAddViewLoadingTime(event, writer)
209212

210213
else -> delegateEventToChildren(event, writer)
@@ -635,6 +638,31 @@ internal open class RumViewScope(
635638
)
636639
}
637640

641+
private fun onUpdateExternalRefreshRate(
642+
event: RumRawEvent.UpdateExternalRefreshRate
643+
) {
644+
if (stopped) return
645+
646+
// Convert frame time (nanoseconds) to refresh rate (Hz)
647+
val refreshRateHz = if (event.frameTimeNanos > 0) {
648+
1_000_000_000.0 / event.frameTimeNanos.toDouble()
649+
} else {
650+
return // Invalid frame time
651+
}
652+
653+
val currentInfo = externalRefreshRateInfo ?: VitalInfo.EMPTY
654+
val newSampleCount = currentInfo.sampleCount + 1
655+
656+
// Calculate incremental mean using the same algorithm as performance metrics
657+
val meanValue = (refreshRateHz + (currentInfo.sampleCount * currentInfo.meanValue)) / newSampleCount
658+
externalRefreshRateInfo = VitalInfo(
659+
newSampleCount,
660+
min(refreshRateHz, currentInfo.minValue),
661+
max(refreshRateHz, currentInfo.maxValue),
662+
meanValue
663+
)
664+
}
665+
638666
@WorkerThread
639667
private fun onSetInternalViewAttribute(event: RumRawEvent.SetInternalViewAttribute) {
640668
if (stopped) return
@@ -905,7 +933,8 @@ internal open class RumViewScope(
905933

906934
val timings = resolveCustomTimings()
907935
val memoryInfo = lastMemoryInfo
908-
val refreshRateInfo = lastFrameRateInfo
936+
// Use external refresh rate data if available, otherwise fall back to internal data
937+
val refreshRateInfo = externalRefreshRateInfo ?: lastFrameRateInfo
909938
val isSlowRendered = resolveRefreshRateInfo(refreshRateInfo) ?: false
910939
// make a copy - by the time we iterate over it on another thread, it may already be changed
911940
val eventFeatureFlags = featureFlags.toMutableMap()

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/AdvancedRumMonitor.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ internal interface AdvancedRumMonitor : RumMonitor, AdvancedNetworkRumMonitor {
4949

5050
fun updatePerformanceMetric(metric: RumPerformanceMetric, value: Double)
5151

52+
fun updateExternalRefreshRate(frameTimeNanos: Long)
53+
5254
fun setInternalViewAttribute(key: String, value: Any?)
5355

5456
fun setSyntheticsAttribute(testId: String, resultId: String)

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/monitor/DatadogRumMonitor.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,10 @@ internal class DatadogRumMonitor(
631631
handleEvent(RumRawEvent.UpdatePerformanceMetric(metric, value))
632632
}
633633

634+
override fun updateExternalRefreshRate(frameTimeNanos: Long) {
635+
handleEvent(RumRawEvent.UpdateExternalRefreshRate(frameTimeNanos))
636+
}
637+
634638
override fun setInternalViewAttribute(key: String, value: Any?) {
635639
handleEvent(RumRawEvent.SetInternalViewAttribute(key, value))
636640
}

features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/RumInternalProxyTest.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ internal class RumInternalProxyTest {
6464
verify(mockRumMonitor).updatePerformanceMetric(metric, value)
6565
}
6666

67+
@Test
68+
fun `M proxy updateExternalRefreshRate to RumMonitor W updateExternalRefreshRate()`(
69+
@LongForgery frameTimeNanos: Long
70+
) {
71+
// Given
72+
val mockRumMonitor = mock(AdvancedRumMonitor::class.java)
73+
val proxy = _RumInternalProxy(mockRumMonitor)
74+
75+
// When
76+
proxy.updateExternalRefreshRate(frameTimeNanos)
77+
78+
// Then
79+
verify(mockRumMonitor).updateExternalRefreshRate(frameTimeNanos)
80+
}
81+
6782
@Test
6883
fun `M proxy enableJankStatsTracking to RumMonitor W enableJankStatsTracking()`() {
6984
// Given

features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/domain/scope/RumViewScopeTest.kt

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7863,6 +7863,248 @@ internal class RumViewScopeTest {
78637863

78647864
// endregion
78657865

7866+
// region External Refresh Rate
7867+
7868+
@Test
7869+
fun `M send update W handleEvent(UpdateExternalRefreshRate+KeepAlive) { single value }`(
7870+
forge: Forge
7871+
) {
7872+
// GIVEN
7873+
val frameTimeNanos = forge.aLong(min = 1_000_000L, max = 50_000_000L) // 1ms to 50ms
7874+
val expectedRefreshRate = 1_000_000_000.0 / frameTimeNanos.toDouble()
7875+
7876+
// WHEN
7877+
testedScope.handleEvent(
7878+
RumRawEvent.UpdateExternalRefreshRate(frameTimeNanos),
7879+
mockWriter
7880+
)
7881+
val result = testedScope.handleEvent(
7882+
RumRawEvent.KeepAlive(),
7883+
mockWriter
7884+
)
7885+
7886+
// THEN
7887+
argumentCaptor<ViewEvent> {
7888+
verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT))
7889+
assertThat(lastValue)
7890+
.hasRefreshRateMetric(expectedRefreshRate, expectedRefreshRate)
7891+
}
7892+
verifyNoMoreInteractions(mockWriter)
7893+
assertThat(result).isSameAs(testedScope)
7894+
}
7895+
7896+
@Test
7897+
fun `M send update W handleEvent(UpdateExternalRefreshRate+KeepAlive) { multiple values }`(
7898+
forge: Forge
7899+
) {
7900+
// GIVEN
7901+
val frameTimesNanos = forge.aList(size = 5) {
7902+
aLong(min = 8_000_000L, max = 20_000_000L) // ~50-125 FPS range
7903+
}
7904+
7905+
var sum = 0.0
7906+
var min = Double.MAX_VALUE
7907+
var max = -Double.MAX_VALUE
7908+
val refreshRates = mutableListOf<Double>()
7909+
7910+
// WHEN
7911+
frameTimesNanos.forEach { frameTime ->
7912+
val refreshRate = 1_000_000_000.0 / frameTime.toDouble()
7913+
refreshRates.add(refreshRate)
7914+
sum += refreshRate
7915+
min = kotlin.math.min(min, refreshRate)
7916+
max = kotlin.math.max(max, refreshRate)
7917+
7918+
testedScope.handleEvent(
7919+
RumRawEvent.UpdateExternalRefreshRate(frameTime),
7920+
mockWriter
7921+
)
7922+
}
7923+
7924+
val result = testedScope.handleEvent(
7925+
RumRawEvent.KeepAlive(),
7926+
mockWriter
7927+
)
7928+
7929+
// THEN
7930+
val expectedAverage = sum / refreshRates.size
7931+
argumentCaptor<ViewEvent> {
7932+
verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT))
7933+
assertThat(lastValue)
7934+
.hasRefreshRateMetric(expectedAverage, min)
7935+
}
7936+
verifyNoMoreInteractions(mockWriter)
7937+
assertThat(result).isSameAs(testedScope)
7938+
}
7939+
7940+
@Test
7941+
fun `M ignore invalid frame time W handleEvent(UpdateExternalRefreshRate+KeepAlive) { zero frame time }`() {
7942+
// WHEN
7943+
testedScope.handleEvent(
7944+
RumRawEvent.UpdateExternalRefreshRate(0L),
7945+
mockWriter
7946+
)
7947+
val result = testedScope.handleEvent(
7948+
RumRawEvent.KeepAlive(),
7949+
mockWriter
7950+
)
7951+
7952+
// THEN
7953+
argumentCaptor<ViewEvent> {
7954+
verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT))
7955+
assertThat(lastValue)
7956+
.hasRefreshRateMetric(null, null)
7957+
}
7958+
verifyNoMoreInteractions(mockWriter)
7959+
assertThat(result).isSameAs(testedScope)
7960+
}
7961+
7962+
@Test
7963+
fun `M ignore invalid frame time W handleEvent(UpdateExternalRefreshRate+KeepAlive) { negative frame time }`(
7964+
forge: Forge
7965+
) {
7966+
// GIVEN
7967+
val negativeFrameTime = -forge.aLong(min = 1L, max = 1_000_000L)
7968+
7969+
// WHEN
7970+
testedScope.handleEvent(
7971+
RumRawEvent.UpdateExternalRefreshRate(negativeFrameTime),
7972+
mockWriter
7973+
)
7974+
val result = testedScope.handleEvent(
7975+
RumRawEvent.KeepAlive(),
7976+
mockWriter
7977+
)
7978+
7979+
// THEN
7980+
argumentCaptor<ViewEvent> {
7981+
verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT))
7982+
assertThat(lastValue)
7983+
.hasRefreshRateMetric(null, null)
7984+
}
7985+
verifyNoMoreInteractions(mockWriter)
7986+
assertThat(result).isSameAs(testedScope)
7987+
}
7988+
7989+
@Test
7990+
fun `M prioritize external data W handleEvent(UpdateExternalRefreshRate+VitalUpdate+KeepAlive)`(
7991+
forge: Forge
7992+
) {
7993+
// GIVEN
7994+
val externalFrameTime = forge.aLong(min = 16_000_000L, max = 17_000_000L) // ~60 FPS
7995+
val expectedExternalRefreshRate = 1_000_000_000.0 / externalFrameTime.toDouble()
7996+
7997+
val internalRefreshRate = forge.aDouble(min = 30.0, max = 45.0) // Different range
7998+
val listenerCaptor = argumentCaptor<VitalListener> {
7999+
verify(mockFrameRateVitalMonitor).register(capture())
8000+
}
8001+
val vitalListener = listenerCaptor.firstValue
8002+
8003+
// WHEN - Add external refresh rate data
8004+
testedScope.handleEvent(
8005+
RumRawEvent.UpdateExternalRefreshRate(externalFrameTime),
8006+
mockWriter
8007+
)
8008+
8009+
// AND - Add internal refresh rate data (should be ignored)
8010+
vitalListener.onVitalUpdate(VitalInfo(1, internalRefreshRate, internalRefreshRate, internalRefreshRate))
8011+
8012+
val result = testedScope.handleEvent(
8013+
RumRawEvent.KeepAlive(),
8014+
mockWriter
8015+
)
8016+
8017+
// THEN - Should use external data, not internal
8018+
argumentCaptor<ViewEvent> {
8019+
verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT))
8020+
assertThat(lastValue)
8021+
.hasRefreshRateMetric(expectedExternalRefreshRate, expectedExternalRefreshRate)
8022+
}
8023+
verifyNoMoreInteractions(mockWriter)
8024+
assertThat(result).isSameAs(testedScope)
8025+
}
8026+
8027+
@Test
8028+
fun `M fallback to internal data W no external data provided`(
8029+
forge: Forge
8030+
) {
8031+
// GIVEN
8032+
val internalRefreshRate = forge.aDouble(min = 55.0, max = 60.0)
8033+
val listenerCaptor = argumentCaptor<VitalListener> {
8034+
verify(mockFrameRateVitalMonitor).register(capture())
8035+
}
8036+
val vitalListener = listenerCaptor.firstValue
8037+
8038+
// WHEN - Only add internal refresh rate data (no external data)
8039+
vitalListener.onVitalUpdate(VitalInfo(1, internalRefreshRate, internalRefreshRate, internalRefreshRate))
8040+
8041+
val result = testedScope.handleEvent(
8042+
RumRawEvent.KeepAlive(),
8043+
mockWriter
8044+
)
8045+
8046+
// THEN - Should use internal data
8047+
argumentCaptor<ViewEvent> {
8048+
verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT))
8049+
assertThat(lastValue)
8050+
.hasRefreshRateMetric(internalRefreshRate, internalRefreshRate)
8051+
}
8052+
verifyNoMoreInteractions(mockWriter)
8053+
assertThat(result).isSameAs(testedScope)
8054+
}
8055+
8056+
@Test
8057+
fun `M not update external refresh rate W view is stopped`(
8058+
forge: Forge
8059+
) {
8060+
// GIVEN
8061+
testedScope.handleEvent(RumRawEvent.StopView(fakeKey, emptyMap()), mockWriter)
8062+
val frameTimeNanos = forge.aLong(min = 16_000_000L, max = 17_000_000L)
8063+
8064+
// WHEN
8065+
val result = testedScope.handleEvent(
8066+
RumRawEvent.UpdateExternalRefreshRate(frameTimeNanos),
8067+
mockWriter
8068+
)
8069+
8070+
// THEN
8071+
// Should not process external refresh rate updates after view is stopped
8072+
assertThat(result).isNull() // View scope should be completed
8073+
}
8074+
8075+
@Test
8076+
fun `M accumulate external refresh rate samples correctly W multiple updates`() {
8077+
// GIVEN
8078+
val frameTime1 = 16_666_667L // 60 FPS
8079+
val frameTime2 = 33_333_333L // 30 FPS
8080+
val frameTime3 = 11_111_111L // 90 FPS
8081+
8082+
val refreshRate1 = 1_000_000_000.0 / frameTime1.toDouble()
8083+
val refreshRate2 = 1_000_000_000.0 / frameTime2.toDouble()
8084+
val refreshRate3 = 1_000_000_000.0 / frameTime3.toDouble()
8085+
8086+
val expectedAverage = (refreshRate1 + refreshRate2 + refreshRate3) / 3.0
8087+
val expectedMin = kotlin.math.min(refreshRate2, kotlin.math.min(refreshRate1, refreshRate3))
8088+
8089+
// WHEN
8090+
testedScope.handleEvent(RumRawEvent.UpdateExternalRefreshRate(frameTime1), mockWriter)
8091+
testedScope.handleEvent(RumRawEvent.UpdateExternalRefreshRate(frameTime2), mockWriter)
8092+
testedScope.handleEvent(RumRawEvent.UpdateExternalRefreshRate(frameTime3), mockWriter)
8093+
8094+
val result = testedScope.handleEvent(RumRawEvent.KeepAlive(), mockWriter)
8095+
8096+
// THEN
8097+
argumentCaptor<ViewEvent> {
8098+
verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT))
8099+
assertThat(lastValue)
8100+
.hasRefreshRateMetric(expectedAverage, expectedMin)
8101+
}
8102+
verifyNoMoreInteractions(mockWriter)
8103+
assertThat(result).isSameAs(testedScope)
8104+
}
8105+
8106+
// endregion
8107+
78668108
// region Internal attributes
78678109

78688110
@Test

0 commit comments

Comments
 (0)