Skip to content

Commit a873737

Browse files
Add SLF4J logging support for Appium local service (#1014)
* Add support for logging appium server output data through SLF4J. * Format changes * minor changes * format changes * added dependency to alf4j-api * name changes * Fixed review comments. * Fixed indentation. * more indentation fixes. * Fixed javadoc errors. * more javadoc fixes. * added null check of method arguments. * Added @VisibleForTesting annotation to avoid codacy warning.
1 parent de71366 commit a873737

File tree

5 files changed

+243
-0
lines changed

5 files changed

+243
-0
lines changed

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ dependencies {
7575
compile 'commons-io:commons-io:2.6'
7676
compile 'org.springframework:spring-context:5.0.5.RELEASE'
7777
compile 'org.aspectj:aspectjweaver:1.9.1'
78+
compile 'org.slf4j:slf4j-api:1.7.25'
7879

7980
testCompile 'junit:junit:4.12'
8081
testCompile 'org.hamcrest:hamcrest-library:1.3'

src/main/java/io/appium/java_client/service/local/AppiumDriverLocalService.java

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
package io.appium.java_client.service.local;
1818

1919
import static com.google.common.base.Preconditions.checkNotNull;
20+
import static org.slf4j.event.Level.DEBUG;
21+
import static org.slf4j.event.Level.INFO;
2022

23+
import com.google.common.annotations.VisibleForTesting;
2124
import com.google.common.collect.ImmutableList;
2225
import com.google.common.collect.ImmutableMap;
2326

@@ -26,6 +29,9 @@
2629
import org.openqa.selenium.net.UrlChecker;
2730
import org.openqa.selenium.os.CommandLine;
2831
import org.openqa.selenium.remote.service.DriverService;
32+
import org.slf4j.Logger;
33+
import org.slf4j.LoggerFactory;
34+
import org.slf4j.event.Level;
2935

3036
import java.io.File;
3137
import java.io.IOException;
@@ -35,11 +41,20 @@
3541
import java.util.List;
3642
import java.util.concurrent.TimeUnit;
3743
import java.util.concurrent.locks.ReentrantLock;
44+
import java.util.function.BiConsumer;
45+
import java.util.function.Consumer;
46+
import java.util.regex.Matcher;
47+
import java.util.regex.Pattern;
48+
3849
import javax.annotation.Nullable;
3950

4051
public final class AppiumDriverLocalService extends DriverService {
4152

4253
private static final String URL_MASK = "http://%s:%d/wd/hub";
54+
private static final Logger LOG = LoggerFactory.getLogger(AppiumDriverLocalService.class);
55+
private static final Pattern LOG_MESSAGE_PATTERN = Pattern.compile("^(.*)\\R");
56+
private static final Pattern LOGGER_CONTEXT_PATTERN = Pattern.compile("^(\\[debug\\] )?\\[(.+?)\\]");
57+
private static final String APPIUM_SERVICE_SLF4J_LOGGER_PREFIX = "appium.service";
4358
private final File nodeJSExec;
4459
private final ImmutableList<String> nodeJSArgs;
4560
private final ImmutableMap<String, String> nodeJSEnvironment;
@@ -219,4 +234,134 @@ public void addOutPutStreams(List<OutputStream> outputStreams) {
219234
public boolean clearOutPutStreams() {
220235
return stream.clear();
221236
}
237+
238+
/**
239+
* Enables server output data logging through
240+
* <a href="http://slf4j.org">SLF4J</a> loggers. This allow server output
241+
* data to be configured with your preferred logging frameworks (e.g.
242+
* java.util.logging, logback, log4j).
243+
*
244+
* <p>NOTE1: You might want to call method {@link #clearOutPutStreams()} before
245+
* calling this method.<br>
246+
* NOTE2: it is required that {@code --log-timestamp} server flag is
247+
* {@code false}.
248+
*
249+
* <p>By default log messages are:
250+
* <ul>
251+
* <li>logged at {@code INFO} level, unless log message is pre-fixed by
252+
* {@code [debug]} then logged at {@code DEBUG} level.</li>
253+
* <li>logged by a <a href="http://slf4j.org">SLF4J</a> logger instance with
254+
* a name corresponding to the appium sub module as prefixed in log message
255+
* (logger name is transformed to lower case, no spaces and prefixed with
256+
* "appium.service.").</li>
257+
* </ul>
258+
* Example log-message: "[ADB] Cannot read version codes of " is logged by
259+
* logger: {@code appium.service.adb} at level {@code INFO}.
260+
* <br>
261+
* Example log-message: "[debug] [XCUITest] Xcode version set to 'x.y.z' "
262+
* is logged by logger {@code appium.service.xcuitest} at level
263+
* {@code DEBUG}.
264+
* <br>
265+
*
266+
* @see #addSlf4jLogMessageConsumer(BiConsumer)
267+
*/
268+
public void enableDefaultSlf4jLoggingOfOutputData() {
269+
addSlf4jLogMessageConsumer((logMessage, ctx) -> {
270+
if (ctx.getLevel().equals(DEBUG)) {
271+
ctx.getLogger().debug(logMessage);
272+
} else {
273+
ctx.getLogger().info(logMessage);
274+
}
275+
});
276+
}
277+
278+
/**
279+
* When a complete log message is available (from server output data) that
280+
* message is parsed for its slf4j context (logger name, logger level etc.)
281+
* and the specified {@code BiConsumer} is invoked with the log message and
282+
* slf4j context.
283+
*
284+
* <p>Use this method only if you want a behavior that differentiates from the
285+
* default behavior as enabled by method
286+
* {@link #enableDefaultSlf4jLoggingOfOutputData()}.
287+
*
288+
* <p>NOTE: You might want to call method {@link #clearOutPutStreams()} before
289+
* calling this method.
290+
*
291+
* <p>implementation detail:
292+
* <ul>
293+
* <li>if log message begins with {@code [debug]} then log level is set to
294+
* {@code DEBUG}, otherwise log level is {@code INFO}.</li>
295+
* <li>the appium sub module name is parsed from the log message and used as
296+
* logger name (prefixed with "appium.service.", all lower case, spaces
297+
* removed). If no appium sub module is detected then "appium.service" is
298+
* used as logger name.</li>
299+
* </ul>
300+
* Example log-message: "[ADB] Cannot read version codes of " is logged by
301+
* {@code appium.service.adb} at level {@code INFO} <br>
302+
* Example log-message: "[debug] [XCUITest] Xcode version set to 'x.y.z' "
303+
* is logged by {@code appium.service.xcuitest} at level {@code DEBUG}
304+
* <br>
305+
*
306+
* @param slf4jLogMessageConsumer
307+
* BiConsumer block to be executed when a log message is
308+
* available.
309+
*/
310+
public void addSlf4jLogMessageConsumer(BiConsumer<String, Slf4jLogMessageContext> slf4jLogMessageConsumer) {
311+
checkNotNull(slf4jLogMessageConsumer, "slf4jLogMessageConsumer parameter is NULL!");
312+
addLogMessageConsumer(logMessage -> {
313+
slf4jLogMessageConsumer.accept(logMessage, parseSlf4jContextFromLogMessage(logMessage));
314+
});
315+
}
316+
317+
@VisibleForTesting
318+
static Slf4jLogMessageContext parseSlf4jContextFromLogMessage(String logMessage) {
319+
Matcher m = LOGGER_CONTEXT_PATTERN.matcher(logMessage);
320+
String loggerName = APPIUM_SERVICE_SLF4J_LOGGER_PREFIX;
321+
Level level = INFO;
322+
if (m.find()) {
323+
loggerName += "." + m.group(2).toLowerCase().replaceAll("\\s+", "");
324+
if (m.group(1) != null) {
325+
level = DEBUG;
326+
}
327+
}
328+
return new Slf4jLogMessageContext(loggerName, level);
329+
}
330+
331+
/**
332+
* When a complete log message is available (from server output data), the
333+
* specified {@code Consumer} is invoked with that log message.
334+
*
335+
* <p>NOTE: You might want to call method {@link #clearOutPutStreams()} before
336+
* calling this method.
337+
*
338+
* <p>If the Consumer fails and throws an exception the exception is logged (at
339+
* WARN level) and execution continues.
340+
* <br>
341+
*
342+
* @param consumer
343+
* Consumer block to be executed when a log message is available.
344+
*
345+
*/
346+
public void addLogMessageConsumer(Consumer<String> consumer) {
347+
checkNotNull(consumer, "consumer parameter is NULL!");
348+
addOutPutStream(new OutputStream() {
349+
StringBuilder lineBuilder = new StringBuilder();
350+
351+
@Override
352+
public void write(int chr) throws IOException {
353+
try {
354+
lineBuilder.append((char) chr);
355+
Matcher matcher = LOG_MESSAGE_PATTERN.matcher(lineBuilder.toString());
356+
if (matcher.matches()) {
357+
consumer.accept(matcher.group(1));
358+
lineBuilder = new StringBuilder();
359+
}
360+
} catch (Exception e) {
361+
// log error and continue
362+
LOG.warn("Log message consumer crashed!", e);
363+
}
364+
}
365+
});
366+
}
222367
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package io.appium.java_client.service.local;
2+
3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
5+
import org.slf4j.event.Level;
6+
7+
/**
8+
* This class provides context to a Slf4jLogMessageConsumer.
9+
*
10+
*/
11+
public final class Slf4jLogMessageContext {
12+
private final Logger logger;
13+
private final Level level;
14+
15+
Slf4jLogMessageContext(String loggerName, Level level) {
16+
this.level = level;
17+
this.logger = LoggerFactory.getLogger(loggerName);
18+
}
19+
20+
/**
21+
* Returns the {@link Logger} instance associated with this context.
22+
*
23+
* @return {@link Logger} instance associated with this context.
24+
*
25+
*/
26+
public Logger getLogger() {
27+
return logger;
28+
}
29+
30+
/**
31+
* Returns log {@link Level} for the log message associated with this context.
32+
*
33+
* @return {@link Level} for log message associated with this context.
34+
*/
35+
public Level getLevel() {
36+
return level;
37+
}
38+
39+
/**
40+
* Returns the name of the {@link Logger} associated with this context.
41+
*
42+
* @return name of {@link Logger} associated with this context.
43+
*/
44+
public String getName() {
45+
return logger.getName();
46+
}
47+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package io.appium.java_client.service.local;
2+
3+
import static io.appium.java_client.service.local.AppiumDriverLocalService.parseSlf4jContextFromLogMessage;
4+
import static org.junit.Assert.assertEquals;
5+
import static org.slf4j.LoggerFactory.getLogger;
6+
import static org.slf4j.event.Level.DEBUG;
7+
import static org.slf4j.event.Level.INFO;
8+
9+
import org.junit.Test;
10+
import org.slf4j.event.Level;
11+
12+
public class AppiumDriverLocalServiceTest {
13+
14+
@Test
15+
public void canParseSlf4jLoggerContext() throws Exception {
16+
assertLoggerContext(INFO, "appium.service.androidbootstrap",
17+
"[AndroidBootstrap] [BOOTSTRAP LOG] [debug] json loading complete.");
18+
assertLoggerContext(INFO, "appium.service.adb",
19+
"[ADB] Cannot read version codes of ");
20+
assertLoggerContext(INFO, "appium.service.xcuitest",
21+
"[XCUITest] Determining device to run tests on: udid: '1234567890', real device: true");
22+
assertLoggerContext(INFO, "appium.service",
23+
"no-prefix log message.");
24+
assertLoggerContext(INFO, "appium.service",
25+
"no-prefix log [not-a-logger-name] message.");
26+
assertLoggerContext(DEBUG, "appium.service.mjsonwp",
27+
"[debug] [MJSONWP] Calling AppiumDriver.getStatus() with args: []");
28+
assertLoggerContext(DEBUG, "appium.service.xcuitest",
29+
"[debug] [XCUITest] Xcode version set to 'x.y.z' ");
30+
assertLoggerContext(DEBUG, "appium.service.jsonwpproxy",
31+
"[debug] [JSONWP Proxy] Proxying [GET /status] to [GET http://localhost:18218/status] with no body");
32+
}
33+
34+
private void assertLoggerContext(Level expectedLevel, String expectedLoggerName, String logMessage) {
35+
Slf4jLogMessageContext ctx = parseSlf4jContextFromLogMessage(logMessage);
36+
assertEquals(expectedLoggerName, ctx.getName());
37+
assertEquals(expectedLevel, ctx.getLevel());
38+
assertEquals(getLogger(expectedLoggerName), ctx.getLogger());
39+
}
40+
}

src/test/java/io/appium/java_client/service/local/ServerBuilderTest.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import java.net.InetAddress;
4040
import java.net.NetworkInterface;
4141
import java.nio.file.Path;
42+
import java.util.ArrayList;
4243
import java.util.Enumeration;
4344
import java.util.List;
4445

@@ -114,6 +115,15 @@ public void tearDown() throws Exception {
114115
ofNullable(PATH_TO_APPIUM_NODE_IN_PROPERTIES).ifPresent(s -> setProperty(APPIUM_PATH, s));
115116
}
116117

118+
@Test public void checkAbilityToAddLogMessageConsumer() {
119+
List<String> log = new ArrayList<>();
120+
service = buildDefaultService();
121+
service.clearOutPutStreams();
122+
service.addLogMessageConsumer(log::add);
123+
service.start();
124+
assertTrue(log.size() > 0);
125+
}
126+
117127
@Test public void checkAbilityToStartDefaultService() {
118128
service = buildDefaultService();
119129
service.start();

0 commit comments

Comments
 (0)