From d868b871e3403318586564cfeb19bc9e0ea5af66 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 17:03:39 +0200 Subject: [PATCH 1/4] 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 46681dea7f3fed73873ba731bf56ee1803ccab89 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 17:04:36 +0200 Subject: [PATCH 2/4] changelog Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d37da44d4b..03d5a9273db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### 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)) + ### Fixes - Backfill release, environment, distribution, tags, and app version/build—and use the matching replay-on-error sample rate—for `ApplicationExitInfo` ANR and native crash events captured before SDK initialization, without reusing options cached by a later app update ([#5762](https://github.com/getsentry/sentry-java/pull/5762)) From 1fdc454898471511b1238ae1f270fdf840560890 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 20 Jul 2026 17:19:26 +0200 Subject: [PATCH 3/4] 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 a2a855bfc244c47f19cc837b005ebcbeb809c59d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 21 Jul 2026 16:32:36 +0200 Subject: [PATCH 4/4] 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()