Skip to content

Commit 1392b58

Browse files
authored
Capture application logs (#52)
* Capture application logs * Tweaks * Fix * Extend test matrix * Simplify * Fix
1 parent f086f5f commit 1392b58

15 files changed

Lines changed: 367 additions & 26 deletions

.github/workflows/tests.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
- uses: actions/checkout@v6
1717
- uses: actions/setup-python@v6
1818
with:
19-
python-version: "3.13"
19+
python-version: "3.14"
2020
- uses: pre-commit/action@v3.0.1
2121

2222
test-coverage:
@@ -40,7 +40,7 @@ jobs:
4040
fail-fast: false
4141
matrix:
4242
java: [17, 21]
43-
spring-boot: [3.0.13, 3.1.12, 3.2.12, 3.3.8, 3.4.3]
43+
spring-boot: [3.0.13, 3.1.12, 3.2.12, 3.3.8, 3.4.3, 3.5.9]
4444
steps:
4545
- uses: actions/checkout@v6
4646
- uses: actions/setup-java@v5
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package io.apitally.common;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
6+
import org.slf4j.LoggerFactory;
7+
8+
import ch.qos.logback.classic.Logger;
9+
import ch.qos.logback.classic.LoggerContext;
10+
import ch.qos.logback.classic.spi.ILoggingEvent;
11+
import ch.qos.logback.core.AppenderBase;
12+
13+
import io.apitally.common.dto.LogRecord;
14+
15+
public class ApitallyAppender extends AppenderBase<ILoggingEvent> {
16+
private static final String NAME = "ApitallyAppender";
17+
private static final int MAX_BUFFER_SIZE = 1000;
18+
private static final int MAX_MESSAGE_LENGTH = 2048;
19+
20+
private static final ThreadLocal<List<LogRecord>> logBuffer = new ThreadLocal<>();
21+
22+
public static synchronized void register() {
23+
if (!(LoggerFactory.getILoggerFactory() instanceof LoggerContext loggerContext)) {
24+
return;
25+
}
26+
Logger rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME);
27+
28+
if (rootLogger.getAppender(NAME) != null) {
29+
return;
30+
}
31+
32+
ApitallyAppender appender = new ApitallyAppender();
33+
appender.setContext(loggerContext);
34+
appender.setName(NAME);
35+
appender.start();
36+
rootLogger.addAppender(appender);
37+
}
38+
39+
public static void startCapture() {
40+
logBuffer.set(new ArrayList<>());
41+
}
42+
43+
public static List<LogRecord> endCapture() {
44+
List<LogRecord> logs = logBuffer.get();
45+
logBuffer.remove();
46+
return logs;
47+
}
48+
49+
@Override
50+
protected void append(ILoggingEvent event) {
51+
List<LogRecord> buffer = logBuffer.get();
52+
if (buffer == null || buffer.size() >= MAX_BUFFER_SIZE) {
53+
return;
54+
}
55+
56+
double timestamp = event.getTimeStamp() / 1000.0;
57+
String loggerName = event.getLoggerName();
58+
String level = event.getLevel().toString();
59+
String message = truncateMessage(event.getFormattedMessage());
60+
61+
buffer.add(new LogRecord(timestamp, loggerName, level, message));
62+
}
63+
64+
private static String truncateMessage(String message) {
65+
if (message == null) {
66+
return null;
67+
}
68+
if (message.length() <= MAX_MESSAGE_LENGTH) {
69+
return message;
70+
}
71+
String suffix = "... (truncated)";
72+
return message.substring(0, MAX_MESSAGE_LENGTH - suffix.length()) + suffix;
73+
}
74+
}

src/main/java/io/apitally/common/ConsumerRegistry.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,9 @@ public List<Consumer> getAndResetConsumers() {
7575
updated.clear();
7676
return data;
7777
}
78+
79+
public void reset() {
80+
consumers.clear();
81+
updated.clear();
82+
}
7883
}

