Debugging, Dev Mode & 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 Apache Struts 7.4.0 (released 2026-10-02) running on Apache Tomcat 11.0.26 and Java 21; the facts were checked on 2026-10-08 and 2026-10-10 against the default.properties, struts-default.xml and struts-excluded-classes.xml files and the class files inside the 7.4.0 struts2-core jar, the Javadoc, the Core Developer Guide pages on development mode, debugging, the debugging interceptor and logging, the Getting Started tutorial on debugging, the Security Guide, the Config Browser plugin page and the Version Notes on the Struts wiki, and every behaviour described as observed was requested with curl on a scratch Tomcat. A Struts application is mostly configuration and reflection, so when something does not work the cause is rarely a stack trace in your own code: a parameter was rejected, an OGNL expression was blocked, a key is missing from a bundle, an action is not mapped. Struts has a set of tools that make those decisions visible: one constant, struts.devMode, that turns the framework from quiet to talkative; the debugging interceptor, which dumps the value stack and the action context for a request; the <s:debug/> tag; the config browser plugin, which lists the loaded configuration; and a logging layer that is plain Log4j 2. The running scenario is the Bookshelf, a catalogue of books exposed as Struts actions. This page uses it to switch devMode on and off and compare the answers, to read the three kinds of message that explain most "it does not work" reports, to find out why an expression renders nothing, to configure logging for development and for production, and to remove every one of these tools before the application goes live. The page on exception handling already shows the "Struts Problem Report" page; the page on parameter injection explains @StrutsParameter, which appears here only through the log messages it produces. The pages on testing, security hardening and deployment cover the neighbouring topics.

One result should be read first, because it changes how you use the tools. In my tests on 7.4.0 and Tomcat 11, ?debug=xml did not work (the document stops in the middle of the output and a problem report follows), the first configuration reload in devMode broke the Problem Report and the /static resources until the next restart, and the config browser worked with devMode off. The sections below say what was seen, with the Log4j 2 lines that prove it.

The Debugging Toolbox at a Glance

Tool What it shows Switch In production

struts.devMode

Reloading of bundles and XML, louder log levels, "Developer Notification" errors, the Problem Report page

<constant name="struts.devMode" value="true"/>

false (the default of default.properties)

Debugging interceptor

The parameters, the action context, the session and the value stack of one request

Request parameter debug=xml, console, command or browser, only while devMode is on

Inert: the parameter is ignored (observed)

<s:debug/> tag

A collapsible block at the end of the page with the value stack and the context

Only while devMode is on

Renders nothing (observed)

Config browser plugin

Namespaces, actions, results, interceptors, constants, beans, jars

A jar on the class path; independent of devMode

