diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42dc0f0..8d58b09 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.0.7] - 2026-06-20
+### Added
+- Implemented a new `SECURITY` severity classification for unhandled filter-level exceptions.
+- Added strict type safety to the dispatch payload structure via `LogDispatchPayload`.
+- Added support for excluding specific URI paths from being intercepted by the filter.
+- Added support for tracking and masking additional HTTP headers in `LogDispatchFilter`.
+- Added `LogDispatchAspectTest` for full AOP test coverage.
+
+### Changed
+- Refactored `LogDispatchFilterTest` into focused, SRP-compliant test files with a shared base test class.
+- Simplified GitHub Actions CI/CD workflows and implemented a PR gating mechanism.
+- Bumped `spring.boot.version` dependency from `3.3.2` to `3.5.15`.
+
+### Documentation
+- Added explicit testing guidelines and contribution instructions via `TESTING.md`.
+- Added a comprehensive troubleshooting guide (`TROUBLESHOOTING.md`).
+- Added `application.properties` configuration examples.
+- Removed deprecated references to `logdispatch.enabled`.
+- Added CI, Java, and License status badges to `README.md`.
+
## [1.0.6] - 2026-06-12
### Changed
- Upgraded internal build infrastructure: Maven wrapper bumped to 3.9.8 and `maven-compiler-plugin` to 3.15.0.
diff --git a/README.md b/README.md
index cf7a2a9..3b694ee 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,29 @@
# LogDispatch Spring Boot Starter
[](https://central.sonatype.com/artifact/in.maheshlangote/logdispatch-spring-boot-starter)
+[](https://github.com/Mahesh-Langote/logdispatch/actions)
+[](https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html)
+[](https://opensource.org/licenses/MIT)
-A lightweight, zero-configuration **Application Performance Monitoring (APM) client** for Spring Boot. Uses Spring AOP to automatically intercept unhandled exceptions from `@RestController` classes and dispatches them asynchronously to your centralized log server.
+A lightweight, zero-configuration **Application Performance Monitoring (APM) client** for Spring Boot.
+
+It uses Spring AOP to automatically intercept unhandled exceptions from `@RestController` classes and dispatch them asynchronously to your centralized log server.
---
## Features
-- **Zero Code Changes** — Works out of the box with no changes to your controllers or exception handlers.
-- **Asynchronous** — All log pushes run in a `CompletableFuture` fire-and-forget thread — zero impact on API response times.
-- **Resilient** — Fails silently if the log server is unreachable. Your app never crashes due to a monitoring failure.
-- **Multi-Tenant Ready** — Uses an `X-API-KEY` header to authenticate and route logs to the correct destination.
-- **Customizable** — Use the `@LogDispatch` annotation to control how errors appear on your dashboard.
+* **Zero Code Changes** — Works out of the box with no changes to your controllers or exception handlers.
+* **Asynchronous** — All log pushes run in a `CompletableFuture` fire-and-forget thread with minimal impact on API response times.
+* **Resilient** — Fails silently if the log server is unreachable. Your application never crashes because of monitoring failures.
+* **Multi-Tenant Ready** — Uses an `X-API-KEY` header to authenticate and route logs correctly.
+* **Customizable** — Use the `@LogDispatch` annotation to control how errors appear on your dashboard.
---
-## Installation
+# Installation
-Add to your `pom.xml`:
+Add the dependency to your `pom.xml`:
```xml
@@ -28,66 +33,96 @@ Add to your `pom.xml`:
```
----
-
## Configuration
-Add to your `application.yml`:
+### application.yml
```yaml
logdispatch:
+ enabled: true
server-url: "https://your-apm-server.com/api/v1/ingest/logs"
api-key: "your-secret-api-key"
masked-headers: "authorization,cookie,x-api-key"
exclude-paths: "/health,/actuator/**,/metrics/**"
```
-| Property | Required | Description |
-|------------------------------|----------|--------------------------------------------------------------------------|
-| `logdispatch.server-url` | ✅ Yes | Full URL of the APM ingest endpoint |
-| `logdispatch.api-key` | ✅ Yes | API key used to authenticate with the APM server |
-| `logdispatch.masked-headers` | ❌ No | Comma-separated list of headers to mask. Defaults to empty (none masked) |
-| `logdispatch.exclude-paths` | ❌ No | Comma-separated list of URI paths to exclude from logging. Supports wildcard patterns (e.g., `/health,/actuator/**`). Defaults to empty (all paths logged) |
+### application.properties
+
+```properties
+logdispatch.enabled=true
+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/**
+```
+
+### Configuration Properties
+
+| Property | Required | Description |
+| ---------------------------- | -------- | --------------------------------------------------------------------------------------- |
+| `logdispatch.enabled` | ❌ No | Enables or disables the SDK. Defaults to `true` |
+| `logdispatch.server-url` | ✅ Yes, when enabled | Full URL of the APM ingest endpoint |
+| `logdispatch.api-key` | ✅ Yes, when enabled | API key used to authenticate with the APM server |
+| `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`. |
+
+Disable LogDispatch in local or test profiles when you want the dependency on the classpath but do not want any APM activity:
+
+```yaml
+# application-dev.yml
+logdispatch:
+ enabled: false
+
+# application-prod.yml
+logdispatch:
+ enabled: true
+ server-url: "https://apm.mycompany.com/ingest"
+ api-key: "${APM_API_KEY}"
+```
+
+When `logdispatch.enabled=false`, the SDK passes requests through without inspecting or dispatching errors, and the health endpoint reports that LogDispatch is disabled.
---
-## How It Works
+# How It Works
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**.
-2. Reads optional metadata from the `@LogDispatch` annotation (feature, api, function names).
-3. Asynchronously `POST`s a JSON payload to `server-url` with `X-API-KEY` in the header.
-4. Logs a `WARN` to your application log if the push fails — and continues silently.
+1. Captures the request URI, HTTP method, exception class, message, and full 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.
+5. Logs a warning if the push fails and continues execution without affecting the application.
---
-## What This SDK Sends
+# What This SDK Sends
Every exception is pushed as a `POST` request to the configured `server-url`.
-### Request Headers
+## Request Headers
-| Header | Value |
-|-----------------|---------------------------|
-| `Content-Type` | `application/json` |
-| `X-API-KEY` | Value of `logdispatch.api-key` |
+| Header | Value |
+| -------------- | ------------------------------ |
+| `Content-Type` | `application/json` |
+| `X-API-KEY` | Value of `logdispatch.api-key` |
-### Request Body
+## Request Body Example
```json
{
- "timestamp": "2026-05-28T17:58:43.805Z",
- "errorType": "IllegalArgumentException",
- "statusCode": 500,
- "errorMessage": "Invalid entries",
- "errorPath": "/api/v1/user/create",
- "affectedFeature": "UserController",
- "affectedAPI": "/api/v1/user/create",
- "apiType": "POST",
+ "timestamp": "2026-05-28T17:58:43.805Z",
+ "errorType": "IllegalArgumentException",
+ "statusCode": 500,
+ "errorMessage": "Invalid entries",
+ "errorPath": "/api/v1/user/create",
+ "affectedFeature": "UserController",
+ "affectedAPI": "/api/v1/user/create",
+ "apiType": "POST",
"affectedFunction": "createUser",
- "stackTrace": "java.lang.IllegalArgumentException: Invalid entries\n\tat com.example...",
- "severity": "CRITICAL",
+ "stackTrace": "java.lang.IllegalArgumentException: Invalid entries\n\tat com.example...",
+ "severity": "CRITICAL",
"inputInformation": {
"queryString": null,
"parameters": {},
@@ -100,39 +135,49 @@ Every exception is pushed as a `POST` request to the configured `server-url`.
}
```
-| Field | Type | Description |
-|---------------------|----------|---------------------------------------------------------------------|
-| `timestamp` | `String` | ISO-8601 UTC timestamp of when the exception occurred |
-| `errorType` | `String` | Simple class name of the exception (e.g. `NullPointerException`) |
-| `statusCode` | `Number` | HTTP status code — `500` for unhandled, `4xx` for known errors |
-| `errorMessage` | `String` | The exception's `.getMessage()` value |
-| `errorPath` | `String` | The request URI where the exception was thrown |
-| `affectedFeature` | `String` | Controller class name, or value from `@LogDispatch(feature = "...")` |
-| `affectedAPI` | `String` | Request path, or value from `@LogDispatch(api = "...")` |
-| `apiType` | `String` | The HTTP request method (e.g., `GET`, `POST`, `PUT`, `DELETE`) |
-| `affectedFunction` | `String` | Method name, or value from `@LogDispatch(function = "...")` |
-| `stackTrace` | `String` | Full stack trace as a newline-separated string |
-| `severity` | `String` | `CRITICAL` for 5xx, `WARNING` for 4xx |
-| `inputInformation` | `Object` | Request details (query params, headers, and body up to 32 KB) |
+## Payload Fields
+
+| Field | Type | Description |
+| ------------------ | ------ | -------------------------------------------------------- |
+| `timestamp` | String | ISO-8601 UTC timestamp |
+| `errorType` | String | Exception class name |
+| `statusCode` | Number | HTTP status code |
+| `errorMessage` | String | Exception message |
+| `errorPath` | String | Request URI |
+| `affectedFeature` | String | Controller name or annotation override |
+| `affectedAPI` | String | API path or annotation override |
+| `apiType` | String | HTTP method |
+| `affectedFunction` | String | Method name or annotation override |
+| `stackTrace` | String | Full stack trace |
+| `severity` | String | WARNING, CRITICAL, or SECURITY |
+| `inputInformation` | Object | Request metadata including headers, parameters, and body |
+
+> **Note:** `inputInformation.body` is skipped for `multipart/form-data` uploads or payloads larger than 32 KB.
-> **Note:** For safety, `inputInformation.body` is skipped for `multipart/form-data` uploads or if the payload exceeds 32 KB to prevent `OutOfMemory` issues.
+---
-### Severity Mapping
+## Severity Mapping
-| HTTP Status | Severity |
-|-------------|--------------|
-| `4xx` | `WARNING` |
-| `5xx` | `CRITICAL` |
+| HTTP Status / Condition | Severity |
+| ----------- | -------- |
+| 4xx (Exception) | WARNING |
+| 5xx (Exception) | CRITICAL |
+| Filter/Routing Error | SECURITY |
---
-## Server Health Check
+# Server Health Check
-The starter automatically exposes a public, lightweight HTTP endpoint that your APM server can poll to check if the application is alive and calculate its total uptime.
+The starter automatically exposes a lightweight endpoint that allows your APM server to verify application health and uptime.
-**Endpoint:** `GET /logdispatch/health`
+### Endpoint
+
+```http
+GET /logdispatch/health
+```
+
+### Response
-**Response (`200 OK`):**
```json
{
"status": "UP",
@@ -141,49 +186,79 @@ The starter automatically exposes a public, lightweight HTTP endpoint that your
}
```
-> **Note:** To prevent abuse, this endpoint has a built-in strict rate limiter of **60 requests per minute per IP address**. Exceeding this limit will return a `429 Too Many Requests` response.
+### Rate Limiting
+
+To prevent abuse, the endpoint is limited to:
+
+```text
+60 requests per minute per IP
+```
+
+Requests exceeding the limit receive:
+
+```http
+429 Too Many Requests
+```
---
-## What Your Server Must Return
+# Expected Server Responses
-Your APM ingest endpoint must comply with the following contract for the SDK to behave correctly.
+Your APM ingest endpoint should follow this contract.
-### ✅ Success — `2xx`
+## Success (2xx)
-Any `2xx` response is treated as success. The SDK does not process the response body.
+Any `2xx` response is treated as successful.
+
+The SDK ignores the response body.
+
+---
-### ❌ `401 Unauthorized` — Bad API Key
+## Unauthorized (401)
-Return a JSON body describing the issue. The SDK will log it as:
+Example response:
+```json
+{
+ "status": 401,
+ "error": "Unauthorized",
+ "message": "Invalid API key"
+}
```
-WARN [LogDispatch] Failed to push error: 401 UNAUTHORIZED : {"status":401,"error":"Unauthorized","message":"..."}
+
+SDK log:
+
+```text
+WARN [LogDispatch] Failed to push error: 401 UNAUTHORIZED : {"status":401,"error":"Unauthorized","message":"Invalid API key"}
```
-### ❌ `4xx` / `5xx` — Any other error
+---
-The SDK catches these, extracts the full response body, and logs at `WARN` level:
+## Other 4xx / 5xx Errors
-```
+Example SDK log:
+
+```text
WARN [LogDispatch] Failed to push error: 500 INTERNAL_SERVER_ERROR : {"status":500,...}
```
-### ❌ Network / Connection Error
+---
+
+## Network Failure
-If the server is unreachable, the SDK logs at `WARN` level and continues:
+Example SDK log:
-```
+```text
WARN [LogDispatch] Failed to push error: Connection refused: connect
```
-> **Important:** The SDK **never rethrows** any exception. It always fails silently so your application is never affected.
+> **Important:** The SDK never rethrows exceptions. Monitoring failures never affect the application.
---
-## Optional: `@LogDispatch` Annotation
+# Optional: @LogDispatch Annotation
-Override the default metadata (class name / method name / request path) with human-readable labels:
+Override default metadata with human-readable labels.
```java
import in.maheshlangote.logdispatch.annotation.LogDispatch;
@@ -193,24 +268,55 @@ import in.maheshlangote.logdispatch.annotation.LogDispatch;
public class PaymentController {
@PostMapping("/pay")
- @LogDispatch(api = "Process Payment", function = "handlePayment")
+ @LogDispatch(
+ api = "Process Payment",
+ function = "handlePayment"
+ )
public void handlePayment() {
// ...
}
}
```
-If an exception is thrown here, the payload will contain:
+Generated payload:
```json
{
- "affectedFeature": "Payment Gateway",
- "affectedAPI": "Process Payment",
+ "affectedFeature": "Payment Gateway",
+ "affectedAPI": "Process Payment",
"affectedFunction": "handlePayment"
}
```
-Without the annotation, it defaults to the controller class name, method name, and raw request URI.
+Without the annotation, the SDK defaults to:
+
+* Controller class name
+* Method name
+* Raw request URI
+
+---
+
+# Testing & Contributing
+
+If you want to contribute to this project, please follow our established automation testing best practices.
+
+Please see the [TESTING.md](TESTING.md) file for detailed guidelines on how to run, structure, and write tests for this SDK.
+
+---
+
+# Troubleshooting
+
+For common problems and solutions, see:
+
+```text
+TROUBLESHOOTING.md
+```
+
+---
+
+## Example App
+
+A runnable Spring Boot demo is available in [example-app](./example-app). It includes dummy REST endpoints that intentionally throw exceptions so you can see LogDispatch capture and dispatch APM log payloads.
---
diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 0000000..e39ad54
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,37 @@
+# Testing Guidelines
+
+We welcome and encourage contributions to the LogDispatch project! To maintain the quality and reliability of the SDK, we strictly adhere to automation testing best practices.
+
+If you are adding new features, fixing bugs, or refactoring code, please ensure your tests comply with the following guidelines.
+
+## 1. Run Tests Locally
+Before opening a Pull Request, always verify that your changes haven't broken existing functionality. You can execute the entire test suite locally using the Maven Wrapper:
+```bash
+./mvnw test
+```
+
+## 2. File Structure & Single Responsibility
+Do not create or add to large, monolithic test classes. Each test file should focus on testing a specific responsibility (Single Responsibility Principle).
+If you are adding a completely new behavior, create a new, appropriately named test file.
+
+## 3. Use the Base Test Class
+When writing tests related to the `LogDispatchFilter`, extend the abstract `LogDispatchFilterBaseTest.java` class.
+This base class contains:
+* Shared constants (API keys, URLs).
+* Pre-configured mocks (e.g., `RestTemplate`).
+* Helper methods for creating dummy `HttpServletRequest` and `FilterChain` objects.
+* Utility methods for capturing and verifying the dispatched JSON payload.
+
+Reusing these utilities keeps the test suite DRY (Don't Repeat Yourself).
+
+## 4. Naming Conventions
+* **Classes:** Test classes must end in `*Test.java` (e.g., `LogDispatchFilterHeaderMaskingTest.java`). The `maven-surefire-plugin` is configured to automatically detect and execute these classes.
+* **Methods:** Test methods should be descriptively named (e.g., `shouldMaskConfiguredHeaderInApmPayload`).
+* **Display Names:** Always decorate your test classes and methods with the JUnit 5 `@DisplayName` annotation to provide clean, human-readable output in the test logs.
+
+## 5. Testing the AOP Aspect
+Any tests verifying the AOP interception logic (which intercepts `@RestController` exceptions and reads `@LogDispatch` annotations) should be placed inside `LogDispatchAspectTest.java` or in similarly named isolated files.
+
+---
+
+Thank you for helping us keep LogDispatch robust!
diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
new file mode 100644
index 0000000..b47d5c7
--- /dev/null
+++ b/TROUBLESHOOTING.md
@@ -0,0 +1,83 @@
+# Troubleshooting
+
+Frequently asked questions and common issues when using LogDispatch.
+
+---
+
+## Why are no errors appearing on my APM dashboard?
+
+**Possible causes and fixes:**
+
+1. **Missing or incorrect configuration:** Verify `logdispatch.server-url` and `logdispatch.api-key` are set and point to your actual APM server.
+2. **No exceptions thrown:** LogDispatch only captures unhandled exceptions from `@RestController` classes and 4xx/5xx filter-level errors. Controlled responses (e.g., `ResponseEntity.status(400).body(...)` without throwing) are not intercepted.
+3. **Network issues:** Check your application logs for `WARN [LogDispatch]` entries. The SDK logs connection failures silently — check for messages like `Connection refused` or `401 UNAUTHORIZED`.
+
+```yml
+# Verify your configuration:
+logdispatch:
+ server-url: "https://your-apm-server.com/api/v1/ingest/logs"
+ api-key: "your-secret-api-key"
+```
+
+---
+
+## Why is the `/logdispatch/health` endpoint returning 429?
+
+The health endpoint has a built-in rate limit of **60 requests per minute per IP address**. If your APM server polls more frequently, it will receive `429 Too Many Requests` responses.
+
+The rate limit uses a 60-second sliding window per client IP. If your monitoring needs a higher frequency, you can:
+
+- Reduce the polling interval to once every second (60 req/min max).
+- Have multiple clients spread across different IPs.
+- Upgrade to a future release that supports a configurable rate limit (tracked in issue #14).
+
+---
+
+## Why are sensitive headers still visible in the APM payload?
+
+The `logdispatch.masked-headers` property masks header values in the `inputInformation.headers` section of the payload. Check the following:
+
+1. **Header names must be lowercase:** The masking is case-insensitive, but the list uses lowercase internally. Use `authorization` not `Authorization`.
+2. **Comma-separated format:** Each header name should be separated by a comma. Leading/trailing spaces are trimmed.
+
+```yml
+# Correct format for masked headers:
+logdispatch:
+ masked-headers: "authorization,cookie,x-api-key,set-cookie"
+```
+
+3. **Verify in application logs:** The masked headers will appear as `********` in the payload sent to the APM server. No additional code changes are needed.
+
+---
+
+## Why is the SDK dispatching on paths I have excluded?
+
+The `logdispatch.exclude-paths` property uses [Ant-style path patterns](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/util/AntPathMatcher.html) and is matched against the request URI.
+
+Common mistakes:
+
+1. **Pattern syntax:** Patterns like `/health` match exactly that path. Wildcard patterns like `/actuator/**` match all sub-paths.
+2. **Comma-separated format:** Paths must be comma-separated. Whitespace around commas is trimmed.
+3. **Case sensitivity:** URIs are matched case-sensitively.
+
+```yml
+# Correct format for exclude paths:
+logdispatch:
+ exclude-paths: "/health,/actuator/**,/metrics/**,/swagger-ui/**"
+```
+
+> **Note:** The `/logdispatch/health` endpoint is automatically excluded and does not need to be added to `exclude-paths`.
+
+---
+
+## Why are there duplicate log entries on my APM dashboard?
+
+Duplicate entries typically occur in one of these scenarios:
+
+1. **Multiple instances of LogDispatch:** If your application has multiple LogDispatch configurations (e.g., manual bean registration alongside auto-configuration), the filter may be registered more than once.
+
+2. **Exception re-throwing:** If a global `@ControllerAdvice` catches an exception and re-throws it, or returns a 4xx/5xx status, the filter may capture it a second time.
+
+3. **Filter and Aspect overlap:** The `LogDispatchAspect` captures exceptions from `@RestController` methods, stores them in request attributes, and the `LogDispatchFilter` picks them up in its `finally` block. This produces a single dispatch per error — but if your security filter or custom filter also triggers an error response, both may be recorded.
+
+**To diagnose:** Check your application logs for duplicate `WARN [LogDispatch]` entries and verify the `errorPath` and `timestamp` fields in the duplicate payloads to trace the origin.
diff --git a/example-app/README.md b/example-app/README.md
new file mode 100644
index 0000000..75ef3e2
--- /dev/null
+++ b/example-app/README.md
@@ -0,0 +1,61 @@
+# LogDispatch Example App
+
+This demo Spring Boot application shows how `logdispatch-spring-boot-starter` captures exceptions from `@RestController` endpoints and dispatches them to the configured APM ingest endpoint.
+
+## Prerequisites
+
+- Java 17 or later
+- Maven, or the Maven wrapper from the repository root
+- An APM ingest endpoint that accepts LogDispatch payloads
+
+## Run the App
+
+From the repository root, install the starter into your local Maven repository:
+
+```bash
+./mvnw -DskipTests -Dgpg.skip install
+```
+
+On Windows:
+
+```powershell
+.\mvnw.cmd -DskipTests -Dgpg.skip install
+```
+
+Then start the example app from the repository root:
+
+```bash
+./mvnw -f example-app/pom.xml spring-boot:run
+```
+
+On Windows:
+
+```powershell
+.\mvnw.cmd -f example-app\pom.xml spring-boot:run
+```
+
+If you do not have a real APM ingest endpoint available yet, the app still runs. LogDispatch will attempt to post to the demo URL in `src/main/resources/application.yml`, log a warning if the endpoint is unreachable, and leave the application response flow unaffected.
+
+## Try the Demo Endpoints
+
+```bash
+curl http://localhost:8080/api/demo/null-pointer
+curl http://localhost:8080/api/demo/illegal-argument
+curl http://localhost:8080/api/demo/illegal-state
+curl http://localhost:8080/api/demo/annotated
+curl -X POST http://localhost:8080/api/demo/body -H "Content-Type: application/json" -d "{\"message\":\"hello\"}"
+```
+
+Each endpoint intentionally throws an exception so you can see LogDispatch capture the request details, exception type, stack trace, and optional `@LogDispatch` metadata.
+
+## Configuration
+
+Edit `src/main/resources/application.yml` to point LogDispatch at your own ingest endpoint:
+
+```yaml
+logdispatch:
+ server-url: "http://localhost:8081/api/v1/ingest/logs"
+ api-key: "demo-api-key"
+```
+
+The example also masks `authorization`, `cookie`, and `x-api-key` request headers in captured input data.
diff --git a/example-app/pom.xml b/example-app/pom.xml
new file mode 100644
index 0000000..93caa68
--- /dev/null
+++ b/example-app/pom.xml
@@ -0,0 +1,46 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.5.15
+
+
+
+ in.maheshlangote
+ logdispatch-example-app
+ 0.0.1-SNAPSHOT
+ LogDispatch Example App
+ Demo Spring Boot application for the LogDispatch starter.
+
+
+ 17
+ 1.0.6
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ in.maheshlangote
+ logdispatch-spring-boot-starter
+ ${logdispatch.version}
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/example-app/src/main/java/in/maheshlangote/logdispatch/example/DemoExceptionController.java b/example-app/src/main/java/in/maheshlangote/logdispatch/example/DemoExceptionController.java
new file mode 100644
index 0000000..4befe05
--- /dev/null
+++ b/example-app/src/main/java/in/maheshlangote/logdispatch/example/DemoExceptionController.java
@@ -0,0 +1,44 @@
+package in.maheshlangote.logdispatch.example;
+
+import in.maheshlangote.logdispatch.annotation.LogDispatch;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/demo")
+@LogDispatch(feature = "Example Demo Controller")
+public class DemoExceptionController {
+
+ @GetMapping("/null-pointer")
+ public String throwNullPointerException() {
+ String value = null;
+ return value.toUpperCase();
+ }
+
+ @GetMapping("/illegal-argument")
+ public String throwIllegalArgumentException() {
+ throw new IllegalArgumentException("The supplied demo value is not valid.");
+ }
+
+ @GetMapping("/illegal-state")
+ public String throwIllegalStateException() {
+ throw new IllegalStateException("The demo workflow is in an invalid state.");
+ }
+
+ @GetMapping("/annotated")
+ @LogDispatch(api = "Annotated Demo Endpoint", function = "throwAnnotatedException")
+ public String throwAnnotatedException() {
+ throw new UnsupportedOperationException("This endpoint demonstrates custom LogDispatch metadata.");
+ }
+
+ @PostMapping("/body")
+ @LogDispatch(api = "Request Body Demo", function = "throwBodyException")
+ public String throwRequestBodyException(@RequestBody Map body) {
+ throw new IllegalArgumentException("Received body keys: " + body.keySet());
+ }
+}
diff --git a/example-app/src/main/java/in/maheshlangote/logdispatch/example/LogDispatchExampleApplication.java b/example-app/src/main/java/in/maheshlangote/logdispatch/example/LogDispatchExampleApplication.java
new file mode 100644
index 0000000..6df1fc1
--- /dev/null
+++ b/example-app/src/main/java/in/maheshlangote/logdispatch/example/LogDispatchExampleApplication.java
@@ -0,0 +1,12 @@
+package in.maheshlangote.logdispatch.example;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class LogDispatchExampleApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(LogDispatchExampleApplication.class, args);
+ }
+}
diff --git a/example-app/src/main/resources/application.yml b/example-app/src/main/resources/application.yml
new file mode 100644
index 0000000..cdae9b4
--- /dev/null
+++ b/example-app/src/main/resources/application.yml
@@ -0,0 +1,13 @@
+server:
+ port: 8080
+
+spring:
+ application:
+ name: logdispatch-example-app
+
+logdispatch:
+ enabled: true
+ server-url: "http://localhost:8081/api/v1/ingest/logs"
+ api-key: "demo-api-key"
+ masked-headers: "authorization,cookie,x-api-key"
+ exclude-paths: "/logdispatch/health"
diff --git a/pom.xml b/pom.xml
index fb52639..e1eb432 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
in.maheshlangotelogdispatch-spring-boot-starter
- 1.0.6
+ 1.0.7LogDispatch Spring Boot StarterA standalone, zero-configuration APM client for intercepting and dispatching logs to a centralized server.https://maheshlangote.in
diff --git a/src/main/java/in/maheshlangote/logdispatch/LogDispatchAspect.java b/src/main/java/in/maheshlangote/logdispatch/LogDispatchAspect.java
index 28ccbc2..83b5e5b 100644
--- a/src/main/java/in/maheshlangote/logdispatch/LogDispatchAspect.java
+++ b/src/main/java/in/maheshlangote/logdispatch/LogDispatchAspect.java
@@ -9,9 +9,6 @@
import org.aspectj.lang.reflect.MethodSignature;
import in.maheshlangote.logdispatch.annotation.LogDispatch;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import java.lang.reflect.Method;
import java.lang.reflect.Method;
/**
@@ -28,12 +25,22 @@
@Aspect
public class LogDispatchAspect {
- private static final Logger log = LoggerFactory.getLogger(LogDispatchAspect.class);
+ private final boolean enabled;
/**
* Constructs a new LogDispatchAspect.
*/
public LogDispatchAspect() {
+ this(true);
+ }
+
+ /**
+ * Constructs a new LogDispatchAspect.
+ *
+ * @param enabled whether LogDispatch should capture controller exceptions
+ */
+ public LogDispatchAspect(boolean enabled) {
+ this.enabled = enabled;
}
/**
@@ -45,14 +52,16 @@ public LogDispatchAspect() {
*/
@AfterThrowing(pointcut = "within(@org.springframework.web.bind.annotation.RestController *)", throwing = "ex")
public void handleControllerException(JoinPoint joinPoint, Throwable ex) {
+ if (!enabled) {
+ return;
+ }
+
String path = "UNKNOWN";
- String httpMethod = "UNKNOWN";
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
path = request.getRequestURI();
- httpMethod = request.getMethod();
request.setAttribute("logdispatch.handled", true);
}
} catch (Exception ignored) {}
diff --git a/src/main/java/in/maheshlangote/logdispatch/LogDispatchFilter.java b/src/main/java/in/maheshlangote/logdispatch/LogDispatchFilter.java
index 885986f..1acfaa1 100644
--- a/src/main/java/in/maheshlangote/logdispatch/LogDispatchFilter.java
+++ b/src/main/java/in/maheshlangote/logdispatch/LogDispatchFilter.java
@@ -9,6 +9,7 @@
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
@@ -21,9 +22,12 @@
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
import java.util.stream.Collectors;
/**
@@ -37,8 +41,11 @@ public class LogDispatchFilter extends OncePerRequestFilter {
private final String serverUrl;
private final String apiKey;
private final RestTemplate restTemplate;
+ private final boolean enabled;
+ private final int timeoutMs;
private final Set maskedHeaders;
private final List excludePaths;
+ private final Executor dispatchExecutor;
private static final AntPathMatcher ANT_PATH_MATCHER = new AntPathMatcher();
@@ -49,17 +56,60 @@ public class LogDispatchFilter extends OncePerRequestFilter {
* @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 (supports wildcard patterns)
+ * @param timeoutMs the HTTP connection and read timeout in milliseconds
*/
- public LogDispatchFilter(String serverUrl, String apiKey, List maskedHeaders, List excludePaths) {
+ public LogDispatchFilter(String serverUrl, String apiKey, List maskedHeaders, List excludePaths, int timeoutMs) {
+ this(true, serverUrl, apiKey, maskedHeaders, excludePaths, timeoutMs);
+ }
+
+ /**
+ * Constructs the LogDispatchFilter.
+ *
+ * @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 (supports wildcard patterns)
+ * @param timeoutMs the HTTP connection and read timeout in milliseconds
+ */
+ public LogDispatchFilter(boolean enabled, String serverUrl, String apiKey, List maskedHeaders,
+ List excludePaths, int timeoutMs) {
+ this(enabled, serverUrl, apiKey, maskedHeaders, excludePaths, new RestTemplate(), null, timeoutMs);
+ }
+
+ LogDispatchFilter(String serverUrl, String apiKey, List maskedHeaders, List excludePaths,
+ RestTemplate restTemplate, Executor dispatchExecutor, int timeoutMs) {
+ this(true, serverUrl, apiKey, maskedHeaders, excludePaths, restTemplate, dispatchExecutor, timeoutMs);
+ }
+
+ LogDispatchFilter(boolean enabled, String serverUrl, String apiKey, List maskedHeaders,
+ List excludePaths, RestTemplate restTemplate, Executor dispatchExecutor, int timeoutMs) {
+ this.enabled = enabled;
this.serverUrl = serverUrl;
this.apiKey = apiKey;
- this.restTemplate = new RestTemplate();
+ this.timeoutMs = (timeoutMs > 0) ? timeoutMs : 3000;
+ this.dispatchExecutor = dispatchExecutor;
+ this.restTemplate = Objects.requireNonNull(restTemplate, "restTemplate");
+
+ // Configure the timeout on the RestTemplate's underlying request factory
+ if (this.restTemplate.getRequestFactory() instanceof SimpleClientHttpRequestFactory) {
+ SimpleClientHttpRequestFactory factory = (SimpleClientHttpRequestFactory) this.restTemplate.getRequestFactory();
+ factory.setConnectTimeout(this.timeoutMs);
+ factory.setReadTimeout(this.timeoutMs);
+ } else {
+ SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+ factory.setConnectTimeout(this.timeoutMs);
+ factory.setReadTimeout(this.timeoutMs);
+ this.restTemplate.setRequestFactory(factory);
+ }
+
this.maskedHeaders = maskedHeaders == null ? Set.of() : maskedHeaders.stream()
.filter(h -> h != null && !h.trim().isEmpty())
- .map(String::toLowerCase)
+ .map(h -> h.trim().toLowerCase(Locale.ROOT))
.collect(Collectors.toSet());
this.excludePaths = excludePaths == null ? List.of() : excludePaths.stream()
.filter(p -> p != null && !p.trim().isEmpty())
+ .map(String::trim)
.collect(Collectors.toList());
}
@@ -79,6 +129,9 @@ private boolean isPathExcluded(String requestPath) {
}
private boolean shouldWrapRequest(HttpServletRequest request) {
+ if ("GET".equalsIgnoreCase(request.getMethod()) && request.getContentLength() <= 0) {
+ return false;
+ }
String contentType = request.getContentType();
if (contentType != null && contentType.toLowerCase().startsWith("multipart/")) {
return false; // Skip file uploads
@@ -93,7 +146,11 @@ private boolean shouldWrapRequest(HttpServletRequest request) {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
-
+ if (!enabled) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
// Check if the request path is excluded
String requestPath = request.getRequestURI();
if (isPathExcluded(requestPath)) {
@@ -151,29 +208,31 @@ private void pushFilterErrorAsync(HttpServletRequest request, int statusCode, Ma
String path = request.getRequestURI();
String method = request.getMethod();
- CompletableFuture.runAsync(() -> {
+ dispatchAsync(() -> {
try {
- String severity = (statusCode >= 500) ? "CRITICAL" : "WARNING";
+ String severity = "SECURITY";
+ String feature = "FilterSecurity/Routing";
- Map payload = new HashMap<>();
- payload.put("timestamp", Instant.now().toString());
- payload.put("errorType", "FilterError");
- payload.put("statusCode", statusCode);
- payload.put("errorMessage", "Request failed with status " + statusCode + " at filter level.");
- payload.put("errorPath", path);
- payload.put("affectedFeature", "FilterSecurity/Routing");
- payload.put("affectedAPI", path);
- payload.put("apiType", method);
- payload.put("affectedFunction", "doFilter");
- payload.put("stackTrace", "No stack trace available for filter-level status codes.");
- payload.put("severity", severity);
- payload.put("inputInformation", inputInfo);
+ LogDispatchPayload payload = new LogDispatchPayload(
+ Instant.now().toString(),
+ "FilterError",
+ statusCode,
+ "Request failed with status " + statusCode + " at filter level.",
+ path,
+ feature,
+ path,
+ method,
+ "doFilter",
+ "No stack trace available for filter-level status codes.",
+ severity,
+ inputInfo
+ );
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-API-KEY", apiKey);
- HttpEntity