Skip to content

Commit 62eccb8

Browse files
authored
[Feat] [SDK-399] Capture logcat output as telemetry events (#369)
* feat(android): capture logcat output as telemetry events * fix: include verbosity logs for debug level * fix(android): reset logcat capture state on unexpected process death * fix(android): skip logcat ring buffer replay on startup * fix(android): classify captured logcat entries as log telemetry type * fix(android): resolve test failure caused by missing Android stub defaults * fix(android): use Rollbar.TAG in ConnectivityDetector to suppress SDK logs from telemetry * docs(android): correct captureLogsAsTelemetry javadoc to reference log telemetry type * docs: fix stale dump() references in telemetry javadoc * fix(android): call stop() before Log.w() to avoid Android stub throwing before state reset
1 parent 4d56b32 commit 62eccb8

7 files changed

Lines changed: 459 additions & 4 deletions

File tree

rollbar-android/src/main/java/com/rollbar/android/AndroidConfiguration.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
package com.rollbar.android;
22

33
import com.rollbar.android.anr.AnrConfiguration;
4+
import com.rollbar.api.payload.data.Level;
45

56
public class AndroidConfiguration {
67
private final AnrConfiguration anrConfiguration;
78
private final boolean mustCaptureNavigationEvents;
9+
private final boolean mustCaptureLogsAsTelemetry;
10+
private final Level minimumLogCaptureLevel;
811

912
AndroidConfiguration(Builder builder) {
1013
anrConfiguration = builder.anrConfiguration;
1114
mustCaptureNavigationEvents = builder.mustCaptureNavigationEvents;
15+
mustCaptureLogsAsTelemetry = builder.mustCaptureLogsAsTelemetry;
16+
minimumLogCaptureLevel = builder.minimumLogCaptureLevel;
1217
}
1318

1419
public AnrConfiguration getAnrConfiguration() {
@@ -19,10 +24,20 @@ public boolean mustCaptureNavigationEvents() {
1924
return mustCaptureNavigationEvents;
2025
}
2126

27+
public boolean mustCaptureLogsAsTelemetry() {
28+
return mustCaptureLogsAsTelemetry;
29+
}
30+
31+
public Level getMinimumLogCaptureLevel() {
32+
return minimumLogCaptureLevel;
33+
}
34+
2235

2336
public static final class Builder {
2437
private AnrConfiguration anrConfiguration;
2538
private boolean mustCaptureNavigationEvents = true;
39+
private boolean mustCaptureLogsAsTelemetry = false;
40+
private Level minimumLogCaptureLevel = Level.WARNING;
2641

2742
public Builder() {
2843
anrConfiguration = new AnrConfiguration.Builder().build();
@@ -49,6 +64,32 @@ public Builder captureNewActivityTelemetryEvents(boolean mustCaptureNavigationEv
4964
return this;
5065
}
5166

67+
/**
68+
* Enable or disable automatic capture of Android log output as telemetry events.
69+
* When enabled, logs emitted via {@code android.util.Log} (and any other source written to
70+
* logcat from this app's UID, including third-party libraries) at or above the configured
71+
* minimum level are recorded as log telemetry events with
72+
* {@link com.rollbar.api.payload.data.Source#CLIENT}.
73+
* Default is disabled.
74+
* @param mustCaptureLogsAsTelemetry if automatic capture must be enabled or disabled.
75+
* @return the builder instance
76+
*/
77+
public Builder captureLogsAsTelemetry(boolean mustCaptureLogsAsTelemetry) {
78+
this.mustCaptureLogsAsTelemetry = mustCaptureLogsAsTelemetry;
79+
return this;
80+
}
81+
82+
/**
83+
* Minimum log level to capture as telemetry when {@link #captureLogsAsTelemetry(boolean)}
84+
* is enabled. Default is {@link Level#WARNING}.
85+
* @param minimumLogCaptureLevel the minimum level (inclusive) to capture.
86+
* @return the builder instance
87+
*/
88+
public Builder minimumLogCaptureLevel(Level minimumLogCaptureLevel) {
89+
this.minimumLogCaptureLevel = minimumLogCaptureLevel;
90+
return this;
91+
}
92+
5293
public AndroidConfiguration build() {
5394
return new AndroidConfiguration(this);
5495
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package com.rollbar.android;
2+
3+
import android.util.Log;
4+
5+
import com.rollbar.api.payload.data.Level;
6+
import com.rollbar.api.payload.data.Source;
7+
import com.rollbar.notifier.telemetry.TelemetryEventTracker;
8+
9+
import java.io.BufferedReader;
10+
import java.io.IOException;
11+
import java.io.InputStreamReader;
12+
import java.nio.charset.Charset;
13+
import java.util.regex.Matcher;
14+
import java.util.regex.Pattern;
15+
16+
class LogcatTelemetryCapture {
17+
18+
// threadtime format: "MM-dd HH:mm:ss.SSS PID TID L Tag: message"
19+
private static final Pattern LOGCAT_LINE_PATTERN = Pattern.compile(
20+
"^\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3}\\s+\\d+\\s+\\d+\\s+([VDIWEF])\\s+(.+?):\\s(.*)$"
21+
);
22+
23+
private final TelemetryEventTracker tracker;
24+
private final Level minimumLevel;
25+
private final String selfTag;
26+
private final ProcessFactory processFactory;
27+
28+
private Thread thread;
29+
private Process process;
30+
private volatile boolean running;
31+
32+
LogcatTelemetryCapture(
33+
TelemetryEventTracker tracker,
34+
Level minimumLevel,
35+
String selfTag
36+
) {
37+
this(tracker, minimumLevel, selfTag, defaultProcessFactory());
38+
}
39+
40+
LogcatTelemetryCapture(
41+
TelemetryEventTracker tracker,
42+
Level minimumLevel,
43+
String selfTag,
44+
ProcessFactory processFactory
45+
) {
46+
this.tracker = tracker;
47+
this.minimumLevel = minimumLevel != null ? minimumLevel : Level.WARNING;
48+
this.selfTag = selfTag;
49+
this.processFactory = processFactory;
50+
}
51+
52+
synchronized void start() {
53+
if (running) {
54+
return;
55+
}
56+
try {
57+
this.process = processFactory.start(logcatPriorityFor(this.minimumLevel));
58+
} catch (IOException e) {
59+
Log.w(Rollbar.TAG, "Failed to start logcat telemetry capture", e);
60+
return;
61+
}
62+
running = true;
63+
thread = new Thread(new Runnable() {
64+
@Override
65+
public void run() {
66+
readLoop();
67+
}
68+
}, "rollbar-logcat-telemetry");
69+
thread.setDaemon(true);
70+
thread.start();
71+
}
72+
73+
synchronized void stop() {
74+
if (!running) {
75+
return;
76+
}
77+
running = false;
78+
if (process != null) {
79+
process.destroy();
80+
process = null;
81+
}
82+
if (thread != null) {
83+
thread.interrupt();
84+
thread = null;
85+
}
86+
}
87+
88+
private void readLoop() {
89+
Process currentProcess = this.process;
90+
if (currentProcess == null) {
91+
return;
92+
}
93+
BufferedReader reader = new BufferedReader(
94+
new InputStreamReader(currentProcess.getInputStream(), Charset.forName("UTF-8")));
95+
try {
96+
String line;
97+
while (running && (line = reader.readLine()) != null) {
98+
processLine(line);
99+
}
100+
} catch (IOException e) {
101+
// Process died or was destroyed — expected on stop().
102+
} finally {
103+
try {
104+
reader.close();
105+
} catch (IOException ignored) {
106+
}
107+
if (running) {
108+
stop();
109+
Log.w(Rollbar.TAG, "logcat process exited unexpectedly; resetting capture state");
110+
}
111+
}
112+
}
113+
114+
void processLine(String line) {
115+
if (line == null) {
116+
return;
117+
}
118+
Matcher matcher = LOGCAT_LINE_PATTERN.matcher(line);
119+
if (!matcher.matches()) {
120+
return;
121+
}
122+
123+
String priority = matcher.group(1);
124+
String tag = matcher.group(2).trim();
125+
String message = matcher.group(3);
126+
127+
if (selfTag != null && selfTag.equals(tag)) {
128+
return;
129+
}
130+
131+
Level level = mapPriorityToLevel(priority);
132+
if (level == null) {
133+
return;
134+
}
135+
if (level.level() < minimumLevel.level()) {
136+
return;
137+
}
138+
139+
try {
140+
tracker.recordLogEventFor(level, Source.CLIENT, message);
141+
} catch (Exception e) {
142+
// Never let a broken tracker kill the reader thread.
143+
}
144+
}
145+
146+
static Level mapPriorityToLevel(String priority) {
147+
if (priority == null || priority.isEmpty()) {
148+
return null;
149+
}
150+
switch (priority.charAt(0)) {
151+
case 'V':
152+
case 'D':
153+
return Level.DEBUG;
154+
case 'I':
155+
return Level.INFO;
156+
case 'W':
157+
return Level.WARNING;
158+
case 'E':
159+
return Level.ERROR;
160+
case 'F':
161+
return Level.CRITICAL;
162+
default:
163+
return null;
164+
}
165+
}
166+
167+
static String logcatPriorityFor(Level level) {
168+
if (level == null) {
169+
return "W";
170+
}
171+
switch (level) {
172+
case DEBUG:
173+
return "V";
174+
case INFO:
175+
return "I";
176+
case WARNING:
177+
return "W";
178+
case ERROR:
179+
return "E";
180+
case CRITICAL:
181+
return "F";
182+
default:
183+
return "W";
184+
}
185+
}
186+
187+
interface ProcessFactory {
188+
Process start(String priorityFilter) throws IOException;
189+
}
190+
191+
private static ProcessFactory defaultProcessFactory() {
192+
return new ProcessFactory() {
193+
@Override
194+
public Process start(String priorityFilter) throws IOException {
195+
return new ProcessBuilder(
196+
"logcat", "-v", "threadtime", "-T", "1", "*:" + priorityFilter)
197+
.redirectErrorStream(true)
198+
.start();
199+
}
200+
};
201+
}
202+
}

rollbar-android/src/main/java/com/rollbar/android/Rollbar.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public class Rollbar implements Closeable {
6767
private final ConnectionAwareSenderFailureStrategy senderFailureStrategy;
6868

6969
private com.rollbar.notifier.Rollbar rollbar;
70+
private LogcatTelemetryCapture logcatTelemetryCapture;
7071
private static Rollbar notifier;
7172

7273
private final int versionCode;
@@ -236,6 +237,7 @@ public static Rollbar init(
236237
if (androidConfiguration != null) {
237238
initAnrDetector(context, androidConfiguration);
238239
initAutomaticCaptureOfNavigationTelemetryEvents(context, androidConfiguration);
240+
initAutomaticCaptureOfLogTelemetryEvents(androidConfiguration);
239241
}
240242
}
241243

@@ -277,12 +279,21 @@ public static Rollbar init(Context context, ConfigProvider provider) {
277279
AndroidConfiguration androidConfiguration = makeDefaultAndroidConfiguration();
278280
initAnrDetector(context, androidConfiguration);
279281
initAutomaticCaptureOfNavigationTelemetryEvents(context, androidConfiguration);
282+
initAutomaticCaptureOfLogTelemetryEvents(androidConfiguration);
280283
}
281284
return notifier;
282285
}
283286

284287
@Override
285288
public void close() throws IOException {
289+
if (logcatTelemetryCapture != null) {
290+
try {
291+
logcatTelemetryCapture.stop();
292+
} catch (Exception e) {
293+
Log.w(TAG, "Error stopping logcat telemetry capture", e);
294+
}
295+
logcatTelemetryCapture = null;
296+
}
286297
if (rollbar != null) {
287298
try {
288299
rollbar.close(false);
@@ -1202,6 +1213,31 @@ private static void initAutomaticCaptureOfNavigationTelemetryEvents(
12021213
}
12031214
}
12041215

1216+
private static void initAutomaticCaptureOfLogTelemetryEvents(
1217+
AndroidConfiguration androidConfiguration
1218+
) {
1219+
if (!androidConfiguration.mustCaptureLogsAsTelemetry()) {
1220+
return;
1221+
}
1222+
1223+
com.rollbar.notifier.Rollbar rollbarNotifier = notifier.rollbar;
1224+
if (rollbarNotifier == null) {
1225+
return;
1226+
}
1227+
1228+
TelemetryEventTracker telemetryEventTracker = rollbarNotifier.getTelemetryEventTracker();
1229+
if (telemetryEventTracker == null) {
1230+
return;
1231+
}
1232+
1233+
LogcatTelemetryCapture logcatTelemetryCapture = new LogcatTelemetryCapture(
1234+
telemetryEventTracker,
1235+
androidConfiguration.getMinimumLogCaptureLevel(),
1236+
TAG);
1237+
logcatTelemetryCapture.start();
1238+
notifier.logcatTelemetryCapture = logcatTelemetryCapture;
1239+
}
1240+
12051241
private String loadAccessTokenFromManifest(Context context) throws NameNotFoundException {
12061242
Context appContext = context.getApplicationContext();
12071243
ApplicationInfo ai = appContext.getPackageManager().getApplicationInfo(appContext.getPackageName(), PackageManager.GET_META_DATA);

rollbar-android/src/main/java/com/rollbar/android/notifier/sender/ConnectivityDetector.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import android.net.NetworkInfo;
1414
import android.os.Bundle;
1515
import android.util.Log;
16+
import com.rollbar.android.Rollbar;
1617
import com.rollbar.notifier.util.ObjectsUtils;
1718

1819
import java.io.Closeable;
@@ -46,7 +47,7 @@ public void updateContext(Context androidContext) {
4647
String message = "This application is missing the " +
4748
"android.permission.ACCESS_NETWORK_STATE permission. The Rollbar notifier " +
4849
"will *not* be able to detect when the network is unavailable.";
49-
Log.w(ConnectivityDetector.class.getCanonicalName(), message);
50+
Log.w(Rollbar.TAG, message);
5051
}
5152
}
5253

0 commit comments

Comments
 (0)