Remove it or block /config-browser/*

Log4j 2

Every decision of the request cycle at DEBUG, rejected parameters, blocked OGNL, missing keys at WARN

log4j2.xml on the class path

warn for the framework, info or warn for the application

struts.ognl.logMissingProperties

A WARN line for every OGNL property that is not found

Constant, default false

false

A debugger

Breakpoints in actions, interceptors and the framework

JDWP agent on the JVM

Never leave an agent configured

The sections that follow take the tools in the order in which a developer meets them: the constant first, because the others depend on it, then the interceptor and the tag, then the config browser, then logging, and last the steps that return the application to production.

The Bookshelf Used on This Page

All examples below are one WAR, built from the Bookshelf of the Struts Getting Started page of this section (the struts2-bom 7.4.0, Jakarta Servlet 6.1 and Pages 4.0 as provided dependencies) and deployed twice to Tomcat 11.0.26: once with struts.devMode=true under the context path /g12debugging, once with false under /g12debuggingprod. The two builds differ only in that constant, the log levels and, in the second, an operations filter that appears near the end. The action class has one annotated and one deliberately unannotated parameter, and logs with Log4j 2:

package org.example.bookshelf;

import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.ActionSupport;
import org.apache.struts2.interceptor.parameter.StrutsParameter;

public class BookAction extends ActionSupport {
    private static final Logger LOG = LogManager.getLogger(BookAction.class);

    private String filter;
    private String author;

    public String getFilter() { return filter; }

    @StrutsParameter
    public void setFilter(String filter) { this.filter = filter; }

    public String getAuthor() { return author; }

    // deliberately NOT annotated with @StrutsParameter
    public void setAuthor(String author) { this.author = author; }

    public List<String> getTitles() { return List.of("Dune", "Neuromancer"); }

    @Override
    public String execute() {
        LOG.debug("Listing books with filter [{}]", filter);
        return SUCCESS;
    }
}

The struts.xml of the development build maps four actions to the same class: books (the list page), books-debug (the same page with <s:debug/>), title (a page that prints a message from the resource bundle and a property that does not exist) and quiet (the list page with the debugging interceptor disabled, shown in its own section):

<struts>
    <constant name="struts.devMode" value="true"/>

    <package name="default" extends="struts-default" namespace="/">
        <action name="books" class="org.example.bookshelf.BookAction">
            <result>/WEB-INF/views/books.jsp</result>
        </action>
        <action name="books-debug" class="org.example.bookshelf.BookAction">
            <result>/WEB-INF/views/books-debug.jsp</result>
        </action>
        <action name="title" class="org.example.bookshelf.BookAction">
            <result>/WEB-INF/views/title.jsp</result>
        </action>
        <action name="quiet" class="org.example.bookshelf.BookAction">
            <interceptor-ref name="defaultStack">
                <param name="debugging.disabled">true</param>
            </interceptor-ref>
            <result>/WEB-INF/views/books.jsp</result>
        </action>
    </package>
</struts>

The page of this section on configuration, struts.xml and constants explains how the same value can come from the environment instead of being edited for every build. It is the recommended way to keep the literal true out of the file that is deployed; the examples here use the literal to keep the comparison easy to read. The page on getting started has the first Bookshelf and its project layout.

struts.devMode: What the Switch Changes

The Core Developer Guide opens its page on development mode with a warning: turn the option off before deploying, because it can expose sensitive data of the application. The same page says what it does when it is on: the resource bundles are reloaded on every request, the XML configuration (struts.xml and the validation files) is reloaded on every request, and problems that are normally ignored are raised to errors. default.properties of 7.4.0 documents the same three effects next to the constant, and its default is false.

That summary is short. The 7.4.0 jar reads the constant in 26 classes (found by searching the class files of struts2-core); the table below lists what each change looks like from the outside. A row marked "observed" was reproduced on the scratch Tomcat, comparing the /g12debugging build with the /g12debuggingprod one; a row marked "class file" was read from the bytecode with javap and not run.

What How it shows Evidence

Resource bundles

A changed BookAction.properties is shown on the next request; with devMode off the first value stays cached

observed

XML configuration

On every request the log says Checking ConfigurationProviders for reload; when struts.xml changed the log adds Detected container provider [Struts XML configuration provider (struts.xml)] needs to be reloaded. at INFO and loads everything again

observed

"Developer Notification"

An ERROR entry with the text "set struts.devMode to false to disable this message" for a parameter that was rejected, in StrutsParameterAuthorizer, and, with annotations not required, for a parameter that has no setter, in ParametersInterceptor

observed

Rejected parameters

A parameter that matches an excluded pattern or does not match the accepted pattern is a WARN line with a link to the Security Guide; with devMode off it is a DEBUG line

observed

Problem Report

An unhandled exception or an unmapped action name answers HTTP 200 with the "Struts Problem Report" page, instead of a container error page

observed

Debug tools

The debugging interceptor and the <s:debug/> tag work, and the console resources (/static/webconsole.html) are served; with devMode off /static/webconsole.html answers 404

observed

OGNL guard

The SecurityMemberAccess uses the struts.devMode.excluded* lists, and logs DevMode enabled, using DevMode excluded classes and packages for OGNL security enforcement! at WARN at start-up

observed

FreeMarker

The template cache and the whitespace stripping of struts.freemarker.whitespaceStripping are switched off

default.properties comment, official page

Expression failures

OgnlValueStack logs the failed expression and, in a second line, that the previous warning was issued "due to devMode set to true"

class file

Double-submit tokens

On a token mismatch the token name, the form token and the session token are logged

class file, see the page on tokens

Other consumers

The date tag, the alias interceptor and the staticParams interceptor report their problems through the same devmode.notification message; the compress tag does not compress (the log says "Avoids compressing output …​ in DevMode")

class file

flowchart TD DM["struts.devMode = true"] --> RL["reload bundles and XML
(struts.i18n.reload,
struts.configuration.xml.reload)"] DM --> NT["Developer Notification
ERROR entries and louder
WARN for rejected input"] DM --> PR["Problem Report page
(unmapped action, exception)"] DM --> DT["debugging interceptor,
s:debug, console resources"] DM --> OG["OGNL: devMode excluded lists,
missing property warnings"] DM --> FM["FreeMarker cache and
whitespace stripping off"] RL --> EX["performance cost"] FM --> EX

The picture is the reason for the warning. Four of the six boxes turn information about the application into output that anybody who can send a request can read, and the other two cost time on every request. None of them is a security control, and the Security Guide says so in its "Disable devMode" tip: in production devMode exposes too much information about the internals of the application and can lead to the evaluation of risky parameter expressions.

Reloading: What Is and Is Not Reloaded

The official page says the whole configuration is reloaded on every request. What happens in 7.4.0 is narrower, and it matters for the speed of the development cycle. On every request ConfigurationManager asks each configuration provider whether its file changed; only when one answers yes does the framework load everything again. To see it, the scratch application was requested, struts.xml was touched, and the next request was logged:

DEBUG o.a.s.c.ConfigurationManager - Checking ConfigurationProviders for reload.
INFO  o.a.s.c.ConfigurationManager - Detected container provider [Struts XML configuration provider (struts.xml)] needs to be reloaded.
DEBUG o.a.s.c.ConfigurationManager - Reloading all providers.
DEBUG o.a.s.c.p.XmlConfigurationProvider - Loading action configurations from: struts-default.xml
...

The resource bundles are different: on a request the framework clears the bundle cache (Resource bundles reloaded at DEBUG), and the next message lookup reads the file again. Editing org/example/bookshelf/BookAction.properties in the exploded WAR changed the title page from Bookshelf to the new text within a request or two in the devMode build (in one run the first request right after the edit still showed the old value), and did not change it in the production build, where the value stayed cached for the life of the application.

An explicit setting wins over devMode. With the four constants that the official page lists for "page rendering is slow", devMode stays on while the reloading stops: after setting struts.i18n.reload and struts.configuration.xml.reload to false in the struts.xml of the scratch application, a changed bundle was no longer picked up and a touched struts.xml no longer produced a reload line.

<constant name="struts.devMode" value="true" />
<constant name="struts.i18n.reload" value="false"/>
<constant name="struts.configuration.xml.reload" value="false"/>
<constant name="struts.freemarker.templatesCache" value="true"/>
<constant name="struts.freemarker.templatesCache.updateDelay" value="120"/>
<constant name="struts.freemarker.mru.max.strong.size" value="120"/>

A word of caution that the documentation does not give. In 7.4.0 on Tomcat 11 the first configuration reload in devMode left two parts of the framework without their servlet context: after struts.xml changed on disk and the reload had run, /static/* answered 500 (Cannot invoke "java.util.List.iterator()" because "this.pathPrefixes" is null in DefaultStaticContentLoader) and an unmapped action name answered 404 with "Unable to show problem report: …​ servletContext is null" instead of the Problem Report; the actions themselves kept working. A fresh deployment answers 200 for both. It was reproduced with and without the config browser plugin. The practical rule is that the in-place reload is good for the XML of actions and results, and that after a change a developer who sees a 500 under /static or a missing Problem Report should reload the context or restart; setting struts.configuration.xml.reload to false and redeploying for every change avoids it.

Developer Notification, Warnings and What They Mean

Most of the value of devMode is in the log. Three message families explain most of the "my parameter does not arrive" and "my page is empty" reports. The first request below sends two parameters to the Bookshelf, filter, whose setter is annotated, and author, whose setter is not, plus a third, isbn, that has no setter at all:

$ curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:18080/g12debugging/books?filter=Dune&author=Herbert&isbn=1"
200

The response is 200 in every case, and the page lists both books. The log of the devMode build has a line that no other configuration produces:

ERROR o.a.s.i.p.StrutsParameterAuthorizer - Developer Notification (set struts.devMode to false to disable this message):
Parameter injection for method [setAuthor] on target [org.example.bookshelf.BookAction] rejected. Ensure it is annotated with @StrutsParameter with an appropriate 'depth'.
DEBUG o.a.s.i.p.StrutsParameterAuthorizer - No matching annotated method found for property [isbn] of depth [0] on target [BookAction], now also checking for public field
DEBUG o.a.s.i.p.StrutsParameterAuthorizer - Matching field for property [isbn] not found on target [BookAction]

The rule that produces the difference is in the Version Notes: 7.0.0 started to log parameter annotation issues at ERROR level in devMode (WW-5433). A setter that exists but lacks @StrutsParameter is a developer mistake and is shown as an error; a request parameter that matches nothing is just input and stays at DEBUG. The official page on development mode says that an unknown field makes the framework throw an exception; in 7.4.0 nothing is thrown in either case, and the only change is the log level. The old "Unexpected Exception caught setting" message does exist, but only when struts.parameters.requireAnnotations is false: in the scratch application with that constant off, isbn produced

ERROR o.a.s.i.p.ParametersInterceptor - Developer Notification (set struts.devMode to false to disable this message):
Unexpected Exception caught setting 'isbn' on 'class org.example.bookshelf.BookAction: Error setting expression 'isbn' with value ['1', ]

and with struts.devMode=false none of these ERROR lines is written. That is the real gain of devMode for the Bookshelf’s developers: the log of the production build at WARN would never mention the rejected author, which is why a developer who tests only against the production configuration can spend an afternoon looking for the cause. The production build has the same information at DEBUG: raising only the org.apache.struts2.interceptor.parameter logger to DEBUG there printed the rejection of setAuthor, the unmatched isbn, and two lines for the hostile names below.

The second family is the rejection of hostile parameter names. A request such as

$ curl -s -o /dev/null "http://localhost:18080/g12debugging/books?isbn=1&author=Herbert&%23session.x=1&(x)(y)=1"

is dropped by the parameter name filters before any setter is looked at. With devMode on the log has two WARN lines, each with a pointer to the Security Guide; with devMode off the same decisions are DEBUG:

WARN  o.a.s.i.p.ParametersInterceptor - Parameter [#session.x] matches excluded pattern [(^|\%\{)((#?)(\[\d\]\.)?)(dojo|struts|session|request|response|application|servlet(Request|Response|Context)|parameters|context|_memberAccess)(\.|\[).*]! See Accepted / Excluded patterns at ...
WARN  o.a.s.i.p.ParametersInterceptor - Parameter [(x)(y)] didn't match accepted pattern [[\w+((\.\w+)|(\[\d+])|(\(\d+\))|(\['(\w-?|[一-龥]-?)+'])|(\('(\w-?|[一-龥]-?)+'\)))*]]! See Accepted / Excluded patterns at ...

The third family is a message that is not about parameters. A page that asks for a key that does not exist prints the key itself, and the log says why; the same request prints the WARN lines in both builds, because a missing key is not a devMode feature:

WARN  o.a.s.t.AbstractLocalizedTextProvider - Missing key [book.missing.key] in bundles [[org/apache/struts2/struts-messages, org/apache/struts2/xwork-messages]]!
WARN  o.a.s.u.TextProviderHelper - The first TextProvider in the ValueStack (org.example.bookshelf.BookAction) could not locate the message resource with key 'book.missing.key'

devMode and the OGNL Guard

The OGNL security settings of the framework (the allowlist, the excluded classes and packages) are described on the page on the value stack and in the security hardening page. Two facts belong here because they only apply in devMode. First, struts-excluded-classes.xml in the 7.4.0 jar has a second set of the exclusion constants: struts.devMode.excludedClasses, struts.devMode.excludedPackageNames, struts.devMode.excludedPackageNamePatterns and struts.devMode.excludedPackageExemptClasses. When devMode is on the guard uses these sets. Comparing the two sets in the jar, the devMode class list is the normal one without org.apache.struts2.ActionContext; the package lists are the same. That is consistent with the debugging tools, which read the context, although the files do not say so. Second, the Version Notes of 7.4.0 say that five dev-mode setters of SecurityMemberAccess were removed while the struts.devMode.* settings themselves are unchanged (WW-5675); code that configured the guard through those setters needs the constants instead.

At start-up the guard says which set it uses, which is the quickest way to find out whether a deployed instance still runs in devMode:

WARN  o.a.s.o.SecurityMemberAccessConfig - DevMode enabled, using DevMode excluded classes and packages for OGNL security enforcement!

The Debugging Interceptor

DebuggingInterceptor is the last interceptor of defaultStack, after workflow (it is number 24 of the 24 on the page on interceptors). It does nothing unless devMode is on. When it is on, it looks for a request parameter named debug with one of four values, and the page that the framework builds replaces the page of the action:

debug= What the request returns in 7.4.0

xml

The parameters, the action context, the request, the session and the value stack as an XML document. Broken in 7.4.0 (see below)

console

A page that opens a popup window with an OGNL console from /static/webconsole.html; the page of the action follows it

command

The result of the OGNL expression in the expression parameter, as text/plain (the console sends its commands this way)

browser

An HTML table with the properties of an object: the action by default, or the object named in the object parameter; decorate=false leaves out the style and the script so that the "Expand" links can insert a fragment

browser

The most useful mode in 7.4.0 is browser. For the Bookshelf, books?author=Herbert&filter=Dune&debug=browser returns a table with one row for each property of the action; the values are those after parameter injection, so a parameter that was rejected is still null:

Property Value shown Type shown

container

There is no read method for container

java.lang.String

filter

Dune

java.lang.String

author

null (the setter is not annotated)

unknown

actionErrors, fieldErrors

empty

java.util.LinkedList, java.util.LinkedHashMap

texts, titles, locale

an "Expand" link

java.util.PropertyResourceBundle, java.util.ImmutableCollections$List12, java.util.Locale

The table is built from a plain request, so it also tells you whether filter arrived. With object=%23parameters (the # must be written %23) the table shows the request parameters as the framework saw them. With object=%23session it shows the session attributes; the Bookshelf’s session held one attribute, named nonce, a security value that a production page must never show. object=%23context returned a single row root null in 7.4.0; the documented default of #context is not what is shown when object is absent, which is the action.

One property of the mode that is easy to miss: the action runs. The log of a debug=browser request has the line Executing action method = execute and the application’s own Listing books with filter [Dune], then No result returned for action ..., because the interceptor replaced the result. The Getting Started page says that the interceptor halts further execution of the action, and the Debugging Interceptor page says that its work happens before the natural result runs. In 7.4.0 "halts" is only true of command, where the log has the debugging interceptor and then No result returned without an execute line. A debug=xml or debug=browser request to an action that saves a book or sends an e-mail therefore does that, once.

command and console

command evaluates an OGNL expression against the value stack and returns the text of the result. It is the engine behind the OGNL console, and it can be called directly:

$ curl -s -G "http://localhost:18080/g12debugging/books" --data "filter=Dune&debug=command&expression=filter"
Dune
$ curl -s -G "http://localhost:18080/g12debugging/books" --data "debug=command&expression=titles.size"
2
$ curl -s -G "http://localhost:18080/g12debugging/books" --data-urlencode "debug=command" --data-urlencode "expression=titles[1]"
Neuromancer

The security guards apply to the console as they do to any expression, and the guard decides in silence: the answer for a method call is the word null, not an error. Requesting expression=titles.size() returned null; so did titles.get(0), a static call to java.lang.System, and the construction of a java.io.File. The reason is in the log, and finding it is the exercise the next section works through:

WARN  o.a.s.o.SecurityMemberAccess - Declaring class [class java.util.AbstractCollection] of member type [public abstract int java.util.AbstractCollection.size()] is not allowlisted! Add to 'struts.allowlist.classes' or 'struts.allowlist.packageNames' configuration.
WARN  o.a.s.o.SecurityMemberAccess - Access to static method [public static java.lang.String java.lang.System.getProperty(java.lang.String)] is blocked!
WARN  o.a.s.o.SecurityMemberAccess - Package [package java.io] of member class [class java.io.File] of member [null] is excluded!

debug=console is a convenience for a browser. The response is a small HTML page whose script opens /static/webconsole.html in a popup (the popup blocker has to allow it, as the Core Developer Guide notes). Two quirks of 7.4.0 were observed. A client that has no session yet gets the console page followed by a Problem Report with IllegalStateException: Cannot create a session after the response has been committed, because the interceptor writes the response before it stores the value stack in the session; with a session cookie from an earlier request it works, and the page of the action follows the popup script. And the console resources are served only in devMode.

The console is a remote expression evaluator. Everything on this page about "never in production" is about this mode: the guard blocks the dangerous classes, but an interface that evaluates OGNL on request and is reachable by an attacker depends on that guard having no gap, which is more than a production system should rely on.

xml

The documented output of debug=xml is a <debug> element with <parameters/>, <context>, <request/>, <session/> and <valueStack>. In 7.4.0 the response of the scratch application starts correctly and ends in the middle of a tag:

<debug>
  <parameters/>
  <context>
    <struts.actionMapping>
      <class>class org.apache.struts2.dispatcher.mapper.ActionMapping</class>
      <name>books</name>
      <namespace>/</namespace>
      <params/>
    </struts.actionMapping>
    <action>
      <actionErrors/>
      ...
    </action>
    <report.conversion.errors>false</report.conversion.errors>
    <attr<html>
<head>
    <title>Struts Problem Report</title>

The status is 200 and the content type is text/xml. The cause is in the Problem Report: UnsupportedOperationException: method makes no sense for a simplified map, raised by AttributeMap.size when DebuggingInterceptor.serializeIt checks whether the attr map was already serialized. It was the same on every action and with or without a session. Use browser, which does not serialize that map; a build that needs a complete dump of one request can use <s:debug/> (next section) or a breakpoint.

Switching It Off for One Action or for the Whole Stack

Because debugging is an ordinary interceptor, it takes the disabled parameter that every interceptor of 7.4.0 accepts (the page on interceptors lists the parameters). The quiet action of the Bookshelf, shown at the top, passes debugging.disabled to defaultStack; with devMode on, quiet?filter=Dune&debug=command&expression=filter returned the normal list page, while books?filter=Dune&debug=command&expression=filter returned Dune.

$ curl -s -G ".../g12debugging/books" --data "filter=Dune&debug=command&expression=filter"
Dune
$ curl -s -G ".../g12debugging/quiet" --data "filter=Dune&debug=command&expression=filter"
<!DOCTYPE html>
<html>
<body>
<h1>Bookshelf (Struts)</h1>
...

This is useful for the one action that handles the card number of a payment and that should never be dumpable even on a developer’s machine. It is not a substitute for turning devMode off. Also note that the stack matters: an action mapped to paramsPrepareParamsStack, as the chain-stack action of the scratch application is, answered debug=command&expression=filter with the normal list page, because that stack, unlike defaultStack, does not contain the debugging interceptor. Such an action has no debug modes at all.

<action name="chain-stack" class="org.example.bookshelf.BookAction">
    <interceptor-ref name="paramsPrepareParamsStack"/>
    <result>/WEB-INF/views/books.jsp</result>
</action>

The debug Tag

<s:debug/> renders the value stack and the stack context as a block at the point where it is placed. The Bookshelf’s books-debug page ends with it:

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html>
<html>
<head><s:head/></head>
<body>
<h1>Bookshelf (Struts)</h1>
<ul>
<s:iterator value="titles"><li><s:property/></li></s:iterator>
</ul>
<s:debug/>
</body>
</html>

With devMode on, the response ends with an inline script (toggleDebug), an inline style, a link [Debug] and a div that is hidden until the link is clicked, with the headings "Struts ValueStack Debug", "Value Stack Contents" and "Stack Context". The first table shows org.example.bookshelf.BookAction with its properties, which is where a developer finds out that filter stayed null. With devMode off the same page renders nothing for the tag: a search for "Struts ValueStack Debug" found one match in the devMode response and none in the production one. The block contains inline script and style, which a strict Content Security Policy blocks; the page on security headers discusses the csp interceptor and nonces.

The tag is safer than the interceptor in one respect: it is in the page, so it exists only where a developer typed it. It is more dangerous in another: a <s:debug/> that is committed to a layout file is a leak that is invisible until devMode is switched on for a test environment that is reachable from the internet. Search the JSPs for s:debug in the release checklist.

The Config Browser Plugin

The Config Browser is a plugin, struts2-config-browser-plugin, that serves actions in the /config-browser namespace with a read-only view of the loaded configuration. The plugin page says to use it only during development and to restrict access "strictly"; the other-plugins page of this section lists what each of its pages shows, so this page records what matters for debugging and for production.

<dependency>
    <groupId>org.apache.struts</groupId>
    <artifactId>struts2-config-browser-plugin</artifactId>
</dependency>

For debugging, the pages answer the questions that the log answers at the cost of a restart. index redirects to actionNames, which lists the namespaces (/config-browser, the default one, /) on the left and the actions of the selected one; showConfig.action?namespace=/&actionName=books shows the class, the default URL, the results, the exception mappings, the interceptors and the validators of one action, showConstants.action shows every constant with its effective value (including struts.devMode), showBeans.action lists the bean implementations and showJars.action the jars on the class path with their versions. The Getting Started tutorial links the pages from a debugging section with <s:url action="index" namespace="config-browser"/>; without the leading slash in the namespace the tag rendered the relative URL config-browser/index.action (followed by a jsessionid path parameter for a client without a cookie), which only resolves from a page at the root of the context. With namespace="/config-browser" it rendered /g12debugging/config-browser/index.action.

Four findings from the scratch Tomcat decide how to treat the plugin:

  • It does not check devMode. The production build, with struts.devMode=false, answered 302 for /config-browser/index.action, then 200 for actionNames.action, showConstants.action, showBeans.action and showJars.action, without authentication. A developer who believes that "devMode off" switches the tools off is wrong about this one, and showConstants prints every security constant of the application.

  • It brings jars. In the Bookshelf the WAR grew from 17 to 20 jars: struts2-config-browser-plugin, struts2-velocity-plugin and velocity-engine-core (a transitive dependency of the plugin).

  • It shows its age. The footer reads Struts (c) 2003 - 2015, the menu entry reads Jars (requires Maven 2 data), and showConfig prints the default URL of the Bookshelf’s action with a double slash (/g12debugging//books.action), because the namespace / is joined with a slash that is already there. The pages work.

  • A security constraint blocks it. The block below, added to the production build’s web.xml, made every URL under /config-browser/ answer 403 (index.action, index, showConstants.action) while /books stayed 200.

<security-constraint>
    <display-name>No Config Browser in production</display-name>
    <web-resource-collection>
        <web-resource-name>config-browser</web-resource-name>
        <url-pattern>/config-browser/*</url-pattern>
    </web-resource-collection>
    <auth-constraint>
        <role-name>no-users</role-name>
    </auth-constraint>
</security-constraint>
<security-role>
    <description>Do not assign users to this role</description>
    <role-name>no-users</role-name>
</security-role>

The better answer is to keep the jar out of the production WAR. A Maven profile that adds the dependency makes that a build decision, and was verified: the default build had 17 jars and no config browser, and mvn package -Pdev-tools had 20 with the three jars named above.

<profiles>
    <profile>
        <id>dev-tools</id>
        <dependencies>
            <dependency>
                <groupId>org.apache.struts</groupId>
                <artifactId>struts2-config-browser-plugin</artifactId>
            </dependency>
        </dependencies>
    </profile>
</profiles>

Logging with Log4j 2

Struts logs through the Log4j 2 API and nothing else. The Core Developer Guide records the history: the XWork logging layer (LoggerFactory, the xwork.loggerFactory property, the Commons Logging and SLF4J delegates) was deprecated in 2.5 and removed in Struts 6.0.0 (WW-4763 in the Version Notes of 6.0.0), and Struts 7.3.0 removed the last java.util.logging and SLF4J call sites from the framework (WW-5620); the container no longer injects a java.util.logging.Logger into a managed bean. There is nothing to configure in struts.xml; the configuration is a normal log4j2.xml.

The Dependency Is Yours

The Logging page says that struts2-core "declares log4j-api and log4j-core". In the 7.4.0 POM only log4j-api is a normal dependency; log4j-core is declared with test scope, so it is not transitive. A WAR that depends on struts2-core alone has the API and no implementation. The scratch WAR without any implementation started and served pages, and its start-up wrote

ERROR Log4j API could not find a logging provider.

and from then on only ERROR entries reached Tomcat’s catalina.out, unformatted (ERROR Dispatcher Could not find action or result: /g12debuggingnolog/nosuch); every WARN, INFO and DEBUG entry of the framework was dropped, which includes all the messages of the previous sections. The application therefore declares the implementation. The struts2-bom does not manage its version (the build failed with "dependencies.dependency.version is missing"), so the version is written out; the one that matches the log4j-api that Struts 7.4.0 brings is 2.26.1:

<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.26.1</version>
</dependency>

The Bookshelf’s own scratch POM names log4j-slf4j2-impl, which brings log4j-core and a bridge for libraries that log to SLF4J. The bridge is a separate concern, as the Logging page says; add log4j-slf4j2-impl (SLF4J 2) or log4j-jcl (Commons Logging) only when a library of the application needs it. The Logging page adds that 6.11.0 still declares slf4j-api and that 7.3.0 dropped it from struts2-core; it had been optional, so nothing changes on the class path of an application.

A Development Configuration

The configuration of the development build writes to a file that is easy to find in Tomcat’s logs directory and raises two loggers: the framework’s root package and the application’s. The file appender uses the system property that Tomcat sets for its base directory:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
    <Appenders>
        <File name="FILE" fileName="${sys:catalina.base}/logs/g12debugging.log">
            <PatternLayout pattern="%d{HH:mm:ss.SSS} %-5p [%t] %c{1.} - %m%n"/>
        </File>
    </Appenders>
    <Loggers>
        <Logger name="org.apache.struts2" level="debug"/>
        <Logger name="org.example.bookshelf" level="debug"/>
        <Root level="warn">
            <AppenderRef ref="FILE"/>
        </Root>
    </Loggers>
</Configuration>

A word about the logger names. The Getting Started tutorial and the Security Guide both configure com.opensymphony.xwork2 next to org.apache.struts2 (the tutorial says that "the two major packages …​ com.opensymphony and org.apache.struts2" are the ones to raise). No class of 7.4.0 lives in com.opensymphony; the XWork packages were renamed in 7.0.0 (WW-3714 in its Version Notes) and what remains is org.apache.struts2 and its sub-packages. A logger for a name that does not exist is harmless but does nothing, and the line is a leftover that is worth deleting when you copy an old configuration.

At DEBUG the framework is chatty: one request to books?filter=Dune&author=Herbert&isbn=1 produced 96 log lines, among them the exact order in which interceptors ran (Executing conditional interceptor: exception, alias, servletConfig, i18n, csp, prepare, chain, scopedModelDriven, modelDriven, params, conversionError, coep, coop, fetchMetadata, validation, workflow, debugging), every SecurityMemberAccess check, the type converter lookup for filter, Executing action method = execute, the action’s own Listing books with filter [Dune], the result processing and the JSP forward. For a developer this is a trace of the request cycle of the architecture page, in order, and it is worth reading once.

Targeted Loggers

The 96 lines are too many for daily work. Log4j 2 loggers are hierarchical, so a configuration can leave the framework at WARN and raise only the package one is investigating. The configuration below keeps org.apache.struts2 at warn, raises the parameter handling (ParametersInterceptor and StrutsParameterAuthorizer) to debug, and keeps the application at debug. The same request then produced 18 lines, which show the decision for every parameter and the application’s own message:

<Loggers>
    <Logger name="org.apache.struts2" level="warn"/>
    <Logger name="org.apache.struts2.interceptor.parameter" level="debug"/>
    <Logger name="org.example.bookshelf" level="debug"/>
    <Root level="warn">
        <AppenderRef ref="FILE"/>
    </Root>
</Loggers>

The loggers that answer the most common questions, with the level at which they start to speak, are:

Logger (package of org.apache.struts2) Level What you learn

interceptor.parameter

DEBUG (WARN for rejected names in devMode)

Which parameters were accepted, rejected or not matched, and why

ognl (SecurityMemberAccess)

WARN

An expression blocked by the allowlist, the excluded classes or the static method rule; the same logger at DEBUG prints every member access check

ognl (OgnlValueStack)

WARN with struts.ognl.logMissingProperties=true

Each property an expression could not find

text (AbstractLocalizedTextProvider, TextProviderHelper)

WARN

A missing message key, with the bundles that were searched

config (ConfigurationManager, XmlConfigurationProvider)

DEBUG, INFO for a reload

Which files were loaded, which provider asked for a reload

dispatcher (Dispatcher, DefaultDispatcherErrorHandler)

WARN, ERROR

An unmapped action or an exception in the request cycle (a Could not find action or result line)

DefaultActionInvocation

DEBUG

The interceptors in order, the action method, the result returned or No result returned

interceptor (ExceptionMappingInterceptor)

DEBUG, configurable

Exceptions caught by the exception interceptor, see the page on exception handling

Logging the Missing Property

When an OGNL expression finds no property, the value is empty and nothing is logged, which is why <s:property value="owner.name"/> prints an empty string for a typo. The constant struts.ognl.logMissingProperties (default false; the comment in default.properties warns that it is very verbose) turns on a WARN per property. With it set to true the title page of the Bookshelf logged

WARN  o.a.s.o.OgnlValueStack - Could not find property [nope]!
WARN  o.a.s.o.OgnlValueStack - Could not find property [owner.name]!
WARN  o.a.s.o.OgnlValueStack - Could not find property [titles.size()]!

together with a handful of lines for framework-internal names (struts, struts.actionMapping, struts.valueStack) that the framework probes on every page. Switch it on for a debugging session and off again; the lines for the internal names make it unsuitable as a permanent setting.

Logging from Your Own Actions

An action logs with the Log4j 2 API like any class, as the Bookshelf’s BookAction does. Two rules from the Logging page are worth repeating. The message syntax is the {} placeholder of Log4j 2 parameterised messages (LOG.debug("... [{}]", filter)), not the #0 syntax of the removed XWork logger. And a java.util.logging.Logger injected into a managed bean by the old container binding no longer exists in 7.3.0 and later; declare the logger as a static final field. The LOG of the Bookshelf printed its line at DEBUG in the development build and, in the production build, where the logger is at info, nothing.

A Debugging Session on the Bookshelf

Three typical reports and the tools that settle each one, in the order in which they were used on the scratch application.

"The list is not filtered by author." The URL is books?author=Herbert. The page lists both books and the browser shows nothing wrong. debug=browser on the same URL shows the author row as null (the action’s property never changed). The development log has the ERROR with the Developer Notification of the previous section: setAuthor is not annotated. The fix is the annotation (@StrutsParameter with a depth that matches the property, as the page on parameter injection explains), and the same debug=browser then shows Herbert (a build of the Bookshelf with the annotation added showed the author row as Herbert, type java.lang.String, instead of null, type unknown).

"The title renders as a key." The title page shows shelf.title. The log says Missing key [shelf.title] in bundles [...] and that the first text provider on the stack was BookAction; the bundle that BookAction reads is org/example/bookshelf/BookAction.properties (see the page on localization) and was missing there (a copy in the root of the class path is not searched for that action). Adding shelf.title=Bookshelf to that file in the exploded WAR displayed Bookshelf on the next request in the devMode build and, in the production build, only after a restart, which is the reloading behaviour of the table above.

"The expression returns nothing." books?debug=command&expression=titles.size() answered null. The log has the SecurityMemberAccess warning that java.util.AbstractCollection is not allowlisted. There are two fixes. The safe one is the property form of the same call, titles.size, which returned 2 through the console and does not need a method call; the other is the allowlist constants named in the warning, which widen the guard for a whole class or package and are decided with the security hardening page, not in a debugging session. The warning also shows why the debugging console cannot be used to explore an application that has the allowlist enabled: it applies the same rules as every expression.

Turning It All Off for Production

The Security Guide has three tips for this: restrict the config browser, disable devMode, reduce the logging level. They are three of the seven steps below, and a deployment that was verified against the production build of the Bookshelf shows each of them as a difference in an answer.

Request struts.devMode=true struts.devMode=false

books?filter=Dune&debug=command&expression=filter

Dune, text/plain

The normal list page; the debug parameter is ignored

books-debug (<s:debug/>)

A "Struts ValueStack Debug" block

No block

nosuch (an unmapped action)

200 with the Struts Problem Report (on a fresh deployment)

404 from the container

static/webconsole.html

200

404

books?author=Herbert (setter not annotated)

ERROR "Developer Notification"

Nothing at WARN; one DEBUG line

config-browser/index.action

302, then the pages (200)

302, then the pages (200) unless blocked

The steps, then, are these:

  1. struts.devMode is false in the deployed configuration. Keep the literal true out of the file that is built, as the constants page of this section suggests, and search for devMode in struts.xml, in a packaged struts.properties, in the filter’s init-param`s and in `default.properties overrides, because a developer’s local file can be packaged by mistake. At start-up a deployed instance that still runs in devMode logs the WARN from the OGNL guard quoted earlier, which a log alert can look for.

  2. The config browser is not in the WAR (the Maven profile) or its namespace is blocked (the constraint).

  3. <s:debug/> is not in any JSP.

  4. The log levels are warn for org.apache.struts2 and info or warn for the application. The Security Guide’s example does the same with a console appender; the configuration below uses a rolling file, which was loaded by the scratch Tomcat.

  5. The debugging interceptor is disabled for the actions that handle secrets, if devMode could ever be switched on in that environment.

  6. A debugger agent is not in the start script.

  7. The struts.ognl.logMissingProperties constant is off.

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN" monitorInterval="30">
    <Appenders>
        <RollingFile name="FILE" fileName="${sys:catalina.base}/logs/bookshelf.log"
                     filePattern="${sys:catalina.base}/logs/bookshelf-%d{yyyy-MM-dd}-%i.log.gz">
            <PatternLayout pattern="%d{HH:mm:ss.SSS} %-5p [%t] %c{1.} - %m%n"/>
            <Policies>
                <SizeBasedTriggeringPolicy size="10 MB"/>
            </Policies>
            <DefaultRolloverStrategy max="5"/>
        </RollingFile>
    </Appenders>
    <Loggers>
        <Logger name="org.apache.struts2" level="warn"/>
        <Logger name="org.example.bookshelf" level="info"/>
        <Root level="warn">
            <AppenderRef ref="FILE"/>
        </Root>
    </Loggers>
</Configuration>

monitorInterval is the attribute that makes Log4j 2 read the file again; it is the production counterpart of devMode’s reloading and is far less risky. On the scratch Tomcat (with a value of 5 seconds, to keep the test short) editing the file in the exploded WAR changed org.apache.struts2 from warn to info and the parameter logger from debug to warn within the interval, and the next request wrote 3 lines instead of 18, without a restart. In production, this is the way to raise a logger for ten minutes when a problem cannot be reproduced elsewhere, and to lower it again.

devMode for One Request

The Javadoc of PrepareOperations has a method for switching devMode on per request: overrideDevMode(boolean) sets an override of the static devMode value for the current thread, with the warning not to set it from a request parameter or any other unprotected source and the suggestion that a signed cookie is one safe way to turn it on per request, and clearDevModeOverride() removes it. The override is read by the debugging interceptor, the <s:debug/> tag and the Problem Report handler (the three classes that call getDevModeOverride() in the 7.4.0 jar); it does not change the OGNL guard or the reloading. The filter below shows how the override is wired, and it is shown as an anti-pattern to recognise, not as part of a production build: it switches devMode, with the debug=command OGNL evaluator, the Problem Report and <s:debug/>, on for any request that carries a static token in a header. It belongs, if anywhere, in a staging or internal-only build, and it must never be deployed where the Internet can reach it. The scratch application ran it on a local Tomcat only:

package org.example.bookshelf.ops;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import org.apache.struts2.dispatcher.PrepareOperations;

/** STAGING / INTERNAL ONLY: switches devMode on for ONE request when the caller presents the ops token. */
public class DevOverrideFilter implements Filter {
    private byte[] token;

