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..949f67c06e 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,52 @@ 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() + terminateSpansIfRequired(now, roots.filter { it.span.autoTerminationMode == AutoTerminationMode.ON_BACKGROUND }) + } + + /** + * 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 -> + if (node.span.isRecording) { + node.span.stop(endTimeMs = endTimeMs) + } + terminateSpansIfRequired(endTimeMs, node.children) + } + } + + private fun buildSpanTree(): List { + // first, create nodes individually + val spans = activeSpans.values.toList().plus(completedSpans.values) + val nodes = spans.map { SpanNode(it, mutableListOf()) }.associateBy(SpanNode::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..3ea1b213f1 --- /dev/null +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/testcases/features/SpanAutoTerminationTest.kt @@ -0,0 +1,192 @@ +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.fakes.config.FakeEnabledFeatureConfig +import io.embrace.android.embracesdk.fakes.config.FakeInstrumentedConfig +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 CHILD_START_SPAN_D = "child_d" + private const val CHILD_START_SPAN_E = "child_e" + 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" + private const val BG_SPAN = "bg_span" + } + + @Rule + @JvmField + val testRule: IntegrationTestRule = IntegrationTestRule() + + @Test + fun `auto termination feature`() { + var firstSessionStart: Long? = null + var firstSessionEnd: Long? = null + var secondSessionStart: Long? = null + var secondSessionEnd: Long? = null + testRule.runTest( + instrumentedConfig = FakeInstrumentedConfig( + enabledFeatures = FakeEnabledFeatureConfig( + bgActivityCapture = true + ) + ), + testCaseAction = { + var hangingSpan: EmbraceSpan? = null + + // first session + firstSessionStart = clock.nowInNanos() + 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) + + val childD = embrace.startSpan(CHILD_START_SPAN_D, parent = parent) + embrace.startSpan(CHILD_START_SPAN_E, parent = childD) + childD?.stop() + + // 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() + } + } + firstSessionEnd = clock.nowInNanos() + + + // background activity + embrace.startSpan(BG_SPAN, autoTerminationMode = AutoTerminationMode.ON_BACKGROUND) + + // second session + secondSessionStart = clock.nowInNanos() + recordSession { + // stop a span without auto termination + hangingSpan?.stop() + } + secondSessionEnd = clock.nowInNanos() + }, + assertAction = { + val message = getSessionEnvelopes(2) + assertFirstSpans(message[0], checkNotNull(firstSessionStart), checkNotNull(firstSessionEnd)) + assertSecondSpans(message[1], checkNotNull(secondSessionStart), checkNotNull(secondSessionEnd)) + } + ) + } + + private fun assertFirstSpans( + first: Envelope, + sessionStart: Long, + sessionEnd: Long + ) { + // startSpan() with children + val rootb = first.findSpanByName(ROOT_START_SPAN) + assertEquals(sessionEnd, rootb.endTimeNanos) + + val childa = first.findSpanByName(CHILD_START_SPAN_A) + assertEquals(sessionEnd, childa.endTimeNanos) + + val childb = first.findSpanByName(CHILD_START_SPAN_B) + assertEquals(sessionEnd, childb.endTimeNanos) + + val childc = first.findSpanByName(CHILD_START_SPAN_C) + assertEquals(sessionEnd, childc.endTimeNanos) + + val childd = first.findSpanByName(CHILD_START_SPAN_D) + assertEquals(sessionStart, childd.endTimeNanos) + + val childe = first.findSpanByName(CHILD_START_SPAN_E) + assertEquals(sessionEnd, childe.endTimeNanos) + + // createSpan() + val rootc = first.findSpanByName(ROOT_CREATE_SPAN) + assertEquals(sessionEnd, 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, + sessionStart: Long, + sessionEnd: Long + ) { + // hanging span + session span, no auto-terminated spans carried over + assertEquals(3, second.data.spans?.size) + + val roota = second.findSpanByName(ROOT_HANGING_SPAN) + assertEquals(sessionStart, roota.endTimeNanos) + + // spans in background activity are not auto-terminated until the next session ends + val bgSpan = second.findSpanByName(BG_SPAN) + assertEquals(sessionEnd, bgSpan.endTimeNanos) + } +}