Skip to content

perf: Avoid per-transaction Timer thread in SentryTracer (JAVA-596)#5670

Merged
runningcode merged 4 commits into
mainfrom
no/tracer-shared-timer
Jul 15, 2026
Merged

perf: Avoid per-transaction Timer thread in SentryTracer (JAVA-596)#5670
runningcode merged 4 commits into
mainfrom
no/tracer-shared-timer

Conversation

@runningcode

@runningcode runningcode commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Fixes JAVA-596
Closes #5663

📜 Description

SentryTracer created a java.util.Timer (new Timer(true)) for every transaction configured with an idle or deadline timeout. new Timer(...) spawns a dedicated thread synchronously on the calling thread — often the main thread on Android — and each such transaction got its own throwaway thread.

This change schedules the idle/deadline timeouts on a dedicated, shared ISentryExecutorService instead:

  • New SentryOptions.getTimerExecutorService() — a single SentryExecutorService created in activate(), kept separate from the main executor so timeout callbacks (which finish transactions) don't contend with cached-event sending.
  • SentryTracer schedules cancellable Futures (idleTimeoutFuture / deadlineTimeoutFuture) on that executor. It does not cache a reference to the executor: it tracks a timersEnabled flag and looks the executor up from the options each time it schedules, so it always uses the instance currently held by the options (e.g. the fresh one installed after an SDK restart) rather than a stale reference. On finish only the futures are cancelled.
  • Lifecycle: the timer executor is closed only on a full SDK shutdown (Scopes.close when not restarting). On restart it is intentionally left running so that pending idle/deadline timeouts of transactions started before the restart still fire and finish those transactions; the abandoned instance self-terminates once idle (allowCoreThreadTimeOut, 30s keep-alive).

💡 Motivation and Context

At scale (screen loads, HTTP spans, user-interaction transactions) the per-transaction Timer was the dominant source of SDK thread churn, and the thread creation happened on the caller's thread. This is item #4 ("Note B") of the thread/executor audit (JAVA-570 / SDK-1347) and is the biggest thread-count win in that effort. Using a dedicated executor (rather than the main one) keeps transaction-finishing work off the executor that sends cached events.

💚 How did you test it?

  • Updated SentryTracerTest idle/deadline coverage to the new Future-based scheduling; the "timer executor shut down" cases now close the dedicated executor to exercise the immediate-finish fallback.
  • Added ScopesTest coverage for the executor lifecycle: a full close shuts the timer executor down, while a restart leaves it running so in-flight timeouts still fire.
  • ActivityLifecycleIntegrationTest idle-timeout test now installs a real timerExecutorService.
  • Full :sentry:test and :sentry-android-core:testDebugUnitTest pass.

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

🔮 Next steps

Follow-up audit items can fold the remaining per-Timer sites (DefaultCompositePerformanceCollector, RateLimiter, LifecycleWatcher) onto the same dedicated scheduler in a separate cleanup pass.

@linear-code

linear-code Bot commented Jul 1, 2026

Copy link
Copy Markdown

JAVA-570

JAVA-596

@sentry

sentry Bot commented Jul 1, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.48.0 (1) release

⚙️ sentry-android Build Distribution Settings

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 374.91 ms 479.94 ms 105.03 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
6b019b7 403.90 ms 546.09 ms 142.19 ms
e63ad34 310.20 ms 366.84 ms 56.63 ms
5865051 319.74 ms 365.60 ms 45.86 ms
a1eadfa 345.67 ms 411.26 ms 65.59 ms
b193867 319.59 ms 403.09 ms 83.50 ms
0eaac1e 316.82 ms 357.34 ms 40.52 ms
22ff2c7 306.60 ms 336.65 ms 30.05 ms
d15471f 286.65 ms 314.68 ms 28.03 ms
05aa61d 310.43 ms 372.40 ms 61.97 ms
fcec2f2 311.35 ms 384.94 ms 73.59 ms

App size

Revision Plain With Sentry Diff
6b019b7 0 B 0 B 0 B
e63ad34 0 B 0 B 0 B
5865051 0 B 0 B 0 B
a1eadfa 0 B 0 B 0 B
b193867 1.58 MiB 2.19 MiB 620.00 KiB
0eaac1e 1.58 MiB 2.19 MiB 619.17 KiB
22ff2c7 0 B 0 B 0 B
d15471f 1.58 MiB 2.13 MiB 559.54 KiB
05aa61d 0 B 0 B 0 B
fcec2f2 1.58 MiB 2.12 MiB 551.51 KiB

