From 85fd8b11a0c380fa02410ab8ac09a87d38a16e62 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Fri, 12 Jun 2026 14:42:12 +0200 Subject: [PATCH 001/195] feat(android): Add standalone app start tracing (#5342) * feat: Add standalone app start transaction (happy path) Introduce experimental `enableStandaloneAppStartTracing` option that creates a separate app start transaction instead of attaching app start as a child span of the first activity transaction. This is the happy path only (foreground importance, activity launch, first frame drawn as end time). The standalone transaction shares the same trace ID as the activity transaction but is not bound to the scope. App start measurements and child spans (process init, content providers, application.onCreate) are attached to the standalone transaction instead of the activity transaction. Includes foreground importance check branching to prepare for the non-activity launch path (next PR). Co-Authored-By: Claude Opus 4.6 (1M context) * feat: Add non-activity app start path with end time resolution When the app starts without launching an activity (service, broadcast receiver, content provider), create a standalone app start transaction with the end time determined by priority: 1. onApplicationPostCreate (Gradle plugin bytecode instrumentation) 2. ApplicationStartInfo timestamps (API 35+) 3. firstIdle - main thread idle handler (pre-API 35 fallback) The non-activity app start transaction stores its trace ID so that if an activity is later launched, the activity transaction reuses the same trace ID to keep both in the same trace. Adds OnNoActivityStartedListener callback from AppStartMetrics to ActivityLifecycleIntegration, triggered by checkCreateTimeOnMain() when no activity was created after Application.onCreate(). Co-Authored-By: Claude Opus 4.6 (1M context) * feat: Support non-activity app start tracing without bytecode instrumentation When an app is launched via broadcast receiver, service, or content provider (no activity), detect this via Handler.post() and create a standalone app start transaction. Resolves app start end time with priority: Gradle plugin > ApplicationStartInfo (API 35+) > process init time. Also attaches child spans (process init, content providers, Application.onCreate) to standalone transactions. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: Consolidate non-activity app start time-span resolution Extract the "try appStartSpan, fall back to sdkInitTimeSpan" logic used for standalone (non-activity) app start transactions into a new AppStartMetrics.getAppStartTimeSpanDirect() helper, removing the duplicated inline fallback in ActivityLifecycleIntegration and the private helper in PerformanceAndroidEventProcessor. Also cache the API 35+ ApplicationStartInfo on registerLifecycleCallbacks so onAppStartSpansSent no longer re-queries ActivityManager, and simplify the non-activity detection path to always use the main-thread IdleHandler. Regenerates the sentry-android-core API to include method additions missed in prior commits on this branch (standalone-app-start options, trace id accessors, OnNoActivityStartedListener). Co-Authored-By: Claude Opus 4.7 (1M context) * chore(samples): Register TestBroadcastReceiver in manifest Wires up the TestBroadcastReceiver added earlier so the sample app can trigger a non-activity cold start via `adb shell am broadcast`. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(app-start): resolve standalone tracing misclassification and duplicate emission Two pre-merge fixes for the standalone app-start tracing path introduced on this branch (issue #5046): - AppStartMetrics.checkCreateTimeOnMain() now defaults appStartType to COLD when UNKNOWN with no active activities. On API < 35 (where ApplicationStartInfo is unavailable) non-activity cold starts were stuck at UNKNOWN, which both misclassified the standalone transaction as App Start Warm and caused PerformanceAndroidEventProcessor.attachAppStartSpans to early-return (dropping process.load / application.load / contentprovider.load phase spans). - ActivityLifecycleIntegration.onActivityPreCreated() now skips emitting a second standalone App Start transaction when the non-activity path has already reported the process's app start (detected via the stashed appStartTraceId). Previously a broadcast followed by an activity launch produced two standalone transactions (a spurious App Start Warm in addition to the broadcast's App Start Cold), violating one-per-process semantics. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(android): refine standalone app start tracing * chore: Update generated files * style(core): Apply spotless formatting * changelog * fix(android): Use stable app start transaction name Rename the standalone app-start transaction to a single App Start name so cold and warm starts group consistently while preserving the app.start op. Co-authored-by: Cursor * feat(android): Add standalone app start tracing Co-authored-by: Cursor * fix(android): Handle non-activity app starts below API 24 Co-authored-by: Cursor * fix(android): Guard app start timestamp clock base Co-authored-by: Cursor * ref(android): Remove app start reason plumbing Co-authored-by: Cursor * ref(android): Clarify no-activity app start handling Rename the private app start helper to reflect that it conditionally handles non-activity starts. Keep comments and tests focused on behavior. Co-Authored-By: Claude Co-authored-by: Cursor * docs(android): Clarify non-activity app start fallback Explain why unresolved non-activity starts default to cold when Activity signals or ApplicationStartInfo classification are unavailable. Co-Authored-By: Claude Co-authored-by: Cursor * fix(android): Preserve legacy no-activity app start guard Only run the no-activity startup check for unresolved app starts or when standalone app start tracing registered a listener. This keeps API 35 ApplicationStartInfo classifications from triggering legacy side effects. Co-Authored-By: Claude Co-authored-by: Cursor * test(android): Opt into standalone no-activity API 35 tests Register a no-op no-activity listener for API 35 end-time resolution tests so they exercise the standalone path under the restored legacy guard. Co-Authored-By: Claude Co-authored-by: Cursor * fix(android): Schedule no-activity idle check when standalone listener is set on API 35+ On API 35+, ApplicationStartInfo resolves appStartType before the standalone app start listener is installed, causing the idle handler condition to be false and skipping the no-activity detection entirely. Register the idle handler from setOnNoActivityStartedListener when the type is already resolved, ensuring onNoActivityStarted() fires for standalone app start tracing on API 35+ devices. Co-authored-by: Cursor * ref(android): Remove dead foregroundImportance check in standalone app start path The foregroundImportance guard was always true at that point because appStartTime is only set to non-null inside the foregroundImportance branch. Remove the redundant check and the misleading else comment that described an unreachable code path. Co-authored-by: Cursor * fix(android): Prevent duplicate standalone app start measurements Require the app-start pending flag even when standalone app-start transactions bypass foreground checks. Preserve completed non-activity app-start timings so fallback resolution does not overwrite stopped spans. Co-authored-by: Cursor * ref(android): Remove unused app start application context Drop dead AppStartMetrics state that was assigned during lifecycle callback registration but never read. Co-authored-by: Cursor * ref(android): Rename getAppStartTimeSpanDirect to getAppStartTimeSpanForStandalone Co-authored-by: Cursor * fix(android): Do not set TTID/TTFD contributing flags on standalone app start spans Co-authored-by: Cursor * fix(android): Add volatile to noActivityStartedListener for cross-thread visibility The field is written by setOnNoActivityStartedListener (called during Sentry.init(), potentially on a background thread) and read on the main thread in handleNoActivityStartIfNeededOnMain. Without volatile, the JMM permits the main thread to see a stale null, silently skipping the listener and preventing standalone app-start transaction creation. Co-authored-by: Cursor * fix(android): Clear stale app start sampling decision in non-activity start path onNoActivityStarted() did not clear the appStartSamplingDecision, which could leak to the first ui.load transaction when an activity eventually starts after a non-activity process launch. Co-authored-by: Cursor * fix: Format adb test commands in TestBroadcastReceiver JavaDoc Co-authored-by: Cursor * ref(android): Rename headless app start handling Use headless terminology for app starts that do not reach an Activity and schedule the headless check from lifecycle callback registration. This removes listener setter side effects while preserving standalone app-start behavior. Co-Authored-By: Claude Co-authored-by: Cursor * fix(android): Align foreground app start measurements Use the foreground app start fallback for foreground standalone app start transactions so measurements match the transaction timestamp. Keep the headless-only span source limited to true headless starts. Co-authored-by: Cursor * fix(android): Gate headless app start end time Resolve the headless app start end timestamp only when standalone headless tracing is active. This avoids stopping legacy app start spans before a later foreground Activity can finish them. Co-authored-by: Cursor * test(android): Update API 35 headless app start expectation Make the ApplicationStartInfo headless test install the listener that now gates headless end-time resolution, matching the standalone path. Co-authored-by: Cursor * Fix headless app-start idle scheduling * ref(android): Clarify headless app start state names Rename private headless app start flags to distinguish the pending main-thread check from the one-shot listener invocation guard. No behavior change. Co-Authored-By: Claude Co-authored-by: Cursor * ref(android): Use app.start origin for headless app start transaction Set the standalone headless app start transaction origin to `auto.app.start` instead of `auto.ui.activity`, which was semantically incorrect for non-activity (broadcast/service/content provider) starts. Also simplify the API 35+ ApplicationStartInfo onCreate timestamp resolution by using the reported nanos directly as the uptime base. Co-authored-by: Cursor * ref(android): refine standalone app start trace continuation Drop the redundant trace-id sharing TransactionContext constructor; the ui.load now shares the app.start trace solely through continueTrace. Don't connect a headless app.start and a following activity's ui.load into the same trace when they are more than 1 minute apart, since such a large gap means they no longer belong to the same launch. Co-authored-by: Cursor * fix(android): align headless app start tests with uptime-based onCreate timestamp ApplicationStartInfo's START_TIMESTAMP_APPLICATION_ONCREATE is captured via SystemClock.uptimeNanos(), the same base as TimeSpan, so no clock re-anchoring is needed. Add the missing headless test setup (foreground-importance stubbing) and fix the API 35 timestamp test to use uptime semantics. Co-authored-by: Cursor * test(android): add standalone app start E2E harness Wire the Android sample app for manual standalone app-start validation and add a reusable harness plus notes for the scenarios verified locally. Trim redundant comments around app-start trace continuation while keeping the non-obvious sampling and parentage details. Co-Authored-By: Claude Co-authored-by: Cursor * chore(android): remove standalone app start report Co-authored-by: Cursor * test(android): clarify app start transaction shapes Co-authored-by: Cursor * chore(android): remove standalone app start harness Co-authored-by: Cursor * fix(android): Preserve app start activity counter Keep the foreground headless guard from faking an observed activity so late standalone app start init still lets the first real activity classify startup and reset warm-start state correctly. Co-authored-by: Cursor * fix(android): Finish app start after activity spans Keep standalone app-start transactions open until activity lifecycle spans are attached so early app-start completion does not drop activity spans. Co-Authored-By: Cursor * feat(samples): enable standalone app start tracing and add headless-start broadcast receiver Co-authored-by: Cursor * fix(changelog): resolve merge conflict and keep standalone app start entry under Unreleased Co-authored-by: Cursor * docs(options): Clarify standalone app start javadoc per review - Use plain quotes for the "App Start" transaction name instead of {@code} - Clarify that the API 35 gate refers to the device's runtime OS version Co-Authored-By: Claude Fable 5 * fix(android): Clarify ApplicationStartInfo onCreate timestamp marks onCreate start START_TIMESTAMP_APPLICATION_ONCREATE is captured right before Application.onCreate is invoked (ActivityThread.handleBindApplication), so it is the onCreate start, not its end. Rename locals, fix comments and javadoc accordingly, and drop the applicationOnCreate.setStoppedAt branch which could have recorded a zero-length application.load span. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Cursor --- CHANGELOG.md | 8 + .../api/sentry-android-core.api | 17 + .../core/ActivityLifecycleIntegration.java | 293 +++++++-- .../android/core/ManifestMetadataReader.java | 10 + .../PerformanceAndroidEventProcessor.java | 56 +- .../android/core/SentryAndroidOptions.java | 49 ++ .../core/performance/AppStartMetrics.java | 209 ++++++- .../core/ActivityLifecycleIntegrationTest.kt | 554 +++++++++++++++++- .../core/ManifestMetadataReaderTest.kt | 30 + .../PerformanceAndroidEventProcessorTest.kt | 183 +++++- .../android/core/SentryAndroidOptionsTest.kt | 6 + .../core/SentryShadowActivityManager.kt | 13 + .../android/core/SentryShadowProcess.kt | 16 +- .../core/performance/AppStartMetricsTest.kt | 195 +++++- .../performance/AppStartMetricsTestApi35.kt | 130 ++++ .../src/main/AndroidManifest.xml | 13 + .../android/TestBroadcastReceiver.java | 26 + 17 files changed, 1695 insertions(+), 113 deletions(-) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b42d4bd09d..cd876f3c57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Features + +- Add `enableStandaloneAppStartTracing` option to send app start as a standalone transaction instead of attaching it as a child span of the first activity transaction ([#5342](https://github.com/getsentry/sentry-java/pull/5342)) + - Disabled by default; opt in via `options.isEnableStandaloneAppStartTracing = true` or manifest meta-data `io.sentry.standalone-app-start-tracing.enable` + - Emits a transaction named `App Start` with op `app.start`, carrying the existing app start measurements and phase spans (`process.load`, `contentprovider.load`, `application.load`, activity lifecycle spans) as direct children of the root + - The standalone transaction shares the same `traceId` as the first `ui.load` activity transaction so they remain linked in the trace view + - Also covers non-activity starts (broadcast receivers, services, content providers) + ### Improvements - Reduce boxing to improve performance ([#5523](https://github.com/getsentry/sentry-java/pull/5523), [#5527](https://github.com/getsentry/sentry-java/pull/5527)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 249549f8366..0500ba44990 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -392,6 +392,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun isEnablePerformanceV2 ()Z public fun isEnableRootCheck ()Z public fun isEnableScopeSync ()Z + public fun isEnableStandaloneAppStartTracing ()Z public fun isEnableSystemEventBreadcrumbs ()Z public fun isEnableSystemEventBreadcrumbsExtras ()Z public fun isReportHistoricalAnrs ()Z @@ -423,6 +424,7 @@ public final class io/sentry/android/core/SentryAndroidOptions : io/sentry/Sentr public fun setEnablePerformanceV2 (Z)V public fun setEnableRootCheck (Z)V public fun setEnableScopeSync (Z)V + public fun setEnableStandaloneAppStartTracing (Z)V public fun setEnableSystemEventBreadcrumbs (Z)V public fun setEnableSystemEventBreadcrumbsExtras (Z)V public fun setFrameMetricsCollector (Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V @@ -740,11 +742,16 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun clear ()V public fun createProcessInitSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getActivityLifecycleTimeSpans ()Ljava/util/List; + public fun getAppStartBaggageHeader ()Ljava/lang/String; public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler; + public fun getAppStartEndTime ()Lio/sentry/SentryDate; public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler; public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision; + public fun getAppStartSentryTraceHeader ()Ljava/lang/String; public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTimeSpanForHeadless ()Lio/sentry/android/core/performance/TimeSpan; public fun getAppStartTimeSpanWithFallback (Lio/sentry/android/core/SentryAndroidOptions;)Lio/sentry/android/core/performance/TimeSpan; + public fun getAppStartTraceId ()Lio/sentry/protocol/SentryId; public fun getAppStartType ()Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; public fun getApplicationOnCreateTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; public fun getClassLoadedUptimeMs ()J @@ -765,12 +772,18 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public static fun onContentProviderPostCreate (Landroid/content/ContentProvider;)V public fun registerLifecycleCallbacks (Landroid/app/Application;)V public fun setAppLaunchedInForeground (Z)V + public fun setAppStartBaggageHeader (Ljava/lang/String;)V public fun setAppStartContinuousProfiler (Lio/sentry/IContinuousProfiler;)V + public fun setAppStartEndTime (Lio/sentry/SentryDate;)V public fun setAppStartProfiler (Lio/sentry/ITransactionProfiler;)V public fun setAppStartSamplingDecision (Lio/sentry/TracesSamplingDecision;)V + public fun setAppStartSentryTraceHeader (Ljava/lang/String;)V + public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V public fun setClassLoadedUptimeMs (J)V + public fun setHeadlessAppStartListener (Lio/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener;)V public fun shouldSendStartMeasurements ()Z + public fun shouldSendStartMeasurements (Z)Z } public final class io/sentry/android/core/performance/AppStartMetrics$AppStartType : java/lang/Enum { @@ -781,6 +794,10 @@ public final class io/sentry/android/core/performance/AppStartMetrics$AppStartTy public static fun values ()[Lio/sentry/android/core/performance/AppStartMetrics$AppStartType; } +public abstract interface class io/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener { + public abstract fun onHeadlessAppStart ()V +} + public class io/sentry/android/core/performance/TimeSpan : java/lang/Comparable { public fun ()V public fun compareTo (Lio/sentry/android/core/performance/TimeSpan;)I diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 9d748e5a27a..19cee7fcce5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -9,6 +9,8 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import io.sentry.Baggage; +import io.sentry.BaggageHeader; import io.sentry.FullyDisplayedReporter; import io.sentry.IScope; import io.sentry.IScopes; @@ -18,6 +20,7 @@ import io.sentry.Instrumenter; import io.sentry.Integration; import io.sentry.NoOpTransaction; +import io.sentry.PropagationContext; import io.sentry.SentryDate; import io.sentry.SentryLevel; import io.sentry.SentryNanotimeDate; @@ -33,6 +36,7 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.protocol.MeasurementValue; +import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; @@ -40,6 +44,7 @@ import java.io.Closeable; import java.io.IOException; import java.lang.ref.WeakReference; +import java.util.Collections; import java.util.Date; import java.util.Map; import java.util.WeakHashMap; @@ -55,12 +60,19 @@ public final class ActivityLifecycleIntegration implements Integration, Closeable, Application.ActivityLifecycleCallbacks { static final String UI_LOAD_OP = "ui.load"; + static final String STANDALONE_APP_START_OP = "app.start"; + private static final String STANDALONE_APP_START_NAME = "App Start"; static final String APP_START_WARM = "app.start.warm"; static final String APP_START_COLD = "app.start.cold"; static final String TTID_OP = "ui.load.initial_display"; static final String TTFD_OP = "ui.load.full_display"; static final long TTFD_TIMEOUT_MILLIS = 25000; + // If a headless app start and the following activity's ui.load are more than this far apart, they + // are treated as unrelated and not connected into the same trace. + static final long APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS = TimeUnit.MINUTES.toNanos(1); private static final String TRACE_ORIGIN = "auto.ui.activity"; + static final String APP_START_SCREEN_DATA = "app.vitals.start.screen"; + static final String APP_START_TRACE_ORIGIN = "auto.app.start"; private final @NotNull Application application; private final @NotNull BuildInfoProvider buildInfoProvider; @@ -77,6 +89,7 @@ public final class ActivityLifecycleIntegration private @Nullable FullyDisplayedReporter fullyDisplayedReporter = null; private @Nullable ISpan appStartSpan; + private @Nullable ITransaction appStartTransaction; private final @NotNull WeakHashMap ttidSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap ttfdSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap activitySpanHelpers = @@ -124,6 +137,11 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions timeToFullDisplaySpanEnabled = this.options.isEnableTimeToFullDisplayTracing(); application.registerActivityLifecycleCallbacks(this); + + if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { + AppStartMetrics.getInstance().setHeadlessAppStartListener(this::onHeadlessAppStart); + } + this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); addIntegrationToSdkVersion("ActivityLifecycle"); } @@ -135,6 +153,7 @@ private boolean isPerformanceEnabled(final @NotNull SentryAndroidOptions options @Override public void close() throws IOException { application.unregisterActivityLifecycleCallbacks(this); + AppStartMetrics.getInstance().setHeadlessAppStartListener(null); if (options != null) { options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration removed."); @@ -239,33 +258,93 @@ private void startTracing(final @NotNull Activity activity) { transactionOptions.setAppStartTransaction(appStartSamplingDecision != null); setSpanOrigin(transactionOptions); - // we can only bind to the scope if there's no running transaction - ITransaction transaction = - scopes.startTransaction( - new TransactionContext( - activityName, - TransactionNameSource.COMPONENT, - UI_LOAD_OP, - appStartSamplingDecision), - transactionOptions); + final @Nullable SentryId storedAppStartTraceId = + AppStartMetrics.getInstance().getAppStartTraceId(); + final boolean isFollowingHeadlessAppStart = (storedAppStartTraceId != null); + + final boolean isAppStart = + !(firstActivityCreated || appStartTime == null || coldStart == null); + // Foreground starts create app.start first; ui.load then shares its trace. + final boolean createStandaloneAppStart = + isAppStart + && options.isEnableStandaloneAppStartTracing() + && !isFollowingHeadlessAppStart; + + if (createStandaloneAppStart) { + final TransactionOptions appStartTransactionOptions = new TransactionOptions(); + appStartTransactionOptions.setBindToScope(false); + appStartTransactionOptions.setStartTimestamp(appStartTime); + appStartTransactionOptions.setAppStartTransaction(appStartSamplingDecision != null); + appStartTransactionOptions.setOrigin(APP_START_TRACE_ORIGIN); + + appStartTransaction = + scopes.startTransaction( + new TransactionContext( + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + appStartSamplingDecision), + appStartTransactionOptions); + appStartTransaction.setData(APP_START_SCREEN_DATA, activityName); + } + + // Continue either the foreground app.start above or an earlier headless app.start. + final @Nullable String continueSentryTrace; + final @Nullable String continueBaggage; + if (createStandaloneAppStart) { + continueSentryTrace = appStartTransaction.toSentryTrace().getValue(); + final @Nullable BaggageHeader baggageHeader = appStartTransaction.toBaggageHeader(null); + continueBaggage = baggageHeader == null ? null : baggageHeader.getValue(); + } else if (isFollowingHeadlessAppStart + && isWithinAppStartContinuationWindow(ttidStartTime)) { + continueSentryTrace = AppStartMetrics.getInstance().getAppStartSentryTraceHeader(); + continueBaggage = AppStartMetrics.getInstance().getAppStartBaggageHeader(); + } else { + continueSentryTrace = null; + continueBaggage = null; + } + + final @Nullable TransactionContext continuedContext = + continueSentryTrace == null + ? null + : continueUiLoadTrace(continueSentryTrace, continueBaggage, activityName); + + final ITransaction transaction; + if (continuedContext != null) { + transaction = scopes.startTransaction(continuedContext, transactionOptions); + } else { + transaction = + scopes.startTransaction( + new TransactionContext( + activityName, + TransactionNameSource.COMPONENT, + UI_LOAD_OP, + appStartSamplingDecision), + transactionOptions); + } + + if (isFollowingHeadlessAppStart) { + // Consume the stored headless app-start trace so it isn't reused by another activity. + AppStartMetrics.getInstance().setAppStartTraceId(null); + AppStartMetrics.getInstance().setAppStartSentryTraceHeader(null); + AppStartMetrics.getInstance().setAppStartBaggageHeader(null); + } final SpanOptions spanOptions = new SpanOptions(); setSpanOrigin(spanOptions); - // in case appStartTime isn't available, we don't create a span for it. - if (!(firstActivityCreated || appStartTime == null || coldStart == null)) { - // start specific span for app start - appStartSpan = - transaction.startChild( - getAppStartOp(coldStart), - getAppStartDesc(coldStart), - appStartTime, - Instrumenter.SENTRY, - spanOptions); - - // in case there's already an end time (e.g. due to deferred SDK init) - // we can finish the app-start span - finishAppStartSpan(); + if (isAppStart) { + if (!createStandaloneAppStart && !options.isEnableStandaloneAppStartTracing()) { + appStartSpan = + transaction.startChild( + getAppStartOp(coldStart), + getAppStartDesc(coldStart), + appStartTime, + Instrumenter.SENTRY, + spanOptions); + + finishAppStartSpan(); + } } final @NotNull ISpan ttidSpan = transaction.startChild( @@ -316,6 +395,61 @@ private void setSpanOrigin(final @NotNull SpanOptions spanOptions) { spanOptions.setOrigin(TRACE_ORIGIN); } + /** + * Whether the ui.load starting at {@code uiLoadStartTime} is close enough in time to the headless + * app start to belong to the same trace. If they are more than {@link + * #APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS} apart, they are treated as unrelated. When + * the headless end time is unknown, we keep the previous behaviour and continue the trace. + */ + private boolean isWithinAppStartContinuationWindow(final @NotNull SentryDate uiLoadStartTime) { + final @Nullable SentryDate appStartEndTime = AppStartMetrics.getInstance().getAppStartEndTime(); + if (appStartEndTime == null) { + return true; + } + return uiLoadStartTime.diff(appStartEndTime) <= APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS; + } + + /** + * Builds a {@link TransactionContext} for the ui.load transaction that shares the standalone + * app.start trace (same traceId and sampleRand) while staying a sibling (no parentSpanId), rather + * than a child. The continued baggage keeps sampling decisions on the same sampleRand. Returns + * null if the trace cannot be continued, so callers can fall back. + */ + private @Nullable TransactionContext continueUiLoadTrace( + final @NotNull String sentryTrace, + final @Nullable String baggage, + final @NotNull String activityName) { + if (options == null || !options.isTracingEnabled()) { + return null; + } + final @NotNull PropagationContext propagationContext = + PropagationContext.fromHeaders( + options.getLogger(), + sentryTrace, + baggage == null ? null : Collections.singletonList(baggage), + options); + final @Nullable Boolean parentSampled = propagationContext.isSampled(); + final @NotNull Baggage continuedBaggage = propagationContext.getBaggage(); + final @Nullable TracesSamplingDecision parentSamplingDecision = + parentSampled == null + ? null + : new TracesSamplingDecision( + parentSampled, + continuedBaggage.getSampleRate(), + propagationContext.getSampleRand()); + final @NotNull TransactionContext context = + new TransactionContext( + propagationContext.getTraceId(), + propagationContext.getSpanId(), + null, + parentSamplingDecision, + continuedBaggage); + context.setName(activityName); + context.setTransactionNameSource(TransactionNameSource.COMPONENT); + context.setOperation(UI_LOAD_OP); + return context; + } + @VisibleForTesting void applyScope(final @NotNull IScope scope, final @NotNull ITransaction transaction) { scope.withTransaction( @@ -440,8 +574,7 @@ public void onActivityPostCreated( final @NotNull Activity activity, final @Nullable Bundle savedInstanceState) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnCreateSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnCreateSpan(getAppStartParent(activity)); } } @@ -479,11 +612,11 @@ public void onActivityStarted(final @NotNull Activity activity) { public void onActivityPostStarted(final @NotNull Activity activity) { final ActivityLifecycleSpanHelper helper = activitySpanHelpers.get(activity); if (helper != null) { - helper.createAndStopOnStartSpan( - appStartSpan != null ? appStartSpan : activitiesWithOngoingTransactions.get(activity)); + helper.createAndStopOnStartSpan(getAppStartParent(activity)); // Needed to handle hybrid SDKs helper.saveSpanToAppStartMetrics(); } + finishAppStartSpan(); } @Override @@ -559,6 +692,9 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // in case the appStartSpan isn't completed yet, we finish it as cancelled to avoid // memory leak finishSpan(appStartSpan, SpanStatus.CANCELLED); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.CANCELLED); + } // we finish the ttidSpan as cancelled in case it isn't completed yet final ISpan ttidSpan = ttidSpanMap.get(activity); @@ -575,6 +711,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { // set it to null in case its been just finished as cancelled appStartSpan = null; + appStartTransaction = null; ttidSpanMap.remove(activity); ttfdSpanMap.remove(activity); } @@ -637,22 +774,23 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I final @NotNull AppStartMetrics appStartMetrics = AppStartMetrics.getInstance(); final @NotNull TimeSpan appStartTimeSpan = appStartMetrics.getAppStartTimeSpan(); final @NotNull TimeSpan sdkInitTimeSpan = appStartMetrics.getSdkInitTimeSpan(); + final @Nullable SentryDate firstFrameEndDate = + options != null ? options.getDateProvider().now() : null; // and we need to set the end time of the app start here, after the first frame is drawn. if (appStartTimeSpan.hasStarted() && appStartTimeSpan.hasNotStopped()) { - appStartTimeSpan.stop(); + stopTimeSpanAtDate(appStartTimeSpan, firstFrameEndDate); } if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { - sdkInitTimeSpan.stop(); + stopTimeSpanAtDate(sdkInitTimeSpan, firstFrameEndDate); } - finishAppStartSpan(); + finishAppStartSpan(firstFrameEndDate); // Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization // with first frame drawn try (final @NotNull ISentryLifecycleToken ignored = fullyDisplayedLock.acquire()) { - if (options != null && ttidSpan != null) { - final SentryDate endDate = options.getDateProvider().now(); - final long durationNanos = endDate.diff(ttidSpan.getStartDate()); + if (options != null && ttidSpan != null && firstFrameEndDate != null) { + final long durationNanos = firstFrameEndDate.diff(ttidSpan.getStartDate()); final long durationMillis = TimeUnit.NANOSECONDS.toMillis(durationNanos); ttidSpan.setMeasurement( MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY, durationMillis, MILLISECOND); @@ -664,10 +802,10 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); ttfdSpan.setMeasurement( MeasurementValue.KEY_TIME_TO_FULL_DISPLAY, durationMillis, MILLISECOND); - finishSpan(ttfdSpan, endDate); + finishSpan(ttfdSpan, firstFrameEndDate); } - finishSpan(ttidSpan, endDate); + finishSpan(ttidSpan, firstFrameEndDate); } else { finishSpan(ttidSpan); if (fullyDisplayedCalled) { @@ -677,6 +815,17 @@ private void onFirstFrameDrawn(final @Nullable ISpan ttfdSpan, final @Nullable I } } + private void stopTimeSpanAtDate( + final @NotNull TimeSpan timeSpan, final @Nullable SentryDate endDate) { + final @Nullable SentryDate startDate = timeSpan.getStartTimestamp(); + if (endDate != null && startDate != null) { + final long durationMillis = TimeUnit.NANOSECONDS.toMillis(endDate.diff(startDate)); + timeSpan.setStoppedAt(timeSpan.getStartUptimeMs() + durationMillis); + } else { + timeSpan.stop(); + } + } + private void onFullFrameDrawn(final @NotNull ISpan ttidSpan, final @NotNull ISpan ttfdSpan) { cancelTtfdAutoClose(); // Sentry.reportFullyDisplayed can be run in any thread, so we have to ensure synchronization @@ -779,6 +928,16 @@ WeakHashMap getTtfdSpanMap() { } } + private @Nullable ISpan getAppStartParent(final @NotNull Activity activity) { + if (appStartTransaction != null) { + return appStartTransaction; + } + if (appStartSpan != null) { + return appStartSpan; + } + return activitiesWithOngoingTransactions.get(activity); + } + private @NotNull String getAppStartOp(final boolean coldStart) { if (coldStart) { return APP_START_COLD; @@ -788,12 +947,70 @@ WeakHashMap getTtfdSpanMap() { } private void finishAppStartSpan() { + finishAppStartSpan(null); + } + + private void finishAppStartSpan(final @Nullable SentryDate endDate) { final @Nullable SentryDate appStartEndTime = - AppStartMetrics.getInstance() - .getAppStartTimeSpanWithFallback(options) - .getProjectedStopTimestamp(); + endDate != null + ? endDate + : AppStartMetrics.getInstance() + .getAppStartTimeSpanWithFallback(options) + .getProjectedStopTimestamp(); if (performanceEnabled && appStartEndTime != null) { finishSpan(appStartSpan, appStartEndTime); + if (appStartTransaction != null && !appStartTransaction.isFinished()) { + appStartTransaction.finish(SpanStatus.OK, appStartEndTime); + } } } + + private void onHeadlessAppStart() { + if (scopes == null || options == null || !performanceEnabled) { + return; + } + + final @NotNull AppStartMetrics metrics = AppStartMetrics.getInstance(); + // Profilers are stopped for headless starts; clear the decision so it doesn't + // leak to a later ui.load transaction if an activity eventually opens. + metrics.setAppStartSamplingDecision(null); + + // For headless starts, appLaunchedInForeground is false, so we can't use + // getAppStartTimeSpanWithFallback (which gates on foreground). + final @NotNull TimeSpan appStartTimeSpan = metrics.getAppStartTimeSpanForHeadless(); + + if (!appStartTimeSpan.hasStarted() || !appStartTimeSpan.hasStopped()) { + return; + } + + final @Nullable SentryDate startTime = appStartTimeSpan.getStartTimestamp(); + final @Nullable SentryDate endTime = appStartTimeSpan.getProjectedStopTimestamp(); + if (startTime == null || endTime == null) { + return; + } + + final TransactionOptions txnOptions = new TransactionOptions(); + txnOptions.setBindToScope(false); + txnOptions.setStartTimestamp(startTime); + txnOptions.setOrigin(APP_START_TRACE_ORIGIN); + + final @NotNull TransactionContext txnContext = + new TransactionContext( + STANDALONE_APP_START_NAME, + TransactionNameSource.COMPONENT, + STANDALONE_APP_START_OP, + null); + + final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); + metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId()); + // Persist trace headers so a later ui.load can share traceId and sampleRand. + metrics.setAppStartSentryTraceHeader(transaction.toSentryTrace().getValue()); + final @Nullable BaggageHeader baggageHeader = transaction.toBaggageHeader(null); + metrics.setAppStartBaggageHeader(baggageHeader == null ? null : baggageHeader.getValue()); + // Persist the end time so a later activity can decide whether its ui.load is close enough in + // time to continue this trace. + metrics.setAppStartEndTime(endTime); + + transaction.finish(SpanStatus.OK, endTime); + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index e16d4b312fc..c34ee0dbfa9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -108,6 +108,9 @@ final class ManifestMetadataReader { static final String ENABLE_PERFORMANCE_V2 = "io.sentry.performance-v2.enable"; + static final String ENABLE_STANDALONE_APP_START_TRACING = + "io.sentry.standalone-app-start-tracing.enable"; + static final String ENABLE_APP_START_PROFILING = "io.sentry.profiling.enable-app-start"; static final String ENABLE_SCOPE_PERSISTENCE = "io.sentry.enable-scope-persistence"; @@ -502,6 +505,13 @@ static void applyMetadata( options.setEnablePerformanceV2( readBool(metadata, logger, ENABLE_PERFORMANCE_V2, options.isEnablePerformanceV2())); + options.setEnableStandaloneAppStartTracing( + readBool( + metadata, + logger, + ENABLE_STANDALONE_APP_START_TRACING, + options.isEnableStandaloneAppStartTracing())); + options.setEnableAppStartProfiling( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java index f7b51cce620..0b50b5080f4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerformanceAndroidEventProcessor.java @@ -1,7 +1,9 @@ package io.sentry.android.core; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD; +import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA; import static io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM; +import static io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP; import static io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP; import io.sentry.EventProcessor; @@ -84,9 +86,21 @@ public SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { // the app start measurement is only sent once and only if the transaction has // the app.start span, which is automatically created by the SDK. if (hasAppStartSpan(transaction)) { - if (appStartMetrics.shouldSendStartMeasurements()) { + // For headless starts, appLaunchedInForeground is false, so only headless standalone app + // start transactions bypass the foreground check, not the duplicate-send guard. + final @Nullable SpanContext traceContext = transaction.getContexts().getTrace(); + final boolean isStandaloneAppStartTxn = + traceContext != null && STANDALONE_APP_START_OP.equals(traceContext.getOperation()); + final boolean isHeadlessStandaloneAppStartTxn = + traceContext != null + && isStandaloneAppStartTxn + && !traceContext.getData().containsKey(APP_START_SCREEN_DATA); + + if (appStartMetrics.shouldSendStartMeasurements(isHeadlessStandaloneAppStartTxn)) { final @NotNull TimeSpan appStartTimeSpan = - appStartMetrics.getAppStartTimeSpanWithFallback(options); + isHeadlessStandaloneAppStartTxn + ? appStartMetrics.getAppStartTimeSpanForHeadless() + : appStartMetrics.getAppStartTimeSpanWithFallback(options); final long appStartUpDurationMs = appStartTimeSpan.getDurationMs(); // if appStartUpDurationMs is 0, metrics are not ready to be sent @@ -216,9 +230,7 @@ private boolean hasAppStartSpan(final @NotNull SentryTransaction txn) { } final @Nullable SpanContext context = txn.getContexts().getTrace(); - return context != null - && (context.getOperation().equals(APP_START_COLD) - || context.getOperation().equals(APP_START_WARM)); + return context != null && context.getOperation().equals(STANDALONE_APP_START_OP); } private void attachAppStartSpans( @@ -245,6 +257,16 @@ private void attachAppStartSpans( } } + // For standalone app start transactions, the transaction root IS the app start span + if (parentSpanId == null) { + final @NotNull String txnOp = traceContext.getOperation(); + if (STANDALONE_APP_START_OP.equals(txnOp)) { + parentSpanId = traceContext.getSpanId(); + } + } + + final boolean isStandalone = STANDALONE_APP_START_OP.equals(traceContext.getOperation()); + // Process init final @NotNull TimeSpan processInitTimeSpan = appStartMetrics.createProcessInitSpan(); if (processInitTimeSpan.hasStarted() @@ -252,7 +274,11 @@ private void attachAppStartSpans( txn.getSpans() .add( timeSpanToSentrySpan( - processInitTimeSpan, parentSpanId, traceId, APP_METRICS_PROCESS_INIT_OP)); + processInitTimeSpan, + parentSpanId, + traceId, + APP_METRICS_PROCESS_INIT_OP, + isStandalone)); } // Content Providers @@ -263,7 +289,11 @@ private void attachAppStartSpans( txn.getSpans() .add( timeSpanToSentrySpan( - contentProvider, parentSpanId, traceId, APP_METRICS_CONTENT_PROVIDER_OP)); + contentProvider, + parentSpanId, + traceId, + APP_METRICS_CONTENT_PROVIDER_OP, + isStandalone)); } } @@ -272,7 +302,8 @@ private void attachAppStartSpans( if (appOnCreate.hasStopped()) { txn.getSpans() .add( - timeSpanToSentrySpan(appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP)); + timeSpanToSentrySpan( + appOnCreate, parentSpanId, traceId, APP_METRICS_APPLICATION_OP, isStandalone)); } } @@ -281,14 +312,17 @@ private static SentrySpan timeSpanToSentrySpan( final @NotNull TimeSpan span, final @Nullable SpanId parentSpanId, final @NotNull SentryId traceId, - final @NotNull String operation) { + final @NotNull String operation, + final boolean isStandaloneAppStart) { final Map defaultSpanData = new HashMap<>(2); defaultSpanData.put(SpanDataConvention.THREAD_ID, AndroidThreadChecker.mainThreadSystemId); defaultSpanData.put(SpanDataConvention.THREAD_NAME, "main"); - defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); - defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + if (!isStandaloneAppStart) { + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTID, true); + defaultSpanData.put(SpanDataConvention.CONTRIBUTES_TTFD, true); + } return new SentrySpan( span.getStartTimestampSecs(), diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java index bb9ec17aabd..ed07c4edaaf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptions.java @@ -246,6 +246,8 @@ public interface BeforeCaptureCallback { private boolean enablePerformanceV2 = true; + private boolean enableStandaloneAppStartTracing = false; + private @Nullable SentryFrameMetricsCollector frameMetricsCollector; private boolean enableTombstone = false; @@ -677,6 +679,53 @@ public void setEnablePerformanceV2(final boolean enablePerformanceV2) { this.enablePerformanceV2 = enablePerformanceV2; } + /** + * @return true if standalone app start tracing is enabled. See {@link + * #setEnableStandaloneAppStartTracing(boolean)} for more details. + */ + @ApiStatus.Experimental + public boolean isEnableStandaloneAppStartTracing() { + return enableStandaloneAppStartTracing; + } + + /** + * Enables or disables standalone app start tracing. + * + *

When enabled, app start is sent as its own transaction instead of an {@code app.start.*} + * child span on the first Activity transaction. + * + *

The SDK reports app start through these paths: + * + *

    + *
  • With an Activity: the SDK sends an "App Start" transaction with operation {@code + * app.start}, plus a separate {@code ui.load} transaction for the Activity. Both + * transactions share the same trace ID. + *
  • Headless app start: for launches started by something like a broadcast receiver, service, + * or content provider without an Activity, the SDK sends only the standalone app-start + * transaction. + *
      + *
    • On devices running Android 15 (API level 35) or newer, the SDK can use {@code + * ApplicationStartInfo} to classify cold versus warm starts and anchor the end time + * at the {@code Application.onCreate} start. + *
    • On devices running older Android versions, headless launches are treated as cold + * once {@code Application.onCreate} finishes without an Activity. The end time falls + * back to the best SDK/plugin timing available. + *
    • With {@code Application.onCreate} instrumentation, the SDK can add an {@code + * application.load} phase span and use the exact {@code Application.onCreate} end + * time. Without that instrumentation, the standalone transaction is still sent, but + * it may only include the {@code process.load} phase span. + *
    + *
  • If an Activity opens after a headless start, its {@code ui.load} transaction reuses the + * app-start trace ID. + *
+ * + * @param enableStandaloneAppStartTracing true if enabled or false otherwise + */ + @ApiStatus.Experimental + public void setEnableStandaloneAppStartTracing(final boolean enableStandaloneAppStartTracing) { + this.enableStandaloneAppStartTracing = enableStandaloneAppStartTracing; + } + @ApiStatus.Internal public @Nullable SentryFrameMetricsCollector getFrameMetricsCollector() { return frameMetricsCollector; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 746805fcfdc..d8cb0827ba4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -10,7 +10,6 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.os.MessageQueue; import android.os.SystemClock; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -19,12 +18,14 @@ import io.sentry.ISentryLifecycleToken; import io.sentry.ITransactionProfiler; import io.sentry.NoOpLogger; +import io.sentry.SentryDate; import io.sentry.TracesSamplingDecision; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.CurrentActivityHolder; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.internal.util.FirstDrawDoneListener; +import io.sentry.protocol.SentryId; import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.LazyEvaluator; import java.util.ArrayList; @@ -49,6 +50,10 @@ */ @ApiStatus.Internal public class AppStartMetrics extends ActivityLifecycleCallbacksAdapter { + public interface HeadlessAppStartListener { + void onHeadlessAppStart(); + } + public enum AppStartType { UNKNOWN, COLD, @@ -84,6 +89,15 @@ public enum AppStartType { private boolean shouldSendStartMeasurements = true; private final AtomicInteger activeActivitiesCounter = new AtomicInteger(); private final AtomicBoolean firstDrawDone = new AtomicBoolean(false); + private final AtomicBoolean headlessAppStartCheckPending = new AtomicBoolean(false); + private final AtomicBoolean headlessAppStartListenerInvoked = new AtomicBoolean(false); + private volatile @Nullable HeadlessAppStartListener headlessAppStartListener; + // Captures a headless app.start so a later ui.load can share its trace. + private @Nullable SentryId appStartTraceId; + private @Nullable String appStartSentryTraceHeader; + private @Nullable String appStartBaggageHeader; + private @Nullable SentryDate appStartEndTime; + private @Nullable ApplicationStartInfo cachedStartInfo; public static @NotNull AppStartMetrics getInstance() { if (instance == null) { @@ -161,6 +175,48 @@ public void setAppLaunchedInForeground(final boolean appLaunchedInForeground) { this.appLaunchedInForeground.setValue(appLaunchedInForeground); } + public void setHeadlessAppStartListener(final @Nullable HeadlessAppStartListener listener) { + this.headlessAppStartListener = listener; + if (listener != null + && isCallbackRegistered + && activeActivitiesCounter.get() == 0 + && !firstDrawDone.get()) { + scheduleHeadlessAppStartCheckOnMain(); + } + } + + public @Nullable SentryId getAppStartTraceId() { + return appStartTraceId; + } + + public void setAppStartTraceId(final @Nullable SentryId traceId) { + this.appStartTraceId = traceId; + } + + public @Nullable String getAppStartSentryTraceHeader() { + return appStartSentryTraceHeader; + } + + public void setAppStartSentryTraceHeader(final @Nullable String appStartSentryTraceHeader) { + this.appStartSentryTraceHeader = appStartSentryTraceHeader; + } + + public @Nullable String getAppStartBaggageHeader() { + return appStartBaggageHeader; + } + + public void setAppStartBaggageHeader(final @Nullable String appStartBaggageHeader) { + this.appStartBaggageHeader = appStartBaggageHeader; + } + + public @Nullable SentryDate getAppStartEndTime() { + return appStartEndTime; + } + + public void setAppStartEndTime(final @Nullable SentryDate appStartEndTime) { + this.appStartEndTime = appStartEndTime; + } + /** * Provides all collected content provider onCreate time spans * @@ -188,14 +244,30 @@ public void onAppStartSpansSent() { activityLifecycles.clear(); } + public boolean shouldSendStartMeasurements(final boolean ignoreForegroundCheck) { + return shouldSendStartMeasurements + && (ignoreForegroundCheck || appLaunchedInForeground.getValue()); + } + public boolean shouldSendStartMeasurements() { - return shouldSendStartMeasurements && appLaunchedInForeground.getValue(); + return shouldSendStartMeasurements(false); } public long getClassLoadedUptimeMs() { return CLASS_LOADED_UPTIME_MS; } + /** + * Returns a valid app start time span, bypassing the foreground check. Tries appStartSpan first, + * falls back to sdkInitTimeSpan. Used for headless starts where appLaunchedInForeground is false. + */ + public @NotNull TimeSpan getAppStartTimeSpanForHeadless() { + if (appStartSpan.hasStarted() && appStartSpan.hasStopped()) { + return appStartSpan; + } + return sdkInitTimeSpan; + } + /** * @return the app start time span if it was started and perf-2 is enabled, falls back to the sdk * init time span otherwise @@ -258,6 +330,14 @@ public void clear() { firstDrawDone.set(false); activeActivitiesCounter.set(0); firstIdle = -1; + headlessAppStartCheckPending.set(false); + headlessAppStartListenerInvoked.set(false); + headlessAppStartListener = null; + appStartTraceId = null; + appStartSentryTraceHeader = null; + appStartBaggageHeader = null; + appStartEndTime = null; + cachedStartInfo = null; } public @Nullable ITransactionProfiler getAppStartProfiler() { @@ -346,6 +426,7 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { activityManager.getHistoricalProcessStartReasons(1); if (!historicalProcessStartReasons.isEmpty()) { final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0); + cachedStartInfo = info; if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) { if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { appStartType = AppStartType.COLD; @@ -357,41 +438,61 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { } } - if (appStartType == AppStartType.UNKNOWN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (appStartType == AppStartType.UNKNOWN || headlessAppStartListener != null) { + scheduleHeadlessAppStartCheckOnMain(); + } + } + + private void scheduleHeadlessAppStartCheckOnMain() { + if (!headlessAppStartCheckPending.compareAndSet(false, true)) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Looper.getMainLooper() .getQueue() .addIdleHandler( - new MessageQueue.IdleHandler() { - @Override - public boolean queueIdle() { - firstIdle = SystemClock.uptimeMillis(); - checkCreateTimeOnMain(); - return false; - } + () -> { + firstIdle = SystemClock.uptimeMillis(); + headlessAppStartCheckPending.set(false); + handleHeadlessAppStartIfNeededOnMain(); + return false; }); - } else if (appStartType == AppStartType.UNKNOWN) { - // We post on the main thread a task to post a check on the main thread. On Pixel devices - // (possibly others) the first task posted on the main thread is called before the - // Activity.onCreate callback. This is a workaround for that, so that the Activity.onCreate - // callback is called before the application one. + } else { final Handler handler = new Handler(Looper.getMainLooper()); handler.post( - new Runnable() { - @Override - public void run() { - // not technically correct, but close enough for pre-M - firstIdle = SystemClock.uptimeMillis(); - handler.post(() -> checkCreateTimeOnMain()); - } + () -> { + firstIdle = SystemClock.uptimeMillis(); + handler.post( + () -> { + headlessAppStartCheckPending.set(false); + handleHeadlessAppStartIfNeededOnMain(); + }); }); } } - private void checkCreateTimeOnMain() { - // if no activity has ever been created, app was launched in background + /** + * Checks whether startup reached an Activity after the main looper had a chance to create one. If + * not, handles the headless app start path. Must be called on the main thread. + */ + private void handleHeadlessAppStartIfNeededOnMain() { if (activeActivitiesCounter.get() == 0) { + // SDK init happened after Application.onCreate (e.g. deferred/late init inside an Activity): + // we missed the Activity's onActivityCreated, but a foreground process means it was a real + // launch, not a headless start. Gated on the listener so only the standalone-app-start path + // (which is what could emit a headless transaction) is affected. + if (headlessAppStartListener != null && ContextUtils.isForegroundImportance()) { + return; + } + appLaunchedInForeground.setValue(false); + // Headless starts have no Activity signal for the pre-API 35 warm/cold heuristic. + // If ApplicationStartInfo did not resolve the type, classify the process start as cold. + if (appStartType == AppStartType.UNKNOWN) { + appStartType = AppStartType.COLD; + } + // we stop the app start profilers, as they are useless and likely to timeout if (appStartProfiler != null && appStartProfiler.isRunning()) { appStartProfiler.close(); @@ -401,6 +502,56 @@ private void checkCreateTimeOnMain() { appStartContinuousProfiler.close(true); appStartContinuousProfiler = null; } + + final @Nullable HeadlessAppStartListener listener = headlessAppStartListener; + if (listener != null && headlessAppStartListenerInvoked.compareAndSet(false, true)) { + resolveHeadlessAppStartEndTime(); + listener.onHeadlessAppStart(); + } + } + } + + private void resolveHeadlessAppStartEndTime() { + // Priority 1: Gradle plugin instrumented onApplicationPostCreate + if (applicationOnCreate.hasStopped()) { + final long stopUptimeMs = + applicationOnCreate.getStartUptimeMs() + applicationOnCreate.getDurationMs(); + stopHeadlessAppStartAt(stopUptimeMs); + return; + } + + // Priority 2: API 35+ ApplicationStartInfo (cached from registerLifecycleCallbacks) + if (cachedStartInfo != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + try { + final @NotNull Map timestamps = cachedStartInfo.getStartupTimestamps(); + final @Nullable Long onCreateStartNanos = + timestamps.get(ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE); + if (onCreateStartNanos != null) { + // The framework captures this timestamp with SystemClock.uptimeNanos() right *before* + // invoking Application.onCreate (see ActivityThread.handleBindApplication), so it marks + // the onCreate start, not its end. Without plugin instrumentation there is no onCreate + // end signal, so this is the best available lower bound for the app start end time. + // Same clock base as TimeSpan, so it can be used directly without re-anchoring. + final long onCreateStartUptimeMs = TimeUnit.NANOSECONDS.toMillis(onCreateStartNanos); + stopHeadlessAppStartAt(onCreateStartUptimeMs); + return; + } + } catch (Throwable ignored) { + // Best effort: never let optional startup timestamp enrichment break app startup. + } + } + + // Priority 3: Process init end time (CLASS_LOADED_UPTIME_MS) + stopHeadlessAppStartAt(CLASS_LOADED_UPTIME_MS); + } + + private void stopHeadlessAppStartAt(final long stopUptimeMs) { + if (appStartSpan.hasStarted()) { + if (appStartSpan.hasNotStopped()) { + appStartSpan.setStoppedAt(stopUptimeMs); + } + } else if (sdkInitTimeSpan.hasStarted() && sdkInitTimeSpan.hasNotStopped()) { + sdkInitTimeSpan.setStoppedAt(stopUptimeMs); } } @@ -413,7 +564,9 @@ public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle saved if (activeActivitiesCounter.incrementAndGet() == 1 && !firstDrawDone.get()) { final long nowUptimeMs = SystemClock.uptimeMillis(); - // If the app (process) was launched more than 1 minute ago, consider it a warm start + // If the app (process) was launched more than 1 minute ago, consider it a warm start. + // NOTE: meaningless in standalone app start mode, where a headless start is already its own + // standalone transaction and therefore cannot be re-classified as warm. final long durationSinceAppStartMillis = nowUptimeMs - appStartSpan.getStartUptimeMs(); if (!appLaunchedInForeground.getValue() || durationSinceAppStartMillis > TimeUnit.MINUTES.toMillis(1)) { @@ -472,7 +625,11 @@ public void onActivityStopped(@NonNull Activity activity) { public void onActivityDestroyed(@NonNull Activity activity) { CurrentActivityHolder.getInstance().clearActivity(activity); - final int remainingActivities = activeActivitiesCounter.decrementAndGet(); + int remainingActivities = activeActivitiesCounter.decrementAndGet(); + if (remainingActivities < 0) { + activeActivitiesCounter.set(0); + remainingActivities = 0; + } // if the app is moving into background // as the next onActivityCreated will treat it as a new warm app start if (remainingActivities == 0 && !activity.isChangingConfigurations()) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 9e94d7b9905..f2ffb4b4b96 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -7,6 +7,7 @@ import android.app.Application import android.content.Context import android.os.Build import android.os.Bundle +import android.os.Handler import android.os.Looper import android.view.View import android.view.ViewTreeObserver @@ -22,17 +23,21 @@ import io.sentry.Sentry import io.sentry.SentryDate import io.sentry.SentryDateProvider import io.sentry.SentryNanotimeDate +import io.sentry.SentryTraceHeader import io.sentry.SentryTracer import io.sentry.Span +import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.SpanStatus.OK import io.sentry.TraceContext +import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.TransactionFinishedCallback import io.sentry.TransactionOptions import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.core.performance.AppStartMetrics.AppStartType import io.sentry.protocol.MeasurementValue +import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource import io.sentry.test.DeferredExecutorService import io.sentry.test.getProperty @@ -52,6 +57,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.ArgumentCaptor +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor @@ -64,6 +70,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager @@ -83,6 +90,9 @@ class ActivityLifecycleIntegrationTest { // start it var transaction: SentryTracer = mock() val buildInfo = mock() + val createdTransactions = mutableListOf() + val capturedContexts = mutableListOf() + val capturedOptions = mutableListOf() fun getSut( apiVersion: Int = Build.VERSION_CODES.Q, @@ -102,8 +112,13 @@ class ActivityLifecycleIntegrationTest { val contextCaptor = argumentCaptor() whenever(scopes.startTransaction(contextCaptor.capture(), optionCaptor.capture())) .thenAnswer { - val t = SentryTracer(contextCaptor.lastValue, scopes, optionCaptor.lastValue) + val context = contextCaptor.lastValue + val options = optionCaptor.lastValue + val t = SentryTracer(context, scopes, options) transaction = t + createdTransactions.add(t) + capturedContexts.add(context) + capturedOptions.add(options) return@thenAnswer t } whenever(buildInfo.sdkInfoVersion).thenReturn(apiVersion) @@ -225,6 +240,192 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + fun `Standalone app start transaction op is app start`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + verify(fixture.scopes, times(2)).startTransaction(any(), any()) + + val contexts = fixture.capturedContexts + val appStartContext = + contexts.single { it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP } + assertEquals("App Start", appStartContext.name) + assertEquals(TransactionNameSource.COMPONENT, appStartContext.transactionNameSource) + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("Activity", appStartTransaction.getData("app.vitals.start.screen")) + assertTrue(contexts.any { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP }) + assertFalse( + contexts.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + } + + @Test + fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.UNKNOWN) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.STANDALONE_APP_START_OP, + fixture.capturedContexts.single().operation, + ) + assertEquals("App Start", fixture.capturedContexts.single().name) + } + + @Test + fun `HeadlessAppStartListener is not registered when standalone flag is off`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `HeadlessAppStartListener is not registered when performance is disabled`() { + val sut = fixture.getSut { it.isEnableStandaloneAppStartTracing = true } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `close clears HeadlessAppStartListener`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + sut.close() + prepareHeadlessAppStart() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `onHeadlessAppStart creates standalone App Start transaction and stashes trace id`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + assertEquals("auto.app.start", options.origin) + assertFalse(options.isBindToScope) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun `onHeadlessAppStart creates standalone App Start transaction on API 23`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessSdkInitAppStart() + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + val options = fixture.capturedOptions.single() + val transaction = fixture.createdTransactions.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(DateUtils.millisToNanos(100), options.startTimestamp!!.nanoTimestamp()) + assertEquals( + transaction.spanContext.traceId, + AppStartMetrics.getInstance().getAppStartTraceId(), + ) + assertTrue(transaction.isFinished) + assertEquals(SpanStatus.OK, transaction.status) + } + + @Test + fun `onHeadlessAppStart creates standalone App Start transaction when appStartType is WARM`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.WARM) + + driveHeadlessAppStart() + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.STANDALONE_APP_START_OP, context.operation) + assertEquals("App Start", context.name) + assertEquals(TransactionNameSource.COMPONENT, context.transactionNameSource) + } + + @Test + fun `onHeadlessAppStart does nothing when appStartTimeSpan is incomplete`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + AppStartMetrics.getInstance().appStartTimeSpan.reset() + AppStartMetrics.getInstance().sdkInitTimeSpan.reset() + + driveHeadlessAppStart() + + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + @Test fun `Activity transaction uses custom deadline timeout when autoTransactionDeadlineTimeoutMillis is set to positive value`() { val sut = fixture.getSut() @@ -528,6 +729,28 @@ class ActivityLifecycleIntegrationTest { assertTrue(span.isFinished) } + @Test + fun `When Activity is destroyed, sets standalone appStartTransaction status to cancelled and finish it`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + sut.onActivityDestroyed(activity) + + val appStartTransaction = + fixture.createdTransactions[ + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP)] + assertEquals(SpanStatus.CANCELLED, appStartTransaction.status) + assertTrue(appStartTransaction.isFinished) + } + @Test fun `When Activity is destroyed, sets appStartSpan to null`() { val sut = fixture.getSut() @@ -882,6 +1105,282 @@ class ActivityLifecycleIntegrationTest { assertNull(appStartSpan) } + @Test + fun `launcher activity emits ui load and standalone App Start sharing trace id`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + val firstFrameDate = SentryNanotimeDate(Date(1499), 0) + fixture.options.dateProvider = SentryDateProvider { firstFrameDate } + setAppStartTime(SentryNanotimeDate(Date(1), 0)) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(2, fixture.capturedContexts.size) + val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val uiLoadTransaction = fixture.createdTransactions[uiLoadIndex] + val appStartTransaction = fixture.createdTransactions[appStartIndex] + + assertEquals(uiLoadTransaction.spanContext.traceId, appStartTransaction.spanContext.traceId) + assertEquals("auto.app.start", fixture.capturedOptions[appStartIndex].origin) + assertEquals("auto.ui.activity", fixture.capturedOptions[uiLoadIndex].origin) + assertFalse(fixture.capturedOptions[appStartIndex].isBindToScope) + assertFalse( + uiLoadTransaction.children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD || + it.operation == ActivityLifecycleIntegration.APP_START_WARM + } + ) + + sut.onActivityPostCreated(activity, fixture.bundle) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + sut.onActivityPostStarted(activity) + + assertTrue(appStartTransaction.children.any { it.operation == "activity.load" }) + + sut.onActivityResumed(activity) + runFirstDraw(fixture.createView()) + + val ttidSpan = + uiLoadTransaction.children.single { it.operation == ActivityLifecycleIntegration.TTID_OP } + assertTrue(ttidSpan.isFinished) + assertTrue(appStartTransaction.isFinished) + assertEquals(ttidSpan.finishDate, appStartTransaction.finishDate) + assertEquals( + ttidSpan.measurements[MeasurementValue.KEY_TIME_TO_INITIAL_DISPLAY]!!.value, + AppStartMetrics.getInstance().appStartTimeSpan.durationMs, + ) + } + + @Test + fun `launcher activity attaches lifecycle spans before finishing stopped standalone App Start`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + val appStartEndDate = SentryNanotimeDate(Date(499), 0) + setAppStartTime(SentryNanotimeDate(Date(1), 0), appStartEndDate) + + val activity = mock() + sut.onActivityPreCreated(activity, fixture.bundle) + sut.onActivityCreated(activity, fixture.bundle) + + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val appStartTransaction = fixture.createdTransactions[appStartIndex] + assertFalse(appStartTransaction.isFinished) + + sut.onActivityPostCreated(activity, fixture.bundle) + sut.onActivityPreStarted(activity) + sut.onActivityStarted(activity) + sut.onActivityPostStarted(activity) + + val activityLoadSpans = appStartTransaction.children.filter { it.operation == "activity.load" } + assertEquals(2, activityLoadSpans.size) + assertTrue(activityLoadSpans.all { it.isFinished }) + assertTrue(appStartTransaction.isFinished) + assertEquals(appStartEndDate.nanoTimestamp(), appStartTransaction.finishDate!!.nanoTimestamp()) + } + + @Test + fun `activity following a headless start reuses trace id and does not emit second standalone`() { + val storedTraceId = SentryId() + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + // headless start always stores the trace header alongside the trace id; the ui.load txn + // continues that trace via continueTrace, sharing the trace id. + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + assertEquals(storedTraceId, context.traceId) + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `activity within a minute of the headless start continues the same trace`() { + val storedTraceId = SentryId() + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + // headless start ended right before the activity opens + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(Date(0), 0) + sut.register(fixture.scopes, fixture.options) + setAppStartTime(date = SentryNanotimeDate(Date(1), 0)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + assertEquals(storedTraceId, context.traceId) + } + + @Test + fun `activity more than a minute after the headless start starts a fresh trace`() { + val storedTraceId = SentryId() + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + AppStartMetrics.getInstance().setAppStartTraceId(storedTraceId) + AppStartMetrics.getInstance().appStartSentryTraceHeader = + SentryTraceHeader(storedTraceId, SpanId(), true).value + // headless start ended at epoch, but the activity opens more than a minute later + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(Date(0), 0) + sut.register(fixture.scopes, fixture.options) + setAppStartTime(date = SentryNanotimeDate(Date(TimeUnit.MINUTES.toMillis(2)), 0)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val context = fixture.capturedContexts.single() + assertEquals(ActivityLifecycleIntegration.UI_LOAD_OP, context.operation) + // too far apart: the ui.load gets its own fresh trace, not the stored one + assertNotEquals(storedTraceId, context.traceId) + // stored continuation state is still consumed so nothing reuses it + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + } + + @Test + fun `onHeadlessAppStart stores sentry-trace and baggage headers for continuation`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + + driveHeadlessAppStart() + + val transaction = fixture.createdTransactions.single() + val metrics = AppStartMetrics.getInstance() + val sentryTraceHeader = metrics.appStartSentryTraceHeader + val baggageHeader = metrics.appStartBaggageHeader + assertNotNull(sentryTraceHeader) + assertNotNull(baggageHeader) + // sentry-trace carries the standalone app.start trace id so a later ui.load txn can continue it + assertTrue(sentryTraceHeader.startsWith(transaction.spanContext.traceId.toString())) + } + + @Test + fun `launcher activity shares standalone App Start trace and sampleRand as a sibling`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + // the app-start sampling decision carries the sampleRand the whole trace should share + AppStartMetrics.getInstance() + .setAppStartSamplingDecision(TracesSamplingDecision(true, 1.0, 0.42)) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(2, fixture.capturedContexts.size) + val appStartIndex = + transactionIndexForOperation(ActivityLifecycleIntegration.STANDALONE_APP_START_OP) + val uiLoadIndex = transactionIndexForOperation(ActivityLifecycleIntegration.UI_LOAD_OP) + // app.start is created first so it roots the trace; ui.load shares it + assertTrue(appStartIndex < uiLoadIndex) + + val appStartContext = fixture.capturedContexts[appStartIndex] + val uiLoadContext = fixture.capturedContexts[uiLoadIndex] + assertEquals(appStartContext.traceId, uiLoadContext.traceId) + // both share the same sampleRand + assertEquals(0.42, appStartContext.baggage?.sampleRand) + assertEquals(0.42, uiLoadContext.baggage?.sampleRand) + // siblings, not parent/child: ui.load has no parent span id + assertNull(uiLoadContext.parentSpanId) + } + + @Test + fun `activity following a headless start shares stored trace and sampleRand as a sibling and clears headers`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + // 1) a headless start emits the standalone app.start and stores its trace headers + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + driveHeadlessAppStart() + val appStartTransaction = fixture.createdTransactions.single() + + // 2) an activity opens and shares the stored trace instead of emitting a second standalone + setAppStartTime() + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val uiLoadContext = + fixture.capturedContexts.last { it.operation == ActivityLifecycleIntegration.UI_LOAD_OP } + assertFalse( + fixture.capturedContexts.drop(1).any { + it.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + ) + assertEquals(appStartTransaction.spanContext.traceId, uiLoadContext.traceId) + // siblings, not parent/child: ui.load has no parent span id + assertNull(uiLoadContext.parentSpanId) + + // stored continuation state is consumed + assertNull(AppStartMetrics.getInstance().getAppStartTraceId()) + assertNull(AppStartMetrics.getInstance().appStartSentryTraceHeader) + assertNull(AppStartMetrics.getInstance().appStartBaggageHeader) + } + + @Test + fun `standalone flag off launcher activity emits single ui load with nested app start cold child`() { + val sut = fixture.getSut { it.tracesSampleRate = 1.0 } + sut.register(fixture.scopes, fixture.options) + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + assertEquals(1, fixture.capturedContexts.size) + assertEquals( + ActivityLifecycleIntegration.UI_LOAD_OP, + fixture.capturedContexts.single().operation, + ) + assertTrue( + fixture.createdTransactions.single().children.any { + it.operation == ActivityLifecycleIntegration.APP_START_COLD + } + ) + } + @Test fun `When SentryPerformanceProvider is disabled, app start time span is still created`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) @@ -1737,6 +2236,59 @@ class ActivityLifecycleIntegrationTest { shadowOf(Looper.getMainLooper()).idle() } + private fun driveHeadlessAppStart() { + // A headless start (broadcast/service) runs in a non-foreground-importance process. The + // foreground guard in AppStartMetrics suppresses the headless path for foreground processes + // (deferred init inside an Activity), so headless scenarios must simulate background + // importance. + mockStatic(ContextUtils::class.java).use { contextUtils -> + contextUtils.`when` { ContextUtils.isForegroundImportance() }.thenReturn(false) + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + } + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + shadowOf(Looper.getMainLooper()).idle() + } + + private fun prepareHeadlessAppStart( + appStartType: AppStartType = AppStartType.COLD, + startUptimeMs: Long = 100, + endUptimeMs: Long = 200, + ) { + AppStartMetrics.getInstance().apply { + this.appStartType = appStartType + setClassLoadedUptimeMs(endUptimeMs) + appStartTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + sdkInitTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + } + } + + private fun prepareHeadlessSdkInitAppStart(startUptimeMs: Long = 100, endUptimeMs: Long = 200) { + AppStartMetrics.getInstance().apply { + appStartTimeSpan.reset() + sdkInitTimeSpan.apply { + setStartedAt(startUptimeMs) + setStartUnixTimeMs(startUptimeMs) + } + setClassLoadedUptimeMs(endUptimeMs) + } + } + + private fun transactionIndexForOperation(operation: String): Int { + val index = fixture.capturedContexts.indexOfFirst { it.operation == operation } + assertTrue(index >= 0) + return index + } + private fun setAppStartTime( date: SentryDate = SentryNanotimeDate(Date(1), 0), stopDate: SentryDate? = null, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index d8ac959601a..3b12e6489a7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1492,6 +1492,36 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isEnablePerformanceV2) } + @Test + fun `applyMetadata reads standalone app start tracing flag to options`() { + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to true) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertTrue(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing false to options`() { + fixture.options.isEnableStandaloneAppStartTracing = true + val bundle = bundleOf(ManifestMetadataReader.ENABLE_STANDALONE_APP_START_TRACING to false) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + + @Test + fun `applyMetadata reads standalone app start tracing flag to options and keeps default if not found`() { + val context = fixture.getContext() + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertFalse(fixture.options.isEnableStandaloneAppStartTracing) + } + @Test fun `applyMetadata reads startupProfiling flag to options`() { // Arrange diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt index e2fed5bb003..173b4e3d999 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerformanceAndroidEventProcessorTest.kt @@ -13,7 +13,9 @@ import io.sentry.SpanStatus import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_COLD +import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_SCREEN_DATA import io.sentry.android.core.ActivityLifecycleIntegration.APP_START_WARM +import io.sentry.android.core.ActivityLifecycleIntegration.STANDALONE_APP_START_OP import io.sentry.android.core.ActivityLifecycleIntegration.UI_LOAD_OP import io.sentry.android.core.performance.ActivityLifecycleTimeSpan import io.sentry.android.core.performance.AppStartMetrics @@ -87,7 +89,7 @@ class PerformanceAndroidEventProcessorTest { fun `add cold start measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -95,11 +97,106 @@ class PerformanceAndroidEventProcessorTest { assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) } + @Test + fun `add cold start measurement for standalone app start transaction launched from background`() { + val sut = fixture.getSut() + + var tr = createStandaloneAppStartTransaction() + setStandaloneColdAppStartMetrics() + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + } + + @Test + fun `standalone app start with instrumented application onCreate attaches process and application spans`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load", "application.load"), tr.spans.map { it.op }) + assertTrue(tr.spans.all { it.parentSpanId == rootSpanId }) + } + + @Test + fun `standalone app start without instrumented application onCreate attaches only process span`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = false) + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + assertTrue(tr.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertEquals(listOf("process.load"), tr.spans.map { it.op }) + assertEquals(rootSpanId, tr.spans.single().parentSpanId) + } + + @Test + fun `standalone app start uses the transaction root span id as parent`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics() + + var tr = createStandaloneAppStartTransaction() + val rootSpanId = tr.contexts.trace!!.spanId + + tr = sut.process(tr, Hint()) + + val processLoadSpan = tr.spans.first { it.op == "process.load" } + assertEquals(rootSpanId, processLoadSpan.parentSpanId) + } + + @Test + fun `standalone app start spans do not carry TTID or TTFD contributing flags`() { + val sut = fixture.getSut(enablePerformanceV2 = true) + setStandaloneColdAppStartMetrics(withApplicationOnCreate = true) + + var tr = createStandaloneAppStartTransaction() + + tr = sut.process(tr, Hint()) + + assertTrue(tr.spans.isNotEmpty()) + for (span in tr.spans) { + assertNull(span.data?.get(SpanDataConvention.CONTRIBUTES_TTID)) + assertNull(span.data?.get(SpanDataConvention.CONTRIBUTES_TTFD)) + } + } + + @Test + fun `foreground standalone app start measurement uses foreground fallback time span`() { + val sut = fixture.getSut(enablePerformanceV2 = false) + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = true + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(101) + } + sdkInitTimeSpan.apply { + setStartedAt(10) + setStoppedAt(30) + } + } + + var tr = createStandaloneAppStartTransaction(appStartScreen = "MainActivity") + + tr = sut.process(tr, Hint()) + + assertEquals(20f, tr.measurements[MeasurementValue.KEY_APP_START_COLD]?.value) + } + @Test fun `add cold start measurement for performance-v2`() { val sut = fixture.getSut(enablePerformanceV2 = true) - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -111,7 +208,7 @@ class PerformanceAndroidEventProcessorTest { fun `add warm start measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.WARM) + var tr = createUiLoadTransactionWithAppStartChildSpan(coldStart = false) setAppStart(fixture.options, false) tr = sut.process(tr, Hint()) @@ -123,7 +220,7 @@ class PerformanceAndroidEventProcessorTest { fun `set app cold start unit measurement`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options) tr = sut.process(tr, Hint()) @@ -136,23 +233,40 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric twice`() { val sut = fixture.getSut() - var tr1 = getTransaction(AppStartType.COLD) + var tr1 = createUiLoadTransactionWithAppStartChildSpan() setAppStart(fixture.options, false) tr1 = sut.process(tr1, Hint()) - var tr2 = getTransaction(AppStartType.UNKNOWN) + var tr2 = createUiLoadTransaction() tr2 = sut.process(tr2, Hint()) assertTrue(tr1.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) assertTrue(tr2.measurements.isEmpty()) } + @Test + fun `do not add standalone app start metric twice`() { + val sut = fixture.getSut() + + setStandaloneColdAppStartMetrics() + + var tr1 = createStandaloneAppStartTransaction() + tr1 = sut.process(tr1, Hint()) + + var tr2 = createStandaloneAppStartTransaction() + tr2 = sut.process(tr2, Hint()) + + assertTrue(tr1.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr2.measurements.containsKey(MeasurementValue.KEY_APP_START_COLD)) + assertFalse(tr2.measurements.containsKey(MeasurementValue.KEY_APP_START_WARM)) + } + @Test fun `do not add app start metric if its not ready`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransactionWithAppStartChildSpan() tr = sut.process(tr, Hint()) @@ -163,7 +277,7 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric if performance is disabled`() { val sut = fixture.getSut(tracesSampleRate = null) - var tr = getTransaction(AppStartType.COLD) + var tr = createUiLoadTransactionWithAppStartChildSpan() tr = sut.process(tr, Hint()) @@ -174,7 +288,7 @@ class PerformanceAndroidEventProcessorTest { fun `do not add app start metric if no app_start span`() { val sut = fixture.getSut(tracesSampleRate = null) - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransaction() tr = sut.process(tr, Hint()) @@ -184,7 +298,7 @@ class PerformanceAndroidEventProcessorTest { @Test fun `do not add slow and frozen frames if not auto transaction`() { val sut = fixture.getSut() - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createTransaction("custom.op") tr = sut.process(tr, Hint()) @@ -194,7 +308,7 @@ class PerformanceAndroidEventProcessorTest { @Test fun `do not add slow and frozen frames if tracing is disabled`() { val sut = fixture.getSut(null) - var tr = getTransaction(AppStartType.UNKNOWN) + var tr = createUiLoadTransaction() tr = sut.process(tr, Hint()) @@ -464,10 +578,10 @@ class PerformanceAndroidEventProcessorTest { val appStartSpan = createAppStartSpan(tr.contexts.trace!!.traceId) tr.spans.add(appStartSpan) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) // then the app start metrics should be attached tr = sut.process(tr, Hint()) - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) assertTrue(tr.spans.any { "application.load" == it.op }) @@ -867,13 +981,44 @@ class PerformanceAndroidEventProcessorTest { } } - private fun getTransaction(type: AppStartType): SentryTransaction { - val op = - when (type) { - AppStartType.COLD -> "app.start.cold" - AppStartType.WARM -> "app.start.warm" - AppStartType.UNKNOWN -> "ui.load" + private fun setStandaloneColdAppStartMetrics(withApplicationOnCreate: Boolean = false) { + AppStartMetrics.getInstance().apply { + appStartType = AppStartType.COLD + isAppLaunchedInForeground = false + classLoadedUptimeMs = 50 + appStartTimeSpan.apply { + setStartedAt(1) + setStoppedAt(100) + } + if (withApplicationOnCreate) { + applicationOnCreateTimeSpan.apply { + setStartedAt(10) + description = "com.example.App.onCreate" + setStoppedAt(42) + } } + } + } + + private fun createUiLoadTransactionWithAppStartChildSpan( + coldStart: Boolean = true + ): SentryTransaction = + createUiLoadTransaction().also { txn -> + txn.spans.add(createAppStartSpan(txn.contexts.trace!!.traceId, coldStart)) + } + + private fun createUiLoadTransaction(): SentryTransaction = createTransaction(UI_LOAD_OP) + + private fun createStandaloneAppStartTransaction( + appStartScreen: String? = null + ): SentryTransaction = + createTransaction(STANDALONE_APP_START_OP).also { txn -> + if (appStartScreen != null) { + txn.contexts.trace!!.setData(APP_START_SCREEN_DATA, appStartScreen) + } + } + + private fun createTransaction(op: String): SentryTransaction { val txn = SentryTransaction(fixture.tracer) txn.contexts.setTrace(SpanContext(op, TracesSamplingDecision(false))) return txn diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt index 819928dcdc4..0eec9502702 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidOptionsTest.kt @@ -156,6 +156,12 @@ class SentryAndroidOptionsTest { assertFalse(sentryOptions.isEnablePerformanceV2) } + @Test + fun `standalone app start tracing is disabled by default`() { + val sentryOptions = SentryAndroidOptions() + assertFalse(sentryOptions.isEnableStandaloneAppStartTracing) + } + fun `when options is initialized, enableScopeSync is enabled by default`() { assertTrue(SentryAndroidOptions().isEnableScopeSync) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt index e7079bd46d0..a959c5dd865 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt @@ -1,6 +1,7 @@ package io.sentry.android.core import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo import android.app.ApplicationStartInfo import android.os.Build import org.robolectric.annotation.Implementation @@ -10,13 +11,25 @@ import org.robolectric.annotation.Implements class SentryShadowActivityManager { companion object { private var historicalProcessStartReasons: List = emptyList() + private var importance: Int = RunningAppProcessInfo.IMPORTANCE_FOREGROUND fun setHistoricalProcessStartReasons(startReasons: List) { historicalProcessStartReasons = startReasons } + fun setImportance(importance: Int) { + this.importance = importance + } + fun reset() { historicalProcessStartReasons = emptyList() + importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND + } + + @Implementation + @JvmStatic + fun getMyMemoryState(outState: RunningAppProcessInfo) { + outState.importance = importance } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt index c3ff6653673..e36388fb185 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowProcess.kt @@ -6,15 +6,25 @@ import org.robolectric.annotation.Implements @Implements(android.os.Process::class) class SentryShadowProcess { companion object { - private var startupTimeMillis: Long = 0 + private var startUptimeMillis: Long = 0 + private var startElapsedRealtime: Long = 0 fun setStartUptimeMillis(value: Long) { - startupTimeMillis = value + startUptimeMillis = value } + fun setStartElapsedRealtime(value: Long) { + startElapsedRealtime = value + } + + @Suppress("unused") + @Implementation + @JvmStatic + fun getStartUptimeMillis(): Long = startUptimeMillis + @Suppress("unused") @Implementation @JvmStatic - fun getStartUptimeMillis(): Long = startupTimeMillis + fun getStartElapsedRealtime(): Long = startElapsedRealtime } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index c15ea3c37d0..ab0013a8c75 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -13,11 +13,14 @@ import io.sentry.DateUtils import io.sentry.IContinuousProfiler import io.sentry.ITransactionProfiler import io.sentry.SentryNanotimeDate +import io.sentry.android.core.ContextUtils import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess +import io.sentry.protocol.SentryId import java.util.Date import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -27,6 +30,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.Before import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -44,6 +48,7 @@ class AppStartMetricsTest { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -65,6 +70,7 @@ class AppStartMetricsTest { metrics.appStartProfiler = mock() metrics.appStartContinuousProfiler = mock() metrics.appStartSamplingDecision = mock() + metrics.setAppStartTraceId(SentryId()) metrics.clear() @@ -78,6 +84,7 @@ class AppStartMetricsTest { assertNull(metrics.appStartProfiler) assertNull(metrics.appStartContinuousProfiler) assertNull(metrics.appStartSamplingDecision) + assertNull(metrics.getAppStartTraceId()) } @Test @@ -167,10 +174,10 @@ class AppStartMetricsTest { // when the looper runs waitForMainLooperIdle() - // but no activity creation happened + // but a headless start happened // then the app wasn't launched in foreground and nothing should be sent assertFalse(metrics.isAppLaunchedInForeground) - assertFalse(metrics.shouldSendStartMeasurements()) + assertFalse(metrics.shouldSendStartMeasurements(false)) val now = TimeUnit.MINUTES.toMillis(2) + 1234567 SystemClock.setCurrentTimeMillis(now) @@ -180,7 +187,7 @@ class AppStartMetricsTest { // then it should restart the timespan assertTrue(metrics.isAppLaunchedInForeground) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) assertTrue(metrics.appStartTimeSpan.hasStarted()) assertEquals(now, metrics.appStartTimeSpan.startUptimeMs) assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) @@ -194,7 +201,7 @@ class AppStartMetricsTest { metrics.sdkInitTimeSpan.start() metrics.registerLifecycleCallbacks(mock()) - // when the handler callback is executed and no activity was launched + // when the handler callback is executed and the start is headless waitForMainLooperIdle() // isAppLaunchedInForeground should be false @@ -208,11 +215,170 @@ class AppStartMetricsTest { assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) } + @Test + fun `headless app start defaults UNKNOWN appStartType to COLD`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + } + + @Test + fun `headless app start does not overwrite existing appStartType`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartType = AppStartMetrics.AppStartType.WARM + metrics.appStartTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `headless app start fires HeadlessAppStartListener`() = headlessProcess { + val listenerCalls = AtomicInteger() + + AppStartMetrics.getInstance().setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + AppStartMetrics.getInstance().registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `foreground process does not fire HeadlessAppStartListener`() { + // Deferred/late SDK init inside an already-running Activity: we missed onActivityCreated, but + // the process is foreground (Robolectric default importance), so this is a real launch, not a + // headless start. The listener must not fire and the headless reclassification must not run. + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(0, listenerCalls.get()) + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + val activity = mock() + whenever(activity.isChangingConfigurations).thenReturn(false) + metrics.onActivityCreated(activity, null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + + metrics.onActivityDestroyed(activity) + SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) + metrics.onActivityCreated(mock(), null) + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + } + + @Test + fun `activity start prevents HeadlessAppStartListener`() { + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + metrics.onActivityCreated(mock(), null) + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(0, listenerCalls.get()) + } + + @Test + fun `resolveHeadlessAppStartEndTime uses applicationOnCreate stop when Gradle plugin instrumented`() = + headlessProcess { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener {} + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime falls back to CLASS_LOADED_UPTIME_MS when no plugin and no ApplicationStartInfo`() = + headlessProcess { + val metrics = AppStartMetrics.getInstance() + metrics.setClassLoadedUptimeMs(200) + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener {} + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime does not overwrite stopped appStartTimeSpan`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.apply { + setStartedAt(100) + setStoppedAt(150) + } + metrics.setHeadlessAppStartListener {} + metrics.applicationOnCreateTimeSpan.apply { + setStartedAt(120) + setStoppedAt(200) + } + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertEquals(50, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `headless app start without listener does not stop sdkInitTimeSpan`() { + val metrics = AppStartMetrics.getInstance() + metrics.sdkInitTimeSpan.setStartedAt(100) + + metrics.registerLifecycleCallbacks(mock()) + waitForMainLooperIdle() + + assertTrue(metrics.sdkInitTimeSpan.hasNotStopped()) + } + + @Test + fun `getAppStartTimeSpanForHeadless falls back to sdkInitTimeSpan when appStartSpan has not stopped`() { + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.sdkInitTimeSpan.apply { + setStartedAt(120) + setStoppedAt(180) + } + + assertSame(metrics.sdkInitTimeSpan, metrics.getAppStartTimeSpanForHeadless()) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle() } + // Simulates a real headless start (broadcast/service), i.e. a non-foreground-importance process. + // The Robolectric default importance in this test class is IMPORTANCE_FOREGROUND, so headless + // scenarios must opt into a background importance explicitly. + private fun headlessProcess(block: () -> T): T = + mockStatic(ContextUtils::class.java).use { contextUtils -> + contextUtils.`when` { ContextUtils.isForegroundImportance() }.thenReturn(false) + block() + } + @Test fun `if app start span is at most 1 minute, appStartTimeSpanWithFallback returns the app start span`() { val appStartTimeSpan = AppStartMetrics.getInstance().appStartTimeSpan @@ -331,12 +497,12 @@ class AppStartMetricsTest { } @Test - fun `registerApplicationForegroundCheck set foreground state to false if no activity is running`() { + fun `registerApplicationForegroundCheck set foreground state to false for headless start`() { val application = mock() AppStartMetrics.getInstance().isAppLaunchedInForeground = true AppStartMetrics.getInstance().registerLifecycleCallbacks(application) assertTrue(AppStartMetrics.getInstance().isAppLaunchedInForeground) - // Main thread performs the check and sets the flag to false if no activity was created + // Main thread performs the check and sets the flag to false if the start is headless waitForMainLooperIdle() assertFalse(AppStartMetrics.getInstance().isAppLaunchedInForeground) } @@ -369,11 +535,11 @@ class AppStartMetricsTest { val appStartMetrics = AppStartMetrics.getInstance() appStartMetrics.addActivityLifecycleTimeSpans(mock()) appStartMetrics.contentProviderOnCreateTimeSpans.add(mock()) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) appStartMetrics.onAppStartSpansSent() assertTrue(appStartMetrics.activityLifecycleTimeSpans.isEmpty()) assertTrue(appStartMetrics.contentProviderOnCreateTimeSpans.isEmpty()) - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) } @Test @@ -387,18 +553,18 @@ class AppStartMetricsTest { // then the app start type should be cold and measurements should be sent assertEquals(AppStartMetrics.AppStartType.COLD, appStartMetrics.appStartType) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) // when the activity gets destroyed appStartMetrics.onAppStartSpansSent() - assertFalse(appStartMetrics.shouldSendStartMeasurements()) + assertFalse(appStartMetrics.shouldSendStartMeasurements(false)) appStartMetrics.onActivityDestroyed(activity0) // then it should reset sending the measurements for the next warm activity appStartMetrics.onActivityCreated(mock(), mock()) assertEquals(AppStartMetrics.AppStartType.WARM, appStartMetrics.appStartType) - assertTrue(appStartMetrics.shouldSendStartMeasurements()) + assertTrue(appStartMetrics.shouldSendStartMeasurements(false)) } @Test @@ -585,7 +751,6 @@ class AppStartMetricsTest { waitForMainLooperIdle() SystemClock.setCurrentTimeMillis(SystemClock.uptimeMillis() + 100) - metrics.isAppLaunchedInForeground = true metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) @@ -791,7 +956,7 @@ class AppStartMetricsTest { whenever(firstActivity.isChangingConfigurations).thenReturn(false) metrics.onActivityCreated(firstActivity, null) assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) metrics.onAppStartSpansSent() waitForMainLooperIdle() @@ -804,7 +969,7 @@ class AppStartMetricsTest { metrics.onActivityCreated(secondActivity, null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) assertTrue(metrics.isAppLaunchedInForeground) - assertTrue(metrics.shouldSendStartMeasurements()) + assertTrue(metrics.shouldSendStartMeasurements(false)) metrics.onAppStartSpansSent() // Third activity - should still be warm @@ -812,7 +977,7 @@ class AppStartMetricsTest { metrics.onActivityCreated(mock(), null) assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) assertTrue(metrics.isAppLaunchedInForeground) - assertFalse(metrics.shouldSendStartMeasurements()) + assertFalse(metrics.shouldSendStartMeasurements(false)) } @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt index d3738943a2c..30686852156 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -1,18 +1,25 @@ package io.sentry.android.core.performance +import android.app.ActivityManager.RunningAppProcessInfo import android.app.Application import android.app.ApplicationStartInfo import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.SentryShadowActivityManager import io.sentry.android.core.SentryShadowProcess +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import org.robolectric.Shadows import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @@ -25,7 +32,9 @@ class AppStartMetricsTestApi35 { fun setup() { AppStartMetrics.getInstance().clear() SentryShadowProcess.setStartUptimeMillis(42) + SentryShadowProcess.setStartElapsedRealtime(42) SentryShadowActivityManager.reset() + AppStartMetrics.getInstance().setClassLoadedUptimeMs(42) AppStartMetrics.getInstance().isAppLaunchedInForeground = true } @@ -42,6 +51,22 @@ class AppStartMetricsTestApi35 { assertEquals(AppStartMetrics.AppStartType.COLD, AppStartMetrics.getInstance().appStartType) } + @Test + fun `known ApplicationStartInfo type without listener does not schedule headless check`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertEquals(-1, metrics.firstIdle) + } + @Test fun `detects warm start using ApplicationStartInfo on API 35`() { val mockStartInfo = mock() @@ -81,4 +106,109 @@ class AppStartMetricsTestApi35 { assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) } + + @Test + fun `headless app start keeps COLD appStartType from ApplicationStartInfo`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(1, listenerCalls.get()) + } + + @Test + fun `known ApplicationStartInfo type with listener handles headless app start`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_WARM) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + metrics.setClassLoadedUptimeMs(200) + metrics.setHeadlessAppStartListener {} + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.WARM, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(100, metrics.appStartTimeSpan.durationMs) + } + + @Test + fun `resolveHeadlessAppStartEndTime uses ApplicationStartInfo onCreate uptime timestamp`() { + val appStartUptimeMs = 100L + // START_TIMESTAMP_APPLICATION_ONCREATE is captured with SystemClock.uptimeNanos() (the same + // base as TimeSpan) right before Application.onCreate is invoked, so it is used directly as + // an uptime value marking the onCreate start, without any clock re-anchoring. + val onCreateStartUptimeMs = 350L + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps) + .thenReturn( + mapOf( + ApplicationStartInfo.START_TIMESTAMP_APPLICATION_ONCREATE to + TimeUnit.MILLISECONDS.toNanos(onCreateStartUptimeMs) + ) + ) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(appStartUptimeMs) + metrics.setHeadlessAppStartListener {} + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + waitForMainLooperIdle() + + assertEquals(250, metrics.appStartTimeSpan.durationMs) + assertFalse(metrics.applicationOnCreateTimeSpan.hasStarted()) + } + + @Test + fun `listener fires when set after registerLifecycleCallbacks resolves type on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.startupTimestamps).thenReturn(emptyMap()) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + SentryShadowActivityManager.setImportance(RunningAppProcessInfo.IMPORTANCE_CACHED) + + val listenerCalls = AtomicInteger() + val metrics = AppStartMetrics.getInstance() + metrics.appStartTimeSpan.setStartedAt(100) + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + // Listener set AFTER registerLifecycleCallbacks — mirrors production ordering + metrics.setHeadlessAppStartListener { listenerCalls.incrementAndGet() } + waitForMainLooperIdle() + + assertEquals(AppStartMetrics.AppStartType.COLD, metrics.appStartType) + assertFalse(metrics.isAppLaunchedInForeground) + assertEquals(1, listenerCalls.get()) + } + + private fun waitForMainLooperIdle() { + Handler(Looper.getMainLooper()).post {} + Shadows.shadowOf(Looper.getMainLooper()).idle() + } } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index e5b5ed2250b..14c8b595fd3 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -37,6 +37,15 @@ android:exported="true" android:foregroundServiceType="remoteMessaging" /> + + + + + + + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java new file mode 100644 index 00000000000..10b4fd4d94e --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/TestBroadcastReceiver.java @@ -0,0 +1,26 @@ +package io.sentry.samples.android; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +/** + * A manifest-declared broadcast receiver for testing standalone app starts. + * + *

Test with: + * + *

{@code
+ * adb shell am force-stop io.sentry.samples.android && \
+ * adb shell am broadcast -a io.sentry.samples.android.TEST_BROADCAST \
+ *   -n io.sentry.samples.android/.TestBroadcastReceiver
+ * }
+ */ +public class TestBroadcastReceiver extends BroadcastReceiver { + private static final String TAG = "SentryAppStart"; + + @Override + public void onReceive(Context context, Intent intent) { + Log.d(TAG, "TestBroadcastReceiver.onReceive() called - no activity will launch"); + } +} From 26ddd89873d8d1bd9b1e0147fe177fd4178b1f1e Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Mon, 15 Jun 2026 11:10:07 +0200 Subject: [PATCH 002/195] Upgrade to asyncProfiler 4.4 (#5418) --- CHANGELOG.md | 4 ++++ gradle/libs.versions.toml | 2 +- .../JfrAsyncProfilerToSentryProfileConverter.java | 9 +-------- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd876f3c57d..648533ec18f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - Reduce boxing to improve performance ([#5523](https://github.com/getsentry/sentry-java/pull/5523), [#5527](https://github.com/getsentry/sentry-java/pull/5527)) +### Dependencies + +- Upgrade to asyncProfiler 4.4 ([#5418](https://github.com/getsentry/sentry-java/pull/5418)) + ### Fixes - Fix performance collector scheduling many tasks in a row ([#5524](https://github.com/getsentry/sentry-java/pull/5524)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e653069e2b3..f6edafdc17f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ androidxLifecycle = "2.2.0" androidxNavigation = "2.4.2" androidxTestCore = "1.7.0" androidxCompose = "1.6.3" -asyncProfiler = "4.2" +asyncProfiler = "4.4" composeCompiler = "1.5.14" coroutines = "1.6.1" espresso = "3.7.0" diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java index b7b5662a8e5..718fae422f7 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -26,7 +26,6 @@ @ApiStatus.Internal public final class JfrAsyncProfilerToSentryProfileConverter extends JfrConverter { - private static final double NANOS_PER_SECOND = 1_000_000_000.0; private static final long UNKNOWN_THREAD_ID = -1; private final @NotNull SentryProfile sentryProfile = new SentryProfile(); @@ -83,7 +82,6 @@ private class ProfileEventVisitor implements EventCollector.Visitor { private final @NotNull SentryStackTraceFactory stackTraceFactory; private final @NotNull JfrReader jfr; private final @NotNull Arguments args; - private final double ticksPerNanosecond; public ProfileEventVisitor( @NotNull SentryProfile sentryProfile, @@ -94,7 +92,6 @@ public ProfileEventVisitor( this.stackTraceFactory = stackTraceFactory; this.jfr = jfr; this.args = args; - ticksPerNanosecond = jfr.ticksPerSec / NANOS_PER_SECOND; } @Override @@ -150,11 +147,7 @@ private void processSampleWithStack(Event event, long threadId, StackTrace stack } private double calculateTimestamp(Event event) { - long nanosFromStart = (long) ((event.time - jfr.chunkStartTicks) / ticksPerNanosecond); - - long timeNs = jfr.chunkStartNanos + nanosFromStart; - - return DateUtils.nanosToSeconds(timeNs); + return DateUtils.nanosToSeconds(jfr.eventTimeToNanos(event.time)); } private int addStackTrace(StackTrace stackTrace) { From 77e5b0a977f40709f429b7507b8b97fc46bcbdc4 Mon Sep 17 00:00:00 2001 From: arb Date: Mon, 15 Jun 2026 11:17:04 +0200 Subject: [PATCH 003/195] chore(samples-android): Add optional SAGP build mode (#5538) Adds an optional useSagp flag to Android sample app builds that, when true, applies the Sentry Android Gradle Plugin. (Defaults to false, which matches existing build behavior.) ``` ./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp=true ``` See the Android sample app README for more details. --- .../sentry-samples-android/README.md | 57 +++++++++++++++++++ .../sentry-samples-android/build.gradle.kts | 32 +++++++++++ 2 files changed, 89 insertions(+) create mode 100644 sentry-samples/sentry-samples-android/README.md diff --git a/sentry-samples/sentry-samples-android/README.md b/sentry-samples/sentry-samples-android/README.md new file mode 100644 index 00000000000..c7c7ad0d89c --- /dev/null +++ b/sentry-samples/sentry-samples-android/README.md @@ -0,0 +1,57 @@ +# Sentry Sample Android App + +Sample application demonstrating how to use the Sentry Android SDK, including core functionality (error reporting, tracing, session replay, +profiling) and integrations (Compose, OkHttp, etc.). + +## How to run it? + +Install the app on your device or emulator: + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug +``` + +or simply open the project in Android Studio and run the `sentry-samples-android` configuration. + +You can also apply the [Sentry Android Gradle Plugin](https://github.com/getsentry/sentry-android-gradle-plugin) (SAGP) when building (not applied by default): + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp=true +``` + +In Android Studio, add `useSagp=true` to `gradle.properties` or pass it as a Gradle project property. + +## Build modes + +### With or without SAGP + +The sample app can be built with or without the SAGP. + +| Gradle Property | Required | Purpose | +|-----------------|--------------------------|----------------------------------------------------------------------------------------------------------------| +| `useSagp` | No (defaults to `false`) | When `true`, apply SAGP when building the sample app. When false or absent, build the sample app without SAGP. | + +You can configure SAGP properties via the lambda passed to `extensions.configure("sentry")` in the sample app's +`build.gradle.kts` file. + +### Builds against your local sentry-java branch + +Regardless of `useSagp`, the sample always depends on sentry-java modules from this monorepo (e.g., `projects.sentryAndroid`). SAGP's SDK +auto-installation is disabled, so the sample never pulls a separate SDK version from Maven. Local SDK changes in your branch are picked up +directly. + +## Viewing SDK output + +### Locally + +Debug builds enable SDK debug logging, so captured envelopes are printed to logcat (tag `Sentry`): + +``` +adb logcat -s Sentry +``` + +### On Sentry UI + +By default, SDK output produced by the sample app appears under the [sentry-sdk test project](https://sentry-sdks.sentry.io/issues/?project=5428559). +To redirect them to your own project, replace the test DSN (i.e., the `io.sentry.dsn` `meta-data` value in `src/main/AndroidManifest.xml` +with your own. diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index ed8cea25661..44d930975ec 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -1,5 +1,7 @@ import com.android.build.api.artifact.SingleArtifact import com.android.build.api.variant.impl.VariantImpl +import io.sentry.android.gradle.extensions.InstrumentationFeature +import io.sentry.android.gradle.extensions.SentryPluginExtension import org.apache.tools.ant.taskdefs.condition.Os import org.gradle.internal.extensions.stdlib.capitalized @@ -7,6 +9,36 @@ plugins { id("com.android.application") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.sentry) apply false +} + +val useSagp = + providers.gradleProperty("useSagp").map { it.equals("true", ignoreCase = true) }.orElse(false) + +if (useSagp.get()) { + apply(plugin = "io.sentry.android.gradle") +} + +plugins.withId("io.sentry.android.gradle") { + // Extension configs match non-SAGP builds. Update locally to test your feature. + extensions.configure("sentry") { + autoInstallation.enabled.set(false) + includeProguardMapping.set(false) + includeDependenciesReport.set(false) + telemetry.set(false) + tracingInstrumentation { + features.set( + setOf( + InstrumentationFeature.COMPOSE, + InstrumentationFeature.DATABASE, + InstrumentationFeature.FILE_IO, + InstrumentationFeature.OKHTTP, + ) + ) + logcat.enabled.set(false) + appStart.enabled.set(false) + } + } } android { From aab75f770bf249460a877aeacdca4902d0ad8929 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 15 Jun 2026 12:18:09 +0200 Subject: [PATCH 004/195] fix(samples): allow leak canary for non-debug builds, so the sample app doesn't crash when using AS profiler (#5545) --- .../sentry-samples-android/src/main/res/values/bools.xml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 sentry-samples/sentry-samples-android/src/main/res/values/bools.xml diff --git a/sentry-samples/sentry-samples-android/src/main/res/values/bools.xml b/sentry-samples/sentry-samples-android/src/main/res/values/bools.xml new file mode 100644 index 00000000000..de1623ff077 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/values/bools.xml @@ -0,0 +1,4 @@ + + + true + From 9d2f4e3096958e67c6acc73926f7fde9e1bf2925 Mon Sep 17 00:00:00 2001 From: arb Date: Mon, 15 Jun 2026 21:51:29 +0200 Subject: [PATCH 005/195] chore(samples-android): Support mavenLocal for builds that apply the SAGP (#5539) chore(samples-android): Support mavenLocal for builds that apply the SAGP Adds wiring that lets us prefer mavenLocal SAGP artifacts, when present, for Android sample app builds that set -PuseSagp=true. If no local artifact is found, we fall back to libs.versions.toml. --- gradle/libs.versions.toml | 3 +- .../sentry-samples-android/README.md | 33 ++++++++++++++----- .../sentry-samples-android/build.gradle.kts | 5 +-- settings.gradle.kts | 15 +++++++-- 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f6edafdc17f..e7fcababd6c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ otelInstrumentationAlpha = "2.26.0-alpha" otelSemanticConventions = "1.40.0" otelSemanticConventionsAlpha = "1.40.0-alpha" retrofit = "2.9.0" +sagp = "6.10.0" slf4j = "1.7.30" springboot2 = "2.7.18" springboot3 = "3.5.0" @@ -66,7 +67,7 @@ springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" } gretty = { id = "org.gretty", version = "4.0.0" } animalsniffer = { id = "ru.vyarus.animalsniffer", version = "2.0.1" } -sentry = { id = "io.sentry.android.gradle", version = "6.6.0"} +sentry = { id = "io.sentry.android.gradle", version.ref = "sagp"} shadow = { id = "com.gradleup.shadow", version = "9.4.1" } [libraries] diff --git a/sentry-samples/sentry-samples-android/README.md b/sentry-samples/sentry-samples-android/README.md index c7c7ad0d89c..f5c8caf8685 100644 --- a/sentry-samples/sentry-samples-android/README.md +++ b/sentry-samples/sentry-samples-android/README.md @@ -16,10 +16,10 @@ or simply open the project in Android Studio and run the `sentry-samples-android You can also apply the [Sentry Android Gradle Plugin](https://github.com/getsentry/sentry-android-gradle-plugin) (SAGP) when building (not applied by default): ``` -./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp=true +./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp ``` -In Android Studio, add `useSagp=true` to `gradle.properties` or pass it as a Gradle project property. +In Android Studio, add `useSagp=` (empty value) to `gradle.properties`, or pass `-PuseSagp` as a Gradle project property. ## Build modes @@ -27,18 +27,33 @@ In Android Studio, add `useSagp=true` to `gradle.properties` or pass it as a Gra The sample app can be built with or without the SAGP. -| Gradle Property | Required | Purpose | -|-----------------|--------------------------|----------------------------------------------------------------------------------------------------------------| -| `useSagp` | No (defaults to `false`) | When `true`, apply SAGP when building the sample app. When false or absent, build the sample app without SAGP. | +| Gradle Property | Required | Purpose | +|-----------------|----------|-------------------------------------------------------------------------------------------------| +| `useSagp` | No | When present, apply SAGP when building the sample app. Omit the property to build without SAGP. | You can configure SAGP properties via the lambda passed to `extensions.configure("sentry")` in the sample app's `build.gradle.kts` file. -### Builds against your local sentry-java branch +### Testing an unpublished SAGP build -Regardless of `useSagp`, the sample always depends on sentry-java modules from this monorepo (e.g., `projects.sentryAndroid`). SAGP's SDK -auto-installation is disabled, so the sample never pulls a separate SDK version from Maven. Local SDK changes in your branch are picked up -directly. +`-PuseSagp` builds check `mavenLocal()` first when resolving SAGP. To test a local SAGP branch: + +1. In your `sentry-android-gradle-plugin` checkout, temporarily set a unique local version in `plugin-build/gradle.properties` (e.g. + `6.10.0-LOCAL`) and publish to Maven Local: + +``` +./gradlew -p plugin-build publishToMavenLocal +``` + +Re-run `publishToMavenLocal` after each SAGP change. + +2. Temporarily bump the `sagp` pin in `gradle/libs.versions.toml` to match that version. + +Then build from sentry-java: + +``` +./gradlew :sentry-samples:sentry-samples-android:installDebug -PuseSagp +``` ## Viewing SDK output diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 44d930975ec..e19c02700fb 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -12,10 +12,7 @@ plugins { alias(libs.plugins.sentry) apply false } -val useSagp = - providers.gradleProperty("useSagp").map { it.equals("true", ignoreCase = true) }.orElse(false) - -if (useSagp.get()) { +if (providers.gradleProperty("useSagp").isPresent) { apply(plugin = "io.sentry.android.gradle") } diff --git a/settings.gradle.kts b/settings.gradle.kts index c435c382b79..51dd84abb4f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,10 +1,19 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") pluginManagement { - repositories { - mavenCentral() - gradlePluginPortal() + repositories { + // Prefer local SAGP artifact if one exists; otherwise fall back to libs.versions.toml. + if (providers.gradleProperty("useSagp").isPresent) { + mavenLocal { + content { + includeGroup("io.sentry") + includeGroup("io.sentry.android.gradle") + } + } } + mavenCentral() + gradlePluginPortal() + } } plugins { From 3eb7173bf3fbd040000b90276e47f41aed8b05db Mon Sep 17 00:00:00 2001 From: arb Date: Tue, 16 Jun 2026 08:09:41 +0200 Subject: [PATCH 006/195] feat(android-sqlite): Add SentrySQLiteDriver (JAVA-275) (#5466) feat(android-sqlite): Add SentrySQLiteDriver (JAVA-275) Introduces support for AndroidX's SQLiteDriver via a new SentrySQLiteDriver wrapper. SentrySQLiteDriver automatically creates spans for each SQL statement it executes. Its data scheme closely tracks that of SentrySupportSQLiteOpenHelper, which it's designed to replace. (Span duration is an important exception; see the SentrySQLiteStatement KDoc for more details.) A key motivation behind Google's use of SQLiteDriver with Room 2.7+ was Kotlin Multiplatform support. We're careful to keep the SentrySQLiteDriver KMP-compatible as well, should we one day want to lift it into sentry-kotlin-multiplatform. --- Co-authored-by: Angus Holder <7407345+angusholder@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- sentry-android-sqlite/README.md | 21 ++ .../android/sqlite/SQLiteSpanManager.kt | 43 +-- .../main/java/io/sentry/sqlite/DbMetadata.kt | 49 +++ .../sqlite/SQLiteSpanInstrumentation.kt | 99 ++++++ .../sentry/sqlite/SentrySQLiteConnection.kt | 15 + .../io/sentry/sqlite/SentrySQLiteDriver.kt | 79 +++++ .../io/sentry/sqlite/SentrySQLiteStatement.kt | 80 +++++ .../src/test/AndroidManifest.xml | 13 + .../java/io/sentry/sqlite/DbMetadataTest.kt | 87 ++++++ .../sqlite/SQLiteSpanInstrumentationTest.kt | 193 ++++++++++++ .../sqlite/SentrySQLiteConnectionTest.kt | 63 ++++ .../sentry/sqlite/SentrySQLiteDriverTest.kt | 145 +++++++++ .../sqlite/SentrySQLiteStatementTest.kt | 291 ++++++++++++++++++ 14 files changed, 1144 insertions(+), 36 deletions(-) create mode 100644 sentry-android-sqlite/README.md create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt create mode 100644 sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt create mode 100644 sentry-android-sqlite/src/test/AndroidManifest.xml create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e7fcababd6c..1ebcb8e0e38 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -95,7 +95,7 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } -androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.5.2" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.6.2" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } diff --git a/sentry-android-sqlite/README.md b/sentry-android-sqlite/README.md new file mode 100644 index 00000000000..7bf9edf3474 --- /dev/null +++ b/sentry-android-sqlite/README.md @@ -0,0 +1,21 @@ +# sentry-android-sqlite + +SQLite instrumentation for AndroidX APIs. + +Two instrumentation paths are supported: + +- **`androidx.sqlite.SQLiteDriver`**: Used by Room 2.7+ and 3.0+. +- **`androidx.sqlite.db.SupportSQLiteOpenHelper`**: Used by SQLDelight and legacy (pre-2.7) Room. Applied automatically by the Sentry Android Gradle Plugin. + +To avoid duplicate spans, only one path should be used per database file. Most Room and SQLDelight APIs enforce that division. The exception is Room's `SupportSQLiteDriver`: either the `SupportSQLiteOpenHelper` it consumes should be wrapped or the support driver itself, but never both. + +## Package layout + +The module is organized as two separate packages: + +- **`io.sentry.android.sqlite`**: Android-specific code. Depends on `android.database.*` and/or on `androidx.sqlite.db.*`. +- **`io.sentry.sqlite`**: No Android-specific code. Depends only on multiplatform `androidx.sqlite.*`. + +The split anticipates future Kotlin Multiplatform support. The `androidx.sqlite.*` interfaces are defined in KMP's `commonMain` source set and are used by Room in non-JVM environments. Classes in `io.sentry.sqlite` are written against those portable interfaces and are intended to lift cleanly into a KMP `commonMain` source set if/when the `sentry` core gains multiplatform targets. + +Note that the module artifact itself (`sentry-android-sqlite`) is currently an Android-only AAR regardless of package layout. diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt index 1bdeb7d369c..3495d3a71f0 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt @@ -3,21 +3,17 @@ package io.sentry.android.sqlite import android.database.CrossProcessCursor import android.database.SQLException import io.sentry.IScopes -import io.sentry.ISpan -import io.sentry.Instrumenter import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage -import io.sentry.SentryStackTraceFactory -import io.sentry.SpanDataConvention import io.sentry.SpanStatus - -private const val TRACE_ORIGIN = "auto.db.sqlite" +import io.sentry.sqlite.SQLiteSpanInstrumentation internal class SQLiteSpanManager( private val scopes: IScopes = ScopesAdapter.getInstance(), - private val databaseName: String? = null, + databaseName: String? = null, ) { - private val stackTraceFactory = SentryStackTraceFactory(scopes.options) + + private val spans = SQLiteSpanInstrumentation.fromDatabaseName(databaseName, scopes) init { SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite") @@ -33,8 +29,8 @@ internal class SQLiteSpanManager( @Suppress("TooGenericExceptionCaught", "UNCHECKED_CAST") @Throws(SQLException::class) fun performSql(sql: String, operation: () -> T): T { - val startTimestamp = scopes.getOptions().dateProvider.now() - var span: ISpan? = null + val startTimestamp = spans.startTimestamp() + return try { val result = operation() /* @@ -45,34 +41,11 @@ internal class SQLiteSpanManager( if (result is CrossProcessCursor) { return SentryCrossProcessCursor(result, this, sql) as T } - span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) - span?.spanContext?.origin = TRACE_ORIGIN - span?.status = SpanStatus.OK + spans.recordSpan(sql, startTimestamp, SpanStatus.OK) result } catch (e: Throwable) { - span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) - span?.spanContext?.origin = TRACE_ORIGIN - span?.status = SpanStatus.INTERNAL_ERROR - span?.throwable = e + spans.recordSpan(sql, startTimestamp, SpanStatus.INTERNAL_ERROR, e) throw e - } finally { - span?.apply { - val isMainThread: Boolean = scopes.options.threadChecker.isMainThread - setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread) - if (isMainThread) { - setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack) - } - // if db name is null, then it's an in-memory database as per - // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:sqlite/sqlite/src/main/java/androidx/sqlite/db/SupportSQLiteOpenHelper.kt;l=38-42 - if (databaseName != null) { - setData(SpanDataConvention.DB_SYSTEM_KEY, "sqlite") - setData(SpanDataConvention.DB_NAME_KEY, databaseName) - } else { - setData(SpanDataConvention.DB_SYSTEM_KEY, "in-memory") - } - - finish() - } } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt new file mode 100644 index 00000000000..aa3c186b6d9 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt @@ -0,0 +1,49 @@ +package io.sentry.sqlite + +/** [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] value for in-memory databases. */ +internal const val DB_SYSTEM_IN_MEMORY = "in-memory" + +/** [DB_SYSTEM_KEY][io.sentry.SpanDataConvention.DB_SYSTEM_KEY] value for SQLite databases. */ +internal const val DB_SYSTEM_SQLITE = "sqlite" + +/** + * Sentinel file name that [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open] interprets as an + * in-memory database (see docs + * [here](https://developer.android.com/reference/androidx/sqlite/driver/AndroidSQLiteDriver)). + */ +private const val IN_MEMORY_DB_FILENAME = ":memory:" + +/** Path separators matching [File.separatorChar][java.io.File.separatorChar]. */ +private val FILE_NAME_PATH_SEPARATORS = charArrayOf('/', '\\') + +internal data class DbMetadata(val name: String?, val system: String) + +/** + * Returns metadata based on the [fileName] argument passed to + * [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open]. + */ +internal fun dbMetadataFromFileName(fileName: String): DbMetadata { + if (fileName == IN_MEMORY_DB_FILENAME) { + return DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY) + } + + val trimmed = fileName.trimEnd { it in FILE_NAME_PATH_SEPARATORS } + if (trimmed.isEmpty()) { + return DbMetadata(name = null, system = DB_SYSTEM_SQLITE) + } + + val index = trimmed.lastIndexOfAny(FILE_NAME_PATH_SEPARATORS) + val basename = if (index >= 0) trimmed.substring(index + 1) else trimmed + return DbMetadata(name = basename.ifEmpty { null }, system = DB_SYSTEM_SQLITE) +} + +/** + * Returns metadata based on + * [SupportSQLiteOpenHelper.databaseName][androidx.sqlite.db.SupportSQLiteOpenHelper.databaseName]. + */ +internal fun dbMetadataFromDatabaseName(databaseName: String?): DbMetadata = + if (databaseName == null) { + DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY) + } else { + DbMetadata(name = databaseName, system = DB_SYSTEM_SQLITE) + } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt new file mode 100644 index 00000000000..4c925198bd5 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt @@ -0,0 +1,99 @@ +package io.sentry.sqlite + +import io.sentry.IScopes +import io.sentry.Instrumenter +import io.sentry.ScopesAdapter +import io.sentry.SentryDate +import io.sentry.SentryLongDate +import io.sentry.SentryStackTraceFactory +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus + +private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" + +/** Shared span instrumentation for SQLite. */ +internal class SQLiteSpanInstrumentation( + private val scopes: IScopes, + private val dbMetadata: DbMetadata, +) { + + private val stackTraceFactory = SentryStackTraceFactory(scopes.options) + + /** + * Returns a start timestamp for a `db.sql.query` span. + * + * Exposed so callers can capture a wall-clock start before accumulating database time. + * Internalizing the start time in [recordSpan] would shift spans to end-of-work on the trace + * timeline, which is less desirable. + */ + fun startTimestamp(): SentryDate = scopes.options.dateProvider.now() + + /** Records a `db.sql.query` span from [startTimestamp] to the moment of invocation. */ + fun recordSpan( + sql: String, + startTimestamp: SentryDate, + status: SpanStatus, + throwable: Throwable? = null, + ) { + recordSpan(sql, startTimestamp, endTimestamp = null, status, throwable) + } + + /** Records a `db.sql.query` span from [startTimestamp] to [startTimestamp] + [durationNanos]. */ + fun recordSpan( + sql: String, + startTimestamp: SentryDate, + durationNanos: Long, + status: SpanStatus, + throwable: Throwable? = null, + ) { + val endTimestamp = SentryLongDate(startTimestamp.nanoTimestamp() + durationNanos) + recordSpan(sql, startTimestamp, endTimestamp, status, throwable) + } + + private fun recordSpan( + sql: String, + startTimestamp: SentryDate, + endTimestamp: SentryDate?, + status: SpanStatus, + throwable: Throwable?, + ) { + scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)?.apply { + spanContext.origin = SQLITE_TRACE_ORIGIN + throwable?.let { this.throwable = it } + + val isMainThread = scopes.options.threadChecker.isMainThread + setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread) + + if (isMainThread) { + setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack) + } + + dbMetadata.name?.let { setData(SpanDataConvention.DB_NAME_KEY, it) } + setData(SpanDataConvention.DB_SYSTEM_KEY, dbMetadata.system) + finish(status, endTimestamp) + } + } + + companion object { + + /** + * Returns [SQLiteSpanInstrumentation] based on the [fileName] argument passed to + * [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open]. + */ + fun fromFileName( + fileName: String, + scopes: IScopes = ScopesAdapter.getInstance(), + ): SQLiteSpanInstrumentation = + SQLiteSpanInstrumentation(scopes, dbMetadataFromFileName(fileName)) + + /** + * Returns [SQLiteSpanInstrumentation] based on + * [SupportSQLiteOpenHelper.databaseName][androidx.sqlite.db.SupportSQLiteOpenHelper.databaseName]. + */ + fun fromDatabaseName( + databaseName: String?, + scopes: IScopes = ScopesAdapter.getInstance(), + ): SQLiteSpanInstrumentation = + SQLiteSpanInstrumentation(scopes, dbMetadataFromDatabaseName(databaseName)) + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt new file mode 100644 index 00000000000..45ee9a39b27 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt @@ -0,0 +1,15 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement + +internal class SentrySQLiteConnection( + private val delegate: SQLiteConnection, + private val spans: SQLiteSpanInstrumentation, +) : SQLiteConnection by delegate { + + override fun prepare(sql: String): SQLiteStatement { + val statement = delegate.prepare(sql) + return statement as? SentrySQLiteStatement ?: SentrySQLiteStatement(statement, spans, sql) + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt new file mode 100644 index 00000000000..9a619c418a5 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -0,0 +1,79 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import io.sentry.ScopesAdapter +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel + +/** + * Wraps a [SQLiteDriver] and automatically adds spans for each SQL statement it executes. + * + * Example usage: + * ``` + * val driver = SentrySQLiteDriver.create(AndroidSQLiteDriver()) + * ``` + * + * If you use Room: + * ``` + * val database = Room.databaseBuilder(context, MyDatabase::class.java, "dbName") + * .setDriver(SentrySQLiteDriver.create(AndroidSQLiteDriver())) + * .build() + * ``` + * + * **Warning:** Do not use [SentrySQLiteDriver] together with + * [SentrySupportSQLiteOpenHelper][io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper] on the + * same database file. Both wrappers instrument at different layers and combining them will produce + * duplicate spans. + * + * @param delegate The [SQLiteDriver] instance to delegate calls to. + */ +internal class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) : + SQLiteDriver { + + init { + SentryIntegrationPackageStorage.getInstance().addIntegration("SQLiteDriver") + } + + override val hasConnectionPool: Boolean + get() = + try { + delegate.hasConnectionPool + } catch (_: LinkageError) { + // Delegates on androidx.sqlite < 2.6.0 won't have a hasConnectionPool property. + false + } + + @Suppress("TooGenericExceptionCaught") + override fun open(fileName: String): SQLiteConnection { + val connection = delegate.open(fileName) + + return try { + val spans = SQLiteSpanInstrumentation.fromFileName(fileName) + // create() ensures delegate is unwrapped, so we don't need to protect against double-wrapping + // the connection. + SentrySQLiteConnection(connection, spans) + } catch (t: Throwable) { + ScopesAdapter.getInstance() + .options + .logger + .log( + SentryLevel.ERROR, + "Failed to instrument SQLite connection; returning uninstrumented connection.", + t, + ) + connection + } + } + + companion object { + + /** + * Wraps the provided delegate in a [SentrySQLiteDriver]. Returns the delegate as-is if already + * wrapped. + */ + @JvmStatic + fun create(delegate: SQLiteDriver): SQLiteDriver = + delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate) + } +} diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt new file mode 100644 index 00000000000..41df37444b5 --- /dev/null +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt @@ -0,0 +1,80 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteStatement +import io.sentry.SentryDate +import io.sentry.SpanStatus + +/** + * Wraps a [SQLiteStatement] and records a single Sentry span covering all [step] calls for the + * statement's lifetime (until [step] iteration is complete or the statement is [reset] or + * [closed][close]). + * + * Span duration is restricted to accumulated database time, i.e., each [step] call is individually + * timed and the durations are summed. Time the application spends between steps (e.g., processing + * rows, sleeping, or doing I/O) is intentionally excluded. + * + * Not thread-safe: assumes sequential access within each SQL statement (normal SQLite usage). + */ +internal class SentrySQLiteStatement( + private val delegate: SQLiteStatement, + private val spans: SQLiteSpanInstrumentation, + private val sql: String, + private val nanoTimeProvider: () -> Long = { System.nanoTime() }, +) : SQLiteStatement by delegate { + + private var firstStepTimestamp: SentryDate? = null + private var accumulatedDbNanos: Long = 0L + private var stepsComplete = false + private var closed = false + + @Suppress("TooGenericExceptionCaught") + override fun step(): Boolean { + if (stepsComplete || closed) { + return delegate.step() + } + + val beforeNanos = nanoTimeProvider() + return try { + if (firstStepTimestamp == null) { + firstStepTimestamp = spans.startTimestamp() + } + + stepsComplete = !delegate.step() + accumulatedDbNanos += nanoTimeProvider() - beforeNanos + if (stepsComplete) { + recordSpan(SpanStatus.OK) + } + !stepsComplete + } catch (e: Throwable) { + accumulatedDbNanos += nanoTimeProvider() - beforeNanos + recordSpan(SpanStatus.INTERNAL_ERROR, e) + throw e + } + } + + override fun reset() { + if (closed) { + return delegate.reset() + } + + try { + recordSpan(SpanStatus.OK) + } finally { + delegate.reset() + stepsComplete = false + } + } + + override fun close() { + closed = true + delegate.use { recordSpan(SpanStatus.OK) } + } + + private fun recordSpan(status: SpanStatus, throwable: Throwable? = null) { + val start = firstStepTimestamp ?: return + val duration = accumulatedDbNanos + firstStepTimestamp = null + accumulatedDbNanos = 0L + spans.recordSpan(sql, start, duration, status, throwable) + } +} diff --git a/sentry-android-sqlite/src/test/AndroidManifest.xml b/sentry-android-sqlite/src/test/AndroidManifest.xml new file mode 100644 index 00000000000..967265a1f16 --- /dev/null +++ b/sentry-android-sqlite/src/test/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt new file mode 100644 index 00000000000..227b9d9558c --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt @@ -0,0 +1,87 @@ +package io.sentry.sqlite + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DbMetadataTest { + + @Test + fun `dbMetadataFromFileName returns in-memory system with no db name for in-memory sentinel`() { + assertEquals( + DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY), + dbMetadataFromFileName(":memory:"), + ) + } + + @Test + fun `dbMetadataFromDatabaseName returns in-memory system with no db name when databaseName is null`() { + assertEquals( + DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY), + dbMetadataFromDatabaseName(null), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for unix path`() { + assertEquals( + DbMetadata(name = "tracks.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data/data/com.example/databases/tracks.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name when fileName has no separator`() { + assertEquals( + DbMetadata(name = "tracks", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("tracks"), + ) + assertEquals( + DbMetadata(name = "tracks.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("tracks.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for relative path with forward slashes`() { + assertEquals( + DbMetadata(name = "myapp.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("databases/myapp.db"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name for windows-style path`() { + assertEquals( + DbMetadata(name = "myapp.db", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("C:\\Users\\app\\databases\\myapp.db"), + ) + } + + @Test + fun `dbMetadataFromFileName uses last separator when both slash types are present`() { + assertEquals( + DbMetadata(name = "db.sqlite", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data\\mixed/path\\db.sqlite"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and db name when fileName ends with separator`() { + assertEquals( + DbMetadata(name = "databases", system = DB_SYSTEM_SQLITE), + dbMetadataFromFileName("/data/data/com.example/databases/"), + ) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and unknown db name when fileName contains only separators`() { + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("/")) + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("///")) + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("\\\\")) + } + + @Test + fun `dbMetadataFromFileName returns sqlite system and unknown db name for empty fileName`() { + assertEquals(DbMetadata(name = null, system = DB_SYSTEM_SQLITE), dbMetadataFromFileName("")) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt new file mode 100644 index 00000000000..ead123a190b --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt @@ -0,0 +1,193 @@ +package io.sentry.sqlite + +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.util.thread.IThreadChecker +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class SQLiteSpanInstrumentationTest { + + private class Fixture { + + val scopes = mock() + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + + fun getSut( + isTransactionActive: Boolean = true, + fileName: String = ":memory:", + ): SQLiteSpanInstrumentation { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) + if (isTransactionActive) { + whenever(scopes.span).thenReturn(sentryTracer) + } + return SQLiteSpanInstrumentation.fromFileName(fileName, scopes) + } + } + + private val fixture = Fixture() + + @Test + fun `recordSpan records a span if a transaction is active`() { + val sut = fixture.getSut(isTransactionActive = true) + sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + assertEquals(1, fixture.sentryTracer.children.size) + } + + @Test + fun `recordSpan does not record a span if no transaction is active`() { + val sut = fixture.getSut(isTransactionActive = false) + val start = sut.startTimestamp() + sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `recordSpan creates a span with correct properties`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + sut.recordSpan("SELECT * FROM users", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.firstOrNull() + assertNotNull(span) + assertEquals("db.sql.query", span.operation) + assertEquals("SELECT * FROM users", span.description) + assertEquals("auto.db.sqlite", span.spanContext.origin) + assertEquals(SpanStatus.OK, span.status) + assertTrue(span.isFinished) + } + + @Test + fun `recordSpan sets finishDate equal to startDate + durationNanos`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + val durationNanos = 42_000_000L + + sut.recordSpan("SELECT 1", start, durationNanos, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals(start, span.startDate) + assertEquals(span.startDate.nanoTimestamp() + durationNanos, span.finishDate!!.nanoTimestamp()) + } + + @Test + fun `recordSpan attaches throwable when provided`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + val exception = RuntimeException("disk I/O error") + + sut.recordSpan("INSERT INTO t VALUES(1)", start, 500_000, SpanStatus.INTERNAL_ERROR, exception) + + val span = fixture.sentryTracer.children.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + } + + @Test + fun `recordSpan sets db system and db name when fileName is not the in-memory sentinel`() { + val sut = fixture.getSut(fileName = "/data/data/com.example/databases/tracks.db") + val start = sut.startTimestamp() + sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `recordSpan sets db system only when fileName is the in-memory sentinel`() { + val sut = fixture.getSut(fileName = ":memory:") + val start = sut.startTimestamp() + sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("in-memory", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertNull(span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `recordSpan sets blocked_main_thread to true and attaches call stack on main thread`() { + val sut = fixture.getSut() + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(true) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("main") + + sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertTrue(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + assertNotNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) + } + + @Test + fun `recordSpan sets blocked_main_thread to false and does not attach a call stack on background thread`() { + val sut = fixture.getSut() + fixture.options.threadChecker = mock() + whenever(fixture.options.threadChecker.isMainThread).thenReturn(false) + whenever(fixture.options.threadChecker.currentThreadName).thenReturn("worker") + + sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertFalse(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) + assertNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) + } + + @Test + fun `recordSpan without a duration finishes the span at the time of invocation`() { + val sut = fixture.getSut() + val start = sut.startTimestamp() + + sut.recordSpan("SELECT 1", start, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertTrue(span.isFinished) + assertEquals(SpanStatus.OK, span.status) + // Unlike the duration overload, no synthetic end timestamp is supplied; the span finishes at + // "now", i.e. at or after its start. + assertTrue(span.finishDate!!.nanoTimestamp() >= start.nanoTimestamp()) + } + + @Test + fun `fromFileName sets db name from fileName`() { + val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(fixture.scopes.options).thenReturn(options) + fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) + + val sut = SQLiteSpanInstrumentation.fromFileName("tracks.db", fixture.scopes) + sut.recordSpan("SELECT 1", sut.startTimestamp(), SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + + @Test + fun `fromDatabaseName sets db name from databaseName`() { + val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(fixture.scopes.options).thenReturn(options) + fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) + whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) + + val sut = SQLiteSpanInstrumentation.fromDatabaseName("tracks.db", fixture.scopes) + sut.recordSpan("SELECT 1", sut.startTimestamp(), SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) + assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt new file mode 100644 index 00000000000..b405d054f03 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt @@ -0,0 +1,63 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteStatement +import io.sentry.IScopes +import io.sentry.SentryOptions +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertSame +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteConnectionTest { + + private class Fixture { + + val scopes = mock() + val mockConnection = mock() + val mockStatement = mock() + lateinit var options: SentryOptions + + fun getSut(): SentrySQLiteConnection { + options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + whenever(mockConnection.prepare("SELECT 1")).thenReturn(mockStatement) + val spans = SQLiteSpanInstrumentation.fromFileName("test.db", scopes) + return SentrySQLiteConnection(mockConnection, spans) + } + } + + private val fixture = Fixture() + + @Test + fun `prepare returns a SentrySQLiteStatement`() { + val sut = fixture.getSut() + val statement = sut.prepare("SELECT 1") + assertIs(statement) + } + + @Test + fun `prepare with already-wrapped statement returns same instance without re-wrapping`() { + val sut = fixture.getSut() + val spans = SQLiteSpanInstrumentation.fromFileName("test.db", fixture.scopes) + val alreadyInstrumented = SentrySQLiteStatement(fixture.mockStatement, spans, "SELECT 1") + whenever(fixture.mockConnection.prepare("SELECT 1")).thenReturn(alreadyInstrumented) + + val statement = sut.prepare("SELECT 1") + + assertSame(alreadyInstrumented, statement) + } + + @Test + fun `all calls are propagated to the delegate`() { + val sut = fixture.getSut() + + sut.prepare("SELECT 1") + verify(fixture.mockConnection).prepare("SELECT 1") + + sut.close() + verify(fixture.mockConnection).close() + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt new file mode 100644 index 00000000000..9b2345a975f --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt @@ -0,0 +1,145 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteStatement +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanDataConvention +import io.sentry.TransactionContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.junit.Before +import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteDriverTest { + + private class Fixture { + + val mockDriver = mock() + val mockConnection = mock() + + fun getSut(fileName: String): SentrySQLiteDriver { + whenever(mockDriver.open(fileName)).thenReturn(mockConnection) + return SentrySQLiteDriver.create(mockDriver) as SentrySQLiteDriver + } + } + + private val fixture = Fixture() + + @Before + fun setup() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + } + + @Test + fun `create registers SQLiteDriver integration`() { + assertFalse(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLiteDriver")) + SentrySQLiteDriver.create(fixture.mockDriver) + assertTrue(SentryIntegrationPackageStorage.getInstance().integrations.contains("SQLiteDriver")) + } + + @Test + fun `create with non-wrapped driver returns SentrySQLiteDriver`() { + val result = SentrySQLiteDriver.create(fixture.mockDriver) + assertIs(result) + } + + @Test + fun `create with already-wrapped driver returns same instance without re-wrapping`() { + val wrapped = SentrySQLiteDriver.create(fixture.mockDriver) + val doubleWrapped = SentrySQLiteDriver.create(wrapped) + assertSame(wrapped, doubleWrapped) + } + + @Test + fun `hasConnectionPool forwards delegate value when supported`() { + whenever(fixture.mockDriver.hasConnectionPool).thenReturn(true) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertTrue(sut.hasConnectionPool) + } + + @Test + fun `hasConnectionPool returns false when delegate throws LinkageError`() { + whenever(fixture.mockDriver.hasConnectionPool).thenThrow(AbstractMethodError()) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertFalse(sut.hasConnectionPool) + } + + @Test + fun `hasConnectionPool does not catch non-LinkageErrors`() { + whenever(fixture.mockDriver.hasConnectionPool).thenThrow(IllegalStateException()) + val sut = SentrySQLiteDriver.create(fixture.mockDriver) as SentrySQLiteDriver + assertFailsWith { sut.hasConnectionPool } + } + + @Test + fun `open returns SentrySQLiteConnection wrapping delegate if wrapping succeeds`() { + val driver = fixture.getSut("myapp.db") + val connection = driver.open("myapp.db") + assertIs(connection) + } + + @Test + fun `open returns the unwrapped delegate if wrapping fails`() { + val brokenScopes = mock() + val validOptions = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(brokenScopes.options) + .thenThrow(RuntimeException("Sentry options unavailable")) + .thenReturn(validOptions) + + Mockito.mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(brokenScopes) + + val driver = fixture.getSut("myapp.db") + val result = driver.open("myapp.db") + + assertSame(fixture.mockConnection, result) + verify(fixture.mockDriver).open("myapp.db") + } + } + + // Smoke test ensuring all layers are properly wired up. + @Test + fun `full stack produces a span with correct metadata`() { + val scopes = mock() + val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + whenever(scopes.options).thenReturn(options) + val tracer = SentryTracer(TransactionContext("name", "op"), scopes) + whenever(scopes.span).thenReturn(tracer) + + val mockStatement = mock() + whenever(fixture.mockConnection.prepare("SELECT * FROM users")).thenReturn(mockStatement) + whenever(mockStatement.step()).thenReturn(true, false) + + Mockito.mockStatic(Sentry::class.java).use { mockedSentry -> + mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + + val driver = fixture.getSut("/data/data/com.example/databases/myapp.db") + val connection = driver.open("/data/data/com.example/databases/myapp.db") + val statement = connection.prepare("SELECT * FROM users") + + assertIs(connection) + assertIs(statement) + + statement.step() + statement.step() + + val span = tracer.children.firstOrNull() + assertNotNull(span) + assertEquals("myapp.db", span.data[SpanDataConvention.DB_NAME_KEY]) + } + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt new file mode 100644 index 00000000000..6691910e358 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt @@ -0,0 +1,291 @@ +package io.sentry.sqlite + +import androidx.sqlite.SQLiteStatement +import io.sentry.SentryLongDate +import io.sentry.SpanStatus +import java.util.concurrent.atomic.AtomicLong +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySQLiteStatementTest { + + private class Fixture { + val mockStatement = mock() + val mockSpans = mock() + val startDate = SentryLongDate(1_000_000_000_000L) + val fakeClock = AtomicLong(0L) + + fun getSut(sql: String): SentrySQLiteStatement { + whenever(mockSpans.startTimestamp()).thenReturn(startDate) + return SentrySQLiteStatement(mockStatement, mockSpans, sql, fakeClock::getAndIncrement) + } + } + + private val fixture = Fixture() + + @Test + fun `step calls recordSpan once after iteration completes`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, true, false) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + sut.step() + verify(fixture.mockSpans) + .recordSpan( + eq("SELECT * FROM users"), + eq(fixture.startDate), + any(), + eq(SpanStatus.OK), + anyOrNull(), + ) + } + + @Test + fun `step that throws an exception calls recordSpan with INTERNAL_ERROR and exception`() { + val sut = fixture.getSut("BAD SQL") + val exception = RuntimeException("db error") + whenever(fixture.mockStatement.step()).thenThrow(exception) + + assertFailsWith { sut.step() } + + verify(fixture.mockSpans) + .recordSpan( + eq("BAD SQL"), + eq(fixture.startDate), + any(), + eq(SpanStatus.INTERNAL_ERROR), + eq(exception), + ) + } + + @Test + fun `step after exception calls recordSpan once new iteration cycle completes`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()) + .thenThrow(RuntimeException("first failure")) + .thenReturn(false) + + assertFailsWith { sut.step() } + verifyCalledRecordSpan(times = 1) + + sut.step() + verifyCalledRecordSpan(times = 2) + } + + @Test + fun `step after step iteration completes does not call recordSpan again`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()).thenReturn(true, false, false) + + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.step() + + verifyCalledRecordSpan(times = 1) + verify(fixture.mockStatement, times(3)).step() + } + + @Test + fun `reset calls recordSpan if step iteration is in progress`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + + sut.reset() + + verifyCalledRecordSpan() + } + + @Test + fun `reset does not call recordSpan if step iteration has not started`() { + val sut = fixture.getSut("SELECT 1") + sut.reset() + verifyNeverCalledRecordSpan() + } + + @Test + fun `reset does not call recordSpan if step iteration has completed`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, false) + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.reset() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `step after reset calls recordSpan when new iteration cycle completes`() { + val sut = fixture.getSut("SELECT 1") + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.reset() + sut.step() + + verifyCalledRecordSpan(times = 2) + } + + @Test + fun `close calls recordSpan if step iteration is in progress`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.step() + verifyNeverCalledRecordSpan() + + sut.close() + + verifyCalledRecordSpan() + } + + @Test + fun `close does not call recordSpan if step iteration has not started`() { + val sut = fixture.getSut("SELECT 1") + sut.close() + verifyNeverCalledRecordSpan() + } + + @Test + fun `close does not call recordSpan if step iteration has completed`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()).thenReturn(true, false) + sut.step() + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.close() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `step after close does not call recordSpan`() { + val sut = fixture.getSut("SELECT 1") + sut.step() + verifyCalledRecordSpan(times = 1) + + sut.close() + sut.step() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `reset after close does not call recordSpan`() { + val sut = fixture.getSut("SELECT 1") + whenever(fixture.mockStatement.step()).thenReturn(true) + sut.step() + sut.close() + verifyCalledRecordSpan(times = 1) + + sut.reset() + + verifyCalledRecordSpan(times = 1) + } + + @Test + fun `recorded duration captures step time but excludes time between steps`() { + val sut = fixture.getSut("SELECT * FROM users") + whenever(fixture.mockStatement.step()) + .thenAnswer { + fixture.fakeClock.addAndGet(10) + true + } + .thenAnswer { + fixture.fakeClock.addAndGet(20) + true + } + .thenAnswer { + fixture.fakeClock.addAndGet(30) + false + } + + sut.step() + // Simulate work done between steps. + fixture.fakeClock.addAndGet(1_000_000) + sut.step() + fixture.fakeClock.addAndGet(2_000_000) + sut.step() + + val durationCaptor = argumentCaptor() + verify(fixture.mockSpans).recordSpan(any(), any(), durationCaptor.capture(), any(), anyOrNull()) + // Each step contributes its internal time (10 + 20 + 30) plus one unit from + // fakeClock::getAndIncrement between before/after reads, so total is 63. + assertEquals(63L, durationCaptor.firstValue) + } + + @Test + fun `all calls are propagated to the delegate`() { + val sut = fixture.getSut("SELECT 1") + + sut.bindBlob(0, byteArrayOf()) + verify(fixture.mockStatement).bindBlob(0, byteArrayOf()) + + sut.bindDouble(0, 1.0) + verify(fixture.mockStatement).bindDouble(0, 1.0) + + sut.bindLong(0, 1L) + verify(fixture.mockStatement).bindLong(0, 1L) + + sut.bindText(0, "text") + verify(fixture.mockStatement).bindText(0, "text") + + sut.bindNull(0) + verify(fixture.mockStatement).bindNull(0) + + sut.getDouble(0) + verify(fixture.mockStatement).getDouble(0) + + sut.getLong(0) + verify(fixture.mockStatement).getLong(0) + + sut.getText(0) + verify(fixture.mockStatement).getText(0) + + sut.isNull(0) + verify(fixture.mockStatement).isNull(0) + + sut.getColumnCount() + verify(fixture.mockStatement).getColumnCount() + + sut.getColumnName(0) + verify(fixture.mockStatement).getColumnName(0) + + sut.step() + verify(fixture.mockStatement).step() + + sut.reset() + verify(fixture.mockStatement).reset() + + sut.clearBindings() + verify(fixture.mockStatement).clearBindings() + + sut.close() + verify(fixture.mockStatement).close() + } + + private fun verifyNeverCalledRecordSpan() { + verifyCalledRecordSpan(times = 0) + } + + private fun verifyCalledRecordSpan(times: Int = 1) { + verify(fixture.mockSpans, times(times)).recordSpan(any(), any(), any(), any(), anyOrNull()) + } +} From 773a0df13db7654c534b8775902021af234fa3d5 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 16 Jun 2026 09:31:06 +0200 Subject: [PATCH 007/195] ci: Remove Codecov and code coverage tooling (JAVA-560) (#5547) * ci: Remove Codecov and code coverage tooling (JAVA-560) Remove the Codecov service integration (codecov.yml, the README badge, and the upload steps across all CI workflows) along with the JaCoCo and Kover coverage tooling that only existed to feed it: the plugins, report and verification tasks across all modules, the version catalog entries, the Config.kt coverage threshold, and the createCoverageReports Makefile target. No SDK code or public API is affected. Co-Authored-By: Claude Opus 4.8 (1M context) * test(sentry): Restore java.lang open after jacoco removal (JAVA-560) SentryTest reflectively rewrites a class's name to fake the Android environment, which requires --add-opens java.base/java.lang=ALL-UNNAMED. The jacoco test agent was implicitly providing this open; now that jacoco is removed, declare it explicitly so the sentry unit tests keep passing. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/agp-matrix.yml | 7 ---- .github/workflows/build.yml | 9 +--- .github/workflows/integration-tests-ui.yml | 7 ---- .github/workflows/spring-boot-2-matrix.yml | 7 ---- .github/workflows/spring-boot-3-matrix.yml | 7 ---- .github/workflows/spring-boot-4-matrix.yml | 7 ---- AGENTS.md | 4 -- Makefile | 13 ++---- README.md | 1 - build.gradle.kts | 42 ------------------- buildSrc/src/main/java/Config.kt | 7 ---- codecov.yml | 23 ---------- gradle/libs.versions.toml | 3 -- sentry-android-core/build.gradle.kts | 2 - sentry-android-fragment/build.gradle.kts | 2 - sentry-android-navigation/build.gradle.kts | 2 - sentry-android-ndk/build.gradle.kts | 2 - sentry-android-replay/build.gradle.kts | 2 - sentry-android-sqlite/build.gradle.kts | 2 - sentry-android-timber/build.gradle.kts | 2 - sentry-apache-http-client-5/build.gradle.kts | 20 --------- sentry-apollo-3/build.gradle.kts | 21 +--------- sentry-apollo-4/build.gradle.kts | 21 +--------- sentry-apollo/build.gradle.kts | 21 +--------- sentry-async-profiler/build.gradle.kts | 20 --------- sentry-compose/build.gradle.kts | 1 - sentry-graphql-22/build.gradle.kts | 20 --------- sentry-graphql-core/build.gradle.kts | 20 --------- sentry-graphql/build.gradle.kts | 20 --------- sentry-jcache/build.gradle.kts | 20 --------- sentry-jdbc/build.gradle.kts | 20 --------- sentry-jul/build.gradle.kts | 17 -------- sentry-kafka/build.gradle.kts | 20 --------- sentry-kotlin-extensions/build.gradle.kts | 21 +--------- sentry-ktor-client/build.gradle.kts | 21 +--------- sentry-launchdarkly-android/build.gradle.kts | 2 - sentry-launchdarkly-server/build.gradle.kts | 20 --------- sentry-log4j2/build.gradle.kts | 20 --------- sentry-logback/build.gradle.kts | 20 --------- sentry-okhttp/build.gradle.kts | 21 +--------- sentry-openfeature/build.gradle.kts | 20 --------- sentry-openfeign/build.gradle.kts | 20 --------- .../build.gradle.kts | 20 --------- .../build.gradle.kts | 20 --------- .../build.gradle.kts | 20 --------- .../build.gradle.kts | 20 --------- sentry-quartz/build.gradle.kts | 20 --------- sentry-reactor/build.gradle.kts | 20 --------- sentry-servlet-jakarta/build.gradle.kts | 20 --------- sentry-servlet/build.gradle.kts | 20 --------- sentry-spotlight/build.gradle.kts | 21 +--------- sentry-spring-7/build.gradle.kts | 20 --------- sentry-spring-boot-4-starter/build.gradle.kts | 20 --------- sentry-spring-boot-4/build.gradle.kts | 20 --------- sentry-spring-boot-jakarta/build.gradle.kts | 20 --------- .../build.gradle.kts | 20 --------- sentry-spring-boot-starter/build.gradle.kts | 20 --------- sentry-spring-boot/build.gradle.kts | 20 --------- sentry-spring-jakarta/build.gradle.kts | 20 --------- sentry-spring/build.gradle.kts | 20 --------- sentry-system-test-support/build.gradle.kts | 1 - sentry-test-support/build.gradle.kts | 1 - sentry/build.gradle.kts | 28 ++++--------- 63 files changed, 20 insertions(+), 928 deletions(-) delete mode 100644 codecov.yml diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index aebcbf87d5e..cc9c153f252 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -112,10 +112,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: build/outputs/androidTest-results/**/*.xml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bb1f45dd60d..9a8a7e6138d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,7 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Run Tests with coverage and Lint + - name: Run Tests and Lint run: make preMerge - name: Install Sentry CLI @@ -57,13 +57,6 @@ jobs: SENTRY_ORG: sentry-sdks SENTRY_PROJECT: sentry-android - - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # pin@v4 - with: - name: sentry-java - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} - - name: Upload test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 4af564cd2c3..102951a6d40 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -94,10 +94,3 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: sentry-sdks SENTRY_PROJECT: sentry-android - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: ./artifacts/*.xml diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 32eeef2442d..b9eb217d578 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -150,10 +150,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 8614e2ca69d..82f379c141c 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -146,10 +146,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: '**/build/test-results/**/*.xml' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index e82b120ec24..d2ec6c096bf 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -146,10 +146,3 @@ jobs: reporter: java-junit output-to: step-summary fail-on-error: false - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: '**/build/test-results/**/*.xml' diff --git a/AGENTS.md b/AGENTS.md index 8d0cccabbc7..a05d9386607 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,9 +37,6 @@ The project uses **Gradle** with Kotlin DSL. Key build files: # Build entire project ./gradlew build -# Create coverage reports -./gradlew jacocoTestReport koverXmlReportRelease - # Generate documentation ./gradlew aggregateJavadocs ``` @@ -149,7 +146,6 @@ The repository is organized into multiple modules: - Write comprehensive unit tests for new features - Android modules require both unit tests and instrumented tests where applicable - System tests validate end-to-end functionality with sample applications -- Coverage reports are generated for both JaCoCo (Java/Android) and Kover (KMP modules) ### Contributing Guidelines 1. Follow existing code style and language diff --git a/Makefile b/Makefile index c9eca8b8b7e..3967ff856ad 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ -.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleBenchmarkTestRelease assembleUiTestRelease assembleUiTestCriticalRelease createCoverageReports runUiTestCritical setupPython systemTest systemTestInteractive check preMerge publish +.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleBenchmarkTestRelease assembleUiTestRelease assembleUiTestCriticalRelease runUiTestCritical setupPython systemTest systemTestInteractive check preMerge publish -all: stop clean javadocs compile createCoverageReports +all: stop clean javadocs compile assembleBenchmarks: assembleBenchmarkTestRelease assembleUiTests: assembleUiTestRelease -preMerge: check createCoverageReports +preMerge: check publish: clean dryRelease # deep clean @@ -51,13 +51,6 @@ assembleUiTestCriticalRelease: runUiTestCritical: ./scripts/test-ui-critical.sh -# Create coverage reports -# - Jacoco for Java & Android modules -# - Kover for KMP modules e.g sentry-compose -createCoverageReports: - ./gradlew jacocoTestReport - ./gradlew koverXmlReportRelease - # Create the Python virtual environment for system tests, and install the necessary dependencies setupPython: @test -d .venv || python3 -m venv .venv diff --git a/README.md b/README.md index 9aaf7aca4d8..0aab8a4e75d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ _Bad software is everywhere, and we're tired of it. Sentry is on a mission to he Sentry SDK for Java and Android =========== [![GH Workflow](https://img.shields.io/github/actions/workflow/status/getsentry/sentry-java/build.yml?branch=main)](https://github.com/getsentry/sentry-java/actions) -[![codecov](https://codecov.io/gh/getsentry/sentry-java/branch/main/graph/badge.svg)](https://codecov.io/gh/getsentry/sentry-java) [![X Follow](https://img.shields.io/twitter/follow/sentry?label=sentry&style=social)](https://x.com/intent/follow?screen_name=sentry) [![Discord Chat](https://img.shields.io/discord/621778831602221064?logo=discord&logoColor=ffffff&color=7389D8)](https://discord.gg/PXa5Apfe7K) diff --git a/build.gradle.kts b/build.gradle.kts index d5b5dfc5d05..93c82cd8c9a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,19 +3,15 @@ import com.vanniktech.maven.publish.JavadocJar import com.vanniktech.maven.publish.MavenPublishBaseExtension import groovy.util.Node import io.gitlab.arturbosch.detekt.extensions.DetektExtension -import kotlinx.kover.gradle.plugin.dsl.KoverReportExtension import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { `java-library` alias(libs.plugins.spotless) apply false - jacoco alias(libs.plugins.detekt) `maven-publish` alias(libs.plugins.binary.compatibility.validator) - alias(libs.plugins.jacoco.android) apply false - alias(libs.plugins.kover) apply false alias(libs.plugins.vanniktech.maven.publish) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.multiplatform) apply false @@ -121,44 +117,6 @@ allprojects { subprojects { apply { plugin("io.sentry.spotless") } - val jacocoAndroidModules = listOf( - "sentry-android-core", - "sentry-android-fragment", - "sentry-android-navigation", - "sentry-android-ndk", - "sentry-android-sqlite", - "sentry-android-replay", - "sentry-android-timber" - ) - if (jacocoAndroidModules.contains(name)) { - afterEvaluate { - jacoco { - toolVersion = "0.8.10" - } - - tasks.withType().configureEach { - configure { - isIncludeNoLocationClasses = true - excludes = listOf("jdk.internal.*") - } - } - } - } - - val koverKmpModules = listOf("sentry-compose") - if (koverKmpModules.contains(name)) { - afterEvaluate { - configure { - androidReports("release") { - xml { - // Change the report file name so the Codecov Github action can find it - setReportFile(project.layout.buildDirectory.file("reports/kover/report.xml").get().asFile) - } - } - } - } - } - plugins.withId(Config.QualityPlugins.detektPlugin) { configure { buildUponDefaultConfig = true diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 3410d9601d3..f0e2e9baf86 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -1,6 +1,4 @@ -import java.math.BigDecimal - object Config { val AGP = System.getenv("VERSION_AGP") ?: "8.13.1" val kotlinStdLib = "stdlib-jdk8" @@ -37,11 +35,6 @@ object Config { } object QualityPlugins { - object Jacoco { - // TODO [POTEL] add tests and restore - val minimumCoverage = BigDecimal.valueOf(0.1) - } - // this can be removed when we upgrade to Gradle 8, which allows us to use a getter for the plugin ID val detektPlugin = "io.gitlab.arturbosch.detekt" } diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 3a53b1f7b3f..00000000000 --- a/codecov.yml +++ /dev/null @@ -1,23 +0,0 @@ -comment: no -codecov: - require_ci_to_pass: no - max_report_age: off - -coverage: - status: - project: - default: - target: 78% - threshold: 4% - patch: off - range: 78...100 - precision: 3 - round: down - -ignore: - - "**/src/test/*" - - "sentry-android-integration-tests/*" - - "sentry-system-test-support/*" - - "sentry-test-support/*" - - "sentry-samples/*" - - "sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/**" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ebcb8e0e38..a305275a118 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,6 @@ composeCompiler = "1.5.14" coroutines = "1.6.1" espresso = "3.7.0" feign = "11.6" -jacoco = "0.8.7" jackson = "2.18.3" jetbrainsCompose = "1.6.11" kotlin = "2.2.0" @@ -59,8 +58,6 @@ errorprone = { id = "net.ltgt.errorprone", version = "3.0.1" } gradle-versions = { id = "com.github.ben-manes.versions", version = "0.42.0" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.8" } -jacoco-android = { id = "com.mxalbert.gradle.jacoco-android", version = "0.2.0" } -kover = { id = "org.jetbrains.kotlinx.kover", version = "0.7.3" } vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.30.0" } springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" } springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index abcca4f8833..f7440b19494 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -5,8 +5,6 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } diff --git a/sentry-android-fragment/build.gradle.kts b/sentry-android-fragment/build.gradle.kts index 7a4178b0652..1bd182d618c 100644 --- a/sentry-android-fragment/build.gradle.kts +++ b/sentry-android-fragment/build.gradle.kts @@ -3,8 +3,6 @@ import io.gitlab.arturbosch.detekt.Detekt plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts index 7f5d1017ec3..eaa204b3860 100644 --- a/sentry-android-navigation/build.gradle.kts +++ b/sentry-android-navigation/build.gradle.kts @@ -3,8 +3,6 @@ import io.gitlab.arturbosch.detekt.Detekt plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index 413fd3a7b77..c2d0a33d823 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -3,8 +3,6 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) } diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 60d38c0ae0a..8d0f63797aa 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -5,8 +5,6 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) // TODO: enable it later // alias(libs.plugins.detekt) diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index 07fa7ad343f..dd28252665e 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -3,8 +3,6 @@ import io.gitlab.arturbosch.detekt.Detekt plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } diff --git a/sentry-android-timber/build.gradle.kts b/sentry-android-timber/build.gradle.kts index 16083b43f1b..d8f8431bef1 100644 --- a/sentry-android-timber/build.gradle.kts +++ b/sentry-android-timber/build.gradle.kts @@ -3,8 +3,6 @@ import io.gitlab.arturbosch.detekt.Detekt plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) } diff --git a/sentry-apache-http-client-5/build.gradle.kts b/sentry-apache-http-client-5/build.gradle.kts index 4c9aba6e31b..df93fbe8823 100644 --- a/sentry-apache-http-client-5/build.gradle.kts +++ b/sentry-apache-http-client-5/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -36,25 +35,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index 8819e0993d4..1eb71bc217a 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -45,25 +44,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { options.errorprone { diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index 85ea2c3b52b..144297ddb9d 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -52,25 +51,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { options.errorprone { diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index 909d52aa127..c115e6b8fe3 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -46,25 +45,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { options.errorprone { diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index 5af2f0bef45..ef000b465a1 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -4,7 +4,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` kotlin("jvm") - jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -39,25 +38,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 3385d0328e2..c45a431b1b3 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -7,7 +7,6 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.compose) id("com.android.library") - alias(libs.plugins.kover) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) alias(libs.plugins.dokka) diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts index a8256ca8a27..c36ca09856d 100644 --- a/sentry-graphql-22/build.gradle.kts +++ b/sentry-graphql-22/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -44,25 +43,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql-core/build.gradle.kts b/sentry-graphql-core/build.gradle.kts index cb8c9f49493..d625c31dea6 100644 --- a/sentry-graphql-core/build.gradle.kts +++ b/sentry-graphql-core/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -43,25 +42,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index 46bef6e4b9d..68efbc7389e 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -44,25 +43,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jcache/build.gradle.kts b/sentry-jcache/build.gradle.kts index a9393a7d905..2c476dbd007 100644 --- a/sentry-jcache/build.gradle.kts +++ b/sentry-jcache/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -39,25 +38,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jdbc/build.gradle.kts b/sentry-jdbc/build.gradle.kts index 0415fd8ccff..8a7808530b1 100644 --- a/sentry-jdbc/build.gradle.kts +++ b/sentry-jdbc/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -37,25 +36,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jul/build.gradle.kts b/sentry-jul/build.gradle.kts index 13bee6418d6..b59a1481d19 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -36,23 +35,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } test { // used to test io.sentry.jul.SentryHandler systemProperty( diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts index ee3ba0d4a60..603014f9af9 100644 --- a/sentry-kafka/build.gradle.kts +++ b/sentry-kafka/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -36,25 +35,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index 55aca007130..5092976de32 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) @@ -40,25 +39,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { // Target version of the generated JVM bytecode. It is used for type resolution. diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index 2965e81ebd3..745acaa11fb 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -4,7 +4,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` alias(libs.plugins.kotlin.jvm) - jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -47,25 +46,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } buildConfig { useJavaOutput() diff --git a/sentry-launchdarkly-android/build.gradle.kts b/sentry-launchdarkly-android/build.gradle.kts index bf59c256ed1..427ec473676 100644 --- a/sentry-launchdarkly-android/build.gradle.kts +++ b/sentry-launchdarkly-android/build.gradle.kts @@ -1,8 +1,6 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) - jacoco - alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) } diff --git a/sentry-launchdarkly-server/build.gradle.kts b/sentry-launchdarkly-server/build.gradle.kts index ee273fa5a9c..207400676a0 100644 --- a/sentry-launchdarkly-server/build.gradle.kts +++ b/sentry-launchdarkly-server/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -40,25 +39,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 68ebd90b1e8..7d406076e2f 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -38,25 +37,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.log4j2") diff --git a/sentry-logback/build.gradle.kts b/sentry-logback/build.gradle.kts index 385209e8c49..d2084e95467 100644 --- a/sentry-logback/build.gradle.kts +++ b/sentry-logback/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -35,25 +34,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.logback") diff --git a/sentry-okhttp/build.gradle.kts b/sentry-okhttp/build.gradle.kts index f7178cf1dfe..ea831f174cc 100644 --- a/sentry-okhttp/build.gradle.kts +++ b/sentry-okhttp/build.gradle.kts @@ -4,7 +4,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` alias(libs.plugins.kotlin.jvm) - jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -46,25 +45,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } buildConfig { useJavaOutput() diff --git a/sentry-openfeature/build.gradle.kts b/sentry-openfeature/build.gradle.kts index 632d16b55cf..5847f48e7b5 100644 --- a/sentry-openfeature/build.gradle.kts +++ b/sentry-openfeature/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -40,25 +39,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-openfeign/build.gradle.kts b/sentry-openfeign/build.gradle.kts index 40119987f72..e9e3a2b18de 100644 --- a/sentry-openfeign/build.gradle.kts +++ b/sentry-openfeign/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -37,25 +36,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts index b4a84300efd..ed6605f8da4 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -43,25 +42,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts index 64db4096bb9..503c92c95f0 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -38,25 +37,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts index 2ab3d4988d5..5b3b9d97ff4 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -48,25 +47,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index f039b3c95ef..21e75c0ed7d 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -44,25 +43,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-quartz/build.gradle.kts b/sentry-quartz/build.gradle.kts index f81254f110f..69c0e72ee07 100644 --- a/sentry-quartz/build.gradle.kts +++ b/sentry-quartz/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -38,25 +37,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-reactor/build.gradle.kts b/sentry-reactor/build.gradle.kts index 9e8b6e74be9..4d389b0a334 100644 --- a/sentry-reactor/build.gradle.kts +++ b/sentry-reactor/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -46,25 +45,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.reactor") diff --git a/sentry-servlet-jakarta/build.gradle.kts b/sentry-servlet-jakarta/build.gradle.kts index ec079b6d65f..728e147dc9b 100644 --- a/sentry-servlet-jakarta/build.gradle.kts +++ b/sentry-servlet-jakarta/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -38,25 +37,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-servlet/build.gradle.kts b/sentry-servlet/build.gradle.kts index ceaa160695a..142a1cd2f20 100644 --- a/sentry-servlet/build.gradle.kts +++ b/sentry-servlet/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -39,25 +38,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spotlight/build.gradle.kts b/sentry-spotlight/build.gradle.kts index dbab6237b12..b034c8267db 100644 --- a/sentry-spotlight/build.gradle.kts +++ b/sentry-spotlight/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.animalsniffer) @@ -38,25 +37,7 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } -} +tasks { check { dependsOn(animalsnifferMain) } } buildConfig { useJavaOutput() diff --git a/sentry-spring-7/build.gradle.kts b/sentry-spring-7/build.gradle.kts index ae8269e7825..ec90aedcbeb 100644 --- a/sentry-spring-7/build.gradle.kts +++ b/sentry-spring-7/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -85,25 +84,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.spring7") diff --git a/sentry-spring-boot-4-starter/build.gradle.kts b/sentry-spring-boot-4-starter/build.gradle.kts index 2c8eab0ba66..c0f655e965f 100644 --- a/sentry-spring-boot-4-starter/build.gradle.kts +++ b/sentry-spring-boot-4-starter/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.springboot4) apply false @@ -41,25 +40,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 3b0b3be8630..43e105ad8db 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -5,7 +5,6 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") - jacoco alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) alias(libs.plugins.errorprone) @@ -111,25 +110,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.spring.boot4") diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 36b7dad3cc6..edd2d605916 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -103,25 +102,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.spring.boot.jakarta") diff --git a/sentry-spring-boot-starter-jakarta/build.gradle.kts b/sentry-spring-boot-starter-jakarta/build.gradle.kts index 60ac812b013..d7d10b73b8c 100644 --- a/sentry-spring-boot-starter-jakarta/build.gradle.kts +++ b/sentry-spring-boot-starter-jakarta/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.springboot3) apply false @@ -41,25 +40,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spring-boot-starter/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts index 6b5bcdf5752..3ef4ac59379 100644 --- a/sentry-spring-boot-starter/build.gradle.kts +++ b/sentry-spring-boot-starter/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } @@ -33,25 +32,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index e54112ae54c..3ed6199fbc5 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -85,25 +84,6 @@ dependencies { testImplementation(projects.sentryAsyncProfiler) } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.spring.boot") diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index cbf2e5346b5..b4a61129df7 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -80,25 +79,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.spring.jakarta") diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index 64380f7e7f4..fced2220f02 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -6,7 +6,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -64,25 +63,6 @@ dependencies { testImplementation(libs.springboot.starter.webflux) } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - -tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - } -} - buildConfig { useJavaOutput() packageName("io.sentry.spring") diff --git a/sentry-system-test-support/build.gradle.kts b/sentry-system-test-support/build.gradle.kts index b8e4a283c87..4d4c7d5bb6e 100644 --- a/sentry-system-test-support/build.gradle.kts +++ b/sentry-system-test-support/build.gradle.kts @@ -2,7 +2,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) id("com.apollographql.apollo") version "4.1.1" diff --git a/sentry-test-support/build.gradle.kts b/sentry-test-support/build.gradle.kts index 29b2083a0a9..f108915d463 100644 --- a/sentry-test-support/build.gradle.kts +++ b/sentry-test-support/build.gradle.kts @@ -2,7 +2,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 4c237803a51..a2ecd281296 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -5,7 +5,6 @@ plugins { `java-library` id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) - jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) @@ -40,15 +39,6 @@ dependencies { configure { test { java.srcDir("src/test/java") } } -jacoco { toolVersion = libs.versions.jacoco.get() } - -tasks.jacocoTestReport { - reports { - xml.required.set(true) - html.required.set(false) - } -} - animalsniffer { ignore = listOf( @@ -63,16 +53,16 @@ tasks.animalsnifferMain { } tasks { - jacocoTestCoverageVerification { - violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } - } - check { - dependsOn(jacocoTestCoverageVerification) - dependsOn(jacocoTestReport) - dependsOn(animalsnifferMain) - } + check { dependsOn(animalsnifferMain) } test { - jvmArgs("--add-opens", "java.base/java.util.concurrent=ALL-UNNAMED") + // java.lang open is needed by tests that reflectively rewrite Class names; it was previously + // provided implicitly by the jacoco test agent, which has been removed. + jvmArgs( + "--add-opens", + "java.base/java.util.concurrent=ALL-UNNAMED", + "--add-opens", + "java.base/java.lang=ALL-UNNAMED", + ) environment["SENTRY_TEST_PROPERTY"] = "\"some-value\"" environment["SENTRY_TEST_MAP_KEY1"] = "\"value1\"" environment["SENTRY_TEST_MAP_KEY2"] = "value2" From f36e6e37ec1517fa6e3c06ab79abc6775e2ef1bf Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 16 Jun 2026 13:40:05 +0200 Subject: [PATCH 008/195] fix(android): Stop duplicating attachments on native events (JAVA-559) (#5548) * fix(android): Stop duplicating attachments on native events (JAVA-559) Scope attachments are synced to the native SDK, so native events already carry them as envelope items in the outbox. When re-ingesting those cached envelopes, SentryClient re-applied the scope attachments on top, sending each attachment twice. Skip re-applying scope attachments for cached envelopes. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../src/main/java/io/sentry/SentryClient.java | 4 +++- .../test/java/io/sentry/SentryClientTest.kt | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 648533ec18f..93cd3765da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### Fixes +- Fix attachments being duplicated on native events that carry scope attachments ([#5548](https://github.com/getsentry/sentry-java/pull/5548)) - Fix performance collector scheduling many tasks in a row ([#5524](https://github.com/getsentry/sentry-java/pull/5524)) ## 8.43.2 diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 5ac81c44936..78225f05d19 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -112,7 +112,9 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul hint = new Hint(); } - if (shouldApplyScopeData(event, hint)) { + // Cached envelopes (e.g. native crashes from the outbox) already carry their attachments as + // envelope items. Re-applying scope attachments here would duplicate them. + if (shouldApplyScopeData(event, hint) && !HintUtils.hasType(hint, Cached.class)) { addScopeAttachmentsToHint(scope, hint); } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index d5b2f0f82a0..ab6fd2075a3 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -870,6 +870,25 @@ class SentryClientTest { assertEquals(scope.level, event.level) } + @Test + fun `when hint is Cached, scope attachments are not added to avoid duplication`() { + val sut = fixture.getSut() + + val event = createEvent() + val scope = createScopeWithAttachments() + + val hints = HintUtils.createWithTypeCheckHint(CustomCachedApplyScopeDataHint()) + sut.captureEvent(event, scope, hints) + + verify(fixture.transport) + .send( + check { actual -> + assertEquals(0, actual.items.count { it.header.type == SentryItemType.Attachment }) + }, + anyOrNull(), + ) + } + @Test fun `when transport factory is NoOp, it should initialize it`() { fixture.sentryOptions.setTransportFactory(NoOpTransportFactory.getInstance()) From 6dff1c9970ad612ac431980c08abb138218465e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:52:26 +0000 Subject: [PATCH 009/195] chore: update scripts/update-sentry-native-ndk.sh to 0.15.0 (#5528) Co-authored-by: GitHub --- CHANGELOG.md | 3 +++ gradle/libs.versions.toml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93cd3765da8..71c8d990122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ ### Dependencies - Upgrade to asyncProfiler 4.4 ([#5418](https://github.com/getsentry/sentry-java/pull/5418)) +- Bump Native SDK from v0.14.2 to v0.15.0 ([#5528](https://github.com/getsentry/sentry-java/pull/5528)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0150) + - [diff](https://github.com/getsentry/sentry-native/compare/0.14.2...0.15.0) ### Fixes diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a305275a118..c16a87ad9b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -150,7 +150,7 @@ quartz = { module = "org.quartz-scheduler:quartz", version = "2.3.0" } reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } -sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.14.2" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.15.0" } servlet-api = { module = "javax.servlet:javax.servlet-api", version = "3.1.0" } servlet-jakarta-api = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.1.0" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } From e52b4e44a1eb195adcd0fbe2761d33e708ac5d49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:49:21 +0200 Subject: [PATCH 010/195] chore(deps): bump actions/setup-java in the github-actions group (#5554) Bumps the github-actions group with 1 update: [actions/setup-java](https://github.com/actions/setup-java). Updates `actions/setup-java` from 5.2.0 to 5.3.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/be666c2fcd27ec809703dec50e508c2fdc7f6654...ad2b38190b15e4d6bdf0c97fb4fca8412226d287) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 5.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/format-code.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-benchmarks.yml | 4 ++-- .github/workflows/integration-tests-size.yml | 2 +- .github/workflows/integration-tests-ui-critical.yml | 2 +- .github/workflows/integration-tests-ui.yml | 2 +- .github/workflows/release-build.yml | 2 +- .github/workflows/spring-boot-2-matrix.yml | 2 +- .github/workflows/spring-boot-3-matrix.yml | 2 +- .github/workflows/spring-boot-4-matrix.yml | 2 +- .github/workflows/system-tests-backend.yml | 2 +- 15 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index cc9c153f252..40f8509fee4 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -33,7 +33,7 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a8a7e6138d..375e94e7499 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 6aa197d6625..e24b7c96c14 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 01ee3db1584..e5e4530933b 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -14,7 +14,7 @@ jobs: uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 28cb78df4e3..ec427af3564 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -13,7 +13,7 @@ jobs: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index af0b44ddadd..2e82024077a 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -14,7 +14,7 @@ jobs: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 65cfcf242fc..4d323f0394a 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -32,7 +32,7 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' @@ -82,7 +82,7 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index 19598699165..e2fa42ddc16 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup Java Version - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: "temurin" java-version: "17" diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 8973148cadd..18809c060e1 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -30,7 +30,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Java 17 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 102951a6d40..f7b95a26d12 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -27,7 +27,7 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 16cfe4531a0..9fecaf32b5e 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -20,7 +20,7 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index b9eb217d578..7628a0bbba0 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -45,7 +45,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 82f379c141c..40670eaf258 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -45,7 +45,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index d2ec6c096bf..128051ed03e 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -45,7 +45,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index b1884cd4a7a..62a1b7665c0 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -112,7 +112,7 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: distribution: 'temurin' java-version: '17' From ba010111864967003758a5e4d750dfe04f995c18 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 17 Jun 2026 13:17:06 +0200 Subject: [PATCH 011/195] perf: Avoid boxing in doubleToBigDecimal timestamp serialization (#5551) * perf: Avoid boxing in doubleToBigDecimal timestamp serialization Change DateUtils.doubleToBigDecimal to take a primitive double instead of a boxed Double, and route the four duplicated private copies (ProfileChunk, ProfileMeasurementValue, SentrySample, SentrySpan) through it. Callers that hold a primitive double timestamp no longer autobox on every serialization, and the duplicated helpers are consolidated into one. Behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- sentry/api/sentry.api | 2 +- sentry/src/main/java/io/sentry/DateUtils.java | 2 +- sentry/src/main/java/io/sentry/ProfileChunk.java | 8 ++------ .../profilemeasurements/ProfileMeasurementValue.java | 8 ++------ sentry/src/main/java/io/sentry/protocol/SentrySpan.java | 8 ++------ .../java/io/sentry/protocol/profiling/SentrySample.java | 8 ++------ 7 files changed, 11 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c8d990122..5dbcde58f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### Improvements -- Reduce boxing to improve performance ([#5523](https://github.com/getsentry/sentry-java/pull/5523), [#5527](https://github.com/getsentry/sentry-java/pull/5527)) +- Reduce boxing to improve performance ([#5523](https://github.com/getsentry/sentry-java/pull/5523), [#5527](https://github.com/getsentry/sentry-java/pull/5527), [#5551](https://github.com/getsentry/sentry-java/pull/5551)) ### Dependencies diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 4757be4894a..22f9366f738 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -384,7 +384,7 @@ public final class io/sentry/DataCategory : java/lang/Enum { public final class io/sentry/DateUtils { public static fun dateToNanos (Ljava/util/Date;)J public static fun dateToSeconds (Ljava/util/Date;)D - public static fun doubleToBigDecimal (Ljava/lang/Double;)Ljava/math/BigDecimal; + public static fun doubleToBigDecimal (D)Ljava/math/BigDecimal; public static fun getCurrentDateTime ()Ljava/util/Date; public static fun getDateTime (J)Ljava/util/Date; public static fun getDateTime (Ljava/lang/String;)Ljava/util/Date; diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index 5e55512ae70..b86bddeaad8 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -166,7 +166,7 @@ public static long secondsToNanos(final @NotNull long seconds) { return seconds * (1000L * 1000L * 1000L); } - public static @NotNull BigDecimal doubleToBigDecimal(final @NotNull Double value) { + public static @NotNull BigDecimal doubleToBigDecimal(final double value) { return BigDecimal.valueOf(value).setScale(6, RoundingMode.DOWN); } } diff --git a/sentry/src/main/java/io/sentry/ProfileChunk.java b/sentry/src/main/java/io/sentry/ProfileChunk.java index a6145ca8e9a..1d159030c1d 100644 --- a/sentry/src/main/java/io/sentry/ProfileChunk.java +++ b/sentry/src/main/java/io/sentry/ProfileChunk.java @@ -1,5 +1,7 @@ package io.sentry; +import static io.sentry.DateUtils.doubleToBigDecimal; + import io.sentry.profilemeasurements.ProfileMeasurement; import io.sentry.protocol.DebugMeta; import io.sentry.protocol.SdkVersion; @@ -8,8 +10,6 @@ import io.sentry.vendor.gson.stream.JsonToken; import java.io.File; import java.io.IOException; -import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -264,10 +264,6 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger writer.endObject(); } - private @NotNull BigDecimal doubleToBigDecimal(final @NotNull Double value) { - return BigDecimal.valueOf(value).setScale(6, RoundingMode.DOWN); - } - @Nullable @Override public Map getUnknown() { diff --git a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java index 2f9ba5e1312..d27114c66ef 100644 --- a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java +++ b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java @@ -1,5 +1,7 @@ package io.sentry.profilemeasurements; +import static io.sentry.DateUtils.doubleToBigDecimal; + import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.JsonDeserializer; @@ -10,8 +12,6 @@ import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.Date; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -92,10 +92,6 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger writer.endObject(); } - private @NotNull BigDecimal doubleToBigDecimal(final @NotNull Double value) { - return BigDecimal.valueOf(value).setScale(6, RoundingMode.DOWN); - } - @Nullable @Override public Map getUnknown() { diff --git a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java index 6274c8b00d7..58930ec1a87 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java +++ b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java @@ -1,5 +1,7 @@ package io.sentry.protocol; +import static io.sentry.DateUtils.doubleToBigDecimal; + import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.JsonDeserializer; @@ -16,8 +18,6 @@ import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.Date; import java.util.HashMap; import java.util.Map; @@ -230,10 +230,6 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger writer.endObject(); } - private @NotNull BigDecimal doubleToBigDecimal(final @NotNull Double value) { - return BigDecimal.valueOf(value).setScale(6, RoundingMode.DOWN); - } - @Nullable @Override public Map getUnknown() { diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java b/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java index 8f1c95641d5..af9053742d3 100644 --- a/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java +++ b/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java @@ -1,5 +1,7 @@ package io.sentry.protocol.profiling; +import static io.sentry.DateUtils.doubleToBigDecimal; + import io.sentry.ILogger; import io.sentry.JsonDeserializer; import io.sentry.JsonSerializable; @@ -8,8 +10,6 @@ import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.HashMap; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -78,10 +78,6 @@ public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) thr writer.endObject(); } - private @NotNull BigDecimal doubleToBigDecimal(final @NotNull Double value) { - return BigDecimal.valueOf(value).setScale(6, RoundingMode.DOWN); - } - @Nullable @Override public Map getUnknown() { From 06b0d8089c88819b4be74dd8eeff4aba34e9877b Mon Sep 17 00:00:00 2001 From: arb Date: Wed, 17 Jun 2026 14:39:28 +0200 Subject: [PATCH 012/195] chore(android-sqlite): Repair start times of spans generated by SentrySQLiteDriver (#5543) chore(android-sqlite): Repair start times of spans generated by SentrySQLiteDriver (JAVA-275) Repairs the nanoTimetamp of the SentryNanotimeDates used as start times for the spans generated by SentrySQLiteDriver. Without those repairs, all spans within a given wall clock millisecond are displayed by Sentry UI as starting at that same millisecond and are re-ordered arbitrarily. Often that's quite confusing as actual BEGIN -> EXECUTE STATEMENT -> END sequences can appear as EXECUTE STATEMENT -> END -> BEGIN (etc.). For more details, see the discussion [here](https://github.com/getsentry/sentry-java/pull/5504#issuecomment-4679631245). --- .../android/sqlite/SQLiteSpanManager.kt | 43 +++++-- .../main/java/io/sentry/sqlite/DbMetadata.kt | 11 -- .../sqlite/SQLiteSpanInstrumentation.kt | 101 ++++++++++------ .../io/sentry/sqlite/SentrySQLiteStatement.kt | 13 +- .../ComputeNanoStartTimestampForChildTest.kt | 100 ++++++++++++++++ .../java/io/sentry/sqlite/DbMetadataTest.kt | 8 -- .../sqlite/SQLiteSpanInstrumentationTest.kt | 112 +++++++++++------- .../sqlite/SentrySQLiteStatementTest.kt | 9 +- 8 files changed, 279 insertions(+), 118 deletions(-) create mode 100644 sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt index 3495d3a71f0..1bdeb7d369c 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt @@ -3,17 +3,21 @@ package io.sentry.android.sqlite import android.database.CrossProcessCursor import android.database.SQLException import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.Instrumenter import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryStackTraceFactory +import io.sentry.SpanDataConvention import io.sentry.SpanStatus -import io.sentry.sqlite.SQLiteSpanInstrumentation + +private const val TRACE_ORIGIN = "auto.db.sqlite" internal class SQLiteSpanManager( private val scopes: IScopes = ScopesAdapter.getInstance(), - databaseName: String? = null, + private val databaseName: String? = null, ) { - - private val spans = SQLiteSpanInstrumentation.fromDatabaseName(databaseName, scopes) + private val stackTraceFactory = SentryStackTraceFactory(scopes.options) init { SentryIntegrationPackageStorage.getInstance().addIntegration("SQLite") @@ -29,8 +33,8 @@ internal class SQLiteSpanManager( @Suppress("TooGenericExceptionCaught", "UNCHECKED_CAST") @Throws(SQLException::class) fun performSql(sql: String, operation: () -> T): T { - val startTimestamp = spans.startTimestamp() - + val startTimestamp = scopes.getOptions().dateProvider.now() + var span: ISpan? = null return try { val result = operation() /* @@ -41,11 +45,34 @@ internal class SQLiteSpanManager( if (result is CrossProcessCursor) { return SentryCrossProcessCursor(result, this, sql) as T } - spans.recordSpan(sql, startTimestamp, SpanStatus.OK) + span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) + span?.spanContext?.origin = TRACE_ORIGIN + span?.status = SpanStatus.OK result } catch (e: Throwable) { - spans.recordSpan(sql, startTimestamp, SpanStatus.INTERNAL_ERROR, e) + span = scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY) + span?.spanContext?.origin = TRACE_ORIGIN + span?.status = SpanStatus.INTERNAL_ERROR + span?.throwable = e throw e + } finally { + span?.apply { + val isMainThread: Boolean = scopes.options.threadChecker.isMainThread + setData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY, isMainThread) + if (isMainThread) { + setData(SpanDataConvention.CALL_STACK_KEY, stackTraceFactory.inAppCallStack) + } + // if db name is null, then it's an in-memory database as per + // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:sqlite/sqlite/src/main/java/androidx/sqlite/db/SupportSQLiteOpenHelper.kt;l=38-42 + if (databaseName != null) { + setData(SpanDataConvention.DB_SYSTEM_KEY, "sqlite") + setData(SpanDataConvention.DB_NAME_KEY, databaseName) + } else { + setData(SpanDataConvention.DB_SYSTEM_KEY, "in-memory") + } + + finish() + } } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt index aa3c186b6d9..598dc524ed1 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DbMetadata.kt @@ -36,14 +36,3 @@ internal fun dbMetadataFromFileName(fileName: String): DbMetadata { val basename = if (index >= 0) trimmed.substring(index + 1) else trimmed return DbMetadata(name = basename.ifEmpty { null }, system = DB_SYSTEM_SQLITE) } - -/** - * Returns metadata based on - * [SupportSQLiteOpenHelper.databaseName][androidx.sqlite.db.SupportSQLiteOpenHelper.databaseName]. - */ -internal fun dbMetadataFromDatabaseName(databaseName: String?): DbMetadata = - if (databaseName == null) { - DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY) - } else { - DbMetadata(name = databaseName, system = DB_SYSTEM_SQLITE) - } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt index 4c925198bd5..5099f38f691 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt @@ -1,17 +1,26 @@ package io.sentry.sqlite import io.sentry.IScopes +import io.sentry.ISpan import io.sentry.Instrumenter import io.sentry.ScopesAdapter import io.sentry.SentryDate import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate import io.sentry.SentryStackTraceFactory import io.sentry.SpanDataConvention import io.sentry.SpanStatus +import java.util.Date private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" -/** Shared span instrumentation for SQLite. */ +/** + * Sentinel for extracting a [SentryNanotimeDate]'s underlying [System.nanoTime] value via + * [SentryDate.diff]. + */ +private val EMPTY_NANO_TIME = SentryNanotimeDate(Date(0), 0L) + +/** Span instrumentation for [SentrySQLiteDriver]. */ internal class SQLiteSpanInstrumentation( private val scopes: IScopes, private val dbMetadata: DbMetadata, @@ -20,44 +29,32 @@ internal class SQLiteSpanInstrumentation( private val stackTraceFactory = SentryStackTraceFactory(scopes.options) /** - * Returns a start timestamp for a `db.sql.query` span. + * Returns a timestamp in nanoseconds for use with [recordSpan]. Timestamp is ns-precise if the + * active parent span uses a [SentryNanotimeDate] (the ordinary case); otherwise it's ms-precise. * - * Exposed so callers can capture a wall-clock start before accumulating database time. - * Internalizing the start time in [recordSpan] would shift spans to end-of-work on the trace - * timeline, which is less desirable. + * Note: Internalizing the start time in [recordSpan] would shift spans to end-of-work on the + * trace timeline, which is less desirable; callers capture the start before doing database work + * and pass it back to [recordSpan]. */ - fun startTimestamp(): SentryDate = scopes.options.dateProvider.now() - - /** Records a `db.sql.query` span from [startTimestamp] to the moment of invocation. */ - fun recordSpan( - sql: String, - startTimestamp: SentryDate, - status: SpanStatus, - throwable: Throwable? = null, - ) { - recordSpan(sql, startTimestamp, endTimestamp = null, status, throwable) - } + fun startTimestamp(): Long = + // Try to retain nanosecond precision + avoid SentryDate allocation... + scopes.span?.computeNanoStartTimestampForChild() + // ...otherwise fall back to millisecond precision + allocate. + ?: scopes.options.dateProvider.now().nanoTimestamp() - /** Records a `db.sql.query` span from [startTimestamp] to [startTimestamp] + [durationNanos]. */ + /** Records a `db.sql.query` span. */ fun recordSpan( sql: String, - startTimestamp: SentryDate, + startTimestampNanos: Long, durationNanos: Long, status: SpanStatus, throwable: Throwable? = null, ) { - val endTimestamp = SentryLongDate(startTimestamp.nanoTimestamp() + durationNanos) - recordSpan(sql, startTimestamp, endTimestamp, status, throwable) - } + val parent = scopes.span ?: return + val startTimestamp = SentryLongDate(startTimestampNanos) + val endTimestamp = SentryLongDate(startTimestampNanos + durationNanos) - private fun recordSpan( - sql: String, - startTimestamp: SentryDate, - endTimestamp: SentryDate?, - status: SpanStatus, - throwable: Throwable?, - ) { - scopes.span?.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY)?.apply { + parent.startChild("db.sql.query", sql, startTimestamp, Instrumenter.SENTRY).apply { spanContext.origin = SQLITE_TRACE_ORIGIN throwable?.let { this.throwable = it } @@ -85,15 +82,43 @@ internal class SQLiteSpanInstrumentation( scopes: IScopes = ScopesAdapter.getInstance(), ): SQLiteSpanInstrumentation = SQLiteSpanInstrumentation(scopes, dbMetadataFromFileName(fileName)) + } +} - /** - * Returns [SQLiteSpanInstrumentation] based on - * [SupportSQLiteOpenHelper.databaseName][androidx.sqlite.db.SupportSQLiteOpenHelper.databaseName]. - */ - fun fromDatabaseName( - databaseName: String?, - scopes: IScopes = ScopesAdapter.getInstance(), - ): SQLiteSpanInstrumentation = - SQLiteSpanInstrumentation(scopes, dbMetadataFromDatabaseName(databaseName)) +/** + * Computes a start timestamp with nanosecond precision for the child of the receiver span. Returns + * null if nanosecond precision isn't possible. + * + * Lets us improve the display of spans in the Sentry UI. If timestamps are only ms-precise, the + * Sentry UI will left-align and arbitrarily reorder spans that share the same wall clock ms: + * ``` + * (Relative start times out of order) + * ↓ + * Parent span ├█████████████┤ + * END TRANSACTION ├███┤ 0.33 ms + * BEGIN IMMEDIATE TRANSACTION ├████┤ 0.02 ms + * INSERT INTO `my_db` … ├██┤ 0.30 ms + * ↑ + * (All spans share the same ms baseline + * even though their execution was staggered) + * ``` + * + * Nanosecond precision ensures proper ordering and lets the spans stagger: + * ``` + * Parent span ├█████████████┤ + * BEGIN IMMEDIATE TRANSACTION ├████┤ 0.02 ms + * INSERT INTO `my_db` … ├██┤ 0.30 ms + * END TRANSACTION ├███┤ 0.33 ms + * ``` + */ +internal fun ISpan.computeNanoStartTimestampForChild(): Long? { + if (startDate !is SentryNanotimeDate) { + return null } + + val parentWallClockNanos = startDate.nanoTimestamp() + val parentMonotonicNanos = startDate.diff(EMPTY_NANO_TIME) + val elapsedSinceParentStart = System.nanoTime() - parentMonotonicNanos + // Return the child's absolute start time. + return parentWallClockNanos + elapsedSinceParentStart } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt index 41df37444b5..a739a396bcb 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt @@ -1,7 +1,6 @@ package io.sentry.sqlite import androidx.sqlite.SQLiteStatement -import io.sentry.SentryDate import io.sentry.SpanStatus /** @@ -22,7 +21,7 @@ internal class SentrySQLiteStatement( private val nanoTimeProvider: () -> Long = { System.nanoTime() }, ) : SQLiteStatement by delegate { - private var firstStepTimestamp: SentryDate? = null + private var firstStepTimestampNanos: Long? = null private var accumulatedDbNanos: Long = 0L private var stepsComplete = false private var closed = false @@ -35,8 +34,8 @@ internal class SentrySQLiteStatement( val beforeNanos = nanoTimeProvider() return try { - if (firstStepTimestamp == null) { - firstStepTimestamp = spans.startTimestamp() + if (firstStepTimestampNanos == null) { + firstStepTimestampNanos = spans.startTimestamp() } stepsComplete = !delegate.step() @@ -71,10 +70,10 @@ internal class SentrySQLiteStatement( } private fun recordSpan(status: SpanStatus, throwable: Throwable? = null) { - val start = firstStepTimestamp ?: return + val startNanos = firstStepTimestampNanos ?: return val duration = accumulatedDbNanos - firstStepTimestamp = null + firstStepTimestampNanos = null accumulatedDbNanos = 0L - spans.recordSpan(sql, start, duration, status, throwable) + spans.recordSpan(sql, startNanos, duration, status, throwable) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt new file mode 100644 index 00000000000..92a98b6e56d --- /dev/null +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt @@ -0,0 +1,100 @@ +package io.sentry.sqlite + +import io.sentry.DateUtils +import io.sentry.ISpan +import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate +import java.util.Date +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ComputeNanoStartTimestampForChildTest { + + @Test + fun `returns parent wall clock plus elapsed monotonic time since parent started`() { + val wallClockMillis = 1_000_000L + val elapsedNanos = 500_000L + val parentMonotonicNanos = System.nanoTime() - elapsedNanos + val span = spanWithNanotimeStart(wallClockMillis, parentMonotonicNanos) + + val timestamp = span.computeNanoStartTimestampForChild()!! + + val elapsedSinceParentStart = timestamp - DateUtils.millisToNanos(wallClockMillis) + assertTrue(elapsedSinceParentStart >= elapsedNanos) + assertTrue(elapsedSinceParentStart < elapsedNanos + TEST_SLACK_NANOS) + } + + @Test + fun `same millisecond wall clocks with different monotonic offsets produce distinct ordered timestamps`() { + val wallClockMillis = 1_000_000L + val wallClockNanos = DateUtils.millisToNanos(wallClockMillis) + val earlierParentMonotonicNanos = System.nanoTime() - 200_000L + val laterParentMonotonicNanos = System.nanoTime() - 800_000L + val earlierSpan = spanWithNanotimeStart(wallClockMillis, earlierParentMonotonicNanos) + val laterSpan = spanWithNanotimeStart(wallClockMillis, laterParentMonotonicNanos) + + assertEquals( + earlierSpan.startDate.nanoTimestamp(), + laterSpan.startDate.nanoTimestamp(), + "Raw parent timestamps share the same ms-quantized value", + ) + + val earlier = earlierSpan.computeNanoStartTimestampForChild()!! + val later = laterSpan.computeNanoStartTimestampForChild()!! + + assertTrue(earlier > wallClockNanos) + assertTrue(later > wallClockNanos) + assertTrue(earlier < later) + assertTrue(later - earlier >= 500_000L) + } + + @Test + fun `returns parent wall clock when no monotonic time has elapsed since parent started`() { + val wallClockMillis = 1_000_000L + val parentMonotonicNanos = System.nanoTime() + val span = spanWithNanotimeStart(wallClockMillis, parentMonotonicNanos) + + val elapsedSinceParentStart = + span.computeNanoStartTimestampForChild()!! - DateUtils.millisToNanos(wallClockMillis) + assertTrue(elapsedSinceParentStart >= 0L) + assertTrue(elapsedSinceParentStart < TEST_SLACK_NANOS) + } + + @Test + fun `works when parent wall clock differs from millisecond baseline`() { + val wallClockMillis = 1_000_001L + val elapsedNanos = 1_500_000L + val parentMonotonicNanos = System.nanoTime() - elapsedNanos + val span = spanWithNanotimeStart(wallClockMillis, parentMonotonicNanos) + + val elapsedSinceParentStart = + span.computeNanoStartTimestampForChild()!! - DateUtils.millisToNanos(wallClockMillis) + assertTrue(elapsedSinceParentStart >= elapsedNanos) + assertTrue(elapsedSinceParentStart < elapsedNanos + TEST_SLACK_NANOS) + } + + @Test + fun `returns null when start date is not SentryNanotimeDate`() { + val span = mock() + whenever(span.startDate).thenReturn(SentryLongDate(DateUtils.millisToNanos(1_000_000L))) + + assertNull(span.computeNanoStartTimestampForChild()) + } + + private fun spanWithNanotimeStart(wallClockMillis: Long, parentMonotonicNanos: Long): ISpan { + val startDate = SentryNanotimeDate(Date(wallClockMillis), parentMonotonicNanos) + val span = mock() + whenever(span.startDate).thenReturn(startDate) + return span + } + + companion object { + + // Upper bound for monotonic drift while the test body runs. + private const val TEST_SLACK_NANOS = 50_000_000L + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt index 227b9d9558c..09d80793ed2 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DbMetadataTest.kt @@ -13,14 +13,6 @@ class DbMetadataTest { ) } - @Test - fun `dbMetadataFromDatabaseName returns in-memory system with no db name when databaseName is null`() { - assertEquals( - DbMetadata(name = null, system = DB_SYSTEM_IN_MEMORY), - dbMetadataFromDatabaseName(null), - ) - } - @Test fun `dbMetadataFromFileName returns sqlite system and db name for unix path`() { assertEquals( diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt index ead123a190b..a38be242ec5 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt @@ -1,15 +1,21 @@ package io.sentry.sqlite import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.SentryDateProvider +import io.sentry.SentryLongDate +import io.sentry.SentryNanotimeDate import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.util.thread.IThreadChecker +import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -40,6 +46,63 @@ class SQLiteSpanInstrumentationTest { private val fixture = Fixture() + @Test + fun `startTimestamp is ns-precise and skips date provider when parent uses SentryNanotimeDate`() { + // Only the parent date is queued. If startTimestamp() were to call dateProvider.now(), + // the queue would underflow and the test would fail loudly — this is what verifies the + // optimization is in effect. + val parentDate = SentryNanotimeDate(Date(1_000_000L), 100_000_000L) + val sut = setUpWithNanotimeDates(parentDate) + + val start = sut.startTimestamp() + + val durationNanos = 42_000_000L + sut.recordSpan("SELECT 1", start, durationNanos, SpanStatus.OK) + + val span = fixture.sentryTracer.children.first() + + // startTimestamp returns an already-ns-precise value, anchored to the parent's wall clock and + // offset by elapsed System.nanoTime(). The exact ns-math is unit-tested in + // ChildStartTimestampOrNullTest; here we verify the integration shape. + assertIs(span.startDate) + assertEquals(start, span.startDate.nanoTimestamp()) + assertEquals(start + durationNanos, span.finishDate!!.nanoTimestamp()) + } + + @Test + fun `startTimestamp falls back to date provider when parent does not use SentryNanotimeDate`() { + val providerDate = SentryNanotimeDate(Date(2_000_000L), 200_000_000L) + val parentSpan = mock() + whenever(parentSpan.startDate).thenReturn(SentryLongDate(1_000_000_000_000_000L)) + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + dateProvider = SentryDateProvider { providerDate } + } + whenever(fixture.scopes.options).thenReturn(options) + whenever(fixture.scopes.span).thenReturn(parentSpan) + + val sut = SQLiteSpanInstrumentation.fromFileName(":memory:", fixture.scopes) + + assertEquals(providerDate.nanoTimestamp(), sut.startTimestamp()) + } + + @Test + fun `startTimestamp falls back to date provider when no transaction is active`() { + val providerDate = SentryNanotimeDate(Date(2_000_000L), 200_000_000L) + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + dateProvider = SentryDateProvider { providerDate } + } + whenever(fixture.scopes.options).thenReturn(options) + whenever(fixture.scopes.span).thenReturn(null) + + val sut = SQLiteSpanInstrumentation.fromFileName(":memory:", fixture.scopes) + + assertEquals(providerDate.nanoTimestamp(), sut.startTimestamp()) + } + @Test fun `recordSpan records a span if a transaction is active`() { val sut = fixture.getSut(isTransactionActive = true) @@ -79,7 +142,6 @@ class SQLiteSpanInstrumentationTest { sut.recordSpan("SELECT 1", start, durationNanos, SpanStatus.OK) val span = fixture.sentryTracer.children.first() - assertEquals(start, span.startDate) assertEquals(span.startDate.nanoTimestamp() + durationNanos, span.finishDate!!.nanoTimestamp()) } @@ -146,48 +208,16 @@ class SQLiteSpanInstrumentationTest { assertNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) } - @Test - fun `recordSpan without a duration finishes the span at the time of invocation`() { - val sut = fixture.getSut() - val start = sut.startTimestamp() - - sut.recordSpan("SELECT 1", start, SpanStatus.OK) - - val span = fixture.sentryTracer.children.first() - assertTrue(span.isFinished) - assertEquals(SpanStatus.OK, span.status) - // Unlike the duration overload, no synthetic end timestamp is supplied; the span finishes at - // "now", i.e. at or after its start. - assertTrue(span.finishDate!!.nanoTimestamp() >= start.nanoTimestamp()) - } - - @Test - fun `fromFileName sets db name from fileName`() { - val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } - whenever(fixture.scopes.options).thenReturn(options) - fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) - whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) - - val sut = SQLiteSpanInstrumentation.fromFileName("tracks.db", fixture.scopes) - sut.recordSpan("SELECT 1", sut.startTimestamp(), SpanStatus.OK) - - val span = fixture.sentryTracer.children.first() - assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) - assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) - } - - @Test - fun `fromDatabaseName sets db name from databaseName`() { - val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + private fun setUpWithNanotimeDates(vararg dates: SentryNanotimeDate): SQLiteSpanInstrumentation { + val dateQueue = ArrayDeque(dates.toList()) + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + dateProvider = SentryDateProvider { dateQueue.removeFirst() } + } whenever(fixture.scopes.options).thenReturn(options) fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) - - val sut = SQLiteSpanInstrumentation.fromDatabaseName("tracks.db", fixture.scopes) - sut.recordSpan("SELECT 1", sut.startTimestamp(), SpanStatus.OK) - - val span = fixture.sentryTracer.children.first() - assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) - assertEquals("tracks.db", span.data[SpanDataConvention.DB_NAME_KEY]) + return SQLiteSpanInstrumentation.fromFileName(":memory:", fixture.scopes) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt index 6691910e358..ce2c3f00cd5 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt @@ -1,7 +1,6 @@ package io.sentry.sqlite import androidx.sqlite.SQLiteStatement -import io.sentry.SentryLongDate import io.sentry.SpanStatus import java.util.concurrent.atomic.AtomicLong import kotlin.test.Test @@ -21,11 +20,11 @@ class SentrySQLiteStatementTest { private class Fixture { val mockStatement = mock() val mockSpans = mock() - val startDate = SentryLongDate(1_000_000_000_000L) + val startTimestampNanos = 1_000_000_000_000L val fakeClock = AtomicLong(0L) fun getSut(sql: String): SentrySQLiteStatement { - whenever(mockSpans.startTimestamp()).thenReturn(startDate) + whenever(mockSpans.startTimestamp()).thenReturn(startTimestampNanos) return SentrySQLiteStatement(mockStatement, mockSpans, sql, fakeClock::getAndIncrement) } } @@ -43,7 +42,7 @@ class SentrySQLiteStatementTest { verify(fixture.mockSpans) .recordSpan( eq("SELECT * FROM users"), - eq(fixture.startDate), + eq(fixture.startTimestampNanos), any(), eq(SpanStatus.OK), anyOrNull(), @@ -61,7 +60,7 @@ class SentrySQLiteStatementTest { verify(fixture.mockSpans) .recordSpan( eq("BAD SQL"), - eq(fixture.startDate), + eq(fixture.startTimestampNanos), any(), eq(SpanStatus.INTERNAL_ERROR), eq(exception), From 3a7603a26335e1f1aafb78c75b4f1079819b25b3 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 17 Jun 2026 15:45:35 +0200 Subject: [PATCH 013/195] perf(android): Replace Date with unix timestamp in SentryNanotimeDate (JAVA-533) (#5550) * perf(android): Replace Date with unix timestamp in SentryNanotimeDate (JAVA-533) SentryNanotimeDate stored a java.util.Date but only ever read its epoch millis. Storing the millis directly avoids a Calendar allocation on every timestamp, which on Android backs every span/transaction timestamp. The default constructor now uses System.currentTimeMillis() instead of DateUtils.getCurrentDateTime() (Calendar with UTC). This is behavior- preserving: the UTC TimeZone only affects calendar field access, not the epoch-millis value the class used. BREAKING: the public SentryNanotimeDate(Date, long) constructor is replaced by SentryNanotimeDate(long unixDate, long nanos). Co-Authored-By: Claude Opus 4.8 (1M context) * changelog * ref(android): Mark SentryNanotimeDate as @ApiStatus.Internal SentryNanotimeDate is the legacy Date+nanoTime precision workaround and is not intended for direct use by consumers. Marking it @ApiStatus.Internal signals this and means the constructor change in this PR is not a public API break per the repo's API policy. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(sentry): Rename unixDate field to unixDateMillis Name the long field for its unit so it is clear it holds the unix timestamp in milliseconds since the epoch. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(changelog): Reword SentryNanotimeDate entry Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sentry): Restore deprecated SentryNanotimeDate Date constructor (JAVA-533) The previous change replaced the (Date, long) constructor with a (long, long) constructor, which was a breaking API change. Add the Date constructor back, delegating to the millis-based one, and mark it deprecated to steer callers toward the new constructor. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sentry): Suppress InlineMeSuggester on deprecated constructor (JAVA-533) Error Prone flagged the deprecated (Date, long) constructor as inlineable, failing the build. Suppress the suggestion to match the existing convention in Sentry.java, keeping the constructor available for backwards compatibility. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sentry): Suppress JavaUtilDate on deprecated constructor (JAVA-533) Error Prone's JavaUtilDate check flagged date.getTime() in the deprecated constructor, failing the build. Suppress it, matching the existing suppression used elsewhere in this class. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 + .../core/ActivityLifecycleIntegration.java | 5 +- .../core/SpanFrameMetricsCollector.java | 3 +- .../core/ActivityLifecycleIntegrationTest.kt | 67 +++++++++---------- .../core/NetworkBreadcrumbsIntegrationTest.kt | 6 +- .../core/SpanFrameMetricsCollectorTest.kt | 15 +++-- .../ActivityLifecycleSpanHelperTest.kt | 9 ++- .../core/performance/AppStartMetricsTest.kt | 3 +- .../apache/ApacheHttpClientTransportTest.kt | 4 +- sentry/api/sentry.api | 2 +- sentry/src/main/java/io/sentry/DateUtils.java | 11 --- .../java/io/sentry/SentryNanotimeDate.java | 29 +++++--- ...efaultCompositePerformanceCollectorTest.kt | 21 ++---- .../java/io/sentry/SentryNanotimeDateTest.kt | 29 ++++---- .../test/java/io/sentry/SentryTracerTest.kt | 5 +- .../transport/AsyncHttpTransportTest.kt | 4 +- 16 files changed, 99 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dbcde58f10..0de5fc72452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Improvements - Reduce boxing to improve performance ([#5523](https://github.com/getsentry/sentry-java/pull/5523), [#5527](https://github.com/getsentry/sentry-java/pull/5527), [#5551](https://github.com/getsentry/sentry-java/pull/5551)) +- Replace `Date` with a unix timestamp in `SentryNanotimeDate` to improve performance ([#5550](https://github.com/getsentry/sentry-java/pull/5550)) + - `SentryNanotimeDate` is now marked `@ApiStatus.Internal`. A new `(long unixDateMillis, long nanos)` constructor was added, where `unixDateMillis` is milliseconds since the epoch. The existing `(Date, long)` constructor is retained but deprecated. ### Dependencies diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 19cee7fcce5..8a891926341 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -45,7 +45,6 @@ import java.io.IOException; import java.lang.ref.WeakReference; import java.util.Collections; -import java.util.Date; import java.util.Map; import java.util.WeakHashMap; import java.util.concurrent.Future; @@ -94,7 +93,7 @@ public final class ActivityLifecycleIntegration private final @NotNull WeakHashMap ttfdSpanMap = new WeakHashMap<>(); private final @NotNull WeakHashMap activitySpanHelpers = new WeakHashMap<>(); - private @NotNull SentryDate lastPausedTime = new SentryNanotimeDate(new Date(0), 0); + private @NotNull SentryDate lastPausedTime = new SentryNanotimeDate(0, 0); private @Nullable Future ttfdAutoCloseFuture = null; // WeakHashMap isn't thread safe but ActivityLifecycleCallbacks is only called from the @@ -729,7 +728,7 @@ public void onActivityDestroyed(final @NotNull Activity activity) { private void clear() { firstActivityCreated = false; - lastPausedTime = new SentryNanotimeDate(new Date(0), 0); + lastPausedTime = new SentryNanotimeDate(0, 0); activitySpanHelpers.clear(); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java index a83454d29b7..074a4a6ea51 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SpanFrameMetricsCollector.java @@ -13,7 +13,6 @@ import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.protocol.MeasurementValue; import io.sentry.util.AutoClosableReentrantLock; -import java.util.Date; import java.util.Iterator; import java.util.SortedSet; import java.util.TreeSet; @@ -33,7 +32,7 @@ public class SpanFrameMetricsCollector // grow indefinitely in case of a long running span private static final int MAX_FRAMES_COUNT = 3600; private static final long ONE_SECOND_NANOS = TimeUnit.SECONDS.toNanos(1); - private static final SentryNanotimeDate EMPTY_NANO_TIME = new SentryNanotimeDate(new Date(0), 0); + private static final SentryNanotimeDate EMPTY_NANO_TIME = new SentryNanotimeDate(0, 0); private final boolean enabled; protected final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index f2ffb4b4b96..19f43432bef 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -41,7 +41,6 @@ import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource import io.sentry.test.DeferredExecutorService import io.sentry.test.getProperty -import java.util.Date import java.util.concurrent.Future import java.util.concurrent.TimeUnit import kotlin.test.AfterTest @@ -936,7 +935,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(false) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) fixture.options.dateProvider = SentryDateProvider { date } @@ -961,7 +960,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(false) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) val activity = mock() @@ -984,8 +983,8 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(false) - val date = SentryNanotimeDate(Date(1), 0) - val date2 = SentryNanotimeDate(Date(2), 2) + val date = SentryNanotimeDate(1, 0) + val date2 = SentryNanotimeDate(2, 2) setAppStartTime(date) val activity = mock() @@ -1011,7 +1010,7 @@ class ActivityLifecycleIntegrationTest { val sut = fixture.getSut { it.tracesSampleRate = 1.0 } sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(true) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) val activity = mock() @@ -1030,8 +1029,8 @@ class ActivityLifecycleIntegrationTest { sut.setFirstActivityCreated(false) // usually set by SentryPerformanceProvider - val date = SentryNanotimeDate(Date(1), 0) - val date2 = SentryNanotimeDate(Date(2), 2) + val date = SentryNanotimeDate(1, 0) + val date2 = SentryNanotimeDate(2, 2) val activity = mock() // Activity onCreate date will be used @@ -1056,7 +1055,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually set by SentryPerformanceProvider - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) val activity = mock() @@ -1080,7 +1079,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually set by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) val appStartMetrics = AppStartMetrics.getInstance() appStartMetrics.appStartType = AppStartType.WARM @@ -1113,9 +1112,9 @@ class ActivityLifecycleIntegrationTest { it.isEnableStandaloneAppStartTracing = true } sut.register(fixture.scopes, fixture.options) - val firstFrameDate = SentryNanotimeDate(Date(1499), 0) + val firstFrameDate = SentryNanotimeDate(1499, 0) fixture.options.dateProvider = SentryDateProvider { firstFrameDate } - setAppStartTime(SentryNanotimeDate(Date(1), 0)) + setAppStartTime(SentryNanotimeDate(1, 0)) val activity = mock() sut.onActivityPreCreated(activity, fixture.bundle) @@ -1168,8 +1167,8 @@ class ActivityLifecycleIntegrationTest { it.isEnableStandaloneAppStartTracing = true } sut.register(fixture.scopes, fixture.options) - val appStartEndDate = SentryNanotimeDate(Date(499), 0) - setAppStartTime(SentryNanotimeDate(Date(1), 0), appStartEndDate) + val appStartEndDate = SentryNanotimeDate(499, 0) + setAppStartTime(SentryNanotimeDate(1, 0), appStartEndDate) val activity = mock() sut.onActivityPreCreated(activity, fixture.bundle) @@ -1230,9 +1229,9 @@ class ActivityLifecycleIntegrationTest { AppStartMetrics.getInstance().appStartSentryTraceHeader = SentryTraceHeader(storedTraceId, SpanId(), true).value // headless start ended right before the activity opens - AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(Date(0), 0) + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(0, 0) sut.register(fixture.scopes, fixture.options) - setAppStartTime(date = SentryNanotimeDate(Date(1), 0)) + setAppStartTime(date = SentryNanotimeDate(1, 0)) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -1254,9 +1253,9 @@ class ActivityLifecycleIntegrationTest { AppStartMetrics.getInstance().appStartSentryTraceHeader = SentryTraceHeader(storedTraceId, SpanId(), true).value // headless start ended at epoch, but the activity opens more than a minute later - AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(Date(0), 0) + AppStartMetrics.getInstance().appStartEndTime = SentryNanotimeDate(0, 0) sut.register(fixture.scopes, fixture.options) - setAppStartTime(date = SentryNanotimeDate(Date(TimeUnit.MINUTES.toMillis(2)), 0)) + setAppStartTime(date = SentryNanotimeDate(TimeUnit.MINUTES.toMillis(2), 0)) val activity = mock() sut.onActivityCreated(activity, fixture.bundle) @@ -1389,7 +1388,7 @@ class ActivityLifecycleIntegrationTest { // usually done by SentryPerformanceProvider, if disabled it's done by // SentryAndroid.init - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.WARM @@ -1415,7 +1414,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.WARM AppStartMetrics.getInstance().sdkInitTimeSpan.setStoppedAt(1234) @@ -1439,7 +1438,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(1), 0) + val startDate = SentryNanotimeDate(1, 0) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.WARM @@ -1474,7 +1473,7 @@ class ActivityLifecycleIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.setFirstActivityCreated(true) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime() val activity = mock() @@ -1988,14 +1987,14 @@ class ActivityLifecycleIntegrationTest { @Test fun `When sentry is initialized mid activity lifecycle, last paused time should be used in favor of app start time`() { val sut = fixture.getSut(importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND) - val now = SentryNanotimeDate(Date(1234), 456) + val now = SentryNanotimeDate(1234, 456) fixture.options.tracesSampleRate = 1.0 fixture.options.dateProvider = SentryDateProvider { now } sut.register(fixture.scopes, fixture.options) // usually done by SentryPerformanceProvider - val startDate = SentryNanotimeDate(Date(5678), 910) + val startDate = SentryNanotimeDate(5678, 910) setAppStartTime(startDate) AppStartMetrics.getInstance().appStartType = AppStartType.COLD @@ -2019,7 +2018,7 @@ class ActivityLifecycleIntegrationTest { fixture.options.tracesSampleRate = 1.0 sut.register(fixture.scopes, fixture.options) - val date = SentryNanotimeDate(Date(1), 0) + val date = SentryNanotimeDate(1, 0) setAppStartTime(date) assertTrue(sut.activitySpanHelpers.isEmpty()) @@ -2036,8 +2035,8 @@ class ActivityLifecycleIntegrationTest { fun `Creates activity lifecycle spans`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) - val startDate = SentryNanotimeDate(Date(2), 0) + val appStartDate = SentryNanotimeDate(1, 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -2076,7 +2075,7 @@ class ActivityLifecycleIntegrationTest { fun `Creates activity lifecycle spans even when no app start span is available`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val startDate = SentryNanotimeDate(Date(2), 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -2134,8 +2133,8 @@ class ActivityLifecycleIntegrationTest { fun `Creates activity lifecycle spans on API lower than 29`() { val sut = fixture.getSut(apiVersion = Build.VERSION_CODES.P) fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) - val startDate = SentryNanotimeDate(Date(2), 0) + val appStartDate = SentryNanotimeDate(1, 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -2187,8 +2186,8 @@ class ActivityLifecycleIntegrationTest { fun `Does not add activity lifecycle spans when firstActivityCreated is true`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) - val startDate = SentryNanotimeDate(Date(2), 0) + val appStartDate = SentryNanotimeDate(1, 0) + val startDate = SentryNanotimeDate(2, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() fixture.options.dateProvider = SentryDateProvider { startDate } @@ -2209,7 +2208,7 @@ class ActivityLifecycleIntegrationTest { fun `When firstActivityCreated is false and app start span has stopped, restart app start to current date`() { val sut = fixture.getSut() fixture.options.tracesSampleRate = 1.0 - val appStartDate = SentryNanotimeDate(Date(1), 0) + val appStartDate = SentryNanotimeDate(1, 0) val appStartMetrics = AppStartMetrics.getInstance() val activity = mock() setAppStartTime(appStartDate) @@ -2290,7 +2289,7 @@ class ActivityLifecycleIntegrationTest { } private fun setAppStartTime( - date: SentryDate = SentryNanotimeDate(Date(1), 0), + date: SentryDate = SentryNanotimeDate(1, 0), stopDate: SentryDate? = null, ) { // set by SentryPerformanceProvider so forcing it here diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt index 4f6ba7fc5f0..711f5f7fe0b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt @@ -5,7 +5,6 @@ import android.net.Network import android.net.NetworkCapabilities import android.os.Build import io.sentry.Breadcrumb -import io.sentry.DateUtils import io.sentry.IScopes import io.sentry.ISentryExecutorService import io.sentry.SentryDateProvider @@ -54,8 +53,9 @@ class NetworkBreadcrumbsIntegrationTest { executorService = executor isEnableNetworkEventBreadcrumbs = enableNetworkEventBreadcrumbs dateProvider = SentryDateProvider { - val nowNanos = TimeUnit.MILLISECONDS.toNanos(nowMs ?: System.currentTimeMillis()) - SentryNanotimeDate(DateUtils.nanosToDate(nowNanos), nowNanos) + val nowMillis = nowMs ?: System.currentTimeMillis() + val nowNanos = TimeUnit.MILLISECONDS.toNanos(nowMillis) + SentryNanotimeDate(nowMillis, nowNanos) } } return NetworkBreadcrumbsIntegration(context, buildInfo) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt index e5d7349d37c..2b6f19a8d31 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SpanFrameMetricsCollectorTest.kt @@ -8,7 +8,6 @@ import io.sentry.SentryNanotimeDate import io.sentry.SpanContext import io.sentry.android.core.internal.util.SentryFrameMetricsCollector import io.sentry.protocol.MeasurementValue -import java.util.Date import java.util.UUID import java.util.concurrent.TimeUnit import kotlin.test.Test @@ -50,11 +49,12 @@ class SpanFrameMetricsCollectorTest { val span = mock() val spanContext = SpanContext("op.fake") whenever(span.spanContext).thenReturn(spanContext) - whenever(span.startDate).thenReturn(SentryNanotimeDate(Date(), startTimeStampNanos)) + whenever(span.startDate) + .thenReturn(SentryNanotimeDate(System.currentTimeMillis(), startTimeStampNanos)) whenever(span.finishDate) .thenReturn( if (endTimeStampNanos != null) { - SentryNanotimeDate(Date(), endTimeStampNanos) + SentryNanotimeDate(System.currentTimeMillis(), endTimeStampNanos) } else { null } @@ -69,11 +69,12 @@ class SpanFrameMetricsCollectorTest { val span = mock() val spanContext = SpanContext("op.fake") whenever(span.spanContext).thenReturn(spanContext) - whenever(span.startDate).thenReturn(SentryNanotimeDate(Date(), startTimeStampNanos)) + whenever(span.startDate) + .thenReturn(SentryNanotimeDate(System.currentTimeMillis(), startTimeStampNanos)) whenever(span.finishDate) .thenReturn( if (endTimeStampNanos != null) { - SentryNanotimeDate(Date(), endTimeStampNanos) + SentryNanotimeDate(System.currentTimeMillis(), endTimeStampNanos) } else { null } @@ -438,8 +439,8 @@ class SpanFrameMetricsCollectorTest { @Test fun `SentryNanoDate diff does nano precision`() { // having this in here, as SpanFrameMetricsCollector relies on this behavior - val a = SentryNanotimeDate(Date(1234), 567) - val b = SentryNanotimeDate(Date(1234), 0) + val a = SentryNanotimeDate(1234, 567) + val b = SentryNanotimeDate(1234, 0) assertEquals(567, a.diff(b)) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt index 710fc835acd..ef048978795 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/ActivityLifecycleSpanHelperTest.kt @@ -12,7 +12,6 @@ import io.sentry.SpanDataConvention import io.sentry.SpanOptions import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext -import java.util.Date import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test @@ -31,8 +30,8 @@ class ActivityLifecycleSpanHelperTest { val appStartSpan: ISpan val scopes = mock() val options = SentryOptions() - val date = SentryNanotimeDate(Date(1), 1000000) - val endDate = SentryNanotimeDate(Date(3), 3000000) + val date = SentryNanotimeDate(1, 1000000) + val endDate = SentryNanotimeDate(3, 3000000) init { whenever(scopes.options).thenReturn(options) @@ -59,7 +58,7 @@ class ActivityLifecycleSpanHelperTest { @Test fun `createAndStopOnCreateSpan creates and finishes onCreate span`() { val helper = fixture.getSut() - val date = SentryNanotimeDate(Date(1), 1) + val date = SentryNanotimeDate(1, 1) helper.setOnCreateStartTimestamp(date) helper.createAndStopOnCreateSpan(fixture.appStartSpan) @@ -99,7 +98,7 @@ class ActivityLifecycleSpanHelperTest { @Test fun `createAndStopOnStartSpan creates and finishes onStart span`() { val helper = fixture.getSut() - val date = SentryNanotimeDate(Date(1), 1) + val date = SentryNanotimeDate(1, 1) helper.setOnStartStartTimestamp(date) helper.createAndStopOnStartSpan(fixture.appStartSpan) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt index ab0013a8c75..2737785349f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTest.kt @@ -18,7 +18,6 @@ import io.sentry.android.core.CurrentActivityHolder import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.SentryShadowProcess import io.sentry.protocol.SentryId -import java.util.Date import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test @@ -639,7 +638,7 @@ class AppStartMetricsTest { @Test fun `createProcessInitSpan creates a span`() { val appStartMetrics = AppStartMetrics.getInstance() - val startDate = SentryNanotimeDate(Date(1), 1000000) + val startDate = SentryNanotimeDate(1, 1000000) appStartMetrics.classLoadedUptimeMs = 10 val startMillis = DateUtils.nanosToMillis(startDate.nanoTimestamp().toDouble()).toLong() appStartMetrics.appStartTimeSpan.setStartedAt(1) diff --git a/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt b/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt index 639dd4e0513..9f5c9b910ad 100644 --- a/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt +++ b/sentry-apache-http-client-5/src/test/kotlin/io/sentry/transport/apache/ApacheHttpClientTransportTest.kt @@ -213,7 +213,7 @@ class ApacheHttpClientTransportTest { val now = Date(9001) val sut = fixture.getSut() fixture.options.dateProvider = mock() - whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now, 0)) + whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now.time, 0)) val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) sut.send(envelope) @@ -226,7 +226,7 @@ class ApacheHttpClientTransportTest { val now = Date(9001) val sut = fixture.getSut() fixture.options.dateProvider = mock() - whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now, 0)) + whenever(fixture.options.dateProvider.now()).thenReturn(SentryNanotimeDate(now.time, 0)) val envelope = SentryEnvelope.from(fixture.options.serializer, SentryEvent(), null) sut.send(envelope, Hint()) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 22f9366f738..e9083350349 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -382,7 +382,6 @@ public final class io/sentry/DataCategory : java/lang/Enum { } public final class io/sentry/DateUtils { - public static fun dateToNanos (Ljava/util/Date;)J public static fun dateToSeconds (Ljava/util/Date;)D public static fun doubleToBigDecimal (D)Ljava/math/BigDecimal; public static fun getCurrentDateTime ()Ljava/util/Date; @@ -3558,6 +3557,7 @@ public final class io/sentry/SentryMetricsEvents$JsonKeys { public final class io/sentry/SentryNanotimeDate : io/sentry/SentryDate { public fun ()V + public fun (JJ)V public fun (Ljava/util/Date;J)V public fun compareTo (Lio/sentry/SentryDate;)I public synthetic fun compareTo (Ljava/lang/Object;)I diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index b86bddeaad8..e407391c394 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -151,17 +151,6 @@ public static double dateToSeconds(final @NotNull Date date) { return millisToSeconds(date.getTime()); } - /** - * Convert {@link Date} to nanoseconds represented as {@link Long}. - * - * @param date - date - * @return nanoseconds - */ - @SuppressWarnings("JavaUtilDate") - public static long dateToNanos(final @NotNull Date date) { - return millisToNanos(date.getTime()); - } - public static long secondsToNanos(final @NotNull long seconds) { return seconds * (1000L * 1000L * 1000L); } diff --git a/sentry/src/main/java/io/sentry/SentryNanotimeDate.java b/sentry/src/main/java/io/sentry/SentryNanotimeDate.java index 98c46ad5325..f3abf3518f7 100644 --- a/sentry/src/main/java/io/sentry/SentryNanotimeDate.java +++ b/sentry/src/main/java/io/sentry/SentryNanotimeDate.java @@ -1,32 +1,43 @@ package io.sentry; import java.util.Date; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** - * Uses {@link Date} in combination with System.nanoTime(). + * Uses a unix timestamp (milliseconds since the epoch) in combination with System.nanoTime(). * - *

A single date only offers millisecond precision but diff can be calculated with up to + *

The unix timestamp only offers millisecond precision but diff can be calculated with up to * nanosecond precision. This increased precision can also be used to calculate a new end date for a * transaction where start date is sent with ms precision and end date is added to it with ns * precision leading to an end timestamp with ns precision that can be used to gain ns precision * transaction durations. * *

This is a workaround for older versions of Java (before 9) and Android API (lower than 26) - * that allows for higher precision than {@link Date} alone would. + * that allows for higher precision than a millisecond timestamp alone would. */ +@ApiStatus.Internal public final class SentryNanotimeDate extends SentryDate { - private final @NotNull Date date; + private final long unixDateMillis; private final long nanos; public SentryNanotimeDate() { - this(DateUtils.getCurrentDateTime(), System.nanoTime()); + this(System.currentTimeMillis(), System.nanoTime()); } + /** + * @deprecated use {@link SentryNanotimeDate#SentryNanotimeDate(long, long)} instead. + */ + @Deprecated + @SuppressWarnings({"InlineMeSuggester", "JavaUtilDate"}) public SentryNanotimeDate(final @NotNull Date date, final long nanos) { - this.date = date; + this(date.getTime(), nanos); + } + + public SentryNanotimeDate(final long unixDateMillis, final long nanos) { + this.unixDateMillis = unixDateMillis; this.nanos = nanos; } @@ -41,7 +52,7 @@ public long diff(final @NotNull SentryDate otherDate) { @Override public long nanoTimestamp() { - return DateUtils.dateToNanos(date); + return DateUtils.millisToNanos(unixDateMillis); } @Override @@ -63,8 +74,8 @@ public long laterDateNanosTimestampByDiff(final @Nullable SentryDate otherDate) public int compareTo(@NotNull SentryDate otherDate) { if (otherDate instanceof SentryNanotimeDate) { final @NotNull SentryNanotimeDate otherNanoDate = (SentryNanotimeDate) otherDate; - final long thisDateMillis = date.getTime(); - final long otherDateMillis = otherNanoDate.date.getTime(); + final long thisDateMillis = unixDateMillis; + final long otherDateMillis = otherNanoDate.unixDateMillis; if (thisDateMillis == otherDateMillis) { return Long.compare(nanos, otherNanoDate.nanos); } else { diff --git a/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt b/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt index ceec3571ebd..f8e3a8f9f98 100644 --- a/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt +++ b/sentry/src/test/java/io/sentry/DefaultCompositePerformanceCollectorTest.kt @@ -4,7 +4,6 @@ import io.sentry.test.getCtor import io.sentry.test.getProperty import io.sentry.test.injectForField import io.sentry.util.thread.ThreadChecker -import java.util.Date import java.util.Timer import java.util.concurrent.TimeUnit import kotlin.test.Test @@ -188,14 +187,8 @@ class DefaultCompositePerformanceCollectorTest { val mockCollector = mock() val dates = listOf( - SentryNanotimeDate( - Date().apply { time = TimeUnit.SECONDS.toMillis(100) }, - TimeUnit.SECONDS.toNanos(100), - ), - SentryNanotimeDate( - Date().apply { time = TimeUnit.SECONDS.toMillis(131) }, - TimeUnit.SECONDS.toNanos(131), - ), + SentryNanotimeDate(TimeUnit.SECONDS.toMillis(100), TimeUnit.SECONDS.toNanos(100)), + SentryNanotimeDate(TimeUnit.SECONDS.toMillis(131), TimeUnit.SECONDS.toNanos(131)), ) whenever(mockDateProvider.now()).thenReturn(dates[0], dates[0], dates[0], dates[1]) val collector = @@ -226,14 +219,8 @@ class DefaultCompositePerformanceCollectorTest { val mockDateProvider = mock() val dates = listOf( - SentryNanotimeDate( - Date().apply { time = TimeUnit.SECONDS.toMillis(100) }, - TimeUnit.SECONDS.toNanos(100), - ), - SentryNanotimeDate( - Date().apply { time = TimeUnit.SECONDS.toMillis(130) }, - TimeUnit.SECONDS.toNanos(130), - ), + SentryNanotimeDate(TimeUnit.SECONDS.toMillis(100), TimeUnit.SECONDS.toNanos(100)), + SentryNanotimeDate(TimeUnit.SECONDS.toMillis(130), TimeUnit.SECONDS.toNanos(130)), ) whenever(mockDateProvider.now()).thenReturn(dates[0], dates[0], dates[0], dates[1]) val collector = fixture.getSut { it.dateProvider = mockDateProvider } diff --git a/sentry/src/test/java/io/sentry/SentryNanotimeDateTest.kt b/sentry/src/test/java/io/sentry/SentryNanotimeDateTest.kt index 86464bcedba..3f7a5dca8b6 100644 --- a/sentry/src/test/java/io/sentry/SentryNanotimeDateTest.kt +++ b/sentry/src/test/java/io/sentry/SentryNanotimeDateTest.kt @@ -1,20 +1,19 @@ package io.sentry -import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals class SentryNanotimeDateTest { @Test fun `doubleValue only offers ms precision`() { - val date = SentryNanotimeDate(Date(1672742031123), 123456789) + val date = SentryNanotimeDate(1672742031123, 123456789) assertEquals(1672742031123000000L, date.nanoTimestamp()) } @Test fun `laterDateNanosByDiff offers ns precision`() { - val startDate = SentryNanotimeDate(Date(1672742031123), 456788) - val finishDate = SentryNanotimeDate(Date(1672742031123), 456789) + val startDate = SentryNanotimeDate(1672742031123, 456788) + val finishDate = SentryNanotimeDate(1672742031123, 456789) val dateInSeconds = startDate.laterDateNanosTimestampByDiff(finishDate) assertEquals(1672742031123000001L, dateInSeconds) } @@ -26,7 +25,7 @@ class SentryNanotimeDateTest { */ @Test fun `laterDateNanosByDiff with SentryLongDate gives ms precision`() { - val startDate = SentryNanotimeDate(Date(1672742031123), 456789) + val startDate = SentryNanotimeDate(1672742031123, 456789) val finishDate = SentryLongDate(61633553039) val dateInSeconds = startDate.laterDateNanosTimestampByDiff(finishDate) assertEquals(1672742031123000000L, dateInSeconds) @@ -36,36 +35,36 @@ class SentryNanotimeDateTest { @Test fun `compareTo() with equal dates returns 0`() { - val date1 = SentryNanotimeDate(Date(1672742031123), 456789) - val date2 = SentryNanotimeDate(Date(1672742031123), 456789) + val date1 = SentryNanotimeDate(1672742031123, 456789) + val date2 = SentryNanotimeDate(1672742031123, 456789) assertEquals(0, date1.compareTo(date2)) } @Test fun `compareTo() returns -1 for earlier ns`() { - val date1 = SentryNanotimeDate(Date(1672742031123), 456788) - val date2 = SentryNanotimeDate(Date(1672742031123), 456789) + val date1 = SentryNanotimeDate(1672742031123, 456788) + val date2 = SentryNanotimeDate(1672742031123, 456789) assertEquals(-1, date1.compareTo(date2)) } @Test fun `compareTo() returns 1 for later ns`() { - val date1 = SentryNanotimeDate(Date(1672742031123), 456789) - val date2 = SentryNanotimeDate(Date(1672742031123), 456788) + val date1 = SentryNanotimeDate(1672742031123, 456789) + val date2 = SentryNanotimeDate(1672742031123, 456788) assertEquals(1, date1.compareTo(date2)) } @Test fun `compareTo() returns -1 for earlier date`() { - val date1 = SentryNanotimeDate(Date(1672742030123), 456789) - val date2 = SentryNanotimeDate(Date(1672742031123), 456789) + val date1 = SentryNanotimeDate(1672742030123, 456789) + val date2 = SentryNanotimeDate(1672742031123, 456789) assertEquals(-1, date1.compareTo(date2)) } @Test fun `compareTo() returns 1 for later date`() { - val date1 = SentryNanotimeDate(Date(1672742031123), 456789) - val date2 = SentryNanotimeDate(Date(1672742030123), 456789) + val date1 = SentryNanotimeDate(1672742031123, 456789) + val date2 = SentryNanotimeDate(1672742030123, 456789) assertEquals(1, date1.compareTo(date2)) } } diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 1ccbcf2f318..3b808dd2220 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -8,7 +8,6 @@ import io.sentry.test.getProperty import io.sentry.util.thread.IThreadChecker import java.time.LocalDateTime import java.time.ZoneOffset -import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -184,7 +183,7 @@ class SentryTracerTest { val tracer = fixture.getSut() val date = SentryNanotimeDate( - Date.from(LocalDateTime.of(2022, 12, 24, 23, 59, 58, 0).toInstant(ZoneOffset.UTC)), + LocalDateTime.of(2022, 12, 24, 23, 59, 58, 0).toInstant(ZoneOffset.UTC).toEpochMilli(), 0, ) tracer.finish(SpanStatus.ABORTED, date) @@ -643,7 +642,7 @@ class SentryTracerTest { @Test fun `when startTimestamp is given, use it as startTimestamp`() { - val date = SentryNanotimeDate(Date(0), 0) + val date = SentryNanotimeDate(0, 0) val transaction = fixture.getSut(startTimestamp = date) assertSame(date, transaction.startDate) diff --git a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt index 70092ffa7ba..6f711cfedbf 100644 --- a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt +++ b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportTest.kt @@ -367,7 +367,7 @@ class AsyncHttpTransportTest { // given val now = Date(9001) fixture.sentryOptions.dateProvider = mock() - whenever(fixture.sentryOptions.dateProvider.now()).thenReturn(SentryNanotimeDate(now, 0)) + whenever(fixture.sentryOptions.dateProvider.now()).thenReturn(SentryNanotimeDate(now.time, 0)) val envelope = SentryEnvelope.from(fixture.sentryOptions.serializer, createSession(), null) whenever(fixture.transportGate.isConnected).thenReturn(true) @@ -387,7 +387,7 @@ class AsyncHttpTransportTest { // given val now = Date(9001) fixture.sentryOptions.dateProvider = mock() - whenever(fixture.sentryOptions.dateProvider.now()).thenReturn(SentryNanotimeDate(now, 0)) + whenever(fixture.sentryOptions.dateProvider.now()).thenReturn(SentryNanotimeDate(now.time, 0)) val envelope = SentryEnvelope.from(fixture.sentryOptions.serializer, createSession(), null) whenever(fixture.transportGate.isConnected).thenReturn(true) From f944a75eb71cc82131a624d4fd91edeaa42bab7b Mon Sep 17 00:00:00 2001 From: runningcode <332597+runningcode@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:53:20 +0000 Subject: [PATCH 014/195] release: 8.44.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0de5fc72452..4cf56d17cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.44.0 ### Features diff --git a/gradle.properties b/gradle.properties index 35641a00053..19127ac9832 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.experimental.lint.version=8.13.1 # Release information -versionName=8.43.2 +versionName=8.44.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 5dc86e8f233e15687c3a51ce4d05226196638f14 Mon Sep 17 00:00:00 2001 From: arb Date: Wed, 17 Jun 2026 17:05:40 +0200 Subject: [PATCH 015/195] chore(android-sqlite): Remove calls to deprecated SentryNanotimeDate constructor (#5562) --- .../java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt | 3 +-- .../sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt | 3 +-- .../java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt | 7 +++---- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt index 5099f38f691..f0998dfdc23 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt @@ -10,7 +10,6 @@ import io.sentry.SentryNanotimeDate import io.sentry.SentryStackTraceFactory import io.sentry.SpanDataConvention import io.sentry.SpanStatus -import java.util.Date private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" @@ -18,7 +17,7 @@ private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" * Sentinel for extracting a [SentryNanotimeDate]'s underlying [System.nanoTime] value via * [SentryDate.diff]. */ -private val EMPTY_NANO_TIME = SentryNanotimeDate(Date(0), 0L) +private val EMPTY_NANO_TIME = SentryNanotimeDate(0, 0L) /** Span instrumentation for [SentrySQLiteDriver]. */ internal class SQLiteSpanInstrumentation( diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt index 92a98b6e56d..13ae1389b77 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/ComputeNanoStartTimestampForChildTest.kt @@ -4,7 +4,6 @@ import io.sentry.DateUtils import io.sentry.ISpan import io.sentry.SentryLongDate import io.sentry.SentryNanotimeDate -import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -86,7 +85,7 @@ class ComputeNanoStartTimestampForChildTest { } private fun spanWithNanotimeStart(wallClockMillis: Long, parentMonotonicNanos: Long): ISpan { - val startDate = SentryNanotimeDate(Date(wallClockMillis), parentMonotonicNanos) + val startDate = SentryNanotimeDate(wallClockMillis, parentMonotonicNanos) val span = mock() whenever(span.startDate).thenReturn(startDate) return span diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt index a38be242ec5..74bd1c7f882 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt @@ -11,7 +11,6 @@ import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.util.thread.IThreadChecker -import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -51,7 +50,7 @@ class SQLiteSpanInstrumentationTest { // Only the parent date is queued. If startTimestamp() were to call dateProvider.now(), // the queue would underflow and the test would fail loudly — this is what verifies the // optimization is in effect. - val parentDate = SentryNanotimeDate(Date(1_000_000L), 100_000_000L) + val parentDate = SentryNanotimeDate(1_000_000L, 100_000_000L) val sut = setUpWithNanotimeDates(parentDate) val start = sut.startTimestamp() @@ -71,7 +70,7 @@ class SQLiteSpanInstrumentationTest { @Test fun `startTimestamp falls back to date provider when parent does not use SentryNanotimeDate`() { - val providerDate = SentryNanotimeDate(Date(2_000_000L), 200_000_000L) + val providerDate = SentryNanotimeDate(2_000_000L, 200_000_000L) val parentSpan = mock() whenever(parentSpan.startDate).thenReturn(SentryLongDate(1_000_000_000_000_000L)) val options = @@ -89,7 +88,7 @@ class SQLiteSpanInstrumentationTest { @Test fun `startTimestamp falls back to date provider when no transaction is active`() { - val providerDate = SentryNanotimeDate(Date(2_000_000L), 200_000_000L) + val providerDate = SentryNanotimeDate(2_000_000L, 200_000_000L) val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" From 10a0bc2fdb190596413bf6d105438474c6663445 Mon Sep 17 00:00:00 2001 From: arb Date: Thu, 18 Jun 2026 11:04:24 +0200 Subject: [PATCH 016/195] feat(android-sqlite): Make SentrySQLiteDriver experimental (JAVA-275) (#5563) Makes SentrySQLiteDriver public + experimental during development. In particular, lets us access the driver via the Sentry Android sample app. --- CHANGELOG.md | 8 ++++++++ sentry-android-sqlite/api/sentry-android-sqlite.api | 12 ++++++++++++ sentry-android-sqlite/build.gradle.kts | 5 +++++ .../main/java/io/sentry/sqlite/SentrySQLiteDriver.kt | 10 +++++++--- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf56d17cee..8428f033b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Features + +- Add experimental `SentrySQLiteDriver` to `sentry-android-sqlite` for instrumenting `androidx.sqlite.SQLiteDriver` ([#5563](https://github.com/getsentry/sentry-java/pull/5563)) + - To use it, pass `SQLiteDriver` to `SentrySQLiteDriver.create(...)` + - Requires `androidx.sqlite:sqlite` (2.5.0+) on runtime classpath (typically provided by Room or SQLDelight) + ## 8.44.0 ### Features diff --git a/sentry-android-sqlite/api/sentry-android-sqlite.api b/sentry-android-sqlite/api/sentry-android-sqlite.api index c8780f1338d..7b9f633b46a 100644 --- a/sentry-android-sqlite/api/sentry-android-sqlite.api +++ b/sentry-android-sqlite/api/sentry-android-sqlite.api @@ -21,3 +21,15 @@ public final class io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper$Compan public final fun create (Landroidx/sqlite/db/SupportSQLiteOpenHelper;)Landroidx/sqlite/db/SupportSQLiteOpenHelper; } +public final class io/sentry/sqlite/SentrySQLiteDriver : androidx/sqlite/SQLiteDriver { + public static final field Companion Lio/sentry/sqlite/SentrySQLiteDriver$Companion; + public synthetic fun (Landroidx/sqlite/SQLiteDriver;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver; + public fun hasConnectionPool ()Z + public fun open (Ljava/lang/String;)Landroidx/sqlite/SQLiteConnection; +} + +public final class io/sentry/sqlite/SentrySQLiteDriver$Companion { + public final fun create (Landroidx/sqlite/SQLiteDriver;)Landroidx/sqlite/SQLiteDriver; +} + diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index dd28252665e..6e0275b29b8 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -47,6 +47,10 @@ android { buildFeatures { buildConfig = true } + // Needed b/c Kotlin 1.4.x would otherwise pull in an older version without the annotations we + // want. + configurations.all { resolutionStrategy.force(libs.jetbrains.annotations.get()) } + androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) } @@ -65,6 +69,7 @@ dependencies { api(projects.sentry) compileOnly(libs.androidx.sqlite) + compileOnly(libs.jetbrains.annotations) implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid)) diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt index 9a619c418a5..e869778b811 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -5,6 +5,7 @@ import androidx.sqlite.SQLiteDriver import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel +import org.jetbrains.annotations.ApiStatus /** * Wraps a [SQLiteDriver] and automatically adds spans for each SQL statement it executes. @@ -28,13 +29,16 @@ import io.sentry.SentryLevel * * @param delegate The [SQLiteDriver] instance to delegate calls to. */ -internal class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) : +@ApiStatus.Experimental +public class SentrySQLiteDriver private constructor(private val delegate: SQLiteDriver) : SQLiteDriver { init { SentryIntegrationPackageStorage.getInstance().addIntegration("SQLiteDriver") } + @Suppress("INAPPLICABLE_JVM_NAME") + @get:JvmName("hasConnectionPool") override val hasConnectionPool: Boolean get() = try { @@ -66,14 +70,14 @@ internal class SentrySQLiteDriver private constructor(private val delegate: SQLi } } - companion object { + public companion object { /** * Wraps the provided delegate in a [SentrySQLiteDriver]. Returns the delegate as-is if already * wrapped. */ @JvmStatic - fun create(delegate: SQLiteDriver): SQLiteDriver = + public fun create(delegate: SQLiteDriver): SQLiteDriver = delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate) } } From f6192aacb057496dd89e4fdb72ed2741e50e03ee Mon Sep 17 00:00:00 2001 From: arb Date: Thu, 18 Jun 2026 11:38:27 +0200 Subject: [PATCH 017/195] chore(android-sqlite): Add SQLite samples to sentry-samples-android (#5504) Adds our SQLite integrations to sentry-android-samples (`SentrySQLiteDriver` and `SentrySupportOpenSQLiteHelper`). The entry point is `SQLiteActivity`. Example SQL statements are identical across integrations so we can observe similarities / differences in how they handle spans. Users can exercise the integrations directly or via Room or SQLDelight. --- gradle/libs.versions.toml | 31 +- .../sentry-samples-android/README.md | 2 +- .../sentry-samples-android/build.gradle.kts | 42 +- .../src/main/AndroidManifest.xml | 8 + .../io/sentry/samples/android/MainActivity.kt | 14 + .../sentry/samples/android/MyApplication.java | 3 + .../samples/android/sqlite/DisplayInfo.kt | 106 +++ .../sentry/samples/android/sqlite/Room2Dao.kt | 42 ++ .../sentry/samples/android/sqlite/Room3Dao.kt | 42 ++ .../samples/android/sqlite/SQLiteActivity.kt | 621 ++++++++++++++++++ .../samples/android/sqlite/SampleDatabases.kt | 222 +++++++ .../io/sentry/samples/android/sqlite/Song.sq | 17 + .../samples/android/sqlite/SqlStatements.kt | 226 +++++++ .../samples/android/sqlite/UiLoadActivity.kt | 69 ++ .../samples/android/sqlite/UiLoadScreen.kt | 110 ++++ 15 files changed, 1542 insertions(+), 13 deletions(-) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room2Dao.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c16a87ad9b6..91a7669194f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,15 +5,18 @@ androidxNavigation = "2.4.2" androidxTestCore = "1.7.0" androidxCompose = "1.6.3" asyncProfiler = "4.4" +camerax = "1.4.0" composeCompiler = "1.5.14" coroutines = "1.6.1" espresso = "3.7.0" feign = "11.6" +gummyBears = "0.12.0" jackson = "2.18.3" jetbrainsCompose = "1.6.11" kotlin = "2.2.0" kotlinSpring7 = "2.2.0" kotlin-compatible-version = "1.9" +ksp = "2.3.9" ktorClient = "3.0.0" logback = "1.2.9" log4j2 = "2.20.0" @@ -21,6 +24,7 @@ nopen = "1.0.1" # see https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html#kotlin-compatibility # see https://developer.android.com/jetpack/androidx/releases/compose-kotlin okhttp = "4.9.2" +openfeature = "1.18.2" otel = "1.60.1" otelInstrumentation = "2.26.0" otelInstrumentationAlpha = "2.26.0-alpha" @@ -28,19 +32,22 @@ otelInstrumentationAlpha = "2.26.0-alpha" otelSemanticConventions = "1.40.0" otelSemanticConventionsAlpha = "1.40.0-alpha" retrofit = "2.9.0" +room2 = "2.8.4" +room3 = "3.0.0-alpha06" sagp = "6.10.0" +sqlite = "2.6.2" +sqliteAlpha = "2.7.0-alpha06" # Required by Room3 3.0.0-alpha* slf4j = "1.7.30" +spotless = "8.4.0" springboot2 = "2.7.18" springboot3 = "3.5.0" springboot4 = "4.0.0" +sqldelight = "2.3.2" + # Android targetSdk = "36" compileSdk = "36" minSdk = "21" -spotless = "8.4.0" -gummyBears = "0.12.0" -camerax = "1.4.0" -openfeature = "1.18.2" [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } @@ -50,6 +57,7 @@ kotlin-jvm-spring7 = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinSpr kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } buildconfig = { id = "com.github.gmazzo.buildconfig", version = "5.6.5" } dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } dokka-javadoc = { id = "org.jetbrains.dokka-javadoc", version = "2.0.0" } @@ -62,6 +70,7 @@ vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.3 springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" } springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } gretty = { id = "org.gretty", version = "4.0.0" } animalsniffer = { id = "ru.vyarus.animalsniffer", version = "2.0.1" } sentry = { id = "io.sentry.android.gradle", version.ref = "sagp"} @@ -92,7 +101,14 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidxLifecycle" } androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "androidxNavigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidxNavigation" } -androidx-sqlite = { module = "androidx.sqlite:sqlite", version = "2.6.2" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room2" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" } +androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } +androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteAlpha" } +androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteAlpha" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } @@ -205,6 +221,7 @@ springboot4-starter-jdbc = { module = "org.springframework.boot:spring-boot-star springboot4-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot4" } springboot4-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache", version.ref = "springboot4" } springboot4-starter-kafka = { module = "org.springframework.boot:spring-boot-starter-kafka", version.ref = "springboot4" } +sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } timber = { module = "com.jakewharton.timber:timber", version = "4.7.1" } # Animalsniffer signature @@ -248,3 +265,7 @@ msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } + +[bundles] +androidx-room2 = ["androidx-room-runtime", "androidx-room-ktx"] +androidx-sqlite-drivers = ["androidx-sqlite-bundled", "androidx-sqlite-framework"] diff --git a/sentry-samples/sentry-samples-android/README.md b/sentry-samples/sentry-samples-android/README.md index f5c8caf8685..99d0edcd1c3 100644 --- a/sentry-samples/sentry-samples-android/README.md +++ b/sentry-samples/sentry-samples-android/README.md @@ -1,7 +1,7 @@ # Sentry Sample Android App Sample application demonstrating how to use the Sentry Android SDK, including core functionality (error reporting, tracing, session replay, -profiling) and integrations (Compose, OkHttp, etc.). +profiling) and integrations (Compose, OkHttp, SQLite, etc.). ## How to run it? diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index e19c02700fb..74e3c3a57b8 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -9,7 +9,9 @@ plugins { id("com.android.application") alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) alias(libs.plugins.sentry) apply false + alias(libs.plugins.sqldelight) } if (providers.gradleProperty("useSagp").isPresent) { @@ -26,9 +28,9 @@ plugins.withId("io.sentry.android.gradle") { tracingInstrumentation { features.set( setOf( + // FILE_IO is disabled for non-SAGP builds. InstrumentationFeature.COMPOSE, InstrumentationFeature.DATABASE, - InstrumentationFeature.FILE_IO, InstrumentationFeature.OKHTTP, ) ) @@ -44,7 +46,8 @@ android { defaultConfig { applicationId = "io.sentry.samples.android" - minSdk = libs.versions.minSdk.get().toInt() + // androidx.sqlite 2.6+ require minSdk 23; the Sentry SDK still supports 21. + minSdk = 23 targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 2 versionName = project.version.toString() @@ -119,7 +122,13 @@ android { } } - kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + // Java 11 b/c androidx.room3 requires it. + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 } androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) @@ -145,6 +154,17 @@ android { @Suppress("UnstableApiUsage") packagingOptions { jniLibs { useLegacyPackaging = true } } } +sqldelight { + databases { + create("SampleSQLDelightDatabase") { + packageName.set("io.sentry.samples.android.sqlite") + // Keep .sq files next to the hand-written Kotlin (src/main/java/.../sqlite) instead of the + // default src/main/sqldelight source root. + srcDirs("src/main/java") + } + } +} + dependencies { implementation( kotlin(Config.kotlinStdLib, org.jetbrains.kotlin.config.KotlinCompilerVersion.VERSION) @@ -152,6 +172,7 @@ dependencies { implementation(projects.sentryAndroid) implementation(projects.sentryAndroidFragment) + implementation(projects.sentryAndroidSqlite) implementation(projects.sentryAndroidTimber) implementation(projects.sentryCompose) implementation(projects.sentryKotlinExtensions) @@ -177,17 +198,24 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.recyclerview) implementation(libs.androidx.browser) + implementation(libs.androidx.room3.runtime) + implementation(libs.bundles.androidx.room2) + implementation(libs.bundles.androidx.sqlite.drivers) + implementation(libs.camerax.camera2) + implementation(libs.camerax.core) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) implementation(libs.coil.compose) implementation(libs.kotlinx.coroutines.android) implementation(libs.lottie.compose) implementation(libs.retrofit) implementation(libs.retrofit.gson) implementation(libs.sentry.native.ndk) + implementation(libs.sqldelight.android.driver) implementation(libs.timber) - implementation(libs.camerax.core) - implementation(libs.camerax.camera2) - implementation(libs.camerax.lifecycle) - implementation(libs.camerax.view) + + ksp(libs.androidx.room.compiler) + ksp(libs.androidx.room3.compiler) debugImplementation(projects.sentryAndroidDistribution) debugImplementation(libs.leakcanary) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 14c8b595fd3..1150dd5ef2e 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -101,6 +101,14 @@ android:name=".TriggerHttpRequestActivity" android:exported="false" /> + + + + ) + + @Query("SELECT * FROM song") suspend fun getAll(): List + + @Query("SELECT count(*) FROM song") suspend fun count(): Int + + /** + * No-op write (matches no rows) used at warm-up to open Room's writer connection up front. A read + * like [count] only opens a reader, so without this the first INSERT would (noisily) open and + * bootstrap the writer connection inside a demo transaction. + */ + @Query("DELETE FROM song WHERE id < 0") suspend fun primeWriter() +} + +@Database(entities = [SongEntity::class], version = 1, exportSchema = false) +abstract class SampleRoom2Database : RoomDatabase() { + + abstract fun songDao(): SongDao +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt new file mode 100644 index 00000000000..145e12d3897 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Room3Dao.kt @@ -0,0 +1,42 @@ +package io.sentry.samples.android.sqlite + +import androidx.room3.Dao +import androidx.room3.Database +import androidx.room3.Entity +import androidx.room3.Insert +import androidx.room3.PrimaryKey +import androidx.room3.Query +import androidx.room3.RoomDatabase + +@Entity(tableName = "song") +data class SongEntity3( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val title: String, + val artist: String, +) + +@Dao +interface SongDao3 { + + @Insert suspend fun insert(song: SongEntity3) + + /** Batch insert: Room runs all rows in a single transaction, reusing one compiled statement. */ + @Insert suspend fun insertAll(songs: List) + + @Query("SELECT * FROM song") suspend fun getAll(): List + + @Query("SELECT count(*) FROM song") suspend fun count(): Int + + /** + * No-op write (matches no rows) used at warm-up to open Room's writer connection up front. A read + * like [count] only opens a reader, so without this the first INSERT would (noisily) open and + * bootstrap the writer connection inside a demo transaction. + */ + @Query("DELETE FROM song WHERE id < 0") suspend fun primeWriter() +} + +@Database(entities = [SongEntity3::class], version = 1, exportSchema = false) +abstract class SampleRoom3Database : RoomDatabase() { + + abstract fun songDao(): SongDao3 +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt new file mode 100644 index 00000000000..1ff6828a757 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -0,0 +1,621 @@ +package io.sentry.samples.android.sqlite + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.keyframes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.HelpOutline +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchColors +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.lifecycleScope +import io.sentry.Sentry +import io.sentry.SpanId +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.SentryId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val SentryPink = Color(0xFFC85B9C) +private val SentryPurple = Color(0xFF7B52FB) +private val SentryRed = Color(0xFFF55459) + +/** Intro text, surfaced via the "?" tooltip next to the "Run it" header. */ +private const val INSTRUCTIONS = + "Tap a button to execute a SQL statement in its own transaction; long press to run it in a ui.load transaction." + +/** Start state of the "SQL run" box. */ +private const val SQL_DETAIL_HINT = "Tap a button above to see the SQL it runs…" + +private val TOGGLE_SECTION_GAP = 24.dp + +private val CONTROL_SECTION_GAP = TOGGLE_SECTION_GAP * 2 + +private val SECTION_HEADER_HEIGHT = 28.dp + +/** Which sentry-android-sqlite integration the demo buttons currently target. */ +private enum class Integration(val color: Color, val apiName: String) { + DRIVER(SentryPurple, "SQLiteDriver"), + OPEN_HELPER(SentryPink, "SupportSQLiteOpenHelper"), +} + +/** + * How one demo button behaves for a given integration: which [SqlStatements] work it runs ([demo]), + * the name/op of the manual transaction a tap wraps it in, and the SQL summary shown in the detail + * panel ([displayInfo]). + */ +private class DemoVariant( + val demo: SqlDemo, + val transactionName: String, + val op: String, + val displayInfo: DisplayInfo, +) + +/** + * A single demo button in the list. [driver] / [openHelper] hold the variant for each integration; + * a null variant means the row doesn't apply to that integration and renders dimmed, explaining why + * on click (Room 3 is driver-only; SQLDelight is open-helper-only). + */ +private class DemoRow(val label: String, val driver: DemoVariant?, val openHelper: DemoVariant?) + +// The demo buttons, top to bottom, paired with each integration's variant. Pure data — the actual +// SQL lives in SqlStatements, dispatched by id. +private val DEMO_ROWS = + listOf( + DemoRow( + label = "Direct (no library)", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_DIRECT, + transactionName = "SentrySQLiteDriver — Direct", + op = "db.sql.driver-direct", + displayInfo = DRIVER_DIRECT, + ), + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_DIRECT, + transactionName = "SentrySupportSQLiteOpenHelper — Direct", + op = "db.sql.openhelper-direct", + displayInfo = OPENHELPER_DIRECT, + ), + ), + DemoRow( + label = "Room 2", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_ROOM2, + transactionName = "SentrySQLiteDriver — Room 2", + op = "db.sql.driver-room2", + displayInfo = DRIVER_ROOM2, + ), + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_ROOM, + transactionName = "SentrySupportSQLiteOpenHelper — Room", + op = "db.sql.openhelper-room", + displayInfo = OPENHELPER_ROOM, + ), + ), + DemoRow( + label = "Room 3", + driver = + DemoVariant( + demo = SqlDemo.DRIVER_ROOM3, + transactionName = "SentrySQLiteDriver — Room 3", + op = "db.sql.driver-room3", + displayInfo = DRIVER_ROOM3, + ), + openHelper = null, // Room 3 only runs on the SQLiteDriver path. + ), + DemoRow( + label = "SQLDelight", + driver = null, // SQLDelight's AndroidSqliteDriver is built on SupportSQLiteOpenHelper. + openHelper = + DemoVariant( + demo = SqlDemo.OPENHELPER_SQLDELIGHT, + transactionName = "SentrySupportSQLiteOpenHelper — SQLDelight", + op = "db.sql.openhelper-sqldelight", + displayInfo = OPENHELPER_SQLDELIGHT, + ), + ), + ) + +/** + * Activity that lets us exercise our two `sentry-android-sqlite` integrations + * ([SentrySQLiteDriver][io.sentry.sqlite.SentrySQLiteDriver] and + * [SentrySupportSQLiteOpenHelper][io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper]), both + * directly and via Room or SQLDelight. + * + * Example SQL statements are deliberately identical across integrations so we can identify + * similarities and differences in their transaction / span support. + */ +class SQLiteActivity : ComponentActivity() { + + private var latestResult by mutableStateOf("") + private var sqlDetail by mutableStateOf(SQL_DETAIL_HINT) + private var heavyWork by mutableStateOf(false) + + /** + * When enabled, every per-button transaction in one screen visit continues [screenTraceHeader], + * so they all share a trace ("session"-like). When disabled (the default), each tap is the root + * of its own trace, which renders as a standalone waterfall scaled to that one transaction — + * easier to read how time is allocated among its spans. + */ + private var shareScreenTrace by mutableStateOf(false) + + /** Which integration the demo buttons target. Switching it disables the rows that don't apply. */ + private var integration by mutableStateOf(Integration.DRIVER) + + /** Incremented on each tap that runs SQL. Used to retrigger the detail box's outline shimmer. */ + private var runTick by mutableStateOf(0) + + /** True while a demo or reset is running SQL on a background thread. */ + private var dbOperationInFlight by mutableStateOf(false) + + /** True for the duration of a reset; disables the reset button immediately (no debounce). */ + private var resetInProgress by mutableStateOf(false) + + /** + * The shared trace used when [shareScreenTrace] is enabled: one trace per visit to this screen. + * onResume() generates a fresh one each time the screen is (re)entered. + */ + private var screenTraceHeader = newScreenTrace() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme { + Surface { + Column( + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val screenHeightDp = LocalConfiguration.current.screenHeightDp + // A small gap below the screen title that grows with screen height and collapses to 0 + // on short screens, so the title isn't crowded against "Configure it" on tall devices. + val titleGap = + (((((screenHeightDp / 4) - 48) / 3).coerceAtLeast(0).dp + TOGGLE_SECTION_GAP) / 2 - + SECTION_HEADER_HEIGHT) + .coerceAtLeast(0.dp) + + // Pulse the "Under the hood" outline in the integration color whenever a tap runs SQL. + val shimmer = remember { Animatable(0f) } + LaunchedEffect(runTick) { + if (runTick == 0) return@LaunchedEffect + shimmer.animateTo( + targetValue = 0f, + animationSpec = + keyframes { + durationMillis = 900 + 0f at 0 + 1f at 200 + 0.4f at 450 + 1f at 650 + 0f at 900 + }, + ) + } + + val detailOutline = + lerp(MaterialTheme.colorScheme.outline, integration.color, shimmer.value) + + Text(text = "SQLite Instrumentation", style = MaterialTheme.typography.headlineSmall) + + Spacer(Modifier.height(titleGap)) + + SectionHeader("Configure it") + + val openHelper = integration == Integration.OPEN_HELPER + val integrationSwitchColors = + SwitchDefaults.colors( + checkedTrackColor = SentryPink, + checkedBorderColor = SentryPink, + uncheckedTrackColor = SentryPurple, + uncheckedBorderColor = SentryPurple, + uncheckedThumbColor = Color.White, + ) + val controlSwitchColors = + SwitchDefaults.colors( + checkedTrackColor = Color.Black, + checkedBorderColor = Color.Black, + ) + ToggleRow( + label = if (openHelper) "SentrySupportSQLiteOpenHelper" else "SentrySQLiteDriver", + checked = openHelper, + labelColor = if (openHelper) SentryPink else SentryPurple, + switchColors = integrationSwitchColors, + ) { + integration = if (it) Integration.OPEN_HELPER else Integration.DRIVER + // Switching integration starts a fresh comparison: clear the detail box and result. + sqlDetail = SQL_DETAIL_HINT + latestResult = "" + } + ToggleRow( + label = if (heavyWork) "Heavy app-level work" else "No app-level work", + checked = heavyWork, + switchColors = controlSwitchColors, + ) { + heavyWork = it + } + ToggleRow( + label = + if (shareScreenTrace) "Single trace for all button clicks" + else "Separate trace per button click", + checked = shareScreenTrace, + switchColors = controlSwitchColors, + ) { + shareScreenTrace = it + } + + SectionHeader("Run it", topPadding = CONTROL_SECTION_GAP) { HelpTooltip() } + + // One consolidated list of demo buttons. Each row dispatches to the selected + // integration's variant; a row that doesn't apply explains why via a toast (see + // [DemoRowButton]). + DEMO_ROWS.forEach { row -> + val variant = if (integration == Integration.DRIVER) row.driver else row.openHelper + DemoRowButton( + label = row.label, + color = integration.color, + variant = variant, + disabledReason = "${row.label} doesn't use the ${integration.apiName}", + ) + } + + ResetButton( + dbOperationInFlight = dbOperationInFlight, + resetInProgress = resetInProgress, + ) + + // Same [CONTROL_SECTION_GAP] above as the other sections, separating the controls from + // the detail output. + SectionHeader("Under the hood", topPadding = CONTROL_SECTION_GAP) + // The latest run result (row counts, errors). Hidden until the first run. + if (latestResult.isNotEmpty()) { + Text( + text = latestResult, + style = MaterialTheme.typography.bodyMedium, + color = if (latestResult.contains("failed")) SentryRed else Color.Unspecified, + ) + } + DetailField("SQL run", sqlDetail, borderColor = detailOutline) + } + } + } + } + } + + override fun onResume() { + super.onResume() + // Start a new trace each time the user (re)enters the screen, so each visit is its own session. + screenTraceHeader = newScreenTrace() + } + + /** Run the variant's SQL statement inside a manual, scope-bound transaction. */ + private fun onTap(variant: DemoVariant) { + if (dbOperationInFlight) return + + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql + runTick++ // shimmer the detail box outline in the integration color + + lifecycleScope.launch { + dbOperationInFlight = true + try { + latestResult = + withContext(Dispatchers.IO) { + runInTransaction(variant.transactionName, variant.op) { + SqlStatements.execute(applicationContext, variant.demo, heavyWork) + } + } + } finally { + dbOperationInFlight = false + } + } + } + + /** + * Run the variant's SQL statement in [UiLoadActivity] with no manual transaction, so its auto + * `ui.load` transaction owns the spans. + */ + private fun onLongPress(variant: DemoVariant) { + if (dbOperationInFlight) return + + sqlDetail = if (heavyWork) variant.displayInfo.sqlHeavy else variant.displayInfo.sql + latestResult = "Opened the auto-load screen — its ui.load transaction owns the db spans." + startActivity(UiLoadActivity.intent(this, variant.demo, heavyWork)) + } + + /** + * A compact, left-justified labeled switch. [labelColor] defaults to [Color.Unspecified] so the + * label inherits the default text color; the integration toggle passes its pink/purple instead. + */ + @androidx.compose.runtime.Composable + private fun ToggleRow( + label: String, + checked: Boolean, + modifier: Modifier = Modifier, + labelColor: Color = Color.Unspecified, + switchColors: SwitchColors = SwitchDefaults.colors(), + onCheckedChange: (Boolean) -> Unit, + ) { + // Constrain the row height: a Switch otherwise reserves ~48dp, leaving a large gap between the + // toggles. 32dp keeps them about one line of text apart. + Row(modifier = modifier.height(32.dp), verticalAlignment = Alignment.CenterVertically) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = switchColors, + modifier = Modifier.scale(0.75f), + ) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = labelColor, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + + @androidx.compose.runtime.Composable + private fun SectionHeader( + title: String, + topPadding: Dp = 8.dp, + trailing: (@androidx.compose.runtime.Composable () -> Unit)? = null, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(top = topPadding)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + trailing?.invoke() + } + HorizontalDivider(thickness = 1.dp, modifier = Modifier.padding(top = 4.dp)) + } + } + + /** + * A circled "?" next to the "Run it" header. Tapping it briefly shows the [INSTRUCTIONS] in a + * tooltip that auto-dismisses after a few seconds. + */ + @OptIn(ExperimentalMaterial3Api::class) + @androidx.compose.runtime.Composable + private fun HelpTooltip() { + val tooltipState = rememberTooltipState(isPersistent = true) + val scope = rememberCoroutineScope() + LaunchedEffect(tooltipState.isVisible) { + if (tooltipState.isVisible) { + delay(4000) + tooltipState.dismiss() + } + } + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(INSTRUCTIONS) } }, + state = tooltipState, + ) { + Icon( + imageVector = Icons.Outlined.HelpOutline, + contentDescription = "What do the buttons do?", + tint = Color.Gray, + modifier = + Modifier.padding(start = 8.dp).size(20.dp).clickable { + scope.launch { tooltipState.show() } + }, + ) + } + } + + /** + * A filled button that runs [variant] on tap (manual transaction) or long-press (ui.load). It's a + * [Surface] rather than a [Button] because Material3's Button has no long-press hook; the + * [combinedClickable] modifier gives us both. + * + * A null [variant] means the row doesn't apply to the selected integration: the button renders + * dimmed and, when clicked, explains why via a toast ([disabledReason]) instead of running. + */ + @OptIn(ExperimentalFoundationApi::class) + @androidx.compose.runtime.Composable + private fun DemoRowButton( + label: String, + color: Color, + variant: DemoVariant?, + disabledReason: String, + ) { + val context = LocalContext.current + val enabled = variant != null + val explain = { Toast.makeText(context, disabledReason, Toast.LENGTH_SHORT).show() } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = ButtonDefaults.shape, + color = if (enabled) color else color.copy(alpha = 0.26f), + contentColor = Color.White, + ) { + Box( + modifier = + Modifier.combinedClickable( + onClick = { if (variant != null) onTap(variant) else explain() }, + onLongClick = { if (variant != null) onLongPress(variant) else explain() }, + ) + .fillMaxWidth() + .heightIn(min = 44.dp) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, style = MaterialTheme.typography.labelLarge) + } + } + } + + @androidx.compose.runtime.Composable + private fun ResetButton(dbOperationInFlight: Boolean, resetInProgress: Boolean) { + // Debounce demo-driven disablement so fast taps don't flicker the button; reset disables + // immediately via [resetInProgress]. [dbOperationInFlight] still guards [onClick] either way. + var enabled by remember { mutableStateOf(true) } + LaunchedEffect(dbOperationInFlight, resetInProgress) { + when { + resetInProgress -> enabled = false + dbOperationInFlight -> { + delay(RESET_DISABLE_DEBOUNCE_MS) + enabled = false + } + else -> enabled = true + } + } + + Button( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + enabled = enabled, + colors = ButtonDefaults.buttonColors(containerColor = Color.Gray, contentColor = Color.White), + onClick = { + if (dbOperationInFlight) return@Button + lifecycleScope.launch { + this@SQLiteActivity.resetInProgress = true + this@SQLiteActivity.dbOperationInFlight = true + try { + val message = withContext(Dispatchers.IO) { resetDatabases() } + latestResult = message + sqlDetail = "DROP: deletes every demo database file, resetting all row counts to 0." + } finally { + this@SQLiteActivity.dbOperationInFlight = false + this@SQLiteActivity.resetInProgress = false + } + } + }, + ) { + Text("Drop all tables (reset)") + } + } + + @androidx.compose.runtime.Composable + private fun DetailField(label: String, value: String, borderColor: Color) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + textStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 12.sp), + // The border color is driven by the shimmer animation so the box pulses on each SQL run. + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = borderColor, + unfocusedBorderColor = borderColor, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + + /** + * Runs [block] inside a scope-bound transaction and returns the result. When [shareScreenTrace] + * is enabled, the transaction continues this screen's trace so all demos in one visit share a + * trace; otherwise it starts its own trace (1 transaction = 1 trace). + */ + private suspend fun runInTransaction( + transactionName: String, + op: String, + block: suspend () -> String, + ): String { + // Continuing the screen trace keeps the shared trace id but mints a fresh span id for this + // transaction; the standalone path (and the continueTrace fallback when tracing is disabled) + // gives the transaction its own trace. + val context = + if (shareScreenTrace) { + Sentry.continueTrace(screenTraceHeader, null)?.apply { + name = transactionName + operation = op + } ?: TransactionContext(transactionName, op) + } else { + TransactionContext(transactionName, op) + } + + val options = TransactionOptions().apply { isBindToScope = true } + val transaction = Sentry.startTransaction(context, options) + + return try { + val result = block() + transaction.status = SpanStatus.OK + result + } catch (t: Throwable) { + transaction.status = SpanStatus.INTERNAL_ERROR + "$transactionName failed: ${t.message}" + } finally { + transaction.finish() + } + } + + /** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */ + private suspend fun resetDatabases(): String { + val cleared = SampleDatabases.reset(applicationContext) + return "Dropped tables: cleared $cleared database file(s)." + } + + private companion object { + + /** Demo SQL shorter than this won't visibly disable the reset button. */ + private const val RESET_DISABLE_DEBOUNCE_MS = 300L + + /** + * Builds a fresh sentry-trace header ("--") representing this screen + * visit's trace. The trailing "-1" marks it sampled so the whole session is kept. + */ + private fun newScreenTrace(): String = "${SentryId()}-${SpanId()}-1" + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt new file mode 100644 index 00000000000..63f217fcfbb --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -0,0 +1,222 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import androidx.room.Room +import androidx.room3.Room as Room3 +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper +import io.sentry.samples.android.sqlite.SampleDatabases.driverDirectLock +import io.sentry.samples.android.sqlite.SampleDatabases.openHelperDirectLock +import io.sentry.samples.android.sqlite.SampleDatabases.reset +import io.sentry.samples.android.sqlite.SampleDatabases.warmUp +import io.sentry.sqlite.SentrySQLiteDriver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Process-lifetime holder for the demo databases used by [SQLiteActivity]. + * + * Real apps open a database once (commonly a DI singleton) and keep it open for the process, so a + * screen that touches the DB almost always finds it already "warm". We model that here: [warmUp] is + * called from `MyApplication` at launch, off the main thread, so the one-time open + Room + * connection-pool bootstrap happens with no active transaction — those `db.sql.query` spans have + * nothing to attach to and are dropped. Every screen afterward reuses the warm handle and records + * only its statements of interest. + * + * Handles are held for the whole process: Android has no reliable "app closed" callback, and the OS + * reclaims the connections on process death, so we never close them except via [reset] (the "Drop + * all tables" button), which closes, deletes the files, and re-warms. + * + * The two "direct" handles wrap a single raw connection that isn't safe for concurrent use, so + * callers serialize their whole unit of work via [driverDirectLock] / [openHelperDirectLock]. Room + * and SQLDelight manage their own connection pools and don't need one. + */ +object SampleDatabases { + + private val sqlAccess = Mutex() + + val driverDirectLock = Any() + val openHelperDirectLock = Any() + + /** Serializes demo SQL and [reset] so handles are never closed mid-statement. */ + suspend fun withSqlAccess(block: suspend () -> T): T = sqlAccess.withLock { block() } + + @Volatile private var driverConnection: SQLiteConnection? = null + @Volatile private var driverRoom2Db: SampleRoom2Database? = null + @Volatile private var driverRoom3Db: SampleRoom3Database? = null + @Volatile private var directHelper: SupportSQLiteOpenHelper? = null + @Volatile private var openHelperRoomDb: SampleRoom2Database? = null + @Volatile private var sqlDelightDriver: AndroidSqliteDriver? = null + + fun driverConnection(context: Context): SQLiteConnection = + synchronized(driverDirectLock) { + driverConnection + ?: SentrySQLiteDriver.create(BundledSQLiteDriver()) + .open(databaseFile(context, "driver_direct.db")) + .also { + it.execSQL(SqlStatements.CREATE_SONG) // one-time table setup, at open + driverConnection = it + } + } + + fun driverRoom2Db(context: Context): SampleRoom2Database = + synchronized(this) { + driverRoom2Db + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "driver_room2.db", + ) + .setDriver(SentrySQLiteDriver.create(BundledSQLiteDriver())) + .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(true) + .build() + .also { driverRoom2Db = it } + } + + fun driverRoom3Db(context: Context): SampleRoom3Database = + synchronized(this) { + driverRoom3Db + ?: Room3.databaseBuilder(context.applicationContext, "driver_room3.db") + .setDriver(SentrySQLiteDriver.create(BundledSQLiteDriver())) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + .also { driverRoom3Db = it } + } + + fun directHelper(context: Context): SupportSQLiteOpenHelper = + synchronized(openHelperDirectLock) { + directHelper ?: buildDirectHelper(context).also { directHelper = it } + } + + fun openHelperRoomDb(context: Context): SampleRoom2Database = + synchronized(this) { + openHelperRoomDb + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "openhelper_room.db", + ) + .openHelperFactory { configuration -> + SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + } + .fallbackToDestructiveMigration(true) + .build() + .also { openHelperRoomDb = it } + } + + fun sqlDelightDriver(context: Context): AndroidSqliteDriver = + synchronized(this) { + sqlDelightDriver + ?: AndroidSqliteDriver( + schema = SampleSQLDelightDatabase.Schema, + context = context.applicationContext, + name = "openhelper_sqldelight.db", + factory = + SupportSQLiteOpenHelper.Factory { configuration -> + SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + }, + ) + .also { sqlDelightDriver = it } + } + + private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper { + val configuration = + SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) + .name("openhelper_direct.db") + .callback( + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL(SqlStatements.CREATE_SONG) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = + Unit + } + ) + .build() + return SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + } + + /** Opens every database on a background thread, forcing the one-time open + bootstrap to run. */ + fun warmUp(context: Context) { + val appContext = context.applicationContext + // Fire-and-forget: the warm-up outlives no particular screen, so a bare scope is fine here. + CoroutineScope(Dispatchers.IO).launch { + runCatching { driverConnection(appContext) } + // primeWriter() + count() opens both Room pool connections (writer + reader), so the first + // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its + // transaction. + runCatching { driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() } + runCatching { driverRoom3Db(appContext).songDao().also { it.primeWriter() }.count() } + runCatching { directHelper(appContext).writableDatabase } + runCatching { openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() } + runCatching { + SampleSQLDelightDatabase(sqlDelightDriver(appContext)) + .songQueries + .countSongs() + .executeAsOne() + } + } + } + + /** + * Closes the open handles, deletes every demo database file, then re-warms. Returns the number of + * files cleared. Waits for any in-flight demo SQL (including [UiLoadActivity]) to finish first. + */ + suspend fun reset(context: Context): Int = withSqlAccess { + closeAll() + val appContext = context.applicationContext + val names = + listOf( + "driver_direct.db", + "driver_room2.db", + "driver_room3.db", + "openhelper_direct.db", + "openhelper_room.db", + "openhelper_sqldelight.db", + ) + val cleared = names.count { appContext.deleteDatabase(it) } + warmUp(appContext) + cleared + } + + private fun closeAll() { + synchronized(driverDirectLock) { + driverConnection?.close() + driverConnection = null + } + synchronized(openHelperDirectLock) { + directHelper?.close() + directHelper = null + } + synchronized(this) { + driverRoom2Db?.close() + driverRoom2Db = null + driverRoom3Db?.close() + driverRoom3Db = null + openHelperRoomDb?.close() + openHelperRoomDb = null + sqlDelightDriver?.close() + sqlDelightDriver = null + } + } + + private fun databaseFile(context: Context, name: String): String = + context.applicationContext.getDatabasePath(name).also { it.parentFile?.mkdirs() }.absolutePath +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq new file mode 100644 index 00000000000..345e55a3582 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/Song.sq @@ -0,0 +1,17 @@ +CREATE TABLE song ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + artist TEXT NOT NULL +); + +insertSong: +INSERT INTO song(title, artist) +VALUES (?, ?); + +selectAll: +SELECT * +FROM song; + +countSongs: +SELECT count(*) +FROM song; diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt new file mode 100644 index 00000000000..543f1169294 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt @@ -0,0 +1,226 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Rows inserted (and then consumed + processed) per demo when "heavy application-level work" is + * enabled. + */ +private const val HEAVY_ROW_COUNT = 50 + +/** + * Identifies a single SQLite demo: one of the two integrations crossed with the way it's used + * (raw/direct, Room, or SQLDelight). Used to dispatch the same SQL from both trace styles. + */ +enum class SqlDemo { + DRIVER_DIRECT, + DRIVER_ROOM2, + DRIVER_ROOM3, + OPENHELPER_DIRECT, + OPENHELPER_ROOM, + OPENHELPER_SQLDELIGHT, +} + +/** + * Executable SQL and demo runners for the SQLite sample screens. The human-readable "SQL run" + * summaries shown in the UI live in the per-demo [DisplayInfo] constants; keep those in lockstep + * with the statements here. + * + * The actual SQL each demo runs is kept separate from how its trace is created so the two screens + * can share it: + * - [SQLiteActivity]: Wraps [execute] in a manual `Sentry.startTransaction(…)`. + * - [UiLoadActivity]: Calls the same [execute] with no manual transaction, so the screen's auto + * `ui.load` transaction owns the resulting `db.sql.query` spans. + * + * All demos read the shared, already-warm handles from [SampleDatabases] and return a short status + * line. [heavy] mirrors the screen's "heavy app-level work" toggle. When enabled, each demo also + * batch inserts [HEAVY_ROW_COUNT] rows and consumes them with per-row [appWork]. + */ +object SqlStatements { + + const val CREATE_SONG = + "CREATE TABLE IF NOT EXISTS song(id INTEGER PRIMARY KEY, title TEXT, artist TEXT)" + const val INSERT_SONG = "INSERT INTO song(title, artist) VALUES (?, ?)" + const val SELECT_SONGS = "SELECT id, title, artist FROM song" + const val COUNT_SONGS = "SELECT count(*) FROM song" + + /** + * A single multi-row INSERT for [rowCount] songs, bound with [batchSongArgs]. One statement <> + * one round-trip, which is the realistic way to add a known batch of rows, rather than a loop of + * [rowCount] single-row inserts. + */ + fun insertSongsBatch(rowCount: Int): String = + "INSERT INTO song(title, artist) VALUES " + List(rowCount) { "(?, ?)" }.joinToString(", ") + + /** Flattened title/artist bind args for [insertSongsBatch]: "song 0", "artist 0", "song 1", … */ + fun batchSongArgs(rowCount: Int): Array = + Array(rowCount * 2) { i -> if (i % 2 == 0) "song ${i / 2}" else "artist ${i / 2}" } + + suspend fun execute(context: Context, demo: SqlDemo, heavy: Boolean): String = + SampleDatabases.withSqlAccess { + when (demo) { + SqlDemo.DRIVER_DIRECT -> driverDirect(context, heavy) + SqlDemo.DRIVER_ROOM2 -> driverWithRoom2(context, heavy) + SqlDemo.DRIVER_ROOM3 -> driverWithRoom3(context, heavy) + SqlDemo.OPENHELPER_DIRECT -> openHelperDirect(context, heavy) + SqlDemo.OPENHELPER_ROOM -> openHelperWithRoom(context, heavy) + SqlDemo.OPENHELPER_SQLDELIGHT -> openHelperWithSqlDelight(context, heavy) + } + } + + // --- 1. SentrySQLiteDriver, used directly ------------------------------------------------- + + private fun driverDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.driverDirectLock) { + val connection = SampleDatabases.driverConnection(context) + insert(connection, "Mishima / Closing", "Philip Glass") + insert(connection, "School of Velocity, op 299 no 1, ", "Carl Czerny") + + if (heavy) { + // One multi-row INSERT for all HEAVY_ROWS rows, rather than a naive loop of single-row + // inserts. + connection.prepare(insertSongsBatch(HEAVY_ROW_COUNT)).use { statement -> + var param = 1 + repeat(HEAVY_ROW_COUNT) { row -> + statement.bindText(param++, "song $row") + statement.bindText(param++, "artist $row") + } + statement.step() + } + + connection.prepare(SELECT_SONGS).use { statement -> + while (statement.step()) { + // Consumption: pull each column across the JNI boundary into the ART heap. + val row = "${statement.getLong(0)}:${statement.getText(1)}:${statement.getText(2)}" + // Application work: e.g. per-row decryption. + appWork(row) + } + } + } + "Driver (Direct): ${count(connection)} rows." + } + + private fun insert(connection: SQLiteConnection, title: String, artist: String) { + connection.prepare(INSERT_SONG).use { statement -> + statement.bindText(1, title) + statement.bindText(2, artist) + statement.step() + } + } + + private fun count(connection: SQLiteConnection): Long = + connection.prepare(COUNT_SONGS).use { statement -> + if (statement.step()) statement.getLong(0) else 0 + } + + // --- 2. SentrySQLiteDriver, used through Room 2.7+ ---------------------------------------- + + private suspend fun driverWithRoom2(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.driverRoom2Db(context).songDao(), "Driver (Room 2)", heavy) + + /** + * Shared Room 2 demo so the driver and open-helper paths run *identical* SQL. The only difference + * is how each integration instruments it: the driver spans every read, while the open helper's + * Room reads go via `moveToNext()` and emit no span, so only the INSERTs are spanned. + */ + private suspend fun roomDemo(dao: SongDao, label: String, heavy: Boolean): String { + dao.insert(SongEntity(title = "Spiders (Kidsmoke)", artist = "Wilco")) + if (heavy) { + // Batch insert: one insertAll() runs all rows in a single transaction, vs. a per-row loop. + dao.insertAll(List(HEAVY_ROW_COUNT) { SongEntity(title = "song $it", artist = "artist $it") }) + dao.getAll().forEach { appWork("${it.id}:${it.title}:${it.artist}") } + } + return "$label: ${dao.count()} rows." + } + + // --- 2b. SentrySQLiteDriver, used through Room 3.0+ (androidx.room3) ----------------------- + + private suspend fun driverWithRoom3(context: Context, heavy: Boolean): String { + val dao = SampleDatabases.driverRoom3Db(context).songDao() + dao.insert(SongEntity3(title = "What's Up", artist = "4 Non Blondes")) + if (heavy) { + // Batch insert: one insertAll() runs all rows in a single transaction, vs. a naive per-row + // loop. + dao.insertAll( + List(HEAVY_ROW_COUNT) { SongEntity3(title = "song $it", artist = "artist $it") } + ) + dao.getAll().forEach { appWork("${it.id}:${it.title}:${it.artist}") } + } + return "Driver (Room 3): ${dao.count()} rows." + } + + // --- 3. SentrySupportSQLiteOpenHelper, used directly -------------------------------------- + + private fun openHelperDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.openHelperDirectLock) { + // Runs the *same* SQL as driverDirect(), so the only difference you see in the Sentry UI is + // how each integration instruments identical statements. + val db = SampleDatabases.directHelper(context).writableDatabase + db.execSQL(INSERT_SONG, arrayOf("Mishima / Closing", "Philip Glass")) + db.execSQL(INSERT_SONG, arrayOf("School of Velocity, op 299 no 1, ", "Carl Czerny")) + if (heavy) { + // One multi-row INSERT for all HEAVY_ROWS rows, rather than a naive loop of single-row + // inserts. + db.execSQL(insertSongsBatch(HEAVY_ROW_COUNT), batchSongArgs(HEAVY_ROW_COUNT)) + db.query(SELECT_SONGS).use { cursor -> + while (cursor.moveToNext()) { + // Consumption: read each column out of the cursor window. + val row = "${cursor.getLong(0)}:${cursor.getString(1)}:${cursor.getString(2)}" + // Application work: e.g. per-row decryption. + appWork(row) + } + } + } + "OpenHelper (Direct): ${querySongCount(db)} rows." + } + + /** + * Runs the shared `SELECT count(*)` through the open helper and returns the value, read the + * normal way: moveToFirst() + getInt(). These are delegated straight to the underlying cursor + * (the open helper only instruments getCount()/onMove()/fillWindow()), so this read produces no + * `db.sql.query` span — the same as a real app reading a scalar count. + */ + private fun querySongCount(db: SupportSQLiteDatabase): Int = + db.query(COUNT_SONGS).use { cursor -> + cursor.moveToFirst() + cursor.getInt(0) + } + + // --- 4. SentrySupportSQLiteOpenHelper, used through Room ---------------------------------- + + // Runs the same [roomDemo] SQL as the driver path; only the instrumentation differs. + private suspend fun openHelperWithRoom(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.openHelperRoomDb(context).songDao(), "OpenHelper (Room)", heavy) + + // --- 5. SentrySupportSQLiteOpenHelper, used through SQLDelight ---------------------------- + + private fun openHelperWithSqlDelight(context: Context, heavy: Boolean): String { + val database = SampleSQLDelightDatabase(SampleDatabases.sqlDelightDriver(context)) + database.songQueries.insertSong("Nightcall", "Kavinsky") + if (heavy) { + // Wrap the batch in one transaction, vs. each insertSong() naively committing on its own. + database.transaction { + repeat(HEAVY_ROW_COUNT) { database.songQueries.insertSong("song $it", "artist $it") } + } + database.songQueries.selectAll().executeAsList().forEach { + appWork("${it.id}:${it.title}:${it.artist}") + } + } + // SQLDelight reads its cursor only via moveToNext(), which is delegated past the wrapper, so + // this count read produces no span. + val count = database.songQueries.countSongs().executeAsOne() + return "OpenHelper (SQLDelight): $count rows." + } + + /** + * Simulates per-row application-level work (e.g. decrypting a column) on consumed results. This + * is deliberately CPU-heavy and unrelated to the SQLite engine. + */ + private fun appWork(value: String) { + val digest = java.security.MessageDigest.getInstance("SHA-256") + var bytes = value.toByteArray() + repeat(500) { bytes = digest.digest(bytes) } + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt new file mode 100644 index 00000000000..b32811e8c91 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt @@ -0,0 +1,69 @@ +package io.sentry.samples.android.sqlite + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.lifecycleScope +import io.sentry.Sentry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Activity that lets us simulate SDK auto-generation of a `ui.load` transaction + attach SQLite + * statement spans to it. + * + * Timing note: the work runs off the main thread, so it finishes after the screen is first drawn. + * Time-to-full-display tracing (enabled in the manifest) keeps the `ui.load` transaction open until + * [Sentry.reportFullyDisplayed], which we call once the work completes — otherwise the transaction + * would auto-finish at first display and the late db spans would have nowhere to attach. + */ +class UiLoadActivity : ComponentActivity() { + + private var status by mutableStateOf("Running under the screen's auto ui.load transaction…") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val id = + SqlDemo.entries.find { it.name == intent.getStringExtra(EXTRA_DEMO_ID) } + ?: run { + finish() + return + } + val heavy = intent.getBooleanExtra(EXTRA_HEAVY, false) + + setContent { UiLoadScreen(status = status, onClose = ::finish) } + + // No Sentry.startTransaction(): the work runs under the auto ui.load:UiLoadActivity span. + lifecycleScope.launch { + status = + try { + val result = + withContext(Dispatchers.IO) { SqlStatements.execute(applicationContext, id, heavy) } + "$result\n\nRan under the auto ui.load transaction." + } catch (t: Throwable) { + "Load failed: ${t.message}" + } finally { + // Close the TTFD window so the ui.load transaction finishes with the db spans attached. + Sentry.reportFullyDisplayed() + } + } + } + + companion object { + private const val EXTRA_DEMO_ID = "demo_id" + private const val EXTRA_HEAVY = "heavy" + + /** Builds the intent that runs [id] (honoring the [heavy] toggle) on this UiLoadScreen. */ + fun intent(context: Context, id: SqlDemo, heavy: Boolean): Intent = + Intent(context, UiLoadActivity::class.java) + .putExtra(EXTRA_DEMO_ID, id.name) + .putExtra(EXTRA_HEAVY, heavy) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt new file mode 100644 index 00000000000..6495726448d --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadScreen.kt @@ -0,0 +1,110 @@ +package io.sentry.samples.android.sqlite + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.sentry.samples.android.R + +private val ShimmerHighlight = Color(0xFFBDBDBD) + +@Composable +fun UiLoadScreen(status: String, onClose: () -> Unit) { + MaterialTheme { + Surface { + Box( + modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding().padding(24.dp) + ) { + Column( + modifier = Modifier.align(Alignment.Center).fillMaxWidth().offset(y = (-48).dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ShimmerSentryGlyph(modifier = Modifier.size(96.dp)) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = status, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } + + Button( + onClick = onClose, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth(), + colors = + ButtonDefaults.buttonColors(containerColor = Color.Black, contentColor = Color.White), + ) { + Text("Close") + } + } + } + } +} + +@Composable +private fun ShimmerSentryGlyph(modifier: Modifier = Modifier) { + val progress = remember { Animatable(0f) } + LaunchedEffect(Unit) { + progress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 700, delayMillis = 250, easing = LinearEasing), + ) + } + + Image( + painter = painterResource(R.drawable.sentry_glyph), + contentDescription = "Sentry", + modifier = + modifier + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val p = progress.value + val band = size.width * 0.5f + // Sweep the highlight band diagonally from off the bottom-left corner (p=0) to off the + // top-right corner (p=1): x travels left→right, y travels bottom→top. + val x = -band + (size.width + 2f * band) * p + val y = (size.height + band) - (size.height + 2f * band) * p + drawRect( + brush = + Brush.linearGradient( + colors = listOf(Color.Black, ShimmerHighlight, Color.Black), + start = Offset(x, y), + end = Offset(x + band, y - band), + ), + blendMode = BlendMode.SrcAtop, + ) + }, + ) +} From 7c1a728e8bd2faa42b8f1c25c9f16a145baab60f Mon Sep 17 00:00:00 2001 From: arb Date: Thu, 18 Jun 2026 12:38:54 +0200 Subject: [PATCH 018/195] chore(android-sqlite): Skip wrapping SupportSQLiteDriver bridge to avoid duplicate spans (#5514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SentrySQLiteDriver.create() now recognizes the Room 2.7+ androidx.sqlite.driver.SupportSQLiteDriver bridge adapter and returns it unwrapped. That lets us protect against the one known vector where using both SentrySQLiteDriver and SentrySupportSQLiteOpenHelper with the same db table is allowed under either the Room or SQLDelight APIs: ```kotlin // AVOID — this configuration produces duplicate spans for every SQL statement. // Step 1: Developer wraps their open helper with Sentry, either manually or // via the Sentry Android Gradle Plugin. val sentryWrappedHelper: SupportSQLiteOpenHelper = SentrySupportSQLiteOpenHelper.create( FrameworkSQLiteOpenHelperFactory().create(configuration) ) // Step 2: Developer builds the compat driver around that wrapped helper. val driver: SQLiteDriver = SupportSQLiteDriver(sentryWrappedHelper) // Step 3: Developer (wrongly!) wraps the driver with Sentry as well. All // spans will now be duplicated. val sentryWrappedDriver: SQLiteDriver = SentrySQLiteDriver.create(driver) Room.databaseBuilder(context, MyDb::class.java, "mydb") .setDriver(sentryWrappedDriver) .build() ``` This commit lets us avoid step 3 by no-op'ing if a developer tries to pass a SupportSQLiteDriver to SentrySQLiteDriver.create(). --- sentry-android-sqlite/proguard-rules.pro | 4 + .../io/sentry/sqlite/SentrySQLiteDriver.kt | 32 ++- .../sqlite/driver/SupportSQLiteDriver.kt | 18 ++ .../sentry/sqlite/SentrySQLiteDriverTest.kt | 11 ++ .../samples/android/sqlite/DisplayInfo.kt | 5 + .../samples/android/sqlite/SQLiteActivity.kt | 183 ++++++++++++++---- .../samples/android/sqlite/SampleDatabases.kt | 181 +++++++++++++++-- .../samples/android/sqlite/SqlStatements.kt | 35 ++++ .../samples/android/sqlite/UiLoadActivity.kt | 5 +- 9 files changed, 412 insertions(+), 62 deletions(-) create mode 100644 sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt diff --git a/sentry-android-sqlite/proguard-rules.pro b/sentry-android-sqlite/proguard-rules.pro index 02ab589d3bd..13fa4bf9dea 100644 --- a/sentry-android-sqlite/proguard-rules.pro +++ b/sentry-android-sqlite/proguard-rules.pro @@ -4,4 +4,8 @@ # https://developer.android.com/studio/build/shrink-code#decode-stack-trace -keepattributes LineNumberTable,SourceFile +# SentrySQLiteDriver.create() uses a runtime class-name check to skip wrapping the Room 2.7+ +# SupportSQLiteDriver bridge adapter and avoid duplicate spans. +-keepnames class androidx.sqlite.driver.SupportSQLiteDriver + ##---------------End: proguard configuration for SQLite ---------- diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt index e869778b811..f0f41782c22 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -22,11 +22,6 @@ import org.jetbrains.annotations.ApiStatus * .build() * ``` * - * **Warning:** Do not use [SentrySQLiteDriver] together with - * [SentrySupportSQLiteOpenHelper][io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper] on the - * same database file. Both wrappers instrument at different layers and combining them will produce - * duplicate spans. - * * @param delegate The [SQLiteDriver] instance to delegate calls to. */ @ApiStatus.Experimental @@ -73,11 +68,32 @@ public class SentrySQLiteDriver private constructor(private val delegate: SQLite public companion object { /** - * Wraps the provided delegate in a [SentrySQLiteDriver]. Returns the delegate as-is if already - * wrapped. + * Name of the bridge adapter often used with Room 2.7+. It implements the `SQLiteDriver` + * interface and its constructor consumes a `SupportSQLiteOpenHelper`. (Users of the Sentry + * Android Gradle Plugin will have the `SupportSQLiteOpenHelper` wrapped for them + * automatically.) We deliberately avoid wrapping the adapter to prevent duplicate spans. + * + * String (rather than an `is` check) lets us avoid a compile-time dependency on + * androidx.sqlite:sqlite-framework. + */ + private const val SUPPORT_SQLITE_DRIVER_FQN = "androidx.sqlite.driver.SupportSQLiteDriver" + + /** + * Wraps the provided delegate in a [SentrySQLiteDriver]. + * + * To avoid duplicate spans, returns the delegate as-is if: + * 1. it's already wrapped, or + * 2. it's an `androidx.sqlite.driver.SupportSQLiteDriver`. + * + * In the case of (2), wrap the open helper passed to the `SupportSQLiteDriver` constructor via + * `SentrySupportSQLiteOpenHelper` instead. */ @JvmStatic public fun create(delegate: SQLiteDriver): SQLiteDriver = - delegate as? SentrySQLiteDriver ?: SentrySQLiteDriver(delegate) + if (delegate is SentrySQLiteDriver || delegate.javaClass.name == SUPPORT_SQLITE_DRIVER_FQN) { + delegate + } else { + SentrySQLiteDriver(delegate) + } } } diff --git a/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt new file mode 100644 index 00000000000..2de7f1d38f5 --- /dev/null +++ b/sentry-android-sqlite/src/test/java/androidx/sqlite/driver/SupportSQLiteDriver.kt @@ -0,0 +1,18 @@ +package androidx.sqlite.driver + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver + +/** + * Minimal stub of `androidx.sqlite.driver.SupportSQLiteDriver` (which lives in + * `androidx.sqlite:sqlite-framework`, not on this module's compile/test classpath) for verifying + * behavior of `SentrySQLiteDriver.create(SupportSQLiteDriver)`. + */ +internal class SupportSQLiteDriver : SQLiteDriver { + + override val hasConnectionPool: Boolean = false + + override fun open(fileName: String): SQLiteConnection { + throw UnsupportedOperationException("Test stub; not for runtime use") + } +} diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt index 9b2345a975f..5816f3d859c 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteDriverTest.kt @@ -3,6 +3,7 @@ package io.sentry.sqlite import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteDriver import androidx.sqlite.SQLiteStatement +import androidx.sqlite.driver.SupportSQLiteDriver import io.sentry.IScopes import io.sentry.Sentry import io.sentry.SentryIntegrationPackageStorage @@ -64,6 +65,16 @@ class SentrySQLiteDriverTest { assertSame(wrapped, doubleWrapped) } + @Test + fun `create with SupportSQLiteDriver bridge returns same instance without wrapping`() { + val bridge = SupportSQLiteDriver() + + val result = SentrySQLiteDriver.create(bridge) + + assertSame(bridge, result) + assertFalse(result is SentrySQLiteDriver) + } + @Test fun `hasConnectionPool forwards delegate value when supported`() { whenever(fixture.mockDriver.hasConnectionPool).thenReturn(true) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt index 14582fe305e..fd80a5aae1e 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/DisplayInfo.kt @@ -87,6 +87,11 @@ internal val OPENHELPER_ROOM = .trimIndent(), ) +// Bridge demos run the same SQL as the driver paths; spans come from the open-helper layer. +internal val BRIDGE_DIRECT = DRIVER_DIRECT + +internal val BRIDGE_ROOM2 = DRIVER_ROOM2 + internal val OPENHELPER_SQLDELIGHT = DisplayInfo( sql = diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt index 1ff6828a757..9a27ecda353 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -1,6 +1,7 @@ package io.sentry.samples.android.sqlite import android.os.Bundle +import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -33,6 +34,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchColors @@ -73,6 +77,7 @@ import kotlinx.coroutines.withContext private val SentryPink = Color(0xFFC85B9C) private val SentryPurple = Color(0xFF7B52FB) +private val SentryOrange = Color(0xFFE8743F) private val SentryRed = Color(0xFFF55459) /** Intro text, surfaced via the "?" tooltip next to the "Run it" header. */ @@ -88,10 +93,33 @@ private val CONTROL_SECTION_GAP = TOGGLE_SECTION_GAP * 2 private val SECTION_HEADER_HEIGHT = 28.dp -/** Which sentry-android-sqlite integration the demo buttons currently target. */ -private enum class Integration(val color: Color, val apiName: String) { - DRIVER(SentryPurple, "SQLiteDriver"), - OPEN_HELPER(SentryPink, "SupportSQLiteOpenHelper"), +/** Which sentry-android-sqlite integration the demo currently targets. */ +private enum class IntegrationMode( + val color: Color, + val segmentLabel: String, + val apiName: String, + val subtitle: String, +) { + DRIVER( + SentryPurple, + "SQLiteDriver", + "SQLiteDriver", + "SentrySQLiteDriver.create(BundledSQLiteDriver)", + ), + OPEN_HELPER( + SentryPink, + "OpenHelper", + "SupportSQLiteOpenHelper", + "SentrySupportSQLiteOpenHelper.create(...)", + ), + // Not directly-supported, but lets us verify behavior when both the DRIVER and OPEN_HELPER + // integrations are used together via the SupportSQLiteDriver bridge. + BRIDGE( + SentryOrange, + "Bridge", + "SupportSQLiteDriver bridge", + "SentrySQLiteDriver.create(SupportSQLiteDriver(Sentry helper))", + ), } /** @@ -107,11 +135,24 @@ private class DemoVariant( ) /** - * A single demo button in the list. [driver] / [openHelper] hold the variant for each integration; - * a null variant means the row doesn't apply to that integration and renders dimmed, explaining why - * on click (Room 3 is driver-only; SQLDelight is open-helper-only). + * A single demo button in the list. [driver] / [openHelper] / [bridge] hold the variant for each + * integration; a null variant means the row doesn't apply and renders dimmed (e.g., Room 3 is + * driver-only; SQLDelight is open-helper-only; etc.). */ -private class DemoRow(val label: String, val driver: DemoVariant?, val openHelper: DemoVariant?) +private class DemoRow( + val label: String, + val driver: DemoVariant?, + val openHelper: DemoVariant?, + val bridge: DemoVariant?, +) { + + fun variantFor(mode: IntegrationMode): DemoVariant? = + when (mode) { + IntegrationMode.DRIVER -> driver + IntegrationMode.OPEN_HELPER -> openHelper + IntegrationMode.BRIDGE -> bridge + } +} // The demo buttons, top to bottom, paired with each integration's variant. Pure data — the actual // SQL lives in SqlStatements, dispatched by id. @@ -133,6 +174,13 @@ private val DEMO_ROWS = op = "db.sql.openhelper-direct", displayInfo = OPENHELPER_DIRECT, ), + bridge = + DemoVariant( + demo = SqlDemo.BRIDGE_DIRECT, + transactionName = "Bridge stack — Direct", + op = "db.sql.bridge-direct", + displayInfo = BRIDGE_DIRECT, + ), ), DemoRow( label = "Room 2", @@ -150,6 +198,13 @@ private val DEMO_ROWS = op = "db.sql.openhelper-room", displayInfo = OPENHELPER_ROOM, ), + bridge = + DemoVariant( + demo = SqlDemo.BRIDGE_ROOM2, + transactionName = "Bridge stack — Room 2", + op = "db.sql.bridge-room2", + displayInfo = BRIDGE_ROOM2, + ), ), DemoRow( label = "Room 3", @@ -161,6 +216,7 @@ private val DEMO_ROWS = displayInfo = DRIVER_ROOM3, ), openHelper = null, // Room 3 only runs on the SQLiteDriver path. + bridge = null, ), DemoRow( label = "SQLDelight", @@ -172,6 +228,7 @@ private val DEMO_ROWS = op = "db.sql.openhelper-sqldelight", displayInfo = OPENHELPER_SQLDELIGHT, ), + bridge = null, ), ) @@ -187,6 +244,7 @@ private val DEMO_ROWS = class SQLiteActivity : ComponentActivity() { private var latestResult by mutableStateOf("") + private var warmUpErrors by mutableStateOf("") private var sqlDetail by mutableStateOf(SQL_DETAIL_HINT) private var heavyWork by mutableStateOf(false) @@ -198,8 +256,8 @@ class SQLiteActivity : ComponentActivity() { */ private var shareScreenTrace by mutableStateOf(false) - /** Which integration the demo buttons target. Switching it disables the rows that don't apply. */ - private var integration by mutableStateOf(Integration.DRIVER) + /** Which integration is currently being demoed. Switching it disables rows that don't apply. */ + private var integration by mutableStateOf(IntegrationMode.DRIVER) /** Incremented on each tap that runs SQL. Used to retrigger the detail box's outline shimmer. */ private var runTick by mutableStateOf(0) @@ -265,31 +323,19 @@ class SQLiteActivity : ComponentActivity() { SectionHeader("Configure it") - val openHelper = integration == Integration.OPEN_HELPER - val integrationSwitchColors = - SwitchDefaults.colors( - checkedTrackColor = SentryPink, - checkedBorderColor = SentryPink, - uncheckedTrackColor = SentryPurple, - uncheckedBorderColor = SentryPurple, - uncheckedThumbColor = Color.White, - ) val controlSwitchColors = SwitchDefaults.colors( checkedTrackColor = Color.Black, checkedBorderColor = Color.Black, ) - ToggleRow( - label = if (openHelper) "SentrySupportSQLiteOpenHelper" else "SentrySQLiteDriver", - checked = openHelper, - labelColor = if (openHelper) SentryPink else SentryPurple, - switchColors = integrationSwitchColors, - ) { - integration = if (it) Integration.OPEN_HELPER else Integration.DRIVER - // Switching integration starts a fresh comparison: clear the detail box and result. - sqlDetail = SQL_DETAIL_HINT - latestResult = "" - } + IntegrationModeSelector( + selected = integration, + onSelected = { + integration = it + sqlDetail = SQL_DETAIL_HINT + latestResult = "" + }, + ) ToggleRow( label = if (heavyWork) "Heavy app-level work" else "No app-level work", checked = heavyWork, @@ -313,12 +359,12 @@ class SQLiteActivity : ComponentActivity() { // integration's variant; a row that doesn't apply explains why via a toast (see // [DemoRowButton]). DEMO_ROWS.forEach { row -> - val variant = if (integration == Integration.DRIVER) row.driver else row.openHelper + val variant = row.variantFor(integration) DemoRowButton( label = row.label, color = integration.color, variant = variant, - disabledReason = "${row.label} doesn't use the ${integration.apiName}", + disabledReason = "${row.label} doesn't support the ${integration.apiName} stack", ) } @@ -330,12 +376,26 @@ class SQLiteActivity : ComponentActivity() { // Same [CONTROL_SECTION_GAP] above as the other sections, separating the controls from // the detail output. SectionHeader("Under the hood", topPadding = CONTROL_SECTION_GAP) + LaunchedEffect(Unit) { + while (!SampleDatabases.isWarmUpComplete()) { + warmUpErrors = SampleDatabases.warmUpErrors + delay(250) + } + warmUpErrors = SampleDatabases.warmUpErrors + } + if (warmUpErrors.isNotEmpty()) { + Text( + text = warmUpErrors, + style = MaterialTheme.typography.bodyMedium, + color = SentryRed, + ) + } // The latest run result (row counts, errors). Hidden until the first run. if (latestResult.isNotEmpty()) { Text( text = latestResult, style = MaterialTheme.typography.bodyMedium, - color = if (latestResult.contains("failed")) SentryRed else Color.Unspecified, + color = if (latestResult.looksLikeError()) SentryRed else Color.Unspecified, ) } DetailField("SQL run", sqlDetail, borderColor = detailOutline) @@ -361,12 +421,13 @@ class SQLiteActivity : ComponentActivity() { lifecycleScope.launch { dbOperationInFlight = true try { - latestResult = + val result = withContext(Dispatchers.IO) { runInTransaction(variant.transactionName, variant.op) { SqlStatements.execute(applicationContext, variant.demo, heavyWork) } } + latestResult = result } finally { dbOperationInFlight = false } @@ -385,9 +446,41 @@ class SQLiteActivity : ComponentActivity() { startActivity(UiLoadActivity.intent(this, variant.demo, heavyWork)) } + @OptIn(ExperimentalMaterial3Api::class) + @androidx.compose.runtime.Composable + private fun IntegrationModeSelector( + selected: IntegrationMode, + onSelected: (IntegrationMode) -> Unit, + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + IntegrationMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + shape = + SegmentedButtonDefaults.itemShape(index = index, count = IntegrationMode.entries.size), + onClick = { onSelected(mode) }, + selected = selected == mode, + icon = {}, + colors = + SegmentedButtonDefaults.colors( + activeContainerColor = mode.color, + activeContentColor = Color.White, + ), + label = { Text(mode.segmentLabel, style = MaterialTheme.typography.labelSmall) }, + ) + } + } + + Text( + text = selected.subtitle, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + modifier = Modifier.padding(top = 6.dp), + ) + } + /** * A compact, left-justified labeled switch. [labelColor] defaults to [Color.Unspecified] so the - * label inherits the default text color; the integration toggle passes its pink/purple instead. + * label inherits the default text color. */ @androidx.compose.runtime.Composable private fun ToggleRow( @@ -533,7 +626,11 @@ class SQLiteActivity : ComponentActivity() { try { val message = withContext(Dispatchers.IO) { resetDatabases() } latestResult = message + warmUpErrors = SampleDatabases.warmUpErrors sqlDetail = "DROP: deletes every demo database file, resetting all row counts to 0." + } catch (t: Throwable) { + Log.e(TAG, "Reset failed", t) + latestResult = "Reset failed: ${t.message ?: t.javaClass.simpleName}" } finally { this@SQLiteActivity.dbOperationInFlight = false this@SQLiteActivity.resetInProgress = false @@ -595,7 +692,8 @@ class SQLiteActivity : ComponentActivity() { result } catch (t: Throwable) { transaction.status = SpanStatus.INTERNAL_ERROR - "$transactionName failed: ${t.message}" + Log.e(TAG, "$transactionName failed", t) + "$transactionName failed: ${t.message ?: t.javaClass.simpleName}" } finally { transaction.finish() } @@ -604,11 +702,20 @@ class SQLiteActivity : ComponentActivity() { /** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */ private suspend fun resetDatabases(): String { val cleared = SampleDatabases.reset(applicationContext) - return "Dropped tables: cleared $cleared database file(s)." + SampleDatabases.awaitWarmUp() + return buildString { + append("Dropped tables: cleared $cleared database file(s).") + if (SampleDatabases.warmUpErrors.isNotEmpty()) { + append("\n\n") + append(SampleDatabases.warmUpErrors) + } + } } private companion object { + private const val TAG = "SQLiteActivity" + /** Demo SQL shorter than this won't visibly disable the reset button. */ private const val RESET_DISABLE_DEBOUNCE_MS = 300L @@ -619,3 +726,5 @@ class SQLiteActivity : ComponentActivity() { private fun newScreenTrace(): String = "${SentryId()}-${SpanId()}-1" } } + +private fun String.looksLikeError(): Boolean = contains("failed", ignoreCase = true) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt index 63f217fcfbb..19b292cd91e 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -1,12 +1,14 @@ package io.sentry.samples.android.sqlite import android.content.Context +import android.util.Log import androidx.room.Room import androidx.room3.Room as Room3 import androidx.sqlite.SQLiteConnection import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteOpenHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.sqlite.driver.SupportSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.execSQL import app.cash.sqldelight.driver.android.AndroidSqliteDriver @@ -18,6 +20,7 @@ import io.sentry.samples.android.sqlite.SampleDatabases.warmUp import io.sentry.sqlite.SentrySQLiteDriver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -42,18 +45,40 @@ import kotlinx.coroutines.sync.withLock */ object SampleDatabases { + private const val TAG = "SampleDatabases" + + /** Non-empty when one or more warm-up steps failed; shown on [SQLiteActivity]. */ + @Volatile + var warmUpErrors: String = "" + private set + + @Volatile private var warmUpComplete = false + @Volatile private var warmUpGeneration = 0 + @Volatile private var warmUpJob: Job? = null + + fun isWarmUpComplete(): Boolean = warmUpComplete + + /** Blocks until the in-flight [warmUp] job (if any) finishes. */ + suspend fun awaitWarmUp() { + warmUpJob?.join() + } + private val sqlAccess = Mutex() val driverDirectLock = Any() + val bridgeDirectLock = Any() val openHelperDirectLock = Any() /** Serializes demo SQL and [reset] so handles are never closed mid-statement. */ suspend fun withSqlAccess(block: suspend () -> T): T = sqlAccess.withLock { block() } @Volatile private var driverConnection: SQLiteConnection? = null + @Volatile private var bridgeConnection: SQLiteConnection? = null @Volatile private var driverRoom2Db: SampleRoom2Database? = null + @Volatile private var bridgeRoom2Db: SampleRoom2Database? = null @Volatile private var driverRoom3Db: SampleRoom3Database? = null @Volatile private var directHelper: SupportSQLiteOpenHelper? = null + @Volatile private var bridgeDirectHelper: SupportSQLiteOpenHelper? = null @Volatile private var openHelperRoomDb: SampleRoom2Database? = null @Volatile private var sqlDelightDriver: AndroidSqliteDriver? = null @@ -68,6 +93,45 @@ object SampleDatabases { } } + /** + * The Room 2.7+ duplicate-span scenario: a Sentry-wrapped open helper bridged to + * [SupportSQLiteDriver], then passed to [SentrySQLiteDriver.create] (which no-ops on the bridge). + */ + fun bridgeConnection(context: Context): SQLiteConnection = + synchronized(bridgeDirectLock) { + bridgeConnection + ?: run { + // SupportSQLiteDriver.open() requires fileName to match the helper's databaseName(); + // use the absolute path Room and the direct driver path both pass to open(). + val dbPath = databaseFile(context, "bridge_direct.db") + SentrySQLiteDriver.create(SupportSQLiteDriver(buildBridgeDirectHelper(context, dbPath))) + .open(dbPath) + .also { + it.execSQL(SqlStatements.CREATE_SONG) + bridgeConnection = it + } + } + } + + fun bridgeRoom2Db(context: Context): SampleRoom2Database = + synchronized(this) { + bridgeRoom2Db + ?: Room.databaseBuilder( + context.applicationContext, + SampleRoom2Database::class.java, + "bridge_room2.db", + ) + .setDriver( + SentrySQLiteDriver.create( + SupportSQLiteDriver(buildBridgeRoom2Helper(context.applicationContext)) + ) + ) + .setQueryCoroutineContext(Dispatchers.IO) + .fallbackToDestructiveMigration(true) + .build() + .also { bridgeRoom2Db = it } + } + fun driverRoom2Db(context: Context): SampleRoom2Database = synchronized(this) { driverRoom2Db @@ -133,10 +197,50 @@ object SampleDatabases { .also { sqlDelightDriver = it } } - private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper { + private fun buildDirectHelper(context: Context): SupportSQLiteOpenHelper = + buildSentryHelper(context, "openhelper_direct.db").also { directHelper = it } + + private fun buildBridgeDirectHelper(context: Context, dbPath: String): SupportSQLiteOpenHelper = + buildSentryHelper(context, dbPath).also { bridgeDirectHelper = it } + + /** + * Open helper for the Bridge + Room 2 stack. Must not create tables in [onCreate] — Room owns the + * schema when [setDriver] is used. Room also passes [SupportSQLiteOpenHelper.databaseName] (the + * short name below), not an absolute path, to [SupportSQLiteDriver.open]. + * + * The callback version must be 1 (FrameworkSQLiteOpenHelper rejects < 1). That sets `PRAGMA + * user_version = 1` before Room opens, so Room would skip [onCreate] and validate the empty file + * as pre-packaged → "invalid schema". [onOpen] clears user_version back to 0 until + * [ROOM_MASTER_TABLE] exists. + */ + private fun buildBridgeRoom2Helper(context: Context): SupportSQLiteOpenHelper { val configuration = SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) - .name("openhelper_direct.db") + .name("bridge_room2.db") + .callback( + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = Unit + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = + Unit + + override fun onOpen(db: SupportSQLiteDatabase) { + if (!db.hasRoomMasterTable()) { + db.execSQL("PRAGMA user_version = 0") + } + } + } + ) + .build() + return SentrySupportSQLiteOpenHelper.create( + FrameworkSQLiteOpenHelperFactory().create(configuration) + ) + } + + private fun buildSentryHelper(context: Context, dbName: String): SupportSQLiteOpenHelper { + val configuration = + SupportSQLiteOpenHelper.Configuration.builder(context.applicationContext) + .name(dbName) .callback( object : SupportSQLiteOpenHelper.Callback(1) { override fun onCreate(db: SupportSQLiteDatabase) { @@ -156,22 +260,50 @@ object SampleDatabases { /** Opens every database on a background thread, forcing the one-time open + bootstrap to run. */ fun warmUp(context: Context) { val appContext = context.applicationContext + val generation = ++warmUpGeneration + warmUpComplete = false + warmUpErrors = "" // Fire-and-forget: the warm-up outlives no particular screen, so a bare scope is fine here. - CoroutineScope(Dispatchers.IO).launch { - runCatching { driverConnection(appContext) } - // primeWriter() + count() opens both Room pool connections (writer + reader), so the first - // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its - // transaction. - runCatching { driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() } - runCatching { driverRoom3Db(appContext).songDao().also { it.primeWriter() }.count() } - runCatching { directHelper(appContext).writableDatabase } - runCatching { openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() } - runCatching { - SampleSQLDelightDatabase(sqlDelightDriver(appContext)) - .songQueries - .countSongs() - .executeAsOne() + warmUpJob = + CoroutineScope(Dispatchers.IO).launch { + val failures = mutableListOf() + runWarmUpStep("driver direct", failures) { driverConnection(appContext) } + runWarmUpStep("bridge direct", failures) { bridgeConnection(appContext) } + // primeWriter() + count() opens both Room pool connections (writer + reader), so the first + // demo INSERT/SELECT reuses them instead of bootstrapping a connection inside its + // transaction. + runWarmUpStep("driver Room 2", failures) { + driverRoom2Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("bridge Room 2", failures) { + bridgeRoom2Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("driver Room 3", failures) { + driverRoom3Db(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("open helper direct", failures) { directHelper(appContext).writableDatabase } + runWarmUpStep("open helper Room", failures) { + openHelperRoomDb(appContext).songDao().also { it.primeWriter() }.count() + } + runWarmUpStep("SQLDelight", failures) { + SampleSQLDelightDatabase(sqlDelightDriver(appContext)) + .songQueries + .countSongs() + .executeAsOne() + } + if (generation == warmUpGeneration) { + warmUpErrors = failures.joinToString("\n") { "Warm-up failed: $it" } + warmUpComplete = true + } } + } + + private inline fun runWarmUpStep(step: String, failures: MutableList, block: () -> Unit) { + try { + block() + } catch (t: Throwable) { + Log.e(TAG, "Warm-up failed: $step", t) + failures.add("$step: ${t.message ?: t.javaClass.simpleName}") } } @@ -185,7 +317,9 @@ object SampleDatabases { val names = listOf( "driver_direct.db", + "bridge_direct.db", "driver_room2.db", + "bridge_room2.db", "driver_room3.db", "openhelper_direct.db", "openhelper_room.db", @@ -201,6 +335,12 @@ object SampleDatabases { driverConnection?.close() driverConnection = null } + synchronized(bridgeDirectLock) { + bridgeConnection?.close() + bridgeConnection = null + bridgeDirectHelper?.close() + bridgeDirectHelper = null + } synchronized(openHelperDirectLock) { directHelper?.close() directHelper = null @@ -208,6 +348,8 @@ object SampleDatabases { synchronized(this) { driverRoom2Db?.close() driverRoom2Db = null + bridgeRoom2Db?.close() + bridgeRoom2Db = null driverRoom3Db?.close() driverRoom3Db = null openHelperRoomDb?.close() @@ -219,4 +361,11 @@ object SampleDatabases { private fun databaseFile(context: Context, name: String): String = context.applicationContext.getDatabasePath(name).also { it.parentFile?.mkdirs() }.absolutePath + + private fun SupportSQLiteDatabase.hasRoomMasterTable(): Boolean = + query("SELECT 1 FROM sqlite_master WHERE name = '$ROOM_MASTER_TABLE' LIMIT 1").use { + it.moveToFirst() + } } + +private const val ROOM_MASTER_TABLE = "room_master_table" diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt index 543f1169294..9bd2d624694 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SqlStatements.kt @@ -18,6 +18,8 @@ enum class SqlDemo { DRIVER_DIRECT, DRIVER_ROOM2, DRIVER_ROOM3, + BRIDGE_DIRECT, + BRIDGE_ROOM2, OPENHELPER_DIRECT, OPENHELPER_ROOM, OPENHELPER_SQLDELIGHT, @@ -64,6 +66,8 @@ object SqlStatements { SqlDemo.DRIVER_DIRECT -> driverDirect(context, heavy) SqlDemo.DRIVER_ROOM2 -> driverWithRoom2(context, heavy) SqlDemo.DRIVER_ROOM3 -> driverWithRoom3(context, heavy) + SqlDemo.BRIDGE_DIRECT -> bridgeDirect(context, heavy) + SqlDemo.BRIDGE_ROOM2 -> bridgeWithRoom2(context, heavy) SqlDemo.OPENHELPER_DIRECT -> openHelperDirect(context, heavy) SqlDemo.OPENHELPER_ROOM -> openHelperWithRoom(context, heavy) SqlDemo.OPENHELPER_SQLDELIGHT -> openHelperWithSqlDelight(context, heavy) @@ -115,6 +119,37 @@ object SqlStatements { if (statement.step()) statement.getLong(0) else 0 } + // --- 1b. SupportSQLiteDriver bridge (helper + driver both wrapped; SDK skips driver wrap) -- + + private fun bridgeDirect(context: Context, heavy: Boolean): String = + synchronized(SampleDatabases.bridgeDirectLock) { + val connection = SampleDatabases.bridgeConnection(context) + insert(connection, "Mishima / Closing", "Philip Glass") + insert(connection, "School of Velocity, op 299 no 1, ", "Carl Czerny") + + if (heavy) { + connection.prepare(insertSongsBatch(HEAVY_ROW_COUNT)).use { statement -> + var param = 1 + repeat(HEAVY_ROW_COUNT) { row -> + statement.bindText(param++, "song $row") + statement.bindText(param++, "artist $row") + } + statement.step() + } + + connection.prepare(SELECT_SONGS).use { statement -> + while (statement.step()) { + val row = "${statement.getLong(0)}:${statement.getText(1)}:${statement.getText(2)}" + appWork(row) + } + } + } + "Bridge (Direct): ${count(connection)} rows." + } + + private suspend fun bridgeWithRoom2(context: Context, heavy: Boolean): String = + roomDemo(SampleDatabases.bridgeRoom2Db(context).songDao(), "Bridge (Room 2)", heavy) + // --- 2. SentrySQLiteDriver, used through Room 2.7+ ---------------------------------------- private suspend fun driverWithRoom2(context: Context, heavy: Boolean): String = diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt index b32811e8c91..3cc6d394daa 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/UiLoadActivity.kt @@ -3,6 +3,7 @@ package io.sentry.samples.android.sqlite import android.content.Context import android.content.Intent import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.getValue @@ -48,7 +49,8 @@ class UiLoadActivity : ComponentActivity() { withContext(Dispatchers.IO) { SqlStatements.execute(applicationContext, id, heavy) } "$result\n\nRan under the auto ui.load transaction." } catch (t: Throwable) { - "Load failed: ${t.message}" + Log.e(TAG, "Load failed", t) + "Load failed: ${t.message ?: t.javaClass.simpleName}" } finally { // Close the TTFD window so the ui.load transaction finishes with the db spans attached. Sentry.reportFullyDisplayed() @@ -57,6 +59,7 @@ class UiLoadActivity : ComponentActivity() { } companion object { + private const val TAG = "UiLoadActivity" private const val EXTRA_DEMO_ID = "demo_id" private const val EXTRA_HEAVY = "heavy" From 547d3e463dec24c1b99586f85e0cd5f2d73b9022 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:04:12 +0200 Subject: [PATCH 019/195] chore: update scripts/update-sentry-native-ndk.sh to 0.15.1 (#5570) Co-authored-by: GitHub --- CHANGELOG.md | 6 ++++++ gradle/libs.versions.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8428f033b78..76b2d974e5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ - To use it, pass `SQLiteDriver` to `SentrySQLiteDriver.create(...)` - Requires `androidx.sqlite:sqlite` (2.5.0+) on runtime classpath (typically provided by Room or SQLDelight) +### Dependencies + +- Bump Native SDK from v0.15.0 to v0.15.1 ([#5570](https://github.com/getsentry/sentry-java/pull/5570)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0151) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.0...0.15.1) + ## 8.44.0 ### Features diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91a7669194f..68521efdfcc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -166,7 +166,7 @@ quartz = { module = "org.quartz-scheduler:quartz", version = "2.3.0" } reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } -sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.15.0" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.15.1" } servlet-api = { module = "javax.servlet:javax.servlet-api", version = "3.1.0" } servlet-jakarta-api = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.1.0" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } From 9c501bbbe56976ba47b467455062992eac005b07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:07:08 +0000 Subject: [PATCH 020/195] chore(deps): bump actions/checkout in the github-actions group (#5569) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6.0.3 to 7.0.0 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/changes-in-high-risk-code.yml | 2 +- .github/workflows/check-tombstone-proto-schema.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/format-code.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-benchmarks.yml | 4 ++-- .github/workflows/integration-tests-size.yml | 2 +- .github/workflows/integration-tests-ui-critical.yml | 4 ++-- .github/workflows/integration-tests-ui.yml | 2 +- .github/workflows/release-build.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/spring-boot-2-matrix.yml | 2 +- .github/workflows/spring-boot-3-matrix.yml | 2 +- .github/workflows/spring-boot-4-matrix.yml | 2 +- .github/workflows/system-tests-backend.yml | 2 +- 18 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 40f8509fee4..8ddb961ec96 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 375e94e7499..f2ffd96f9c5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} submodules: 'recursive' diff --git a/.github/workflows/changes-in-high-risk-code.yml b/.github/workflows/changes-in-high-risk-code.yml index 028b4217ef2..78918167207 100644 --- a/.github/workflows/changes-in-high-risk-code.yml +++ b/.github/workflows/changes-in-high-risk-code.yml @@ -16,7 +16,7 @@ jobs: high_risk_code: ${{ steps.changes.outputs.high_risk_code }} high_risk_code_files: ${{ steps.changes.outputs.high_risk_code_files }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Get changed files id: changes uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 diff --git a/.github/workflows/check-tombstone-proto-schema.yml b/.github/workflows/check-tombstone-proto-schema.yml index 535b2170fae..3e30f97e45e 100644 --- a/.github/workflows/check-tombstone-proto-schema.yml +++ b/.github/workflows/check-tombstone-proto-schema.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Check for newer Tombstone proto schema run: ./scripts/check-tombstone-proto-schema.sh diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e24b7c96c14..ccc9cc04a85 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index e5e4530933b..38680fe0a23 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -20,7 +20,7 @@ jobs: java-version: '17' - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # TODO: remove this when upstream is fixed - name: Disable Gradle configuration cache (see https://github.com/fossas/fossa-cli/issues/872) diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index ec427af3564..2892df16701 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 2e82024077a..fabd36736aa 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 4d323f0394a..45b063705dc 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' @@ -77,7 +77,7 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index e2fa42ddc16..5c212d5895a 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Java Version uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 18809c060e1..7d0b74b4329 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Java 17 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 @@ -77,7 +77,7 @@ jobs: arch: x86_64 steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Enable KVM run: | diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index f7b95a26d12..92e29ecbef7 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 9fecaf32b5e..050782006f0 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eddeaa24cd9..dd266d948c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: token: ${{ steps.token.outputs.token }} # Needs to be set, otherwise git describe --tags will fail with: No names found, cannot describe anything diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 7628a0bbba0..6e0b1366c9f 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 40670eaf258..00e93f5442b 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 128051ed03e..450dbd8c98d 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout Repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 62a1b7665c0..67f81f2fb64 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -98,7 +98,7 @@ jobs: agent: "false" agent-auto-init: "true" steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: 'recursive' From 8da852cc8e39d8246ba5a712c88d38b64618b074 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 19 Jun 2026 11:11:14 +0200 Subject: [PATCH 021/195] fix(android): Make FirstDrawDoneListener cleanup OnGlobalLayoutListener after use (#5567) * fix(android): Make FirstDrawDoneListener cleanup OnGlobalLayoutListener after use The OnGlobalLayoutListener registered in onDraw() to defer removal of the OnDrawListener was never itself removed. In single-Activity apps (e.g. React Native), this caused an unbounded per-navigation leak on the ViewTreeObserver, accumulating one listener per registerForNextDraw call. Make the OnGlobalLayoutListener remove itself after firing. Fixes JAVA-545 Co-Authored-By: Claude Opus 4.6 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 +++ .../internal/util/FirstDrawDoneListener.java | 9 +++++- .../util/FirstDrawDoneListenerTest.kt | 31 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b2d974e5a..395b1f2e2b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Fix `FirstDrawDoneListener` leaking an `OnGlobalLayoutListener` per registration ([#5567](https://github.com/getsentry/sentry-java/pull/5567)) + ### Features - Add experimental `SentrySQLiteDriver` to `sentry-android-sqlite` for instrumenting `androidx.sqlite.SQLiteDriver` ([#5563](https://github.com/getsentry/sentry-java/pull/5563)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java index f2612b4aa84..0629b7a4908 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/FirstDrawDoneListener.java @@ -112,7 +112,14 @@ public void onDraw() { // OnDrawListeners cannot be removed within onDraw, so we remove it with a // GlobalLayoutListener view.getViewTreeObserver() - .addOnGlobalLayoutListener(() -> view.getViewTreeObserver().removeOnDrawListener(this)); + .addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + view.getViewTreeObserver().removeOnGlobalLayoutListener(this); + view.getViewTreeObserver().removeOnDrawListener(FirstDrawDoneListener.this); + } + }); mainThreadHandler.postAtFrontOfQueue(callback); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt index 008a036cbfc..44d6d9fd03a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/FirstDrawDoneListenerTest.kt @@ -128,6 +128,37 @@ class FirstDrawDoneListenerTest { assertTrue(fixture.onDrawListeners.isEmpty()) } + @Test + fun `OnGlobalLayoutListener is removed after cleanup`() { + val view = fixture.getSut() + + // Initialize mOnGlobalLayoutListeners via a dummy add/remove + val dummyGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {} + view.viewTreeObserver.addOnGlobalLayoutListener(dummyGlobalListener) + view.viewTreeObserver.removeOnGlobalLayoutListener(dummyGlobalListener) + + // CopyOnWriteArray wraps an internal ArrayList called mData + val copyOnWriteArray: Any = view.viewTreeObserver.getProperty("mOnGlobalLayoutListeners") + val mDataField = copyOnWriteArray.javaClass.getDeclaredField("mData") + mDataField.isAccessible = true + + @Suppress("UNCHECKED_CAST") + fun globalLayoutListeners(): ArrayList<*> = mDataField.get(copyOnWriteArray) as ArrayList<*> + + assertTrue(globalLayoutListeners().isEmpty()) + + FirstDrawDoneListener.registerForNextDraw(view, {}, fixture.buildInfo) + + // onDraw registers a cleanup OnGlobalLayoutListener + view.viewTreeObserver.dispatchOnDraw() + assertFalse(globalLayoutListeners().isEmpty()) + + // onGlobalLayout fires the cleanup, which removes both the draw and layout listeners + view.viewTreeObserver.dispatchOnGlobalLayout() + assertTrue(globalLayoutListeners().isEmpty()) + assertTrue(fixture.onDrawListeners.isEmpty()) + } + @Test fun `registerForNextDraw calls the given callback on the main thread after onDraw`() { val view = fixture.getSut() From f4269fd1cb8cbeef665b2e1316819fc632e2e338 Mon Sep 17 00:00:00 2001 From: runningcode <332597+runningcode@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:51:59 +0000 Subject: [PATCH 022/195] release: 8.44.1 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 395b1f2e2b1..b3bdcd38bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.44.1 ### Fixes diff --git a/gradle.properties b/gradle.properties index 19127ac9832..f2e3da3ca09 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.experimental.lint.version=8.13.1 # Release information -versionName=8.44.0 +versionName=8.44.1 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 69943b840653f4dde405bdc32c4fde5732dcc43f Mon Sep 17 00:00:00 2001 From: arb Date: Fri, 19 Jun 2026 16:00:18 +0200 Subject: [PATCH 023/195] refactor(android-sqlite): Rename classes instrumenting SQLite spans for consistency (#5555) --- ...QLiteSpanManager.kt => OpenHelperSpans.kt} | 3 +- .../sqlite/SentryCrossProcessCursor.kt | 8 +-- .../sqlite/SentrySupportSQLiteDatabase.kt | 21 ++++--- .../sqlite/SentrySupportSQLiteOpenHelper.kt | 6 +- .../sqlite/SentrySupportSQLiteStatement.kt | 17 +++--- ...eSpanInstrumentation.kt => DriverSpans.kt} | 26 ++++----- .../sentry/sqlite/SentrySQLiteConnection.kt | 2 +- .../io/sentry/sqlite/SentrySQLiteDriver.kt | 2 +- .../io/sentry/sqlite/SentrySQLiteStatement.kt | 4 +- ...nManagerTest.kt => OpenHelperSpansTest.kt} | 6 +- .../sqlite/SentryCrossProcessCursorTest.kt | 4 +- .../sqlite/SentrySupportSQLiteDatabaseTest.kt | 4 +- .../SentrySupportSQLiteStatementTest.kt | 4 +- ...trumentationTest.kt => DriverSpansTest.kt} | 55 +++++++++---------- .../sqlite/SentrySQLiteConnectionTest.kt | 4 +- .../sqlite/SentrySQLiteStatementTest.kt | 10 ++-- 16 files changed, 82 insertions(+), 94 deletions(-) rename sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/{SQLiteSpanManager.kt => OpenHelperSpans.kt} (96%) rename sentry-android-sqlite/src/main/java/io/sentry/sqlite/{SQLiteSpanInstrumentation.kt => DriverSpans.kt} (81%) rename sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/{SQLiteSpanManagerTest.kt => OpenHelperSpansTest.kt} (97%) rename sentry-android-sqlite/src/test/java/io/sentry/sqlite/{SQLiteSpanInstrumentationTest.kt => DriverSpansTest.kt} (77%) diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt similarity index 96% rename from sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt rename to sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt index 1bdeb7d369c..059eb1bb1b5 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SQLiteSpanManager.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/OpenHelperSpans.kt @@ -13,7 +13,8 @@ import io.sentry.SpanStatus private const val TRACE_ORIGIN = "auto.db.sqlite" -internal class SQLiteSpanManager( +/** Span instrumentation for [SentrySupportSQLiteOpenHelper]. */ +internal class OpenHelperSpans( private val scopes: IScopes = ScopesAdapter.getInstance(), private val databaseName: String? = null, ) { diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt index 1f3796a8975..f5f8424aca3 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentryCrossProcessCursor.kt @@ -13,7 +13,7 @@ import android.database.CursorWindow */ internal class SentryCrossProcessCursor( private val delegate: CrossProcessCursor, - private val spanManager: SQLiteSpanManager, + private val spans: OpenHelperSpans, private val sql: String, ) : CrossProcessCursor by delegate { // We have to start the span only the first time, regardless of how many times its methods get @@ -25,7 +25,7 @@ internal class SentryCrossProcessCursor( return delegate.count } isSpanStarted = true - return spanManager.performSql(sql) { delegate.count } + return spans.performSql(sql) { delegate.count } } override fun onMove(oldPosition: Int, newPosition: Int): Boolean { @@ -33,7 +33,7 @@ internal class SentryCrossProcessCursor( return delegate.onMove(oldPosition, newPosition) } isSpanStarted = true - return spanManager.performSql(sql) { delegate.onMove(oldPosition, newPosition) } + return spans.performSql(sql) { delegate.onMove(oldPosition, newPosition) } } override fun fillWindow(position: Int, window: CursorWindow?) { @@ -41,6 +41,6 @@ internal class SentryCrossProcessCursor( return delegate.fillWindow(position, window) } isSpanStarted = true - return spanManager.performSql(sql) { delegate.fillWindow(position, window) } + return spans.performSql(sql) { delegate.fillWindow(position, window) } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt index bfe3265f89b..458203a232f 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabase.kt @@ -14,11 +14,11 @@ import androidx.sqlite.db.SupportSQLiteStatement * and it's created automatically by the [SentrySupportSQLiteOpenHelper]. * * @param delegate The [SupportSQLiteDatabase] instance to delegate calls to. - * @param sqLiteSpanManager The [SQLiteSpanManager] responsible for the creation of the spans. + * @param spans The [OpenHelperSpans] manager responsible for the creation of the spans. */ internal class SentrySupportSQLiteDatabase( private val delegate: SupportSQLiteDatabase, - private val sqLiteSpanManager: SQLiteSpanManager, + private val spans: OpenHelperSpans, ) : SupportSQLiteDatabase by delegate { /** * Compiles the given SQL statement. It will return Sentry's wrapper around @@ -28,35 +28,34 @@ internal class SentrySupportSQLiteDatabase( * @return Compiled statement. */ override fun compileStatement(sql: String): SupportSQLiteStatement = - SentrySupportSQLiteStatement(delegate.compileStatement(sql), sqLiteSpanManager, sql) + SentrySupportSQLiteStatement(delegate.compileStatement(sql), spans, sql) @Suppress("AcronymName") // To keep consistency with framework method name. override fun execPerConnectionSQL( sql: String, @SuppressLint("ArrayReturn") bindArgs: Array?, ) { - sqLiteSpanManager.performSql(sql) { delegate.execPerConnectionSQL(sql, bindArgs) } + spans.performSql(sql) { delegate.execPerConnectionSQL(sql, bindArgs) } } - override fun query(query: String): Cursor = - sqLiteSpanManager.performSql(query) { delegate.query(query) } + override fun query(query: String): Cursor = spans.performSql(query) { delegate.query(query) } override fun query(query: String, bindArgs: Array): Cursor = - sqLiteSpanManager.performSql(query) { delegate.query(query, bindArgs) } + spans.performSql(query) { delegate.query(query, bindArgs) } override fun query(query: SupportSQLiteQuery): Cursor = - sqLiteSpanManager.performSql(query.sql) { delegate.query(query) } + spans.performSql(query.sql) { delegate.query(query) } override fun query(query: SupportSQLiteQuery, cancellationSignal: CancellationSignal?): Cursor = - sqLiteSpanManager.performSql(query.sql) { delegate.query(query, cancellationSignal) } + spans.performSql(query.sql) { delegate.query(query, cancellationSignal) } @Throws(SQLException::class) override fun execSQL(sql: String) { - sqLiteSpanManager.performSql(sql) { delegate.execSQL(sql) } + spans.performSql(sql) { delegate.execSQL(sql) } } @Throws(SQLException::class) override fun execSQL(sql: String, bindArgs: Array) { - sqLiteSpanManager.performSql(sql) { delegate.execSQL(sql, bindArgs) } + spans.performSql(sql) { delegate.execSQL(sql, bindArgs) } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt index 76b405d9f11..12b63cfa128 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteOpenHelper.kt @@ -33,14 +33,14 @@ import androidx.sqlite.db.SupportSQLiteOpenHelper public class SentrySupportSQLiteOpenHelper private constructor(private val delegate: SupportSQLiteOpenHelper) : SupportSQLiteOpenHelper by delegate { - private val sqLiteSpanManager = SQLiteSpanManager(databaseName = delegate.databaseName) + private val spans = OpenHelperSpans(databaseName = delegate.databaseName) private val sentryWritableDatabase: SupportSQLiteDatabase by lazy { - SentrySupportSQLiteDatabase(delegate.writableDatabase, sqLiteSpanManager) + SentrySupportSQLiteDatabase(delegate.writableDatabase, spans) } private val sentryReadableDatabase: SupportSQLiteDatabase by lazy { - SentrySupportSQLiteDatabase(delegate.readableDatabase, sqLiteSpanManager) + SentrySupportSQLiteDatabase(delegate.readableDatabase, spans) } override val writableDatabase: SupportSQLiteDatabase diff --git a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt index 1a364dc27ba..3df6d287b28 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/android/sqlite/SentrySupportSQLiteStatement.kt @@ -9,25 +9,22 @@ import androidx.sqlite.db.SupportSQLiteStatement * [SentrySupportSQLiteDatabase.compileStatement]. * * @param delegate The [SupportSQLiteStatement] instance to delegate calls to. - * @param sqLiteSpanManager The [SQLiteSpanManager] responsible for the creation of the spans. + * @param spans The [OpenHelperSpans] manager responsible for the creation of the spans. * @param sql The query string. */ internal class SentrySupportSQLiteStatement( private val delegate: SupportSQLiteStatement, - private val sqLiteSpanManager: SQLiteSpanManager, + private val spans: OpenHelperSpans, private val sql: String, ) : SupportSQLiteStatement by delegate { - override fun execute() = sqLiteSpanManager.performSql(sql) { delegate.execute() } + override fun execute() = spans.performSql(sql) { delegate.execute() } - override fun executeUpdateDelete(): Int = - sqLiteSpanManager.performSql(sql) { delegate.executeUpdateDelete() } + override fun executeUpdateDelete(): Int = spans.performSql(sql) { delegate.executeUpdateDelete() } - override fun executeInsert(): Long = - sqLiteSpanManager.performSql(sql) { delegate.executeInsert() } + override fun executeInsert(): Long = spans.performSql(sql) { delegate.executeInsert() } - override fun simpleQueryForLong(): Long = - sqLiteSpanManager.performSql(sql) { delegate.simpleQueryForLong() } + override fun simpleQueryForLong(): Long = spans.performSql(sql) { delegate.simpleQueryForLong() } override fun simpleQueryForString(): String? = - sqLiteSpanManager.performSql(sql) { delegate.simpleQueryForString() } + spans.performSql(sql) { delegate.simpleQueryForString() } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt similarity index 81% rename from sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt rename to sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt index f0998dfdc23..b3c0eb7c713 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SQLiteSpanInstrumentation.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/DriverSpans.kt @@ -20,20 +20,17 @@ private const val SQLITE_TRACE_ORIGIN = "auto.db.sqlite" private val EMPTY_NANO_TIME = SentryNanotimeDate(0, 0L) /** Span instrumentation for [SentrySQLiteDriver]. */ -internal class SQLiteSpanInstrumentation( - private val scopes: IScopes, - private val dbMetadata: DbMetadata, -) { +internal class DriverSpans(private val scopes: IScopes, private val dbMetadata: DbMetadata) { private val stackTraceFactory = SentryStackTraceFactory(scopes.options) /** - * Returns a timestamp in nanoseconds for use with [recordSpan]. Timestamp is ns-precise if the - * active parent span uses a [SentryNanotimeDate] (the ordinary case); otherwise it's ms-precise. + * Returns a timestamp in nanoseconds for use with [record]. Timestamp is ns-precise if the active + * parent span uses a [SentryNanotimeDate] (the ordinary case); otherwise it's ms-precise. * - * Note: Internalizing the start time in [recordSpan] would shift spans to end-of-work on the - * trace timeline, which is less desirable; callers capture the start before doing database work - * and pass it back to [recordSpan]. + * Note: Internalizing the start time in [record] would shift spans to end-of-work on the trace + * timeline, which is less desirable; callers capture the start before doing database work and + * pass it back to [record]. */ fun startTimestamp(): Long = // Try to retain nanosecond precision + avoid SentryDate allocation... @@ -42,7 +39,7 @@ internal class SQLiteSpanInstrumentation( ?: scopes.options.dateProvider.now().nanoTimestamp() /** Records a `db.sql.query` span. */ - fun recordSpan( + fun record( sql: String, startTimestampNanos: Long, durationNanos: Long, @@ -73,14 +70,11 @@ internal class SQLiteSpanInstrumentation( companion object { /** - * Returns [SQLiteSpanInstrumentation] based on the [fileName] argument passed to + * Returns [DriverSpans] based on the [fileName] argument passed to * [SQLiteDriver.open][androidx.sqlite.SQLiteDriver.open]. */ - fun fromFileName( - fileName: String, - scopes: IScopes = ScopesAdapter.getInstance(), - ): SQLiteSpanInstrumentation = - SQLiteSpanInstrumentation(scopes, dbMetadataFromFileName(fileName)) + fun fromFileName(fileName: String, scopes: IScopes = ScopesAdapter.getInstance()): DriverSpans = + DriverSpans(scopes, dbMetadataFromFileName(fileName)) } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt index 45ee9a39b27..e01544b0523 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteConnection.kt @@ -5,7 +5,7 @@ import androidx.sqlite.SQLiteStatement internal class SentrySQLiteConnection( private val delegate: SQLiteConnection, - private val spans: SQLiteSpanInstrumentation, + private val spans: DriverSpans, ) : SQLiteConnection by delegate { override fun prepare(sql: String): SQLiteStatement { diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt index f0f41782c22..22f6353d883 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -48,7 +48,7 @@ public class SentrySQLiteDriver private constructor(private val delegate: SQLite val connection = delegate.open(fileName) return try { - val spans = SQLiteSpanInstrumentation.fromFileName(fileName) + val spans = DriverSpans.fromFileName(fileName) // create() ensures delegate is unwrapped, so we don't need to protect against double-wrapping // the connection. SentrySQLiteConnection(connection, spans) diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt index a739a396bcb..e220a74cd1e 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteStatement.kt @@ -16,7 +16,7 @@ import io.sentry.SpanStatus */ internal class SentrySQLiteStatement( private val delegate: SQLiteStatement, - private val spans: SQLiteSpanInstrumentation, + private val spans: DriverSpans, private val sql: String, private val nanoTimeProvider: () -> Long = { System.nanoTime() }, ) : SQLiteStatement by delegate { @@ -74,6 +74,6 @@ internal class SentrySQLiteStatement( val duration = accumulatedDbNanos firstStepTimestampNanos = null accumulatedDbNanos = 0L - spans.recordSpan(sql, startNanos, duration, status, throwable) + spans.record(sql, startNanos, duration, status, throwable) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt similarity index 97% rename from sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt rename to sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt index 6fd6fa51bb3..0552094838e 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SQLiteSpanManagerTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/OpenHelperSpansTest.kt @@ -21,13 +21,13 @@ import org.junit.Before import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -class SQLiteSpanManagerTest { +class OpenHelperSpansTest { private class Fixture { private val scopes = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions - fun getSut(isSpanActive: Boolean = true, databaseName: String? = null): SQLiteSpanManager { + fun getSut(isSpanActive: Boolean = true, databaseName: String? = null): OpenHelperSpans { options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } whenever(scopes.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) @@ -35,7 +35,7 @@ class SQLiteSpanManagerTest { if (isSpanActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SQLiteSpanManager(scopes, databaseName) + return OpenHelperSpans(scopes, databaseName) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt index 44836dd0c97..27eff29c9f3 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentryCrossProcessCursorTest.kt @@ -20,7 +20,7 @@ import org.mockito.kotlin.whenever class SentryCrossProcessCursorTest { private class Fixture { private val scopes = mock() - private val spanManager = SQLiteSpanManager(scopes) + private val spans = OpenHelperSpans(scopes) val mockCursor = mock() lateinit var options: SentryOptions lateinit var sentryTracer: SentryTracer @@ -33,7 +33,7 @@ class SentryCrossProcessCursorTest { if (isSpanActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SentryCrossProcessCursor(mockCursor, spanManager, sql) + return SentryCrossProcessCursor(mockCursor, spans, sql) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt index 81bd964cc87..6a47eb6fa92 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteDatabaseTest.kt @@ -23,7 +23,7 @@ import org.mockito.kotlin.whenever class SentrySupportSQLiteDatabaseTest { private class Fixture { private val scopes = mock() - private val spanManager = SQLiteSpanManager(scopes) + private val spans = OpenHelperSpans(scopes) val mockDatabase = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions @@ -41,7 +41,7 @@ class SentrySupportSQLiteDatabaseTest { whenever(scopes.span).thenReturn(sentryTracer) } - return SentrySupportSQLiteDatabase(mockDatabase, spanManager) + return SentrySupportSQLiteDatabase(mockDatabase, spans) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt index b2b4998ace8..c4d810adbcd 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/android/sqlite/SentrySupportSQLiteStatementTest.kt @@ -18,7 +18,7 @@ import org.mockito.kotlin.whenever class SentrySupportSQLiteStatementTest { private class Fixture { private val scopes = mock() - private val spanManager = SQLiteSpanManager(scopes) + private val spans = OpenHelperSpans(scopes) val mockStatement = mock() lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions @@ -31,7 +31,7 @@ class SentrySupportSQLiteStatementTest { if (isSpanActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SentrySupportSQLiteStatement(mockStatement, spanManager, sql) + return SentrySupportSQLiteStatement(mockStatement, spans, sql) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt similarity index 77% rename from sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt rename to sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt index 74bd1c7f882..319fc20d7ce 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SQLiteSpanInstrumentationTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/DriverSpansTest.kt @@ -21,7 +21,7 @@ import kotlin.test.assertTrue import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -class SQLiteSpanInstrumentationTest { +class DriverSpansTest { private class Fixture { @@ -29,17 +29,14 @@ class SQLiteSpanInstrumentationTest { lateinit var sentryTracer: SentryTracer lateinit var options: SentryOptions - fun getSut( - isTransactionActive: Boolean = true, - fileName: String = ":memory:", - ): SQLiteSpanInstrumentation { + fun getSut(isTransactionActive: Boolean = true, fileName: String = ":memory:"): DriverSpans { options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } whenever(scopes.options).thenReturn(options) sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) if (isTransactionActive) { whenever(scopes.span).thenReturn(sentryTracer) } - return SQLiteSpanInstrumentation.fromFileName(fileName, scopes) + return DriverSpans.fromFileName(fileName, scopes) } } @@ -56,7 +53,7 @@ class SQLiteSpanInstrumentationTest { val start = sut.startTimestamp() val durationNanos = 42_000_000L - sut.recordSpan("SELECT 1", start, durationNanos, SpanStatus.OK) + sut.record("SELECT 1", start, durationNanos, SpanStatus.OK) val span = fixture.sentryTracer.children.first() @@ -81,7 +78,7 @@ class SQLiteSpanInstrumentationTest { whenever(fixture.scopes.options).thenReturn(options) whenever(fixture.scopes.span).thenReturn(parentSpan) - val sut = SQLiteSpanInstrumentation.fromFileName(":memory:", fixture.scopes) + val sut = DriverSpans.fromFileName(":memory:", fixture.scopes) assertEquals(providerDate.nanoTimestamp(), sut.startTimestamp()) } @@ -97,31 +94,31 @@ class SQLiteSpanInstrumentationTest { whenever(fixture.scopes.options).thenReturn(options) whenever(fixture.scopes.span).thenReturn(null) - val sut = SQLiteSpanInstrumentation.fromFileName(":memory:", fixture.scopes) + val sut = DriverSpans.fromFileName(":memory:", fixture.scopes) assertEquals(providerDate.nanoTimestamp(), sut.startTimestamp()) } @Test - fun `recordSpan records a span if a transaction is active`() { + fun `record method records a span if a transaction is active`() { val sut = fixture.getSut(isTransactionActive = true) - sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + sut.record("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) assertEquals(1, fixture.sentryTracer.children.size) } @Test - fun `recordSpan does not record a span if no transaction is active`() { + fun `record method does not record a span if no transaction is active`() { val sut = fixture.getSut(isTransactionActive = false) val start = sut.startTimestamp() - sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + sut.record("SELECT 1", start, 1_000_000, SpanStatus.OK) assertEquals(0, fixture.sentryTracer.children.size) } @Test - fun `recordSpan creates a span with correct properties`() { + fun `record method creates a span with correct properties`() { val sut = fixture.getSut() val start = sut.startTimestamp() - sut.recordSpan("SELECT * FROM users", start, 1_000_000, SpanStatus.OK) + sut.record("SELECT * FROM users", start, 1_000_000, SpanStatus.OK) val span = fixture.sentryTracer.children.firstOrNull() assertNotNull(span) @@ -133,24 +130,24 @@ class SQLiteSpanInstrumentationTest { } @Test - fun `recordSpan sets finishDate equal to startDate + durationNanos`() { + fun `record method sets finishDate equal to startDate + durationNanos`() { val sut = fixture.getSut() val start = sut.startTimestamp() val durationNanos = 42_000_000L - sut.recordSpan("SELECT 1", start, durationNanos, SpanStatus.OK) + sut.record("SELECT 1", start, durationNanos, SpanStatus.OK) val span = fixture.sentryTracer.children.first() assertEquals(span.startDate.nanoTimestamp() + durationNanos, span.finishDate!!.nanoTimestamp()) } @Test - fun `recordSpan attaches throwable when provided`() { + fun `record method attaches throwable when provided`() { val sut = fixture.getSut() val start = sut.startTimestamp() val exception = RuntimeException("disk I/O error") - sut.recordSpan("INSERT INTO t VALUES(1)", start, 500_000, SpanStatus.INTERNAL_ERROR, exception) + sut.record("INSERT INTO t VALUES(1)", start, 500_000, SpanStatus.INTERNAL_ERROR, exception) val span = fixture.sentryTracer.children.first() assertEquals(SpanStatus.INTERNAL_ERROR, span.status) @@ -158,10 +155,10 @@ class SQLiteSpanInstrumentationTest { } @Test - fun `recordSpan sets db system and db name when fileName is not the in-memory sentinel`() { + fun `record method sets db system and db name when fileName is not the in-memory sentinel`() { val sut = fixture.getSut(fileName = "/data/data/com.example/databases/tracks.db") val start = sut.startTimestamp() - sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + sut.record("SELECT 1", start, 1_000_000, SpanStatus.OK) val span = fixture.sentryTracer.children.first() assertEquals("sqlite", span.data[SpanDataConvention.DB_SYSTEM_KEY]) @@ -169,10 +166,10 @@ class SQLiteSpanInstrumentationTest { } @Test - fun `recordSpan sets db system only when fileName is the in-memory sentinel`() { + fun `record method sets db system only when fileName is the in-memory sentinel`() { val sut = fixture.getSut(fileName = ":memory:") val start = sut.startTimestamp() - sut.recordSpan("SELECT 1", start, 1_000_000, SpanStatus.OK) + sut.record("SELECT 1", start, 1_000_000, SpanStatus.OK) val span = fixture.sentryTracer.children.first() assertEquals("in-memory", span.data[SpanDataConvention.DB_SYSTEM_KEY]) @@ -180,13 +177,13 @@ class SQLiteSpanInstrumentationTest { } @Test - fun `recordSpan sets blocked_main_thread to true and attaches call stack on main thread`() { + fun `record method sets blocked_main_thread to true and attaches call stack on main thread`() { val sut = fixture.getSut() fixture.options.threadChecker = mock() whenever(fixture.options.threadChecker.isMainThread).thenReturn(true) whenever(fixture.options.threadChecker.currentThreadName).thenReturn("main") - sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + sut.record("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) val span = fixture.sentryTracer.children.first() assertTrue(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) @@ -194,20 +191,20 @@ class SQLiteSpanInstrumentationTest { } @Test - fun `recordSpan sets blocked_main_thread to false and does not attach a call stack on background thread`() { + fun `record method sets blocked_main_thread to false and does not attach a call stack on background thread`() { val sut = fixture.getSut() fixture.options.threadChecker = mock() whenever(fixture.options.threadChecker.isMainThread).thenReturn(false) whenever(fixture.options.threadChecker.currentThreadName).thenReturn("worker") - sut.recordSpan("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) + sut.record("SELECT 1", sut.startTimestamp(), 1_000_000, SpanStatus.OK) val span = fixture.sentryTracer.children.first() assertFalse(span.getData(SpanDataConvention.BLOCKED_MAIN_THREAD_KEY) as Boolean) assertNull(span.getData(SpanDataConvention.CALL_STACK_KEY)) } - private fun setUpWithNanotimeDates(vararg dates: SentryNanotimeDate): SQLiteSpanInstrumentation { + private fun setUpWithNanotimeDates(vararg dates: SentryNanotimeDate): DriverSpans { val dateQueue = ArrayDeque(dates.toList()) val options = SentryOptions().apply { @@ -217,6 +214,6 @@ class SQLiteSpanInstrumentationTest { whenever(fixture.scopes.options).thenReturn(options) fixture.sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes) whenever(fixture.scopes.span).thenReturn(fixture.sentryTracer) - return SQLiteSpanInstrumentation.fromFileName(":memory:", fixture.scopes) + return DriverSpans.fromFileName(":memory:", fixture.scopes) } } diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt index b405d054f03..212e3b032e4 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteConnectionTest.kt @@ -24,7 +24,7 @@ class SentrySQLiteConnectionTest { options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } whenever(scopes.options).thenReturn(options) whenever(mockConnection.prepare("SELECT 1")).thenReturn(mockStatement) - val spans = SQLiteSpanInstrumentation.fromFileName("test.db", scopes) + val spans = DriverSpans.fromFileName("test.db", scopes) return SentrySQLiteConnection(mockConnection, spans) } } @@ -41,7 +41,7 @@ class SentrySQLiteConnectionTest { @Test fun `prepare with already-wrapped statement returns same instance without re-wrapping`() { val sut = fixture.getSut() - val spans = SQLiteSpanInstrumentation.fromFileName("test.db", fixture.scopes) + val spans = DriverSpans.fromFileName("test.db", fixture.scopes) val alreadyInstrumented = SentrySQLiteStatement(fixture.mockStatement, spans, "SELECT 1") whenever(fixture.mockConnection.prepare("SELECT 1")).thenReturn(alreadyInstrumented) diff --git a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt index ce2c3f00cd5..bc6b074545a 100644 --- a/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt +++ b/sentry-android-sqlite/src/test/java/io/sentry/sqlite/SentrySQLiteStatementTest.kt @@ -19,7 +19,7 @@ class SentrySQLiteStatementTest { private class Fixture { val mockStatement = mock() - val mockSpans = mock() + val mockSpans = mock() val startTimestampNanos = 1_000_000_000_000L val fakeClock = AtomicLong(0L) @@ -40,7 +40,7 @@ class SentrySQLiteStatementTest { verifyNeverCalledRecordSpan() sut.step() verify(fixture.mockSpans) - .recordSpan( + .record( eq("SELECT * FROM users"), eq(fixture.startTimestampNanos), any(), @@ -58,7 +58,7 @@ class SentrySQLiteStatementTest { assertFailsWith { sut.step() } verify(fixture.mockSpans) - .recordSpan( + .record( eq("BAD SQL"), eq(fixture.startTimestampNanos), any(), @@ -224,7 +224,7 @@ class SentrySQLiteStatementTest { sut.step() val durationCaptor = argumentCaptor() - verify(fixture.mockSpans).recordSpan(any(), any(), durationCaptor.capture(), any(), anyOrNull()) + verify(fixture.mockSpans).record(any(), any(), durationCaptor.capture(), any(), anyOrNull()) // Each step contributes its internal time (10 + 20 + 30) plus one unit from // fakeClock::getAndIncrement between before/after reads, so total is 63. assertEquals(63L, durationCaptor.firstValue) @@ -285,6 +285,6 @@ class SentrySQLiteStatementTest { } private fun verifyCalledRecordSpan(times: Int = 1) { - verify(fixture.mockSpans, times(times)).recordSpan(any(), any(), any(), any(), anyOrNull()) + verify(fixture.mockSpans, times(times)).record(any(), any(), any(), any(), anyOrNull()) } } From 05aa61daa3d25b2c82424779b5dec47c2c37556b Mon Sep 17 00:00:00 2001 From: arb Date: Fri, 19 Jun 2026 17:20:19 +0200 Subject: [PATCH 024/195] chore(samples-android): Adapt SQLite demo screen to SAGP build mode (#5568) Exposes a `BuildConfig.USE_SAGP` property from the recently introduced -PuseSagp flag ([#5538](https://github.com/getsentry/sentry-java/pull/5538)). Lets us update the SQLite screen in the Android sample app so that it swizzles between auto-instrumenting vs manually wrapping `SQLiteDriver`, depending on the whether SAGP was applied to the build. --- .../sentry-samples-android/build.gradle.kts | 12 ++ .../samples/android/sqlite/SQLiteActivity.kt | 111 +++++++++++++----- .../samples/android/sqlite/SampleDatabases.kt | 45 ++++--- 3 files changed, 112 insertions(+), 56 deletions(-) diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 74e3c3a57b8..96ded862f95 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -1,4 +1,5 @@ import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.variant.BuildConfigField import com.android.build.api.variant.impl.VariantImpl import io.sentry.android.gradle.extensions.InstrumentationFeature import io.sentry.android.gradle.extensions.SentryPluginExtension @@ -135,6 +136,17 @@ android { } androidComponents.onVariants { variant -> + variant.buildConfigFields?.put( + "USE_SAGP", + providers.provider { + BuildConfigField( + type = "boolean", + value = providers.gradleProperty("useSagp").isPresent.toString(), + comment = "Whether the Sentry Android Gradle Plugin was applied", + ) + }, + ) + val taskName = "toggle${variant.name.capitalized()}NativeLogging" val toggleNativeLoggingTask = project.tasks.register(taskName) { diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt index 9a27ecda353..54334b6e407 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SQLiteActivity.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.HelpOutline @@ -45,6 +46,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -70,6 +72,7 @@ import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.TransactionOptions import io.sentry.protocol.SentryId +import io.sentry.samples.android.BuildConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -93,33 +96,45 @@ private val CONTROL_SECTION_GAP = TOGGLE_SECTION_GAP * 2 private val SECTION_HEADER_HEIGHT = 28.dp +private const val SAGP_DIRECT_DRIVER_MESSAGE = + "SAGP doesn't auto-instrument SQLiteDriver for direct use" + /** Which sentry-android-sqlite integration the demo currently targets. */ private enum class IntegrationMode( val color: Color, val segmentLabel: String, val apiName: String, - val subtitle: String, ) { - DRIVER( - SentryPurple, - "SQLiteDriver", - "SQLiteDriver", - "SentrySQLiteDriver.create(BundledSQLiteDriver)", - ), - OPEN_HELPER( - SentryPink, - "OpenHelper", - "SupportSQLiteOpenHelper", - "SentrySupportSQLiteOpenHelper.create(...)", - ), + + DRIVER(SentryPurple, "SQLiteDriver", "SQLiteDriver"), + OPEN_HELPER(SentryPink, "OpenHelper", "SupportSQLiteOpenHelper"), // Not directly-supported, but lets us verify behavior when both the DRIVER and OPEN_HELPER // integrations are used together via the SupportSQLiteDriver bridge. - BRIDGE( - SentryOrange, - "Bridge", - "SupportSQLiteDriver bridge", - "SentrySQLiteDriver.create(SupportSQLiteDriver(Sentry helper))", - ), + BRIDGE(SentryOrange, "Bridge", "SupportSQLiteDriver bridge"); + + fun subtitle(): String = + when (this) { + DRIVER -> + if (BuildConfig.USE_SAGP) { + "BundledSQLiteDriver (SAGP auto-wrap)" + } else { + "SentrySQLiteDriver.create(BundledSQLiteDriver)" + } + + OPEN_HELPER -> + if (BuildConfig.USE_SAGP) { + "FrameworkSQLiteOpenHelperFactory (SAGP auto-wrap)" + } else { + "SentrySupportSQLiteOpenHelper.create(...)" + } + + BRIDGE -> + if (BuildConfig.USE_SAGP) { + "SupportSQLiteDriver(open helper) (SAGP auto-wrap)" + } else { + "SentrySQLiteDriver.create(SupportSQLiteDriver(Sentry helper))" + } + } } /** @@ -318,6 +333,7 @@ class SQLiteActivity : ComponentActivity() { lerp(MaterialTheme.colorScheme.outline, integration.color, shimmer.value) Text(text = "SQLite Instrumentation", style = MaterialTheme.typography.headlineSmall) + SagpBuildPill() Spacer(Modifier.height(titleGap)) @@ -364,6 +380,7 @@ class SQLiteActivity : ComponentActivity() { label = row.label, color = integration.color, variant = variant, + sagpDisabledReason = sagpDisabledReason(integration, row), disabledReason = "${row.label} doesn't support the ${integration.apiName} stack", ) } @@ -447,7 +464,7 @@ class SQLiteActivity : ComponentActivity() { } @OptIn(ExperimentalMaterial3Api::class) - @androidx.compose.runtime.Composable + @Composable private fun IntegrationModeSelector( selected: IntegrationMode, onSelected: (IntegrationMode) -> Unit, @@ -471,18 +488,36 @@ class SQLiteActivity : ComponentActivity() { } Text( - text = selected.subtitle, + text = selected.subtitle(), style = MaterialTheme.typography.bodySmall, color = Color.Gray, modifier = Modifier.padding(top = 6.dp), ) } + @Composable + private fun SagpBuildPill() { + val useSagp = BuildConfig.USE_SAGP + + Surface( + shape = RoundedCornerShape(percent = 50), + color = if (useSagp) SentryPurple.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.2f), + modifier = Modifier.padding(top = 6.dp), + ) { + Text( + text = if (useSagp) "Built with SAGP" else "Built without SAGP", + style = MaterialTheme.typography.labelSmall, + color = if (useSagp) SentryPurple else Color.DarkGray, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } + } + /** * A compact, left-justified labeled switch. [labelColor] defaults to [Color.Unspecified] so the * label inherits the default text color. */ - @androidx.compose.runtime.Composable + @Composable private fun ToggleRow( label: String, checked: Boolean, @@ -509,11 +544,11 @@ class SQLiteActivity : ComponentActivity() { } } - @androidx.compose.runtime.Composable + @Composable private fun SectionHeader( title: String, topPadding: Dp = 8.dp, - trailing: (@androidx.compose.runtime.Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, ) { Column(modifier = Modifier.fillMaxWidth().padding(top = topPadding)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -529,7 +564,7 @@ class SQLiteActivity : ComponentActivity() { * tooltip that auto-dismisses after a few seconds. */ @OptIn(ExperimentalMaterial3Api::class) - @androidx.compose.runtime.Composable + @Composable private fun HelpTooltip() { val tooltipState = rememberTooltipState(isPersistent = true) val scope = rememberCoroutineScope() @@ -565,16 +600,19 @@ class SQLiteActivity : ComponentActivity() { * dimmed and, when clicked, explains why via a toast ([disabledReason]) instead of running. */ @OptIn(ExperimentalFoundationApi::class) - @androidx.compose.runtime.Composable + @Composable private fun DemoRowButton( label: String, color: Color, variant: DemoVariant?, + sagpDisabledReason: String?, disabledReason: String, ) { val context = LocalContext.current - val enabled = variant != null - val explain = { Toast.makeText(context, disabledReason, Toast.LENGTH_SHORT).show() } + val enabled = variant != null && sagpDisabledReason == null + val explain = { + Toast.makeText(context, sagpDisabledReason ?: disabledReason, Toast.LENGTH_SHORT).show() + } Surface( modifier = Modifier.fillMaxWidth(), @@ -585,8 +623,8 @@ class SQLiteActivity : ComponentActivity() { Box( modifier = Modifier.combinedClickable( - onClick = { if (variant != null) onTap(variant) else explain() }, - onLongClick = { if (variant != null) onLongPress(variant) else explain() }, + onClick = { if (enabled) onTap(variant) else explain() }, + onLongClick = { if (enabled) onLongPress(variant) else explain() }, ) .fillMaxWidth() .heightIn(min = 44.dp) @@ -598,7 +636,7 @@ class SQLiteActivity : ComponentActivity() { } } - @androidx.compose.runtime.Composable + @Composable private fun ResetButton(dbOperationInFlight: Boolean, resetInProgress: Boolean) { // Debounce demo-driven disablement so fast taps don't flicker the button; reset disables // immediately via [resetInProgress]. [dbOperationInFlight] still guards [onClick] either way. @@ -642,7 +680,7 @@ class SQLiteActivity : ComponentActivity() { } } - @androidx.compose.runtime.Composable + @Composable private fun DetailField(label: String, value: String, borderColor: Color) { OutlinedTextField( value = value, @@ -699,6 +737,15 @@ class SQLiteActivity : ComponentActivity() { } } + private fun sagpDisabledReason(mode: IntegrationMode, row: DemoRow): String? { + if (!BuildConfig.USE_SAGP) return null + val demo = row.variantFor(mode)?.demo ?: return null + return when (demo) { + SqlDemo.DRIVER_DIRECT -> SAGP_DIRECT_DRIVER_MESSAGE + else -> null + } + } + /** Closes + deletes every demo database file (via [SampleDatabases]), then re-warms them. */ private suspend fun resetDatabases(): String { val cleared = SampleDatabases.reset(applicationContext) diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt index 19b292cd91e..f01a529499d 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/sqlite/SampleDatabases.kt @@ -5,6 +5,7 @@ import android.util.Log import androidx.room.Room import androidx.room3.Room as Room3 import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteOpenHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory @@ -13,6 +14,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.execSQL import app.cash.sqldelight.driver.android.AndroidSqliteDriver import io.sentry.android.sqlite.SentrySupportSQLiteOpenHelper +import io.sentry.samples.android.BuildConfig import io.sentry.samples.android.sqlite.SampleDatabases.driverDirectLock import io.sentry.samples.android.sqlite.SampleDatabases.openHelperDirectLock import io.sentry.samples.android.sqlite.SampleDatabases.reset @@ -85,12 +87,10 @@ object SampleDatabases { fun driverConnection(context: Context): SQLiteConnection = synchronized(driverDirectLock) { driverConnection - ?: SentrySQLiteDriver.create(BundledSQLiteDriver()) - .open(databaseFile(context, "driver_direct.db")) - .also { - it.execSQL(SqlStatements.CREATE_SONG) // one-time table setup, at open - driverConnection = it - } + ?: wrapDriver(BundledSQLiteDriver()).open(databaseFile(context, "driver_direct.db")).also { + it.execSQL(SqlStatements.CREATE_SONG) // one-time table setup, at open + driverConnection = it + } } /** @@ -104,7 +104,7 @@ object SampleDatabases { // SupportSQLiteDriver.open() requires fileName to match the helper's databaseName(); // use the absolute path Room and the direct driver path both pass to open(). val dbPath = databaseFile(context, "bridge_direct.db") - SentrySQLiteDriver.create(SupportSQLiteDriver(buildBridgeDirectHelper(context, dbPath))) + wrapDriver(SupportSQLiteDriver(buildBridgeDirectHelper(context, dbPath))) .open(dbPath) .also { it.execSQL(SqlStatements.CREATE_SONG) @@ -122,9 +122,7 @@ object SampleDatabases { "bridge_room2.db", ) .setDriver( - SentrySQLiteDriver.create( - SupportSQLiteDriver(buildBridgeRoom2Helper(context.applicationContext)) - ) + wrapDriver(SupportSQLiteDriver(buildBridgeRoom2Helper(context.applicationContext))) ) .setQueryCoroutineContext(Dispatchers.IO) .fallbackToDestructiveMigration(true) @@ -140,7 +138,7 @@ object SampleDatabases { SampleRoom2Database::class.java, "driver_room2.db", ) - .setDriver(SentrySQLiteDriver.create(BundledSQLiteDriver())) + .setDriver(wrapDriver(BundledSQLiteDriver())) .setQueryCoroutineContext(Dispatchers.IO) .fallbackToDestructiveMigration(true) .build() @@ -151,7 +149,7 @@ object SampleDatabases { synchronized(this) { driverRoom3Db ?: Room3.databaseBuilder(context.applicationContext, "driver_room3.db") - .setDriver(SentrySQLiteDriver.create(BundledSQLiteDriver())) + .setDriver(wrapDriver(BundledSQLiteDriver())) .setQueryCoroutineContext(Dispatchers.IO) .build() .also { driverRoom3Db = it } @@ -171,9 +169,7 @@ object SampleDatabases { "openhelper_room.db", ) .openHelperFactory { configuration -> - SentrySupportSQLiteOpenHelper.create( - FrameworkSQLiteOpenHelperFactory().create(configuration) - ) + wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) } .fallbackToDestructiveMigration(true) .build() @@ -189,9 +185,7 @@ object SampleDatabases { name = "openhelper_sqldelight.db", factory = SupportSQLiteOpenHelper.Factory { configuration -> - SentrySupportSQLiteOpenHelper.create( - FrameworkSQLiteOpenHelperFactory().create(configuration) - ) + wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) }, ) .also { sqlDelightDriver = it } @@ -232,9 +226,7 @@ object SampleDatabases { } ) .build() - return SentrySupportSQLiteOpenHelper.create( - FrameworkSQLiteOpenHelperFactory().create(configuration) - ) + return wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) } private fun buildSentryHelper(context: Context, dbName: String): SupportSQLiteOpenHelper { @@ -252,17 +244,22 @@ object SampleDatabases { } ) .build() - return SentrySupportSQLiteOpenHelper.create( - FrameworkSQLiteOpenHelperFactory().create(configuration) - ) + return wrapOpenHelper(FrameworkSQLiteOpenHelperFactory().create(configuration)) } + private fun wrapDriver(driver: SQLiteDriver): SQLiteDriver = + if (BuildConfig.USE_SAGP) driver else SentrySQLiteDriver.create(driver) + + private fun wrapOpenHelper(delegate: SupportSQLiteOpenHelper): SupportSQLiteOpenHelper = + if (BuildConfig.USE_SAGP) delegate else SentrySupportSQLiteOpenHelper.create(delegate) + /** Opens every database on a background thread, forcing the one-time open + bootstrap to run. */ fun warmUp(context: Context) { val appContext = context.applicationContext val generation = ++warmUpGeneration warmUpComplete = false warmUpErrors = "" + Log.i(TAG, "Warm-up starting (USE_SAGP=${BuildConfig.USE_SAGP})") // Fire-and-forget: the warm-up outlives no particular screen, so a bare scope is fine here. warmUpJob = CoroutineScope(Dispatchers.IO).launch { From 0c118e902e8632b9fc107e8063613ea19fd11e70 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 22 Jun 2026 11:02:52 +0200 Subject: [PATCH 025/195] feat(android): Report app start reason as `app.vitals.start.reason` on standalone app start transaction (#5552) * feat(android): Report app start reason on standalone app start transaction Read ApplicationStartInfo.getReason() (API 35+) and attach it as app.start.reason trace data on the standalone app.start transaction in both the foreground and headless paths. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(android): Register StandaloneAppStart SDK integration marker Advertise that standalone app start tracing is active by adding a StandaloneAppStart marker to the SDK metadata integrations when the feature is enabled. Internal SDK metadata only. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(changelog): Note app.start.reason is searchable in Trace Explorer Address review feedback to mention that customers can search and group by the app.vitals.start.reason attribute. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 2 + .../core/ActivityLifecycleIntegration.java | 10 +++ .../core/performance/AppStartMetrics.java | 45 ++++++++++++ .../core/ActivityLifecycleIntegrationTest.kt | 71 +++++++++++++++++++ .../performance/AppStartMetricsTestApi35.kt | 42 +++++++++++ 6 files changed, 171 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3bdcd38bc4..5d9d4dddac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Emits a transaction named `App Start` with op `app.start`, carrying the existing app start measurements and phase spans (`process.load`, `contentprovider.load`, `application.load`, activity lifecycle spans) as direct children of the root - The standalone transaction shares the same `traceId` as the first `ui.load` activity transaction so they remain linked in the trace view - Also covers non-activity starts (broadcast receivers, services, content providers) + - On Android 15+ (API 35), the standalone `app.start` transaction reports why the OS started the process via `app.vitals.start.reason` trace data (e.g. `launcher`, `broadcast`, `service`, `content_provider`), derived from `ApplicationStartInfo.getReason()`. You can search and group by this attribute in the Trace Explorer. ([#5552](https://github.com/getsentry/sentry-java/pull/5552)) ### Improvements diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 0500ba44990..58325d08b5b 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -746,6 +746,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun getAppStartContinuousProfiler ()Lio/sentry/IContinuousProfiler; public fun getAppStartEndTime ()Lio/sentry/SentryDate; public fun getAppStartProfiler ()Lio/sentry/ITransactionProfiler; + public fun getAppStartReason ()Ljava/lang/String; public fun getAppStartSamplingDecision ()Lio/sentry/TracesSamplingDecision; public fun getAppStartSentryTraceHeader ()Ljava/lang/String; public fun getAppStartTimeSpan ()Lio/sentry/android/core/performance/TimeSpan; @@ -780,6 +781,7 @@ public class io/sentry/android/core/performance/AppStartMetrics : io/sentry/andr public fun setAppStartSentryTraceHeader (Ljava/lang/String;)V public fun setAppStartTraceId (Lio/sentry/protocol/SentryId;)V public fun setAppStartType (Lio/sentry/android/core/performance/AppStartMetrics$AppStartType;)V + public fun setCachedStartInfo (Landroid/app/ApplicationStartInfo;)V public fun setClassLoadedUptimeMs (J)V public fun setHeadlessAppStartListener (Lio/sentry/android/core/performance/AppStartMetrics$HeadlessAppStartListener;)V public fun shouldSendStartMeasurements ()Z diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 8a891926341..d70ff837178 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -71,6 +71,7 @@ public final class ActivityLifecycleIntegration static final long APP_START_TO_UI_LOAD_CONTINUATION_MAX_GAP_NANOS = TimeUnit.MINUTES.toNanos(1); private static final String TRACE_ORIGIN = "auto.ui.activity"; static final String APP_START_SCREEN_DATA = "app.vitals.start.screen"; + static final String APP_START_REASON_DATA = "app.vitals.start.reason"; static final String APP_START_TRACE_ORIGIN = "auto.app.start"; private final @NotNull Application application; @@ -139,6 +140,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions if (performanceEnabled && this.options.isEnableStandaloneAppStartTracing()) { AppStartMetrics.getInstance().setHeadlessAppStartListener(this::onHeadlessAppStart); + addIntegrationToSdkVersion("StandaloneAppStart"); } this.options.getLogger().log(SentryLevel.DEBUG, "ActivityLifecycleIntegration installed."); @@ -285,6 +287,10 @@ private void startTracing(final @NotNull Activity activity) { appStartSamplingDecision), appStartTransactionOptions); appStartTransaction.setData(APP_START_SCREEN_DATA, activityName); + final @Nullable String appStartReason = AppStartMetrics.getInstance().getAppStartReason(); + if (appStartReason != null) { + appStartTransaction.setData(APP_START_REASON_DATA, appStartReason); + } } // Continue either the foreground app.start above or an earlier headless app.start. @@ -1001,6 +1007,10 @@ private void onHeadlessAppStart() { null); final @NotNull ITransaction transaction = scopes.startTransaction(txnContext, txnOptions); + final @Nullable String appStartReason = metrics.getAppStartReason(); + if (appStartReason != null) { + transaction.setData(APP_START_REASON_DATA, appStartReason); + } metrics.setAppStartTraceId(transaction.getSpanContext().getTraceId()); // Persist trace headers so a later ui.load can share traceId and sampleRand. metrics.setAppStartSentryTraceHeader(transaction.toSentryTrace().getValue()); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index d8cb0827ba4..36cae8686ca 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -166,6 +166,45 @@ public void setAppStartType(final @NotNull AppStartType appStartType) { return appStartType; } + /** + * The reason the OS started the process, mapped from {@link ApplicationStartInfo#getReason()}. + * Only available on API 35+ (when {@link #cachedStartInfo} was resolved); returns {@code null} + * otherwise or for an unmapped reason. + */ + public @Nullable String getAppStartReason() { + if (cachedStartInfo == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { + return null; + } + switch (cachedStartInfo.getReason()) { + case ApplicationStartInfo.START_REASON_ALARM: + return "alarm"; + case ApplicationStartInfo.START_REASON_BACKUP: + return "backup"; + case ApplicationStartInfo.START_REASON_BOOT_COMPLETE: + return "boot_complete"; + case ApplicationStartInfo.START_REASON_BROADCAST: + return "broadcast"; + case ApplicationStartInfo.START_REASON_CONTENT_PROVIDER: + return "content_provider"; + case ApplicationStartInfo.START_REASON_JOB: + return "job"; + case ApplicationStartInfo.START_REASON_LAUNCHER: + return "launcher"; + case ApplicationStartInfo.START_REASON_LAUNCHER_RECENTS: + return "launcher_recents"; + case ApplicationStartInfo.START_REASON_PUSH: + return "push"; + case ApplicationStartInfo.START_REASON_SERVICE: + return "service"; + case ApplicationStartInfo.START_REASON_START_ACTIVITY: + return "start_activity"; + case ApplicationStartInfo.START_REASON_OTHER: + return "other"; + default: + return null; + } + } + public boolean isAppLaunchedInForeground() { return appLaunchedInForeground.getValue(); } @@ -372,6 +411,12 @@ public void setClassLoadedUptimeMs(final long classLoadedUptimeMs) { CLASS_LOADED_UPTIME_MS = classLoadedUptimeMs; } + @TestOnly + @ApiStatus.Internal + public void setCachedStartInfo(final @Nullable ApplicationStartInfo cachedStartInfo) { + this.cachedStartInfo = cachedStartInfo; + } + /** * Called by instrumentation * diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index 19f43432bef..8b842a0cfa9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.ActivityManager import android.app.ActivityManager.RunningAppProcessInfo import android.app.Application +import android.app.ApplicationStartInfo import android.content.Context import android.os.Build import android.os.Bundle @@ -274,6 +275,76 @@ class ActivityLifecycleIntegrationTest { ) } + @Test + @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) + fun `Standalone app start transaction carries app start reason when available`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + val startInfo = + mock().apply { + whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_LAUNCHER) + } + AppStartMetrics.getInstance().setCachedStartInfo(startInfo) + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertEquals("launcher", appStartTransaction.getData("app.vitals.start.reason")) + } + + @Test + fun `Standalone app start transaction has no app start reason when unavailable`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + + setAppStartTime() + + val activity = mock() + sut.onActivityCreated(activity, fixture.bundle) + + val appStartTransaction = + fixture.createdTransactions.single { + it.spanContext.operation == ActivityLifecycleIntegration.STANDALONE_APP_START_OP + } + assertNull(appStartTransaction.getData("app.vitals.start.reason")) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) + fun `Headless standalone app start transaction carries app start reason when available`() { + val sut = + fixture.getSut { + it.tracesSampleRate = 1.0 + it.isEnableStandaloneAppStartTracing = true + } + sut.register(fixture.scopes, fixture.options) + prepareHeadlessAppStart(appStartType = AppStartType.COLD) + val startInfo = + mock().apply { + whenever(reason).thenReturn(ApplicationStartInfo.START_REASON_BROADCAST) + } + AppStartMetrics.getInstance().setCachedStartInfo(startInfo) + + driveHeadlessAppStart() + + val transaction = fixture.createdTransactions.single() + assertEquals("broadcast", transaction.getData("app.vitals.start.reason")) + } + @Test fun `HeadlessAppStartListener is registered when standalone flag is on and performance enabled`() { val sut = diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt index 30686852156..b5d87ab77cb 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -15,6 +15,7 @@ import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import org.junit.Before import org.junit.runner.RunWith import org.mockito.kotlin.mock @@ -207,6 +208,47 @@ class AppStartMetricsTestApi35 { assertEquals(1, listenerCalls.get()) } + @Test + fun `getAppStartReason maps ApplicationStartInfo reason to string on API 35`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(ApplicationStartInfo.START_REASON_BROADCAST) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertEquals("broadcast", metrics.appStartReason) + } + + @Test + fun `getAppStartReason returns null when no ApplicationStartInfo is available`() { + SentryShadowActivityManager.setHistoricalProcessStartReasons(emptyList()) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertNull(metrics.appStartReason) + } + + @Test + fun `getAppStartReason returns null for an unmapped reason`() { + val mockStartInfo = mock() + whenever(mockStartInfo.startupState).thenReturn(ApplicationStartInfo.STARTUP_STATE_STARTED) + whenever(mockStartInfo.startType).thenReturn(ApplicationStartInfo.START_TYPE_COLD) + whenever(mockStartInfo.reason).thenReturn(Int.MAX_VALUE) + SentryShadowActivityManager.setHistoricalProcessStartReasons(listOf(mockStartInfo)) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertNull(metrics.appStartReason) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle() From f037273666b1f288823a4d6a135dcf517dd61468 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 22 Jun 2026 14:00:00 +0200 Subject: [PATCH 026/195] fix(replay): Release MediaMuxer when no frames are encoded (#5583) * fix(replay): Release MediaMuxer when no frames are encoded The MediaMuxer is created when the video encoder is constructed, but its release() was reachable only on the happy path. Two cases leaked it: - createVideoOf returned early when frameCount was 0 without releasing the encoder. - SimpleMp4FrameMuxer.release() called muxer.stop() before muxer.release(). stop() throws if the muxer was never started (no frame ever muxed), so release() was skipped. This surfaced as a CloseGuard "resource was acquired but never released" warning. Guard stop() behind the started flag so release() is always reached, and release the encoder on the no-frames return path. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++++++ .../src/main/java/io/sentry/android/replay/ReplayCache.kt | 4 ++++ .../io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt | 6 +++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d9d4dddac0..8d66d755a7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixes + +- Release `MediaMuxer` when a replay segment has no encodable frames to avoid a resource leak ([#5583](https://github.com/getsentry/sentry-java/pull/5583)) + ## 8.44.1 ### Fixes diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 32e42dafac1..b3b9edae055 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -199,6 +199,10 @@ public class ReplayCache(private val options: SentryOptions, private val replayI if (frameCount == 0) { options.logger.log(DEBUG, "Generated a video with no frames, not capturing a replay segment") + encoderLock.acquire().use { + encoder?.release() + encoder = null + } deleteFile(videoFile) return null } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt index 36741686701..e32af9bb44b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -67,7 +67,11 @@ internal class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer } override fun release() { - muxer.stop() + // stop() throws if the muxer was never started (e.g. no frame was ever muxed), so we guard it + // to ensure release() is always reached and the underlying resources are freed + if (started) { + muxer.stop() + } muxer.release() } From 57d359a2dee07eb48c5b2f6fad04d540af7fe407 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Mon, 22 Jun 2026 14:21:24 +0200 Subject: [PATCH 027/195] docs(replay): Add THIRD_PARTY_NOTICES entry for SimpleMp4FrameMuxer (#5586) * docs(replay): Add THIRD_PARTY_NOTICES entry for SimpleMp4FrameMuxer SimpleMp4FrameMuxer is adapted from the flutter_screen_recorder library and carries a complete attribution header, but the corresponding entry in THIRD_PARTY_NOTICES.md was never added. Warden's check-code-attribution flags the missing entry as independently required regardless of header completeness. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(replay): Cover all adapted flutter_screen_recorder and Curtains files SimpleFrameMuxer and SimpleVideoEncoder are adapted from the same flutter_screen_recorder library as SimpleMp4FrameMuxer; fold all three into one notice entry. Also extend the existing Square Curtains scope to list io.sentry.android.replay.Windows, which is adapted from Curtains but was not mentioned. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(replay): Correct adapted-from URL in SimpleVideoEncoder header The attribution header pointed at the upstream SimpleFrameMuxer.kt instead of SimpleVideoEncoder.kt. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- THIRD_PARTY_NOTICES.md | 39 ++++++++++++++++++- .../replay/video/SimpleVideoEncoder.kt | 2 +- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c1fa7e8f65b..925add4a71a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -154,7 +154,7 @@ limitations under the License. ### Scope -The Sentry Java SDK includes an adapted version of Square's Curtains library for null-safe `Window.Callback` handling. The code resides in `io.sentry.android.replay.util.FixedWindowCallback`. +The Sentry Java SDK includes adapted versions of Square's Curtains library for null-safe `Window.Callback` handling and for tracking attached window roots. The code resides in `io.sentry.android.replay.util.FixedWindowCallback` and `io.sentry.android.replay.Windows`. ``` Copyright 2021 Square Inc. @@ -513,3 +513,40 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` + +--- + +## fzyzcjy — Flutter Screen Recorder (MIT) + +**Source:** https://github.com/fzyzcjy/flutter_screen_recorder (Commit: dce41cec25c66baf42c6bac4198e95874ce3eb9d)
+**License:** MIT License
+**Copyright:** Copyright (c) 2021 fzyzcjy + +### Scope + +The Sentry Android Replay SDK includes adapted versions of the video encoding and muxing classes from the flutter_screen_recorder library, used to encode and mux replay video frames into an MP4 file. The code resides in the `io.sentry.android.replay.video` package and includes `SimpleFrameMuxer`, `SimpleMp4FrameMuxer`, and `SimpleVideoEncoder`. + +``` +Copyright (c) 2021 fzyzcjy + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +In addition to the standard MIT license, this library requires the following: The recorder itself +only saves data on user's phone locally, thus it does not have any privacy problem. However, if +you are going to get the records out of the local storage (e.g. upload the records to your +server), please explicitly ask the user for permission, and promise to only use the records to +debug your app. This is a part of the license of this library. +``` diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index a400be865e7..de14aadaaab 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -1,6 +1,6 @@ /** * Adapted from - * https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleFrameMuxer.kt + * https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleVideoEncoder.kt * * Copyright (c) 2021 fzyzcjy * From f982bad2175a3302c67624af6ec2bd27d72a549f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 23 Jun 2026 11:13:24 +0200 Subject: [PATCH 028/195] build(samples): Remove outputs.upToDateWhen { false } from systemTest tasks (#5522) * build(samples): Remove outputs.upToDateWhen { false } from systemTest tasks The systemTest tasks in the sample modules forced Gradle to always treat their outputs as out of date, disabling up-to-date checks and build cache reuse. Removing this lets Gradle rely on its normal input/output tracking for the Test tasks. Co-Authored-By: Claude Opus 4.8 (1M context) * build(samples): Track systemTest app archive via convention plugin The system tests launch the packaged sample (war/shadowJar/bootJar) from build/libs as a separate process, so the archive is a real input to the systemTest task even though it is not on the test classpath. Without it, removing outputs.upToDateWhen { false } would let Gradle mark systemTest up-to-date while a separate jar build refreshed the artifact, skipping verification against the rebuilt sample. Move that wiring into a single io.sentry.systemtest convention plugin in build-logic instead of repeating it in every sample build file. The plugin auto-detects the packaging task (war, else shadowJar, else bootJar), mirroring the selection in test/system-test-runner.py, and declares its archive as an input and dependency. Each sample just applies the plugin. Co-Authored-By: Claude Opus 4.8 (1M context) * build(samples): Track OpenTelemetry agent jar as systemTest input The agent-based OpenTelemetry samples are launched by the runner with -javaagent:, started outside the test JVM. That jar is not on the test classpath nor one of the app archives, so without tracking it systemTest could stay up-to-date and be skipped while the runner launches a newer agent. Add a usesOpenTelemetryAgent opt-in to the io.sentry.systemtest plugin; the three agent samples enable it and the agent jar is then tracked as a content input. The runner already builds and launches the agent before invoking the task, so it is tracked by path without a cross-project task dependency, which keeps it configuration-on-demand and configuration cache compatible. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../kotlin/io.sentry.systemtest.gradle.kts | 38 +++++++++++++++++++ .../io/sentry/gradle/SystemTestExtension.kt | 17 +++++++++ .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../sentry-samples-console/build.gradle.kts | 3 +- .../sentry-samples-jul/build.gradle.kts | 3 +- .../sentry-samples-log4j2/build.gradle.kts | 3 +- .../sentry-samples-logback/build.gradle.kts | 3 +- .../sentry-samples-spring-7/build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 6 ++- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 6 ++- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 6 ++- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../build.gradle.kts | 3 +- .../sentry-samples-spring/build.gradle.kts | 3 +- 24 files changed, 86 insertions(+), 44 deletions(-) create mode 100644 build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts create mode 100644 build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt diff --git a/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts b/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts new file mode 100644 index 00000000000..a21079e1336 --- /dev/null +++ b/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts @@ -0,0 +1,38 @@ +import io.sentry.gradle.SystemTestExtension +import org.gradle.api.tasks.ClasspathNormalizer + +val systemTest = extensions.create("sentrySystemTest") + +// The sample system tests launch the packaged app (war/shadowJar/bootJar) from build/libs as a +// separate process, so the archive is a real input even though it is not on the test classpath. +// Agent-based samples are additionally launched with -javaagent:, another runtime +// input not on the classpath. See test/system-test-runner.py. +tasks.matching { it.name == "systemTest" }.configureEach { + val archiveTask = + listOf("war", "shadowJar", "bootJar").firstOrNull { it in tasks.names } + ?: throw GradleException( + "io.sentry.systemtest is applied to $path but none of war/shadowJar/bootJar " + + "exist to provide the launched app archive for the systemTest task" + ) + // Declaring the archive as an input also wires the dependency on its producing task. + inputs + .files(tasks.named(archiveTask)) + .withPropertyName("appArchive") + .withNormalizer(ClasspathNormalizer::class.java) + + if (systemTest.usesOpenTelemetryAgent.get()) { + // The runner builds the agent and launches the app with -javaagent before invoking this task, + // so the agent jar is tracked for content only (by path, no cross-project task dependency): a + // change to it makes systemTest out of date even though it runs outside the test JVM. + val version = providers.gradleProperty("versionName").get() + inputs + .files( + rootProject.layout.projectDirectory.file( + "sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/" + + "sentry-opentelemetry-agent-$version.jar" + ) + ) + .withPropertyName("openTelemetryAgent") + .withNormalizer(ClasspathNormalizer::class.java) + } +} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt new file mode 100644 index 00000000000..9111ce17b1f --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt @@ -0,0 +1,17 @@ +package io.sentry.gradle + +import org.gradle.api.provider.Property + +/** Configuration for the `io.sentry.systemtest` convention plugin. */ +abstract class SystemTestExtension { + /** + * Set to `true` for samples that the system-test runner launches with the Sentry OpenTelemetry + * Java agent (`-javaagent`). The agent jar is then tracked as a `systemTest` input so the task + * re-runs when the agent changes, even though it is started outside the test JVM. + */ + abstract val usesOpenTelemetryAgent: Property + + init { + usesOpenTelemetryAgent.convention(false) + } +} diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts index f5d14dc2c38..9db90129958 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.console.Main") } @@ -71,8 +72,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts index 483f6bea799..261894baaa0 100644 --- a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.console.Main") } @@ -74,8 +75,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-console/build.gradle.kts b/sentry-samples/sentry-samples-console/build.gradle.kts index 79878ab9a08..3e70e79ae71 100644 --- a/sentry-samples/sentry-samples-console/build.gradle.kts +++ b/sentry-samples/sentry-samples-console/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.console.Main") } @@ -75,8 +76,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-jul/build.gradle.kts b/sentry-samples/sentry-samples-jul/build.gradle.kts index 01e6a95f13d..310af1e7bce 100644 --- a/sentry-samples/sentry-samples-jul/build.gradle.kts +++ b/sentry-samples/sentry-samples-jul/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.jul.Main") } @@ -66,8 +67,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-log4j2/build.gradle.kts index 005e1116528..962fd56a839 100644 --- a/sentry-samples/sentry-samples-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-log4j2/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.log4j2.Main") } @@ -72,8 +73,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-logback/build.gradle.kts b/sentry-samples/sentry-samples-logback/build.gradle.kts index 05f96c346a8..1a7f3a23875 100644 --- a/sentry-samples/sentry-samples-logback/build.gradle.kts +++ b/sentry-samples/sentry-samples-logback/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.logback.Main") } @@ -66,8 +67,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-7/build.gradle.kts b/sentry-samples/sentry-samples-spring-7/build.gradle.kts index e3300cd2841..3e108aabd1e 100644 --- a/sentry-samples/sentry-samples-spring-7/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-7/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.kotlin.spring) id("war") alias(libs.plugins.gretty) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring7.Main") } @@ -77,8 +78,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts index 64ef57692c3..722788830f1 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-4" @@ -90,8 +91,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts index e12b960e0fd..b9551ffcf74 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-4" @@ -110,6 +111,9 @@ tasks.register("bootRunWithAgent").configure { jvmArgs = listOf("-Dotel.javaagent.debug=true", "-javaagent:$agentJarPath") } +// The runner launches this sample with -javaagent, so track the agent jar as a systemTest input. +sentrySystemTest { usesOpenTelemetryAgent = true } + tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" @@ -118,8 +122,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts index 7329d5cc0ea..d793201d4c0 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-4-otlp" @@ -91,8 +92,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts index a311b8a972e..6d8d3c81e09 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-4-webflux" @@ -70,8 +71,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts index cdb33ecc675..4e463671a78 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-4" @@ -92,8 +93,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts index 7966e621ebd..553affc3620 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-jakarta" @@ -95,8 +96,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts index 3c7e00ae552..e4fefab7de7 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-jakarta" @@ -120,6 +121,9 @@ tasks.register("bootRunWithAgent").configure { jvmArgs = listOf("-Dotel.javaagent.debug=true", "-javaagent:$agentJarPath") } +// The runner launches this sample with -javaagent, so track the agent jar as a systemTest input. +sentrySystemTest { usesOpenTelemetryAgent = true } + tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" @@ -128,8 +132,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index d5e4caa595d..65850a6f2bd 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-jakarta" @@ -98,8 +99,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts index 0b8c5a181e7..e32eec82ac8 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") } @@ -140,8 +141,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts index b78f1f01881..085d6e362af 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") } @@ -154,6 +155,9 @@ tasks.register("bootRunWithAgent").configure { jvmArgs = listOf("-Dotel.javaagent.debug=true", "-javaagent:$agentJarPath") } +// The runner launches this sample with -javaagent, so track the agent jar as a systemTest input. +sentrySystemTest { usesOpenTelemetryAgent = true } + tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" @@ -162,8 +166,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts index 8b2079ddd9c..3e462517ded 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-webflux-jakarta" @@ -72,8 +73,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts index 2127dbfd79f..8dc51e07a53 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") } @@ -107,8 +108,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 0a2a6f2da57..54fe99d56d4 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring.boot.SentryDemoApplication") } @@ -141,8 +142,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts index 3dec793e5c9..5fe0334a629 100644 --- a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.spring) id("war") alias(libs.plugins.gretty) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring.jakarta.Main") } @@ -77,8 +78,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test diff --git a/sentry-samples/sentry-samples-spring/build.gradle.kts b/sentry-samples/sentry-samples-spring/build.gradle.kts index 02e7f632450..3ab6610d96d 100644 --- a/sentry-samples/sentry-samples-spring/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.spring) id("war") alias(libs.plugins.gretty) + id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.spring.Main") } @@ -78,8 +79,6 @@ tasks.register("systemTest").configure { testClassesDirs = test.output.classesDirs classpath = test.runtimeClasspath - outputs.upToDateWhen { false } - maxParallelForks = 1 // Cap JVM args per test From ec5e3a55656fea8e4eec6aeff74a211df4894a6d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 23 Jun 2026 20:09:38 +0200 Subject: [PATCH 029/195] fix(android): Fix crash when getHistoricalProcessStartReasons is called from a wrong process (#5597) * fix(android): Fix crash when getHistoricalProcessStartReasons is called from a wrong process * test(android): Add test and changelog for getHistoricalProcessStartReasons crash fix Co-Authored-By: Claude Opus 4.6 (1M context) * SecurityException -> RuntimeException --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../core/performance/AppStartMetrics.java | 31 +++++++++++++------ .../core/SentryShadowActivityManager.kt | 7 +++++ .../performance/AppStartMetricsTestApi35.kt | 14 +++++++++ 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d66d755a7c..0c3574f1fa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Fix crash when `getHistoricalProcessStartReasons` is called from an isolated or wrong-userId process ([#5597](https://github.com/getsentry/sentry-java/pull/5597)) - Release `MediaMuxer` when a replay segment has no encodable frames to avoid a resource leak ([#5583](https://github.com/getsentry/sentry-java/pull/5583)) ## 8.44.1 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java index 36cae8686ca..828e103e8b6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/performance/AppStartMetrics.java @@ -11,6 +11,7 @@ import android.os.Handler; import android.os.Looper; import android.os.SystemClock; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -467,18 +468,28 @@ public void registerLifecycleCallbacks(final @NotNull Application application) { final @Nullable ActivityManager activityManager = (ActivityManager) application.getSystemService(Context.ACTIVITY_SERVICE); if (activityManager != null) { - final List historicalProcessStartReasons = - activityManager.getHistoricalProcessStartReasons(1); - if (!historicalProcessStartReasons.isEmpty()) { - final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0); - cachedStartInfo = info; - if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) { - if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { - appStartType = AppStartType.COLD; - } else { - appStartType = AppStartType.WARM; + try { + final List historicalProcessStartReasons = + activityManager.getHistoricalProcessStartReasons(1); + if (!historicalProcessStartReasons.isEmpty()) { + final @NotNull ApplicationStartInfo info = historicalProcessStartReasons.get(0); + cachedStartInfo = info; + if (info.getStartupState() == ApplicationStartInfo.STARTUP_STATE_STARTED) { + if (info.getStartType() == ApplicationStartInfo.START_TYPE_COLD) { + appStartType = AppStartType.COLD; + } else { + appStartType = AppStartType.WARM; + } } } + } catch (RuntimeException ignored) { + // getHistoricalProcessStartReasons may throw different kinds of exceptions, namely: + // - SecurityException when called from an isolated process + // - IllegalArgumentException when called with a wrong userId + // - others + // See impl: + // https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java;l=10866-10893 + Log.w("AppStartMetrics", ignored); // no logger instance here, so we just Log } } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt index a959c5dd865..93cb4759e99 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryShadowActivityManager.kt @@ -12,11 +12,16 @@ class SentryShadowActivityManager { companion object { private var historicalProcessStartReasons: List = emptyList() private var importance: Int = RunningAppProcessInfo.IMPORTANCE_FOREGROUND + private var historicalProcessStartReasonsException: RuntimeException? = null fun setHistoricalProcessStartReasons(startReasons: List) { historicalProcessStartReasons = startReasons } + fun setHistoricalProcessStartReasonsException(exception: RuntimeException) { + historicalProcessStartReasonsException = exception + } + fun setImportance(importance: Int) { this.importance = importance } @@ -24,6 +29,7 @@ class SentryShadowActivityManager { fun reset() { historicalProcessStartReasons = emptyList() importance = RunningAppProcessInfo.IMPORTANCE_FOREGROUND + historicalProcessStartReasonsException = null } @Implementation @@ -35,6 +41,7 @@ class SentryShadowActivityManager { @Implementation fun getHistoricalProcessStartReasons(maxNum: Int): List { + historicalProcessStartReasonsException?.let { throw it } return historicalProcessStartReasons.take(maxNum) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt index b5d87ab77cb..0624e70b898 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/performance/AppStartMetricsTestApi35.kt @@ -249,6 +249,20 @@ class AppStartMetricsTestApi35 { assertNull(metrics.appStartReason) } + @Test + fun `does not crash when getHistoricalProcessStartReasons throws RuntimeException`() { + SentryShadowActivityManager.setHistoricalProcessStartReasonsException( + RuntimeException("isolated process") + ) + val metrics = AppStartMetrics.getInstance() + + val app = ApplicationProvider.getApplicationContext() + metrics.registerLifecycleCallbacks(app) + + assertEquals(AppStartMetrics.AppStartType.UNKNOWN, metrics.appStartType) + assertNull(metrics.appStartReason) + } + private fun waitForMainLooperIdle() { Handler(Looper.getMainLooper()).post {} Shadows.shadowOf(Looper.getMainLooper()).idle() From 818350078b0238d8db99964f3464614643490fa5 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 24 Jun 2026 13:23:23 +0200 Subject: [PATCH 030/195] fix(replay): Fix flaky ComposeMaskingOptionsTest (#5613) The `when sentry-unmask modifier is set unmasks the node` test intermittently failed because Robolectric can report zero bounds for some nodes when running the full test class, making them invisible (shouldMask = isVisible && ...). Restructure the test to: - Explicitly find the "Make Request" node and assert it IS visible and unmasked - Assert other visible nodes remain masked, with a guard against empty iteration - Tolerate intermittent zero-bounds on non-identifiable nodes (Robolectric artifact) Validated with the repro from getsentry/repro#51: 20/20 passes (vs ~10% flake rate before the fix). Fixes #5585 Co-authored-by: Claude Opus 4.6 (1M context) --- .../ComposeMaskingOptionsTest.kt | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index e043b035668..fe3fbc1ba67 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -228,18 +228,24 @@ class ComposeMaskingOptionsTest { val textNodes = activity.get().collectNodesOfType(options) assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] - textNodes.forEach { - if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { - assertFalse( - it.shouldMask, - "Node with text ${(it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text} should not be masked", - ) - } else { - assertTrue( - it.shouldMask, - "Node with text ${(it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text} should be masked", - ) + + val unmaskNode = + textNodes.first { + (it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request" } + assertTrue(unmaskNode.isVisible, "The unmasked node must be visible for the test to be valid") + assertFalse(unmaskNode.shouldMask, "Node with sentryReplayUnmask() should not be masked") + + // Robolectric may intermittently report zero bounds for some nodes when running + // the full test class, making them invisible (shouldMask = isVisible && ...). + // Assert that all other visible nodes remain masked. + val otherVisibleNodes = textNodes.filter { it !== unmaskNode && it.isVisible } + assertTrue(otherVisibleNodes.isNotEmpty(), "Expected at least one other visible text node") + otherVisibleNodes.forEach { + assertTrue( + it.shouldMask, + "Node with text ${(it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text} should be masked", + ) } } From 3c89fa4c79a2af40618acffcdc84f9a98eb4aca8 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 24 Jun 2026 14:33:54 +0200 Subject: [PATCH 031/195] ci(replay): Skip snapshot upload on PRs from forks (#5621) Fork PRs don't have access to the SENTRY_AUTH_TOKEN secret, so the sentry-cli snapshot upload would fail anyway. Guard the step to run only on pushes and same-repo PRs. Co-authored-by: Claude Opus 4.8 --- .github/workflows/integration-tests-ui.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 92e29ecbef7..e271227b97e 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -78,7 +78,8 @@ jobs: run: curl -sL https://sentry.io/get-cli/ | bash - name: Upload Replay Snapshots to Sentry - if: ${{ !cancelled() && env.SAUCE_USERNAME != null }} + # Skip on PRs from forks, which don't have access to the upload secret + if: ${{ !cancelled() && env.SAUCE_USERNAME != null && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} run: | shopt -s globstar nullglob pngs=(artifacts/**/*.png) From 477b848f9ad9a2eac9efa22553ca3da49cf0ab68 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 24 Jun 2026 16:44:37 +0200 Subject: [PATCH 032/195] ci(build): Skip snapshot upload on PRs from forks (#5622) Fork PRs don't have access to SENTRY_AUTH_TOKEN, so the upload step would attempt to run without credentials. Guard it the same way the replay snapshot upload is guarded so fork PRs cleanly skip it. --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f2ffd96f9c5..6cba7e07e0a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,6 +49,8 @@ jobs: run: curl -sL https://sentry.io/get-cli/ | bash - name: Upload Snapshots to Sentry + # Skip on PRs from forks, which don't have access to the upload secret + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} run: | sentry-cli build snapshots ./sentry-android-core/build/test-snapshots \ --app-id sentry-android-core From 693fc159de6b16dff56436c11b55543111b4d207 Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:01:18 -0700 Subject: [PATCH 033/195] fix: use System.nanoTime() for cron check-in duration measurement (#5611) * fix: use System.nanoTime() for cron check-in duration measurement System.currentTimeMillis() is a wall-clock value and is subject to NTP adjustments and DST transitions. For long-running cron jobs this can produce incorrect or even negative durations in the check-in payload. Switch the start/end capture in CheckInUtils.withCheckIn() and the three SentryCheckInAdvice implementations (sentry-spring, sentry-spring-jakarta, sentry-spring-7) to System.nanoTime(), which is guaranteed monotonic. Use DateUtils.nanosToSeconds() (already present) to convert the delta. Fixes #5579 * changelog --------- Co-authored-by: Roman Zavarnitsyn --- CHANGELOG.md | 1 + .../java/io/sentry/spring7/checkin/SentryCheckInAdvice.java | 4 ++-- .../io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java | 4 ++-- .../java/io/sentry/spring/checkin/SentryCheckInAdvice.java | 4 ++-- sentry/src/main/java/io/sentry/util/CheckInUtils.java | 4 ++-- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c3574f1fa6..5a01e722486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Use `System.nanoTime()` for cron check-in duration measurement to avoid incorrect durations from wall-clock adjustments ([#5611](https://github.com/getsentry/sentry-java/pull/5611)) - Fix crash when `getHistoricalProcessStartReasons` is called from an isolated or wrong-userId process ([#5597](https://github.com/getsentry/sentry-java/pull/5597)) - Release `MediaMuxer` when a replay segment has no encodable frames to avoid a resource leak ([#5583](https://github.com/getsentry/sentry-java/pull/5583)) diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java index 274c20ac89a..d2c164b9a6e 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java @@ -91,7 +91,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl TracingUtils.startNewTrace(scopes); @Nullable SentryId checkInId = null; - final long startTime = System.currentTimeMillis(); + final long startTime = System.nanoTime(); boolean didError = false; try { @@ -105,7 +105,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } finally { final @NotNull CheckInStatus status = didError ? CheckInStatus.ERROR : CheckInStatus.OK; CheckIn checkIn = new CheckIn(checkInId, monitorSlug, status); - checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); + checkIn.setDuration(DateUtils.nanosToSeconds(System.nanoTime() - startTime)); scopes.captureCheckIn(checkIn); } } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java index d2b93471f1c..fa64ac0e3e4 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java @@ -91,7 +91,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl TracingUtils.startNewTrace(scopes); @Nullable SentryId checkInId = null; - final long startTime = System.currentTimeMillis(); + final long startTime = System.nanoTime(); boolean didError = false; try { @@ -105,7 +105,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } finally { final @NotNull CheckInStatus status = didError ? CheckInStatus.ERROR : CheckInStatus.OK; CheckIn checkIn = new CheckIn(checkInId, monitorSlug, status); - checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); + checkIn.setDuration(DateUtils.nanosToSeconds(System.nanoTime() - startTime)); scopes.captureCheckIn(checkIn); } } diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java index 719ead46b51..a96e9e29808 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java @@ -94,7 +94,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl TracingUtils.startNewTrace(scopes); @Nullable SentryId checkInId = null; - final long startTime = System.currentTimeMillis(); + final long startTime = System.nanoTime(); boolean didError = false; try { @@ -108,7 +108,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } finally { final @NotNull CheckInStatus status = didError ? CheckInStatus.ERROR : CheckInStatus.OK; CheckIn checkIn = new CheckIn(checkInId, monitorSlug, status); - checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); + checkIn.setDuration(DateUtils.nanosToSeconds(System.nanoTime() - startTime)); scopes.captureCheckIn(checkIn); } } diff --git a/sentry/src/main/java/io/sentry/util/CheckInUtils.java b/sentry/src/main/java/io/sentry/util/CheckInUtils.java index 7b44fffbc35..3deea093142 100644 --- a/sentry/src/main/java/io/sentry/util/CheckInUtils.java +++ b/sentry/src/main/java/io/sentry/util/CheckInUtils.java @@ -37,7 +37,7 @@ public static U withCheckIn( try (final @NotNull ISentryLifecycleToken ignored = Sentry.forkedScopes("CheckInUtils").makeCurrent()) { final @NotNull IScopes scopes = Sentry.getCurrentScopes(); - final long startTime = System.currentTimeMillis(); + final long startTime = System.nanoTime(); boolean didError = false; TracingUtils.startNewTrace(scopes); @@ -61,7 +61,7 @@ public static U withCheckIn( if (environment != null) { checkIn.setEnvironment(environment); } - checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); + checkIn.setDuration(DateUtils.nanosToSeconds(System.nanoTime() - startTime)); scopes.captureCheckIn(checkIn); } } From 0499903a71e617bd84b2f748b25f7ca2db71f134 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:26:29 +0200 Subject: [PATCH 034/195] chore: update scripts/update-sentry-native-ndk.sh to 0.15.2 (#5610) Co-authored-by: GitHub --- CHANGELOG.md | 6 ++++++ gradle/libs.versions.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a01e722486..596c36b3320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ - Fix crash when `getHistoricalProcessStartReasons` is called from an isolated or wrong-userId process ([#5597](https://github.com/getsentry/sentry-java/pull/5597)) - Release `MediaMuxer` when a replay segment has no encodable frames to avoid a resource leak ([#5583](https://github.com/getsentry/sentry-java/pull/5583)) +### Dependencies + +- Bump Native SDK from v0.15.1 to v0.15.2 ([#5610](https://github.com/getsentry/sentry-java/pull/5610)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0152) + - [diff](https://github.com/getsentry/sentry-native/compare/0.15.1...0.15.2) + ## 8.44.1 ### Fixes diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68521efdfcc..24064703ca1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -166,7 +166,7 @@ quartz = { module = "org.quartz-scheduler:quartz", version = "2.3.0" } reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } -sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.15.1" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.15.2" } servlet-api = { module = "javax.servlet:javax.servlet-api", version = "3.1.0" } servlet-jakarta-api = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.1.0" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } From f8e292e692fee3774289917409f05080b443bec3 Mon Sep 17 00:00:00 2001 From: 0xadam-brown <281682121+0xadam-brown@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:30:08 +0000 Subject: [PATCH 035/195] release: 8.45.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 596c36b3320..e1a4d05122c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.45.0 ### Fixes diff --git a/gradle.properties b/gradle.properties index f2e3da3ca09..f83b851f8d9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.experimental.lint.version=8.13.1 # Release information -versionName=8.44.1 +versionName=8.45.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 2c01eff3d05e76446bc1264d9235077ce183fee6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 24 Jun 2026 18:50:01 +0200 Subject: [PATCH 036/195] fix(changelog): Move app start reason to 8.45.0 (#5625) --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a4d05122c..48a1115f8ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 8.45.0 +### Features + +- On Android 15+ (API 35), the standalone `app.start` transaction now reports why the OS started the process via `app.vitals.start.reason` trace data (e.g. `launcher`, `broadcast`, `service`, `content_provider`), derived from `ApplicationStartInfo.getReason()`. You can search and group by this attribute in the Trace Explorer. ([#5552](https://github.com/getsentry/sentry-java/pull/5552)) + ### Fixes - Use `System.nanoTime()` for cron check-in duration measurement to avoid incorrect durations from wall-clock adjustments ([#5611](https://github.com/getsentry/sentry-java/pull/5611)) @@ -41,7 +45,6 @@ - Emits a transaction named `App Start` with op `app.start`, carrying the existing app start measurements and phase spans (`process.load`, `contentprovider.load`, `application.load`, activity lifecycle spans) as direct children of the root - The standalone transaction shares the same `traceId` as the first `ui.load` activity transaction so they remain linked in the trace view - Also covers non-activity starts (broadcast receivers, services, content providers) - - On Android 15+ (API 35), the standalone `app.start` transaction reports why the OS started the process via `app.vitals.start.reason` trace data (e.g. `launcher`, `broadcast`, `service`, `content_provider`), derived from `ApplicationStartInfo.getReason()`. You can search and group by this attribute in the Trace Explorer. ([#5552](https://github.com/getsentry/sentry-java/pull/5552)) ### Improvements From 6424f21f3573988056d194317e654ef11d605426 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 25 Jun 2026 10:50:50 +0200 Subject: [PATCH 037/195] build: Remove redundant test source set declarations (#5624) The line configure { test { java.srcDir("src/test/java") } } re-added Gradle's default test source directory, which is a no-op. Remove it from all 51 build files. --- sentry-apache-http-client-5/build.gradle.kts | 2 -- sentry-apollo-3/build.gradle.kts | 2 -- sentry-apollo-4/build.gradle.kts | 2 -- sentry-apollo/build.gradle.kts | 2 -- sentry-async-profiler/build.gradle.kts | 2 -- sentry-graphql-22/build.gradle.kts | 2 -- sentry-graphql-core/build.gradle.kts | 2 -- sentry-graphql/build.gradle.kts | 2 -- sentry-jcache/build.gradle.kts | 2 -- sentry-jdbc/build.gradle.kts | 2 -- sentry-jul/build.gradle.kts | 2 -- sentry-kafka/build.gradle.kts | 2 -- sentry-kotlin-extensions/build.gradle.kts | 2 -- sentry-ktor-client/build.gradle.kts | 2 -- sentry-launchdarkly-server/build.gradle.kts | 2 -- sentry-log4j2/build.gradle.kts | 2 -- sentry-logback/build.gradle.kts | 2 -- sentry-okhttp/build.gradle.kts | 2 -- sentry-openfeature/build.gradle.kts | 2 -- sentry-openfeign/build.gradle.kts | 2 -- .../sentry-opentelemetry-agentcustomization/build.gradle.kts | 2 -- .../sentry-opentelemetry-bootstrap/build.gradle.kts | 2 -- sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts | 2 -- sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts | 2 -- sentry-quartz/build.gradle.kts | 2 -- sentry-reactor/build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- sentry-samples/sentry-samples-console-otlp/build.gradle.kts | 2 -- sentry-samples/sentry-samples-console/build.gradle.kts | 2 -- sentry-samples/sentry-samples-jul/build.gradle.kts | 2 -- sentry-samples/sentry-samples-log4j2/build.gradle.kts | 2 -- sentry-samples/sentry-samples-logback/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring-7/build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- .../sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts | 2 -- .../sentry-samples-spring-boot-4-otlp/build.gradle.kts | 2 -- .../sentry-samples-spring-boot-4-webflux/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts | 2 -- sentry-servlet-jakarta/build.gradle.kts | 2 -- sentry-servlet/build.gradle.kts | 2 -- sentry-spotlight/build.gradle.kts | 2 -- sentry-spring-7/build.gradle.kts | 2 -- sentry-spring-boot-4-starter/build.gradle.kts | 2 -- sentry-spring-boot-4/build.gradle.kts | 2 -- sentry-spring-boot-jakarta/build.gradle.kts | 2 -- sentry-spring-boot-starter-jakarta/build.gradle.kts | 2 -- sentry-spring-boot-starter/build.gradle.kts | 2 -- sentry-spring-jakarta/build.gradle.kts | 2 -- sentry-system-test-support/build.gradle.kts | 2 -- sentry-test-support/build.gradle.kts | 2 -- sentry/build.gradle.kts | 2 -- 51 files changed, 102 deletions(-) diff --git a/sentry-apache-http-client-5/build.gradle.kts b/sentry-apache-http-client-5/build.gradle.kts index df93fbe8823..00916258b8f 100644 --- a/sentry-apache-http-client-5/build.gradle.kts +++ b/sentry-apache-http-client-5/build.gradle.kts @@ -33,8 +33,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index 1eb71bc217a..d70085e27bd 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -42,8 +42,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index 144297ddb9d..d9f41891dc1 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -49,8 +49,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index c115e6b8fe3..0fc853886df 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -43,8 +43,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index ef000b465a1..17093fe6a09 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -36,8 +36,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts index c36ca09856d..3c0667fd0d4 100644 --- a/sentry-graphql-22/build.gradle.kts +++ b/sentry-graphql-22/build.gradle.kts @@ -41,8 +41,6 @@ dependencies { testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql-core/build.gradle.kts b/sentry-graphql-core/build.gradle.kts index d625c31dea6..62635ded34e 100644 --- a/sentry-graphql-core/build.gradle.kts +++ b/sentry-graphql-core/build.gradle.kts @@ -40,8 +40,6 @@ dependencies { testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index 68efbc7389e..30000655079 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -41,8 +41,6 @@ dependencies { testImplementation("com.netflix.graphql.dgs:graphql-error-types:4.9.2") } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jcache/build.gradle.kts b/sentry-jcache/build.gradle.kts index 2c476dbd007..1cc3b6e0e3d 100644 --- a/sentry-jcache/build.gradle.kts +++ b/sentry-jcache/build.gradle.kts @@ -36,8 +36,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jdbc/build.gradle.kts b/sentry-jdbc/build.gradle.kts index 8a7808530b1..1e86048053e 100644 --- a/sentry-jdbc/build.gradle.kts +++ b/sentry-jdbc/build.gradle.kts @@ -34,8 +34,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-jul/build.gradle.kts b/sentry-jul/build.gradle.kts index b59a1481d19..66c46bcee21 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -33,8 +33,6 @@ dependencies { testImplementation(libs.slf4j.api) } -configure { test { java.srcDir("src/test/java") } } - tasks { test { // used to test io.sentry.jul.SentryHandler diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts index 603014f9af9..ef1ff252468 100644 --- a/sentry-kafka/build.gradle.kts +++ b/sentry-kafka/build.gradle.kts @@ -33,8 +33,6 @@ dependencies { testImplementation(libs.kafka.clients) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index 5092976de32..8c4312641a8 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -37,8 +37,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } tasks.withType().configureEach { diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index 745acaa11fb..647563cc1d1 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -44,8 +44,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } buildConfig { diff --git a/sentry-launchdarkly-server/build.gradle.kts b/sentry-launchdarkly-server/build.gradle.kts index 207400676a0..370252c2154 100644 --- a/sentry-launchdarkly-server/build.gradle.kts +++ b/sentry-launchdarkly-server/build.gradle.kts @@ -37,8 +37,6 @@ dependencies { testImplementation(libs.launchdarkly.server) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 7d406076e2f..1c5cf94e8eb 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -35,8 +35,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.log4j2") diff --git a/sentry-logback/build.gradle.kts b/sentry-logback/build.gradle.kts index d2084e95467..1c42a4e1c03 100644 --- a/sentry-logback/build.gradle.kts +++ b/sentry-logback/build.gradle.kts @@ -32,8 +32,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.logback") diff --git a/sentry-okhttp/build.gradle.kts b/sentry-okhttp/build.gradle.kts index ea831f174cc..d547720c174 100644 --- a/sentry-okhttp/build.gradle.kts +++ b/sentry-okhttp/build.gradle.kts @@ -43,8 +43,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } buildConfig { diff --git a/sentry-openfeature/build.gradle.kts b/sentry-openfeature/build.gradle.kts index 5847f48e7b5..fbabcb81aa5 100644 --- a/sentry-openfeature/build.gradle.kts +++ b/sentry-openfeature/build.gradle.kts @@ -37,8 +37,6 @@ dependencies { testImplementation(libs.openfeature) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-openfeign/build.gradle.kts b/sentry-openfeign/build.gradle.kts index e9e3a2b18de..9b1ac2bbc29 100644 --- a/sentry-openfeign/build.gradle.kts +++ b/sentry-openfeign/build.gradle.kts @@ -34,8 +34,6 @@ dependencies { testImplementation(libs.okhttp.mockwebserver) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts index ed6605f8da4..71f31ce2afb 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts @@ -40,8 +40,6 @@ dependencies { testImplementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts index 503c92c95f0..d4bd1af9ede 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -35,8 +35,6 @@ dependencies { testImplementation(libs.otel.semconv.incubating) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts index 5b3b9d97ff4..91ec023e178 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -45,8 +45,6 @@ dependencies { testImplementation(libs.otel.semconv.incubating) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index 21e75c0ed7d..d63c8a5c451 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -41,8 +41,6 @@ dependencies { // testImplementation(libs.otel.semconv.incubating) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-quartz/build.gradle.kts b/sentry-quartz/build.gradle.kts index 69c0e72ee07..6e227abafe6 100644 --- a/sentry-quartz/build.gradle.kts +++ b/sentry-quartz/build.gradle.kts @@ -35,8 +35,6 @@ dependencies { testImplementation(libs.mockito.inline) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-reactor/build.gradle.kts b/sentry-reactor/build.gradle.kts index 4d389b0a334..07024b3a23b 100644 --- a/sentry-reactor/build.gradle.kts +++ b/sentry-reactor/build.gradle.kts @@ -43,8 +43,6 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter") } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.reactor") diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts index 9db90129958..23df981060f 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts @@ -62,8 +62,6 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts index 261894baaa0..9bb0678bf65 100644 --- a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts @@ -65,8 +65,6 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-console/build.gradle.kts b/sentry-samples/sentry-samples-console/build.gradle.kts index 3e70e79ae71..8fdef6ef70e 100644 --- a/sentry-samples/sentry-samples-console/build.gradle.kts +++ b/sentry-samples/sentry-samples-console/build.gradle.kts @@ -66,8 +66,6 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-jul/build.gradle.kts b/sentry-samples/sentry-samples-jul/build.gradle.kts index 310af1e7bce..5381f3ff2f0 100644 --- a/sentry-samples/sentry-samples-jul/build.gradle.kts +++ b/sentry-samples/sentry-samples-jul/build.gradle.kts @@ -57,8 +57,6 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-log4j2/build.gradle.kts index 962fd56a839..07df6703c85 100644 --- a/sentry-samples/sentry-samples-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-log4j2/build.gradle.kts @@ -63,8 +63,6 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-logback/build.gradle.kts b/sentry-samples/sentry-samples-logback/build.gradle.kts index 1a7f3a23875..bb37638d8c5 100644 --- a/sentry-samples/sentry-samples-logback/build.gradle.kts +++ b/sentry-samples/sentry-samples-logback/build.gradle.kts @@ -57,8 +57,6 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-spring-7/build.gradle.kts b/sentry-samples/sentry-samples-spring-7/build.gradle.kts index 3e108aabd1e..6de7ed62e9f 100644 --- a/sentry-samples/sentry-samples-spring-7/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-7/build.gradle.kts @@ -68,8 +68,6 @@ tasks.withType().configureEach { } } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts index 722788830f1..afdb92e5c5b 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts @@ -81,8 +81,6 @@ dependencies { dependencyManagement { imports { mavenBom(libs.otel.instrumentation.bom.get().toString()) } } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts index b9551ffcf74..f0e2d468fec 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts @@ -84,8 +84,6 @@ dependencies { testImplementation("ch.qos.logback:logback-core:1.5.16") } -configure { test { java.srcDir("src/test/java") } } - tasks.register("bootRunWithAgent").configure { group = "application" diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts index d793201d4c0..d7c2c009bc9 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts @@ -82,8 +82,6 @@ dependencies { dependencyManagement { imports { mavenBom(libs.otel.instrumentation.bom.get().toString()) } } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts index 6d8d3c81e09..20ccf2d662c 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts @@ -49,8 +49,6 @@ dependencies { testImplementation("ch.qos.logback:logback-core:1.5.16") } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { kotlin { explicitApi() diff --git a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts index 4e463671a78..2cc1f34b9eb 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -83,8 +83,6 @@ dependencies { testImplementation("ch.qos.logback:logback-core:1.5.16") } -configure { test { java.srcDir("src/test/java") } } - tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" diff --git a/sentry-servlet-jakarta/build.gradle.kts b/sentry-servlet-jakarta/build.gradle.kts index 728e147dc9b..3cdc4772f18 100644 --- a/sentry-servlet-jakarta/build.gradle.kts +++ b/sentry-servlet-jakarta/build.gradle.kts @@ -35,8 +35,6 @@ dependencies { testImplementation(libs.servlet.jakarta.api) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-servlet/build.gradle.kts b/sentry-servlet/build.gradle.kts index 142a1cd2f20..9f12d4ee177 100644 --- a/sentry-servlet/build.gradle.kts +++ b/sentry-servlet/build.gradle.kts @@ -36,8 +36,6 @@ dependencies { testImplementation(libs.springboot.starter.web) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spotlight/build.gradle.kts b/sentry-spotlight/build.gradle.kts index b034c8267db..71498aecd92 100644 --- a/sentry-spotlight/build.gradle.kts +++ b/sentry-spotlight/build.gradle.kts @@ -35,8 +35,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - tasks { check { dependsOn(animalsnifferMain) } } buildConfig { diff --git a/sentry-spring-7/build.gradle.kts b/sentry-spring-7/build.gradle.kts index ec90aedcbeb..4e5ea54d294 100644 --- a/sentry-spring-7/build.gradle.kts +++ b/sentry-spring-7/build.gradle.kts @@ -82,8 +82,6 @@ dependencies { testImplementation(projects.sentryReactor) } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.spring7") diff --git a/sentry-spring-boot-4-starter/build.gradle.kts b/sentry-spring-boot-4-starter/build.gradle.kts index c0f655e965f..bffe53aab01 100644 --- a/sentry-spring-boot-4-starter/build.gradle.kts +++ b/sentry-spring-boot-4-starter/build.gradle.kts @@ -38,8 +38,6 @@ dependencies { errorprone(libs.nullaway) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 43e105ad8db..2a6634b257f 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -108,8 +108,6 @@ dependencies { testImplementation(libs.springboot4.resttestclient) } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.spring.boot4") diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index edd2d605916..1ed9373f4bf 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -100,8 +100,6 @@ dependencies { testImplementation(projects.sentryAsyncProfiler) } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.spring.boot.jakarta") diff --git a/sentry-spring-boot-starter-jakarta/build.gradle.kts b/sentry-spring-boot-starter-jakarta/build.gradle.kts index d7d10b73b8c..c6fe511073e 100644 --- a/sentry-spring-boot-starter-jakarta/build.gradle.kts +++ b/sentry-spring-boot-starter-jakarta/build.gradle.kts @@ -38,8 +38,6 @@ dependencies { errorprone(libs.nullaway) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spring-boot-starter/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts index 3ef4ac59379..f4da56179cb 100644 --- a/sentry-spring-boot-starter/build.gradle.kts +++ b/sentry-spring-boot-starter/build.gradle.kts @@ -30,8 +30,6 @@ dependencies { errorprone(libs.nullaway) } -configure { test { java.srcDir("src/test/java") } } - tasks.withType().configureEach { options.errorprone { check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index b4a61129df7..f103bfcbe0a 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -77,8 +77,6 @@ dependencies { testImplementation(projects.sentryReactor) } -configure { test { java.srcDir("src/test/java") } } - buildConfig { useJavaOutput() packageName("io.sentry.spring.jakarta") diff --git a/sentry-system-test-support/build.gradle.kts b/sentry-system-test-support/build.gradle.kts index 4d4c7d5bb6e..7f08bf6d01b 100644 --- a/sentry-system-test-support/build.gradle.kts +++ b/sentry-system-test-support/build.gradle.kts @@ -41,8 +41,6 @@ dependencies { implementation(libs.mockito.kotlin) } -configure { test { java.srcDir("src/test/java") } } - apollo { service("service") { srcDir("src/main/graphql") diff --git a/sentry-test-support/build.gradle.kts b/sentry-test-support/build.gradle.kts index f108915d463..a0b508c9715 100644 --- a/sentry-test-support/build.gradle.kts +++ b/sentry-test-support/build.gradle.kts @@ -31,5 +31,3 @@ dependencies { implementation(libs.kotlin.test.junit) implementation(libs.mockito.kotlin) } - -configure { test { java.srcDir("src/test/java") } } diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index a2ecd281296..9717cb176ae 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -37,8 +37,6 @@ dependencies { signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") } -configure { test { java.srcDir("src/test/java") } } - animalsniffer { ignore = listOf( From d735888152fb47be1e04654e453e37febe3a0b9d Mon Sep 17 00:00:00 2001 From: arb Date: Thu, 25 Jun 2026 12:04:33 +0200 Subject: [PATCH 038/195] chore(android-sqlite): Update SQLite instrumentation documentation after 8.45.0 release (#5572) We'll be coordinating the 8.45.0 release with SAGP auto-instrumentation for the SentrySQLiteDriver. Commit contains related documentation updates. --- sentry-android-sqlite/README.md | 4 +++- .../main/java/io/sentry/sqlite/SentrySQLiteDriver.kt | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/sentry-android-sqlite/README.md b/sentry-android-sqlite/README.md index 7bf9edf3474..307beb51f0e 100644 --- a/sentry-android-sqlite/README.md +++ b/sentry-android-sqlite/README.md @@ -4,11 +4,13 @@ SQLite instrumentation for AndroidX APIs. Two instrumentation paths are supported: -- **`androidx.sqlite.SQLiteDriver`**: Used by Room 2.7+ and 3.0+. +- **`androidx.sqlite.SQLiteDriver`**: Used by Room 2.7+ and 3.0+. Applied automatically by the Sentry Android Gradle Plugin. - **`androidx.sqlite.db.SupportSQLiteOpenHelper`**: Used by SQLDelight and legacy (pre-2.7) Room. Applied automatically by the Sentry Android Gradle Plugin. To avoid duplicate spans, only one path should be used per database file. Most Room and SQLDelight APIs enforce that division. The exception is Room's `SupportSQLiteDriver`: either the `SupportSQLiteOpenHelper` it consumes should be wrapped or the support driver itself, but never both. +See the [SQLite integration docs](https://docs.sentry.io/platforms/android/integrations/room-and-sqlite/) for more details. + ## Package layout The module is organized as two separate packages: diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt index 22f6353d883..4a616ba3abe 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -22,6 +22,9 @@ import org.jetbrains.annotations.ApiStatus * .build() * ``` * + * If you're using the Sentry Android Gradle Plugin (SAGP) 6.13.0+, wrapping will be performed + * automatically. + * * @param delegate The [SQLiteDriver] instance to delegate calls to. */ @ApiStatus.Experimental @@ -87,9 +90,16 @@ public class SentrySQLiteDriver private constructor(private val delegate: SQLite * * In the case of (2), wrap the open helper passed to the `SupportSQLiteDriver` constructor via * `SentrySupportSQLiteOpenHelper` instead. + * + * Note that wrapping will be performed if the delegate isn't a `SupportSQLiteDriver` itself but + * wraps or subclasses one. In that case, ensure the open helper passed to the support driver + * constructor is *not* wrapped. */ + // Warning! The SAGP depends on this method's ABI. @JvmStatic public fun create(delegate: SQLiteDriver): SQLiteDriver = + // FQN check simplifies our SAGP implementation, allowing it to naively instrument all + // RoomDatabase.Builder.setDriver() call sites. if (delegate is SentrySQLiteDriver || delegate.javaClass.name == SUPPORT_SQLITE_DRIVER_FQN) { delegate } else { From 6bbdfbea7809761ded2735fc5a81acbab5182dc2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 25 Jun 2026 12:48:15 +0200 Subject: [PATCH 039/195] chore(changelog): Add 8.43.3 hotfix (#5620) Add release notes for version 8.43.3 with fixes. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a1115f8ae..2049c2b2543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,12 @@ - Fix attachments being duplicated on native events that carry scope attachments ([#5548](https://github.com/getsentry/sentry-java/pull/5548)) - Fix performance collector scheduling many tasks in a row ([#5524](https://github.com/getsentry/sentry-java/pull/5524)) +## 8.43.3 + +### Fixes + +- Fix crash when `getHistoricalProcessStartReasons` is called from an isolated or wrong-userId process ([#5597](https://github.com/getsentry/sentry-java/pull/5597)) + ## 8.43.2 ### Improvements From fa825503d1a24bca46aa0f7a71b9d1a06ee00351 Mon Sep 17 00:00:00 2001 From: arb Date: Thu, 25 Jun 2026 13:42:36 +0200 Subject: [PATCH 040/195] chore(deps): Bump dependencies associated with SentrySQLiteDriver (#5630) Bumps SAGP to 6.13.0, Room 3 to 3.0.0-rc01, and androidx.sqlite to 2.7.0-rc01. Lets us ensure the Android sample app runs against the latest Room build + picks up the SQLiteDriver auto-instrumentation introduced in SAGP 6.13.0. --- gradle/libs.versions.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24064703ca1..3984cb7115b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,10 +33,10 @@ otelSemanticConventions = "1.40.0" otelSemanticConventionsAlpha = "1.40.0-alpha" retrofit = "2.9.0" room2 = "2.8.4" -room3 = "3.0.0-alpha06" -sagp = "6.10.0" +room3 = "3.0.0-rc01" +sagp = "6.13.0" sqlite = "2.6.2" -sqliteAlpha = "2.7.0-alpha06" # Required by Room3 3.0.0-alpha* +sqliteRc = "2.7.0-rc01" # Required by Room3 3.0.0-rc* slf4j = "1.7.30" spotless = "8.4.0" springboot2 = "2.7.18" @@ -107,8 +107,8 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = " androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } -androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteAlpha" } -androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteAlpha" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteRc" } +androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteRc" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } From f082155e971b6ff724767cdc68f269f188f58d0a Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 25 Jun 2026 13:46:19 +0200 Subject: [PATCH 041/195] build: Remove redundant Java compatibility block from sentry-apollo-4 (#5633) The root build script already sets sourceCompatibility/targetCompatibility to VERSION_1_8 for every java-library subproject, so the module-level declaration was a no-op. --- sentry-apollo-4/build.gradle.kts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index d9f41891dc1..abb7ccb760e 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -11,11 +11,6 @@ plugins { alias(libs.plugins.animalsniffer) } -configure { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - tasks.withType().configureEach { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 From e0a2a6e63c9cf289a0d15f16b91b2ce19adf2fc6 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 25 Jun 2026 14:18:18 +0200 Subject: [PATCH 042/195] build: Remove redundant mavenCentral repository declarations (#5638) settings.gradle.kts already declares google(), mavenCentral() and mavenLocal() via dependencyResolutionManagement for every project, so the module-level repositories { mavenCentral() } blocks were redundant. With the default PREFER_PROJECT mode they only narrowed each project to mavenCentral; removing them falls back to the central superset and resolution is unaffected. --- sentry-reactor/build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- sentry-samples/sentry-samples-console-otlp/build.gradle.kts | 2 -- sentry-samples/sentry-samples-console/build.gradle.kts | 2 -- sentry-samples/sentry-samples-jul/build.gradle.kts | 2 -- sentry-samples/sentry-samples-log4j2/build.gradle.kts | 2 -- sentry-samples/sentry-samples-logback/build.gradle.kts | 2 -- sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts | 2 -- sentry-samples/sentry-samples-servlet/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring-7/build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- .../sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts | 2 -- .../sentry-samples-spring-boot-4-otlp/build.gradle.kts | 2 -- .../sentry-samples-spring-boot-4-webflux/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- .../sentry-samples-spring-boot-jakarta/build.gradle.kts | 2 -- .../build.gradle.kts | 2 -- .../sentry-samples-spring-boot-opentelemetry/build.gradle.kts | 2 -- .../sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts | 2 -- .../sentry-samples-spring-boot-webflux/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring-boot/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts | 2 -- sentry-samples/sentry-samples-spring/build.gradle.kts | 2 -- 25 files changed, 50 deletions(-) diff --git a/sentry-reactor/build.gradle.kts b/sentry-reactor/build.gradle.kts index 07024b3a23b..615ce38ecc5 100644 --- a/sentry-reactor/build.gradle.kts +++ b/sentry-reactor/build.gradle.kts @@ -62,8 +62,6 @@ tasks.withType().configureEach { } } -repositories { mavenCentral() } - tasks.jar { manifest { attributes( diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts index 23df981060f..5b67053449e 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts @@ -15,8 +15,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts index 9bb0678bf65..232a4ff2248 100644 --- a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts @@ -15,8 +15,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-console/build.gradle.kts b/sentry-samples/sentry-samples-console/build.gradle.kts index 8fdef6ef70e..f490939ed61 100644 --- a/sentry-samples/sentry-samples-console/build.gradle.kts +++ b/sentry-samples/sentry-samples-console/build.gradle.kts @@ -15,8 +15,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-jul/build.gradle.kts b/sentry-samples/sentry-samples-jul/build.gradle.kts index 5381f3ff2f0..25e682a19ba 100644 --- a/sentry-samples/sentry-samples-jul/build.gradle.kts +++ b/sentry-samples/sentry-samples-jul/build.gradle.kts @@ -15,8 +15,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-log4j2/build.gradle.kts index 07df6703c85..52c5c8bb035 100644 --- a/sentry-samples/sentry-samples-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-log4j2/build.gradle.kts @@ -15,8 +15,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-logback/build.gradle.kts b/sentry-samples/sentry-samples-logback/build.gradle.kts index bb37638d8c5..d608f0aa549 100644 --- a/sentry-samples/sentry-samples-logback/build.gradle.kts +++ b/sentry-samples/sentry-samples-logback/build.gradle.kts @@ -15,8 +15,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts b/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts index 202b8d8f058..90bc1ffc86f 100644 --- a/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts +++ b/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts @@ -19,8 +19,6 @@ java.sourceCompatibility = JavaVersion.VERSION_1_8 java.targetCompatibility = JavaVersion.VERSION_1_8 -repositories { mavenCentral() } - dependencies { implementation(platform(libs.springboot2.bom)) implementation(libs.springboot.starter.web) diff --git a/sentry-samples/sentry-samples-servlet/build.gradle.kts b/sentry-samples/sentry-samples-servlet/build.gradle.kts index 9dc9278bcb9..01ecef54154 100644 --- a/sentry-samples/sentry-samples-servlet/build.gradle.kts +++ b/sentry-samples/sentry-samples-servlet/build.gradle.kts @@ -8,8 +8,6 @@ java.sourceCompatibility = JavaVersion.VERSION_1_8 java.targetCompatibility = JavaVersion.VERSION_1_8 -repositories { mavenCentral() } - dependencies { implementation(projects.sentryServlet) implementation("javax.servlet:javax.servlet-api:4.0.1") diff --git a/sentry-samples/sentry-samples-spring-7/build.gradle.kts b/sentry-samples/sentry-samples-spring-7/build.gradle.kts index 6de7ed62e9f..daeab91f28d 100644 --- a/sentry-samples/sentry-samples-spring-7/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-7/build.gradle.kts @@ -26,8 +26,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - dependencyManagement { imports { mavenBom(SpringBootPlugin.BOM_COORDINATES) } } dependencies { diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts index afdb92e5c5b..090afbd4542 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts @@ -17,8 +17,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts index f0e2d468fec..fa73c191a92 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts @@ -18,8 +18,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts index d7c2c009bc9..22245cae979 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts @@ -17,8 +17,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts index 20ccf2d662c..b75f70b3574 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts @@ -17,8 +17,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - dependencies { implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) diff --git a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts index 2cc1f34b9eb..17ec5b2a45f 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -17,8 +17,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - configure { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts index 553affc3620..39d6dbf39b6 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts @@ -18,8 +18,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - dependencyManagement { imports { mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts index e4fefab7de7..6f1af65dc88 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -19,8 +19,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - dependencyManagement { imports { mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index 65850a6f2bd..320a9cc2512 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -18,8 +18,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - dependencyManagement { imports { mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts index e32eec82ac8..27d0cd1a772 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts @@ -21,8 +21,6 @@ java.sourceCompatibility = JavaVersion.VERSION_11 java.targetCompatibility = JavaVersion.VERSION_11 -repositories { mavenCentral() } - fun springBoot2SupportsOptionalIntegrations(): Boolean { val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") val parts = version.split(".").map { it.toIntOrNull() ?: 0 } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts index 085d6e362af..37aae899e4f 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts @@ -21,8 +21,6 @@ java.sourceCompatibility = JavaVersion.VERSION_11 java.targetCompatibility = JavaVersion.VERSION_11 -repositories { mavenCentral() } - fun springBoot2SupportsOptionalIntegrations(): Boolean { val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") val parts = version.split(".").map { it.toIntOrNull() ?: 0 } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts index 3e462517ded..213eb60296d 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts @@ -18,8 +18,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - dependencyManagement { imports { mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts index 8dc51e07a53..836608500dc 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -21,8 +21,6 @@ java.sourceCompatibility = JavaVersion.VERSION_11 java.targetCompatibility = JavaVersion.VERSION_11 -repositories { mavenCentral() } - fun springBoot2SupportsGraphql(): Boolean { val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") val parts = version.split(".").map { it.toIntOrNull() ?: 0 } diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 54fe99d56d4..0c8d2dc28e7 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -21,8 +21,6 @@ java.sourceCompatibility = JavaVersion.VERSION_11 java.targetCompatibility = JavaVersion.VERSION_11 -repositories { mavenCentral() } - fun springBoot2SupportsOptionalIntegrations(): Boolean { val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") val parts = version.split(".").map { it.toIntOrNull() ?: 0 } diff --git a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts index 5fe0334a629..2b360019cb5 100644 --- a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts @@ -24,8 +24,6 @@ java.sourceCompatibility = JavaVersion.VERSION_17 java.targetCompatibility = JavaVersion.VERSION_17 -repositories { mavenCentral() } - // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" diff --git a/sentry-samples/sentry-samples-spring/build.gradle.kts b/sentry-samples/sentry-samples-spring/build.gradle.kts index 3ab6610d96d..236e577a17a 100644 --- a/sentry-samples/sentry-samples-spring/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring/build.gradle.kts @@ -25,8 +25,6 @@ java { targetCompatibility = JavaVersion.VERSION_1_8 } -repositories { mavenCentral() } - // Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version extra["kotlin-coroutines.version"] = "1.9.0" From 2ebf90a0da3127c7b3adee4a86fe3c142bc6fa26 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 25 Jun 2026 15:45:49 +0200 Subject: [PATCH 043/195] perf(core): SDK Overhead Reduction (#5499) * collection: SDK Overhead Reduction * perf(core): Skip java.specification.version lookup on Android Android is never Java 9+, so the System.getProperty + Double.valueOf parse in the Platform static initializer is unnecessary overhead on the Android cold-start path. Short-circuit to isJavaNinePlus=false when isAndroid is true. * perf(android): Replace reflective OptionsContainer with direct subclass Replace OptionsContainer.create(SentryAndroidOptions.class) which uses getDeclaredConstructor().newInstance() with a direct SentryAndroidOptionsContainer subclass that returns new SentryAndroidOptions() without reflection. Make OptionsContainer non-final (@Open) with a protected no-arg constructor so Android can subclass it. * collection: SDK Overhead reduction for JVM * perf(core): Short-circuit combined scope breadcrumbs Avoid allocating and sorting a merged breadcrumb queue when only one component scope has breadcrumbs. This keeps the full merge path for multi-scope breadcrumbs and returns the default write scope queue when all scopes are empty. Co-Authored-By: Claude * perf(core): Reduce envelope writer buffer size Use an explicit 512-character BufferedWriter buffer for envelope item and envelope serialization. This avoids allocating the oversized default char buffer for each short-lived serialization writer while preserving the existing OutputStreamWriter-based encoding path. Co-Authored-By: Claude * changelog * perf(core): Remove redundant event map copies Avoid creating temporary maps when applying scope and options tags or scope extras. The event setters already copy these maps, so this preserves snapshot semantics while reducing allocation overhead. Co-Authored-By: Claude * changelog * changelog * perf(core): Short-circuit combined scope collections Avoid allocating merged collection copies when only one combined scope contains values. This extends the breadcrumbs optimization to tags, attributes, extras, and attachments while preserving merge behavior when multiple scopes contribute data. Co-Authored-By: Claude * changelog * perf(android): Use TimeZone.getDefault for device timezone Avoid constructing a Calendar only to read the default device timezone. The locale passed to Calendar does not affect the timezone value, so TimeZone.getDefault returns the same value with less work during device context collection. Co-Authored-By: Claude * perf(core): Replace Calendar with Date in DateUtils Avoid constructing Calendar instances when DateUtils only needs the current epoch millis or a Date for an existing millis value. Date stores epoch millis without timezone state, so the returned values are unchanged while avoiding unnecessary Calendar allocation and field computation. Co-Authored-By: Claude * perf(core): Reduce JsonWriter stack allocation Shrink the vendored JsonWriter nesting stack from 32 entries to 8 entries. The stack still grows on demand for deeply nested payloads, while common SDK serialization avoids the larger initial array allocation. Co-Authored-By: Claude * perf(core): Lazily allocate Breadcrumb data Avoid allocating a ConcurrentHashMap for breadcrumbs that never set data. Initialize the data map on first write while preserving concurrent writes with double-checked locking. Co-Authored-By: Claude * perf(core): Reduce context serialization allocations Use sorted key arrays when serializing contexts to avoid allocating an ArrayList for each serialization. This preserves deterministic key ordering while keeping the snapshot representation smaller. Co-Authored-By: Claude * perf(core): Lazily allocate reflection serializer state Defer creation of the reflection serializer visiting set until reflection serialization is actually needed. Normal SDK payload serialization uses explicit serializers, so this avoids an unused HashSet allocation for each writer. * perf(core): Lazily create reflection JSON serializer Defer creation of JsonReflectionObjectSerializer until unknown-object reflection serialization is needed. Normal SDK payloads use explicit serializers, so this avoids allocating unused reflection serializer state for each writer. * fix(android): Preserve locale timezone extension Keep the Calendar-based timezone path for Android 13+ locales that carry a Unicode tz extension. This preserves the existing device timezone behavior while keeping the direct default timezone fast path for normal locales. Co-Authored-By: Claude * perf(core): Replace ISO8601 timestamp handling Replace the Calendar-backed vendored ISO8601 formatting and parsing path with a small Sentry-specific utility that works directly from epoch milliseconds. This avoids formatter and parser allocations on timestamp-heavy serialization paths while keeping the existing DateUtils API as the facade. Co-Authored-By: Claude * ref(core): Move ISO8601 utility to vendor package Move the Sentry ISO8601 helper under the vendor package and mark it as internal API so the adapted public-domain date conversion code is isolated from core SDK classes. Update attribution metadata to reflect the public-domain dedication source. Co-Authored-By: Claude * perf(core): Avoid cloning Date getters * fix(core): Preserve ISO8601 utility compatibility Match edge-case behavior from the previous vendored ISO8601 utility for date-only timestamps, trailing characters after Z, and Gregorian cutover dates. * fix(core): Preserve mutable breadcrumb data access Initialize the lazy breadcrumb data map when callers request the full map. This keeps getData() mutable for existing callers while preserving lazy allocation for breadcrumbs that only serialize or read individual values. Co-Authored-By: Claude * docs(android): Explain timezone Calendar fallback Document why Android 13+ locales with Unicode timezone extensions keep using Calendar while normal locales use the default timezone directly for performance. Co-Authored-By: Claude * fix(core): Avoid KeySetView in context serialization Use ConcurrentHashMap.keys() when creating sorted context key snapshots so the serialization path stays compatible with Android API 21. Keep the array snapshot optimization without relying on KeySetView, which AnimalSniffer rejects for the SDK's minSdk. Co-Authored-By: Claude * test(core): Add breadcrumb timestamp serialization coverage Cover that breadcrumbs backed by timestamp milliseconds serialize the same timestamp as breadcrumbs backed by Date for the same instant. * fix(core): Parse date-only timestamps with timezones Preserve ISO8601 parser compatibility for date-only values that include a timezone suffix. Keep modern date-only timezone parsing on the fast path and add parity coverage against the previous parser. * docs(core): Add timezone changelog entry * docs(core): Add DateUtils changelog entry * docs(core): Add JsonWriter changelog entry * docs(core): Add breadcrumb changelog entry * docs(core): Add contexts changelog entry * docs(core): Add reflection state changelog entry * docs(core): Add reflection serializer changelog entry * docs(core): Add ISO8601 handling changelog entry * docs(core): Add Date getter changelog entries * changelog --------- Co-authored-by: Claude --- CHANGELOG.md | 26 ++ THIRD_PARTY_NOTICES.md | 16 + .../sentry/android/core/DeviceInfoUtil.java | 11 +- .../io/sentry/android/core/SentryAndroid.java | 3 +- .../core/SentryAndroidOptionsContainer.java | 16 + .../sentry/android/core/DeviceInfoUtilTest.kt | 32 ++ sentry/api/sentry.api | 10 +- .../src/main/java/io/sentry/Breadcrumb.java | 65 ++- .../java/io/sentry/CombinedScopeView.java | 157 ++++++- sentry/src/main/java/io/sentry/DateUtils.java | 35 +- .../java/io/sentry/JsonObjectSerializer.java | 14 +- .../JsonReflectionObjectSerializer.java | 10 +- .../main/java/io/sentry/JsonSerializer.java | 5 +- .../java/io/sentry/MainEventProcessor.java | 3 +- .../main/java/io/sentry/MonitorContexts.java | 6 +- .../main/java/io/sentry/OptionsContainer.java | 18 +- .../src/main/java/io/sentry/SentryClient.java | 9 +- .../java/io/sentry/SentryEnvelopeItem.java | 41 +- .../src/main/java/io/sentry/SentryEvent.java | 2 +- sentry/src/main/java/io/sentry/Session.java | 8 +- .../src/main/java/io/sentry/protocol/App.java | 3 +- .../java/io/sentry/protocol/Contexts.java | 6 +- .../main/java/io/sentry/protocol/Device.java | 3 +- .../java/io/sentry/util/CollectionUtils.java | 21 + .../main/java/io/sentry/util/Platform.java | 21 +- .../io/sentry/vendor/SentryIso8601Utils.java | 397 ++++++++++++++++++ .../sentry/vendor/gson/stream/JsonWriter.java | 4 +- .../src/test/java/io/sentry/BreadcrumbTest.kt | 36 ++ .../java/io/sentry/CombinedScopeViewTest.kt | 69 +++ .../src/test/java/io/sentry/DateUtilsTest.kt | 204 +++++++++ .../io/sentry/JsonObjectSerializerTest.kt | 24 ++ .../java/io/sentry/MainEventProcessorTest.kt | 13 + .../java/io/sentry/MonitorContextsTest.kt | 19 + .../test/java/io/sentry/SentryClientTest.kt | 18 + .../test/java/io/sentry/protocol/AppTest.kt | 5 +- .../protocol/BreadcrumbSerializationTest.kt | 8 + .../java/io/sentry/protocol/DeviceTest.kt | 5 +- .../SentryBaseEventSerializationTest.kt | 23 + 38 files changed, 1252 insertions(+), 114 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java create mode 100644 sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java create mode 100644 sentry/src/test/java/io/sentry/MonitorContextsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2049c2b2543..851fc3985e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## Unreleased + +### Behavioral Changes + +- Collections returned by scope (e.g. `getBreadcrumbs`, `getTags`, `getAttachments`) are shared state and should not be mutated. ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) + - Previously, when going through `CombinedScopeView`, we were returning a copy where mutations didn't show up in the underlying scopes. + - This has now changed in order to reduce SDK overhead. +- `Date` objects returned by SDK data model getters are shared state and should not be mutated. ([#5603](https://github.com/getsentry/sentry-java/pull/5603)) + - Previously, these getters returned defensive copies for some date fields. + - This has now changed in order to reduce SDK overhead. + +### Performance + +- Reduce writer buffer size from 8192 to 512 ([#5544](https://github.com/getsentry/sentry-java/pull/5544)) +- Remove redundant event map copies ([#5536](https://github.com/getsentry/sentry-java/pull/5536)) +- Optimize combined scope by adding an early return if only one scope has data ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) +- Reduce model access overhead by avoiding defensive `Date` copies in SDK data model getters. ([#5603](https://github.com/getsentry/sentry-java/pull/5603)) +- Reduce timestamp parsing and formatting overhead with Sentry-specific ISO-8601 handling. ([#5602](https://github.com/getsentry/sentry-java/pull/5602)) +- Reduce JSON serialization overhead by creating the reflection serializer only when unknown-object fallback serialization is needed. ([#5601](https://github.com/getsentry/sentry-java/pull/5601)) +- Reduce JSON serialization overhead by allocating reflection cycle-tracking state only when reflection serialization is used. ([#5600](https://github.com/getsentry/sentry-java/pull/5600)) +- Reduce context serialization overhead by sorting key snapshots with arrays instead of temporary lists. ([#5599](https://github.com/getsentry/sentry-java/pull/5599)) +- Reduce breadcrumb allocation overhead by creating the `Breadcrumb` data map only when data is added. ([#5598](https://github.com/getsentry/sentry-java/pull/5598)) +- Reduce JSON serialization overhead by lowering the initial `JsonWriter` nesting stack size while preserving on-demand growth. ([#5591](https://github.com/getsentry/sentry-java/pull/5591)) +- Reduce timestamp helper overhead by replacing unnecessary `Calendar` usage in `DateUtils` with direct `Date` creation. ([#5589](https://github.com/getsentry/sentry-java/pull/5589)) +- Reduce Android startup overhead by using the default timezone directly on older devices or when no timezone info is available in the locale. ([#5587](https://github.com/getsentry/sentry-java/pull/5587)) + ## 8.45.0 ### Features diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 925add4a71a..7b87b92dcb3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -62,6 +62,22 @@ limitations under the License. --- +## Howard Hinnant — Date Algorithms (Public Domain) + +**Source:** https://howardhinnant.github.io/date_algorithms.html
+**License:** Public Domain
+**Copyright:** None; public domain dedication by Howard Hinnant + +### Scope + +The Sentry Java SDK includes adapted civil date conversion algorithms from Howard Hinnant's date algorithms for UTC ISO 8601 timestamp parsing and formatting. The code resides in `io.sentry.vendor.SentryIso8601Utils`. + +``` +Consider these donated to the public domain. +``` + +--- + ## Android Open Source Project — Base64 (Apache 2.0) **Source:** https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/Base64.java
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index f3b17c5854a..63b88c0e440 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -257,14 +257,19 @@ private void setDeviceIO( @SuppressWarnings("NewApi") @NotNull private TimeZone getTimeZone() { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N) { + // Only use the costly Calendar API on Android 13+ (API Level 33+) when the locale contains a + // Unicode timezone extension (for example "en-US-u-tz-usnyc"), because Calendar honors that + // extension. For all other cases, use the process default timezone directly for performance. + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { LocaleList locales = context.getResources().getConfiguration().getLocales(); if (!locales.isEmpty()) { Locale locale = locales.get(0); - return Calendar.getInstance(locale).getTimeZone(); + if (locale.getUnicodeLocaleType("tz") != null) { + return Calendar.getInstance(locale).getTimeZone(); + } } } - return Calendar.getInstance().getTimeZone(); + return TimeZone.getDefault(); } @SuppressWarnings("JdkObsolete") diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 0d249f73790..f27259fd635 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -9,7 +9,6 @@ import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; -import io.sentry.OptionsContainer; import io.sentry.Sentry; import io.sentry.SentryLevel; import io.sentry.SentryOptions; @@ -98,7 +97,7 @@ public static void init( @NotNull Sentry.OptionsConfiguration configuration) { try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { Sentry.init( - OptionsContainer.create(SentryAndroidOptions.class), + new SentryAndroidOptionsContainer(), options -> { final io.sentry.util.LoadClass classLoader = new io.sentry.util.LoadClass(); final boolean isTimberUpstreamAvailable = diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java new file mode 100644 index 00000000000..678f7ab29b2 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java @@ -0,0 +1,16 @@ +package io.sentry.android.core; + +import io.sentry.OptionsContainer; +import org.jetbrains.annotations.NotNull; + +/** + * Direct OptionsContainer for SentryAndroidOptions that avoids reflective + * getDeclaredConstructor().newInstance() on the Android startup path. + */ +final class SentryAndroidOptionsContainer extends OptionsContainer { + + @Override + public @NotNull SentryAndroidOptions createInstance() { + return new SentryAndroidOptions(); + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt index 6d90d6be538..faf993e1610 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt @@ -2,16 +2,22 @@ package io.sentry.android.core import android.content.Context import android.content.Intent +import android.content.res.Configuration import android.os.BatteryManager +import android.os.Build +import android.os.LocaleList import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.internal.util.CpuInfoUtils +import java.util.Locale +import java.util.TimeZone import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.runner.RunWith +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) class DeviceInfoUtilTest { @@ -47,6 +53,32 @@ class DeviceInfoUtilTest { assertNotNull(deviceInfo.memorySize) } + @Test + fun `sets default timezone`() { + val deviceInfoUtil = DeviceInfoUtil.getInstance(context, SentryAndroidOptions()) + val deviceInfo = deviceInfoUtil.collectDeviceInformation(false, false) + + assertEquals(TimeZone.getDefault(), deviceInfo.timezone) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `preserves timezone from locale unicode extension`() { + val defaultTimeZone = TimeZone.getDefault() + try { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + val configuration = Configuration(context.resources.configuration) + configuration.setLocales(LocaleList(Locale.forLanguageTag("en-US-u-tz-usnyc"))) + val localizedContext = context.createConfigurationContext(configuration) + val deviceInfoUtil = DeviceInfoUtil(localizedContext, SentryAndroidOptions()) + val deviceInfo = deviceInfoUtil.collectDeviceInformation(false, false) + + assertEquals("America/New_York", deviceInfo.timezone?.id) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + @Test fun `does include cpu data`() { CpuInfoUtils.getInstance().setCpuMaxFrequencies(listOf(1024)) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e9083350349..04c876fdbdb 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1312,7 +1312,6 @@ public final class io/sentry/JsonObjectReader : io/sentry/ObjectReader { public final class io/sentry/JsonObjectSerializer { public static final field OBJECT_PLACEHOLDER Ljava/lang/String; - public final field jsonReflectionObjectSerializer Lio/sentry/JsonReflectionObjectSerializer; public fun (I)V public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;Ljava/lang/Object;)V } @@ -2067,7 +2066,8 @@ public abstract interface class io/sentry/ObjectWriter { public abstract fun value (Z)Lio/sentry/ObjectWriter; } -public final class io/sentry/OptionsContainer { +public class io/sentry/OptionsContainer { + protected fun ()V public static fun create (Ljava/lang/Class;)Lio/sentry/OptionsContainer; public fun createInstance ()Ljava/lang/Object; } @@ -7618,6 +7618,7 @@ public final class io/sentry/util/CollectionUtils { public static fun newHashMap (Ljava/util/Map;)Ljava/util/Map; public static fun reverseListIterator (Ljava/util/concurrent/CopyOnWriteArrayList;)Ljava/util/ListIterator; public static fun size (Ljava/lang/Iterable;)I + public static fun toSortedStringArray (Ljava/util/Enumeration;I)[Ljava/lang/String; } public abstract interface class io/sentry/util/CollectionUtils$Mapper { @@ -8075,6 +8076,11 @@ public class io/sentry/vendor/Base64 { public static fun encodeToString ([BIII)Ljava/lang/String; } +public final class io/sentry/vendor/SentryIso8601Utils { + public static fun formatTimestamp (J)Ljava/lang/String; + public static fun parseTimestamp (Ljava/lang/String;)J +} + public class io/sentry/vendor/gson/internal/bind/util/ISO8601Utils { public static final field TIMEZONE_UTC Ljava/util/TimeZone; public fun ()V diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index d122d1459bf..fff6954ee56 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -34,8 +34,10 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparab /** The type of breadcrumb. */ private @Nullable String type; + private static final @NotNull Map EMPTY_DATA = Collections.emptyMap(); + /** Data associated with this breadcrumb. */ - private @NotNull Map data = new ConcurrentHashMap<>(); + private volatile @NotNull Map data = EMPTY_DATA; /** Dotted strings that indicate what the crumb is or where it comes from. */ private @Nullable String category; @@ -78,9 +80,11 @@ public Breadcrumb(final long timestamp) { this.type = breadcrumb.type; this.category = breadcrumb.category; this.origin = breadcrumb.origin; - final Map dataClone = CollectionUtils.newConcurrentHashMap(breadcrumb.data); - if (dataClone != null) { - this.data = dataClone; + if (!breadcrumb.data.isEmpty()) { + final Map dataClone = CollectionUtils.newConcurrentHashMap(breadcrumb.data); + if (dataClone != null) { + this.data = dataClone; + } } this.unknown = CollectionUtils.newConcurrentHashMap(breadcrumb.unknown); this.level = breadcrumb.level; @@ -100,7 +104,7 @@ public static Breadcrumb fromMap( @NotNull Date timestamp = DateUtils.getCurrentDateTime(); String message = null; String type = null; - @NotNull Map data = new ConcurrentHashMap<>(); + Map data = null; String category = null; String origin = null; SentryLevel level = null; @@ -129,6 +133,9 @@ public static Breadcrumb fromMap( if (untypedData != null) { for (Map.Entry dataEntry : untypedData.entrySet()) { if (dataEntry.getKey() instanceof String && dataEntry.getValue() != null) { + if (data == null) { + data = new ConcurrentHashMap<>(); + } data.put((String) dataEntry.getKey(), dataEntry.getValue()); } else { options @@ -166,7 +173,9 @@ public static Breadcrumb fromMap( final Breadcrumb breadcrumb = new Breadcrumb(timestamp); breadcrumb.message = message; breadcrumb.type = type; - breadcrumb.data = data; + if (data != null) { + breadcrumb.data = data; + } breadcrumb.category = category; breadcrumb.origin = origin; breadcrumb.level = level; @@ -494,7 +503,7 @@ public static Breadcrumb fromMap( breadcrumb.setData("view.tag", viewTag); } for (final Map.Entry entry : additionalData.entrySet()) { - breadcrumb.getData().put(entry.getKey(), entry.getValue()); + breadcrumb.setData(entry.getKey(), entry.getValue()); } breadcrumb.setLevel(SentryLevel.INFO); return breadcrumb; @@ -553,9 +562,9 @@ public Breadcrumb(@Nullable String message) { @SuppressWarnings("JavaUtilDate") public @NotNull Date getTimestamp() { if (timestamp != null) { - return (Date) timestamp.clone(); + return timestamp; } else if (timestampMs != null) { - // we memoize it here into timestamp to avoid instantiating Calendar again and again + // we memoize it here into timestamp to avoid creating a Date again and again timestamp = DateUtils.getDateTime(timestampMs); return timestamp; } @@ -598,6 +607,20 @@ public void setType(@Nullable String type) { this.type = type; } + private @NotNull Map getOrCreateData() { + Map currentData = data; + if (currentData == EMPTY_DATA) { + synchronized (this) { + currentData = data; + if (currentData == EMPTY_DATA) { + currentData = new ConcurrentHashMap<>(); + data = currentData; + } + } + } + return currentData; + } + /** * Returns the data map * @@ -606,7 +629,7 @@ public void setType(@Nullable String type) { @ApiStatus.Internal @NotNull public Map getData() { - return data; + return getOrCreateData(); } /** @@ -636,7 +659,7 @@ public void setData(@Nullable String key, @Nullable Object value) { if (value == null) { removeData(key); } else { - data.put(key, value); + getOrCreateData().put(key, value); } } @@ -649,7 +672,10 @@ public void removeData(@Nullable String key) { if (key == null) { return; } - data.remove(key); + final Map currentData = data; + if (currentData != EMPTY_DATA) { + currentData.remove(key); + } } /** @@ -823,7 +849,12 @@ public static final class JsonKeys { public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { writer.beginObject(); - writer.name(JsonKeys.TIMESTAMP).value(logger, getTimestamp()); + writer + .name(JsonKeys.TIMESTAMP) + .value( + timestampMs != null + ? DateUtils.getTimestampFromMillis(timestampMs) + : DateUtils.getTimestamp(getTimestamp())); if (message != null) { writer.name(JsonKeys.MESSAGE).value(message); } @@ -859,7 +890,7 @@ public static final class Deserializer implements JsonDeserializer { @NotNull Date timestamp = DateUtils.getCurrentDateTime(); String message = null; String type = null; - @NotNull Map data = new ConcurrentHashMap<>(); + Map data = null; String category = null; String origin = null; SentryLevel level = null; @@ -884,7 +915,7 @@ public static final class Deserializer implements JsonDeserializer { Map deserializedData = CollectionUtils.newConcurrentHashMap( (Map) reader.nextObjectOrNull()); - if (deserializedData != null) { + if (deserializedData != null && !deserializedData.isEmpty()) { data = deserializedData; } break; @@ -913,7 +944,9 @@ public static final class Deserializer implements JsonDeserializer { Breadcrumb breadcrumb = new Breadcrumb(timestamp); breadcrumb.message = message; breadcrumb.type = type; - breadcrumb.data = data; + if (data != null) { + breadcrumb.data = data; + } breadcrumb.category = category; breadcrumb.origin = origin; breadcrumb.level = level; diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java index f21f8697fa4..ea2d752d44b 100644 --- a/sentry/src/main/java/io/sentry/CombinedScopeView.java +++ b/sentry/src/main/java/io/sentry/CombinedScopeView.java @@ -171,10 +171,31 @@ public void setFingerprint(@NotNull List fingerprint) { @Override public @NotNull Queue getBreadcrumbs() { + final @NotNull Queue globalBreadcrumbs = globalScope.getBreadcrumbs(); + final @NotNull Queue isolationBreadcrumbs = isolationScope.getBreadcrumbs(); + final @NotNull Queue currentBreadcrumbs = scope.getBreadcrumbs(); + + final boolean hasGlobalBreadcrumbs = !globalBreadcrumbs.isEmpty(); + final boolean hasIsolationBreadcrumbs = !isolationBreadcrumbs.isEmpty(); + final boolean hasCurrentBreadcrumbs = !currentBreadcrumbs.isEmpty(); + + if (!hasGlobalBreadcrumbs && !hasIsolationBreadcrumbs && !hasCurrentBreadcrumbs) { + return getDefaultScopeValue(globalBreadcrumbs, isolationBreadcrumbs, currentBreadcrumbs); + } + if (!hasIsolationBreadcrumbs && !hasCurrentBreadcrumbs) { + return globalBreadcrumbs; + } + if (!hasGlobalBreadcrumbs && !hasCurrentBreadcrumbs) { + return isolationBreadcrumbs; + } + if (!hasGlobalBreadcrumbs && !hasIsolationBreadcrumbs) { + return currentBreadcrumbs; + } + final @NotNull List allBreadcrumbs = new ArrayList<>(); - allBreadcrumbs.addAll(globalScope.getBreadcrumbs()); - allBreadcrumbs.addAll(isolationScope.getBreadcrumbs()); - allBreadcrumbs.addAll(scope.getBreadcrumbs()); + allBreadcrumbs.addAll(globalBreadcrumbs); + allBreadcrumbs.addAll(isolationBreadcrumbs); + allBreadcrumbs.addAll(currentBreadcrumbs); Collections.sort(allBreadcrumbs); final @NotNull Queue breadcrumbs = @@ -224,10 +245,31 @@ public void clear() { @Override public @NotNull Map getTags() { + final @NotNull Map globalTags = globalScope.getTags(); + final @NotNull Map isolationTags = isolationScope.getTags(); + final @NotNull Map currentTags = scope.getTags(); + + final boolean hasGlobalTags = !globalTags.isEmpty(); + final boolean hasIsolationTags = !isolationTags.isEmpty(); + final boolean hasCurrentTags = !currentTags.isEmpty(); + + if (!hasGlobalTags && !hasIsolationTags && !hasCurrentTags) { + return getDefaultScopeValue(globalTags, isolationTags, currentTags); + } + if (!hasIsolationTags && !hasCurrentTags) { + return globalTags; + } + if (!hasGlobalTags && !hasCurrentTags) { + return isolationTags; + } + if (!hasGlobalTags && !hasIsolationTags) { + return currentTags; + } + final @NotNull Map allTags = new ConcurrentHashMap<>(); - allTags.putAll(globalScope.getTags()); - allTags.putAll(isolationScope.getTags()); - allTags.putAll(scope.getTags()); + allTags.putAll(globalTags); + allTags.putAll(isolationTags); + allTags.putAll(currentTags); return allTags; } @@ -243,10 +285,32 @@ public void removeTag(@Nullable String key) { @Override public @NotNull Map getAttributes() { + final @NotNull Map globalAttributes = globalScope.getAttributes(); + final @NotNull Map isolationAttributes = + isolationScope.getAttributes(); + final @NotNull Map currentAttributes = scope.getAttributes(); + + final boolean hasGlobalAttributes = !globalAttributes.isEmpty(); + final boolean hasIsolationAttributes = !isolationAttributes.isEmpty(); + final boolean hasCurrentAttributes = !currentAttributes.isEmpty(); + + if (!hasGlobalAttributes && !hasIsolationAttributes && !hasCurrentAttributes) { + return getDefaultScopeValue(globalAttributes, isolationAttributes, currentAttributes); + } + if (!hasIsolationAttributes && !hasCurrentAttributes) { + return globalAttributes; + } + if (!hasGlobalAttributes && !hasCurrentAttributes) { + return isolationAttributes; + } + if (!hasGlobalAttributes && !hasIsolationAttributes) { + return currentAttributes; + } + final @NotNull Map allAttributes = new ConcurrentHashMap<>(); - allAttributes.putAll(globalScope.getAttributes()); - allAttributes.putAll(isolationScope.getAttributes()); - allAttributes.putAll(scope.getAttributes()); + allAttributes.putAll(globalAttributes); + allAttributes.putAll(isolationAttributes); + allAttributes.putAll(currentAttributes); return allAttributes; } @@ -272,11 +336,32 @@ public void removeAttribute(@Nullable String key) { @Override public @NotNull Map getExtras() { - final @NotNull Map allTags = new ConcurrentHashMap<>(); - allTags.putAll(globalScope.getExtras()); - allTags.putAll(isolationScope.getExtras()); - allTags.putAll(scope.getExtras()); - return allTags; + final @NotNull Map globalExtras = globalScope.getExtras(); + final @NotNull Map isolationExtras = isolationScope.getExtras(); + final @NotNull Map currentExtras = scope.getExtras(); + + final boolean hasGlobalExtras = !globalExtras.isEmpty(); + final boolean hasIsolationExtras = !isolationExtras.isEmpty(); + final boolean hasCurrentExtras = !currentExtras.isEmpty(); + + if (!hasGlobalExtras && !hasIsolationExtras && !hasCurrentExtras) { + return getDefaultScopeValue(globalExtras, isolationExtras, currentExtras); + } + if (!hasIsolationExtras && !hasCurrentExtras) { + return globalExtras; + } + if (!hasGlobalExtras && !hasCurrentExtras) { + return isolationExtras; + } + if (!hasGlobalExtras && !hasIsolationExtras) { + return currentExtras; + } + + final @NotNull Map allExtras = new ConcurrentHashMap<>(); + allExtras.putAll(globalExtras); + allExtras.putAll(isolationExtras); + allExtras.putAll(currentExtras); + return allExtras; } @Override @@ -342,6 +427,23 @@ public void removeContexts(@Nullable String key) { return getSpecificScope(null); } + private @NotNull T getDefaultScopeValue( + final @NotNull T globalValue, + final @NotNull T isolationValue, + final @NotNull T currentValue) { + switch (getOptions().getDefaultScopeType()) { + case CURRENT: + return currentValue; + case ISOLATION: + return isolationValue; + case GLOBAL: + return globalValue; + default: + // calm the compiler + return currentValue; + } + } + IScope getSpecificScope(final @Nullable ScopeType scopeType) { if (scopeType != null) { switch (scopeType) { @@ -373,10 +475,31 @@ IScope getSpecificScope(final @Nullable ScopeType scopeType) { @Override public @NotNull List getAttachments() { + final @NotNull List globalAttachments = globalScope.getAttachments(); + final @NotNull List isolationAttachments = isolationScope.getAttachments(); + final @NotNull List currentAttachments = scope.getAttachments(); + + final boolean hasGlobalAttachments = !globalAttachments.isEmpty(); + final boolean hasIsolationAttachments = !isolationAttachments.isEmpty(); + final boolean hasCurrentAttachments = !currentAttachments.isEmpty(); + + if (!hasGlobalAttachments && !hasIsolationAttachments && !hasCurrentAttachments) { + return getDefaultScopeValue(globalAttachments, isolationAttachments, currentAttachments); + } + if (!hasIsolationAttachments && !hasCurrentAttachments) { + return globalAttachments; + } + if (!hasGlobalAttachments && !hasCurrentAttachments) { + return isolationAttachments; + } + if (!hasGlobalAttachments && !hasIsolationAttachments) { + return currentAttachments; + } + final @NotNull List allAttachments = new CopyOnWriteArrayList<>(); - allAttachments.addAll(globalScope.getAttachments()); - allAttachments.addAll(isolationScope.getAttachments()); - allAttachments.addAll(scope.getAttachments()); + allAttachments.addAll(globalAttachments); + allAttachments.addAll(isolationAttachments); + allAttachments.addAll(currentAttachments); return allAttachments; } diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index e407391c394..fcba83fbe05 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -1,13 +1,8 @@ package io.sentry; -import static io.sentry.vendor.gson.internal.bind.util.ISO8601Utils.TIMEZONE_UTC; - -import io.sentry.vendor.gson.internal.bind.util.ISO8601Utils; +import io.sentry.vendor.SentryIso8601Utils; import java.math.BigDecimal; import java.math.RoundingMode; -import java.text.ParseException; -import java.text.ParsePosition; -import java.util.Calendar; import java.util.Date; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -15,6 +10,7 @@ /** Utilities to deal with dates */ @ApiStatus.Internal +@SuppressWarnings("JavaUtilDate") public final class DateUtils { private DateUtils() {} @@ -24,10 +20,9 @@ private DateUtils() {} * * @return the UTC Date */ - @SuppressWarnings("JdkObsolete") + @SuppressWarnings("JavaUtilDate") public static @NotNull Date getCurrentDateTime() { - final Calendar calendar = Calendar.getInstance(TIMEZONE_UTC); - return calendar.getTime(); + return new Date(); } /** @@ -39,8 +34,8 @@ private DateUtils() {} public static @NotNull Date getDateTime(final @NotNull String timestamp) throws IllegalArgumentException { try { - return ISO8601Utils.parse(timestamp, new ParsePosition(0)); - } catch (ParseException e) { + return getDateTime(SentryIso8601Utils.parseTimestamp(timestamp)); + } catch (IllegalArgumentException e) { throw new IllegalArgumentException("timestamp is not ISO format " + timestamp); } } @@ -51,7 +46,6 @@ private DateUtils() {} * @param timestamp millis eg 1581410911.988 (1581410911 seconds and 988 millis) * @return the UTC Date */ - @SuppressWarnings("JdkObsolete") public static @NotNull Date getDateTimeWithMillisPrecision(final @NotNull String timestamp) throws IllegalArgumentException { try { @@ -69,7 +63,17 @@ private DateUtils() {} * @return the UTC/ISO 8601 timestamp */ public static @NotNull String getTimestamp(final @NotNull Date date) { - return ISO8601Utils.format(date, true); + return getTimestampFromMillis(date.getTime()); + } + + /** + * Get the UTC/ISO 8601 timestamp from millis. + * + * @param millis the UTC millis from the epoch + * @return the UTC/ISO 8601 timestamp + */ + static @NotNull String getTimestampFromMillis(final long millis) { + return SentryIso8601Utils.formatTimestamp(millis); } /** @@ -78,10 +82,9 @@ private DateUtils() {} * @param millis the UTC millis from the epoch * @return the UTC Date */ + @SuppressWarnings("JavaUtilDate") public static @NotNull Date getDateTime(final long millis) { - final Calendar calendar = Calendar.getInstance(TIMEZONE_UTC); - calendar.setTimeInMillis(millis); - return calendar.getTime(); + return new Date(millis); } /** diff --git a/sentry/src/main/java/io/sentry/JsonObjectSerializer.java b/sentry/src/main/java/io/sentry/JsonObjectSerializer.java index 5f986746be9..38abc960521 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonObjectSerializer.java @@ -28,10 +28,11 @@ public final class JsonObjectSerializer { public static final String OBJECT_PLACEHOLDER = "[OBJECT]"; - public final JsonReflectionObjectSerializer jsonReflectionObjectSerializer; + private final int maxDepth; + private @Nullable JsonReflectionObjectSerializer jsonReflectionObjectSerializer; public JsonObjectSerializer(int maxDepth) { - jsonReflectionObjectSerializer = new JsonReflectionObjectSerializer(maxDepth); + this.maxDepth = maxDepth; } public void serialize( @@ -127,7 +128,7 @@ public void serialize( writer.value(object.toString()); } else { try { - Object serializableObject = jsonReflectionObjectSerializer.serialize(object, logger); + Object serializableObject = getJsonReflectionObjectSerializer().serialize(object, logger); serialize(writer, logger, serializableObject); } catch (Exception exception) { logger.log(SentryLevel.ERROR, "Failed serializing unknown object.", exception); @@ -138,6 +139,13 @@ public void serialize( // Helper + private @NotNull JsonReflectionObjectSerializer getJsonReflectionObjectSerializer() { + if (jsonReflectionObjectSerializer == null) { + jsonReflectionObjectSerializer = new JsonReflectionObjectSerializer(maxDepth); + } + return jsonReflectionObjectSerializer; + } + private void serializeDate( @NotNull ObjectWriter writer, @NotNull ILogger logger, @NotNull Date date) throws IOException { diff --git a/sentry/src/main/java/io/sentry/JsonReflectionObjectSerializer.java b/sentry/src/main/java/io/sentry/JsonReflectionObjectSerializer.java index 97c23031044..bb9ee1fcd3f 100644 --- a/sentry/src/main/java/io/sentry/JsonReflectionObjectSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonReflectionObjectSerializer.java @@ -30,7 +30,7 @@ @ApiStatus.Internal public final class JsonReflectionObjectSerializer { - private final Set visiting = new HashSet<>(); + private @Nullable Set visiting; private final int maxDepth; JsonReflectionObjectSerializer(int maxDepth) { @@ -69,6 +69,7 @@ public final class JsonReflectionObjectSerializer { } else if (object.getClass().isEnum()) { return object.toString(); } else { + final Set visiting = getVisiting(); if (visiting.contains(object)) { logger.log(SentryLevel.INFO, "Cyclic reference detected. Calling toString() on object."); return object.toString(); @@ -135,6 +136,13 @@ public final class JsonReflectionObjectSerializer { // Helper + private @NotNull Set getVisiting() { + if (visiting == null) { + visiting = new HashSet<>(); + } + return visiting; + } + private @NotNull List list(@NotNull Object[] objectArray, @NotNull ILogger logger) throws Exception { List list = new ArrayList<>(); diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index 2b24090d0cc..79a1c72bef3 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -64,6 +64,8 @@ public final class JsonSerializer implements ISerializer { @SuppressWarnings("CharsetObjectCanBeUsed") private static final Charset UTF_8 = Charset.forName("UTF-8"); + private static final int WRITER_BUFFER_SIZE = 512; + /** the SentryOptions */ private final @NotNull SentryOptions options; @@ -233,7 +235,8 @@ public void serialize(@NotNull SentryEnvelope envelope, @NotNull OutputStream ou // we do not want to close these as we would also close the stream that was passed in final BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(outputStream); - final Writer writer = new BufferedWriter(new OutputStreamWriter(bufferedOutputStream, UTF_8)); + final Writer writer = + new BufferedWriter(new OutputStreamWriter(bufferedOutputStream, UTF_8), WRITER_BUFFER_SIZE); try { envelope diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index 8c684bfb65a..d84c9e47be8 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -11,7 +11,6 @@ import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -191,7 +190,7 @@ private void setSdk(final @NotNull SentryBaseEvent event) { private void setTags(final @NotNull SentryBaseEvent event) { if (event.getTags() == null) { - event.setTags(new HashMap<>(options.getTags())); + event.setTags(options.getTags()); } else { for (Map.Entry item : options.getTags().entrySet()) { if (!event.getTags().containsKey(item.getKey())) { diff --git a/sentry/src/main/java/io/sentry/MonitorContexts.java b/sentry/src/main/java/io/sentry/MonitorContexts.java index 193d9ee5a6f..a52ecc6b97f 100644 --- a/sentry/src/main/java/io/sentry/MonitorContexts.java +++ b/sentry/src/main/java/io/sentry/MonitorContexts.java @@ -1,10 +1,9 @@ package io.sentry; +import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.util.Collections; -import java.util.List; import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -49,8 +48,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger throws IOException { writer.beginObject(); // Serialize in alphabetical order to keep determinism. - final List sortedKeys = Collections.list(keys()); - Collections.sort(sortedKeys); + final String[] sortedKeys = CollectionUtils.toSortedStringArray(keys(), size()); for (final String key : sortedKeys) { final Object value = get(key); if (value != null) { diff --git a/sentry/src/main/java/io/sentry/OptionsContainer.java b/sentry/src/main/java/io/sentry/OptionsContainer.java index 52032880aaf..b29aef2e000 100644 --- a/sentry/src/main/java/io/sentry/OptionsContainer.java +++ b/sentry/src/main/java/io/sentry/OptionsContainer.java @@ -1,28 +1,40 @@ package io.sentry; +import com.jakewharton.nopen.annotation.Open; +import io.sentry.util.Objects; import java.lang.reflect.InvocationTargetException; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public final class OptionsContainer { +@Open +public class OptionsContainer { public @NotNull static OptionsContainer create(final @NotNull Class clazz) { return new OptionsContainer<>(clazz); } - private final @NotNull Class clazz; + private final @Nullable Class clazz; private OptionsContainer(final @NotNull Class clazz) { super(); this.clazz = clazz; } + /** Constructor for subclasses that create the instance directly without reflection. */ + protected OptionsContainer() { + super(); + this.clazz = null; + } + public @NotNull T createInstance() throws InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { - return clazz.getDeclaredConstructor().newInstance(); + return Objects.requireNonNull(clazz, "OptionsContainer clazz is required") + .getDeclaredConstructor() + .newInstance(); } } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 78225f05d19..a739eddd9d9 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -27,7 +27,6 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; -import java.util.HashMap; import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -1425,7 +1424,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri event.setUser(scope.getUser()); } if (event.getTags() == null) { - event.setTags(new HashMap<>(scope.getTags())); + event.setTags(scope.getTags()); } else { for (Map.Entry item : scope.getTags().entrySet()) { if (!event.getTags().containsKey(item.getKey())) { @@ -1483,7 +1482,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri replayEvent.setUser(scope.getUser()); } if (replayEvent.getTags() == null) { - replayEvent.setTags(new HashMap<>(scope.getTags())); + replayEvent.setTags(scope.getTags()); } else { for (Map.Entry item : scope.getTags().entrySet()) { if (!replayEvent.getTags().containsKey(item.getKey())) { @@ -1523,7 +1522,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri sentryBaseEvent.setUser(scope.getUser()); } if (sentryBaseEvent.getTags() == null) { - sentryBaseEvent.setTags(new HashMap<>(scope.getTags())); + sentryBaseEvent.setTags(scope.getTags()); } else { for (Map.Entry item : scope.getTags().entrySet()) { if (!sentryBaseEvent.getTags().containsKey(item.getKey())) { @@ -1537,7 +1536,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri sortBreadcrumbsByDate(sentryBaseEvent, scope.getBreadcrumbs()); } if (sentryBaseEvent.getExtras() == null) { - sentryBaseEvent.setExtras(new HashMap<>(scope.getExtras())); + sentryBaseEvent.setExtras(scope.getExtras()); } else { for (Map.Entry item : scope.getExtras().entrySet()) { if (!sentryBaseEvent.getExtras().containsKey(item.getKey())) { diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index dbbc36524db..728478f5906 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -41,6 +41,8 @@ public final class SentryEnvelopeItem { @SuppressWarnings("CharsetObjectCanBeUsed") private static final Charset UTF_8 = Charset.forName("UTF-8"); + private static final int WRITER_BUFFER_SIZE = 512; + private final SentryEnvelopeItemHeader header; // Either dataFactory is set or data needs to be set. private final @Nullable Callable dataFactory; @@ -85,7 +87,9 @@ public final class SentryEnvelopeItem { new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(session, writer); return stream.toByteArray(); } @@ -119,7 +123,9 @@ public final class SentryEnvelopeItem { new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(event, writer); return stream.toByteArray(); } @@ -179,7 +185,9 @@ public static SentryEnvelopeItem fromUserFeedback( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(userFeedback, writer); return stream.toByteArray(); } @@ -206,7 +214,9 @@ public static SentryEnvelopeItem fromCheckIn( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(checkIn, writer); return stream.toByteArray(); } @@ -344,7 +354,9 @@ private static void ensureAttachmentSizeLimit( } try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(profileChunk, writer); return stream.toByteArray(); } catch (IOException e) { @@ -403,7 +415,9 @@ private static void ensureAttachmentSizeLimit( profilingTraceData.readDeviceCpuFrequencies(); try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(profilingTraceData, writer); return stream.toByteArray(); } catch (IOException e) { @@ -437,7 +451,9 @@ private static void ensureAttachmentSizeLimit( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(clientReport, writer); return stream.toByteArray(); } @@ -481,7 +497,8 @@ public static SentryEnvelopeItem fromReplay( try { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); final Writer writer = - new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { // relay expects the payload to be in this exact order: [event,rrweb,video] final Map replayPayload = new LinkedHashMap<>(); // first serialize replay event json bytes @@ -541,7 +558,9 @@ public static SentryEnvelopeItem fromLogs( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(logEvents, writer); return stream.toByteArray(); } @@ -571,7 +590,9 @@ public static SentryEnvelopeItem fromMetrics( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(metricsEvents, writer); return stream.toByteArray(); } diff --git a/sentry/src/main/java/io/sentry/SentryEvent.java b/sentry/src/main/java/io/sentry/SentryEvent.java index 007d50681fb..8b8575fe7ec 100644 --- a/sentry/src/main/java/io/sentry/SentryEvent.java +++ b/sentry/src/main/java/io/sentry/SentryEvent.java @@ -114,7 +114,7 @@ public SentryEvent(final @NotNull Date timestamp) { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public Date getTimestamp() { - return (Date) timestamp.clone(); + return timestamp; } public void setTimestamp(final @NotNull Date timestamp) { diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 3ce2d70e89e..2fdfffb35d9 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -131,10 +131,7 @@ public boolean isTerminated() { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getStarted() { - if (started == null) { - return null; - } - return (Date) started.clone(); + return started; } public @Nullable String getDistinctId() { @@ -193,8 +190,7 @@ public int errorCount() { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getTimestamp() { - final Date timestampRef = timestamp; - return timestampRef != null ? (Date) timestampRef.clone() : null; + return timestamp; } /** Ends a session and update its values */ diff --git a/sentry/src/main/java/io/sentry/protocol/App.java b/sentry/src/main/java/io/sentry/protocol/App.java index 989c3464be8..878ad0ec960 100644 --- a/sentry/src/main/java/io/sentry/protocol/App.java +++ b/sentry/src/main/java/io/sentry/protocol/App.java @@ -98,8 +98,7 @@ public void setAppIdentifier(final @Nullable String appIdentifier) { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getAppStartTime() { - final Date appStartTimeRef = appStartTime; - return appStartTimeRef != null ? (Date) appStartTimeRef.clone() : null; + return appStartTime; } public void setAppStartTime(final @Nullable Date appStartTime) { diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index 35168e5bcc2..83a770eb0fc 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -10,14 +10,13 @@ import io.sentry.ProfileContext; import io.sentry.SpanContext; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.CollectionUtils; import io.sentry.util.HintUtils; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.util.Collections; import java.util.Enumeration; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -302,8 +301,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger throws IOException { writer.beginObject(); // Serialize in alphabetical order to keep determinism. - final List sortedKeys = Collections.list(keys()); - Collections.sort(sortedKeys); + final String[] sortedKeys = CollectionUtils.toSortedStringArray(keys(), internalStorage.size()); for (final String key : sortedKeys) { final Object value = get(key); if (value != null) { diff --git a/sentry/src/main/java/io/sentry/protocol/Device.java b/sentry/src/main/java/io/sentry/protocol/Device.java index e6113efbcb5..5b765640a39 100644 --- a/sentry/src/main/java/io/sentry/protocol/Device.java +++ b/sentry/src/main/java/io/sentry/protocol/Device.java @@ -366,8 +366,7 @@ public void setScreenDpi(final @Nullable Integer screenDpi) { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getBootTime() { - final Date bootTimeRef = bootTime; - return bootTimeRef != null ? (Date) bootTimeRef.clone() : null; + return bootTime; } public void setBootTime(final @Nullable Date bootTime) { diff --git a/sentry/src/main/java/io/sentry/util/CollectionUtils.java b/sentry/src/main/java/io/sentry/util/CollectionUtils.java index 266055fa1ce..5b00eb6531c 100644 --- a/sentry/src/main/java/io/sentry/util/CollectionUtils.java +++ b/sentry/src/main/java/io/sentry/util/CollectionUtils.java @@ -1,7 +1,9 @@ package io.sentry.util; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.ListIterator; @@ -15,9 +17,28 @@ /** Util class for Collections */ @ApiStatus.Internal public final class CollectionUtils { + private static final String[] EMPTY_STRINGS = new String[0]; private CollectionUtils() {} + public static @NotNull String[] toSortedStringArray( + final @NotNull Enumeration source, final int size) { + String[] sorted = size == 0 ? EMPTY_STRINGS : new String[size]; + int index = 0; + while (source.hasMoreElements()) { + if (index == sorted.length) { + sorted = Arrays.copyOf(sorted, sorted.length + 1); + } + sorted[index] = source.nextElement(); + index++; + } + if (index != sorted.length) { + sorted = Arrays.copyOf(sorted, index); + } + Arrays.sort(sorted); + return sorted; + } + /** * Returns an Iterator size * diff --git a/sentry/src/main/java/io/sentry/util/Platform.java b/sentry/src/main/java/io/sentry/util/Platform.java index cc924fb2815..ad2a4e7f3c3 100644 --- a/sentry/src/main/java/io/sentry/util/Platform.java +++ b/sentry/src/main/java/io/sentry/util/Platform.java @@ -20,16 +20,21 @@ public final class Platform { isAndroid = false; } - try { - final @Nullable String javaStringVersion = System.getProperty("java.specification.version"); - if (javaStringVersion != null) { - final @NotNull double javaVersion = Double.parseDouble(javaStringVersion); - isJavaNinePlus = javaVersion >= 9.0; - } else { + if (isAndroid) { + // Android is never Java 9+, skip the system property lookup + parse on the startup path. + isJavaNinePlus = false; + } else { + try { + final @Nullable String javaStringVersion = System.getProperty("java.specification.version"); + if (javaStringVersion != null) { + final @NotNull double javaVersion = Double.parseDouble(javaStringVersion); + isJavaNinePlus = javaVersion >= 9.0; + } else { + isJavaNinePlus = false; + } + } catch (Throwable e) { isJavaNinePlus = false; } - } catch (Throwable e) { - isJavaNinePlus = false; } } diff --git a/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java new file mode 100644 index 00000000000..b5cb1811aa9 --- /dev/null +++ b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java @@ -0,0 +1,397 @@ +// Civil date conversion algorithms adapted from Howard Hinnant's date algorithms. +// Placed in the public domain by Howard Hinnant. +// https://howardhinnant.github.io/date_algorithms.html + +package io.sentry.vendor; + +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.SimpleTimeZone; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public final class SentryIso8601Utils { + + private static final long MILLIS_PER_SECOND = 1000L; + private static final long MILLIS_PER_MINUTE = 60L * MILLIS_PER_SECOND; + private static final long MILLIS_PER_HOUR = 60L * MILLIS_PER_MINUTE; + private static final long MILLIS_PER_DAY = 24L * MILLIS_PER_HOUR; + private static final long GREGORIAN_CUTOVER_MILLIS = -12219292800000L; + private static final int DAYS_0000_TO_1970 = 719468; + + private SentryIso8601Utils() {} + + public static long parseTimestamp(final @NotNull String timestamp) { + final int length = timestamp.length(); + int offset = 0; + + final int year = parseInt(timestamp, offset, offset += 4); + if (checkOffset(timestamp, offset, '-')) { + offset++; + } + + final int month = parseInt(timestamp, offset, offset += 2); + if (checkOffset(timestamp, offset, '-')) { + offset++; + } + + final int day = parseInt(timestamp, offset, offset += 2); + + if (!checkOffset(timestamp, offset, 'T')) { + if (offset == length) { + return dateOnlyEpochMillis(year, month, day); + } + final char timezoneIndicator = timestamp.charAt(offset); + if (timezoneIndicator == 'Z' || timezoneIndicator == '+' || timezoneIndicator == '-') { + return dateOnlyEpochMillisWithTimezone(timestamp, length, offset, year, month, day); + } + throw new IllegalArgumentException("Invalid date separator"); + } + validateDate(year, month, day); + offset++; + + final int hour = parseInt(timestamp, offset, offset += 2); + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + + final int minute = parseInt(timestamp, offset, offset += 2); + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + + int second = 0; + int millisecond = 0; + if (length > offset) { + final char c = timestamp.charAt(offset); + if (c != 'Z' && c != '+' && c != '-') { + second = parseInt(timestamp, offset, offset += 2); + if (second > 59 && second < 63) { + second = 59; + } + if (checkOffset(timestamp, offset, '.')) { + offset++; + final int endOffset = indexOfNonDigit(timestamp, offset); + if (endOffset == offset) { + throw new IllegalArgumentException("Missing millisecond digits"); + } + final int parseEndOffset = Math.min(endOffset, offset + 3); + final int fraction = parseInt(timestamp, offset, parseEndOffset); + switch (parseEndOffset - offset) { + case 1: + millisecond = fraction * 100; + break; + case 2: + millisecond = fraction * 10; + break; + default: + millisecond = fraction; + break; + } + offset = endOffset; + } + } + } + validateTime(hour, minute, second, millisecond); + + if (length <= offset) { + throw new IllegalArgumentException("No time zone indicator"); + } + + final int timezoneOffsetMillis; + final boolean allowTrailingCharacters; + final char timezoneIndicator = timestamp.charAt(offset); + if (timezoneIndicator == 'Z') { + timezoneOffsetMillis = 0; + offset++; + allowTrailingCharacters = true; + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + final int sign = timezoneIndicator == '+' ? 1 : -1; + offset++; + final int timezoneHour = parseInt(timestamp, offset, offset += 2); + int timezoneMinute = 0; + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + if (length >= offset + 2) { + timezoneMinute = parseInt(timestamp, offset, offset += 2); + } + validateTimezone(timezoneHour, timezoneMinute); + timezoneOffsetMillis = + sign * (int) (timezoneHour * MILLIS_PER_HOUR + timezoneMinute * MILLIS_PER_MINUTE); + allowTrailingCharacters = false; + } else { + throw new IllegalArgumentException("Invalid time zone indicator"); + } + + if (!allowTrailingCharacters && offset != length) { + throw new IllegalArgumentException("Invalid trailing characters"); + } + + if (isBeforeGregorianCutover(year, month, day)) { + return epochMillisWithCalendar( + year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis); + } + + return epochMillis(year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis); + } + + public static @NotNull String formatTimestamp(final long millis) { + if (millis < GREGORIAN_CUTOVER_MILLIS) { + return formatTimestampWithCalendar(millis); + } + + final long epochDay = Math.floorDiv(millis, MILLIS_PER_DAY); + int millisOfDay = (int) Math.floorMod(millis, MILLIS_PER_DAY); + + final int[] yearMonthDay = epochDayToYearMonthDay(epochDay); + final int hour = millisOfDay / (int) MILLIS_PER_HOUR; + millisOfDay -= hour * (int) MILLIS_PER_HOUR; + final int minute = millisOfDay / (int) MILLIS_PER_MINUTE; + millisOfDay -= minute * (int) MILLIS_PER_MINUTE; + final int second = millisOfDay / (int) MILLIS_PER_SECOND; + final int millisecond = millisOfDay - second * (int) MILLIS_PER_SECOND; + + final StringBuilder timestamp = new StringBuilder("yyyy-MM-ddThh:mm:ss.sssZ".length()); + padInt(timestamp, yearMonthDay[0], "yyyy".length()); + timestamp.append('-'); + padInt(timestamp, yearMonthDay[1], "MM".length()); + timestamp.append('-'); + padInt(timestamp, yearMonthDay[2], "dd".length()); + timestamp.append('T'); + padInt(timestamp, hour, "hh".length()); + timestamp.append(':'); + padInt(timestamp, minute, "mm".length()); + timestamp.append(':'); + padInt(timestamp, second, "ss".length()); + timestamp.append('.'); + padInt(timestamp, millisecond, "sss".length()); + timestamp.append('Z'); + return timestamp.toString(); + } + + private static long dateOnlyEpochMillis(final int year, final int month, final int day) { + return new GregorianCalendar(year, month - 1, day).getTimeInMillis(); + } + + private static long dateOnlyEpochMillisWithTimezone( + final @NotNull String timestamp, + final int length, + int offset, + final int year, + final int month, + final int day) { + final int timezoneOffsetMillis; + final boolean allowTrailingCharacters; + final char timezoneIndicator = timestamp.charAt(offset); + if (timezoneIndicator == 'Z') { + timezoneOffsetMillis = 0; + offset++; + allowTrailingCharacters = true; + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + final int sign = timezoneIndicator == '+' ? 1 : -1; + offset++; + final int timezoneHour = parseInt(timestamp, offset, offset += 2); + int timezoneMinute = 0; + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + if (length >= offset + 2) { + timezoneMinute = parseInt(timestamp, offset, offset += 2); + } + validateTimezone(timezoneHour, timezoneMinute); + timezoneOffsetMillis = + sign * (int) (timezoneHour * MILLIS_PER_HOUR + timezoneMinute * MILLIS_PER_MINUTE); + allowTrailingCharacters = false; + } else { + throw new IllegalArgumentException("Invalid time zone indicator"); + } + + if (!allowTrailingCharacters && offset != length) { + throw new IllegalArgumentException("Invalid trailing characters"); + } + + if (isBeforeGregorianCutover(year, month, day)) { + return epochMillisWithCalendar(year, month, day, 0, 0, 0, 0, timezoneOffsetMillis); + } + validateDate(year, month, day); + return epochMillis(year, month, day, 0, 0, 0, 0, timezoneOffsetMillis); + } + + private static long epochMillisWithCalendar( + final int year, + final int month, + final int day, + final int hour, + final int minute, + final int second, + final int millisecond, + final int timezoneOffsetMillis) { + final GregorianCalendar calendar = new GregorianCalendar(new SimpleTimeZone(timezoneOffsetMillis, "GMT")); + calendar.setLenient(false); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month - 1); + calendar.set(Calendar.DAY_OF_MONTH, day); + calendar.set(Calendar.HOUR_OF_DAY, hour); + calendar.set(Calendar.MINUTE, minute); + calendar.set(Calendar.SECOND, second); + calendar.set(Calendar.MILLISECOND, millisecond); + return calendar.getTimeInMillis(); + } + + private static @NotNull String formatTimestampWithCalendar(final long millis) { + final GregorianCalendar calendar = new GregorianCalendar(new SimpleTimeZone(0, "UTC")); + calendar.setTimeInMillis(millis); + + final StringBuilder timestamp = new StringBuilder("yyyy-MM-ddThh:mm:ss.sssZ".length()); + padInt(timestamp, calendar.get(Calendar.YEAR), "yyyy".length()); + timestamp.append('-'); + padInt(timestamp, calendar.get(Calendar.MONTH) + 1, "MM".length()); + timestamp.append('-'); + padInt(timestamp, calendar.get(Calendar.DAY_OF_MONTH), "dd".length()); + timestamp.append('T'); + padInt(timestamp, calendar.get(Calendar.HOUR_OF_DAY), "hh".length()); + timestamp.append(':'); + padInt(timestamp, calendar.get(Calendar.MINUTE), "mm".length()); + timestamp.append(':'); + padInt(timestamp, calendar.get(Calendar.SECOND), "ss".length()); + timestamp.append('.'); + padInt(timestamp, calendar.get(Calendar.MILLISECOND), "sss".length()); + timestamp.append('Z'); + return timestamp.toString(); + } + + private static long epochMillis( + final int year, + final int month, + final int day, + final int hour, + final int minute, + final int second, + final int millisecond, + final int timezoneOffsetMillis) { + return daysFromYearMonthDay(year, month, day) * MILLIS_PER_DAY + + hour * MILLIS_PER_HOUR + + minute * MILLIS_PER_MINUTE + + second * MILLIS_PER_SECOND + + millisecond + - timezoneOffsetMillis; + } + + private static long daysFromYearMonthDay(int year, final int month, final int day) { + year -= month <= 2 ? 1 : 0; + final long era = Math.floorDiv(year, 400); + final int yearOfEra = (int) (year - era * 400); + final int dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; + final int dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear; + return era * 146097 + dayOfEra - DAYS_0000_TO_1970; + } + + private static int[] epochDayToYearMonthDay(long epochDay) { + epochDay += DAYS_0000_TO_1970; + final long era = Math.floorDiv(epochDay, 146097); + final int dayOfEra = (int) (epochDay - era * 146097); + final int yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36524 - dayOfEra / 146096) / 365; + final int year = (int) (yearOfEra + era * 400); + final int dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100); + final int monthPrime = (5 * dayOfYear + 2) / 153; + final int day = dayOfYear - (153 * monthPrime + 2) / 5 + 1; + final int month = monthPrime < 10 ? monthPrime + 3 : monthPrime - 9; + return new int[] {year + (month <= 2 ? 1 : 0), month, day}; + } + + private static boolean isBeforeGregorianCutover(final int year, final int month, final int day) { + return year < 1582 || (year == 1582 && (month < 10 || (month == 10 && day < 15))); + } + + private static void validateDate(final int year, final int month, final int day) { + if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) { + throw new IllegalArgumentException("Invalid date"); + } + } + + private static void validateTime( + final int hour, final int minute, final int second, final int millisecond) { + if (hour < 0 + || hour > 23 + || minute < 0 + || minute > 59 + || second < 0 + || second > 59 + || millisecond < 0 + || millisecond > 999) { + throw new IllegalArgumentException("Invalid time"); + } + } + + private static void validateTimezone(final int hour, final int minute) { + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + throw new IllegalArgumentException("Invalid time zone"); + } + } + + private static int daysInMonth(final int year, final int month) { + switch (month) { + case 2: + return isLeapYear(year) ? 29 : 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } + } + + private static boolean isLeapYear(final int year) { + return (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0); + } + + private static boolean checkOffset( + final @NotNull String value, final int offset, final char expected) { + return offset < value.length() && value.charAt(offset) == expected; + } + + private static int parseInt( + final @NotNull String value, final int beginIndex, final int endIndex) { + if (beginIndex < 0 || endIndex > value.length() || beginIndex >= endIndex) { + throw new NumberFormatException(value); + } + + int result = 0; + for (int i = beginIndex; i < endIndex; i++) { + final char c = value.charAt(i); + if (c < '0' || c > '9') { + throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); + } + result = result * 10 + c - '0'; + } + return result; + } + + private static void padInt( + final @NotNull StringBuilder buffer, final int value, final int length) { + if (value < 0) { + buffer.append('-'); + padInt(buffer, -value, length); + return; + } + final String strValue = Integer.toString(value); + for (int i = length - strValue.length(); i > 0; i--) { + buffer.append('0'); + } + buffer.append(strValue); + } + + private static int indexOfNonDigit(final @NotNull String string, final int offset) { + for (int i = offset; i < string.length(); i++) { + final char c = string.charAt(i); + if (c < '0' || c > '9') { + return i; + } + } + return string.length(); + } +} diff --git a/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonWriter.java b/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonWriter.java index b030bc174b7..3119c833fd2 100644 --- a/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonWriter.java +++ b/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonWriter.java @@ -17,7 +17,7 @@ // Source: https://github.com/google/gson // Tag: gson-parent-2.8.7 // Commit Hash: 4520489c29e770c64b11ca35e0a0fdf17a1874ab -// Changes: @ApiStatus.Internal, SuppressWarnings +// Changes: @ApiStatus.Internal, SuppressWarnings, reduced stack size package io.sentry.vendor.gson.stream; @@ -175,7 +175,7 @@ public class JsonWriter implements Closeable, Flushable { /** The output data, containing at most one top-level array or object. */ private final Writer out; - private int[] stack = new int[32]; + private int[] stack = new int[8]; private int stackSize = 0; { push(EMPTY_DOCUMENT); diff --git a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt index 30c322641b8..f51acca81cb 100644 --- a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt +++ b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt @@ -1,6 +1,9 @@ package io.sentry import java.util.Date +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -329,6 +332,39 @@ class BreadcrumbTest { breadcrumb.removeData(null) } + @Test + fun `getData returns mutable map for new breadcrumb`() { + val breadcrumb = Breadcrumb() + + breadcrumb.data["k"] = "v" + + assertEquals("v", breadcrumb.getData("k")) + } + + @Test + fun `concurrent first writes keep all data entries`() { + val breadcrumb = Breadcrumb() + val count = 32 + val executor = Executors.newFixedThreadPool(count) + val start = CountDownLatch(1) + val futures = + (0 until count).map { index -> + executor.submit { + start.await() + breadcrumb.setData("key-$index", index) + } + } + + start.countDown() + futures.forEach { it.get(5, TimeUnit.SECONDS) } + executor.shutdown() + + assertEquals(count, breadcrumb.data.size) + for (index in 0 until count) { + assertEquals(index, breadcrumb.data["key-$index"]) + } + } + class TestKey(val id: Long) { override fun toString(): String = id.toString() } diff --git a/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt b/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt index d768d6d32d6..fd187235a92 100644 --- a/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt +++ b/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt @@ -11,6 +11,7 @@ import junit.framework.TestCase.assertTrue import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNotSame import kotlin.test.assertNull import kotlin.test.assertSame import org.junit.Assert.assertNotEquals @@ -72,6 +73,74 @@ class CombinedScopeViewTest { assertEquals("current 2", breadcrumbs.poll().message) } + @Test + fun `returns single non-empty breadcrumb queue directly`() { + var combined = fixture.getSut() + fixture.globalScope.addBreadcrumb(Breadcrumb.info("global")) + assertSame(fixture.globalScope.breadcrumbs, combined.breadcrumbs) + + combined = fixture.getSut() + fixture.isolationScope.addBreadcrumb(Breadcrumb.info("isolation")) + assertSame(fixture.isolationScope.breadcrumbs, combined.breadcrumbs) + + combined = fixture.getSut() + fixture.scope.addBreadcrumb(Breadcrumb.info("current")) + assertSame(fixture.scope.breadcrumbs, combined.breadcrumbs) + } + + @Test + fun `returns default write scope breadcrumbs when all scopes are empty`() { + val combined = fixture.getSut(SentryOptions().also { it.defaultScopeType = ScopeType.CURRENT }) + + assertSame(fixture.scope.breadcrumbs, combined.breadcrumbs) + } + + @Test + fun `returns merged breadcrumb copy when multiple scopes have breadcrumbs`() { + val combined = fixture.getSut() + + fixture.globalScope.addBreadcrumb(Breadcrumb.info("global")) + fixture.isolationScope.addBreadcrumb(Breadcrumb.info("isolation")) + + val breadcrumbs = combined.breadcrumbs + + assertNotSame(fixture.globalScope.breadcrumbs, breadcrumbs) + assertNotSame(fixture.isolationScope.breadcrumbs, breadcrumbs) + assertEquals(2, breadcrumbs.size) + } + + @Test + fun `returns single non-empty combined collections directly`() { + val globalScope = mock() + val isolationScope = mock() + val scope = mock() + val combined = CombinedScopeView(globalScope, isolationScope, scope) + + val tags = mapOf("tag" to "value") + whenever(globalScope.tags).thenReturn(emptyMap()) + whenever(isolationScope.tags).thenReturn(emptyMap()) + whenever(scope.tags).thenReturn(tags) + assertSame(tags, combined.tags) + + val attributes = mapOf("attribute" to SentryAttribute.named("attribute", "value")) + whenever(globalScope.attributes).thenReturn(emptyMap()) + whenever(isolationScope.attributes).thenReturn(emptyMap()) + whenever(scope.attributes).thenReturn(attributes) + assertSame(attributes, combined.attributes) + + val extras = mapOf("extra" to "value") + whenever(globalScope.extras).thenReturn(emptyMap()) + whenever(isolationScope.extras).thenReturn(emptyMap()) + whenever(scope.extras).thenReturn(extras) + assertSame(extras, combined.extras) + + val attachments = listOf(createAttachment("attachment.png")) + whenever(globalScope.attachments).thenReturn(emptyList()) + whenever(isolationScope.attachments).thenReturn(emptyList()) + whenever(scope.attachments).thenReturn(attachments) + assertSame(attachments, combined.attachments) + } + @Test fun `oldest breadcrumbs are dropped first`() { val options = SentryOptions().also { it.maxBreadcrumbs = 5 } diff --git a/sentry/src/test/java/io/sentry/DateUtilsTest.kt b/sentry/src/test/java/io/sentry/DateUtilsTest.kt index 9e234b50c1b..97882198e62 100644 --- a/sentry/src/test/java/io/sentry/DateUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/DateUtilsTest.kt @@ -1,12 +1,16 @@ package io.sentry +import io.sentry.vendor.gson.internal.bind.util.ISO8601Utils +import java.text.ParsePosition import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Date +import java.util.TimeZone import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -34,6 +38,54 @@ class DateUtilsTest { assertEquals("2020-03-27T08:52:58.000Z", timestamp) } + @Test + fun `When ISO date has offset`() { + val input = + mapOf( + "2020-03-27T10:52:58.015+02:00" to "2020-03-27T08:52:58.015Z", + "2020-03-27T10:52:58.015+0200" to "2020-03-27T08:52:58.015Z", + "2020-03-27T10:52:58.015+02" to "2020-03-27T08:52:58.015Z", + "2020-03-27T05:52:58.015-03:00" to "2020-03-27T08:52:58.015Z", + ) + + input.forEach { + val timestamp = convertDate(DateUtils.getDateTime(it.key)).format(isoFormat) + + assertEquals(it.value, timestamp) + } + } + + @Test + fun `When ISO date uses compact separators`() { + val date = DateUtils.getDateTime("20200327T085258.015Z") + + val utcDate = convertDate(date) + val timestamp = utcDate.format(isoFormat) + + assertEquals("2020-03-27T08:52:58.015Z", timestamp) + } + + @Test + fun `When ISO date has short fraction`() { + val input = + mapOf( + "2020-03-27T08:52:58.1Z" to "2020-03-27T08:52:58.100Z", + "2020-03-27T08:52:58.12Z" to "2020-03-27T08:52:58.120Z", + "2020-03-27T08:52:58.123456Z" to "2020-03-27T08:52:58.123Z", + ) + + input.forEach { + val timestamp = convertDate(DateUtils.getDateTime(it.key)).format(isoFormat) + + assertEquals(it.value, timestamp) + } + } + + @Test + fun `When ISO date is invalid`() { + assertFailsWith { DateUtils.getDateTime("2020-02-30T08:52:58Z") } + } + @Test fun `Converts from Date to ISO 8601 and back to Date`() { val currentDate = DateUtils.getCurrentDateTime() @@ -78,6 +130,147 @@ class DateUtilsTest { assertTrue { utcCurrentDate.minusSeconds(1).isBefore(utcDate) } } + @Test + fun `Formats millis to ISO 8601 timestamp`() { + val input = + mapOf( + Instant.parse("1970-01-01T00:00:00.000Z").toEpochMilli() to "1970-01-01T00:00:00.000Z", + Instant.parse("1969-12-31T23:59:59.999Z").toEpochMilli() to "1969-12-31T23:59:59.999Z", + Instant.parse("2000-02-29T12:34:56.789Z").toEpochMilli() to "2000-02-29T12:34:56.789Z", + Instant.parse("1900-03-01T00:00:00.000Z").toEpochMilli() to "1900-03-01T00:00:00.000Z", + Instant.parse("2100-03-01T00:00:00.000Z").toEpochMilli() to "2100-03-01T00:00:00.000Z", + Instant.parse("2400-02-29T23:59:59.999Z").toEpochMilli() to "2400-02-29T23:59:59.999Z", + ) + + input.forEach { assertEquals(it.value, DateUtils.getTimestampFromMillis(it.key)) } + } + + @Test + fun `Fast timestamp formatter matches previous ISO8601 formatter`() { + val input = + listOf( + "1582-10-04T00:00:00.000Z", + "1582-10-15T00:00:00.000Z", + "1900-03-01T00:00:00.000Z", + "1969-12-31T23:59:59.999Z", + "1970-01-01T00:00:00.000Z", + "1999-12-31T23:59:59.999Z", + "2000-02-29T12:34:56.789Z", + "2020-03-27T08:52:58.015Z", + "2024-02-29T23:59:59.001Z", + "2100-03-01T00:00:00.000Z", + "2400-02-29T23:59:59.999Z", + ) + + input + .map { ISO8601Utils.parse(it, ParsePosition(0)).time } + .forEach { + assertEquals( + ISO8601Utils.format(Date(it), true), + DateUtils.getTimestampFromMillis(it), + "millis=$it", + ) + } + } + + @Test + fun `Fast timestamp parser matches previous ISO8601 parser`() { + val input = + listOf( + "2020-03-27T08:52Z", + "2020-03-27T08:52:58Z", + "2020-03-27T08:52:58.015Z", + "20200327T085258.015Z", + "2020-03-27T10:52:58.015+02:00", + "2020-03-27T10:52:58.015+0200", + "2020-03-27T10:52:58.015+02", + "2020-03-27T05:52:58.015-03:00", + "2020-03-27T05:22:58.015-0330", + "2020-03-27T08:52:58.1Z", + "2020-03-27T08:52:58.12Z", + "2020-03-27T08:52:58.123456Z", + "2020-03-27T08:52:58Ztrailing", + "2016-12-31T23:59:60Z", + "1582-10-04T00:00:00.000Z", + "1582-10-15T00:00:00.000Z", + "1900-03-01T00:00:00.000Z", + "2000-02-29T12:34:56.789Z", + "2100-03-01T00:00:00.000Z", + ) + + input.forEach { + assertEquals( + ISO8601Utils.parse(it, ParsePosition(0)).time, + DateUtils.getDateTime(it).time, + "timestamp=$it", + ) + } + } + + @Test + fun `Fast timestamp parser matches previous ISO8601 parser for date-only values`() { + withDefaultTimeZone("America/Los_Angeles") { + val input = listOf("2020-03-27", "20200327", "2020-02-30") + + input.forEach { + assertEquals( + ISO8601Utils.parse(it, ParsePosition(0)).time, + DateUtils.getDateTime(it).time, + "timestamp=$it", + ) + } + } + } + + @Test + fun `Fast timestamp parser matches previous ISO8601 parser for date-only values with timezone`() { + val input = + listOf( + "2020-03-27Z", + "2020-03-27+02:00", + "2020-03-27+0200", + "2020-03-27+02", + "2020-03-27-03:30", + "20200327Z", + "20200327+02:00", + "20200327-0330", + ) + + input.forEach { + assertEquals( + ISO8601Utils.parse(it, ParsePosition(0)).time, + DateUtils.getDateTime(it).time, + "timestamp=$it", + ) + } + } + + @Test + fun `Fast timestamp parser rejects invalid date-only values with timezone like previous ISO8601 parser`() { + val timestamp = "2020-02-30Z" + + assertFailsWith { ISO8601Utils.parse(timestamp, ParsePosition(0)) } + assertFailsWith { DateUtils.getDateTime(timestamp) } + } + + @Test + fun `Fast timestamp parser rejects date-time without timezone like previous ISO8601 parser`() { + val input = listOf("2020-03-27T08:52", "2020-03-27T08:52:58", "2020-03-27T08:52:58.015") + + input.forEach { + assertFailsWith("timestamp=$it") { ISO8601Utils.parse(it, ParsePosition(0)) } + assertFailsWith("timestamp=$it") { DateUtils.getDateTime(it) } + } + } + + @Test + fun `Fast timestamp parser rejects Gregorian cutover gap like previous ISO8601 parser`() { + val timestamp = "1582-10-10T00:00:00.000Z" + + assertFailsWith { ISO8601Utils.parse(timestamp, ParsePosition(0)) } + assertFailsWith { DateUtils.getDateTime(timestamp) } + } + @Test fun `Millis formats to Date`() { val millis = 1591533492L * 1000L + 631 @@ -86,6 +279,7 @@ class DateUtilsTest { val utcActual = convertDate(actual) val timestamp = utcActual.format(isoFormat) + assertEquals(millis, actual.time) assertEquals("2020-06-07T12:38:12.631Z", timestamp) } @@ -120,6 +314,16 @@ class DateUtilsTest { private fun convertDate(date: Date): LocalDateTime = Instant.ofEpochMilli(date.time).atZone(utcTimeZone).toLocalDateTime() + private fun withDefaultTimeZone(timeZoneId: String, block: () -> Unit) { + val previousTimeZone = TimeZone.getDefault() + try { + TimeZone.setDefault(TimeZone.getTimeZone(timeZoneId)) + block() + } finally { + TimeZone.setDefault(previousTimeZone) + } + } + private fun assertClose(expected: Double, actual: Double?) { assertNotNull(actual) val diff = Math.abs(expected - actual) diff --git a/sentry/src/test/java/io/sentry/JsonObjectSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonObjectSerializerTest.kt index 3323be84cda..572c27abced 100644 --- a/sentry/src/test/java/io/sentry/JsonObjectSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonObjectSerializerTest.kt @@ -7,6 +7,8 @@ import java.util.Locale import java.util.TimeZone import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicIntegerArray +import kotlin.test.assertNotNull +import kotlin.test.assertNull import org.junit.Test import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -192,6 +194,21 @@ internal class JsonObjectSerializerTest { verify(jsonSerializable).serialize(fixture.writer, fixture.logger) } + @Test + fun `serialize json serializable does not create reflection serializer`() { + val serializer = fixture.getSUT() + val jsonSerializable: JsonSerializable = mock() + serializer.serialize(fixture.writer, fixture.logger, jsonSerializable) + assertNull(serializer.reflectionObjectSerializer) + } + + @Test + fun `serialize unknown object creates reflection serializer`() { + val serializer = fixture.getSUT() + serializer.serialize(fixture.writer, fixture.logger, object {}) + assertNotNull(serializer.reflectionObjectSerializer) + } + @Test fun `serialize unknown object without data`() { val value = object {} @@ -355,3 +372,10 @@ internal class JsonObjectSerializerTest { data class ClassWithEnumProperty(val enumProperty: DataCategory) data class ClassWithLocaleProperty(val localeProperty: Locale) + +private val JsonObjectSerializer.reflectionObjectSerializer: JsonReflectionObjectSerializer? + get() { + val field = JsonObjectSerializer::class.java.getDeclaredField("jsonReflectionObjectSerializer") + field.isAccessible = true + return field.get(this) as JsonReflectionObjectSerializer? + } diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index 229fd571871..fe5c835c90f 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -358,6 +358,19 @@ class MainEventProcessorTest { } } + @Test + fun `options tags are copied when applied to event`() { + val sut = fixture.getSut(tags = mapOf("tag1" to "value1")) + val event = SentryEvent() + + sut.process(event, Hint()) + val eventTags = event.tags!! + + fixture.sentryOptions.setTag("tag2", "value2") + + assertFalse(eventTags.containsKey("tag2")) + } + @Test fun `when event has a tag set with the same name as SentryOptions tags, the tag value from the event is retained`() { val sut = fixture.getSut(tags = mapOf("tag1" to "value1", "tag2" to "value2")) diff --git a/sentry/src/test/java/io/sentry/MonitorContextsTest.kt b/sentry/src/test/java/io/sentry/MonitorContextsTest.kt new file mode 100644 index 00000000000..2b0d57e605e --- /dev/null +++ b/sentry/src/test/java/io/sentry/MonitorContextsTest.kt @@ -0,0 +1,19 @@ +package io.sentry + +import io.sentry.protocol.SerializationUtils +import kotlin.test.Test +import kotlin.test.assertEquals +import org.mockito.kotlin.mock + +class MonitorContextsTest { + @Test + fun `serializes entries in alphabetical order`() { + val contexts = + MonitorContexts().apply { + put("b", 2) + put("a", 1) + } + + assertEquals("{\"a\":1,\"b\":2}", SerializationUtils.serializeToString(contexts, mock())) + } +} diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index ab6fd2075a3..f51345957e4 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -534,6 +534,24 @@ class SentryClientTest { assertNotNull(event.request) { assertEquals("post", it.method) } } + @Test + fun `when captureEvent applies scope tags and extras, event map containers are copied`() { + val event = SentryEvent() + val scope = createScope() + + val sut = fixture.getSut() + + sut.captureEvent(event, scope) + val eventTags = event.tags!! + val eventExtras = event.extras!! + + scope.setTag("newTag", "newValue") + scope.setExtra("newExtra", "newValue") + + assertFalse(eventTags.containsKey("newTag")) + assertFalse(eventExtras.containsKey("newExtra")) + } + @Test fun `when breadcrumbs are not empty, sort them out by date`() { val b1 = Breadcrumb(DateUtils.getDateTime("2020-03-27T08:52:58.001Z")) diff --git a/sentry/src/test/java/io/sentry/protocol/AppTest.kt b/sentry/src/test/java/io/sentry/protocol/AppTest.kt index 84b4c7088e3..cc0f504b7c8 100644 --- a/sentry/src/test/java/io/sentry/protocol/AppTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/AppTest.kt @@ -5,10 +5,11 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNotSame +import kotlin.test.assertSame class AppTest { @Test - fun `copying app wont have the same references`() { + fun `copying app keeps date reference and copies collections`() { val app = App() app.appBuild = "app build" app.appIdentifier = "app identifier" @@ -28,7 +29,7 @@ class AppTest { assertNotNull(clone) assertNotSame(app, clone) - assertNotSame(app.appStartTime, clone.appStartTime) + assertSame(app.appStartTime, clone.appStartTime) assertNotSame(app.permissions, clone.permissions) assertNotSame(app.viewNames, clone.viewNames) diff --git a/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt index 72856c3c27d..a33ddb91a2b 100644 --- a/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt @@ -11,6 +11,7 @@ import io.sentry.SentryLevel import io.sentry.SentryOptions import java.io.StringReader import java.io.StringWriter +import java.util.Date import kotlin.test.assertEquals import kotlin.test.assertTrue import org.junit.Test @@ -49,6 +50,13 @@ class BreadcrumbSerializationTest { assertEquals(expectedJson, actualJson) } + @Test + fun `timestampMs fast path serializes same timestamp as Date fallback`() { + val timestampMs = DateUtils.getDateTime("2009-11-16T01:08:47.123Z").time + + assertEquals(serialize(Breadcrumb(Date(timestampMs))), serialize(Breadcrumb(timestampMs))) + } + @Test fun deserializeFromMap() { val map: Map = diff --git a/sentry/src/test/java/io/sentry/protocol/DeviceTest.kt b/sentry/src/test/java/io/sentry/protocol/DeviceTest.kt index 121cbe6537f..a67305c37ea 100644 --- a/sentry/src/test/java/io/sentry/protocol/DeviceTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/DeviceTest.kt @@ -6,11 +6,12 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNotSame +import kotlin.test.assertSame class DeviceTest { @Test - fun `copying device wont have the same references`() { + fun `copying device keeps date reference and copies other mutable references`() { val device = Device() device.archs = arrayOf("archs1", "archs2") device.bootTime = Date() @@ -23,7 +24,7 @@ class DeviceTest { assertNotNull(clone) assertNotSame(device, clone) assertNotSame(device.archs, clone.archs) - assertNotSame(device.bootTime, clone.bootTime) + assertSame(device.bootTime, clone.bootTime) assertNotSame(device.timezone, clone.timezone) assertNotSame(device.unknown, clone.unknown) } diff --git a/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt index 4cafb1ed8a8..35322d2659e 100644 --- a/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt @@ -9,6 +9,7 @@ import io.sentry.SentryBaseEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.vendor.gson.stream.JsonToken import kotlin.test.assertEquals +import kotlin.test.assertFalse import org.junit.After import org.junit.Before import org.junit.Test @@ -102,4 +103,26 @@ class SentryBaseEventSerializationTest { assertEquals(expectedJson, actualJson) } + + @Test + fun `setTags copies source map`() { + val source = mutableMapOf("a" to "1") + val sut = Sut() + + sut.tags = source + source["b"] = "2" + + assertFalse(sut.tags!!.containsKey("b")) + } + + @Test + fun `setExtras copies source map`() { + val source = mutableMapOf("a" to "1") + val sut = Sut() + + sut.setExtras(source) + source["b"] = "2" + + assertFalse(sut.extras!!.containsKey("b")) + } } From 8c43a107a007ae5e2aea365bdf434318784049d7 Mon Sep 17 00:00:00 2001 From: adinauer <2542832+adinauer@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:21:16 +0000 Subject: [PATCH 044/195] release: 8.46.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 851fc3985e5..085cf8e35df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.46.0 ### Behavioral Changes diff --git a/gradle.properties b/gradle.properties index f83b851f8d9..804e4b58573 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.experimental.lint.version=8.13.1 # Release information -versionName=8.45.0 +versionName=8.46.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From d500866b45ecf8012bdd05876ab70b538a7d6371 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 25 Jun 2026 20:21:03 +0200 Subject: [PATCH 045/195] fix(replay): Fix network detail response body size being unknown for gzip-compressed responses (#5592) * fix(replay): Derive response body size from peeked bytes when contentLength is unknown For gzip-compressed responses, OkHttp strips the Content-Length header during transparent decompression, so response.body.contentLength() returns -1. This caused NetworkRequestData.responseBodySize to be unknown for replay network details. Add originalByteCount to NetworkBody, set it from the actual byte array in NetworkBodyParser.fromBytes, and use it as a fallback in NetworkDetailCaptureUtils when the passed bodySize is null or -1. This piggybacks on the existing peekBody call with no additional I/O. Co-Authored-By: Claude Opus 4.6 (1M context) * changelog * Changelog * fix(replay): make NetworkBody 3-arg constructor package-private Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 ++ .../io/sentry/util/network/NetworkBody.java | 15 ++++- .../util/network/NetworkBodyParser.java | 17 ++++-- .../network/NetworkDetailCaptureUtils.java | 8 ++- .../util/network/NetworkBodyParserTest.kt | 21 +++++++ .../network/NetworkDetailCaptureUtilsTest.kt | 58 +++++++++++++++++++ 6 files changed, 117 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 085cf8e35df..4e8df66b782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 8.46.0 +### Fixes + +- Session Replay: Fix network detail response body size being unknown for gzip-compressed responses ([#5592](https://github.com/getsentry/sentry-java/pull/5592)) + ### Behavioral Changes - Collections returned by scope (e.g. `getBreadcrumbs`, `getTags`, `getAttachments`) are shared state and should not be mutated. ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkBody.java b/sentry/src/main/java/io/sentry/util/network/NetworkBody.java index 5b4f6365ad4..bcea8cff6e7 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkBody.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkBody.java @@ -16,15 +16,24 @@ public final class NetworkBody { private final @Nullable Object body; private final @Nullable List warnings; + private final long originalByteCount; public NetworkBody(final @Nullable Object body) { - this(body, null); + this(body, null, -1); } public NetworkBody( final @Nullable Object body, final @Nullable List warnings) { + this(body, warnings, -1); + } + + NetworkBody( + final @Nullable Object body, + final @Nullable List warnings, + final long originalByteCount) { this.body = body; this.warnings = warnings; + this.originalByteCount = originalByteCount; } public @Nullable Object getBody() { @@ -35,6 +44,10 @@ public NetworkBody( return warnings; } + long getOriginalByteCount() { + return originalByteCount; + } + // Based on // https://github.com/getsentry/sentry/blob/ccb61aa9b0f33e1333830093a5ce3bd5db88ef33/static/app/utils/replays/replay.tsx#L5-L12 public enum NetworkBodyWarning { diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java b/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java index 49325a99003..42df5ca35b9 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java @@ -45,24 +45,33 @@ private NetworkBodyParser() {} return null; } + final boolean isTruncated = bytes.length > maxSizeBytes; + final long originalByteCount = bytes.length; + if (contentType != null && isBinaryContentType(contentType)) { // For binary content, return a description instead of the actual content return new NetworkBody( - "[Binary data, " + bytes.length + " bytes, type: " + contentType + "]"); + "[Binary data, " + bytes.length + " bytes, type: " + contentType + "]", + null, + originalByteCount); } // Convert to string and parse try { final String effectiveCharset = charset != null ? charset : "UTF-8"; final int size = Math.min(bytes.length, maxSizeBytes); - final boolean isPartial = bytes.length > maxSizeBytes; final String content = new String(bytes, 0, size, effectiveCharset); - return parse(content, contentType, isPartial, logger); + final NetworkBody parsed = parse(content, contentType, isTruncated, logger); + if (parsed == null) { + return null; + } + return new NetworkBody(parsed.getBody(), parsed.getWarnings(), originalByteCount); } catch (UnsupportedEncodingException e) { logger.log(SentryLevel.WARNING, "Failed to decode bytes: " + e.getMessage()); return new NetworkBody( "[Failed to decode bytes, " + bytes.length + " bytes]", - Collections.singletonList(NetworkBody.NetworkBodyWarning.BODY_PARSE_ERROR)); + Collections.singletonList(NetworkBody.NetworkBodyWarning.BODY_PARSE_ERROR), + originalByteCount); } } diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java index e0438c375b1..f5134693e00 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkDetailCaptureUtils.java @@ -160,9 +160,15 @@ private static boolean shouldCaptureUrl( body = bodyExtractor.extract(httpObject); } + // When contentLength is unknown (-1), use the actual byte count from body extraction + Long effectiveBodySize = bodySize; + if ((bodySize == null || bodySize == -1L) && body != null && body.getOriginalByteCount() >= 0) { + effectiveBodySize = body.getOriginalByteCount(); + } + Map headers = getCaptureHeaders(headerExtractor.extract(httpObject), allowedHeaders); - return new ReplayNetworkRequestOrResponse(bodySize, body, headers); + return new ReplayNetworkRequestOrResponse(effectiveBodySize, body, headers); } } diff --git a/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt b/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt index 3b1da25a0c2..04a19d47712 100644 --- a/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt +++ b/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt @@ -341,6 +341,27 @@ class NetworkBodyParserTest { val body = NetworkBodyParser.fromBytes(bytes, "image/png", null, bytes.size, logger) assertNotNull(body) assertEquals("[Binary data, 100 bytes, type: image/png]", body.body) + assertEquals(100, body.originalByteCount) + } + + @Test + fun `originalByteCount is set when body fits within limit`() { + val logger = mock() + val bytes = """{"key":"value"}""".toByteArray() + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) + assertNotNull(body) + assertEquals(bytes.size.toLong(), body.originalByteCount) + } + + @Test + fun `originalByteCount is set to capped size when body is truncated`() { + val logger = mock() + val bytes = """{"key":"value"}""".toByteArray() + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size - 1, logger) + assertNotNull(body) + assertEquals(bytes.size.toLong(), body.originalByteCount) } @Test diff --git a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt index cf4ec4828ff..25b142af7e9 100644 --- a/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/network/NetworkDetailCaptureUtilsTest.kt @@ -1,12 +1,70 @@ package io.sentry.util.network +import io.sentry.ILogger import java.util.LinkedHashMap import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue import org.junit.Test +import org.mockito.kotlin.mock class NetworkDetailCaptureUtilsTest { + @Test + fun `createResponse uses originalByteCount when bodySize is unknown`() { + val logger = mock() + val jsonBytes = """{"key":"value"}""".toByteArray() + + val result = + NetworkDetailCaptureUtils.createResponse( + jsonBytes, + -1L, + true, + { bytes -> + NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) + }, + emptyList(), + { emptyMap() }, + ) + + assertEquals(jsonBytes.size.toLong(), result.size) + } + + @Test + fun `createResponse keeps explicit bodySize when available`() { + val logger = mock() + val jsonBytes = """{"key":"value"}""".toByteArray() + + val result = + NetworkDetailCaptureUtils.createResponse( + jsonBytes, + 42L, + true, + { bytes -> + NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) + }, + emptyList(), + { emptyMap() }, + ) + + assertEquals(42L, result.size) + } + + @Test + fun `createResponse keeps null bodySize when body capture is off`() { + val result = + NetworkDetailCaptureUtils.createResponse( + "unused", + null, + false, + { null }, + emptyList(), + { emptyMap() }, + ) + + assertNull(result.size) + } + @Test fun `getCaptureHeaders should match headers case-insensitively`() { // Setup: allHeaders with mixed case keys From b3299ecb0a4145c3409adfb2ad70c8c444741626 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 06:47:34 +0000 Subject: [PATCH 046/195] chore(deps): bump the github-actions group across 1 directory with 6 updates (#5647) Bumps the github-actions group with 6 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/setup-java](https://github.com/actions/setup-java) | `5.3.0` | `5.4.0` | | [gradle/actions/setup-gradle](https://github.com/gradle/actions) | `6.1.0` | `6.2.0` | | [actions/cache](https://github.com/actions/cache) | `5.0.5` | `6.0.0` | | [getsentry/craft/.github/workflows/changelog-preview.yml](https://github.com/getsentry/craft) | `2.26.10` | `2.26.12` | | [getsentry/craft](https://github.com/getsentry/craft) | `2.26.10` | `2.26.12` | | [actions/setup-python](https://github.com/actions/setup-python) | `6.2.0` | `6.3.0` | Updates `actions/setup-java` from 5.3.0 to 5.4.0 - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/ad2b38190b15e4d6bdf0c97fb4fca8412226d287...1bcf9fb12cf4aa7d266a90ae39939e61372fe520) Updates `gradle/actions/setup-gradle` from 6.1.0 to 6.2.0 - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/50e97c2cd7a37755bbfafc9c5b7cafaece252f6e...3f131e8634966bd73d06cc69884922b02e6faf92) Updates `actions/cache` from 5.0.5 to 6.0.0 - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...2c8a9bd7457de244a408f35966fab2fb45fda9c8) Updates `getsentry/craft/.github/workflows/changelog-preview.yml` from 2.26.10 to 2.26.12 - [Release notes](https://github.com/getsentry/craft/releases) - [Changelog](https://github.com/getsentry/craft/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/craft/compare/acdb88019720182caf57293360d7cdc8db9e75ac...9312e4dfc82e545ef0cad911c23f430fe5f52673) Updates `getsentry/craft` from 2.26.10 to 2.26.12 - [Release notes](https://github.com/getsentry/craft/releases) - [Changelog](https://github.com/getsentry/craft/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/craft/compare/acdb88019720182caf57293360d7cdc8db9e75ac...9312e4dfc82e545ef0cad911c23f430fe5f52673) Updates `actions/setup-python` from 6.2.0 to 6.3.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/a309ff8b426b58ec0e2a45f0f869d46889d02405...ece7cb06caefa5fff74198d8649806c4678c61a1) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 5.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: gradle/actions/setup-gradle dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: actions/cache dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: getsentry/craft/.github/workflows/changelog-preview.yml dependency-version: 2.26.12 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: getsentry/craft dependency-version: 2.26.12 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: actions/setup-python dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agp-matrix.yml | 6 +++--- .github/workflows/build.yml | 6 +++--- .github/workflows/changelog-preview.yml | 2 +- .github/workflows/codeql-analysis.yml | 4 ++-- .github/workflows/enforce-license-compliance.yml | 4 ++-- .github/workflows/format-code.yml | 4 ++-- .github/workflows/generate-javadocs.yml | 4 ++-- .github/workflows/integration-tests-benchmarks.yml | 10 +++++----- .github/workflows/integration-tests-size.yml | 6 +++--- .github/workflows/integration-tests-ui-critical.yml | 6 +++--- .github/workflows/integration-tests-ui.yml | 4 ++-- .github/workflows/release-build.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/spring-boot-2-matrix.yml | 8 ++++---- .github/workflows/spring-boot-3-matrix.yml | 8 ++++---- .github/workflows/spring-boot-4-matrix.yml | 8 ++++---- .github/workflows/system-tests-backend.yml | 6 +++--- 17 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 8ddb961ec96..d196d595d73 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -33,13 +33,13 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -50,7 +50,7 @@ jobs: sudo udevadm trigger --name-match=kvm - name: AVD cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 id: avd-cache with: path: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6cba7e07e0a..57106c8e05a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,20 +25,20 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index d814ca72002..3e510787ce2 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -15,5 +15,5 @@ permissions: jobs: changelog-preview: - uses: getsentry/craft/.github/workflows/changelog-preview.yml@acdb88019720182caf57293360d7cdc8db9e75ac # v2 + uses: getsentry/craft/.github/workflows/changelog-preview.yml@9312e4dfc82e545ef0cad911c23f430fe5f52673 # v2 secrets: inherit diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ccc9cc04a85..c3cad17b1a9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,13 +25,13 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 38680fe0a23..33a0cc237fc 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - name: Set up Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 2892df16701..3fc47aa0f6b 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -13,13 +13,13 @@ jobs: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index fabd36736aa..be15b66d370 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -14,13 +14,13 @@ jobs: submodules: 'recursive' - name: set up JDK 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - name: Generate Aggregate Javadocs run: | diff --git a/.github/workflows/integration-tests-benchmarks.yml b/.github/workflows/integration-tests-benchmarks.yml index 45b063705dc..fa025030e8a 100644 --- a/.github/workflows/integration-tests-benchmarks.yml +++ b/.github/workflows/integration-tests-benchmarks.yml @@ -32,13 +32,13 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -82,17 +82,17 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + - uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 id: app-plain-cache with: path: sentry-android-integration-tests/test-app-plain/build/outputs/apk/release/test-app-plain-release.apk diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index 5c212d5895a..d67237d7089 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -23,20 +23,20 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Java Version - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: "temurin" java-version: "17" # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 7d0b74b4329..bd4a9058ddc 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -30,13 +30,13 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Java 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -86,7 +86,7 @@ jobs: sudo udevadm trigger --name-match=kvm - name: AVD cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 id: avd-cache with: path: | diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index e271227b97e..9404975a1fc 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -27,13 +27,13 @@ jobs: submodules: 'recursive' - name: 'Set up Java: 17' - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 050782006f0..eac4a94966c 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -20,13 +20,13 @@ jobs: submodules: 'recursive' - name: Setup Java Version - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - name: Build artifacts run: make publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd266d948c2..807236d4ed2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: fetch-depth: 0 submodules: 'recursive' - name: Prepare release - uses: getsentry/craft@acdb88019720182caf57293360d7cdc8db9e75ac # v2 + uses: getsentry/craft@9312e4dfc82e545ef0cad911c23f430fe5f52673 # v2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 6e0b1366c9f..cf69a869def 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -35,7 +35,7 @@ jobs: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.10.5' @@ -45,20 +45,20 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 00e93f5442b..2a94987549c 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -35,7 +35,7 @@ jobs: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.10.5' @@ -45,20 +45,20 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 450dbd8c98d..b5516e17453 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -35,7 +35,7 @@ jobs: submodules: 'recursive' - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.10.5' @@ -45,20 +45,20 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - name: Cache buildSrc - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: buildSrc/build key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 67f81f2fb64..ed6b5eab5f7 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -102,7 +102,7 @@ jobs: with: submodules: 'recursive' - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.10.5' @@ -112,13 +112,13 @@ jobs: python3 -m pip install -r requirements.txt - name: Set up Java - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5 with: distribution: 'temurin' java-version: '17' - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} From c4538565b7fe7d1d50dddb46c44b2b93129d74ee Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 26 Jun 2026 10:21:49 +0200 Subject: [PATCH 047/195] test(replay): ignore flaky ComposeMaskingOptionsTest unmask test (#5648) * test(replay): ignore flaky ComposeMaskingOptionsTest unmask test Co-Authored-By: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> * Format code --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Sentry Github Bot --- .../android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt index fe3fbc1ba67..baf0a32a415 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -44,6 +44,7 @@ import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHiera import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode import java.io.File +import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -219,6 +220,9 @@ class ComposeMaskingOptionsTest { } @Test + @Ignore( + "Flaky: Robolectric intermittently reports zero bounds for nodes, causing isVisible=false and making the assertion non-deterministic" + ) fun `when sentry-unmask modifier is set unmasks the node`() { ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.sentryReplayUnmask() } val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() From d8b6ce11cabd05be9a3f03a1d20fe247956d091d Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 26 Jun 2026 10:52:36 +0200 Subject: [PATCH 048/195] perf(android): Hit-test gestures without getLocationOnScreen (#5595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(android): Hit-test gestures without getLocationOnScreen (JAVA-534) ViewUtils.findTarget called View.getLocationOnScreen for every visited view, and that walks from the view up to the root each time, making the traversal O(N*depth) per tap and scroll start. Instead, map the touch point down into each child's local coordinate space as we descend the tree — the same way ViewGroup dispatches touch events — so each view costs O(1) and the whole traversal is O(N). The locators still receive the original decor-view-relative coordinates, since the Compose locator hit-tests against window coordinates. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog * Update changelog * test(android): Cover scroll and child matrix in findTarget hit-testing (JAVA-534) The existing test only exercised the left/top offset path of mapToChild. Add cases for a scrolled parent and a non-identity child matrix so the other two coordinate-mapping branches are covered, and switch the class to Robolectric so the real Matrix math runs. Co-Authored-By: Claude Opus 4.8 (1M context) * Move changelog entry to Unreleased as a performance improvement Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++ .../core/internal/gestures/ViewUtils.java | 88 +++++++++++---- .../core/internal/gestures/ViewHelpers.kt | 29 ++--- .../core/internal/gestures/ViewUtilsTest.kt | 100 +++++++++++++++++- .../gestures/ComposeGestureTargetLocator.kt | 9 +- 5 files changed, 184 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8df66b782..eaec96a2e0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Performance + +- Speed up touch gesture target detection on deeply nested view hierarchies by hit-testing in local coordinates instead of calling `getLocationOnScreen` per view ([#5595](https://github.com/getsentry/sentry-java/pull/5595)) + ## 8.46.0 ### Fixes diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java index 501a05a5007..6f52612e50d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -1,13 +1,14 @@ package io.sentry.android.core.internal.gestures; import android.content.res.Resources; +import android.graphics.Matrix; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.gestures.UiElement; -import java.util.LinkedList; +import java.util.ArrayDeque; import java.util.List; import java.util.Queue; import org.jetbrains.annotations.ApiStatus; @@ -17,30 +18,53 @@ @ApiStatus.Internal public final class ViewUtils { - private static final int[] coordinates = new int[2]; - /** - * Verifies if the given touch coordinates are within the bounds of the given view. + * Verifies if the given touch coordinates, expressed in the view's own local coordinate space, + * are within the bounds of the given view. * * @param view the view to check if the touch coordinates are within its bounds - * @param x - the x coordinate of a {@link MotionEvent} - * @param y - the y coordinate of {@link MotionEvent} + * @param localX - the x coordinate of the touch, relative to the view's top-left corner + * @param localY - the y coordinate of the touch, relative to the view's top-left corner * @return true if the touch coordinates are within the bounds of the view, false otherwise */ private static boolean touchWithinBounds( - final @Nullable View view, final float x, final float y) { + final @Nullable View view, final float localX, final float localY) { if (view == null) { return false; } - view.getLocationOnScreen(coordinates); - int vx = coordinates[0]; - int vy = coordinates[1]; + final int w = view.getWidth(); + final int h = view.getHeight(); - int w = view.getWidth(); - int h = view.getHeight(); + return !(localX < 0 || localX > w || localY < 0 || localY > h); + } - return !(x < vx || x > vx + w || y < vy || y > vy + h); + /** + * Maps a touch point expressed in the parent's local coordinate space into the child's local + * coordinate space. This mirrors how {@link ViewGroup} dispatches touch events to its children + * and lets us hit-test the whole tree with a single downward traversal, instead of calling {@link + * View#getLocationOnScreen(int[])} (which walks up to the root) for every view. + */ + private static @NotNull ViewWithLocation mapToChild( + final @NotNull View child, + final float parentX, + final float parentY, + final int parentScrollX, + final int parentScrollY) { + float childX = parentX + parentScrollX - child.getLeft(); + float childY = parentY + parentScrollY - child.getTop(); + + final @Nullable Matrix matrix = child.getMatrix(); + if (matrix != null && !matrix.isIdentity()) { + final Matrix inverse = new Matrix(); + if (matrix.invert(inverse)) { + final float[] point = {childX, childY}; + inverse.mapPoints(point); + childX = point[0]; + childY = point[1]; + } + } + return new ViewWithLocation(child, childX, childY); } /** @@ -48,8 +72,8 @@ private static boolean touchWithinBounds( * given {@code viewTargetSelector}. * * @param decorView - the root view of this window - * @param x - the x coordinate of a {@link MotionEvent} - * @param y - the y coordinate of {@link MotionEvent} + * @param x - the x coordinate of a {@link MotionEvent}, relative to the decor view + * @param y - the y coordinate of {@link MotionEvent}, relative to the decor view * @param targetType - the type of target to find * @return the {@link View} that contains the touch coordinates and complements the {@code * viewTargetSelector} @@ -62,25 +86,35 @@ private static boolean touchWithinBounds( final UiElement.Type targetType) { final List locators = options.getGestureTargetLocators(); - final Queue queue = new LinkedList<>(); - queue.add(decorView); + final Queue queue = new ArrayDeque<>(); + // The touch coordinates from the MotionEvent are already relative to the decor view, i.e. in + // its local coordinate space. + queue.add(new ViewWithLocation(decorView, x, y)); @Nullable UiElement target = null; - while (queue.size() > 0) { - final View view = queue.poll(); + while (!queue.isEmpty()) { + final ViewWithLocation current = queue.poll(); + final View view = current.view; - if (!touchWithinBounds(view, x, y)) { + if (!touchWithinBounds(view, current.x, current.y)) { // if the touch is not hitting the view, skip traversal of its children continue; } if (view instanceof ViewGroup) { final ViewGroup viewGroup = (ViewGroup) view; + final int scrollX = viewGroup.getScrollX(); + final int scrollY = viewGroup.getScrollY(); for (int i = 0; i < viewGroup.getChildCount(); i++) { - queue.add(viewGroup.getChildAt(i)); + final @Nullable View child = viewGroup.getChildAt(i); + if (child != null) { + queue.add(mapToChild(child, current.x, current.y, scrollX, scrollY)); + } } } + // Locators receive the original decor-view-relative coordinates, as the Compose locator + // hit-tests against window coordinates. for (int i = 0; i < locators.size(); i++) { final GestureTargetLocator locator = locators.get(i); final @Nullable UiElement newTarget = locator.locate(view, x, y, targetType); @@ -96,6 +130,18 @@ private static boolean touchWithinBounds( return target; } + private static final class ViewWithLocation { + final @NotNull View view; + final float x; + final float y; + + ViewWithLocation(final @NotNull View view, final float x, final float y) { + this.view = view; + this.x = x; + this.y = y; + } + } + /** * Retrieves the human-readable view id based on {@code view.getContext().getResources()}, falls * back to a hexadecimal id representation in case the view id is not available in the resources. diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt index 1a4f28bbe35..15123ce0a31 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewHelpers.kt @@ -5,9 +5,6 @@ import android.content.res.Resources import android.view.MotionEvent import android.view.View import android.view.Window -import kotlin.math.abs -import org.mockito.kotlin.any -import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -35,31 +32,17 @@ internal inline fun mockView( context: Context? = null, finalize: (T) -> Unit = {}, ): T { - val coordinates = IntArray(2) - if (!touchWithinBounds) { - coordinates[0] = (event.x).toInt() + 10 - coordinates[1] = (event.y).toInt() + 10 - } else { - coordinates[0] = (event.x).toInt() - 10 - coordinates[1] = (event.y).toInt() - 10 - } + // The decor-view-relative touch point used in these tests is (0, 0), and child views are mocked + // at offset (0, 0), so the point reaches every view unchanged. A view therefore contains the + // touch iff its width/height are non-negative; a negative size marks the touch as outside. + val size = if (touchWithinBounds) 10 else -1 val mockView: T = mock { whenever(it.id).thenReturn(id) whenever(it.context).thenReturn(context) whenever(it.isClickable).thenReturn(clickable) whenever(it.visibility).thenReturn(if (visible) View.VISIBLE else View.GONE) - - whenever(it.getLocationOnScreen(any())).doAnswer { - val array = it.arguments[0] as IntArray - array[0] = coordinates[0] - array[1] = coordinates[1] - null - } - - val diffPosX = abs(event.x - coordinates[0]).toInt() - val diffPosY = abs(event.y - coordinates[1]).toInt() - whenever(it.width).thenReturn(diffPosX + 10) - whenever(it.height).thenReturn(diffPosY + 10) + whenever(it.width).thenReturn(size) + whenever(it.height).thenReturn(size) finalize(this.mock) } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt index 77a38e6ccc1..10064b1cd74 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt @@ -2,10 +2,19 @@ package io.sentry.android.core.internal.gestures import android.content.Context import android.content.res.Resources +import android.graphics.Matrix import android.view.View +import android.view.ViewGroup +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.internal.gestures.UiElement +import io.sentry.util.LazyEvaluator import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.doThrow @@ -14,12 +23,13 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@RunWith(AndroidJUnit4::class) class ViewUtilsTest { @Test fun `getResourceId returns resourceId when available`() { val view = mock { - whenever(it.id).doReturn(View.generateViewId()) + whenever(it.id).doReturn(0x7f010001) val context = mock() val resources = mock() @@ -80,6 +90,94 @@ class ViewUtilsTest { verify(context, never()).resources } + @Test + fun `findTarget hit-tests children in their own local coordinate space`() { + val child = clickableChild() + val decorView = + mock { + whenever(it.width).thenReturn(1000) + whenever(it.height).thenReturn(1000) + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(child) + } + val options = optionsWithViewLocator() + + // (120, 220) maps to (20, 20) in the child's space -> inside its 50x50 bounds. + assertNotNull(ViewUtils.findTarget(options, decorView, 120f, 220f, UiElement.Type.CLICKABLE)) + + // (90, 220) maps to (-10, 20) in the child's space -> outside, despite being inside the decor. + assertNull(ViewUtils.findTarget(options, decorView, 90f, 220f, UiElement.Type.CLICKABLE)) + } + + @Test + fun `findTarget accounts for parent scroll when mapping into a child`() { + val child = clickableChild() + val decorView = + mock { + whenever(it.width).thenReturn(1000) + whenever(it.height).thenReturn(1000) + whenever(it.scrollX).thenReturn(30) + whenever(it.scrollY).thenReturn(40) + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(child) + } + val options = optionsWithViewLocator() + + // With scroll (30, 40), (90, 180) maps to (90 + 30 - 100, 180 + 40 - 200) = (20, 20) -> inside. + assertNotNull(ViewUtils.findTarget(options, decorView, 90f, 180f, UiElement.Type.CLICKABLE)) + + // The same point without accounting for scroll would map to (-10, -20) -> outside the child. + assertNull(ViewUtils.findTarget(options, decorView, 50f, 140f, UiElement.Type.CLICKABLE)) + } + + @Test + fun `findTarget applies the inverse of a non-identity child matrix`() { + // The child is visually translated by (40, 40) within its parent, so a parent-space point is + // mapped back by (-40, -40) to reach the child's own coordinate space. + val matrix = Matrix().apply { setTranslate(40f, 40f) } + val child = clickableChild { whenever(it.matrix).thenReturn(matrix) } + val decorView = + mock { + whenever(it.width).thenReturn(1000) + whenever(it.height).thenReturn(1000) + whenever(it.childCount).thenReturn(1) + whenever(it.getChildAt(0)).thenReturn(child) + } + val options = optionsWithViewLocator() + + // (180, 280) lands at (80, 80) before the matrix (outside 50x50), but the inverse pulls it to + // (40, 40) -> inside. + assertNotNull(ViewUtils.findTarget(options, decorView, 180f, 280f, UiElement.Type.CLICKABLE)) + + // (130, 230) lands at (30, 30) before the matrix (inside), but the inverse pushes it to + // (-10, -10) -> outside. + assertNull(ViewUtils.findTarget(options, decorView, 130f, 230f, UiElement.Type.CLICKABLE)) + } + + // A clickable child positioned at (100, 200) within its parent, 50x50 in size. + private fun clickableChild(finalize: (View) -> Unit = {}): View { + val context = mock() + val resources = mock() + whenever(context.resources).thenReturn(resources) + whenever(resources.getResourceEntryName(any())).thenReturn("child") + return mock { + whenever(it.id).thenReturn(0x7f010001) + whenever(it.context).thenReturn(context) + whenever(it.isClickable).thenReturn(true) + whenever(it.visibility).thenReturn(View.VISIBLE) + whenever(it.left).thenReturn(100) + whenever(it.top).thenReturn(200) + whenever(it.width).thenReturn(50) + whenever(it.height).thenReturn(50) + finalize(this.mock) + } + } + + private fun optionsWithViewLocator(): SentryAndroidOptions = + SentryAndroidOptions().apply { + gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true })) + } + @Test fun `getResourceIdWithFallback falls back to hexadecimal id when resource not found`() { val view = diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt index 54deb774c53..47dda6eda9c 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/gestures/ComposeGestureTargetLocator.kt @@ -15,7 +15,7 @@ import io.sentry.compose.boundsInWindow import io.sentry.internal.gestures.GestureTargetLocator import io.sentry.internal.gestures.UiElement import io.sentry.util.AutoClosableReentrantLock -import java.util.LinkedList +import java.util.ArrayDeque import java.util.Queue @OptIn(InternalComposeUiApi::class) @@ -45,7 +45,7 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT val rootLayoutNode = root.root // Pair - val queue: Queue> = LinkedList() + val queue: Queue> = ArrayDeque() queue.add(Pair(rootLayoutNode, null)) // the final tag to return, only relevant for clicks @@ -92,7 +92,10 @@ public class ComposeGestureTargetLocator(private val logger: ILogger) : GestureT } } } - queue.addAll(node.zSortedChildren.asMutableList().map { Pair(it, tag) }) + val children = node.zSortedChildren.asMutableList() + for (index in children.indices) { + queue.add(Pair(children[index], tag)) + } } } From d28345f99bf478304e7bfab7beda00c46b01ece0 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Mon, 29 Jun 2026 10:18:37 +0200 Subject: [PATCH 049/195] fix(core): Guard clearSession with session lock to prevent NPE (#5657) * fix(core): Guard clearSession with session lock to prevent NPE clearSession() reset the session field without acquiring sessionLock, unlike the other session mutators (startSession, endSession, withSession). This allowed it to null out the session between a null-check and a dereference (e.g. session.clone()) in those locked methods, leading to a NullPointerException. Acquire sessionLock so all session mutations are mutually exclusive. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog * changelog * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++++ sentry/src/main/java/io/sentry/Scope.java | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaec96a2e0a..dbb532e1f82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) + ### Performance - Speed up touch gesture target detection on deeply nested view hierarchies by hit-testing in local coordinates instead of calling `getLocationOnScreen` per view ([#5595](https://github.com/getsentry/sentry-java/pull/5595)) diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 9e8d3ee554e..282fc4df67f 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -1147,7 +1147,9 @@ public SentryOptions getOptions() { @ApiStatus.Internal @Override public void clearSession() { - session = null; + try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { + session = null; + } } @ApiStatus.Internal From 151b497f664e05aed370dcab83278ba3c68ee826 Mon Sep 17 00:00:00 2001 From: XYZboom <58654313+XYZboom@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:52:49 +0800 Subject: [PATCH 050/195] Add @Throws on SentryOkHttpInterceptor::intercept. (#5654) Fixes #5653 --- CHANGELOG.md | 4 ++++ .../src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt | 1 + 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb532e1f82..4a37500b9a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Behavioral Changes + +- `SentryOkHttpInterceptor::intercept` now throws `IOException`. This is a source-only and Java-only breaking change ([#5654](https://github.com/getsentry/sentry-java/pull/5654)) + ### Fixes - Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index ea8fdb44159..7031be3b0b3 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -77,6 +77,7 @@ public open class SentryOkHttpInterceptor( } @Suppress("LongMethod") + @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { var request = chain.request() From 012eaebafc1507c0a4767236b7acc5c26fca1988 Mon Sep 17 00:00:00 2001 From: Chris Aigner <25478494+christophaigner@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:55:43 +0200 Subject: [PATCH 051/195] docs: Add AI Use section to CONTRIBUTING.md (#5659) Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- CONTRIBUTING.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7eb38413d64..f4354c72a89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,3 +68,8 @@ issue without a closing keyword is not enough. Build and tests are automatically run against branches and pull requests via GH Actions. + + +# AI Use + +You are welcome to use whatever tools you prefer for making a contribution. However, any changes you propose have to be reviewed and tested by you, a human, first, before you submit a pull request with them for the Sentry team to review. If we feel like that did not happen, we will close the PR outright. For example, we will not review visibly AI-generated PRs from an agent instructed to look for and "fix" open issues in the repo. This aligns with our SDK principle: [every line has an owner](https://develop.sentry.dev/sdk/getting-started/principles/#every-line-has-an-owner). From 8fe8bad58f1cfd746f853286f0f241a3f2c5b3fb Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 30 Jun 2026 11:20:16 +0200 Subject: [PATCH 052/195] perf: Reduce reflection cost during SDK init (Init Reflection stack) (#5634) * collection: Reduce reflection cost during SDK init * perf(core): [Init Reflection 1] Probe class availability without initializing (#5635) * perf(core): Probe class availability without initializing the class LoadClass.loadClass used Class.forName(name) which initializes the class. Used purely for availability probing during init, this eagerly runs unrelated static initializers (e.g. Compose's Owner, the fragment integration). Use Class.forName(name, false, classLoader) so the class is only initialized lazily on first real use. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog * changelog: move init reflection entries to Performance * perf(core): Limit no-init class probing to isClassAvailable The previous change made loadClass itself skip class initialization, which affected callers that load a class to actually use it (NDK integration, OTEL span factory and scopes storage). Restore loadClass to its initializing behavior and confine the non-initializing probe to isClassAvailable, which is only ever used for classpath availability checks. This keeps SDK init cheap while leaving real-use callers unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../main/java/io/sentry/util/LoadClass.java | 34 ++++++++- .../test/java/io/sentry/util/LoadClassTest.kt | 70 +++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/util/LoadClassTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a37500b9a1..dce1fd22d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Performance - Speed up touch gesture target detection on deeply nested view hierarchies by hit-testing in local coordinates instead of calling `getLocationOnScreen` per view ([#5595](https://github.com/getsentry/sentry-java/pull/5595)) +- Probe class availability without initializing the class during SDK init ([#5635](https://github.com/getsentry/sentry-java/pull/5635)) ## 8.46.0 diff --git a/sentry/src/main/java/io/sentry/util/LoadClass.java b/sentry/src/main/java/io/sentry/util/LoadClass.java index 1946ce8381f..2c39cace39b 100644 --- a/sentry/src/main/java/io/sentry/util/LoadClass.java +++ b/sentry/src/main/java/io/sentry/util/LoadClass.java @@ -12,15 +12,23 @@ public class LoadClass { /** - * Try to load a class via reflection + * Loads and initializes a class via reflection. Use this when you intend to actually use the + * class (e.g. instantiate it or invoke its methods). The returned class is fully initialized, so + * its static initializers run. To merely check whether a class is on the classpath, use {@link + * #isClassAvailable} instead, which avoids running those initializers. * * @param clazz the full class name * @param logger an instance of ILogger * @return a Class<?> if it's available, or null */ public @Nullable Class loadClass(final @NotNull String clazz, final @Nullable ILogger logger) { + return loadClass(clazz, logger, true); + } + + private @Nullable Class loadClass( + final @NotNull String clazz, final @Nullable ILogger logger, final boolean initialize) { try { - return Class.forName(clazz); + return Class.forName(clazz, initialize, LoadClass.class.getClassLoader()); } catch (ClassNotFoundException e) { if (logger != null) { logger.log(SentryLevel.INFO, "Class not available: " + clazz); @@ -37,8 +45,19 @@ public class LoadClass { return null; } + /** + * Probes whether a class is on the classpath without initializing it. Use this for availability + * checks (e.g. deciding whether to register an integration); the class is not initialized, so its + * static initializers do not run until something actually uses it. This keeps SDK init cheap by + * not triggering unrelated initializers. If you need to use the class, use {@link #loadClass} + * instead. + * + * @param clazz the full class name + * @param logger an instance of ILogger + * @return true if the class is on the classpath + */ public boolean isClassAvailable(final @NotNull String clazz, final @Nullable ILogger logger) { - return loadClass(clazz, logger) != null; + return loadClass(clazz, logger, false) != null; } public boolean isClassAvailable( @@ -46,6 +65,15 @@ public boolean isClassAvailable( return isClassAvailable(clazz, options != null ? options.getLogger() : null); } + /** + * Like {@link #isClassAvailable}, but defers the (non-initializing) availability check until the + * result is first read. Use this when the check itself should not run during SDK init but only + * later, on first access. + * + * @param clazz the full class name + * @param logger an instance of ILogger + * @return a lazily-evaluated availability check + */ public LazyEvaluator isClassAvailableLazy( final @NotNull String clazz, final @Nullable ILogger logger) { return new LazyEvaluator<>(() -> isClassAvailable(clazz, logger)); diff --git a/sentry/src/test/java/io/sentry/util/LoadClassTest.kt b/sentry/src/test/java/io/sentry/util/LoadClassTest.kt new file mode 100644 index 00000000000..7a8bc802049 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/LoadClassTest.kt @@ -0,0 +1,70 @@ +package io.sentry.util + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LoadClassTest { + @Test + fun `loadClass returns the class when it is available`() { + assertNotNull(LoadClass().loadClass("io.sentry.SentryEvent", null)) + } + + @Test + fun `loadClass returns null when the class is not available`() { + assertNull(LoadClass().loadClass("io.sentry.ThisClassDoesNotExist", null)) + } + + @Test + fun `isClassAvailable reflects whether the class is on the classpath`() { + val loadClass = LoadClass() + assertNotNull(loadClass.loadClass("io.sentry.SentryEvent", null)) + assertFalse( + loadClass.isClassAvailable("io.sentry.ThisClassDoesNotExist", null as io.sentry.ILogger?) + ) + } + + @Test + fun `isClassAvailable does not run the static initializer of the probed class`() { + // Reading the flag initializes the flag holder, not the probe. + assertFalse(IsClassAvailableNoInitFlag.initialized) + + // Obtaining the name via ::class.java does not initialize the probe either. + LoadClass() + .isClassAvailable(IsClassAvailableNoInitProbe::class.java.name, null as io.sentry.ILogger?) + + // Availability probing must not trigger the probe's static initializer. + assertFalse(IsClassAvailableNoInitFlag.initialized) + } + + @Test + fun `loadClass runs the static initializer of the loaded class`() { + assertFalse(LoadClassInitFlag.initialized) + + LoadClass().loadClass(LoadClassInitProbe::class.java.name, null) + + assertTrue(LoadClassInitFlag.initialized) + } +} + +private object IsClassAvailableNoInitFlag { + @JvmField var initialized = false +} + +private object IsClassAvailableNoInitProbe { + init { + IsClassAvailableNoInitFlag.initialized = true + } +} + +private object LoadClassInitFlag { + @JvmField var initialized = false +} + +private object LoadClassInitProbe { + init { + LoadClassInitFlag.initialized = true + } +} From e279b061f72b2bbcf3b1b3c178024933e0031f60 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 30 Jun 2026 11:45:14 +0200 Subject: [PATCH 053/195] perf(android): Avoid exception-driven control flow in getResourceId (#5631) * perf(android): Avoid exception-driven control flow in getResourceId ViewUtils.getResourceId threw Resources.NotFoundException for views with no id or a generated id, and callers caught and discarded it. During a view-hierarchy snapshot and on every gesture this ran per view, so in Compose-heavy apps where most views have generated ids the SDK constructed an exception (and a native stack trace fill) per view on the main thread. Add a non-throwing resolveResourceId that returns null for unresolved ids and route the hot callers through it. The public getResourceId remains as a throwing wrapper for backward compatibility. Behavior (emitted identifiers and fallbacks) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../core/ViewHierarchyEventProcessor.java | 6 +- .../AndroidViewGestureTargetLocator.java | 10 +- .../core/internal/gestures/ViewUtils.java | 29 ++-- .../core/internal/gestures/ViewUtilsTest.kt | 126 ++++++++---------- 5 files changed, 85 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dce1fd22d22..6f9baf77a16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Speed up touch gesture target detection on deeply nested view hierarchies by hit-testing in local coordinates instead of calling `getLocationOnScreen` per view ([#5595](https://github.com/getsentry/sentry-java/pull/5595)) - Probe class availability without initializing the class during SDK init ([#5635](https://github.com/getsentry/sentry-java/pull/5635)) +- Avoid constructing an exception per view when resolving view ids during view-hierarchy and gesture capture ([#5631](https://github.com/getsentry/sentry-java/pull/5631)) ## 8.46.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java index c32b05892f9..7090985a38b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ViewHierarchyEventProcessor.java @@ -256,8 +256,10 @@ private static ViewHierarchyNode viewToNode(@NotNull final View view) { node.setType(className); try { - final String identifier = ViewUtils.getResourceId(view); - node.setIdentifier(identifier); + final @Nullable String identifier = ViewUtils.getResourceIdOrNull(view); + if (identifier != null) { + node.setIdentifier(identifier); + } } catch (Throwable e) { // ignored } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java index c85fb80dc35..5f6187cd39a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/AndroidViewGestureTargetLocator.java @@ -1,6 +1,5 @@ package io.sentry.android.core.internal.gestures; -import android.content.res.Resources; import android.view.View; import android.widget.AbsListView; import android.widget.ScrollView; @@ -42,13 +41,12 @@ && isViewScrollable(view, isAndroidXAvailable.getValue())) { } private UiElement createUiElement(final @NotNull View targetView) { - try { - final String resourceName = ViewUtils.getResourceId(targetView); - @Nullable String className = ClassUtil.getClassName(targetView); - return new UiElement(targetView, className, resourceName, null, ORIGIN); - } catch (Resources.NotFoundException ignored) { + final @Nullable String resourceName = ViewUtils.getResourceIdOrNull(targetView); + if (resourceName == null) { return null; } + @Nullable String className = ClassUtil.getClassName(targetView); + return new UiElement(targetView, className, resourceName, null, ORIGIN); } private static boolean isViewTappable(final @NotNull View view) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java index 6f52612e50d..78c73713bd4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/ViewUtils.java @@ -150,32 +150,37 @@ private static final class ViewWithLocation { * @return human-readable view id */ static String getResourceIdWithFallback(final @NotNull View view) { - final int viewId = view.getId(); - try { - return getResourceId(view); - } catch (Resources.NotFoundException e) { + final @Nullable String resourceId = getResourceIdOrNull(view); + if (resourceId == null) { // fall back to hex representation of the id - return "0x" + Integer.toString(viewId, 16); + return "0x" + Integer.toString(view.getId(), 16); } + return resourceId; } /** - * Retrieves the human-readable view id based on {@code view.getContext().getResources()}. + * Retrieves the human-readable view id based on {@code view.getContext().getResources()}, or + * {@code null} when the view has no resource-backed id. Returning {@code null} rather than + * throwing avoids exception-driven control flow on hot, main-thread paths such as view-hierarchy + * snapshots and gesture target resolution. * * @param view - the view whose id is being retrieved - * @return human-readable view id - * @throws Resources.NotFoundException in case the view id was not found + * @return human-readable view id, or {@code null} if it cannot be resolved */ - public static String getResourceId(final @NotNull View view) throws Resources.NotFoundException { + public static @Nullable String getResourceIdOrNull(final @NotNull View view) { final int viewId = view.getId(); if (viewId == View.NO_ID || isViewIdGenerated(viewId)) { - throw new Resources.NotFoundException(); + return null; } final Resources resources = view.getContext().getResources(); - if (resources != null) { + if (resources == null) { + return ""; + } + try { return resources.getResourceEntryName(viewId); + } catch (Resources.NotFoundException e) { + return null; } - return ""; } private static boolean isViewIdGenerated(int id) { diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt index 10064b1cd74..ed3e6d8ca89 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/ViewUtilsTest.kt @@ -11,13 +11,11 @@ import io.sentry.internal.gestures.UiElement import io.sentry.util.LazyEvaluator import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doThrow import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -25,71 +23,6 @@ import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) class ViewUtilsTest { - @Test - fun `getResourceId returns resourceId when available`() { - val view = - mock { - whenever(it.id).doReturn(0x7f010001) - - val context = mock() - val resources = mock() - whenever(resources.getResourceEntryName(it.id)).thenReturn("test_view") - whenever(context.resources).thenReturn(resources) - whenever(it.context).thenReturn(context) - } - - assertEquals(ViewUtils.getResourceId(view), "test_view") - } - - @Test - fun `getResourceId throws when resource id is not available`() { - val view = - mock { - whenever(it.id).doReturn(View.generateViewId()) - - val context = mock() - val resources = mock() - whenever(resources.getResourceEntryName(any())).doThrow(Resources.NotFoundException()) - whenever(context.resources).thenReturn(resources) - whenever(it.context).thenReturn(context) - } - - assertFailsWith { ViewUtils.getResourceId(view) } - } - - @Test - fun `when view has no id set, resource name is not looked up `() { - val context = mock() - val resources = mock() - whenever(context.resources).thenReturn(resources) - - val view = - mock { - whenever(it.id).doReturn(View.NO_ID) - whenever(it.context).thenReturn(context) - } - - assertFailsWith { ViewUtils.getResourceId(view) } - verify(context, never()).resources - } - - @Test - fun `when view id is generated, resource name is not looked up `() { - val context = mock() - val resources = mock() - whenever(context.resources).thenReturn(resources) - - val view = - mock { - // View.generateViewId() starts with 1 - whenever(it.id).doReturn(1) - whenever(it.context).thenReturn(context) - } - - assertFailsWith { ViewUtils.getResourceId(view) } - verify(context, never()).resources - } - @Test fun `findTarget hit-tests children in their own local coordinate space`() { val child = clickableChild() @@ -178,6 +111,65 @@ class ViewUtilsTest { gestureTargetLocators = listOf(AndroidViewGestureTargetLocator(LazyEvaluator { true })) } + @Test + fun `getResourceIdOrNull returns resource name when available`() { + val view = + mock { + whenever(it.id).doReturn(0x7f010001) + + val context = mock() + val resources = mock() + whenever(resources.getResourceEntryName(it.id)).thenReturn("test_view") + whenever(context.resources).thenReturn(resources) + whenever(it.context).thenReturn(context) + } + + assertEquals("test_view", ViewUtils.getResourceIdOrNull(view)) + } + + @Test + fun `getResourceIdOrNull returns null without throwing for generated id`() { + val context = mock() + val view = + mock { + // View.generateViewId() starts with 1 + whenever(it.id).doReturn(1) + whenever(it.context).thenReturn(context) + } + + assertNull(ViewUtils.getResourceIdOrNull(view)) + verify(context, never()).resources + } + + @Test + fun `getResourceIdOrNull returns null without throwing when view has no id`() { + val context = mock() + val view = + mock { + whenever(it.id).doReturn(View.NO_ID) + whenever(it.context).thenReturn(context) + } + + assertNull(ViewUtils.getResourceIdOrNull(view)) + verify(context, never()).resources + } + + @Test + fun `getResourceIdOrNull returns null without throwing when resource not found`() { + val view = + mock { + whenever(it.id).doReturn(1234) + + val context = mock() + val resources = mock() + whenever(resources.getResourceEntryName(it.id)).thenThrow(Resources.NotFoundException()) + whenever(context.resources).thenReturn(resources) + whenever(it.context).thenReturn(context) + } + + assertNull(ViewUtils.getResourceIdOrNull(view)) + } + @Test fun `getResourceIdWithFallback falls back to hexadecimal id when resource not found`() { val view = From 3859a2cc37716e2c8d9f149a0d94a1552f22c248 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 30 Jun 2026 11:58:42 +0200 Subject: [PATCH 054/195] perf(android): Defer SentryFrameMetricsCollector thread startup (#5641) * perf(android): Start frame metrics thread lazily on first collection SentryFrameMetricsCollector created and started its HandlerThread in the constructor, blocking the calling thread (the main thread during SDK init) on HandlerThread.getLooper(). The handler is only needed once startCollection() registers a listener, so start the thread lazily there instead. Apps that never collect frame metrics no longer start the thread at all. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + .../util/SentryFrameMetricsCollector.java | 35 +++++++++++++++---- .../util/SentryFrameMetricsCollectorTest.kt | 10 ++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f9baf77a16..fb4fee3db81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Speed up touch gesture target detection on deeply nested view hierarchies by hit-testing in local coordinates instead of calling `getLocationOnScreen` per view ([#5595](https://github.com/getsentry/sentry-java/pull/5595)) - Probe class availability without initializing the class during SDK init ([#5635](https://github.com/getsentry/sentry-java/pull/5635)) - Avoid constructing an exception per view when resolving view ids during view-hierarchy and gesture capture ([#5631](https://github.com/getsentry/sentry-java/pull/5631)) +- Start the frame metrics thread lazily on first collection instead of during SDK init ([#5641](https://github.com/getsentry/sentry-java/pull/5641)) ## 8.46.0 diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java index 241ab1e4cca..4f76a51e86f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java @@ -14,12 +14,14 @@ import android.view.Window; import androidx.annotation.RequiresApi; import io.sentry.ILogger; +import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.SentryUUID; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.ContextUtils; import io.sentry.android.core.SentryFramesDelayResult; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.lang.ref.WeakReference; import java.lang.reflect.Field; @@ -45,7 +47,8 @@ public final class SentryFrameMetricsCollector implements Application.ActivityLi private final @NotNull Set trackedWindows = new CopyOnWriteArraySet<>(); private final @NotNull ILogger logger; - private @Nullable Handler handler; + private volatile @Nullable Handler handler; + private final @NotNull AutoClosableReentrantLock handlerLock = new AutoClosableReentrantLock(); private @Nullable WeakReference currentWindow; private final @NotNull Map listenerMap = new ConcurrentHashMap<>(); @@ -113,12 +116,8 @@ public SentryFrameMetricsCollector( } isAvailable = true; - HandlerThread handlerThread = - new HandlerThread("io.sentry.android.core.internal.util.SentryFrameMetricsCollector"); - handlerThread.setUncaughtExceptionHandler( - (thread, e) -> logger.log(SentryLevel.ERROR, "Error during frames measurements.", e)); - handlerThread.start(); - handler = new Handler(handlerThread.getLooper()); + // The frame metrics HandlerThread is started lazily on the first startCollection() call. + // Starting it here would block the main thread on HandlerThread.getLooper() during SDK init. // We have to register the lifecycle callback, even if no profile is started, otherwise when we // start a profile, we wouldn't have the current activity and couldn't get the frameMetrics. @@ -281,12 +280,34 @@ public void onActivityDestroyed(@NotNull Activity activity) {} if (!isAvailable) { return null; } + ensureHandlerThreadStarted(); final String uid = SentryUUID.generateSentryId(); listenerMap.put(uid, listener); trackCurrentWindow(); return uid; } + /** + * Lazily starts the background HandlerThread used to receive frame metrics. Deferred out of the + * constructor because {@link HandlerThread#getLooper()} blocks the caller (the main thread during + * SDK init) until the thread is ready, and the handler is only needed once collection starts. + */ + private void ensureHandlerThreadStarted() { + if (handler != null) { + return; + } + try (final @NotNull ISentryLifecycleToken ignored = handlerLock.acquire()) { + if (handler == null) { + final HandlerThread handlerThread = + new HandlerThread("io.sentry.android.core.internal.util.SentryFrameMetricsCollector"); + handlerThread.setUncaughtExceptionHandler( + (thread, e) -> logger.log(SentryLevel.ERROR, "Error during frames measurements.", e)); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + } + } + } + public void stopCollection(final @Nullable String listenerId) { if (!isAvailable) { return; diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt index 02f65665a9e..f90c07b70e6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollectorTest.kt @@ -141,6 +141,16 @@ class SentryFrameMetricsCollectorTest { assertNotNull(id) } + @Test + fun `handler thread is started lazily on first startCollection`() { + val collector = fixture.getSut(context) + // not started during construction (would block the main thread on getLooper at SDK init) + assertNull(collector.getProperty("handler")) + + collector.startCollection(mock()) + assertNotNull(collector.getProperty("handler")) + } + @Test fun `collector calls addOnFrameMetricsAvailableListener when an activity starts`() { val collector = fixture.getSut(context) From 307edcd968452d07d801c46362bf98f815fea808 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Tue, 30 Jun 2026 03:50:02 -0700 Subject: [PATCH 055/195] refactor: do not start redundant UI event transaction when one is already on Scope (#5658) SentryGestureListener.startTracing always started a UI transaction and only later, in applyScope, declined to bind it when the Scope already held a manually-bound transaction. The unbound UI transaction then gathered no children and was dropped as an idle transaction. Now we read the Scope's bound transaction first and return early without starting a new one when it is present. Fixes #5491 Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- CHANGELOG.md | 2 ++ .../internal/gestures/SentryGestureListener.java | 15 +++++++++++++++ .../gestures/SentryGestureListenerTracingTest.kt | 12 ++++++++++++ 3 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4fee3db81..73bb2ef396e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixes +- Don't start a redundant UI interaction transaction when a transaction is already bound to the Scope ([#5491](https://github.com/getsentry/sentry-java/issues/5491)) + - Previously, `SentryGestureListener` always started a UI transaction and only afterwards skipped binding it to the Scope when a manually-bound transaction already existed, leaving the new transaction to be dropped as an idle transaction without children. - Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) ### Performance diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java index 8caffedad94..61a32b675db 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/gestures/SentryGestureListener.java @@ -244,6 +244,21 @@ private void startTracing(final @NotNull UiElement target, final @NotNull Gestur } } + // if there's already a transaction bound to the Scope (e.g. started manually by the user), we + // skip starting a new UI transaction: it would never be bound to the Scope in applyScope, would + // gather no children, and would be dropped as an idle transaction without children + final @Nullable ITransaction[] boundTransaction = {null}; + scopes.configureScope(scope -> boundTransaction[0] = scope.getTransaction()); + if (boundTransaction[0] != null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Transaction won't be created for view with id: %s since there's already a transaction bound to the Scope.", + viewIdentifier); + return; + } + // we can only bind to the scope if there's no running transaction final String name = getActivityName(activity) + "." + viewIdentifier; final String op = UI_ACTION + "." + getGestureType(eventType); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt index fe994f4a828..9d7606bfe44 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt @@ -160,6 +160,18 @@ class SentryGestureListenerTracingTest { sut.onSingleTapUp(fixture.event) } + @Test + fun `when a transaction is already bound to the Scope, does not start a new UI transaction`() { + val sut = fixture.getSut() + val boundTransaction = SentryTracer(TransactionContext("bound", "op"), fixture.scopes) + whenever(fixture.scope.transaction).thenReturn(boundTransaction) + + sut.onSingleTapUp(fixture.event) + + verify(fixture.scopes, never()).startTransaction(any(), any()) + assertEquals(false, boundTransaction.isFinished) + } + @Test fun `stopTracing remove transaction from scope`() { val sut = fixture.getSut() From 58b65f0fd57114f98e9f2bd4517e8ffae1d51e05 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 1 Jul 2026 11:48:59 +0200 Subject: [PATCH 056/195] chore: Add PR template checkbox for cross sdk review on public API changes (#5665) Add PR template checkbox for cross sdk review on public API changes --- .github/pull_request_template.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b337ac9ea4e..e4a12165077 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -25,6 +25,7 @@ - [ ] Review from the native team if needed. - [ ] No breaking change or entry added to the changelog. - [ ] No breaking change for hybrid SDKs or communicated to hybrid SDKs. +- [ ] Public API changes reviewed by another Mobile SDK team member or implemented according to the [develop docs](https://develop.sentry.dev/) spec. ## :crystal_ball: Next steps From d06126055527212a23f245ea8640d20b61bb5cd2 Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:32:56 -0700 Subject: [PATCH 057/195] fix: guard executor shutdown in BaseCaptureStrategy.stop() (#5627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: guard executor shutdown in BaseCaptureStrategy.stop() Each start/stop cycle leaked one SentryReplayPersister-* thread because stop() reset delegated properties (segmentTimestamp, currentReplayId) whose setters dispatch to persistingExecutor, initialising the lazy — but stop() never shut it down. Replace the lazy delegate with an explicit nullable holder so the executor is only created when actually needed and can be detected at stop() time. Call shutdownNow() (non-blocking) rather than the blocking shutdown() to avoid ANRs when stop() runs on the main thread. Fixes #5564 * style: apply spotless formatting * refactor(replay): move persistingExecutor ownership to ReplayIntegration Move persistingExecutor out of BaseCaptureStrategy and into ReplayIntegration, passing it as a constructor argument to CaptureStrategy subclasses. Shut it down in ReplayIntegration.close() alongside replayExecutor so executor lifecycle is managed in one place. * Fix leak in ReplayIntegration due to persisting executor not being shut down Add the persistingExecutor argument to SessionCaptureStrategy and BufferCaptureStrategy constructor calls in tests, and add changelog entry. Co-Authored-By: Claude Opus 4.6 (1M context) * Remove stray merge conflict marker from CHANGELOG.md Co-Authored-By: Claude Opus 4.6 (1M context) * Remove no-op leak test from SessionCaptureStrategyTest The test used a mocked executor that never spawned threads, so the thread-count assertion was always true regardless of the fix. The executor lifecycle is now owned by ReplayIntegration, not SessionCaptureStrategy, so the test belonged at the wrong layer. Co-Authored-By: Claude Opus 4.6 (1M context) * Add executor leak regression test to ReplayIntegrationTest Uses real ScheduledThreadPoolExecutor threads so the test actually fails if the shutdown in close() is removed. Co-Authored-By: Claude Opus 4.6 (1M context) * Use shutdownNow() for replay executors in close() to avoid ANR shutdown() calls awaitTermination() which blocks up to shutdownTimeoutMillis. Since close() can run on the main thread (via Sentry.close() from hybrid SDKs), this risks an ANR. shutdownNow() is non-blocking and sufficient at teardown. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Roman Zavarnitsyn Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../android/replay/ReplayIntegration.kt | 36 +++++++++++++++++-- .../replay/capture/BaseCaptureStrategy.kt | 19 +--------- .../replay/capture/BufferCaptureStrategy.kt | 8 +++-- .../replay/capture/SessionCaptureStrategy.kt | 11 +++++- .../replay/util/ReplayExecutorService.kt | 8 +++++ .../android/replay/ReplayIntegrationTest.kt | 27 ++++++++++++++ .../capture/BufferCaptureStrategyTest.kt | 6 ++++ .../capture/SessionCaptureStrategyTest.kt | 8 +++++ 9 files changed, 101 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73bb2ef396e..b2f260761b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Don't start a redundant UI interaction transaction when a transaction is already bound to the Scope ([#5491](https://github.com/getsentry/sentry-java/issues/5491)) - Previously, `SentryGestureListener` always started a UI transaction and only afterwards skipped binding it to the Scope when a manually-bound transaction already existed, leaving the new transaction to be dropped as an idle transaction without children. - Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) +- Fix memory leak in `ReplayIntegration` due to persisting executor not being shut down ([#5627](https://github.com/getsentry/sentry-java/pull/5627)) ### Performance diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 116ab45af06..612517438f6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -107,10 +107,17 @@ public class ReplayIntegration( private var gestureRecorder: GestureRecorder? = null private val random by lazy { Random() } internal val rootViewsSpy by lazy { RootViewsSpy.install() } - private val replayExecutor by lazy { + internal val lazyReplayExecutor = lazy { val delegate = Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) ReplayExecutorService(delegate, options) } + internal val replayExecutor by lazyReplayExecutor + internal val lazyPersistingExecutor = lazy { + val delegate = + Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) + ReplayExecutorService(delegate, options) + } + internal val persistingExecutor by lazyPersistingExecutor internal val isEnabled = AtomicBoolean(false) internal val isManualPause = AtomicBoolean(false) @@ -192,6 +199,7 @@ public class ReplayIntegration( scopes, dateProvider, replayExecutor, + persistingExecutor, replayCacheProvider, ) } else { @@ -201,6 +209,7 @@ public class ReplayIntegration( dateProvider, random, replayExecutor, + persistingExecutor, replayCacheProvider, ) } @@ -373,7 +382,20 @@ public class ReplayIntegration( recorder?.close() recorder = null rootViewsSpy.close() - replayExecutor.shutdown() + if (lazyReplayExecutor.isInitialized()) { + if (options.threadChecker.isMainThread) { + replayExecutor.gracefulShutdown() + } else { + replayExecutor.shutdown() + } + } + if (lazyPersistingExecutor.isInitialized()) { + if (options.threadChecker.isMainThread) { + persistingExecutor.gracefulShutdown() + } else { + persistingExecutor.shutdown() + } + } lifecycle.currentState = CLOSED } } @@ -554,4 +576,14 @@ public class ReplayIntegration( return ret } } + + private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayPersister-" + cnt++) + ret.setDaemon(true) + return ret + } + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index dab98ec4e24..6bb58c5e2a2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -25,7 +25,6 @@ import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.CaptureStrategy.Companion.createSegment import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.gestures.ReplayGestureConverter -import io.sentry.android.replay.util.ReplayExecutorService import io.sentry.android.replay.util.ReplayRunnable import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebEvent @@ -34,9 +33,7 @@ import java.io.File import java.util.Date import java.util.Deque import java.util.concurrent.ConcurrentLinkedDeque -import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.ThreadFactory import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference @@ -50,6 +47,7 @@ internal abstract class BaseCaptureStrategy( private val scopes: IScopes?, private val dateProvider: ICurrentDateProvider, protected val replayExecutor: ScheduledExecutorService, + protected val persistingExecutor: ScheduledExecutorService, private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null, ) : CaptureStrategy { internal companion object { @@ -58,11 +56,6 @@ internal abstract class BaseCaptureStrategy( private const val MAX_TRACE_IDS = 100 } - private val persistingExecutor: ScheduledExecutorService by lazy { - val delegate = - Executors.newSingleThreadScheduledExecutor(ReplayPersistingExecutorServiceThreadFactory()) - ReplayExecutorService(delegate, options) - } private val gestureConverter = ReplayGestureConverter(dateProvider) protected val isTerminating = AtomicBoolean(false) @@ -192,16 +185,6 @@ internal abstract class BaseCaptureStrategy( } } - private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory { - private var cnt = 0 - - override fun newThread(r: Runnable): Thread { - val ret = Thread(r, "SentryReplayPersister-" + cnt++) - ret.setDaemon(true) - return ret - } - } - private inline fun persistableAtomicNullable( initialValue: T? = null, propertyName: String, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 0eea2043bd8..0df8a642f63 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -33,6 +33,7 @@ internal class BufferCaptureStrategy( private val dateProvider: ICurrentDateProvider, private val random: Random, executor: ScheduledExecutorService, + persistingExecutor: ScheduledExecutorService, replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null, ) : BaseCaptureStrategy( @@ -40,6 +41,7 @@ internal class BufferCaptureStrategy( scopes, dateProvider, executor, + persistingExecutor, replayCacheProvider = replayCacheProvider, ) { // TODO: capture envelopes for buffered segments instead, but don't send them until buffer is @@ -150,8 +152,10 @@ internal class BufferCaptureStrategy( ) return this } - // we hand over replayExecutor to the new strategy to preserve order of execution - val captureStrategy = SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor) + // we hand over replayExecutor and persistingExecutor to the new strategy to preserve order of + // execution + val captureStrategy = + SessionCaptureStrategy(options, scopes, dateProvider, replayExecutor, persistingExecutor) captureStrategy.recorderConfig = recorderConfig captureStrategy.start( segmentId = currentSegment, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 4d3ee588f01..d62efb534cc 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -21,8 +21,17 @@ internal class SessionCaptureStrategy( private val scopes: IScopes?, private val dateProvider: ICurrentDateProvider, executor: ScheduledExecutorService, + persistingExecutor: ScheduledExecutorService, replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null, -) : BaseCaptureStrategy(options, scopes, dateProvider, executor, replayCacheProvider) { +) : + BaseCaptureStrategy( + options, + scopes, + dateProvider, + executor, + persistingExecutor, + replayCacheProvider, + ) { internal companion object { private const val TAG = "SessionCaptureStrategy" } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt index 31a3279d074..9e9491f516f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt @@ -57,6 +57,14 @@ internal class ReplayExecutorService( } } } + + fun gracefulShutdown() { + synchronized(this) { + if (!isShutdown) { + delegate.shutdown() + } + } + } } internal class ReplayRunnable(val taskName: String, delegate: Runnable) : Runnable by delegate diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 3df0c9f005f..61b5213e76f 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -754,6 +754,12 @@ class ReplayIntegrationTest { null } }, + mock { + whenever(mock.submit(any())).doAnswer { + (it.arguments[0] as Runnable).run() + null + } + }, ) { _ -> fixture.replayCache } @@ -1104,6 +1110,20 @@ class ReplayIntegrationTest { assertEquals(traceId, traceIdRegistered) } + @Test + fun `close shuts down replay executors`() { + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay = fixture.getSut(context) + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.stop() + replay.close() + + assertTrue(replay.replayExecutor.isShutdown) + assertTrue(replay.persistingExecutor.isShutdown) + } + private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy = SessionCaptureStrategy( options, @@ -1116,5 +1136,12 @@ class ReplayIntegrationTest { null } }, + persistingExecutor = + mock { + whenever(mock.submit(any())).doAnswer { + (it.arguments[0] as Runnable).run() + null + } + }, ) } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index 380e9b3ce75..b5048e856ff 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -111,6 +111,12 @@ class BufferCaptureStrategyTest { null } }, + mock { + whenever(it.submit(any())).doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + } + }, ) { _ -> replayCache } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index b5a00bc624b..dd9e6c6ce1d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -122,6 +122,14 @@ class SessionCaptureStrategyTest { .whenever(it) .submit(any()) }, + mock { + doAnswer { invocation -> + (invocation.arguments[0] as Runnable).run() + null + } + .whenever(it) + .submit(any()) + }, ) { _ -> replayCache } From 0980ed763492be856f205dabfea93f14e8942878 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 2 Jul 2026 10:18:42 +0200 Subject: [PATCH 058/195] perf(core): Drop per-instance lock from SentryId and SpanId (#5645) * perf(core): Drop per-instance lock from SentryId and SpanId (JAVA-589) SentryId and SpanId stored their string value behind a LazyEvaluator, which allocates an AutoClosableReentrantLock (a ReentrantLock with its internal Sync) plus a capturing lambda on every instance. Since one SentryId is created per event/transaction and one SpanId per span, this per-instance lock machinery is far heavier than the single String it guards, and the eager string-arg constructors gained no laziness at all. Replace the LazyEvaluator with a plain volatile String guarded by a double-checked synchronized(this) block. Eager constructors now assign the value directly; the no-arg and UUID constructors still defer UUID-string generation. Synchronization is retained because UUID generation is non-idempotent and two racing threads must not produce different ids. Follow-up to the SDK Overhead Reduction work (#5499). Co-Authored-By: Claude Opus 4.8 * changelog --------- Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 1 + sentry/src/main/java/io/sentry/SpanId.java | 31 ++++++++++----- .../java/io/sentry/protocol/SentryId.java | 39 ++++++++++++------- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f260761b3..2a9e71a0cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Probe class availability without initializing the class during SDK init ([#5635](https://github.com/getsentry/sentry-java/pull/5635)) - Avoid constructing an exception per view when resolving view ids during view-hierarchy and gesture capture ([#5631](https://github.com/getsentry/sentry-java/pull/5631)) - Start the frame metrics thread lazily on first collection instead of during SDK init ([#5641](https://github.com/getsentry/sentry-java/pull/5641)) +- Reduce `SentryId` and `SpanId` allocation overhead by replacing their per-instance `LazyEvaluator` (and its lock) with a lightweight lazily-generated `String`. ([#5645](https://github.com/getsentry/sentry-java/pull/5645)) ## 8.46.0 diff --git a/sentry/src/main/java/io/sentry/SpanId.java b/sentry/src/main/java/io/sentry/SpanId.java index fcc7f3a4f38..2048647f9f9 100644 --- a/sentry/src/main/java/io/sentry/SpanId.java +++ b/sentry/src/main/java/io/sentry/SpanId.java @@ -2,24 +2,35 @@ import static io.sentry.util.StringUtils.PROPER_NIL_UUID; -import io.sentry.util.LazyEvaluator; import java.io.IOException; import java.util.Objects; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; public final class SpanId implements JsonSerializable { public static final SpanId EMPTY_ID = new SpanId(PROPER_NIL_UUID.replace("-", "").substring(0, 16)); - private final @NotNull LazyEvaluator lazyValue; + private volatile @Nullable String value; public SpanId(final @NotNull String value) { - Objects.requireNonNull(value, "value is required"); - this.lazyValue = new LazyEvaluator<>(() -> value); + this.value = Objects.requireNonNull(value, "value is required"); } - public SpanId() { - this.lazyValue = new LazyEvaluator<>(SentryUUID::generateSpanId); + public SpanId() {} + + private @NotNull String getValue() { + String result = value; + if (result == null) { + synchronized (this) { + result = value; + if (result == null) { + result = SentryUUID.generateSpanId(); + value = result; + } + } + } + return result; } @Override @@ -27,17 +38,17 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SpanId spanId = (SpanId) o; - return lazyValue.getValue().equals(spanId.lazyValue.getValue()); + return getValue().equals(spanId.getValue()); } @Override public int hashCode() { - return lazyValue.getValue().hashCode(); + return getValue().hashCode(); } @Override public String toString() { - return lazyValue.getValue(); + return getValue(); } // JsonElementSerializer @@ -45,7 +56,7 @@ public String toString() { @Override public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { - writer.value(lazyValue.getValue()); + writer.value(getValue()); } // JsonElementDeserializer diff --git a/sentry/src/main/java/io/sentry/protocol/SentryId.java b/sentry/src/main/java/io/sentry/protocol/SentryId.java index a5bd7980c3f..8d85afe4639 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryId.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryId.java @@ -6,7 +6,6 @@ import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryUUID; -import io.sentry.util.LazyEvaluator; import io.sentry.util.StringUtils; import io.sentry.util.UUIDStringUtils; import java.io.IOException; @@ -19,19 +18,15 @@ public final class SentryId implements JsonSerializable { public static final SentryId EMPTY_ID = new SentryId(StringUtils.PROPER_NIL_UUID.replace("-", "")); - private final @NotNull LazyEvaluator lazyStringValue; + private volatile @Nullable String value; + private final @Nullable UUID uuid; public SentryId() { this((UUID) null); } public SentryId(@Nullable UUID uuid) { - if (uuid != null) { - this.lazyStringValue = - new LazyEvaluator<>(() -> normalize(UUIDStringUtils.toSentryIdString(uuid))); - } else { - this.lazyStringValue = new LazyEvaluator<>(SentryUUID::generateSentryId); - } + this.uuid = uuid; } public SentryId(final @NotNull String sentryIdString) { @@ -42,16 +37,30 @@ public SentryId(final @NotNull String sentryIdString) { + "or 36 characters long (completed UUID). Received: " + sentryIdString); } - if (normalized.length() == 36) { - this.lazyStringValue = new LazyEvaluator<>(() -> normalize(normalized)); - } else { - this.lazyStringValue = new LazyEvaluator<>(() -> normalized); + this.uuid = null; + this.value = normalized.length() == 36 ? normalized.replace("-", "") : normalized; + } + + private @NotNull String getValue() { + String result = value; + if (result == null) { + synchronized (this) { + result = value; + if (result == null) { + result = + uuid != null + ? normalize(UUIDStringUtils.toSentryIdString(uuid)) + : SentryUUID.generateSentryId(); + value = result; + } + } } + return result; } @Override public String toString() { - return lazyStringValue.getValue(); + return getValue(); } @Override @@ -59,12 +68,12 @@ public boolean equals(final @Nullable Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SentryId sentryId = (SentryId) o; - return lazyStringValue.getValue().equals(sentryId.lazyStringValue.getValue()); + return getValue().equals(sentryId.getValue()); } @Override public int hashCode() { - return lazyStringValue.getValue().hashCode(); + return getValue().hashCode(); } private @NotNull String normalize(@NotNull String uuidString) { From 30862fca3f9c52541d12e1d966e19f22ed22d402 Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:23:22 -0700 Subject: [PATCH 059/195] fix(compose): add isImportantForBounds() to SentryTagModifierNode for compose-ui 1.11+ (#5672) * fix(compose): add isImportantForBounds() to SentryTagModifierNode for compose-ui 1.11+ compose-ui 1.11 added SemanticsModifierNode.isImportantForBounds() as an abstract method. SentryTagModifierNode was compiled against compose-ui 1.6.x, where the method does not exist, so its bytecode lacks an implementation. When an accessibility client (TalkBack, UiAutomator, adb uiautomator dump) traverses the Compose semantics tree at runtime on 1.11+, the JVM cannot find the method and throws AbstractMethodError. Adding fun isImportantForBounds(): Boolean = false without the override keyword (since the method is absent from the 1.6.x compile-time dependency) places the method in the class bytecode. The JVM satisfies the abstract method requirement via signature matching at runtime. SentryTagModifierNode stores only a semantic tag with no layout/visual effect, so false is the correct return value. * fix formatting * chore(changelog): Add Changelog entry --------- Co-authored-by: Markus Hintersteiner --- CHANGELOG.md | 1 + .../kotlin/io/sentry/compose/SentryModifier.kt | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a9e71a0cf4..31c600cbcf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Previously, `SentryGestureListener` always started a UI transaction and only afterwards skipped binding it to the Scope when a manually-bound transaction already existed, leaving the new transaction to be dropped as an idle transaction without children. - Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) - Fix memory leak in `ReplayIntegration` due to persisting executor not being shut down ([#5627](https://github.com/getsentry/sentry-java/pull/5627)) +- Fix AbstractMethodError when compose-ui 1.11+ is used in combination with `Modifier.sentryTag()` or the Sentry Kotlin compiler plugin ([#5672](https://github.com/getsentry/sentry-java/pull/5672)) ### Performance diff --git a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt index 3fec407987b..787c66b3b0b 100644 --- a/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt +++ b/sentry-compose/src/androidMain/kotlin/io/sentry/compose/SentryModifier.kt @@ -53,5 +53,15 @@ public object SentryModifier { override fun SemanticsPropertyReceiver.applySemantics() { this[SentryTag] = tag } + + // SemanticsModifierNode.isImportantForBounds() was added as an abstract method in + // compose-ui 1.11. Classes compiled against earlier versions lack this method in + // their bytecode, which causes AbstractMethodError when the accessibility tree is + // traversed on 1.11+ runtimes. We can't use the `override` keyword here because + // the method doesn't exist in the compile-time dependency (compose-ui 1.6.x), but + // the JVM satisfies the abstract-method requirement at runtime via signature + // matching. SentryTagModifierNode only stores a semantic tag and has no visual + // effect on layout, so it is not important for bounds. + @Suppress("unused") fun isImportantForBounds(): Boolean = false } } From 4414d9f4cd5601bee4c95f72a868b2c1ddff1f80 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 2 Jul 2026 10:27:25 +0200 Subject: [PATCH 060/195] build: Remove global per-test JVM heap cap (#5671) * build: Remove global per-test JVM heap cap The minHeapSize/maxHeapSize cap in the root build.gradle.kts was applied to every module's test task. Most modules do not need it, so remove it and let tests use the JVM defaults. If a specific module turns out to require a larger heap, the cap can be re-added to that module only. Co-Authored-By: Claude Opus 4.8 * build: Restore per-test heap cap for sentry-android-core CI showed :sentry-android-core:testReleaseUnitTest fails with OutOfMemoryError (Robolectric loading the android-all jar) once the global cap is removed. Restore the 256m/2g cap for this module only, where it is actually needed. Co-Authored-By: Claude Opus 4.8 * build: Drop stale comment about root build.gradle.kts Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- build.gradle.kts | 4 ---- sentry-android-core/build.gradle.kts | 6 ++++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 93c82cd8c9a..2e334f43a65 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -103,10 +103,6 @@ allprojects { TestLogEvent.PASSED, TestLogEvent.FAILED ) - - // Cap JVM args per test - minHeapSize = "256m" - maxHeapSize = "2g" } withType().configureEach { options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing", "-Xlint:-try")) diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index f7440b19494..0388b7de486 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -40,6 +40,12 @@ android { unitTests.apply { isReturnDefaultValues = true isIncludeAndroidResources = true + // Robolectric loads the android-all jar into each test JVM, which needs more heap + // than the default. + all { + it.minHeapSize = "256m" + it.maxHeapSize = "2g" + } } } From ea2a517b565d0dc5c37a0e2f22f68324f0fc724f Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 2 Jul 2026 11:23:30 +0200 Subject: [PATCH 061/195] perf(core): Lazily allocate AutoClosableReentrantLock (JAVA-588) (#5643) * perf(core): Lazily allocate AutoClosableReentrantLock (JAVA-588) AutoClosableReentrantLock extended ReentrantLock, so every SDK object holding one allocated a ReentrantLock (and its AbstractQueuedSynchronizer) eagerly in its field initializer. A customer Perfetto trace showed ~81 such allocations on the main thread during SentryAndroid.init, many for locks that are never acquired during init. Hold the ReentrantLock internally and create it lazily on first acquire(), using an AtomicReferenceFieldUpdater CAS so creation stays atomic and Loom-friendly (no synchronized, preserving #3715). Every call site uses acquire() only, so dropping the ReentrantLock superclass touches no caller. Co-Authored-By: Claude Opus 4.8 (1M context) * changelog * perf(core): Harden lazy lock init and mark AutoClosableReentrantLock internal (JAVA-588) Replace the unreachable candidate fallback after a failed CAS with an explicit non-null check, so a broken invariant fails loudly instead of handing two threads different locks. Mark the class @ApiStatus.Internal and make the lazy-allocation test assert the lock field directly. Co-Authored-By: Claude Fable 5 * perf(core): Return the lock itself as the lifecycle token (JAVA-588) Every acquire() allocated a fresh lifecycle token, which is per-use garbage on every lock acquisition forever, not just at init. The token was stateless apart from its lock reference, so AutoClosableReentrantLock now implements ISentryLifecycleToken itself and acquire() returns this, making the steady-state acquire/close path allocation-free. Semantics are unchanged: try-with-resources closes once per acquire, so reentrant acquires stay balanced, and unlocking without holding the lock still throws IllegalMonitorStateException. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + sentry/api/sentry.api | 3 +- .../util/AutoClosableReentrantLock.java | 69 +++++++++++++++---- .../util/AutoClosableReentrantLockTest.kt | 58 ++++++++++++++++ 4 files changed, 116 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c600cbcf4..95a6a5b5f67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Avoid constructing an exception per view when resolving view ids during view-hierarchy and gesture capture ([#5631](https://github.com/getsentry/sentry-java/pull/5631)) - Start the frame metrics thread lazily on first collection instead of during SDK init ([#5641](https://github.com/getsentry/sentry-java/pull/5641)) - Reduce `SentryId` and `SpanId` allocation overhead by replacing their per-instance `LazyEvaluator` (and its lock) with a lightweight lazily-generated `String`. ([#5645](https://github.com/getsentry/sentry-java/pull/5645)) +- Lazily allocate the `ReentrantLock` backing `AutoClosableReentrantLock` to avoid eager lock allocations for SDK objects that never contend during `SentryAndroid.init` ([#5643](https://github.com/getsentry/sentry-java/pull/5643)) ## 8.46.0 diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 04c876fdbdb..383ea92b116 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7589,9 +7589,10 @@ public abstract class io/sentry/transport/TransportResult { public static fun success ()Lio/sentry/transport/TransportResult; } -public final class io/sentry/util/AutoClosableReentrantLock : java/util/concurrent/locks/ReentrantLock { +public final class io/sentry/util/AutoClosableReentrantLock : io/sentry/ISentryLifecycleToken { public fun ()V public fun acquire ()Lio/sentry/ISentryLifecycleToken; + public fun close ()V } public final class io/sentry/util/CheckInUtils { diff --git a/sentry/src/main/java/io/sentry/util/AutoClosableReentrantLock.java b/sentry/src/main/java/io/sentry/util/AutoClosableReentrantLock.java index 2a95a58b5fe..cf53d860e08 100644 --- a/sentry/src/main/java/io/sentry/util/AutoClosableReentrantLock.java +++ b/sentry/src/main/java/io/sentry/util/AutoClosableReentrantLock.java @@ -1,29 +1,70 @@ package io.sentry.util; import io.sentry.ISentryLifecycleToken; +import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; import java.util.concurrent.locks.ReentrantLock; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; -public final class AutoClosableReentrantLock extends ReentrantLock { +/** + * Hands out an {@link ISentryLifecycleToken} from {@link #acquire()} for use with + * try-with-resources (replacing {@code synchronized} blocks). + * + *

The underlying {@link ReentrantLock} is created lazily on the first {@link #acquire()}. Many + * SDK objects hold a lock but never contend on it (especially during {@code SentryAndroid.init}), + * so the eager allocation of a {@link ReentrantLock} (and its {@code AbstractQueuedSynchronizer}) + * was pure GC and main-thread overhead. We keep a {@link ReentrantLock} rather than reverting to + * {@code synchronized} to stay friendly to virtual threads (Loom), see #3715. + * + *

{@link #acquire()} returns this instance as the token, so the steady-state acquire/close path + * allocates nothing. Reentrant acquires stay balanced because try-with-resources calls {@link + * #close()} exactly once per acquire. + */ +@ApiStatus.Internal +public final class AutoClosableReentrantLock implements ISentryLifecycleToken { - private static final long serialVersionUID = -3283069816958445549L; + private static final @NotNull AtomicReferenceFieldUpdater< + AutoClosableReentrantLock, ReentrantLock> + LOCK_UPDATER = + AtomicReferenceFieldUpdater.newUpdater( + AutoClosableReentrantLock.class, ReentrantLock.class, "lock"); - public ISentryLifecycleToken acquire() { - lock(); - return new AutoClosableReentrantLockLifecycleToken(this); - } + private volatile @Nullable ReentrantLock lock; - static final class AutoClosableReentrantLockLifecycleToken implements ISentryLifecycleToken { + public @NotNull ISentryLifecycleToken acquire() { + getOrCreateLock().lock(); + return this; + } - private final @NotNull ReentrantLock lock; + @Override + public void close() { + Objects.requireNonNull(lock, "close() called before acquire()").unlock(); + } - AutoClosableReentrantLockLifecycleToken(final @NotNull ReentrantLock lock) { - this.lock = lock; + private @NotNull ReentrantLock getOrCreateLock() { + final @Nullable ReentrantLock existing = lock; + if (existing != null) { + return existing; } - - @Override - public void close() { - lock.unlock(); + final @NotNull ReentrantLock candidate = new ReentrantLock(); + if (LOCK_UPDATER.compareAndSet(this, null, candidate)) { + return candidate; } + // The CAS can only fail because another thread installed its lock first, and the field is + // never reset, so all callers end up contending on that same instance. + return Objects.requireNonNull(lock, "lock must have been set by the winning thread"); + } + + @TestOnly + boolean isLocked() { + final @Nullable ReentrantLock current = lock; + return current != null && current.isLocked(); + } + + @TestOnly + boolean isLockAllocated() { + return lock != null; } } diff --git a/sentry/src/test/java/io/sentry/util/AutoClosableReentrantLockTest.kt b/sentry/src/test/java/io/sentry/util/AutoClosableReentrantLockTest.kt index 4a69b9638e7..943a2c2bf70 100644 --- a/sentry/src/test/java/io/sentry/util/AutoClosableReentrantLockTest.kt +++ b/sentry/src/test/java/io/sentry/util/AutoClosableReentrantLockTest.kt @@ -1,7 +1,12 @@ package io.sentry.util +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertSame import kotlin.test.assertTrue class AutoClosableReentrantLockTest { @@ -11,4 +16,57 @@ class AutoClosableReentrantLockTest { lock.acquire().use { assertTrue(lock.isLocked) } assertFalse(lock.isLocked) } + + @Test + fun `acquire returns the lock itself as the token, allocating nothing`() { + val lock = AutoClosableReentrantLock() + lock.acquire().use { token -> assertSame(lock, token) } + } + + @Test + fun `does not allocate the underlying lock until first acquire`() { + val lock = AutoClosableReentrantLock() + assertFalse(lock.isLockAllocated) + lock.acquire().use {} + assertTrue(lock.isLockAllocated) + } + + @Test + fun `supports reentrant acquire from the same thread`() { + val lock = AutoClosableReentrantLock() + lock.acquire().use { + lock.acquire().use { assertTrue(lock.isLocked) } + assertTrue(lock.isLocked) + } + assertFalse(lock.isLocked) + } + + @Test + fun `mutually excludes concurrent threads`() { + val lock = AutoClosableReentrantLock() + val inCriticalSection = AtomicInteger(0) + val maxObserved = AtomicInteger(0) + val start = CountDownLatch(1) + val threadCount = 8 + val iterations = 1000 + val threads = + (0 until threadCount).map { + Thread { + start.await() + repeat(iterations) { + lock.acquire().use { + val current = inCriticalSection.incrementAndGet() + maxObserved.accumulateAndGet(current, ::maxOf) + inCriticalSection.decrementAndGet() + } + } + } + } + threads.forEach(Thread::start) + start.countDown() + threads.forEach { it.join(TimeUnit.SECONDS.toMillis(10)) } + + assertEquals(1, maxObserved.get()) + assertFalse(lock.isLocked) + } } From 844c3e85ea53e298315edb075db1fd39bfdd47f0 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 2 Jul 2026 11:24:49 +0200 Subject: [PATCH 062/195] build: Suppress obsolete Java 8 option warning under JDK 21+ (#5664) The root build compiles Java with -Xlint:all -Werror. On JDK 21+, javac flags -source/-target 8 as obsolete, and -Werror promotes that warning to an error, failing :sentry:compileJava. CI pins JDK 17, where the warning does not exist, so this only breaks local builds and tooling on newer JDKs (e.g. the Kotlin LSP's bundled JDK 25, whose Gradle project import aborts and loses cross-module resolution). Add -Xlint:-options, the suppression javac itself recommends when intentionally targeting an older release. Co-authored-by: Claude Opus 4.8 (1M context) --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 2e334f43a65..55b5a71a1e5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -105,7 +105,7 @@ allprojects { ) } withType().configureEach { - options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing", "-Xlint:-try")) + options.compilerArgs.addAll(arrayOf("-Xlint:all", "-Werror", "-Xlint:-classfile", "-Xlint:-processing", "-Xlint:-try", "-Xlint:-options")) } } } From ac08f86d4a15996d553eceef3d1cdfbbfe6bcc32 Mon Sep 17 00:00:00 2001 From: markushi <1411808+markushi@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:52:17 +0000 Subject: [PATCH 063/195] release: 8.47.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a6a5b5f67..5c43f0976ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.47.0 ### Behavioral Changes diff --git a/gradle.properties b/gradle.properties index 804e4b58573..4c0a1cffd1e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ android.useAndroidX=true android.experimental.lint.version=8.13.1 # Release information -versionName=8.46.0 +versionName=8.47.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From 7d8a3947cce374aa65ec6b9e702733ff89a0f29d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 Jul 2026 13:44:30 +0200 Subject: [PATCH 064/195] fix(android-fragment): support detach/attach navigation in fragment tracing (#5660) * fix(android-fragment): support detach/attach navigation in fragment tracing For detach/attach tab navigation (manual tab switching, ViewPager v1 with FragmentPagerAdapter, custom navigation frameworks), onFragmentCreated is skipped for off-screen fragments that are re-attached. Previously this left ui.load spans open until the 30s activity transaction deadline, producing inflated performance data. Fix by calling startTracing in onFragmentViewCreated as well as onFragmentCreated. startTracing is idempotent (no-op if a span is already running), so the normal onFragmentCreated -> onFragmentViewCreated path is unaffected. Add matching stopTracing calls in onFragmentResumed (covers the detach/attach path where onFragmentStarted may be skipped) and onFragmentViewDestroyed (failsafe for fragments destroyed before reaching STARTED or RESUMED). stopTracing is also idempotent, so the normal path is unaffected. Co-Authored-By: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> * Format code * Add changelog entry and detach/attach sample Co-Authored-By: Claude Opus 4.6 (1M context) * Remove duplicate test methods in fragment lifecycle test Co-Authored-By: Claude Opus 4.6 (1M context) * fix: Update screen name on scope for detach/attach fragment re-attachment onFragmentCreated is skipped during detach/attach navigation, so the screen name was never updated for re-attached fragments. Mirror the screen tracking into onFragmentViewCreated to cover that path. Co-Authored-By: Claude Opus 4.6 (1M context) * Format code * Address PR feedback: internalize guards into startTracing and fix sample layout Move isAdded check, screen tracking, and tracing logic into startTracing() to deduplicate guards from onFragmentCreated and onFragmentViewCreated. Fix DetachAttachTabsActivity sample rendering on API 35+ by using NoActionBar theme and fitsSystemWindows. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: Decouple screen tracking from performance tracing in fragments Screen name updates on scope should work independently of whether performance tracing is enabled. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Sentry Github Bot Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../SentryFragmentLifecycleCallbacks.kt | 39 ++++-- .../SentryFragmentLifecycleCallbacksTest.kt | 117 +++++++++++++++++- .../src/main/AndroidManifest.xml | 5 + .../android/DetachAttachTabsActivity.kt | 50 ++++++++ .../io/sentry/samples/android/MainActivity.kt | 12 ++ .../layout/activity_detach_attach_tabs.xml | 32 +++++ .../src/main/res/layout/fragment_tab.xml | 12 ++ 8 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt create mode 100644 sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml create mode 100644 sentry-samples/sentry-samples-android/src/main/res/layout/fragment_tab.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c43f0976ed..3a290e2b3e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- Fix fragment tracing not working with detach/attach navigation ([#5660](https://github.com/getsentry/sentry-java/pull/5660)) - Don't start a redundant UI interaction transaction when a transaction is already bound to the Scope ([#5491](https://github.com/getsentry/sentry-java/issues/5491)) - Previously, `SentryGestureListener` always started a UI transaction and only afterwards skipped binding it to the Scope when a manually-bound transaction already existed, leaving the new transaction to be dropped as an idle transaction without children. - Fix potential NPE within `Scope.endSession()` ([#5657](https://github.com/getsentry/sentry-java/pull/5657)) diff --git a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt index 230510fb4de..374713ba969 100644 --- a/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt +++ b/sentry-android-fragment/src/main/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacks.kt @@ -76,14 +76,7 @@ public class SentryFragmentLifecycleCallbacks( ) { addBreadcrumb(fragment, FragmentLifecycleState.CREATED) - // we only start the tracing for the fragment if the fragment has been added to its activity - // and not only to the backstack - if (fragment.isAdded) { - if (scopes.options.isEnableScreenTracking) { - scopes.configureScope { it.screen = getFragmentName(fragment) } - } - startTracing(fragment) - } + startTracing(fragment) } override fun onFragmentViewCreated( @@ -93,17 +86,30 @@ public class SentryFragmentLifecycleCallbacks( savedInstanceState: Bundle?, ) { addBreadcrumb(fragment, FragmentLifecycleState.VIEW_CREATED) + + // For detach/attach navigation (e.g. manual tab switching, ViewPager v1 with + // FragmentPagerAdapter, custom navigation frameworks), onFragmentCreated is never called for + // off-screen fragments that are re-attached. Starting here enables a narrower + // "view created -> resumed" span for those paths. startTracing is idempotent, so for the + // normal onFragmentCreated -> onFragmentViewCreated path this is a no-op. + startTracing(fragment) } override fun onFragmentStarted(fragmentManager: FragmentManager, fragment: Fragment) { addBreadcrumb(fragment, FragmentLifecycleState.STARTED) - // ViewPager2 locks background fragments to STARTED state + // ViewPager2 locks background fragments to STARTED state, so we stop here to avoid + // spans hanging for off-screen fragments that never reach RESUMED. stopTracing(fragment) } override fun onFragmentResumed(fragmentManager: FragmentManager, fragment: Fragment) { addBreadcrumb(fragment, FragmentLifecycleState.RESUMED) + + // For detach/attach navigation, onFragmentStarted may not fire before onFragmentResumed. + // If a span is still running here, stop it now. stopTracing is idempotent, so this is a + // no-op for the normal path where onFragmentStarted already stopped the span. + stopTracing(fragment) } override fun onFragmentPaused(fragmentManager: FragmentManager, fragment: Fragment) { @@ -116,6 +122,10 @@ public class SentryFragmentLifecycleCallbacks( override fun onFragmentViewDestroyed(fragmentManager: FragmentManager, fragment: Fragment) { addBreadcrumb(fragment, FragmentLifecycleState.VIEW_DESTROYED) + + // Failsafe: cancel any span that didn't finish via the normal started/resumed path + // (e.g. fragment view destroyed before reaching STARTED or RESUMED). + stopTracing(fragment) } override fun onFragmentDestroyed(fragmentManager: FragmentManager, fragment: Fragment) { @@ -153,6 +163,16 @@ public class SentryFragmentLifecycleCallbacks( fragmentsWithOngoingTransactions.containsKey(fragment) private fun startTracing(fragment: Fragment) { + if (!fragment.isAdded) { + return + } + + val fragmentName = getFragmentName(fragment) + + if (scopes.options.isEnableScreenTracking) { + scopes.configureScope { it.screen = fragmentName } + } + if (!isPerformanceEnabled || isRunningSpan(fragment)) { return } @@ -160,7 +180,6 @@ public class SentryFragmentLifecycleCallbacks( var transaction: ISpan? = null scopes.configureScope { transaction = it.transaction } - val fragmentName = getFragmentName(fragment) val span = transaction?.startChild(FRAGMENT_LOAD_OP, fragmentName) span?.let { diff --git a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt index 9446e1caef5..997c1206398 100644 --- a/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt +++ b/sentry-android-fragment/src/test/java/io/sentry/android/fragment/SentryFragmentLifecycleCallbacksTest.kt @@ -43,9 +43,15 @@ class SentryFragmentLifecycleCallbacksTest { enableAutoFragmentLifecycleTracing: Boolean = false, tracesSampleRate: Double? = 1.0, isAdded: Boolean = true, + enableScreenTracking: Boolean = false, ): SentryFragmentLifecycleCallbacks { whenever(scopes.options) - .thenReturn(SentryOptions().apply { setTracesSampleRate(tracesSampleRate) }) + .thenReturn( + SentryOptions().apply { + setTracesSampleRate(tracesSampleRate) + isEnableScreenTracking = enableScreenTracking + } + ) whenever(span.spanContext) .thenReturn(SpanContext(SentryId.EMPTY_ID, SpanId.EMPTY_ID, "op", null, null)) whenever(transaction.startChild(any(), any())).thenReturn(span) @@ -251,6 +257,115 @@ class SentryFragmentLifecycleCallbacksTest { verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) } + @Test + fun `When fragment view is created via detach-attach, it should start tracing if enabled`() { + // Simulates detach/attach navigation: onFragmentCreated is NOT called, only + // onFragmentViewCreated + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.transaction) + .startChild( + check { assertEquals(SentryFragmentLifecycleCallbacks.FRAGMENT_LOAD_OP, it) }, + check { assertEquals("androidx.fragment.app.Fragment", it) }, + ) + } + + @Test + fun `When fragment view is created via detach-attach, it should update screen name`() { + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true, enableScreenTracking = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.scope).screen = "androidx.fragment.app.Fragment" + } + + @Test + fun `When performance is disabled, it should still update screen name`() { + val sut = + fixture.getSut(enableAutoFragmentLifecycleTracing = false, enableScreenTracking = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.scope).screen = "androidx.fragment.app.Fragment" + verify(fixture.transaction, never()).startChild(any(), any()) + } + + @Test + fun `When fragment view is created after onFragmentCreated, it should not start a second span`() { + // Normal path: onFragmentCreated already started the span; onFragmentViewCreated is a no-op + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + + verify(fixture.transaction).startChild(any(), any()) + } + + @Test + fun `When fragment is resumed, it should stop tracing if span is still running`() { + // Simulates detach/attach path where onFragmentStarted may be skipped + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + sut.onFragmentResumed(fixture.fragmentManager, fixture.fragment) + + verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) + } + + @Test + fun `When fragment is resumed after started, it should not double-finish the span`() { + // Normal path: onFragmentStarted already stopped the span; onFragmentResumed is a no-op + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentCreated(fixture.fragmentManager, fixture.fragment, savedInstanceState = null) + sut.onFragmentStarted(fixture.fragmentManager, fixture.fragment) + sut.onFragmentResumed(fixture.fragmentManager, fixture.fragment) + + verify(fixture.span).finish(any()) + } + + @Test + fun `When fragment view is destroyed before started, it should stop tracing as failsafe`() { + val sut = fixture.getSut(enableAutoFragmentLifecycleTracing = true) + + sut.onFragmentViewCreated( + fixture.fragmentManager, + fixture.fragment, + view = mock(), + savedInstanceState = null, + ) + sut.onFragmentViewDestroyed(fixture.fragmentManager, fixture.fragment) + + verify(fixture.span).finish(check { assertEquals(SpanStatus.OK, it) }) + } + private fun verifyBreadcrumbAdded(expectedState: String) { verify(fixture.scopes) .addBreadcrumb( diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 1150dd5ef2e..d72087fbfa5 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -65,6 +65,11 @@ android:name=".ThirdActivityFragment" android:exported="false" /> + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt new file mode 100644 index 00000000000..3a38814c5d8 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/DetachAttachTabsActivity.kt @@ -0,0 +1,50 @@ +package io.sentry.samples.android + +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit + +class DetachAttachTabsActivity : AppCompatActivity(R.layout.activity_detach_attach_tabs) { + + private val tags = arrayOf("tab_a", "tab_b") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + findViewById(R.id.btn_tab_a).setOnClickListener { showTab(0) } + findViewById(R.id.btn_tab_b).setOnClickListener { showTab(1) } + + if (savedInstanceState == null) { + val tabB = TabFragmentB() + supportFragmentManager.commit { + add(R.id.tab_container, TabFragmentA(), tags[0]) + add(R.id.tab_container, tabB, tags[1]) + detach(tabB) + } + } + } + + private fun showTab(index: Int) { + supportFragmentManager.commit { + for (i in tags.indices) { + val frag = supportFragmentManager.findFragmentByTag(tags[i]) ?: continue + if (i == index) attach(frag) else detach(frag) + } + } + } +} + +class TabFragmentA : Fragment(R.layout.fragment_tab) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById(R.id.tab_label).text = "Tab A" + } +} + +class TabFragmentB : Fragment(R.layout.fragment_tab) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view.findViewById(R.id.tab_label).text = "Tab B" + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index b87e7a3190c..d53f7e4687f 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -794,6 +794,18 @@ fun IntegrationsScreen() { } } } + item { + SentryTraced("open_detach_attach_tabs") { + OutlinedButton( + onClick = { + activity.startActivity(Intent(activity, DetachAttachTabsActivity::class.java)) + }, + modifier = Modifier, + ) { + Text("Open Detach/Attach Tabs", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } item { SentryTraced("open_permissions_activity") { OutlinedButton( diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml new file mode 100644 index 00000000000..b2dc323d185 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_detach_attach_tabs.xml @@ -0,0 +1,32 @@ + + + + + +