Skip to content

Commit

Permalink
test: add otel export assertions
Browse files Browse the repository at this point in the history
  • Loading branch information
fractalwrench committed Oct 11, 2024
1 parent a47a22e commit ddbf505
Show file tree
Hide file tree
Showing 11 changed files with 218 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import io.embrace.android.embracesdk.internal.payload.Span
import io.embrace.android.embracesdk.internal.spans.findAttributeValue
import io.embrace.android.embracesdk.network.EmbraceNetworkRequest
import io.embrace.android.embracesdk.network.http.HttpMethod
import io.embrace.android.embracesdk.testframework.actions.EmbraceAssertionInterface
import io.embrace.android.embracesdk.testframework.actions.EmbracePayloadAssertionInterface
import io.embrace.android.embracesdk.testframework.assertions.assertMatches
import io.opentelemetry.semconv.ExceptionAttributes
import io.opentelemetry.semconv.HttpAttributes
Expand Down Expand Up @@ -348,7 +348,7 @@ internal class NetworkRequestApiTest {
)
}

private fun EmbraceAssertionInterface.validateAndReturnExpectedNetworkSpan(): Span {
private fun EmbracePayloadAssertionInterface.validateAndReturnExpectedNetworkSpan(): Span {
val session = getSingleSessionEnvelope()

val unfilteredSpans = checkNotNull(session.data.spans)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import io.embrace.android.embracesdk.internal.spans.EmbraceSpanData
import io.embrace.android.embracesdk.spans.EmbraceSpanEvent
import io.embrace.android.embracesdk.spans.ErrorCode
import io.embrace.android.embracesdk.testframework.IntegrationTestRule
import io.embrace.android.embracesdk.testframework.actions.EmbraceAssertionInterface
import io.embrace.android.embracesdk.testframework.actions.EmbracePayloadAssertionInterface
import io.opentelemetry.api.trace.SpanId
import io.opentelemetry.context.Context
import org.junit.Assert.assertEquals
Expand Down Expand Up @@ -324,9 +324,9 @@ internal class TracingApiTest {
)
}

private fun EmbraceAssertionInterface.getSdkInitSpanFromBackgroundActivity(): List<Span> {
private fun EmbracePayloadAssertionInterface.getSdkInitSpanFromBackgroundActivity(): List<Span> {
val lastSentBackgroundActivity = getSingleSessionEnvelope(ApplicationState.BACKGROUND)
val spans = checkNotNull(lastSentBackgroundActivity.data.spans)
return spans.filter { it.name == "emb-sdk-init" }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.embrace.android.embracesdk.assertions.findSpanOfType
import io.embrace.android.embracesdk.fakes.FakeBreadcrumbBehavior
import io.embrace.android.embracesdk.fakes.TestPlatformSerializer
import io.embrace.android.embracesdk.internal.arch.schema.EmbType
import io.embrace.android.embracesdk.internal.clock.nanosToMillis
import io.embrace.android.embracesdk.testframework.IntegrationTestRule
import io.embrace.android.embracesdk.testframework.assertions.assertMatches
import io.embrace.android.embracesdk.testframework.export.ExportedSpanValidator
import io.embrace.android.embracesdk.testframework.export.FilteredSpanExporter
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
Expand All @@ -33,7 +36,7 @@ internal class ActivityFeatureTest {
)
},
testCaseAction = {
recordSession() {
recordSession {
startTimeMs = clock.now()
simulateActivityLifecycle()
}
Expand All @@ -50,7 +53,11 @@ internal class ActivityFeatureTest {
assertEquals(startTimeMs, startTimeNanos?.nanosToMillis())
assertEquals(startTimeMs + 30000L, endTimeNanos?.nanosToMillis())
}
},
otelExportAssertion = {
val spans = awaitSpansWithType(EmbType.Ux.View, 1)
assertSpansMatchGoldenFile(spans, "ux-view-export.json")
}
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ internal class LowPowerFeatureTest {
assertEquals("emb-device-low-power", span.name)
assertEquals(startTimeMs, span.startTimeNanos?.nanosToMillis())
assertEquals(startTimeMs + tickTimeMs, span.endTimeNanos?.nanosToMillis())
},
otelExportAssertion = {
val spans = awaitSpansWithType(EmbType.System.LowPower, 1)
assertSpansMatchGoldenFile(spans, "system-low-power-export.json")
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ import io.embrace.android.embracesdk.internal.injection.InitModule
import io.embrace.android.embracesdk.internal.injection.ModuleInitBootstrapper
import io.embrace.android.embracesdk.internal.utils.Provider
import io.embrace.android.embracesdk.testframework.actions.EmbraceActionInterface
import io.embrace.android.embracesdk.testframework.actions.EmbraceAssertionInterface
import io.embrace.android.embracesdk.testframework.actions.EmbracePreSdkStartInterface
import io.embrace.android.embracesdk.testframework.actions.EmbraceOtelExportAssertionInterface
import io.embrace.android.embracesdk.testframework.actions.EmbracePayloadAssertionInterface
import io.embrace.android.embracesdk.testframework.actions.EmbraceSetupInterface
import io.embrace.android.embracesdk.testframework.export.FilteredSpanExporter
import org.junit.rules.ExternalResource

/**
Expand Down Expand Up @@ -68,20 +70,24 @@ internal class IntegrationTestRule(
) : ExternalResource() {

/**
* Used to perform actions on the Embrace class under test
* Instance of the test harness that is recreating on every test iteration
*/
private lateinit var action: EmbraceActionInterface
lateinit var setup: EmbraceSetupInterface

/**
* Used to perform actions on the output generated by a test
* Used to perform actions on the Embrace class under test
*/
private lateinit var assertion: EmbraceAssertionInterface
private lateinit var action: EmbraceActionInterface

/**
* Instance of the test harness that is recreating on every test iteration
* Used to perform actions on the payload generated by a test
*/
lateinit var setup: EmbraceSetupInterface
private lateinit var payloadAssertion: EmbracePayloadAssertionInterface

lateinit var preSdkStart: EmbracePreSdkStartInterface
private lateinit var otelAssertion: EmbraceOtelExportAssertionInterface
private lateinit var spanExporter: FilteredSpanExporter

lateinit var bootstrapper: ModuleInitBootstrapper

/**
Expand All @@ -94,21 +100,25 @@ internal class IntegrationTestRule(
setupAction: EmbraceSetupInterface.() -> Unit = {},
preSdkStartAction: EmbracePreSdkStartInterface.() -> Unit = {},
testCaseAction: EmbraceActionInterface.() -> Unit,
assertAction: EmbraceAssertionInterface.() -> Unit = {},
assertAction: EmbracePayloadAssertionInterface.() -> Unit = {},
otelExportAssertion: EmbraceOtelExportAssertionInterface.() -> Unit = {},
) {
setupAction(setup)
with(setup) {
val embraceImpl = EmbraceImpl(bootstrapper)
EmbraceHooks.setImpl(embraceImpl)
preSdkStartAction(preSdkStart)
embraceImpl.addSpanExporter(spanExporter)

if (startSdk) {
embraceImpl.start(overriddenCoreModule.context, appFramework) {
overriddenConfigService.apply { appFramework = it }
}
}
}
testCaseAction(action)
assertAction(assertion)
assertAction(payloadAssertion)
otelExportAssertion(otelAssertion)
}

/**
Expand All @@ -119,7 +129,9 @@ internal class IntegrationTestRule(
preSdkStart = EmbracePreSdkStartInterface(setup)
bootstrapper = setup.createBootstrapper()
action = EmbraceActionInterface(setup, bootstrapper)
assertion = EmbraceAssertionInterface(bootstrapper)
payloadAssertion = EmbracePayloadAssertionInterface(bootstrapper)
spanExporter = FilteredSpanExporter()
otelAssertion = EmbraceOtelExportAssertionInterface(spanExporter)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.embrace.android.embracesdk.testframework.actions

import io.embrace.android.embracesdk.internal.arch.schema.EmbType
import io.embrace.android.embracesdk.testframework.export.ExportedSpanValidator
import io.embrace.android.embracesdk.testframework.export.FilteredSpanExporter
import io.opentelemetry.sdk.trace.data.SpanData

/**
* Provides assertions that can be used in integration tests to validate the behavior of the SDK,
* specifically in what its OTel export looks like.
*/
internal class EmbraceOtelExportAssertionInterface(
private val spanExporter: FilteredSpanExporter,
private val validator: ExportedSpanValidator = ExportedSpanValidator()
) {

/**
* Retrieves spans with the specified type and waits until either the expected
* number of spans is reached or a timeout is exceeded.
*/
fun awaitSpansWithType(type: EmbType, expectedCount: Int): List<SpanData> {
return spanExporter.awaitSpansWithType(type, expectedCount)
}

/**
* Asserts that the provided spans match the golden file.
*/
fun assertSpansMatchGoldenFile(spans: List<SpanData>, goldenFile: String) {
validator.validate(spans, goldenFile)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import org.junit.Assert
* Provides assertions that can be used in integration tests to validate the behavior of the SDK,
* specifically in what its payload looks like.
*/
internal class EmbraceAssertionInterface(
internal class EmbracePayloadAssertionInterface(
bootstrapper: ModuleInitBootstrapper
) {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.embrace.android.embracesdk.testframework.export

import com.squareup.moshi.Types
import io.embrace.android.embracesdk.ResourceReader
import io.embrace.android.embracesdk.fakes.TestPlatformSerializer
import io.embrace.android.embracesdk.internal.utils.threadLocal
import io.opentelemetry.sdk.trace.data.SpanData
import org.junit.Assert.assertEquals

internal class ExportedSpanValidator {

private val serializer: TestPlatformSerializer by threadLocal {
TestPlatformSerializer()
}

private val type =
Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)
private val listType = Types.newParameterizedType(List::class.java, type)

fun validate(spanDataList: List<SpanData>, goldenFile: String) {
val expected: List<Map<String, Any>> = readExpectedSpan(goldenFile)
val actual = spanDataList.map { it.representAsMap() }
assertEquals(expected, actual)
}

private fun readExpectedSpan(goldenFile: String): List<Map<String, String>> {
val inputStream = ResourceReader.readResource(goldenFile)
return serializer.fromJson(inputStream, listType)
}

private fun SpanData.representAsMap(): Map<String, Any> {
val attrs: Map<String, String> = representAttributes()
return mapOf(
"name" to name,
"kind" to kind.toString(),
"status" to status.statusCode.toString(),
"startEpochNanos" to startEpochNanos.toString(),
"endEpochNanos" to endEpochNanos.toString(),
"hasEnded" to hasEnded().toString(),
"totalAttributeCount" to totalAttributeCount.toString(),
"attributes" to attrs,
"totalRecordedEvents" to totalRecordedEvents.toString(),
"events" to events,
"instrumentationScopeName" to instrumentationScopeInfo.name,
)
}

private fun SpanData.representAttributes(): Map<String, String> {
val ignoreList = listOf("emb.process_identifier", "emb.private.sequence_id")
val attrs: Map<String, String> = attributes.asMap().map {
it.key.key to it.value.toString()
}.toMap()
.filter { it.key !in ignoreList }
.toSortedMap(compareBy { it })
return attrs
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package io.embrace.android.embracesdk.testframework.export

import io.embrace.android.embracesdk.assertions.returnIfConditionMet
import io.embrace.android.embracesdk.internal.arch.schema.EmbType
import io.opentelemetry.sdk.common.CompletableResultCode
import io.opentelemetry.sdk.trace.data.SpanData
import io.opentelemetry.sdk.trace.export.SpanExporter

/**
* A [SpanExporter] used in the integration tests that allows retrieving exported spans
* to perform assertions against.
*/
internal class FilteredSpanExporter : SpanExporter {

private val spanData = mutableListOf<SpanData>()

override fun export(spans: MutableCollection<SpanData>): CompletableResultCode {
spanData.addAll(spans)
return CompletableResultCode.ofSuccess()
}

override fun flush(): CompletableResultCode {
return CompletableResultCode.ofSuccess()
}

override fun shutdown(): CompletableResultCode {
return CompletableResultCode.ofSuccess()
}

fun awaitSpansWithType(type: EmbType, expectedCount: Int): List<SpanData> {
return awaitSpanExport({
it.attributes.asMap().any { entry ->
entry.key.key == "emb.type" && entry.value == type.value
}
}, expectedCount)
}

private fun awaitSpanExport(
spanFilter: (SpanData) -> Boolean,
expectedCount: Int,
): List<SpanData> {
val supplier = { spanData.filter(spanFilter) }
return returnIfConditionMet(
desiredValueSupplier = supplier,
dataProvider = supplier,
condition = { data ->
data.size == expectedCount
},
errorMessageSupplier = {
"Timeout. Expected $expectedCount spans, but got ${supplier().size}."
}
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[
{
"name": "emb-device-low-power",
"kind": "INTERNAL",
"status": "UNSET",
"startEpochNanos": "169220160000000000",
"endEpochNanos": "169220163000000000",
"hasEnded": "true",
"totalAttributeCount": "3",
"attributes": {
"emb.type": "sys.low_power"
},
"totalRecordedEvents": "0",
"events": [],
"instrumentationScopeName": "io.embrace.android.embracesdk.core"
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[
{
"name": "emb-screen-view",
"kind": "INTERNAL",
"status": "UNSET",
"startEpochNanos": "169220160000000000",
"endEpochNanos": "169220190000000000",
"hasEnded": "true",
"totalAttributeCount": "4",
"attributes": {
"emb.type": "ux.view",
"view.name": "android.app.Activity"
},
"totalRecordedEvents": "0",
"events": [],
"instrumentationScopeName": "io.embrace.android.embracesdk.core"
}
]

0 comments on commit ddbf505

Please sign in to comment.