Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ logdispatch:
api-key: "your-secret-api-key"
masked-headers: "authorization,cookie,x-api-key"
exclude-paths: "/health,/actuator/**,/metrics/**"
max-stack-frames: 100
```

### application.properties
Expand All @@ -54,6 +55,7 @@ logdispatch.server-url=https://your-apm-server.com/api/v1/ingest/logs
logdispatch.api-key=your-secret-api-key
logdispatch.masked-headers=authorization,cookie,x-api-key
logdispatch.exclude-paths=/health,/actuator/**,/metrics/**
logdispatch.max-stack-frames=100
```

### Configuration Properties
Expand All @@ -66,6 +68,7 @@ logdispatch.exclude-paths=/health,/actuator/**,/metrics/**
| `logdispatch.masked-headers` | ❌ No | Comma-separated list of headers to mask. Defaults to none |
| `logdispatch.exclude-paths` | ❌ No | Comma-separated list of URI paths to exclude. Supports wildcards such as `/actuator/**` |
| `logdispatch.timeout-ms` | ❌ No | Connection and read timeout in milliseconds. Defaults to `3000`. |
| `logdispatch.max-stack-frames` | ❌ No | Maximum stack frames sent per error. Defaults to `100`. |
̉| `logdispatch.health.enabled` | ❌ No | Enables or disables the `/logdispatch/health` endpoint. Defaults to `true` |

Disable LogDispatch in local or test profiles when you want the dependency on the classpath but do not want any APM activity:
Expand All @@ -90,7 +93,7 @@ When `logdispatch.enabled=false`, the SDK passes requests through without inspec

When a `@RestController` method throws an unhandled exception, or when a filter rejects a request (e.g., `403 Forbidden`, `404 Not Found`), the SDK:

1. Captures the request URI, HTTP method, exception class, message, and full stack trace.
1. Captures the request URI, HTTP method, exception class, message, and bounded stack trace.
2. Reads optional metadata from the `@LogDispatch` annotation.
3. Asynchronously sends a JSON payload to the configured `server-url`.
4. Includes the `X-API-KEY` header for authentication.
Expand Down
56 changes: 48 additions & 8 deletions src/main/java/in/maheshlangote/logdispatch/LogDispatchFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@
private final Set<String> maskedHeaders;
private final List<String> excludePaths;
private final Executor dispatchExecutor;
private final int maxStackFrames;

private static final AntPathMatcher ANT_PATH_MATCHER = new AntPathMatcher();
private static final int DEFAULT_MAX_STACK_FRAMES = 100;

/**
* Constructs the LogDispatchFilter.
Expand All @@ -59,7 +61,7 @@
* @param timeoutMs the HTTP connection and read timeout in milliseconds
*/
public LogDispatchFilter(String serverUrl, String apiKey, List<String> maskedHeaders, List<String> excludePaths, int timeoutMs) {
this(true, serverUrl, apiKey, maskedHeaders, excludePaths, timeoutMs);
this(true, serverUrl, apiKey, maskedHeaders, excludePaths, timeoutMs, DEFAULT_MAX_STACK_FRAMES);
}

/**
Expand All @@ -74,20 +76,46 @@
*/
public LogDispatchFilter(boolean enabled, String serverUrl, String apiKey, List<String> maskedHeaders,
List<String> excludePaths, int timeoutMs) {
this(enabled, serverUrl, apiKey, maskedHeaders, excludePaths, new RestTemplate(), null, timeoutMs);
this(enabled, serverUrl, apiKey, maskedHeaders, excludePaths, timeoutMs, DEFAULT_MAX_STACK_FRAMES);
}

