diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt index 467d37335..d2d561c7a 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt @@ -7,7 +7,10 @@ package com.datadog.reactnative import android.content.Context +import android.hardware.display.DisplayManager +import android.os.Build import android.util.Log +import android.view.Display import com.datadog.android.privacy.TrackingConsent import com.datadog.android.rum.RumPerformanceMetric import com.datadog.android.rum.configuration.VitalsUpdateFrequency @@ -18,6 +21,7 @@ import com.facebook.react.bridge.ReadableMap import java.util.Locale import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.max /** The entry point to initialize Datadog's features. */ @Suppress("TooManyFunctions") @@ -248,9 +252,10 @@ class DdSdkImplementation( return { if (jsRefreshRateMonitoringEnabled && it > 0.0) { + val normalizedFrameTimeSeconds = normalizeFrameTime(it, appContext) datadog.getRumMonitor() ._getInternal() - ?.updatePerformanceMetric(RumPerformanceMetric.JS_FRAME_TIME, it) + ?.updatePerformanceMetric(RumPerformanceMetric.JS_FRAME_TIME, normalizedFrameTimeSeconds) } if (jsLongTasksMonitoringEnabled && it > @@ -263,6 +268,49 @@ class DdSdkImplementation( } } + /** + * Normalizes frameTime values so when are turned into FPS metrics they are normalized on a range of zero to 60fps. + * @param frameTimeSeconds: the frame time to normalize. In seconds. + * @param context: The current app context + * @param fpsBudget: The maximum fps under which the frame Time will be normalized [0-fpsBudget]. Defaults to 60Hz. + * @param deviceDisplayFps: The maximum fps supported by the device. If not provided it will be set from the value obtained from the app context. + */ + @Suppress("CyclomaticComplexMethod") + fun normalizeFrameTime( + frameTimeSeconds: Double, + context: Context, + fpsBudget: Double? = null, + deviceDisplayFps: Double? = null, + ) : Double { + val frameTimeMs = frameTimeSeconds * 1000.0 + val frameBudgetHz = fpsBudget ?: DEFAULT_REFRESH_HZ + val maxDeviceDisplayHz = deviceDisplayFps ?: getMaxDisplayRefreshRate(context) + ?: 60.0 + + val maxDeviceFrameTimeMs = 1000.0 / maxDeviceDisplayHz + val budgetFrameTimeMs = 1000.0 / frameBudgetHz + + if (listOf( + maxDeviceDisplayHz, frameTimeMs, frameBudgetHz, budgetFrameTimeMs, maxDeviceFrameTimeMs + ).any { !it.isFinite() || it <= 0.0 } + ) return 1.0 / DEFAULT_REFRESH_HZ + + + var normalizedFrameTimeMs = frameTimeMs / (maxDeviceFrameTimeMs / budgetFrameTimeMs) + + normalizedFrameTimeMs = max(normalizedFrameTimeMs, maxDeviceFrameTimeMs) + + return normalizedFrameTimeMs / 1000.0 // in seconds + } + + @Suppress("CyclomaticComplexMethod") + private fun getMaxDisplayRefreshRate(context: Context?): Double { + val dm = context?.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager ?: return 60.0 + val display: Display = dm.getDisplay(Display.DEFAULT_DISPLAY) ?: return DEFAULT_REFRESH_HZ + + return display.supportedModes.maxOf { it.refreshRate.toDouble() } + } + // endregion companion object { @@ -273,6 +321,7 @@ class DdSdkImplementation( internal const val DD_DROP_ACTION = "_dd.action.drop_action" internal const val MONITOR_JS_ERROR_MESSAGE = "Error monitoring JS refresh rate" internal const val PACKAGE_INFO_NOT_FOUND_ERROR_MESSAGE = "Error getting package info" + internal const val DEFAULT_REFRESH_HZ = 60.0 internal const val NAME = "DdSdk" } } diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt index 4abf9b981..5df758890 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt @@ -59,6 +59,7 @@ import java.util.Locale import java.util.stream.Stream import kotlin.time.Duration.Companion.seconds import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.data.Offset import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -78,7 +79,6 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow import org.mockito.kotlin.eq import org.mockito.kotlin.inOrder -import org.mockito.kotlin.isNotNull import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -3130,6 +3130,132 @@ internal class DdSdkTest { } } + @Test + fun `𝕄 normalize frameTime according to the device's refresh rate`() { + // 10 fps, 60Hz device, 60 fps budget -> 10 fps + var frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.1, + context = mockContext, + fpsBudget = 60.0, + deviceDisplayFps = 60.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.1) + + // 30 fps, 60Hz device, 60 fps budget -> 30 fps + frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.03, + context = mockContext, + fpsBudget = 60.0, + deviceDisplayFps = 60.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.03) + + // 60 fps, 60Hz device, 60 fps budget -> 60 fps + frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.016, + context = mockContext, + fpsBudget = 60.0, + deviceDisplayFps = 60.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.016, Offset.offset(0.005)) + + // 60 fps, 120Hz device, 60 fps budget -> 30 fps + frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.016, + context = mockContext, + fpsBudget = 60.0, + deviceDisplayFps = 120.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.032) + + // 120 fps, 120Hz device, 60 fps budget -> 60 fps + frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.0083, + context = mockContext, + fpsBudget = 60.0, + deviceDisplayFps = 120.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.016, Offset.offset(0.005)) + + // 90 fps, 120Hz device, 60 fps budget -> 45 fps + frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.0111, + context = mockContext, + fpsBudget = 60.0, + deviceDisplayFps = 120.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.0222, Offset.offset(0.001)) + + // 100 fps, 120Hz device, 60 fps budget -> 50 fps + frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.01, + context = mockContext, + fpsBudget = 60.0, + deviceDisplayFps = 120.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.02, Offset.offset(0.001)) + + // 120 fps, 120Hz device, 120 fps budget -> 120 fps + frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.0083, + context = mockContext, + fpsBudget = 120.0, + deviceDisplayFps = 120.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.0083, Offset.offset(0.001)) + + // 80 fps, 160Hz device, 60 fps budget -> 30 fps + frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.0125, + context = mockContext, + fpsBudget = 60.0, + deviceDisplayFps = 160.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.033, Offset.offset(0.001)) + + // 160 fps, 160Hz device, 60 fps budget -> 60 fps + frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.00625, + context = mockContext, + fpsBudget = 60.0, + deviceDisplayFps = 160.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.016, Offset.offset(0.001)) + + // Edge cases + frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.0, + context = mockContext, + fpsBudget = 0.0, + deviceDisplayFps = 0.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.016, Offset.offset(0.001)) + + frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.016, + context = mockContext, + fpsBudget = 0.0, + deviceDisplayFps = 0.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.016, Offset.offset(0.001)) + + frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.016, + context = mockContext, + fpsBudget = 60.0, + deviceDisplayFps = 0.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.016, Offset.offset(0.001)) + + frameTimeSeconds = testedBridgeSdk.normalizeFrameTime( + frameTimeSeconds = 0.016, + context = mockContext, + fpsBudget = 0.0, + deviceDisplayFps = 60.0 + ) + assertThat(frameTimeSeconds).isEqualTo(0.016, Offset.offset(0.001)) + } + // endregion // region Internal diff --git a/packages/core/ios/Sources/DdSdkImplementation.swift b/packages/core/ios/Sources/DdSdkImplementation.swift index 9d057d158..387f8495d 100644 --- a/packages/core/ios/Sources/DdSdkImplementation.swift +++ b/packages/core/ios/Sources/DdSdkImplementation.swift @@ -203,7 +203,8 @@ public class DdSdkImplementation: NSObject { // Leave JS thread ASAP to give as much time to JS engine work. sharedQueue.async { if (shouldRecordFrameTime) { - rumMonitorInternal.updatePerformanceMetric(at: now, metric: .jsFrameTimeSeconds, value: frameTime, attributes: [:]) + let normalizedFrameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(frameTime) + rumMonitorInternal.updatePerformanceMetric(at: now, metric: .jsFrameTimeSeconds, value: normalizedFrameTimeSeconds, attributes: [:]) } if (shouldRecordLongTask) { rumMonitorInternal.addLongTask(at: now, duration: frameTime, attributes: ["long_task.target": "javascript"]) @@ -213,5 +214,23 @@ public class DdSdkImplementation: NSObject { return frameTimeCallback } + + // Normalizes frameTime values so when they are turned into FPS metrics they are normalized on a range between 0 and fpsBudget. If fpsBudget is not provided it will default to 60hz. + public static func normalizeFrameTimeForDeviceRefreshRate(_ frameTime: Double, fpsBudget: Double? = nil, deviceDisplayFps: Double? = nil) -> Double { + let DEFAULT_REFRESH_HZ = 60.0 + let frameTimeMs: Double = frameTime * 1000.0 + let frameBudgetHz: Double = fpsBudget ?? DEFAULT_REFRESH_HZ + let maxDeviceDisplayHz = deviceDisplayFps ?? Double(UIScreen.main.maximumFramesPerSecond) + let maxDeviceFrameTimeMs = 1000.0 / maxDeviceDisplayHz + let budgetFrameTimeMs = 1000.0 / frameBudgetHz + + guard maxDeviceDisplayHz > 0, frameTimeMs.isFinite, frameTimeMs > 0, frameBudgetHz > 0, budgetFrameTimeMs.isFinite, budgetFrameTimeMs > 0, maxDeviceFrameTimeMs.isFinite, maxDeviceFrameTimeMs > 0 else { + return 1.0 / DEFAULT_REFRESH_HZ + } + + var normalizedFrameTimeMs = frameTimeMs / (maxDeviceFrameTimeMs / budgetFrameTimeMs) + normalizedFrameTimeMs = max(normalizedFrameTimeMs, maxDeviceFrameTimeMs) + return normalizedFrameTimeMs / 1000.0 // in seconds + } } diff --git a/packages/core/ios/Tests/DdSdkTests.swift b/packages/core/ios/Tests/DdSdkTests.swift index efbf57b96..bf610997b 100644 --- a/packages/core/ios/Tests/DdSdkTests.swift +++ b/packages/core/ios/Tests/DdSdkTests.swift @@ -1069,6 +1069,114 @@ class DdSdkTests: XCTestCase { XCTAssertEqual(rumMonitorMock.receivedLongTasks.first?.value, 0.25) XCTAssertEqual(rumMonitorMock.lastReceivedPerformanceMetrics[.jsFrameTimeSeconds], 0.25) } + + func testFrameTimeNormalizationFromCallback() { + let mockRefreshRateMonitor = MockJSRefreshRateMonitor() + let rumMonitorMock = MockRUMMonitor() + + DdSdkImplementation( + mainDispatchQueue: DispatchQueueMock(), + jsDispatchQueue: DispatchQueueMock(), + jsRefreshRateMonitor: mockRefreshRateMonitor, + RUMMonitorProvider: { rumMonitorMock }, + RUMMonitorInternalProvider: { rumMonitorMock._internalMock } + ).initialize( + configuration: .mockAny( + longTaskThresholdMs: 200, + vitalsUpdateFrequency: "average" + ), + resolve: mockResolve, + reject: mockReject + ) + + XCTAssertTrue(mockRefreshRateMonitor.isStarted) + + // 10 fps + mockRefreshRateMonitor.executeFrameCallback(frameTime: 0.1) + sharedQueue.sync {} + XCTAssertEqual(rumMonitorMock.lastReceivedPerformanceMetrics[.jsFrameTimeSeconds], 0.1) + + // 30 fps + mockRefreshRateMonitor.executeFrameCallback(frameTime: 0.03) + sharedQueue.sync {} + XCTAssertEqual(rumMonitorMock.lastReceivedPerformanceMetrics[.jsFrameTimeSeconds], 0.03) + + // 45 fps + mockRefreshRateMonitor.executeFrameCallback(frameTime: 0.02) + sharedQueue.sync {} + XCTAssertEqual(rumMonitorMock.lastReceivedPerformanceMetrics[.jsFrameTimeSeconds], 0.02) + + // 60 fps + mockRefreshRateMonitor.executeFrameCallback(frameTime: 0.016) + sharedQueue.sync {} + XCTAssertEqual(rumMonitorMock.lastReceivedPerformanceMetrics[.jsFrameTimeSeconds]!, 0.016, accuracy: 0.001) + + // 90 fps + mockRefreshRateMonitor.executeFrameCallback(frameTime: 0.011) + sharedQueue.sync {} + XCTAssertEqual(rumMonitorMock.lastReceivedPerformanceMetrics[.jsFrameTimeSeconds]!, 0.016, accuracy: 0.001) + + // 120 fps + mockRefreshRateMonitor.executeFrameCallback(frameTime: 0.008) + sharedQueue.sync {} + XCTAssertEqual(rumMonitorMock.lastReceivedPerformanceMetrics[.jsFrameTimeSeconds]!, 0.016, accuracy: 0.001) + } + + func testFrameTimeNormalizationUtilityFunction() { + + // 10 fps, 60fps capable device, 60 fps budget -> Normalized to 10fps + var frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0.1, fpsBudget: 60.0, deviceDisplayFps: 60.0) + XCTAssertEqual(frameTimeSeconds, 0.1, accuracy: 0.01) + + // 30 fps, 60fps capable device, 60 fps budget -> Normalized to 30fps + frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0.03, fpsBudget: 60.0, deviceDisplayFps: 60.0) + XCTAssertEqual(frameTimeSeconds, 0.03, accuracy: 0.01) + + // 60 fps, 60fps capable device, 60 fps budget-> Normalized to 60fps + frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0.016, fpsBudget: 60.0, deviceDisplayFps: 60.0) + XCTAssertEqual(frameTimeSeconds, 0.016, accuracy: 0.01) + + // 60 fps, 120fps capable device, 60 fps budget -> Normalized to 30fps + frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0.016, fpsBudget: 60.0, deviceDisplayFps: 120.0) + XCTAssertEqual(frameTimeSeconds, 0.03, accuracy: 0.01) + + // 120 fps, 120fps capable device, 60 fps budget -> Normalized to 60fps + frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0.0083, fpsBudget: 60.0, deviceDisplayFps: 120.0) + XCTAssertEqual(frameTimeSeconds, 0.016, accuracy: 0.001) + + // 90 fps, 120fps capable device, 60 fps budget -> Normalized to 45fps + frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0.0111, fpsBudget: 60.0, deviceDisplayFps: 120.0) + XCTAssertEqual(frameTimeSeconds, 0.0222, accuracy: 0.001) + + // 100 fps, 120fps capable device, 60 fps budget -> Normalized to 50fps + frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0.01, fpsBudget: 60.0, deviceDisplayFps: 120.0) + XCTAssertEqual(frameTimeSeconds, 0.02, accuracy: 0.001) + + // 120 fps, 120fps capable device, 120 fps budget -> Normalized to 120fps + frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0.0083, fpsBudget: 120.0, deviceDisplayFps: 120.0) + XCTAssertEqual(frameTimeSeconds, 0.0083, accuracy: 0.001) + + // 80 fps, 160fps capable device, 60 fps budget -> Normalized to 30fps + frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0.0125, fpsBudget: 60.0, deviceDisplayFps: 160.0) + XCTAssertEqual(frameTimeSeconds, 0.033, accuracy: 0.001) + + // 160 fps, 160fps capable device, 60 fps budget -> Normalized to 60fps + frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0.00625, fpsBudget: 60.0, deviceDisplayFps: 160.0) + XCTAssertEqual(frameTimeSeconds, 0.016, accuracy: 0.001) + + // Edge cases + frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0, fpsBudget: 0, deviceDisplayFps: 0) + XCTAssertEqual(frameTimeSeconds, 0.016, accuracy: 0.001) + + frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0.016, fpsBudget: 0, deviceDisplayFps: 0) + XCTAssertEqual(frameTimeSeconds, 0.016, accuracy: 0.001) + + frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0.016, fpsBudget: 60.0, deviceDisplayFps: 0) + XCTAssertEqual(frameTimeSeconds, 0.016, accuracy: 0.001) + + frameTimeSeconds = DdSdkImplementation.normalizeFrameTimeForDeviceRefreshRate(0.016, fpsBudget: 0, deviceDisplayFps: 60.0) + XCTAssertEqual(frameTimeSeconds, 0.016, accuracy: 0.001) + } func testSDKInitializationWithCustomEndpoints() throws { let mockRefreshRateMonitor = MockJSRefreshRateMonitor()