diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/NetworkRequestApiTest.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/NetworkRequestApiTest.kt index 4ded916551..8562cb8b96 100644 --- a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/NetworkRequestApiTest.kt +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/NetworkRequestApiTest.kt @@ -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 @@ -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) diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/TracingApiTest.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/TracingApiTest.kt index 0cb0cc77a3..6caa602622 100644 --- a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/TracingApiTest.kt +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/TracingApiTest.kt @@ -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 @@ -324,9 +324,9 @@ internal class TracingApiTest { ) } - private fun EmbraceAssertionInterface.getSdkInitSpanFromBackgroundActivity(): List { + private fun EmbracePayloadAssertionInterface.getSdkInitSpanFromBackgroundActivity(): List { val lastSentBackgroundActivity = getSingleSessionEnvelope(ApplicationState.BACKGROUND) val spans = checkNotNull(lastSentBackgroundActivity.data.spans) return spans.filter { it.name == "emb-sdk-init" } } -} \ No newline at end of file +} diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/ActivityFeatureTest.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/ActivityFeatureTest.kt index 7cda033cc0..dd6d9786a8 100644 --- a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/ActivityFeatureTest.kt +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/ActivityFeatureTest.kt @@ -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 @@ -33,7 +36,7 @@ internal class ActivityFeatureTest { ) }, testCaseAction = { - recordSession() { + recordSession { startTimeMs = clock.now() simulateActivityLifecycle() } @@ -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") } ) } -} \ No newline at end of file +} diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/LowPowerFeatureTest.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/LowPowerFeatureTest.kt index 86d25eb3a5..685621fdc9 100644 --- a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/LowPowerFeatureTest.kt +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/LowPowerFeatureTest.kt @@ -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") } ) } diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/IntegrationTestRule.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/IntegrationTestRule.kt index 28133340a2..288625398d 100644 --- a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/IntegrationTestRule.kt +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/IntegrationTestRule.kt @@ -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 /** @@ -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 /** @@ -94,13 +100,16 @@ 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 } @@ -108,7 +117,8 @@ internal class IntegrationTestRule( } } testCaseAction(action) - assertAction(assertion) + assertAction(payloadAssertion) + otelExportAssertion(otelAssertion) } /** @@ -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) } /** diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/actions/EmbraceOtelExportAssertionInterface.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/actions/EmbraceOtelExportAssertionInterface.kt new file mode 100644 index 0000000000..63b2548af4 --- /dev/null +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/actions/EmbraceOtelExportAssertionInterface.kt @@ -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 { + return spanExporter.awaitSpansWithType(type, expectedCount) + } + + /** + * Asserts that the provided spans match the golden file. + */ + fun assertSpansMatchGoldenFile(spans: List, goldenFile: String) { + validator.validate(spans, goldenFile) + } +} diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/actions/EmbraceAssertionInterface.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/actions/EmbracePayloadAssertionInterface.kt similarity index 99% rename from embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/actions/EmbraceAssertionInterface.kt rename to embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/actions/EmbracePayloadAssertionInterface.kt index 483050bf3b..8bbc298548 100644 --- a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/actions/EmbraceAssertionInterface.kt +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/actions/EmbracePayloadAssertionInterface.kt @@ -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 ) { diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/export/ExportedSpanValidator.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/export/ExportedSpanValidator.kt new file mode 100644 index 0000000000..3893b9e8db --- /dev/null +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/export/ExportedSpanValidator.kt @@ -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, goldenFile: String) { + val expected: List> = readExpectedSpan(goldenFile) + val actual = spanDataList.map { it.representAsMap() } + assertEquals(expected, actual) + } + + private fun readExpectedSpan(goldenFile: String): List> { + val inputStream = ResourceReader.readResource(goldenFile) + return serializer.fromJson(inputStream, listType) + } + + private fun SpanData.representAsMap(): Map { + val attrs: Map = 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 { + val ignoreList = listOf("emb.process_identifier", "emb.private.sequence_id") + val attrs: Map = attributes.asMap().map { + it.key.key to it.value.toString() + }.toMap() + .filter { it.key !in ignoreList } + .toSortedMap(compareBy { it }) + return attrs + } +} diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/export/FilteredSpanExporter.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/export/FilteredSpanExporter.kt new file mode 100644 index 0000000000..a1a4614417 --- /dev/null +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testframework/export/FilteredSpanExporter.kt @@ -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() + + override fun export(spans: MutableCollection): 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 { + 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 { + 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}." + } + ) + } +} diff --git a/embrace-android-sdk/src/integrationTest/resources/system-low-power-export.json b/embrace-android-sdk/src/integrationTest/resources/system-low-power-export.json new file mode 100644 index 0000000000..aee67ec0e9 --- /dev/null +++ b/embrace-android-sdk/src/integrationTest/resources/system-low-power-export.json @@ -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" + } +] diff --git a/embrace-android-sdk/src/integrationTest/resources/ux-view-export.json b/embrace-android-sdk/src/integrationTest/resources/ux-view-export.json new file mode 100644 index 0000000000..ca09ba9722 --- /dev/null +++ b/embrace-android-sdk/src/integrationTest/resources/ux-view-export.json @@ -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" + } +]