Skip to content

perf(core): Create outbox and cache dirs lazily instead of during init (JAVA-613) - #5792

Open
runningcode wants to merge 11 commits into
mainfrom
no/java-613-lazy-outbox-cache-mkdirs
Open

perf(core): Create outbox and cache dirs lazily instead of during init (JAVA-613)#5792
runningcode wants to merge 11 commits into
mainfrom
no/java-613-lazy-outbox-cache-mkdirs

Conversation

@runningcode

@runningcode runningcode commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

📜 Description

Sentry.initConfigurations created the outbox and cache directories synchronously via mkdirs() on the init thread — which on Android is the main thread. This moves both off the synchronous init path by creating each directory lazily in its consumer:

  • Cache dir — created at the start of EnvelopeCache.storeInternal, so the first envelope write (on the transport thread) creates it. Covers envelopes, sessions, crash markers, and the Android ANR/tombstone markers, which all live under cacheDirPath.
  • Outbox dir — created in EnvelopeFileObserverIntegration.startOutboxSender (runs on the executor) before startWatching(), and before writing the startup-crash marker in AndroidEnvelopeCache. The native SDK already creates the outbox dir itself during sentry_init, so NDK crash writes are unaffected.

⚠️ Behavior change for outbox writers

Previously Sentry.init created the outbox dir synchronously, so it was guaranteed to exist once init returned. It is now created on the SDK executor, so anything writing envelopes into the outbox itself must create the dir first (new File(outboxPath).mkdirs()). I checked RN, Flutter, Unity and none of them should be affected by this. Native was fixed in 0.16.0 and that is already merged.

💡 Motivation and Context

This can cause ANRs as we are blocking the main thread with disk I/O during startup.

💚 How did you test it?

Relying on integration tests for this. Some of them failed with this integration. I also asked JR which other SDKs might be affected by this.

📝 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

Verify the init-time improvement as a batch via Perfetto/macrobenchmark (individually below noise).

@linear-code

linear-code Bot commented Jul 20, 2026

Copy link
Copy Markdown

JAVA-613

@runningcode
runningcode marked this pull request as ready for review July 20, 2026 15:05
@sentry

sentry Bot commented Jul 20, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

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

⚙️ sentry-android Build Distribution Settings

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 316.78 ms 361.19 ms 44.42 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
05aa61d 326.06 ms 385.46 ms 59.40 ms
bb0ff41 315.84 ms 350.76 ms 34.92 ms
806307f 357.85 ms 424.64 ms 66.79 ms
d501a7e 307.33 ms 341.94 ms 34.61 ms
0ee65e9 321.06 ms 361.24 ms 40.18 ms
ed33deb 334.19 ms 362.30 ms 28.11 ms
9fbb112 401.87 ms 515.87 ms 114.00 ms
b8bd880 314.56 ms 336.50 ms 21.94 ms
5b1a06b 315.40 ms 353.33 ms 37.94 ms
6edfca2 316.43 ms 398.90 ms 82.46 ms

App size

Revision Plain With Sentry Diff
05aa61d 0 B 0 B 0 B
bb0ff41 0 B 0 B 0 B
806307f 1.58 MiB 2.10 MiB 533.42 KiB
d501a7e 0 B 0 B 0 B
0ee65e9 0 B 0 B 0 B
ed33deb 1.58 MiB 2.13 MiB 559.52 KiB
9fbb112 1.58 MiB 2.11 MiB 539.18 KiB
b8bd880 1.58 MiB 2.29 MiB 722.92 KiB
5b1a06b 0 B 0 B 0 B
6edfca2 1.58 MiB 2.13 MiB 559.07 KiB

Previous results on branch: no/java-613-lazy-outbox-cache-mkdirs

Startup times

Revision Plain With Sentry Diff
3bc1a37 309.29 ms 344.30 ms 35.01 ms
c32efb9 313.52 ms 358.94 ms 45.42 ms
bf0ce6e 399.04 ms 409.38 ms 10.33 ms
8c52758 300.98 ms 374.93 ms 73.96 ms
4b8719a 318.06 ms 348.42 ms 30.36 ms
e888e97 315.58 ms 363.50 ms 47.92 ms
9b3dd56 337.72 ms 419.82 ms 82.09 ms

App size