    @Override
    public void init(FilterConfig config) {
        String configured = config.getInitParameter("opsToken");   // injected per environment
        token = (configured == null || configured.isBlank())
                ? null : configured.getBytes(StandardCharsets.UTF_8);
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        String presented = ((HttpServletRequest) req).getHeader("X-Ops-Token");
        boolean allowed = token != null && presented != null
                && MessageDigest.isEqual(token, presented.getBytes(StandardCharsets.UTF_8));
        try {
            if (allowed) {
                PrepareOperations.overrideDevMode(true);
            }
            chain.doFilter(req, res);
        } finally {
            PrepareOperations.clearDevModeOverride();
        }
    }
}

The filter is declared before the struts2 filter in web.xml with an opsToken init-param whose value in the scratch application is the placeholder CHANGE_ME (a staging deployment would inject the secret from the environment and never commit it). Requested on the scratch application with struts.devMode=false, without the header or with a wrong one, books?filter=Dune&debug=command&expression=filter returned the list page; with the right header it returned Dune, books-debug showed the <s:debug/> block, and an unmapped action name answered 200 with the Problem Report. Without the header the same URLs behaved as in the production column of the table above.

The method exists and works, and that is the danger. The filter puts an OGNL evaluator behind a static shared secret in a header, which any proxy in the path can log, and it is the same remote evaluator that the Security Guide asks you to remove; it contradicts the rule that devMode is never on in production and the Security Hardening checklist. Do not ship it in a WAR that is deployed where the Internet can reach it, not even disabled by a missing token. For a production incident use the logging route above.

