Monitor Your Java Apps with Spring Boot Actuator
Have you worked with Spring Boot Actuator yet? It’s an immensely helpful library that helps you monitor app health and interactions with the app - perfect for going to production! Spring Boot Actuator includes a built-in endpoint for tracing HTTP calls to your application - very useful for monitoring OpenID Connect (OIDC) requests - but unfortunately the default implementation does not trace body contents. In this post, I’ll show you how to extend the httptrace endpoint for capturing contents and tracing the OIDC flow.
Let’s get started!
Table of Contents
- Create an OpenID Connect App with Spring Initializr and Okta
- Add Test Controller to the Spring Boot App
- Add Spring Boot Actuator Dependency
- Add Custom HTTP Tracing to your Spring Boot App
- Use Spring Boot Actuator to Capture Request and Response Body Contents
- Tracing the Authenticated User
- HTTPTrace Endpoint Extension
- Inspect OpenID Connect HTTP Trace
- Learn More About Spring Boot
Create an OpenID Connect App with Spring Initializr and Okta
You can use the excellent Spring Initializr website or API for creating a sample OIDC application with Okta integration:
curl https://start.spring.io/starter.zip \
-d bootVersion=2.4.5.RELEASE \
-d dependencies=web,okta \
-d packageName=com.okta.developer.demo -d baseDir=demo | tar -xzvf -
Open a terminal window and navigate to the demo
directory where you expanded this project.
Before you begin, you’ll need a free Okta developer account. Install the Okta CLI and run okta register
to sign up for a new account. If you already have an account, run okta login
.
Then, run okta apps create
. Select the default app name, or change it as you see fit.
Choose Web and press Enter.
Select Okta Spring Boot Starter.
Then, change the Redirect URI to http://localhost:8080/authorization-code/callback
and use http://localhost:8080
for the Logout Redirect URI.
What does the Okta CLI do?
The Okta CLI will create an OIDC Web App in your Okta Org. It will add the redirect URIs you specified and grant access to the Everyone group. You will see output like the following when it’s finished:
Okta application configuration has been written to:
/path/to/app/src/main/resources/application.properties
Open src/main/resources/application.properties
to see the issuer and credentials for your app.
okta.oauth2.issuer=https://dev-133337.okta.com/oauth2/default
okta.oauth2.client-id=0oab8eb55Kb9jdMIr5d6
okta.oauth2.client-secret=NEVER-SHOW-SECRETS
NOTE: You can also use the Okta Admin Console to create your app. See Create a Spring Boot App for more information.
Remove the values from your application.properties
and start your app with them on the command line for tighter security.
OKTA_OAUTH2_REDIRECTURI=/authorization-code/callback \
OKTA_OAUTH2_ISSUER=<issuer>/oauth2 \
OKTA_OAUTH2_CLIENT_ID=<client id> \
OKTA_OAUTH2_CLIENT_SECRET=<client secret> \
./mvnw spring-boot:run
Add Test Controller to the Spring Boot App
It’s a good practice to add a simple controller for testing the authentication flow. By default, access will only be allowed to authenticated users.
@Controller
@RequestMapping(value = "/hello")
public class HelloController {
@GetMapping(value = "/greeting")
@ResponseBody
public String getGreeting(Principal user) {
return "Good morning " + user.getName();
}
}
You can test this out by restarting the app and browsing to /hello/greeting.
Add Spring Boot Actuator Dependency
Enable Spring Boot Actuator by adding the starter Maven dependency to the pom.xml file
:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
To enable the httptrace endpoint, edit the src/main/resources/application.properties
and add the following line:
management.endpoints.web.exposure.include=info,health,httptrace
To make it so HTTP tracing works with Spring Boot 2.2+, you have to add a HttpTraceRepository
bean. Add it to your DemoApplication
class.
package com.okta.developer.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.actuate.trace.http.HttpTraceRepository;
import org.springframework.boot.actuate.trace.http.InMemoryHttpTraceRepository;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@Bean
public HttpTraceRepository httpTraceRepository() {
return new InMemoryHttpTraceRepository();
}
}
You can test the out-of-the-box actuator features running the application browsing to /hello/greeting, and logging in.
Under the auto-configuration, Spring Security filters have higher precedence than filters added by the httptrace actuator.
This means only authenticated calls are traced by default. We are going to change that here soon, but for now, you can see what is traced at /actuator/httptrace. The response should look like this JSON payload:
{
"traces":[
{
"timestamp":"2019-05-19T05:38:42.726Z",
"principal":{
"name":"***"
},
"session":{
"id":"***"
},
"request":{
"method":"GET",
"uri":"http://localhost:8080/",
"headers":{},
"remoteAddress":"0:0:0:0:0:0:0:1"
},
"response":{
"status":200,
"headers":{}
},
"timeTaken":145
}
]
}
Add Custom HTTP Tracing to your Spring Boot App
HTTP tracing is not very flexible. Andy Wilkinson, the author of the httptrace actuator, suggests implementing your own endpoint if body tracing is required.
Alternatively, with some custom filters, we can enhance the base implementation without much work. In the following sections I’ll show you how to:
- Create a filter for capturing request and response body
- Configure the filters precedence for tracing OIDC calls
- Create the httptrace endpoint extension with a custom trace repository to store additional data
Use Spring Boot Actuator to Capture Request and Response Body Contents
Next, create a filter for tracing the request and response body contents. This filter will have precedence over the httptrace filter, so the cached body contents are available when the actuator saves the trace.
@Component
@ConditionalOnProperty(prefix = "management.trace.http", name = "enabled", matchIfMissing = true)
public class ContentTraceFilter extends OncePerRequestFilter {
private ContentTraceManager traceManager;
@Value("${management.trace.http.tracebody:false}")
private boolean traceBody;
public ContentTraceFilter(ContentTraceManager traceManager) {
super();
this.traceManager = traceManager;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!isRequestValid(request) || !traceBody) {
filterChain.doFilter(request, response);
return;
}
ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(
request, 1000);
ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(
response);
try {
filterChain.doFilter(wrappedRequest, wrappedResponse);
traceManager.updateBody(wrappedRequest, wrappedResponse);
} finally {
wrappedResponse.copyBodyToResponse();
}
}
private boolean isRequestValid(HttpServletRequest request) {
try {
new URI(request.getRequestURL().toString());
return true;
} catch (URISyntaxException ex) {
return false;
}
}
}
Notice the call to a ContentTraceManager
, a simple @RequestScope
bean that will store the additional data:
@Component
@RequestScope
@ConditionalOnProperty(prefix = "management.trace.http", name = "enabled", matchIfMissing = true)
public class ContentTraceManager {
private ContentTrace trace;
public ContentTraceManager(ContentTrace trace) {
this.trace=trace;
}
protected static Logger logger = LoggerFactory
.getLogger(ContentTraceManager.class);
public void updateBody(ContentCachingRequestWrapper wrappedRequest,
ContentCachingResponseWrapper wrappedResponse) {
String requestBody = getRequestBody(wrappedRequest);
getTrace().setRequestBody(requestBody);
String responseBody = getResponseBody(wrappedResponse);
getTrace().setResponseBody(responseBody);
}
protected String getRequestBody(
ContentCachingRequestWrapper wrappedRequest) {
try {
if (wrappedRequest.getContentLength() <= 0) {
return null;
}
return new String(wrappedRequest.getContentAsByteArray(), 0,
wrappedRequest.getContentLength(),
wrappedRequest.getCharacterEncoding());
} catch (UnsupportedEncodingException e) {
logger.error(
"Could not read cached request body: " + e.getMessage());
return null;
}
}
protected String getResponseBody(
ContentCachingResponseWrapper wrappedResponse) {
try {
if (wrappedResponse.getContentSize() <= 0) {
return null;
}
return new String(wrappedResponse.getContentAsByteArray(), 0,
wrappedResponse.getContentSize(),
wrappedResponse.getCharacterEncoding());
} catch (UnsupportedEncodingException e) {
logger.error(
"Could not read cached response body: " + e.getMessage());
return null;
}
}
public ContentTrace getTrace() {
if (trace == null) {
trace = new ContentTrace();
}
return trace;
}
}
For modeling the trace with additional data, compose a custom ContentTrace
class with the built-in HttpTrace
information, adding properties for storing the body contents.
public class ContentTrace {
protected HttpTrace httpTrace;
protected String requestBody;
protected String responseBody;
protected Authentication principal;
public ContentTrace() {
}
public void setHttpTrace(HttpTrace httpTrace) {
this.httpTrace = httpTrace;
}
}
Add setters and getters for
httpTrace
,principal
,requestBody
andresponseBody
.
Configure Filter Precedence
For capturing requests to OIDC endpoints in your application, the tracing filters have to sit before Spring Security filters. As long as ContentTraceFilter
has precedence over HttpTraceFilter
, both can be placed before or after SecurityContextPersistenceFilter
, the first one in the Spring Security filter chain.
@Configuration
@ConditionalOnProperty(prefix = "management.trace.http", name = "enabled", matchIfMissing = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private HttpTraceFilter httpTraceFilter;
private ContentTraceFilter contentTraceFilter;
public WebSecurityConfig(
HttpTraceFilter httpTraceFilter, ContentTraceFilter contentTraceFilter
) {
this.httpTraceFilter = httpTraceFilter;
this.contentTraceFilter = contentTraceFilter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(contentTraceFilter,
SecurityContextPersistenceFilter.class)
.addFilterAfter(httpTraceFilter,
SecurityContextPersistenceFilter.class)
.authorizeRequests().anyRequest().authenticated()
.and().oauth2Client()
.and().oauth2Login();
}
}
Tracing the Authenticated User
We’re installing the trace filters before the Spring Security filter chain. This means that the Principal is no longer available when the HttpTraceFilter saves the trace. We can restore this trace data with a new filter and the ContentTraceManager.
@Component
@ConditionalOnProperty(prefix = "management.trace.http", name = "enabled", matchIfMissing = true)
public class PrincipalTraceFilter extends OncePerRequestFilter {
private ContentTraceManager traceManager;
private HttpTraceProperties traceProperties;
public PrincipalTraceFilter(
ContentTraceManager traceManager,
HttpTraceProperties traceProperties
) {
super();
this.traceManager = traceManager;
this.traceProperties = traceProperties;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
if (!isRequestValid(request)) {
filterChain.doFilter(request, response);
return;
}
try {
filterChain.doFilter(request, response);
} finally {
if (traceProperties.getInclude().contains(Include.PRINCIPAL)) {
traceManager.updatePrincipal();
}
}
}
private boolean isRequestValid(HttpServletRequest request) {
try {
new URI(request.getRequestURL().toString());
return true;
} catch (URISyntaxException ex) {
return false;
}
}
}
Add the missing ContentTraceManager
class for updating the principal:
public class ContentTraceManager {
public void updatePrincipal() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
getTrace().setPrincipal(authentication);
}
}
}
The PrincipalTraceFilter
must have lower precedence than the Spring Security filter chain, so the authenticated principal is available when requested from the security context. Modify the WebSecurityConfig
to insert the filter after the FilterSecurityInterceptor
, the last filter in the security chain.
@Configuration
@ConditionalOnProperty(prefix = "management.trace.http", name = "enabled", matchIfMissing = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private HttpTraceFilter httpTraceFilter;
private ContentTraceFilter contentTraceFilter;
private PrincipalTraceFilter principalTraceFilter;
public WebSecurityConfig(
HttpTraceFilter httpTraceFilter,
ContentTraceFilter contentTraceFilter,
PrincipalTraceFilter principalTraceFilter
) {
super();
this.httpTraceFilter = httpTraceFilter;
this.contentTraceFilter = contentTraceFilter;
this.principalTraceFilter = principalTraceFilter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(contentTraceFilter,
SecurityContextPersistenceFilter.class)
.addFilterAfter(httpTraceFilter,
SecurityContextPersistenceFilter.class)
.addFilterAfter(principalTraceFilter,
FilterSecurityInterceptor.class)
.authorizeRequests().anyRequest().authenticated()
.and().oauth2Client()
.and().oauth2Login();
}
}
HTTPTrace Endpoint Extension
Finally, define the endpoint enhancement using the @EndpointWebExtension
annotation. Implement a CustomHttpTraceRepository
to store and retrieve a ContentTrace
with the additional data.
@Component
@EndpointWebExtension(endpoint = HttpTraceEndpoint.class)
@ConditionalOnProperty(prefix = "management.trace.http", name = "enabled", matchIfMissing = true)
public class HttpTraceEndpointExtension {
private CustomHttpTraceRepository repository;
public HttpTraceEndpointExtension(CustomHttpTraceRepository repository) {
super();
this.repository = repository;
}
@ReadOperation
public ContentTraceDescriptor contents() {
List<ContentTrace> traces = repository.findAllWithContent();
return new ContentTraceDescriptor(traces);
}
}
Redefine a descriptor for the endpoint return type:
public class ContentTraceDescriptor {
protected List<ContentTrace> traces;
public ContentTraceDescriptor(List<ContentTrace> traces) {
super();
this.traces = traces;
}
public List<ContentTrace> getTraces() {
return traces;
}
public void setTraces(List<ContentTrace> traces) {
this.traces = traces;
}
}
Create the CustomHttpTraceRepository
implementing the HttpTraceRepository
interface:
@Component
@ConditionalOnProperty(prefix = "management.trace.http", name = "enabled", matchIfMissing = true)
public class CustomHttpTraceRepository implements HttpTraceRepository {
private final List<ContentTrace> contents = new LinkedList<>();
private ContentTraceManager traceManager;
public CustomHttpTraceRepository(ContentTraceManager traceManager) {
super();
this.traceManager = traceManager;
}
@Override
public void add(HttpTrace trace) {
synchronized (this.contents) {
ContentTrace contentTrace = traceManager.getTrace();
contentTrace.setHttpTrace(trace);
this.contents.add(0, contentTrace);
}
}
@Override
public List<HttpTrace> findAll() {
synchronized (this.contents) {
return contents.stream().map(ContentTrace::getHttpTrace)
.collect(Collectors.toList());
}
}
public List<ContentTrace> findAllWithContent() {
synchronized (this.contents) {
return Collections.unmodifiableList(new ArrayList<>(this.contents));
}
}
}
Delete the HttpTraceRepository
bean you defined in DemoApplication
. If you don’t do this, you’ll get the following error:
Parameter 0 of method httpTraceFilter in o.s.b.a.a.t.h.HttpTraceAutoConfiguration$ServletTraceFilterConfiguration
required a single bean, but 2 were found:
Inspect OpenID Connect HTTP Trace
Modify the application.properties
file for tracing all available data by adding the following line:
management.trace.http.include=request-headers,response-headers,cookie-headers,principal,time-taken,authorization-header,remote-address,session-id
Run the application again and call the secured controller /hello/greeting. Authenticate against Okta and then inspect the traces at /actuator/httptrace.
You should now see OIDC calls in the trace as well as the request and response contents. For example, in the trace below, a request to the application authorization endpoint redirects to the Okta authorization server, initiating the OIDC authorization code flow.
{
"httpTrace": {
"timestamp": "2019-05-22T00:52:22.383Z",
"principal": null,
"session": {
"id": "C2174F5E5F85B313B2284639EE4016E7"
},
"request": {
"method": "GET",
"uri": "http://localhost:8080/oauth2/authorization/okta",
"headers": {
"cookie": [
"JSESSIONID=C2174F5E5F85B313B2284639EE4016E7"
],
"accept-language": [
"en-US,en;q=0.9"
],
"upgrade-insecure-requests": [
"1"
],
"host": [
"localhost:8080"
],
"connection": [
"keep-alive"
],
"accept-encoding": [
"gzip, deflate, br"
],
"accept": [
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"
],
"user-agent": [
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36"
]
},
"remoteAddress": "0:0:0:0:0:0:0:1"
},
"response": {
"status": 302,
"headers": {
"X-Frame-Options": [
"DENY"
],
"Cache-Control": [
"no-cache, no-store, max-age=0, must-revalidate"
],
"X-Content-Type-Options": [
"nosniff"
],
"Expires": [
"0"
],
"Pragma": [
"no-cache"
],
"X-XSS-Protection": [
"1; mode=block"
],
"Location": [
"https://dev-239352.okta.com/oauth2/default/v1/authorize?response_type=code&client_id=0oalrp4qx3Do43VyI356&scope=openid%20profile%20email&state=1uzHRyaHVmyKcpb7eAvJVrdJTZ6wTgkPv3fsC14qdOk%3D&redirect_uri=http://localhost:8080/authorization-code/callback"
]
}
},
"timeTaken": 9
},
"requestBody": null,
"responseBody": null
}
All of the code in this post can be found on GitHub in the okta-spring-boot-custom-actuator-example repository.
Learn More About Spring Boot
That’s all there is to it! You just learned how to configure and extend the httptrace
actuator endpoint for monitoring your OIDC application. For more insights about Spring Boot Actuator, Spring Boot in general, or user authentication, check out the links below:
- Spring Boot and Okta in 2 Minutes
- Java Microservices with Spring Boot and Spring Cloud
- Spring Boot Actuator Endpoints
- Implementing Custom Endpoints
- Okta Authentication Quickstart Guides Java Spring
As always, if you have any comments or questions about this post, feel free to comment below. Don’t miss out on any of our cool content in the future by following us on Twitter and YouTube.
Changelog:
- Apr 17, 2021: Upgraded to Spring Boot 2.4 and streamlined setup with the Okta CLI. See changes in okta-blog#734; example app changes can be viewed in this pull request.
Okta Developer Blog Comment Policy
We welcome relevant and respectful comments. Off-topic comments may be removed.