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
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,29 @@
/**
* Servlet {@link Filter} for recording {@link HttpExchange HTTP exchanges}.
*
* <p>
* This filter runs near the end of the filter chain (at {@code LOWEST_PRECEDENCE - 10})
* so it captures enriched headers added by earlier filters. When used together with
* {@link HttpExchangesStartingFilter}, it coordinates recording to ensure that requests
* short-circuited by high-priority filters (such as Spring Security) are still captured
* by the starting filter.
*
* <p>
* When {@link HttpExchangesStartingFilter} is present:
* <ul>
* <li>This filter reuses the {@link HttpExchange.Started} instance that the starting
* filter stored as a request attribute.</li>
* <li>After recording, this filter sets the {@code finished} attribute so the starting
* filter does not record the exchange a second time.</li>
* </ul>
*
* @author Dave Syer
* @author Wallace Wadge
* @author Andy Wilkinson
* @author Venil Noronha
* @author Madhura Bhave
* @since 4.0.0
* @see HttpExchangesStartingFilter
*/
public class HttpExchangesFilter extends OncePerRequestFilter implements Ordered {

Expand Down Expand Up @@ -82,21 +99,40 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
filterChain.doFilter(request, response);
return;
}
RecordableServletHttpRequest sourceRequest = new RecordableServletHttpRequest(request);
HttpExchange.Started startedHttpExchange = HttpExchange.start(sourceRequest);
// Reuse the Started exchange created by HttpExchangesStartingFilter if present,
// otherwise create a new one (standalone use without
// HttpExchangesStartingFilter).
HttpExchange.Started startedExchange = getOrCreateStartedExchange(request);
int status = HttpStatus.INTERNAL_SERVER_ERROR.value();
try {
filterChain.doFilter(request, response);
status = response.getStatus();
}
finally {
RecordableServletHttpResponse sourceResponse = new RecordableServletHttpResponse(response, status);
HttpExchange finishedExchange = startedHttpExchange.finish(sourceResponse, request::getUserPrincipal,
HttpExchange finishedExchange = startedExchange.finish(sourceResponse, request::getUserPrincipal,
() -> getSessionId(request), this.includes);
this.repository.add(finishedExchange);
// Signal HttpExchangesStartingFilter not to record the exchange again.
request.setAttribute(HttpExchangesStartingFilter.ATTRIBUTE_FINISHED, Boolean.TRUE);
}
}

/**
* Returns the {@link HttpExchange.Started} stored by
* {@link HttpExchangesStartingFilter}, or creates a fresh one if the starting filter
* is not in the chain.
* @param request the source request
* @return the started exchange to use for recording
*/
private HttpExchange.Started getOrCreateStartedExchange(HttpServletRequest request) {
Object attribute = request.getAttribute(HttpExchangesStartingFilter.ATTRIBUTE_STARTED);
if (attribute instanceof HttpExchange.Started started) {
return started;
}
return HttpExchange.start(new RecordableServletHttpRequest(request));
}

