Skip to content

Add SLF4J logging support for Appium local service #1014

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ dependencies {
compile 'commons-io:commons-io:2.6'
compile 'org.springframework:spring-context:5.0.5.RELEASE'
compile 'org.aspectj:aspectjweaver:1.9.1'
compile 'org.slf4j:slf4j-api:1.7.25'

testCompile 'junit:junit:4.12'
testCompile 'org.hamcrest:hamcrest-library:1.3'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
package io.appium.java_client.service.local;

import static com.google.common.base.Preconditions.checkNotNull;
import static org.slf4j.event.Level.DEBUG;
import static org.slf4j.event.Level.INFO;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
Expand All @@ -26,6 +28,9 @@
import org.openqa.selenium.net.UrlChecker;
import org.openqa.selenium.os.CommandLine;
import org.openqa.selenium.remote.service.DriverService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;

import java.io.File;
import java.io.IOException;
Expand All @@ -35,11 +40,19 @@
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.annotation.Nullable;

public final class AppiumDriverLocalService extends DriverService {

private static final String URL_MASK = "http://%s:%d/wd/hub";
private static final Logger LOG = LoggerFactory.getLogger(AppiumDriverLocalService.class);
private static final Pattern LOG_MESSAGE_PATTERN = Pattern.compile("^(.*)\\R");
private static final Pattern LOGGER_CONTEXT_PATTERN = Pattern.compile("^(\\[debug\\] )?\\[(.+?)\\]");
private final File nodeJSExec;
private final ImmutableList<String> nodeJSArgs;
private final ImmutableMap<String, String> nodeJSEnvironment;
Expand Down Expand Up @@ -219,4 +232,127 @@ public void addOutPutStreams(List<OutputStream> outputStreams) {
public boolean clearOutPutStreams() {
return stream.clear();
}

/**
* Enables server output data logging through
* <a href="http://slf4j.org">SLF4J</a> loggers. This allow server output
* data to be configured with your preferred logging frameworks (e.g.
* java.util.logging, logback, log4j).</br>
* </br>
* NOTE1: You might want to call method {@link #clearOutPutStreams()} before
* calling this method. </br>
* NOTE2: it is required that {@link --log-timestamp} server flag is
* {@link false}. </br>
* </br>
* By default log messages are:
* <li>logged at {@code INFO} level, unless log message is pre-fixed by
* {@code [debug]} then logged at {@code DEBUG} level.</li>
* <li>logged by a <a href="http://slf4j.org">SLF4J</a> logger instance with
* a name corresponding to the appium sub module as prefixed in log message
* (logger name is transformed to lower case, no spaces and prefixed with
* "appium.service.").</li> </br>
* Example log-message: "[ADB] Cannot read version codes of " is logged by
* logger: {@code appium.service.adb} at level {@code INFO}. </br>
* Example log-message: "[debug] [XCUITest] Xcode version set to 'x.y.z' "
* is logged by logger {@code appium.service.xcuitest} at level
* {@code DEBUG}.
*
* </br>
* </br>
*
* @see #addSlf4jLogMessageConsumer(BiConsumer)
*/
public void enableDefaultSlf4jLoggingOfOutputData() {
addSlf4jLogMessageConsumer((logMessage, ctx) -> {
if (ctx.level().equals(DEBUG)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mapping is not sufficient, since Appium server uses more levels, for example warn and error

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will add the other levels as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I have never seen an actual log message on either warn/error level. Do you know if they follow same format as debug? i.e. log message is prefixed with log level. Info level tag is not added in log message.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just able to trigger a WARNmessage, but there is no log level indication in the log message it self. So will ignore warn and error levels for now.

Example

[Appium] Deprecated server args: 
[Appium]   --device-name => --default-capabilities '{"deviceName":"someName"}'

According to this, the above log message is at WARNlevel:
https://github.com/appium/appium/blob/7a00b3f875991ba1d95359ed53a3eda47911f64b/lib/main.js#L44

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the actual level is added there if logging timestamp is enabled:

2018-08-31 08:17:13:813 - info: [debug] [JSONWP Proxy] Got response with status 200: {"value":{"using":"class chain","value":"**/XCUIElementTypeCollectionView[`name == 'add_participants.list'`]/XCUIElementTypeCell[$name == 'user_cell.name' AND value == 'qrj7pcz5'$]","description":"unable to find an element"},"sessionId":"A4FBAD14-ED57-46D4-9407-B91C76BCB415","status":7}
2018-08-31 08:17:13:814 - info: [debug] [MJSONWP] Matched JSONWP error code 7 to NoSuchElementError
2018-08-31 08:17:13:815 - error: [W3C] Encountered internal error running command: NoSuchElementError: An element could not be located on the page using the given search parameters.
2018-08-31 08:17:13:815 - error: [W3C]     at XCUITestDriver.callee$0$0$ (/usr/local/lib/node_modules/appium/node_modules/appium-xcuitest-driver/lib/commands/find.js:130:13)

ctx.logger().debug(logMessage);
} else {
ctx.logger().info(logMessage);
}
});
}

/**
* When a complete log message is available (from server output data) that
* message is parsed for its slf4j context (logger name, logger level etc.)
* and the specified {@code BiConsumer} is invoked with the log message and
* slf4j context.</br>
* </br>
* Use this method only if you want a behavior that differentiates from the
* default behavior as enabled by method
* {@link #enableDefaultSlf4jLoggingOfOutputData()}. </br>
* </br>
* NOTE: You might want to call method {@link #clearOutPutStreams()} before
* calling this method. </br>
* </br>
* implementation detail:
* <li>if log message begins with {@code [debug]} then log level is set to
* {@code DEBUG}, otherwise log level is {@code INFO}.</li>
* <li>the appium sub module name is parsed from the log message and used as
* logger name (prefixed with "appium.service.", all lower case, spaces
* removed). If no appium sub module is detected then "appium.service" is
* used as logger name.</li> </br>
* Example log-message: "[ADB] Cannot read version codes of " is logged by
* {@code appium.service.adb} at level {@code INFO} </br>
* Example log-message: "[debug] [XCUITest] Xcode version set to 'x.y.z' "
* is logged by {@code appium.service.xcuitest} at level {@code DEBUG}
*
* @param slf4jLogMessageConsumer
* BiConsumer block to be executed when a log message is
* available.
*/
public void addSlf4jLogMessageConsumer(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find it useful to return self instance from such methods, so one can chain them

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it is nice to return self to chain method calls. But no other methods in this class does that, so I figured to follow same pattern as other methods. Would be strange if only this method returned self.

BiConsumer<String, Slf4jLogMessageContext> slf4jLogMessageConsumer) {
addLogMessageConsumer(logMessage -> {
slf4jLogMessageConsumer.accept(logMessage, parseSlf4jContextFromLogMessage(logMessage));
});
}

static Slf4jLogMessageContext parseSlf4jContextFromLogMessage(String logMessage) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

codacy does not like package-level visibility. Please add public

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mykola-mokhnach There is a big difference between public and package private. Making it public will make it part of the public API, which is not desired in this case.

I don't think we should make an implementation detail public just because codacy says so, or?

Matcher m = LOGGER_CONTEXT_PATTERN.matcher(logMessage);
String loggerName = "appium.service";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be in the constant

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not following you on this. What should be in the constant? Which constant?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private static final String LOGGER_NAME

Level level = INFO;
if (m.find()) {
loggerName += "." + m.group(2).toLowerCase().replaceAll("\\s+", "");
if (m.group(1) != null) {
level = DEBUG;
}
}
return new Slf4jLogMessageContext(loggerName, level);
}

/**
* When a complete log message is available (from server output data), the
* specified {@code Consumer} is invoked with that log message. </br>
* </br>
* NOTE: You might want to call method {@link #clearOutPutStreams()} before
* calling this method. </br>
* </br>
* If the Consumer fails and throws an exception the exception is logged (at
* WARN level) and execution continues.
*
* @param consumer
* Consumer block to be executed when a log message is available.
*
*/
public void addLogMessageConsumer(Consumer<String> consumer) {
addOutPutStream(new OutputStream() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Output

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not following this comment? addOutPutStream(...) is the name of an already existing method.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a typo in this word

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mykola-mokhnach
I'm still not following what this has to do with this PR?
As I said the addOutPutStream(...) method is already existing. Changing its name would be a breaking change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

then yes, it is better to keep it as is if it was there

StringBuilder lineBuilder = new StringBuilder();

@Override
public void write(int chr) throws IOException {
try {
lineBuilder.append((char) chr);
Matcher matcher = LOG_MESSAGE_PATTERN.matcher(lineBuilder.toString());
if (matcher.matches()) {
consumer.accept(matcher.group(1));
lineBuilder = new StringBuilder();
}
} catch (Exception e) {
// log error and continue
LOG.warn("Log message consumer crashed!", e);
}
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package io.appium.java_client.service.local;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;

/**
* This class provides context to a Slf4jLogMessageConsumer.
*
* @see {@link AppiumDriverLocalService#addSlf4jLogMessageConsumer(java.util.function.BiConsumer)}
*
*/
public final class Slf4jLogMessageContext {
private final Logger logger;
private final Level level;

Slf4jLogMessageContext(String loggerName, Level level) {
this.level = level;
this.logger = LoggerFactory.getLogger(loggerName);
}

/**
* @return {@link Logger} instance associated with this context.
*
*/
public Logger logger() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

java getters usually start with get

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will fix that

return logger;
}

/**
* @return {@link Level} for log message associated with this context.
*/
public Level level() {
return level;
}

/**
* @return name of {@link Logger} associated with this context.
*/
public String name() {
return logger.getName();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package io.appium.java_client.service.local;

import static io.appium.java_client.service.local.AppiumDriverLocalService.parseSlf4jContextFromLogMessage;
import static org.junit.Assert.assertEquals;
import static org.slf4j.LoggerFactory.getLogger;
import static org.slf4j.event.Level.DEBUG;
import static org.slf4j.event.Level.INFO;

import org.junit.Test;
import org.slf4j.event.Level;

public class AppiumDriverLocalServiceTest {

@Test public void canParseSlf4jLoggerContext() throws Exception {
assertLoggerContext(INFO, "appium.service.androidbootstrap", "[AndroidBootstrap] [BOOTSTRAP LOG] [debug] json loading complete.");
assertLoggerContext(INFO, "appium.service.adb", "[ADB] Cannot read version codes of ");
assertLoggerContext(INFO, "appium.service.xcuitest", "[XCUITest] Determining device to run tests on: udid: '1234567890', real device: true");
assertLoggerContext(INFO, "appium.service", "no-prefix log message.");
assertLoggerContext(INFO, "appium.service", "no-prefix log [not-a-logger-name] message.");
assertLoggerContext(DEBUG, "appium.service.mjsonwp", "[debug] [MJSONWP] Calling AppiumDriver.getStatus() with args: []");
assertLoggerContext(DEBUG, "appium.service.xcuitest", "[debug] [XCUITest] Xcode version set to 'x.y.z' ");
assertLoggerContext(DEBUG, "appium.service.jsonwpproxy", "[debug] [JSONWP Proxy] Proxying [GET /status] to [GET http://localhost:18218/status] with no body");

}

private void assertLoggerContext(Level expectedLevel, String expectedLoggerName, String logMessage) {
Slf4jLogMessageContext ctx = parseSlf4jContextFromLogMessage(logMessage);
assertEquals(expectedLoggerName, ctx.name());
assertEquals(expectedLevel, ctx.level());
assertEquals(getLogger(expectedLoggerName), ctx.logger());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;

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

@Test public void checkAbilityToAddLogMessageConsumer() {
List<String> log = new ArrayList<>();
service = buildDefaultService();
service.clearOutPutStreams();
service.addLogMessageConsumer(log::add);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we also have a possibility to remove a consumer?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Essentially a consumer in this case is an OutputStream (as added with addOutPutStream(...) method). So consumers can be removed with the clearOutPutStreams() method.

service.start();
assertTrue(log.size() > 0);
}

@Test public void checkAbilityToStartDefaultService() {
service = buildDefaultService();
service.start();
Expand Down