Skip to content

[improve][broker] Bridge JUL to Log4j2 to unify third-party library logging under the project's Log4j2 configuration - #26330

Open
geniusjoe wants to merge 4 commits into
apache:masterfrom
geniusjoe:dev/jul-log4j-bridge
Open

[improve][broker] Bridge JUL to Log4j2 to unify third-party library logging under the project's Log4j2 configuration#26330
geniusjoe wants to merge 4 commits into
apache:masterfrom
geniusjoe:dev/jul-log4j-bridge

Conversation

@geniusjoe

@geniusjoe geniusjoe commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #26229

Motivation

Third-party libraries used by Pulsar (Jersey/Jetty, gRPC, Guava, etc.) log via java.util.logging (JUL). Without a JUL-to-Log4j2 bridge, these logs are written directly to stdout/stderr and never appear in pulsar.log. This makes it very difficult to diagnose issues in production — for example, when Jetty throws "Response Header Fields Too Large" due to oversized message properties in HTTP response headers, the error is only visible on stdout and lost from the structured log file.

Modifications

  1. Added log4j-jul dependency (org.apache.logging.log4j:log4j-jul) to the version catalog (gradle/libs.versions.toml) and to the server/shell distribution builds.

  2. Configured JUL bridge in startup scripts — Added -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager to bin/pulsar, bin/pulsar-admin-common.sh, and bin/pulsar-perf so that all JUL logs are routed to Log4j2 at runtime.

  3. Configured JUL bridge for tests — Added the same JVM argument and testRuntimeOnly dependency in pulsar.java-conventions.gradle.kts so that all module tests also use the JUL bridge.

  4. Updated LICENSE files — Added the Apache-2.0 license entry for log4j-jul in both server and shell distribution LICENSE files.

  5. Added a test (PersistentTopicsTest.testGetMessageByIdLargePropertiesExceed16KB) that verifies:

    • The JUL bridge is active (LogManager class is org.apache.logging.log4j.jul.LogManager)
    • When message properties exceed Jetty's maxResponseHeaderSize (16384 bytes), the admin API returns HTTP 500

Verifying this change

This change added tests and can be verified as follows:

  • Added testGetMessageByIdLargePropertiesExceed16KB in PersistentTopicsTest that validates JUL bridge activation and the expected HTTP 500 behavior when response headers overflow.
  • Ran tests across multiple modules (pulsar-common, pulsar-broker-common, pulsar-broker, pulsar-metadata, pulsar-client-original, pulsar-proxy, managed-ledger, pulsar-transaction-coordinator) to confirm the global JVM argument change does not break existing tests.

Current error format:

2026-08-14T10:37:02,861 - ERROR - [PulsarTestContext-executor-OrderedExecutor-0-0:ServerRuntime$Responder] - Error while closing the output stream in order to commit response.
org.eclipse.jetty.http.HttpException$RuntimeException: 500: Response Header Fields Too Large
	at org.eclipse.jetty.server.internal.HttpConnection$SendCallback.process(HttpConnection.java:894)
	at org.eclipse.jetty.util.IteratingCallback.processing(IteratingCallback.java:374)
	at org.eclipse.jetty.util.IteratingCallback.iterate(IteratingCallback.java:351)
	at org.eclipse.jetty.server.internal.HttpConnection$HttpStreamOverHTTP1.send(HttpConnection.java:1549)
	at org.eclipse.jetty.server.HttpStream$Wrapper.send(HttpStream.java:202)
	at org.eclipse.jetty.server.HttpStream$Wrapper.send(HttpStream.java:202)
	at org.eclipse.jetty.session.AbstractSessionManager$SessionStreamWrapper.send(AbstractSessionManager.java:1552)
	at org.eclipse.jetty.server.internal.HttpChannelState$ChannelResponse.write(HttpChannelState.java:1430)
	at org.eclipse.jetty.server.Response$Wrapper.write(Response.java:841)
	at org.eclipse.jetty.server.handler.EventsHandler$EventsResponse.write(EventsHandler.java:376)
	at org.eclipse.jetty.server.Response$Wrapper.write(Response.java:841)
	at org.eclipse.jetty.compression.server.internal.CompressionResponse.write(CompressionResponse.java:175)
	at org.eclipse.jetty.server.Response$Wrapper.write(Response.java:841)
	at org.eclipse.jetty.server.handler.ContextResponse.write(ContextResponse.java:56)
	at org.eclipse.jetty.ee10.servlet.ServletContextResponse.write(ServletContextResponse.java:224)
	at org.eclipse.jetty.ee10.servlet.HttpOutput.channelWrite(HttpOutput.java:238)
	at org.eclipse.jetty.ee10.servlet.HttpOutput.channelWrite(HttpOutput.java:231)
	at org.eclipse.jetty.ee10.servlet.HttpOutput.write(HttpOutput.java:924)
	at org.glassfish.jersey.servlet.internal.ResponseWriter$NonCloseableOutputStreamWrapper.write(ResponseWriter.java:298)
	at java.base/java.io.ByteArrayOutputStream.writeTo(ByteArrayOutputStream.java:170)
	at org.glassfish.jersey.message.internal.CommittingOutputStream.flushBuffer(CommittingOutputStream.java:286)
	at org.glassfish.jersey.message.internal.CommittingOutputStream.commit(CommittingOutputStream.java:235)
	at org.glassfish.jersey.message.internal.CommittingOutputStream.close(CommittingOutputStream.java:250)
	at org.glassfish.jersey.message.internal.OutboundMessageContext.close(OutboundMessageContext.java:568)
	at org.glassfish.jersey.server.ContainerResponse.close(ContainerResponse.java:406)
	at org.glassfish.jersey.server.ServerRuntime$Responder.writeResponse(ServerRuntime.java:763)
	at org.glassfish.jersey.server.ServerRuntime$Responder.processResponse(ServerRuntime.java:398)
	at org.glassfish.jersey.server.ServerRuntime$Responder.process(ServerRuntime.java:388)
	at org.glassfish.jersey.server.ServerRuntime$AsyncResponder$3.run(ServerRuntime.java:926)
	at org.glassfish.jersey.internal.Errors$1.call(Errors.java:248)
	at org.glassfish.jersey.internal.Errors$1.call(Errors.java:244)
	at org.glassfish.jersey.internal.Errors.process(Errors.java:292)
	at org.glassfish.jersey.internal.Errors.process(Errors.java:274)
	at org.glassfish.jersey.internal.Errors.process(Errors.java:244)
	at org.glassfish.jersey.process.internal.RequestScope.runInScope(RequestScope.java:266)
	at org.glassfish.jersey.server.ServerRuntime$AsyncResponder.resume(ServerRuntime.java:963)
	at org.glassfish.jersey.server.ServerRuntime$AsyncResponder.resume(ServerRuntime.java:914)

Does this pull request potentially affect one of the following parts:

  • Dependencies (add or upgrade a dependency)
  • The public API
  • The schema
  • The default values of configurations
  • The threading model
  • The binary protocol
  • The REST endpoints
  • The admin CLI options
  • The metrics
  • Anything that affects deployment

Local Workflow

geniusjoe#2

…ogging under the project's Log4j2 configuration
@geniusjoe
geniusjoe requested a review from lhotari August 14, 2026 06:50
@geniusjoe

Copy link
Copy Markdown
Contributor Author

@lhotari
Hi Lari, this PR implements the JUL-to-Log4j2 bridge as suggested in #26229 (comment) — added log4j-jul dependency and -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager to the startup scripts. Would appreciate your review, thanks!

@lhotari

lhotari commented Aug 14, 2026

Copy link
Copy Markdown
Member

Please merge origin/master and resolve merge conflicts since log4j has been upgraded to 2.26.1 version

# Conflicts:
#	distribution/server/src/assemble/LICENSE.bin.txt
#	distribution/shell/src/assemble/LICENSE.bin.txt
@lhotari

lhotari commented Aug 14, 2026

Copy link
Copy Markdown
Member

checkstyle fails

@lhotari lhotari left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking this up, @geniusjoe — bridging JUL is the right call and the wiring you did is sound.