private boolean isRequestValid(HttpServletRequest request) {
try {
new URI(request.getRequestURL().toString());
Expand All @@ -107,6 +143,12 @@ private boolean isRequestValid(HttpServletRequest request) {
}
}

/**
* Return the session id for the given request, or {@code null} if the request does
* not have a session.
* @param request the source request
* @return the session id or {@code null} if there is no session
*/
private @Nullable String getSessionId(HttpServletRequest request) {
HttpSession session = request.getSession(false);
return (session != null) ? session.getId() : null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* Copyright 2012-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.boot.servlet.actuate.web.exchanges;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Set;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;

import org.springframework.boot.actuate.web.exchanges.HttpExchange;
import org.springframework.boot.actuate.web.exchanges.HttpExchangeRepository;
import org.springframework.boot.actuate.web.exchanges.Include;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.web.filter.OncePerRequestFilter;

/**
* A high-priority Servlet {@link jakarta.servlet.Filter} that starts recording an
* {@link HttpExchange} at the very beginning of the filter chain. It coordinates with
* {@link HttpExchangesFilter}, which runs later with low priority and finishes recording
* for requests that reach it. For requests that are short-circuited by high-priority
* filters (such as Spring Security rejecting unauthenticated requests), this filter acts
* as a fallback and records the exchange in the {@code finally} block after the chain
* returns.
*
* <p>
* This filter works together with {@link HttpExchangesFilter}:
* <ul>
* <li>This filter stores the {@link HttpExchange.Started} instance as a request attribute
* ({@value #ATTRIBUTE_STARTED}).</li>
* <li>{@link HttpExchangesFilter} detects that attribute and uses it to finish the
* exchange, setting the {@value #ATTRIBUTE_FINISHED} attribute to prevent
* double-recording.</li>
* <li>If {@link HttpExchangesFilter} is never reached (e.g. request rejected by Spring
* Security), this filter's {@code finally} block records the exchange.</li>
* </ul>
*
* @author Spring Boot Team
* @since 4.0.0
* @see HttpExchangesFilter
*/
public class HttpExchangesStartingFilter extends OncePerRequestFilter implements Ordered {

/**
* Name of the request attribute holding the {@link HttpExchange.Started} created by
* this filter.
*/
static final String ATTRIBUTE_STARTED = HttpExchangesStartingFilter.class.getName() + ".started";

/**
* Name of the request attribute set to {@code Boolean.TRUE} by
* {@link HttpExchangesFilter} once it has finished and recorded the exchange,
* preventing this filter from recording it a second time.
*/
static final String ATTRIBUTE_FINISHED = HttpExchangesStartingFilter.class.getName() + ".finished";

// Run as early as possible so we capture even security-rejected requests
private int order = Ordered.HIGHEST_PRECEDENCE + 1;

private final HttpExchangeRepository repository;

private final Set<Include> includes;

/**
* Create a new {@link HttpExchangesStartingFilter} instance.
* @param repository the repository used to record events
* @param includes the include options
*/
public HttpExchangesStartingFilter(HttpExchangeRepository repository, Set<Include> includes) {
this.repository = repository;
this.includes = includes;
}

@Override
public int getOrder() {
return this.order;
}

public void setOrder(int order) {
this.order = order;
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!isRequestValid(request)) {
filterChain.doFilter(request, response);
return;
}
RecordableServletHttpRequest sourceRequest = new RecordableServletHttpRequest(request);
HttpExchange.Started startedExchange = HttpExchange.start(sourceRequest);
request.setAttribute(ATTRIBUTE_STARTED, startedExchange);
int status = HttpStatus.INTERNAL_SERVER_ERROR.value();
try {
filterChain.doFilter(request, response);
status = response.getStatus();
}
finally {
// Only record the exchange here if HttpExchangesFilter hasn't already done
// so.
// HttpExchangesFilter sets ATTRIBUTE_FINISHED when it records the exchange.
if (!Boolean.TRUE.equals(request.getAttribute(ATTRIBUTE_FINISHED))) {
RecordableServletHttpResponse sourceResponse = new RecordableServletHttpResponse(response, status);
HttpExchange finishedExchange = startedExchange.finish(sourceResponse, request::getUserPrincipal,
() -> {
HttpSession session = request.getSession(false);
return (session != null) ? session.getId() : null;
}, this.includes);
this.repository.add(finishedExchange);
}
}
}

private boolean isRequestValid(HttpServletRequest request) {
try {
new URI(request.getRequestURL().toString());
return true;
}
catch (URISyntaxException ex) {
return false;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,23 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.servlet.actuate.web.exchanges.HttpExchangesFilter;
import org.springframework.boot.servlet.actuate.web.exchanges.HttpExchangesStartingFilter;
import org.springframework.context.annotation.Bean;

/**
* {@link EnableAutoConfiguration Auto-configuration} to record {@link HttpExchange HTTP
* exchanges}.
*
* <p>
* Registers two filters that work together to ensure all requests — including those
* rejected by high-priority filters such as Spring Security — are captured:
* <ul>
* <li>{@link HttpExchangesStartingFilter}: high-priority filter that starts recording at
* the beginning of the filter chain.</li>
* <li>{@link HttpExchangesFilter}: low-priority filter that finishes recording after all
* other filters have run (capturing enriched headers).</li>
* </ul>
*
* @author Dave Syer
* @since 4.0.0
*/
Expand All @@ -50,4 +61,11 @@ HttpExchangesFilter httpExchangesFilter(HttpExchangeRepository repository, HttpE
return new HttpExchangesFilter(repository, properties.getRecording().getInclude());
}

@Bean
@ConditionalOnMissingBean
HttpExchangesStartingFilter httpExchangesStartingFilter(HttpExchangeRepository repository,
HttpExchangesProperties properties) {
return new HttpExchangesStartingFilter(repository, properties.getRecording().getInclude());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;

import org.springframework.boot.actuate.web.exchanges.HttpExchange;
import org.springframework.boot.actuate.web.exchanges.HttpExchange.Session;
import org.springframework.boot.actuate.web.exchanges.InMemoryHttpExchangeRepository;
import org.springframework.boot.actuate.web.exchanges.Include;
Expand Down Expand Up @@ -121,4 +122,43 @@ void filterRejectsInvalidRequests() throws ServletException, IOException {
assertThat(this.repository.findAll()).isEmpty();
}

@Test
void filterReusesStartedExchangeFromStartingFilter() throws ServletException, IOException {
// Simulate HttpExchangesStartingFilter having set the ATTRIBUTE_STARTED
MockHttpServletRequest request = new MockHttpServletRequest();
HttpExchange.Started preStarted = HttpExchange.start(new RecordableServletHttpRequest(request));
request.setAttribute(HttpExchangesStartingFilter.ATTRIBUTE_STARTED, preStarted);

this.filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());

// Exchange is recorded exactly once
assertThat(this.repository.findAll()).hasSize(1);
}

@Test
void filterSetsFinishedAttributeAfterRecording() throws ServletException, IOException {
MockHttpServletRequest request = new MockHttpServletRequest();
this.filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());

// ATTRIBUTE_FINISHED must be set so HttpExchangesStartingFilter won't
// double-record
assertThat(request.getAttribute(HttpExchangesStartingFilter.ATTRIBUTE_FINISHED)).isEqualTo(Boolean.TRUE);
}

@Test
void twoFiltersCombinedRecordExchangeOnce() throws ServletException, IOException {
HttpExchangesStartingFilter startingFilter = new HttpExchangesStartingFilter(this.repository,
EnumSet.allOf(Include.class));

MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();

// Execute the starting filter; its chain delegate calls the finishing filter.
startingFilter.doFilter(request, response,
(req, resp) -> this.filter.doFilter(req, resp, new MockFilterChain()));

// Only one exchange should be recorded despite two filters running.
assertThat(this.repository.findAll()).hasSize(1);
}

}
Loading