Skip to content

Plain Log4j2 — ntfy-log4j2

Maven Central Javadoc

A Log4j2 appender plugin (<Ntfy>) plus a one-line programmatic installer. It maps Log4j2 LogEvents onto the same framework-neutral AlertEngine the Logback adapter uses, so rate limiting, storm digests, priorities/tags, truncation, sync-vs-async delivery, excluded-loggers, auth modes and locale behave identically to every other adapter in the family.

Use this when you log through Log4j2 (with or without SLF4J in front of it) and want ERROR logs (and, with warnTopic, WARN logs too — see level-routing.md) to alert — declaratively in log4j2.xml, or with one line in main.

Runnable example: examples/log4j2 — a plain Log4j2 app wired through log4j2.xml, whose CI-run integration tests cover the base alert path, sync-vs-async delivery, and the clean-shutdown digest flush.

Install

<dependency>
  <groupId>io.github.pimak</groupId>
  <artifactId>ntfy-log4j2</artifactId>
  <version>2.1.0</version>
</dependency>

The appender pulls ntfy-core transitively — you only declare this one artifact. Log4j2 itself is a provided-scope dependency (compiled against 2.26.1), so the version your application already declares is the one that runs; the module adds no Log4j2 of its own. Java 21+, like the rest of the family.

Base configuration

There are two entry points. Unlike Logback, there is no fully zero-code auto-install — see why below.

Declarative: log4j2.xml

The module registers an Ntfy appender plugin, so you declare it like any other appender and reference it from a logger:

<Configuration>
  <Appenders>
    <Ntfy name="ntfy" url="${env:NTFY_URL}" topic="${env:NTFY_TOPIC}"/>
  </Appenders>
  <Loggers>
    <Root level="info">
      <AppenderRef ref="ntfy"/>
    </Root>
  </Loggers>
</Configuration>

url and topic are the only required attributes; every other one is optional and, when unset, keeps the engine default. All attributes are string-valued (durations use the same 5s/3m/PT5S/bare-millis syntax as everywhere else):

url, topic, token, username, password, title, appName, maxStackFrames, connectTimeout, requestTimeout, maxAlertsPerWindow, suppressionWindow, errorPriority, digestPriority, errorTags, digestTags, clickUrl, actions, excludedLoggers, locale, enabled, async, asyncQueueCapacity, requireHttpsForCredentials, includeMdcKeys, startupPing, startupPingWarn, startupPingFailFast, startupPingNotifyFailures.

This is the same roster as the Logback appender's XML setters — the camelCase spelling of each canonical key — so the per-key reference in configuration.md applies unchanged. Log4j2's own ${env:…} / ${sys:…} lookups work in any attribute value, as in the snippet above.

Built-in async delivery is an attribute rather than a wrapper appender:

<Ntfy name="ntfy" url="${env:NTFY_URL}" topic="${env:NTFY_TOPIC}"
      async="true" asyncQueueCapacity="1024"/>

With async="true", each error alert is handed to the engine's bounded queue and published by a daemon worker, so a slow or unreachable ntfy server never blocks the logging thread. Overflow drops fold into the storm digest rather than being lost. See alert-behavior.md for the full semantics. Log4j2's own <Async> appender remains available if you prefer its offloading.

Programmatic one-liner

public static void main(String[] args) {
  NtfyLog4j2Installer.install(); // attaches to the root logger when the environment configures ntfy
  // ...
}

install() reads the ambient ConfigLoader chain shared with core, JUL and Logback — system property ntfy.<key> > env var NTFY_<KEY> > classpath ntfy.properties:

export NTFY_URL=https://ntfy.example.com
export NTFY_TOPIC=my-app-alerts
export NTFY_TOKEN=tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

It returns Optional<NtfyLog4j2Appender> — empty when ntfy is not configured, so the call is always safe — and it never throws. Use install(LoggerContext) to target a specific context, and uninstall(LoggerContext, appender) on shutdown if you manage the lifecycle yourself. The installed appender survives a Log4j2 reconfiguration: it re-attaches itself to the new configuration rather than disappearing on the next log4j2.xml reload.

Classpath endpoints require an explicit opt-in. If the endpoint url comes only from a classpath ntfy.properties (no ntfy.url sysprop or NTFY_URL env var), install() is refused with a warning: any jar on the classpath can ship such a file, so honoring it blindly would let a compromised dependency redirect your error logs. If the file is yours, opt in with -Dntfy.allow-classpath-endpoint=true or NTFY_ALLOW_CLASSPATH_ENDPOINT=true (the flag itself is intentionally ignored inside ntfy.properties), or set the URL via env/sysprop instead. This is the same guard the Logback and JUL auto-installs apply.

