/** * Copyright 2008 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ package org.waveprotocol.wave.client.debug.logger; import com.google.gwt.core.client.Duration; import com.google.gwt.core.client.GWT; import com.google.gwt.dom.client.DivElement; import com.google.gwt.dom.client.Document; import com.google.gwt.dom.client.Element; import com.google.gwt.dom.client.StyleInjector; import com.google.gwt.i18n.client.NumberFormat; import com.google.gwt.resources.client.ClientBundle; import com.google.gwt.resources.client.CssResource; import com.google.gwt.user.client.Cookies; import org.waveprotocol.wave.client.common.util.DomHelper; import org.waveprotocol.wave.common.logging.AbstractLogger; import org.waveprotocol.wave.common.logging.AbstractLogger.NonNotifyingLogger; import org.waveprotocol.wave.common.logging.InMemoryLogSink; import org.waveprotocol.wave.common.logging.LogSink; import org.waveprotocol.wave.common.logging.LogUtils; import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.TreeSet; /** * * Debug logger */ public class DomLogger extends AbstractLogger implements NonNotifyingLogger { public static final String WEBDRIVER_GET_FATAL_ERROR_HOOK_NAME = "webdriverGetFatalError"; public interface Resources extends ClientBundle { /** CSS class names used by Logger. These are used in Logger.css */ interface Css extends CssResource { String panel(); String entry(); String module(); String error(); String fatal(); String trace(); String time(); String msg(); } @Source("Logger.css") Css css(); } /** This class is to be injected that decides on how to handle errors. */ public interface ErrorHandler { void handleClientErrors(Level level, Throwable t, Object... messages); } /** * @return content of the latest fatal error message if any. */ @SuppressWarnings("unused") // NOTE(user): Used by nativeSetupWebDriverTestPins() static String webdriverGetFatalError() { return DomLogger.latestFatalError; } private static native void nativeSetupWebDriverTestPins(String hookName) /*-{ $wnd[hookName] = function() { return @org.waveprotocol.wave.client.debug.logger.DomLogger::webdriverGetFatalError()(); } }-*/; /** * This is exclusively for Webdriver purpose. * It stores the content of the latest fatal error message if any. */ private static String latestFatalError = ""; static { // TODO(user): prevent exposing this hook if not in ll=debug mode. // Surprisingly doing a LogLevel.showDebug() at this location always returns false. if (GWT.isClient()) { DomLogger.nativeSetupWebDriverTestPins(WEBDRIVER_GET_FATAL_ERROR_HOOK_NAME); } } /** The singleton instance of handler error messages. */ private static ErrorHandler errorHandler = null; /** Whether we should log to console instead of the dom for efficiency purposes. */ private static boolean enableConsoleLogging = false; /** The singleton instance of our resources. */ private static Resources RESOURCES = null; /** * Element the Logger will append log entries to. No logging * will take place when outputElm = null; */ private static Element outputElm = null; /** * All log modules */ private static Set<String> modules = new TreeSet<String>(); /** * Modules to log to buffer. */ private static HashSet<String> enabledModulesBuffer = new HashSet<String>(); /** * In memory log buffer. */ public static InMemoryLogSink logbuffer = new InMemoryLogSink(); /** * Enabled log modules */ private static HashSet<String> enabledModules = new HashSet<String>(); private static ArrayList<LoggerListener> listeners = new ArrayList<LoggerListener>(); /** * Only Logger's with level below this static will log */ private static int maxLevel = Level.TRACE.value(); /** * Cookie name */ private static final String COOKIE_DEBUGLOG_MODULES = "wdm"; static { if (GWT.isClient()) { RESOURCES = GWT.create(Resources.class); // Inject the CSS once. StyleInjector.inject(RESOURCES.css().getText()); // NOTE(user): GWT.create fails if called outside GWTTestCase or the actual client. // Get enabled log modules from cookie String cookie = Cookies.getCookie(COOKIE_DEBUGLOG_MODULES); if (cookie != null) { String[] cookies = cookie.split("\\|"); for (int i = 1; i < cookies.length; i++) { enabledModules.add(cookies[i]); } } } } /** Sets the handler for error messages. This must be set before any errors can be handled. */ public static void setErrorHandler(ErrorHandler handler) { errorHandler = handler; } /** Sets whether we should use console logging */ public static void setEnableConsoleLogging(boolean enable) { enableConsoleLogging = enable; } /** * Sets a cookie value with an expiry date a year from now. * @param key The name of the cookie to set. * @param value The new value for the cookie. */ @SuppressWarnings("deprecation") // Calendar not supported by GWT public static void setCookieValue(String key, String value) { // Only set the cookie value if it is changing: if (value.equals(Cookies.getCookie(key))) { return; } // Set the cookie to expire in one year Date d = new Date(); d.setYear(d.getYear() + 1); Cookies.setCookie(key, value, d); } /** * The Logger's module name */ private final String module; /** * Sets the listener that is interested in logger events. */ public static void addLoggerListener(LoggerListener listener) { DomLogger.listeners.add(listener); } /** * Removes the listener that is interested in logger events. */ public static void removeLoggerListener(LoggerListener listener) { DomLogger.listeners.remove(listener); } private void triggerOnNewLogger(String loggerName) { for (LoggerListener l : listeners) { l.onNewLogger(loggerName); } } /** * Enables all logging (although logging is still subject to module * and max-level disabling) * * @param outputElm The Element to which Logger will * appendChild log entries */ public static void enable(Element outputElm) { DomLogger.outputElm = outputElm; if (RESOURCES != null) { outputElm.addClassName(RESOURCES.css().panel()); } if (shouldLogToConsole()) { appendEntry("Your debug output has been logged to the console. " + "In firefox it is logged to FireBug's console. " + "In Safari it is logged to the error console.", Level.ERROR); } maybeClearOutputCache(); } /** * @return whether we should log to the brower's console */ private static boolean shouldLogToConsole() { return enableConsoleLogging && consoleLoggingAvailable(); } @Override public boolean isModuleEnabled() { return isModuleEnabled(module); } /** * Disables all logging (but remembers module and level disabling) */ public static void disable() { DomLogger.outputElm = null; } /** * Set Loggers' maximum logging level * * @param maxLevel Only Loggers with value below this will log */ public static void setMaxLevel(Level maxLevel) { DomLogger.maxLevel = maxLevel.value(); } /** * Constructs a logger with the default log sink. * @param module */ public DomLogger(String module) { this(module, new GWTLogSink(module)); } /** * Constructs a Logger * * @param module Module string. Log entries will be prefixed with this string; and * logging can be enabled/disabled per-module. Loggers may share module string. */ public DomLogger(String module, LogSink logSink) { super(logSink); if (!modules.contains(module)) { modules.add(module); triggerOnNewLogger(module); } this.module = module; if (GWT.isClient()) { setupNativeLogging(module); } } /** * A static log method for calling by JSNI methods, see {@link #setupNativeLogging(String)} * @param module * * @param msg */ @SuppressWarnings("unused") private static void nativeLog(String module, String msg) { new DomLogger(module).trace().log(msg); } /** * A static logXml method for calling by JSNI methods, see {@link #setupNativeLogging(String)} * * @param module * @param xml */ @SuppressWarnings("unused") private static void nativeLogXml(String module, String xml) { new DomLogger(module).trace().logXml(xml); } /** * A static log method for calling by JSNI methods, see {@link #setupNativeLogging(String)} * * @param module * @param label * @param javaObject */ @SuppressWarnings("unused") private static void nativeLog(String module, String label, Object javaObject) { new DomLogger(module).trace().log(label, javaObject); } /** * Setup a few native methods for logging. Lets JSNI method call <module>Log(msg), * <module>LogXMl(xml), <module>LogJava(javaObject), and <module>LogObject(object). * NB: this puts limitations on logging module names. E.g., 'drag-drop' is outlawed, * as drag-dropLog would not be a legal function name. * * @param module */ private native void setupNativeLogging(String module) /*-{ if (typeof window[module + "Log"] == "undefined") { window[module + "Log"] = function(msg) { @org.waveprotocol.wave.client.debug.logger.DomLogger::nativeLog(Ljava/lang/String;Ljava/lang/String;) (module, msg); } window[module + "LogXml"] = function(xml) { @org.waveprotocol.wave.client.debug.logger.DomLogger::nativeLogXml(Ljava/lang/String;Ljava/lang/String;) (module, xml); } window[module + "LogJava"] = function(label, javaObject) { @org.waveprotocol.wave.client.debug.logger.DomLogger::nativeLog(Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;) (module, label, javaObject); } window[module + "LogObject"] = function(object) { var msg = ''; for (a in object) { msg += a + ': ' + object[a] + '<br/>'; } @org.waveprotocol.wave.client.debug.logger.DomLogger::nativeLog(Ljava/lang/String;Ljava/lang/String;) (module, msg); } } }-*/; /** * Toggles whether this logger's module is enabled/disabled */ public void toggleModule() { toggleModule(module); } /** * Toggles whether a module is enabled/disabled * * @param module */ public static void toggleModule(String module) { enableModule(module, !isModuleEnabled(module)); } /** * Enables/disables this logger's module * * @param enable True if this logger's module should be enabled; * false otherwise */ public void enableModule(boolean enable) { enableModule(module, enable); } /** * Enables/disables buffer for this log's module * * @param enable True if module should be enabled; false otherwise */ public void enableModuleBuffer(boolean enable) { enableModuleBuffer(module, enable); } /** * Enables/disables log module * * @param module * @param enable True if module should be enabled; false otherwise */ public static void enableModule(String module, boolean enable) { if (enable) { enabledModules.add(module); } else { enabledModules.remove(module); } persistEnabledModules(); } /** * Enables/disables buffer for log module * * @param module * @param enable True if module should be enabled; false otherwise */ public static void enableModuleBuffer(String module, boolean enable) { if (enable) { enabledModulesBuffer.add(module); } else { enabledModulesBuffer.remove(module); } } /** * Enables all (known) modules */ public static void enableAllModules() { enabledModules.addAll(modules); } /** * Disables all modules */ public static void disableAllModules() { enabledModules.clear(); } /** * @param module * @return True if module is enabled. */ public static boolean isModuleEnabled(String module) { return enabledModules.contains(module); } /** * @return True if the module is enabled and global logging is enabled. */ public boolean isModuleLoggingEnabled() { return isModuleEnabled(module); } /** * Persists enabled module names in cookie */ private static void persistEnabledModules() { String cookie = "|"; for (String s : enabledModules) { cookie += s + "|"; } try { setCookieValue(COOKIE_DEBUGLOG_MODULES, cookie); } catch (Error ignoreOutsideGWT) { // NOTE(user): this fails with an UnsatisfiedLinkError when // running tests outside GWTTestCase, it's not required for the client to // function so this makes it more robust, I should catch // UnsatisfiedLinkError but that class is not available in GWT } } /** * @return All modules */ public static Collection<String> getModules() { return modules; } /** * Turns attribute of element into string suitable for human consumption * * @param e * @param attr * @return String rendering of attribute */ private static String attr(Element e, String attr) { String a = e.getAttribute(attr); return a == null ? "" : " " + attr + "='" + a + "'"; } /** * Shortens a string to 10 chars by adding '...' * * @param in * @return Shortened version of in */ private static String shortString(String in) { return in.length() <= 10 ? in : in.substring(0, 9) + "..."; } /** * Outputs debug-string representing a DOM element * * @param e * @return XML rendering of element */ @SuppressWarnings("unused") private static String toXml(Element e) { String out = ""; if (DomHelper.isTextNode(e)) { out = "'" + shortString(e.getNodeValue()) + "'"; } else { String tagName = e.getTagName(); out = "<" + tagName; if (tagName.equals("INPUT")) { out += attr(e, "type"); out += attr(e, "value"); } out += attr(e, "class"); if (tagName.equals("A")) { out += ">" + e.getInnerText() + "</A>"; } else { out += "/>"; } } return out; } private static boolean shouldLogToBuffer(String module, Level level) { // Buffer all error messages. return enabledModulesBuffer.contains(module) || level.value() <= Level.ERROR.value(); } private static boolean shouldLogToPanel(String module, Level level) { // Always log fatal messages. if (level.value() == Level.FATAL.value()) { return true; } return outputElm != null && enabledModules.contains(module) && level.value() <= maxLevel; } /** * {@inheritDoc} * * @return If Logger should log based on module, level and * whether logging system is enabled */ @Override protected boolean shouldLog(Level level) { // For production and unit-tests, don't log full stack traces: // NOTE(user): LogLevel.showErrors() indirectly causes a GWT.create, so // guard by GWT.isClient(). boolean shouldShowErrorDetail = GWT.isClient() && LogLevel.showErrors(); // Only log in client/GWTTestCases when logging is not disabled. return shouldShowErrorDetail && (shouldLogToBuffer(module, level) || shouldLogToPanel(module, level)); } /** * Formats a log entry. * @param module * @param msg * @param timestamp */ public static String formatLogEntry(String module, String msg, String timestamp) { return "<span class='" + RESOURCES.css().module() + "'>" + module + "</span> " + "<span class='" + RESOURCES.css().time() + "'>(" + timeStamp() + "):</span> " + "<span class='" + RESOURCES.css().msg() + "'>" + msg + "</span>"; } /** * Appends an entry to the log panel. * @param formatted * @param level */ public static void appendEntry(String formatted, Level level) { DivElement entry = Document.get().createDivElement(); entry.setClassName(RESOURCES.css().entry()); entry.setInnerHTML(formatted); // Add the style name associated with the log level. switch (level) { case ERROR: entry.addClassName(RESOURCES.css().error()); break; case FATAL: entry.addClassName(RESOURCES.css().fatal()); break; case TRACE: entry.addClassName(RESOURCES.css().trace()); break; } // Make fatals detectable by WebDriver, so that tests can early out on // failure: if (level.equals(Level.FATAL)) { latestFatalError = formatted; } writeOrCacheOutput(entry); } /** List of output divs waiting to be written to the log panel. */ private static List<DivElement> outputCache = new ArrayList<DivElement>(); /** Write output or cache it if the output element is not set. */ private static void writeOrCacheOutput(DivElement element) { outputCache.add(element); maybeClearOutputCache(); } /** Clear the output cache if the output element is set. */ private static void maybeClearOutputCache() { if (outputElm != null) { for (DivElement e : outputCache) { outputElm.appendChild(e); } outputCache.clear(); outputElm.setScrollTop(1000000); } } @Override protected void logPlainTextInner(Level level, String msg) { // if we are not logging to console, we want to escape. if (!shouldLogToConsole()) { super.logPlainTextInner(level, msg); } else { doLog(level, msg); } } @Override public void logWithoutNotifying(String message, Level level) { assert this.sink instanceof GWTLogSink : "we pass a GWTLogSink in the constructor so this cast should always be safe"; ((GWTLogSink) this.sink).writeLog(message, level); } private static void notifyLoggerListenersIfErrorOrFatal(Level level) { if (level == Level.ERROR) { for (LoggerListener l : listeners) { l.onError(); } } else if (level == Level.FATAL) { for (LoggerListener l : listeners) { l.onFatal(); } } } /** * Log output sink to Debug panel. */ public static final class GWTLogSink extends LogSink { private final String module; public GWTLogSink(String module) { this.module = module; } @Override public void log(Level level, String message) { lazyLog(level, message); } @Override public void lazyLog(Level level, Object... messages) { String formatted = ""; boolean shouldLogToBuffer = shouldLogToBuffer(module, level); boolean shouldLogToPanel = shouldLogToPanel(module, level); if (shouldLogToBuffer) { logbuffer.lazyLog(level, messages); } if (shouldLogToPanel) { formatted = formatLogEntry(module, LogUtils.stringifyLogObject(messages), timeStamp()); if (shouldLogToConsole()) { // bring up the panel to notify the user that there is something to show. if (level == Level.FATAL) { showOutput(); } String t = module + " (" + timeStamp() + "): " + formatted; logToConsole(t); } else { logToPanel(formatted, level); } } notifyLoggerListenersIfErrorOrFatal(level); } /** * Write log without invoking listeners. */ private void writeLog(String msg, Level level) { String formatted = ""; boolean shouldLogToBuffer = shouldLogToBuffer(module, level); boolean shouldLogToPanel = shouldLogToPanel(module, level); if (shouldLogToBuffer || shouldLogToPanel) { formatted = formatLogEntry(module, msg, timeStamp()); } if (shouldLogToBuffer) { logbuffer.log(level, formatted); } if (shouldLogToPanel) { if (shouldLogToConsole()) { logToConsole(formatted); } else { logToPanel(formatted, level); } } } private void logToPanel(String formatted, Level level) { showOutput(); appendEntry(formatted, level); } } static final NumberFormat TIMESTAMP_FORMAT = (GWT.isClient() ? NumberFormat.getFormat("0000000000.000") : null); /** * @return Timestamp for use in log */ private static String timeStamp() { double ts = Duration.currentTimeMillis() / 1000.0; // divide the startTime to second from millsecond and seconds is much easier to read // for the user. if (TIMESTAMP_FORMAT != null) { return TIMESTAMP_FORMAT.format(ts); } else { return Double.toString(ts); } } /** * Trigger onNeedOutput on all listeners. */ public static void showOutput() { for (LoggerListener l : listeners) { l.onNeedOutput(); } } @Override protected void handleClientErrors(Level level, Throwable t, Object... messages) { if (errorHandler != null) { errorHandler.handleClientErrors(level, t, messages); } } public static native void logToConsole(String msg) /*-{ var log = $wnd.console; if (log) { if (log.markTimeline) { log.markTimeline(msg); } if (log.log) { log.log(msg); } } }-*/; public static native boolean consoleLoggingAvailable() /*-{ return !!($wnd.console); }-*/; }