Skip to content

Commit 7c18f01

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, contain a span from SentryTraced. (The issue has been with SentryTraced from the beginning – see #2507).
1 parent 07a8d1b commit 7c18f01

5 files changed

Lines changed: 103 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+
- `SentryTraced` now lets any idle parent transaction finish after the idle timeout rather than waiting for the deadline and reporting `DEADLINE_EXCEEDED` ([#XXXX](https://github.com/getsentry/sentry-java/pull/XXXX)).
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: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,11 @@ 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+
// An idle span doesn't keep this transaction from finishing (see hasAllChildrenFinished), so
473+
// creating one leaves the idle timer running.
474+
if (!spanOptions.isIdle()) {
475+
cancelIdleTimer();
476+
}
473477
final Span span =
474478
new Span(
475479
this,
@@ -762,10 +766,20 @@ private void updateBaggageValues(final @NotNull Baggage baggage) {
762766
return null;
763767
}
764768

769+
/**
770+
* Returns true if no child span should keep this transaction from finishing.
771+
*
772+
* <p>Idle spans don't count (but spans created under them do). Including idle spans would mean an
773+
* idle transaction would never restart its idle timer once it had an unfinished idle span,
774+
* causing the transaction to always live until its deadline.
775+
*/
765776
private boolean hasAllChildrenFinished() {
766777
@NotNull final ListIterator<Span> iterator = this.children.listIterator();
767778
while (iterator.hasNext()) {
768779
@NotNull final Span span = iterator.next();
780+
if (span.getOptions().isIdle()) {
781+
continue;
782+
}
769783
// This is used in the spanFinishCallback, when the span isn't finished, but has a finish
770784
// date
771785
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)