From 4fdb86b44df03d796cc7b71cc9348086b79e54c7 Mon Sep 17 00:00:00 2001 From: buongarzoni Date: Mon, 20 Apr 2026 18:29:10 -0300 Subject: [PATCH] feat(android): capture logcat output as telemetry events --- .../rollbar/android/AndroidConfiguration.java | 41 ++++ .../android/LogcatTelemetryCapture.java | 198 ++++++++++++++++++ .../java/com/rollbar/android/Rollbar.java | 36 ++++ .../android/LogcatTelemetryCaptureTest.java | 147 +++++++++++++ 4 files changed, 422 insertions(+) create mode 100644 rollbar-android/src/main/java/com/rollbar/android/LogcatTelemetryCapture.java create mode 100644 rollbar-android/src/test/java/com/rollbar/android/LogcatTelemetryCaptureTest.java diff --git a/rollbar-android/src/main/java/com/rollbar/android/AndroidConfiguration.java b/rollbar-android/src/main/java/com/rollbar/android/AndroidConfiguration.java index 8bf625f7..aed9087f 100644 --- a/rollbar-android/src/main/java/com/rollbar/android/AndroidConfiguration.java +++ b/rollbar-android/src/main/java/com/rollbar/android/AndroidConfiguration.java @@ -1,14 +1,19 @@ package com.rollbar.android; import com.rollbar.android.anr.AnrConfiguration; +import com.rollbar.api.payload.data.Level; public class AndroidConfiguration { private final AnrConfiguration anrConfiguration; private final boolean mustCaptureNavigationEvents; + private final boolean mustCaptureLogsAsTelemetry; + private final Level minimumLogCaptureLevel; AndroidConfiguration(Builder builder) { anrConfiguration = builder.anrConfiguration; mustCaptureNavigationEvents = builder.mustCaptureNavigationEvents; + mustCaptureLogsAsTelemetry = builder.mustCaptureLogsAsTelemetry; + minimumLogCaptureLevel = builder.minimumLogCaptureLevel; } public AnrConfiguration getAnrConfiguration() { @@ -19,10 +24,20 @@ public boolean mustCaptureNavigationEvents() { return mustCaptureNavigationEvents; } + public boolean mustCaptureLogsAsTelemetry() { + return mustCaptureLogsAsTelemetry; + } + + public Level getMinimumLogCaptureLevel() { + return minimumLogCaptureLevel; + } + public static final class Builder { private AnrConfiguration anrConfiguration; private boolean mustCaptureNavigationEvents = true; + private boolean mustCaptureLogsAsTelemetry = false; + private Level minimumLogCaptureLevel = Level.WARNING; public Builder() { anrConfiguration = new AnrConfiguration.Builder().build(); @@ -49,6 +64,32 @@ public Builder captureNewActivityTelemetryEvents(boolean mustCaptureNavigationEv return this; } + /** + * Enable or disable automatic capture of Android log output as telemetry events. + * When enabled, logs emitted via {@code android.util.Log} (and any other source written to + * logcat from this app's UID, including third-party libraries) at or above the configured + * minimum level are recorded as manual telemetry events with + * {@link com.rollbar.api.payload.data.Source#CLIENT}. + * Default is disabled. + * @param mustCaptureLogsAsTelemetry if automatic capture must be enabled or disabled. + * @return the builder instance + */ + public Builder captureLogsAsTelemetry(boolean mustCaptureLogsAsTelemetry) { + this.mustCaptureLogsAsTelemetry = mustCaptureLogsAsTelemetry; + return this; + } + + /** + * Minimum log level to capture as telemetry when {@link #captureLogsAsTelemetry(boolean)} + * is enabled. Default is {@link Level#WARNING}. + * @param minimumLogCaptureLevel the minimum level (inclusive) to capture. + * @return the builder instance + */ + public Builder minimumLogCaptureLevel(Level minimumLogCaptureLevel) { + this.minimumLogCaptureLevel = minimumLogCaptureLevel; + return this; + } + public AndroidConfiguration build() { return new AndroidConfiguration(this); } diff --git a/rollbar-android/src/main/java/com/rollbar/android/LogcatTelemetryCapture.java b/rollbar-android/src/main/java/com/rollbar/android/LogcatTelemetryCapture.java new file mode 100644 index 00000000..9b6dde05 --- /dev/null +++ b/rollbar-android/src/main/java/com/rollbar/android/LogcatTelemetryCapture.java @@ -0,0 +1,198 @@ +package com.rollbar.android; + +import android.util.Log; + +import com.rollbar.api.payload.data.Level; +import com.rollbar.api.payload.data.Source; +import com.rollbar.notifier.telemetry.TelemetryEventTracker; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +class LogcatTelemetryCapture { + + // threadtime format: "MM-dd HH:mm:ss.SSS PID TID L Tag: message" + private static final Pattern LOGCAT_LINE_PATTERN = Pattern.compile( + "^\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3}\\s+\\d+\\s+\\d+\\s+([VDIWEF])\\s+(.+?):\\s(.*)$" + ); + + private final TelemetryEventTracker tracker; + private final Level minimumLevel; + private final String selfTag; + private final ProcessFactory processFactory; + + private Thread thread; + private Process process; + private volatile boolean running; + + LogcatTelemetryCapture( + TelemetryEventTracker tracker, + Level minimumLevel, + String selfTag + ) { + this(tracker, minimumLevel, selfTag, defaultProcessFactory()); + } + + LogcatTelemetryCapture( + TelemetryEventTracker tracker, + Level minimumLevel, + String selfTag, + ProcessFactory processFactory + ) { + this.tracker = tracker; + this.minimumLevel = minimumLevel != null ? minimumLevel : Level.WARNING; + this.selfTag = selfTag; + this.processFactory = processFactory; + } + + synchronized void start() { + if (running) { + return; + } + try { + this.process = processFactory.start(logcatPriorityFor(this.minimumLevel)); + } catch (IOException e) { + Log.w(Rollbar.TAG, "Failed to start logcat telemetry capture", e); + return; + } + running = true; + thread = new Thread(new Runnable() { + @Override + public void run() { + readLoop(); + } + }, "rollbar-logcat-telemetry"); + thread.setDaemon(true); + thread.start(); + } + + synchronized void stop() { + if (!running) { + return; + } + running = false; + if (process != null) { + process.destroy(); + process = null; + } + if (thread != null) { + thread.interrupt(); + thread = null; + } + } + + private void readLoop() { + Process currentProcess = this.process; + if (currentProcess == null) { + return; + } + BufferedReader reader = new BufferedReader( + new InputStreamReader(currentProcess.getInputStream(), Charset.forName("UTF-8"))); + try { + String line; + while (running && (line = reader.readLine()) != null) { + processLine(line); + } + } catch (IOException e) { + // Process died or was destroyed — expected on stop(). + } finally { + try { + reader.close(); + } catch (IOException ignored) { + } + } + } + + void processLine(String line) { + if (line == null) { + return; + } + Matcher matcher = LOGCAT_LINE_PATTERN.matcher(line); + if (!matcher.matches()) { + return; + } + + String priority = matcher.group(1); + String tag = matcher.group(2).trim(); + String message = matcher.group(3); + + if (selfTag != null && selfTag.equals(tag)) { + return; + } + + Level level = mapPriorityToLevel(priority); + if (level == null) { + return; + } + if (level.level() < minimumLevel.level()) { + return; + } + + try { + tracker.recordManualEventFor(level, Source.CLIENT, message); + } catch (Exception e) { + // Never let a broken tracker kill the reader thread. + } + } + + static Level mapPriorityToLevel(String priority) { + if (priority == null || priority.isEmpty()) { + return null; + } + switch (priority.charAt(0)) { + case 'V': + case 'D': + return Level.DEBUG; + case 'I': + return Level.INFO; + case 'W': + return Level.WARNING; + case 'E': + return Level.ERROR; + case 'F': + return Level.CRITICAL; + default: + return null; + } + } + + static String logcatPriorityFor(Level level) { + if (level == null) { + return "W"; + } + switch (level) { + case DEBUG: + return "D"; + case INFO: + return "I"; + case WARNING: + return "W"; + case ERROR: + return "E"; + case CRITICAL: + return "F"; + default: + return "W"; + } + } + + interface ProcessFactory { + Process start(String priorityFilter) throws IOException; + } + + private static ProcessFactory defaultProcessFactory() { + return new ProcessFactory() { + @Override + public Process start(String priorityFilter) throws IOException { + return new ProcessBuilder( + "logcat", "-v", "threadtime", "*:" + priorityFilter) + .redirectErrorStream(true) + .start(); + } + }; + } +} diff --git a/rollbar-android/src/main/java/com/rollbar/android/Rollbar.java b/rollbar-android/src/main/java/com/rollbar/android/Rollbar.java index 02811fe1..2200fbdf 100644 --- a/rollbar-android/src/main/java/com/rollbar/android/Rollbar.java +++ b/rollbar-android/src/main/java/com/rollbar/android/Rollbar.java @@ -67,6 +67,7 @@ public class Rollbar implements Closeable { private final ConnectionAwareSenderFailureStrategy senderFailureStrategy; private com.rollbar.notifier.Rollbar rollbar; + private LogcatTelemetryCapture logcatTelemetryCapture; private static Rollbar notifier; private final int versionCode; @@ -236,6 +237,7 @@ public static Rollbar init( if (androidConfiguration != null) { initAnrDetector(context, androidConfiguration); initAutomaticCaptureOfNavigationTelemetryEvents(context, androidConfiguration); + initAutomaticCaptureOfLogTelemetryEvents(androidConfiguration); } } @@ -277,12 +279,21 @@ public static Rollbar init(Context context, ConfigProvider provider) { AndroidConfiguration androidConfiguration = makeDefaultAndroidConfiguration(); initAnrDetector(context, androidConfiguration); initAutomaticCaptureOfNavigationTelemetryEvents(context, androidConfiguration); + initAutomaticCaptureOfLogTelemetryEvents(androidConfiguration); } return notifier; } @Override public void close() throws IOException { + if (logcatTelemetryCapture != null) { + try { + logcatTelemetryCapture.stop(); + } catch (Exception e) { + Log.w(TAG, "Error stopping logcat telemetry capture", e); + } + logcatTelemetryCapture = null; + } if (rollbar != null) { try { rollbar.close(false); @@ -1202,6 +1213,31 @@ private static void initAutomaticCaptureOfNavigationTelemetryEvents( } } + private static void initAutomaticCaptureOfLogTelemetryEvents( + AndroidConfiguration androidConfiguration + ) { + if (!androidConfiguration.mustCaptureLogsAsTelemetry()) { + return; + } + + com.rollbar.notifier.Rollbar rollbarNotifier = notifier.rollbar; + if (rollbarNotifier == null) { + return; + } + + TelemetryEventTracker telemetryEventTracker = rollbarNotifier.getTelemetryEventTracker(); + if (telemetryEventTracker == null) { + return; + } + + LogcatTelemetryCapture logcatTelemetryCapture = new LogcatTelemetryCapture( + telemetryEventTracker, + androidConfiguration.getMinimumLogCaptureLevel(), + TAG); + logcatTelemetryCapture.start(); + notifier.logcatTelemetryCapture = logcatTelemetryCapture; + } + private String loadAccessTokenFromManifest(Context context) throws NameNotFoundException { Context appContext = context.getApplicationContext(); ApplicationInfo ai = appContext.getPackageManager().getApplicationInfo(appContext.getPackageName(), PackageManager.GET_META_DATA); diff --git a/rollbar-android/src/test/java/com/rollbar/android/LogcatTelemetryCaptureTest.java b/rollbar-android/src/test/java/com/rollbar/android/LogcatTelemetryCaptureTest.java new file mode 100644 index 00000000..2281a40b --- /dev/null +++ b/rollbar-android/src/test/java/com/rollbar/android/LogcatTelemetryCaptureTest.java @@ -0,0 +1,147 @@ +package com.rollbar.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.rollbar.api.payload.data.Level; +import com.rollbar.api.payload.data.Source; +import com.rollbar.notifier.telemetry.TelemetryEventTracker; + +import org.junit.Before; +import org.junit.Test; + +public class LogcatTelemetryCaptureTest { + + private TelemetryEventTracker tracker; + + @Before + public void setUp() { + tracker = mock(TelemetryEventTracker.class); + } + + @Test + public void mapPriorityToLevel_knownPriorities() { + assertEquals(Level.DEBUG, LogcatTelemetryCapture.mapPriorityToLevel("V")); + assertEquals(Level.DEBUG, LogcatTelemetryCapture.mapPriorityToLevel("D")); + assertEquals(Level.INFO, LogcatTelemetryCapture.mapPriorityToLevel("I")); + assertEquals(Level.WARNING, LogcatTelemetryCapture.mapPriorityToLevel("W")); + assertEquals(Level.ERROR, LogcatTelemetryCapture.mapPriorityToLevel("E")); + assertEquals(Level.CRITICAL, LogcatTelemetryCapture.mapPriorityToLevel("F")); + } + + @Test + public void mapPriorityToLevel_unknownReturnsNull() { + assertNull(LogcatTelemetryCapture.mapPriorityToLevel("X")); + assertNull(LogcatTelemetryCapture.mapPriorityToLevel("")); + assertNull(LogcatTelemetryCapture.mapPriorityToLevel(null)); + } + + @Test + public void logcatPriorityFor_levels() { + assertEquals("D", LogcatTelemetryCapture.logcatPriorityFor(Level.DEBUG)); + assertEquals("I", LogcatTelemetryCapture.logcatPriorityFor(Level.INFO)); + assertEquals("W", LogcatTelemetryCapture.logcatPriorityFor(Level.WARNING)); + assertEquals("E", LogcatTelemetryCapture.logcatPriorityFor(Level.ERROR)); + assertEquals("F", LogcatTelemetryCapture.logcatPriorityFor(Level.CRITICAL)); + assertEquals("W", LogcatTelemetryCapture.logcatPriorityFor(null)); + } + + @Test + public void processLine_recordsWarningAtThreshold() { + LogcatTelemetryCapture capture = newCapture(Level.WARNING); + + capture.processLine("04-20 12:34:56.789 1234 5678 W MyTag: warn message"); + + verify(tracker).recordManualEventFor(eq(Level.WARNING), eq(Source.CLIENT), eq("warn message")); + } + + @Test + public void processLine_recordsErrorAboveThreshold() { + LogcatTelemetryCapture capture = newCapture(Level.WARNING); + + capture.processLine("04-20 12:34:56.789 1234 5678 E MyTag: boom"); + + verify(tracker).recordManualEventFor(eq(Level.ERROR), eq(Source.CLIENT), eq("boom")); + } + + @Test + public void processLine_skipsBelowThreshold() { + LogcatTelemetryCapture capture = newCapture(Level.WARNING); + + capture.processLine("04-20 12:34:56.789 1234 5678 I MyTag: info message"); + capture.processLine("04-20 12:34:56.789 1234 5678 D MyTag: debug message"); + + verifyNoInteractions(tracker); + } + + @Test + public void processLine_skipsSelfTag() { + LogcatTelemetryCapture capture = newCapture(Level.WARNING); + + capture.processLine("04-20 12:34:56.789 1234 5678 W Rollbar: recursion risk"); + + verifyNoInteractions(tracker); + } + + @Test + public void processLine_skipsUnparseable() { + LogcatTelemetryCapture capture = newCapture(Level.WARNING); + + capture.processLine("--------- beginning of main"); + capture.processLine(""); + capture.processLine(null); + + verifyNoInteractions(tracker); + } + + @Test + public void processLine_trimsTag() { + LogcatTelemetryCapture capture = newCapture(Level.WARNING); + + capture.processLine("04-20 12:34:56.789 1234 5678 W MyTag : message"); + + verify(tracker).recordManualEventFor(eq(Level.WARNING), eq(Source.CLIENT), eq("message")); + } + + @Test + public void processLine_trackerThrow_doesNotPropagate() { + doThrow(new RuntimeException("tracker boom")) + .when(tracker).recordManualEventFor(any(), any(), any()); + LogcatTelemetryCapture capture = newCapture(Level.WARNING); + + capture.processLine("04-20 12:34:56.789 1234 5678 W MyTag: message"); + + verify(tracker).recordManualEventFor(eq(Level.WARNING), eq(Source.CLIENT), eq("message")); + } + + @Test + public void processLine_messageWithColon_preserved() { + LogcatTelemetryCapture capture = newCapture(Level.WARNING); + + capture.processLine("04-20 12:34:56.789 1234 5678 W MyTag: key: value"); + + verify(tracker).recordManualEventFor(eq(Level.WARNING), eq(Source.CLIENT), eq("key: value")); + } + + @Test + public void processLine_defaultsToWarningWhenMinLevelIsNull() { + LogcatTelemetryCapture capture = newCapture(null); + + capture.processLine("04-20 12:34:56.789 1234 5678 I MyTag: info"); + verify(tracker, never()).recordManualEventFor(any(), any(), any()); + + capture.processLine("04-20 12:34:56.789 1234 5678 W MyTag: warn"); + verify(tracker).recordManualEventFor(eq(Level.WARNING), eq(Source.CLIENT), eq("warn")); + } + + private LogcatTelemetryCapture newCapture(Level minLevel) { + return new LogcatTelemetryCapture(tracker, minLevel, "Rollbar"); + } +}