From da0c39edd975ae08cc9e711658b0802b5031eb2a Mon Sep 17 00:00:00 2001 From: Jamie Lynch Date: Fri, 6 Dec 2024 10:17:37 +0000 Subject: [PATCH] allow auto termination of spans --- .../session/SessionPayloadSourceImpl.kt | 8 ++ .../injection/PayloadSourceModuleImpl.kt | 2 + .../internal/spans/SpanRepository.kt | 48 +++++++ .../session/SessionPayloadSourceImplTest.kt | 4 + .../internal/session/PayloadFactoryBaTest.kt | 2 + .../internal/session/SessionHandlerTest.kt | 3 + .../features/SpanAutoTerminationTest.kt | 131 ++++++++++++++++++ 7 files changed, 198 insertions(+) create mode 100644 embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/SpanAutoTerminationTest.kt diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/envelope/session/SessionPayloadSourceImpl.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/envelope/session/SessionPayloadSourceImpl.kt index 46ac6085d4..4779d74269 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/envelope/session/SessionPayloadSourceImpl.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/envelope/session/SessionPayloadSourceImpl.kt @@ -2,11 +2,13 @@ package io.embrace.android.embracesdk.internal.envelope.session import io.embrace.android.embracesdk.internal.arch.schema.AppTerminationCause import io.embrace.android.embracesdk.internal.arch.schema.EmbType +import io.embrace.android.embracesdk.internal.clock.Clock import io.embrace.android.embracesdk.internal.logging.EmbLogger import io.embrace.android.embracesdk.internal.payload.SessionPayload import io.embrace.android.embracesdk.internal.payload.Span import io.embrace.android.embracesdk.internal.payload.toNewPayload import io.embrace.android.embracesdk.internal.session.captureDataSafely +import io.embrace.android.embracesdk.internal.session.lifecycle.ProcessStateService import io.embrace.android.embracesdk.internal.session.orchestrator.SessionSnapshotType import io.embrace.android.embracesdk.internal.spans.CurrentSessionSpan import io.embrace.android.embracesdk.internal.spans.EmbraceSpanData @@ -19,6 +21,8 @@ internal class SessionPayloadSourceImpl( private val currentSessionSpan: CurrentSessionSpan, private val spanRepository: SpanRepository, private val otelPayloadMapper: OtelPayloadMapper, + private val processStateService: ProcessStateService, + private val clock: Clock, private val logger: EmbLogger, ) : SessionPayloadSource { @@ -31,6 +35,10 @@ internal class SessionPayloadSourceImpl( val isCacheAttempt = endType == SessionSnapshotType.PERIODIC_CACHE val includeSnapshots = endType != SessionSnapshotType.JVM_CRASH + if (!endType.forceQuit && processStateService.isInBackground) { + spanRepository.autoTerminateSpans(clock.now()) + } + // Snapshots should only be included if the process is expected to last beyond the current session val snapshots: List? = if (includeSnapshots) { retrieveSpanSnapshots(isCacheAttempt) diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/PayloadSourceModuleImpl.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/PayloadSourceModuleImpl.kt index d7b714772d..eeb648f83e 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/PayloadSourceModuleImpl.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/injection/PayloadSourceModuleImpl.kt @@ -56,6 +56,8 @@ internal class PayloadSourceModuleImpl( otelModule.currentSessionSpan, otelModule.spanRepository, otelPayloadMapperProvider(), + essentialServiceModule.processStateService, + initModule.clock, initModule.logger ) } diff --git a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/spans/SpanRepository.kt b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/spans/SpanRepository.kt index d63623fb02..a16bcaa576 100644 --- a/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/spans/SpanRepository.kt +++ b/embrace-android-core/src/main/kotlin/io/embrace/android/embracesdk/internal/spans/SpanRepository.kt @@ -2,6 +2,7 @@ package io.embrace.android.embracesdk.internal.spans import io.embrace.android.embracesdk.internal.arch.schema.EmbType import io.embrace.android.embracesdk.internal.utils.lockAndRun +import io.embrace.android.embracesdk.spans.AutoTerminationMode import io.embrace.android.embracesdk.spans.EmbraceSpan import io.embrace.android.embracesdk.spans.ErrorCode import java.util.concurrent.ConcurrentHashMap @@ -101,4 +102,51 @@ class SpanRepository { } private fun notTracked(spanId: String): Boolean = activeSpans[spanId] == null && completedSpans[spanId] == null + + /** + * Automatically terminates root spans + */ + fun autoTerminateSpans(now: Long) { + val roots = buildSpanTree().filter { it.span.autoTerminationMode == AutoTerminationMode.ON_BACKGROUND } + terminateSpansIfRequired(now, roots) + } + + /** + * Terminates any spans & their descendants that are set to auto terminate on the process entering the background. + * + * The root span and their descendants are terminated via depth-first traversal. The end time is guaranteed + * to be the same for any auto-terminated spans. + */ + private fun terminateSpansIfRequired(endTimeMs: Long, nodes: List) { + nodes.forEach { node -> + val span = node.span + span.stop(endTimeMs = endTimeMs) + terminateSpansIfRequired(endTimeMs, node.children) + } + } + + private fun buildSpanTree(): List { + // first, create nodes individually + val spans = activeSpans.values.toList() + val nodes = spans.map { SpanNode(it, mutableListOf()) }.associateBy { it.span } + val roots = mutableListOf() + + // then build relationships between nodes + spans.forEach { span -> + nodes[span]?.let { node -> + if (span.parent != null) { + nodes[span.parent]?.children?.add(node) + } else { + roots.add(node) + } + } + } + // finally, return a list of root nodes + return roots.toList() + } + + private data class SpanNode( + val span: EmbraceSpan, + val children: MutableList, + ) } diff --git a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/envelope/session/SessionPayloadSourceImplTest.kt b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/envelope/session/SessionPayloadSourceImplTest.kt index 93cdb03c30..762f105390 100644 --- a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/envelope/session/SessionPayloadSourceImplTest.kt +++ b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/envelope/session/SessionPayloadSourceImplTest.kt @@ -1,7 +1,9 @@ package io.embrace.android.embracesdk.internal.envelope.session +import io.embrace.android.embracesdk.fakes.FakeClock import io.embrace.android.embracesdk.fakes.FakeCurrentSessionSpan import io.embrace.android.embracesdk.fakes.FakePersistableEmbraceSpan +import io.embrace.android.embracesdk.fakes.FakeProcessStateService import io.embrace.android.embracesdk.fakes.FakeSpanData import io.embrace.android.embracesdk.internal.arch.schema.EmbType import io.embrace.android.embracesdk.internal.logging.EmbLoggerImpl @@ -49,6 +51,8 @@ internal class SessionPayloadSourceImplTest { crashId: String?, ): List = emptyList() }, + FakeProcessStateService(), + FakeClock(), EmbLoggerImpl() ) } diff --git a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/session/PayloadFactoryBaTest.kt b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/session/PayloadFactoryBaTest.kt index 39568539ab..12be35b2f9 100644 --- a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/session/PayloadFactoryBaTest.kt +++ b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/session/PayloadFactoryBaTest.kt @@ -130,6 +130,8 @@ internal class PayloadFactoryBaTest { currentSessionSpan, spanRepository, FakeOtelPayloadMapper(), + FakeProcessStateService(), + FakeClock(), logger ) ) diff --git a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/session/SessionHandlerTest.kt b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/session/SessionHandlerTest.kt index 4aa7214a3d..a89592b294 100644 --- a/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/session/SessionHandlerTest.kt +++ b/embrace-android-core/src/test/java/io/embrace/android/embracesdk/internal/session/SessionHandlerTest.kt @@ -12,6 +12,7 @@ import io.embrace.android.embracesdk.fakes.FakeMemoryCleanerService import io.embrace.android.embracesdk.fakes.FakeMetadataService import io.embrace.android.embracesdk.fakes.FakeOtelPayloadMapper import io.embrace.android.embracesdk.fakes.FakePreferenceService +import io.embrace.android.embracesdk.fakes.FakeProcessStateService import io.embrace.android.embracesdk.fakes.FakeSessionIdTracker import io.embrace.android.embracesdk.fakes.FakeSessionPropertiesService import io.embrace.android.embracesdk.fakes.FakeUserService @@ -97,6 +98,8 @@ internal class SessionHandlerTest { currentSessionSpan, spanRepository, FakeOtelPayloadMapper(), + FakeProcessStateService(), + FakeClock(), logger ) val payloadSourceModule = FakePayloadSourceModule( diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/SpanAutoTerminationTest.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/SpanAutoTerminationTest.kt new file mode 100644 index 0000000000..7a63f17300 --- /dev/null +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/SpanAutoTerminationTest.kt @@ -0,0 +1,131 @@ +package io.embrace.android.embracesdk.testcases.features + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.embrace.android.embracesdk.assertions.findSpanByName +import io.embrace.android.embracesdk.internal.payload.Envelope +import io.embrace.android.embracesdk.internal.payload.SessionPayload +import io.embrace.android.embracesdk.spans.AutoTerminationMode +import io.embrace.android.embracesdk.spans.EmbraceSpan +import io.embrace.android.embracesdk.testframework.IntegrationTestRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class SpanAutoTerminationTest { + + private companion object { + private const val ROOT_HANGING_SPAN = "root_a" + private const val ROOT_START_SPAN = "root_b" + private const val CHILD_START_SPAN_A = "child_a" + private const val CHILD_START_SPAN_B = "child_b" + private const val CHILD_START_SPAN_C = "child_c" + private const val ROOT_CREATE_SPAN = "root_c" + private const val ROOT_RECORD_SPAN = "root_d" + private const val ROOT_RECORD_COMPLETED_SPAN = "root_e" + private const val ROOT_STOPPED_SPAN = "root_f" + } + + @Rule + @JvmField + val testRule: IntegrationTestRule = IntegrationTestRule() + + @Test + fun `auto termination feature`() { + testRule.runTest( + testCaseAction = { + var hangingSpan: EmbraceSpan? = null + recordSession { + // start a span without auto termination + hangingSpan = embrace.startSpan(ROOT_HANGING_SPAN, autoTerminationMode = AutoTerminationMode.NONE) + + // start a span with children and auto termination + val parent = embrace.startSpan(ROOT_START_SPAN, autoTerminationMode = AutoTerminationMode.ON_BACKGROUND) + embrace.startSpan( + CHILD_START_SPAN_A, + parent = parent, + autoTerminationMode = AutoTerminationMode.ON_BACKGROUND + ) + val childB = embrace.startSpan(CHILD_START_SPAN_B, parent = parent) + embrace.startSpan(CHILD_START_SPAN_C, parent = childB) + + // create a span with auto termination + embrace.createSpan(ROOT_CREATE_SPAN, autoTerminationMode = AutoTerminationMode.ON_BACKGROUND)?.start() + + // record a span + embrace.recordSpan(ROOT_RECORD_SPAN) { + embrace.addBreadcrumb("Hello, world!") + } + + // record a completed span + embrace.recordCompletedSpan(ROOT_RECORD_COMPLETED_SPAN, clock.now() - 1000, clock.now()) + + // stop a span with auto termination + embrace.createSpan(ROOT_STOPPED_SPAN, autoTerminationMode = AutoTerminationMode.ON_BACKGROUND)?.apply { + start() + stop() + } + } + + recordSession { + // stop a span without auto termination + clock.tick(1000) + hangingSpan?.stop() + } + }, + assertAction = { + val message = getSessionEnvelopes(2) + assertFirstSpans(message[0]) + assertSecondSpans(message[1]) + } + ) + } + + private fun assertFirstSpans(first: Envelope) { + val expectedEnd = 169220190000000000 + + // startSpan() with children + val rootb = first.findSpanByName(ROOT_START_SPAN) + assertEquals(expectedEnd, rootb.endTimeNanos) + + val childa = first.findSpanByName(CHILD_START_SPAN_A) + assertEquals(expectedEnd, childa.endTimeNanos) + + val childb = first.findSpanByName(CHILD_START_SPAN_B) + assertEquals(expectedEnd, childb.endTimeNanos) + + val childc = first.findSpanByName(CHILD_START_SPAN_C) + assertEquals(expectedEnd, childc.endTimeNanos) + + // createSpan() + val rootc = first.findSpanByName(ROOT_CREATE_SPAN) + assertEquals(expectedEnd, rootc.endTimeNanos) + + // recordSpan() + val rootd = first.findSpanByName(ROOT_RECORD_SPAN) + assertEquals(rootd.startTimeNanos, rootd.endTimeNanos) + + // recordCompletedSpan() + val roote = first.findSpanByName(ROOT_RECORD_COMPLETED_SPAN) + assertNotNull(roote.endTimeNanos) + + // stopped span + val rootf = first.findSpanByName(ROOT_STOPPED_SPAN) + assertEquals(rootf.startTimeNanos, rootf.endTimeNanos) + + // non-terminated span + val hangingSpan = checkNotNull(first.data.spanSnapshots?.single { it.name == ROOT_HANGING_SPAN }) + assertNull(hangingSpan.endTimeNanos) + } + + private fun assertSecondSpans(second: Envelope) { + // hanging span + session span, no auto-terminated spans carried over + assertEquals(2, second.data.spans?.size) + + val roota = second.findSpanByName(ROOT_HANGING_SPAN) + assertEquals(169220191000000000, roota.endTimeNanos) + } +}