Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -13,6 +19,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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

- 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))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
import io.sentry.SentryLevel;
import io.sentry.SentryOptions;
import io.sentry.util.AutoClosableReentrantLock;
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;
Expand Down Expand Up @@ -67,6 +69,12 @@ private void startOutboxSender(
final @NotNull IScopes scopes,
final @NotNull SentryOptions options,
final @NotNull String path) {
// 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.
if (!FileUtils.createDirectory(new File(path))) {
options.getLogger().log(SentryLevel.ERROR, "Failed to create outbox dir %s", path);
}

final OutboxSender outboxSender =
new OutboxSender(
scopes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ private boolean storeInternalAndroid(@NotNull SentryEnvelope envelope, @NotNull

@TestOnly
public @NotNull File getDirectory() {
return directory;
return directory.getFile();
}

private void writeStartupCrashMarkerFile() {
Expand All @@ -106,7 +106,14 @@ 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 create it here in case the native
// SDK (which normally creates it) is disabled.
final File outboxDir = new File(outboxPath);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

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

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

@runningcode runningcode Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

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();
} catch (Throwable e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
7 changes: 7 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -7692,6 +7692,7 @@ public final class io/sentry/util/ExceptionUtils {

public final class io/sentry/util/FileUtils {
public fun <init> ()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;
Expand Down Expand Up @@ -7757,6 +7758,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 <init> (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 <init> (Lio/sentry/util/LazyEvaluator$Evaluator;)V
public fun getValue ()Ljava/lang/Object;
Expand Down
22 changes: 13 additions & 9 deletions sentry/src/main/java/io/sentry/Sentry.java
Original file line number Diff line number Diff line change
Expand Up @@ -463,8 +463,9 @@ private static void handleAppStartProfilingConfig(
() -> {
final String cacheDirPath = options.getCacheDirPathWithoutDsn();
if (cacheDirPath != null) {
final @NotNull File cacheDir = new File(cacheDirPath);
final @NotNull File appStartProfilingConfigFile =
new File(cacheDirPath, 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);
Expand All @@ -481,6 +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 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
Expand Down Expand Up @@ -616,19 +625,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();
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
final IEnvelopeCache envelopeCache = options.getEnvelopeDiskCache();
// only overwrite the cache impl if it's not already set
if (envelopeCache instanceof NoOpEnvelopeCache) {
Expand Down
6 changes: 5 additions & 1 deletion sentry/src/main/java/io/sentry/SentryOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -1104,7 +1104,11 @@ String getCacheDirPathWithoutDsn() {
}

/**
* Returns the outbox path if cacheDirPath is set
* Returns the outbox path if cacheDirPath is set.
*
* <p>The directory is created lazily by the SDK on a background thread, so it is not guaranteed
Comment thread
0xadam-brown marked this conversation as resolved.
* 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
*/
Expand Down
14 changes: 6 additions & 8 deletions sentry/src/main/java/io/sentry/cache/CacheStrategy.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,7 +40,7 @@ abstract class CacheStrategy {
protected @NotNull SentryOptions options;
protected final @NotNull LazyEvaluator<ISerializer> serializer =
new LazyEvaluator<>(() -> options.getSerializer());
protected final @NotNull File directory;
protected final @NotNull LazyDirectory directory;
private final int maxSize;

CacheStrategy(
Expand All @@ -48,9 +49,7 @@ abstract class CacheStrategy {
final int maxSize) {
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;
}

Expand All @@ -60,13 +59,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;
Expand Down
17 changes: 12 additions & 5 deletions sentry/src/main/java/io/sentry/cache/EnvelopeCache.java
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,13 @@ 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.
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()) {
Expand Down Expand Up @@ -199,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.");
Expand Down Expand Up @@ -372,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.
*
* <p>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
*/
Expand All @@ -385,7 +392,7 @@ public void discard(final @NotNull SentryEnvelope envelope) {
fileNameMap.put(envelope, fileName);
}

return new File(directory.getAbsolutePath(), fileName);
return new File(directory.getFile(), fileName);
}
}

Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -431,7 +438,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;
}
Expand Down
16 changes: 16 additions & 0 deletions sentry/src/main/java/io/sentry/util/FileUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -36,6 +37,21 @@ public static boolean deleteRecursively(@Nullable File file) {
return file.delete();
}

/**
* Creates the directory and any missing parents, if it does not exist yet.
*
* <p>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) {
// 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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

not gonna lie this reads super confusing ๐Ÿ˜‚

}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* 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!
Expand Down
38 changes: 38 additions & 0 deletions sentry/src/main/java/io/sentry/util/LazyDirectory.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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.
*
* <p>Read paths should use {@link #getFile()}, which never touches the filesystem. Write paths
* 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 {

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() {
// 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);

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

return file;
}
}
Loading
Loading