Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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 >
Expand All @@ -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 {
Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion packages/core/ios/Sources/DdSdkImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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
}
}
108 changes: 108 additions & 0 deletions packages/core/ios/Tests/DdSdkTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down