Skip to content

Commit

Permalink
Merge pull request #1748 from embrace-io/auto-term
Browse files Browse the repository at this point in the history
Allow auto termination of spans
  • Loading branch information
fractalwrench authored Dec 10, 2024
2 parents 56eb868 + e880753 commit 053b1e1
Show file tree
Hide file tree
Showing 7 changed files with 260 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {

Expand All @@ -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<Span>? = if (includeSnapshots) {
retrieveSpanSnapshots(isCacheAttempt)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ internal class PayloadSourceModuleImpl(
otelModule.currentSessionSpan,
otelModule.spanRepository,
otelPayloadMapperProvider(),
essentialServiceModule.processStateService,
initModule.clock,
initModule.logger
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<SpanNode>) {
nodes.forEach { node ->
if (node.span.isRecording) {
node.span.stop(endTimeMs = endTimeMs)
}
terminateSpansIfRequired(endTimeMs, node.children)
}
}

private fun buildSpanTree(): List<SpanNode> {
// 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<SpanNode>()

// 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<SpanNode>,
)
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -49,6 +51,8 @@ internal class SessionPayloadSourceImplTest {
crashId: String?,
): List<Span> = emptyList()
},
FakeProcessStateService(),
FakeClock(),
EmbLoggerImpl()
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ internal class PayloadFactoryBaTest {
currentSessionSpan,
spanRepository,
FakeOtelPayloadMapper(),
FakeProcessStateService(),
FakeClock(),
logger
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,6 +98,8 @@ internal class SessionHandlerTest {
currentSessionSpan,
spanRepository,
FakeOtelPayloadMapper(),
FakeProcessStateService(),
FakeClock(),
logger
)
val payloadSourceModule = FakePayloadSourceModule(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SessionPayload>,
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<SessionPayload>,
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)
}
}

0 comments on commit 053b1e1

Please sign in to comment.