Build an Agent-aware Tomcat WAR
Copy the build profile, deployment descriptor fragment, and Java classes used by Banalytics Site to precompile JSP pages and publish bounded events into Banalytics Agent.
This is a reference implementation, not a conceptual overview. It shows the concrete project files required to produce a WAR for Tomcat Integration while preserving a normal-Tomcat build with Agent integration disabled.
Target Tomcat 9 and keep the Agent bridge optional
Tomcat Integration starts a separate embedded Tomcat 9 container in the Agent JVM. The WAR must use Servlet 4 and javax.servlet; a Tomcat 10 or jakarta.servlet-only application will not deploy. Package normal libraries into the WAR, but keep the Servlet API as provided.
The reference design has two outputs. The normal build contains source JSP files and has no Agent-specific listener or filter. The compile-jsp profile precompiles the JSP files, removes their source from the archive, and inserts the Agent integration descriptor. Keep direct Agent API classes in a profile-only source directory, so the normal build needs neither those classes nor the Core Processing Model JAR.
Technical references: use Tomcat Integration Thing for connector, TLS, workspace, statistics, and access-log configuration; use Tomcat Web Application Thing for context paths, health probes, application variables, and redeploy semantics. The complete module API and event reference is in Tomcat Integration 2.1.0 technical documentation.
| File | Purpose | Included in a normal build | Included with compile-jsp |
|---|---|---|---|
WEB-INF/web.xml | Base servlet, Tiles, and filter configuration; contains two build markers. | Yes | Yes, copied and augmented. |
banalytics-integration-web.xml | Listener, filter, and flush interval for page-view events. | No | Inserted into generated web.xml. |
AgentCountingEventPublisher | Buffers counters and calls the typed Agent event engine. | Not compiled. | Compiled and active through the listener. |
AgentCountingEventListener and filter | Starts the scheduler and records approved request outcomes. | Not registered. | Registered by the fragment. |
Add the WAR and JSP compiler build profile
Add the following base configuration to pom.xml. The reference Banalytics Site uses Jasper 9.0.107 to build against the Tomcat 9 line; choose and test a compatible Tomcat 9 JspC version for the Agent release you target.
<properties>
<java-version>20</java-version>
<tomcat-version>9.0.107</tomcat-version>
<jspc.sourcesDirectory>${project.build.directory}/generated-sources/jspc</jspc.sourcesDirectory>
<jspc.workDirectory>${project.build.directory}/jspc</jspc.workDirectory>
<banalytics.integrationWebFragment>${project.basedir}/src/main/resources/banalytics-integration-web.xml</banalytics.integrationWebFragment>
<war.webXml>${project.basedir}/src/main/webapp/WEB-INF/web.xml</war.webXml>
<war.packagingExcludes/>
</properties>
<dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>ROOT</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
<configuration><source>${java-version}</source><target>${java-version}</target></configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>3.3.2</version>
<configuration>
<webXml>${war.webXml}</webXml>
<packagingExcludes>${war.packagingExcludes}</packagingExcludes>
</configuration>
</plugin>
</plugins>
</build>
Then add this profile. It executes JspC, compiles the generated servlet sources, injects the generated servlet mappings and Agent fragment into a copied web.xml, and omits source JSP files from the final WAR.
<profile>
<id>compile-jsp</id>
<properties>
<war.webXml>${jspc.workDirectory}/web.xml</war.webXml>
<war.packagingExcludes>**/*.jsp,**/*.jspx</war.packagingExcludes>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.tomcat</groupId><artifactId>tomcat-jasper</artifactId>
<version>${tomcat-version}</version><scope>provided</scope>
<exclusions><exclusion><groupId>org.apache.tomcat</groupId><artifactId>tomcat-servlet-api</artifactId></exclusion></exclusions>
</dependency>
</dependencies>
<build><plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-antrun-plugin</artifactId><version>3.1.0</version>
<executions><execution><id>jspc</id><phase>process-classes</phase><goals><goal>run</goal></goals>
<configuration><target>
<mkdir dir="${jspc.sourcesDirectory}"/><mkdir dir="${jspc.workDirectory}"/>
<java classname="org.apache.jasper.JspC" fork="true" failonerror="true" maxmemory="1024m">
<classpath><path refid="maven.plugin.classpath"/><path refid="maven.compile.classpath"/></classpath>
<arg value="-die1"/><arg value="-l"/><arg value="-uriroot"/><arg value="${project.basedir}/src/main/webapp"/>
<arg value="-d"/><arg value="${jspc.sourcesDirectory}"/><arg value="-p"/><arg value="org.apache.jsp"/>
<arg value="-javaEncoding"/><arg value="UTF-8"/><arg value="-webinc"/><arg value="${jspc.workDirectory}/jspc-servlet-mappings.xml"/>
</java>
<loadfile property="jspc.servletMappings" srcFile="${jspc.workDirectory}/jspc-servlet-mappings.xml"/>
<copy file="${project.basedir}/src/main/webapp/WEB-INF/web.xml" tofile="${jspc.workDirectory}/web.xml" overwrite="true"/>
<loadfile property="banalytics.integrationFragment" srcFile="${banalytics.integrationWebFragment}"/>
<replace file="${jspc.workDirectory}/web.xml" token="<!-- BANALYTICS_INTEGRATION -->" value="${banalytics.integrationFragment}"/>
<replace file="${jspc.workDirectory}/web.xml" token="<!-- JSPC_SERVLET_MAPPINGS -->" value="${jspc.servletMappings}"/>
</target></configuration>
</execution></executions>
<dependencies><dependency><groupId>org.apache.tomcat</groupId><artifactId>tomcat-jasper</artifactId><version>${tomcat-version}</version></dependency></dependencies>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId><artifactId>build-helper-maven-plugin</artifactId><version>3.6.1</version>
<executions><execution><id>add-jspc-sources</id><phase>process-classes</phase><goals><goal>add-source</goal></goals>
<configuration><sources><source>${jspc.sourcesDirectory}</source></sources></configuration>
</execution></executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId>
<executions><execution><id>compile-jsp-servlets</id><phase>prepare-package</phase><goals><goal>compile</goal></goals></execution></executions>
</plugin>
</plugins></build>
</profile>
Run mvn -Pcompile-jsp clean package. The artifact is target/ROOT.war. Do not place this release archive in the Tomcat Integration working directory; use a separate release folder and select that file in Tomcat Web Application Thing.
Attach core-processing-model-xxx.jar when code imports Agent classes
The Java implementation below imports BoxEngine and CountingEvent directly. Attach the exact matching module JAR from the installed Agent:
<BANALYTICS_HOME>/modules/core-processing-model-xxx.jar
Use the same version as the Agent that will host the WAR. Add it to the compile-jsp profile as a system-scoped compile dependency. Supply the absolute JAR path through Maven settings, a CI variable, or -Dcore-processing-model.jar=....
<properties>
<core-processing-model.jar>/absolute/path/to/banalytics/modules/core-processing-model-2.1.0.jar</core-processing-model.jar>
</properties>
<dependency>
<groupId>com.banalytics.box</groupId>
<artifactId>core-processing-model</artifactId>
<version>2.1.0</version>
<scope>system</scope>
<systemPath>${core-processing-model.jar}</systemPath>
</dependency>
Do not bundle this JAR into WEB-INF/lib: the Agent provides the compatible runtime type through its WAR bridge. Verify the produced WAR does not contain a second core-processing-model JAR. Put direct-import classes under src/agent-integration/java and add that source root only in the compile-jsp profile; the ordinary build then remains independent of the Agent installation.
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>3.6.1</version>
<executions><execution>
<id>add-agent-integration-sources</id>
<phase>generate-sources</phase>
<goals><goal>add-source</goal></goals>
<configuration><sources>
<source>${project.basedir}/src/agent-integration/java</source>
</sources></configuration>
</execution></executions>
</plugin>
import com.banalytics.box.module.BoxEngine;
import com.banalytics.box.module.counter.event.CountingEvent;
BoxEngine engine = (BoxEngine) servletContext.getAttribute(BoxEngine.class.getName());
if (engine != null) {
engine.fireEvent(new CountingEvent("workflow.completed", 1));
}
Register event code only in the Agent build
Put the first marker in the base src/main/webapp/WEB-INF/web.xml, before the application filters. Keep the second marker near the end of the descriptor; JspC replaces it with generated servlet and servlet-mapping declarations.
<!-- BANALYTICS_INTEGRATION -->
<!-- JSPC_SERVLET_MAPPINGS -->
Create src/main/resources/banalytics-integration-web.xml. This is an XML fragment, not a complete web-app document. Replace com.example.web with your package name.
<listener>
<listener-class>com.example.web.AgentCountingEventListener</listener-class>
</listener>
<context-param>
<param-name>agentCountingFlushIntervalSec</param-name>
<param-value>30</param-value>
</context-param>
<filter>
<filter-name>CountingEventFilter</filter-name>
<filter-class>com.example.web.CountingEventFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>CountingEventFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
The Banalytics Site uses the equivalent fragment to register PageViewEventListener and PageViewStatisticsFilter with a 30-second interval. It is injected before the normal filters so the counted response status is the one actually returned to the browser.
Copy the minimal event publisher, listener, and filter
Place the following three classes under src/agent-integration/java/com/example/web. They form a self-contained direct-API implementation. The publisher retrieves the Agent-supplied BoxEngine from the servlet context and fires a typed CountingEvent. This is why the Core Processing Model JAR from the preceding section is required for the profile build.
AgentCountingEventPublisher.java
package com.example.web;
import com.banalytics.box.module.BoxEngine;
import com.banalytics.box.module.counter.event.CountingEvent;
import javax.servlet.ServletContext;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;
final class AgentCountingEventPublisher {
static final String ATTRIBUTE = AgentCountingEventPublisher.class.getName();
private final ServletContext context;
private final ConcurrentMap<String, AtomicInteger> counts = new ConcurrentHashMap<>();
AgentCountingEventPublisher(ServletContext context) { this.context = context; }
static AgentCountingEventPublisher from(ServletContext context) {
return (AgentCountingEventPublisher) context.getAttribute(ATTRIBUTE);
}
void record(String key, int delta) {
if (key != null && !key.isBlank() && delta != 0)
counts.computeIfAbsent(key, ignored -> new AtomicInteger()).addAndGet(delta);
}
void flush() {
Object engineAttribute = context.getAttribute(BoxEngine.class.getName());
BoxEngine engine = engineAttribute instanceof BoxEngine ? (BoxEngine) engineAttribute : null;
if (engine == null) return;
for (Map.Entry<String, AtomicInteger> entry : counts.entrySet()) {
int value = entry.getValue().getAndSet(0);
if (value == 0) continue;
try { engine.fireEvent(new CountingEvent(entry.getKey(), value)); }
catch (Throwable error) {
entry.getValue().addAndGet(value);
context.log("Unable to publish counter " + entry.getKey(), error);
}
}
}
}
AgentCountingEventListener.java
package com.example.web;
import javax.servlet.ServletContext;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class AgentCountingEventListener implements ServletContextListener {
private ScheduledExecutorService scheduler;
private AgentCountingEventPublisher publisher;
@Override public void contextInitialized(ServletContextEvent event) {
ServletContext context = event.getServletContext();
publisher = new AgentCountingEventPublisher(context);
context.setAttribute(AgentCountingEventPublisher.ATTRIBUTE, publisher);
int interval = readInterval(context);
scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleWithFixedDelay(publisher::flush, interval, interval, TimeUnit.SECONDS);
}
@Override public void contextDestroyed(ServletContextEvent event) {
if (scheduler != null) scheduler.shutdownNow();
if (publisher != null) publisher.flush();
}
private static int readInterval(ServletContext context) {
try {
int value = Integer.parseInt(context.getInitParameter("agentCountingFlushIntervalSec"));
if (value > 0) return value;
} catch (Exception ignored) { }
return 30;
}
}
CountingEventFilter.java
package com.example.web;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class CountingEventFilter implements Filter {
private AgentCountingEventPublisher publisher;
@Override public void init(FilterConfig config) {
publisher = AgentCountingEventPublisher.from(config.getServletContext());
}
@Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
chain.doFilter(request, response); return;
}
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
boolean failed = false;
try { chain.doFilter(request, response); }
catch (IOException | ServletException | RuntimeException | Error error) { failed = true; throw error; }
finally {
String path = pathWithinContext(httpRequest);
if (publisher != null && shouldCount(httpRequest, path)) {
int status = failed ? HttpServletResponse.SC_INTERNAL_SERVER_ERROR : httpResponse.getStatus();
publisher.record(httpRequest.getMethod().toUpperCase() + " (" + status + ") " + path, 1);
}
}
}
private static String pathWithinContext(HttpServletRequest request) {
String uri = request.getRequestURI(), context = request.getContextPath();
return context != null && !context.isEmpty() && uri.startsWith(context) ? uri.substring(context.length()) : uri;
}
private static boolean shouldCount(HttpServletRequest request, String path) {
return "GET".equals(request.getMethod()) && path.startsWith("/kb/");
}
}
Adapt shouldCount to your public routes. Banalytics Site uses its TilesRouteRegistry instead, so only recognised pages are counted. Do not count every asset, unbounded URL, user identifier, request ID, or exception message: Counter Thing retains each distinct key.
Deploy the archive and route events to the counter
- Create a Tomcat Integration Thing with a deliberate bind address, connector policy, and writable workspace.
- Build with
mvn -Pcompile-jsp clean package, copytarget/ROOT.warto a release folder outside that workspace, then create a Tomcat Web Application Thing with a unique path such as/site. - Start the Web Application Thing. Leave the health URL empty for the unmodified Site, or configure a lightweight application-specific readiness URL.
- Create a Counter Thing. Select
IN_MEMORYfor temporary totals, orDATA_SOURCEwhen totals must survive a restart. - In Event Manager, select only the intended
CountingEventvalues and use Forward Event To Consumer with the Counter Thing as target. - Open
readCountersand verify the key, value, and pending-write state before using the total operationally.
Calling BoxEngine.fireEvent only places the event on the Agent event bus. The Event Manager rule and forwarding action are what deliver it to Counter Thing. Counter Thing accepts a nonblank key and nonzero signed value; it adds positive values, subtracts negative values, and does not preserve source-event history.
BoxEngine is trusted Agent-side code. Review the archive, authenticate and authorise browser requests independently, and expose only the smallest event and integration surface required.Verify the generated WAR before release
- The Agent build completes only when every JSP compiles; source
.jspand.jspxfiles are absent from the final WAR. - The packaged
WEB-INF/web.xmlcontains the generated JSP mappings plus the listener and filter from the integration fragment. - A normal
mvn clean packagebuild still succeeds, keeps the source JSP files, and contains neither the Agent listener nor the event filter. - After Agent deployment, visit one permitted route and verify the aggregated
GET (status) pathkey through Counter Thing after the flush interval. - Stop the web application cleanly and confirm its final buffered values are flushed; simulate a temporary event-engine failure and confirm values remain buffered until the next successful flush.
For a new release, replace the source WAR atomically and use Redeploy. Do not rely on Reload to apply application-variable changes, and retain the previous approved archive as the rollback artifact.
Need a reviewed Agent-aware WAR pattern?
Share the deployment target, JSP framework, expected event volume, and required Counter Thing metric. We can help validate the build and integration contract.