Skip to content

Commit 221e9db

Browse files
authored
Merge branch 'main' into markushi/fix/frame-metrics-listener
2 parents 7f5590e + d0e5fdc commit 221e9db

File tree

39 files changed

+2361
-1111
lines changed

39 files changed

+2361
-1111
lines changed

.cursor/rules/offline.mdc

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
---
2+
alwaysApply: true
3+
description: Java SDK Offline behaviour
4+
---
5+
# Java SDK Offline behaviour
6+
7+
By default offline caching is enabled for Android but disabled for JVM.
8+
It can be enabled by setting SentryOptions.cacheDirPath.
9+
10+
For Android, AndroidEnvelopeCache is used. For JVM, if cache path has been configured, EnvelopeCache will be used.
11+
12+
Any error, event, transaction, profile, replay etc. is turned into an envelope and then sent into ITransport.send.
13+
The default implementation is AsyncHttpTransport.
14+
15+
If an envelope is dropped due to rate limit and has previously been cached (Cached hint) it will be discarded from the IEnvelopeCache.
16+
17+
AsyncHttpTransport.send will enqueue an AsyncHttpTransport.EnvelopeSender task onto an executor.
18+
19+
Any envelope that doesn't have the Cached hint will be stored in IEnvelopeCache by the EventSender task. Previously cached envelopes (Cached hint) will have a noop cache passed to AsyncHttpTransport.EnvelopeSender and thus not cache again. It is also possible cache is disabled in general.
20+
21+
An envelope being sent directly from SDK API like Sentry.captureException will not have the Retryable hint.
22+
23+
In case the SDK is offline, it'll mark the envelope to be retried if it has the Retryable hint.
24+
If the envelope is not retryable and hasn't been sent to offline cache, it's recorded as lost in a client report.
25+
26+
In case the envelope can't be sent due to an error or network connection problems it'll be marked for retry if it has the Retryable hint.
27+
If it's not retryable and hasn't been cached, it's recorded as lost in a client report.
28+
29+
In case the envelope is sent successfully, it'll be discarded from cache.
30+
31+
The SDK has multiple mechanisms to deal with envelopes on disk.
32+
- OutboxSender: Sends events coming from other SDKs like NDK that wrote them to disk.
33+
- io.sentry.EnvelopeSender: This is the offline cache.
34+
35+
Both of these are set up through an integration (SendCachedEnvelopeIntegration) which is configured to use SendFireAndForgetOutboxSender or SendFireAndForgetEnvelopeSender.
36+
37+
io.sentry.EnvelopeSender is able to pick up files in the cache directory and send them.
38+
It will trigger sending envelopes in cache dir on init and when the connection status changes (e.g. the SDK comes back online, meaning it has Internet connection again).
39+
40+
## When Envelope Files Are Removed From Cache
41+
42+
Envelope files are removed from the cache directory in the following scenarios:
43+
44+
### 1. Successful Send to Sentry Server
45+
When `AsyncHttpTransport` successfully sends an envelope to the Sentry server, it calls `envelopeCache.discard(envelope)` to remove the cached file. This happens in `AsyncHttpTransport.EnvelopeSender.flush()` when `result.isSuccess()` is true.
46+
47+
### 2. Rate Limited Previously Cached Envelopes
48+
If an envelope is dropped due to rate limiting **and** has previously been cached (indicated by the `Cached` hint), it gets discarded immediately via `envelopeCache.discard(envelope)` in `AsyncHttpTransport.send()`.
49+
In this case the discarded envelope is recorded as lost in client reports.
50+
51+
### 3. Offline Cache Processing (EnvelopeSender)
52+
When the SDK processes cached envelope files from disk (via `EnvelopeSender`), files are deleted after processing **unless** they are marked for retry. In `EnvelopeSender.processFile()`, the file is deleted with `safeDelete(file)` if `!retryable.isRetry()`.
53+
54+
### 4. Session File Management
55+
Session-related files (session.json, previous_session.json) are removed during session lifecycle events like session start/end and abnormal exits.
56+
57+
### 5. Cache rotation
58+
If the number of files in the cache directory has reached the configured limit (SentryOptions.maxCacheItems), the oldest file will be deleted to make room.
59+
This happens in `CacheStrategy.rotateCacheIfNeeded`. The deleted envelope will be recorded as lost in client reports.
60+
61+
## Retry Mechanism
62+
63+
**Important**: The SDK does NOT implement a traditional "max retry count" mechanism. Instead:
64+
65+
### Infinite Retry Approach
66+
- **Retryable envelopes**: Stay in cache indefinitely and are retried when conditions improve (network connectivity restored, rate limits expire, etc.)
67+
- **Non-retryable envelopes**: If they fail to send, they're immediately recorded as lost (not cached for retry)
68+
69+
### When Envelopes Are Permanently Lost (Not Due to Retry Limits)
70+
71+
1. **Queue Overflow**: When the transport executor queue is full - recorded as `DiscardReason.QUEUE_OVERFLOW`
72+
73+
2. **Network Errors (Non-Retryable)**: When an envelope isn't marked as retryable and fails due to network issues - recorded as `DiscardReason.NETWORK_ERROR`
74+
75+
3. **Rate Limiting**: When envelope items are dropped due to active rate limits - recorded as `DiscardReason.RATELIMIT_BACKOFF`
76+
77+
4. **Cache Overflow**: When the cache directory has reached maxCacheItems, old files are deleted - recorded as `DiscardReason.CACHE_OVERFLOW`
78+
79+
### Cache Processing Triggers
80+
Cached envelopes are processed when:
81+
- Network connectivity is restored (via connection status observer)
82+
- SDK initialization occurs
83+
- Rate limits expire
84+
- Manual flush operations
85+
86+
### File Deletion Implementation
87+
The actual file deletion is handled by `EnvelopeCache.discard()` which calls `envelopeFile.delete()` and logs errors if deletion fails.

