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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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 org.jetbrains.annotations.NotNull;
Expand Down Expand Up @@ -67,6 +68,10 @@ 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
// written by hybrid SDKs, instead of blocking Sentry.init on the mkdirs.
new LazyDirectory(path).getOrCreate();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Outbox missing after init returns

Medium Severity

Outbox creation moved onto the executor inside startOutboxSender, so after Sentry.init returns the outbox may still be missing when NDK is disabled (native no longer materializes it during init). Hybrid SDKs that write envelopes to the outbox immediately after init can fail in that window, whereas init previously created the directory synchronously before returning.

Additional Locations (1)
Fix in Cursorย Fix in Web

Reviewed by Cursor Bugbot for commit a2a855b. Configure here.


final OutboxSender outboxSender =
new OutboxSender(
scopes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -47,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;
}
Expand Down Expand Up @@ -93,7 +95,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 +108,10 @@ 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 {
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
7 changes: 7 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (Lio/sentry/SentryOptions;Lio/sentry/util/LazyDirectory;I)V
public fun <init> (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
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
11 changes: 3 additions & 8 deletions sentry/src/main/java/io/sentry/Sentry.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cache send errors on missing dir

Medium Severity

Removing init-time mkdirs() for the cache dir leaves it absent on fresh installs, but SendCachedEnvelopeFireAndForgetIntegration still processes that path at startup via DirectoryProcessor. When the dir does not exist, listFiles() returns null and logs an ERROR that the cache dir is not a directoryโ€”noise on every cold start without profiling (which would incidentally create the parent via profilingTracesDir.mkdirs()). Lazy creation only happens later in EnvelopeCache.storeInternal, so the startup read path was not covered.

Additional Locations (1)
Fix in Cursorย Fix in Web

Reviewed by Cursor Bugbot for commit a2a855b. Configure here.

final IEnvelopeCache envelopeCache = options.getEnvelopeDiskCache();
// only overwrite the cache impl if it's not already set
if (envelopeCache instanceof NoOpEnvelopeCache) {
Expand Down
17 changes: 7 additions & 10 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,18 +40,15 @@ 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(
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 File(directoryPath);

this.directory = Objects.requireNonNull(directory, "Directory is required.");
this.maxSize = maxSize;
}

Expand All @@ -60,13 +58,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
25 changes: 18 additions & 7 deletions sentry/src/main/java/io/sentry/cache/EnvelopeCache.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -83,15 +84,22 @@ 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);
}
}

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

Expand All @@ -109,10 +117,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 +210,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 @@ -385,7 +396,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);
}
}

Expand Down Expand Up @@ -431,7 +442,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
33 changes: 33 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,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;
}
}
17 changes: 8 additions & 9 deletions sentry/src/test/java/io/sentry/SentryTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -183,35 +183,35 @@ 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
it.cacheDirPath = getTempPath()
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
it.cacheDirPath = getTempPath()
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
Expand All @@ -221,8 +221,7 @@ class SentryTest {

val cacheDirPathWithoutDsn = sentryOptions!!.cacheDirPathWithoutDsn!!
val file = File(cacheDirPathWithoutDsn)
assertTrue(file.exists())
file.deleteOnExit()
assertFalse(file.exists())
}

@Test
Expand Down
3 changes: 2 additions & 1 deletion sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<File> {
val f1 = Files.createTempFile(fixture.dir.toPath(), "f1", ".json").toFile()
Expand Down
Loading
Loading