Modern applications need more than just “this method was called.” Developers need visibility into what data flows through their services, and traceability of requests across layers, including when things go wrong.
Spring Cloud Sleuth solves the traceability problem by enriching logs with trace IDs and span IDs. But Sleuth doesn’t log method parameters or exceptions on its own.
By combining Sleuth with Spring AOP (Aspect-Oriented Programming), we can:
- Log method arguments and return values
- Capture and log exceptions with stack traces
- Tie everything to trace/span IDs for end-to-end observability
Why Sleuth + AOP?
- Sleuth: Adds unique identifiers (trace/span IDs) to logs for correlation.
- AOP: Intercepts method calls so you can log parameters, results, and exceptions automatically.
- Together: Provide a complete picture of what happened, with which data, and under which request trace.
Project Setup
Dependencies (pom.xml
)
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Sleuth for distributed tracing -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>
<!-- AOP for logging method parameters and exceptions -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Lombok (optional) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Jackson (for serializing method args/results) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
Also include the Spring Cloud BOM:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2023.0.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Application Code
Domain Object
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
private String id;
private String name;
}
Service Layer
import org.springframework.stereotype.Service;
@Service
public class UserService {
public User processUser(User user) {
if (user.getName() == null || user.getName().isBlank()) {
throw new IllegalArgumentException("User name cannot be empty");
}
user.setName(user.getName().toUpperCase());
return user;
}
}
Controller
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {
private final UserService userService;
@PostMapping("/process")
public User processUser(@RequestBody User user) {
return userService.processUser(user);
}
}
Logging Aspect (with Exception Support)
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
@Slf4j
@Aspect
@Component
public class LoggingAspect {
private final ObjectMapper mapper = new ObjectMapper();
// Target all methods in your application package
@Pointcut("execution(* com.example..*(..))")
public void applicationPackagePointcut() {}
@Before("applicationPackagePointcut()")
public void logMethodArguments(JoinPoint joinPoint) {
try {
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();
log.info("Entering {}.{}() with args: {}",
className, methodName, mapper.writeValueAsString(args));
} catch (Exception e) {
log.warn("Could not log method arguments: {}", e.getMessage());
}
}
@AfterReturning(pointcut = "applicationPackagePointcut()", returning = "result")
public void logMethodReturn(JoinPoint joinPoint, Object result) {
try {
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();
log.info("Exiting {}.{}() with result: {}",
className, methodName, mapper.writeValueAsString(result));
} catch (Exception e) {
log.warn("Could not log method result: {}", e.getMessage());
}
}
@AfterThrowing(pointcut = "applicationPackagePointcut()", throwing = "ex")
public void logMethodException(JoinPoint joinPoint, Throwable ex) {
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();
log.error("Exception in {}.{}() with message: {}",
className, methodName, ex.getMessage(), ex);
}
}
Configuration
application.yml
server:
port: 8080
spring:
application:
name: single-service-logging
logging:
level:
root: INFO
com.example: INFO
Running the Example
1. Normal Request
Request:
curl -X POST http://localhost:8080/users/process \
-H "Content-Type: application/json" \
-d '{"id":"101","name":"Alice"}'
Response:
{
"id": "101",
"name": "ALICE"
}
Logs:
INFO [single-service-logging,abc123def456,abc123def456]
Entering UserController.processUser() with args: [{"id":"101","name":"Alice"}]
INFO [single-service-logging,abc123def456,789ghi012jkl]
Entering UserService.processUser() with args: [{"id":"101","name":"Alice"}]
INFO [single-service-logging,abc123def456,789ghi012jkl]
Exiting UserService.processUser() with result: {"id":"101","name":"ALICE"}
INFO [single-service-logging,abc123def456,abc123def456]
Exiting UserController.processUser() with result: {"id":"101","name":"ALICE"}
2. Exception Case
Request:
curl -X POST http://localhost:8080/users/process \
-H "Content-Type: application/json" \
-d '{"id":"102","name":""}'
Response:
{
"timestamp": "2025-09-13T16:30:00.123+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/users/process"
}
Logs:
INFO [single-service-logging,xyz789lmn456,xyz789lmn456]
Entering UserController.processUser() with args: [{"id":"102","name":""}]
INFO [single-service-logging,xyz789lmn456,opq123rst789]
Entering UserService.processUser() with args: [{"id":"102","name":""}]
ERROR [single-service-logging,xyz789lmn456,opq123rst789]
Exception in UserService.processUser() with message: User name cannot be empty
java.lang.IllegalArgumentException: User name cannot be empty
at com.example.UserService.processUser(UserService.java:8)
...
Benefits
- Traceability: Sleuth’s trace/span IDs correlate logs across layers.
- Visibility: Arguments and results are automatically logged.
- Error Tracking: Exceptions are logged with full stack traces under the same trace ID.
- Maintainability: No need to sprinkle
log.info()
andtry/catch
everywhere.
Next Steps
- Connect to Zipkin or Jaeger for distributed trace visualization.
- Extend aspect to log execution time for performance insights.
- Exclude sensitive data (passwords, tokens, etc.) from logs.
With this enhanced setup, you now have end-to-end observability in a Spring Boot app:
- Inputs and outputs
- Exceptions and stack traces
- All tied together by Sleuth trace/span IDs.