Logging

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against GWT 2.13.1 (2026-06-19), gwt-maven-plugin 1.3.0 and the Jakarta Servlet 6 stack of the modular-webapp archetype, with a JDK 21 toolchain and -sourceLevel 17, and was checked on 2026-10-09. Every output block was produced with the Bookshelf scenario of this section: a three-module application compiled to JavaScript, served by Jetty 12 (EE 10) and loaded in headless Chrome 155. It covers client code (the logging emulation that is compiled to JavaScript), shared code (a class that logs on both sides), server code (the remote logging servlet and the stack trace deobfuscator) and build settings (the properties and the compiler flag that decide what is left in the output).

Logging in a browser has an unusual constraint: the log lives in the user’s machine, in a console you cannot see. GWT therefore splits the problem in three. A compile-time switch decides how much logging exists at all, a set of handlers decides where a record goes on the client (console, popup, your own widget), and a remote handler can send records to the server, where the obfuscated stack trace is turned back into Java class and method names.

The logging emulation

GWT emulates a subset of java.util.logging, so the logging code has the same syntax as server code and can be shared. The classes live in the JRE emulation (see JRE emulation and shared code); what is output is decided by the logging modules described below.

Table 1. The java.util.logging classes that exist in the browser (GWT 2.13.1 emulation sources)
Class What the emulation provides

Logger

getLogger(String), getGlobal(), addHandler, removeHandler, getHandlers, setLevel, getLevel, getName, getParent, setParent, getUseParentHandlers, setUseParentHandlers, isLoggable, log(Level, String), log(Level, String, Throwable), log(Level, Supplier), log(Level, Throwable, Supplier), log(LogRecord) and the level methods severe, warning, info, config, fine, finer, finest (each with a String and a Supplier form)

Level

The constants ALL, CONFIG, FINE, FINER, FINEST, INFO, OFF, SEVERE, WARNING, plus parse(String), getName(), intValue()

Handler, Formatter

The abstract base classes: publish, flush, close, setLevel, getLevel, isLoggable(LogRecord), setFormatter, getFormatter

LogRecord

Level, message, logger name, millis and Throwable, with their getters and setters

LogManager

getLogManager(), addLogger, getLogger, getLoggerNames; it creates the root logger (named with the empty string, level INFO) and the missing parents of a logger

What is not there matters, because it is a compile error and not a silent difference. The emulated Logger has no parameterised overloads (log(Level, String, Object) and log(Level, String, Object[])), no logp, logrb, entering, exiting, throwing, no Filter, no resource bundles and no getAnonymousLogger; there is no FileHandler, ConsoleHandler or SocketHandler, and no logging.properties (LogManager cannot read a configuration). The reproduced error messages are in the pitfalls table at the end of the page. Logger names form a tree exactly as on the server: Foo.Bar and Foo.Baz are children of Foo, and a name without a dot has the root logger (the empty name) as parent.

The levels are the JDK constants with the JDK numbers, and Level.parse accepts names only (case-insensitively; anything else throws IllegalArgumentException with the text Invalid level "..."):

Table 2. Level values in the emulation
ALL FINEST FINER FINE CONFIG INFO WARNING SEVERE OFF

Integer.MIN_VALUE

300

400

500

700

800

900

1000

Integer.MAX_VALUE