Previous results on branch: no/tracer-shared-timer

Startup times

Revision Plain With Sentry Diff
fde1218 312.75 ms 364.31 ms 51.56 ms
4c1140e 323.30 ms 390.94 ms 67.64 ms

App size

Revision Plain With Sentry Diff
fde1218 0 B 0 B 0 B
4c1140e 0 B 0 B 0 B

@runningcode runningcode force-pushed the no/tracer-shared-timer branch 3 times, most recently from 1835215 to ccb508e Compare July 2, 2026 16:39
@runningcode runningcode changed the title perf: Avoid per-transaction Timer thread in SentryTracer (JAVA-570) perf: Avoid per-transaction Timer thread in SentryTracer (JAVA-596) Jul 2, 2026
@runningcode runningcode marked this pull request as ready for review July 2, 2026 17:04

@markushi markushi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one, looks great to me. Can't wait until we get rid of all timer tasks.

Comment thread sentry/src/main/java/io/sentry/SentryOptions.java
Comment thread sentry/src/main/java/io/sentry/Sentry.java
Comment thread sentry/src/main/java/io/sentry/SentryExecutorService.java Outdated
Comment thread sentry/src/main/java/io/sentry/SentryExecutorService.java Outdated
Comment thread sentry/src/main/java/io/sentry/SentryTracer.java Outdated

@romtsn romtsn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

couple of minor things but LGTM otherwise! great improvement

runningcode and others added 3 commits July 15, 2026 09:48
Transactions with an idle or deadline timeout each created a
java.util.Timer, which spawns a thread synchronously on the calling
thread (often the main thread on Android). At scale (screen loads, HTTP
spans) this was the dominant source of SDK thread churn.

Schedule the idle/deadline timeouts on a dedicated, shared
ISentryExecutorService held by SentryOptions instead, so no thread is
created per transaction. It is kept separate from the main executor so
timeout callbacks (which finish transactions) don't contend with cached
event sending, and it is not prewarmed: its single worker thread is
spawned lazily on the first scheduled timeout and reused thereafter. The
dedicated executor uses removeOnCancelPolicy so cancelled timeouts (idle
timers are rescheduled per child span) don't accumulate in its queue. On
finish only the scheduled futures are cancelled; the executor is closed
with the SDK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared timer executor introduced for transaction idle/deadline
timeouts was shut down on every Scopes.close(), including SDK restart.
This cancelled the pending idle timeout of any transaction started
before the restart (e.g. an in-flight activity transaction), so it
never auto-finished and its envelope was never sent.

Only close the timer executor on a full close, not on restart, matching
the pre-existing per-transaction Timer behaviour. Enable core-thread
timeout on the timer executor so the instance abandoned by a restart
self-terminates once idle instead of leaking a thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@runningcode runningcode force-pushed the no/tracer-shared-timer branch from ccb508e to 9a94c51 Compare July 15, 2026 07:49
SentryTracer cached the shared timer executor in a field for the
lifetime of the transaction. Replace that field with a boolean flag
tracking whether timeouts may still be scheduled, and fetch the
executor from the options each time one is scheduled. This ensures the
tracer always uses the executor currently held by the options (e.g. the
fresh one installed after an SDK restart) rather than a stale reference.

Also make the timer executor's keep-alive duration a constructor
argument backed by the named TIMER_KEEP_ALIVE_SECONDS constant, and
raise it from 10s to 30s so the shared worker thread is less likely to
be torn down and respawned between transactions under normal use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@runningcode runningcode enabled auto-merge (squash) July 15, 2026 09:15
@runningcode runningcode disabled auto-merge July 15, 2026 09:15
@runningcode runningcode merged commit bcd3e76 into main Jul 15, 2026
70 of 72 checks passed
@runningcode runningcode deleted the no/tracer-shared-timer branch July 15, 2026 09:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ANR: per-transaction java.util.Timer in SentryTracer spawns a thread on the calling thread

3 participants