Loading content...
You're investigating a production incident. The system is serving errors to customers, and every minute of downtime costs money. You open the log aggregator, search for the problematic service, and... you're drowning in noise.
Millions of log entries pour through. Most are routine health checks. Thousands track normal request processing. Buried somewhere in this deluge is the one error that explains everything—but finding it is like searching for a specific grain of sand on a beach.
Now imagine the opposite extreme: you open the logs and find... almost nothing. The service was configured to log only critical errors, and whatever went wrong wasn't classified as critical. You have no visibility into what led up to the failure, no breadcrumbs to follow.
Both scenarios represent logging failures. The solution is logging levels—a hierarchy that categorizes log messages by severity, enabling you to tune the verbosity based on context and filter effectively during investigations.
By the end of this page, you will understand the standard logging levels, know exactly when to use each one, appreciate how levels work together in a coherent strategy, and avoid common mistakes that undermine logging effectiveness.
Logging levels are a severity-based classification system for log messages. Each log statement is assigned a level that indicates its importance, urgency, and intended audience. This classification serves multiple purposes:
Most logging frameworks implement a similar hierarchy, though exact names and numbers may vary. The core concept is universal: higher severity levels indicate more urgent situations that demand attention.
The Threshold Concept:
When you configure a logging level (e.g., "INFO"), you capture all messages at that level and above. If your production logging is set to INFO, you'll see INFO, WARN, ERROR, and FATAL messages—but not DEBUG or TRACE.
This threshold mechanism is what makes logging levels powerful. You can inject detailed DEBUG logging during development without worrying about it cluttering production logs—as long as production is configured at a higher level.
However, this only works if you assign levels correctly. Misuse of levels—logging errors as INFO, or debugging information as WARN—undermines the entire system.
TRACE and DEBUG are the most verbose levels, used for detailed diagnostic information that's typically too noisy for production but invaluable during development and troubleshooting.
123456789101112131415161718192021222324252627282930313233343536373839404142
public class PaymentProcessor { private static final Logger logger = LoggerFactory.getLogger(PaymentProcessor.class); public PaymentResult processPayment(PaymentRequest request) { // TRACE: Extremely detailed, step-by-step execution logger.trace("ENTERING processPayment: request={}", request); // DEBUG: Useful diagnostic information logger.debug("Processing payment: amount={}, currency={}, merchantId={}", request.getAmount(), request.getCurrency(), request.getMerchantId()); // Validate request ValidationResult validation = validator.validate(request); logger.debug("Validation result: valid={}, errors={}", validation.isValid(), validation.getErrors()); if (!validation.isValid()) { logger.debug("Payment rejected due to validation: errors={}", validation.getErrors()); return PaymentResult.invalid(validation.getErrors()); } // Select payment gateway PaymentGateway gateway = gatewaySelector.select(request); logger.debug("Selected gateway: name={}, priority={}", gateway.getName(), gateway.getPriority()); // Process through gateway logger.trace("Calling gateway.process with cardToken={} (masked)", maskToken(request.getCardToken())); GatewayResponse response = gateway.process(request); logger.trace("Gateway response received: rawResponse={}", response.getRawResponse()); logger.debug("Gateway response: status={}, transactionId={}, responseTimeMs={}", response.getStatus(), response.getTransactionId(), response.getResponseTimeMs()); // TRACE: Method exit logger.trace("EXITING processPayment: result={}", response.getStatus()); return PaymentResult.from(response); }}TRACE and DEBUG logs can significantly impact performance, even when disabled. Many logging frameworks still evaluate log arguments before checking the level. Use lazy evaluation or level guards: if (logger.isDebugEnabled()) { logger.debug(...); } for expensive operations.
INFO is the default production logging level in most systems. It captures significant events in the normal operation of your application—the "heartbeat" that tells you the system is alive and functioning correctly.
INFO logs should answer: "What is the system doing right now?" without drowning you in detail.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
public class ApplicationLifecycle { private static final Logger logger = LoggerFactory.getLogger(ApplicationLifecycle.class); public void startup() { // Service lifecycle: startup logger.info("SERVICE_STARTING: version={}, environment={}, instance={}", config.getVersion(), config.getEnvironment(), config.getInstanceId()); // Configuration loaded logger.info("CONFIG_LOADED: databaseHost={}, cacheEnabled={}, maxConnections={}", config.getDatabaseHost(), config.isCacheEnabled(), config.getMaxConnections()); // Dependencies connected connectDatabase(); logger.info("DATABASE_CONNECTED: host={}, connectionPoolSize={}", config.getDatabaseHost(), connectionPool.getSize()); connectCache(); logger.info("CACHE_CONNECTED: host={}, cluster={}", config.getCacheHost(), config.getCacheCluster()); // Ready to serve logger.info("SERVICE_STARTED: readyToServeTraffic=true, startupTimeMs={}", System.currentTimeMillis() - startTime); }} public class OrderService { private static final Logger logger = LoggerFactory.getLogger(OrderService.class); public Order createOrder(CreateOrderRequest request) { // Business operation: order created Order order = orderRepository.save(buildOrder(request)); logger.info("ORDER_CREATED: orderId={}, userId={}, itemCount={}, totalAmount={}, currency={}", order.getId(), order.getUserId(), order.getItems().size(), order.getTotalAmount(), order.getCurrency()); return order; } public void shipOrder(String orderId) { Order order = orderRepository.findById(orderId); order.setStatus(OrderStatus.SHIPPED); orderRepository.save(order); logger.info("ORDER_SHIPPED: orderId={}, userId={}, shippingProvider={}, trackingNumber={}", order.getId(), order.getUserId(), order.getShippingProvider(), order.getTrackingNumber()); }} public class BatchProcessor { private static final Logger logger = LoggerFactory.getLogger(BatchProcessor.class); public void processInvoiceBatch(List<Invoice> invoices) { // Batch job: start logger.info("BATCH_STARTED: jobType=INVOICE_PROCESSING, totalItems={}, batchId={}", invoices.size(), batchId); int processed = 0; int failed = 0; for (Invoice invoice : invoices) { try { processInvoice(invoice); processed++; // Progress milestone (every 100 items) if (processed % 100 == 0) { logger.info("BATCH_PROGRESS: batchId={}, processed={}, failed={}, remaining={}", batchId, processed, failed, invoices.size() - processed - failed); } } catch (Exception e) { failed++; // Error would be logged at ERROR level } } // Batch job: complete logger.info("BATCH_COMPLETED: batchId={}, processed={}, failed={}, durationMs={}", batchId, processed, failed, System.currentTimeMillis() - startTime); }}The INFO Level Sweet Spot:
INFO logs should be:
A useful rule of thumb: You should be able to read the INFO logs of a small service and understand what happened over the last hour in a few minutes.
Read your INFO logs aloud as if narrating the system's activities: 'The service started... connected to the database... processed an order... shipped the order...' If this sounds like a coherent story, your INFO logging is well-designed.
WARN (or WARNING) indicates situations that are unusual or potentially problematic, but not yet failures. The system can continue operating, but something deserves attention—either now or soon.
WARN logs are your early warning system. They flag conditions that, if left unaddressed, may escalate to errors.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
public class ResourceMonitor { private static final Logger logger = LoggerFactory.getLogger(ResourceMonitor.class); public void checkConnectionPool() { int used = connectionPool.getActiveConnections(); int max = connectionPool.getMaxConnections(); double utilizationPercent = (used * 100.0) / max; if (utilizationPercent >= 80) { // Resource exhaustion approaching logger.warn("CONNECTION_POOL_HIGH_UTILIZATION: used={}, max={}, utilization={}%", used, max, utilizationPercent); } }} public class ExternalApiClient { private static final Logger logger = LoggerFactory.getLogger(ExternalApiClient.class); public Response callWithRetry(Request request) { int attempts = 0; Exception lastException = null; while (attempts < maxRetries) { try { Response response = httpClient.execute(request); if (attempts > 0) { // Retry succeeded - warn that initial attempts failed logger.warn("API_CALL_RETRY_SUCCEEDED: endpoint={}, attempts={}, finalStatus={}", request.getEndpoint(), attempts + 1, response.getStatus()); } return response; } catch (TimeoutException e) { attempts++; lastException = e; logger.warn("API_CALL_TIMEOUT: endpoint={}, attempt={}, maxRetries={}, timeoutMs={}", request.getEndpoint(), attempts, maxRetries, timeoutMs); } } throw new ApiCallFailedException("All retries exhausted", lastException); }} public class CacheService { private static final Logger logger = LoggerFactory.getLogger(CacheService.class); public Data getWithFallback(String key) { try { Data cached = cache.get(key); if (cached != null) { return cached; } } catch (CacheException e) { // Cache unavailable - fallback to database logger.warn("CACHE_FALLBACK_TO_DATABASE: key={}, cacheError={}, fallbackReason=CACHE_UNAVAILABLE", key, e.getMessage()); } // Fallback to database return database.get(key); }} public class RateLimiter { private static final Logger logger = LoggerFactory.getLogger(RateLimiter.class); public boolean tryAcquire(String clientId) { TokenBucket bucket = buckets.get(clientId); double utilizationPercent = (1 - (bucket.getAvailableTokens() / bucket.getMaxTokens())) * 100; if (utilizationPercent >= 90) { // Approaching rate limit logger.warn("RATE_LIMIT_NEAR_EXHAUSTION: clientId={}, utilization={}%, tokensRemaining={}", clientId, utilizationPercent, bucket.getAvailableTokens()); } return bucket.tryAcquire(); }}The key distinction: WARN means 'something unusual happened, but we handled it.' ERROR means 'something failed, and we couldn't fully handle it.' If the operation ultimately succeeded (even with degradation or retries), it's WARN. If it failed, it's ERROR.
Actionable Warnings:
The best WARN logs imply an action:
If a WARN log doesn't imply any possible action, consider whether it belongs at DEBUG level instead.
ERROR indicates that something has failed. An operation could not be completed, an exception was caught that prevents normal processing, or the system is unable to fulfill a request.
ERROR logs are the primary signal for incident detection and response. They should be:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
public class OrderService { private static final Logger logger = LoggerFactory.getLogger(OrderService.class); public OrderResult processOrder(Order order) { try { // Attempt to process PaymentResult payment = paymentService.charge(order); if (!payment.isSuccess()) { // Business operation failed - but this is expected in normal operation // Consider if this should be WARN instead if declines are common logger.error("PAYMENT_DECLINED: orderId={}, userId={}, amount={}, " + "declineReason={}, declineCode={}", order.getId(), order.getUserId(), order.getAmount(), payment.getDeclineReason(), payment.getDeclineCode()); return OrderResult.paymentDeclined(payment.getDeclineReason()); } return OrderResult.success(order.getId()); } catch (PaymentServiceException e) { // Payment service failed unexpectedly logger.error("PAYMENT_SERVICE_ERROR: orderId={}, userId={}, amount={}, " + "error={}, errorType={}, serviceHost={}", order.getId(), order.getUserId(), order.getAmount(), e.getMessage(), e.getClass().getSimpleName(), paymentService.getHost(), e); return OrderResult.serviceError("Payment service unavailable"); } catch (DatabaseException e) { // Database operation failed logger.error("DATABASE_ERROR: operation=SAVE_ORDER, orderId={}, " + "error={}, sqlState={}", order.getId(), e.getMessage(), e.getSqlState(), e); throw new OrderProcessingException("Failed to save order", e); } }} public class DataIntegrityValidator { private static final Logger logger = LoggerFactory.getLogger(DataIntegrityValidator.class); public void validateUserAccount(User user) { // Check invariants if (user.getBalance() < 0) { // This should never happen - indicates bug or data corruption logger.error("DATA_INTEGRITY_VIOLATION: userId={}, issue=NEGATIVE_BALANCE, " + "balance={}, lastTransaction={}", user.getId(), user.getBalance(), user.getLastTransactionId()); throw new DataIntegrityException("User balance cannot be negative"); } if (user.getCreatedAt().isAfter(user.getLastLoginAt())) { logger.error("DATA_INTEGRITY_VIOLATION: userId={}, issue=INVALID_TIMESTAMPS, " + "createdAt={}, lastLoginAt={}", user.getId(), user.getCreatedAt(), user.getLastLoginAt()); // Don't throw - this might be clock skew, just flag for investigation } }} public class MessageProcessor { private static final Logger logger = LoggerFactory.getLogger(MessageProcessor.class); public void processMessage(Message message) { try { handler.handle(message); } catch (InvalidMessageException e) { // Message format error - data issue logger.error("MESSAGE_INVALID: messageId={}, queue={}, error={}, " + "messagePreview={}", message.getId(), message.getQueue(), e.getMessage(), truncate(message.getBody(), 200), e); // Send to dead-letter queue deadLetterQueue.send(message); } catch (ProcessingException e) { // Processing logic failed logger.error("MESSAGE_PROCESSING_FAILED: messageId={}, queue={}, " + "error={}, retryable={}, attemptNumber={}", message.getId(), message.getQueue(), e.getMessage(), e.isRetryable(), message.getAttemptNumber(), e); if (e.isRetryable() && message.getAttemptNumber() < maxRetries) { requeue(message); } else { deadLetterQueue.send(message); } } }}ERROR logs without sufficient context are frustrating to debug. Always include: 1) What operation was attempted, 2) What input/identifiers were involved, 3) What error occurred, 4) The full stack trace for unexpected exceptions. A cryptic 'Error occurred' message is nearly useless.
Error Log Best Practices:
FATAL (or CRITICAL) indicates the most severe failures—situations where the application cannot continue operating or has suffered an unrecoverable error. These logs should be extremely rare and always trigger immediate alerts.
In many applications, you may never log at FATAL level. It's reserved for catastrophic situations that prevent the system from functioning at all.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
public class ApplicationBootstrap { private static final Logger logger = LoggerFactory.getLogger(ApplicationBootstrap.class); public void initialize() { try { // Load required configuration Config config = loadConfiguration(); // Connect to required database connectDatabase(config); } catch (ConfigurationException e) { // Cannot start without valid configuration logger.error("FATAL: CONFIGURATION_LOAD_FAILED - Application cannot start. " + "error={}, configPath={}", e.getMessage(), configPath, e); System.exit(1); } catch (DatabaseConnectionException e) { // Cannot start without database logger.error("FATAL: DATABASE_CONNECTION_FAILED - Application cannot start. " + "host={}, port={}, error={}, retriesAttempted={}", config.getDbHost(), config.getDbPort(), e.getMessage(), maxRetries, e); System.exit(1); } }} public class DataIntegrityChecker { private static final Logger logger = LoggerFactory.getLogger(DataIntegrityChecker.class); public void verifyStartupIntegrity() { // Check critical data structures if (!ledger.isBalanced()) { // Financial ledger out of balance - catastrophic data corruption logger.error("FATAL: LEDGER_INTEGRITY_VIOLATION - Financial ledger is not balanced. " + "expectedSum={}, actualSum={}, discrepancy={}. " + "Application halted to prevent further damage.", ledger.getExpectedSum(), ledger.getActualSum(), ledger.getExpectedSum() - ledger.getActualSum()); // Trigger emergency alert emergencyAlert.send("CRITICAL: Ledger integrity failure - immediate investigation required"); System.exit(1); } }} public class SecurityMonitor { private static final Logger logger = LoggerFactory.getLogger(SecurityMonitor.class); public void onIntegrityCheckFailed(IntegrityViolation violation) { // Evidence of tampering or attack logger.error("FATAL: SECURITY_INTEGRITY_VIOLATION - Possible security breach detected. " + "type={}, resource={}, expectedHash={}, actualHash={}, " + "Initiating security lockdown.", violation.getType(), violation.getResource(), violation.getExpectedHash(), violation.getActualHash()); // Initiate security response securityResponse.initiateLockedown(violation); // In some cases, we might not exit but enter a restricted mode }}Every FATAL log should trigger an immediate page to on-call engineers. These events indicate the system has failed in a way that requires human intervention. If you're logging FATAL regularly, either the system has serious problems or you're misusing the level.
Choosing the correct logging level requires judgment. Here's a decision framework to help you select appropriately:
| If the situation is... | Level | Example |
|---|---|---|
| Fine-grained execution details for deep debugging | TRACE | Entering/exiting methods, loop iterations |
| Information useful for diagnosing specific issues | DEBUG | Variable values, cache hit/miss, query parameters |
| Normal, significant business or operational events | INFO | Request processed, order shipped, batch completed |
| Unusual situation handled, but worth monitoring | WARN | Retry succeeded, fallback used, resource near limit |
| Operation failed, but system continues | ERROR | Payment declined, database timeout, API error |
| System cannot continue, human intervention required | FATAL | Startup failed, critical data corruption |
Imagine your system handling peak traffic. Will this log statement generate thousands of messages per second? If so, it probably shouldn't be INFO or higher. If errors occur at high volume, aggregate them rather than logging each individually.
Even experienced engineers make mistakes with logging levels. Here are the most common anti-patterns and how to avoid them:
1234567891011121314151617
// ❌ BAD: Expected behavior logged as ERRORpublic boolean login(String username, String password) { User user = userRepo.findByUsername(username); if (user == null) { // This is expected - users mistype usernames logger.error("User not found: {}", username); return false; } if (!passwordEncoder.matches(password, user.getPasswordHash())) { // This is expected - users mistype passwords logger.error("Invalid password for user: {}", username); return false; } return true;}12345678910111213141516171819
// ✅ GOOD: Expected behavior at appropriate levelpublic boolean login(String username, String password) { User user = userRepo.findByUsername(username); if (user == null) { // Expected - DEBUG level, or INFO if needed for security monitoring logger.info("LOGIN_FAILED: reason=USER_NOT_FOUND, username={}", maskUsername(username)); return false; } if (!passwordEncoder.matches(password, user.getPasswordHash())) { // Could be security concern at volume - WARN if repeated logger.info("LOGIN_FAILED: reason=INVALID_PASSWORD, userId={}", user.getId()); return false; } return true;}Let's consolidate the key insights about logging levels:
What's Next:
Knowing the levels is only half the story. The next page covers what to log—the specific information and context that makes logs useful for investigation, monitoring, and auditing.
You now understand the logging level hierarchy and when to use each level. Consistent, appropriate level usage is a hallmark of professional-grade logging. Next, we'll explore what specific information to capture in your logs.