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.
| Class | What the emulation provides |
|---|---|
|
|
|
The constants |
|
The abstract base classes: |
|
Level, message, logger name, millis and |
|
|
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 "..."):
| ALL | FINEST | FINER | FINE | CONFIG | INFO | WARNING | SEVERE | OFF |
|---|---|---|---|---|---|---|---|---|
|
300 |
400 |
500 |
700 |
800 |
900 |
1000 |
|
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.
| Module not inherited | Module inherited (defaults) | |
|---|---|---|
Compile-time level |
|
|
Root logger handlers |
none, unless |
a |
|
not available; using these classes does not compile |
available |
Uncaught exceptions |
reported by the browser as a plain |
logged at |
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.
| Property | Values and default | Effect |
|---|---|---|
|
|
The master switch. It is forwarded to |
|
|
The level of the root logger at startup, chosen by deferred binding of |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Forwarded to |
|
|
The compile-time level read by the |
|
|
Turns logging on without any configuration while you run the code server |
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.
| Build and URL | Records that reached the console |
|---|---|
|
the two |
|
the same two |
|
|
|
additionally the |
|
the |
|
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.
| Handler | Output | When it is active |
|---|---|---|
|
|
Enabled by default |
|
|
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 |
|
|
Disabled by default |
|
A |
Disabled by default ( |
|
One GWT-RPC call per record, see Remote logging |
Disabled by default |
|
Nothing; the replacement used when a handler property is |
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:
GWT.log |
Logger |
|
|---|---|---|
Output |
The development shell logger in classic Dev Mode, the browser console in Super Dev Mode (the |
The handlers of the logger tree |
Production |
The Javadoc says calls are optimized out; in the compiled Bookshelf the text of a |
Depends on |
Levels, names, hierarchy |
none |
yes |
Remote delivery and stack-trace deobfuscation |
none |
yes, with the remote handler |
Source: GWT Javadoc — GWT
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.
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>
Source: GWT — Logging: Remote Logging
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,
ALLby default. Restrict what is sent withsetLevelon the handler instance of the root logger. In aTRUEbuild with this code in the entry point, the browser console showedINFOrecords and the server received only theSEVEREones:for (Handler handler : Logger.getLogger("").getHandlers()) { if (handler instanceof SimpleRemoteLogHandler) { handler.setLevel(Level.WARNING); // WARNING and SEVERE are sent } }Source: GWT — Logging: Remote Logging
-
Failures stay out of the loop. The handler’s callback logs failures to the logger
WireActivityLogger, whichRemoteLogHandlerBaseexcludes. On failure the client loggedSEVERE: Remote logging failed:followed by the exception (against a server that did not know the URL, the exception wascom.google.gwt.user.client.rpc.StatusCodeException: 501 Unsupported method ('POST')). When the servlet cannot process a record it answers with the stringRemote logging failed, check stack trace for details., logsRemote logging failedwith the cause atSEVERE, and the client then logsRemote 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.simpleRemoteHandlerdisabled (or loggingFALSE) theremote_loggingstring 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:
| Mode | cache.js size |
The logged exception |
|---|---|---|
|
266,361 bytes (91,103 gzip) |
|
|
346,052 bytes (102,741 gzip), 29.9 per cent more |
|
|
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.
| Configuration | Bytes | Gzip -9 | Bytes vs no module |
|---|---|---|---|
Logging module not inherited ( |
247,181 |
84,874 |
0 |
Module inherited, |
245,633 |
84,262 |
-1,548 |
Module, default |
257,207 |
87,896 |
+10,026 |
Module, |
257,207 |
87,913 |
+10,026 |
Module, |
257,121 |
87,847 |
+9,940 |
Module, |
268,613 |
91,666 |
+21,432 |
Module, |
272,691 |
92,171 |
+25,510 |
Module, |
272,607 |
92,173 |
+25,426 |
What the numbers say:
-
FALSEis the build that carries no logging machinery. The handlers, theLogConfigurationimplementations 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,WARNINGandTRUEcost 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 stringsUNGUARDED-AUDIT,INFO-METHOD,FINE-METHODandSEVERE-METHOD. The calls becameif(!flag){return}checks against fields that theLoggerclass sets in its static initialiser (the compiled initialiser of theFALSEbuild sets one flag totrueand four tofalse), and the compiler does not fold them. The comment inCore.gwt.xml(a call such aslogger.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 literalGUARDED-AUDITwas also present in theFALSEoutput, behind a call of the formSJ()&&...where the function returnedfalse. The guard does what it promises at runtime: the expression inside the block, hereWindow.getClientWidth()plus a string concatenation, is not evaluated, while the argument of an unguardedLOG.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
Source: gwt-maven-plugin — gwt:compile
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:
-
bookshelf-clientinheritscom.google.gwt.logging.Loggingand enablesgwt.logging.simpleRemoteHandler; the defaultgwt.logging.enabledisSEVERE, so a production page creates onlySEVERErecords and sends them to the server. -
LoanDesk(the class that renews a loan) logs aSEVERErecord with the exception when a renewal is refused. ItsFINEcalls produce no output in production and appear in Super Dev Mode. -
LateFeeinbookshelf-sharedlogs on both sides with the same code. -
bookshelf-servermapsBookshelfRemoteLoggingServletat/bookshelf/remote_loggingand starts with-Dbookshelf.symbolMaps=...pointing at the deploy directory of the deployed build. -
The profile
quiet-logsproduces 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
hellofollowed by a line break andINFO: forged lineappeared 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 usesetLoggerNameOverrideso 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.logand 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. -
RemoteLoggingServiceImplextendsRemoteServiceServlet, 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
logLevelparameter 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 thatLevel.parserejects stops the application at startup (see the pitfalls). It cannot affect other users. AFALSEbuild ignores the parameter. -
HTML sinks.
HasWidgetsLogHandlerrenders formatter output in anHTMLwidget; it escapes<and>in the message but is a display aid, not a sanitiser. Custom log views should usesetText.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
|
|
Add |
|
The emulation has no parameterised |
Concatenate the message or use the |
|
|
Use |
|
Only |
Write a |
Nothing is logged in production, or only errors |
The module sets |
Set |
|
The build keeps only |
Build with |
|
|
Build public pages with |
|
The servlet is not mapped at |
Map the servlet at |
|
The page loaded |
Serve the module script from the application server through the |
|
Someone called |
Normal; use the JSON servlet for a plain HTTP endpoint |
The server log shows |
|
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 |
The compiler keeps the argument expression and the |
Guard expensive messages with |
|
With |
Do not use |
Records appear twice in the log panel or console |
Handlers on the root logger and on a child logger both received the record because |
Add a handler to one logger only, or call |
Remote logging floods the server |
|
|
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, withGWT.logfor hosted mode, and recommends Log4J for the server. Both statements describe GWT 1.x. Since 2.1.0 the client has thejava.util.loggingemulation, and since 2.5 the output can be compiled out by level.GWT.logis removed from production output (no occurrence of aGWT.logstring 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
SEVEREand above on the browser console, and the popup and system handlers are off. Its namesHTMLFormatterandTextFormatterareHtmlLogFormatterandTextLogFormatter, and it says thatDevelopmentModeLogHandleroutput is seen in the "DevMode window", which no longer exists in Super Dev Mode. -
The sample. The guide links the
LogExamplesources at a fixed commit of the repository. Thesamplesfolder of the 2.13.1 tag has nologexample, 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 returnsfalse; 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.loggingemulation was added to GWT. -
2.5.0. "Added option to compile out logging calls below SEVERE and WARN"; the
gwt.logging.enabledvaluesSEVEREandWARNINGcome from this release. -
2.5.1 and 2.6.0. The deobfuscator got a constructor option to conserve server memory (later
setLazyLoad) and moved tocom.google.gwt.core.server. -
2.7.0. New defaults: records at
SEVEREand above go to the browser console;PopupLogHandlerandSystemLogHandlerare no longer enabled by default;FirebugLogHandlerandNullLoggingPopupwere 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.RemoteLoggingServiceImplingwt-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.