Why there is no zero-code auto-install

The Logback adapter attaches itself with no configuration at all through Logback's ch.qos.logback.classic.spi.Configurator SPI. Log4j2 has no equivalent for augmenting an existing configuration: the only comparable hook is a ConfigurationFactory, and exactly one factory can win for a given context — a delegating one shipped by a library would silently contend with the application's own (or another library's), which is precisely the kind of surprise an alerting tool must not introduce. So on Log4j2 you name the appender in log4j2.xml or call NtfyLog4j2Installer.install(); both are one line, and the installer path still takes its whole configuration from the environment.

MDC context in alert bodies

log4j2's ThreadContext is its MDC. Name the keys you want attached to alerts with includeMdcKeys, and each one is rendered as its own key: value line just after Logger::

<Ntfy name="ntfy"
      url="https://ntfy.example.com"
      topic="my-app-alerts"
      includeMdcKeys="correlation-id,tenant"/>
Message: order settlement failed
Logger: com.acme.billing.SettlementService
correlation-id: 7f3a91c2-…
tenant: acme-eu
Caused by: java.lang.IllegalStateException: ledger closed

It is an allow-list with no wildcard form: a key you have not named is never published, so a ThreadContext carrying a session token or an e-mail address cannot leak into a topic. Unset — the default — bodies are byte-identical to before.

The values are read from the event's captured context data (LogEvent.getContextData()), not from the ThreadContext thread-local. That distinction is what keeps the projection correct behind an <Async> appender or this appender's own async delivery, where the publishing thread is not the thread that logged. See mdc-context.md for the guards (key cap, value truncation, control-character scrubbing) and alert-behavior.md for how the context block interacts with the 4096-byte body limit.

Startup self-test

Alerting only speaks when something breaks, so a revoked token or a wrong topic stays invisible until the first real error alert fails to deliver. The opt-in self-test makes one round-trip at boot and reports a clear diagnostic instead:

<Ntfy name="ntfy"
      url="https://ntfy.example.com"
      topic="my-app-alerts"
      startupPing="probe"/>

probe is read-only and publishes nothing; publish sends one low-priority test notification through the production path and is the only mode that proves alerts are genuinely deliverable (ntfy grants read and write separately). It runs in the background and never delays startup. Add startupPingFailFast="true" to turn a failure into a failed startup in CI or staging. Full details, including the failure diagnostics, in configuration.md.

startup-ping covers the primary topic only. The optional WARN route has its own independent flag — ntfy grants permissions per topic, so a healthy ERROR route proves nothing about the WARN one:

      warnTopic="my-app-warnings"
      startupPingWarn="probe"

When one route fails and another passed, the diagnosis is published as a notification on the passing route (startup-ping-notify-failures, on by default), so a broken route reaches you rather than only a status log.

Behavior notes specific to this adapter

  • Level gate: by default the appender submits only events at ERROR or above (Level.isMoreSpecificThan(Level.ERROR)), matching the Logback appender's floor and the JUL handler's SEVERE. With warnTopic set, WARN also alerts, on that topic. See level-routing.md and filtering.md.
  • Two level floors: the installer, the reattach listener and the Spring starter attach the appender at ERROR — or WARN when a warn route is active — so log4j2 never dispatches sub-threshold events to it in the first place; the in-appender gate above is the second floor. If you declare <Ntfy> yourself, the level on the referencing <Logger>/<Root> applies before either of them.
  • NO_ALERT marker: supported, including markers that reach it through Marker.getParents(). See filtering.md.
  • Diagnostics go to Log4j2's StatusLogger, never back through the logging pipeline the appender publishes from. See troubleshooting.md.
  • GraalVM native image: reachability metadata for the plugin is generated at build time by log4j-core's own annotation processor and ships inside the jar — nothing to register by hand.

The example, in full

The runnable examples/log4j2 app is wired by exactly this file — CI compiles it and its integration tests run against it, so nothing shown here can drift from what is actually tested.

examples/log4j2/src/main/resources/log4j2.xml
<?xml version="1.0" encoding="UTF-8"?>
<!--
    Example Log4j2 configuration wiring the ntfy alert appender explicitly. The <Ntfy> element only
    resolves because ntfy-log4j2 is on the classpath: the jar ships the Log4j2Plugins.dat descriptor
    log4j2 uses to map the element name onto NtfyLog4j2Appender.

    Every ntfy setting is resolved through Log4j2 property substitution with a default, so the same
    file works out of the box (NTFY_URL / NTFY_TOPIC environment variables) AND lets this module's
    integration tests point the appender at a loopback ntfy stand-in — the tests run exactly this
    file.

    Why the doubled ${sys:… :-${env:… :-default}} form: unlike Logback's ${NAME:-default}, a Log4j2
    lookup is source-specific — env: reads only the environment, sys: only system properties.
    Nesting the environment lookup inside the system-property lookup's default value yields the
    familiar precedence chain "-D wins over the environment wins over the built-in default" in a
    single expression, and it is the system-property rung the integration tests drive.

    Alternative: with ntfy-log4j2 on the classpath you need no <Ntfy> element at all —
    NtfyLog4j2Installer.install() wires the appender from ambient config in one line and survives
    log4j2 reconfiguration (see docs/log4j2.md).

    shutdownHook="enable" is log4j2's default, spelled out because it is what makes the clean
    shutdown work: on JVM exit log4j2 stops this LoggerContext, and the ntfy appender's stop()
    drains any queued async alerts and flushes the pending storm digest while the HTTP client is
    still live. status="warn" routes the appender's own diagnostics (a malformed attribute, or the
    engine declining to activate) to log4j2's StatusLogger — never back through this pipeline.
