diff --git a/firebase-crashlytics/src/androidTest/assets/firebase_settings_heap_dumps.json b/firebase-crashlytics/src/androidTest/assets/firebase_settings_heap_dumps.json new file mode 100644 index 00000000000..37c6a8189cd --- /dev/null +++ b/firebase-crashlytics/src/androidTest/assets/firebase_settings_heap_dumps.json @@ -0,0 +1,22 @@ +{ + "settings_version": 3, + "cache_duration": 7200, + "features": { + "collect_logged_exceptions": true, + "collect_reports": true + }, + "app": { + "status": "activated", + "report_upload_variant": 2, + "native_report_upload_variant": 2, + "update_required": true + }, + "fabric": { + "app_id": "abcd12345abcde12345abcde", + "org_id": "12345abcde12345abcde1234", + "bundle_id": "com.google.firebase.crashlytics.sdk.test" + }, + "profiling": { + "heap_dump_collection_enabled": true + } +} diff --git a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerTest.java b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerTest.java index ae67f55313e..65c00c49ea4 100644 --- a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerTest.java +++ b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerTest.java @@ -32,7 +32,6 @@ import android.content.Context; import android.content.SharedPreferences; import android.os.Bundle; -import android.os.ProfilingTrigger; import androidx.test.filters.SdkSuppress; import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.TaskCompletionSource; @@ -55,6 +54,7 @@ import com.google.firebase.crashlytics.internal.settings.TestSettings; import com.google.firebase.installations.FirebaseInstallationsApi; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -63,7 +63,6 @@ import java.util.TreeSet; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -573,66 +572,36 @@ public void testUploadDisabledThenEnabled() throws Exception { @SdkSuppress(minSdkVersion = 37) // ProfilingManager @Test - public void testWritingProfilingManagerTriggerAnomaly() { + public void testWritingProfilingManagerTriggerHeapDumpEnabledMarker() { LogFileManager logFileManager = new LogFileManager(testFileStore); CrashlyticsController controller = builder().setLogFileManager(logFileManager).build(); - controller.writeTriggerTypeFile("sessionId", ProfilingTrigger.TRIGGER_TYPE_ANOMALY); - - List triggers = - testFileStore - .getSessionFiles( - "sessionId", - (dir, name) -> List.of("trigger-type-anomaly", "trigger-type-oom").contains(name)) - .stream() - .map( - triggerFile -> { - switch (triggerFile.getName()) { - case "trigger-type-anomaly": - return ProfilingTrigger.TRIGGER_TYPE_ANOMALY; - case "trigger-type-oom": - return ProfilingTrigger.TRIGGER_TYPE_OOM; - } - - return ProfilingTrigger.TRIGGER_TYPE_NONE; - }) - .filter(trigger -> trigger != ProfilingTrigger.TRIGGER_TYPE_NONE) - .collect(Collectors.toList()); - - assertFalse(triggers.isEmpty()); - assertEquals(triggers, List.of(ProfilingTrigger.TRIGGER_TYPE_ANOMALY)); + controller.writeTriggerHeapDumpCollectionEnabledMarker("sessionId", true); + + List markers = + testFileStore.getSessionFiles( + "sessionId", (dir, name) -> name.contains("trigger-heap-dump-collection-enabled")); + + assertFalse(markers.isEmpty()); + assertEquals(1, markers.size()); + assertEquals("trigger-heap-dump-collection-enabled", markers.get(0).getName()); } @SdkSuppress(minSdkVersion = 37) // ProfilingTrigger @Test - public void testWritingProfilingManagerTriggerOom() { + public void testWritingProfilingManagerTriggerHeapDumpMarker() throws IOException { LogFileManager logFileManager = new LogFileManager(testFileStore); CrashlyticsController controller = builder().setLogFileManager(logFileManager).build(); - controller.writeTriggerTypeFile("sessionId", ProfilingTrigger.TRIGGER_TYPE_OOM); - - List triggers = - testFileStore - .getSessionFiles( - "sessionId", - (dir, name) -> List.of("trigger-type-anomaly", "trigger-type-oom").contains(name)) - .stream() - .map( - triggerFile -> { - switch (triggerFile.getName()) { - case "trigger-type-anomaly": - return ProfilingTrigger.TRIGGER_TYPE_ANOMALY; - case "trigger-type-oom": - return ProfilingTrigger.TRIGGER_TYPE_OOM; - } - - return ProfilingTrigger.TRIGGER_TYPE_NONE; - }) - .filter(trigger -> trigger != ProfilingTrigger.TRIGGER_TYPE_NONE) - .collect(Collectors.toList()); - - assertFalse(triggers.isEmpty()); - assertEquals(triggers, List.of(ProfilingTrigger.TRIGGER_TYPE_OOM)); + controller.writeTriggerHeapDumpGeneratedMarker("sessionId", "path/to/heap"); + + List markers = + testFileStore.getSessionFiles( + "sessionId", (dir, name) -> name.contains("trigger-heap-dump-generated")); + + assertFalse(markers.isEmpty()); + assertEquals(1, markers.size()); + assertEquals("trigger-heap-dump-generated", markers.get(0).getName()); } @SdkSuppress(minSdkVersion = 30) // ApplicationExitInfo diff --git a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorTest.java b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorTest.java index e79d1b8acb5..927a48d83ed 100644 --- a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorTest.java +++ b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorTest.java @@ -32,9 +32,11 @@ import static org.mockito.Mockito.when; import android.app.ApplicationExitInfo; +import android.content.Context; import android.os.Parcel; import android.system.OsConstants; import androidx.test.filters.SdkSuppress; +import androidx.test.platform.app.InstrumentationRegistry; import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.Tasks; import com.google.firebase.concurrent.TestOnlyExecutors; @@ -50,6 +52,7 @@ import com.google.firebase.crashlytics.internal.persistence.FileStore; import com.google.firebase.crashlytics.internal.send.DataTransportCrashlyticsReportSender; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -652,7 +655,65 @@ public void testIsOom_returnTrueOnReasonLowMemory() { assertTrue(isOom); } + @SdkSuppress(minSdkVersion = 37) + @Test + public void testIsMemoryLimiterKill() { + ApplicationExitInfo mlk = + makeApplicationExitInfo(ApplicationExitInfo.REASON_OTHER, 0, 0, "MemoryLimiter:AnonSwap"); + + boolean isMlk = reportingCoordinator.isMemoryLimiterKill("sessionId", List.of(mlk)); + + assertTrue(isMlk); + } + + @SdkSuppress(minSdkVersion = 37) + @Test + public void testWasHeapDumpGeneratedViaFile_returnsTrueForCorrectSessionTime() + throws IOException { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + File profilingDir = new File(context.getFilesDir(), "profiling"); + assertTrue(profilingDir.mkdir()); + + File heapDump = + new File(context.getFilesDir(), "profiling/com.google.firebase.crashlytics.test.hprof"); + assertTrue(heapDump.createNewFile()); + + long sessionTime = heapDump.lastModified(); + + boolean generated = + SessionReportingCoordinator.wasHeapDumpGeneratedViaFile(context.getFilesDir(), sessionTime); + + assertTrue(generated); + } + + @SdkSuppress(minSdkVersion = 37) + @Test + public void testWasHeapDumpGeneratedViaFile_returnsFalseForIncorrectSessionTime() + throws IOException { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + File profilingDir = new File(context.getFilesDir(), "profiling"); + assertTrue(profilingDir.mkdir()); + + File heapDump = + new File(context.getFilesDir(), "profiling/com.google.firebase.crashlytics.test.hprof"); + assertTrue(heapDump.createNewFile()); + + long sessionTime = heapDump.lastModified() + 100; + + boolean generated = + SessionReportingCoordinator.wasHeapDumpGeneratedViaFile(context.getFilesDir(), sessionTime); + + assertFalse(generated); + } + private ApplicationExitInfo makeApplicationExitInfo(int reason, int subreason, int status) { + return makeApplicationExitInfo(reason, subreason, status, ""); + } + + private ApplicationExitInfo makeApplicationExitInfo( + int reason, int subreason, int status, String description) { Parcel dest = Parcel.obtain(); dest.writeInt(1); @@ -669,7 +730,7 @@ private ApplicationExitInfo makeApplicationExitInfo(int reason, int subreason, i dest.writeLong(1L); dest.writeLong(1L); dest.writeLong(1L); - dest.writeString(""); + dest.writeString(description); dest.writeByteArray(new byte[] {}); dest.setDataPosition(0); diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java index 52473f623f9..11dd633063a 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java @@ -67,7 +67,6 @@ import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.Collectors; class CrashlyticsController { @@ -86,8 +85,12 @@ class CrashlyticsController { private static final String VERSION_CONTROL_INFO_KEY = "com.crashlytics.version-control-info"; private static final String VERSION_CONTROL_INFO_FILE = "version-control-info.textproto"; private static final String META_INF_FOLDER = "META-INF/"; - private static final String TRIGGER_TYPE_ANOMALY_FILENAME = "trigger-type-anomaly"; - private static final String TRIGGER_TYPE_OOM_FILENAME = "trigger-type-oom"; + + private static final String TRIGGER_HEAP_DUMP_GENERATED_MARKER_FILENAME = + "trigger-heap-dump-generated"; + + private static final String TRIGGER_HEAP_DUMP_ENABLED_MARKER_FILENAME = + "trigger-heap-dump-collection-enabled"; private static final Charset UTF_8 = Charset.forName("UTF-8"); @@ -258,6 +261,11 @@ public Task then(@Nullable Settings settings) throws Exception { "Received null app settings, cannot send reports at crash time."); return Tasks.forResult(null); } + + if (ex instanceof OutOfMemoryError) { + Logger.getLogger().i("OOMs will be sent on restart"); + return Tasks.forResult(null); + } // Data collection is enabled, so it's safe to send the report. return Tasks.whenAll( logAnalyticsAppExceptionEvents(), @@ -970,35 +978,31 @@ private void writeApplicationExitInfoEventIfRelevant(String sessionId) { private void writeProfilingManagerInfo(String sessionId) { ActivityManager manager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); - // For anomaly triggers, there should be a file written to disk by the registered consumer. - // However, for OOMs, it is less guaranteed that the process had enough resources to write the - // corresponding file. This is ok because we can check ApplicationExitInfo to see if an OOM - // occurred. - List triggers = - fileStore - .getSessionFiles( - sessionId, - (dir, name) -> - List.of(TRIGGER_TYPE_ANOMALY_FILENAME, TRIGGER_TYPE_OOM_FILENAME) - .contains(name)) - .stream() - .map( - triggerFile -> { - switch (triggerFile.getName()) { - case TRIGGER_TYPE_ANOMALY_FILENAME: - return ProfilingTrigger.TRIGGER_TYPE_ANOMALY; - case TRIGGER_TYPE_OOM_FILENAME: - return ProfilingTrigger.TRIGGER_TYPE_OOM; - } - - return ProfilingTrigger.TRIGGER_TYPE_NONE; - }) - .filter(trigger -> trigger != ProfilingTrigger.TRIGGER_TYPE_NONE) - .collect(Collectors.toList()); + Logger.getLogger().d("Collecting ProfilingManager info..."); + List markerFiles = + fileStore.getSessionFiles( + sessionId, + (dir, name) -> + List.of( + TRIGGER_HEAP_DUMP_GENERATED_MARKER_FILENAME, + TRIGGER_HEAP_DUMP_ENABLED_MARKER_FILENAME) + .contains(name)); List appExits = manager.getHistoricalProcessExitReasons(null, 0, 0); - reportingCoordinator.persistProfilingManagerInfo(sessionId, triggers, appExits); + boolean isHeapDumpCollectionEnabled = + markerFiles.stream() + .anyMatch(file -> file.getName().contains(TRIGGER_HEAP_DUMP_ENABLED_MARKER_FILENAME)); + boolean wasHeapDumpGeneratedViaMarker = + markerFiles.stream() + .anyMatch(file -> file.getName().contains(TRIGGER_HEAP_DUMP_GENERATED_MARKER_FILENAME)); + + reportingCoordinator.persistProfilingManagerInfo( + context.getFilesDir(), + sessionId, + appExits, + isHeapDumpCollectionEnabled, + wasHeapDumpGeneratedViaMarker); } @SuppressLint("WrongConstant") // TRIGGER_TYPE_OOM, TRIGGER_TYPE_ANOMALY @@ -1007,6 +1011,19 @@ private void registerProfilingManagerListener( String sessionId, @Background Executor backgroundExecutor) { ProfilingManager profilingManager = context.getSystemService(ProfilingManager.class); + boolean isTriggerHeapDumpCollectionEnabled = + settingsProvider.getSettingsSync().profiling.heapDumpCollectionEnabled; + + Logger.getLogger() + .i( + "Registering with ProfilingManager; heap dump collection is " + + (isTriggerHeapDumpCollectionEnabled ? "enabled" : "disabled")); + + // It is necessary to write the marker file for whether heap dump collection is enabled + // before any callback is invoked because OOMs will trigger after the process restarts & the + // flag could have been changed. + writeTriggerHeapDumpCollectionEnabledMarker(sessionId, isTriggerHeapDumpCollectionEnabled); + profilingManager.addProfilingTriggers( List.of( new ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_OOM).build(), @@ -1014,28 +1031,60 @@ private void registerProfilingManagerListener( profilingManager.registerForAllProfilingResults( backgroundExecutor, (result) -> { - writeTriggerTypeFile(sessionId, result.getTriggerType()); + // Only write the heap dump trigger for Memory Limiter kills. Currently, only Limiter + // kills invoke the callback _before_ the process shuts down, ensuring that the marker + // file is part of the session. OOMs trigger the callback _after_ the process restarts, + // meaning that the marker file will be associated with the newly opened session. + // OOM heap dumps will be associated with the correct session via other means. + if (result.getTriggerType() == ProfilingTrigger.TRIGGER_TYPE_ANOMALY) { + writeTriggerHeapDumpGeneratedMarker(sessionId, result.getResultFilePath()); + } }); } @RequiresApi(api = VERSION_CODES.CINNAMON_BUN) @VisibleForTesting - void writeTriggerTypeFile(String sessionId, int triggerType) { - String triggerFilename = - triggerType == ProfilingTrigger.TRIGGER_TYPE_ANOMALY - ? TRIGGER_TYPE_ANOMALY_FILENAME - : triggerType == ProfilingTrigger.TRIGGER_TYPE_OOM - ? TRIGGER_TYPE_OOM_FILENAME - : "trigger-type-unknown"; + void writeTriggerHeapDumpGeneratedMarker(String sessionId, @Nullable String heapDumpPath) { + if (heapDumpPath == null || heapDumpPath.isEmpty()) { + return; + } try { - if (!fileStore.getSessionFile(sessionId, triggerFilename).createNewFile()) { - Logger.getLogger() - .d("Trigger file " + triggerFilename + " exists for session: " + sessionId); - } + createTriggerFile(fileStore, sessionId, TRIGGER_HEAP_DUMP_GENERATED_MARKER_FILENAME); + Logger.getLogger().d("Wrote heap dump generation marker"); + } catch (IOException e) { + Logger.getLogger() + .e("Unable to create trigger file " + TRIGGER_HEAP_DUMP_GENERATED_MARKER_FILENAME); + } + } + + @RequiresApi(api = VERSION_CODES.CINNAMON_BUN) + @VisibleForTesting + void writeTriggerHeapDumpCollectionEnabledMarker(String sessionId, boolean isEnabled) { + if (!isEnabled) { + return; + } + + try { + createTriggerFile(fileStore, sessionId, TRIGGER_HEAP_DUMP_ENABLED_MARKER_FILENAME); + Logger.getLogger().d("Wrote heap dump collection enabled marker"); } catch (IOException e) { - Logger.getLogger().e("Unable to touch trigger file " + triggerFilename); + Logger.getLogger() + .e("Unable to create trigger file " + TRIGGER_HEAP_DUMP_ENABLED_MARKER_FILENAME); + } + } + + @RequiresApi(api = VERSION_CODES.CINNAMON_BUN) + @SuppressWarnings("UnusedReturnValue") + private static File createTriggerFile( + FileStore fileStore, String sessionId, String triggerFilename) throws IOException { + File triggerFile = fileStore.getSessionFile(sessionId, triggerFilename); + + if (!triggerFile.createNewFile()) { + Logger.getLogger().d("Trigger file " + triggerFilename + " exists for session: " + sessionId); } + + return triggerFile; } // endregion } diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java index e6128f509fe..de9d202a503 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java @@ -47,6 +47,7 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; @@ -178,24 +179,47 @@ public void persistRelevantAppExitInfoEvent( @RequiresApi(api = VERSION_CODES.CINNAMON_BUN) public void persistProfilingManagerInfo( - String sessionId, List triggers, List applicationExitInfoList) { + File filesDir, + String sessionId, + List applicationExitInfoList, + boolean isHeapDumpCollectionEnabled, + boolean wasHeapDumpGeneratedViaMarker) { + Optional trigger = - triggers.stream() - .findFirst() - .or( - () -> - isOom(sessionId, applicationExitInfoList) - ? Optional.of(ProfilingTrigger.TRIGGER_TYPE_OOM) - : Optional.empty()); + isOom(sessionId, applicationExitInfoList) + ? Optional.of(ProfilingTrigger.TRIGGER_TYPE_OOM) + : isMemoryLimiterKill(sessionId, applicationExitInfoList) + ? Optional.of(ProfilingTrigger.TRIGGER_TYPE_ANOMALY) + : Optional.empty(); trigger.ifPresent( - t -> - reportPersistence.persistProfilingManagerInfo( - ProfilingManagerInfo.builder() - .setProfilingTrigger( - ProfilingManagerInfo.ProfilingTrigger.builder().setTrigger(t).build()) - .build(), - sessionId)); + t -> { + boolean wasHeapDumpGenerated = + wasHeapDumpGeneratedViaMarker + || (t == ProfilingTrigger.TRIGGER_TYPE_OOM + && wasHeapDumpGeneratedViaFile( + filesDir, reportPersistence.getStartTimestampMillis(sessionId))); + + Logger.getLogger() + .d( + "Trigger present; " + + t + + ", heap dump collection enabled? " + + (isHeapDumpCollectionEnabled ? "yes" : "no") + + ", heap dump generated? " + + (wasHeapDumpGenerated ? "yes" : "no")); + + reportPersistence.persistProfilingManagerInfo( + ProfilingManagerInfo.builder() + .setProfilingTrigger( + ProfilingManagerInfo.ProfilingTrigger.builder() + .setTrigger(t) + .setIsHeapDumpCollectionEnabled(isHeapDumpCollectionEnabled) + .setWasHeapDumpGenerated(wasHeapDumpGenerated) + .build()) + .build(), + sessionId); + }); } public void finalizeSessionWithNativeEvent( @@ -517,4 +541,58 @@ boolean isOom(String sessionId, List applicationExitInfoLis return relevant != null; } + + @RequiresApi(api = VERSION_CODES.CINNAMON_BUN) + boolean isMemoryLimiterKill(String sessionId, List applicationExitInfoList) { + ApplicationExitInfo relevant = + findRelevantApplicationExitInfo( + sessionId, + applicationExitInfoList, + aei -> + aei.getReason() == ApplicationExitInfo.REASON_OTHER + && aei.getDescription() != null + && aei.getDescription().contains("MemoryLimiter:AnonSwap")); + + return relevant != null; + } + + @RequiresApi(api = VERSION_CODES.CINNAMON_BUN) + @VisibleForTesting + static boolean wasHeapDumpGeneratedViaFile(File filesDir, long sessionStartTime) { + File heapDumpRoot = new File(filesDir, "profiling"); + if (!heapDumpRoot.exists() || !heapDumpRoot.isDirectory()) { + Logger.getLogger().d("Directory profiling/ doesn't exit"); + return false; + } + + File[] heapDumps = + heapDumpRoot.listFiles( + (dir, name) -> + name.toLowerCase().endsWith(".hprof") + || name.toLowerCase().endsWith(".perfetto-java-heap-dump")); + + if (heapDumps == null || heapDumps.length == 0) { + Logger.getLogger().d("No heap dumps present"); + return false; + } + + return Arrays.stream(heapDumps) + // There is no way to filter dumps per process (this is relevant only to multi process + // Android apps) + .anyMatch( + heapDump -> { + long lastModifiedTime = heapDump.lastModified(); + + Logger.getLogger() + .d( + "Perfetto file [lmt=" + + lastModifiedTime + + ", sst=" + + sessionStartTime + + "] " + + heapDump.getName()); + + return lastModifiedTime >= sessionStartTime; + }); + } } diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/CrashlyticsReport.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/CrashlyticsReport.java index 9546cc078f1..b6981f84c11 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/CrashlyticsReport.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/CrashlyticsReport.java @@ -1376,15 +1376,22 @@ public static ProfilingTrigger.Builder builder() { return new AutoValue_CrashlyticsReport_ProfilingManagerInfo_ProfilingTrigger.Builder(); } - @NonNull public abstract int getTrigger(); + public abstract boolean getIsHeapDumpCollectionEnabled(); + + public abstract boolean getWasHeapDumpGenerated(); + @AutoValue.Builder public abstract static class Builder { + public abstract ProfilingTrigger.Builder setTrigger(int value); + @NonNull - public abstract ProfilingTrigger.Builder setTrigger(@NonNull int value); + public abstract ProfilingTrigger.Builder setIsHeapDumpCollectionEnabled(boolean value); @NonNull + public abstract ProfilingTrigger.Builder setWasHeapDumpGenerated(boolean value); + public abstract ProfilingTrigger build(); } } diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/serialization/CrashlyticsReportJsonTransform.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/serialization/CrashlyticsReportJsonTransform.java index a29a3078c74..f1187d10deb 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/serialization/CrashlyticsReportJsonTransform.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/serialization/CrashlyticsReportJsonTransform.java @@ -959,6 +959,12 @@ private static ProfilingTrigger parseProfilingTrigger(@NonNull JsonReader jsonRe case "trigger": builder.setTrigger(jsonReader.nextInt()); break; + case "isHeapDumpCollectionEnabled": + builder.setIsHeapDumpCollectionEnabled(jsonReader.nextBoolean()); + break; + case "wasHeapDumpGenerated": + builder.setWasHeapDumpGenerated(jsonReader.nextBoolean()); + break; default: jsonReader.skipValue(); } diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/DefaultSettingsJsonTransform.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/DefaultSettingsJsonTransform.java index 8d68e3ebf04..1017494a10d 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/DefaultSettingsJsonTransform.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/DefaultSettingsJsonTransform.java @@ -15,6 +15,7 @@ package com.google.firebase.crashlytics.internal.settings; import com.google.firebase.crashlytics.internal.common.CurrentTimeProvider; +import com.google.firebase.crashlytics.internal.settings.Settings.Profiling; import org.json.JSONObject; /** @@ -52,6 +53,9 @@ static Settings defaultSettings(CurrentTimeProvider currentTimeProvider) { long expiresAtMillis = currentTimeProvider.getCurrentTimeMillis() + (cacheDurationSeconds * 1000); + Profiling profiling = + new Profiling(SettingsJsonConstants.PROFILING_HEAP_DUMP_COLLECTION_ENABLED_DEFAULT); + return new Settings( expiresAtMillis, sessionData, @@ -60,6 +64,7 @@ static Settings defaultSettings(CurrentTimeProvider currentTimeProvider) { cacheDurationSeconds, onDemandUploadRatePerMinute, onDemandBackoffBase, - onDemandBackoffStepDurationSeconds); + onDemandBackoffStepDurationSeconds, + profiling); } } diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/Settings.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/Settings.java index e13dadd2633..7ff3dda5eca 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/Settings.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/Settings.java @@ -49,6 +49,16 @@ public FeatureFlagData(boolean collectReports, boolean collectAnrs, boolean coll public final double onDemandBackoffBase; public final int onDemandBackoffStepDurationSeconds; + public static class Profiling { + public final boolean heapDumpCollectionEnabled; + + public Profiling(boolean heapDumpCollectionEnabled) { + this.heapDumpCollectionEnabled = heapDumpCollectionEnabled; + } + } + + public final Profiling profiling; + public Settings( long expiresAtMillis, SessionData sessionData, @@ -58,6 +68,28 @@ public Settings( double onDemandUploadRatePerMinute, double onDemandBackoffBase, int onDemandBackoffStepDurationSeconds) { + this( + expiresAtMillis, + sessionData, + featureFlagData, + settingsVersion, + cacheDuration, + onDemandUploadRatePerMinute, + onDemandBackoffBase, + onDemandBackoffStepDurationSeconds, + new Profiling(false)); + } + + public Settings( + long expiresAtMillis, + SessionData sessionData, + FeatureFlagData featureFlagData, + int settingsVersion, + int cacheDuration, + double onDemandUploadRatePerMinute, + double onDemandBackoffBase, + int onDemandBackoffStepDurationSeconds, + Profiling profiling) { this.expiresAtMillis = expiresAtMillis; this.sessionData = sessionData; this.featureFlagData = featureFlagData; @@ -66,6 +98,7 @@ public Settings( this.onDemandUploadRatePerMinute = onDemandUploadRatePerMinute; this.onDemandBackoffBase = onDemandBackoffBase; this.onDemandBackoffStepDurationSeconds = onDemandBackoffStepDurationSeconds; + this.profiling = profiling; } public boolean isExpired(long currentTimeMillis) { diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/SettingsJsonConstants.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/SettingsJsonConstants.java index d1e4079c7df..59766cdb7c4 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/SettingsJsonConstants.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/SettingsJsonConstants.java @@ -26,6 +26,10 @@ class SettingsJsonConstants { static final String ON_DEMAND_BACKOFF_STEP_DURATION_SECONDS_KEY = "on_demand_backoff_step_duration_seconds"; + static final String PROFILING_KEY = "profiling"; + + static final boolean PROFILING_HEAP_DUMP_COLLECTION_ENABLED_DEFAULT = false; + // Top-level Defaults static final int SETTINGS_VERSION_DEFAULT = 0; @@ -42,6 +46,8 @@ class SettingsJsonConstants { // App JSON Keys static final String APP_STATUS_KEY = "status"; + static final String PROFILING_HEAP_DUMP_COLLECTION_ENABLED_KEY = "heap_dump_collection_enabled"; + // Settings JSON Keys static final String SETTINGS_MAX_CUSTOM_EXCEPTION_EVENTS_KEY = "max_custom_exception_events"; static final String SETTINGS_MAX_COMPLETE_SESSIONS_COUNT_KEY = "max_complete_sessions_count"; diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/SettingsV3JsonTransform.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/SettingsV3JsonTransform.java index e1cbbc6463e..d288a779ad1 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/SettingsV3JsonTransform.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/settings/SettingsV3JsonTransform.java @@ -15,6 +15,7 @@ package com.google.firebase.crashlytics.internal.settings; import com.google.firebase.crashlytics.internal.common.CurrentTimeProvider; +import com.google.firebase.crashlytics.internal.settings.Settings.Profiling; import org.json.JSONException; import org.json.JSONObject; @@ -55,6 +56,11 @@ public Settings buildFromJson(CurrentTimeProvider currentTimeProvider, JSONObjec long expiresAtMillis = getExpiresAtFrom(currentTimeProvider, cacheDuration, json); + Profiling profiling = + json.has(SettingsJsonConstants.PROFILING_KEY) + ? buildProfilingDataFrom(json.getJSONObject(SettingsJsonConstants.PROFILING_KEY)) + : buildProfilingDataFrom(new JSONObject()); + return new Settings( expiresAtMillis, sessionData, @@ -63,7 +69,8 @@ public Settings buildFromJson(CurrentTimeProvider currentTimeProvider, JSONObjec cacheDuration, onDemandUploadRatePerMinute, onDemandBackoffBase, - onDemandBackoffStepDurationSeconds); + onDemandBackoffStepDurationSeconds, + profiling); } private static Settings.FeatureFlagData buildFeatureFlagDataFrom(JSONObject json) { @@ -96,6 +103,14 @@ private static Settings.SessionData buildSessionDataFrom(JSONObject json) { return new Settings.SessionData(maxCustomExceptionEvents, maxCompleteSessionsCount); } + private static Settings.Profiling buildProfilingDataFrom(JSONObject json) { + boolean heapDumpCollectionEnabled = + json.optBoolean( + SettingsJsonConstants.PROFILING_HEAP_DUMP_COLLECTION_ENABLED_KEY, + SettingsJsonConstants.PROFILING_HEAP_DUMP_COLLECTION_ENABLED_DEFAULT); + return new Profiling(heapDumpCollectionEnabled); + } + private static long getExpiresAtFrom( CurrentTimeProvider currentTimeProvider, long cacheDurationSeconds, JSONObject json) { diff --git a/firebase-sessions/test-app/src/main/cpp/CMakeLists.txt b/firebase-sessions/test-app/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000000..ba8ff905de2 --- /dev/null +++ b/firebase-sessions/test-app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.18.1) + +project("native-lib") + +add_library( # Sets the name of the library. + native-lib + + # Sets the library as a shared library. + SHARED + + # Provides a relative path to your source file(s). + native-lib.cpp ) + +find_library( # Sets the name of the path variable. + log-lib + + # Specifies the name of the NDK library that + # you want CMake to locate. + log ) + +target_link_libraries( # Specifies the target library. + native-lib + + # Links the target library to the log library + # included in the NDK. + ${log-lib} ) + +target_link_options(native-lib PRIVATE "-Wl,-z,max-page-size=16384") diff --git a/firebase-sessions/test-app/src/main/cpp/native-lib.cpp b/firebase-sessions/test-app/src/main/cpp/native-lib.cpp new file mode 100644 index 00000000000..7fdb5b1843a --- /dev/null +++ b/firebase-sessions/test-app/src/main/cpp/native-lib.cpp @@ -0,0 +1,53 @@ +#include +#include +#include +#include + +#include +#include + +void* arr[] = {nullptr, nullptr}; + +int64_t total_device_memory() { + long page_size = sysconf(_SC_PAGESIZE); + long phys_pages = sysconf(_SC_PHYS_PAGES); + + return page_size == -1 || phys_pages == -1 ? -1 + : static_cast(page_size) * static_cast(phys_pages); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_google_firebase_testing_sessions_FirstFragment_createNativeLeak( + JNIEnv* env, + jobject /* this */) { + + const char* tag = "App"; + + __android_log_print(ANDROID_LOG_INFO, tag, "About to cause a leak... [pid=%d]", getpid()); + + int64_t total_ram = total_device_memory(); + int64_t tenth = total_ram / 10; + + __android_log_print(ANDROID_LOG_INFO, tag, " Total RAM: %lld (%lld MiBs)", + total_ram, total_ram / 1024 / 1024); + + bool funk = false; + + for (unsigned int i = 0; i < 10 * 2; ++i) { + void* mem = mmap(nullptr, tenth, + PROT_READ | PROT_WRITE, + MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); + + if (mem == MAP_FAILED) { + __android_log_print(ANDROID_LOG_INFO, tag, " Failed to allocate tenth [%u]", i); + break; + } + + memset(mem, i, tenth); + arr[static_cast(funk)] = mem; + funk = !funk; + + __android_log_print(ANDROID_LOG_INFO, tag, " Allocated 1/10th [%u]", i); + sleep(1); + } +} diff --git a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt index f5502b86db9..6cfa3ac3db2 100644 --- a/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt +++ b/firebase-sessions/test-app/src/main/kotlin/com/google/firebase/testing/sessions/FirstFragment.kt @@ -25,9 +25,11 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.annotation.RequiresApi import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.google.firebase.crashlytics.internal.Logger import com.google.firebase.perf.FirebasePerformance import com.google.firebase.perf.trace import com.google.firebase.testing.sessions.TestApplication.Companion.myProcessName @@ -39,6 +41,8 @@ import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.concurrent.thread +import kotlin.jvm.java /** A simple [Fragment] subclass as the default destination in the navigation. */ class FirstFragment : Fragment() { @@ -62,6 +66,7 @@ class FirstFragment : Fragment() { return binding.root } + @RequiresApi(Build.VERSION_CODES.O_MR1) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -74,6 +79,42 @@ class FirstFragment : Fragment() { Thread.sleep(1_000) } } + val pinnedMemory = mutableListOf() + binding.buttonOom.setOnClickListener { + Logger.getLogger().i("OOM clicked") + + thread(name = "lmk-filler-thread") { + val runtime = Runtime.getRuntime() + val maxHeap = runtime.maxMemory() + + Logger.getLogger().i("Max Java Heap: ${maxHeap / (1024 * 1024)} MB") + + while (true) { + val usedHeap = runtime.totalMemory() - runtime.freeMemory() + val usage = 1 + + if (usedHeap >= maxHeap * usage) { + break + } + + val chunk = ByteArray(2 * 1024 * 1024) + + for (i in chunk.indices step 4096) { + chunk[i] = (i and 0xFF).toByte() + } + + pinnedMemory.add(chunk) + } + } + } + binding.buttonMlk.setOnClickListener { + thread { + Logger.getLogger().i("Waiting 5 seconds....") + Thread.sleep(5000) + + createNativeLeak() + } + } binding.createTrace.setOnClickListener { lifecycleScope.launch(Dispatchers.IO) { val performanceTrace = performance.newTrace("test_trace") @@ -146,7 +187,13 @@ class FirstFragment : Fragment() { _binding = null } + private external fun createNativeLeak() + companion object { + init { + System.loadLibrary("native-lib") + } + fun getDateText(): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) diff --git a/firebase-sessions/test-app/src/main/res/layout/fragment_first.xml b/firebase-sessions/test-app/src/main/res/layout/fragment_first.xml index b40bee65a09..4d3b0b1e97f 100644 --- a/firebase-sessions/test-app/src/main/res/layout/fragment_first.xml +++ b/firebase-sessions/test-app/src/main/res/layout/fragment_first.xml @@ -47,13 +47,29 @@ app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintTop_toBottomOf="@id/button_non_fatal" /> +