Revision Plain With Sentry Diff
3bc1a37 0 B 0 B 0 B
c32efb9 0 B 0 B 0 B
bf0ce6e 0 B 0 B 0 B
8c52758 0 B 0 B 0 B
4b8719a 0 B 0 B 0 B
e888e97 0 B 0 B 0 B
9b3dd56 0 B 0 B 0 B

@runningcode

Copy link
Copy Markdown
Contributor Author

Native needs to be merged first: getsentry/sentry-native#1889

Comment thread sentry/src/main/java/io/sentry/Sentry.java
runningcode and others added 4 commits July 28, 2026 09:10
…t (JAVA-613)

Sentry.initConfigurations created the outbox and cache directories
synchronously on the init thread, which on Android is the main thread.

Create each directory lazily in its consumer instead: the cache dir on
the first envelope write (transport thread), and the outbox dir in the
file-observer integration (executor thread) and before writing the
startup-crash marker. The native SDK already creates the outbox dir
itself during sentry_init, so NDK crash writes are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ect (JAVA-613)

Replace the duplicated "create the dir if it does not exist" idiom in the
envelope cache, outbox file observer, and startup-crash-marker paths with a
single LazyDirectory type that materializes the directory on first access.

CacheStrategy now owns its directory as a LazyDirectory: write paths call
getOrCreate(), while path-building and validity checks use getFile() so they
do not create the directory as a side effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Have the composition roots (EnvelopeCache.create and AndroidEnvelopeCache)
build the LazyDirectory and pass it into CacheStrategy, so the cache no
longer constructs its own directory collaborator from a path string. The
public String constructor is kept and delegates, preserving binary
compatibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@runningcode
runningcode force-pushed the no/java-613-lazy-outbox-cache-mkdirs branch from a2a855b to 648d7f2 Compare July 28, 2026 07:15
Comment thread sentry/src/main/java/io/sentry/Sentry.java
runningcode and others added 3 commits July 28, 2026 09:38
Creating the outbox dir lazily moved the mkdirs() off the init thread onto
the SDK executor, so the dir is no longer guaranteed to exist once
Sentry.init returns. Writers that drop envelopes into the outbox
themselves raced that executor task and could fail with ENOENT, which is
what EnvelopeTests.sendsNativeTransaction hit on a slow emulator.

Have both external writers create the dir before writing, and document on
getOutboxPath that the directory is created lazily so hybrid SDKs writing
envelopes directly know they have to do the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-613)

The removed init-time mkdirs() ran on the dsn-hashed cache path and created
the un-hashed parent as a side effect. handleAppStartProfilingConfig writes
app_start_profiling_config into that parent via createNewFile(), which fails
with IOException when the parent is missing, and the surrounding catch
swallows it into a log line. The next launch then finds no config and cannot
start app-start profiling.

This was masked because the profiling traces dir still calls mkdirs() on
<cacheDir>/<dsnHash>/profiling_traces, creating the un-hashed grandparent --
but only when profiling is enabled, which every existing test did. The new
test leaves profiling off so nothing else materializes the dir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The LazyDirectory value object was doing two unrelated jobs: holding a
directory that a long-lived cache creates on first write, and acting as a
one-shot mkdirs() helper at three call sites that constructed it only to
discard it immediately.

Split those apart. FileUtils.createDirectory covers the one-shot case and
returns whether the directory exists afterwards, so the callers log the
failure instead of discarding mkdirs()' return value and failing later in
an unrelated-looking write. LazyDirectory keeps only the cache use and
gains resolve(), which creates the parent before returning the child, so
write paths no longer depend on an earlier getOrCreate() call having run.

Creation is deliberately not cached: on Android the cache dir lives under
Context.getCacheDir(), which the system may wipe at any time, so each
write re-checks and the directory heals itself.

Also revert the LazyDirectory injection into CacheStrategy. Nothing
injected a custom instance, so it only added a public EnvelopeCache
constructor to the API surface for an internal change.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9e04ed7. Configure here.

Comment thread sentry/src/main/java/io/sentry/util/FileUtils.java
Comment thread sentry/src/main/java/io/sentry/cache/EnvelopeCache.java
…613)

