TL;DR
Replaced 860 scattered logs with one AOP aspect. Passwords get filtered automatically, execution time gets logged automatically. Everything just works.
The Problem: Logging Chaos
I analyzed our codebase and found a shocking mess:
// AuthController - Overly verbose
log.debug("Login request received: username={}", request.getUsername());
log.debug("Login successful: username={}, userId={}", ...);
log.info("Login request Origin header: {}", ...);
log.info("All headers: {}", ...);
// UserController - Complete silence
public ResponseEntity<...> getUser(...) {
// Nothing...
}
// StudentController - Duplicate patterns
log.info("Student list query - classId: {}, grade: {}, ...");
log.info("Student list success - count: {}", students.size());
86 files. 860 log calls. 0 consistency.
Every developer had their own logging style. Some used DEBUG, others INFO. Some logged everything, others nothing. Debugging was a nightmare - first you had to check if logging even existed for that API.
The Solution: One AOP to Rule Them All
Step 1: Define What to Log
First, I established a clear logging policy:
✅ AOP Handles Automatically (DEBUG level)
- All controller method calls
- Request parameters (filtered for security)
- Execution time
- Success/failure status
✅ Developers Handle Manually (INFO level)
- Important business events (login, signup)
- Data creation/modification/deletion
Step 2: The ControllerLoggingAspect
@Aspect
@Component
@Slf4j
public class ControllerLoggingAspect {
@Around("@within(org.springframework.web.bind.annotation.RestController)")
public Object logControllerMethods(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = method.getName();
// Extract HTTP method (GET, POST, etc.)
String httpMethod = extractHttpMethod(method);
// Extract safe parameters (with sensitive data filtering)
Map<String, Object> params = extractSafeParameters(method, joinPoint.getArgs());
String paramsStr = formatParameters(params);
// Request log
log.debug("[{}] {} {}() called with [{}]",
className, httpMethod, methodName, paramsStr);
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
// Success log with execution time
long duration = System.currentTimeMillis() - startTime;
log.debug("[{}] {} {}() completed successfully in {}ms",
className, httpMethod, methodName, duration);
return result;
} catch (Exception e) {
// Failure log
long duration = System.currentTimeMillis() - startTime;
log.debug("[{}] {} {}() failed in {}ms with exception: {}",
className, httpMethod, methodName, duration, e.getClass().getSimpleName());
throw e;
}
}
}
Critical Feature: Automatic Sensitive Data Filtering
Logging passwords is a security incident waiting to happen. Here’s how I prevent it:
private static final String[] SENSITIVE_PARAM_NAMES = {
"password", "pwd", "token", "accessToken", "refreshToken",
"secret", "apiKey", "authorization", "cookie"
};
private Map<String, Object> extractSafeParameters(Method method, Object[] args) {
Map<String, Object> params = new HashMap<>();
Parameter[] parameters = method.getParameters();
for (int i = 0; i < parameters.length; i++) {
String paramName = parameters[i].getName();
// Filter sensitive data
if (isSensitiveParameter(paramName)) {
params.put(paramName, "***FILTERED***");
continue;
}
// Skip Spring framework internals
if (isSpringFrameworkParameter(parameters[i].getType())) {
continue;
}
// Add safe parameters
Object arg = args[i];
if (arg == null) {
params.put(paramName, "null");
} else if (isPrimitiveOrWrapper(arg)) {
params.put(paramName, arg);
} else {
// Complex objects: show class name only (avoid toString() overhead)
params.put(paramName, arg.getClass().getSimpleName());
}
}
return params;
}
private boolean isSensitiveParameter(String paramName) {
String lowerParamName = paramName.toLowerCase();
return Arrays.stream(SENSITIVE_PARAM_NAMES)
.anyMatch(lowerParamName::contains);
}
Real Log Output
// Before: Password exposed
[AuthController] POST login() called with [username=testuser, password=secret123!]
// After: Automatically filtered
[AuthController] POST login() called with [username=testuser, password=***FILTERED***]
Smart HTTP Method Detection
Knowing the HTTP method makes debugging much easier:
private String extractHttpMethod(Method method) {
if (method.isAnnotationPresent(GetMapping.class)) {
return "GET";
} else if (method.isAnnotationPresent(PostMapping.class)) {
return "POST";
} else if (method.isAnnotationPresent(PutMapping.class)) {
return "PUT";
} else if (method.isAnnotationPresent(DeleteMapping.class)) {
return "DELETE";
} else if (method.isAnnotationPresent(RequestMapping.class)) {
RequestMapping mapping = method.getAnnotation(RequestMapping.class);
if (mapping.method().length > 0) {
return mapping.method()[0].name();
}
}
return "HTTP";
}
Now logs clearly show: [StudentController] GET getStudents() instead of just [StudentController] getStudents().
Performance Optimizations
1. Avoid toString() Overhead
// ❌ Bad: Calls expensive toString()
log.debug("Request: {}", request); // Slow for large objects
// ✅ Good: Just show class name
params.put(paramName, arg.getClass().getSimpleName()); // "StudentRequest"
2. Disable DEBUG in Production
# application-prod.yml
logging:
level:
com.example: INFO # DEBUG logs disabled
com.example.aspect: INFO # AOP logs also disabled
When log level is INFO, log.debug() isn’t even executed. Zero performance impact.
3. Skip Framework Internals
private boolean isSpringFrameworkParameter(Class<?> paramType) {
String packageName = paramType.getPackage() != null ?
paramType.getPackage().getName() : "";
return packageName.startsWith("jakarta.servlet") ||
packageName.startsWith("org.springframework.security") ||
paramType.getName().contains("Authentication") ||
paramType.getName().contains("Principal");
}
No point logging HttpServletRequest or Authentication. They just clutter the logs.
Real-World Results
Actual Log Output
[StudentController] GET getStudents() called with [classId=1, grade=5, status=ENROLLED]
[StudentController] GET getStudents() completed successfully in 42ms
[AuthController] POST login() called with [username=testuser, password=***FILTERED***]
[AuthController] POST login() completed successfully in 156ms
[TaskController] POST createTask() called with [request=TaskInstanceRequest]
[TaskController] POST createTask() failed in 23ms with exception: BusinessException
Clean. Clear. Consistent. Every log shows:
- Which controller and method
- HTTP method
- Parameters (safely filtered)
- Execution time
- Success or failure
What Developers Focus On Now
@PostMapping
public ResponseEntity<...> createStudent(@RequestBody StudentRequest request) {
StudentDetailResponse response = studentService.createStudent(request);
// Only log important business events
log.info("Student created: studentId={}, username={}",
response.getId(), response.getUsername());
return ResponseEntity.status(HttpStatus.CREATED).body(...);
}
The AOP handles the boilerplate. Developers focus on business-critical events.
The Numbers
Before
- 86 files with manual logging
- 860 log statements
- Inconsistent formats
- Security risks (passwords in logs)
- Debugging requires checking if logs exist
After
- 1 AOP Aspect covers all controllers
- 100% consistent format
- Automatic security filtering
- Automatic performance measurement
- 30 minutes to implement (estimated 4-6 hours)
Lessons Learned
1. Policy Before Code
Don’t write logging code until you’ve defined what to log. Separate concerns:
- DEBUG: Framework handles (method calls, params, timing)
- INFO: Developers handle (business events)
2. Automate Security
Humans make mistakes. Automatic filtering prevents security incidents.
3. Environment-Specific Configuration
- Development: DEBUG for easy debugging
- Production: INFO+ for performance
4. Know AOP Limitations
Internal method calls bypass the proxy:
public void outerMethod() {
innerMethod(); // No AOP interception
}
private void innerMethod() {
// No logs here
}
This is a Spring AOP limitation. Use AspectJ for compile-time weaving if you need internal call interception.
Production Checklist
Before deploying:
- Set log level to INFO in production
- Verify sensitive data filtering works
- Update
SENSITIVE_PARAM_NAMESfor new sensitive fields - Monitor performance impact (should be negligible with INFO level)
Advanced Patterns
Custom Audit Logging
For critical operations, combine AOP with custom annotations:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuditLog {
String event();
}
// Usage
@PostMapping
@AuditLog(event = "STUDENT_DELETION")
public ResponseEntity<...> deleteStudent(@PathVariable Long id) {
// AOP will log this as a critical audit event
}
Correlation IDs
Add request correlation for distributed tracing:
String correlationId = MDC.get("correlationId");
log.debug("[{}] [{}] {} {}() called",
correlationId, className, httpMethod, methodName);
Bottom line: 860 manual logs replaced by 1 AOP aspect. Consistency, security, and performance all improved. 🎯
Sometimes the best code is the code that writes itself.
Comments