.github/workflows/release-build.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,3 @@ jobs:
3939
path: |
4040
./*/build/distributions/*.zip
4141
./sentry-opentelemetry/*/build/distributions/*.zip
42-
./sentry-android-ndk/build/intermediates/merged_native_libs/release/out/lib/*

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,27 @@
22

33
## Unreleased
44

5+
### Improvements
6+
7+
- Session Replay: Use main thread looper to schedule replay capture ([#4542](https://github.com/getsentry/sentry-java/pull/4542))
8+
- Use single `LifecycleObserver` and multi-cast it to the integrations interested in lifecycle states ([#4567](https://github.com/getsentry/sentry-java/pull/4567))
9+
10+
### Fixes
11+
12+
- Cache network capabilities and status to reduce IPC calls ([#4560](https://github.com/getsentry/sentry-java/pull/4560))
13+
- Deduplicate battery breadcrumbs ([#4561](https://github.com/getsentry/sentry-java/pull/4561))
14+
- Remove unused method in ManifestMetadataReader ([#4585](https://github.com/getsentry/sentry-java/pull/4585))
15+
- Have single `NetworkCallback` registered at a time to reduce IPC calls ([#4562](https://github.com/getsentry/sentry-java/pull/4562))
16+
- Limit ProGuard keep rules for native methods within `sentry-android-ndk` to the `io.sentry.**` namespace. ([#4427](https://github.com/getsentry/sentry-java/pull/4427))
17+
- If you relied on the Sentry SDK to keep native method names for JNI compatibility within your namespace, please review your ProGuard rules and ensure the configuration still works. Especially when you're not consuming any of the default Android proguard rules (`proguard-android.txt` or `proguard-android-optimize.txt`) the following config should be present:
18+
```
19+
-keepclasseswithmembernames class * {
20+
native <methods>;
21+
}
22+
```
23+
24+
## 8.18.0
25+
526
### Features
627

728
- Add `SentryUserFeedbackButton` Composable ([#4559](https://github.com/getsentry/sentry-java/pull/4559))

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled
1111
android.useAndroidX=true
1212

1313
# Release information
14-
versionName=8.17.0
14+
versionName=8.18.0
1515

1616
# Override the SDK name on native crashes on Android
1717
sentryAndroidSdkName=sentry.native.android

scripts/update-gradle.sh

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,6 @@ set-version)
3232
version="${version:1}"
3333
fi
3434

35-
# Remove trailing ".0" - gradlew expects '7.1' instead of '7.1.0'
36-
if [[ "$version" == *".0" ]]; then
37-
version="${version:0:${#version}-2}"
38-
fi
3935
echo "Setting gradle version to '$version'"
4036

4137
# This sets version to gradle-wrapper.properties.

sentry-android-core/api/sentry-android-core.api

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,17 @@ public final class io/sentry/android/core/AppLifecycleIntegration : io/sentry/In
166166
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
167167
}
168168

169-
public final class io/sentry/android/core/AppState {
169+
public final class io/sentry/android/core/AppState : java/io/Closeable {
170+
public fun close ()V
170171
public static fun getInstance ()Lio/sentry/android/core/AppState;
171172
public fun isInBackground ()Ljava/lang/Boolean;
172173
}
173174

175+
public abstract interface class io/sentry/android/core/AppState$AppStateListener {
176+
public abstract fun onBackground ()V
177+
public abstract fun onForeground ()V
178+
}
179+
174180
public final class io/sentry/android/core/BuildConfig {
175181
public static final field BUILD_TYPE Ljava/lang/String;
176182
public static final field DEBUG Z
@@ -263,7 +269,7 @@ public final class io/sentry/android/core/NdkIntegration : io/sentry/Integration
263269
}
264270

265271
public final class io/sentry/android/core/NetworkBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable {
266-
public fun <init> (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/ILogger;)V
272+
public fun <init> (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)V
267273
public fun close ()V
268274
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
269275
}
@@ -422,11 +428,13 @@ public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerfo
422428
public fun onSpanStarted (Lio/sentry/ISpan;)V
423429
}
424430

425-
public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : io/sentry/Integration, java/io/Closeable {
431+
public final class io/sentry/android/core/SystemEventsBreadcrumbsIntegration : io/sentry/Integration, io/sentry/android/core/AppState$AppStateListener, java/io/Closeable {
426432
public fun <init> (Landroid/content/Context;)V
427433
public fun <init> (Landroid/content/Context;Ljava/util/List;)V
428434
public fun close ()V
429435
public static fun getDefaultActions ()Ljava/util/List;
436+
public fun onBackground ()V
437+
public fun onForeground ()V
430438
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
431439
}
432440

sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator;
2929
import io.sentry.android.core.internal.modules.AssetsModulesLoader;
3030
import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider;
31+
import io.sentry.android.core.internal.util.AndroidCurrentDateProvider;
3132
import io.sentry.android.core.internal.util.AndroidThreadChecker;
3233
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
3334
import io.sentry.android.core.performance.AppStartMetrics;
@@ -127,6 +128,7 @@ static void loadDefaultAndMetadataOptions(
127128
options.setCacheDirPath(getCacheDir(context).getAbsolutePath());
128129

129130
readDefaultOptionValues(options, context, buildInfoProvider);
131+
AppState.getInstance().registerLifecycleObserver(options);
130132
}
131133

132134
@TestOnly
@@ -157,7 +159,8 @@ static void initializeIntegrationsAndProcessors(
157159

158160
if (options.getConnectionStatusProvider() instanceof NoOpConnectionStatusProvider) {
159161
options.setConnectionStatusProvider(
160-
new AndroidConnectionStatusProvider(context, options.getLogger(), buildInfoProvider));
162+
new AndroidConnectionStatusProvider(
163+
context, options, buildInfoProvider, AndroidCurrentDateProvider.getInstance()));
161164
}
162165

163166
if (options.getCacheDirPath() != null) {
@@ -380,8 +383,7 @@ static void installDefaultIntegrations(
380383
}
381384
options.addIntegration(new AppComponentsBreadcrumbsIntegration(context));
382385
options.addIntegration(new SystemEventsBreadcrumbsIntegration(context));
383-
options.addIntegration(
384-
new NetworkBreadcrumbsIntegration(context, buildInfoProvider, options.getLogger()));
386+
options.addIntegration(new NetworkBreadcrumbsIntegration(context, buildInfoProvider));
385387
if (isReplayAvailable) {
386388
final ReplayIntegration replay =
387389
new ReplayIntegration(context, CurrentDateProvider.getInstance());

sentry-android-core/src/main/java/io/sentry/android/core/AppLifecycleIntegration.java

Lines changed: 26 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion;
44

5-
import androidx.lifecycle.ProcessLifecycleOwner;
65
import io.sentry.IScopes;
6+
import io.sentry.ISentryLifecycleToken;
77
import io.sentry.Integration;
88
import io.sentry.SentryLevel;
99
import io.sentry.SentryOptions;
10-
import io.sentry.android.core.internal.util.AndroidThreadChecker;
10+
import io.sentry.util.AutoClosableReentrantLock;
1111
import io.sentry.util.Objects;
1212
import java.io.Closeable;
1313
import java.io.IOException;
@@ -17,20 +17,11 @@
1717

1818
public final class AppLifecycleIntegration implements Integration, Closeable {
1919

20+
private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock();
2021
@TestOnly @Nullable volatile LifecycleWatcher watcher;
2122

2223
private @Nullable SentryAndroidOptions options;
2324

24-
private final @NotNull MainLooperHandler handler;
25-
26-
public AppLifecycleIntegration() {
27-
this(new MainLooperHandler());
28-
}
29-
30-
AppLifecycleIntegration(final @NotNull MainLooperHandler handler) {
31-
this.handler = handler;
32-
}
33-
3425
@Override
3526
public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions options) {
3627
Objects.requireNonNull(scopes, "Scopes are required");
@@ -55,85 +46,47 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions
5546

5647
if (this.options.isEnableAutoSessionTracking()
5748
|| this.options.isEnableAppLifecycleBreadcrumbs()) {
58-
try {
59-
Class.forName("androidx.lifecycle.DefaultLifecycleObserver");
60-
Class.forName("androidx.lifecycle.ProcessLifecycleOwner");
61-
if (AndroidThreadChecker.getInstance().isMainThread()) {
62-
addObserver(scopes);
63-
} else {
64-
// some versions of the androidx lifecycle-process require this to be executed on the main
65-
// thread.
66-
handler.post(() -> addObserver(scopes));
49+
try (final ISentryLifecycleToken ignored = lock.acquire()) {
50+
if (watcher != null) {
51+
return;
6752
}
68-
} catch (ClassNotFoundException e) {
69-
options
70-
.getLogger()
71-
.log(
72-
SentryLevel.WARNING,
73-
"androidx.lifecycle is not available, AppLifecycleIntegration won't be installed");
74-
} catch (IllegalStateException e) {
75-
options
76-
.getLogger()
77-
.log(SentryLevel.ERROR, "AppLifecycleIntegration could not be installed", e);
78-
}
79-
}
80-
}
8153

82-
private void addObserver(final @NotNull IScopes scopes) {
83-
// this should never happen, check added to avoid warnings from NullAway
84-
if (this.options == null) {
85-
return;
86-
}
54+
watcher =
55+
new LifecycleWatcher(
56+
scopes,
57+
this.options.getSessionTrackingIntervalMillis(),
58+
this.options.isEnableAutoSessionTracking(),
59+
this.options.isEnableAppLifecycleBreadcrumbs());
8760

88-
watcher =
89-
new LifecycleWatcher(
90-
scopes,
91-
this.options.getSessionTrackingIntervalMillis(),
92-
this.options.isEnableAutoSessionTracking(),
93-
this.options.isEnableAppLifecycleBreadcrumbs());
61+
AppState.getInstance().addAppStateListener(watcher);
62+
}
9463

95-
try {
96-
ProcessLifecycleOwner.get().getLifecycle().addObserver(watcher);
9764
options.getLogger().log(SentryLevel.DEBUG, "AppLifecycleIntegration installed.");
9865
addIntegrationToSdkVersion("AppLifecycle");
99-
} catch (Throwable e) {
100-
// This is to handle a potential 'AbstractMethodError' gracefully. The error is triggered in
101-
// connection with conflicting dependencies of the androidx.lifecycle.
102-
// //See the issue here: https://github.com/getsentry/sentry-java/pull/2228
103-
watcher = null;
104-
options
105-
.getLogger()
106-
.log(
107-
SentryLevel.ERROR,
108-
"AppLifecycleIntegration failed to get Lifecycle and could not be installed.",
109-
e);
11066
}
11167
}
11268

11369
private void removeObserver() {
114-
final @Nullable LifecycleWatcher watcherRef = watcher;
70+
final @Nullable LifecycleWatcher watcherRef;
71+
try (final ISentryLifecycleToken ignored = lock.acquire()) {
72+
watcherRef = watcher;
73+
watcher = null;
74+
}
75+
11576
if (watcherRef != null) {
116-
ProcessLifecycleOwner.get().getLifecycle().removeObserver(watcherRef);
77+
AppState.getInstance().removeAppStateListener(watcherRef);
11778
if (options != null) {
11879
options.getLogger().log(SentryLevel.DEBUG, "AppLifecycleIntegration removed.");
11980
}
12081
}
121-
watcher = null;
12282
}
12383

12484
@Override
12585
public void close() throws IOException {
126-
if (watcher == null) {
127-
return;
128-
}
129-
if (AndroidThreadChecker.getInstance().isMainThread()) {
130-
removeObserver();
131-
} else {
132-
// some versions of the androidx lifecycle-process require this to be executed on the main
133-
// thread.
134-
// avoid method refs on Android due to some issues with older AGP setups
135-
// noinspection Convert2MethodRef
136-
handler.post(() -> removeObserver());
137-
}
86+
removeObserver();
87+
// TODO: probably should move it to Scopes.close(), but that'd require a new interface and
88+
// different implementations for Java and Android. This is probably fine like this too, because
89+
// integrations are closed in the same place
90+
AppState.getInstance().unregisterLifecycleObserver();
13891
}
13992
}

0 commit comments

Comments
 (0)