import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
/**
* AOP aspect that intercepts all public methods on Spring MVC controller classes
* (annotated with {@code @RestController} or {@code @Controller}) and logs:
* <ul>
* <li>Fully-qualified or simple class name of the controller</li>
* <li>Fully-qualified or simple method signature, including each parameter's
* type (with or without package prefix) and parameter name</li>
* <li>Actual argument values passed at runtime</li>
* <li>Execution time in milliseconds</li>
* </ul>
*
* <p><strong>Configuration properties</strong> (in {@code application.properties} / profile files):</p>
* <pre>
* # Master switch โ set to false to disable the aspect entirely (default: true)
* myapp.aop.controller.logging.enabled=true
*
* # Include the full package name in the class identifier logged (default: true)
* # true โ com.net.bosch.crmmaster.controller.AppointmentController
* # false โ AppointmentController
* myapp.aop.controller.logging.include-class-package=true
*
* # Include the full package name for each parameter type logged (default: true)
* # true โ (com.net.bosch.crmmaster.dto.AppointmentRequest req, java.lang.String location)
* # false โ (AppointmentRequest req, String location)
* myapp.aop.controller.logging.include-param-package=true
*
* # Include the full package name in the method's declaring class (default: true)
* myapp.aop.controller.logging.include-method-package=true
* </pre>
*
* <p>The aspect is registered as a Spring {@link Component} and will be picked up
* by component scanning automatically. It sits in the {@code com.net.bosch.aspect}
* package alongside {@link CommonPointcuts}, {@link HmacValidationAspect}, and
* {@link LocationValidationAspect}.</p>
*
* @author RE Prime Platform โ Bosch
* @since 2.3.3
* @see CommonPointcuts
*/
@Aspect
@Component
public class ControllerEndpointLoggingAspect {
private static final Logger log = LoggerFactory.getLogger(ControllerEndpointLoggingAspect.class);
// -------------------------------------------------------------------------
// Configuration properties injected from application-*.properties
// -------------------------------------------------------------------------
/**
* Master switch. When {@code false} the aspect is a no-op and simply
* delegates to the join point without any logging overhead.
*
* <p>Property: {@code myapp.aop.controller.logging.enabled} (default: {@code true})</p>
*/
@Value("${myapp.aop.controller.logging.enabled:true}")
private boolean enabled;
/**
* When {@code true}, the fully-qualified class name (with package) is
* included in the log output; when {@code false}, only the simple class
* name is used.
*
* <p>Property: {@code myapp.aop.controller.logging.include-class-package} (default: {@code true})</p>
*/
@Value("${myapp.aop.controller.logging.include-class-package:true}")
private boolean includeClassPackage;
/**
* When {@code true}, each parameter's type is logged with its fully-qualified
* package name; when {@code false}, only the simple type name is used.
*
* <p>Property: {@code myapp.aop.controller.logging.include-param-package} (default: {@code true})</p>
*/
@Value("${myapp.aop.controller.logging.include-param-package:true}")
private boolean includeParamPackage;
/**
* When {@code true}, the method's declaring class is logged with its
* fully-qualified package name; when {@code false}, only the simple name
* is used.
*
* <p>Property: {@code myapp.aop.controller.logging.include-method-package} (default: {@code true})</p>
*/
@Value("${myapp.aop.controller.logging.include-method-package:true}")
private boolean includeMethodPackage;
// -------------------------------------------------------------------------
// Pointcut definitions
// -------------------------------------------------------------------------
/**
* Matches any type (and therefore any method execution within that type)
* that is annotated with Spring's {@code @RestController}.
*/
@Pointcut("@within(org.springframework.web.bind.annotation.RestController)")
public void restControllerClass() { }
/**
* Matches any type annotated with Spring's {@code @Controller}.
*/
@Pointcut("@within(org.springframework.stereotype.Controller)")
public void controllerClass() { }
/**
* Composite pointcut: any public method execution inside a class that is
* annotated with either {@code @RestController} or {@code @Controller}.
*/
@Pointcut("(restControllerClass() || controllerClass()) && execution(public * *(..))")
public void allControllerEndpoints() { }
// -------------------------------------------------------------------------
// Advice
// -------------------------------------------------------------------------
/**
* Around advice applied to {@link #allControllerEndpoints()}.
*
* <p>Behaviour:</p>
* <ol>
* <li>Short-circuits immediately when {@link #enabled} is {@code false}.</li>
* <li>Resolves the controller class identifier (simple or fully-qualified)
* driven by {@link #includeClassPackage}.</li>
* <li>Builds a human-readable method signature that lists each parameter
* with its type (optionally package-qualified) and parameter name,
* driven by {@link #includeParamPackage} and {@link #includeMethodPackage}.</li>
* <li>Logs the argument values actually supplied at runtime.</li>
* <li>Measures end-to-end execution time with {@link org.springframework.util.StopWatch}.</li>
* <li>Re-throws any exception unchanged so that Spring MVC's
* {@code @ExceptionHandler} / global error handling is never bypassed.</li>
* </ol>
*
* @param pjp the proceeding join point supplied by the AspectJ runtime
* @return the value returned by the controller method, forwarded unchanged
* @throws Throwable any exception thrown by the controller method, re-thrown unchanged
*/
@Around("allControllerEndpoints()")
public Object logControllerEndpoint(ProceedingJoinPoint pjp) throws Throwable {
// Master switch โ zero overhead fast path
if (!enabled) {
return pjp.proceed();
}
org.springframework.util.StopWatch stopWatch = new org.springframework.util.StopWatch();
stopWatch.start();
try {
Object result = pjp.proceed();
stopWatch.stop();
logEntry(pjp, stopWatch.getTotalTimeMillis(), null);
return result;
} catch (Throwable ex) {
stopWatch.stop();
logEntry(pjp, stopWatch.getTotalTimeMillis(), ex);
throw ex; // always re-throw so Spring MVC error handling is unaffected
}
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Builds and emits the structured log entry.
*
* @param pjp the proceeding join point
* @param elapsedMs elapsed execution time in milliseconds
* @param thrownEx the exception thrown by the controller method, or
* {@code null} if the method returned normally
*/
private void logEntry(ProceedingJoinPoint pjp, long elapsedMs, Throwable thrownEx) {
MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
Method method = methodSignatumyapp.getMethod();
// --- Controller class identifier -----------------------------------
Class<?> targetClass = pjp.getTarget().getClass();
String classIdentifier = includeClassPackage
? targetClass.getName() // com.net.bosch.crmmaster.controller.AppointmentController
: targetClass.getSimpleName(); // AppointmentController
// --- Method declaring class identifier -----------------------------
String methodClassIdentifier = includeMethodPackage
? method.getDeclaringClass().getName()
: method.getDeclaringClass().getSimpleName();
// --- Parameter signature -------------------------------------------
Parameter[] parameters = method.getParameters();
Object[] args = pjp.getArgs();
String paramSignature = buildParameterSignature(parameters, args);
// --- Method name (with or without declaring-class package) ---------
String methodIdentifier = methodClassIdentifier + "#" + method.getName();
// --- Return type ---------------------------------------------------
String returnType = resolveTypeName(method.getReturnType());
// --- Log ----------------------------------------------------------
if (thrownEx == null) {
log.info(
"[CONTROLLER-AOP] class=[{}] method=[{}] params=[{}] returnType=[{}] timeTaken=[{} ms]",
classIdentifier,
methodIdentifier,
paramSignature,
returnType,
elapsedMs
);
} else {
log.error(
"[CONTROLLER-AOP] class=[{}] method=[{}] params=[{}] returnType=[{}] timeTaken=[{} ms] exception=[{}]",
classIdentifier,
methodIdentifier,
paramSignature,
returnType,
elapsedMs,
thrownEx.getClass().getName() + ": " + thrownEx.getMessage()
);
}
}
/**
* Constructs a readable parameter list, e.g.:
* <pre>
* (com.net.bosch.crmmaster.dto.AppointmentRequest appointmentRequest,
* java.lang.String location, int page)
* </pre>
* or, with package names disabled:
* <pre>
* (AppointmentRequest appointmentRequest, String location, int page)
* </pre>
* Actual runtime argument values are appended after a {@code =} sign so
* that both type metadata and live data are visible in one log line:
* <pre>
* (AppointmentRequest appointmentRequest=AppointmentRequest{...}, String location=IN, int page=0)
* </pre>
*
* @param parameters the formal parameters from {@link Method#getParameters()}
* @param args the actual argument values from {@link ProceedingJoinPoint#getArgs()}
* @return a single formatted string representing the full parameter list
*/
private String buildParameterSignature(Parameter[] parameters, Object[] args) {
if (parameters == null || parameters.length == 0) {
return "()";
}
StringBuilder sb = new StringBuilder("(");
for (int i = 0; i < parameters.length; i++) {
Parameter param = parameters[i];
String typeName = resolveTypeName(param.getType());
String paramName = param.isNamePresent() ? param.getName() : "arg" + i;
Object argValue = (args != null && i < args.length) ? args[i] : "<unavailable>";
sb.append(typeName)
.append(" ")
.append(paramName)
.append("=")
.append(safeToString(argValue));
if (i < parameters.length - 1) {
sb.append(", ");
}
}
sb.append(")");
return sb.toString();
}
/**
* Resolves a {@link Class} to either its fully-qualified name or its
* simple name depending on the {@link #includeParamPackage} flag.
*
* <p>Primitive types (e.g. {@code int}, {@code boolean}) are always
* returned by their keyword name regardless of the flag, since they have
* no package.</p>
*
* @param type the class whose name should be resolved
* @return the resolved type name string
*/
private String resolveTypeName(Class<?> type) {
if (type.isPrimitive() || type.isArray()) {
// Primitives and arrays: use the canonical name (e.g. "int", "int[]")
return type.getCanonicalName() != null ? type.getCanonicalName() : type.getName();
}
return includeParamPackage ? type.getName() : type.getSimpleName();
}
/**
* Safely converts an argument value to a {@link String} for logging.
*
* <p>Catches any exception thrown by the argument's {@code toString()} method
* (e.g. lazy-loaded JPA proxies, streams) and returns a safe fallback so
* that the logging aspect itself never causes a failumyapp.</p>
*
* @param value the runtime argument value, may be {@code null}
* @return a safe string representation of {@code value}
*/
private String safeToString(Object value) {
if (value == null) {
return "null";
}
try {
return value.toString();
} catch (Exception ex) {
return "<toString-failed: " + value.getClass().getName() + ">";
}
}
}