-->
<Configuration name="ntfy-example" status="warn" shutdownHook="enable">

  <Appenders>

    <Console name="CONSOLE" target="SYSTEM_OUT">
      <PatternLayout pattern="%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n"/>
    </Console>

    <!--
        Attributes are camelCase and all string-valued; url and topic are the only required ones.
        An omitted attribute keeps ntfy-core's default rather than being clobbered by a 0/false.

        For an auth-protected topic add: token="tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"

        async: built-in async delivery (the default since 2.0) hands each error alert to a bounded
        queue drained by a daemon worker, so a slow or unreachable ntfy server never blocks the
        logging thread. Set false for pre-2.0 synchronous, inline delivery — the guarantee a
        short-lived process may prefer, since the publish completes before the logging call
        returns.

        Storm suppression: at most maxAlertsPerWindow individual alerts per suppressionWindow
        (milliseconds, or 30s/5m/… ); the overflow is reported as one aggregated digest — also
        flushed on a clean shutdown, so suppressed errors are never lost. These are the library
        defaults, spelled out so the example shows where to tune them.
    -->
    <Ntfy name="NTFY"
          url="${sys:ntfy.url:-${env:NTFY_URL:-https://ntfy.example.com}}"
          topic="${sys:ntfy.topic:-${env:NTFY_TOPIC:-my-app-alerts}}"
          async="${sys:ntfy.async:-${env:NTFY_ASYNC:-true}}"
          maxAlertsPerWindow="${sys:ntfy.maxAlertsPerWindow:-${env:NTFY_MAX_ALERTS_PER_WINDOW:-3}}"
          suppressionWindow="${sys:ntfy.suppressionWindow:-${env:NTFY_SUPPRESSION_WINDOW:-180000}}"/>

  </Appenders>

  <Loggers>
    <!--
        Root at INFO so the console appender sees the whole application narrative. The ntfy
        appender gates on ERROR internally, so referencing it from an INFO root publishes error
        alerts only — no INFO/WARN line ever reaches the topic.
    -->
    <Root level="info">
      <AppenderRef ref="CONSOLE"/>
      <AppenderRef ref="NTFY"/>
    </Root>
  </Loggers>

</Configuration>

Going further

The base config above covers the common case. Everything else is shared across all adapters and lives in the cross-cutting reference pages:

  • Configuration reference — every key and how it resolves (sysprop > env > ntfy.properties), the <Ntfy> attribute spellings, and duration syntax.
  • Authentication — token vs username/password and the token-wins rule.
  • Alert behavior — immediate alerts, storm suppression, and digests.
  • MDC context — the include-mdc-keys allow-list, its guards, and why there is no wildcard.
  • Level routing — the warn-topic opt-in: which levels alert and where.
  • Filtering — excluded-loggers, the NO_ALERT marker, and self-exclusion.
  • Troubleshooting — the diagnostics the engine emits via Log4j2's StatusLogger, and how to surface them.
  • Compatibility — tested Log4j2 / JDK versions.