Skip to content

Commit 4de201a

Browse files
dfrommijzheaux
authored andcommitted
Use consistent list of micrometer tags in web observation handler
The tag `spring.security.reached.filter.name` is only set if a filter-name is available, otherwise the tag is omitted entirely. This leads to issues with metric-exporters that don't support dynamic tags, but rather expect tag-names of a metric to be always the same. The most prominent example is the Prometheus-exporter. Instead of omitting the tag if no filer-name is set, a none-value is applied instead, making the tag-list consistent in all cases Closes gh-13179
1 parent b438bc5 commit 4de201a

File tree

4 files changed

+113
-12
lines changed

4 files changed

+113
-12
lines changed

web/src/main/java/org/springframework/security/web/ObservationFilterChainDecorator.java

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.concurrent.atomic.AtomicInteger;
2323
import java.util.concurrent.atomic.AtomicReference;
2424

25+
import io.micrometer.common.KeyValue;
2526
import io.micrometer.common.KeyValues;
2627
import io.micrometer.observation.Observation;
2728
import io.micrometer.observation.ObservationConvention;
@@ -515,13 +516,11 @@ public String getContextualName(FilterChainObservationContext context) {
515516

516517
@Override
517518
public KeyValues getLowCardinalityKeyValues(FilterChainObservationContext context) {
518-
KeyValues kv = KeyValues.of(CHAIN_SIZE_NAME, String.valueOf(context.getChainSize()))
519+
return KeyValues.of(CHAIN_SIZE_NAME, String.valueOf(context.getChainSize()))
519520
.and(CHAIN_POSITION_NAME, String.valueOf(context.getChainPosition()))
520-
.and(FILTER_SECTION_NAME, context.getFilterSection());
521-
if (context.getFilterName() != null) {
522-
kv = kv.and(FILTER_NAME, context.getFilterName());
523-
}
524-
return kv;
521+
.and(FILTER_SECTION_NAME, context.getFilterSection())
522+
.and(FILTER_NAME, (context.getFilterName() != null && !context.getFilterName().isEmpty())
523+
? context.getFilterName() : KeyValue.NONE_VALUE);
525524
}
526525

527526
@Override

web/src/main/java/org/springframework/security/web/server/ObservationWebFilterChainDecorator.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -686,13 +686,11 @@ public String getContextualName(WebFilterChainObservationContext context) {
686686

687687
@Override
688688
public KeyValues getLowCardinalityKeyValues(WebFilterChainObservationContext context) {
689-
KeyValues kv = KeyValues.of(CHAIN_SIZE_NAME, String.valueOf(context.getChainSize()))
689+
return KeyValues.of(CHAIN_SIZE_NAME, String.valueOf(context.getChainSize()))
690690
.and(CHAIN_POSITION_NAME, String.valueOf(context.getChainPosition()))
691-
.and(FILTER_SECTION_NAME, context.getFilterSection());
692-
if (context.getFilterName() != null) {
693-
kv = kv.and(FILTER_NAME, context.getFilterName());
694-
}
695-
return kv;
691+
.and(FILTER_SECTION_NAME, context.getFilterSection())
692+
.and(FILTER_NAME, (context.getFilterName() != null && !context.getFilterName().isEmpty())
693+
? context.getFilterName() : KeyValue.NONE_VALUE);
696694
}
697695

698696
@Override

web/src/test/java/org/springframework/security/web/ObservationFilterChainDecoratorTests.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,22 @@
1616

1717
package org.springframework.security.web;
1818

19+
import java.io.IOException;
1920
import java.util.List;
21+
import java.util.stream.Stream;
2022

2123
import io.micrometer.observation.Observation;
2224
import io.micrometer.observation.ObservationHandler;
2325
import io.micrometer.observation.ObservationRegistry;
2426
import jakarta.servlet.Filter;
2527
import jakarta.servlet.FilterChain;
28+
import jakarta.servlet.ServletException;
29+
import jakarta.servlet.ServletRequest;
30+
import jakarta.servlet.ServletResponse;
2631
import org.junit.jupiter.api.Test;
32+
import org.junit.jupiter.params.ParameterizedTest;
33+
import org.junit.jupiter.params.provider.Arguments;
34+
import org.junit.jupiter.params.provider.MethodSource;
2735
import org.mockito.ArgumentCaptor;
2836

2937
import org.springframework.mock.web.MockHttpServletRequest;
@@ -106,4 +114,48 @@ void decorateFiltersWhenErrorsThenClosesObservationOnlyOnce() throws Exception {
106114
verify(handler).onScopeClosed(any());
107115
}
108116

117+
@ParameterizedTest
118+
@MethodSource("decorateFiltersWhenCompletesThenHasSpringSecurityReachedFilterNameTag")
119+
void decorateFiltersWhenCompletesThenHasSpringSecurityReachedFilterNameTag(Filter filter,
120+
String expectedFilterNameTag) throws Exception {
121+
ObservationHandler<Observation.Context> handler = mock(ObservationHandler.class);
122+
given(handler.supportsContext(any())).willReturn(true);
123+
ObservationRegistry registry = ObservationRegistry.create();
124+
registry.observationConfig().observationHandler(handler);
125+
ObservationFilterChainDecorator decorator = new ObservationFilterChainDecorator(registry);
126+
FilterChain chain = mock(FilterChain.class);
127+
FilterChain decorated = decorator.decorate(chain, List.of(filter));
128+
decorated.doFilter(new MockHttpServletRequest("GET", "/"), new MockHttpServletResponse());
129+
ArgumentCaptor<Observation.Context> context = ArgumentCaptor.forClass(Observation.Context.class);
130+
verify(handler, times(3)).onScopeClosed(context.capture());
131+
assertThat(context.getValue().getLowCardinalityKeyValue("spring.security.reached.filter.name").getValue())
132+
.isEqualTo(expectedFilterNameTag);
133+
}
134+
135+
static Stream<Arguments> decorateFiltersWhenCompletesThenHasSpringSecurityReachedFilterNameTag() {
136+
Filter filterWithName = new BasicAuthenticationFilter();
137+
138+
// Anonymous class leads to an empty filter-name
139+
Filter filterWithoutName = new Filter() {
140+
@Override
141+
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
142+
throws IOException, ServletException {
143+
chain.doFilter(request, response);
144+
}
145+
};
146+
147+
return Stream.of(Arguments.of(filterWithName, "BasicAuthenticationFilter"),
148+
Arguments.of(filterWithoutName, "none"));
149+
}
150+
151+
private static class BasicAuthenticationFilter implements Filter {
152+
153+
@Override
154+
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
155+
throws IOException, ServletException {
156+
chain.doFilter(request, response);
157+
}
158+
159+
}
160+
109161
}

web/src/test/java/org/springframework/security/web/server/ObservationWebFilterChainDecoratorTests.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,30 @@
1818

1919
import java.util.ArrayList;
2020
import java.util.List;
21+
import java.util.stream.Stream;
2122

2223
import io.micrometer.observation.Observation;
2324
import io.micrometer.observation.ObservationHandler;
2425
import io.micrometer.observation.ObservationRegistry;
2526
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor;
2627
import org.junit.jupiter.api.Test;
28+
import org.junit.jupiter.params.ParameterizedTest;
29+
import org.junit.jupiter.params.provider.Arguments;
30+
import org.junit.jupiter.params.provider.MethodSource;
31+
import org.mockito.ArgumentCaptor;
2732
import reactor.core.publisher.Mono;
2833

2934
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
3035
import org.springframework.mock.web.server.MockServerWebExchange;
36+
import org.springframework.web.server.ServerWebExchange;
3137
import org.springframework.web.server.WebFilter;
3238
import org.springframework.web.server.WebFilterChain;
3339

3440
import static org.assertj.core.api.Assertions.assertThat;
3541
import static org.mockito.ArgumentMatchers.any;
3642
import static org.mockito.BDDMockito.given;
3743
import static org.mockito.Mockito.mock;
44+
import static org.mockito.Mockito.times;
3845
import static org.mockito.Mockito.verify;
3946
import static org.mockito.Mockito.verifyNoInteractions;
4047

@@ -113,6 +120,51 @@ void decorateWhenCustomAfterFilterThenObserves() {
113120
handler.assertSpanStop(9, "http");
114121
}
115122

123+
@ParameterizedTest
124+
@MethodSource("decorateFiltersWhenCompletesThenHasSpringSecurityReachedFilterNameTagArguments")
125+
void decorateFiltersWhenCompletesThenHasSpringSecurityReachedFilterNameTag(WebFilter filter,
126+
String expectedFilterNameTag) {
127+
ObservationHandler<Observation.Context> handler = mock(ObservationHandler.class);
128+
given(handler.supportsContext(any())).willReturn(true);
129+
ObservationRegistry registry = ObservationRegistry.create();
130+
registry.observationConfig().observationHandler(handler);
131+
ObservationWebFilterChainDecorator decorator = new ObservationWebFilterChainDecorator(registry);
132+
WebFilterChain chain = mock(WebFilterChain.class);
133+
given(chain.filter(any())).willReturn(Mono.empty());
134+
WebFilterChain decorated = decorator.decorate(chain, List.of(filter));
135+
decorated.filter(MockServerWebExchange.from(MockServerHttpRequest.get("/").build())).block();
136+
137+
ArgumentCaptor<Observation.Context> context = ArgumentCaptor.forClass(Observation.Context.class);
138+
verify(handler, times(3)).onStop(context.capture());
139+
140+
assertThat(context.getValue().getLowCardinalityKeyValue("spring.security.reached.filter.name").getValue())
141+
.isEqualTo(expectedFilterNameTag);
142+
}
143+
144+
static Stream<Arguments> decorateFiltersWhenCompletesThenHasSpringSecurityReachedFilterNameTagArguments() {
145+
WebFilter filterWithName = new BasicAuthenticationFilter();
146+
147+
// Anonymous class leads to an empty filter-name
148+
WebFilter filterWithoutName = new WebFilter() {
149+
@Override
150+
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
151+
return chain.filter(exchange);
152+
}
153+
};
154+
155+
return Stream.of(Arguments.of(filterWithName, "BasicAuthenticationFilter"),
156+
Arguments.of(filterWithoutName, "none"));
157+
}
158+
159+
static class BasicAuthenticationFilter implements WebFilter {
160+
161+
@Override
162+
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
163+
return chain.filter(exchange);
164+
}
165+
166+
}
167+
116168
static class AccumulatingObservationHandler implements ObservationHandler<Observation.Context> {
117169

118170
List<Event> contexts = new ArrayList<>();

0 commit comments

Comments
 (0)