Skip to content
Merged
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))
Comment thread
runningcode marked this conversation as resolved.
- 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);
Comment thread
runningcode marked this conversation as resolved.
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
runningcode 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();
Comment thread
runningcode marked this conversation as resolved.
}
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);
Comment thread
runningcode marked this conversation as resolved.
return file;
}
}
Loading
Loading