504 lines
14 KiB
Markdown
504 lines
14 KiB
Markdown
# HTTP Exchanges
|
|
|
|
You can enable recording of HTTP exchanges by providing a bean of type `HttpExchangeRepository` in your application's configuration. For convenience, Spring Boot offers `InMemoryHttpExchangeRepository`, which, by default, stores the last 100 request-response exchanges. `InMemoryHttpExchangeRepository` is limited compared to tracing solutions, and we recommend using it only for development environments. For production environments, we recommend using a production-ready tracing or observability solution, such as Zipkin or OpenTelemetry. Alternatively, you can create your own `HttpExchangeRepository`.
|
|
|
|
You can use the `httpexchanges` endpoint to obtain information about the request-response exchanges that are stored in the `HttpExchangeRepository`.
|
|
|
|
## Basic Configuration
|
|
|
|
### In-Memory Repository (Development)
|
|
|
|
```java
|
|
@Configuration
|
|
public class HttpExchangesConfiguration {
|
|
|
|
@Bean
|
|
public InMemoryHttpExchangeRepository httpExchangeRepository() {
|
|
return new InMemoryHttpExchangeRepository();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Custom Repository Size
|
|
|
|
```java
|
|
@Configuration
|
|
public class HttpExchangesConfiguration {
|
|
|
|
@Bean
|
|
public InMemoryHttpExchangeRepository httpExchangeRepository() {
|
|
return new InMemoryHttpExchangeRepository(1000); // Store last 1000 exchanges
|
|
}
|
|
}
|
|
```
|
|
|
|
## Custom HTTP Exchange Recording
|
|
|
|
To customize the items that are included in each recorded exchange, use the `management.httpexchanges.recording.include` configuration property:
|
|
|
|
```yaml
|
|
management:
|
|
httpexchanges:
|
|
recording:
|
|
include:
|
|
- request-headers
|
|
- response-headers
|
|
- cookie-headers
|
|
- authorization-header
|
|
- principal
|
|
- remote-address
|
|
- session-id
|
|
- time-taken
|
|
```
|
|
|
|
Available options:
|
|
- `request-headers`: Include request headers
|
|
- `response-headers`: Include response headers
|
|
- `cookie-headers`: Include cookie headers
|
|
- `authorization-header`: Include authorization header
|
|
- `principal`: Include principal information
|
|
- `remote-address`: Include remote address
|
|
- `session-id`: Include session ID
|
|
- `time-taken`: Include request processing time
|
|
|
|
## Custom HTTP Exchange Repository
|
|
|
|
### Database-backed Repository
|
|
|
|
```java
|
|
@Entity
|
|
@Table(name = "http_exchanges")
|
|
public class HttpExchangeEntity {
|
|
|
|
@Id
|
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
private Long id;
|
|
|
|
@Column(name = "timestamp")
|
|
private Instant timestamp;
|
|
|
|
@Column(name = "method")
|
|
private String method;
|
|
|
|
@Column(name = "uri", length = 2000)
|
|
private String uri;
|
|
|
|
@Column(name = "status")
|
|
private Integer status;
|
|
|
|
@Column(name = "time_taken")
|
|
private Long timeTaken;
|
|
|
|
@Column(name = "principal")
|
|
private String principal;
|
|
|
|
@Column(name = "remote_address")
|
|
private String remoteAddress;
|
|
|
|
@Column(name = "session_id")
|
|
private String sessionId;
|
|
|
|
@Lob
|
|
@Column(name = "request_headers")
|
|
private String requestHeaders;
|
|
|
|
@Lob
|
|
@Column(name = "response_headers")
|
|
private String responseHeaders;
|
|
|
|
// Constructors, getters, setters
|
|
}
|
|
|
|
@Repository
|
|
public interface HttpExchangeEntityRepository extends JpaRepository<HttpExchangeEntity, Long> {
|
|
|
|
List<HttpExchangeEntity> findTop100ByOrderByTimestampDesc();
|
|
|
|
@Modifying
|
|
@Query("DELETE FROM HttpExchangeEntity h WHERE h.timestamp < :cutoff")
|
|
void deleteOlderThan(@Param("cutoff") Instant cutoff);
|
|
}
|
|
|
|
@Component
|
|
public class DatabaseHttpExchangeRepository implements HttpExchangeRepository {
|
|
|
|
private final HttpExchangeEntityRepository repository;
|
|
private final ObjectMapper objectMapper;
|
|
|
|
public DatabaseHttpExchangeRepository(HttpExchangeEntityRepository repository) {
|
|
this.repository = repository;
|
|
this.objectMapper = new ObjectMapper();
|
|
}
|
|
|
|
@Override
|
|
public List<HttpExchange> findAll() {
|
|
return repository.findTop100ByOrderByTimestampDesc()
|
|
.stream()
|
|
.map(this::toHttpExchange)
|
|
.collect(Collectors.toList());
|
|
}
|
|
|
|
@Override
|
|
public void add(HttpExchange httpExchange) {
|
|
HttpExchangeEntity entity = toEntity(httpExchange);
|
|
repository.save(entity);
|
|
}
|
|
|
|
private HttpExchangeEntity toEntity(HttpExchange exchange) {
|
|
HttpExchangeEntity entity = new HttpExchangeEntity();
|
|
entity.setTimestamp(exchange.getTimestamp());
|
|
|
|
HttpExchange.Request request = exchange.getRequest();
|
|
entity.setMethod(request.getMethod());
|
|
entity.setUri(request.getUri().toString());
|
|
entity.setPrincipal(exchange.getPrincipal() != null ?
|
|
exchange.getPrincipal().getName() : null);
|
|
entity.setRemoteAddress(request.getRemoteAddress());
|
|
|
|
if (exchange.getResponse() != null) {
|
|
entity.setStatus(exchange.getResponse().getStatus());
|
|
}
|
|
|
|
entity.setTimeTaken(exchange.getTimeTaken() != null ?
|
|
exchange.getTimeTaken().toMillis() : null);
|
|
|
|
try {
|
|
entity.setRequestHeaders(objectMapper.writeValueAsString(request.getHeaders()));
|
|
if (exchange.getResponse() != null) {
|
|
entity.setResponseHeaders(objectMapper.writeValueAsString(
|
|
exchange.getResponse().getHeaders()));
|
|
}
|
|
} catch (Exception e) {
|
|
// Handle serialization error
|
|
}
|
|
|
|
return entity;
|
|
}
|
|
|
|
private HttpExchange toHttpExchange(HttpExchangeEntity entity) {
|
|
// Implement conversion from entity to HttpExchange
|
|
// This is complex due to HttpExchange being immutable
|
|
// Consider using a builder pattern or reflection
|
|
return null; // Simplified for brevity
|
|
}
|
|
|
|
@Scheduled(fixedRate = 3600000) // Clean up every hour
|
|
public void cleanup() {
|
|
Instant cutoff = Instant.now().minus(Duration.ofDays(7));
|
|
repository.deleteOlderThan(cutoff);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Filtered HTTP Exchange Repository
|
|
|
|
```java
|
|
@Component
|
|
public class FilteredHttpExchangeRepository implements HttpExchangeRepository {
|
|
|
|
private final HttpExchangeRepository delegate;
|
|
private final Set<String> excludePaths;
|
|
private final Set<String> excludeUserAgents;
|
|
|
|
public FilteredHttpExchangeRepository(HttpExchangeRepository delegate) {
|
|
this.delegate = delegate;
|
|
this.excludePaths = Set.of("/actuator/health", "/actuator/metrics", "/favicon.ico");
|
|
this.excludeUserAgents = Set.of("kube-probe", "ELB-HealthChecker");
|
|
}
|
|
|
|
@Override
|
|
public List<HttpExchange> findAll() {
|
|
return delegate.findAll();
|
|
}
|
|
|
|
@Override
|
|
public void add(HttpExchange httpExchange) {
|
|
if (shouldRecord(httpExchange)) {
|
|
delegate.add(httpExchange);
|
|
}
|
|
}
|
|
|
|
private boolean shouldRecord(HttpExchange exchange) {
|
|
String path = exchange.getRequest().getUri().getPath();
|
|
|
|
// Skip health check and monitoring endpoints
|
|
if (excludePaths.contains(path)) {
|
|
return false;
|
|
}
|
|
|
|
// Skip requests from monitoring tools
|
|
String userAgent = exchange.getRequest().getHeaders().getFirst("User-Agent");
|
|
if (userAgent != null && excludeUserAgents.stream().anyMatch(userAgent::contains)) {
|
|
return false;
|
|
}
|
|
|
|
// Skip successful static resource requests
|
|
if (path.startsWith("/static/") || path.startsWith("/css/") || path.startsWith("/js/")) {
|
|
return exchange.getResponse() == null || exchange.getResponse().getStatus() >= 400;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Async HTTP Exchange Recording
|
|
|
|
### Async Repository Wrapper
|
|
|
|
```java
|
|
@Component
|
|
public class AsyncHttpExchangeRepository implements HttpExchangeRepository {
|
|
|
|
private final HttpExchangeRepository delegate;
|
|
private final TaskExecutor taskExecutor;
|
|
|
|
public AsyncHttpExchangeRepository(HttpExchangeRepository delegate,
|
|
@Qualifier("httpExchangeTaskExecutor") TaskExecutor taskExecutor) {
|
|
this.delegate = delegate;
|
|
this.taskExecutor = taskExecutor;
|
|
}
|
|
|
|
@Override
|
|
public List<HttpExchange> findAll() {
|
|
return delegate.findAll();
|
|
}
|
|
|
|
@Override
|
|
public void add(HttpExchange httpExchange) {
|
|
taskExecutor.execute(() -> {
|
|
try {
|
|
delegate.add(httpExchange);
|
|
} catch (Exception e) {
|
|
// Log error but don't let it affect the main request
|
|
log.error("Failed to record HTTP exchange", e);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
@Configuration
|
|
public class HttpExchangeTaskExecutorConfiguration {
|
|
|
|
@Bean("httpExchangeTaskExecutor")
|
|
public TaskExecutor httpExchangeTaskExecutor() {
|
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
|
executor.setCorePoolSize(1);
|
|
executor.setMaxPoolSize(2);
|
|
executor.setQueueCapacity(1000);
|
|
executor.setThreadNamePrefix("http-exchange-");
|
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy());
|
|
executor.initialize();
|
|
return executor;
|
|
}
|
|
}
|
|
```
|
|
|
|
## HTTP Exchanges Endpoint
|
|
|
|
### Accessing HTTP Exchanges
|
|
|
|
```
|
|
GET /actuator/httpexchanges
|
|
```
|
|
|
|
Response format:
|
|
|
|
```json
|
|
{
|
|
"exchanges": [
|
|
{
|
|
"timestamp": "2023-12-01T10:30:00.123Z",
|
|
"request": {
|
|
"method": "GET",
|
|
"uri": "http://localhost:8080/api/users/123",
|
|
"headers": {
|
|
"accept": ["application/json"],
|
|
"user-agent": ["Mozilla/5.0..."]
|
|
},
|
|
"remoteAddress": "192.168.1.100"
|
|
},
|
|
"response": {
|
|
"status": 200,
|
|
"headers": {
|
|
"content-type": ["application/json"],
|
|
"content-length": ["256"]
|
|
}
|
|
},
|
|
"principal": {
|
|
"name": "john.doe"
|
|
},
|
|
"session": {
|
|
"id": "JSESSIONID123"
|
|
},
|
|
"timeTaken": "PT0.025S"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### Securing the Endpoint
|
|
|
|
```java
|
|
@Configuration
|
|
public class HttpExchangesSecurityConfig {
|
|
|
|
@Bean
|
|
@Order(1)
|
|
public SecurityFilterChain httpExchangesSecurityFilterChain(HttpSecurity http) throws Exception {
|
|
return http
|
|
.requestMatcher(EndpointRequest.to("httpexchanges"))
|
|
.authorizeHttpRequests(requests ->
|
|
requests.anyRequest().hasRole("ADMIN"))
|
|
.httpBasic(withDefaults())
|
|
.build();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Custom HTTP Exchange Information
|
|
|
|
### Including Custom Data
|
|
|
|
```java
|
|
@Component
|
|
public class CustomHttpExchangeRepository implements HttpExchangeRepository {
|
|
|
|
private final InMemoryHttpExchangeRepository delegate;
|
|
|
|
public CustomHttpExchangeRepository() {
|
|
this.delegate = new InMemoryHttpExchangeRepository();
|
|
}
|
|
|
|
@Override
|
|
public List<HttpExchange> findAll() {
|
|
return delegate.findAll();
|
|
}
|
|
|
|
@Override
|
|
public void add(HttpExchange httpExchange) {
|
|
HttpExchange enrichedExchange = enrichExchange(httpExchange);
|
|
delegate.add(enrichedExchange);
|
|
}
|
|
|
|
private HttpExchange enrichExchange(HttpExchange original) {
|
|
// Add custom information to the exchange
|
|
// Note: HttpExchange is immutable, so we need to create a wrapper
|
|
// or use reflection to modify internal state
|
|
|
|
// For demonstration, we'll just add it normally
|
|
// In practice, you might need to create a custom implementation
|
|
return original;
|
|
}
|
|
}
|
|
|
|
@Component
|
|
public class HttpExchangeEnricher {
|
|
|
|
public void enrich(HttpServletRequest request, HttpServletResponse response) {
|
|
// Add custom attributes that can be picked up by the repository
|
|
request.setAttribute("custom.trace.id", getTraceId());
|
|
request.setAttribute("custom.user.role", getUserRole());
|
|
request.setAttribute("custom.api.version", getApiVersion(request));
|
|
}
|
|
|
|
private String getTraceId() {
|
|
// Get from tracing context
|
|
return "trace-123";
|
|
}
|
|
|
|
private String getUserRole() {
|
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
return auth != null ? auth.getAuthorities().toString() : "anonymous";
|
|
}
|
|
|
|
private String getApiVersion(HttpServletRequest request) {
|
|
return request.getHeader("API-Version");
|
|
}
|
|
}
|
|
```
|
|
|
|
## Performance Considerations
|
|
|
|
### Configuration for Production
|
|
|
|
```yaml
|
|
management:
|
|
httpexchanges:
|
|
recording:
|
|
include:
|
|
- time-taken
|
|
- principal
|
|
- remote-address
|
|
# Exclude detailed headers to reduce memory usage
|
|
exclude:
|
|
- request-headers
|
|
- response-headers
|
|
endpoint:
|
|
httpexchanges:
|
|
enabled: false # Disable in production for security
|
|
```
|
|
|
|
### Custom Sampling
|
|
|
|
```java
|
|
@Component
|
|
public class SamplingHttpExchangeRepository implements HttpExchangeRepository {
|
|
|
|
private final HttpExchangeRepository delegate;
|
|
private final Random random = new Random();
|
|
private final double samplingRate;
|
|
|
|
public SamplingHttpExchangeRepository(HttpExchangeRepository delegate,
|
|
@Value("${app.http-exchanges.sampling-rate:0.1}") double samplingRate) {
|
|
this.delegate = delegate;
|
|
this.samplingRate = samplingRate;
|
|
}
|
|
|
|
@Override
|
|
public List<HttpExchange> findAll() {
|
|
return delegate.findAll();
|
|
}
|
|
|
|
@Override
|
|
public void add(HttpExchange httpExchange) {
|
|
if (random.nextDouble() < samplingRate) {
|
|
delegate.add(httpExchange);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Production Use**: Disable HTTP exchanges endpoint in production or secure it properly
|
|
2. **Memory Management**: Use limited-size repositories to prevent memory leaks
|
|
3. **Sensitive Data**: Be careful not to log sensitive information in headers
|
|
4. **Performance**: Consider async recording for high-throughput applications
|
|
5. **Sampling**: Use sampling in production to reduce overhead
|
|
6. **Retention**: Implement cleanup policies for stored exchanges
|
|
7. **Security**: Ensure recorded data doesn't contain credentials or tokens
|
|
|
|
### Production Configuration Example
|
|
|
|
```yaml
|
|
management:
|
|
endpoint:
|
|
httpexchanges:
|
|
enabled: false # Disabled in production
|
|
httpexchanges:
|
|
recording:
|
|
include:
|
|
- time-taken
|
|
- principal
|
|
- remote-address
|
|
exclude:
|
|
- authorization-header
|
|
- cookie-headers
|
|
- request-headers
|
|
- response-headers
|
|
|
|
logging:
|
|
level:
|
|
org.springframework.boot.actuate.web.exchanges: WARN
|
|
``` |