src/main/java/io/apitally/common/RequestLogger.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import io.apitally.common.dto.ExceptionDto;
2828
import io.apitally.common.dto.Header;
29+
import io.apitally.common.dto.LogRecord;
2930
import io.apitally.common.dto.Request;
3031
import io.apitally.common.dto.RequestLogItem;
3132
import io.apitally.common.dto.Response;
@@ -147,7 +148,7 @@ public void setSuspendUntil(long timestamp) {
147148
this.suspendUntil = timestamp;
148149
}
149150

150-
public void logRequest(Request request, Response response, Exception exception) {
151+
public void logRequest(Request request, Response response, Exception exception, List<LogRecord> logs) {
151152
if (!enabled || suspendUntil != null && suspendUntil > System.currentTimeMillis()) {
152153
return;
153154
}
@@ -181,7 +182,11 @@ public void logRequest(Request request, Response response, Exception exception)
181182
exceptionDto = new ExceptionDto(exception);
182183
}
183184

184-
RequestLogItem item = new RequestLogItem(request, response, exceptionDto);
185+
if (!config.isLogCaptureEnabled() && logs != null) {
186+
logs = null;
187+
}
188+
189+
RequestLogItem item = new RequestLogItem(request, response, exceptionDto, logs);
185190
pendingWrites.add(item);
186191

187192
if (pendingWrites.size() > MAX_PENDING_WRITES) {
@@ -280,6 +285,9 @@ public void writeToFile() throws IOException {
280285
if (item.getException() != null) {
281286
itemNode.set("exception", objectMapper.valueToTree(item.getException()));
282287
}
288+
if (item.getLogs() != null && !item.getLogs().isEmpty()) {
289+
itemNode.set("logs", objectMapper.valueToTree(item.getLogs()));
290+
}
283291

284292
String serializedItem = objectMapper.writeValueAsString(itemNode);
285293
currentFile.writeLine(serializedItem.getBytes(StandardCharsets.UTF_8));

src/main/java/io/apitally/common/RequestLoggingConfig.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public class RequestLoggingConfig {
1111
private boolean responseHeadersIncluded = true;
1212
private boolean responseBodyIncluded = false;
1313
private boolean exceptionIncluded = true;
14+
private boolean logCaptureEnabled = false;
1415
private List<String> queryParamMaskPatterns = new ArrayList<>();
1516
private List<String> headerMaskPatterns = new ArrayList<>();
1617
private List<String> bodyFieldMaskPatterns = new ArrayList<>();
@@ -73,6 +74,14 @@ public void setExceptionIncluded(boolean exceptionIncluded) {
7374
this.exceptionIncluded = exceptionIncluded;
7475
}
7576

77+
public boolean isLogCaptureEnabled() {
78+
return logCaptureEnabled;
79+
}
80+
81+
public void setLogCaptureEnabled(boolean logCaptureEnabled) {
82+
this.logCaptureEnabled = logCaptureEnabled;
83+
}
84+
7685
public List<String> getQueryParamMaskPatterns() {
7786
return queryParamMaskPatterns;
7887
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package io.apitally.common.dto;
2+
3+
public class LogRecord {
4+
private final double timestamp;
5+
private final String logger;
6+
private final String level;
7+
private final String message;
8+
9+
public LogRecord(double timestamp, String logger, String level, String message) {
10+
this.timestamp = timestamp;
11+
this.logger = logger;
12+
this.level = level;
13+
this.message = message;
14+
}
15+
16+
public double getTimestamp() {
17+
return timestamp;
18+
}
19+
20+
public String getLogger() {
21+
return logger;
22+
}
23+
24+
public String getLevel() {
25+
return level;
26+
}
27+
28+
public String getMessage() {
29+
return message;
30+
}
31+
}

src/main/java/io/apitally/common/dto/RequestLogItem.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.apitally.common.dto;
22

3+
import java.util.List;
34
import java.util.UUID;
45

56
import com.fasterxml.jackson.annotation.JsonProperty;
@@ -9,12 +10,14 @@ public class RequestLogItem extends BaseDto {
910
private final Request request;
1011
private final Response response;
1112
private final ExceptionDto exception;
13+
private final List<LogRecord> logs;
1214

13-
public RequestLogItem(Request request, Response response, ExceptionDto exception) {
15+
public RequestLogItem(Request request, Response response, ExceptionDto exception, List<LogRecord> logs) {
1416
this.uuid = UUID.randomUUID().toString();
1517
this.request = request;
1618
this.response = response;
1719
this.exception = exception;
20+
this.logs = logs;
1821
}
1922

2023
@JsonProperty("uuid")
@@ -36,4 +39,9 @@ public Response getResponse() {
3639
public ExceptionDto getException() {
3740
return exception;
3841
}
42+
43+
@JsonProperty("logs")
44+
public List<LogRecord> getLogs() {
45+
return logs;
46+
}
3947
}

src/main/java/io/apitally/spring/ApitallyAutoConfiguration.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import org.springframework.core.Ordered;
1111
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
1212

13+
import io.apitally.common.ApitallyAppender;
1314
import io.apitally.common.ApitallyClient;
1415
import io.apitally.common.dto.Path;
1516

@@ -25,6 +26,11 @@ public ApitallyClient apitallyClient(ApitallyProperties properties,
2526
Map<String, String> versions = ApitallyUtils.getVersions();
2627
client.setStartupData(paths, versions, "java:spring");
2728
client.startSync();
29+
30+
if (properties.getRequestLogging().isEnabled() && properties.getRequestLogging().isLogCaptureEnabled()) {
31+
ApitallyAppender.register();
32+
}
33+
2834
return client;
2935
}
3036

src/main/java/io/apitally/spring/ApitallyFilter.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.io.IOException;
44
import java.util.Collections;
5+
import java.util.List;
56

67
import org.slf4j.Logger;
78
import org.slf4j.LoggerFactory;
@@ -13,13 +14,16 @@
1314
import org.springframework.web.util.ContentCachingRequestWrapper;
1415
import org.springframework.web.util.ContentCachingResponseWrapper;
1516

17+
import io.apitally.common.ApitallyAppender;
1618
import io.apitally.common.ApitallyClient;
1719
import io.apitally.common.ConsumerRegistry;
1820
import io.apitally.common.RequestLogger;
1921
import io.apitally.common.dto.Consumer;
2022
import io.apitally.common.dto.Header;
23+
import io.apitally.common.dto.LogRecord;
2124
import io.apitally.common.dto.Request;
2225
import io.apitally.common.dto.Response;
26+
2327
import jakarta.servlet.FilterChain;
2428
import jakarta.servlet.ServletException;
2529
import jakarta.servlet.ServletOutputStream;
@@ -65,9 +69,16 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht
6569
? new CountingResponseWrapper(response)
6670
: null;
6771

72+
final boolean shouldCaptureLogs = client.requestLogger.getConfig().isEnabled()
73+
&& client.requestLogger.getConfig().isLogCaptureEnabled();
74+
6875
Exception exception = null;
6976
final long startTime = System.currentTimeMillis();
7077

78+
if (shouldCaptureLogs) {
79+
ApitallyAppender.startCapture();
80+
}
81+
7182
try {
7283
filterChain.doFilter(
7384
cachingRequest != null ? cachingRequest : request,
@@ -76,6 +87,7 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht
7687
exception = e;
7788
throw e;
7889
} finally {
90+
final List<LogRecord> capturedLogs = shouldCaptureLogs ? ApitallyAppender.endCapture() : null;
7991
final long responseTimeInMillis = System.currentTimeMillis() - startTime;
8092
final String path = (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
8193

@@ -126,7 +138,8 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht
126138
request.getRequestURL().toString(), requestHeaders, requestSize, requestBody),
127139
new Response(response.getStatus(), responseTimeInMillis / 1000.0, responseHeaders,
128140
responseSize, responseBody),
129-
exception);
141+
exception,
142+
capturedLogs);
130143
}
131144

132145
// Add validation error to counter

0 commit comments

Comments
 (0)