Skip to content

Commit b1bdbbb

Browse files
committed
fix(tracing): Stop idle spans from keeping idle transactions alive until their deadline (JAVA-795)
An idle span (SpanOptions.isIdle) is meant to be finished by its parent transaction when the transaction finishes. Before this commit, SentryTracer treated an unfinished idle span as blocking. Creating one canceled the idle timer; SentryTracer.hasAllChildrenFinished() counted it as unfinished; and the timer never restarted. As a consequence, any idle transaction that i) waited for its children (i.e., all Android idle transactions, including ui.load and nav transactions) and ii) had an unfinished idle span was kept alive until its deadline (30 s by default), causing it to be reported as DEADLINE_EXCEEDED even though in most cases no actual work was keeping the transaction alive. **Current producers of idle spans** SentryTraced is the SDK's only producer of idle spans, so the effects mentioned above are limited to idle transactions that, prior to this commit, contained a span from SentryTraced. (The issue has been with SentryTraced from the beginning – see #2507).
1 parent 07a8d1b commit b1bdbbb

5 files changed

Lines changed: 105 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Fixes
66

7+
- Idle spans (including those produced by `SentryTraced`) now let any idle parent transaction finish after the idle timeout rather than waiting for the deadline and reporting `DEADLINE_EXCEEDED` ([#6396](https://github.com/getsentry/sentry-java/pull/6396)).
78
- Keep UI profiling running when `startProfiler` is called before a requested stop takes effect at the end of the current chunk ([#6291](https://github.com/getsentry/sentry-java/pull/6291))
89
- Before, the start call was ignored and the profiler still stopped at the end of the chunk, even in `TRACE` mode while sampled spans were active. On API 34 and lower, every later profile also stopped after one chunk.
910

‎sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import io.sentry.ITransaction
3131
import io.sentry.NoOpSpan
3232
import io.sentry.Sentry
3333
import io.sentry.SentryOptions
34+
import io.sentry.SpanStatus
3435
import io.sentry.TransactionOptions
3536
import kotlin.test.assertFailsWith
3637
import org.junit.After
@@ -446,8 +447,30 @@ class SentryTracedTest {
446447
assertThat(renderingTx.countSpans(OP_RENDER)).isEqualTo(0)
447448
}
448449

450+
@Test
451+
fun `an idle transaction hosting a traced composable finishes after the idle timeout`() {
452+
val tx =
453+
initSentryAndStartTransaction(
454+
"tx",
455+
configureTransactionOptions = { transactionOptions ->
456+
transactionOptions.idleTimeout = 1_000
457+
transactionOptions.isWaitForChildren = true
458+
},
459+
)
460+
461+
rule.setContent {
462+
ProvideSentrySpan(tx) { SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } }
463+
}
464+
tx.waitForSpanCount(OP_COMPOSE, 1)
465+
466+
rule.waitUntil(timeoutMillis = 5_000) { tx.isFinished }
467+
assertThat(tx.status).isEqualTo(SpanStatus.OK)
468+
assertThat(tx.singleSpan(OP_PARENT_COMPOSITION).isFinished).isTrue()
469+
}
470+
449471
private fun initSentryAndStartTransaction(
450472
name: String,
473+
configureTransactionOptions: (TransactionOptions) -> Unit = {},
451474
configureOptions: (SentryOptions) -> Unit = {},
452475
): ITransaction {
453476
lateinit var tx: ITransaction
@@ -460,17 +483,24 @@ class SentryTracedTest {
460483
},
461484
true,
462485
)
463-
tx = startBoundTransaction(name)
486+
tx = startBoundTransaction(name, configureTransactionOptions)
464487
var boundTransaction: ITransaction? = null
465488
Sentry.configureScope { boundTransaction = it.transaction }
466489
assertThat(boundTransaction).isSameInstanceAs(tx)
467490
}
468491
return tx
469492
}
470493

471-
private fun startBoundTransaction(name: String): ITransaction {
472-
val transaction =
473-
Sentry.startTransaction(name, "test", TransactionOptions().apply { isBindToScope = true })
494+
private fun startBoundTransaction(
495+
name: String,
496+
configureTransactionOptions: (TransactionOptions) -> Unit = {},
497+
): ITransaction {
498+
val transactionOptions =
499+
TransactionOptions().apply {
500+
isBindToScope = true
501+
configureTransactionOptions(this)
502+
}
503+
val transaction = Sentry.startTransaction(name, "test", transactionOptions)
474504
Sentry.configureScope { it.setTransaction(transaction) }
475505
return transaction
476506
}

‎sentry/src/main/java/io/sentry/SentryTracer.java‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,13 @@ private ISpan createChild(
469469
if (children.size() < scopes.getOptions().getMaxSpans()) {
470470
Objects.requireNonNull(parentSpanId, "parentSpanId is required");
471471
Objects.requireNonNull(operation, "operation is required");
472-
cancelIdleTimer();
472+
473+
// When this transaction finishes, we want its idle spans to finish as well. We don't let idle
474+
// spans cancel the idle timer b/c it would keep the transaction alive until its deadline.
475+
if (!spanOptions.isIdle()) {
476+
cancelIdleTimer();
477+
}
478+
473479
final Span span =
474480
new Span(
475481
this,
@@ -762,10 +768,20 @@ private void updateBaggageValues(final @NotNull Baggage baggage) {
762768
return null;
763769
}
764770

771+
/**
772+
* Returns true if no child span should keep this transaction from finishing.
773+
*
774+
* <p>Ignores idle spans. Including them would mean an idle transaction would never restart its
775+
* idle timer once it had an unfinished idle span, causing the transaction to always live until
776+
* its deadline.
777+
*/
765778
private boolean hasAllChildrenFinished() {
766779
@NotNull final ListIterator<Span> iterator = this.children.listIterator();
767780
while (iterator.hasNext()) {
768781
@NotNull final Span span = iterator.next();
782+
if (span.getOptions().isIdle()) {
783+
continue;
784+
}
769785
// This is used in the spanFinishCallback, when the span isn't finished, but has a finish
770786
// date
771787
if (!span.isFinished() && span.getFinishDate() == null) {

‎sentry/src/main/java/io/sentry/SpanOptions.java‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,11 @@ public void setStartTimestamp(@Nullable SentryDate startTimestamp) {
4848
private boolean trimEnd = false;
4949

5050
/**
51-
* true if the span is considered idle and should be automatically finished when it's parent gets
52-
* finished.
51+
* `true` if the span is idle, in which case its transaction finishes it when the transaction
52+
* finishes, with the transaction's status and end time.
53+
*
54+
* <p>Note: An unfinished idle span doesn't keep a transaction that waits for its children from
55+
* finishing, but non-idle spans created under it do.
5356
*/
5457
private boolean isIdle = false;
5558

‎sentry/src/test/java/io/sentry/SentryTracerTest.kt‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,6 +1204,54 @@ class SentryTracerTest {
12041204
assertNull(transaction.idleTimeoutFuture)
12051205
}
12061206

1207+
@Test
1208+
fun `when idle transaction has only an unfinished idle span, finishes the transaction and the idle span after the idle timeout`() {
1209+
val transaction = fixture.getSut(waitForChildren = true, idleTimeout = 50)
1210+
1211+
val idleSpan = transaction.startChild("ui.load", null, SpanOptions().apply { isIdle = true })
1212+
1213+
await.untilAsserted {
1214+
verify(fixture.scopes).captureTransaction(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())
1215+
}
1216+
assertThat(transaction.status).isEqualTo(SpanStatus.OK)
1217+
assertThat(idleSpan.isFinished).isTrue()
1218+
assertThat(idleSpan.status).isEqualTo(SpanStatus.OK)
1219+
}
1220+
1221+
@Test
1222+
fun `when idle transaction has an idle span with a finished child, finishes the transaction after the idle timeout`() {
1223+
val transaction = fixture.getSut(waitForChildren = true, idleTimeout = 50)
1224+
val idleSpan = transaction.startChild("ui.load", null, SpanOptions().apply { isIdle = true })
1225+
1226+
idleSpan.startChild("ui.render").finish()
1227+
1228+
await.untilAsserted {
1229+
verify(fixture.scopes).captureTransaction(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())
1230+
}
1231+
assertThat(transaction.status).isEqualTo(SpanStatus.OK)
1232+
}
1233+
1234+
@Test
1235+
fun `when idle transaction has an unfinished span under an idle span, does not finish the transaction until that span finishes`() {
1236+
val transaction = fixture.getSut(waitForChildren = true, idleTimeout = 50)
1237+
val idleSpan = transaction.startChild("ui.load", null, SpanOptions().apply { isIdle = true })
1238+
val spanUnderIdleSpan = idleSpan.startChild("ui.render")
1239+
1240+
transaction.startChild("op").finish()
1241+
Thread.sleep(200)
1242+
1243+
assertThat(transaction.isFinished).isFalse()
1244+
verify(fixture.scopes, never())
1245+
.captureTransaction(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())
1246+
1247+
spanUnderIdleSpan.finish()
1248+
1249+
await.untilAsserted {
1250+
verify(fixture.scopes).captureTransaction(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())
1251+
}
1252+
assertThat(transaction.status).isEqualTo(SpanStatus.OK)
1253+
}
1254+
12071255
@Test
12081256
fun `when trimEnd, trims idle transaction time to the latest child timestamp`() {
12091257
val transaction =

0 commit comments

Comments
 (0)