Skip to content
1 change: 1 addition & 0 deletions detekt_custom_safe_calls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,7 @@ datadog:
- "kotlin.collections.listOf(android.view.Window)"
- "kotlin.collections.listOf(com.datadog.android.api.InternalLogger.Target)"
- "kotlin.collections.listOf(com.datadog.android.rum.internal.vitals.FPSVitalListener)"
- "kotlin.collections.listOf(com.datadog.android.flags.model.BatchedFlagEvaluations.FlagEvaluation)"
- "kotlin.collections.listOf(com.datadog.android.rum.model.ActionEvent.Interface)"
- "kotlin.collections.listOf(com.datadog.android.rum.model.ErrorEvent.Interface)"
- "kotlin.collections.listOf(com.datadog.android.rum.model.LongTaskEvent.Interface)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,67 @@ import com.datadog.android.api.net.Request
import com.datadog.android.api.net.RequestExecutionContext
import com.datadog.android.api.net.RequestFactory
import com.datadog.android.api.storage.RawBatchEvent
import com.datadog.android.core.internal.utils.join
import java.util.UUID

/**
* Placeholder RequestFactory for evaluations endpoint.
* Factory for creating requests to the EVP intake endpoint for evaluation logging.
*
* Request format:
* - Endpoint: /api/v2/flagevaluations
* - Content-Type: text/plain
* - Payload: NDJSON (newline-delimited JSON) of FlagEvaluation events
*
* @param internalLogger logger for errors
* @param customEvaluationEndpoint optional custom endpoint override
*/
internal class EvaluationsRequestFactory(
@Suppress("UnusedPrivateProperty")
private val internalLogger: InternalLogger,
@Suppress("UnusedPrivateProperty")
private val customEvaluationEndpoint: String?
) : RequestFactory {

override fun create(
context: DatadogContext,
executionContext: RequestExecutionContext,
batchData: List<RawBatchEvent>,
batchMetadata: ByteArray?
): Request? {
return null
): Request {
val requestId = UUID.randomUUID().toString()
val url = customEvaluationEndpoint ?: (context.site.intakeEndpoint + EVALUATION_ENDPOINT)

return Request(
id = requestId,
description = "Evaluation Request",
url = url,
headers = buildHeaders(
requestId = requestId,
clientToken = context.clientToken,
source = context.source,
sdkVersion = context.sdkVersion
),
body = batchData.map { it.data }
.join(
separator = PAYLOAD_SEPARATOR,
internalLogger = internalLogger
),
contentType = RequestFactory.CONTENT_TYPE_TEXT_UTF8
)
}

private fun buildHeaders(
requestId: String,
clientToken: String,
source: String,
sdkVersion: String
): Map<String, String> = mapOf(
RequestFactory.HEADER_API_KEY to clientToken,
RequestFactory.HEADER_EVP_ORIGIN to source,
RequestFactory.HEADER_EVP_ORIGIN_VERSION to sdkVersion,
RequestFactory.HEADER_REQUEST_ID to requestId
)

private companion object {
private const val EVALUATION_ENDPOINT = "/api/v2/flagevaluations"
private val PAYLOAD_SEPARATOR = "\n".toByteArray(Charsets.UTF_8)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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.flags.internal.storage

import com.datadog.android.api.feature.Feature
import com.datadog.android.api.feature.FeatureSdkCore
import com.datadog.android.api.storage.EventType
import com.datadog.android.api.storage.RawBatchEvent
import com.datadog.android.flags.internal.EvaluationEventWriter
import com.datadog.android.flags.model.FlagEvaluation

/**
* Persists serialized flag evaluation events to SDK Core storage.
*
* Events are written to the EVALUATIONS feature storage and will be uploaded
* to the EVP intake endpoint by the SDK Core's upload mechanism.
*/
internal class EvaluationEventRecordWriter(private val sdkCore: FeatureSdkCore) : EvaluationEventWriter {
override fun writeAll(events: List<FlagEvaluation>) {
Copy link
Member

Choose a reason for hiding this comment

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

nit: maybe can be just write

if (events.isEmpty()) return

Copy link
Member

Choose a reason for hiding this comment

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

one more thing: it is better to wrap a code block below synchronized(this) to avoid race condition when the same batch is accessed

sdkCore.getFeature(Feature.FLAGS_EVALUATIONS_FEATURE_NAME)
?.withWriteContext { _, writeScope ->
writeScope { batchWriter ->
for (event in events) {
val serializedRecord = event.toJson().toString().toByteArray(Charsets.UTF_8)
val rawBatchEvent = RawBatchEvent(data = serializedRecord)
batchWriter.write(
event = rawBatchEvent,
batchMetadata = null,
eventType = EventType.DEFAULT
)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
* 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.flags.internal.net

import com.datadog.android.api.InternalLogger
import com.datadog.android.api.context.DatadogContext
import com.datadog.android.api.net.RequestExecutionContext
import com.datadog.android.api.net.RequestFactory
import com.datadog.android.api.storage.RawBatchEvent
import com.datadog.android.core.internal.utils.join
import com.datadog.android.flags.utils.forge.ForgeConfigurator
import fr.xgouchet.elmyr.Forge
import fr.xgouchet.elmyr.annotation.Forgery
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.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension

@ExtendWith(MockitoExtension::class, ForgeExtension::class)
@ForgeConfiguration(ForgeConfigurator::class)
internal class EvaluationsRequestFactoryTest {

private lateinit var testedFactory: EvaluationsRequestFactory

@Forgery
lateinit var fakeDatadogContext: DatadogContext

@Mock
lateinit var mockInternalLogger: InternalLogger

@BeforeEach
fun `set up`() {
testedFactory = EvaluationsRequestFactory(
internalLogger = mockInternalLogger,
customEvaluationEndpoint = null
)
}

@Test
fun `M create a proper request W create()`(
@Forgery batchData: List<RawBatchEvent>,
@Forgery executionContext: RequestExecutionContext,
@StringForgery batchMetadata: String,
forge: Forge
) {
// Given
val metaData = forge.aNullable { batchMetadata.toByteArray() }

// When
val request = testedFactory.create(fakeDatadogContext, executionContext, batchData, metaData)

// Then
requireNotNull(request)
assertThat(request.url).isEqualTo("${fakeDatadogContext.site.intakeEndpoint}/api/v2/flagevaluations")
assertThat(request.contentType).isEqualTo(RequestFactory.CONTENT_TYPE_TEXT_UTF8)
assertThat(request.headers.minus(RequestFactory.HEADER_REQUEST_ID)).isEqualTo(
mapOf(
RequestFactory.HEADER_API_KEY to fakeDatadogContext.clientToken,
RequestFactory.HEADER_EVP_ORIGIN to fakeDatadogContext.source,
RequestFactory.HEADER_EVP_ORIGIN_VERSION to fakeDatadogContext.sdkVersion
)
)
assertThat(request.headers[RequestFactory.HEADER_REQUEST_ID]).isNotEmpty()
assertThat(request.id).isEqualTo(request.headers[RequestFactory.HEADER_REQUEST_ID])
assertThat(request.description).isEqualTo("Evaluation Request")
assertThat(request.body).isEqualTo(
batchData.map { it.data }.join(
separator = "\n".toByteArray(Charsets.UTF_8),
internalLogger = mockInternalLogger
)
)
}

@Test
fun `M generate unique request IDs W create() { multiple calls }`(
@Forgery batchData: List<RawBatchEvent>,
@Forgery executionContext: RequestExecutionContext,
@StringForgery batchMetadata: String,
forge: Forge
) {
// Given
val metadata = forge.aNullable { batchMetadata.toByteArray() }

// When
val request1 = testedFactory.create(fakeDatadogContext, executionContext, batchData, metadata)
val request2 = testedFactory.create(fakeDatadogContext, executionContext, batchData, metadata)

// Then
assertThat(request1.id).isNotEqualTo(request2.id)
assertThat(request1.headers[RequestFactory.HEADER_REQUEST_ID])
.isNotEqualTo(request2.headers[RequestFactory.HEADER_REQUEST_ID])
assertThat(request1.id).isEqualTo(request1.headers[RequestFactory.HEADER_REQUEST_ID])
assertThat(request2.id).isEqualTo(request2.headers[RequestFactory.HEADER_REQUEST_ID])
}

@Test
fun `M preserve batch data order W create() { multiple batch events }`(
Copy link
Member

Choose a reason for hiding this comment

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

does it actually matter? should we preserve the order?

@Forgery executionContext: RequestExecutionContext,
@StringForgery batchMetadata: String,
forge: Forge
) {
// Given
val event1 = RawBatchEvent(data = "event1".toByteArray())
val event2 = RawBatchEvent(data = "event2".toByteArray())
val event3 = RawBatchEvent(data = "event3".toByteArray())
val batchData = listOf(event1, event2, event3)
val metadata = forge.aNullable { batchMetadata.toByteArray() }

// When
val request = testedFactory.create(fakeDatadogContext, executionContext, batchData, metadata)

// Then
val expectedBody = "event1\nevent2\nevent3".toByteArray()
assertThat(request.body).isEqualTo(expectedBody)
}

@Test
fun `M use custom endpoint W create() { custom endpoint provided }`(
@Forgery batchData: List<RawBatchEvent>,
@Forgery executionContext: RequestExecutionContext,
@StringForgery(regex = "https://[a-z]+\\.com(/[a-z]+)+") customEndpoint: String,
@StringForgery batchMetadata: String,
forge: Forge
) {
// Given
val factoryWithCustomEndpoint = EvaluationsRequestFactory(
internalLogger = mockInternalLogger,
customEvaluationEndpoint = customEndpoint
)
val metadata = forge.aNullable { batchMetadata.toByteArray() }

// When
val request = factoryWithCustomEndpoint.create(fakeDatadogContext, executionContext, batchData, metadata)

// Then
requireNotNull(request)
assertThat(request.url).isEqualTo(customEndpoint)
assertThat(request.contentType).isEqualTo(RequestFactory.CONTENT_TYPE_TEXT_UTF8)
assertThat(request.headers.minus(RequestFactory.HEADER_REQUEST_ID)).isEqualTo(
mapOf(
RequestFactory.HEADER_API_KEY to fakeDatadogContext.clientToken,
RequestFactory.HEADER_EVP_ORIGIN to fakeDatadogContext.source,
RequestFactory.HEADER_EVP_ORIGIN_VERSION to fakeDatadogContext.sdkVersion
)
)
assertThat(request.headers[RequestFactory.HEADER_REQUEST_ID]).isNotEmpty()
assertThat(request.id).isEqualTo(request.headers[RequestFactory.HEADER_REQUEST_ID])
assertThat(request.description).isEqualTo("Evaluation Request")
assertThat(request.body).isEqualTo(
batchData.map { it.data }.join(
separator = "\n".toByteArray(Charsets.UTF_8),
internalLogger = mockInternalLogger
)
)
}
}
Loading
Loading