Zero‑Intrusion AOP for Global API Logging in Spring Boot
The article explains how to implement a zero‑intrusion, AOP‑based global API logging solution for Spring Boot applications, addressing common issues of manual logging such as code clutter, missing logs, inconsistent formats, privacy leaks, and performance overhead by using repeatable request wrappers, configurable filters, structured DTOs, asynchronous persistence, and comprehensive trace and desensitization utilities.
Why Traditional Hard‑Coded Logging Fails
Almost every backend project needs API logs that capture request parameters, response data, latency, client IP, exceptions, trace IDs, and the operator. Hard‑coded logging in each controller leads to bloated code, high business intrusion, missing logs from new developers, inconsistent log formats that hinder ELK searches, loss of exception stacks, privacy violations (e.g., plain‑text phone numbers or ID cards), massive log payloads that fill disks, and tangled parsing of GET/POST, form, JSON, and file uploads.
AOP as the Optimal Solution
Aspect‑Oriented Programming (AOP) solves all the above problems by providing cross‑cutting interception with zero business intrusion, unified log format, full‑interface coverage, extensibility, auditability, and observability.
Core Design Principles
Zero Intrusion : No changes to business code, no new annotations, automatic interception.
Full Coverage : Handles GET, POST, PUT, DELETE, form data, JSON, and file uploads.
High Availability : Aspect exceptions automatically degrade without affecting the main business flow.
High Performance : Asynchronous persistence, length truncation, and non‑blocking logging.
Security Compliance : Mandatory desensitization to prevent plain‑text privacy data.
Observability : Captures TraceID, latency, IP, operator, and full exception stack.
Complete Execution Flow
Request enters the container → a repeatable‑read filter caches the body → AOP around advice intercepts the controller → request data is parsed and parameters are desensitized → start time is recorded → business logic executes → normal or exception result is captured → log DTO is assembled → console logs are printed with level‑based formatting → asynchronous persistence → ThreadLocal is cleared to avoid memory leaks.
Prerequisite Maven Dependencies
<!-- Web core -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- AOP core -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- JSON serialization -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.48</version>
</dependency>
<!-- Async thread pool -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-task</artifactId>
</dependency>Pre‑Processing Components
1. Repeatable Request Wrapper
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.*;
public class RepeatReadRequestWrapper extends HttpServletRequestWrapper {
private final byte[] body;
public RepeatReadRequestWrapper(HttpServletRequest request) throws IOException {
super(request);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
InputStream is = request.getInputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
body = baos.toByteArray();
}
@Override
public ServletInputStream getInputStream() {
return new ByteArrayInputStream(body);
}
public String getBodyString() {
return new String(body);
}
}2. Global Filter Registration
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class RepeatReadFilter implements Filter {
@Override
public void doFilter(jakarta.servlet.ServletRequest request, jakarta.servlet.ServletResponse response, FilterChain chain) throws IOException, ServletException {
if (request instanceof HttpServletRequest) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
RepeatReadRequestWrapper wrapper = new RepeatReadRequestWrapper(httpRequest);
chain.doFilter(wrapper, response);
return;
}
chain.doFilter(request, response);
}
}Utility Classes
Log DTO
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ApiLogDTO {
private String traceId;
private LocalDateTime requestTime;
private String requestUrl;
private String requestMethod;
private String requestContentType;
private String clientIp;
private String serverIp;
private String requestHeader;
private String requestParams;
private String responseResult;
private Long costTime;
private String costLevel;
private String operateUser;
private Boolean success;
private String errorMsg;
private String errorStack;
}IP Utility (supports multi‑level proxy headers)
public class HttpIpUtil {
public static String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
return ip.split(",")[0].trim();
}
ip = request.getHeader("X-Real-IP");
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
return ip.trim();
}
ip = request.getHeader("Proxy-Client-IP");
if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) {
return ip.trim();
}
return request.getRemoteAddr();
}
}Desensitization Utility
public class DesensitizeUtil {
public static String phone(String str) {
if (str == null || str.length() != 11) return str;
return str.substring(0, 3) + "****" + str.substring(7);
}
public static String idCard(String str) {
if (str == null || str.length() != 18) return str;
return str.substring(0, 4) + "**********" + str.substring(14);
}
public static String bankCard(String str) {
if (str == null || str.length() < 10) return str;
return "****" + str.substring(str.length() - 4);
}
public static String autoDesensitize(String json) {
if (json == null || json.length() > 20000) return json;
json = json.replaceAll("1[3-9]\\d{9}", "***********");
json = json.replaceAll("[1-9]\\d{17}", "******************");
json = json.replaceAll("[1-9]\\d{13}", "*************");
return json;
}
}Asynchronous Thread‑Pool Configuration
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@EnableAsync
@Configuration
public class AsyncLogConfig {
@Bean("logAsyncExecutor")
public Executor logAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("log-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}Logging Aspect
import com.alibaba.fastjson2.JSON;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
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.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.time.LocalDateTime;
import java.util.List;
@Slf4j
@Aspect
@Component
public class ApiLogAspect {
@Value("${api.log.enable:true}")
private Boolean logEnable;
@Value("${api.log.max-length:5000}")
private Integer maxParamLength;
@Value("${api.log.slow-time:1000}")
private Long slowTime;
@Value("${api.log.exclude-url:}")
private List<String> excludeUrlList;
@Pointcut("execution(public * com.*..controller.*.*(..))")
public void apiPointCut() {}
@Around("apiPointCut()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
if (!logEnable) {
return pjp.proceed();
}
ApiLogDTO apiLog = new ApiLogDTO();
apiLog.setTraceId(TraceUtil.getTraceId());
apiLog.setRequestTime(LocalDateTime.now());
apiLog.setSuccess(true);
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String url = request.getRequestURI();
if (excludeUrlList.stream().anyMatch(url::startsWith)) {
return pjp.proceed();
}
apiLog.setRequestUrl(url);
apiLog.setRequestMethod(request.getMethod());
apiLog.setRequestContentType(request.getContentType());
apiLog.setClientIp(HttpIpUtil.getClientIp(request));
apiLog.setServerIp(request.getLocalAddr());
apiLog.setOperateUser(getCurrentUser(request));
String requestParams = parseRequestParams(request, pjp);
requestParams = DesensitizeUtil.autoDesensitize(requestParams);
apiLog.setRequestParams(truncate(requestParams));
long start = System.currentTimeMillis();
Object result = null;
try {
result = pjp.proceed();
String resStr = DesensitizeUtil.autoDesensitize(JSON.toJSONString(result));
apiLog.setResponseResult(truncate(resStr));
} catch (Throwable e) {
apiLog.setSuccess(false);
apiLog.setErrorMsg(e.getMessage());
apiLog.setErrorStack(truncateStack(getStackTrace(e)));
throw e;
} finally {
long cost = System.currentTimeMillis() - start;
apiLog.setCostTime(cost);
apiLog.setCostLevel(cost >= slowTime ? "SLOW" : (cost > 200 ? "NORMAL" : "FAST"));
if (apiLog.getSuccess()) {
if ("SLOW".equals(apiLog.getCostLevel())) {
log.warn("[Slow API Alert] {}", JSON.toJSONString(apiLog));
} else {
log.info("[API Log] {}", JSON.toJSONString(apiLog));
}
} else {
log.error("[API Exception Log] {}", JSON.toJSONString(apiLog));
}
asyncSaveLog(apiLog);
TraceUtil.clear();
}
return result;
}
private String parseRequestParams(HttpServletRequest request, ProceedingJoinPoint pjp) {
String contentType = request.getContentType();
if (contentType != null && contentType.contains("multipart/form-data")) {
return "[File upload request, body ignored]";
}
if ("POST".equalsIgnoreCase(request.getMethod()) && contentType != null && contentType.contains("application/json")) {
if (request instanceof RepeatReadRequestWrapper) {
return ((RepeatReadRequestWrapper) request).getBodyString();
}
}
return JSON.toJSONString(pjp.getArgs());
}
private String truncate(String str) {
if (str == null) return "";
if (str.length() > maxParamLength) {
return str.substring(0, maxParamLength) + "...[Truncated]";
}
return str;
}
private String truncateStack(String stack) {
if (stack.length() > 8000) {
return stack.substring(0, 8000) + "...[Stack Truncated]";
}
return stack;
}
private String getStackTrace(Throwable e) {
StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw));
return sw.toString();
}
private String getCurrentUser(HttpServletRequest request) {
String token = request.getHeader("token");
return token == null ? "Anonymous" : "Authenticated User";
}
@Async("logAsyncExecutor")
public void asyncSaveLog(ApiLogDTO logDTO) {
// Implement persistence to MySQL / Redis / MQ here.
}
}AOP Global Configuration
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true, exposeProxy = true)
public class AopConfig {}Dynamic YAML Configuration (application.yml)
# Global API log configuration
api:
log:
enable: true # master switch
max-length: 5000 # max field length
slow-time: 1000 # slow‑API threshold (ms)
exclude-url: /actuator,/health,/swagger,/v3/api-docs,/favicon.icoSupported Scenarios
GET requests : URL parameters are automatically parsed, desensitized, and latency is recorded.
POST JSON : Body is read via RepeatReadRequestWrapper, solving the classic loss‑of‑parameters bug.
POST form : Method arguments are captured and logged normally.
File upload : multipart/form-data is detected and the binary payload is skipped to prevent log explosion.
Exception handling : Exception message, full stack trace, and failure flag are logged.
Slow API detection : Requests exceeding the configured threshold trigger a WARN log for performance tuning.
Common Pitfalls and Resolutions
POST body unreadable after AOP : The servlet input stream can be read only once. Solution – wrap the request with RepeatReadRequestWrapper to cache the body.
Internal this calls not intercepted : Enable exposeProxy=true and use AopContext.currentProxy() for self‑invocation.
Duplicate log entries : Overly broad pointcuts capture non‑controller classes. Solution – use a precise pointcut like execution(public * com.*..controller.*.*(..)).
OOM due to huge payloads : Dynamically truncate overly long parameters and skip file‑upload streams.
ThreadLocal memory leak : Ensure TraceUtil.clear() is called in a finally block.
Incorrect client IP behind Nginx : Retrieve IP from X-Forwarded-For, X-Real-IP, and fallback to request.getRemoteAddr().
Aspect exception breaking business : Isolate try‑catch inside the aspect, log asynchronously, and degrade gracefully.
Conclusion
This zero‑intrusion AOP framework provides a comprehensive, configurable, and high‑performance solution for global API logging in Spring Boot applications. It eliminates manual logging boilerplate, ensures consistent and secure log output, supports asynchronous persistence, and offers built‑in mechanisms for handling large payloads, privacy desensitization, and slow‑API alerts.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
