Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions firebase-crashlytics/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Unreleased

- [feature] Added OOM and Anomaly trigger collection for the ProfilingManager API [#8343]

# 20.0.6

- [fixed] Fixed race condition that caused logs from background threads to not be attached to
Expand Down
4 changes: 2 additions & 2 deletions firebase-crashlytics/firebase-crashlytics.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ android {
timeOutInMs 60 * 1000
}
namespace "com.google.firebase.crashlytics"
compileSdkVersion project.compileSdkVersion
compileSdkVersion 37
testOptions.unitTests.includeAndroidResources = true
defaultConfig {
minSdkVersion project.minSdkVersion
Expand Down Expand Up @@ -99,7 +99,7 @@ dependencies {
testImplementation(libs.androidx.test.runner)
testImplementation(libs.junit)
testImplementation(libs.mockito.core)
testImplementation(libs.robolectric)
testImplementation("org.robolectric:robolectric:4.16.1")
testImplementation(libs.truth)
testImplementation(project(":integ-testing"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
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;
Expand Down Expand Up @@ -62,6 +63,7 @@
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;
Expand Down Expand Up @@ -569,6 +571,70 @@ public void testUploadDisabledThenEnabled() throws Exception {
verifyNoMoreInteractions(mockSessionReportingCoordinator);
}

@SdkSuppress(minSdkVersion = 37) // ProfilingManager
@Test
public void testWritingProfilingManagerTriggerAnomaly() {
LogFileManager logFileManager = new LogFileManager(testFileStore);
CrashlyticsController controller = builder().setLogFileManager(logFileManager).build();

controller.writeTriggerTypeFile("sessionId", ProfilingTrigger.TRIGGER_TYPE_ANOMALY);

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

@SdkSuppress(minSdkVersion = 37) // ProfilingTrigger
@Test
public void testWritingProfilingManagerTriggerOom() {
LogFileManager logFileManager = new LogFileManager(testFileStore);
CrashlyticsController controller = builder().setLogFileManager(logFileManager).build();

controller.writeTriggerTypeFile("sessionId", ProfilingTrigger.TRIGGER_TYPE_OOM);

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

@SdkSuppress(minSdkVersion = 30) // ApplicationExitInfo
@Test
public void testFatalEvent_sendsAppExceptionEvent() throws Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.app.ApplicationExitInfo;
import android.os.Parcel;
import android.system.OsConstants;
import com.google.android.gms.tasks.Task;
import com.google.android.gms.tasks.Tasks;
import com.google.firebase.concurrent.TestOnlyExecutors;
Expand Down Expand Up @@ -612,6 +615,65 @@ public void testRemoveAllReports_deletesPersistedReports() {
verify(reportPersistence).deleteAllReports();
}

@Test
public void testIsOom_skipsAllIrrelevantAppExitInfos() {
ApplicationExitInfo nonOom1 = makeApplicationExitInfo(ApplicationExitInfo.REASON_ANR, 0, 0);
ApplicationExitInfo nonOom2 = makeApplicationExitInfo(ApplicationExitInfo.REASON_CRASH, 0, 0);
ApplicationExitInfo nonOom3 =
makeApplicationExitInfo(ApplicationExitInfo.REASON_SIGNALED, 0, 0);

boolean isOom = reportingCoordinator.isOom("sessionId", List.of(nonOom1, nonOom2, nonOom3));

assertFalse(isOom);
}

@Test
public void testIsOom_returnsTrueOnReasonSignaled() {
ApplicationExitInfo oom =
makeApplicationExitInfo(ApplicationExitInfo.REASON_SIGNALED, 0, OsConstants.SIGKILL);
ApplicationExitInfo nonOom = makeApplicationExitInfo(ApplicationExitInfo.REASON_ANR, 0, 0);

boolean isOom = reportingCoordinator.isOom("sessionId", List.of(nonOom, oom));

assertTrue(isOom);
}

@Test
public void testIsOom_returnTrueOnReasonLowMemory() {
ApplicationExitInfo oom =
makeApplicationExitInfo(
ApplicationExitInfo.REASON_LOW_MEMORY, /* SUBREASON_OOM_KILL= */ 30, 0);
ApplicationExitInfo nonOom = makeApplicationExitInfo(ApplicationExitInfo.REASON_ANR, 0, 0);

boolean isOom = reportingCoordinator.isOom("sessionId", List.of(nonOom, oom));

assertTrue(isOom);
}

private ApplicationExitInfo makeApplicationExitInfo(int reason, int subreason, int status) {
Parcel dest = Parcel.obtain();

dest.writeInt(1);
dest.writeInt(1);
dest.writeInt(1);
dest.writeInt(1);
dest.writeString("process");
dest.writeString("com.test");
dest.writeInt(1);
dest.writeInt(reason);
dest.writeInt(subreason);
dest.writeInt(status);
dest.writeInt(1);
dest.writeLong(1L);
dest.writeLong(1L);
dest.writeLong(1L);
dest.writeString("");
dest.writeByteArray(new byte[] {});
dest.setDataPosition(0);

return ApplicationExitInfo.CREATOR.createFromParcel(dest);
}

private void addCustomKeysToUserMetadata(Map<String, String> customKeys) throws Exception {
reportMetadata.setCustomKeys(customKeys);
for (Map.Entry<String, String> entry : customKeys.entrySet()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@
import static org.mockito.Mockito.when;

import androidx.annotation.Nullable;
import androidx.test.filters.SdkSuppress;
import com.google.firebase.crashlytics.internal.CrashlyticsTestCase;
import com.google.firebase.crashlytics.internal.common.CrashlyticsAppQualitySessionsSubscriber;
import com.google.firebase.crashlytics.internal.common.CrashlyticsReportWithSessionId;
import com.google.firebase.crashlytics.internal.model.CrashlyticsReport;
import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.ProfilingManagerInfo;
import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.ProfilingManagerInfo.ProfilingTrigger;
import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session;
import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session.Application;
import com.google.firebase.crashlytics.internal.model.CrashlyticsReport.Session.Event;
Expand Down Expand Up @@ -178,6 +181,43 @@ public void testLoadFinalizedReports_reportThenEvent_returnsReportWithEvent() {
finalizedReport);
}

@SdkSuppress(minSdkVersion = 37) // ProfilingManager, ProfilingTrigger
@Test
public void testLoadFinalizedReports_reportWithProfilingManagerTrigger_containsTriggers() {
String sessionId = "sessionId";
CrashlyticsReport report = makeTestReport(sessionId);
CrashlyticsReport.Session.Event event = makeTestEvent("crash", "reason");

ProfilingManagerInfo pmi =
ProfilingManagerInfo.builder()
.setProfilingTrigger(
ProfilingTrigger.builder()
.setTrigger(android.os.ProfilingTrigger.TRIGGER_TYPE_ANOMALY)
.build())
.build();

reportPersistence.persistReport(report);
reportPersistence.persistEvent(event, sessionId);
reportPersistence.persistProfilingManagerInfo(pmi, sessionId);
reportPersistence.finalizeReports("skipSession", System.currentTimeMillis());

List<CrashlyticsReportWithSessionId> reports = reportPersistence.loadFinalizedReports();

assertEquals(1, reports.size());
assertEquals(1, reports.get(0).getReport().getSession().getEvents().size());
assertEquals(
pmi,
reports
.get(0)
.getReport()
.getSession()
.getEvents()
.get(0)
.getApp()
.getExecution()
.getProfilingManagerInfo());
}

@Test
public void testLoadFinalizedReports_reportThenMultipleEvents_returnsReportWithMultipleEvents() {
final String sessionId = "testSession";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,22 @@
import android.content.Context;
import android.os.Build;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import android.os.Environment;
import android.os.ProfilingManager;
import android.os.ProfilingTrigger;
import android.os.StatFs;
import android.util.Base64;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import com.google.android.gms.tasks.SuccessContinuation;
import com.google.android.gms.tasks.Task;
import com.google.android.gms.tasks.TaskCompletionSource;
import com.google.android.gms.tasks.Tasks;
import com.google.firebase.annotations.concurrent.Background;
import com.google.firebase.crashlytics.internal.CrashlyticsNativeComponent;
import com.google.firebase.crashlytics.internal.Logger;
import com.google.firebase.crashlytics.internal.NativeSessionFileProvider;
Expand Down Expand Up @@ -57,10 +62,12 @@
import java.util.Map;
import java.util.SortedSet;
import java.util.concurrent.Callable;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;

class CrashlyticsController {

Expand All @@ -79,6 +86,8 @@ 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 Charset UTF_8 = Charset.forName("UTF-8");

Expand Down Expand Up @@ -175,6 +184,12 @@ public void onUncaughtException(
Thread.setDefaultUncaughtExceptionHandler(crashHandler);
}

@RequiresApi(api = VERSION_CODES.CINNAMON_BUN)
void enableProfilingManagerListener(String sessionId) {
// Set up profiling manager here, use background executor because it's just file I/O
registerProfilingManagerListener(sessionId, crashlyticsWorkers.diskWrite.getExecutor());
}

void handleUncaughtException(
@NonNull SettingsProvider settingsProvider,
@NonNull final Thread thread,
Expand Down Expand Up @@ -603,6 +618,10 @@ private void doCloseSessions(
finalizePreviousNativeSession(mostRecentSessionIdToClose);
}

if (android.os.Build.VERSION.SDK_INT >= VERSION_CODES.CINNAMON_BUN) {
writeProfilingManagerInfo(mostRecentSessionIdToClose);
}

String currentSessionId = null;
if (skipCurrentSession) {
currentSessionId = sortedOpenSessions.get(0);
Expand Down Expand Up @@ -943,5 +962,80 @@ private void writeApplicationExitInfoEventIfRelevant(String sessionId) {
.v("ANR feature enabled, but device is API " + android.os.Build.VERSION.SDK_INT);
}
}

// endregion

// region ProfilingManager
@RequiresApi(api = VERSION_CODES.CINNAMON_BUN)
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<Integer> 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());

List<ApplicationExitInfo> appExits = manager.getHistoricalProcessExitReasons(null, 0, 0);

reportingCoordinator.persistProfilingManagerInfo(sessionId, triggers, appExits);
}

@SuppressLint("WrongConstant") // TRIGGER_TYPE_OOM, TRIGGER_TYPE_ANOMALY
@RequiresApi(api = VERSION_CODES.CINNAMON_BUN)
private void registerProfilingManagerListener(
String sessionId, @Background Executor backgroundExecutor) {
ProfilingManager profilingManager = context.getSystemService(ProfilingManager.class);

profilingManager.addProfilingTriggers(
List.of(
new ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_OOM).build(),
new ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_ANOMALY).build()));
profilingManager.registerForAllProfilingResults(
backgroundExecutor,
(result) -> {
writeTriggerTypeFile(sessionId, result.getTriggerType());
});
}

@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";

try {
if (!fileStore.getSessionFile(sessionId, triggerFilename).createNewFile()) {
Logger.getLogger()
.d("Trigger file " + triggerFilename + " exists for session: " + sessionId);
}
} catch (IOException e) {
Logger.getLogger().e("Unable to touch trigger file " + triggerFilename);
}
}
// endregion
}
Loading
Loading