From 429252abb3872170953bd2e8b3c75183c499a02f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 17:03:39 +0200 Subject: [PATCH 01/11] 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 --- .../core/EnvelopeFileObserverIntegration.java | 8 ++++++++ .../core/cache/AndroidEnvelopeCache.java | 6 ++++++ .../core/EnvelopeFileObserverIntegrationTest.kt | 17 +++++++++++++++++ .../core/cache/AndroidEnvelopeCacheTest.kt | 14 ++++++++++++++ sentry/src/main/java/io/sentry/Sentry.java | 11 +++-------- .../java/io/sentry/cache/EnvelopeCache.java | 5 +++++ sentry/src/test/java/io/sentry/SentryTest.kt | 17 ++++++++--------- .../java/io/sentry/cache/EnvelopeCacheTest.kt | 16 ++++++++++++++++ 8 files changed, 77 insertions(+), 17 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java index 482d90c6e6c..7cb4ffdcc78 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java @@ -12,6 +12,7 @@ import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.Closeable; +import java.io.File; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -67,6 +68,13 @@ private void startOutboxSender( final @NotNull IScopes scopes, final @NotNull SentryOptions options, final @NotNull String path) { + // Create the outbox dir lazily here (on the executor) so the observer can watch it for + // envelopes written by hybrid SDKs, instead of blocking Sentry.init on the mkdirs. + final File outboxDir = new File(path); + if (!outboxDir.isDirectory()) { + outboxDir.mkdirs(); + } + final OutboxSender outboxSender = new OutboxSender( scopes, diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index e1590e47943..484a1ea0119 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -108,6 +108,12 @@ private void writeStartupCrashMarkerFile() { } final File crashMarkerFile = new File(outboxPath, STARTUP_CRASH_MARKER_FILE); try { + // The outbox dir is no longer created during Sentry.init, so ensure it exists here in case + // the native SDK (which normally creates it) is disabled. + final File outboxDir = crashMarkerFile.getParentFile(); + if (outboxDir != null && !outboxDir.isDirectory()) { + outboxDir.mkdirs(); + } crashMarkerFile.createNewFile(); } catch (Throwable e) { options.getLogger().log(ERROR, "Error writing the startup crash marker file to the disk", e); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt index 97276d67566..0b13f4ca4d8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/EnvelopeFileObserverIntegrationTest.kt @@ -14,6 +14,8 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -122,4 +124,19 @@ class EnvelopeFileObserverIntegrationTest { verify(fixture.logger) .log(eq(SentryLevel.DEBUG), eq("EnvelopeFileObserverIntegration installed.")) } + + @Test + fun `register creates the outbox dir when it does not exist yet`() { + val outboxDir = File(file, "outbox") + assertFalse(outboxDir.exists()) + + fixture.getSut { it.executorService = ImmediateExecutorService() } + val integration = + object : EnvelopeFileObserverIntegration() { + override fun getPath(options: SentryOptions): String = outboxDir.absolutePath + } + integration.register(fixture.scopes, fixture.scopes.options) + + assertTrue(outboxDir.isDirectory) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt index 09d3a779df0..a4063ccb148 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt @@ -125,6 +125,20 @@ class AndroidEnvelopeCacheTest { assertTrue(fixture.startupCrashMarkerFile.exists()) } + @Test + fun `creates outbox dir when writing startup crash file and dir does not exist yet`() { + val cache = fixture.getSut(dir = tmpDir, appStartMillis = 1000L, currentTimeMillis = 2000L) + + val outboxDir = File(fixture.options.outboxPath!!) + assertTrue(outboxDir.deleteRecursively()) + assertFalse(outboxDir.exists()) + + val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) + cache.storeEnvelope(fixture.envelope, hints) + + assertTrue(fixture.startupCrashMarkerFile.exists()) + } + @Test fun `when no AnrV2 hint exists, does not write last anr report file`() { val cache = fixture.getSut(tmpDir) diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 8bba9d92e4f..77c7376b4ad 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -616,19 +616,14 @@ private static void initConfigurations(final @NotNull SentryOptions options) { // TODO: read values from conf file, Build conf or system envs // eg release, distinctId, sentryClientName - // this should be after setting serializers - final String outboxPath = options.getOutboxPath(); - if (outboxPath != null) { - final File outboxDir = new File(outboxPath); - outboxDir.mkdirs(); - } else { + // The outbox and cache dirs are created lazily by their consumers (envelope cache, outbox file + // observer, native SDK) off the init thread, so we don't stat/mkdir them here. + if (options.getOutboxPath() == null) { logger.log(SentryLevel.INFO, "No outbox dir path is defined in options."); } final String cacheDirPath = options.getCacheDirPath(); if (cacheDirPath != null) { - final File cacheDir = new File(cacheDirPath); - cacheDir.mkdirs(); final IEnvelopeCache envelopeCache = options.getEnvelopeDiskCache(); // only overwrite the cache impl if it's not already set if (envelopeCache instanceof NoOpEnvelopeCache) { diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 475993bbc1d..3e753f8a2ce 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -109,6 +109,11 @@ public boolean storeEnvelope(final @NotNull SentryEnvelope envelope, final @NotN private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @NotNull Hint hint) { Objects.requireNonNull(envelope, "Envelope is required."); + // Create the cache dir lazily on the first write so Sentry.init doesn't block on the mkdirs. + if (!directory.isDirectory()) { + directory.mkdirs(); + } + rotateCacheIfNeeded(allEnvelopeFiles()); final File currentSessionFile = getCurrentSessionFile(directory.getAbsolutePath()); diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 8d05697fda4..ab8533c9c60 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -183,7 +183,7 @@ class SentryTest { } @Test - fun `outboxPath should be created at initialization`() { + fun `outboxPath is not created during initialization`() { var sentryOptions: SentryOptions? = null initForTest { it.dsn = dsn @@ -191,13 +191,13 @@ class SentryTest { sentryOptions = it } + // The outbox dir is created lazily by its consumers (file observer, native SDK), not at init. val file = File(sentryOptions!!.outboxPath!!) - assertTrue(file.exists()) - file.deleteOnExit() + assertFalse(file.exists()) } @Test - fun `cacheDirPath should be created at initialization`() { + fun `cacheDirPath is not created during initialization`() { var sentryOptions: SentryOptions? = null initForTest { it.dsn = dsn @@ -205,13 +205,13 @@ class SentryTest { sentryOptions = it } + // The cache dir is created lazily on the first envelope store, not at init. val file = File(sentryOptions!!.cacheDirPath!!) - assertTrue(file.exists()) - file.deleteOnExit() + assertFalse(file.exists()) } @Test - fun `getCacheDirPathWithoutDsn should be created at initialization`() { + fun `cacheDirPathWithoutDsn is not created during initialization`() { var sentryOptions: SentryOptions? = null initForTest { it.dsn = dsn @@ -221,8 +221,7 @@ class SentryTest { val cacheDirPathWithoutDsn = sentryOptions!!.cacheDirPathWithoutDsn!! val file = File(cacheDirPathWithoutDsn) - assertTrue(file.exists()) - file.deleteOnExit() + assertFalse(file.exists()) } @Test diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index 973070e0afe..981a1337f43 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -79,6 +79,22 @@ class EnvelopeCacheTest { file.deleteRecursively() } + @Test + fun `creates cache dir on store when it does not exist yet`() { + val cache = fixture.getSUT() + + val file = File(fixture.options.cacheDirPath!!) + assertTrue(file.deleteRecursively()) + assertFalse(file.exists()) + + cache.store(SentryEnvelope.from(fixture.options.serializer, createSession(), null)) + + assertTrue(file.exists()) + assertEquals(1, file.list()?.size) + + file.deleteRecursively() + } + @Test fun `tolerates discarding unknown envelope`() { val cache = fixture.getSUT() From 0ba52d9a8dc3127f872fe9eb60a0ff2fb19f13e8 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 17:04:36 +0200 Subject: [PATCH 02/11] changelog Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cb55637577..fcb8f7e1386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### 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)) - 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)) - Speed up deserialization of arbitrary JSON objects by typing numbers without throwing exceptions ([#5783](https://github.com/getsentry/sentry-java/pull/5783)) From 435f985da857dd202d0ea354a119d109f639db02 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 17:19:26 +0200 Subject: [PATCH 03/11] 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 --- .../core/EnvelopeFileObserverIntegration.java | 11 +++--- .../core/cache/AndroidEnvelopeCache.java | 14 ++++---- sentry/api/sentry.api | 6 ++++ .../java/io/sentry/cache/CacheStrategy.java | 12 +++---- .../java/io/sentry/cache/EnvelopeCache.java | 14 ++++---- .../java/io/sentry/util/LazyDirectory.java | 33 +++++++++++++++++ .../java/io/sentry/cache/EnvelopeCacheTest.kt | 2 +- .../java/io/sentry/util/LazyDirectoryTest.kt | 35 +++++++++++++++++++ 8 files changed, 97 insertions(+), 30 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/util/LazyDirectory.java create mode 100644 sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java index 7cb4ffdcc78..9b75a69e3f5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java @@ -10,9 +10,9 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.LazyDirectory; import io.sentry.util.Objects; import java.io.Closeable; -import java.io.File; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -68,12 +68,9 @@ private void startOutboxSender( final @NotNull IScopes scopes, final @NotNull SentryOptions options, final @NotNull String path) { - // Create the outbox dir lazily here (on the executor) so the observer can watch it for - // envelopes written by hybrid SDKs, instead of blocking Sentry.init on the mkdirs. - final File outboxDir = new File(path); - if (!outboxDir.isDirectory()) { - outboxDir.mkdirs(); - } + // Materialize the outbox dir here (on the executor) so the observer can watch it for envelopes + // written by hybrid SDKs, instead of blocking Sentry.init on the mkdirs. + new LazyDirectory(path).getOrCreate(); final OutboxSender outboxSender = new OutboxSender( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index 484a1ea0119..ff230a2f7a9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -18,6 +18,7 @@ import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.FileUtils; import io.sentry.util.HintUtils; +import io.sentry.util.LazyDirectory; import io.sentry.util.Objects; import java.io.File; import java.io.FileNotFoundException; @@ -93,7 +94,7 @@ private boolean storeInternalAndroid(@NotNull SentryEnvelope envelope, @NotNull @TestOnly public @NotNull File getDirectory() { - return directory; + return directory.getFile(); } private void writeStartupCrashMarkerFile() { @@ -106,14 +107,11 @@ private void writeStartupCrashMarkerFile() { .log(DEBUG, "Outbox path is null, the startup crash marker file will not be written"); return; } - final File crashMarkerFile = new File(outboxPath, STARTUP_CRASH_MARKER_FILE); + // The outbox dir is no longer created during Sentry.init, so materialize it here in case the + // native SDK (which normally creates it) is disabled. + final File outboxDir = new LazyDirectory(outboxPath).getOrCreate(); + final File crashMarkerFile = new File(outboxDir, STARTUP_CRASH_MARKER_FILE); try { - // The outbox dir is no longer created during Sentry.init, so ensure it exists here in case - // the native SDK (which normally creates it) is disabled. - final File outboxDir = crashMarkerFile.getParentFile(); - if (outboxDir != null && !outboxDir.isDirectory()) { - outboxDir.mkdirs(); - } crashMarkerFile.createNewFile(); } catch (Throwable e) { options.getLogger().log(ERROR, "Error writing the startup crash marker file to the disk", e); diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index c623e71d08f..a7751d7c693 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7757,6 +7757,12 @@ public final class io/sentry/util/JsonSerializationUtils { public static fun calendarToMap (Ljava/util/Calendar;)Ljava/util/Map; } +public final class io/sentry/util/LazyDirectory { + public fun (Ljava/lang/String;)V + public fun getFile ()Ljava/io/File; + public fun getOrCreate ()Ljava/io/File; +} + public final class io/sentry/util/LazyEvaluator { public fun (Lio/sentry/util/LazyEvaluator$Evaluator;)V public fun getValue ()Ljava/lang/Object; diff --git a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java index 479c0e42eaf..5c67b04bd6c 100644 --- a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java +++ b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java @@ -10,6 +10,7 @@ import io.sentry.SentryOptions; import io.sentry.Session; import io.sentry.clientreport.DiscardReason; +import io.sentry.util.LazyDirectory; import io.sentry.util.LazyEvaluator; import io.sentry.util.Objects; import java.io.BufferedInputStream; @@ -39,7 +40,7 @@ abstract class CacheStrategy { protected @NotNull SentryOptions options; protected final @NotNull LazyEvaluator serializer = new LazyEvaluator<>(() -> options.getSerializer()); - protected final @NotNull File directory; + protected final @NotNull LazyDirectory directory; private final int maxSize; CacheStrategy( @@ -49,7 +50,7 @@ abstract class CacheStrategy { Objects.requireNonNull(directoryPath, "Directory is required."); this.options = Objects.requireNonNull(options, "SentryOptions is required."); - this.directory = new File(directoryPath); + this.directory = new LazyDirectory(directoryPath); this.maxSize = maxSize; } @@ -60,13 +61,12 @@ abstract class CacheStrategy { * @return true if valid and has permissions or false otherwise */ protected boolean isDirectoryValid() { - if (!directory.isDirectory() || !directory.canWrite() || !directory.canRead()) { + final File dir = directory.getFile(); + if (!dir.isDirectory() || !dir.canWrite() || !dir.canRead()) { options .getLogger() .log( - ERROR, - "The directory for caching files is inaccessible.: %s", - directory.getAbsolutePath()); + ERROR, "The directory for caching files is inaccessible.: %s", dir.getAbsolutePath()); return false; } return true; diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 3e753f8a2ce..c6b1fb07114 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -110,14 +110,12 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not Objects.requireNonNull(envelope, "Envelope is required."); // Create the cache dir lazily on the first write so Sentry.init doesn't block on the mkdirs. - if (!directory.isDirectory()) { - directory.mkdirs(); - } + final String directoryPath = directory.getOrCreate().getAbsolutePath(); rotateCacheIfNeeded(allEnvelopeFiles()); - final File currentSessionFile = getCurrentSessionFile(directory.getAbsolutePath()); - final File previousSessionFile = getPreviousSessionFile(directory.getAbsolutePath()); + final File currentSessionFile = getCurrentSessionFile(directoryPath); + final File previousSessionFile = getPreviousSessionFile(directoryPath); if (HintUtils.hasType(hint, SessionEnd.class)) { if (!currentSessionFile.delete()) { @@ -204,7 +202,7 @@ private boolean storeInternal(final @NotNull SentryEnvelope envelope, final @Not @SuppressWarnings("JavaUtilDate") private void tryEndPreviousSession(final @NotNull Hint hint) { final Object sdkHint = HintUtils.getSentrySdkHint(hint); - final File previousSessionFile = getPreviousSessionFile(directory.getAbsolutePath()); + final File previousSessionFile = getPreviousSessionFile(directory.getFile().getAbsolutePath()); if (previousSessionFile.exists()) { options.getLogger().log(WARNING, "Previous session is not ended, we'd need to end it."); @@ -390,7 +388,7 @@ public void discard(final @NotNull SentryEnvelope envelope) { fileNameMap.put(envelope, fileName); } - return new File(directory.getAbsolutePath(), fileName); + return new File(directory.getFile().getAbsolutePath(), fileName); } } @@ -436,7 +434,7 @@ public void discard(final @NotNull SentryEnvelope envelope) { if (isDirectoryValid()) { // lets filter the session.json here final File[] files = - directory.listFiles((__, fileName) -> fileName.endsWith(SUFFIX_ENVELOPE_FILE)); + directory.getFile().listFiles((__, fileName) -> fileName.endsWith(SUFFIX_ENVELOPE_FILE)); if (files != null) { return files; } diff --git a/sentry/src/main/java/io/sentry/util/LazyDirectory.java b/sentry/src/main/java/io/sentry/util/LazyDirectory.java new file mode 100644 index 00000000000..05d01f0bedf --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/LazyDirectory.java @@ -0,0 +1,33 @@ +package io.sentry.util; + +import java.io.File; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * A filesystem directory that is created on demand rather than up front, so the (potentially + * blocking) {@code mkdirs()} runs on the thread that first writes into it instead of on the SDK + * init thread. + */ +@ApiStatus.Internal +public final class LazyDirectory { + + private final @NotNull File file; + + public LazyDirectory(final @NotNull String path) { + this.file = new File(path); + } + + /** Returns the directory without touching the filesystem. */ + public @NotNull File getFile() { + return file; + } + + /** Returns the directory, creating it and any missing parents if it does not exist yet. */ + public @NotNull File getOrCreate() { + if (!file.isDirectory()) { + file.mkdirs(); + } + return file; + } +} diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index 981a1337f43..8360bcf2ff0 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -466,7 +466,7 @@ class EnvelopeCacheTest { cache.store(envelopeA, Hint()) cache.store(envelopeB, Hint()) - assertEquals(2, cache.directory.list()?.size) + assertEquals(2, cache.directory.file.list()?.size) } @Test diff --git a/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt b/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt new file mode 100644 index 00000000000..01376b56be2 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt @@ -0,0 +1,35 @@ +package io.sentry.util + +import com.google.common.truth.Truth.assertThat +import java.nio.file.Files +import kotlin.test.Test + +class LazyDirectoryTest { + @Test + fun `getFile does not create the directory`() { + val path = Files.createTempDirectory("lazy-dir-test").resolve("outbox") + val lazyDirectory = LazyDirectory(path.toString()) + + assertThat(lazyDirectory.file.exists()).isFalse() + } + + @Test + fun `getOrCreate creates the directory and any missing parents`() { + val path = Files.createTempDirectory("lazy-dir-test").resolve("nested").resolve("outbox") + val lazyDirectory = LazyDirectory(path.toString()) + + val created = lazyDirectory.getOrCreate() + + assertThat(created.isDirectory).isTrue() + assertThat(created.absolutePath).isEqualTo(path.toFile().absolutePath) + } + + @Test + fun `getOrCreate is idempotent when the directory already exists`() { + val path = Files.createTempDirectory("lazy-dir-test").resolve("outbox") + val lazyDirectory = LazyDirectory(path.toString()) + + assertThat(lazyDirectory.getOrCreate().isDirectory).isTrue() + assertThat(lazyDirectory.getOrCreate().isDirectory).isTrue() + } +} From 648d7f23cbf0af30919684334ccdad11c5804dcf Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 21 Jul 2026 16:32:36 +0200 Subject: [PATCH 04/11] 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 --- .../android/core/cache/AndroidEnvelopeCache.java | 3 ++- sentry/api/sentry.api | 1 + .../src/main/java/io/sentry/cache/CacheStrategy.java | 7 ++----- .../src/main/java/io/sentry/cache/EnvelopeCache.java | 12 ++++++++++-- .../test/java/io/sentry/cache/CacheStrategyTest.kt | 3 ++- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index ff230a2f7a9..9d0b98a2896 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -48,7 +48,8 @@ public AndroidEnvelopeCache(final @NotNull SentryAndroidOptions options) { final @NotNull ICurrentDateProvider currentDateProvider) { super( options, - Objects.requireNonNull(options.getCacheDirPath(), "cacheDirPath must not be null"), + new LazyDirectory( + Objects.requireNonNull(options.getCacheDirPath(), "cacheDirPath must not be null")), options.getMaxCacheItems()); this.currentDateProvider = currentDateProvider; } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index a7751d7c693..e592bc7c4c5 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4836,6 +4836,7 @@ public class io/sentry/cache/EnvelopeCache : io/sentry/cache/IEnvelopeCache { protected static final field UTF_8 Ljava/nio/charset/Charset; protected final field cacheLock Lio/sentry/util/AutoClosableReentrantLock; protected final field sessionLock Lio/sentry/util/AutoClosableReentrantLock; + public fun (Lio/sentry/SentryOptions;Lio/sentry/util/LazyDirectory;I)V public fun (Lio/sentry/SentryOptions;Ljava/lang/String;I)V public static fun create (Lio/sentry/SentryOptions;)Lio/sentry/cache/IEnvelopeCache; public fun discard (Lio/sentry/SentryEnvelope;)V diff --git a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java index 5c67b04bd6c..67f597cc4af 100644 --- a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java +++ b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java @@ -45,13 +45,10 @@ abstract class CacheStrategy { CacheStrategy( final @NotNull SentryOptions options, - final @NotNull String directoryPath, + final @NotNull LazyDirectory directory, final int maxSize) { - Objects.requireNonNull(directoryPath, "Directory is required."); this.options = Objects.requireNonNull(options, "SentryOptions is required."); - - this.directory = new LazyDirectory(directoryPath); - + this.directory = Objects.requireNonNull(directory, "Directory is required."); this.maxSize = maxSize; } diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index c6b1fb07114..9df6f6d808b 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -26,6 +26,7 @@ import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.HintUtils; +import io.sentry.util.LazyDirectory; import io.sentry.util.Objects; import java.io.BufferedInputStream; import java.io.BufferedReader; @@ -83,7 +84,7 @@ public class EnvelopeCache extends CacheStrategy implements IEnvelopeCache { options.getLogger().log(WARNING, "cacheDirPath is null, returning NoOpEnvelopeCache"); return NoOpEnvelopeCache.getInstance(); } else { - return new EnvelopeCache(options, cacheDirPath, maxCacheItems); + return new EnvelopeCache(options, new LazyDirectory(cacheDirPath), maxCacheItems); } } @@ -91,7 +92,14 @@ public EnvelopeCache( final @NotNull SentryOptions options, final @NotNull String cacheDirPath, final int maxCacheItems) { - super(options, cacheDirPath, maxCacheItems); + this(options, new LazyDirectory(cacheDirPath), maxCacheItems); + } + + public EnvelopeCache( + final @NotNull SentryOptions options, + final @NotNull LazyDirectory directory, + final int maxCacheItems) { + super(options, directory, maxCacheItems); previousSessionLatch = new CountDownLatch(1); } diff --git a/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt b/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt index 881d0da37b3..e0975214ea3 100644 --- a/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt +++ b/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt @@ -9,6 +9,7 @@ import io.sentry.Session import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport import io.sentry.clientreport.DiscardReason import io.sentry.clientreport.DiscardedEvent +import io.sentry.util.LazyDirectory import java.io.ByteArrayInputStream import java.io.File import java.io.InputStreamReader @@ -130,7 +131,7 @@ class CacheStrategyTest { } private class CustomCache(options: SentryOptions, path: String, maxSize: Int) : - CacheStrategy(options, path, maxSize) + CacheStrategy(options, LazyDirectory(path), maxSize) private fun createTempFilesSortByOldestToNewest(): Array { val f1 = Files.createTempFile(fixture.dir.toPath(), "f1", ".json").toFile() From 6b422c39c7114cf3ce984bf8bbed6d04dafeba5e Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 28 Jul 2026 09:38:32 +0200 Subject: [PATCH 05/11] 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) --- .../java/io/sentry/uitest/android/critical/MainActivity.kt | 3 +++ .../java/io/sentry/uitest/android/EnvelopeTests.kt | 7 ++++++- sentry/src/main/java/io/sentry/SentryOptions.java | 6 +++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt index 46bfe7e44b7..7e0ff9d61c3 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/src/main/java/io/sentry/uitest/android/critical/MainActivity.kt @@ -66,6 +66,9 @@ class MainActivity : ComponentActivity() { Button(onClick = { Sentry.close() }) { Text("Close SDK") } Button( onClick = { + // The SDK creates the outbox dir lazily on its executor, so an external + // writer has to create it itself. + File(outboxPath).mkdirs() val file = File(outboxPath, "corrupted.envelope") val corruptedEnvelopeContent = """ diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt index ade47363296..3fa2c904873 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt @@ -262,9 +262,14 @@ class EnvelopeTests : BaseUiTest() { optionsRef = options } + // The SDK creates the outbox dir lazily on its executor, so an external writer racing + // Sentry.init has to create it itself. + val outboxDir = File(optionsRef!!.outboxPath!!) + outboxDir.mkdirs() + // based on // https://github.com/getsentry/sentry-native/blob/20d5d5f75f1f48228f2f47e2bb99b17f9996ebbf/ndk/lib/src/androidTest/java/io/sentry/ndk/SentryNdkTest.java#L131 - File(optionsRef!!.outboxPath, "14779dbf-b2f0-4c00-f4e5-4a287abc4267") + File(outboxDir, "14779dbf-b2f0-4c00-f4e5-4a287abc4267") .writeText( """ {"dsn":"https://key@sentry.io/proj","event_id":"729ff878-5539-458d-f657-a1acf423a127","sent_at":"2025-04-02T10:02:04.732577Z"} diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index f10f2aede05..93104101f81 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -1104,7 +1104,11 @@ String getCacheDirPathWithoutDsn() { } /** - * Returns the outbox path if cacheDirPath is set + * Returns the outbox path if cacheDirPath is set. + * + *

The directory is created lazily by the SDK on a background thread, so it is not guaranteed + * to exist when {@code Sentry.init} returns. Callers writing envelopes here directly (for example + * hybrid SDKs) must create it themselves, e.g. {@code new File(outboxPath).mkdirs()}. * * @return the outbox path or null if not set */ From 2dfe4e13e4ab26b81f1e64be90f1061aade2e396 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 28 Jul 2026 09:59:58 +0200 Subject: [PATCH 06/11] 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 //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) --- sentry/src/main/java/io/sentry/Sentry.java | 7 ++++++- sentry/src/test/java/io/sentry/SentryTest.kt | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 77c7376b4ad..32730e7a91b 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -24,6 +24,7 @@ import io.sentry.util.DebugMetaPropertiesApplier; import io.sentry.util.FileUtils; import io.sentry.util.InitUtil; +import io.sentry.util.LazyDirectory; import io.sentry.util.LoadClass; import io.sentry.util.Platform; import io.sentry.util.SentryRandom; @@ -463,8 +464,9 @@ private static void handleAppStartProfilingConfig( () -> { final String cacheDirPath = options.getCacheDirPathWithoutDsn(); if (cacheDirPath != null) { + final @NotNull LazyDirectory cacheDir = new LazyDirectory(cacheDirPath); final @NotNull File appStartProfilingConfigFile = - new File(cacheDirPath, APP_START_PROFILING_CONFIG_FILE_NAME); + new File(cacheDir.getFile(), APP_START_PROFILING_CONFIG_FILE_NAME); try { // We always delete the config file for app start profiling FileUtils.deleteRecursively(appStartProfilingConfigFile); @@ -481,6 +483,9 @@ private static void handleAppStartProfilingConfig( "Tracing is disabled and app start profiling will not start."); return; } + // The cache dir is no longer created during init, so materialize it here before + // writing: createNewFile() fails if the parent is missing. + cacheDir.getOrCreate(); if (appStartProfilingConfigFile.createNewFile()) { // If old app start profiling is false, it means the transaction will not be // sampled, but we create the file anyway to allow continuous profiling on app diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index ab8533c9c60..98cda8e9d82 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -1316,6 +1316,23 @@ class SentryTest { assertTrue(appStartProfilingConfigFile.exists()) } + @Test + fun `init creates app start profiling config when the cache dir does not exist yet`() { + val path = getTempPath() + // Profiling is left disabled on purpose: it is the only other init-time consumer that creates + // the cache dir, so with it off nothing materializes the dir before the config is written. + initForTest { + it.dsn = dsn + it.cacheDirPath = path + it.isEnableAppStartProfiling = false + it.isStartProfilerOnAppStart = true + it.tracesSampleRate = 0.0 + it.profilesSampleRate = null + it.executorService = ImmediateExecutorService() + } + assertTrue(File(path, "app_start_profiling_config").exists()) + } + @Test fun `init saves SentryAppStartProfilingOptions to disk`() { var options = SentryOptions() From 9e04ed7a672bbf644d6a1b4d01eadaec783b4260 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 28 Jul 2026 10:35:51 +0200 Subject: [PATCH 07/11] 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. --- .../core/EnvelopeFileObserverIntegration.java | 9 +++++--- .../core/cache/AndroidEnvelopeCache.java | 14 +++++++------ sentry/api/sentry.api | 3 ++- sentry/src/main/java/io/sentry/Sentry.java | 16 ++++++++------ .../java/io/sentry/cache/CacheStrategy.java | 5 +++-- .../java/io/sentry/cache/EnvelopeCache.java | 14 +++---------- .../main/java/io/sentry/util/FileUtils.java | 14 +++++++++++++ .../java/io/sentry/util/LazyDirectory.java | 17 ++++++++++++--- .../java/io/sentry/cache/CacheStrategyTest.kt | 3 +-- .../java/io/sentry/util/LazyDirectoryTest.kt | 21 +++++++++++++++++++ 10 files changed, 82 insertions(+), 34 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java index 9b75a69e3f5..ab95ae32daa 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/EnvelopeFileObserverIntegration.java @@ -10,9 +10,10 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.util.AutoClosableReentrantLock; -import io.sentry.util.LazyDirectory; +import io.sentry.util.FileUtils; import io.sentry.util.Objects; import java.io.Closeable; +import java.io.File; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -68,9 +69,11 @@ private void startOutboxSender( final @NotNull IScopes scopes, final @NotNull SentryOptions options, final @NotNull String path) { - // Materialize the outbox dir here (on the executor) so the observer can watch it for envelopes + // Create the outbox dir here (on the executor) so the observer can watch it for envelopes // written by hybrid SDKs, instead of blocking Sentry.init on the mkdirs. - new LazyDirectory(path).getOrCreate(); + if (!FileUtils.createDirectory(new File(path))) { + options.getLogger().log(SentryLevel.ERROR, "Failed to create outbox dir %s", path); + } final OutboxSender outboxSender = new OutboxSender( diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index 9d0b98a2896..1ef02dfdd2c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -18,7 +18,6 @@ import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.FileUtils; import io.sentry.util.HintUtils; -import io.sentry.util.LazyDirectory; import io.sentry.util.Objects; import java.io.File; import java.io.FileNotFoundException; @@ -48,8 +47,7 @@ public AndroidEnvelopeCache(final @NotNull SentryAndroidOptions options) { final @NotNull ICurrentDateProvider currentDateProvider) { super( options, - new LazyDirectory( - Objects.requireNonNull(options.getCacheDirPath(), "cacheDirPath must not be null")), + Objects.requireNonNull(options.getCacheDirPath(), "cacheDirPath must not be null"), options.getMaxCacheItems()); this.currentDateProvider = currentDateProvider; } @@ -108,9 +106,13 @@ private void writeStartupCrashMarkerFile() { .log(DEBUG, "Outbox path is null, the startup crash marker file will not be written"); return; } - // The outbox dir is no longer created during Sentry.init, so materialize it here in case the - // native SDK (which normally creates it) is disabled. - final File outboxDir = new LazyDirectory(outboxPath).getOrCreate(); + // 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); + if (!FileUtils.createDirectory(outboxDir)) { + options.getLogger().log(ERROR, "Failed to create outbox dir %s", outboxPath); + return; + } final File crashMarkerFile = new File(outboxDir, STARTUP_CRASH_MARKER_FILE); try { crashMarkerFile.createNewFile(); diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e592bc7c4c5..8630cfeafd5 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4836,7 +4836,6 @@ public class io/sentry/cache/EnvelopeCache : io/sentry/cache/IEnvelopeCache { protected static final field UTF_8 Ljava/nio/charset/Charset; protected final field cacheLock Lio/sentry/util/AutoClosableReentrantLock; protected final field sessionLock Lio/sentry/util/AutoClosableReentrantLock; - public fun (Lio/sentry/SentryOptions;Lio/sentry/util/LazyDirectory;I)V public fun (Lio/sentry/SentryOptions;Ljava/lang/String;I)V public static fun create (Lio/sentry/SentryOptions;)Lio/sentry/cache/IEnvelopeCache; public fun discard (Lio/sentry/SentryEnvelope;)V @@ -7693,6 +7692,7 @@ public final class io/sentry/util/ExceptionUtils { public final class io/sentry/util/FileUtils { public fun ()V + public static fun createDirectory (Ljava/io/File;)Z public static fun deleteRecursively (Ljava/io/File;)Z public static fun readBytesFromFile (Ljava/lang/String;J)[B public static fun readText (Ljava/io/File;)Ljava/lang/String; @@ -7762,6 +7762,7 @@ public final class io/sentry/util/LazyDirectory { public fun (Ljava/lang/String;)V public fun getFile ()Ljava/io/File; public fun getOrCreate ()Ljava/io/File; + public fun resolve (Ljava/lang/String;)Ljava/io/File; } public final class io/sentry/util/LazyEvaluator { diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 32730e7a91b..266aa39e793 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -24,7 +24,6 @@ import io.sentry.util.DebugMetaPropertiesApplier; import io.sentry.util.FileUtils; import io.sentry.util.InitUtil; -import io.sentry.util.LazyDirectory; import io.sentry.util.LoadClass; import io.sentry.util.Platform; import io.sentry.util.SentryRandom; @@ -464,9 +463,9 @@ private static void handleAppStartProfilingConfig( () -> { final String cacheDirPath = options.getCacheDirPathWithoutDsn(); if (cacheDirPath != null) { - final @NotNull LazyDirectory cacheDir = new LazyDirectory(cacheDirPath); + final @NotNull File cacheDir = new File(cacheDirPath); final @NotNull File appStartProfilingConfigFile = - new File(cacheDir.getFile(), APP_START_PROFILING_CONFIG_FILE_NAME); + new File(cacheDir, APP_START_PROFILING_CONFIG_FILE_NAME); try { // We always delete the config file for app start profiling FileUtils.deleteRecursively(appStartProfilingConfigFile); @@ -483,9 +482,14 @@ private static void handleAppStartProfilingConfig( "Tracing is disabled and app start profiling will not start."); return; } - // The cache dir is no longer created during init, so materialize it here before - // writing: createNewFile() fails if the parent is missing. - cacheDir.getOrCreate(); + // The cache dir is no longer created during init, so create it here before writing: + // createNewFile() fails if the parent is missing. + if (!FileUtils.createDirectory(cacheDir)) { + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to create cache dir %s", cacheDirPath); + return; + } if (appStartProfilingConfigFile.createNewFile()) { // If old app start profiling is false, it means the transaction will not be // sampled, but we create the file anyway to allow continuous profiling on app diff --git a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java index 67f597cc4af..5f3e605f2a1 100644 --- a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java +++ b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java @@ -45,10 +45,11 @@ abstract class CacheStrategy { CacheStrategy( final @NotNull SentryOptions options, - final @NotNull LazyDirectory directory, + final @NotNull String directoryPath, final int maxSize) { + Objects.requireNonNull(directoryPath, "Directory is required."); this.options = Objects.requireNonNull(options, "SentryOptions is required."); - this.directory = Objects.requireNonNull(directory, "Directory is required."); + this.directory = new LazyDirectory(directoryPath); this.maxSize = maxSize; } diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 9df6f6d808b..ea86185097a 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -26,7 +26,6 @@ import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.HintUtils; -import io.sentry.util.LazyDirectory; import io.sentry.util.Objects; import java.io.BufferedInputStream; import java.io.BufferedReader; @@ -84,7 +83,7 @@ public class EnvelopeCache extends CacheStrategy implements IEnvelopeCache { options.getLogger().log(WARNING, "cacheDirPath is null, returning NoOpEnvelopeCache"); return NoOpEnvelopeCache.getInstance(); } else { - return new EnvelopeCache(options, new LazyDirectory(cacheDirPath), maxCacheItems); + return new EnvelopeCache(options, cacheDirPath, maxCacheItems); } } @@ -92,14 +91,7 @@ public EnvelopeCache( final @NotNull SentryOptions options, final @NotNull String cacheDirPath, final int maxCacheItems) { - this(options, new LazyDirectory(cacheDirPath), maxCacheItems); - } - - public EnvelopeCache( - final @NotNull SentryOptions options, - final @NotNull LazyDirectory directory, - final int maxCacheItems) { - super(options, directory, maxCacheItems); + super(options, cacheDirPath, maxCacheItems); previousSessionLatch = new CountDownLatch(1); } @@ -396,7 +388,7 @@ public void discard(final @NotNull SentryEnvelope envelope) { fileNameMap.put(envelope, fileName); } - return new File(directory.getFile().getAbsolutePath(), fileName); + return directory.resolve(fileName); } } diff --git a/sentry/src/main/java/io/sentry/util/FileUtils.java b/sentry/src/main/java/io/sentry/util/FileUtils.java index 73b83713c77..7159db183f8 100644 --- a/sentry/src/main/java/io/sentry/util/FileUtils.java +++ b/sentry/src/main/java/io/sentry/util/FileUtils.java @@ -8,6 +8,7 @@ import java.io.FileReader; import java.io.IOException; import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @ApiStatus.Internal @@ -36,6 +37,19 @@ public static boolean deleteRecursively(@Nullable File file) { return file.delete(); } + /** + * Creates the directory and any missing parents, if it does not exist yet. + * + *

Callers are expected to log a failure: a missing directory otherwise surfaces later as an + * unrelated-looking write error. + * + * @param directory the directory to create + * @return true if the directory exists once this returns, false if it could not be created + */ + public static boolean createDirectory(final @NotNull File directory) { + return directory.isDirectory() || directory.mkdirs(); + } + /** * Reads the content of a File into a String. If the file does not exist or is not a file, null is * returned. Do not use with large files, as the String is kept in memory! diff --git a/sentry/src/main/java/io/sentry/util/LazyDirectory.java b/sentry/src/main/java/io/sentry/util/LazyDirectory.java index 05d01f0bedf..0bf745fb21e 100644 --- a/sentry/src/main/java/io/sentry/util/LazyDirectory.java +++ b/sentry/src/main/java/io/sentry/util/LazyDirectory.java @@ -8,6 +8,11 @@ * A filesystem directory that is created on demand rather than up front, so the (potentially * blocking) {@code mkdirs()} runs on the thread that first writes into it instead of on the SDK * init thread. + * + *

Read paths should use {@link #getFile()}, which never touches the filesystem. Write paths + * should use {@link #resolve(String)} so the directory is guaranteed to exist before the file is + * written to. Creation is not cached: on Android the cache dir lives under {@code + * Context.getCacheDir()}, which the system may wipe at any time, so each write re-checks. */ @ApiStatus.Internal public final class LazyDirectory { @@ -25,9 +30,15 @@ public LazyDirectory(final @NotNull String path) { /** Returns the directory, creating it and any missing parents if it does not exist yet. */ public @NotNull File getOrCreate() { - if (!file.isDirectory()) { - file.mkdirs(); - } + FileUtils.createDirectory(file); return file; } + + /** + * Returns a file inside this directory, creating the directory first so the returned file can be + * written to. + */ + public @NotNull File resolve(final @NotNull String child) { + return new File(getOrCreate(), child); + } } diff --git a/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt b/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt index e0975214ea3..881d0da37b3 100644 --- a/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt +++ b/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt @@ -9,7 +9,6 @@ import io.sentry.Session import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport import io.sentry.clientreport.DiscardReason import io.sentry.clientreport.DiscardedEvent -import io.sentry.util.LazyDirectory import java.io.ByteArrayInputStream import java.io.File import java.io.InputStreamReader @@ -131,7 +130,7 @@ class CacheStrategyTest { } private class CustomCache(options: SentryOptions, path: String, maxSize: Int) : - CacheStrategy(options, LazyDirectory(path), maxSize) + CacheStrategy(options, path, maxSize) private fun createTempFilesSortByOldestToNewest(): Array { val f1 = Files.createTempFile(fixture.dir.toPath(), "f1", ".json").toFile() diff --git a/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt b/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt index 01376b56be2..25a7d31b42a 100644 --- a/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt +++ b/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt @@ -32,4 +32,25 @@ class LazyDirectoryTest { assertThat(lazyDirectory.getOrCreate().isDirectory).isTrue() assertThat(lazyDirectory.getOrCreate().isDirectory).isTrue() } + + @Test + fun `resolve creates the directory so the child can be written to`() { + val path = Files.createTempDirectory("lazy-dir-test").resolve("outbox") + val lazyDirectory = LazyDirectory(path.toString()) + + val child = lazyDirectory.resolve("envelope.envelope") + + assertThat(child.parentFile.isDirectory).isTrue() + assertThat(child.createNewFile()).isTrue() + } + + @Test + fun `getOrCreate recreates the directory after it is deleted`() { + val path = Files.createTempDirectory("lazy-dir-test").resolve("outbox") + val lazyDirectory = LazyDirectory(path.toString()) + + assertThat(lazyDirectory.getOrCreate().delete()).isTrue() + + assertThat(lazyDirectory.getOrCreate().isDirectory).isTrue() + } } From 86670d6d01128aea55b038332abe9f36c7b38ab5 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 28 Jul 2026 10:56:31 +0200 Subject: [PATCH 08/11] 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. --- .../main/java/io/sentry/util/FileUtils.java | 4 +- .../test/java/io/sentry/util/FileUtilsTest.kt | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/util/FileUtils.java b/sentry/src/main/java/io/sentry/util/FileUtils.java index 7159db183f8..337a9a8863d 100644 --- a/sentry/src/main/java/io/sentry/util/FileUtils.java +++ b/sentry/src/main/java/io/sentry/util/FileUtils.java @@ -47,7 +47,9 @@ public static boolean deleteRecursively(@Nullable File file) { * @return true if the directory exists once this returns, false if it could not be created */ public static boolean createDirectory(final @NotNull File directory) { - return directory.isDirectory() || directory.mkdirs(); + // mkdirs() also returns false when another thread created the directory first, so re-check + // instead of reporting a failure the caller would act on by skipping its write. + return directory.isDirectory() || directory.mkdirs() || directory.isDirectory(); } /** diff --git a/sentry/src/test/java/io/sentry/util/FileUtilsTest.kt b/sentry/src/test/java/io/sentry/util/FileUtilsTest.kt index 65745c0d3d2..60eb092e125 100644 --- a/sentry/src/test/java/io/sentry/util/FileUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/FileUtilsTest.kt @@ -1,7 +1,10 @@ package io.sentry.util +import com.google.common.truth.Truth.assertThat import java.io.File import java.nio.file.Files +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CyclicBarrier import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -73,4 +76,51 @@ class FileUtilsTest { f.writeText(text) assertEquals(text, FileUtils.readText(f)) } + + @Test + fun `createDirectory creates the directory and any missing parents`() { + val dir = File(Files.createTempDirectory("create-dir-test").toFile(), "nested/outbox") + + assertThat(FileUtils.createDirectory(dir)).isTrue() + assertThat(dir.isDirectory).isTrue() + } + + @Test + fun `createDirectory returns true when the directory already exists`() { + val dir = Files.createTempDirectory("create-dir-test").toFile() + + assertThat(FileUtils.createDirectory(dir)).isTrue() + } + + @Test + fun `createDirectory returns false when the directory cannot be created`() { + val file = Files.createTempFile("create-dir-test", "test").toFile() + + // a regular file already occupies the path, so it can never become a directory + assertThat(FileUtils.createDirectory(file)).isFalse() + } + + @Test + fun `createDirectory returns true for every caller when threads race to create it`() { + val threadCount = 8 + // mkdirs() returns false for the losers of the race, so every caller must still see success + repeat(50) { iteration -> + val dir = File(Files.createTempDirectory("create-dir-race").toFile(), "run$iteration/outbox") + val barrier = CyclicBarrier(threadCount) + val results = ConcurrentLinkedQueue() + + val threads = + (1..threadCount).map { + Thread { + barrier.await() + results.add(FileUtils.createDirectory(dir)) + } + } + threads.forEach { it.start() } + threads.forEach { it.join() } + + assertThat(results).hasSize(threadCount) + assertThat(results).doesNotContain(false) + } + } } From bb93d876636efa7b56b10f5c06a9bfd3a4baaa09 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 28 Jul 2026 13:48:20 +0200 Subject: [PATCH 09/11] 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. --- sentry/api/sentry.api | 1 - .../main/java/io/sentry/cache/EnvelopeCache.java | 6 +++++- .../main/java/io/sentry/util/LazyDirectory.java | 14 +++----------- .../test/java/io/sentry/cache/EnvelopeCacheTest.kt | 13 +++++++++++++ .../test/java/io/sentry/util/LazyDirectoryTest.kt | 11 ----------- 5 files changed, 21 insertions(+), 24 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8630cfeafd5..14780d1a4b2 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7762,7 +7762,6 @@ public final class io/sentry/util/LazyDirectory { public fun (Ljava/lang/String;)V public fun getFile ()Ljava/io/File; public fun getOrCreate ()Ljava/io/File; - public fun resolve (Ljava/lang/String;)Ljava/io/File; } public final class io/sentry/util/LazyEvaluator { diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index ea86185097a..618de655478 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -375,6 +375,10 @@ public void discard(final @NotNull SentryEnvelope envelope) { * Returns the envelope's file path. If the envelope wasn't added to the cache beforehand, a * random file name is assigned. * + *

This only computes a path and never creates the directory, so that {@link + * #discard(SentryEnvelope)} doesn't resurrect a cache dir it is only deleting from. Writers go + * through {@link #storeInternal}, which creates the directory up front. + * * @param envelope the SentryEnvelope object * @return the file */ @@ -388,7 +392,7 @@ public void discard(final @NotNull SentryEnvelope envelope) { fileNameMap.put(envelope, fileName); } - return directory.resolve(fileName); + return new File(directory.getFile(), fileName); } } diff --git a/sentry/src/main/java/io/sentry/util/LazyDirectory.java b/sentry/src/main/java/io/sentry/util/LazyDirectory.java index 0bf745fb21e..412d6887b14 100644 --- a/sentry/src/main/java/io/sentry/util/LazyDirectory.java +++ b/sentry/src/main/java/io/sentry/util/LazyDirectory.java @@ -10,9 +10,9 @@ * init thread. * *

Read paths should use {@link #getFile()}, which never touches the filesystem. Write paths - * should use {@link #resolve(String)} so the directory is guaranteed to exist before the file is - * written to. Creation is not cached: on Android the cache dir lives under {@code - * Context.getCacheDir()}, which the system may wipe at any time, so each write re-checks. + * should call {@link #getOrCreate()} once before writing. Creation is not cached: on Android the + * cache dir lives under {@code Context.getCacheDir()}, which the system may wipe at any time, so + * each write re-checks. */ @ApiStatus.Internal public final class LazyDirectory { @@ -33,12 +33,4 @@ public LazyDirectory(final @NotNull String path) { FileUtils.createDirectory(file); return file; } - - /** - * Returns a file inside this directory, creating the directory first so the returned file can be - * written to. - */ - public @NotNull File resolve(final @NotNull String child) { - return new File(getOrCreate(), child); - } } diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index 8360bcf2ff0..dda06ee7e63 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -104,6 +104,19 @@ class EnvelopeCacheTest { // no exception thrown } + @Test + fun `does not create cache dir on discard`() { + val cache = fixture.getSUT() + + val file = File(fixture.options.cacheDirPath!!) + assertTrue(file.deleteRecursively()) + assertFalse(file.exists()) + + cache.discard(SentryEnvelope.from(fixture.options.serializer, createSession(), null)) + + assertFalse(file.exists()) + } + @Test fun `creates current file on session start`() { val cache = fixture.getSUT() diff --git a/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt b/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt index 25a7d31b42a..13e4df001e4 100644 --- a/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt +++ b/sentry/src/test/java/io/sentry/util/LazyDirectoryTest.kt @@ -33,17 +33,6 @@ class LazyDirectoryTest { assertThat(lazyDirectory.getOrCreate().isDirectory).isTrue() } - @Test - fun `resolve creates the directory so the child can be written to`() { - val path = Files.createTempDirectory("lazy-dir-test").resolve("outbox") - val lazyDirectory = LazyDirectory(path.toString()) - - val child = lazyDirectory.resolve("envelope.envelope") - - assertThat(child.parentFile.isDirectory).isTrue() - assertThat(child.createNewFile()).isTrue() - } - @Test fun `getOrCreate recreates the directory after it is deleted`() { val path = Files.createTempDirectory("lazy-dir-test").resolve("outbox") From 1c44d8302b6a453aa548aa77600416171921ba6a Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 28 Jul 2026 16:29:19 +0200 Subject: [PATCH 10/11] 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. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcb8f7e1386..c11ea0449f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Behavioral Changes + +- The outbox and cache directories are no longer created by `Sentry.init` ([#5792](https://github.com/getsentry/sentry-java/pull/5792)) + - 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. + - 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()`. + ### Improvements - 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)) From eda31588261a3ed1a8d300795430f58d525ae4d4 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 28 Jul 2026 16:29:19 +0200 Subject: [PATCH 11/11] 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. --- sentry/src/main/java/io/sentry/util/LazyDirectory.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry/src/main/java/io/sentry/util/LazyDirectory.java b/sentry/src/main/java/io/sentry/util/LazyDirectory.java index 412d6887b14..62334c7665d 100644 --- a/sentry/src/main/java/io/sentry/util/LazyDirectory.java +++ b/sentry/src/main/java/io/sentry/util/LazyDirectory.java @@ -30,6 +30,8 @@ public LazyDirectory(final @NotNull String path) { /** Returns the directory, creating it and any missing parents if it does not exist yet. */ public @NotNull File getOrCreate() { + // A failed mkdirs is not reported here: callers are write paths, so the failure surfaces as the + // write error they already log and report. FileUtils.createDirectory(file); return file; }