File.mkdirs() returns false both when it cannot create the directory and
when another thread got there first, so createDirectory reported a failure
for a directory that was present. Callers act on that by skipping their
write: a startup crash would go unmarked and the app-start profiling config
would not be written, even though the directory existed.

Re-check for the directory when mkdirs() fails, which separates losing the
race from a genuine failure such as missing permissions.

The added test fails reliably without the fix, with most of the racing
threads observing false.
getCurrentFile went through LazyDirectory.resolve, which creates the
directory as a side effect. discard() calls it while deleting from the
cache, so discarding an envelope could resurrect a cache dir that had
just been removed.

Compute the path without touching the filesystem instead, and drop
resolve entirely: the remaining write paths already call getOrCreate
once before writing.

@0xadam-brown 0xadam-brown 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.

Thanks @runningcode!

A few comments, but not blockers.

Fwiw, I asked my LLM whether the PR accounts for all possible code paths that may need to create the directory, now that we're not doing so in init(), and it said it does. But I'm not familiar with those paths at all, so feel free to ask for another look if you're also not sure.

final File crashMarkerFile = new File(outboxPath, STARTUP_CRASH_MARKER_FILE);
// The outbox dir is no longer created during Sentry.init, so create it here in case the native
// SDK (which normally creates it) is disabled.
final File outboxDir = new File(outboxPath);

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.

Longer-term side note: Seems like Sentry.init() would benefit from an onCompleted callback so we can i) do work off the main thread and ii) provide users with guarantees about what will be in place when we invoke the callback.

That'd^^ let us centralize our policies + avoid having to check for existence (etc.) in multiple places.

(Obviously way out of scope here, but something for us to think about.)

@runningcode runningcode Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

sounds interesting! probably related to this two phase init idea: #5704

Comment thread sentry/src/main/java/io/sentry/SentryOptions.java

/** Returns the directory, creating it and any missing parents if it does not exist yet. */
public @NotNull File getOrCreate() {
FileUtils.createDirectory(file);

@0xadam-brown 0xadam-brown Jul 28, 2026

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.

m: We're ignoring a possible false return value (which indicates that the directory couldn't be created). Seems we should either return null from this method or update the Javadoc to advertise it's a best-effort-only approach.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a very good catch. To be fair, the previous code would also ignore the return value of mkdirs when creating the dir.
LazyDirectory is just a basic class. Adding a logger to it would be overkill. The error should surface when trying to write later on. Ill make a comment to make this explicit.

Comment thread CHANGELOG.md

### Performance

- Create the outbox and cache directories lazily in their consumers instead of during SDK init, moving the `mkdirs()` calls off the init (main) thread ([#5792](https://github.com/getsentry/sentry-java/pull/5792))

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.

m: We should also mention that direct outbox writers now need to create the outbox directory before writing envelopes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I feel like this might confuse consumers of the SDK but I'll do this. I think it makes sense to put in the release notes.

@runningcode runningcode left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the review


/** Returns the directory, creating it and any missing parents if it does not exist yet. */
public @NotNull File getOrCreate() {
FileUtils.createDirectory(file);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a very good catch. To be fair, the previous code would also ignore the return value of mkdirs when creating the dir.
LazyDirectory is just a basic class. Adding a logger to it would be overkill. The error should surface when trying to write later on. Ill make a comment to make this explicit.

Comment thread sentry/src/main/java/io/sentry/SentryOptions.java
Comment thread CHANGELOG.md

### Performance

- Create the outbox and cache directories lazily in their consumers instead of during SDK init, moving the `mkdirs()` calls off the init (main) thread ([#5792](https://github.com/getsentry/sentry-java/pull/5792))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I feel like this might confuse consumers of the SDK but I'll do this. I think it makes sense to put in the release notes.

Sentry.init used to mkdirs() the outbox and cache dirs synchronously, so
both were guaranteed to exist once it returned. They are now created by
whichever component first writes into them, off the init thread, which
breaks anyone writing envelopes into the outbox path directly instead of
going through the SDK -- notably the hybrid SDKs' captureEnvelope.

Call that out under Behavioral Changes so hybrid maintainers see it; the
existing Performance entry only describes the win, not the cost.
…613)

getOrCreate() ignoring the return value of createDirectory() reads like
an oversight next to the callers that do log it, so record that write
paths already surface the failure via their own error handling.
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.

2 participants