diff --git a/sentry-android-core/.gitignore b/sentry-android-core/.gitignore index b91faf9e76f..4e4a8504480 100644 --- a/sentry-android-core/.gitignore +++ b/sentry-android-core/.gitignore @@ -1 +1,4 @@ INSTALLATION +last_crash +.options-cache/ +.scope-cache/ diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 8aa2350d0d7..9a8c0746795 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -55,6 +55,28 @@ public final class io/sentry/android/core/AnrIntegration : io/sentry/Integration public final fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V } +public final class io/sentry/android/core/AnrIntegrationFactory { + public fun ()V + public static fun create (Landroid/content/Context;Lio/sentry/android/core/BuildInfoProvider;)Lio/sentry/Integration; +} + +public final class io/sentry/android/core/AnrV2EventProcessor : io/sentry/BackfillingEventProcessor { + public fun (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/BuildInfoProvider;)V + public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; +} + +public class io/sentry/android/core/AnrV2Integration : io/sentry/Integration, java/io/Closeable { + public fun (Landroid/content/Context;)V + public fun close ()V + public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V +} + +public final class io/sentry/android/core/AnrV2Integration$AnrV2Hint : io/sentry/hints/BlockingFlushHint, io/sentry/hints/Backfillable { + public fun (JLio/sentry/ILogger;JZ)V + public fun shouldEnrich ()Z + public fun timestamp ()J +} + public final class io/sentry/android/core/AppComponentsBreadcrumbsIntegration : android/content/ComponentCallbacks2, io/sentry/Integration, java/io/Closeable { public fun (Landroid/content/Context;)V public fun close ()V @@ -271,9 +293,11 @@ public final class io/sentry/android/core/ViewHierarchyEventProcessor : io/sentr } public final class io/sentry/android/core/cache/AndroidEnvelopeCache : io/sentry/cache/EnvelopeCache { + public static final field LAST_ANR_REPORT Ljava/lang/String; public fun (Lio/sentry/android/core/SentryAndroidOptions;)V public fun getDirectory ()Ljava/io/File; public static fun hasStartupCrashMarker (Lio/sentry/SentryOptions;)Z + public static fun lastReportedAnr (Lio/sentry/SentryOptions;)J public fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 16465c8b783..c3a1578bc47 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -19,6 +19,8 @@ import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.fragment.FragmentLifecycleIntegration; import io.sentry.android.timber.SentryTimberIntegration; +import io.sentry.cache.PersistingOptionsObserver; +import io.sentry.cache.PersistingScopeObserver; import io.sentry.compose.gestures.ComposeGestureTargetLocator; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.transport.NoOpEnvelopeCache; @@ -139,6 +141,7 @@ static void initializeIntegrationsAndProcessors( options.addEventProcessor(new PerformanceAndroidEventProcessor(options, activityFramesTracker)); options.addEventProcessor(new ScreenshotEventProcessor(options, buildInfoProvider)); options.addEventProcessor(new ViewHierarchyEventProcessor(options)); + options.addEventProcessor(new AnrV2EventProcessor(context, options, buildInfoProvider)); options.setTransportGate(new AndroidTransportGate(context, options.getLogger())); final SentryFrameMetricsCollector frameMetricsCollector = new SentryFrameMetricsCollector(context, options, buildInfoProvider); @@ -170,6 +173,11 @@ static void initializeIntegrationsAndProcessors( options.addCollector(new AndroidCpuCollector(options.getLogger(), buildInfoProvider)); } options.setTransactionPerformanceCollector(new DefaultTransactionPerformanceCollector(options)); + + if (options.getCacheDirPath() != null) { + options.addScopeObserver(new PersistingScopeObserver(options)); + options.addOptionsObserver(new PersistingOptionsObserver(options)); + } } private static void installDefaultIntegrations( @@ -213,7 +221,7 @@ private static void installDefaultIntegrations( // AppLifecycleIntegration has to be installed before AnrIntegration, because AnrIntegration // relies on AppState set by it options.addIntegration(new AppLifecycleIntegration()); - options.addIntegration(new AnrIntegration(context)); + options.addIntegration(AnrIntegrationFactory.create(context, buildInfoProvider)); // registerActivityLifecycleCallbacks is only available if Context is an AppContext if (context instanceof Application) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegrationFactory.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegrationFactory.java new file mode 100644 index 00000000000..cff8f72cd36 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegrationFactory.java @@ -0,0 +1,21 @@ +package io.sentry.android.core; + +import android.content.Context; +import android.os.Build; +import io.sentry.Integration; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public final class AnrIntegrationFactory { + + @NotNull + public static Integration create( + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.R) { + return new AnrV2Integration(context); + } else { + return new AnrIntegration(context); + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java new file mode 100644 index 00000000000..80d8faaf9d1 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -0,0 +1,563 @@ +package io.sentry.android.core; + +import static io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME; +import static io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME; +import static io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME; +import static io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME; +import static io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME; +import static io.sentry.cache.PersistingScopeObserver.USER_FILENAME; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.content.Context; +import android.content.pm.PackageInfo; +import android.os.Build; +import android.util.DisplayMetrics; +import androidx.annotation.WorkerThread; +import io.sentry.BackfillingEventProcessor; +import io.sentry.Breadcrumb; +import io.sentry.Hint; +import io.sentry.IpAddressUtils; +import io.sentry.SentryBaseEvent; +import io.sentry.SentryEvent; +import io.sentry.SentryExceptionFactory; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.SentryStackTraceFactory; +import io.sentry.SpanContext; +import io.sentry.cache.PersistingOptionsObserver; +import io.sentry.cache.PersistingScopeObserver; +import io.sentry.hints.Backfillable; +import io.sentry.protocol.App; +import io.sentry.protocol.Contexts; +import io.sentry.protocol.DebugImage; +import io.sentry.protocol.DebugMeta; +import io.sentry.protocol.Device; +import io.sentry.protocol.OperatingSystem; +import io.sentry.protocol.Request; +import io.sentry.protocol.SdkVersion; +import io.sentry.protocol.User; +import io.sentry.util.HintUtils; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * AnrV2Integration processes events on a background thread, hence the event processors will also be + * invoked on the same background thread, so we can safely read data from disk synchronously. + */ +@ApiStatus.Internal +@WorkerThread +public final class AnrV2EventProcessor implements BackfillingEventProcessor { + + /** + * Default value for {@link SentryEvent#getEnvironment()} set when both event and {@link + * SentryOptions} do not have the environment field set. + */ + static final String DEFAULT_ENVIRONMENT = "production"; + + private final @NotNull Context context; + + private final @NotNull SentryAndroidOptions options; + + private final @NotNull BuildInfoProvider buildInfoProvider; + + private final @NotNull SentryExceptionFactory sentryExceptionFactory; + + public AnrV2EventProcessor( + final @NotNull Context context, + final @NotNull SentryAndroidOptions options, + final @NotNull BuildInfoProvider buildInfoProvider) { + this.context = context; + this.options = options; + this.buildInfoProvider = buildInfoProvider; + + final SentryStackTraceFactory sentryStackTraceFactory = + new SentryStackTraceFactory( + this.options.getInAppExcludes(), this.options.getInAppIncludes()); + + sentryExceptionFactory = new SentryExceptionFactory(sentryStackTraceFactory); + } + + @Override + public @Nullable SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { + final Object unwrappedHint = HintUtils.getSentrySdkHint(hint); + if (!(unwrappedHint instanceof Backfillable)) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "The event is not Backfillable, but has been passed to BackfillingEventProcessor, skipping."); + return event; + } + + // we always set exception values, platform, os and device even if the ANR is not enrich-able + // even though the OS context may change in the meantime (OS update), we consider this an + // edge-case + setExceptions(event); + setPlatform(event); + mergeOS(event); + setDevice(event); + + if (!((Backfillable) unwrappedHint).shouldEnrich()) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "The event is Backfillable, but should not be enriched, skipping."); + return event; + } + + backfillScope(event); + + backfillOptions(event); + + setStaticValues(event); + + return event; + } + + // region scope persisted values + private void backfillScope(final @NotNull SentryEvent event) { + setRequest(event); + setUser(event); + setScopeTags(event); + setBreadcrumbs(event); + setExtras(event); + setContexts(event); + setTransaction(event); + setFingerprints(event); + setLevel(event); + setTrace(event); + } + + private void setTrace(final @NotNull SentryEvent event) { + final SpanContext spanContext = + PersistingScopeObserver.read(options, TRACE_FILENAME, SpanContext.class); + if (event.getContexts().getTrace() == null && spanContext != null) { + event.getContexts().setTrace(spanContext); + } + } + + private void setLevel(final @NotNull SentryEvent event) { + final SentryLevel level = + PersistingScopeObserver.read(options, LEVEL_FILENAME, SentryLevel.class); + if (event.getLevel() == null) { + event.setLevel(level); + } + } + + @SuppressWarnings("unchecked") + private void setFingerprints(final @NotNull SentryEvent event) { + final List fingerprint = + (List) PersistingScopeObserver.read(options, FINGERPRINT_FILENAME, List.class); + if (event.getFingerprints() == null) { + event.setFingerprints(fingerprint); + } + } + + private void setTransaction(final @NotNull SentryEvent event) { + final String transaction = + PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String.class); + if (event.getTransaction() == null) { + event.setTransaction(transaction); + } + } + + private void setContexts(final @NotNull SentryBaseEvent event) { + final Contexts persistedContexts = + PersistingScopeObserver.read(options, CONTEXTS_FILENAME, Contexts.class); + if (persistedContexts == null) { + return; + } + final Contexts eventContexts = event.getContexts(); + for (Map.Entry entry : new Contexts(persistedContexts).entrySet()) { + if (!eventContexts.containsKey(entry.getKey())) { + eventContexts.put(entry.getKey(), entry.getValue()); + } + } + } + + @SuppressWarnings("unchecked") + private void setExtras(final @NotNull SentryBaseEvent event) { + final Map extras = + (Map) PersistingScopeObserver.read(options, EXTRAS_FILENAME, Map.class); + if (extras == null) { + return; + } + if (event.getExtras() == null) { + event.setExtras(new HashMap<>(extras)); + } else { + for (Map.Entry item : extras.entrySet()) { + if (!event.getExtras().containsKey(item.getKey())) { + event.getExtras().put(item.getKey(), item.getValue()); + } + } + } + } + + @SuppressWarnings("unchecked") + private void setBreadcrumbs(final @NotNull SentryBaseEvent event) { + final List breadcrumbs = + (List) + PersistingScopeObserver.read( + options, BREADCRUMBS_FILENAME, List.class, new Breadcrumb.Deserializer()); + if (breadcrumbs == null) { + return; + } + if (event.getBreadcrumbs() == null) { + event.setBreadcrumbs(new ArrayList<>(breadcrumbs)); + } else { + event.getBreadcrumbs().addAll(breadcrumbs); + } + } + + @SuppressWarnings("unchecked") + private void setScopeTags(final @NotNull SentryBaseEvent event) { + final Map tags = + (Map) + PersistingScopeObserver.read(options, PersistingScopeObserver.TAGS_FILENAME, Map.class); + if (tags == null) { + return; + } + if (event.getTags() == null) { + event.setTags(new HashMap<>(tags)); + } else { + for (Map.Entry item : tags.entrySet()) { + if (!event.getTags().containsKey(item.getKey())) { + event.setTag(item.getKey(), item.getValue()); + } + } + } + } + + private void setUser(final @NotNull SentryBaseEvent event) { + if (event.getUser() == null) { + final User user = PersistingScopeObserver.read(options, USER_FILENAME, User.class); + event.setUser(user); + } + } + + private void setRequest(final @NotNull SentryBaseEvent event) { + if (event.getRequest() == null) { + final Request request = + PersistingScopeObserver.read(options, REQUEST_FILENAME, Request.class); + event.setRequest(request); + } + } + + // endregion + + // region options persisted values + private void backfillOptions(final @NotNull SentryEvent event) { + setRelease(event); + setEnvironment(event); + setDist(event); + setDebugMeta(event); + setSdk(event); + setApp(event); + setOptionsTags(event); + } + + private void setApp(final @NotNull SentryBaseEvent event) { + App app = event.getContexts().getApp(); + if (app == null) { + app = new App(); + } + app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); + + final PackageInfo packageInfo = + ContextUtils.getPackageInfo(context, options.getLogger(), buildInfoProvider); + if (packageInfo != null) { + app.setAppIdentifier(packageInfo.packageName); + } + + // backfill versionName and versionCode from the persisted release string + final String release = + event.getRelease() != null + ? event.getRelease() + : PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); + if (release != null) { + try { + final String versionName = + release.substring(release.indexOf('@') + 1, release.indexOf('+')); + final String versionCode = release.substring(release.indexOf('+') + 1); + app.setAppVersion(versionName); + app.setAppBuild(versionCode); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to parse release from scope cache: %s", release); + } + } + + event.getContexts().setApp(app); + } + + private void setRelease(final @NotNull SentryBaseEvent event) { + if (event.getRelease() == null) { + final String release = + PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); + event.setRelease(release); + } + } + + private void setEnvironment(final @NotNull SentryBaseEvent event) { + if (event.getEnvironment() == null) { + final String environment = + PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String.class); + event.setEnvironment(environment != null ? environment : DEFAULT_ENVIRONMENT); + } + } + + private void setDebugMeta(final @NotNull SentryBaseEvent event) { + DebugMeta debugMeta = event.getDebugMeta(); + + if (debugMeta == null) { + debugMeta = new DebugMeta(); + } + if (debugMeta.getImages() == null) { + debugMeta.setImages(new ArrayList<>()); + } + List images = debugMeta.getImages(); + if (images != null) { + final String proguardUuid = + PersistingOptionsObserver.read(options, PROGUARD_UUID_FILENAME, String.class); + + final DebugImage debugImage = new DebugImage(); + debugImage.setType(DebugImage.PROGUARD); + debugImage.setUuid(proguardUuid); + images.add(debugImage); + event.setDebugMeta(debugMeta); + } + } + + private void setDist(final @NotNull SentryBaseEvent event) { + if (event.getDist() == null) { + final String dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String.class); + event.setDist(dist); + } + // if there's no user-set dist, fall back to versionCode from the persisted release string + if (event.getDist() == null) { + final String release = + PersistingOptionsObserver.read(options, RELEASE_FILENAME, String.class); + if (release != null) { + try { + final String versionCode = release.substring(release.indexOf('+') + 1); + event.setDist(versionCode); + } catch (Throwable e) { + options + .getLogger() + .log(SentryLevel.ERROR, "Failed to parse release from scope cache: %s", release); + } + } + } + } + + private void setSdk(final @NotNull SentryBaseEvent event) { + if (event.getSdk() == null) { + final SdkVersion sdkVersion = + PersistingOptionsObserver.read(options, SDK_VERSION_FILENAME, SdkVersion.class); + event.setSdk(sdkVersion); + } + } + + @SuppressWarnings("unchecked") + private void setOptionsTags(final @NotNull SentryBaseEvent event) { + final Map tags = + (Map) + PersistingOptionsObserver.read( + options, PersistingOptionsObserver.TAGS_FILENAME, Map.class); + if (tags == null) { + return; + } + if (event.getTags() == null) { + event.setTags(new HashMap<>(tags)); + } else { + for (Map.Entry item : tags.entrySet()) { + if (!event.getTags().containsKey(item.getKey())) { + event.setTag(item.getKey(), item.getValue()); + } + } + } + } + // endregion + + // region static values + private void setStaticValues(final @NotNull SentryEvent event) { + mergeUser(event); + setSideLoadedInfo(event); + } + + private void setPlatform(final @NotNull SentryBaseEvent event) { + if (event.getPlatform() == null) { + // this actually means JVM related. + event.setPlatform(SentryBaseEvent.DEFAULT_PLATFORM); + } + } + + private void setExceptions(final @NotNull SentryEvent event) { + final Throwable throwable = event.getThrowableMechanism(); + if (throwable != null) { + event.setExceptions(sentryExceptionFactory.getSentryExceptions(throwable)); + } + } + + private void mergeUser(final @NotNull SentryBaseEvent event) { + if (options.isSendDefaultPii()) { + if (event.getUser() == null) { + final User user = new User(); + user.setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); + event.setUser(user); + } else if (event.getUser().getIpAddress() == null) { + event.getUser().setIpAddress(IpAddressUtils.DEFAULT_IP_ADDRESS); + } + } + + // userId should be set even if event is Cached as the userId is static and won't change anyway. + final User user = event.getUser(); + if (user == null) { + event.setUser(getDefaultUser()); + } else if (user.getId() == null) { + user.setId(getDeviceId()); + } + } + + /** + * Sets the default user which contains only the userId. + * + * @return the User object + */ + private @NotNull User getDefaultUser() { + User user = new User(); + user.setId(getDeviceId()); + + return user; + } + + private @Nullable String getDeviceId() { + try { + return Installation.id(context); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting installationId.", e); + } + return null; + } + + private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { + try { + final Map sideLoadedInfo = + ContextUtils.getSideLoadedInfo(context, options.getLogger(), buildInfoProvider); + + if (sideLoadedInfo != null) { + for (final Map.Entry entry : sideLoadedInfo.entrySet()) { + event.setTag(entry.getKey(), entry.getValue()); + } + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting side loaded info.", e); + } + } + + private void setDevice(final @NotNull SentryBaseEvent event) { + if (event.getContexts().getDevice() == null) { + event.getContexts().setDevice(getDevice()); + } + } + + // only use static data that does not change between app launches (e.g. timezone, boottime, + // battery level will change) + @SuppressLint("NewApi") + private @NotNull Device getDevice() { + Device device = new Device(); + if (options.isSendDefaultPii()) { + device.setName(ContextUtils.getDeviceName(context, buildInfoProvider)); + } + device.setManufacturer(Build.MANUFACTURER); + device.setBrand(Build.BRAND); + device.setFamily(ContextUtils.getFamily(options.getLogger())); + device.setModel(Build.MODEL); + device.setModelId(Build.ID); + device.setArchs(ContextUtils.getArchitectures(buildInfoProvider)); + + final ActivityManager.MemoryInfo memInfo = + ContextUtils.getMemInfo(context, options.getLogger()); + if (memInfo != null) { + // in bytes + device.setMemorySize(getMemorySize(memInfo)); + } + + device.setSimulator(buildInfoProvider.isEmulator()); + + DisplayMetrics displayMetrics = ContextUtils.getDisplayMetrics(context, options.getLogger()); + if (displayMetrics != null) { + device.setScreenWidthPixels(displayMetrics.widthPixels); + device.setScreenHeightPixels(displayMetrics.heightPixels); + device.setScreenDensity(displayMetrics.density); + device.setScreenDpi(displayMetrics.densityDpi); + } + + if (device.getId() == null) { + device.setId(getDeviceId()); + } + + return device; + } + + @SuppressLint("NewApi") + private @NotNull Long getMemorySize(final @NotNull ActivityManager.MemoryInfo memInfo) { + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN) { + return memInfo.totalMem; + } + // using Runtime as a fallback + return java.lang.Runtime.getRuntime().totalMemory(); // JVM in bytes too + } + + private void mergeOS(final @NotNull SentryBaseEvent event) { + final OperatingSystem currentOS = event.getContexts().getOperatingSystem(); + final OperatingSystem androidOS = getOperatingSystem(); + + // make Android OS the main OS using the 'os' key + event.getContexts().setOperatingSystem(androidOS); + + if (currentOS != null) { + // add additional OS which was already part of the SentryEvent (eg Linux read from NDK) + String osNameKey = currentOS.getName(); + if (osNameKey != null && !osNameKey.isEmpty()) { + osNameKey = "os_" + osNameKey.trim().toLowerCase(Locale.ROOT); + } else { + osNameKey = "os_1"; + } + event.getContexts().put(osNameKey, currentOS); + } + } + + private @NotNull OperatingSystem getOperatingSystem() { + OperatingSystem os = new OperatingSystem(); + os.setName("Android"); + os.setVersion(Build.VERSION.RELEASE); + os.setBuild(Build.DISPLAY); + + try { + os.setKernelVersion(ContextUtils.getKernelVersion(options.getLogger())); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error getting OperatingSystem.", e); + } + + return os; + } + // endregion +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java new file mode 100644 index 00000000000..e62f4088b32 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -0,0 +1,276 @@ +package io.sentry.android.core; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.app.ApplicationExitInfo; +import android.content.Context; +import android.os.Looper; +import io.sentry.DateUtils; +import io.sentry.Hint; +import io.sentry.IHub; +import io.sentry.ILogger; +import io.sentry.Integration; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.android.core.cache.AndroidEnvelopeCache; +import io.sentry.exception.ExceptionMechanismException; +import io.sentry.hints.Backfillable; +import io.sentry.hints.BlockingFlushHint; +import io.sentry.protocol.Mechanism; +import io.sentry.protocol.SentryId; +import io.sentry.transport.CurrentDateProvider; +import io.sentry.transport.ICurrentDateProvider; +import io.sentry.util.HintUtils; +import io.sentry.util.Objects; +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressLint("NewApi") // we check this in AnrIntegrationFactory +public class AnrV2Integration implements Integration, Closeable { + + // using 91 to avoid timezone change hassle, 90 days is how long Sentry keeps the events + static final long NINETY_DAYS_THRESHOLD = TimeUnit.DAYS.toMillis(91); + + private final @NotNull Context context; + private final @NotNull ICurrentDateProvider dateProvider; + private @Nullable SentryAndroidOptions options; + + public AnrV2Integration(final @NotNull Context context) { + // using CurrentDateProvider instead of AndroidCurrentDateProvider as AppExitInfo uses + // System.currentTimeMillis + this(context, CurrentDateProvider.getInstance()); + } + + AnrV2Integration( + final @NotNull Context context, final @NotNull ICurrentDateProvider dateProvider) { + this.context = context; + this.dateProvider = dateProvider; + } + + @SuppressLint("NewApi") // we do the check in the AnrIntegrationFactory + @Override + public void register(@NotNull IHub hub, @NotNull SentryOptions options) { + this.options = + Objects.requireNonNull( + (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, + "SentryAndroidOptions is required"); + + this.options + .getLogger() + .log(SentryLevel.DEBUG, "AnrIntegration enabled: %s", this.options.isAnrEnabled()); + + if (this.options.getCacheDirPath() == null) { + this.options + .getLogger() + .log(SentryLevel.INFO, "Cache dir is not set, unable to process ANRs"); + return; + } + + if (this.options.isAnrEnabled()) { + final ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + + List applicationExitInfoList = + activityManager.getHistoricalProcessExitReasons(null, 0, 0); + + if (applicationExitInfoList.size() != 0) { + options + .getExecutorService() + .submit( + new AnrProcessor( + new ArrayList<>( + applicationExitInfoList), // just making a deep copy to be safe, as we're + // modifying the list + hub, + this.options, + dateProvider)); + } else { + options.getLogger().log(SentryLevel.DEBUG, "No records in historical exit reasons."); + } + options.getLogger().log(SentryLevel.DEBUG, "AnrV2Integration installed."); + addIntegrationToSdkVersion(); + } + } + + @Override + public void close() throws IOException { + if (options != null) { + options.getLogger().log(SentryLevel.DEBUG, "AnrV2Integration removed."); + } + } + + static class AnrProcessor implements Runnable { + + final @NotNull List exitInfos; + private final @NotNull IHub hub; + private final @NotNull SentryAndroidOptions options; + private final long threshold; + + AnrProcessor( + final @NotNull List exitInfos, + final @NotNull IHub hub, + final @NotNull SentryAndroidOptions options, + final @NotNull ICurrentDateProvider dateProvider) { + this.exitInfos = exitInfos; + this.hub = hub; + this.options = options; + this.threshold = dateProvider.getCurrentTimeMillis() - NINETY_DAYS_THRESHOLD; + } + + @SuppressLint("NewApi") // we check this in AnrIntegrationFactory + @Override + public void run() { + final long lastReportedAnrTimestamp = AndroidEnvelopeCache.lastReportedAnr(options); + + // search for the latest ANR to report it separately as we're gonna enrich it. The latest + // ANR will be first in the list, as it's filled last-to-first in order of appearance + ApplicationExitInfo latestAnr = null; + for (ApplicationExitInfo applicationExitInfo : exitInfos) { + if (applicationExitInfo.getReason() == ApplicationExitInfo.REASON_ANR) { + latestAnr = applicationExitInfo; + // remove it, so it's not reported twice + exitInfos.remove(applicationExitInfo); + break; + } + } + + if (latestAnr == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "No ANRs have been found in the historical exit reasons list."); + return; + } + + if (latestAnr.getTimestamp() < threshold) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Latest ANR happened too long ago, returning early."); + return; + } + + if (latestAnr.getTimestamp() <= lastReportedAnrTimestamp) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Latest ANR has already been reported, returning early."); + return; + } + + // report the remainder without enriching + reportNonEnrichedHistoricalAnrs(exitInfos, lastReportedAnrTimestamp); + + // report the latest ANR with enriching, if contexts are available, otherwise report it + // non-enriched + reportAsSentryEvent(latestAnr, true); + } + + private void reportNonEnrichedHistoricalAnrs( + final @NotNull List exitInfos, final long lastReportedAnr) { + // we reverse the list, because the OS puts errors in order of appearance, last-to-first + // and we want to write a marker file after each ANR has been processed, so in case the app + // gets killed meanwhile, we can proceed from the last reported ANR and not process the entire + // list again + Collections.reverse(exitInfos); + for (ApplicationExitInfo applicationExitInfo : exitInfos) { + if (applicationExitInfo.getReason() == ApplicationExitInfo.REASON_ANR) { + if (applicationExitInfo.getTimestamp() < threshold) { + options + .getLogger() + .log(SentryLevel.DEBUG, "ANR happened too long ago %s.", applicationExitInfo); + continue; + } + + if (applicationExitInfo.getTimestamp() <= lastReportedAnr) { + options + .getLogger() + .log(SentryLevel.DEBUG, "ANR has already been reported %s.", applicationExitInfo); + continue; + } + + reportAsSentryEvent(applicationExitInfo, false); // do not enrich past events + } + } + } + + private void reportAsSentryEvent( + final @NotNull ApplicationExitInfo exitInfo, final boolean shouldEnrich) { + final long anrTimestamp = exitInfo.getTimestamp(); + final Throwable anrThrowable = buildAnrThrowable(exitInfo); + final AnrV2Hint anrHint = + new AnrV2Hint( + options.getFlushTimeoutMillis(), options.getLogger(), anrTimestamp, shouldEnrich); + + final Hint hint = HintUtils.createWithTypeCheckHint(anrHint); + + final SentryEvent event = new SentryEvent(anrThrowable); + event.setTimestamp(DateUtils.getDateTime(anrTimestamp)); + event.setLevel(SentryLevel.FATAL); + + final @NotNull SentryId sentryId = hub.captureEvent(event, hint); + final boolean isEventDropped = sentryId.equals(SentryId.EMPTY_ID); + if (!isEventDropped) { + // Block until the event is flushed to disk and the last_reported_anr marker is updated + if (!anrHint.waitFlush()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Timed out waiting to flush ANR event to disk. Event: %s", + event.getEventId()); + } + } + } + + private @NotNull Throwable buildAnrThrowable(final @NotNull ApplicationExitInfo exitInfo) { + final boolean isBackground = + exitInfo.getImportance() != ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; + + String message = "ANR"; + if (isBackground) { + message = "Background " + message; + } + + // TODO: here we should actually parse the trace file and extract the thread dump from there + // and then we could properly get the main thread stracktrace and construct a proper exception + final ApplicationNotResponding error = + new ApplicationNotResponding(message, Looper.getMainLooper().getThread()); + final Mechanism mechanism = new Mechanism(); + mechanism.setType("ANRv2"); + return new ExceptionMechanismException(mechanism, error, error.getThread(), true); + } + } + + @ApiStatus.Internal + public static final class AnrV2Hint extends BlockingFlushHint implements Backfillable { + + private final long timestamp; + + private final boolean shouldEnrich; + + public AnrV2Hint( + final long flushTimeoutMillis, + final @NotNull ILogger logger, + final long timestamp, + final boolean shouldEnrich) { + super(flushTimeoutMillis, logger); + this.timestamp = timestamp; + this.shouldEnrich = shouldEnrich; + } + + public long timestamp() { + return timestamp; + } + + @Override + public boolean shouldEnrich() { + return shouldEnrich; + } + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index d75b384165a..d43dde9471c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -1,6 +1,7 @@ package io.sentry.android.core; import static android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; +import static android.content.Context.ACTIVITY_SERVICE; import android.annotation.SuppressLint; import android.app.ActivityManager; @@ -10,9 +11,17 @@ import android.content.pm.PackageManager; import android.os.Build; import android.os.Process; +import android.provider.Settings; +import android.util.DisplayMetrics; import io.sentry.ILogger; import io.sentry.SentryLevel; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -151,4 +160,168 @@ static boolean isForegroundImportance(final @NotNull Context context) { } return false; } + + /** + * Get the device's current kernel version, as a string. Attempts to read /proc/version, and falls + * back to the 'os.version' System Property. + * + * @return the device's current kernel version, as a string + */ + @SuppressWarnings("DefaultCharset") + static @Nullable String getKernelVersion(final @NotNull ILogger logger) { + // its possible to try to execute 'uname' and parse it or also another unix commands or even + // looking for well known root installed apps + final String errorMsg = "Exception while attempting to read kernel information"; + final String defaultVersion = System.getProperty("os.version"); + + final File file = new File("/proc/version"); + if (!file.canRead()) { + return defaultVersion; + } + try (BufferedReader br = new BufferedReader(new FileReader(file))) { + return br.readLine(); + } catch (IOException e) { + logger.log(SentryLevel.ERROR, errorMsg, e); + } + + return defaultVersion; + } + + @SuppressWarnings("deprecation") + static @Nullable Map getSideLoadedInfo( + final @NotNull Context context, + final @NotNull ILogger logger, + final @NotNull BuildInfoProvider buildInfoProvider) { + String packageName = null; + try { + final PackageInfo packageInfo = getPackageInfo(context, logger, buildInfoProvider); + final PackageManager packageManager = context.getPackageManager(); + + if (packageInfo != null && packageManager != null) { + packageName = packageInfo.packageName; + + // getInstallSourceInfo requires INSTALL_PACKAGES permission which is only given to system + // apps. + final String installerPackageName = packageManager.getInstallerPackageName(packageName); + + final Map sideLoadedInfo = new HashMap<>(); + + if (installerPackageName != null) { + sideLoadedInfo.put("isSideLoaded", "false"); + // could be amazon, google play etc + sideLoadedInfo.put("installerStore", installerPackageName); + } else { + // if it's installed via adb, system apps or untrusted sources + sideLoadedInfo.put("isSideLoaded", "true"); + } + + return sideLoadedInfo; + } + } catch (IllegalArgumentException e) { + // it'll never be thrown as we are querying its own App's package. + logger.log(SentryLevel.DEBUG, "%s package isn't installed.", packageName); + } + + return null; + } + + /** + * Get the human-facing Application name. + * + * @return Application name + */ + static @Nullable String getApplicationName( + final @NotNull Context context, final @NotNull ILogger logger) { + try { + final ApplicationInfo applicationInfo = context.getApplicationInfo(); + final int stringId = applicationInfo.labelRes; + if (stringId == 0) { + if (applicationInfo.nonLocalizedLabel != null) { + return applicationInfo.nonLocalizedLabel.toString(); + } + return context.getPackageManager().getApplicationLabel(applicationInfo).toString(); + } else { + return context.getString(stringId); + } + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Error getting application name.", e); + } + + return null; + } + + /** + * Get the DisplayMetrics object for the current application. + * + * @return the DisplayMetrics object for the current application + */ + static @Nullable DisplayMetrics getDisplayMetrics( + final @NotNull Context context, final @NotNull ILogger logger) { + try { + return context.getResources().getDisplayMetrics(); + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Error getting DisplayMetrics.", e); + return null; + } + } + + /** + * Fake the device family by using the first word in the Build.MODEL. Works well in most cases... + * "Nexus 6P" -> "Nexus", "Galaxy S7" -> "Galaxy". + * + * @return family name of the device, as best we can tell + */ + static @Nullable String getFamily(final @NotNull ILogger logger) { + try { + return Build.MODEL.split(" ", -1)[0]; + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Error getting device family.", e); + return null; + } + } + + @SuppressLint("NewApi") // we're wrapping into if-check with sdk version + static @Nullable String getDeviceName( + final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider) { + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + return Settings.Global.getString(context.getContentResolver(), "device_name"); + } else { + return null; + } + } + + @SuppressWarnings("deprecation") + @SuppressLint("NewApi") // we're wrapping into if-check with sdk version + static @NotNull String[] getArchitectures(final @NotNull BuildInfoProvider buildInfoProvider) { + final String[] supportedAbis; + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.LOLLIPOP) { + supportedAbis = Build.SUPPORTED_ABIS; + } else { + supportedAbis = new String[] {Build.CPU_ABI, Build.CPU_ABI2}; + } + return supportedAbis; + } + + /** + * Get MemoryInfo object representing the memory state of the application. + * + * @return MemoryInfo object representing the memory state of the application + */ + static @Nullable ActivityManager.MemoryInfo getMemInfo( + final @NotNull Context context, final @NotNull ILogger logger) { + try { + final ActivityManager actManager = + (ActivityManager) context.getSystemService(ACTIVITY_SERVICE); + final ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo(); + if (actManager != null) { + actManager.getMemoryInfo(memInfo); + return memInfo; + } + logger.log(SentryLevel.INFO, "Error getting MemoryInfo."); + return null; + } catch (Throwable e) { + logger.log(SentryLevel.ERROR, "Error getting MemoryInfo.", e); + return null; + } + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 6a50b665773..e4fc09a88ef 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -1,6 +1,5 @@ package io.sentry.android.core; -import static android.content.Context.ACTIVITY_SERVICE; import static android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED; import static android.os.BatteryManager.EXTRA_TEMPERATURE; @@ -9,7 +8,6 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.os.BatteryManager; @@ -18,7 +16,6 @@ import android.os.LocaleList; import android.os.StatFs; import android.os.SystemClock; -import android.provider.Settings; import android.util.DisplayMetrics; import io.sentry.DateUtils; import io.sentry.EventProcessor; @@ -38,10 +35,7 @@ import io.sentry.protocol.User; import io.sentry.util.HintUtils; import io.sentry.util.Objects; -import java.io.BufferedReader; import java.io.File; -import java.io.FileReader; -import java.io.IOException; import java.util.Calendar; import java.util.Date; import java.util.HashMap; @@ -105,7 +99,7 @@ public DefaultAndroidEventProcessor( map.put(ROOTED, rootChecker.isDeviceRooted()); - String kernelVersion = getKernelVersion(); + final String kernelVersion = ContextUtils.getKernelVersion(options.getLogger()); if (kernelVersion != null) { map.put(KERNEL_VERSION, kernelVersion); } @@ -113,7 +107,8 @@ public DefaultAndroidEventProcessor( // its not IO, but it has been cached in the old version as well map.put(EMULATOR, buildInfoProvider.isEmulator()); - final Map sideLoadedInfo = getSideLoadedInfo(); + final Map sideLoadedInfo = + ContextUtils.getSideLoadedInfo(context, options.getLogger(), buildInfoProvider); if (sideLoadedInfo != null) { map.put(SIDE_LOADED, sideLoadedInfo); } @@ -253,7 +248,7 @@ private void setDist(final @NotNull SentryBaseEvent event, final @NotNull String } private void setAppExtras(final @NotNull App app, final @NotNull Hint hint) { - app.setAppName(getApplicationName()); + app.setAppName(ContextUtils.getApplicationName(context, options.getLogger())); app.setAppStartTime(DateUtils.toUtilDate(AppStartState.getInstance().getAppStartTime())); // This should not be set by Hybrid SDKs since they have their own app's lifecycle @@ -267,28 +262,6 @@ private void setAppExtras(final @NotNull App app, final @NotNull Hint hint) { } } - @SuppressWarnings("deprecation") - private @NotNull String getAbi() { - return Build.CPU_ABI; - } - - @SuppressWarnings("deprecation") - private @NotNull String getAbi2() { - return Build.CPU_ABI2; - } - - @SuppressWarnings({"ObsoleteSdkInt", "deprecation", "NewApi"}) - private void setArchitectures(final @NotNull Device device) { - final String[] supportedAbis; - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.LOLLIPOP) { - supportedAbis = Build.SUPPORTED_ABIS; - } else { - supportedAbis = new String[] {getAbi(), getAbi2()}; - // we were not checking CPU_ABI2, but I've added to the list now - } - device.setArchs(supportedAbis); - } - @SuppressWarnings({"ObsoleteSdkInt", "NewApi"}) private @NotNull Long getMemorySize(final @NotNull ActivityManager.MemoryInfo memInfo) { if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN) { @@ -305,14 +278,14 @@ private void setArchitectures(final @NotNull Device device) { Device device = new Device(); if (options.isSendDefaultPii()) { - device.setName(getDeviceName()); + device.setName(ContextUtils.getDeviceName(context, buildInfoProvider)); } device.setManufacturer(Build.MANUFACTURER); device.setBrand(Build.BRAND); - device.setFamily(getFamily()); + device.setFamily(ContextUtils.getFamily(options.getLogger())); device.setModel(Build.MODEL); device.setModelId(Build.ID); - setArchitectures(device); + device.setArchs(ContextUtils.getArchitectures(buildInfoProvider)); // setting such values require IO hence we don't run for transactions if (errorEvent) { @@ -332,7 +305,7 @@ private void setArchitectures(final @NotNull Device device) { options.getLogger().log(SentryLevel.ERROR, "Error getting emulator.", e); } - DisplayMetrics displayMetrics = getDisplayMetrics(); + DisplayMetrics displayMetrics = ContextUtils.getDisplayMetrics(context, options.getLogger()); if (displayMetrics != null) { device.setScreenWidthPixels(displayMetrics.widthPixels); device.setScreenHeightPixels(displayMetrics.heightPixels); @@ -379,7 +352,8 @@ private void setDeviceIO(final @NotNull Device device, final boolean applyScopeD } device.setOnline(connected); - final ActivityManager.MemoryInfo memInfo = getMemInfo(); + final ActivityManager.MemoryInfo memInfo = + ContextUtils.getMemInfo(context, options.getLogger()); if (memInfo != null) { // in bytes device.setMemorySize(getMemorySize(memInfo)); @@ -414,15 +388,6 @@ private void setDeviceIO(final @NotNull Device device, final boolean applyScopeD } } - @SuppressWarnings({"ObsoleteSdkInt", "NewApi"}) - private @Nullable String getDeviceName() { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.JELLY_BEAN_MR1) { - return Settings.Global.getString(context.getContentResolver(), "device_name"); - } else { - return null; - } - } - @SuppressWarnings("NewApi") private TimeZone getTimeZone() { if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N) { @@ -447,46 +412,10 @@ private TimeZone getTimeZone() { return null; } - /** - * Get MemoryInfo object representing the memory state of the application. - * - * @return MemoryInfo object representing the memory state of the application - */ - private @Nullable ActivityManager.MemoryInfo getMemInfo() { - try { - ActivityManager actManager = (ActivityManager) context.getSystemService(ACTIVITY_SERVICE); - ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo(); - if (actManager != null) { - actManager.getMemoryInfo(memInfo); - return memInfo; - } - options.getLogger().log(SentryLevel.INFO, "Error getting MemoryInfo."); - return null; - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting MemoryInfo.", e); - return null; - } - } - private @Nullable Intent getBatteryIntent() { return context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); } - /** - * Fake the device family by using the first word in the Build.MODEL. Works well in most cases... - * "Nexus 6P" -> "Nexus", "Galaxy S7" -> "Galaxy". - * - * @return family name of the device, as best we can tell - */ - private @Nullable String getFamily() { - try { - return Build.MODEL.split(" ", -1)[0]; - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting device family.", e); - return null; - } - } - /** * Get the device's current battery level (as a percentage of total). * @@ -734,20 +663,6 @@ private boolean isExternalStorageMounted() { } } - /** - * Get the DisplayMetrics object for the current application. - * - * @return the DisplayMetrics object for the current application - */ - private @Nullable DisplayMetrics getDisplayMetrics() { - try { - return context.getResources().getDisplayMetrics(); - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting DisplayMetrics.", e); - return null; - } - } - private @NotNull OperatingSystem getOperatingSystem() { OperatingSystem os = new OperatingSystem(); os.setName("Android"); @@ -800,56 +715,6 @@ private void setAppPackageInfo(final @NotNull App app, final @NotNull PackageInf } } - /** - * Get the device's current kernel version, as a string. Attempts to read /proc/version, and falls - * back to the 'os.version' System Property. - * - * @return the device's current kernel version, as a string - */ - @SuppressWarnings("DefaultCharset") - private @Nullable String getKernelVersion() { - // its possible to try to execute 'uname' and parse it or also another unix commands or even - // looking for well known root installed apps - String errorMsg = "Exception while attempting to read kernel information"; - String defaultVersion = System.getProperty("os.version"); - - File file = new File("/proc/version"); - if (!file.canRead()) { - return defaultVersion; - } - try (BufferedReader br = new BufferedReader(new FileReader(file))) { - return br.readLine(); - } catch (IOException e) { - options.getLogger().log(SentryLevel.ERROR, errorMsg, e); - } - - return defaultVersion; - } - - /** - * Get the human-facing Application name. - * - * @return Application name - */ - private @Nullable String getApplicationName() { - try { - ApplicationInfo applicationInfo = context.getApplicationInfo(); - int stringId = applicationInfo.labelRes; - if (stringId == 0) { - if (applicationInfo.nonLocalizedLabel != null) { - return applicationInfo.nonLocalizedLabel.toString(); - } - return context.getPackageManager().getApplicationLabel(applicationInfo).toString(); - } else { - return context.getString(stringId); - } - } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error getting application name.", e); - } - - return null; - } - /** * Sets the default user which contains only the userId. * @@ -871,42 +736,6 @@ private void setAppPackageInfo(final @NotNull App app, final @NotNull PackageInf return null; } - @SuppressWarnings("deprecation") - private @Nullable Map getSideLoadedInfo() { - String packageName = null; - try { - final PackageInfo packageInfo = - ContextUtils.getPackageInfo(context, options.getLogger(), buildInfoProvider); - final PackageManager packageManager = context.getPackageManager(); - - if (packageInfo != null && packageManager != null) { - packageName = packageInfo.packageName; - - // getInstallSourceInfo requires INSTALL_PACKAGES permission which is only given to system - // apps. - final String installerPackageName = packageManager.getInstallerPackageName(packageName); - - final Map sideLoadedInfo = new HashMap<>(); - - if (installerPackageName != null) { - sideLoadedInfo.put("isSideLoaded", "false"); - // could be amazon, google play etc - sideLoadedInfo.put("installerStore", installerPackageName); - } else { - // if it's installed via adb, system apps or untrusted sources - sideLoadedInfo.put("isSideLoaded", "true"); - } - - return sideLoadedInfo; - } - } catch (IllegalArgumentException e) { - // it'll never be thrown as we are querying its own App's package. - options.getLogger().log(SentryLevel.DEBUG, "%s package isn't installed.", packageName); - } - - return null; - } - @SuppressWarnings("unchecked") private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { try { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java index 1104adadd47..18f8bd05faf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/cache/AndroidEnvelopeCache.java @@ -5,16 +5,21 @@ import io.sentry.Hint; import io.sentry.SentryEnvelope; +import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.UncaughtExceptionHandlerIntegration; +import io.sentry.android.core.AnrV2Integration; import io.sentry.android.core.AppStartState; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.android.core.internal.util.AndroidCurrentDateProvider; import io.sentry.cache.EnvelopeCache; -import io.sentry.hints.DiskFlushNotification; import io.sentry.transport.ICurrentDateProvider; +import io.sentry.util.FileUtils; import io.sentry.util.HintUtils; import io.sentry.util.Objects; import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStream; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; @@ -22,6 +27,8 @@ @ApiStatus.Internal public final class AndroidEnvelopeCache extends EnvelopeCache { + public static final String LAST_ANR_REPORT = "last_anr_report"; + private final @NotNull ICurrentDateProvider currentDateProvider; public AndroidEnvelopeCache(final @NotNull SentryAndroidOptions options) { @@ -45,7 +52,8 @@ public void store(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { final SentryAndroidOptions options = (SentryAndroidOptions) this.options; final Long appStartTime = AppStartState.getInstance().getAppStartMillis(); - if (HintUtils.hasType(hint, DiskFlushNotification.class) && appStartTime != null) { + if (HintUtils.hasType(hint, UncaughtExceptionHandlerIntegration.UncaughtExceptionHint.class) + && appStartTime != null) { long timeSinceSdkInit = currentDateProvider.getCurrentTimeMillis() - appStartTime; if (timeSinceSdkInit <= options.getStartupCrashDurationThresholdMillis()) { options @@ -57,6 +65,21 @@ public void store(@NotNull SentryEnvelope envelope, @NotNull Hint hint) { writeStartupCrashMarkerFile(); } } + + HintUtils.runIfHasType( + hint, + AnrV2Integration.AnrV2Hint.class, + (anrHint) -> { + final long timestamp = anrHint.timestamp(); + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Writing last reported ANR marker with timestamp %d", + timestamp); + + writeLastReportedAnrMarker(anrHint.timestamp()); + }); } @TestOnly @@ -74,7 +97,7 @@ private void writeStartupCrashMarkerFile() { .log(DEBUG, "Outbox path is null, the startup crash marker file will not be written"); return; } - final File crashMarkerFile = new File(options.getOutboxPath(), STARTUP_CRASH_MARKER_FILE); + final File crashMarkerFile = new File(outboxPath, STARTUP_CRASH_MARKER_FILE); try { crashMarkerFile.createNewFile(); } catch (Throwable e) { @@ -91,7 +114,7 @@ public static boolean hasStartupCrashMarker(final @NotNull SentryOptions options return false; } - final File crashMarkerFile = new File(options.getOutboxPath(), STARTUP_CRASH_MARKER_FILE); + final File crashMarkerFile = new File(outboxPath, STARTUP_CRASH_MARKER_FILE); try { final boolean exists = crashMarkerFile.exists(); if (exists) { @@ -112,4 +135,43 @@ public static boolean hasStartupCrashMarker(final @NotNull SentryOptions options } return false; } + + public static long lastReportedAnr(final @NotNull SentryOptions options) { + final String cacheDirPath = + Objects.requireNonNull( + options.getCacheDirPath(), "Cache dir path should be set for getting ANRs reported"); + + final File lastAnrMarker = new File(cacheDirPath, LAST_ANR_REPORT); + try { + if (lastAnrMarker.exists() && lastAnrMarker.canRead()) { + final String content = FileUtils.readText(lastAnrMarker); + // we wrapped into try-catch already + //noinspection ConstantConditions + return Long.parseLong(content.trim()); + } else { + options + .getLogger() + .log(DEBUG, "Last ANR marker does not exist. %s.", lastAnrMarker.getAbsolutePath()); + } + } catch (Throwable e) { + options.getLogger().log(ERROR, "Error reading last ANR marker", e); + } + return 0L; + } + + private void writeLastReportedAnrMarker(final long timestamp) { + final String cacheDirPath = options.getCacheDirPath(); + if (cacheDirPath == null) { + options.getLogger().log(DEBUG, "Cache dir path is null, the ANR marker will not be written"); + return; + } + + final File anrMarker = new File(cacheDirPath, LAST_ANR_REPORT); + try (final OutputStream outputStream = new FileOutputStream(anrMarker)) { + outputStream.write(String.valueOf(timestamp).getBytes(UTF_8)); + outputStream.flush(); + } catch (Throwable e) { + options.getLogger().log(ERROR, "Error writing the ANR marker to the disk", e); + } + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 114646985ad..762777ce68c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -14,12 +14,15 @@ import io.sentry.android.core.internal.modules.AssetsModulesLoader import io.sentry.android.core.internal.util.AndroidMainThreadChecker import io.sentry.android.fragment.FragmentLifecycleIntegration import io.sentry.android.timber.SentryTimberIntegration +import io.sentry.cache.PersistingOptionsObserver +import io.sentry.cache.PersistingScopeObserver import io.sentry.compose.gestures.ComposeGestureTargetLocator import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config import java.io.File import kotlin.test.BeforeTest import kotlin.test.Test @@ -141,7 +144,7 @@ class AndroidOptionsInitializerTest { fun `AndroidEventProcessor added to processors list`() { fixture.initSut() val actual = - fixture.sentryOptions.eventProcessors.any { it is DefaultAndroidEventProcessor } + fixture.sentryOptions.eventProcessors.firstOrNull { it is DefaultAndroidEventProcessor } assertNotNull(actual) } @@ -149,7 +152,7 @@ class AndroidOptionsInitializerTest { fun `PerformanceAndroidEventProcessor added to processors list`() { fixture.initSut() val actual = - fixture.sentryOptions.eventProcessors.any { it is PerformanceAndroidEventProcessor } + fixture.sentryOptions.eventProcessors.firstOrNull { it is PerformanceAndroidEventProcessor } assertNotNull(actual) } @@ -164,7 +167,7 @@ class AndroidOptionsInitializerTest { fun `ScreenshotEventProcessor added to processors list`() { fixture.initSut() val actual = - fixture.sentryOptions.eventProcessors.any { it is ScreenshotEventProcessor } + fixture.sentryOptions.eventProcessors.firstOrNull { it is ScreenshotEventProcessor } assertNotNull(actual) } @@ -172,7 +175,15 @@ class AndroidOptionsInitializerTest { fun `ViewHierarchyEventProcessor added to processors list`() { fixture.initSut() val actual = - fixture.sentryOptions.eventProcessors.any { it is ViewHierarchyEventProcessor } + fixture.sentryOptions.eventProcessors.firstOrNull { it is ViewHierarchyEventProcessor } + assertNotNull(actual) + } + + @Test + fun `AnrV2EventProcessor added to processors list`() { + fixture.initSut() + val actual = + fixture.sentryOptions.eventProcessors.firstOrNull { it is AnrV2EventProcessor } assertNotNull(actual) } @@ -534,4 +545,40 @@ class AndroidOptionsInitializerTest { assertIs(fixture.sentryOptions.transactionPerformanceCollector) } + + @Test + fun `PersistingScopeObserver is set to options`() { + fixture.initSut() + + assertTrue { fixture.sentryOptions.scopeObservers.any { it is PersistingScopeObserver } } + } + + @Test + fun `PersistingOptionsObserver is set to options`() { + fixture.initSut() + + assertTrue { fixture.sentryOptions.optionsObservers.any { it is PersistingOptionsObserver } } + } + + @Test + fun `when cacheDir is not set, persisting observers are not set to options`() { + fixture.initSut(configureOptions = { cacheDirPath = null }) + + assertFalse(fixture.sentryOptions.optionsObservers.any { it is PersistingOptionsObserver }) + assertFalse(fixture.sentryOptions.scopeObservers.any { it is PersistingScopeObserver }) + } + + @Config(sdk = [30]) + @Test + fun `AnrV2Integration added to integrations list for API 30 and above`() { + fixture.initSut(useRealContext = true) + + val anrv2Integration = + fixture.sentryOptions.integrations.firstOrNull { it is AnrV2Integration } + assertNotNull(anrv2Integration) + + val anrv1Integration = + fixture.sentryOptions.integrations.firstOrNull { it is AnrIntegration } + assertNull(anrv1Integration) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt new file mode 100644 index 00000000000..96cbba43fe9 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2EventProcessorTest.kt @@ -0,0 +1,434 @@ +package io.sentry.android.core + +import android.app.ActivityManager +import android.app.ActivityManager.MemoryInfo +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IpAddressUtils +import io.sentry.NoOpLogger +import io.sentry.SentryBaseEvent +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.SentryLevel.DEBUG +import io.sentry.SpanContext +import io.sentry.cache.PersistingOptionsObserver +import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME +import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME +import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE +import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME +import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME +import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME +import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME +import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME +import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME +import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME +import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME +import io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME +import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE +import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME +import io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME +import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME +import io.sentry.cache.PersistingScopeObserver.USER_FILENAME +import io.sentry.hints.Backfillable +import io.sentry.protocol.Browser +import io.sentry.protocol.Contexts +import io.sentry.protocol.DebugImage +import io.sentry.protocol.DebugMeta +import io.sentry.protocol.Device +import io.sentry.protocol.OperatingSystem +import io.sentry.protocol.Request +import io.sentry.protocol.Response +import io.sentry.protocol.SdkVersion +import io.sentry.protocol.User +import io.sentry.util.HintUtils +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivityManager +import org.robolectric.shadows.ShadowBuild +import java.io.File +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame + +@RunWith(AndroidJUnit4::class) +class AnrV2EventProcessorTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + class Fixture { + + val buildInfo = mock() + lateinit var context: Context + val options = SentryAndroidOptions().apply { + setLogger(NoOpLogger.getInstance()) + isSendDefaultPii = true + } + + fun getSut( + dir: TemporaryFolder, + currentSdk: Int = 21, + populateScopeCache: Boolean = false, + populateOptionsCache: Boolean = false + ): AnrV2EventProcessor { + options.cacheDirPath = dir.newFolder().absolutePath + whenever(buildInfo.sdkInfoVersion).thenReturn(currentSdk) + whenever(buildInfo.isEmulator).thenReturn(true) + + if (populateScopeCache) { + persistScope(TRACE_FILENAME, SpanContext("ui.load")) + persistScope(USER_FILENAME, User().apply { username = "bot"; id = "bot@me.com" }) + persistScope(TAGS_FILENAME, mapOf("one" to "two")) + persistScope( + BREADCRUMBS_FILENAME, + listOf(Breadcrumb.debug("test"), Breadcrumb.navigation("from", "to")) + ) + persistScope(EXTRAS_FILENAME, mapOf("key" to 123)) + persistScope(TRANSACTION_FILENAME, "TestActivity") + persistScope(FINGERPRINT_FILENAME, listOf("finger", "print")) + persistScope(LEVEL_FILENAME, SentryLevel.INFO) + persistScope( + CONTEXTS_FILENAME, + Contexts().apply { + setResponse(Response().apply { bodySize = 1024 }) + setBrowser(Browser().apply { name = "Google Chrome" }) + } + ) + persistScope( + REQUEST_FILENAME, + Request().apply { url = "google.com"; method = "GET" } + ) + } + + if (populateOptionsCache) { + persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.2.0+232") + persistOptions(PROGUARD_UUID_FILENAME, "uuid") + persistOptions(SDK_VERSION_FILENAME, SdkVersion("sentry.java.android", "6.15.0")) + persistOptions(DIST_FILENAME, "232") + persistOptions(ENVIRONMENT_FILENAME, "debug") + persistOptions(PersistingOptionsObserver.TAGS_FILENAME, mapOf("option" to "tag")) + } + + return AnrV2EventProcessor(context, options, buildInfo) + } + + fun persistScope(filename: String, entity: T) { + val dir = File(options.cacheDirPath, SCOPE_CACHE).also { it.mkdirs() } + val file = File(dir, filename) + options.serializer.serialize(entity, file.writer()) + } + + fun persistOptions(filename: String, entity: T) { + val dir = File(options.cacheDirPath, OPTIONS_CACHE).also { it.mkdirs() } + val file = File(dir, filename) + options.serializer.serialize(entity, file.writer()) + } + + fun mockOutDeviceInfo() { + ShadowBuild.setManufacturer("Google") + ShadowBuild.setBrand("Pixel") + ShadowBuild.setModel("Pixel 3XL") + + val activityManager = + context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val shadowActivityManager = Shadow.extract(activityManager) + shadowActivityManager.setMemoryInfo(MemoryInfo().apply { totalMem = 2048 }) + } + } + + private val fixture = Fixture() + + @BeforeTest + fun `set up`() { + fixture.context = ApplicationProvider.getApplicationContext() + } + + @Test + fun `when event is not backfillable, does not enrich`() { + val processed = processEvent(Hint()) + + assertNull(processed.platform) + assertNull(processed.exceptions) + assertEquals(emptyMap(), processed.contexts) + } + + @Test + fun `when backfillable event is not enrichable, sets platform`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint(shouldEnrich = false)) + + val processed = processEvent(hint) + + assertEquals(SentryBaseEvent.DEFAULT_PLATFORM, processed.platform) + } + + @Test + fun `when backfillable event is not enrichable, sets OS`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint(shouldEnrich = false)) + + ShadowBuild.setVersionRelease("7.8.123") + val processed = processEvent(hint) + + assertEquals("7.8.123", processed.contexts.operatingSystem!!.version) + assertEquals("Android", processed.contexts.operatingSystem!!.name) + } + + @Test + fun `when backfillable event already has OS, sets Android as main OS and existing as secondary`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint(shouldEnrich = false)) + + val linuxOs = OperatingSystem().apply { name = " Linux " } + val processed = processEvent(hint) { + contexts.setOperatingSystem(linuxOs) + } + + assertSame(linuxOs, processed.contexts["os_linux"]) + assertEquals("Android", processed.contexts.operatingSystem!!.name) + } + + @Test + fun `when backfillable event already has OS without name, sets Android as main OS and existing with generated name`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint(shouldEnrich = false)) + + val osNoName = OperatingSystem().apply { version = "1.0" } + val processed = processEvent(hint) { + contexts.setOperatingSystem(osNoName) + } + + assertSame(osNoName, processed.contexts["os_1"]) + assertEquals("Android", processed.contexts.operatingSystem!!.name) + } + + @Test + @Config(qualifiers = "w360dp-h640dp-xxhdpi") + fun `when backfillable event is not enrichable, sets device`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint(shouldEnrich = false)) + + fixture.mockOutDeviceInfo() + + val processed = processEvent(hint) + + val device = processed.contexts.device!! + assertEquals("Google", device.manufacturer) + assertEquals("Pixel", device.brand) + assertEquals("Pixel", device.family) + assertEquals("Pixel 3XL", device.model) + assertEquals(true, device.isSimulator) + assertEquals(2048, device.memorySize) + assertEquals(1080, device.screenWidthPixels) + assertEquals(1920, device.screenHeightPixels) + assertEquals(3.0f, device.screenDensity) + assertEquals(480, device.screenDpi) + } + + @Test + fun `when backfillable event is enrichable, still sets static data`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + + val processed = processEvent(hint) + + assertNotNull(processed.platform) + assertFalse(processed.contexts.isEmpty()) + } + + @Test + fun `when backfillable event is enrichable, backfills serialized scope data`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + + val processed = processEvent(hint, populateScopeCache = true) + + // user + assertEquals("bot", processed.user!!.username) + assertEquals("bot@me.com", processed.user!!.id) + // trace + assertEquals("ui.load", processed.contexts.trace!!.operation) + // tags + assertEquals("two", processed.tags!!["one"]) + // breadcrumbs + assertEquals("test", processed.breadcrumbs!![0].message) + assertEquals("debug", processed.breadcrumbs!![0].type) + assertEquals("navigation", processed.breadcrumbs!![1].type) + assertEquals("to", processed.breadcrumbs!![1].data["to"]) + assertEquals("from", processed.breadcrumbs!![1].data["from"]) + // extras + assertEquals(123, processed.extras!!["key"]) + // transaction + assertEquals("TestActivity", processed.transaction) + // fingerprint + assertEquals(listOf("finger", "print"), processed.fingerprints) + // level + assertEquals(SentryLevel.INFO, processed.level) + // request + assertEquals("google.com", processed.request!!.url) + assertEquals("GET", processed.request!!.method) + // contexts + assertEquals(1024, processed.contexts.response!!.bodySize) + assertEquals("Google Chrome", processed.contexts.browser!!.name) + } + + @Test + fun `when backfillable event is enrichable, backfills serialized options data`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + + val processed = processEvent(hint, populateOptionsCache = true) + + // release + assertEquals("io.sentry.samples@1.2.0+232", processed.release) + // proguard uuid + assertEquals(DebugImage.PROGUARD, processed.debugMeta!!.images!![0].type) + assertEquals("uuid", processed.debugMeta!!.images!![0].uuid) + // sdk version + assertEquals("sentry.java.android", processed.sdk!!.name) + assertEquals("6.15.0", processed.sdk!!.version) + // dist + assertEquals("232", processed.dist) + // environment + assertEquals("debug", processed.environment) + // app + // robolectric defaults + assertEquals("io.sentry.android.core.test", processed.contexts.app!!.appIdentifier) + assertEquals("io.sentry.android.core.test", processed.contexts.app!!.appName) + assertEquals("1.2.0", processed.contexts.app!!.appVersion) + assertEquals("232", processed.contexts.app!!.appBuild) + // tags + assertEquals("tag", processed.tags!!["option"]) + } + + @Test + fun `if release is in wrong format, does not crash and leaves app version and build empty`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val original = SentryEvent() + + val processor = fixture.getSut(tmpDir) + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples") + + val processed = processor.process(original, hint) + + assertEquals("io.sentry.samples", processed!!.release) + assertNull(processed!!.contexts.app!!.appVersion) + assertNull(processed!!.contexts.app!!.appBuild) + } + + @Test + fun `if environment is not persisted, uses default`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + + val processed = processEvent(hint) + + assertEquals(AnrV2EventProcessor.DEFAULT_ENVIRONMENT, processed.environment) + } + + @Test + fun `if dist is not persisted, backfills it from release`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val original = SentryEvent() + + val processor = fixture.getSut(tmpDir) + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.2.0+232") + + val processed = processor.process(original, hint) + + assertEquals("232", processed!!.dist) + } + + @Test + fun `merges user`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + + val processed = processEvent(hint, populateScopeCache = true) + + assertEquals("bot@me.com", processed.user!!.id) + assertEquals("bot", processed.user!!.username) + assertEquals(IpAddressUtils.DEFAULT_IP_ADDRESS, processed.user!!.ipAddress) + } + + @Test + fun `uses installation id for user, if it has no id`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val original = SentryEvent() + + val processor = fixture.getSut(tmpDir) + fixture.persistOptions(USER_FILENAME, User()) + + val processed = processor.process(original, hint) + + assertEquals(Installation.deviceId, processed!!.user!!.id) + } + + @Test + fun `when event has some fields set, does not override them`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + + val processed = processEvent(hint, populateScopeCache = true, populateOptionsCache = true) { + contexts.setDevice( + Device().apply { + brand = "Pixel" + model = "3XL" + memorySize = 4096 + } + ) + platform = "NotAndroid" + + transaction = "MainActivity" + level = DEBUG + breadcrumbs = listOf(Breadcrumb.debug("test")) + + environment = "debug" + release = "io.sentry.samples@1.1.0+220" + debugMeta = DebugMeta().apply { + images = listOf(DebugImage().apply { type = DebugImage.PROGUARD; uuid = "uuid1" }) + } + } + + assertEquals("NotAndroid", processed.platform) + assertEquals("Pixel", processed.contexts.device!!.brand) + assertEquals("3XL", processed.contexts.device!!.model) + assertEquals(4096, processed.contexts.device!!.memorySize) + + assertEquals("MainActivity", processed.transaction) + assertEquals(DEBUG, processed.level) + assertEquals(3, processed.breadcrumbs!!.size) + assertEquals("debug", processed.breadcrumbs!![0].type) + assertEquals("debug", processed.breadcrumbs!![1].type) + assertEquals("navigation", processed.breadcrumbs!![2].type) + + assertEquals("debug", processed.environment) + assertEquals("io.sentry.samples@1.1.0+220", processed.release) + assertEquals("220", processed.contexts.app!!.appBuild) + assertEquals("1.1.0", processed.contexts.app!!.appVersion) + assertEquals(2, processed.debugMeta!!.images!!.size) + assertEquals("uuid1", processed.debugMeta!!.images!![0].uuid) + assertEquals("uuid", processed.debugMeta!!.images!![1].uuid) + } + + private fun processEvent( + hint: Hint, + populateScopeCache: Boolean = false, + populateOptionsCache: Boolean = false, + configureEvent: SentryEvent.() -> Unit = {} + ): SentryEvent { + val original = SentryEvent().apply(configureEvent) + + val processor = fixture.getSut( + tmpDir, + populateScopeCache = populateScopeCache, + populateOptionsCache = populateOptionsCache + ) + return processor.process(original, hint)!! + } + + internal class BackfillableHint(private val shouldEnrich: Boolean = true) : Backfillable { + override fun shouldEnrich(): Boolean = shouldEnrich + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt new file mode 100644 index 00000000000..6a8474ba9c8 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AnrV2IntegrationTest.kt @@ -0,0 +1,334 @@ +package io.sentry.android.core + +import android.app.ActivityManager +import android.app.ApplicationExitInfo +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.ILogger +import io.sentry.SentryLevel +import io.sentry.android.core.AnrV2Integration.AnrV2Hint +import io.sentry.android.core.cache.AndroidEnvelopeCache +import io.sentry.exception.ExceptionMechanismException +import io.sentry.hints.DiskFlushNotification +import io.sentry.protocol.SentryId +import io.sentry.test.ImmediateExecutorService +import io.sentry.util.HintUtils +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argThat +import org.mockito.kotlin.check +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivityManager +import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class AnrV2IntegrationTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + class Fixture { + lateinit var context: Context + lateinit var shadowActivityManager: ShadowActivityManager + lateinit var lastReportedAnrFile: File + + val options = SentryAndroidOptions() + val hub = mock() + val logger = mock() + + fun getSut( + dir: TemporaryFolder?, + useImmediateExecutorService: Boolean = true, + isAnrEnabled: Boolean = true, + flushTimeoutMillis: Long = 0L, + lastReportedAnrTimestamp: Long? = null, + lastEventId: SentryId = SentryId() + ): AnrV2Integration { + options.run { + setLogger(this@Fixture.logger) + isDebug = true + cacheDirPath = dir?.newFolder()?.absolutePath + executorService = + if (useImmediateExecutorService) ImmediateExecutorService() else mock() + this.isAnrEnabled = isAnrEnabled + this.flushTimeoutMillis = flushTimeoutMillis + } + options.cacheDirPath?.let { cacheDir -> + lastReportedAnrFile = File(cacheDir, AndroidEnvelopeCache.LAST_ANR_REPORT) + lastReportedAnrFile.writeText(lastReportedAnrTimestamp.toString()) + } + whenever(hub.captureEvent(any(), anyOrNull())).thenReturn(lastEventId) + + return AnrV2Integration(context) + } + + fun addAppExitInfo( + reason: Int? = ApplicationExitInfo.REASON_ANR, + timestamp: Long? = null, + importance: Int? = null + ) { + val builder = ApplicationExitInfoBuilder.newBuilder() + if (reason != null) { + builder.setReason(reason) + } + if (timestamp != null) { + builder.setTimestamp(timestamp) + } + if (importance != null) { + builder.setImportance(importance) + } + shadowActivityManager.addApplicationExitInfo(builder.build()) + } + } + + private val fixture = Fixture() + private val oldTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(10) + private val newTimestamp = oldTimestamp + TimeUnit.DAYS.toMillis(5) + + @BeforeTest + fun `set up`() { + fixture.context = ApplicationProvider.getApplicationContext() + val activityManager = + fixture.context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + fixture.shadowActivityManager = Shadow.extract(activityManager) + } + + @Test + fun `when cacheDir is not set, does not process historical exits`() { + val integration = fixture.getSut(null, useImmediateExecutorService = false) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.options.executorService, never()).submit(any()) + } + + @Test + fun `when anr tracking is not enabled, does not process historical exits`() { + val integration = + fixture.getSut(tmpDir, isAnrEnabled = false, useImmediateExecutorService = false) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.options.executorService, never()).submit(any()) + } + + @Test + fun `when historical exit list is empty, does not process historical exits`() { + val integration = fixture.getSut(tmpDir, useImmediateExecutorService = false) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.options.executorService, never()).submit(any()) + } + + @Test + fun `when there are no ANRs in historical exits, does not capture events`() { + val integration = fixture.getSut(tmpDir) + fixture.addAppExitInfo(reason = null) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.hub, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when latest ANR is older than 90 days, does not capture events`() { + val oldTimestamp = System.currentTimeMillis() - + AnrV2Integration.NINETY_DAYS_THRESHOLD - + TimeUnit.DAYS.toMillis(2) + val integration = fixture.getSut(tmpDir) + fixture.addAppExitInfo(timestamp = oldTimestamp) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.hub, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when latest ANR has already been reported, does not capture events`() { + val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = oldTimestamp) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.hub, never()).captureEvent(any(), anyOrNull()) + } + + @Test + fun `when latest ANR has not been reported, captures event with enriching`() { + val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.hub).captureEvent( + check { + assertEquals(newTimestamp, it.timestamp.time) + assertEquals(SentryLevel.FATAL, it.level) + assertTrue { + it.throwable is ApplicationNotResponding && + it.throwable!!.message == "Background ANR" + } + assertTrue { + (it.throwableMechanism as ExceptionMechanismException).exceptionMechanism.type == "ANRv2" + } + }, + argThat { + val hint = HintUtils.getSentrySdkHint(this) + (hint as AnrV2Hint).shouldEnrich() + } + ) + } + + @Test + fun `when latest ANR has foreground importance, does not add Background to the name`() { + val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + fixture.addAppExitInfo( + timestamp = newTimestamp, + importance = ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + ) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.hub).captureEvent( + argThat { + throwable is ApplicationNotResponding && throwable!!.message == "ANR" + }, + anyOrNull() + ) + } + + @Test + fun `waits for ANR events to be flushed on disk`() { + val integration = fixture.getSut( + tmpDir, + lastReportedAnrTimestamp = oldTimestamp, + flushTimeoutMillis = 3000L + ) + fixture.addAppExitInfo(timestamp = newTimestamp) + + whenever(fixture.hub.captureEvent(any(), any())).thenAnswer { invocation -> + val hint = HintUtils.getSentrySdkHint(invocation.getArgument(1)) + as DiskFlushNotification + thread { + Thread.sleep(1000L) + hint.markFlushed() + } + SentryId() + } + + integration.register(fixture.hub, fixture.options) + + verify(fixture.hub).captureEvent(any(), anyOrNull()) + // shouldn't fall into timed out state, because we marked event as flushed on another thread + verify(fixture.logger, never()).log( + any(), + argThat { startsWith("Timed out") }, + any() + ) + } + + @Test + fun `when latest ANR event was dropped, does not block flushing`() { + val integration = fixture.getSut( + tmpDir, + lastReportedAnrTimestamp = oldTimestamp, + lastEventId = SentryId.EMPTY_ID + ) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.hub).captureEvent(any(), anyOrNull()) + // we do not call markFlushed, hence it should time out waiting for flush, but because + // we drop the event, it should not even come to this if-check + verify(fixture.logger, never()).log( + any(), + argThat { startsWith("Timed out") }, + any() + ) + } + + @Test + fun `historical ANRs are reported non-enriched`() { + val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp - 2 * 60 * 1000) + fixture.addAppExitInfo(timestamp = newTimestamp - 1 * 60 * 1000) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.hub, times(2)).captureEvent( + any(), + argThat { + val hint = HintUtils.getSentrySdkHint(this) + !(hint as AnrV2Hint).shouldEnrich() + } + ) + } + + @Test + fun `historical ANRs are reported in reverse order to keep track of last reported ANR in a marker file`() { + val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + // robolectric uses addFirst when adding exit infos, so the last one here will be the first on the list + fixture.addAppExitInfo(timestamp = newTimestamp - TimeUnit.DAYS.toMillis(2)) + fixture.addAppExitInfo(timestamp = newTimestamp - TimeUnit.DAYS.toMillis(1)) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.hub, fixture.options) + + // the order is reverse here, so the oldest ANR will be reported first to keep track of + // last reported ANR in a marker file + inOrder(fixture.hub) { + verify(fixture.hub).captureEvent( + argThat { timestamp.time == newTimestamp - TimeUnit.DAYS.toMillis(2) }, + anyOrNull() + ) + verify(fixture.hub).captureEvent( + argThat { timestamp.time == newTimestamp - TimeUnit.DAYS.toMillis(1) }, + anyOrNull() + ) + verify(fixture.hub).captureEvent( + argThat { timestamp.time == newTimestamp }, + anyOrNull() + ) + } + } + + @Test + fun `ANR timestamp is passed with the hint`() { + val integration = fixture.getSut(tmpDir, lastReportedAnrTimestamp = oldTimestamp) + fixture.addAppExitInfo(timestamp = newTimestamp) + + integration.register(fixture.hub, fixture.options) + + verify(fixture.hub).captureEvent( + any(), + argThat { + val hint = HintUtils.getSentrySdkHint(this) + (hint as AnrV2Hint).timestamp() == newTimestamp + } + ) + } +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsUnitTests.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsUnitTests.kt index 23eafc75714..8f8846b9547 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsUnitTests.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsUnitTests.kt @@ -1,17 +1,27 @@ package io.sentry.android.core +import android.app.ActivityManager +import android.app.ActivityManager.MemoryInfo import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.ILogger import io.sentry.NoOpLogger import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivityManager +import org.robolectric.shadows.ShadowBuild import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) class ContextUtilsUnitTests { @@ -23,6 +33,7 @@ class ContextUtilsUnitTests { fun `set up`() { context = ApplicationProvider.getApplicationContext() logger = NoOpLogger.getInstance() + ShadowBuild.reset() } @Test @@ -56,4 +67,97 @@ class ContextUtilsUnitTests { val mockedVersionName = ContextUtils.getVersionName(mockedPackageInfo) assertNotNull(mockedVersionName) } + + @Test + fun `when context is valid, getApplicationName returns application name`() { + val appName = ContextUtils.getApplicationName(context, logger) + assertEquals("io.sentry.android.core.test", appName) + } + + @Test + fun `when context is invalid, getApplicationName returns null`() { + val appName = ContextUtils.getApplicationName(mock(), logger) + assertNull(appName) + } + + @Test + fun `isSideLoaded returns true for test context`() { + val sideLoadedInfo = + ContextUtils.getSideLoadedInfo(context, logger, BuildInfoProvider(logger)) + assertEquals("true", sideLoadedInfo?.get("isSideLoaded")) + } + + @Test + fun `when installerPackageName is not null, sideLoadedInfo returns false and installerStore`() { + val mockedContext = spy(context) { + val mockedPackageManager = spy(mock.packageManager) { + whenever(mock.getInstallerPackageName(any())).thenReturn("play.google.com") + } + whenever(mock.packageManager).thenReturn(mockedPackageManager) + } + val sideLoadedInfo = + ContextUtils.getSideLoadedInfo(mockedContext, logger, BuildInfoProvider(logger)) + assertEquals("false", sideLoadedInfo?.get("isSideLoaded")) + assertEquals("play.google.com", sideLoadedInfo?.get("installerStore")) + } + + @Test + @Config(qualifiers = "w360dp-h640dp-xxhdpi") + fun `when display metrics specified, getDisplayMetrics returns correct values`() { + val displayMetrics = ContextUtils.getDisplayMetrics(context, logger) + assertEquals(1080, displayMetrics!!.widthPixels) + assertEquals(1920, displayMetrics.heightPixels) + assertEquals(3.0f, displayMetrics.density) + assertEquals(480, displayMetrics.densityDpi) + } + + @Test + fun `when display metrics are not specified, getDisplayMetrics returns null`() { + val displayMetrics = ContextUtils.getDisplayMetrics(mock(), logger) + assertNull(displayMetrics) + } + + @Test + fun `when Build MODEL specified, getFamily returns correct value`() { + ShadowBuild.setModel("Pixel 3XL") + val family = ContextUtils.getFamily(logger) + assertEquals("Pixel", family) + } + + @Test + fun `when Build MODEL is not specified, getFamily returns null`() { + ShadowBuild.setModel(null) + val family = ContextUtils.getFamily(logger) + assertNull(family) + } + + @Test + fun `when supported abis is specified, getArchitectures returns correct values`() { + val architectures = ContextUtils.getArchitectures(BuildInfoProvider(logger)) + assertEquals("armeabi-v7a", architectures[0]) + } + + @Test + fun `when memory info is specified, returns correct values`() { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val shadowActivityManager = Shadow.extract(activityManager) + + shadowActivityManager.setMemoryInfo( + MemoryInfo().apply { + availMem = 128 + totalMem = 2048 + lowMemory = true + } + ) + val memInfo = ContextUtils.getMemInfo(context, logger) + assertEquals(128, memInfo!!.availMem) + assertEquals(2048, memInfo.totalMem) + assertTrue(memInfo.lowMemory) + } + + @Test + fun `when memory info is not specified, returns null`() { + val memInfo = ContextUtils.getMemInfo(mock(), logger) + assertNull(memInfo) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 5070cc00bff..5ec657c21ca 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -1,8 +1,12 @@ package io.sentry.android.core +import android.app.ActivityManager +import android.app.ApplicationExitInfo +import android.content.Context import android.os.Bundle import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.Breadcrumb import io.sentry.Hint import io.sentry.ILogger import io.sentry.Sentry @@ -11,13 +15,26 @@ import io.sentry.SentryLevel import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.FATAL import io.sentry.SentryOptions +import io.sentry.SentryOptions.BeforeSendCallback import io.sentry.Session import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.android.fragment.FragmentLifecycleIntegration import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.IEnvelopeCache +import io.sentry.cache.PersistingOptionsObserver +import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME +import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE +import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME +import io.sentry.cache.PersistingScopeObserver +import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME +import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE +import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME import io.sentry.transport.NoOpEnvelopeCache import io.sentry.util.StringUtils +import org.awaitility.kotlin.await +import org.awaitility.kotlin.withAlias +import org.junit.Rule +import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.mockito.Mockito import org.mockito.kotlin.any @@ -25,7 +42,14 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivityManager +import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder +import java.io.File import java.nio.file.Files +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import kotlin.io.path.absolutePathString import kotlin.test.BeforeTest import kotlin.test.Test @@ -38,9 +62,14 @@ import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) class SentryAndroidTest { + @get:Rule + val tmpDir = TemporaryFolder() + class Fixture { + lateinit var shadowActivityManager: ShadowActivityManager fun initSut( + context: Context? = null, autoInit: Boolean = false, logger: ILogger? = null, options: Sentry.OptionsConfiguration? = null @@ -49,21 +78,43 @@ class SentryAndroidTest { putString(ManifestMetadataReader.DSN, "https://key@sentry.io/123") putBoolean(ManifestMetadataReader.AUTO_INIT, autoInit) } - val mockContext = ContextUtilsTest.mockMetaData(metaData = metadata) + val mockContext = context ?: ContextUtilsTest.mockMetaData(metaData = metadata) when { logger != null -> SentryAndroid.init(mockContext, logger) options != null -> SentryAndroid.init(mockContext, options) else -> SentryAndroid.init(mockContext) } } + + fun addAppExitInfo( + reason: Int? = ApplicationExitInfo.REASON_ANR, + timestamp: Long? = null, + importance: Int? = null + ) { + val builder = ApplicationExitInfoBuilder.newBuilder() + if (reason != null) { + builder.setReason(reason) + } + if (timestamp != null) { + builder.setTimestamp(timestamp) + } + if (importance != null) { + builder.setImportance(importance) + } + shadowActivityManager.addApplicationExitInfo(builder.build()) + } } private val fixture = Fixture() + private lateinit var context: Context @BeforeTest fun `set up`() { Sentry.close() AppStartState.getInstance().resetInstance() + context = ApplicationProvider.getApplicationContext() + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + fixture.shadowActivityManager = Shadow.extract(activityManager) } @Test @@ -141,7 +192,10 @@ class SentryAndroidTest { // fragment integration is not auto-installed in the test, since the context is not Application // but we just verify here that the single integration is preserved - assertEquals(refOptions!!.integrations.filterIsInstance().size, 1) + assertEquals( + refOptions!!.integrations.filterIsInstance().size, + 1 + ) } @Test @@ -186,7 +240,10 @@ class SentryAndroidTest { val dsnHash = StringUtils.calculateStringHash(options!!.dsn, options!!.logger) val expectedCacheDir = "$cacheDirPath/$dsnHash" assertEquals(expectedCacheDir, options!!.cacheDirPath) - assertEquals(expectedCacheDir, (options!!.envelopeDiskCache as AndroidEnvelopeCache).directory.absolutePath) + assertEquals( + expectedCacheDir, + (options!!.envelopeDiskCache as AndroidEnvelopeCache).directory.absolutePath + ) } @Test @@ -203,7 +260,10 @@ class SentryAndroidTest { } } - private fun initSentryWithForegroundImportance(inForeground: Boolean, callback: (session: Session?) -> Unit) { + private fun initSentryWithForegroundImportance( + inForeground: Boolean, + callback: (session: Session?) -> Unit + ) { val context = ContextUtilsTest.createMockContext() Mockito.mockStatic(ContextUtils::class.java).use { mockedContextUtils -> @@ -233,6 +293,86 @@ class SentryAndroidTest { } } + @Test + @Config(sdk = [30]) + fun `AnrV2 events get enriched with previously persisted scope and options data, the new data gets persisted after that`() { + val cacheDir = tmpDir.newFolder().absolutePath + fixture.addAppExitInfo(timestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)) + val asserted = AtomicBoolean(false) + lateinit var options: SentryOptions + + fixture.initSut(context) { + it.dsn = "https://key@sentry.io/123" + it.cacheDirPath = cacheDir + // beforeSend is called after event processors are applied, so we can assert here + // against the enriched ANR event + it.beforeSend = BeforeSendCallback { event, hint -> + assertEquals("MainActivity", event.transaction) + assertEquals("Debug!", event.breadcrumbs!![0].message) + assertEquals("staging", event.environment) + assertEquals("io.sentry.sample@2.0.0", event.release) + asserted.set(true) + null + } + + // have to do it after the cacheDir is set to options, because it adds a dsn hash after + prefillOptionsCache(it.cacheDirPath!!) + prefillScopeCache(it.cacheDirPath!!) + + it.release = "io.sentry.sample@1.1.0+220" + it.environment = "debug" + // this is necessary to delay the AnrV2Integration processing to execute the configure + // scope block below (otherwise it won't be possible as hub is no-op before .init) + it.executorService.submit { + Thread.sleep(2000L) + Sentry.configureScope { scope -> + // make sure the scope values changed to test that we're still using previously + // persisted values for the old ANR events + assertEquals("TestActivity", scope.transactionName) + } + } + options = it + } + Sentry.configureScope { + it.setTransaction("TestActivity") + it.addBreadcrumb(Breadcrumb.error("Error!")) + } + await.withAlias("Failed because of BeforeSend callback above, but we swallow BeforeSend exceptions, hence the timeout") + .untilTrue(asserted) + + // assert that persisted values have changed + options.executorService.close(1000L) // finalizes all enqueued persisting tasks + assertEquals( + "TestActivity", + PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String::class.java) + ) + assertEquals( + "io.sentry.sample@1.1.0+220", + PersistingOptionsObserver.read(options, RELEASE_FILENAME, String::class.java) + ) + } + + private fun prefillScopeCache(cacheDir: String) { + val scopeDir = File(cacheDir, SCOPE_CACHE).also { it.mkdirs() } + File(scopeDir, BREADCRUMBS_FILENAME).writeText( + """ + [{ + "timestamp": "2009-11-16T01:08:47.000Z", + "message": "Debug!", + "type": "debug", + "level": "debug" + }] + """.trimIndent() + ) + File(scopeDir, TRANSACTION_FILENAME).writeText("\"MainActivity\"") + } + + private fun prefillOptionsCache(cacheDir: String) { + val optionsDir = File(cacheDir, OPTIONS_CACHE).also { it.mkdirs() } + File(optionsDir, RELEASE_FILENAME).writeText("\"io.sentry.sample@2.0.0\"") + File(optionsDir, ENVIRONMENT_FILENAME).writeText("\"staging\"") + } + private class CustomEnvelopCache : IEnvelopeCache { override fun iterator(): MutableIterator = TODO() override fun store(envelope: SentryEnvelope, hint: Hint) = Unit diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt index a5d1bb1f24c..3eee455d98c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/cache/AndroidEnvelopeCacheTest.kt @@ -1,41 +1,51 @@ package io.sentry.android.core.cache +import io.sentry.NoOpLogger import io.sentry.SentryEnvelope +import io.sentry.UncaughtExceptionHandlerIntegration.UncaughtExceptionHint +import io.sentry.android.core.AnrV2Integration.AnrV2Hint import io.sentry.android.core.AppStartState import io.sentry.android.core.SentryAndroidOptions import io.sentry.cache.EnvelopeCache -import io.sentry.hints.DiskFlushNotification import io.sentry.transport.ICurrentDateProvider import io.sentry.util.HintUtils +import org.junit.Rule +import org.junit.rules.TemporaryFolder import org.mockito.kotlin.mock import org.mockito.kotlin.whenever import java.io.File -import java.nio.file.Files -import java.nio.file.Path +import java.lang.IllegalArgumentException import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue class AndroidEnvelopeCacheTest { + + @get:Rule + val tmpDir = TemporaryFolder() + private class Fixture { - private val dir: Path = Files.createTempDirectory("sentry-cache") val envelope = mock { whenever(it.header).thenReturn(mock()) } val options = SentryAndroidOptions() val dateProvider = mock() - lateinit var markerFile: File + lateinit var startupCrashMarkerFile: File + lateinit var lastReportedAnrFile: File fun getSut( + dir: TemporaryFolder, appStartMillis: Long? = null, currentTimeMillis: Long? = null ): AndroidEnvelopeCache { - options.cacheDirPath = dir.toAbsolutePath().toFile().absolutePath + options.cacheDirPath = dir.newFolder("sentry-cache").absolutePath val outboxDir = File(options.outboxPath!!) outboxDir.mkdirs() - markerFile = File(outboxDir, EnvelopeCache.STARTUP_CRASH_MARKER_FILE) + startupCrashMarkerFile = File(outboxDir, EnvelopeCache.STARTUP_CRASH_MARKER_FILE) + lastReportedAnrFile = File(options.cacheDirPath!!, AndroidEnvelopeCache.LAST_ANR_REPORT) if (appStartMillis != null) { AppStartState.getInstance().setAppStartMillis(appStartMillis) @@ -56,57 +66,133 @@ class AndroidEnvelopeCacheTest { } @Test - fun `when no flush hint exists, does not write startup crash file`() { - val cache = fixture.getSut() + fun `when no uncaught hint exists, does not write startup crash file`() { + val cache = fixture.getSut(tmpDir) cache.store(fixture.envelope) - assertFalse(fixture.markerFile.exists()) + assertFalse(fixture.startupCrashMarkerFile.exists()) } @Test fun `when startup time is null, does not write startup crash file`() { - val cache = fixture.getSut() + val cache = fixture.getSut(tmpDir) - val hints = HintUtils.createWithTypeCheckHint(DiskFlushHint()) + val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) cache.store(fixture.envelope, hints) - assertFalse(fixture.markerFile.exists()) + assertFalse(fixture.startupCrashMarkerFile.exists()) } @Test fun `when time since sdk init is more than duration threshold, does not write startup crash file`() { - val cache = fixture.getSut(appStartMillis = 1000L, currentTimeMillis = 5000L) + val cache = fixture.getSut(dir = tmpDir, appStartMillis = 1000L, currentTimeMillis = 5000L) - val hints = HintUtils.createWithTypeCheckHint(DiskFlushHint()) + val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) cache.store(fixture.envelope, hints) - assertFalse(fixture.markerFile.exists()) + assertFalse(fixture.startupCrashMarkerFile.exists()) } @Test fun `when outbox dir is not set, does not write startup crash file`() { - val cache = fixture.getSut(appStartMillis = 1000L, currentTimeMillis = 2000L) + val cache = fixture.getSut(dir = tmpDir, appStartMillis = 1000L, currentTimeMillis = 2000L) fixture.options.cacheDirPath = null - val hints = HintUtils.createWithTypeCheckHint(DiskFlushHint()) + val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) cache.store(fixture.envelope, hints) - assertFalse(fixture.markerFile.exists()) + assertFalse(fixture.startupCrashMarkerFile.exists()) } @Test fun `when time since sdk init is less than duration threshold, writes startup crash file`() { - val cache = fixture.getSut(appStartMillis = 1000L, currentTimeMillis = 2000L) + val cache = fixture.getSut(dir = tmpDir, appStartMillis = 1000L, currentTimeMillis = 2000L) - val hints = HintUtils.createWithTypeCheckHint(DiskFlushHint()) + val hints = HintUtils.createWithTypeCheckHint(UncaughtHint()) cache.store(fixture.envelope, hints) - assertTrue(fixture.markerFile.exists()) + assertTrue(fixture.startupCrashMarkerFile.exists()) + } + + @Test + fun `when no AnrV2 hint exists, does not write last anr report file`() { + val cache = fixture.getSut(tmpDir) + + cache.store(fixture.envelope) + + assertFalse(fixture.lastReportedAnrFile.exists()) } - internal class DiskFlushHint : DiskFlushNotification { - override fun markFlushed() {} + @Test + fun `when cache dir is not set, does not write last anr report file`() { + val cache = fixture.getSut(tmpDir) + + fixture.options.cacheDirPath = null + + val hints = HintUtils.createWithTypeCheckHint( + AnrV2Hint( + 0, + NoOpLogger.getInstance(), + 12345678L, + false + ) + ) + cache.store(fixture.envelope, hints) + + assertFalse(fixture.lastReportedAnrFile.exists()) + } + + @Test + fun `when AnrV2 hint exists, writes last anr report timestamp into file`() { + val cache = fixture.getSut(tmpDir) + + val hints = HintUtils.createWithTypeCheckHint( + AnrV2Hint( + 0, + NoOpLogger.getInstance(), + 12345678L, + false + ) + ) + cache.store(fixture.envelope, hints) + + assertTrue(fixture.lastReportedAnrFile.exists()) + assertEquals("12345678", fixture.lastReportedAnrFile.readText()) } + + @Test + fun `when cache dir is not set, throws upon reading last reported anr file`() { + fixture.getSut(tmpDir) + + fixture.options.cacheDirPath = null + + try { + AndroidEnvelopeCache.lastReportedAnr(fixture.options) + } catch (e: Throwable) { + assertTrue { e is IllegalArgumentException } + } + } + + @Test + fun `when last reported anr file does not exist, returns 0 upon reading`() { + fixture.getSut(tmpDir) + + val lastReportedAnr = AndroidEnvelopeCache.lastReportedAnr(fixture.options) + + assertEquals(0L, lastReportedAnr) + } + + @Test + fun `when last reported anr file exists, returns timestamp from the file upon reading`() { + fixture.getSut(tmpDir) + fixture.lastReportedAnrFile.writeText("87654321") + + val lastReportedAnr = AndroidEnvelopeCache.lastReportedAnr(fixture.options) + + assertEquals(87654321L, lastReportedAnr) + } + + internal class UncaughtHint : UncaughtExceptionHint(0, NoOpLogger.getInstance()) } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index 01f3d4d392b..fc91a505d7e 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -2,6 +2,7 @@ import android.content.Intent; import android.os.Bundle; +import android.os.Handler; import androidx.appcompat.app.AppCompatActivity; import io.sentry.Attachment; import io.sentry.ISpan; @@ -30,7 +31,10 @@ public class MainActivity extends AppCompatActivity { private int crashCount = 0; private int screenLoadCount = 0; + final Object mutex = new Object(); + @Override + @SuppressWarnings("deprecation") protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -148,11 +152,35 @@ protected void onCreate(Bundle savedInstanceState) { // Sentry. // NOTE: By default it doesn't raise if the debugger is attached. That can also be // configured. - try { - Thread.sleep(10000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + new Thread( + new Runnable() { + @Override + public void run() { + synchronized (mutex) { + while (true) { + try { + Thread.sleep(10000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + } + }) + .start(); + + new Handler() + .postDelayed( + new Runnable() { + @Override + public void run() { + synchronized (mutex) { + // Shouldn't happen + throw new IllegalStateException(); + } + } + }, + 1000); }); binding.openSecondActivity.setOnClickListener( diff --git a/sentry-test-support/api/sentry-test-support.api b/sentry-test-support/api/sentry-test-support.api index 53079bdb7c0..1432f2414f8 100644 --- a/sentry-test-support/api/sentry-test-support.api +++ b/sentry-test-support/api/sentry-test-support.api @@ -7,6 +7,14 @@ public final class io/sentry/SkipError : java/lang/Error { public fun (Ljava/lang/String;)V } +public final class io/sentry/test/ImmediateExecutorService : io/sentry/ISentryExecutorService { + public fun ()V + public fun close (J)V + public fun schedule (Ljava/lang/Runnable;J)Ljava/util/concurrent/Future; + public fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future; + public fun submit (Ljava/util/concurrent/Callable;)Ljava/util/concurrent/Future; +} + public final class io/sentry/test/ReflectionKt { public static final fun containsMethod (Ljava/lang/Class;Ljava/lang/String;Ljava/lang/Class;)Z public static final fun containsMethod (Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/Class;)Z diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/Mocks.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/Mocks.kt new file mode 100644 index 00000000000..9966aab7e25 --- /dev/null +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/Mocks.kt @@ -0,0 +1,18 @@ +// ktlint-disable filename +package io.sentry.test + +import io.sentry.ISentryExecutorService +import org.mockito.kotlin.mock +import java.util.concurrent.Callable +import java.util.concurrent.Future + +class ImmediateExecutorService : ISentryExecutorService { + override fun submit(runnable: Runnable): Future<*> { + runnable.run() + return mock() + } + + override fun submit(callable: Callable): Future = mock() + override fun schedule(runnable: Runnable, delayMillis: Long): Future<*> = mock() + override fun close(timeoutMillis: Long) {} +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e0958376cb5..4060b76e645 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -25,6 +25,9 @@ public final class io/sentry/Attachment { public fun getSerializable ()Lio/sentry/JsonSerializable; } +public abstract interface class io/sentry/BackfillingEventProcessor : io/sentry/EventProcessor { +} + public final class io/sentry/Baggage { public fun (Lio/sentry/ILogger;)V public fun (Ljava/util/Map;Ljava/lang/String;ZLio/sentry/ILogger;)V @@ -86,6 +89,7 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public fun (Ljava/lang/String;)V public fun (Ljava/util/Date;)V public static fun debug (Ljava/lang/String;)Lio/sentry/Breadcrumb; + public fun equals (Ljava/lang/Object;)Z public static fun error (Ljava/lang/String;)Lio/sentry/Breadcrumb; public fun getCategory ()Ljava/lang/String; public fun getData ()Ljava/util/Map; @@ -95,6 +99,7 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public fun getTimestamp ()Ljava/util/Date; public fun getType ()Ljava/lang/String; public fun getUnknown ()Ljava/util/Map; + public fun hashCode ()I public static fun http (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/Breadcrumb; public static fun http (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)Lio/sentry/Breadcrumb; public static fun info (Ljava/lang/String;)Lio/sentry/Breadcrumb; @@ -491,13 +496,31 @@ public abstract interface class io/sentry/IMemoryCollector { public abstract fun collect ()Lio/sentry/MemoryCollectionData; } +public abstract interface class io/sentry/IOptionsObserver { + public fun setDist (Ljava/lang/String;)V + public fun setEnvironment (Ljava/lang/String;)V + public fun setProguardUuid (Ljava/lang/String;)V + public fun setRelease (Ljava/lang/String;)V + public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V + public fun setTags (Ljava/util/Map;)V +} + public abstract interface class io/sentry/IScopeObserver { - public abstract fun addBreadcrumb (Lio/sentry/Breadcrumb;)V - public abstract fun removeExtra (Ljava/lang/String;)V - public abstract fun removeTag (Ljava/lang/String;)V - public abstract fun setExtra (Ljava/lang/String;Ljava/lang/String;)V - public abstract fun setTag (Ljava/lang/String;Ljava/lang/String;)V - public abstract fun setUser (Lio/sentry/protocol/User;)V + public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V + public fun removeExtra (Ljava/lang/String;)V + public fun removeTag (Ljava/lang/String;)V + public fun setBreadcrumbs (Ljava/util/Collection;)V + public fun setContexts (Lio/sentry/protocol/Contexts;)V + public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V + public fun setExtras (Ljava/util/Map;)V + public fun setFingerprint (Ljava/util/Collection;)V + public fun setLevel (Lio/sentry/SentryLevel;)V + public fun setRequest (Lio/sentry/protocol/Request;)V + public fun setTag (Ljava/lang/String;Ljava/lang/String;)V + public fun setTags (Ljava/util/Map;)V + public fun setTrace (Lio/sentry/SpanContext;)V + public fun setTransaction (Ljava/lang/String;)V + public fun setUser (Lio/sentry/protocol/User;)V } public abstract interface class io/sentry/ISentryClient { @@ -535,6 +558,7 @@ public abstract interface class io/sentry/ISentryExecutorService { public abstract interface class io/sentry/ISerializer { public abstract fun deserialize (Ljava/io/Reader;Ljava/lang/Class;)Ljava/lang/Object; + public abstract fun deserializeCollection (Ljava/io/Reader;Ljava/lang/Class;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; public abstract fun deserializeEnvelope (Ljava/io/InputStream;)Lio/sentry/SentryEnvelope; public abstract fun serialize (Lio/sentry/SentryEnvelope;Ljava/io/OutputStream;)V public abstract fun serialize (Ljava/lang/Object;Ljava/io/Writer;)V @@ -613,6 +637,7 @@ public abstract interface class io/sentry/IntegrationName { } public final class io/sentry/IpAddressUtils { + public static final field DEFAULT_IP_ADDRESS Ljava/lang/String; public static fun isDefault (Ljava/lang/String;)Z } @@ -675,6 +700,7 @@ public abstract interface class io/sentry/JsonSerializable { public final class io/sentry/JsonSerializer : io/sentry/ISerializer { public fun (Lio/sentry/SentryOptions;)V public fun deserialize (Ljava/io/Reader;Ljava/lang/Class;)Ljava/lang/Object; + public fun deserializeCollection (Ljava/io/Reader;Ljava/lang/Class;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; public fun deserializeEnvelope (Ljava/io/InputStream;)Lio/sentry/SentryEnvelope; public fun serialize (Lio/sentry/SentryEnvelope;Ljava/io/OutputStream;)V public fun serialize (Ljava/lang/Object;Ljava/io/Writer;)V @@ -1231,6 +1257,7 @@ public abstract class io/sentry/SentryBaseEvent { public fun getEnvironment ()Ljava/lang/String; public fun getEventId ()Lio/sentry/protocol/SentryId; public fun getExtra (Ljava/lang/String;)Ljava/lang/Object; + public fun getExtras ()Ljava/util/Map; public fun getPlatform ()Ljava/lang/String; public fun getRelease ()Ljava/lang/String; public fun getRequest ()Lio/sentry/protocol/Request; @@ -1424,6 +1451,7 @@ public final class io/sentry/SentryEvent : io/sentry/SentryBaseEvent, io/sentry/ public fun setModule (Ljava/lang/String;Ljava/lang/String;)V public fun setModules (Ljava/util/Map;)V public fun setThreads (Ljava/util/List;)V + public fun setTimestamp (Ljava/util/Date;)V public fun setTransaction (Ljava/lang/String;)V public fun setUnknown (Ljava/util/Map;)V } @@ -1447,6 +1475,11 @@ public final class io/sentry/SentryEvent$JsonKeys { public fun ()V } +public final class io/sentry/SentryExceptionFactory { + public fun (Lio/sentry/SentryStackTraceFactory;)V + public fun getSentryExceptions (Ljava/lang/Throwable;)Ljava/util/List; +} + public final class io/sentry/SentryInstantDate : io/sentry/SentryDate { public fun ()V public fun (Ljava/time/Instant;)V @@ -1526,6 +1559,7 @@ public class io/sentry/SentryOptions { public fun addInAppExclude (Ljava/lang/String;)V public fun addInAppInclude (Ljava/lang/String;)V public fun addIntegration (Lio/sentry/Integration;)V + public fun addOptionsObserver (Lio/sentry/IOptionsObserver;)V public fun addScopeObserver (Lio/sentry/IScopeObserver;)V public fun addTracingOrigin (Ljava/lang/String;)V public fun getBeforeBreadcrumb ()Lio/sentry/SentryOptions$BeforeBreadcrumbCallback; @@ -1568,6 +1602,7 @@ public class io/sentry/SentryOptions { public fun getMaxSpans ()I public fun getMaxTraceFileSize ()J public fun getModulesLoader ()Lio/sentry/internal/modules/IModulesLoader; + public fun getOptionsObservers ()Ljava/util/List; public fun getOutboxPath ()Ljava/lang/String; public fun getProfilesSampleRate ()Ljava/lang/Double; public fun getProfilesSampler ()Lio/sentry/SentryOptions$ProfilesSamplerCallback; @@ -1577,6 +1612,7 @@ public class io/sentry/SentryOptions { public fun getReadTimeoutMillis ()I public fun getRelease ()Ljava/lang/String; public fun getSampleRate ()Ljava/lang/Double; + public fun getScopeObservers ()Ljava/util/List; public fun getSdkVersion ()Lio/sentry/protocol/SdkVersion; public fun getSentryClientName ()Ljava/lang/String; public fun getSerializer ()Lio/sentry/ISerializer; @@ -1749,6 +1785,10 @@ public final class io/sentry/SentryStackTraceFactory { public fun getStackFrames ([Ljava/lang/StackTraceElement;)Ljava/util/List; } +public final class io/sentry/SentryThreadFactory { + public fun (Lio/sentry/SentryStackTraceFactory;Lio/sentry/SentryOptions;)V +} + public final class io/sentry/SentryTraceHeader { public static final field SENTRY_TRACE_HEADER Ljava/lang/String; public fun (Lio/sentry/protocol/SentryId;Lio/sentry/SpanId;Ljava/lang/Boolean;)V @@ -1931,6 +1971,7 @@ public class io/sentry/SpanContext : io/sentry/JsonSerializable, io/sentry/JsonU public fun (Lio/sentry/protocol/SentryId;Lio/sentry/SpanId;Ljava/lang/String;Lio/sentry/SpanId;Lio/sentry/TracesSamplingDecision;)V public fun (Ljava/lang/String;)V public fun (Ljava/lang/String;Lio/sentry/TracesSamplingDecision;)V + public fun equals (Ljava/lang/Object;)Z public fun getDescription ()Ljava/lang/String; public fun getOperation ()Ljava/lang/String; public fun getParentSpanId ()Lio/sentry/SpanId; @@ -1942,6 +1983,7 @@ public class io/sentry/SpanContext : io/sentry/JsonSerializable, io/sentry/JsonU public fun getTags ()Ljava/util/Map; public fun getTraceId ()Lio/sentry/protocol/SentryId; public fun getUnknown ()Ljava/util/Map; + public fun hashCode ()I public fun serialize (Lio/sentry/JsonObjectWriter;Lio/sentry/ILogger;)V public fun setDescription (Ljava/lang/String;)V public fun setOperation (Ljava/lang/String;)V @@ -2165,6 +2207,10 @@ public final class io/sentry/UncaughtExceptionHandlerIntegration : io/sentry/Int public fun uncaughtException (Ljava/lang/Thread;Ljava/lang/Throwable;)V } +public class io/sentry/UncaughtExceptionHandlerIntegration$UncaughtExceptionHint : io/sentry/hints/BlockingFlushHint, io/sentry/hints/SessionEnd { + public fun (JLio/sentry/ILogger;)V +} + public final class io/sentry/UserFeedback : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public fun (Lio/sentry/protocol/SentryId;)V public fun (Lio/sentry/protocol/SentryId;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V @@ -2215,6 +2261,52 @@ public abstract interface class io/sentry/cache/IEnvelopeCache : java/lang/Itera public abstract fun store (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V } +public final class io/sentry/cache/PersistingOptionsObserver : io/sentry/IOptionsObserver { + public static final field DIST_FILENAME Ljava/lang/String; + public static final field ENVIRONMENT_FILENAME Ljava/lang/String; + public static final field OPTIONS_CACHE Ljava/lang/String; + public static final field PROGUARD_UUID_FILENAME Ljava/lang/String; + public static final field RELEASE_FILENAME Ljava/lang/String; + public static final field SDK_VERSION_FILENAME Ljava/lang/String; + public static final field TAGS_FILENAME Ljava/lang/String; + public fun (Lio/sentry/SentryOptions;)V + public static fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; + public static fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/Class;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public fun setDist (Ljava/lang/String;)V + public fun setEnvironment (Ljava/lang/String;)V + public fun setProguardUuid (Ljava/lang/String;)V + public fun setRelease (Ljava/lang/String;)V + public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V + public fun setTags (Ljava/util/Map;)V +} + +public final class io/sentry/cache/PersistingScopeObserver : io/sentry/IScopeObserver { + public static final field BREADCRUMBS_FILENAME Ljava/lang/String; + public static final field CONTEXTS_FILENAME Ljava/lang/String; + public static final field EXTRAS_FILENAME Ljava/lang/String; + public static final field FINGERPRINT_FILENAME Ljava/lang/String; + public static final field LEVEL_FILENAME Ljava/lang/String; + public static final field REQUEST_FILENAME Ljava/lang/String; + public static final field SCOPE_CACHE Ljava/lang/String; + public static final field TAGS_FILENAME Ljava/lang/String; + public static final field TRACE_FILENAME Ljava/lang/String; + public static final field TRANSACTION_FILENAME Ljava/lang/String; + public static final field USER_FILENAME Ljava/lang/String; + public fun (Lio/sentry/SentryOptions;)V + public static fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; + public static fun read (Lio/sentry/SentryOptions;Ljava/lang/String;Ljava/lang/Class;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public fun setBreadcrumbs (Ljava/util/Collection;)V + public fun setContexts (Lio/sentry/protocol/Contexts;)V + public fun setExtras (Ljava/util/Map;)V + public fun setFingerprint (Ljava/util/Collection;)V + public fun setLevel (Lio/sentry/SentryLevel;)V + public fun setRequest (Lio/sentry/protocol/Request;)V + public fun setTags (Ljava/util/Map;)V + public fun setTrace (Lio/sentry/SpanContext;)V + public fun setTransaction (Ljava/lang/String;)V + public fun setUser (Lio/sentry/protocol/User;)V +} + public final class io/sentry/clientreport/ClientReport : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public fun (Ljava/util/Date;Ljava/util/List;)V public fun getDiscardedEvents ()Ljava/util/List; @@ -2347,6 +2439,16 @@ public abstract interface class io/sentry/hints/AbnormalExit { public abstract interface class io/sentry/hints/ApplyScopeData { } +public abstract interface class io/sentry/hints/Backfillable { + public abstract fun shouldEnrich ()Z +} + +public abstract class io/sentry/hints/BlockingFlushHint : io/sentry/hints/DiskFlushNotification, io/sentry/hints/Flushable { + public fun (JLio/sentry/ILogger;)V + public fun markFlushed ()V + public fun waitFlush ()Z +} + public abstract interface class io/sentry/hints/Cached { } @@ -2555,6 +2657,7 @@ public final class io/sentry/profilemeasurements/ProfileMeasurementValue$JsonKey public final class io/sentry/protocol/App : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field TYPE Ljava/lang/String; public fun ()V + public fun equals (Ljava/lang/Object;)Z public fun getAppBuild ()Ljava/lang/String; public fun getAppIdentifier ()Ljava/lang/String; public fun getAppName ()Ljava/lang/String; @@ -2565,6 +2668,7 @@ public final class io/sentry/protocol/App : io/sentry/JsonSerializable, io/sentr public fun getInForeground ()Ljava/lang/Boolean; public fun getPermissions ()Ljava/util/Map; public fun getUnknown ()Ljava/util/Map; + public fun hashCode ()I public fun serialize (Lio/sentry/JsonObjectWriter;Lio/sentry/ILogger;)V public fun setAppBuild (Ljava/lang/String;)V public fun setAppIdentifier (Ljava/lang/String;)V @@ -2600,9 +2704,11 @@ public final class io/sentry/protocol/App$JsonKeys { public final class io/sentry/protocol/Browser : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field TYPE Ljava/lang/String; public fun ()V + public fun equals (Ljava/lang/Object;)Z public fun getName ()Ljava/lang/String; public fun getUnknown ()Ljava/util/Map; public fun getVersion ()Ljava/lang/String; + public fun hashCode ()I public fun serialize (Lio/sentry/JsonObjectWriter;Lio/sentry/ILogger;)V public fun setName (Ljava/lang/String;)V public fun setUnknown (Ljava/util/Map;)V @@ -2721,6 +2827,7 @@ public final class io/sentry/protocol/DebugMeta$JsonKeys { public final class io/sentry/protocol/Device : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field TYPE Ljava/lang/String; public fun ()V + public fun equals (Ljava/lang/Object;)Z public fun getArchs ()[Ljava/lang/String; public fun getBatteryLevel ()Ljava/lang/Float; public fun getBatteryTemperature ()Ljava/lang/Float; @@ -2749,6 +2856,7 @@ public final class io/sentry/protocol/Device : io/sentry/JsonSerializable, io/se public fun getTimezone ()Ljava/util/TimeZone; public fun getUnknown ()Ljava/util/Map; public fun getUsableMemory ()Ljava/lang/Long; + public fun hashCode ()I public fun isCharging ()Ljava/lang/Boolean; public fun isLowMemory ()Ljava/lang/Boolean; public fun isOnline ()Ljava/lang/Boolean; @@ -2846,6 +2954,7 @@ public final class io/sentry/protocol/Device$JsonKeys { public final class io/sentry/protocol/Gpu : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field TYPE Ljava/lang/String; public fun ()V + public fun equals (Ljava/lang/Object;)Z public fun getApiType ()Ljava/lang/String; public fun getId ()Ljava/lang/Integer; public fun getMemorySize ()Ljava/lang/Integer; @@ -2855,6 +2964,7 @@ public final class io/sentry/protocol/Gpu : io/sentry/JsonSerializable, io/sentr public fun getVendorId ()Ljava/lang/String; public fun getVendorName ()Ljava/lang/String; public fun getVersion ()Ljava/lang/String; + public fun hashCode ()I public fun isMultiThreadedRendering ()Ljava/lang/Boolean; public fun serialize (Lio/sentry/JsonObjectWriter;Lio/sentry/ILogger;)V public fun setApiType (Ljava/lang/String;)V @@ -2978,12 +3088,14 @@ public final class io/sentry/protocol/Message$JsonKeys { public final class io/sentry/protocol/OperatingSystem : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field TYPE Ljava/lang/String; public fun ()V + public fun equals (Ljava/lang/Object;)Z public fun getBuild ()Ljava/lang/String; public fun getKernelVersion ()Ljava/lang/String; public fun getName ()Ljava/lang/String; public fun getRawDescription ()Ljava/lang/String; public fun getUnknown ()Ljava/util/Map; public fun getVersion ()Ljava/lang/String; + public fun hashCode ()I public fun isRooted ()Ljava/lang/Boolean; public fun serialize (Lio/sentry/JsonObjectWriter;Lio/sentry/ILogger;)V public fun setBuild (Ljava/lang/String;)V @@ -3014,6 +3126,7 @@ public final class io/sentry/protocol/OperatingSystem$JsonKeys { public final class io/sentry/protocol/Request : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public fun ()V public fun (Lio/sentry/protocol/Request;)V + public fun equals (Ljava/lang/Object;)Z public fun getBodySize ()Ljava/lang/Long; public fun getCookies ()Ljava/lang/String; public fun getData ()Ljava/lang/Object; @@ -3025,6 +3138,7 @@ public final class io/sentry/protocol/Request : io/sentry/JsonSerializable, io/s public fun getQueryString ()Ljava/lang/String; public fun getUnknown ()Ljava/util/Map; public fun getUrl ()Ljava/lang/String; + public fun hashCode ()I public fun serialize (Lio/sentry/JsonObjectWriter;Lio/sentry/ILogger;)V public fun setBodySize (Ljava/lang/Long;)V public fun setCookies (Ljava/lang/String;)V @@ -3123,6 +3237,7 @@ public final class io/sentry/protocol/SdkVersion : io/sentry/JsonSerializable, i public fun (Ljava/lang/String;Ljava/lang/String;)V public fun addIntegration (Ljava/lang/String;)V public fun addPackage (Ljava/lang/String;Ljava/lang/String;)V + public fun equals (Ljava/lang/Object;)Z public fun getIntegrationSet ()Ljava/util/Set; public fun getIntegrations ()Ljava/util/List; public fun getName ()Ljava/lang/String; @@ -3130,6 +3245,7 @@ public final class io/sentry/protocol/SdkVersion : io/sentry/JsonSerializable, i public fun getPackages ()Ljava/util/List; public fun getUnknown ()Ljava/util/Map; public fun getVersion ()Ljava/lang/String; + public fun hashCode ()I public fun serialize (Lio/sentry/JsonObjectWriter;Lio/sentry/ILogger;)V public fun setName (Ljava/lang/String;)V public fun setUnknown (Ljava/util/Map;)V @@ -3504,6 +3620,7 @@ public final class io/sentry/protocol/TransactionNameSource : java/lang/Enum { public final class io/sentry/protocol/User : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public fun ()V public fun (Lio/sentry/protocol/User;)V + public fun equals (Ljava/lang/Object;)Z public fun getData ()Ljava/util/Map; public fun getEmail ()Ljava/lang/String; public fun getId ()Ljava/lang/String; @@ -3512,6 +3629,7 @@ public final class io/sentry/protocol/User : io/sentry/JsonSerializable, io/sent public fun getSegment ()Ljava/lang/String; public fun getUnknown ()Ljava/util/Map; public fun getUsername ()Ljava/lang/String; + public fun hashCode ()I public fun serialize (Lio/sentry/JsonObjectWriter;Lio/sentry/ILogger;)V public fun setData (Ljava/util/Map;)V public fun setEmail (Ljava/lang/String;)V diff --git a/sentry/src/main/java/io/sentry/BackfillingEventProcessor.java b/sentry/src/main/java/io/sentry/BackfillingEventProcessor.java new file mode 100644 index 00000000000..2d8d7bc5575 --- /dev/null +++ b/sentry/src/main/java/io/sentry/BackfillingEventProcessor.java @@ -0,0 +1,8 @@ +package io.sentry; + +/** + * Marker interface for event processors that process events that have to be backfilled, i.e. + * currently stored in-memory data (like Scope or SentryOptions) is irrelevant, because the event + * happened in the past. + */ +public interface BackfillingEventProcessor extends EventProcessor {} diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index 868360c6646..07502d7bf96 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -1,6 +1,7 @@ package io.sentry; import io.sentry.util.CollectionUtils; +import io.sentry.util.Objects; import io.sentry.util.UrlUtils; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -434,6 +435,24 @@ public void setLevel(@Nullable SentryLevel level) { this.level = level; } + @SuppressWarnings("JavaUtilDate") + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Breadcrumb that = (Breadcrumb) o; + return timestamp.getTime() == that.timestamp.getTime() + && Objects.equals(message, that.message) + && Objects.equals(type, that.type) + && Objects.equals(category, that.category) + && level == that.level; + } + + @Override + public int hashCode() { + return Objects.hash(timestamp, message, type, category, level); + } + // region json @Nullable diff --git a/sentry/src/main/java/io/sentry/IOptionsObserver.java b/sentry/src/main/java/io/sentry/IOptionsObserver.java new file mode 100644 index 00000000000..519e9222b56 --- /dev/null +++ b/sentry/src/main/java/io/sentry/IOptionsObserver.java @@ -0,0 +1,25 @@ +package io.sentry; + +import io.sentry.protocol.SdkVersion; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A SentryOptions observer that tracks changes of SentryOptions. All methods are "default", so + * implementors can subscribe to only those properties, that they are interested in. + */ +public interface IOptionsObserver { + + default void setRelease(@Nullable String release) {} + + default void setProguardUuid(@Nullable String proguardUuid) {} + + default void setSdkVersion(@Nullable SdkVersion sdkVersion) {} + + default void setEnvironment(@Nullable String environment) {} + + default void setDist(@Nullable String dist) {} + + default void setTags(@NotNull Map tags) {} +} diff --git a/sentry/src/main/java/io/sentry/IScopeObserver.java b/sentry/src/main/java/io/sentry/IScopeObserver.java index 1efd6d7cffd..d8d8bc68e60 100644 --- a/sentry/src/main/java/io/sentry/IScopeObserver.java +++ b/sentry/src/main/java/io/sentry/IScopeObserver.java @@ -1,20 +1,45 @@ package io.sentry; +import io.sentry.protocol.Contexts; +import io.sentry.protocol.Request; import io.sentry.protocol.User; +import java.util.Collection; +import java.util.Map; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -/** Observer for the sync. of Scopes across SDKs */ +/** + * A Scope observer that tracks changes on Scope. All methods are "default", so implementors can + * subscribe to only those properties, that they are interested in. + */ public interface IScopeObserver { - void setUser(@Nullable User user); + default void setUser(@Nullable User user) {} - void addBreadcrumb(@NotNull Breadcrumb crumb); + default void addBreadcrumb(@NotNull Breadcrumb crumb) {} - void setTag(@NotNull String key, @NotNull String value); + default void setBreadcrumbs(@NotNull Collection breadcrumbs) {} - void removeTag(@NotNull String key); + default void setTag(@NotNull String key, @NotNull String value) {} - void setExtra(@NotNull String key, @NotNull String value); + default void removeTag(@NotNull String key) {} - void removeExtra(@NotNull String key); + default void setTags(@NotNull Map tags) {} + + default void setExtra(@NotNull String key, @NotNull String value) {} + + default void removeExtra(@NotNull String key) {} + + default void setExtras(@NotNull Map extras) {} + + default void setRequest(@Nullable Request request) {} + + default void setFingerprint(@NotNull Collection fingerprint) {} + + default void setLevel(@Nullable SentryLevel level) {} + + default void setContexts(@NotNull Contexts contexts) {} + + default void setTransaction(@Nullable String transaction) {} + + default void setTrace(@Nullable SpanContext spanContext) {} } diff --git a/sentry/src/main/java/io/sentry/ISerializer.java b/sentry/src/main/java/io/sentry/ISerializer.java index dd44d108ccd..f74c9ab022b 100644 --- a/sentry/src/main/java/io/sentry/ISerializer.java +++ b/sentry/src/main/java/io/sentry/ISerializer.java @@ -10,6 +10,12 @@ import org.jetbrains.annotations.Nullable; public interface ISerializer { + + @Nullable T deserializeCollection( + @NotNull Reader reader, + @NotNull Class clazz, + @Nullable JsonDeserializer elementDeserializer); + @Nullable T deserialize(@NotNull Reader reader, @NotNull Class clazz); @Nullable diff --git a/sentry/src/main/java/io/sentry/IpAddressUtils.java b/sentry/src/main/java/io/sentry/IpAddressUtils.java index 7562ddf7a6e..5d18220e6e7 100644 --- a/sentry/src/main/java/io/sentry/IpAddressUtils.java +++ b/sentry/src/main/java/io/sentry/IpAddressUtils.java @@ -7,7 +7,7 @@ @ApiStatus.Internal public final class IpAddressUtils { - static final String DEFAULT_IP_ADDRESS = "{{auto}}"; + public static final String DEFAULT_IP_ADDRESS = "{{auto}}"; private static final List DEFAULT_IP_ADDRESS_VALID_VALUES = Arrays.asList(DEFAULT_IP_ADDRESS, "{{ auto }}"); diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index e3de57e9715..6923c548646 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -39,6 +39,7 @@ import java.io.StringWriter; import java.io.Writer; import java.nio.charset.Charset; +import java.util.Collection; import java.util.HashMap; import java.util.Map; import org.jetbrains.annotations.NotNull; @@ -116,6 +117,31 @@ public JsonSerializer(@NotNull SentryOptions options) { // Deserialize + @SuppressWarnings("unchecked") + @Override + public @Nullable T deserializeCollection( + @NotNull Reader reader, + @NotNull Class clazz, + @Nullable JsonDeserializer elementDeserializer) { + try { + JsonObjectReader jsonObjectReader = new JsonObjectReader(reader); + if (Collection.class.isAssignableFrom(clazz)) { + if (elementDeserializer == null) { + // if the object has no known deserializer we do best effort and deserialize it as map + return (T) jsonObjectReader.nextObjectOrNull(); + } + + return (T) jsonObjectReader.nextList(options.getLogger(), elementDeserializer); + } else { + return (T) jsonObjectReader.nextObjectOrNull(); + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error when deserializing", e); + return null; + } + } + + @SuppressWarnings("unchecked") @Override public @Nullable T deserialize(@NotNull Reader reader, @NotNull Class clazz) { try { @@ -124,6 +150,8 @@ public JsonSerializer(@NotNull SentryOptions options) { if (deserializer != null) { Object object = deserializer.deserialize(jsonObjectReader, options.getLogger()); return clazz.cast(object); + } else if (isKnownPrimitive(clazz)) { + return (T) jsonObjectReader.nextObjectOrNull(); } else { return null; // No way to deserialize objects we don't know about. } @@ -223,4 +251,11 @@ public void serialize(@NotNull SentryEnvelope envelope, @NotNull OutputStream ou jsonObjectWriter.value(options.getLogger(), object); return stringWriter.toString(); } + + private boolean isKnownPrimitive(final @NotNull Class clazz) { + return clazz.isArray() + || Collection.class.isAssignableFrom(clazz) + || String.class.isAssignableFrom(clazz) + || Map.class.isAssignableFrom(clazz); + } } diff --git a/sentry/src/main/java/io/sentry/NoOpSerializer.java b/sentry/src/main/java/io/sentry/NoOpSerializer.java index ad517e2e5f4..503c39aec38 100644 --- a/sentry/src/main/java/io/sentry/NoOpSerializer.java +++ b/sentry/src/main/java/io/sentry/NoOpSerializer.java @@ -20,6 +20,14 @@ public static NoOpSerializer getInstance() { private NoOpSerializer() {} + @Override + public @Nullable T deserializeCollection( + @NotNull Reader reader, + @NotNull Class clazz, + @Nullable JsonDeserializer elementDeserializer) { + return null; + } + @Override public @Nullable T deserialize(@NotNull Reader reader, @NotNull Class clazz) { return null; diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index ac276686111..997f93c05d7 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -40,7 +40,7 @@ public final class Scope { private @NotNull List fingerprint = new ArrayList<>(); /** Scope's breadcrumb queue */ - private @NotNull Queue breadcrumbs; + private final @NotNull Queue breadcrumbs; /** Scope's tags */ private @NotNull Map tags = new ConcurrentHashMap<>(); @@ -152,6 +152,10 @@ public Scope(final @NotNull SentryOptions options) { */ public void setLevel(final @Nullable SentryLevel level) { this.level = level; + + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setLevel(level); + } } /** @@ -176,6 +180,10 @@ public void setTransaction(final @NotNull String transaction) { tx.setName(transaction, TransactionNameSource.CUSTOM); } this.transactionName = transaction; + + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setTransaction(transaction); + } } else { options.getLogger().log(SentryLevel.WARNING, "Transaction cannot be null"); } @@ -207,6 +215,16 @@ public ISpan getSpan() { public void setTransaction(final @Nullable ITransaction transaction) { synchronized (transactionLock) { this.transaction = transaction; + + for (final IScopeObserver observer : options.getScopeObservers()) { + if (transaction != null) { + observer.setTransaction(transaction.getName()); + observer.setTrace(transaction.getSpanContext()); + } else { + observer.setTransaction(null); + observer.setTrace(null); + } + } } } @@ -227,10 +245,8 @@ public void setTransaction(final @Nullable ITransaction transaction) { public void setUser(final @Nullable User user) { this.user = user; - if (options.isEnableScopeSync()) { - for (final IScopeObserver observer : options.getScopeObservers()) { - observer.setUser(user); - } + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setUser(user); } } @@ -250,6 +266,10 @@ public void setUser(final @Nullable User user) { */ public void setRequest(final @Nullable Request request) { this.request = request; + + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setRequest(request); + } } /** @@ -272,6 +292,10 @@ public void setFingerprint(final @NotNull List fingerprint) { return; } this.fingerprint = new ArrayList<>(fingerprint); + + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setFingerprint(fingerprint); + } } /** @@ -335,10 +359,9 @@ public void addBreadcrumb(@NotNull Breadcrumb breadcrumb, @Nullable Hint hint) { if (breadcrumb != null) { this.breadcrumbs.add(breadcrumb); - if (options.isEnableScopeSync()) { - for (final IScopeObserver observer : options.getScopeObservers()) { - observer.addBreadcrumb(breadcrumb); - } + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.addBreadcrumb(breadcrumb); + observer.setBreadcrumbs(breadcrumbs); } } else { options.getLogger().log(SentryLevel.INFO, "Breadcrumb was dropped by beforeBreadcrumb"); @@ -358,6 +381,10 @@ public void addBreadcrumb(final @NotNull Breadcrumb breadcrumb) { /** Clear all the breadcrumbs */ public void clearBreadcrumbs() { breadcrumbs.clear(); + + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setBreadcrumbs(breadcrumbs); + } } /** Clears the transaction. */ @@ -366,6 +393,11 @@ public void clearTransaction() { transaction = null; } transactionName = null; + + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setTransaction(null); + observer.setTrace(null); + } } /** @@ -412,10 +444,9 @@ public void clear() { public void setTag(final @NotNull String key, final @NotNull String value) { this.tags.put(key, value); - if (options.isEnableScopeSync()) { - for (final IScopeObserver observer : options.getScopeObservers()) { - observer.setTag(key, value); - } + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setTag(key, value); + observer.setTags(tags); } } @@ -427,10 +458,9 @@ public void setTag(final @NotNull String key, final @NotNull String value) { public void removeTag(final @NotNull String key) { this.tags.remove(key); - if (options.isEnableScopeSync()) { - for (final IScopeObserver observer : options.getScopeObservers()) { - observer.removeTag(key); - } + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.removeTag(key); + observer.setTags(tags); } } @@ -453,10 +483,9 @@ Map getExtras() { public void setExtra(final @NotNull String key, final @NotNull String value) { this.extra.put(key, value); - if (options.isEnableScopeSync()) { - for (final IScopeObserver observer : options.getScopeObservers()) { - observer.setExtra(key, value); - } + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setExtra(key, value); + observer.setExtras(extra); } } @@ -468,10 +497,9 @@ public void setExtra(final @NotNull String key, final @NotNull String value) { public void removeExtra(final @NotNull String key) { this.extra.remove(key); - if (options.isEnableScopeSync()) { - for (final IScopeObserver observer : options.getScopeObservers()) { - observer.removeExtra(key); - } + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.removeExtra(key); + observer.setExtras(extra); } } @@ -492,6 +520,10 @@ public void removeExtra(final @NotNull String key) { */ public void setContexts(final @NotNull String key, final @NotNull Object value) { this.contexts.put(key, value); + + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setContexts(contexts); + } } /** diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 4b420a19a70..27abd369fc5 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -223,6 +223,33 @@ private static synchronized void init( for (final Integration integration : options.getIntegrations()) { integration.register(HubAdapter.getInstance(), options); } + + notifyOptionsObservers(options); + } + + @SuppressWarnings("FutureReturnValueIgnored") + private static void notifyOptionsObservers(final @NotNull SentryOptions options) { + // enqueue a task to trigger the static options change for the observers. Since the executor + // is single-threaded, this task will be enqueued sequentially after all integrations that rely + // on the observers have done their work, even if they do that async. + try { + options + .getExecutorService() + .submit( + () -> { + // for static things like sentry options we can immediately trigger observers + for (final IOptionsObserver observer : options.getOptionsObservers()) { + observer.setRelease(options.getRelease()); + observer.setProguardUuid(options.getProguardUuid()); + observer.setSdkVersion(options.getSdkVersion()); + observer.setDist(options.getDist()); + observer.setEnvironment(options.getEnvironment()); + observer.setTags(options.getTags()); + } + }); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to notify options observers.", e); + } } @SuppressWarnings("FutureReturnValueIgnored") diff --git a/sentry/src/main/java/io/sentry/SentryBaseEvent.java b/sentry/src/main/java/io/sentry/SentryBaseEvent.java index f29a92b1476..ed0593f9a51 100644 --- a/sentry/src/main/java/io/sentry/SentryBaseEvent.java +++ b/sentry/src/main/java/io/sentry/SentryBaseEvent.java @@ -290,7 +290,7 @@ public void setDebugMeta(final @Nullable DebugMeta debugMeta) { } @Nullable - Map getExtras() { + public Map getExtras() { return extra; } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index c5a5299bc12..a34af318016 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -3,7 +3,7 @@ import io.sentry.clientreport.DiscardReason; import io.sentry.exception.SentryEnvelopeException; import io.sentry.hints.AbnormalExit; -import io.sentry.hints.DiskFlushNotification; +import io.sentry.hints.Backfillable; import io.sentry.protocol.Contexts; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryTransaction; @@ -304,7 +304,15 @@ private SentryEvent processEvent( final @NotNull List eventProcessors) { for (final EventProcessor processor : eventProcessors) { try { - event = processor.process(event, hint); + // only wire backfillable events through the backfilling processors, skip from others, and + // the other way around + final boolean isBackfillingProcessor = processor instanceof BackfillingEventProcessor; + final boolean isBackfillable = HintUtils.hasType(hint, Backfillable.class); + if (isBackfillable && isBackfillingProcessor) { + event = processor.process(event, hint); + } else if (!isBackfillable && !isBackfillingProcessor) { + event = processor.process(event, hint); + } } catch (Throwable e) { options .getLogger() @@ -448,9 +456,9 @@ Session updateSessionData( } if (session.update(status, userAgent, crashedOrErrored, abnormalMechanism)) { - // if hint is DiskFlushNotification, it means we have an uncaughtException - // and we can end the session. - if (HintUtils.hasType(hint, DiskFlushNotification.class)) { + // if we have an uncaughtExceptionHint we can end the session. + if (HintUtils.hasType( + hint, UncaughtExceptionHandlerIntegration.UncaughtExceptionHint.class)) { session.end(); } } diff --git a/sentry/src/main/java/io/sentry/SentryEvent.java b/sentry/src/main/java/io/sentry/SentryEvent.java index 86ff9bc880a..8eeea440282 100644 --- a/sentry/src/main/java/io/sentry/SentryEvent.java +++ b/sentry/src/main/java/io/sentry/SentryEvent.java @@ -112,6 +112,10 @@ public Date getTimestamp() { return (Date) timestamp.clone(); } + public void setTimestamp(final @NotNull Date timestamp) { + this.timestamp = timestamp; + } + public @Nullable Message getMessage() { return message; } diff --git a/sentry/src/main/java/io/sentry/SentryExceptionFactory.java b/sentry/src/main/java/io/sentry/SentryExceptionFactory.java index 84755dcb263..49be9100447 100644 --- a/sentry/src/main/java/io/sentry/SentryExceptionFactory.java +++ b/sentry/src/main/java/io/sentry/SentryExceptionFactory.java @@ -12,12 +12,14 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; /** class responsible for converting Java Throwable to SentryExceptions */ -final class SentryExceptionFactory { +@ApiStatus.Internal +public final class SentryExceptionFactory { /** the SentryStackTraceFactory */ private final @NotNull SentryStackTraceFactory sentryStackTraceFactory; @@ -38,7 +40,7 @@ public SentryExceptionFactory(final @NotNull SentryStackTraceFactory sentryStack * @param throwable the {@link Throwable} to build this instance from */ @NotNull - List getSentryExceptions(final @NotNull Throwable throwable) { + public List getSentryExceptions(final @NotNull Throwable throwable) { return getSentryExceptions(extractExceptionQueue(throwable)); } diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 0681ae6fa5a..b90105fd8d3 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -273,7 +273,9 @@ public class SentryOptions { private @Nullable SSLSocketFactory sslSocketFactory; /** list of scope observers */ - private final @NotNull List observers = new ArrayList<>(); + private final @NotNull List observers = new CopyOnWriteArrayList<>(); + + private final @NotNull List optionsObservers = new CopyOnWriteArrayList<>(); /** * Enable the Java to NDK Scope sync. The default value for sentry-java is disabled and enabled @@ -1338,10 +1340,29 @@ public void addScopeObserver(final @NotNull IScopeObserver observer) { * @return the Scope observer list */ @NotNull - List getScopeObservers() { + public List getScopeObservers() { return observers; } + /** + * Adds a SentryOptions observer + * + * @param observer the Observer + */ + public void addOptionsObserver(final @NotNull IOptionsObserver observer) { + optionsObservers.add(observer); + } + + /** + * Returns the list of SentryOptions observers + * + * @return the SentryOptions observer list + */ + @NotNull + public List getOptionsObservers() { + return optionsObservers; + } + /** * Returns if the Java to NDK Scope sync is enabled * diff --git a/sentry/src/main/java/io/sentry/SentryThreadFactory.java b/sentry/src/main/java/io/sentry/SentryThreadFactory.java index a93b04d9c35..9d27741d532 100644 --- a/sentry/src/main/java/io/sentry/SentryThreadFactory.java +++ b/sentry/src/main/java/io/sentry/SentryThreadFactory.java @@ -8,12 +8,14 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; /** class responsible for converting Java Threads to SentryThreads */ -final class SentryThreadFactory { +@ApiStatus.Internal +public final class SentryThreadFactory { /** the SentryStackTraceFactory */ private final @NotNull SentryStackTraceFactory sentryStackTraceFactory; diff --git a/sentry/src/main/java/io/sentry/SpanContext.java b/sentry/src/main/java/io/sentry/SpanContext.java index 6b7a23adb47..7b387a8fb28 100644 --- a/sentry/src/main/java/io/sentry/SpanContext.java +++ b/sentry/src/main/java/io/sentry/SpanContext.java @@ -200,6 +200,24 @@ public void setSamplingDecision(final @Nullable TracesSamplingDecision samplingD this.samplingDecision = samplingDecision; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SpanContext)) return false; + SpanContext that = (SpanContext) o; + return traceId.equals(that.traceId) + && spanId.equals(that.spanId) + && Objects.equals(parentSpanId, that.parentSpanId) + && op.equals(that.op) + && Objects.equals(description, that.description) + && status == that.status; + } + + @Override + public int hashCode() { + return Objects.hash(traceId, spanId, parentSpanId, op, description, status); + } + // region JsonSerializable public static final class JsonKeys { diff --git a/sentry/src/main/java/io/sentry/UncaughtExceptionHandlerIntegration.java b/sentry/src/main/java/io/sentry/UncaughtExceptionHandlerIntegration.java index 746be679716..6924d08fdb0 100644 --- a/sentry/src/main/java/io/sentry/UncaughtExceptionHandlerIntegration.java +++ b/sentry/src/main/java/io/sentry/UncaughtExceptionHandlerIntegration.java @@ -1,18 +1,15 @@ package io.sentry; -import static io.sentry.SentryLevel.ERROR; - +import com.jakewharton.nopen.annotation.Open; import io.sentry.exception.ExceptionMechanismException; -import io.sentry.hints.DiskFlushNotification; -import io.sentry.hints.Flushable; +import io.sentry.hints.BlockingFlushHint; import io.sentry.hints.SessionEnd; import io.sentry.protocol.Mechanism; import io.sentry.protocol.SentryId; import io.sentry.util.HintUtils; import io.sentry.util.Objects; import java.io.Closeable; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -150,33 +147,12 @@ public void close() { } } - private static final class UncaughtExceptionHint - implements DiskFlushNotification, Flushable, SessionEnd { - - private final CountDownLatch latch; - private final long flushTimeoutMillis; - private final @NotNull ILogger logger; - - UncaughtExceptionHint(final long flushTimeoutMillis, final @NotNull ILogger logger) { - this.flushTimeoutMillis = flushTimeoutMillis; - latch = new CountDownLatch(1); - this.logger = logger; - } - - @Override - public boolean waitFlush() { - try { - return latch.await(flushTimeoutMillis, TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.log(ERROR, "Exception while awaiting for flush in UncaughtExceptionHint", e); - } - return false; - } + @Open // open for tests + @ApiStatus.Internal + public static class UncaughtExceptionHint extends BlockingFlushHint implements SessionEnd { - @Override - public void markFlushed() { - latch.countDown(); + public UncaughtExceptionHint(final long flushTimeoutMillis, final @NotNull ILogger logger) { + super(flushTimeoutMillis, logger); } } } diff --git a/sentry/src/main/java/io/sentry/cache/CacheUtils.java b/sentry/src/main/java/io/sentry/cache/CacheUtils.java new file mode 100644 index 00000000000..1eb5f7e19f4 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/CacheUtils.java @@ -0,0 +1,115 @@ +package io.sentry.cache; + +import static io.sentry.SentryLevel.DEBUG; +import static io.sentry.SentryLevel.ERROR; +import static io.sentry.SentryLevel.INFO; + +import io.sentry.JsonDeserializer; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.Charset; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +final class CacheUtils { + + @SuppressWarnings("CharsetObjectCanBeUsed") + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + static void store( + final @NotNull SentryOptions options, + final @NotNull T entity, + final @NotNull String dirName, + final @NotNull String fileName) { + final File cacheDir = ensureCacheDir(options, dirName); + if (cacheDir == null) { + options.getLogger().log(INFO, "Cache dir is not set, cannot store in scope cache"); + return; + } + + final File file = new File(cacheDir, fileName); + if (file.exists()) { + options.getLogger().log(DEBUG, "Overwriting %s in scope cache", fileName); + if (!file.delete()) { + options.getLogger().log(SentryLevel.ERROR, "Failed to delete: %s", file.getAbsolutePath()); + } + } + + try (final OutputStream outputStream = new FileOutputStream(file); + final Writer writer = new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8))) { + options.getSerializer().serialize(entity, writer); + } catch (Throwable e) { + options.getLogger().log(ERROR, e, "Error persisting entity: %s", fileName); + } + } + + static void delete( + final @NotNull SentryOptions options, + final @NotNull String dirName, + final @NotNull String fileName) { + final File cacheDir = ensureCacheDir(options, dirName); + if (cacheDir == null) { + options.getLogger().log(INFO, "Cache dir is not set, cannot delete from scope cache"); + return; + } + + final File file = new File(cacheDir, fileName); + if (file.exists()) { + options.getLogger().log(DEBUG, "Deleting %s from scope cache", fileName); + if (!file.delete()) { + options.getLogger().log(SentryLevel.ERROR, "Failed to delete: %s", file.getAbsolutePath()); + } + } + } + + static @Nullable T read( + final @NotNull SentryOptions options, + final @NotNull String dirName, + final @NotNull String fileName, + final @NotNull Class clazz, + final @Nullable JsonDeserializer elementDeserializer) { + final File cacheDir = ensureCacheDir(options, dirName); + if (cacheDir == null) { + options.getLogger().log(INFO, "Cache dir is not set, cannot read from scope cache"); + return null; + } + + final File file = new File(cacheDir, fileName); + if (file.exists()) { + try (final Reader reader = + new BufferedReader(new InputStreamReader(new FileInputStream(file), UTF_8))) { + if (elementDeserializer == null) { + return options.getSerializer().deserialize(reader, clazz); + } else { + return options.getSerializer().deserializeCollection(reader, clazz, elementDeserializer); + } + } catch (Throwable e) { + options.getLogger().log(ERROR, e, "Error reading entity from scope cache: %s", fileName); + } + } else { + options.getLogger().log(DEBUG, "No entry stored for %s", fileName); + } + return null; + } + + private static @Nullable File ensureCacheDir( + final @NotNull SentryOptions options, final @NotNull String cacheDirName) { + final String cacheDir = options.getCacheDirPath(); + if (cacheDir == null) { + return null; + } + final File dir = new File(cacheDir, cacheDirName); + dir.mkdirs(); + return dir; + } +} diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 973a978fcc7..2e7f71f46da 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -16,7 +16,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.Session; -import io.sentry.hints.DiskFlushNotification; +import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.hints.SessionEnd; import io.sentry.hints.SessionStart; import io.sentry.transport.NoOpEnvelopeCache; @@ -204,7 +204,7 @@ public void store(final @NotNull SentryEnvelope envelope, final @NotNull Hint hi writeEnvelopeToDisk(envelopeFile, envelope); // write file to the disk when its about to crash so crashedLastRun can be marked on restart - if (HintUtils.hasType(hint, DiskFlushNotification.class)) { + if (HintUtils.hasType(hint, UncaughtExceptionHandlerIntegration.UncaughtExceptionHint.class)) { writeCrashMarkerFile(); } } diff --git a/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java new file mode 100644 index 00000000000..296b6025349 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/PersistingOptionsObserver.java @@ -0,0 +1,133 @@ +package io.sentry.cache; + +import static io.sentry.SentryLevel.ERROR; + +import io.sentry.IOptionsObserver; +import io.sentry.JsonDeserializer; +import io.sentry.SentryOptions; +import io.sentry.protocol.SdkVersion; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class PersistingOptionsObserver implements IOptionsObserver { + public static final String OPTIONS_CACHE = ".options-cache"; + public static final String RELEASE_FILENAME = "release.json"; + public static final String PROGUARD_UUID_FILENAME = "proguard-uuid.json"; + public static final String SDK_VERSION_FILENAME = "sdk-version.json"; + public static final String ENVIRONMENT_FILENAME = "environment.json"; + public static final String DIST_FILENAME = "dist.json"; + public static final String TAGS_FILENAME = "tags.json"; + + private final @NotNull SentryOptions options; + + public PersistingOptionsObserver(final @NotNull SentryOptions options) { + this.options = options; + } + + @SuppressWarnings("FutureReturnValueIgnored") + private void serializeToDisk(final @NotNull Runnable task) { + try { + options + .getExecutorService() + .submit( + () -> { + try { + task.run(); + } catch (Throwable e) { + options.getLogger().log(ERROR, "Serialization task failed", e); + } + }); + } catch (Throwable e) { + options.getLogger().log(ERROR, "Serialization task could not be scheduled", e); + } + } + + @Override + public void setRelease(@Nullable String release) { + serializeToDisk( + () -> { + if (release == null) { + delete(RELEASE_FILENAME); + } else { + store(release, RELEASE_FILENAME); + } + }); + } + + @Override + public void setProguardUuid(@Nullable String proguardUuid) { + serializeToDisk( + () -> { + if (proguardUuid == null) { + delete(PROGUARD_UUID_FILENAME); + } else { + store(proguardUuid, PROGUARD_UUID_FILENAME); + } + }); + } + + @Override + public void setSdkVersion(@Nullable SdkVersion sdkVersion) { + serializeToDisk( + () -> { + if (sdkVersion == null) { + delete(SDK_VERSION_FILENAME); + } else { + store(sdkVersion, SDK_VERSION_FILENAME); + } + }); + } + + @Override + public void setDist(@Nullable String dist) { + serializeToDisk( + () -> { + if (dist == null) { + delete(DIST_FILENAME); + } else { + store(dist, DIST_FILENAME); + } + }); + } + + @Override + public void setEnvironment(@Nullable String environment) { + serializeToDisk( + () -> { + if (environment == null) { + delete(ENVIRONMENT_FILENAME); + } else { + store(environment, ENVIRONMENT_FILENAME); + } + }); + } + + @Override + public void setTags(@NotNull Map tags) { + serializeToDisk(() -> store(tags, TAGS_FILENAME)); + } + + private void store(final @NotNull T entity, final @NotNull String fileName) { + CacheUtils.store(options, entity, OPTIONS_CACHE, fileName); + } + + private void delete(final @NotNull String fileName) { + CacheUtils.delete(options, OPTIONS_CACHE, fileName); + } + + public static @Nullable T read( + final @NotNull SentryOptions options, + final @NotNull String fileName, + final @NotNull Class clazz) { + return read(options, fileName, clazz, null); + } + + public static @Nullable T read( + final @NotNull SentryOptions options, + final @NotNull String fileName, + final @NotNull Class clazz, + final @Nullable JsonDeserializer elementDeserializer) { + return CacheUtils.read(options, OPTIONS_CACHE, fileName, clazz, elementDeserializer); + } +} diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java new file mode 100644 index 00000000000..bfccc8d30f9 --- /dev/null +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -0,0 +1,164 @@ +package io.sentry.cache; + +import static io.sentry.SentryLevel.ERROR; + +import io.sentry.Breadcrumb; +import io.sentry.IScopeObserver; +import io.sentry.JsonDeserializer; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.SpanContext; +import io.sentry.protocol.Contexts; +import io.sentry.protocol.Request; +import io.sentry.protocol.User; +import java.util.Collection; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class PersistingScopeObserver implements IScopeObserver { + + public static final String SCOPE_CACHE = ".scope-cache"; + public static final String USER_FILENAME = "user.json"; + public static final String BREADCRUMBS_FILENAME = "breadcrumbs.json"; + public static final String TAGS_FILENAME = "tags.json"; + public static final String EXTRAS_FILENAME = "extras.json"; + public static final String CONTEXTS_FILENAME = "contexts.json"; + public static final String REQUEST_FILENAME = "request.json"; + public static final String LEVEL_FILENAME = "level.json"; + public static final String FINGERPRINT_FILENAME = "fingerprint.json"; + public static final String TRANSACTION_FILENAME = "transaction.json"; + public static final String TRACE_FILENAME = "trace.json"; + + private final @NotNull SentryOptions options; + + public PersistingScopeObserver(final @NotNull SentryOptions options) { + this.options = options; + } + + @Override + public void setUser(final @Nullable User user) { + serializeToDisk( + () -> { + if (user == null) { + delete(USER_FILENAME); + } else { + store(user, USER_FILENAME); + } + }); + } + + @Override + public void setBreadcrumbs(@NotNull Collection breadcrumbs) { + serializeToDisk(() -> store(breadcrumbs, BREADCRUMBS_FILENAME)); + } + + @Override + public void setTags(@NotNull Map tags) { + serializeToDisk(() -> store(tags, TAGS_FILENAME)); + } + + @Override + public void setExtras(@NotNull Map extras) { + serializeToDisk(() -> store(extras, EXTRAS_FILENAME)); + } + + @Override + public void setRequest(@Nullable Request request) { + serializeToDisk( + () -> { + if (request == null) { + delete(REQUEST_FILENAME); + } else { + store(request, REQUEST_FILENAME); + } + }); + } + + @Override + public void setFingerprint(@NotNull Collection fingerprint) { + serializeToDisk(() -> store(fingerprint, FINGERPRINT_FILENAME)); + } + + @Override + public void setLevel(@Nullable SentryLevel level) { + serializeToDisk( + () -> { + if (level == null) { + delete(LEVEL_FILENAME); + } else { + store(level, LEVEL_FILENAME); + } + }); + } + + @Override + public void setTransaction(@Nullable String transaction) { + serializeToDisk( + () -> { + if (transaction == null) { + delete(TRANSACTION_FILENAME); + } else { + store(transaction, TRANSACTION_FILENAME); + } + }); + } + + @Override + public void setTrace(@Nullable SpanContext spanContext) { + serializeToDisk( + () -> { + if (spanContext == null) { + delete(TRACE_FILENAME); + } else { + store(spanContext, TRACE_FILENAME); + } + }); + } + + @Override + public void setContexts(@NotNull Contexts contexts) { + serializeToDisk(() -> store(contexts, CONTEXTS_FILENAME)); + } + + @SuppressWarnings("FutureReturnValueIgnored") + private void serializeToDisk(final @NotNull Runnable task) { + try { + options + .getExecutorService() + .submit( + () -> { + try { + task.run(); + } catch (Throwable e) { + options.getLogger().log(ERROR, "Serialization task failed", e); + } + }); + } catch (Throwable e) { + options.getLogger().log(ERROR, "Serialization task could not be scheduled", e); + } + } + + private void store(final @NotNull T entity, final @NotNull String fileName) { + CacheUtils.store(options, entity, SCOPE_CACHE, fileName); + } + + private void delete(final @NotNull String fileName) { + CacheUtils.delete(options, SCOPE_CACHE, fileName); + } + + public static @Nullable T read( + final @NotNull SentryOptions options, + final @NotNull String fileName, + final @NotNull Class clazz) { + return read(options, fileName, clazz, null); + } + + public static @Nullable T read( + final @NotNull SentryOptions options, + final @NotNull String fileName, + final @NotNull Class clazz, + final @Nullable JsonDeserializer elementDeserializer) { + return CacheUtils.read(options, SCOPE_CACHE, fileName, clazz, elementDeserializer); + } +} diff --git a/sentry/src/main/java/io/sentry/hints/Backfillable.java b/sentry/src/main/java/io/sentry/hints/Backfillable.java new file mode 100644 index 00000000000..26015f5d9b7 --- /dev/null +++ b/sentry/src/main/java/io/sentry/hints/Backfillable.java @@ -0,0 +1,9 @@ +package io.sentry.hints; + +/** + * Marker interface for events that have to be backfilled with the event data (contexts, tags, etc.) + * that is persisted on disk between application launches + */ +public interface Backfillable { + boolean shouldEnrich(); +} diff --git a/sentry/src/main/java/io/sentry/hints/BlockingFlushHint.java b/sentry/src/main/java/io/sentry/hints/BlockingFlushHint.java new file mode 100644 index 00000000000..476d6ce8bcd --- /dev/null +++ b/sentry/src/main/java/io/sentry/hints/BlockingFlushHint.java @@ -0,0 +1,39 @@ +package io.sentry.hints; + +import static io.sentry.SentryLevel.ERROR; + +import io.sentry.ILogger; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public abstract class BlockingFlushHint implements DiskFlushNotification, Flushable { + + private final CountDownLatch latch; + private final long flushTimeoutMillis; + private final @NotNull ILogger logger; + + public BlockingFlushHint(final long flushTimeoutMillis, final @NotNull ILogger logger) { + this.flushTimeoutMillis = flushTimeoutMillis; + latch = new CountDownLatch(1); + this.logger = logger; + } + + @Override + public boolean waitFlush() { + try { + return latch.await(flushTimeoutMillis, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.log(ERROR, "Exception while awaiting for flush in BlockingFlushHint", e); + } + return false; + } + + @Override + public void markFlushed() { + latch.countDown(); + } +} diff --git a/sentry/src/main/java/io/sentry/protocol/App.java b/sentry/src/main/java/io/sentry/protocol/App.java index aa76f567fc1..4ace02b52e6 100644 --- a/sentry/src/main/java/io/sentry/protocol/App.java +++ b/sentry/src/main/java/io/sentry/protocol/App.java @@ -7,6 +7,7 @@ import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; import io.sentry.util.CollectionUtils; +import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.util.Date; @@ -137,6 +138,26 @@ public void setInForeground(final @Nullable Boolean inForeground) { this.inForeground = inForeground; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + App app = (App) o; + return Objects.equals(appIdentifier, app.appIdentifier) + && Objects.equals(appStartTime, app.appStartTime) + && Objects.equals(deviceAppHash, app.deviceAppHash) + && Objects.equals(buildType, app.buildType) + && Objects.equals(appName, app.appName) + && Objects.equals(appVersion, app.appVersion) + && Objects.equals(appBuild, app.appBuild); + } + + @Override + public int hashCode() { + return Objects.hash( + appIdentifier, appStartTime, deviceAppHash, buildType, appName, appVersion, appBuild); + } + // region json @Nullable diff --git a/sentry/src/main/java/io/sentry/protocol/Browser.java b/sentry/src/main/java/io/sentry/protocol/Browser.java index cd5d3df5e1d..260ef1ec64a 100644 --- a/sentry/src/main/java/io/sentry/protocol/Browser.java +++ b/sentry/src/main/java/io/sentry/protocol/Browser.java @@ -7,6 +7,7 @@ import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; import io.sentry.util.CollectionUtils; +import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.util.Map; @@ -48,6 +49,19 @@ public void setVersion(final @Nullable String version) { this.version = version; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Browser browser = (Browser) o; + return Objects.equals(name, browser.name) && Objects.equals(version, browser.version); + } + + @Override + public int hashCode() { + return Objects.hash(name, version); + } + // region json @Nullable diff --git a/sentry/src/main/java/io/sentry/protocol/Device.java b/sentry/src/main/java/io/sentry/protocol/Device.java index 2ca24bc5f2d..296b81d1af9 100644 --- a/sentry/src/main/java/io/sentry/protocol/Device.java +++ b/sentry/src/main/java/io/sentry/protocol/Device.java @@ -7,8 +7,10 @@ import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; import io.sentry.util.CollectionUtils; +import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; +import java.util.Arrays; import java.util.Date; import java.util.List; import java.util.Locale; @@ -403,6 +405,81 @@ public void setBatteryTemperature(final @Nullable Float batteryTemperature) { this.batteryTemperature = batteryTemperature; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Device device = (Device) o; + return Objects.equals(name, device.name) + && Objects.equals(manufacturer, device.manufacturer) + && Objects.equals(brand, device.brand) + && Objects.equals(family, device.family) + && Objects.equals(model, device.model) + && Objects.equals(modelId, device.modelId) + && Arrays.equals(archs, device.archs) + && Objects.equals(batteryLevel, device.batteryLevel) + && Objects.equals(charging, device.charging) + && Objects.equals(online, device.online) + && orientation == device.orientation + && Objects.equals(simulator, device.simulator) + && Objects.equals(memorySize, device.memorySize) + && Objects.equals(freeMemory, device.freeMemory) + && Objects.equals(usableMemory, device.usableMemory) + && Objects.equals(lowMemory, device.lowMemory) + && Objects.equals(storageSize, device.storageSize) + && Objects.equals(freeStorage, device.freeStorage) + && Objects.equals(externalStorageSize, device.externalStorageSize) + && Objects.equals(externalFreeStorage, device.externalFreeStorage) + && Objects.equals(screenWidthPixels, device.screenWidthPixels) + && Objects.equals(screenHeightPixels, device.screenHeightPixels) + && Objects.equals(screenDensity, device.screenDensity) + && Objects.equals(screenDpi, device.screenDpi) + && Objects.equals(bootTime, device.bootTime) + && Objects.equals(id, device.id) + && Objects.equals(language, device.language) + && Objects.equals(locale, device.locale) + && Objects.equals(connectionType, device.connectionType) + && Objects.equals(batteryTemperature, device.batteryTemperature); + } + + @Override + public int hashCode() { + int result = + Objects.hash( + name, + manufacturer, + brand, + family, + model, + modelId, + batteryLevel, + charging, + online, + orientation, + simulator, + memorySize, + freeMemory, + usableMemory, + lowMemory, + storageSize, + freeStorage, + externalStorageSize, + externalFreeStorage, + screenWidthPixels, + screenHeightPixels, + screenDensity, + screenDpi, + bootTime, + timezone, + id, + language, + locale, + connectionType, + batteryTemperature); + result = 31 * result + Arrays.hashCode(archs); + return result; + } + public enum DeviceOrientation implements JsonSerializable { PORTRAIT, LANDSCAPE; diff --git a/sentry/src/main/java/io/sentry/protocol/Gpu.java b/sentry/src/main/java/io/sentry/protocol/Gpu.java index e00462fff6a..b8a5a1e3bc2 100644 --- a/sentry/src/main/java/io/sentry/protocol/Gpu.java +++ b/sentry/src/main/java/io/sentry/protocol/Gpu.java @@ -7,6 +7,7 @@ import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; import io.sentry.util.CollectionUtils; +import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.util.Map; @@ -130,6 +131,36 @@ public void setNpotSupport(final @Nullable String npotSupport) { this.npotSupport = npotSupport; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Gpu gpu = (Gpu) o; + return Objects.equals(name, gpu.name) + && Objects.equals(id, gpu.id) + && Objects.equals(vendorId, gpu.vendorId) + && Objects.equals(vendorName, gpu.vendorName) + && Objects.equals(memorySize, gpu.memorySize) + && Objects.equals(apiType, gpu.apiType) + && Objects.equals(multiThreadedRendering, gpu.multiThreadedRendering) + && Objects.equals(version, gpu.version) + && Objects.equals(npotSupport, gpu.npotSupport); + } + + @Override + public int hashCode() { + return Objects.hash( + name, + id, + vendorId, + vendorName, + memorySize, + apiType, + multiThreadedRendering, + version, + npotSupport); + } + // region JsonSerializable public static final class JsonKeys { diff --git a/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java b/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java index a305a17eccb..6a8107aaa0e 100644 --- a/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java +++ b/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java @@ -7,6 +7,7 @@ import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; import io.sentry.util.CollectionUtils; +import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.util.Map; @@ -103,6 +104,24 @@ public void setRooted(final @Nullable Boolean rooted) { this.rooted = rooted; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + OperatingSystem that = (OperatingSystem) o; + return Objects.equals(name, that.name) + && Objects.equals(version, that.version) + && Objects.equals(rawDescription, that.rawDescription) + && Objects.equals(build, that.build) + && Objects.equals(kernelVersion, that.kernelVersion) + && Objects.equals(rooted, that.rooted); + } + + @Override + public int hashCode() { + return Objects.hash(name, version, rawDescription, build, kernelVersion, rooted); + } + // JsonSerializable public static final class JsonKeys { diff --git a/sentry/src/main/java/io/sentry/protocol/Request.java b/sentry/src/main/java/io/sentry/protocol/Request.java index b25ad49f9f4..2e51a231979 100644 --- a/sentry/src/main/java/io/sentry/protocol/Request.java +++ b/sentry/src/main/java/io/sentry/protocol/Request.java @@ -7,6 +7,7 @@ import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; import io.sentry.util.CollectionUtils; +import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.util.Map; @@ -191,19 +192,6 @@ public void setOthers(final @Nullable Map other) { this.other = CollectionUtils.newConcurrentHashMap(other); } - // region json - - @Nullable - @Override - public Map getUnknown() { - return unknown; - } - - @Override - public void setUnknown(@Nullable Map unknown) { - this.unknown = unknown; - } - public @Nullable String getFragment() { return fragment; } @@ -220,6 +208,39 @@ public void setBodySize(final @Nullable Long bodySize) { this.bodySize = bodySize; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Request request = (Request) o; + return Objects.equals(url, request.url) + && Objects.equals(method, request.method) + && Objects.equals(queryString, request.queryString) + && Objects.equals(cookies, request.cookies) + && Objects.equals(headers, request.headers) + && Objects.equals(env, request.env) + && Objects.equals(bodySize, request.bodySize) + && Objects.equals(fragment, request.fragment); + } + + @Override + public int hashCode() { + return Objects.hash(url, method, queryString, cookies, headers, env, bodySize, fragment); + } + + // region json + + @Nullable + @Override + public Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + public static final class JsonKeys { public static final String URL = "url"; public static final String METHOD = "method"; diff --git a/sentry/src/main/java/io/sentry/protocol/SdkVersion.java b/sentry/src/main/java/io/sentry/protocol/SdkVersion.java index e8dda2e6556..eaa0aa34c91 100644 --- a/sentry/src/main/java/io/sentry/protocol/SdkVersion.java +++ b/sentry/src/main/java/io/sentry/protocol/SdkVersion.java @@ -160,6 +160,19 @@ public void addIntegration(final @NotNull String integration) { return sdk; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SdkVersion that = (SdkVersion) o; + return name.equals(that.name) && version.equals(that.version); + } + + @Override + public int hashCode() { + return Objects.hash(name, version); + } + // JsonKeys public static final class JsonKeys { diff --git a/sentry/src/main/java/io/sentry/protocol/User.java b/sentry/src/main/java/io/sentry/protocol/User.java index 86a5992a069..b696e4d5b8b 100644 --- a/sentry/src/main/java/io/sentry/protocol/User.java +++ b/sentry/src/main/java/io/sentry/protocol/User.java @@ -7,6 +7,7 @@ import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; import io.sentry.util.CollectionUtils; +import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.util.Map; @@ -189,6 +190,23 @@ public void setData(final @Nullable Map data) { this.data = CollectionUtils.newConcurrentHashMap(data); } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + User user = (User) o; + return Objects.equals(email, user.email) + && Objects.equals(id, user.id) + && Objects.equals(username, user.username) + && Objects.equals(segment, user.segment) + && Objects.equals(ipAddress, user.ipAddress); + } + + @Override + public int hashCode() { + return Objects.hash(email, id, username, segment, ipAddress); + } + // region json @Nullable diff --git a/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java b/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java index 49ff077b114..4d4a3842e94 100644 --- a/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java +++ b/sentry/src/main/java/io/sentry/transport/AsyncHttpTransport.java @@ -6,6 +6,7 @@ import io.sentry.SentryEnvelope; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.UncaughtExceptionHandlerIntegration; import io.sentry.cache.IEnvelopeCache; import io.sentry.clientreport.DiscardReason; import io.sentry.hints.Cached; @@ -84,7 +85,8 @@ public void send(final @NotNull SentryEnvelope envelope, final @NotNull Hint hin } } else { SentryEnvelope envelopeThatMayIncludeClientReport; - if (HintUtils.hasType(hint, DiskFlushNotification.class)) { + if (HintUtils.hasType( + hint, UncaughtExceptionHandlerIntegration.UncaughtExceptionHint.class)) { envelopeThatMayIncludeClientReport = options.getClientReportRecorder().attachReportToEnvelope(filteredEnvelope); } else { diff --git a/sentry/src/main/java/io/sentry/util/HintUtils.java b/sentry/src/main/java/io/sentry/util/HintUtils.java index fcf752ba56f..60e519a7e86 100644 --- a/sentry/src/main/java/io/sentry/util/HintUtils.java +++ b/sentry/src/main/java/io/sentry/util/HintUtils.java @@ -9,6 +9,7 @@ import io.sentry.Hint; import io.sentry.ILogger; import io.sentry.hints.ApplyScopeData; +import io.sentry.hints.Backfillable; import io.sentry.hints.Cached; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -99,7 +100,8 @@ public static void runIfHasType( * @return true if it should apply scope's data or false otherwise */ public static boolean shouldApplyScopeData(@NotNull Hint hint) { - return !hasType(hint, Cached.class) || hasType(hint, ApplyScopeData.class); + return (!hasType(hint, Cached.class) && !hasType(hint, Backfillable.class)) + || hasType(hint, ApplyScopeData.class); } @FunctionalInterface diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index cf54a1a7497..0654a890a2e 100644 --- a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt @@ -1132,6 +1132,64 @@ class JsonSerializerTest { verify(stream, never()).close() } + @Test + fun `known primitives can be deserialized`() { + val string = serializeToString("value") + val collection = serializeToString(listOf("hello", "hallo")) + val map = serializeToString(mapOf("one" to "two")) + + val deserializedString = fixture.serializer.deserialize(StringReader(string), String::class.java) + val deserializedCollection = fixture.serializer.deserialize(StringReader(collection), List::class.java) + val deserializedMap = fixture.serializer.deserialize(StringReader(map), Map::class.java) + + assertEquals("value", deserializedString) + assertEquals(listOf("hello", "hallo"), deserializedCollection) + assertEquals(mapOf("one" to "two"), deserializedMap) + } + + @Test + fun `collection with element deserializer can be deserialized`() { + val breadcrumb1 = Breadcrumb.debug("test") + val breadcrumb2 = Breadcrumb.navigation("one", "other") + val collection = serializeToString(listOf(breadcrumb1, breadcrumb2)) + + val deserializedCollection = fixture.serializer.deserializeCollection(StringReader(collection), List::class.java, Breadcrumb.Deserializer()) + + assertEquals(listOf(breadcrumb1, breadcrumb2), deserializedCollection) + } + + @Test + fun `collection without element deserializer can be deserialized as map`() { + val timestamp = Date(0) + val timestampSerialized = serializeToString(timestamp) + .removePrefix("\"") + .removeSuffix("\"") + val collection = serializeToString( + listOf( + Breadcrumb(timestamp).apply { message = "test" }, + Breadcrumb(timestamp).apply { category = "navigation" } + ) + ) + + val deserializedCollection = fixture.serializer.deserialize(StringReader(collection), List::class.java) + + assertEquals( + listOf( + mapOf( + "data" to emptyMap(), + "message" to "test", + "timestamp" to timestampSerialized + ), + mapOf( + "data" to emptyMap(), + "category" to "navigation", + "timestamp" to timestampSerialized + ) + ), + deserializedCollection + ) + } + private fun assertSessionData(expectedSession: Session?) { assertNotNull(expectedSession) assertEquals(UUID.fromString("c81d4e2e-bcf2-11e6-869b-7df92533d2db"), expectedSession.sessionId) diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index a6afd4e57a9..628c15cad9b 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -1,17 +1,20 @@ package io.sentry +import io.sentry.SentryLevel.WARNING import io.sentry.protocol.Request import io.sentry.protocol.User import io.sentry.test.callMethod import org.junit.Assert.assertArrayEquals -import org.mockito.kotlin.any +import org.mockito.kotlin.argThat import org.mockito.kotlin.eq import org.mockito.kotlin.mock -import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNotSame import kotlin.test.assertNull @@ -107,7 +110,8 @@ class ScopeTest { scope.setTag("tag", "tag") scope.setExtra("extra", "extra") - val transaction = SentryTracer(TransactionContext("transaction-name", "op"), NoOpHub.getInstance()) + val transaction = + SentryTracer(TransactionContext("transaction-name", "op"), NoOpHub.getInstance()) scope.transaction = transaction val attachment = Attachment("path/log.txt") @@ -177,7 +181,12 @@ class ScopeTest { user.id = "456" request.method = "post" - scope.setTransaction(SentryTracer(TransactionContext("newTransaction", "op"), NoOpHub.getInstance())) + scope.setTransaction( + SentryTracer( + TransactionContext("newTransaction", "op"), + NoOpHub.getInstance() + ) + ) // because you can only set a new list to scope val newFingerprints = mutableListOf("def", "ghf") @@ -564,10 +573,9 @@ class ScopeTest { } @Test - fun `Scope set user sync scopes if enabled`() { + fun `Scope set user sync scopes`() { val observer = mock() val options = SentryOptions().apply { - isEnableScopeSync = true addScopeObserver(observer) } val scope = Scope(options) @@ -578,141 +586,225 @@ class ScopeTest { } @Test - fun `Scope set user wont sync scopes if disabled`() { + fun `Scope add breadcrumb sync scopes`() { val observer = mock() val options = SentryOptions().apply { addScopeObserver(observer) } val scope = Scope(options) - scope.user = User() - verify(observer, never()).setUser(any()) + val breadcrumb = Breadcrumb() + scope.addBreadcrumb(breadcrumb) + verify(observer).addBreadcrumb(eq(breadcrumb)) + verify(observer).setBreadcrumbs(argThat { elementAt(0) == breadcrumb }) + + scope.addBreadcrumb(Breadcrumb.debug("test")) + verify(observer).addBreadcrumb(argThat { message == "test" }) + verify(observer, times(2)).setBreadcrumbs( + argThat { + elementAt(0) == breadcrumb && elementAt(1).message == "test" + } + ) } @Test - fun `Scope add breadcrumb sync scopes if enabled`() { + fun `Scope clear breadcrumbs sync scopes`() { val observer = mock() val options = SentryOptions().apply { - isEnableScopeSync = true addScopeObserver(observer) } val scope = Scope(options) - val breadrumb = Breadcrumb() - scope.addBreadcrumb(breadrumb) - verify(observer).addBreadcrumb(eq(breadrumb)) + val breadcrumb = Breadcrumb() + scope.addBreadcrumb(breadcrumb) + assertFalse(scope.breadcrumbs.isEmpty()) + + scope.clearBreadcrumbs() + verify(observer, times(2)).setBreadcrumbs(argThat { isEmpty() }) } @Test - fun `Scope add breadcrumb wont sync scopes if disabled`() { + fun `Scope set tag sync scopes`() { val observer = mock() val options = SentryOptions().apply { addScopeObserver(observer) } val scope = Scope(options) - scope.addBreadcrumb(Breadcrumb()) - verify(observer, never()).addBreadcrumb(any()) + scope.setTag("a", "b") + verify(observer).setTag(eq("a"), eq("b")) + verify(observer).setTags(argThat { get("a") == "b" }) + + scope.setTag("one", "two") + verify(observer).setTag(eq("one"), eq("two")) + verify(observer, times(2)).setTags(argThat { get("a") == "b" && get("one") == "two" }) } @Test - fun `Scope set tag sync scopes if enabled`() { + fun `Scope remove tag sync scopes`() { val observer = mock() val options = SentryOptions().apply { - isEnableScopeSync = true addScopeObserver(observer) } val scope = Scope(options) scope.setTag("a", "b") - verify(observer).setTag(eq("a"), eq("b")) + assertFalse(scope.tags.isEmpty()) + + scope.removeTag("a") + verify(observer).removeTag(eq("a")) + verify(observer, times(2)).setTags(emptyMap()) } @Test - fun `Scope set tag wont sync scopes if disabled`() { + fun `Scope set extra sync scopes`() { val observer = mock() val options = SentryOptions().apply { addScopeObserver(observer) } val scope = Scope(options) - scope.setTag("a", "b") - verify(observer, never()).setTag(any(), any()) + scope.setExtra("a", "b") + verify(observer).setExtra(eq("a"), eq("b")) + verify(observer).setExtras(argThat { get("a") == "b" }) + + scope.setExtra("one", "two") + verify(observer).setExtra(eq("one"), eq("two")) + verify(observer, times(2)).setExtras(argThat { get("a") == "b" && get("one") == "two" }) } @Test - fun `Scope remove tag sync scopes if enabled`() { + fun `Scope remove extra sync scopes`() { val observer = mock() val options = SentryOptions().apply { - isEnableScopeSync = true addScopeObserver(observer) } val scope = Scope(options) - scope.removeTag("a") - verify(observer).removeTag(eq("a")) + scope.setExtra("a", "b") + assertFalse(scope.extras.isEmpty()) + + scope.removeExtra("a") + verify(observer).removeExtra(eq("a")) + verify(observer, times(2)).setExtras(emptyMap()) } @Test - fun `Scope remove tag wont sync scopes if disabled`() { + fun `Scope set level sync scopes`() { val observer = mock() val options = SentryOptions().apply { addScopeObserver(observer) } val scope = Scope(options) - scope.removeTag("a") - verify(observer, never()).removeTag(any()) + scope.level = WARNING + verify(observer).setLevel(eq(WARNING)) } @Test - fun `Scope set extra sync scopes if enabled`() { + fun `Scope set transaction name sync scopes`() { val observer = mock() val options = SentryOptions().apply { - isEnableScopeSync = true addScopeObserver(observer) } val scope = Scope(options) - scope.setExtra("a", "b") - verify(observer).setExtra(eq("a"), eq("b")) + scope.setTransaction("main") + verify(observer).setTransaction(eq("main")) } @Test - fun `Scope set extra wont sync scopes if disabled`() { + fun `Scope set transaction sync scopes`() { val observer = mock() val options = SentryOptions().apply { addScopeObserver(observer) } val scope = Scope(options) - scope.setExtra("a", "b") - verify(observer, never()).setExtra(any(), any()) + scope.transaction = mock { + whenever(mock.name).thenReturn("main") + whenever(mock.spanContext).thenReturn(SpanContext("ui.load")) + } + verify(observer).setTransaction(eq("main")) + verify(observer).setTrace(argThat { operation == "ui.load" }) } @Test - fun `Scope remove extra sync scopes if enabled`() { + fun `Scope set transaction null sync scopes`() { val observer = mock() val options = SentryOptions().apply { - isEnableScopeSync = true addScopeObserver(observer) } val scope = Scope(options) - scope.removeExtra("a") - verify(observer).removeExtra(eq("a")) + scope.transaction = null + verify(observer).setTransaction(null) + verify(observer).setTrace(null) } @Test - fun `Scope remove extra wont sync scopes if enabled`() { + fun `Scope clear transaction sync scopes`() { val observer = mock() val options = SentryOptions().apply { addScopeObserver(observer) } val scope = Scope(options) - scope.removeExtra("a") - verify(observer, never()).removeExtra(any()) + scope.transaction = mock { + whenever(mock.name).thenReturn("main") + whenever(mock.spanContext).thenReturn(SpanContext("ui.load")) + } + verify(observer).setTransaction(eq("main")) + verify(observer).setTrace(argThat { operation == "ui.load" }) + + scope.clearTransaction() + verify(observer).setTransaction(null) + verify(observer).setTrace(null) + } + + @Test + fun `Scope set request sync scopes`() { + val observer = mock() + val options = SentryOptions().apply { + addScopeObserver(observer) + } + val scope = Scope(options) + + scope.request = Request().apply { url = "https://google.com" } + verify(observer).setRequest(argThat { url == "https://google.com" }) + } + + @Test + fun `Scope set fingerprint sync scopes`() { + val observer = mock() + val options = SentryOptions().apply { + addScopeObserver(observer) + } + val scope = Scope(options) + + scope.fingerprint = listOf("finger", "print") + verify(observer).setFingerprint( + argThat { + elementAt(0) == "finger" && elementAt(1) == "print" + } + ) + } + + @Test + fun `Scope set contexts sync scopes`() { + val observer = mock() + val options = SentryOptions().apply { + addScopeObserver(observer) + } + val scope = Scope(options) + + data class Obj(val stuff: Int) + scope.setContexts("test", Obj(3)) + verify(observer).setContexts( + argThat { + (get("test") as Obj).stuff == 3 + } + ) } @Test @@ -831,7 +923,8 @@ class ScopeTest { @Test fun `when transaction is started, sets transaction name on the transaction object`() { val scope = Scope(SentryOptions()) - val sentryTransaction = SentryTracer(TransactionContext("transaction-name", "op"), NoOpHub.getInstance()) + val sentryTransaction = + SentryTracer(TransactionContext("transaction-name", "op"), NoOpHub.getInstance()) scope.transaction = sentryTransaction assertEquals("transaction-name", scope.transactionName) scope.setTransaction("new-name") @@ -844,7 +937,8 @@ class ScopeTest { fun `when transaction is set after transaction name is set, clearing transaction does not bring back old transaction name`() { val scope = Scope(SentryOptions()) scope.setTransaction("transaction-a") - val sentryTransaction = SentryTracer(TransactionContext("transaction-name", "op"), NoOpHub.getInstance()) + val sentryTransaction = + SentryTracer(TransactionContext("transaction-name", "op"), NoOpHub.getInstance()) scope.setTransaction(sentryTransaction) assertEquals("transaction-name", scope.transactionName) scope.clearTransaction() @@ -854,7 +948,8 @@ class ScopeTest { @Test fun `withTransaction returns the current Transaction bound to the Scope`() { val scope = Scope(SentryOptions()) - val sentryTransaction = SentryTracer(TransactionContext("transaction-name", "op"), NoOpHub.getInstance()) + val sentryTransaction = + SentryTracer(TransactionContext("transaction-name", "op"), NoOpHub.getInstance()) scope.setTransaction(sentryTransaction) scope.withTransaction { diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index f7866832115..c57c97f9f25 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -7,8 +7,8 @@ import io.sentry.clientreport.DropEverythingEventProcessor import io.sentry.exception.SentryEnvelopeException import io.sentry.hints.AbnormalExit import io.sentry.hints.ApplyScopeData +import io.sentry.hints.Backfillable import io.sentry.hints.Cached -import io.sentry.hints.DiskFlushNotification import io.sentry.protocol.Mechanism import io.sentry.protocol.Request import io.sentry.protocol.SdkVersion @@ -695,6 +695,51 @@ class SentryClientTest { ) } + @Test + fun `backfillable events are only wired through backfilling processors`() { + val backfillingProcessor = mock() + val nonBackfillingProcessor = mock() + fixture.sentryOptions.addEventProcessor(backfillingProcessor) + fixture.sentryOptions.addEventProcessor(nonBackfillingProcessor) + + val event = SentryEvent() + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + + fixture.getSut().captureEvent(event, hint) + + verify(backfillingProcessor).process(eq(event), eq(hint)) + verify(nonBackfillingProcessor, never()).process(any(), anyOrNull()) + } + + @Test + fun `scope is not applied to backfillable events`() { + val event = SentryEvent() + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + val scope = createScope() + + fixture.getSut().captureEvent(event, scope, hint) + + assertNull(event.user) + assertNull(event.level) + assertNull(event.breadcrumbs) + assertNull(event.request) + } + + @Test + fun `non-backfillable events are only wired through regular processors`() { + val backfillingProcessor = mock() + val nonBackfillingProcessor = mock() + fixture.sentryOptions.addEventProcessor(backfillingProcessor) + fixture.sentryOptions.addEventProcessor(nonBackfillingProcessor) + + val event = SentryEvent() + + fixture.getSut().captureEvent(event) + + verify(backfillingProcessor, never()).process(any(), anyOrNull()) + verify(nonBackfillingProcessor).process(eq(event), anyOrNull()) + } + @Test fun `transaction dropped by beforeSendTransaction is recorded`() { fixture.sentryOptions.setBeforeSendTransaction { transaction, hint -> @@ -2206,10 +2251,6 @@ class SentryClientTest { override fun mechanism(): String? = mechanism } - internal class DiskFlushNotificationHint : DiskFlushNotification { - override fun markFlushed() {} - } - private fun eventProcessorThrows(): EventProcessor { return object : EventProcessor { override fun process(event: SentryEvent, hint: Hint): SentryEvent? { @@ -2217,6 +2258,10 @@ class SentryClientTest { } } } + + private class BackfillableHint : Backfillable { + override fun shouldEnrich(): Boolean = false + } } class DropEverythingEventProcessor : EventProcessor { diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 2314fe55aec..85ca667436e 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -320,6 +320,16 @@ class SentryOptionsTest { assertTrue(options.scopeObservers.contains(observer)) } + @Test + fun `when adds options observer, observer list has it`() { + val observer = mock() + val options = SentryOptions().apply { + addOptionsObserver(observer) + } + + assertTrue(options.optionsObservers.contains(observer)) + } + @Test fun `copies options from another SentryOptions instance`() { val externalOptions = ExternalOptions() diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index a84cf32dcc0..b43d9c3491b 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -4,9 +4,12 @@ import io.sentry.cache.EnvelopeCache import io.sentry.cache.IEnvelopeCache import io.sentry.internal.modules.CompositeModulesLoader import io.sentry.internal.modules.IModulesLoader +import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryId +import io.sentry.test.ImmediateExecutorService import io.sentry.util.thread.IMainThreadChecker import io.sentry.util.thread.MainThreadChecker +import org.awaitility.kotlin.await import org.junit.rules.TemporaryFolder import org.mockito.kotlin.any import org.mockito.kotlin.argThat @@ -16,6 +19,7 @@ import org.mockito.kotlin.verify import java.io.File import java.nio.file.Files import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -112,7 +116,10 @@ class SentryTest { it.setDebug(true) it.setLogger(logger) } - verify(logger).log(eq(SentryLevel.WARNING), eq("Sentry has been already initialized. Previous configuration will be overwritten.")) + verify(logger).log( + eq(SentryLevel.WARNING), + eq("Sentry has been already initialized. Previous configuration will be overwritten.") + ) } @Test @@ -124,7 +131,10 @@ class SentryTest { it.setDebug(true) it.setLogger(logger) } - verify(logger).log(eq(SentryLevel.WARNING), eq("Sentry has been already initialized. Previous configuration will be overwritten.")) + verify(logger).log( + eq(SentryLevel.WARNING), + eq("Sentry has been already initialized. Previous configuration will be overwritten.") + ) } @Test @@ -500,6 +510,106 @@ class SentryTest { verify(hub).reportFullyDisplayed() } + @Test + fun `init notifies option observers`() { + val optionsObserver = InMemoryOptionsObserver() + + Sentry.init { + it.dsn = dsn + + it.executorService = ImmediateExecutorService() + + it.addOptionsObserver(optionsObserver) + + it.release = "io.sentry.sample@1.1.0+220" + it.proguardUuid = "uuid" + it.dist = "220" + it.sdkVersion = SdkVersion("sentry.java.android", "6.13.0") + it.environment = "debug" + it.setTag("one", "two") + } + + assertEquals("io.sentry.sample@1.1.0+220", optionsObserver.release) + assertEquals("debug", optionsObserver.environment) + assertEquals("220", optionsObserver.dist) + assertEquals("uuid", optionsObserver.proguardUuid) + assertEquals(mapOf("one" to "two"), optionsObserver.tags) + assertEquals(SdkVersion("sentry.java.android", "6.13.0"), optionsObserver.sdkVersion) + } + + @Test + fun `if there is work enqueued, init notifies options observers after that work is done`() { + val optionsObserver = InMemoryOptionsObserver().apply { + setRelease("io.sentry.sample@2.0.0") + setEnvironment("production") + } + val triggered = AtomicBoolean(false) + + Sentry.init { + it.dsn = dsn + + it.addOptionsObserver(optionsObserver) + + it.release = "io.sentry.sample@1.1.0+220" + it.environment = "debug" + + it.executorService.submit { + // here the values should be still old. Sentry.init will submit another runnable + // to notify the options observers, but because the executor is single-threaded, the + // work will be enqueued and the observers will be notified after current work is + // finished, ensuring that even if something is using the options observer from a + // different thread, it will still use the old values. + Thread.sleep(1000L) + assertEquals("io.sentry.sample@2.0.0", optionsObserver.release) + assertEquals("production", optionsObserver.environment) + triggered.set(true) + } + } + + await.untilTrue(triggered) + assertEquals("io.sentry.sample@1.1.0+220", optionsObserver.release) + assertEquals("debug", optionsObserver.environment) + } + + private class InMemoryOptionsObserver : IOptionsObserver { + var release: String? = null + private set + var environment: String? = null + private set + var proguardUuid: String? = null + private set + var sdkVersion: SdkVersion? = null + private set + var dist: String? = null + private set + var tags: Map = mapOf() + private set + + override fun setRelease(release: String?) { + this.release = release + } + + override fun setEnvironment(environment: String?) { + this.environment = environment + } + + override fun setProguardUuid(proguardUuid: String?) { + this.proguardUuid = proguardUuid + } + + override fun setSdkVersion(sdkVersion: SdkVersion?) { + this.sdkVersion = sdkVersion + } + + override fun setDist(dist: String?) { + this.dist = dist + } + + override fun setTags(tags: MutableMap) { + this.tags = tags + } + } + private class CustomMainThreadChecker : IMainThreadChecker { override fun isMainThread(threadId: Long): Boolean = false } diff --git a/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt b/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt new file mode 100644 index 00000000000..ba42810cd95 --- /dev/null +++ b/sentry/src/test/java/io/sentry/cache/CacheUtilsTest.kt @@ -0,0 +1,87 @@ +package io.sentry.cache + +import io.sentry.SentryOptions +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull + +internal class CacheUtilsTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + @Test + fun `if cacheDir is not set, store does nothing`() { + CacheUtils.store(SentryOptions(), "Hallo!", "stuff", "test.json") + + val (_, file) = tmpDirAndFile() + assertFalse(file.exists()) + } + + @Test + fun `store stores data in the file`() { + val (cacheDir, file) = tmpDirAndFile() + + CacheUtils.store( + SentryOptions().apply { cacheDirPath = cacheDir }, + "Hallo!", + "stuff", + "test.json" + ) + + assertEquals("\"Hallo!\"", file.readText()) + } + + @Test + fun `if cacheDir is not set, read returns null`() { + val value = CacheUtils.read( + SentryOptions(), + "stuff", + "test.json", + String::class.java, + null + ) + + assertNull(value) + } + + @Test + fun `read reads data from the file`() { + val (cacheDir, file) = tmpDirAndFile() + file.writeText("\"Hallo!\"") + + val value = CacheUtils.read( + SentryOptions().apply { cacheDirPath = cacheDir }, + "stuff", + "test.json", + String::class.java, + null + ) + + assertEquals("Hallo!", value) + } + + @Test + fun `delete deletes the file`() { + val (cacheDir, file) = tmpDirAndFile() + file.writeText("Hallo!") + + assertEquals("Hallo!", file.readText()) + + val options = SentryOptions().apply { cacheDirPath = cacheDir } + + CacheUtils.delete(options, "stuff", "test.json") + + assertFalse(file.exists()) + } + + private fun tmpDirAndFile(): Pair { + val cacheDir = tmpDir.newFolder().absolutePath + val dir = File(cacheDir, "stuff").also { it.mkdirs() } + return cacheDir to File(dir, "test.json") + } +} diff --git a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt index dfe7e9b0dd2..2a4ee2dc40d 100644 --- a/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt +++ b/sentry/src/test/java/io/sentry/cache/EnvelopeCacheTest.kt @@ -2,15 +2,16 @@ package io.sentry.cache import io.sentry.ILogger import io.sentry.ISerializer +import io.sentry.NoOpLogger import io.sentry.SentryCrashLastRunState import io.sentry.SentryEnvelope import io.sentry.SentryEvent import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.Session +import io.sentry.UncaughtExceptionHandlerIntegration.UncaughtExceptionHint import io.sentry.cache.EnvelopeCache.PREFIX_CURRENT_SESSION_FILE import io.sentry.cache.EnvelopeCache.SUFFIX_CURRENT_SESSION_FILE -import io.sentry.hints.DiskFlushNotification import io.sentry.hints.SessionEndHint import io.sentry.hints.SessionStartHint import io.sentry.protocol.User @@ -250,7 +251,7 @@ class EnvelopeCacheTest { } @Test - fun `write java marker file to disk when disk flush hint`() { + fun `write java marker file to disk when uncaught exception hint`() { val cache = fixture.getSUT() val markerFile = File(fixture.options.cacheDirPath!!, EnvelopeCache.CRASH_MARKER_FILE) @@ -258,16 +259,12 @@ class EnvelopeCacheTest { val envelope = SentryEnvelope.from(fixture.serializer, SentryEvent(), null) - val hints = HintUtils.createWithTypeCheckHint(DiskFlushHint()) + val hints = HintUtils.createWithTypeCheckHint(UncaughtExceptionHint(0, NoOpLogger.getInstance())) cache.store(envelope, hints) assertTrue(markerFile.exists()) } - internal class DiskFlushHint : DiskFlushNotification { - override fun markFlushed() {} - } - private fun createSession(): Session { return Session("dis", User(), "env", "rel") } diff --git a/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt new file mode 100644 index 00000000000..ded3908f140 --- /dev/null +++ b/sentry/src/test/java/io/sentry/cache/PersistingOptionsObserverTest.kt @@ -0,0 +1,143 @@ +package io.sentry.cache + +import io.sentry.SentryOptions +import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME +import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME +import io.sentry.cache.PersistingOptionsObserver.PROGUARD_UUID_FILENAME +import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME +import io.sentry.cache.PersistingOptionsObserver.SDK_VERSION_FILENAME +import io.sentry.cache.PersistingOptionsObserver.TAGS_FILENAME +import io.sentry.protocol.SdkVersion +import io.sentry.test.ImmediateExecutorService +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.Test +import kotlin.test.assertEquals + +class StoreOptionsValue(private val store: PersistingOptionsObserver.(T) -> Unit) { + operator fun invoke(value: T, observer: PersistingOptionsObserver) { + observer.store(value) + } +} + +class DeleteOptionsValue(private val delete: PersistingOptionsObserver.() -> Unit) { + operator fun invoke(observer: PersistingOptionsObserver) { + observer.delete() + } +} + +@RunWith(Parameterized::class) +class PersistingOptionsObserverTest( + private val entity: T, + private val store: StoreOptionsValue, + private val filename: String, + private val delete: DeleteOptionsValue, + private val deletedEntity: T? +) { + + @get:Rule + val tmpDir = TemporaryFolder() + + class Fixture { + + val options = SentryOptions() + + fun getSut(cacheDir: TemporaryFolder): PersistingOptionsObserver { + options.run { + executorService = ImmediateExecutorService() + cacheDirPath = cacheDir.newFolder().absolutePath + } + return PersistingOptionsObserver(options) + } + } + + private val fixture = Fixture() + + @Test + fun `store and delete scope value`() { + val sut = fixture.getSut(tmpDir) + store(entity, sut) + + val persisted = read() + assertEquals(entity, persisted) + + delete(sut) + val persistedAfterDeletion = read() + assertEquals(deletedEntity, persistedAfterDeletion) + } + + private fun read(): T? = PersistingOptionsObserver.read( + fixture.options, + filename, + entity!!::class.java + ) + + companion object { + + private fun release(): Array = arrayOf( + "io.sentry.sample@1.1.0+23", + StoreOptionsValue { setRelease(it) }, + RELEASE_FILENAME, + DeleteOptionsValue { setRelease(null) }, + null + ) + + private fun proguardUuid(): Array = arrayOf( + "8a258c81-641d-4e54-b06e-a0f56b1ee2ef", + StoreOptionsValue { setProguardUuid(it) }, + PROGUARD_UUID_FILENAME, + DeleteOptionsValue { setProguardUuid(null) }, + null + ) + + private fun sdkVersion(): Array = arrayOf( + SdkVersion("sentry.java.android", "6.13.0"), + StoreOptionsValue { setSdkVersion(it) }, + SDK_VERSION_FILENAME, + DeleteOptionsValue { setSdkVersion(null) }, + null + ) + + private fun dist(): Array = arrayOf( + "223", + StoreOptionsValue { setDist(it) }, + DIST_FILENAME, + DeleteOptionsValue { setDist(null) }, + null + ) + + private fun environment(): Array = arrayOf( + "debug", + StoreOptionsValue { setEnvironment(it) }, + ENVIRONMENT_FILENAME, + DeleteOptionsValue { setEnvironment(null) }, + null + ) + + private fun tags(): Array = arrayOf( + mapOf( + "one" to "two", + "tag" to "none" + ), + StoreOptionsValue> { setTags(it) }, + TAGS_FILENAME, + DeleteOptionsValue { setTags(emptyMap()) }, + emptyMap() + ) + + @JvmStatic + @Parameterized.Parameters(name = "{2}") + fun data(): Collection> { + return listOf( + release(), + proguardUuid(), + dist(), + environment(), + sdkVersion(), + tags() + ) + } + } +} diff --git a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt new file mode 100644 index 00000000000..d31b7088cfd --- /dev/null +++ b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt @@ -0,0 +1,284 @@ +package io.sentry.cache + +import io.sentry.Breadcrumb +import io.sentry.DateUtils +import io.sentry.JsonDeserializer +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.SpanContext +import io.sentry.SpanId +import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME +import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME +import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME +import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME +import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME +import io.sentry.cache.PersistingScopeObserver.REQUEST_FILENAME +import io.sentry.cache.PersistingScopeObserver.TAGS_FILENAME +import io.sentry.cache.PersistingScopeObserver.TRACE_FILENAME +import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME +import io.sentry.cache.PersistingScopeObserver.USER_FILENAME +import io.sentry.protocol.App +import io.sentry.protocol.Browser +import io.sentry.protocol.Contexts +import io.sentry.protocol.Device +import io.sentry.protocol.Device.DeviceOrientation.PORTRAIT +import io.sentry.protocol.Gpu +import io.sentry.protocol.OperatingSystem +import io.sentry.protocol.Request +import io.sentry.protocol.SentryId +import io.sentry.protocol.User +import io.sentry.test.ImmediateExecutorService +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import kotlin.test.Test +import kotlin.test.assertEquals + +class StoreScopeValue(private val store: PersistingScopeObserver.(T) -> Unit) { + operator fun invoke(value: T, observer: PersistingScopeObserver) { + observer.store(value) + } +} + +class DeleteScopeValue(private val delete: PersistingScopeObserver.() -> Unit) { + operator fun invoke(observer: PersistingScopeObserver) { + observer.delete() + } +} + +@RunWith(Parameterized::class) +class PersistingScopeObserverTest( + private val entity: T, + private val store: StoreScopeValue, + private val filename: String, + private val delete: DeleteScopeValue, + private val deletedEntity: T?, + private val elementDeserializer: JsonDeserializer? +) { + + @get:Rule + val tmpDir = TemporaryFolder() + + class Fixture { + + val options = SentryOptions() + + fun getSut(cacheDir: TemporaryFolder): PersistingScopeObserver { + options.run { + executorService = ImmediateExecutorService() + cacheDirPath = cacheDir.newFolder().absolutePath + } + return PersistingScopeObserver(options) + } + } + + private val fixture = Fixture() + + @Test + fun `store and delete scope value`() { + val sut = fixture.getSut(tmpDir) + store(entity, sut) + + val persisted = read() + assertEquals(entity, persisted) + + delete(sut) + val persistedAfterDeletion = read() + assertEquals(deletedEntity, persistedAfterDeletion) + } + + private fun read(): T? = PersistingScopeObserver.read( + fixture.options, + filename, + entity!!::class.java, + elementDeserializer + ) + + companion object { + + private fun user(): Array = arrayOf( + User().apply { + email = "user@user.com" + id = "c4d61c1b-c144-431e-868f-37a46be5e5f2" + ipAddress = "192.168.0.1" + }, + StoreScopeValue { setUser(it) }, + USER_FILENAME, + DeleteScopeValue { setUser(null) }, + null, + null + ) + + private fun breadcrumbs(): Array = arrayOf( + listOf( + Breadcrumb.navigation("one", "two"), + Breadcrumb.userInteraction("click", "viewId", "viewClass") + ), + StoreScopeValue> { setBreadcrumbs(it) }, + BREADCRUMBS_FILENAME, + DeleteScopeValue { setBreadcrumbs(emptyList()) }, + emptyList(), + Breadcrumb.Deserializer() + ) + + private fun tags(): Array = arrayOf( + mapOf( + "one" to "two", + "tag" to "none" + ), + StoreScopeValue> { setTags(it) }, + TAGS_FILENAME, + DeleteScopeValue { setTags(emptyMap()) }, + emptyMap(), + null + ) + + private fun extras(): Array = arrayOf( + mapOf( + "one" to listOf("thing1", "thing2"), + "two" to 2, + "three" to 3.2 + ), + StoreScopeValue> { setExtras(it) }, + EXTRAS_FILENAME, + DeleteScopeValue { setExtras(emptyMap()) }, + emptyMap(), + null + ) + + private fun request(): Array = arrayOf( + Request().apply { + url = "https://google.com" + method = "GET" + queryString = "search" + cookies = "d84f4cfc-5310-4818-ad4f-3f8d22ceaca8" + fragment = "fragment" + bodySize = 1000 + }, + StoreScopeValue { setRequest(it) }, + REQUEST_FILENAME, + DeleteScopeValue { setRequest(null) }, + null, + null + ) + + private fun fingerprint(): Array = arrayOf( + listOf("finger", "print"), + StoreScopeValue> { setFingerprint(it) }, + FINGERPRINT_FILENAME, + DeleteScopeValue { setFingerprint(emptyList()) }, + emptyList(), + null + ) + + private fun level(): Array = arrayOf( + SentryLevel.WARNING, + StoreScopeValue { setLevel(it) }, + LEVEL_FILENAME, + DeleteScopeValue { setLevel(null) }, + null, + null + ) + + private fun transaction(): Array = arrayOf( + "MainActivity", + StoreScopeValue { setTransaction(it) }, + TRANSACTION_FILENAME, + DeleteScopeValue { setTransaction(null) }, + null, + null + ) + + private fun trace(): Array = arrayOf( + SpanContext(SentryId(), SpanId(), "ui.load", null, null), + StoreScopeValue { setTrace(it) }, + TRACE_FILENAME, + DeleteScopeValue { setTrace(null) }, + null, + null + ) + + private fun contexts(): Array = arrayOf( + Contexts().apply { + setApp( + App().apply { + appBuild = "1" + appIdentifier = "io.sentry.sample" + appName = "sample" + appStartTime = DateUtils.getCurrentDateTime() + buildType = "debug" + appVersion = "2021" + } + ) + setBrowser( + Browser().apply { + name = "Chrome" + } + ) + setDevice( + Device().apply { + name = "Pixel 3XL" + manufacturer = "Google" + brand = "Pixel" + family = "Pixels" + model = "3XL" + isCharging = true + isOnline = true + orientation = PORTRAIT + isSimulator = false + memorySize = 4096 + freeMemory = 2048 + usableMemory = 1536 + isLowMemory = false + storageSize = 64000 + freeStorage = 32000 + screenWidthPixels = 1080 + screenHeightPixels = 1920 + screenDpi = 446 + connectionType = "wifi" + batteryTemperature = 37.0f + batteryLevel = 92.0f + locale = "en-US" + } + ) + setGpu( + Gpu().apply { + vendorName = "GeForce" + memorySize = 1000 + } + ) + setOperatingSystem( + OperatingSystem().apply { + isRooted = true + build = "2021.123_alpha" + name = "Android" + version = "12" + } + ) + }, + StoreScopeValue { setContexts(it) }, + CONTEXTS_FILENAME, + DeleteScopeValue { setContexts(Contexts()) }, + Contexts(), + null + ) + + @JvmStatic + @Parameterized.Parameters(name = "{2}") + fun data(): Collection> { + return listOf( + user(), + breadcrumbs(), + tags(), + extras(), + request(), + fingerprint(), + level(), + transaction(), + trace(), + contexts() + ) + } + } +} diff --git a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt index 4bca606ad6f..000f575ecc2 100644 --- a/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt +++ b/sentry/src/test/java/io/sentry/clientreport/ClientReportTest.kt @@ -13,9 +13,9 @@ import io.sentry.SentryEnvelopeItem import io.sentry.SentryEvent import io.sentry.SentryOptions import io.sentry.Session +import io.sentry.UncaughtExceptionHandlerIntegration.UncaughtExceptionHint import io.sentry.UserFeedback import io.sentry.dsnString -import io.sentry.hints.DiskFlushNotification import io.sentry.hints.Retryable import io.sentry.protocol.SentryId import io.sentry.protocol.SentryTransaction @@ -196,8 +196,8 @@ class ClientReportTestHelper(val options: SentryOptions) { companion object { fun retryableHint() = HintUtils.createWithTypeCheckHint(TestRetryable()) - fun diskFlushNotificationHint() = HintUtils.createWithTypeCheckHint(TestDiskFlushNotification()) - fun retryableDiskFlushNotificationHint() = HintUtils.createWithTypeCheckHint(TestRetryableDiskFlushNotification()) + fun uncaughtExceptionHint() = HintUtils.createWithTypeCheckHint(TestUncaughtExceptionHint()) + fun retryableUncaughtExceptionHint() = HintUtils.createWithTypeCheckHint(TestRetryableUncaughtException()) fun assertClientReport(clientReportRecorder: IClientReportRecorder, expectedEvents: List) { val recorder = clientReportRecorder as ClientReportRecorder @@ -229,7 +229,7 @@ class TestRetryable : Retryable { } } -class TestRetryableDiskFlushNotification : Retryable, DiskFlushNotification { +class TestRetryableUncaughtException : UncaughtExceptionHint(0, NoOpLogger.getInstance()), Retryable { private var retry = false var flushed = false @@ -246,7 +246,7 @@ class TestRetryableDiskFlushNotification : Retryable, DiskFlushNotification { } } -class TestDiskFlushNotification : DiskFlushNotification { +class TestUncaughtExceptionHint : UncaughtExceptionHint(0, NoOpLogger.getInstance()) { var flushed = false override fun markFlushed() { diff --git a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportClientReportTest.kt b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportClientReportTest.kt index 6cc9d4a3062..61ba2403b68 100644 --- a/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportClientReportTest.kt +++ b/sentry/src/test/java/io/sentry/transport/AsyncHttpTransportClientReportTest.kt @@ -4,9 +4,9 @@ import io.sentry.SentryEnvelope import io.sentry.SentryOptions import io.sentry.SentryOptionsManipulator import io.sentry.Session -import io.sentry.clientreport.ClientReportTestHelper.Companion.diskFlushNotificationHint -import io.sentry.clientreport.ClientReportTestHelper.Companion.retryableDiskFlushNotificationHint import io.sentry.clientreport.ClientReportTestHelper.Companion.retryableHint +import io.sentry.clientreport.ClientReportTestHelper.Companion.retryableUncaughtExceptionHint +import io.sentry.clientreport.ClientReportTestHelper.Companion.uncaughtExceptionHint import io.sentry.clientreport.DiscardReason import io.sentry.clientreport.IClientReportRecorder import io.sentry.dsnString @@ -160,12 +160,12 @@ class AsyncHttpTransportClientReportTest { } @Test - fun `attaches report and records lost envelope on full queue for non retryable disk flush notification`() { + fun `attaches report and records lost envelope on full queue for non retryable uncaught exception`() { // given givenSetup(cancel = true) // when - fixture.getSUT().send(fixture.envelopeBeforeAttachingClientReport, diskFlushNotificationHint()) + fixture.getSUT().send(fixture.envelopeBeforeAttachingClientReport, uncaughtExceptionHint()) // then verify(fixture.clientReportRecorder, times(1)).attachReportToEnvelope(same(fixture.envelopeBeforeAttachingClientReport)) @@ -174,12 +174,12 @@ class AsyncHttpTransportClientReportTest { } @Test - fun `attaches report and records lost envelope on full queue for retryable disk flush notification`() { + fun `attaches report and records lost envelope on full queue for retryable uncaught exception`() { // given givenSetup(cancel = true) // when - fixture.getSUT().send(fixture.envelopeBeforeAttachingClientReport, retryableDiskFlushNotificationHint()) + fixture.getSUT().send(fixture.envelopeBeforeAttachingClientReport, retryableUncaughtExceptionHint()) // then verify(fixture.clientReportRecorder, times(1)).attachReportToEnvelope(same(fixture.envelopeBeforeAttachingClientReport)) diff --git a/sentry/src/test/java/io/sentry/util/HintUtilsTest.kt b/sentry/src/test/java/io/sentry/util/HintUtilsTest.kt index 5fc37f16fce..598c1b2375e 100644 --- a/sentry/src/test/java/io/sentry/util/HintUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/HintUtilsTest.kt @@ -3,6 +3,7 @@ package io.sentry.util import io.sentry.CustomCachedApplyScopeDataHint import io.sentry.Hint import io.sentry.hints.ApplyScopeData +import io.sentry.hints.Backfillable import io.sentry.hints.Cached import org.mockito.kotlin.mock import kotlin.test.Test @@ -33,4 +34,10 @@ class HintUtilsTest { val hints = HintUtils.createWithTypeCheckHint(CustomCachedApplyScopeDataHint()) assertTrue(HintUtils.shouldApplyScopeData(hints)) } + + @Test + fun `if event is Backfillable, it should not apply scopes data`() { + val hints = HintUtils.createWithTypeCheckHint(mock()) + assertFalse(HintUtils.shouldApplyScopeData(hints)) + } }