Java Spring Boot Logging: Log Levels, Logback, JSON Logs & Production Best Practices
Production-Grade Logging in Spring Boot: A Complete Guide to Logging Levels, Files, JSON Logs, Correlation IDs, and Best Practices Logging is one of the most important parts of a production backend system. When everything works, you may not think much about logs. But when production starts returni

Production-Grade Logging in Spring Boot: A Complete Guide to Logging Levels, Files, JSON Logs, Correlation IDs, and Best Practices Logging is one of the most important parts of a production backend system. When everything works, you may not think much about logs. But when production starts returning 500 errors at 2 AM, a customer reports that an API is failing, or a payment request behaves unexpectedly, logs become one of your most important debugging tools. Poor logging makes production debugging painful. Good logging helps you answer: What happened? When did it happen? Which user triggered it? Which request caused it? Which service handled it? How long did it take? What failed? Why did it fail? What should we investigate next? In this article, we will build a production-grade logging strategy for a Java Spring Boot application. Production-grade logging is not simply: log.info("User created"); Production logging should be: Structured Searchable Consistent Secure Configurable Environment-aware Correlated across requests Useful during debugging Suitable for monitoring and alerting A good logging architecture might look like this: Spring Boot Application | v Logback | +---- application.log | +---- error.log | +---- audit.log | +---- access.log | v Log Aggregation | +---- ELK +---- Grafana Loki +---- CloudWatch +---- Datadog +---- Splunk The goal is not to log everything. The goal is to log the right information at the right level. Spring Boot uses SLF4J as the logging abstraction and commonly uses Logback as the underlying logging implementation. The most common log levels are: TRACE DEBUG INFO WARN ERROR The order represents increasing severity. TRACE is the most detailed logging level. Example: log.trace("Entering calculateInvoice() with customerId={}", customerId); Use TRACE for very detailed diagnostic information. Usually: Production: OFF Development: Sometimes ON Debugging: Useful Avoid keeping TRACE enabled globally in production because it can generate huge amounts of logs. DEBUG is useful for developers. Example: log.debug("Fetching customer with customerId={}", customerId); Another example: log.debug("Payment request received for orderId={}", orderId); DEBUG logs are useful when troubleshooting a specific feature. A common production strategy is: INFO -> Default DEBUG -> Temporarily enabled when troubleshooting INFO should contain important application events. For example: log.info("User successfully created. userId={}", userId); Or: log.info("Order successfully created. orderId={}, customerId={}", orderId, customerId); Good INFO logs might include: Application started User registered Order created Payment completed File uploaded Scheduled job completed External integration connected But don't log every line of your application at INFO. WARN indicates something unexpected or potentially problematic. Example: log.warn("Login attempt failed. email={}", email); Another example: log.warn("Payment provider response time is high. durationMs={}", durationMs); WARN means: "The application is still functioning, but someone should pay attention." Examples: Retry occurred External API is slow Deprecated API was called Configuration is missing but has a fallback Login failed repeatedly Database connection pool is close to its limit ERROR represents a failure that needs investigation. Example: log.error("Failed to create order. orderId={}", orderId, exception); Notice that the exception is passed separately: log.error("Failed to create order", exception); Instead of: log.error("Failed to create order " + exception.getMessage()); The first approach preserves the stack trace. One of the most important production logging rules is: Logs should never become a source of sensitive data leakage. Never log: Passwords Access tokens Refresh tokens API keys Credit card numbers CVV Session IDs Private keys Authorization headers Bad: log.info("Login request: username={}, password={}", username, password); Good: log.info("Login attempt received. username={}", username); Even better, depending on your privacy requirements, avoid logging email addresses or other personal identifiers unless there is a clear operational reason. Avoid string concatenation. Don't do this: log.info("User created: " + userId); Prefer: log.info("User created. userId={}", userId); For multiple values: log.info( "Order created. orderId={}, customerId={}, amount={}", orderId, customerId, amount ); Parameterized logging is cleaner and avoids unnecessary string construction. Spring Boot makes it easy to configure Logback. You can create: src/main/resources/logback-spring.xml A basic configuration: <?xml version="1.0" encoding="UTF-8"?> <configuration> <property name="LOG_DIR" value="./logs"/> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern> %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n </pattern> </encoder> </appender> <root level="INFO"> <appender-ref ref="CONSOLE"/> </root> </configuration> Now the application produces readable logs such as: 2026-08-08 10:30:25.123 [http-nio-8080-exec-1] INFO c.example.user.UserService - User created. userId=123 For a production application, you may want separate files. For example: logs/ โโโ application.log โโโ error.log โโโ audit.log โโโ access.log This makes troubleshooting easier. For example: application.log contains general application events. error.log contains ERROR events. audit.log contains important security/business events. access.log contains HTTP request information. Example: <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_DIR}/error.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern> ${LOG_DIR}/archive/error.%d{yyyy-MM-dd}.%i.log.gz </fileNamePattern> <maxFileSize>100MB</maxFileSize> <maxHistory>30</maxHistory> <totalSizeCap>5GB</totalSizeCap> </rollingPolicy> <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <level>ERROR</level> </filter> <encoder> <pattern> %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n </pattern> </encoder> </appender> Now ERROR logs can be stored separately. Imagine your application generates: application.log and you never rotate it. After several months: application.log = 150 GB Your server's disk eventually becomes full. This can cause much bigger problems. For example: Application | v Disk full | +---- Logging fails +---- Database operations may fail +---- Temporary files cannot be created +---- Application becomes unstable That's why production applications need: Maximum file size Maximum history Total storage limit Compression Time-based rotation Example: <maxFileSize>100MB</maxFileSize> <maxHistory>30</maxHistory> <totalSizeCap>5GB</totalSizeCap> Not every important event is an application error. Consider: Admin disabled user 123 This isn't an ERROR. It is an audit event. Create a separate audit logger. private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT"); Then: auditLogger.info( "User disabled. adminId={}, targetUserId={}", adminId, targetUserId ); This gives you a separate audit stream. For systems involving multiple users, administrators, payments, permissions, or sensitive operations, audit logging becomes extremely valuable. Examples: USER_CREATED USER_DISABLED USER_ENABLED PASSWORD_CHANGED ROLE_CHANGED LOGIN_SUCCESS LOGIN_FAILED API_KEY_CREATED API_KEY_REVOKED DATA_EXPORTED PAYMENT_COMPLETED Instead of: log.info("Something happened"); Use structured information: auditLogger.info( "AUDIT event=ROLE_CHANGED actorId={} targetUserId={} oldRole={} newRole={}", actorId, targetUserId, oldRole, newRole ); Now the event can easily be searched. For backend systems, request logging is extremely useful. You want to know: HTTP Method URL Status Code Execution Time Request ID User ID Example: GET /api/users/123 status=200 duration=45ms requestId=9f7a2 A servlet filter is one approach. @Component public class RequestLoggingFilter extends OncePerRequestFilter { private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { long start = System.currentTimeMillis(); try { filterChain.doFilter(request, response); } finally { long duration = System.currentTimeMillis() - start; log.info( "HTTP request method={} uri={} status={} durationMs={}", request.getMethod(), request.getRequestURI(), response.getStatus(), duration ); } } } This gives you a basic access log. This is one of the most useful concepts in distributed systems. Imagine a request: Frontend | v API Gateway | v User Service | v Payment Service | v Notification Service One request could generate dozens of logs. How do you identify which logs belong to the same request? Use a: Correlation ID Example: requestId=7f83ab29 Then every service logs: requestId=7f83ab29 Now you can search the entire system using that ID. SLF4J provides MDC: MDC.put("requestId", requestId); Example: @Component public class CorrelationIdFilter extends OncePerRequestFilter { private static final String REQUEST_ID = "requestId"; @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String requestId = request.getHeader(REQUEST_ID); if (requestId == null || requestId.isBlank()) { requestId = UUID.randomUUID().toString(); } MDC.put(REQUEST_ID, requestId); response.setHeader(REQUEST_ID, requestId); try { filterChain.doFilter(request, response); } finally { MDC.remove(REQUEST_ID); } } } Now add it to Logback: <pattern> %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{requestId}] %logger{36} - %msg%n </pattern> The resulting log becomes: 2026-08-08 12:20:10.120 [http-nio-8080-exec-2] INFO [7f83ab29] UserService - Fetching user userId=123 This is much easier to debug. Traditional logs look like: User created successfully userId=123 Structured logs can look like JSON: { "timestamp": "2026-08-08T12:20:10.120Z", "level": "INFO", "service": "user-service", "requestId": "7f83ab29", "userId": "123", "event": "USER_CREATED" } This is much easier for log aggregation systems to process. For production environments, structured JSON logging is often preferable. Suppose you use: ELK Grafana Loki Datadog AWS CloudWatch Splunk You can query structured fields. For example: level = ERROR service = payment-service environment = production Or: requestId = 7f83ab29 Or: durationMs > 1000 This becomes much more powerful than searching plain text. Bad: try { paymentService.process(payment); } catch (Exception e) { log.error("Payment failed: " + e.getMessage()); } This loses the stack trace. Better: try { paymentService.process(payment); } catch (Exception e) { log.error( "Payment processing failed. paymentId={}", paymentId, e ); } Now you get: ERROR Payment processing failed. paymentId=123 java.lang.IllegalStateException: Payment provider timeout at PaymentService.process(...) at PaymentController.create(...) The stack trace is extremely important for debugging. A common mistake is: Repository โ Service โ Controller Every layer catches and logs the same exception. You might get: ERROR Database failure ERROR Service failure ERROR Controller failure Three logs for one problem. Prefer centralized exception handling when possible. For Spring Boot: @RestControllerAdvice public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); @ExceptionHandler(Exception.class) public ResponseEntity<?> handleException( Exception exception) { log.error( "Unhandled application exception", exception ); return ResponseEntity .internalServerError() .body("Something went wrong"); } } Now unexpected exceptions can be logged centrally. Not every useful log is technical. Business events can be extremely valuable. Example: log.info( "Order completed. orderId={}, customerId={}, amount={}, currency={}", orderId, customerId, amount, currency ); This can help answer questions such as: How many orders were completed? Which payment failed? How long does checkout take? Which customer experienced the problem? Logging should help both developers and operations teams. Suppose your application calls: Stripe Salesforce OpenAI AWS Google Maps Email provider SMS provider You should log useful metadata. Example: long start = System.currentTimeMillis(); try { PaymentResponse response = paymentClient.createPayment(request); long duration = System.currentTimeMillis() - start; log.info( "Payment provider call completed. provider={} status={} durationMs={}", "stripe", response.status(), duration ); } catch (Exception e) { long duration = System.currentTimeMillis() - start; log.error( "Payment provider call failed. provider={} durationMs={}", "stripe", duration, e ); } But never log: Authorization header API key Access token Full card details Sensitive request payload Don't log every SQL query in production unless you have a specific reason. For example, enabling: spring.jpa.show-sql=true in production can create huge amounts of output. For development: spring.jpa.show-sql=true may be useful. For production: spring.jpa.show-sql=false Instead, monitor slow queries through proper database monitoring and profiling tools. Your logging configuration should change based on the environment. Development: DEBUG Readable console logs More diagnostic information Production: INFO JSON logs Error tracking Structured fields Log rotation Centralized log collection Example: spring: profiles: active: dev You can maintain: application-dev.yml application-prod.yml And configure logging accordingly. A practical production architecture might look like: Spring Boot | v Logback | +-------------+-------------+ | | | v v v Application Error Audit Logs Logs Logs | | | +-------------+-------------+ | v Log Collector | +-------------+-------------+ | | | v v v CloudWatch Loki ELK | v Dashboard | v Alerts This is much better than simply SSHing into a server and running: tail -f application.log every time something breaks. If your application runs inside Docker or Kubernetes, writing logs only to local files may not be the best strategy. A common approach is: Application | v stdout / stderr | v Docker / Kubernetes | v Log Collector | v Centralized Logging Platform For example: Spring Boot โ stdout โ Docker โ Fluent Bit โ Elasticsearch โ Kibana This allows logs to remain available even when containers are recreated. In Kubernetes, pods are disposable. That means this: Pod A | +--- application.log is not necessarily a reliable long-term logging strategy. Instead: Pod | v stdout | v Container Runtime | v Log Collector | v Centralized Storage This is generally more suitable for cloud-native applications. A useful rule: TRACE โ Extremely detailed diagnostics DEBUG โ Developer troubleshooting INFO โ Important application events WARN โ Unexpected but recoverable situation ERROR โ Failure requiring investigation Don't do this: log.error("User logged in successfully"); Use: log.info("User login successful. userId={}", userId); Log levels should communicate severity. More logs do not automatically mean better observability. Bad: log.info("Starting method"); log.info("Entering service"); log.info("Repository called"); log.info("Repository returned"); log.info("Service completed"); log.info("Controller completed"); This creates noise. Instead: log.info( "User profile updated. userId={} durationMs={}", userId, durationMs ); Log meaningful events. Logging can affect application performance. Especially dangerous: log.debug( "Huge object: {}", objectWithThousandsOfFields ); When DEBUG isn't enabled, parameterized logging helps avoid unnecessary string concatenation, but object serialization or expensive argument computation can still cost time. Avoid: log.debug("Response: {}", expensiveMethod()); if the computation itself is expensive. You can guard expensive operations: if (log.isDebugEnabled()) { log.debug("Detailed response: {}", expensiveMethod()); } Use this only when the computation is genuinely expensive. High-throughput applications can benefit from asynchronous logging. Instead of: Application | v Write log | v Disk you can use: Application | v Async Queue | v Logger | v Disk / Collector This reduces the amount of time application threads spend waiting on logging operations. However, asynchronous logging should be configured carefully to avoid losing logs during abrupt shutdowns and to prevent queue overflow. Production logs should have a retention policy. For example: Application logs โ 30 days Audit logs โ 90 days Security logs โ 180 days The actual retention period should be based on: Compliance Security requirements Business requirements Storage cost Incident investigation needs Don't keep everything forever. A simplified production configuration could look like: <configuration> <property name="LOG_DIR" value="./logs"/> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern> %d{yyyy-MM-dd'T'HH:mm:ss.SSS} %-5level [%X{requestId}] %logger{36} - %msg%n </pattern> </encoder> </appender> <appender name="APPLICATION_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_DIR}/application.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern> ${LOG_DIR}/archive/application.%d{yyyy-MM-dd}.%i.log.gz </fileNamePattern> <maxFileSize>100MB</maxFileSize> <maxHistory>30</maxHistory> <totalSizeCap>5GB</totalSizeCap> </rollingPolicy> <encoder> <pattern> %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{requestId}] %logger{36} - %msg%n </pattern> </encoder> </appender> <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_DIR}/error.log</file> <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <level>ERROR</level> </filter> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern> ${LOG_DIR}/archive/error.%d{yyyy-MM-dd}.%i.log.gz </fileNamePattern> <maxFileSize>100MB</maxFileSize> <maxHistory>30</maxHistory> <totalSizeCap>2GB</totalSizeCap> </rollingPolicy> <encoder> <pattern> %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{requestId}] %logger{36} - %msg%n </pattern> </encoder> </appender> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="APPLICATION_FILE"/> <appender-ref ref="ERROR_FILE"/> </root> </configuration> This gives you: logs/ โโโ application.log โโโ error.log โโโ archive/ โโโ application.2026-08-08.0.log.gz โโโ error.2026-08-08.0.log.gz โโโ ... Imagine a customer reports: "My payment failed." Without structured logging, you might search: payment failed and find thousands of results. With production-grade logging, you can search: requestId=7f83ab29 Then you might see: INFO requestId=7f83ab29 Payment request received. orderId=ORD-123 INFO requestId=7f83ab29 Calling payment provider. provider=stripe WARN requestId=7f83ab29 Payment provider response slow. durationMs=4200 ERROR requestId=7f83ab29 Payment provider call failed. orderId=ORD-123 Now you know exactly what happened. That's the real value of production logging. Before deploying a Spring Boot application, check: [ ] TRACE is disabled in production [ ] DEBUG is used intentionally [ ] INFO contains meaningful events [ ] WARN represents recoverable problems [ ] ERROR represents actual failures [ ] Passwords are never logged [ ] Tokens are never logged [ ] API keys are never logged [ ] Sensitive headers are never logged [ ] Sensitive payloads are not logged [ ] Log rotation is configured [ ] Maximum file size is configured [ ] Retention policy exists [ ] Disk usage is monitored [ ] Request ID exists [ ] Correlation ID exists [ ] Important business events are logged [ ] External API failures are logged [ ] Slow operations can be identified [ ] Structured logging is available [ ] Logs can be centralized [ ] Alerts can be created [ ] Logs are searchable [ ] Audit events are separated when required For a modern Spring Boot backend, my preferred baseline would be: Spring Boot | +--- SLF4J | +--- Logback | +--- Structured JSON | +--- Request ID / Correlation ID | +--- INFO as default | +--- DEBUG for troubleshooting | +--- ERROR for failures | +--- Audit logging for important actions | +--- Log rotation / retention | +--- Centralized log aggregation | +--- Monitoring + Alerting For cloud deployments, I would generally prefer sending structured logs to a centralized platform rather than depending exclusively on local log files. Logging is not something you add at the end of development. It is part of backend architecture. A production-ready application should make it easy to understand: What happened? When? Where? Who triggered it? Which request? Which service? How long? What failed? Why? The goal isn't to create millions of log lines. The goal is to create useful signals. A good production logging system gives developers confidence when everything is working and, more importantly, gives them the information they need when something goes wrong. If your application is running in production, ask yourself: "If this API fails at 3 AM, can I understand exactly what happened from the logs?" If the answer is no, your logging strategy probably needs another iteration.
Key Takeaways
- โขProduction-Grade Logging in Spring Boot: A Complete Guide to Logging Levels, Files, JSON Logs, Correlation IDs, and Best Practices Logging is one of the most important parts of a production backend system. When everything works, you may not think much about logs. But when production starts returni
- โขThis story was reported by Dev.to, covering developments in the dev space.
- โขAI advancements continue to reshape industries โ read the full article on Dev.to for complete coverage.
๐ Continue reading the full article:
Read Full Article on Dev.to โShare this article



