Skip to content

RUM-8875 Add updateExternalRefreshRate to internal RUM API#2772

Merged
awforsythe merged 5 commits into
developfrom
aforsythe/RUM-8875/external-refresh-rate
Jul 8, 2025
Merged

RUM-8875 Add updateExternalRefreshRate to internal RUM API#2772
awforsythe merged 5 commits into
developfrom
aforsythe/RUM-8875/external-refresh-rate

Conversation

@awforsythe

@awforsythe awforsythe commented Jul 1, 2025

Copy link
Copy Markdown
Contributor

refs: RUM-8875

What does this PR do?

This PR adds a new updateExternalRefreshRate function to the internal AdvancedRumMonitor interface, then updates RumViewScope so that it will populate refresh_rate_average and related properties based on the data received via this API function, if any such calls have been made.

If no calls have been made to updateExternalRefreshRate in the context of the current view, RumViewScope will continue to use the data collected from frameRateVitalListener as before.

Regardless of whether any calls are made to updateExternalRefreshRate, the vitals monitoring system within com.datadog.android.rum.internal.vitals will continue operating exactly as it does now. We make no attempt to disable or otherwise interfere with the frameRateVitalListener.

Motivation

Per RUM-8875: FPSVitalListener uses JankStats to sample framerate metrics, but this approach does not work with Unity on Android, as Unity manages its rendering pipeline internally in a way that doesn't expose frame timing information to JankStats. As a result, Unity apps simply do not receive useful data for refresh_rate_average et al. in RUM View events.

Providing a new updateExternalRefreshRate will allow the Unity SDK to periodically self-report frame timing data collected from the Unity runtime, ensuring that the Android SDK can populate refresh-rate-related View properties with useful data.

Additional Notes

I'm new to this codebase and I'm not terribly familiar with Kotlin, so please feel free to treat these changes with extra scrutiny.

Summary of code changes

  • AdvancedRumMonitor.kt: Add fun updateExternalRefreshRate(frameTimeSeconds: Double) to AdvancedRumMonitor interface
  • apiSurface: Regenerated
  • dd-sdk-android-rum.api: Regenerated
  • _RumInternalProxy.kt: Proxy calls to AdvancedRum impl
  • RumRawEvent.kt: Define UpdateExternalRefreshRate event
  • RumViewManagerScope.kt: Register that event in silentOrphanEventTypes, so that calls to updateExternalRefreshRate made when no view is active will be dropped without error
  • RumViewScope.kt:
    • Add externalRefreshRateInfo as a nullable VitalInfo property
    • Handle UpdateExternalRefreshRate events by calling onUpdateExternalRefreshRate
    • In onUpdateExternalRefreshRate, set or update externalRefreshRateInfo property based on supplied value, guarding against divide-by-zero
    • When constructing view events, prefer externalRefreshRateInfo for refresh rate metrics if set; use lastFrameRateInfo otherwise
  • DatadogRumMonitor.kt:
    • Handle updateExternalRefreshRate calls by constructing an UpdateExternalRefreshRate event and passing it to handleEvent

Summary of tests added

  • RumInternalProxyTest.kt: Add a test to exercise proxying of internal API calls for new function
  • RumRawEventExt.kt: Ensure that new event type is included in Forge.anyRumEvent
  • RumViewScopeTest.kt: Add test coverage for changes to RumViewScope, asserting that:
    • if an external refresh rate sample is supplied, that value is written to subsequent view events
    • when multiple samples are supplied, they are averaged as expected
    • if a refresh rate of zero seconds is supplied, it is ignored and no divide by zero occurs
    • if a negative refresh rate is supplied, it is ignored
    • if refresh rate samples have been received from both updateExternalRefreshRate and the internal JankStats listener, the external data will be preferred
    • if only internal JankStats data is present, it will be used as normal
    • if the view is stopped, external refresh rate events will be ignored
    • both average and min are computed precisely and accurately from external samples

Review checklist (to be filled by reviewers)

  • Feature or bugfix MUST have appropriate tests (unit, integration, e2e)
  • Make sure you discussed the feature or bugfix with the maintaining team in an Issue
  • Make sure each commit and the PR mention the Issue number (cf the CONTRIBUTING doc)