flowchart TD call["logger.log(level, message, thrown)"] --> gate{"Compile-time level
jre.logging.logLevel"} gate -->|"OFF"| stop1["Nothing happens"] gate -->|"ALL"| eff{"Level at least the effective
level of the logger?
own level, else nearest parent, else INFO"} gate -->|"INFO, WARNING or SEVERE"| fixed{"Level at least the fixed
threshold of the build?"} eff -->|"no"| stop2["Dropped"] fixed -->|"no"| stop2 eff -->|"yes"| rec["new LogRecord with level, message,
thrown and logger name"] fixed -->|"yes"| rec rec --> own["Handlers of this logger"] own --> parents["Handlers of every parent
while useParentHandlers is true"] parents --> pub["Handler.publish: handler level,
formatter, output"]

The diagram is the body of Logger.isLoggable and Logger.actuallyLog in the emulation. It has a consequence that catches people: when the build keeps only SEVERE, WARNING or INFO (the compile-time level is not ALL), the emulated isLoggable compares the level against a fixed threshold and never looks at setLevel. The run in What each setting prints (run in Chrome) shows it.

Adding logging to a module

The shortest recipe is the one in the Developer Guide: inherit the logging module and create a logger.

<!-- in the .gwt.xml file -->
<inherits name="com.google.gwt.logging.Logging"/>
private static final Logger LOG = Logger.getLogger(LoanDesk.class.getName());
...
LOG.log(Level.SEVERE, "Cannot renew book " + bookId, e);

The logging module is not part of com.google.gwt.user.User; the Bookshelf module lists it explicitly. What inheriting it changes is easy to misjudge, because the emulation is always compiled in, so code that uses Logger builds either way. The table compares the two cases as measured on a production-mode build.

Table 3. What com.google.gwt.logging.Logging adds (measured, production build, Chrome 155)
Module not inherited Module inherited (defaults)

Compile-time level

jre.logging.logLevel is OFF in production (and ALL in Super Dev Mode)

gwt.logging.enabled is SEVERE, so only SEVERE records exist (and TRUE in Super Dev Mode)

Root logger handlers

none, unless jre.logging.simpleConsoleHandler applies (see below)

a ConsoleLogHandler (DevelopmentModeLogHandler is also added but is inert in compiled JavaScript)

?logLevel= URL parameter, LogConfiguration, HasWidgetsLogHandler, the remote handler

not available; using these classes does not compile

available

Uncaught exceptions

reported by the browser as a plain Uncaught ... message

logged at SEVERE through the com.google.gwt.logging.client.LogConfiguration logger

The Developer Guide warns that logging still works without the module, because the emulation is always present, but that you then get no default handlers and no way to configure the root logger. That is the intended setup for a library: it logs through java.util.logging and lets the application that includes it decide whether anything is shown.

Console logging without the logging module

The properties behind the module are defined in Core.gwt.xml, so a module that does not inherit Logging can still turn on plain console output with one property. It is the cheapest option: it adds essentially no code.

<!-- the Logging module is not inherited -->
<set-property name="jre.logging.logLevel" value="INFO"/>

Compiled this way (the same sources, -setProperty jre.logging.logLevel=INFO) the production file was 247,179 bytes against 247,181 for the build without logging, and the browser console showed the records without timestamp or logger name, as written by the emulation’s internal SimpleConsoleLogHandler (excerpt):

Bookshelf starting
Cannot renew book 7
Exception: java.lang.IllegalStateException: Loan limit reached for book 7
Error: java.lang.IllegalStateException: Loan limit reached for book 7
    at W0.Xe [as Kb] (bookshelf-0.js:1779:162)

The values of jre.logging.logLevel are OFF, SEVERE, WARNING, INFO and ALL, and the comment in Core.gwt.xml describes them: OFF compiles out all logging, the other values keep the framework and compile out the basic calls (fine(), info() and so on) below the level. It also notes that logger.log(Level.INFO, "foo") will not compile out, which the size measurements below confirm.

The properties

All of the configuration is done with deferred-binding properties, so each choice is a compile-time decision that produces different JavaScript (with -setProperty or <set-property> fixing one value, a single permutation results). The values and defaults below are read from LogImpl.gwt.xml, Logging.gwt.xml and Core.gwt.xml of 2.13.1, and the behaviour was checked by compiling and running each one.

Table 4. The logging properties (GWT 2.13.1)
Property Values and default Effect

gwt.logging.enabled

TRUE, FALSE, SEVERE, WARNING. LogImpl defaults it to FALSE; the Logging module sets SEVERE; both set TRUE when jre.debugMode is ENABLED (Super Dev Mode)

The master switch. It is forwarded to jre.logging.logLevel: TRUE gives ALL, SEVERE gives SEVERE, WARNING gives WARNING, FALSE gives OFF. It also selects the LogConfiguration implementation, which answers LogConfiguration.loggingIsEnabled(Level)

gwt.logging.logLevel

ALL, FINEST, FINER, FINE, CONFIG, INFO, WARNING, SEVERE; default INFO

The level of the root logger at startup, chosen by deferred binding of DefaultLevel. The URL parameter logLevel overrides it

gwt.logging.consoleHandler

ENABLED (default), DISABLED

ConsoleLogHandler: the browser’s own console

gwt.logging.developmentModeHandler

ENABLED (default), DISABLED

DevelopmentModeLogHandler: GWT.log, only when the client runs as bytecode

gwt.logging.systemHandler

ENABLED, DISABLED (default)

SystemLogHandler: System.out and System.err, only when the client runs as bytecode

gwt.logging.hasWidgetsHandler

ENABLED, DISABLED (default)

HasWidgetsLogHandler, which LogImpl binds to PopupLogHandler: the popup in the upper left corner

gwt.logging.simpleRemoteHandler

ENABLED, DISABLED (default)

SimpleRemoteLogHandler: sends records to the server with GWT-RPC

gwt.logging.simpleConsoleHandler

ENABLED, DISABLED; LogImpl defaults to ENABLED and the Logging module sets DISABLED

Forwarded to jre.logging.simpleConsoleHandler: whether the emulation puts its own console handler on the root logger

jre.logging.logLevel

OFF, SEVERE, WARNING, INFO, ALL; default OFF, ALL when jre.debugMode is ENABLED

The compile-time level read by the Logger class itself (the first box of the diagram above)

jre.debugMode

ENABLED, DISABLED; ENABLED when superdevmode is on

Turns logging on without any configuration while you run the code server

flowchart LR enabled["gwt.logging.enabled
TRUE, FALSE, SEVERE, WARNING"] --> jre["jre.logging.logLevel
ALL, OFF, SEVERE, WARNING"] jre --> logger["Logger emulation
constant thresholds"] enabled --> impl["LogConfiguration implementation
Null, Severe, Warning or Regular"] impl --> root["Root logger set up
in onModuleLoad"] level["gwt.logging.logLevel
or ?logLevel=..."] --> root hp["consoleHandler, developmentModeHandler, systemHandler,
hasWidgetsHandler, simpleRemoteHandler"] --> root root --> handlers["Handlers added to the root logger
(NullLogHandler instances are skipped)"]

The Developer Guide shows three of these properties in a module file. In the Bookshelf the module declares what the application wants in every build and leaves the rest to the defaults:

<module>
  <inherits name="com.google.gwt.user.User"/>
  <inherits name="com.google.gwt.logging.Logging"/>
  <!-- send SEVERE records to the server -->
  <set-property name="gwt.logging.simpleRemoteHandler" value="ENABLED"/>
  <!-- the default root level; ?logLevel=... overrides it when the build keeps more than SEVERE -->
  <set-property name="gwt.logging.logLevel" value="INFO"/>
  ...
</module>

What each setting prints (run in Chrome)

The Bookshelf renews a loan when the page is opened with ?renew=1. It logs a SEVERE message with an exception and then calls info, fine and severe on a logger. The table lists the console lines that appeared for one permutation of each build.

Table 5. Console output by build and URL (headless Chrome 155, ConsoleLogHandler)
Build and URL Records that reached the console

gwt.logging.enabled=SEVERE (the default of the module), ?renew=1

the two SEVERE records only; info() and fine() calls are dropped

SEVERE, ?renew=1&logLevel=ALL

the same two SEVERE records: the parameter changes the root logger level, but the build only creates SEVERE records

gwt.logging.enabled=TRUE, ?renew=1

INFO, SEVERE records; the fine() calls are dropped because the root level is INFO

TRUE, ?renew=1&logLevel=ALL

additionally the FINE records

TRUE, ?renew=1&logLevel=WARNING

the SEVERE records only

gwt.logging.enabled=FALSE, ?renew=1

nothing

In a TRUE build the console showed (the second line of each record is the level and the message; frames abbreviated):

Fri Oct 09 01:21:17 GMT+200 2026 com.example.bookshelf.client.App
INFO: Bookshelf starting
Fri Oct 09 01:21:17 GMT+200 2026 com.example.bookshelf.client.loan.LoanDesk
SEVERE: Cannot renew book 7
java.lang.IllegalStateException: Loan limit reached for book 7
	at Unknown.xf(bookshelf-0.js)
	at Unknown.dk(bookshelf-0.js)

The WARNING build was not run in the browser; its behaviour follows from the sources, where LogConfigurationImplWarning.loggingIsEnabled(Level) accepts levels from 900 up and the emulation uses the WARNING threshold. In Super Dev Mode the same module logs every record at INFO and above without any property change, because jre.debugMode is ENABLED there: the run with the code server printed the starting message, the audit lines and the SEVERE record. In that mode LogImpl.gwt.xml also replaces the ConsoleLogHandler with the emulation’s plain handler, so the lines carry no timestamp:

Bookshelf starting
Cannot renew book 7
Exception: java.lang.IllegalStateException: Loan limit reached for book 7
Error: java.lang.IllegalStateException: Loan limit reached for book 7
    at nZe_g$.Mm_g$ [as createError_0_g$] (bookshelf-0.js:7450:10)

Handlers and formatters

Handlers are attached to the root logger by LogConfiguration, an entry point that the logging module registers. Its onModuleLoad sets setUseParentHandlers(false) on the root logger, sets its level (from the logLevel URL parameter or from gwt.logging.logLevel) and adds one handler per enabled property, skipping any that the property turned into a NullLogHandler. A disabled handler is therefore not just silent but absent from the output. The replacement happens where LogConfiguration creates the handler through GWT.create, so a handler that you build with new is not affected by these properties.

Table 6. The handlers of com.google.gwt.logging.client (GWT 2.13.1)
Handler Output When it is active

ConsoleLogHandler

console.error for SEVERE, console.warn for WARNING, console.info for INFO, console.debug below; text from TextLogFormatter(true) (date, logger name, level, message, stack trace). It does nothing if window.console is missing

Enabled by default

DevelopmentModeLogHandler

GWT.log(message, thrown); isSupported() is !GWT.isScript()

Enabled by default, but compiled JavaScript (production and Super Dev Mode) is script, so it only writes when the client classes run as Java bytecode

SystemLogHandler

System.out up to WARNING, System.err above; same isSupported() rule

Disabled by default

PopupLogHandler, HasWidgetsLogHandler

A HasWidgets container receives an HTML widget per record, formatted by HtmlLogFormatter(true); the popup variant puts a LoggingPopup in the upper left corner

Disabled by default (gwt.logging.hasWidgetsHandler)

SimpleRemoteLogHandler

One GWT-RPC call per record, see Remote logging

Disabled by default

NullLogHandler

Nothing; the replacement used when a handler property is DISABLED or logging is FALSE

n/a

Two formatters ship with GWT. TextLogFormatter writes the record date (Date.toString()), the logger name, a line break, LEVEL: message and, when asked, the stack trace; HtmlLogFormatter colours the record by level (red for SEVERE, orange for WARNING, green for INFO, blue for CONFIG, purple for the fine levels) and escapes <, > and tabs in the text. Both extend an internal FormatterImpl; the Developer Guide names them HTMLFormatter and TextFormatter, but the classes are HtmlLogFormatter and TextLogFormatter.

The popup is invasive, as the Developer Guide says: the run with gwt.logging.hasWidgetsHandler=ENABLED put a <div class="gwt-PopupPanel" style="left: 0px; top: 0px; position: absolute; ..."> over the application, and added about 11 KB to the JavaScript (see the size table). For a real application the Developer Guide recommends sending records to a panel you own.

A log area in your own page

HasWidgetsLogHandler takes any HasWidgets that accepts several add calls. The Bookshelf attaches one to the root logger when the page is opened with ?logpanel=1:

if (Window.Location.getParameter("logpanel") != null) {
  FlowPanel panel = new FlowPanel();
  RootPanel.get().add(panel);
  Logger.getLogger("").addHandler(new HasWidgetsLogHandler(panel));
}

The serialised page then contained one coloured span with a code element per record, for example INFO: UNGUARDED-AUDIT 756 7 and SEVERE: Cannot renew book 7 followed by the stack trace with line breaks. Because the handler is created with new, it works whatever gwt.logging.hasWidgetsHandler says, but the logging itself must be enabled: in a gwt.logging.enabled=FALSE build the panel stayed empty. The widget is an HTML widget filled with formatter output, so if you write your own sink use setText or SafeHtml (see SafeHtml & XSS) for the record message.

Writing a handler

A handler extends java.util.logging.Handler and implements publish, flush and close. The custom handler in the remote section is a complete example; the points to remember are to honour isLoggable(record) (the handler level defaults to ALL), to set a Formatter if you format, and never to log from inside publish through a logger that the same handler receives. RemoteLogHandlerBase, the base class of SimpleRemoteLogHandler, shows the guard that GWT itself uses: it keeps a list of excluded logger names (WireActivityLogger) so that the failure of the transport is never sent through the same transport.

GWT.log and Window.alert

Before the emulation existed, GWT.log and Window.alert were the only tools, and both are still there with a narrower job. GWT.log(String) and GWT.log(String, Throwable) are debugging aids for development, not a logging framework:

Table 7. GWT.log compared with java.util.logging
GWT.log Logger

Output

The development shell logger in classic Dev Mode, the browser console in Super Dev Mode (the SuperDevModeLogger of Core.gwt.xml writes it with console.log)

The handlers of the logger tree

Production

The Javadoc says calls are optimized out; in the compiled Bookshelf the text of a GWT.log call was absent from both the TRUE and the FALSE build

Depends on gwt.logging.enabled; SEVERE by default

Levels, names, hierarchy

none

yes

Remote delivery and stack-trace deobfuscation

none

yes, with the remote handler

Window.alert is a modal dialog that stops the page; it is a user interface, not a log. Use GWT.log for throwaway development output (Development Mode & Debugging shows it in Super Dev Mode) and a Logger for anything that should survive into a production build.

Shared code and the server

GWT emulates the API, but client and server are two separate logging worlds. The client has a root logger and a logger tree of its own, compiled into the JavaScript; the server uses the JVM’s java.util.logging (or whatever framework you bridge it to). A class in the shared module logs to whichever tree is active where it executes, and the Developer Guide advises against adding or changing handlers in shared code because the configuration code cannot behave the same on both sides.

The Bookshelf has a shared class that is used on both sides:

package com.example.bookshelf.shared;

import java.util.logging.Level;
import java.util.logging.Logger;

public final class LateFee {
  private static final Logger LOG = Logger.getLogger(LateFee.class.getName());

  private LateFee() {}

  public static long cents(int daysLate) {
    long cents = Math.min(daysLate, 30) * 25L;
    LOG.log(Level.INFO, "late fee for " + daysLate + " days is " + cents + " cents");
    return cents;
  }
}

The catalogue service on the server calls LateFee.cents(3), and so does the entry point in the browser. The two outputs differ in format and destination:

# Jetty console (server root logger, JDK SimpleFormatter)
Oct 09, 2026 1:28:25 AM com.example.bookshelf.shared.LateFee cents
INFO: late fee for 3 days is 75 cents

# browser console (client root logger, TextLogFormatter)
Fri Oct 09 01:28:31 GMT+200 2026 com.example.bookshelf.shared.LateFee
INFO: late fee for 3 days is 75 cents

The shared code uses string concatenation rather than {0} placeholders because the emulation has no parameterised log methods, and the JDK Logger would accept both. Use the Supplier forms (LOG.fine(() -> expensive())) when building the message is costly.

On the server nothing GWT-specific is needed: pick the framework the rest of the application uses. The remote logging code logs through java.util.logging, so with SLF4J and Logback the usual step is the jul-to-slf4j bridge, which routes JDK records to SLF4J once SLF4JBridgeHandler.install() has been called at startup (the SLF4J documentation also warns that bridging has a translation cost for disabled levels). The book of 2007 recommends Log4J for the server, which is still a valid choice; what changed is that the same API is now available in the browser.

Uncaught exceptions

The LogConfiguration entry point also installs an uncaught exception handler if none is set yet. When logging is enabled, an exception that nobody catches is logged at SEVERE with the exception message and stack trace:

// in the Bookshelf entry point, behind ?boom=1: nobody catches this
Scheduler.get().scheduleDeferred(() -> {
  throw new IllegalStateException("Unexpected failure in a scheduled command");
});
# gwt.logging.enabled=TRUE
Fri Oct 09 01:29:14 GMT+200 2026 com.google.gwt.logging.client.LogConfiguration
SEVERE: Unexpected failure in a scheduled command

# gwt.logging.enabled=FALSE
Uncaught java.lang.IllegalStateException: Unexpected failure in a scheduled command

An application that calls GWT.setUncaughtExceptionHandler itself takes the place of that handler. One that only calls GWT.log, as the first version of the Bookshelf entry point did, reports nothing in production, because GWT.log is removed from production output; log through a Logger instead.

Remote logging

Records written in the browser are lost unless something sends them to the server. GWT ships a handler that does it with GWT-RPC, and the pieces to do it with anything else.

sequenceDiagram participant App as Browser (obfuscated JavaScript) participant H as SimpleRemoteLogHandler participant S as RemoteLoggingServiceImpl participant D as StackTraceDeobfuscator participant L as Server logger (java.util.logging) App->>H: Logger.log(SEVERE, message, thrown) H->>S: POST /bookshelf/remote_logging (GWT-RPC, header X-GWT-Permutation) S->>D: deobfuscateStackTrace(thrown, strongName) D->>D: read symbolMaps/strongName.symbolMap D-->>S: Java class, method, line S->>L: Logger.getLogger(name).log(record) S-->>H: null when logged, else an error text

SimpleRemoteLogHandler and the servlet

Enabling the handler is one property. The client then creates RemoteLoggingService, whose relative path is remote_logging, so the module base URL (/bookshelf/ for the Bookshelf, from rename-to) decides where it posts. The server needs a servlet mapped at that URL. In 2.13.1 the servlet exists in two packages: com.google.gwt.logging.server.RemoteLoggingServiceImpl for javax.servlet (artifact gwt-servlet) and com.google.gwt.logging.server.jakarta.RemoteLoggingServiceImpl (artifact gwt-servlet-jakarta, available since GWT 2.11) for Jakarta Servlet. Both extend RemoteServiceServlet and have two setters: setLoggerNameOverride(String) and setSymbolMapsDirectory(String). The Bookshelf subclass reads the directory from a system property:

package com.example.bookshelf.server;

import com.google.gwt.logging.server.jakarta.RemoteLoggingServiceImpl;
import jakarta.servlet.ServletException;

public class BookshelfRemoteLoggingServlet extends RemoteLoggingServiceImpl {
  @Override
  public void init() throws ServletException {
    super.init();
    String dir = System.getProperty("bookshelf.symbolMaps");
    if (dir != null && !dir.isBlank()) {
      setSymbolMapsDirectory(dir);
    }
  }
}
<servlet>
  <servlet-name>remoteLogging</servlet-name>
  <servlet-class>com.example.bookshelf.server.BookshelfRemoteLoggingServlet</servlet-class>
</servlet>
<servlet-mapping>
  <servlet-name>remoteLogging</servlet-name>
  <url-pattern>/bookshelf/remote_logging</url-pattern>
</servlet-mapping>

With gwt.logging.simpleRemoteHandler enabled and the page opened with ?renew=1, the Jetty console received:

Oct 09, 2026 1:12:54 AM com.google.gwt.logging.server.RemoteLoggingServiceUtil logOnServer
SEVERE: Cannot renew book 7
java.lang.IllegalStateException: Loan limit reached for book 7
	at java.lang.Throwable.Throwable(Throwable.java:63)
	at java.lang.Exception.Exception(Exception.java:28)
	at java.lang.RuntimeException.RuntimeException(RuntimeException.java:28)
	at java.lang.IllegalStateException.IllegalStateException(IllegalStateException.java:27)
	at com.example.bookshelf.client.loan.LoanDesk.checkLimit(LoanDesk.java:23)
	at com.example.bookshelf.client.loan.LoanDesk.tryRenew(LoanDesk.java:12)
	at com.example.bookshelf.client.App.$onModuleLoad(App.java:26)

The record arrives in the server’s own logger tree, under the name of the client logger (or the override), through RemoteLoggingServiceUtil.logOnServer. The stack trace is in Java names because the servlet was told where the symbol maps are, which is the subject of the next section.

Some behaviour that the sources and the runs establish:

  • No batching, no back-off. The Developer Guide calls it "the simplest possible way": one RPC call per record, with no retry. Every record that a handler accepts is a request, so do not enable it at ALL.

  • The handler has its own level, ALL by default. Restrict what is sent with setLevel on the handler instance of the root logger. In a TRUE build with this code in the entry point, the browser console showed INFO records and the server received only the SEVERE ones:

    for (Handler handler : Logger.getLogger("").getHandlers()) {
      if (handler instanceof SimpleRemoteLogHandler) {
        handler.setLevel(Level.WARNING);   // WARNING and SEVERE are sent
      }
    }
  • Failures stay out of the loop. The handler’s callback logs failures to the logger WireActivityLogger, which RemoteLogHandlerBase excludes. On failure the client logged SEVERE: Remote logging failed: followed by the exception (against a server that did not know the URL, the exception was com.google.gwt.user.client.rpc.StatusCodeException: 501 Unsupported method ('POST')). When the servlet cannot process a record it answers with the string Remote logging failed, check stack trace for details., logs Remote logging failed with the cause at SEVERE, and the client then logs Remote logging failed: with that text.

  • No Java 17 flag is needed for this direction. The record is deserialised on the server; the run needed no --add-opens (the pitfall about serialising exceptions on the server side belongs to GWT-RPC).

  • The RPC proxy is part of the output only when the handler is. With gwt.logging.simpleRemoteHandler disabled (or logging FALSE) the remote_logging string and the service proxy are absent from the JavaScript (see the size table).

A JSON endpoint instead of GWT-RPC

GWT-RPC needs the serialisation policy files and a servlet that extends RemoteServiceServlet. A project that already talks JSON, or that wants the log endpoint on another service, can use the helper classes that the logging module provides: JsonLogRecordClientUtil (client) turns a LogRecord into JSON, and the JSON overload of RemoteLoggingServiceUtil.logOnServer (server) reads it back and logs it; its parameters are the JSON text, the strong name, a StackTraceDeobfuscator and a logger name override. The handler below sends only SEVERE records with RequestBuilder (see RequestBuilder, JSON & XML). It sends the permutation strong name in the same header that GWT-RPC uses, and it stays silent on failure.

public class JsonLogHandler extends Handler {
  private final String url;

  public JsonLogHandler(String url) {
    this.url = url;
    setLevel(Level.SEVERE);
  }

  @Override
  public void publish(LogRecord record) {
    if (!isLoggable(record)) {
      return;
    }
    RequestBuilder request = new RequestBuilder(RequestBuilder.POST, url);
    request.setHeader("Content-Type", "application/json; charset=utf-8");
    request.setHeader(RpcRequestBuilder.STRONG_NAME_HEADER, GWT.getPermutationStrongName());
    try {
      request.sendRequest(JsonLogRecordClientUtil.logRecordAsJson(record), new RequestCallback() {
        @Override public void onResponseReceived(Request request, Response response) { }
        @Override public void onError(Request request, Throwable exception) { }
      });
    } catch (RequestException e) {
      // a broken log channel must not break the application
    }
  }

  @Override public void flush() { }
  @Override public void close() { }
}

The matching servlet checks the size before it parses anything, takes the strong name from the header and delegates to the GWT utility, with the deobfuscator built from the same directory as before:

public class JsonLogServlet extends HttpServlet {
  private static final int MAX_BODY = 16 * 1024;
  private StackTraceDeobfuscator deobfuscator;   // com.google.gwt.core.server

  @Override
  public void init() {
    String dir = System.getProperty("bookshelf.symbolMaps");
    if (dir != null && !dir.isBlank()) {
      deobfuscator = StackTraceDeobfuscator.fromFileSystem(dir);
    }
  }

  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    byte[] body = req.getInputStream().readNBytes(MAX_BODY + 1);
    if (body.length > MAX_BODY) {
      resp.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
      return;
    }
    String strongName = req.getHeader("X-GWT-Permutation");
    try {
      RemoteLoggingServiceUtil.logOnServer(
          new String(body, StandardCharsets.UTF_8), strongName, deobfuscator, "client");
      resp.setStatus(HttpServletResponse.SC_NO_CONTENT);
    } catch (RemoteLoggingException e) {
      resp.sendError(HttpServletResponse.SC_BAD_REQUEST);
    }
  }
}

With ?renew=1&json=1 the server printed the same SEVERE: Cannot renew book 7 record and the same Java frames as for the RPC handler. Probing the servlet with curl gave 204 for a valid record, 400 for the body not json (the utility wraps the parse failure in a RemoteLoggingException whose message is "Failed to deserialize JSON") and 413 for a 20,000 byte body.

Stack-trace deobfuscation

A production build renames methods and classes, so the stack trace of an exception in the browser names functions such as cf and qf (GWT prints a frame as at Unknown.cf(bookshelf-0.js)); a trace that a user sends you is not readable as it is. The compiler therefore writes a symbol map for each permutation, and the server uses it to translate the frames back.

How traces are produced: compiler.stackMode

The compiler flags and properties in general are covered on Compiler & Compilation, and the symbol map files on Linkers & Bootstrap.

The property compiler.stackMode (StackTrace.gwt.xml) decides what a Throwable carries. It defaults to native: the stack comes from the browser’s own error object. The legacy property compiler.emulatedStack set to true selects emulated, and strip removes the information. Measured on the same TRUE build:

Table 8. The three stack modes (-setProperty compiler.stackMode=..., one permutation)
Mode cache.js size The logged exception

native (default)

266,361 bytes (91,103 gzip)

at Unknown.xf(bookshelf-0.js), one line per frame

emulated

346,052 bytes (102,741 gzip), 29.9 per cent more

at Unknown.yf(Unknown Source): frames exist but have no file name

strip

257,038 bytes (88,110 gzip), 3.5 per cent less

the message only, no frames

Keep native for a deobfuscation setup: it is the default, it is the smaller of the two modes that produce frames, and it is the one the runs on this page deobfuscate. emulated swaps in a different stack collector (CollectorEmulated) and costs the 80 KB above; the configuration properties compiler.emulatedStack.recordLineNumbers and compiler.emulatedStack.recordFileNames of the same module add line numbers and file names to the data it emits.

Symbol maps

SymbolMapsLinker is added by Core.gwt.xml to every module, so a normal compile already writes one file per permutation (no flag is needed). Its name is the strong name of the permutation, the same hash as the .cache.js file that the browser downloaded:

war/bookshelf/A48F32891F48147C430355C88AABB006.cache.js                 274,771 bytes (served to browsers)
deploy/bookshelf/symbolMaps/A48F32891F48147C430355C88AABB006.symbolMap  1,112,702 bytes (never served)
extra/bookshelf/symbolMaps/A48F32891F48147C430355C88AABB006.symbolMap   the same file

The files are in the compiler’s -deploy directory (by default WEB-INF/deploy under the -war directory) and, as a copy, in -extra. With the Maven plugin that is bookshelf-client/target/gwt/deploy/bookshelf/symbolMaps (the plugin’s deploy parameter defaults to ${project.build.directory}/gwt/deploy); in the war that the modular-webapp layout assembles the directory is not included, so a deployment step has to carry it to the server. A file is a comment header and one line per symbol:

# { 0 }
# {  }
# jsName, jsniIdent, className, memberName, sourceUri, sourceLine, fragmentNumber
Ub,com.example.bookshelf.client.loan.LoanDesk::checkLimit(J)V,com.example.bookshelf.client.loan.LoanDesk,checkLimit,com/example/bookshelf/client/loan/LoanDesk.java,46,0
Vb,com.example.bookshelf.client.loan.LoanDesk::tryRenew(J)Z,com.example.bookshelf.client.loan.LoanDesk,tryRenew,com/example/bookshelf/client/loan/LoanDesk.java,14,0

The file is about four times the size of the script, and it lists class names, method names and source paths: treat it as internal. It must not go into the folder that is served to browsers.

The server side

com.google.gwt.core.server.StackTraceDeobfuscator (moved there from the logging package in 2.6.0; the old com.google.gwt.logging.server.StackTraceDeobfuscator is a deprecated subclass) reads the symbol maps. Its factories open them from a directory (fromFileSystem(String)), the classpath (fromResource(String)) or a URL (fromUrl(URL)), and setLazyLoad(true) caches only the symbols that were asked for and discards the rest of the map, which its Javadoc describes as a large memory saving at the price of occasional extra disk reads. The method deobfuscateStackTrace takes a Throwable and the strong name and rewrites the stack trace of the exception and of its causes.

The strong name travels with the request. GWT-RPC clients send it in the header X-GWT-Permutation (RpcRequestBuilder.STRONG_NAME_HEADER), and the server reads it with getPermutationStrongName() in RemoteServiceServlet. In your own code the client side is GWT.getPermutationStrongName(). The servlet shown above passes the header value to the deobfuscator; RemoteLoggingServiceImpl does it for you once setSymbolMapsDirectory has been called.

The effect, from the same renewal exception on the same build:

# without symbol maps on the server
SEVERE: Cannot renew book 7
java.lang.IllegalStateException: Loan limit reached for book 7
	at Unknown.cf(bookshelf-0.js)
	at Unknown.qf(bookshelf-0.js)
	at Unknown.Xj(bookshelf-0.js)
	at Unknown.new N6(bookshelf-0.js)

# -Dbookshelf.symbolMaps=.../target/gwt/deploy/bookshelf/symbolMaps
SEVERE: Cannot renew book 7
java.lang.IllegalStateException: Loan limit reached for book 7
	at java.lang.Throwable.Throwable(Throwable.java:63)
	...
	at com.example.bookshelf.client.loan.LoanDesk.checkLimit(LoanDesk.java:23)
	at com.example.bookshelf.client.loan.LoanDesk.tryRenew(LoanDesk.java:12)
	at com.example.bookshelf.client.App.$onModuleLoad(App.java:26)

Three details of the output are worth knowing. The line numbers are those of the method declarations (checkLimit was declared on line 23 of the file at that time, tryRenew on line 12, onModuleLoad on line 26): without source-map information the symbol map only knows where a method starts. The first frames are the constructors of the exception classes (Throwable, Exception, RuntimeException, IllegalStateException) because the stack is captured where the exception is created. A server given a directory that does not contain the strong name of the running build did not complain: the trace simply stayed obfuscated. The source explains it: when the symbol map is missing or unreadable, the deobfuscator swallows the IOException and returns empty data for the symbols.

That last point is the operational rule. The symbol maps must match the deployed .cache.js names exactly, so archive the deploy directory of every release together with it (clients still run old permutations from caches for a long time), and rebuild nothing between the build that is deployed and the build whose maps you keep.

What compiles out of production

The usual promise is that logging compiles out of production. The measurements say which part of it is true. The table compares the same application sources (a Bookshelf variant without the popup and the guard classes, so that the only difference is the logging configuration), one permutation (locale=en, user.agent=safari), default OBFUSCATED style and -optimize level.

Table 9. Size of the permutation file by logging configuration (JDK 21, GWT 2.13.1)
Configuration Bytes Gzip -9 Bytes vs no module

Logging module not inherited (jre.logging.logLevel OFF)

247,181

84,874

0

Module inherited, gwt.logging.enabled=FALSE (remote handler also enabled in the module)

245,633

84,262

-1,548

Module, default SEVERE, console handler only

257,207

87,896

+10,026

Module, WARNING, console handler only

257,207

87,913

+10,026

Module, TRUE, console handler only

257,121

87,847

+9,940

Module, TRUE, console and popup handlers (no remote)

268,613

91,666

+21,432

Module, SEVERE, console and remote handlers

272,691

92,171

+25,510

Module, TRUE, console and remote handlers

272,607

92,173

+25,426

What the numbers say:

  • FALSE is the build that carries no logging machinery. The handlers, the LogConfiguration implementations other than the null one, the popup and the RPC proxy of the remote handler are replaced by deferred binding and disappear (compare 245,633 bytes with 272,691). It is even a little smaller than not inheriting the module.

  • SEVERE, WARNING and TRUE cost the same. The level only changes constants; the code of the handlers is the part that weighs. The remote handler alone adds about 15 KB (272,691 against 257,207), the popup about 11 KB.

  • The message of a disabled call is not removed. In every build above, including FALSE, the compiled file still contained the strings UNGUARDED-AUDIT, INFO-METHOD, FINE-METHOD and SEVERE-METHOD. The calls became if(!flag){return} checks against fields that the Logger class sets in its static initialiser (the compiled initialiser of the FALSE build sets one flag to true and four to false), and the compiler does not fold them. The comment in Core.gwt.xml (a call such as logger.log(Level.INFO, "foo") will not compile out) and the Developer Guide, which says that other logging-related code you write is not guaranteed to compile out, agree.

  • LogConfiguration.loggingIsEnabled() is a runtime check too. The guarded literal GUARDED-AUDIT was also present in the FALSE output, behind a call of the form SJ()&&... where the function returned false. The guard does what it promises at runtime: the expression inside the block, here Window.getClientWidth() plus a string concatenation, is not evaluated, while the argument of an unguarded LOG.log(Level.INFO, "..." + Window.getClientWidth()) is built before the call and then discarded. The Developer Guide example uses the same pattern for a message that is expensive to compute.

if (LogConfiguration.loggingIsEnabled(Level.FINE)) {
  LOG.fine(describe(book));       // describe(...) runs only when FINE records are possible
}

The Developer Guide adds that code which compiles out in production is still present in Development Mode, and that the same condition hides it there too. For loggingIsEnabled(Level) the answer in a SEVERE build is true only for SEVERE, in a WARNING build for levels from WARNING up, in a TRUE build for every level and in a FALSE build always false.

To make a production-only profile, put the property on the compiler command line and keep the development builds on the defaults. In the Bookshelf POM:

<profile>
  <id>quiet-logs</id>
  <build>
    <plugins>
      <plugin>
        <groupId>net.ltgt.gwt.maven</groupId>
        <artifactId>gwt-maven-plugin</artifactId>
        <configuration>
          <compilerArgs combine.self="override">
            <arg>-generateJsInteropExports</arg>
            <arg>-setProperty</arg>
            <arg>gwt.logging.enabled=FALSE</arg>
          </compilerArgs>
        </configuration>
      </plugin>
    </plugins>
  </build>
</profile>
mvn -B package -P quiet-logs

The six permutation files of the full Bookshelf went from about 296 KB to about 279 KB with the profile, the string remote_logging disappeared from all of them, and an uncaught exception was reported by the browser as a plain Uncaught ... line. The combine.self="override" attribute replaces the compiler arguments of the parent POM; without it the list is merged and -generateJsInteropExports would be duplicated.

Worked example: the Bookshelf renewal path

The following pieces make the whole flow of this page run in the Bookshelf, in this order:

  1. bookshelf-client inherits com.google.gwt.logging.Logging and enables gwt.logging.simpleRemoteHandler; the default gwt.logging.enabled is SEVERE, so a production page creates only SEVERE records and sends them to the server.

  2. LoanDesk (the class that renews a loan) logs a SEVERE record with the exception when a renewal is refused. Its FINE calls produce no output in production and appear in Super Dev Mode.

  3. LateFee in bookshelf-shared logs on both sides with the same code.

  4. bookshelf-server maps BookshelfRemoteLoggingServlet at /bookshelf/remote_logging and starts with -Dbookshelf.symbolMaps=... pointing at the deploy directory of the deployed build.

  5. The profile quiet-logs produces the build without any of it, for a deployment that must not log.

public final class LoanDesk {
  private static final Logger LOG = Logger.getLogger(LoanDesk.class.getName());

  public static boolean tryRenew(long bookId) {
    LOG.fine("Renewing book " + bookId);
    try {
      checkLimit(bookId);
      return true;
    } catch (IllegalStateException e) {
      LOG.log(Level.SEVERE, "Cannot renew book " + bookId, e);
      return false;
    }
  }

  private static void checkLimit(long bookId) {
    if (bookId > 0) {
      throw new IllegalStateException("Loan limit reached for book " + bookId);
    }
  }
}
mvn -B package -DskipTests
MAVEN_OPTS="-Dbookshelf.symbolMaps=$PWD/bookshelf-client/target/gwt/deploy/bookshelf/symbolMaps" \
  mvn -B -pl bookshelf-server org.eclipse.jetty.ee10:jetty-ee10-maven-plugin:12.1.10:run-war -Djetty.http.port=8924
# open http://localhost:8924/?renew=1 and read the Jetty console

Security notes

  • Client log input is attacker input. Anyone can post to the remote logging endpoint, with any text in the record and any logger name. A record posted to the JSON servlet with the message hello followed by a line break and INFO: forged line appeared in the Jetty console as two lines, the second looking like a record of its own. Treat the messages as untrusted: escape or neutralise line breaks before they reach a text log (log forging, CWE-117), cap the body size as the JSON servlet above does, rate-limit by session or address, authenticate the endpoint when the application has users, and use setLoggerNameOverride so that browser records go to one named logger (and to an appender with its own retention) instead of choosing their own.

  • Do not log secrets. The browser console is readable by the user and by extensions, GWT.log and logger output end up in server logs through the remote handler, and a record can carry an exception message or a whole request. Never log tokens, passwords, session ids or personal data; log identifiers.

  • RemoteLoggingServiceImpl extends RemoteServiceServlet, not the XSRF-protected servlet. A record has no side effect beyond a log line, so rate limiting is the usual defence, but protect the endpoint properly if records raise alerts.

  • Symbol maps are internal. They reveal class names, method names and source paths. Keep them in the deploy directory and out of the public folder (see the pitfall table).

  • The logLevel parameter is user input. In any build that keeps logging, a visitor can change the level of the page they are looking at, and a value that Level.parse rejects stops the application at startup (see the pitfalls). It cannot affect other users. A FALSE build ignores the parameter.

  • HTML sinks. HasWidgetsLogHandler renders formatter output in an HTML widget; it escapes < and > in the message but is a display aid, not a sanitiser. Custom log views should use setText.

Pitfalls

Table 10. Common problems with GWT logging (messages reproduced on GWT 2.13.1 unless marked)
Symptom Cause Fix

No source code is available for type com.google.gwt.logging.client.HasWidgetsLogHandler; did you forget to inherit a required module?

LogConfiguration, HasWidgetsLogHandler, JsonLogRecordClientUtil and the other com.google.gwt.logging.client classes need the module, and it is not part of User

Add <inherits name="com.google.gwt.logging.Logging"/>; a library that only logs should not use these classes

The method log(Level, String, Throwable) in the type Logger is not applicable for the arguments (Level, String, long)

The emulation has no parameterised log(Level, String, Object); LOG.log(Level.INFO, "renewing {0}", id) compiles on the JVM only

Concatenate the message or use the Supplier overloads

The method entering(String, String) is undefined for the type Logger

entering, exiting, throwing, logp and logrb are not emulated

Use fine with a message

No source code is available for type java.util.logging.FileHandler; did you forget to inherit a required module?

Only Handler, Formatter and the GWT handlers exist in the browser

Write a Handler subclass (see the JSON handler) or use the remote handler

Nothing is logged in production, or only errors

The module sets gwt.logging.enabled to SEVERE; without the module the compile-time level is OFF

Set TRUE (or WARNING) for a debug build; keep SEVERE or FALSE for production

?logLevel=ALL has no effect

The build keeps only SEVERE records, and the emulation ignores setLevel when the compile-time level is not ALL

Build with gwt.logging.enabled=TRUE when you need the parameter

Uncaught Error: java.lang.IllegalArgumentException: Invalid level "bogus" and an application that does not start

LogConfiguration.onModuleLoad calls Level.parse on ?logLevel=bogus; the exception ends module loading

Build public pages with gwt.logging.enabled=FALSE, where the parameter is never read; in other builds do not link to URLs with an untrusted logLevel

SEVERE: Remote logging failed: followed by com.google.gwt.user.client.rpc.StatusCodeException: 501 Unsupported method ('POST')

The servlet is not mapped at <module base URL>remote_logging; whatever is at that URL (here a static file server) answered the POST

Map the servlet at /<rename-to>/remote_logging (the Bookshelf uses /bookshelf/remote_logging)

Access to XMLHttpRequest at 'http://127.0.0.1:9924/bookshelf/remote_logging' ... has been blocked by CORS policy, then StatusCodeException: 0

The page loaded bookshelf.nocache.js directly from the code server, so the module base URL is the code server and the POST is cross-origin

Serve the module script from the application server through the -launcherDir setup of Development mode and debugging, so the module base URL is the application server

Exception while dispatching incoming RPC call with Content-Type was '(null)'. Expected 'text/x-gwt-rpc'. (HTTP 500)

Someone called /bookshelf/remote_logging with curl or a browser; it only accepts GWT-RPC

Normal; use the JSON servlet for a plain HTTP endpoint

The server log shows Unknown.cf(bookshelf-0.js) frames

setSymbolMapsDirectory was not called, the directory has no file named like the permutation, or the maps belong to another build; none of these reports an error

Deploy the symbol maps of the same build and archive them for every release

The message text of a disabled log call is in the .cache.js file

The compiler keeps the argument expression and the if(!flag) check; only the handlers and the RPC proxy are removed by deferred binding

Guard expensive messages with LogConfiguration.loggingIsEnabled(Level); do not rely on string removal to hide text

Logger.getName() returns null

With jre.logging.logLevel OFF the emulated getLogger returns a placeholder logger without name or handlers

Do not use getName() for logic

Records appear twice in the log panel or console

Handlers on the root logger and on a child logger both received the record because useParentHandlers is true

Add a handler to one logger only, or call setUseParentHandlers(false) on the child

Remote logging floods the server

SimpleRemoteLogHandler sends one request per record at the level ALL of the handler, with no batching

setLevel(Level.WARNING) on the handler, rate limit on the server, or send in batches with your own handler

Where older material is outdated

  • Client logging in the book. GWT in Action (chapter 3, "Implementing application logging") tells the reader that the only built-in client-side logging in web mode is Window.alert, with GWT.log for hosted mode, and recommends Log4J for the server. Both statements describe GWT 1.x. Since 2.1.0 the client has the java.util.logging emulation, and since 2.5 the output can be compiled out by level. GWT.log is removed from production output (no occurrence of a GWT.log string was left in the compiled Bookshelf) and prints to the browser console in Super Dev Mode, where the old hosted-mode window no longer exists.

  • Wrapping a JavaScript logger. The book suggests wrapping a library such as Log4JavaScript with JSNI. That is now unnecessary; if you want a custom sink, write a Handler.

  • Default handlers in the Developer Guide. The guide says that the popup and system handlers are attached by default. Since 2.7.0 the default is SEVERE and above on the browser console, and the popup and system handlers are off. Its names HTMLFormatter and TextFormatter are HtmlLogFormatter and TextLogFormatter, and it says that DevelopmentModeLogHandler output is seen in the "DevMode window", which no longer exists in Super Dev Mode.

  • The sample. The guide links the LogExample sources at a fixed commit of the repository. The samples folder of the 2.13.1 tag has no logexample, so use the pages and sources cited here.

  • Guaranteed compile-out. The guide says code behind LogConfiguration.loggingIsEnabled() is guaranteed to be removed. In 2.13.1 the check stays in the output and returns false; the saving is the work that the check prevents at runtime.

What changed recently

The cross-section view of releases is on What’s Changed & Migration.

  • 2.1.0 (milestone 1). The java.util.logging emulation was added to GWT.

  • 2.5.0. "Added option to compile out logging calls below SEVERE and WARN"; the gwt.logging.enabled values SEVERE and WARNING come from this release.

  • 2.5.1 and 2.6.0. The deobfuscator got a constructor option to conserve server memory (later setLazyLoad) and moved to com.google.gwt.core.server.

  • 2.7.0. New defaults: records at SEVERE and above go to the browser console; PopupLogHandler and SystemLogHandler are no longer enabled by default; FirebugLogHandler and NullLoggingPopup were removed.

  • 2.8.0. "Improved logging and stack traces for superdev mode", in the release notes' words.

  • 2.11.0. Jakarta artifacts for GWT-RPC: com.google.gwt.logging.server.jakarta.RemoteLoggingServiceImpl in gwt-servlet-jakarta.

The properties and defaults described here are those of the 2.13.1 sources. The Developer Guide page does not reflect the defaults that 2.7.0 introduced, which is why the page and the behaviour disagree in the places listed above.