As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Logging is something every application does, but we rarely think about how it works until it becomes a problem. I remember watching an application slow to a crawl, not because of complex business logic, but because every thread was stuck waiting to write a line to a file. That was when I realized logging isn't just a utility; it's an architectural concern. In modern Java systems, with their microservices and distributed calls, a naive approach to logging can drain performance and obscure the very information you need. Here are five methods I use to build logging that is both insightful and efficient.
Let's start with the format of the logs themselves. For years, I wrote logs as sentences. A line like "Order 12345 processed for customer 67890 in 142ms" is easy for me to read. But for a machine trying to aggregate thousands of these lines per second to find a slow order for a specific customer, it's a nightmare. It involves parsing text, which is slow and error-prone.
This is where structured logging changes everything. Instead of writing a paragraph, you log a bundle of data. Think of it as providing the raw ingredients—order_id, customer_id, processing_time_ms, success—rather than a pre-made sentence. A logging framework can then output this bundle as JSON. Suddenly, your log management system can instantly find all logs where customer_id is "67890" and processing_time_ms is greater than "1000", without any messy text parsing. It turns your logs from a storybook into a queryable database.
Here’s a practical shift. The old way is familiar but opaque:
log.info("Order {} processed for customer {}", orderId, customerId);
The structured way provides immediate, filterable context:
import net.logstash.logback.argument.StructuredArguments;
log.info("order_processed",
StructuredArguments.keyValue("order_id", orderId),
StructuredArguments.keyValue("customer_id", customerId),
StructuredArguments.keyValue("processing_time_ms", duration),
StructuredArguments.keyValue("success", true)
);
This produces a log entry that isn't just a string:
{
"timestamp": "2023-10-05T14:23:45Z",
"level": "INFO",
"logger": "OrderService",
"message": "order_processed",
"order_id": "ord_12345",
"customer_id": "cust_67890",
"processing_time_ms": 142,
"success": true
}
The first time I made this switch, the operational clarity was immediate. Debugging a user's journey across services became a matter of querying for their user ID, not grepping through gigabytes of text files.
However, creating a beautiful log entry is pointless if the act of writing it freezes your application. This is the next big challenge: Input/Output latency. Writing to disk, or worse, sending logs over the network to a central server, is slow. If your application thread has to wait for that write to finish before it can handle the next user request, you've tied your business logic speed to your disk speed or network health.
The solution is to get your application threads out of the Input/Output business. Let them do their job and hand off the logging work to someone else. This is done with asynchronous appenders. Your application thread places the log message into a in-memory queue, like dropping a letter into a mailbox, and immediately goes back to work. A separate, dedicated thread is responsible for checking that mailbox and actually posting the letters (writing the logs).
This simple separation has a profound effect. During a traffic surge, or if the log storage has a hiccup, your application can keep processing requests. The queue might fill up, which we'll handle, but the core transaction flow remains unaffected.
Configuring this in a popular framework like Logback is straightforward:
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<!-- How many log events can wait in line? -->
<queueSize>2048</queueSize>
<!-- Critical: What if the queue is full?
'true' means drop the new log, don't block the app. -->
<neverBlock>true</neverBlock>
<appender-ref ref="JSON_FILE" />
</appender>
<appender name="JSON_FILE" class="ch.qos.logback.core.FileAppender">
<file>logs/app.json</file>
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<root level="info">
<appender-ref ref="ASYNC" />
</root>
I set neverBlock to true after a painful lesson. An overloaded log aggregator caused the queue to fill, and with the default blocking behavior, every single application thread waited. The entire service stalled. With neverBlock, the app drops the log event and moves on. You might lose a log line, but you keep the service alive. It's a trade-off for reliability.
Now, even with async logging, you can drown yourself in data. A healthy system generates a lot of logs. If you log every single database call, every cache hit, every incoming HTTP request at the DEBUG level, the volume becomes unmanageable and expensive. But you can't log nothing, because when an error occurs, you need that context.
This is where dynamic sampling is invaluable. The idea is to log only a sample of high-frequency, low-value events, while logging every single instance of rare, high-value events (like errors). It's like having a smart filter. For routine "heartbeat" events, you might log only one out of every hundred. But for an authentication failure, you log every one.
You can build a simple sampler to wrap your logger:
public class SampledLogger {
private final Logger log;
private final RateLimiter rateLimiter;
private final Map<String, AtomicLong> eventCounters = new ConcurrentHashMap<>();
public SampledLogger(Class<?> clazz, int eventsPerMinute) {
this.log = LoggerFactory.getLogger(clazz);
// Create a limiter: e.g., 100 events per minute
this.rateLimiter = RateLimiter.create(eventsPerMinute / 60.0);
}
public void debugHighVolume(String eventKey, String message, Object... args) {
// 1. Count every occurrence
long totalCount = eventCounters
.computeIfAbsent(eventKey, k -> new AtomicLong())
.incrementAndGet();
// 2. Only actually log if the rate limiter allows
if (rateLimiter.tryAcquire()) {
// Log the sample, and include how many times it happened since last sample
log.debug("[Sampled 1 of {}] " + message, totalCount, args);
// Reset the counter for this key
eventCounters.get(eventKey).set(0);
}
}
}
// Usage in a busy message processor
private SampledLogger sampledLog = new SampledLogger(getClass(), 60); // Sample 60/min
public void onMessage(Message msg) {
// This will only log a fraction of the time
sampledLog.debugHighVolume("msg_received",
"Processing message ID {} from {}", msg.getId(), msg.getSource());
// ... process the message ...
}
This technique gave me the confidence to add verbose logging in performance-critical loops. I know it won't blow up my log storage, but if I need to debug an issue, I can temporarily increase the sample rate and see a more detailed flow.
Once your application grows beyond a single process, a new problem emerges. A single user request might touch five different Java services. If each service logs independently, how do you piece together what happened to that one request? You need a way to stitch these logs together across process and network boundaries.
The mechanism for this is context propagation. When a request enters your system, you generate or extract a unique identifier for it—often called a trace_id or correlation_id. You then attach this identifier to every single log statement that occurs while processing that request, no matter which thread or service it travels through.
In Java, the Mapped Diagnostic Context is the tool for this job. The MDC is a thread-local map where you can store key-value pairs. Most logging frameworks can be configured to automatically include all MDC entries in every log line.
You typically set this up in a web filter or interceptor:
public class TracingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) request;
// Get the ID from incoming headers, or generate a new one
String traceId = httpReq.getHeader("X-Trace-Id");
if (traceId == null || traceId.isEmpty()) {
traceId = UUID.randomUUID().toString();
}
// Store it in the MDC for this thread
MDC.put("trace_id", traceId);
MDC.put("user_id", extractUserId(httpReq));
MDC.put("request_path", httpReq.getRequestURI());
// Pass it downstream for other services
((HttpServletResponse) response).setHeader("X-Trace-Id", traceId);
try {
chain.doFilter(request, response);
} finally {
// Clean up! Essential for thread pools.
MDC.clear();
}
}
}
With this, every log from this request, across all classes, will contain the same trace_id. In your log aggregator, you can search for that ID and see the complete, cross-service story. The finally block is critical. If you use a thread pool (and you do), failing to clear the MDC will cause one request's trace ID to leak onto the logs of the next unrelated request handled by the same thread.
The final technique is about gaining deep insight on demand. In production, you usually run with INFO or WARN level logging. DEBUG logging is often too verbose and slow to leave on all the time. But when there's an incident, you desperately need that DEBUG-level detail to see what's happening inside a component.
Restarting the service with a changed config is slow and might hide the problem. Instead, you can build the ability to change log levels at runtime, often via a secured administrative endpoint.
@RestController
@RequestMapping("/internal/admin")
public class LogLevelController {
@PostMapping("/loglevel")
public String changeLogLevel(@RequestParam String logger,
@RequestParam String level) {
LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory();
ch.qos.logback.classic.Logger targetLogger;
if ("ROOT".equalsIgnoreCase(logger)) {
targetLogger = ctx.getLogger(Logger.ROOT_LOGGER_NAME);
} else {
targetLogger = ctx.getLogger(logger);
}
Level newLevel = Level.toLevel(level); // Converts string "DEBUG" to Level.DEBUG
Level oldLevel = targetLogger.getLevel();
targetLogger.setLevel(newLevel);
return String.format("Changed %s from %s to %s",
logger, oldLevel, newLevel);
}
}
This is powerful, but use it with extreme caution. It should be locked down to internal admin networks only. I often pair this with an automatic reversion timer, so DEBUG level is only turned on for 10 minutes before automatically snapping back to WARN. This prevents someone from accidentally leaving verbose logging on and degrading performance.
You can get even more specific. Imagine a particular user is reporting an issue. You can create logic that enables DEBUG logging only for requests that contain their user ID in the MDC.
public class UserSpecificLogger {
public static void enableDebugForUser(String userId) {
LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory();
String virtualLoggerName = "userTracker." + userId;
ch.qos.logback.classic.Logger userLogger = ctx.getLogger(virtualLoggerName);
userLogger.setLevel(Level.DEBUG);
// Add an appender that only fires if the MDC has this user_id
FilteredAppender appender = new FilteredAppender(userId);
appender.setContext(ctx);
appender.start();
userLogger.addAppender(appender);
}
}
// A custom filter for the appender
class UserIdFilter extends Filter<ILoggingEvent> {
private final String targetUserId;
public UserIdFilter(String userId) { this.targetUserId = userId; }
@Override
public FilterReply decide(ILoggingEvent event) {
String eventUserId = event.getMDCPropertyMap().get("user_id");
return targetUserId.equals(eventUserId) ?
FilterReply.ACCEPT : FilterReply.DENY;
}
}
When you combine these five techniques, logging stops being a passive output and becomes an active, scalable observation system. Structured logging gives you data, not text. Asynchronous appenders protect your performance. Dynamic sampling manages volume. Context propagation tells coherent stories across your architecture. Runtime adjustment lets you probe deeply when needed.
Implementing this requires upfront thought. You must choose libraries that support these patterns, like Logback or Log4j 2 with their encoders and async appenders. You need to design how trace IDs flow across your service boundaries. But the payoff is immense. You get a clear window into your running system without paying a heavy performance tax. For me, this transformed logging from a debugging afterthought into a foundational pillar of reliable system operation.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)