The IDE and a Debugger

Everything above works without a debugger, but for an action that does something wrong after parameter injection a breakpoint is the fastest tool. Set the breakpoint in the action method or in a setter; the debugger’s call stack at that point is a second way to read the architecture page, with DefaultActionInvocation.invoke calling one interceptor after another. For a servlet container the JDWP agent is started by catalina.sh jpda start and the IDE attaches to port 8000; the JSP page of this section on testing and debugging (Attaching a Debugger) has the exact options, the warning that a JDWP port is an unauthenticated code execution interface, and the container variant. A breakpoint in a .jsp works through the SMAP of the compiled page, as that page explains.

Pitfalls

Symptom Cause Fix

The application logs nothing from Struts and catalina.out has "Log4j API could not find a logging provider"

Only log4j-api is a dependency of struts2-core; log4j-core is not

Add log4j-core (explicit version) or a bridge, and a log4j2.xml

devMode is off but the config browser answers 200

The plugin does not look at devMode

Remove the jar from the WAR (profile) or constrain /config-browser/*

?debug=xml returns half a document and a Problem Report

AttributeMap.size throws in serializeIt in 7.4.0

Use debug=browser

debug=xml or debug=browser on a POST changed data

The action method runs before the interceptor replaces the result

Never debug an unsafe action in a shared environment; use command, which does not run the action

debug=console shows an "IllegalStateException: Cannot create a session"

No session existed when the interceptor stored the stack

Make a request that creates the session first

/static/* is 500 and the Problem Report is a 404 after editing struts.xml

The first devMode reload in 7.4.0 leaves static content and error handling without the servlet context

Reload the context; set struts.configuration.xml.reload to false and redeploy for XML changes

The action’s setter is ignored and the page works

The setter lacks @StrutsParameter; the message is an ERROR only in devMode

Annotate it, and test with devMode on

Nothing about the rejected parameter in the production log

Rejected input is DEBUG when devMode is off

Raise org.apache.struts2.interceptor.parameter to debug for a while (monitorInterval)

A method call in the console, titles.size(), answers null

The OGNL guard blocked it and the answer is silent; the reason is a WARN

Read the log; use the property form titles.size

The resource bundle edit is not shown

devMode is off or struts.i18n.reload is false; or the file is in the wrong package

Edit org/example/bookshelf/BookAction.properties, or set the constants

Log lines for com.opensymphony.xwork2 never appear

No class of 7.x is in that package

Configure org.apache.struts2

A page is slow in development

devMode disables the FreeMarker cache and reloads

The four constants of the Development Mode page, as above

A java.util.logging call from an injected logger fails

The container no longer injects a Logger since 7.3.0

Declare a Log4j 2 static final logger

Tomcat warns that a Log4j2-TF-* thread was not stopped when a context with a monitorInterval is undeployed (seen in catalina.out)

The Bookshelf does not stop Log4j when the web application stops

The Log4j page for Jakarta EE describes the log4j-jakarta-web module for start-up and shutdown; not tried here

What Changed Recently

Version Change

6.0.0

The deprecated XWork logging layer is dropped (WW-4763) and the profiling layer is removed (WW-4779); the list of fixed issues includes setting struts.devMode from a system property (WW-4928) and the debug=browser mode of the debugging interceptor (WW-5054)

7.0.0

Parameter annotation problems are logged at ERROR in devMode (WW-5433); the DevMode excluded-class configuration is cleaned up (WW-5439); the XWork packages are renamed (WW-3714); Log4j 2.22.1 (WW-5384)

7.3.0

SLF4J and java.util.logging are removed from the framework, and the container no longer injects a Logger (WW-5620)

7.4.0

Five dev-mode setters of SecurityMemberAccess are removed and the struts.devMode.* constants are unchanged (WW-5675); the log4j-api that comes with struts2-core is 2.26.1 (the Bookshelf’s log4j-core has to be chosen to match)

Where the Books and the Official Pages Are Outdated

The older books on Struts 2 teach devMode as a one-line trick. One of them has it among the best practices of a late chapter, as the setting that makes a developer’s life easier by reloading and by raising errors; the rest of the advice there still holds (devMode off in production, a restart when the XML is wrong), but three of its assumptions changed. The first is that a reload rebuilds a working application: in 7.4.0 a reload leaves the static content and the Problem Report broken until a restart, as seen above. The second is the debugging page, which in those books is a way to dump everything in XML; on 7.4.0 the XML mode is broken and the browser mode is the working one. The third is logging, which was a choice of three libraries in the 2.0 generation and is now one API; and the book’s logger and package names (com.opensymphony) are org.apache.struts2.

The official pages have their own inconsistencies, found while verifying the examples of this page:

  • The Getting Started page says that the debugging interceptor halts the action; xml and browser run it first (command does not). The object default is said to be #context; with no object the action is shown.

  • The Debugging page still shows a complete <debug> document for ?debug=xml, with class com.opensymphony.xwork2.ActionSupport in it; the 7.4.0 interceptor fails on the attr element, and the class names are org.apache.struts2.ActionSupport.

  • The Getting Started page on debugging says that the browser page needs the Struts dojo plugin for its expand and collapse links; the dojo plugin is not among the 17 plugins published for 7.4.0, and the 7.4.0 page works without it (the links use a script in the page).

  • The Development Mode page says that an unknown request field throws an exception; in 7.4.0 it is a log entry, and an explicit struts.i18n.reload or struts.configuration.xml.reload constant is honoured over devMode, which the page implies only with its slow-rendering example.

  • The Logging page says struts2-core declares log4j-core; the POM has it only for tests.

  • The Security Guide’s Log4j 2 sample configures com.opensymphony.xwork2, and both it and the Getting Started how-to use a pattern with %C{2} and %F:%L; the Log4j layouts page explains that location information makes Log4j take a snapshot of the stack and walk it, so use a cheaper pattern in production and configure org.apache.struts2.

  • The Config Browser pages carry "2003 - 2015", ask for "Maven 2 data" and print a double slash in the default URL of an action in the root namespace.

Summary

  • struts.devMode is one constant with many effects: reloading, Developer Notification errors, louder warnings, the Problem Report page, the debug tools and the devMode OGNL lists. Off by default; never on in production.

  • The debugging interceptor works only in devMode; command and browser work in 7.4.0, xml does not, xml and browser run the action, and debugging.disabled removes the interceptor from an action.

  • <s:debug/> is a block in the page that exists only in devMode; the config browser exists as long as its jar is on the class path and has to be removed or blocked.

  • Logging is Log4j 2: add log4j-core yourself, raise org.apache.struts2 subpackages instead of the whole tree, and use monitorInterval to change levels without a restart.

  • Most "why does it not work" answers are one WARN or ERROR line away: a rejected parameter, a blocked expression, a missing key.

References