/**
* Constructs the LogDispatchFilter with a configurable stack trace limit.
*
* @param enabled whether LogDispatch should inspect and dispatch request errors
* @param serverUrl the endpoint URL of the centralized APM server
* @param apiKey the authentication key required by the APM server
* @param maskedHeaders list of headers to mask
* @param excludePaths list of URI paths to exclude from logging
* @param timeoutMs the HTTP connection and read timeout in milliseconds
* @param maxStackFrames maximum stack frames included in an error payload
*/
public LogDispatchFilter(boolean enabled, String serverUrl, String apiKey, List<String> maskedHeaders,
List<String> excludePaths, int timeoutMs, int maxStackFrames) {
this(enabled, serverUrl, apiKey, maskedHeaders, excludePaths, new RestTemplate(), null, timeoutMs,
maxStackFrames);
}

LogDispatchFilter(String serverUrl, String apiKey, List<String> maskedHeaders, List<String> excludePaths,
RestTemplate restTemplate, Executor dispatchExecutor, int timeoutMs) {
this(true, serverUrl, apiKey, maskedHeaders, excludePaths, restTemplate, dispatchExecutor, timeoutMs);
this(true, serverUrl, apiKey, maskedHeaders, excludePaths, restTemplate, dispatchExecutor, timeoutMs,
DEFAULT_MAX_STACK_FRAMES);
}

LogDispatchFilter(boolean enabled, String serverUrl, String apiKey, List<String> maskedHeaders,
List<String> excludePaths, RestTemplate restTemplate, Executor dispatchExecutor, int timeoutMs) {
this(enabled, serverUrl, apiKey, maskedHeaders, excludePaths, restTemplate, dispatchExecutor, timeoutMs,
DEFAULT_MAX_STACK_FRAMES);
}

LogDispatchFilter(boolean enabled, String serverUrl, String apiKey, List<String> maskedHeaders,
List<String> excludePaths, RestTemplate restTemplate, Executor dispatchExecutor, int timeoutMs,
int maxStackFrames) {
this.enabled = enabled;
this.serverUrl = serverUrl;
this.apiKey = apiKey;
this.timeoutMs = (timeoutMs > 0) ? timeoutMs : 3000;
this.maxStackFrames = (maxStackFrames > 0) ? maxStackFrames : DEFAULT_MAX_STACK_FRAMES;
this.dispatchExecutor = dispatchExecutor;
this.restTemplate = Objects.requireNonNull(restTemplate, "restTemplate");

Expand Down Expand Up @@ -162,7 +190,7 @@
// but avoid wrapping if it's a file upload or a huge payload to prevent OutOfMemory issues.
HttpServletRequest requestToUse = request;
if (shouldWrapRequest(request)) {
requestToUse = new ContentCachingRequestWrapper(request);

Check warning on line 193 in src/main/java/in/maheshlangote/logdispatch/LogDispatchFilter.java

View workflow job for this annotation

GitHub Actions / build (17)

ContentCachingRequestWrapper(jakarta.servlet.http.HttpServletRequest) in org.springframework.web.util.ContentCachingRequestWrapper has been deprecated and marked for removal

Check warning on line 193 in src/main/java/in/maheshlangote/logdispatch/LogDispatchFilter.java

View workflow job for this annotation

GitHub Actions / build (21)

ContentCachingRequestWrapper(jakarta.servlet.http.HttpServletRequest) in org.springframework.web.util.ContentCachingRequestWrapper has been deprecated and marked for removal
}

Throwable unhandledException = null;
Expand Down Expand Up @@ -251,10 +279,7 @@
try {
String severity = (statusCode >= 500) ? "CRITICAL" : "WARNING";

StringBuilder stackTrace = new StringBuilder();
for (StackTraceElement element : ex.getStackTrace()) {
stackTrace.append(element.toString()).append("\n");
}
String stackTrace = formatStackTrace(ex);

LogDispatchPayload payload = new LogDispatchPayload(
Instant.now().toString(),
Expand All @@ -266,7 +291,7 @@
api,
method,
function,
stackTrace.toString(),
stackTrace,
severity,
inputInfo
);
Expand All @@ -286,6 +311,21 @@
});
}

