From 8b7f3dc4a56573997f9e6a7a01d590427b54766e Mon Sep 17 00:00:00 2001 From: Timur Valeev Date: Tue, 12 May 2026 02:02:04 +0100 Subject: [PATCH] RUM-13949: Adds `CpuDatapointReader`, which reads CPU-usage metrics from the existing `CpuVitalReader` vital and wraps them as `DataPoint` values; and `CpuEventSerializer`, which converts a batch of CPU `DataPoint`s into a `RumTimeseriesCpuEvent` JSON object ready for ingestion. --- detekt_custom_safe_calls.yml | 2 + .../timeseries/provider/CpuDatapointReader.kt | 71 +++++ .../serializer/CpuEventSerializer.kt | 91 ++++++ .../provider/CpuDatapointReaderTest.kt | 275 ++++++++++++++++++ .../serializer/CpuEventSerializerTest.kt | 259 +++++++++++++++++ 5 files changed, 698 insertions(+) create mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/provider/CpuDatapointReader.kt create mode 100644 features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/serializer/CpuEventSerializer.kt create mode 100644 features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/timeseries/provider/CpuDatapointReaderTest.kt create mode 100644 features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/timeseries/serializer/CpuEventSerializerTest.kt diff --git a/detekt_custom_safe_calls.yml b/detekt_custom_safe_calls.yml index 813d493d92..627d7db73c 100644 --- a/detekt_custom_safe_calls.yml +++ b/detekt_custom_safe_calls.yml @@ -1135,6 +1135,7 @@ datadog: - "kotlin.Char.titlecase(java.util.Locale)" - "kotlin.CharArray.constructor(kotlin.Int, kotlin.Function1)" - "kotlin.Double.coerceAtMost(kotlin.Double)" + - "kotlin.Double.coerceIn(kotlin.Double, kotlin.Double)" - "kotlin.Double.isNaN()" - "kotlin.Double.pow(kotlin.Int)" - "kotlin.Double.pow(kotlin.Double)" @@ -1168,6 +1169,7 @@ datadog: - "kotlin.IntArray.constructor(kotlin.Int)" - "kotlin.IntArray.joinToString(kotlin.CharSequence, kotlin.CharSequence, kotlin.CharSequence, kotlin.Int, kotlin.CharSequence, kotlin.Function1?)" - "kotlin.IntArray.toSet()" + - "kotlin.Long.coerceAtLeast(kotlin.Long)" - "kotlin.Long.coerceIn(kotlin.Long, kotlin.Long)" - "kotlin.Long.hashCode()" - "kotlin.Long.or(kotlin.Long)" diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/provider/CpuDatapointReader.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/provider/CpuDatapointReader.kt new file mode 100644 index 0000000000..c9f0ce6bae --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/provider/CpuDatapointReader.kt @@ -0,0 +1,71 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.timeseries.provider + +import com.datadog.android.api.InternalLogger +import com.datadog.android.core.internal.persistence.file.canReadSafe +import com.datadog.android.core.internal.persistence.file.existsSafe +import com.datadog.android.core.internal.persistence.file.readTextSafe +import com.datadog.android.internal.time.TimeProvider +import java.io.File + +/** + * Reads CPU usage as a percentage by computing successive deltas of the utime field + * from `/proc/self/stat`. + * + * CLK_TCK = 100 Hz on Android bionic: 100 ticks/s ≡ 100% single-core CPU. + * Multi-core spikes are clamped to 100.0. The first call always returns null. + */ +internal class CpuDatapointReader( + internal val statFile: File = STAT_FILE, + private val cpuTimeProvider: TimeProvider, + override val intervalMs: Long, + private val internalLogger: InternalLogger +) : DataPointsReader(cpuTimeProvider) { + + private var lastUtime: Double? = null + private var lastTimestampMs: Long? = null + + @Suppress("ReturnCount") + override fun readValue(): Double? { + val nowMs = cpuTimeProvider.getDeviceTimestampMillis() + val cpuTicks = readCpuTicks() ?: return null + val prevTicks = lastUtime + val prevMs = lastTimestampMs + lastUtime = cpuTicks + lastTimestampMs = nowMs + if (prevTicks == null || prevMs == null) return null + val elapsedMs = (nowMs - prevMs).coerceAtLeast(1L) + // CLK_TCK = 100 Hz on Android bionic → 100 ticks/s = 100% CPU on one core + return ((cpuTicks - prevTicks) * MS_PER_SECOND / elapsedMs).coerceIn(0.0, MAX_CPU_PERCENT) + } + + @Suppress("ReturnCount") + private fun readCpuTicks(): Double? { + if (!statFile.existsSafe(internalLogger) || !statFile.canReadSafe(internalLogger)) return null + val stat = statFile.readTextSafe(internalLogger = internalLogger) ?: return null + val tokens = stat.split(' ') + return when { + tokens.size <= UTIME_IDX -> null + tokens.size <= STIME_IDX -> tokens[UTIME_IDX].toDoubleOrNull() + else -> { + val utime = tokens[UTIME_IDX].toDoubleOrNull() ?: return null + val stime = tokens[STIME_IDX].toDoubleOrNull() ?: return null + utime + stime + } + } + } + + companion object { + private const val STAT_PATH = "/proc/self/stat" + internal val STAT_FILE = File(STAT_PATH) + private const val UTIME_IDX = 13 + private const val STIME_IDX = 14 + private const val MS_PER_SECOND = 1000.0 + private const val MAX_CPU_PERCENT = 100.0 + } +} diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/serializer/CpuEventSerializer.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/serializer/CpuEventSerializer.kt new file mode 100644 index 0000000000..4e91d7cd9b --- /dev/null +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/serializer/CpuEventSerializer.kt @@ -0,0 +1,91 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.timeseries.serializer + +import com.datadog.android.internal.time.TimeProvider +import com.datadog.android.rum.RumSessionType +import com.datadog.android.rum.internal.timeseries.DataPoint +import com.datadog.android.rum.internal.timeseries.DeltaCompression +import com.datadog.android.rum.internal.timeseries.DeltaCompression.mapToDeltaCompressed +import com.datadog.android.rum.internal.timeseries.DeltaCompression.roundToLongSafely +import com.datadog.android.rum.internal.toTimeseriesCpuSessionType +import com.datadog.android.rum.model.TimeseriesCpuEvent +import com.google.gson.JsonObject +import java.util.UUID + +internal class CpuEventSerializer( + private val sessionId: String, + private val applicationId: String, + private val sessionType: RumSessionType, + private val timeProvider: TimeProvider, + private val useDeltaCompression: Boolean = false +) : JsonSerializer { + + override fun serialize(dataPoints: List>): JsonObject? { + if (dataPoints.isEmpty()) return null + val data = dataPoints.map { sample -> + TimeseriesCpuEvent.Data( + timestamp = sample.timestampNs, + dataPoint = TimeseriesCpuEvent.DataPoint(sample.value) + ) + } + val start = data.firstOrNull()?.timestamp ?: 0L + val end = data.lastOrNull()?.timestamp ?: 0L + val deltaEncoded = if (useDeltaCompression) encodeDelta(data) else null + val schema = if (deltaEncoded != null) { + TimeseriesCpuEvent.Schema.DELTA_SCALAR + } else { + TimeseriesCpuEvent.Schema.OBJECT + } + val json = TimeseriesCpuEvent( + dd = TimeseriesCpuEvent.Dd(), + application = TimeseriesCpuEvent.Application(id = applicationId), + session = TimeseriesCpuEvent.Session( + id = sessionId, + type = sessionType.toTimeseriesCpuSessionType() + ), + source = TimeseriesCpuEvent.Source.ANDROID, + date = timeProvider.getDeviceTimestampMillis(), + service = null, + version = null, + timeseries = TimeseriesCpuEvent.Timeseries( + id = UUID.randomUUID().toString(), + schema = schema, + start = start, + end = end, + data = data + ) + ).toJson() as JsonObject + + if (deltaEncoded != null) { + val timeseriesJson = json.getAsJsonObject("timeseries") + timeseriesJson.remove("data") + timeseriesJson.add("data", deltaEncoded) + } + return json + } + + private fun encodeDelta(data: List): JsonObject? { + if (data.size <= 1) return null + + val ts = data.mapToDeltaCompressed { it.timestamp } + val cpuUsageArray = data.mapToDeltaCompressed { + roundToLongSafely(it.dataPoint.cpuUsage.toDouble(), replaceNaNWith = 0L) + } + + return JsonObject().apply { + addProperty("precision", DeltaCompression.PRECISION) + addProperty("resolution", RESOLUTION_NS) + add("ts", ts) + add("value", cpuUsageArray) + } + } + + companion object { + private const val RESOLUTION_NS = "ns" + } +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/timeseries/provider/CpuDatapointReaderTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/timeseries/provider/CpuDatapointReaderTest.kt new file mode 100644 index 0000000000..c08d1347d0 --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/timeseries/provider/CpuDatapointReaderTest.kt @@ -0,0 +1,275 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.timeseries.provider + +import com.datadog.android.internal.time.TimeProvider +import com.datadog.android.rum.utils.forge.Configurator +import fr.xgouchet.elmyr.annotation.DoubleForgery +import fr.xgouchet.elmyr.annotation.IntForgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.data.Offset +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.api.io.TempDir +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.io.File +import java.util.concurrent.TimeUnit + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class CpuDatapointReaderTest { + + private lateinit var testedReader: CpuDatapointReader + + @TempDir + lateinit var tempDir: File + + private lateinit var fakeStatFile: File + + @Mock + lateinit var mockTimeProvider: TimeProvider + + @LongForgery(min = 100L, max = 60_000L) + var fakeIntervalMs: Long = 0L + + @LongForgery(min = 1_000L, max = 1_000_000L) + var fakeStartTimestampMs: Long = 0L + + // /proc/self/stat fields (indices 0–12 before utime at index 13) + @IntForgery(1) + var fakePid: Int = 0 + + @StringForgery(regex = "\\(\\w+\\)") + lateinit var fakeCommand: String + + @StringForgery(regex = "[RSDZTtWXxKWP]") + lateinit var fakeState: String + + @IntForgery(min = 1) + var fakePpid: Int = 0 + + @IntForgery(min = 1) + var fakePgrp: Int = 0 + + @IntForgery(min = 1) + var fakeSession: Int = 0 + + @IntForgery(min = 1) + var fakeTtyNr: Int = 0 + + @IntForgery(min = 1) + var fakeTpgid: Int = 0 + + @IntForgery(min = 1) + var fakeFlags: Int = 0 + + @IntForgery(min = 1) + var fakeMinFlt: Int = 0 + + @IntForgery(min = 1) + var fakeCMinFlt: Int = 0 + + @IntForgery(min = 1) + var fakeMajFlt: Int = 0 + + @IntForgery(min = 1) + var fakeCMajFlt: Int = 0 + + @IntForgery(min = 1, max = 10_000) + var fakeUtime: Int = 0 + + @IntForgery(min = 1, max = 10_000) + var fakeStime: Int = 0 + + @BeforeEach + fun `set up`() { + fakeStatFile = File(tempDir, "stat") + whenever(mockTimeProvider.getDeviceTimestampMillis()) doReturn fakeStartTimestampMs + testedReader = CpuDatapointReader( + statFile = fakeStatFile, + cpuTimeProvider = mockTimeProvider, + intervalMs = fakeIntervalMs, + internalLogger = mock() + ) + } + + @Test + fun `M use default stat file W init()`() { + // When + val reader = CpuDatapointReader( + cpuTimeProvider = mockTimeProvider, + intervalMs = fakeIntervalMs, + internalLogger = mock() + ) + + // Then + assertThat(reader.statFile).isEqualTo(CpuDatapointReader.STAT_FILE) + } + + @Test + fun `M return null W read() {first sample}`() { + // Given + fakeStatFile.writeText(generateStatContent(fakeUtime)) + + // When + val result = testedReader.read() + + // Then + assertThat(result).isNull() + } + + @Test + fun `M return cpu percent W read() {second sample}`( + @DoubleForgery(min = 1.0, max = 80.0) fakeCpuPercent: Double, + @LongForgery(min = 500L, max = 5000L) fakeElapsedMs: Long + ) { + // Given — seed reference point (utime + stime together form cpuTicks) + fakeStatFile.writeText(generateStatContent(fakeUtime)) + testedReader.read() + + // deltaTicks drives the percent: percent = deltaTicks * 1000 / elapsedMs (CLK_TCK=100) + val deltaTicks = (fakeCpuPercent * fakeElapsedMs / 1000.0).toInt().coerceAtLeast(1) + whenever(mockTimeProvider.getDeviceTimestampMillis()) doReturn (fakeStartTimestampMs + fakeElapsedMs) + // Distribute delta arbitrarily across utime and stime + val deltaUtime = deltaTicks / 2 + val deltaStime = deltaTicks - deltaUtime + fakeStatFile.writeText(generateStatContent(fakeUtime + deltaUtime, fakeStime + deltaStime)) + + // When + val result = testedReader.read() + + // Then + assertThat(result).isNotNull + val expectedPercent = deltaTicks * 1000.0 / fakeElapsedMs + assertThat(result!!.value).isCloseTo(expectedPercent, Offset.offset(0.1)) + assertThat(result.timestampNs).isEqualTo( + TimeUnit.MILLISECONDS.toNanos(fakeStartTimestampMs + fakeElapsedMs) + ) + } + + @Test + fun `M return 0 W read() {utime unchanged}`(@LongForgery(min = 500L, max = 5000L) fakeElapsedMs: Long) { + // Given + fakeStatFile.writeText(generateStatContent(fakeUtime)) + testedReader.read() + + whenever(mockTimeProvider.getDeviceTimestampMillis()) doReturn (fakeStartTimestampMs + fakeElapsedMs) + + // When + val result = testedReader.read() + + // Then + assertThat(result).isNotNull + assertThat(result!!.value).isEqualTo(0.0) + } + + @Test + fun `M clamp to 100 W read() {multi-core spike}`(@LongForgery(min = 500L, max = 2000L) fakeElapsedMs: Long) { + // Given — seed reference point + fakeStatFile.writeText(generateStatContent(fakeUtime)) + testedReader.read() + + // 200 ticks/s over the interval → 200% on one core → clamped to 100 + val deltaUtime = (200.0 * fakeElapsedMs / 1000.0).toInt() + whenever(mockTimeProvider.getDeviceTimestampMillis()) doReturn (fakeStartTimestampMs + fakeElapsedMs) + fakeStatFile.writeText(generateStatContent(fakeUtime + deltaUtime)) + + // When + val result = testedReader.read() + + // Then + assertThat(result).isNotNull + assertThat(result!!.value).isEqualTo(100.0) + } + + @Test + fun `M use only utime W read() {stime field absent}`( + @LongForgery(min = 500L, max = 5000L) fakeElapsedMs: Long, + @IntForgery(min = 1, max = 500) fakeDeltaUtime: Int + ) { + // Given — content with exactly 14 tokens (utime at [13], no stime at [14]) + fakeStatFile.writeText(generateStatContent(fakeUtime, includeStime = false)) + testedReader.read() + + whenever(mockTimeProvider.getDeviceTimestampMillis()) doReturn (fakeStartTimestampMs + fakeElapsedMs) + fakeStatFile.writeText(generateStatContent(fakeUtime + fakeDeltaUtime, includeStime = false)) + + // When + val result = testedReader.read() + + // Then + val expectedPercent = fakeDeltaUtime * 1000.0 / fakeElapsedMs + assertThat(result).isNotNull + assertThat(result!!.value).isCloseTo(expectedPercent.coerceAtMost(100.0), Offset.offset(0.1)) + } + + @Test + fun `M return null W read() {stat file missing}`() { + // When + val result = testedReader.read() + + // Then + assertThat(result).isNull() + } + + @Test + fun `M return null W read() {stat file has invalid content}`(@StringForgery fakeContent: String) { + // Given + fakeStatFile.writeText(fakeContent) + + // When + val result = testedReader.read() + + // Then + assertThat(result).isNull() + } + + @Test + fun `M return null W read() {stat file missing on second sample}`( + @LongForgery(min = 500L, max = 5000L) fakeElapsedMs: Long + ) { + // Given — seed reference point + fakeStatFile.writeText(generateStatContent(fakeUtime)) + testedReader.read() + + fakeStatFile.delete() + whenever(mockTimeProvider.getDeviceTimestampMillis()) doReturn (fakeStartTimestampMs + fakeElapsedMs) + + // When + val result = testedReader.read() + + // Then + assertThat(result).isNull() + } + + private fun generateStatContent(utime: Int, stime: Int = fakeStime, includeStime: Boolean = true): String { + val fields = mutableListOf( + fakePid, fakeCommand, fakeState, fakePpid, fakePgrp, + fakeSession, fakeTtyNr, fakeTpgid, fakeFlags, + fakeMinFlt, fakeCMinFlt, fakeMajFlt, fakeCMajFlt, + utime + ) + if (includeStime) fields.add(stime) + return fields.joinToString(" ") + } +} diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/timeseries/serializer/CpuEventSerializerTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/timeseries/serializer/CpuEventSerializerTest.kt new file mode 100644 index 0000000000..253f15339d --- /dev/null +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/timeseries/serializer/CpuEventSerializerTest.kt @@ -0,0 +1,259 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.rum.internal.timeseries.serializer + +import com.datadog.android.internal.time.TimeProvider +import com.datadog.android.rum.RumSessionType +import com.datadog.android.rum.internal.timeseries.DataPoint +import com.datadog.android.rum.model.TimeseriesCpuEvent +import com.datadog.android.rum.utils.forge.Configurator +import fr.xgouchet.elmyr.annotation.DoubleForgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.data.Offset +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.UUID +import kotlin.math.roundToLong + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class CpuEventSerializerTest { + + @Mock + lateinit var mockTimeProvider: TimeProvider + + @StringForgery + lateinit var fakeSessionId: String + + @StringForgery + lateinit var fakeApplicationId: String + + @LongForgery(min = 1L) + var fakeNowMs: Long = 0L + + @BeforeEach + fun `set up`() { + whenever(mockTimeProvider.getDeviceTimestampMillis()) doReturn fakeNowMs + } + + @Test + fun `M return null W serialize() { empty input }`() { + // Given + val testedSerializer = CpuEventSerializer( + sessionId = fakeSessionId, + applicationId = fakeApplicationId, + sessionType = RumSessionType.USER, + timeProvider = mockTimeProvider + ) + + // When + val result = testedSerializer.serialize(emptyList()) + + // Then + assertThat(result).isNull() + } + + @Test + fun `M produce object schema W serialize() { compression off }`( + @DoubleForgery(min = 0.0, max = 100.0) fakeCpuValue: Double, + @LongForgery(min = 1L) fakeTs: Long + ) { + // Given + val testedSerializer = CpuEventSerializer( + sessionId = fakeSessionId, + applicationId = fakeApplicationId, + sessionType = RumSessionType.USER, + timeProvider = mockTimeProvider, + useDeltaCompression = false + ) + val samples = listOf(DataPoint(fakeTs, fakeCpuValue), DataPoint(fakeTs + 1L, fakeCpuValue + 0.1)) + + // When + val json = testedSerializer.serialize(samples) ?: error(NON_NULL_JSON_ERROR) + + // Then + val timeseries = json.getAsJsonObject(KEY_TIMESERIES) + assertThat(timeseries.get(KEY_SCHEMA).asString).isEqualTo(VALUE_SCHEMA_OBJECT) + val data = timeseries.get(KEY_DATA).asJsonArray + assertThat(data).hasSize(2) + assertThat(json.getAsJsonObject(KEY_SESSION).get(KEY_TYPE).asString).isEqualTo(VALUE_TYPE_USER) + assertThat(json.getAsJsonObject(KEY_APPLICATION).get(KEY_ID).asString).isEqualTo(fakeApplicationId) + assertThat(json.get(KEY_DATE).asLong).isEqualTo(fakeNowMs) + assertThat(timeseries.get(KEY_START).asLong).isEqualTo(fakeTs) + assertThat(timeseries.get(KEY_END).asLong).isEqualTo(fakeTs + 1L) + // id is a valid UUID + UUID.fromString(timeseries.get(KEY_ID).asString) + } + + @Test + fun `M map session type W serialize() { synthetics }`( + @DoubleForgery(min = 0.0, max = 100.0) fakeCpuValue: Double, + @LongForgery(min = 1L) fakeTs: Long + ) { + // Given + val testedSerializer = CpuEventSerializer( + sessionId = fakeSessionId, + applicationId = fakeApplicationId, + sessionType = RumSessionType.SYNTHETICS, + timeProvider = mockTimeProvider + ) + val samples = listOf(DataPoint(fakeTs, fakeCpuValue)) + + // When + val json = testedSerializer.serialize(samples) ?: error(NON_NULL_JSON_ERROR) + + // Then + assertThat(json.getAsJsonObject(KEY_SESSION).get(KEY_TYPE).asString).isEqualTo(VALUE_TYPE_SYNTHETICS) + // 1-sample fallback to OBJECT schema even if delta were on + assertThat(json.getAsJsonObject(KEY_TIMESERIES).get(KEY_SCHEMA).asString).isEqualTo(VALUE_SCHEMA_OBJECT) + } + + @Test + fun `M produce delta-scalar schema W serialize() { compression on, multi-sample }`( + @DoubleForgery(min = 0.001, max = 100.0) fakeFirstCpu: Double, + @DoubleForgery(min = 0.001, max = 100.0) fakeSecondCpu: Double, + @DoubleForgery(min = 0.001, max = 100.0) fakeThirdCpu: Double, + @LongForgery(min = 1L, max = 1_000_000L) fakeTs1: Long, + @LongForgery(min = 1L, max = 1_000L) fakeTimestampStep: Long + ) { + // Given + val testedSerializer = CpuEventSerializer( + sessionId = fakeSessionId, + applicationId = fakeApplicationId, + sessionType = RumSessionType.USER, + timeProvider = mockTimeProvider, + useDeltaCompression = true + ) + + val fakeTs2 = fakeTs1 + fakeTimestampStep + val fakeTs3 = fakeTs2 + fakeTimestampStep + + val samples = listOf( + DataPoint(fakeTs1, fakeFirstCpu), + DataPoint(fakeTs2, fakeSecondCpu), + DataPoint(fakeTs3, fakeThirdCpu) + ) + + // When + val json = testedSerializer.serialize(samples) ?: error(NON_NULL_JSON_ERROR) + + // Then + val timeseries = json.getAsJsonObject(KEY_TIMESERIES) + assertThat(timeseries.get(KEY_SCHEMA).asString).isEqualTo(VALUE_SCHEMA_DELTA_SCALAR) + val data = timeseries.get(KEY_DATA).asJsonObject + assertThat(data.get(KEY_PRECISION).asInt).isEqualTo(EXPECTED_PRECISION) + assertThat(data.get(KEY_RESOLUTION).asString).isEqualTo(VALUE_RESOLUTION_NS) + + val tsArray = data.get(KEY_TS).asJsonArray + assertThat(tsArray[0].asLong).isEqualTo(fakeTs1) + assertThat(tsArray[1].asLong).isEqualTo(fakeTimestampStep) + assertThat(tsArray[2].asLong).isEqualTo(fakeTimestampStep) + + val cpuArray = data.get(KEY_VALUE).asJsonArray + val scaled = listOf(fakeFirstCpu, fakeSecondCpu, fakeThirdCpu).map { (it * SCALE).roundToLong() } + assertThat(cpuArray[0].asLong).isEqualTo(scaled[0]) + assertThat(cpuArray[1].asLong).isEqualTo(scaled[1] - scaled[0]) + assertThat(cpuArray[2].asLong).isEqualTo(scaled[2] - scaled[1]) + } + + @Test + fun `M fall back to object schema W serialize() { compression on, single sample }`( + @DoubleForgery(min = 0.001, max = 100.0) fakeCpu: Double, + @LongForgery(min = 1L) fakeTs: Long + ) { + // Given - encodeDelta returns null when data.size <= 1 + val testedSerializer = CpuEventSerializer( + sessionId = fakeSessionId, + applicationId = fakeApplicationId, + sessionType = RumSessionType.USER, + timeProvider = mockTimeProvider, + useDeltaCompression = true + ) + + // When + val json = testedSerializer.serialize(listOf(DataPoint(fakeTs, fakeCpu))) + ?: error(NON_NULL_JSON_ERROR) + + // Then + val timeseries = json.getAsJsonObject(KEY_TIMESERIES) + assertThat(timeseries.get(KEY_SCHEMA).asString).isEqualTo(VALUE_SCHEMA_OBJECT) + // data is a JsonArray (object schema), not a JsonObject (delta) + assertThat(timeseries.get(KEY_DATA).isJsonArray).isTrue() + } + + @Test + fun `M preserve cpu_usage W serialize() { object schema }`( + @DoubleForgery(min = 1.0, max = 100.0) fakeCpu: Double, + @LongForgery(min = 1L) fakeTs: Long + ) { + // Given + val testedSerializer = CpuEventSerializer( + sessionId = fakeSessionId, + applicationId = fakeApplicationId, + sessionType = RumSessionType.USER, + timeProvider = mockTimeProvider + ) + + // When + val json = testedSerializer.serialize( + listOf(DataPoint(fakeTs, fakeCpu), DataPoint(fakeTs + 1L, fakeCpu)) + ) ?: error(NON_NULL_JSON_ERROR) + + // Then + val parsed = TimeseriesCpuEvent.fromJsonObject(json) + assertThat(parsed.timeseries.data).hasSize(2) + assertThat(parsed.timeseries.data.first().dataPoint.cpuUsage.toDouble()) + .isCloseTo(fakeCpu, Offset.offset(CPU_OFFSET)) + } + + private companion object { + private const val SCALE: Long = 10_000L + private const val EXPECTED_PRECISION: Int = 4 + private const val CPU_OFFSET: Double = 0.0001 + + // JSON keys + private const val KEY_TIMESERIES: String = "timeseries" + private const val KEY_SCHEMA: String = "schema" + private const val KEY_DATA: String = "data" + private const val KEY_SESSION: String = "session" + private const val KEY_APPLICATION: String = "application" + private const val KEY_TYPE: String = "type" + private const val KEY_ID: String = "id" + private const val KEY_DATE: String = "date" + private const val KEY_START: String = "start" + private const val KEY_END: String = "end" + private const val KEY_PRECISION: String = "precision" + private const val KEY_RESOLUTION: String = "resolution" + private const val KEY_TS: String = "ts" + private const val KEY_VALUE: String = "value" + + // JSON values + private const val VALUE_SCHEMA_OBJECT: String = "object" + private const val VALUE_SCHEMA_DELTA_SCALAR: String = "delta-scalar" + private const val VALUE_TYPE_USER: String = "user" + private const val VALUE_TYPE_SYNTHETICS: String = "synthetics" + private const val VALUE_RESOLUTION_NS: String = "ns" + + private const val NON_NULL_JSON_ERROR: String = "expected non-null json" + } +}