I checked the parts that usually go wrong with this change and they hold up: every path you added the flag to does have log4j-jul on its classpath, in the distributions (lib/*) and in a dev checkout (bin/pulsar and bin/pulsar-perf fall back to distribution/server/build/classpath.txt, bin/pulsar-admin-common.sh to the shell one, and exportClasspath writes both from distLib). The flag and the testRuntimeOnly dependency both come from pulsar.java-conventions, so test JVMs can't get one without the other. I also ran log4j-jul 2.26.1 standalone to confirm the level mapping the new test asserts (SEVEREERROR, WARNINGWARN, INFOINFO) and that message text passes through unmangled. Catalog entry and both LICENSE.bin.txt files are correct.

Requesting changes for a few completeness gaps, plus one semantics change that I think needs to be an explicit decision rather than something we inherit by default.


1. Function instance JVMs don't get the flag

RuntimeUtils.getCmd builds a fresh command — args.add("java") / args.add("-cp") at RuntimeUtils.java:334-335 — and adds the logging properties at :361-372, but never java.util.logging.manager. Child JVMs don't inherit the parent's -D flags, so process- and Kubernetes-runtime functions aren't bridged. gRPC is the functions control-plane transport and logs exclusively via JUL, so this is a meaningful gap rather than a corner case.

One subtlety for whoever fixes it: the child -cp is only the java-instance.jar fat jar, and pulsar-functions/runtime-all/build.gradle.kts:36-38 bundles log4j-slf4j2-impl, log4j-api and log4j-core but not log4j-jul. Adding the flag alone might still work by accident — the JDK's LogManager static init retries the class on the thread context classloader, and JavaInstanceMain builds a classloader from pulsar.functions.instance.classpath (lib/*, which does contain log4j-jul) that JavaInstanceStarter:177 installs as the TCCL. But that only holds if nothing touches JUL before that line; otherwise you get Could not load Logmanager plus a ClassNotFoundException dump and a silent fallback to the default manager. Please add log4j-jul to runtime-all so it's deterministic instead of ordering-dependent.

If you'd rather keep this PR focused, that's fine — but then please say in the description that function instances are out of scope, so we don't assume they're covered.

2. Several shipped launchers were left out

All of these ship in distributions whose lib/ now contains log4j-jul, so only activation is missing — one line each:

  • bin/bookkeeper — execs JVMs at :259, :261, :265, :267, :270, :274 (bookie, autorecovery, LocalBookKeeper, FileSystemUpgrade, BookieShell, generic command). Right now bin/pulsar bookie and bin/bookkeeper bookie log differently despite starting the same org.apache.bookkeeper.server.Main.
  • bin/function-localrunner — execs at :201.
  • The Windows launchers — pulsar-admin.cmd, pulsar-client.cmd, pulsar-shell.cmd and pulsar-perf.cmd all call bin/pulsar-admin-common.cmd, which wasn't touched (rg logging.manager bin/*.cmd finds nothing). The server distribution ships all of bin/ (distribution/server/build.gradle.kts:230) and the shell distribution ships pulsar-admin-common.cmd and pulsar-shell.cmd explicitly (distribution/shell/build.gradle.kts:123,127), so the same CLI at the same version behaves differently on Windows than on Linux/macOS.

3. ApiLoggerAdapter is the default, and this silently breaks java.util.logging.config.file

This is the item I'd most like a deliberate decision on.

In log4j-jul 2.26.1 the LogManager constructor reads the log4j.jul.LoggerAdapter property and, when unset, falls back unconditionally to new ApiLoggerAdapter(). There's no log4j-core auto-detection any more: 2.23.1 did LoaderUtil.loadClass(CORE_LOGGER_CLASS_NAME) and picked CoreLoggerAdapter when it succeeded, and 2.24.0 replaced that with a plain // Use API by default / new ApiLoggerAdapter() (logging-log4j2#2353, cited in the source). So even though we ship log4j-core everywhere the bridge is enabled, we get the API adapter, whose isLoggable(...) consults only the Log4j2 level and whose log(LogRecord) never invokes JUL Handlers. Both mutators warn via StatusLogger, which conf/log4j2.yaml:22 suppresses at status: ERROR, so it all fails silently.

I measured this on JDK 21 with log4j 2.26.1, using a logging.properties containing io.grpc.level=SEVERE:

stock JUL (today) after this PR + CoreLoggerAdapter
java.util.logging.config.file level honoured — INFO suppressed ignored still ignored
programmatic Logger.setLevel() works warns, no effect works
addHandler(...) works inert inert

So the operator-visible regression is real: anyone currently damping a chatty third-party logger through -Djava.util.logging.config.file loses that suppression on upgrade, and those records then flow at the Log4j2 root level (info by default). Note that switching to -Dlog4j.jul.LoggerAdapter=org.apache.logging.log4j.jul.CoreLoggerAdapter does not fix it — it only restores the programmatic API. The reason is structural: log4j-jul's LogManager.addLogger always returns false, so JUL never registers a logger to apply the file's .level entries to, under either adapter.

That means those levels have to move into conf/log4j2.yaml, and it needs a release note. Nothing in this PR compensates for it today.

There's a smaller build-side echo of the same root cause: the convention plugin applies the flag to every test JVM (pulsar.java-conventions.gradle.kts:262), and Gradle's test worker installs its own JUL capture — JavaUtilLoggingSystem.startCapture() does LogManager.reset(), SLF4JBridgeHandler.install() and a root setLevel(...) at the JUL level mapped from Gradle's log level (WARNING at the default LIFECYCLE) — all three of which become no-ops under ApiLogger. For most modules the net effect is nil, since they pick up buildtools/src/main/resources/log4j2.xml at root warn, the same threshold; but pulsar-broker, pulsar-proxy and pulsar-client-admin ship their own test config at root INFO, so those test JVMs will surface JUL records that were previously filtered.

4. The comment overstates what happens by default

The comment added to all three scripts says the records are "routed to pulsar.log instead of stdout". That isn't accurate in either direction:

  • Console is the default, not a file. bin/pulsar:331 defaults PULSAR_LOG_APPENDER to RoutingAppender and :334 defaults the route to Console, which conf/log4j2.yaml:61 targets at SYSTEM_OUT; bin/pulsar-admin-common.sh:152,158 has the same defaults, and bin/pulsar-perf:147 uses Console outright. Only bin/pulsar-daemon:81 switches to RollingFile. So under a plain bin/pulsar broker, and in the container images, bridged records still go to stdout.
  • And it's never literally pulsar.log. bin/pulsar:389-420 defaults PULSAR_LOG_FILE per command (pulsar-broker.log, bookkeeper.log, pulsar-proxy.log, …), and bin/pulsar-daemon:124 uses pulsar-$command-$HOSTNAME.log. pulsar.log is only the fallback in conf/log4j2.yaml:31-32, which applies to the CLI tools — and those default to Console anyway.

None of this undermines the change: the records now go through our Log4j2 config with our layout, levels and appender routing. Worth noting a real side benefit too — the JDK's default JUL ConsoleHandler writes two-line SimpleFormatter text to stderr, so with PULSAR_LOG_FORMAT=json those records used to break JSON parsing wherever the two descriptors get merged (the container images, and bin/pulsar-daemon:160's nohup … > "$out" 2>&1); after this change they're well-formed JSON on stdout. Please just reword the comment (and the motivation section) to say the records are routed through Log4j2 rather than specifically to pulsar.log.

5. No way to override the bridge

In all three scripts the new flag is appended after the user's extra options — bin/pulsar:326,328 (BOOKIE_EXTRA_OPTS / PULSAR_EXTRA_OPTS) versus :363, bin/pulsar-admin-common.sh:148 versus :172, bin/pulsar-perf:144 versus :171. Duplicate -D properties are last-wins, so an operator who sets PULSAR_EXTRA_OPTS=-Djava.util.logging.manager=... (an APM agent supplying its own LogManager, or simply wanting to revert) is silently overridden, even though bin/pulsar:88 documents PULSAR_EXTRA_OPTS as "Extra options to be passed to the jvm".

Every -Dpulsar.log.* flag in that block has the same ordering, but each is backed by a PULSAR_LOG_* env var; this one changes JVM-global JUL semantics with no escape hatch at all.

The good news is that these scripts already have the right idiom for exactly this case — -Djava.net.preferIPv4Stack=true is prepended early so that the later *_EXTRA_OPTS append wins on last-wins:

  • bin/pulsar:269OPTS="-Djava.net.preferIPv4Stack=true $OPTS -Djute.maxbuffer=10485760", with PULSAR_EXTRA_OPTS appended at :328
  • bin/pulsar-admin-common.sh:99OPTS="-Djava.net.preferIPv4Stack=true $OPTS", with PULSAR_EXTRA_OPTS at :148
  • bin/pulsar-perf:96OPTS="-Djava.net.preferIPv4Stack=true $OPTS ...", with PULSAR_EXTRA_OPTS at :144

That's the closest analogue to this flag: another JVM-global -Djava.* platform default that we want to set but still let operators override. So the fix is the same one line in each of the three scripts:

OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager $OPTS"

placed anywhere before the *_EXTRA_OPTS append — right next to the preferIPv4Stack line is the natural home. (bin/bookkeeper:198 sets preferIPv4Stack the same way, which is convenient for item 2.) No new environment variable needed; a PULSAR_JUL_BRIDGE_ENABLED switch would be a reasonable extra on top, but it isn't a substitute, since it wouldn't make PULSAR_EXTRA_OPTS itself win.

6. The description is stale

"Verifying this change" still describes PersistentTopicsTest.testGetMessageByIdLargePropertiesExceed16KB, but e83a7135d24 removed that method and replaced it with JulBridgeTest; no oversized-header test remains. Please update that section so the PR describes what it actually adds. Given items 3 and 4, "Anything that affects deployment" is worth ticking too.


One last practical note, not a change request: the bin/ scripts only regenerate distribution/*/build/classpath.txt when the file is missing, and nothing else depends on exportClasspath. So anyone with a checkout that already ran a bin/ script before this merges will have a cached classpath.txt without log4j-jul and will get the Could not load Logmanager stack trace until they delete it. Might be worth a line in the PR description so people aren't confused by it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Admin API getMessageById returns 500 Internal Server Error when message properties exceed response header default 8KB limit

2 participants