private String formatStackTrace(Throwable exception) {
StackTraceElement[] frames = exception.getStackTrace();
int includedFrames = Math.min(frames.length, maxStackFrames);
StringBuilder stackTrace = new StringBuilder();
for (int i = 0; i < includedFrames; i++) {
stackTrace.append(frames[i]).append("\n");
}
if (frames.length > includedFrames) {
stackTrace.append("... ")
.append(frames.length - includedFrames)
.append(" more frames truncated\n");
}
return stackTrace.toString();
}

private void dispatchAsync(Runnable task) {
if (dispatchExecutor == null) {
CompletableFuture.runAsync(task);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ public FilterRegistrationBean<LogDispatchFilter> logDispatchFilterRegistration(L
properties.getApiKey(),
properties.getMaskedHeaders(),
properties.getExcludePaths(),
properties.getTimeoutMs()
properties.getTimeoutMs(),
properties.getMaxStackFrames()
));
registrationBean.addUrlPatterns("/*");
// Use Highest Precedence to ensure it wraps everything including security filters
Expand All @@ -83,4 +84,4 @@ public FilterRegistrationBean<LogDispatchFilter> logDispatchFilterRegistration(L
public LogDispatchHealthController logDispatchHealthController(LogDispatchProperties properties) {
return new LogDispatchHealthController(properties.isEnabled());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class LogDispatchProperties {
private List<String> maskedHeaders = List.of();
private List<String> excludePaths = List.of();
private int timeoutMs = 3000;
private int maxStackFrames = 100;

/**
* Returns whether LogDispatch is enabled.
Expand Down Expand Up @@ -155,4 +156,22 @@ public int getTimeoutMs() {
public void setTimeoutMs(int timeoutMs) {
this.timeoutMs = timeoutMs;
}

/**
* Returns the maximum number of stack frames included in an error payload.
*
* @return maximum stack frames
*/
public int getMaxStackFrames() {
return maxStackFrames;
}

/**
* Sets the maximum number of stack frames included in an error payload.
*
* @param maxStackFrames maximum stack frames
*/
public void setMaxStackFrames(int maxStackFrames) {
this.maxStackFrames = maxStackFrames;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package in.maheshlangote.logdispatch;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;

import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

@DisplayName("Stack Trace Limit Tests")
class LogDispatchFilterStackTraceLimitTest extends LogDispatchFilterBaseTest {

@Test
@DisplayName("Should truncate stack traces to the configured frame limit")
void shouldTruncateStackTraceToConfiguredFrameLimit() throws Exception {
filter = new LogDispatchFilter(
true,
SERVER_URL,
API_KEY,
List.of(),
List.of(),
restTemplate,
Runnable::run,
3000,
2
);
RuntimeException exception = new RuntimeException("boom");
exception.setStackTrace(new StackTraceElement[] {
frame("first"), frame("second"), frame("third"), frame("fourth")
});
MockHttpServletRequest request = request("GET", "/fail");
request.setAttribute("logdispatch.exception", exception);
MockHttpServletResponse response = new MockHttpServletResponse();

filter.doFilter(request, response, chainWithStatus(500));

Map<String, Object> payload = dispatchedPayload();
assertThat(payload.get("stackTrace").toString())
.contains("first", "second", "... 2 more frames truncated")
.doesNotContain("third", "fourth");
}

private static StackTraceElement frame(String method) {
return new StackTraceElement("Example", method, "Example.java", 1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import in.maheshlangote.logdispatch.LogDispatchAspect;
import in.maheshlangote.logdispatch.LogDispatchHealthController;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
Expand Down Expand Up @@ -32,6 +33,16 @@ void shouldRegisterBeansWithDisabledProperties() {
assertThat(context.getBean(LogDispatchProperties.class).isEnabled()).isFalse();
});
}

@Test
@DisplayName("Should bind the configured stack trace frame limit")
void shouldBindConfiguredStackTraceFrameLimit() {
contextRunner
.withPropertyValues("logdispatch.max-stack-frames=25")
.run(context -> assertThat(context.getBean(LogDispatchProperties.class).getMaxStackFrames())
.isEqualTo(25));
}

@Test
void shouldNotRegisterHealthControllerWhenHealthDisabled() {
contextRunner
Expand Down
Loading