Skip to content

Commit 5e6844d

Browse files
runningcodeclaude
andauthored
perf(core): Create outbox and cache dirs lazily instead of during init (JAVA-613) (#5792)
* perf(core): Create outbox and cache dirs lazily instead of during init (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> * changelog Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ref(core): Encapsulate lazy dir creation in a LazyDirectory value object (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> * ref(core): Inject the cache LazyDirectory via the constructor (JAVA-613) 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> * fix(core): Create the outbox dir in external envelope writers (JAVA-613) 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> * fix(core): Create the cache dir before writing app-start config (JAVA-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> * ref(core): Simplify the lazy directory creation helpers (JAVA-613) 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. * fix(core): Report success when losing the createDirectory race (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. * fix(core): Don't recreate the cache dir when discarding (JAVA-613) 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. * docs(core): Note the lazy dir creation as a behavioral change (JAVA-613) 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. * docs(core): Explain why LazyDirectory swallows a failed mkdirs (JAVA-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. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c318acd commit 5e6844d

18 files changed

Lines changed: 311 additions & 36 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
### Behavioral Changes
6+
7+
- The outbox and cache directories are no longer created by `Sentry.init` ([#5792](https://github.com/getsentry/sentry-java/pull/5792))
8+
- They are now created lazily by whichever component first writes into them, off the init thread. As a result, the directories at `SentryOptions.getOutboxPath()` and `SentryOptions.getCacheDirPath()` are not guaranteed to exist once `Sentry.init` returns.
9+
- If you write envelopes into the outbox path yourself instead of going through the SDK — as hybrid SDKs do for `captureEnvelope` — create the directory first, e.g. `new File(outboxPath).mkdirs()`.
10+
511
### Improvements
612

713
- Skip building Android manifest metadata debug log messages when debug logging is disabled, reducing allocations during SDK init ([#5790](https://github.com/getsentry/sentry-java/pull/5790))
@@ -14,6 +20,7 @@
1420

1521
### Performance
1622

23+
- 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))
1724
- Reduce the number of SDK threads: `LifecycleWatcher` now schedules the session-end task on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5819](https://github.com/getsentry/sentry-java/pull/5819))
1825
- Reduce the number of SDK threads: `RateLimiter` now schedules its rate-limit-lifted notifications on the shared timer executor instead of creating a dedicated `java.util.Timer` thread ([#5814](https://github.com/getsentry/sentry-java/pull/5814))
1926
- Speed up deserialization of arbitrary JSON objects by typing numbers without throwing exceptions ([#5783](https://github.com/getsentry/sentry-java/pull/5783))

‎sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
import io.sentry.SentryLevel;
1111
import io.sentry.SentryOptions;
1212
import io.sentry.util.AutoClosableReentrantLock;
13+
import io.sentry.util.FileUtils;
1314
import io.sentry.util.Objects;
1415
import java.io.Closeable;
16+
import java.io.File;
1517
import org.jetbrains.annotations.NotNull;
1618
import org.jetbrains.annotations.Nullable;
1719
import org.jetbrains.annotations.TestOnly;
@@ -67,6 +69,12 @@ private void startOutboxSender(
6769
final @NotNull IScopes scopes,
6870
final @NotNull SentryOptions options,
6971
final @NotNull String path) {
72+
// Create the outbox dir here (on the executor) so the observer can watch it for envelopes
73+
// written by hybrid SDKs, instead of blocking Sentry.init on the mkdirs.
74+
if (!FileUtils.createDirectory(new File(path))) {
75+
options.getLogger().log(SentryLevel.ERROR, "Failed to create outbox dir %s", path);
76+
}
77+
7078
final OutboxSender outboxSender =
7179
new OutboxSender(
7280
scopes,

‎sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ private boolean storeInternalAndroid(@NotNull SentryEnvelope envelope, @NotNull
9393

9494
@TestOnly
9595
public @NotNull File getDirectory() {
96-
return directory;
96+
return directory.getFile();
9797
}
9898

9999
private void writeStartupCrashMarkerFile() {
@@ -106,7 +106,14 @@ private void writeStartupCrashMarkerFile() {
106106
.log(DEBUG, "Outbox path is null, the startup crash marker file will not be written");
107107
return;
108108
}
109-
final File crashMarkerFile = new File(outboxPath, STARTUP_CRASH_MARKER_FILE);
109+
// The outbox dir is no longer created during Sentry.init, so create it here in case the native
110+
// SDK (which normally creates it) is disabled.
111+
final File outboxDir = new File(outboxPath);
112+
if (!FileUtils.createDirectory(outboxDir)) {
113+
options.getLogger().log(ERROR, "Failed to create outbox dir %s", outboxPath);
114+
return;
115+
}
116+
final File crashMarkerFile = new File(outboxDir, STARTUP_CRASH_MARKER_FILE);
110117
try {
111118
crashMarkerFile.createNewFile();
112119
} catch (Throwable e) {

‎sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import kotlin.test.AfterTest
1414
import kotlin.test.BeforeTest
1515
import kotlin.test.Test
1616
import kotlin.test.assertEquals
17+
import kotlin.test.assertFalse
18+
import kotlin.test.assertTrue
1719
import org.junit.runner.RunWith
1820
import org.mockito.kotlin.eq
1921
import org.mockito.kotlin.mock
@@ -122,4 +124,19 @@ class EnvelopeFileObserverIntegrationTest {
122124
verify(fixture.logger)
123125
.log(eq(SentryLevel.DEBUG), eq("EnvelopeFileObserverIntegration installed."))
124126
}
127+
128+
@Test
129+
fun `register creates the outbox dir when it does not exist yet`() {
130+
val outboxDir = File(file, "outbox")
131+
assertFalse(outboxDir.exists())
132+
133+
fixture.getSut { it.executorService = ImmediateExecutorService() }
134+
val integration =
135+
object : EnvelopeFileObserverIntegration() {
136+
override fun getPath(options: SentryOptions): String = outboxDir.absolutePath
137+
}
138+
integration.register(fixture.scopes, fixture.scopes.options)
139+
140+
assertTrue(outboxDir.isDirectory)
141+
}
125142
}

‎sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ class AndroidEnvelopeCacheTest {
125125
assertTrue(fixture.startupCrashMarkerFile.exists())
126126
}
127127

128+
@Test
129+
fun `creates outbox dir when writing startup crash file and dir does not exist yet`() {
130+
val cache = fixture.getSut(dir = tmpDir, appStartMillis = 1000L, currentTimeMillis = 2000L)
131+
132+
val outboxDir = File(fixture.options.outboxPath!!)
133+
assertTrue(outboxDir.deleteRecursively())
134+
assertFalse(outboxDir.exists())
135+
136+
val hints = HintUtils.createWithTypeCheckHint(UncaughtHint())
137+
cache.storeEnvelope(fixture.envelope, hints)
138+
139+
assertTrue(fixture.startupCrashMarkerFile.exists())
140+
}
141+
128142
@Test
129143
fun `when no AnrV2 hint exists, does not write last anr report file`() {
130144
val cache = fixture.getSut(tmpDir)

‎sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ class MainActivity : ComponentActivity() {
6666
Button(onClick = { Sentry.close() }) { Text("Close SDK") }
6767
Button(
6868
onClick = {
69+
// The SDK creates the outbox dir lazily on its executor, so an external
70+
// writer has to create it itself.
71+
File(outboxPath).mkdirs()
6972
val file = File(outboxPath, "corrupted.envelope")
7073
val corruptedEnvelopeContent =
7174
"""

‎sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,9 +262,14 @@ class EnvelopeTests : BaseUiTest() {
262262
optionsRef = options
263263
}
264264

265+
// The SDK creates the outbox dir lazily on its executor, so an external writer racing
266+
// Sentry.init has to create it itself.
267+
val outboxDir = File(optionsRef!!.outboxPath!!)
268+
outboxDir.mkdirs()
269+
265270
// based on
266271
// https://github.com/getsentry/sentry-native/blob/20d5d5f75f1f48228f2f47e2bb99b17f9996ebbf/ndk/lib/src/androidTest/java/io/sentry/ndk/SentryNdkTest.java#L131
267-
File(optionsRef!!.outboxPath, "14779dbf-b2f0-4c00-f4e5-4a287abc4267")
272+
File(outboxDir, "14779dbf-b2f0-4c00-f4e5-4a287abc4267")
268273
.writeText(
269274
"""
270275
{"dsn":"https://key@sentry.io/proj","event_id":"729ff878-5539-458d-f657-a1acf423a127","sent_at":"2025-04-02T10:02:04.732577Z"}

‎sentry/api/sentry.api‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7692,6 +7692,7 @@ public final class io/sentry/util/ExceptionUtils {
76927692

76937693
public final class io/sentry/util/FileUtils {
76947694
public fun <init> ()V
7695+
public static fun createDirectory (Ljava/io/File;)Z
76957696
public static fun deleteRecursively (Ljava/io/File;)Z
76967697
public static fun readBytesFromFile (Ljava/lang/String;J)[B
76977698
public static fun readText (Ljava/io/File;)Ljava/lang/String;
@@ -7757,6 +7758,12 @@ public final class io/sentry/util/JsonSerializationUtils {
77577758
public static fun calendarToMap (Ljava/util/Calendar;)Ljava/util/Map;
77587759
}
77597760

7761+
public final class io/sentry/util/LazyDirectory {
7762+
public fun <init> (Ljava/lang/String;)V
7763+
public fun getFile ()Ljava/io/File;
7764+
public fun getOrCreate ()Ljava/io/File;
7765+
}
7766+
77607767
public final class io/sentry/util/LazyEvaluator {
77617768
public fun <init> (Lio/sentry/util/LazyEvaluator$Evaluator;)V
77627769
public fun getValue ()Ljava/lang/Object;

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -463,8 +463,9 @@ private static void handleAppStartProfilingConfig(
463463
() -> {
464464
final String cacheDirPath = options.getCacheDirPathWithoutDsn();
465465
if (cacheDirPath != null) {
466+
final @NotNull File cacheDir = new File(cacheDirPath);
466467
final @NotNull File appStartProfilingConfigFile =
467-
new File(cacheDirPath, APP_START_PROFILING_CONFIG_FILE_NAME);
468+
new File(cacheDir, APP_START_PROFILING_CONFIG_FILE_NAME);
468469
try {
469470
// We always delete the config file for app start profiling
470471
FileUtils.deleteRecursively(appStartProfilingConfigFile);
@@ -481,6 +482,14 @@ private static void handleAppStartProfilingConfig(
481482
"Tracing is disabled and app start profiling will not start.");
482483
return;
483484
}
485+
// The cache dir is no longer created during init, so create it here before writing:
486+
// createNewFile() fails if the parent is missing.
487+
if (!FileUtils.createDirectory(cacheDir)) {
488+
options
489+
.getLogger()
490+
.log(SentryLevel.ERROR, "Failed to create cache dir %s", cacheDirPath);
491+
return;
492+
}
484493
if (appStartProfilingConfigFile.createNewFile()) {
485494
// If old app start profiling is false, it means the transaction will not be
486495
// sampled, but we create the file anyway to allow continuous profiling on app
@@ -616,19 +625,14 @@ private static void initConfigurations(final @NotNull SentryOptions options) {
616625
// TODO: read values from conf file, Build conf or system envs
617626
// eg release, distinctId, sentryClientName
618627

619-
// this should be after setting serializers
620-
final String outboxPath = options.getOutboxPath();
621-
if (outboxPath != null) {
622-
final File outboxDir = new File(outboxPath);
623-
outboxDir.mkdirs();
624-
} else {
628+
// The outbox and cache dirs are created lazily by their consumers (envelope cache, outbox file
629+
// observer, native SDK) off the init thread, so we don't stat/mkdir them here.
630+
if (options.getOutboxPath() == null) {
625631
logger.log(SentryLevel.INFO, "No outbox dir path is defined in options.");
626632
}
627633

628634
final String cacheDirPath = options.getCacheDirPath();
629635
if (cacheDirPath != null) {
630-
final File cacheDir = new File(cacheDirPath);
631-
cacheDir.mkdirs();
632636
final IEnvelopeCache envelopeCache = options.getEnvelopeDiskCache();
633637
// only overwrite the cache impl if it's not already set
634638
if (envelopeCache instanceof NoOpEnvelopeCache) {

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1104,7 +1104,11 @@ String getCacheDirPathWithoutDsn() {
11041104
}
11051105

11061106
/**
1107-
* Returns the outbox path if cacheDirPath is set
1107+
* Returns the outbox path if cacheDirPath is set.
1108+
*
1109+
* <p>The directory is created lazily by the SDK on a background thread, so it is not guaranteed
1110+
* to exist when {@code Sentry.init} returns. Callers writing envelopes here directly (for example
1111+
* hybrid SDKs) must create it themselves, e.g. {@code new File(outboxPath).mkdirs()}.
11081112
*
11091113
* @return the outbox path or null if not set
11101114
*/

0 commit comments

Comments
 (0)