Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .github/workflows/flutter_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ jobs:
with:
persist-credentials: false

- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: "zulu"
java-version: "17"

- name: Set up Flutter
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
Expand All @@ -33,6 +38,10 @@ jobs:
- name: Install dependencies
run: flutter pub get

- name: Android ANR stack formatter unit tests
working-directory: android
run: ./gradlew :anr-stack-formatter-tests:test --no-daemon

- name: Create api-config file
run: tool/create-api-config-file.sh

Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Bump Android `compileSdkVersion` from 35 to 36 (aligned with Flutter default since May 2025).

### Fixed

- Android `ANRTracker` now caps main-thread stack traces (frames and total
characters) and avoids logging the full trace, reducing risk of OOM when
capturing ANR diagnostics on low-memory devices ([#174](https://github.com/grafana/faro-flutter-sdk/issues/174)).

### Added

- Android: JUnit tests for bounded ANR stack formatting (`AnrStackTraceFormatter`),
run via `./gradlew :anr-stack-formatter-tests:test` in CI.

## [0.13.0] - 2026-04-09

### Added
Expand Down
36 changes: 36 additions & 0 deletions android/anr-stack-formatter-tests/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
plugins {
id 'java-library'
}

java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

repositories {
google()
mavenCentral()
}

dependencies {
implementation 'androidx.annotation:annotation-jvm:1.9.1'
testImplementation 'junit:junit:4.13.2'
}

sourceSets {
main {
java {
srcDirs = ['../src/main/java']
include 'com/grafana/faro/AnrStackTraceFormatter.java'
}
}
test {
java {
srcDir 'src/test/java'
}
}
}

tasks.withType(Test).configureEach {
useJUnit()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.grafana.faro;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

import org.junit.Test;

/**
* Unit tests for bounded ANR stack string building (#174).
*
* <p>TDD-style: these assert the caps that prevent unbounded allocation; if
* {@link AnrStackTraceFormatter} regresses to dumping the full stack without
* limits, the max-frames and max-chars cases fail.
*/
public class AnrStackTraceFormatterTest {

@Test
public void buildBoundedStackTraceString_empty_returnsEmpty() {
AnrStackTraceFormatter.Result r =
AnrStackTraceFormatter.buildBoundedStackTraceString(
new StackTraceElement[0], 128, 64 * 1024);
assertEquals("", r.text);
assertFalse(r.truncated);
assertEquals(0, r.includedFrames);
}

@Test
public void buildBoundedStackTraceString_smallStack_notTruncated() {
StackTraceElement[] trace =
new StackTraceElement[] {
new StackTraceElement("a.A", "m", "A.java", 1),
new StackTraceElement("b.B", "n", "B.java", 2),
};
AnrStackTraceFormatter.Result r =
AnrStackTraceFormatter.buildBoundedStackTraceString(
trace, 128, 64 * 1024);
assertFalse(r.truncated);
assertEquals(2, r.includedFrames);
assertTrue(r.text.contains("a.A.m"));
assertTrue(r.text.contains("b.B.n"));
}

@Test
public void buildBoundedStackTraceString_respectsMaxFrames() {
StackTraceElement[] trace = new StackTraceElement[20];
for (int i = 0; i < trace.length; i++) {
trace[i] = new StackTraceElement("pkg.C", "run", "C.java", i);
}
AnrStackTraceFormatter.Result r =
AnrStackTraceFormatter.buildBoundedStackTraceString(
trace, 5, 64 * 1024);
assertTrue(r.truncated);
assertEquals(5, r.includedFrames);
assertTrue(r.text.contains("truncated"));
}

@Test
public void buildBoundedStackTraceString_respectsMaxChars() {
StackTraceElement[] trace =
new StackTraceElement[] {
new StackTraceElement(
"very.long.package.name.ClassName",
"veryLongMethodName",
"VeryLongFileName.java",
999),
};
AnrStackTraceFormatter.Result r =
AnrStackTraceFormatter.buildBoundedStackTraceString(
trace, 128, 40);
assertTrue(r.truncated);
assertEquals(0, r.includedFrames);
assertTrue(r.text.contains("truncated"));
assertTrue(
"output must stay within maxChars to limit allocation",
r.text.length() <= 40);
}

@Test
public void buildBoundedStackTraceString_outputNeverExceedsMaxChars() {
StackTraceElement[] trace = new StackTraceElement[200];
for (int i = 0; i < trace.length; i++) {
trace[i] =
new StackTraceElement(
"some.pkg.DeepClassNameNumber" + i,
"method" + i,
"SourceFile.java",
i);
}
int maxChars = 500;
AnrStackTraceFormatter.Result r =
AnrStackTraceFormatter.buildBoundedStackTraceString(
trace, 128, maxChars);
assertTrue(
"bounded formatter must not grow past maxChars",
r.text.length() <= maxChars);
}
}
2 changes: 2 additions & 0 deletions android/settings.gradle
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
rootProject.name = 'faro'

include 'anr-stack-formatter-tests'
53 changes: 29 additions & 24 deletions android/src/main/java/com/grafana/faro/ANRTracker.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import org.json.JSONObject;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
Expand All @@ -25,6 +24,15 @@ public class ANRTracker extends Thread {
private static final String TAG = "ANRTracker";
private static final long TIMEOUT = 5000L; // Time interval for checking ANR, in milliseconds
private static final long CHECK_INTERVAL = 500L; // Time to wait between checks, in milliseconds

/**
* Maximum stack frames to stringify. Deep Flutter stacks can be huge and
* building unbounded strings risks OOM on low-memory devices (see #174).
*/
private static final int MAX_STACK_FRAMES = 128;

/** Maximum characters for the stack trace string, including truncation note. */
private static final int MAX_STACKTRACE_CHARS = 64 * 1024;

// Thread-safe list to store ANR information
private static final List<String> anrList = Collections.synchronizedList(new ArrayList<>());
Expand Down Expand Up @@ -169,44 +177,41 @@ public void stopTracking() {
*/
private void handleAnrDetected() {
try {
// Get the main thread's stack trace
StackTraceElement[] stackTrace = mainThread.getStackTrace();

// Build a readable stack trace
StringBuilder stackTraceStr = new StringBuilder();
for (StackTraceElement element : stackTrace) {
stackTraceStr.append(element.getClassName())
.append(".")
.append(element.getMethodName())
.append("(")
.append(element.getFileName())
.append(":")
.append(element.getLineNumber())
.append(")\n");
}

// Create JSON object with ANR information
int totalFrames = stackTrace.length;
AnrStackTraceFormatter.Result stackResult =
AnrStackTraceFormatter.buildBoundedStackTraceString(
stackTrace, MAX_STACK_FRAMES, MAX_STACKTRACE_CHARS);

JSONObject anrInfo = new JSONObject();
try {
anrInfo.put("type", "ANR");
anrInfo.put("timestamp", System.currentTimeMillis());
anrInfo.put("stacktrace", stackTraceStr.toString());

// Add duration estimate (at least TIMEOUT ms)
anrInfo.put("stacktrace", stackResult.text);
anrInfo.put("stacktrace_truncated", stackResult.truncated);
anrInfo.put("stacktrace_total_frames", totalFrames);
anrInfo.put("stacktrace_included_frames", stackResult.includedFrames);
anrInfo.put("duration", TIMEOUT);
} catch (JSONException e) {
Log.e(TAG, "Error creating ANR JSON", e);
}

// Store the ANR information

String anrData = anrInfo.toString();
synchronized (anrList) {
anrList.add(anrData);
}

Log.w(TAG, "ANR detected: " + stackTraceStr);

Log.w(
TAG,
"ANR detected: included "
+ stackResult.includedFrames
+ "/"
+ totalFrames
+ " frames, truncated="
+ stackResult.truncated);
} catch (Exception e) {
Log.e(TAG, "Error handling ANR", e);
}
}

}
91 changes: 91 additions & 0 deletions android/src/main/java/com/grafana/faro/AnrStackTraceFormatter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.grafana.faro;

import androidx.annotation.NonNull;

/**
* Formats main-thread stack traces for ANR reporting with bounded allocation.
*/
public final class AnrStackTraceFormatter {

private AnrStackTraceFormatter() {}

/** Result of {@link #buildBoundedStackTraceString}. */
public static final class Result {
public final @NonNull String text;
public final boolean truncated;
public final int includedFrames;

public Result(@NonNull String text, boolean truncated, int includedFrames) {
this.text = text;
this.truncated = truncated;
this.includedFrames = includedFrames;
}
}

/**
* Builds a stack trace string capped by {@code maxFrames} and {@code maxChars}
* to limit peak allocation on ANR paths.
*/
@NonNull
public static Result buildBoundedStackTraceString(
@NonNull StackTraceElement[] stackTrace, int maxFrames, int maxChars) {
int total = stackTrace.length;
if (total == 0) {
return new Result("", false, 0);
}

StringBuilder sb = new StringBuilder(Math.min(total, maxFrames) * 80);
int included = 0;
boolean truncated = false;

for (int i = 0; i < total && included < maxFrames; i++) {
String line = formatStackFrameLine(stackTrace[i]);
int nextLen = sb.length() + line.length();
if (nextLen > maxChars) {
truncated = true;
break;
}
sb.append(line);
included++;
}

if (included < total) {
truncated = true;
}

if (truncated) {
String note =
"... (truncated: "
+ included
+ " of "
+ total
+ " frames, maxFrames="
+ maxFrames
+ ", maxChars="
+ maxChars
+ ")\n";
if (sb.length() + note.length() > maxChars) {
sb.setLength(Math.max(0, maxChars - note.length()));
}
sb.append(note);
}

String out = sb.toString();
if (out.length() > maxChars) {
out = out.substring(0, maxChars);
}
return new Result(out, truncated, included);
}

@NonNull
private static String formatStackFrameLine(@NonNull StackTraceElement element) {
return element.getClassName()
+ "."
+ element.getMethodName()
+ "("
+ element.getFileName()
+ ":"
+ element.getLineNumber()
+ ")\n";
}
}
Loading