Skip to content

MDC context in alert bodies

By default an alert tells you WHAT broke — message, logger, cause chain, root-cause frames — but never for WHOM or in WHICH request. Acting on one therefore means going back to your centralized logs to find the request it came from, which is exactly the trip the alert exists to spare you.

include-mdc-keys closes that gap: name the MDC keys you care about, and their values are rendered into the alert body, one key: value line each, immediately after the Logger: line.

# Spring application.yml
ntfy:
  url: https://ntfy.example.com
  topic: my-app-alerts
  include-mdc-keys: 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
  at com.acme.billing.Ledger.assertOpen(Ledger.java:88)
Time: 2026-08-15T09:14:22.417Z

Lines follow the order you configured, not the MDC map's own iteration order, so the block reads the same in every alert. A key that is absent from the MDC — or whose value is null or blank — produces no line at all rather than an empty one, so the block stays dense. Unset (the default) means no context block and bodies byte-identical to previous versions.

Where the setting lives

The canonical key is include-mdc-keys, and its value is a comma-separated list. See configuration.md for the full key reference; the spellings are:

Surface Spelling
System property -Dntfy.include-mdc-keys=correlation-id,tenant
Environment NTFY_INCLUDE_MDC_KEYS=correlation-id,tenant
ntfy.properties ntfy.include-mdc-keys=correlation-id,tenant
Logback XML <includeMdcKeys>correlation-id,tenant</includeMdcKeys>
Log4j2 XML <Ntfy ... includeMdcKeys="correlation-id,tenant"/>
Spring Boot ntfy.include-mdc-keys: correlation-id, tenant
Micronaut ntfy.include-mdc-keys: correlation-id, tenant
Quarkus quarkus.ntfy.include-mdc-keys=correlation-id,tenant
Programmatic NtfyConfig.builder().includeMdcKeys(List.of("correlation-id", "tenant"))

Framework support

Every adapter reads the event's own captured context, never a thread-local. That distinction is what keeps the values correct when the thread that publishes the alert is not the thread that logged it — which is the normal case behind an async appender, and behind this engine's own asynchronous delivery.

Framework Source Notes
Logback (and Spring Boot, Micronaut) ILoggingEvent.getMDCPropertyMap() Captured at event construction, so correct behind an AsyncAppender.
Log4j2 (standalone, and Log4j2-backed Spring Boot) LogEvent.getContextData() Log4j2's ThreadContext is its MDC. Correct behind an <Async> appender.
JBoss LogManager (and Quarkus) org.jboss.logmanager.ExtLogRecord, read per allow-listed key Reached reflectively so ntfy-jul keeps its zero-dependency promise.
Plain java.util.logging — JUL has no MDC concept, so the block is always empty. Same situation as SLF4J markers, which the JUL path already does not carry (see jul.md).

One caveat for the JUL and Quarkus paths: this engine's own async=true mode is safe, because the body is built on the submitting thread and only the blocking HTTP send is offloaded. But if a JBoss AsyncHandler sits in front of the ntfy handler, MDC fidelity depends on that handler copying the record before hand-off.

Storm digests carry no MDC. A digest aggregates N events from N different requests, so per-event context there would be meaningless at best and misleading at worst. Only individual error alerts get a context block.

Why there is no wildcard

There is deliberately no *, no prefix match, and no "include everything" mode. That is the security design of the feature, not a gap in it. An MDC is a free-form, application-populated map: whatever a framework, a filter, or a library decided to put in it is in there, and on a typical web application that includes session identifiers, authenticated user names, tokens and other things nobody chose to publish. A wildcard would push all of it off-host to an ntfy topic — permanently, retroactively, and with every future key a dependency starts setting silently included. Requiring the operator to name each key inverts that: no MDC value is ever published unless someone wrote its key in the configuration, and the set of things that can leave the process is fixed at configuration time rather than by whatever the MDC happens to hold at the moment an error fires.

Note the asymmetry with excluded-loggers, which is a deny-list for exactly the mirrored reason. Both choices follow from the same rule — fail towards the safe outcome — and the safe outcome differs: for alerting, failing safe means an unknown error still pages you; for content, failing safe means an unknown value stays home.

Guards applied to every value

The rendering is bounded on four axes, all enforced by MdcProjector and none of them configurable:

  • At most 16 keys are rendered, however many the allow-list names.
  • Every value is scrubbed of control characters (\n, \r, \t, NUL, ANSI escapes), Unicode line/paragraph separators, and bidi/format characters — each is replaced by a space.
  • Every value is truncated to 256 characters, the ASCII ... suffix counting toward that budget.
  • The whole context block is capped at 1024 characters in aggregate, whatever the per-value lengths add up to.

Keys are scrubbed and truncated on the same terms as values, because a key can itself come from an environment variable rather than from source. The scrubbing is what makes the block structurally trustworthy: an MDC value of acme\nCaused by: java.lang.Forged is rendered on one line with the newline replaced by a space, so it cannot forge a body line and make an alert claim an exception that never happened.

The four limits also serve the 4096-byte body budget: the 1024-character aggregate is what keeps a context block to roughly a quarter of the payload. Read alert-behavior.md for the one surprising consequence — under truncation pressure a large context block costs you stack frames, deliberately.

Not to be confused with excluded-loggers

The two settings look similar and point in opposite directions, so it is worth stating the shape once: adding a name to excluded-loggers makes you see less; adding a name to include-mdc-keys makes each alert you already see say more.

excluded-loggers include-mdc-keys
Direction deny-list — everything alerts, named prefixes do not allow-list — nothing is attached, named keys are
Decides which events produce a notification which context is rendered into a notification
Default (unset) every event alerts no MDC line is rendered at all
Matching logger-name prefix, at package boundaries exact MDC key, no prefixes and no wildcard

Nothing on this page can suppress an alert, and nothing on it can create one. The gates that decide which events alert at all live in filtering.md.

Building AlertEvents yourself

If you drive AlertEngine.submit directly rather than through an adapter, you own the allow-list step: the engine renders whatever the event's mdcValues holds and never intersects it with include-mdc-keys. The bounding above still applies — AlertEvent's constructor re-applies the scrubbing, truncation and caps to anything you hand it — but selection is yours. Project explicitly, the way the adapters do:

Map<String, String> mdc =
    MdcProjector.project(config.getIncludeMdcKeys(), MDC::get);   // or any key -> value lookup
engine.submit(new AlertEvent(logger, message, millis, causes, frames, markers, mdc));

See core.md for the rest of the programmatic surface.

One limitation

Because every string surface is comma-separated, an MDC key whose name contains a comma is unreachable from the sysprop, environment, ntfy.properties, Logback XML, Log4j2 XML, Spring, Micronaut and Quarkus spellings. Use the programmatic list form for it: NtfyConfig.builder().includeMdcKeys(List.of("odd,key")).

Confirming what is in effect

When the allow-list is non-empty the engine announces it once at startup, alongside its ACTIVE and excluded-loggers lines, so you can confirm which keys are actually being published. It lists key names only and never a value. See troubleshooting.md for the wording.

See also

  • configuration.md — the full key reference and per-framework examples.
  • filtering.md — the gates that decide which events alert in the first place.
  • alert-behavior.md — where the context block sits in the body, and what truncation sacrifices first.
  • core.md — the programmatic builder and MdcProjector.