@awforsythe
awforsythe marked this pull request as ready for review July 1, 2025 16:03
@awforsythe
awforsythe requested review from a team as code owners July 1, 2025 16:03
handleEvent(RumRawEvent.UpdatePerformanceMetric(metric, value))
}

override fun updateExternalRefreshRate(frameTimeSeconds: Double) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how often this method will be called? is it on the scale of every few milliseconds?

@awforsythe awforsythe Jul 1, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I think it would be unwise of us to call it every frame. My initial plan was that the Unity SDK would respect the VitalsUpdateFrequency setting that (presumably) configures the internal vital monitors, so depending on that setting, it would be called roughly every 100ms, every 500ms, every 1000ms, or never.

(It may also be called immediately before stopView, in the event that we're stopping a view in which we've never called updateExternalRefreshRate.)

0xnm
0xnm previously approved these changes Jul 2, 2025

@0xnm 0xnm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice!

argumentCaptor<ViewEvent> {
verify(mockWriter).write(eq(mockEventBatchWriter), capture(), eq(EventType.DEFAULT))
assertThat(lastValue)
.hasRefreshRateMetric(expectedRefreshRate, expectedRefreshRate)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: maybe it is better use named arguments here to avoid questions why both arguments are the same

Comment on lines +7915 to +7916
min = kotlin.math.min(min, refreshRate)
max = kotlin.math.max(max, refreshRate)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
min = kotlin.math.min(min, refreshRate)
max = kotlin.math.max(max, refreshRate)
min = min(min, refreshRate)
max = max(max, refreshRate)

and the add the imports for kotlin.math.min and kotlin.math.max.

Alternatively coerceAtMost / coerceAtLeast methods can be used.

Comment on lines +8003 to +8017
// WHEN - Add external refresh rate data
testedScope.handleEvent(
RumRawEvent.UpdateExternalRefreshRate(externalFrameTime),
mockWriter
)

// AND - Add internal refresh rate data (should be ignored)
vitalListener.onVitalUpdate(VitalInfo(1, internalRefreshRate, internalRefreshRate, internalRefreshRate))

val result = testedScope.handleEvent(
RumRawEvent.KeepAlive(),
mockWriter
)

// THEN - Should use external data, not internal

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: we can probably remove comments from When/And/Then as it is pretty much clear from the code itself.

Same for below.

Comment on lines +7994 to +7997
val externalFrameTime = forge.aDouble(min = 0.016, max = 0.017) // ~60 FPS
val expectedExternalRefreshRate = 1.0 / externalFrameTime

val internalRefreshRate = forge.aDouble(min = 30.0, max = 45.0) // Different range

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably we can put some very wide range here since it is about priority? so range doesn't probably matter. Otherwise min = 0.016, max = 0.017 looks a bit questionable.

Same can be applied for the tests below.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 69.86%. Comparing base (208da2c) to head (e8b26e5).
Report is 4 commits behind head on develop.

Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #2772      +/-   ##
===========================================
+ Coverage    69.79%   69.86%   +0.07%     
===========================================
  Files          824      824              
  Lines        30877    30894      +17     
  Branches      5196     5201       +5     
===========================================
+ Hits         21548    21582      +34     
+ Misses        7852     7844       -8     
+ Partials      1477     1468       -9     
Files with missing lines Coverage Δ
...otlin/com/datadog/android/rum/_RumInternalProxy.kt 72.22% <100.00%> (+1.63%) ⬆️
...g/android/rum/internal/domain/scope/RumRawEvent.kt 100.00% <100.00%> (ø)
...d/rum/internal/domain/scope/RumViewManagerScope.kt 91.09% <ø> (ø)
.../android/rum/internal/domain/scope/RumViewScope.kt 94.29% <100.00%> (+0.08%) ⬆️
.../android/rum/internal/monitor/DatadogRumMonitor.kt 85.06% <100.00%> (-0.26%) ⬇️

... and 34 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@awforsythe

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @0xnm! I tweaked the new unit tests as you recommended.

@awforsythe
awforsythe requested a review from 0xnm July 2, 2025 20:05
@awforsythe
awforsythe merged commit dc9cc0b into develop Jul 8, 2025
25 checks passed
@awforsythe
awforsythe deleted the aforsythe/RUM-8875/external-refresh-rate branch July 8, 2025 12:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants