Java Observability
Overview
Make a running system diagnosable. Three pillars: logs (what happened), metrics
(how much/how often), traces (where time went).
Logging
- Use the project's facade — prefer SLF4J; else the project standard (Log4j2). Never
introduce a new framework (including
java.util.logging). Never use System.out/System.err.
- Parameterized, not concatenated:
log.info("created order {}", id) — never
"created order " + id. Placeholders defer string building until the level is enabled.
- Levels deliberately: TRACE (rare low-level), DEBUG (diagnostics), INFO (major events),
WARN (recoverable/unexpected), ERROR (failures needing investigation).
- Preserve stack traces: pass the exception as the last arg (
log.error("charge failed {}", id, ex)),
never just ex.getMessage().
- Log once at the boundary where the error is best understood; not in tight loops/getters.
- Never log sensitive data: no passwords, tokens, card numbers, PII, or full request/response
payloads. Log identifiers and counts (IDs, status, sizes), not whole objects.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class PaymentProcessor {
private static final Logger LOGGER = LoggerFactory.getLogger(PaymentProcessor.class);
public Receipt process(final Payment payment) {
LOGGER.info("Processing payment customerId={} amount={}", payment.customerId(), payment.amount());
// never log cardNumber/cvv/tokens
...
}
}
Structured logging & correlation
- Prefer structured/key-value output (logstash/ECS encoder, or consistent
key={} pairs) so logs
are queryable, not just human-readable.
- Carry a correlation/trace id across a request via MDC (
MDC.put("traceId", id)); set it at
the entry boundary (filter/interceptor) and clear it in finally to avoid thread-pool leakage.
For keys you add deeper in the flow, MDC.remove(key) just those in finally — MDC.clear()
there would wipe the context the boundary owns. Put stable context (userId, operation) in MDC too,
not hand-concatenated into every message.
MDC.put("traceId", traceId);
try {
handle(request);
} finally {
MDC.clear(); // prevent context bleeding across pooled threads
}
Metrics & tracing
- Metrics: use the project's metrics facade — Micrometer (
MeterRegistry) in Spring — for
counters/timers/gauges; don't hand-roll. Name consistently (orders.placed), tag with low
cardinality (avoid user ids as tags). Time critical paths with a Timer.
- Tracing: prefer OpenTelemetry (or the project's tracer). Don't manually thread span/trace
ids through method signatures — propagate via context (and mirror the trace id into MDC so logs
correlate with spans). Instrument at boundaries (HTTP, messaging, DB), not every method.
- Emit telemetry as a side concern: it must not change control flow or behavior.
public final class PaymentProcessor {
private final Timer chargeTimer;
public PaymentProcessor(final MeterRegistry registry) {
this.chargeTimer = registry.timer("payments.charge"); // stable name, no per-user tags
}
public Receipt charge(final Payment payment) {
return chargeTimer.record(() -> gateway.charge(payment));
}
}
Time in finally. Manual timing (elapsed-time logs, Timer.Sample) must stop/log in a
finally block — placed after the call, an exception skips it, losing timing for exactly the
calls you care about. Timer.record(...) does this for you.
// ❌ skipped when charge() throws
final long start = System.nanoTime();
final Receipt receipt = gateway.charge(payment);
LOGGER.debug("charge durationMs={}", (System.nanoTime() - start) / 1_000_000);
// ✅ recorded on success and failure alike
final Timer.Sample sample = Timer.start(registry);
try {
return gateway.charge(payment);
} finally {
sample.stop(chargeTimer);
}
Retrofitting existing code
Asked to "improve the logging/observability" of existing code? Work per unit of work
(request/message/job), not per statement:
- Correlate first: MDC ids at the entry boundary, cleared in
finally (MDC.remove for keys
added mid-flow).
- Replace offenders:
System.out / printStackTrace() / concatenation → facade + key={} pairs.
- Narrate the unit of work with the full level palette:
- INFO — start and outcome: identifiers + counts, one line each, never per item
- DEBUG — per-item / per-step detail inside loops and branches
- WARN — recoverable anomalies: retries, fallbacks, skipped items, partial success
(
WARN "Fulfilled 3 of 5 lines" beats an INFO that hides the mismatch in two numbers)
- ERROR — the unit of work failed; exception as last arg, once, at the boundary
- Measure rates/latency instead of logging them: a
Timer on the critical path, Counters for
failure/skip events.
- Sweep before finishing: no PII/secrets/payloads crept in; telemetry changed no behavior.
Common mistakes
| Rationalization |
Reality |
""user: " + name" |
Use {} placeholders — concatenation allocates even when the level is off. |
"java.util.logging is built in" |
Use the project facade (SLF4J/Log4j2); don't add a framework. |
| "Log the whole request to debug" |
Never log secrets/PII/full payloads; log identifiers. |
"log.error(e.getMessage())" |
Drops the stack trace — pass the exception. |
| "I'll pass the traceId as a parameter everywhere" |
Use MDC / context propagation; clear MDC in finally. |
"A userId tag on the metric is handy" |
High-cardinality tags blow up metrics storage; tag low-cardinality only. |
| "Log the duration after the call returns" |
An exception skips it — stop/log timing in finally, or use Timer.record. |
Red flags — stop
+ inside a log.* call; System.out/System.err; a new logging framework
- A secret/token/card number/PII or whole payload in a log call;
log.error(e.getMessage())
MDC.put without a matching MDC.clear() in finally
- Hand-rolled counters/timers instead of Micrometer; high-cardinality metric tags
- A duration log or
Timer.Sample.stop that isn't in a finally block
- Telemetry that alters control flow
1---2name: java-observability3description: Use when adding, reviewing, or retrofitting logging, metrics, tracing, or diagnostics in Java — including "improve the logging/observability of this code" requests. Covers the logging facade with parameterized structured messages, the full level palette (DEBUG/INFO/WARN/ERROR) across a unit of work, correlation IDs / MDC, never logging secrets/PII, preserving stack traces, and Micrometer / OpenTelemetry. Catches string-concatenated logs, System.out, sensitive-data logging, and missing correlation/telemetry.4---56# Java Observability78## Overview910Make a running system diagnosable. Three pillars: **logs** (what happened), **metrics**11(how much/how often), **traces** (where time went).1213## Logging1415- **Use the project's facade** — prefer **SLF4J**; else the project standard (Log4j2). Never16 introduce a new framework (including `java.util.logging`). Never use `System.out`/`System.err`.17- **Parameterized, not concatenated:** `log.info("created order {}", id)` — never18 `"created order " + id`. Placeholders defer string building until the level is enabled.19- **Levels deliberately:** TRACE (rare low-level), DEBUG (diagnostics), INFO (major events),20 WARN (recoverable/unexpected), ERROR (failures needing investigation).21- **Preserve stack traces:** pass the exception as the last arg (`log.error("charge failed {}", id, ex)`),22 never just `ex.getMessage()`.23- **Log once** at the boundary where the error is best understood; not in tight loops/getters.24- **Never log sensitive data:** no passwords, tokens, card numbers, PII, or full request/response25 payloads. Log **identifiers and counts** (IDs, status, sizes), not whole objects.2627```java28import org.slf4j.Logger;29import org.slf4j.LoggerFactory;3031public final class PaymentProcessor {32 private static final Logger LOGGER = LoggerFactory.getLogger(PaymentProcessor.class);3334 public Receipt process(final Payment payment) {35 LOGGER.info("Processing payment customerId={} amount={}", payment.customerId(), payment.amount());36 // never log cardNumber/cvv/tokens37 ...38 }39}40```4142## Structured logging & correlation4344- Prefer **structured/key-value** output (logstash/ECS encoder, or consistent `key={}` pairs) so logs45 are queryable, not just human-readable.46- Carry a **correlation/trace id** across a request via **MDC** (`MDC.put("traceId", id)`); set it at47 the entry boundary (filter/interceptor) and **clear it in `finally`** to avoid thread-pool leakage.48 For keys you add **deeper in the flow**, `MDC.remove(key)` just those in `finally` — `MDC.clear()`49 there would wipe the context the boundary owns. Put stable context (userId, operation) in MDC too,50 not hand-concatenated into every message.5152```java53MDC.put("traceId", traceId);54try {55 handle(request);56} finally {57 MDC.clear(); // prevent context bleeding across pooled threads58}59```6061## Metrics & tracing6263- **Metrics:** use the project's metrics facade — **Micrometer** (`MeterRegistry`) in Spring — for64 counters/timers/gauges; don't hand-roll. Name consistently (`orders.placed`), tag with low65 cardinality (avoid user ids as tags). Time critical paths with a `Timer`.66- **Tracing:** prefer **OpenTelemetry** (or the project's tracer). Don't manually thread span/trace67 ids through method signatures — propagate via context (and mirror the trace id into MDC so logs68 correlate with spans). Instrument at boundaries (HTTP, messaging, DB), not every method.69- Emit telemetry as a side concern: it must not change control flow or behavior.7071```java72public final class PaymentProcessor {73 private final Timer chargeTimer;7475 public PaymentProcessor(final MeterRegistry registry) {76 this.chargeTimer = registry.timer("payments.charge"); // stable name, no per-user tags77 }7879 public Receipt charge(final Payment payment) {80 return chargeTimer.record(() -> gateway.charge(payment));81 }82}83```8485**Time in `finally`.** Manual timing (elapsed-time logs, `Timer.Sample`) must stop/log in a86**`finally`** block — placed after the call, an exception skips it, losing timing for exactly the87calls you care about. `Timer.record(...)` does this for you.8889```java90// ❌ skipped when charge() throws91final long start = System.nanoTime();92final Receipt receipt = gateway.charge(payment);93LOGGER.debug("charge durationMs={}", (System.nanoTime() - start) / 1_000_000);9495// ✅ recorded on success and failure alike96final Timer.Sample sample = Timer.start(registry);97try {98 return gateway.charge(payment);99} finally {100 sample.stop(chargeTimer);101}102```103104## Retrofitting existing code105106Asked to "improve the logging/observability" of existing code? Work per **unit of work**107(request/message/job), not per statement:1081091. **Correlate first:** MDC ids at the entry boundary, cleared in `finally` (`MDC.remove` for keys110 added mid-flow).1112. **Replace offenders:** `System.out` / `printStackTrace()` / concatenation → facade + `key={}` pairs.1123. **Narrate the unit of work with the full level palette:**113 - **INFO** — start and outcome: identifiers + counts, one line each, never per item114 - **DEBUG** — per-item / per-step detail inside loops and branches115 - **WARN** — recoverable anomalies: retries, fallbacks, skipped items, **partial success**116 (`WARN "Fulfilled 3 of 5 lines"` beats an INFO that hides the mismatch in two numbers)117 - **ERROR** — the unit of work failed; exception as last arg, once, at the boundary1184. **Measure rates/latency instead of logging them:** a `Timer` on the critical path, `Counter`s for119 failure/skip events.1205. **Sweep before finishing:** no PII/secrets/payloads crept in; telemetry changed no behavior.121122## Common mistakes123124| Rationalization | Reality |125|---------------------------------------------------|------------------------------------------------------------------------------|126| "`"user: " + name`" | Use `{}` placeholders — concatenation allocates even when the level is off. |127| "`java.util.logging` is built in" | Use the project facade (SLF4J/Log4j2); don't add a framework. |128| "Log the whole request to debug" | Never log secrets/PII/full payloads; log identifiers. |129| "`log.error(e.getMessage())`" | Drops the stack trace — pass the exception. |130| "I'll pass the traceId as a parameter everywhere" | Use MDC / context propagation; clear MDC in `finally`. |131| "A `userId` tag on the metric is handy" | High-cardinality tags blow up metrics storage; tag low-cardinality only. |132| "Log the duration after the call returns" | An exception skips it — stop/log timing in `finally`, or use `Timer.record`. |133134## Red flags — stop135136- `+` inside a `log.*` call; `System.out`/`System.err`; a new logging framework137- A secret/token/card number/PII or whole payload in a log call; `log.error(e.getMessage())`138- `MDC.put` without a matching `MDC.clear()` in `finally`139- Hand-rolled counters/timers instead of Micrometer; high-cardinality metric tags140- A duration log or `Timer.Sample.stop` that isn't in a `finally` block141- Telemetry that alters control flow