diff --git a/build.gradle b/build.gradle index 3aab36235418..d588111139ce 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,8 @@ configure(allprojects) { project -> mavenBom "io.projectreactor:reactor-bom:2020.0.15" mavenBom "io.r2dbc:r2dbc-bom:Arabba-SR12" mavenBom "io.rsocket:rsocket-bom:1.1.1" + mavenBom "io.micrometer:micrometer-bom:2.0.0-SNAPSHOT" + mavenBom "io.micrometer:micrometer-tracing-bom:1.0.0-SNAPSHOT" mavenBom "org.eclipse.jetty:jetty-bom:11.0.7" mavenBom "org.jetbrains.kotlin:kotlin-bom:1.6.10" mavenBom "org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.0" @@ -249,6 +251,7 @@ configure(allprojects) { project -> } repositories { mavenCentral() + maven { url "https://repo.spring.io/snapshot" } maven { url "https://repo.spring.io/libs-spring-framework-build" } } } diff --git a/spring-core/spring-core.gradle b/spring-core/spring-core.gradle index 7e49806e0dc1..60d046fc5432 100644 --- a/spring-core/spring-core.gradle +++ b/spring-core/spring-core.gradle @@ -47,6 +47,7 @@ dependencies { optional("io.reactivex.rxjava3:rxjava") optional("io.smallrye.reactive:mutiny") optional("io.netty:netty-buffer") + optional("io.micrometer:micrometer-api") testImplementation("jakarta.annotation:jakarta.annotation-api") testImplementation("jakarta.xml.bind:jakarta.xml.bind-api") testImplementation("com.google.code.findbugs:jsr305") diff --git a/spring-core/src/main/java/org/springframework/core/observability/AutoTimer.java b/spring-core/src/main/java/org/springframework/core/observability/AutoTimer.java new file mode 100644 index 000000000000..18d569ac4b08 --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/observability/AutoTimer.java @@ -0,0 +1,114 @@ +/* + * Copyright 2012-2022 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.core.observability; + +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import io.micrometer.api.annotation.Timed; +import io.micrometer.api.instrument.Timer; +import io.micrometer.api.instrument.Timer.Builder; + +import org.springframework.util.CollectionUtils; + +/** + * Strategy that can be used to apply {@link Timer Timers} automatically instead of using + * {@link Timed @Timed}. + * + * @author Tadaya Tsuyukubo + * @author Stephane Nicoll + * @author Phillip Webb + * @since 6.0.0 + */ +@FunctionalInterface +public interface AutoTimer { + + /** + * An {@link AutoTimer} implementation that is enabled but applies no additional + * customizations. + */ + AutoTimer ENABLED = builder -> { + }; + + /** + * An {@link AutoTimer} implementation that is disabled and will not record metrics. + */ + AutoTimer DISABLED = new AutoTimer() { + + @Override + public boolean isEnabled() { + return false; + } + + @Override + public void apply(Builder builder) { + throw new IllegalStateException("AutoTimer is disabled"); + } + + }; + + /** + * Return if the auto-timer is enabled and metrics should be recorded. + * @return if the auto-timer is enabled + */ + default boolean isEnabled() { + return true; + } + + /** + * Factory method to create a new {@link Builder Timer.Builder} with auto-timer + * settings {@link #apply(Builder) applied}. + * @param name the name of the timer + * @return a new builder instance with auto-settings applied + */ + default Builder builder(String name) { + return builder(() -> Timer.builder(name)); + } + + /** + * Factory method to create a new {@link Builder Timer.Builder} with auto-timer + * settings {@link #apply(Builder) applied}. + * @param supplier the builder supplier + * @return a new builder instance with auto-settings applied + */ + default Builder builder(Supplier supplier) { + Builder builder = supplier.get(); + apply(builder); + return builder; + } + + /** + * Called to apply any auto-timer settings to the given {@link Builder Timer.Builder}. + * @param builder the builder to apply settings to + */ + void apply(Builder builder); + + static void apply(AutoTimer autoTimer, String metricName, Set annotations, Consumer action) { + if (!CollectionUtils.isEmpty(annotations)) { + for (Timed annotation : annotations) { + action.accept(Timer.builder(annotation, metricName)); + } + } + else { + if (autoTimer != null && autoTimer.isEnabled()) { + action.accept(autoTimer.builder(metricName)); + } + } + } + +} diff --git a/spring-core/src/main/java/org/springframework/core/observability/annotation/TimedAnnotations.java b/spring-core/src/main/java/org/springframework/core/observability/annotation/TimedAnnotations.java new file mode 100644 index 000000000000..68cf750bcc5f --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/observability/annotation/TimedAnnotations.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-2022 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.core.observability.annotation; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import io.micrometer.api.annotation.Timed; + +import org.springframework.core.annotation.MergedAnnotationCollectors; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.util.ConcurrentReferenceHashMap; + +/** + * Utility used to obtain {@link Timed @Timed} annotations from bean methods. + * + * @author Phillip Webb + * @since 6.0.0 + */ +public final class TimedAnnotations { + + private static final Map> cache = new ConcurrentReferenceHashMap<>(); + + private TimedAnnotations() { + } + + /** + * Return {@link Timed} annotations that should be used for the given {@code method} + * and {@code type}. + * @param method the source method + * @param type the source type + * @return the {@link Timed} annotations to use or an empty set + */ + public static Set get(Method method, Class type) { + Set methodAnnotations = findTimedAnnotations(method); + if (!methodAnnotations.isEmpty()) { + return methodAnnotations; + } + return findTimedAnnotations(type); + } + + private static Set findTimedAnnotations(AnnotatedElement element) { + if (element == null) { + return Collections.emptySet(); + } + Set result = cache.get(element); + if (result != null) { + return result; + } + MergedAnnotations annotations = MergedAnnotations.from(element); + result = (!annotations.isPresent(Timed.class)) ? Collections.emptySet() + : annotations.stream(Timed.class).collect(MergedAnnotationCollectors.toAnnotationSet()); + cache.put(element, result); + return result; + } + +} diff --git a/spring-test/spring-test.gradle b/spring-test/spring-test.gradle index d7725e34f4bc..4f4b8b0bc7ca 100644 --- a/spring-test/spring-test.gradle +++ b/spring-test/spring-test.gradle @@ -67,6 +67,7 @@ dependencies { testImplementation("org.apache.httpcomponents:httpclient") testImplementation("io.projectreactor.netty:reactor-netty-http") testImplementation("de.bechte.junit:junit-hierarchicalcontextrunner") + testImplementation("io.micrometer:micrometer-tracing-integration-test") testRuntimeOnly("org.junit.vintage:junit-vintage-engine") { exclude group: "junit", module: "junit" } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/ObservabilityPlaygroundTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/ObservabilityPlaygroundTests.java new file mode 100644 index 000000000000..388847538865 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/ObservabilityPlaygroundTests.java @@ -0,0 +1,133 @@ +/* + * Copyright 2002-2022 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.test.web.servlet; + +import java.time.Duration; +import java.util.function.BiConsumer; + +import io.micrometer.api.annotation.Timed; +import io.micrometer.api.instrument.MeterRegistry; +import io.micrometer.api.instrument.Timer; +import io.micrometer.api.instrument.TimerRecordingHandler; +import io.micrometer.api.instrument.simple.SimpleMeterRegistry; +import io.micrometer.api.instrument.transport.http.context.HttpServerHandlerContext; +import io.micrometer.tracing.Tracer; +import io.micrometer.tracing.test.SampleTestRunner; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.observability.DefaultWebMvcTagsProvider; +import org.springframework.web.servlet.observability.WebMvcObservabilityFilter; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; + +/** + * Just a demo to try MVC instrumentation out with Zipkin, will be deleted later. + */ +public class ObservabilityPlaygroundTests extends SampleTestRunner { + private final SimpleMeterRegistry meterRegistry; + private final MockMvc mockMvc; + + public ObservabilityPlaygroundTests() { + this.meterRegistry = new SimpleMeterRegistry(); + this.meterRegistry.config().timerRecordingHandler(new TestTimerRecordingHandler()); + this.mockMvc = standaloneSetup(new TestController()) + .addFilters(new WebMvcObservabilityFilter(this.meterRegistry, new DefaultWebMvcTagsProvider(), "http.server.rq", null)) + .build(); + } + + @Override + protected MeterRegistry getMeterRegistry() { + return this.meterRegistry; + } + + @Override + protected SampleRunnerConfig getSampleRunnerConfig() { + return SampleRunnerConfig.builder().build(); + } + + @Override + public BiConsumer yourCode() { + return ((tracer, registry) -> { + try { + mockMvc.perform(get("/")); + mockMvc.perform(get("/api/people/12345")); + mockMvc.perform(get("/oops")); + } + catch (Exception e) { + e.printStackTrace(); + } + + System.out.println(meterRegistry.getMetersAsString()); + }); + } + + @Timed + @RestController + static class TestController { + @GetMapping("/") + String hello() { + return "hello"; + } + + @GetMapping("/api/people/{id}") + String personById(@PathVariable String id) { + return id; + } + + @GetMapping("/oops") + ResponseEntity oops() { + return ResponseEntity.badRequest().body("oops"); + } + } + + static class TestTimerRecordingHandler implements TimerRecordingHandler { + @Override + public void onStart(Timer.Sample sample, HttpServerHandlerContext context) { + System.out.println(sample + " started " + context); + } + + @Override + public void onError(Timer.Sample sample, HttpServerHandlerContext context, Throwable throwable) { + System.out.println(sample + " failed " + context); + } + + @Override + public void onStop(Timer.Sample sample, HttpServerHandlerContext context, Timer timer, Duration duration) { + System.out.println(sample + " stopped " + context); + } + + @Override + public void onScopeOpened(Timer.Sample sample, HttpServerHandlerContext context) { + System.out.println(sample + " scope opened " + context); + } + + @Override + public void onScopeClosed(Timer.Sample sample, HttpServerHandlerContext context) { + System.out.println(sample + " scope closed " + context); + } + + @Override + public boolean supportsContext(Timer.HandlerContext context) { + return context instanceof HttpServerHandlerContext; + } + } + +} diff --git a/spring-webmvc/spring-webmvc.gradle b/spring-webmvc/spring-webmvc.gradle index cb8bd88e583f..66efe27e96ba 100644 --- a/spring-webmvc/spring-webmvc.gradle +++ b/spring-webmvc/spring-webmvc.gradle @@ -31,6 +31,7 @@ dependencies { optional("org.jetbrains.kotlin:kotlin-stdlib") optional("org.reactivestreams:reactive-streams") optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + optional("io.micrometer:micrometer-api") testImplementation(testFixtures(project(":spring-beans"))) testImplementation(testFixtures(project(":spring-core"))) testImplementation(testFixtures(project(":spring-context"))) diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/DefaultWebMvcTagsProvider.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/DefaultWebMvcTagsProvider.java new file mode 100644 index 000000000000..2bb49159f6aa --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/DefaultWebMvcTagsProvider.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-2022 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.web.servlet.observability; + +import java.util.Collections; +import java.util.List; + +import io.micrometer.api.instrument.Tag; +import io.micrometer.api.instrument.Tags; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Default implementation of {@link WebMvcTagsProvider}. + * + * @author Jon Schneider + * @since 6.0.0 + */ +public class DefaultWebMvcTagsProvider implements WebMvcTagsProvider { + + private final boolean ignoreTrailingSlash; + + private final List contributors; + + public DefaultWebMvcTagsProvider() { + this(false); + } + + /** + * Creates a new {@link DefaultWebMvcTagsProvider} that will provide tags from the + * given {@code contributors} in addition to its own. + * @param contributors the contributors that will provide additional tags + */ + public DefaultWebMvcTagsProvider(List contributors) { + this(false, contributors); + } + + public DefaultWebMvcTagsProvider(boolean ignoreTrailingSlash) { + this(ignoreTrailingSlash, Collections.emptyList()); + } + + /** + * Creates a new {@link DefaultWebMvcTagsProvider} that will provide tags from the + * given {@code contributors} in addition to its own. + * @param ignoreTrailingSlash whether trailing slashes should be ignored when + * determining the {@code uri} tag. + * @param contributors the contributors that will provide additional tags + */ + public DefaultWebMvcTagsProvider(boolean ignoreTrailingSlash, List contributors) { + this.ignoreTrailingSlash = ignoreTrailingSlash; + this.contributors = contributors; + } + + @Override + public Iterable getTags(HttpServletRequest request, HttpServletResponse response, Object handler, + Throwable exception) { + Tags tags = Tags.of(WebMvcTags.method(request), WebMvcTags.uri(request, response, this.ignoreTrailingSlash), + WebMvcTags.exception(exception), WebMvcTags.status(response), WebMvcTags.outcome(response)); + for (WebMvcTagsContributor contributor : this.contributors) { + tags = tags.and(contributor.getTags(request, response, handler, exception)); + } + return tags; + } + + @Override + public Iterable getLongRequestTags(HttpServletRequest request, Object handler) { + Tags tags = Tags.of(WebMvcTags.method(request), WebMvcTags.uri(request, null, this.ignoreTrailingSlash)); + for (WebMvcTagsContributor contributor : this.contributors) { + tags = tags.and(contributor.getLongRequestTags(request, handler)); + } + return tags; + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/HttpServletRequestWrapper.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/HttpServletRequestWrapper.java new file mode 100644 index 000000000000..13f0d248f262 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/HttpServletRequestWrapper.java @@ -0,0 +1,111 @@ +/* + * Copyright 2012-2022 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.web.servlet.observability; + +import java.util.Collection; +import java.util.Collections; + +import io.micrometer.api.instrument.transport.http.HttpServerRequest; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.web.servlet.HandlerMapping; + +/** + * An {@link HttpServerRequest} that wraps/delegates to {@link HttpServletRequest}. + * + * @author Marcin Grzejszczak + * @author Jonatan Ivanov + * + * @since 6.0.0 + */ +public final class HttpServletRequestWrapper implements HttpServerRequest { + private final HttpServletRequest request; + + private HttpServletRequestWrapper(HttpServletRequest request) { + this.request = request; + } + + /** + * Static factory method to create an instance. + * @param request the request to wrap + * @return an {@link HttpServletRequestWrapper} instance that uses the provided request. + */ + static HttpServletRequestWrapper wrap(HttpServletRequest request) { + return new HttpServletRequestWrapper(request); + } + + @Override + public String method() { + return this.request.getMethod(); + } + + @Override + public String path() { + return this.request.getRequestURI(); + } + + @Override + public String route() { + Object routeCandidate = this.request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + return routeCandidate instanceof String ? (String) routeCandidate : null; + } + + @Override + public String url() { + StringBuffer url = this.request.getRequestURL(); + if (this.request.getQueryString() != null && !this.request.getQueryString().isEmpty()) { + url.append('?').append(this.request.getQueryString()); + } + + return url.toString(); + } + + @Override + public String header(String name) { + return this.request.getHeader(name); + } + + @Override + public String remoteIp() { + return this.request.getRemoteAddr(); + } + + @Override + public int remotePort() { + return this.request.getRemotePort(); + } + + @Override + public Collection headerNames() { + return Collections.list(this.request.getHeaderNames()); + } + + @Override + public Object unwrap() { + return this.request; + } + + @Override + public Object getAttribute(String key) { + return this.request.getAttribute(key); + } + + @Override + public void setAttribute(String key, Object value) { + this.request.setAttribute(key, value); + } +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/HttpServletResponseWrapper.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/HttpServletResponseWrapper.java new file mode 100644 index 000000000000..eab25d6a3a04 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/HttpServletResponseWrapper.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-2022 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.web.servlet.observability; + +import java.util.Collection; + +import io.micrometer.api.instrument.transport.http.HttpServerRequest; +import io.micrometer.api.instrument.transport.http.HttpServerResponse; +import jakarta.servlet.http.HttpServletResponse; + +/** + * A {@link HttpServerResponse} that wraps and delegates to {@link HttpServletResponse}. + * + * @author Marcin Grzejszczak + * @author Jonatan Ivanov + * + * @since 6.0.0 + */ +public final class HttpServletResponseWrapper implements HttpServerResponse { + private final HttpServerRequest request; + private final HttpServletResponse response; + private final Throwable error; + + private HttpServletResponseWrapper(HttpServerRequest request, HttpServletResponse response, Throwable error) { + this.request = request; + this.response = response; + this.error = error; + } + + /** + * Static factory method to create an instance. + * @param request the request to wrap + * @param response the response to wrap + * @param error the error to wrap + * @return an {@link HttpServletResponseWrapper} instance that uses the provided response. + */ + static HttpServletResponseWrapper wrap(HttpServerRequest request, HttpServletResponse response, Throwable error) { + return new HttpServletResponseWrapper(request, response, error); + } + + @Override + public int statusCode() { + return this.response.getStatus(); + } + + @Override + public String header(String name) { + return this.response.getHeader(name); + } + + @Override + public Collection headerNames() { + return this.response.getHeaderNames(); + } + + @Override + public Object unwrap() { + return this.response; + } + + @Override + public HttpServerRequest request() { + return this.request; + } + + @Override + public Throwable error() { + return this.error; + } +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/LongTaskTimingHandlerInterceptor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/LongTaskTimingHandlerInterceptor.java new file mode 100644 index 000000000000..86b5bd3546d0 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/LongTaskTimingHandlerInterceptor.java @@ -0,0 +1,165 @@ +/* + * Copyright 2012-2022 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.web.servlet.observability; + +import java.lang.reflect.AnnotatedElement; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import io.micrometer.api.annotation.Timed; +import io.micrometer.api.instrument.LongTaskTimer; +import io.micrometer.api.instrument.MeterRegistry; +import io.micrometer.api.instrument.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.annotation.MergedAnnotationCollectors; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * A {@link HandlerInterceptor} that supports Micrometer's long task timers configured on + * a handler using {@link Timed @Timed} with {@link Timed#longTask() longTask} set to + * {@code true}. + * + * @author Andy Wilkinson + * @since 6.0.0 + */ +public class LongTaskTimingHandlerInterceptor implements HandlerInterceptor { + + private static final Log logger = LogFactory.getLog(LongTaskTimingHandlerInterceptor.class); + + private final MeterRegistry registry; + + private final WebMvcTagsProvider tagsProvider; + + /** + * Creates a new {@code LongTaskTimingHandlerInterceptor} that will create + * {@link LongTaskTimer LongTaskTimers} using the given registry. Timers will be + * tagged using the given {@code tagsProvider}. + * @param registry the registry + * @param tagsProvider the tags provider + */ + public LongTaskTimingHandlerInterceptor(MeterRegistry registry, WebMvcTagsProvider tagsProvider) { + this.registry = registry; + this.tagsProvider = tagsProvider; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + LongTaskTimingContext timingContext = LongTaskTimingContext.get(request); + if (timingContext == null) { + startAndAttachTimingContext(request, handler); + } + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) + throws Exception { + if (!request.isAsyncStarted()) { + stopLongTaskTimers(LongTaskTimingContext.get(request)); + } + } + + private void startAndAttachTimingContext(HttpServletRequest request, Object handler) { + Set annotations = getTimedAnnotations(handler); + Collection longTaskTimerSamples = getLongTaskTimerSamples(request, handler, annotations); + LongTaskTimingContext timingContext = new LongTaskTimingContext(longTaskTimerSamples); + timingContext.attachTo(request); + } + + private Collection getLongTaskTimerSamples(HttpServletRequest request, Object handler, + Set annotations) { + List samples = new ArrayList<>(); + try { + annotations.stream().filter(Timed::longTask).forEach(annotation -> { + Iterable tags = this.tagsProvider.getLongRequestTags(request, handler); + LongTaskTimer.Builder builder = LongTaskTimer.builder(annotation).tags(tags); + LongTaskTimer timer = builder.register(this.registry); + samples.add(timer.start()); + }); + } + catch (Exception ex) { + logger.warn("Failed to start long task timers", ex); + // Allow request-response exchange to continue, unaffected by metrics problem + } + return samples; + } + + private Set getTimedAnnotations(Object handler) { + if (!(handler instanceof HandlerMethod)) { + return Collections.emptySet(); + } + return getTimedAnnotations((HandlerMethod) handler); + } + + private Set getTimedAnnotations(HandlerMethod handler) { + Set timed = findTimedAnnotations(handler.getMethod()); + if (timed.isEmpty()) { + return findTimedAnnotations(handler.getBeanType()); + } + return timed; + } + + private Set findTimedAnnotations(AnnotatedElement element) { + return MergedAnnotations.from(element).stream(Timed.class) + .collect(MergedAnnotationCollectors.toAnnotationSet()); + } + + private void stopLongTaskTimers(LongTaskTimingContext timingContext) { + for (LongTaskTimer.Sample sample : timingContext.getLongTaskTimerSamples()) { + sample.stop(); + } + } + + /** + * Context object attached to a request to retain information across the multiple + * interceptor calls that happen with async requests. + */ + static class LongTaskTimingContext { + + private static final String ATTRIBUTE = LongTaskTimingContext.class.getName(); + + private final Collection longTaskTimerSamples; + + LongTaskTimingContext(Collection longTaskTimerSamples) { + this.longTaskTimerSamples = longTaskTimerSamples; + } + + Collection getLongTaskTimerSamples() { + return this.longTaskTimerSamples; + } + + void attachTo(HttpServletRequest request) { + request.setAttribute(ATTRIBUTE, this); + } + + static LongTaskTimingContext get(HttpServletRequest request) { + return (LongTaskTimingContext) request.getAttribute(ATTRIBUTE); + } + + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/WebMvcObservabilityFilter.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/WebMvcObservabilityFilter.java new file mode 100644 index 000000000000..bbb9141b0fb6 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/WebMvcObservabilityFilter.java @@ -0,0 +1,203 @@ +/* + * Copyright 2012-2022 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.web.servlet.observability; + +import java.io.IOException; +import java.util.Collections; +import java.util.Set; + +import io.micrometer.api.annotation.Timed; +import io.micrometer.api.instrument.MeterRegistry; +import io.micrometer.api.instrument.Timer; +import io.micrometer.api.instrument.transport.http.context.HttpServerHandlerContext; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.observability.AutoTimer; +import org.springframework.core.observability.annotation.TimedAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.util.NestedServletException; + +/** + * Intercepts incoming HTTP requests handled by Spring MVC handlers and records them. + * + * @author Jon Schneider + * @author Phillip Webb + * @author Chanhyeong LEE + * @since 6.0.0 + */ +public class WebMvcObservabilityFilter extends OncePerRequestFilter { + + private static final Log logger = LogFactory.getLog(WebMvcObservabilityFilter.class); + + private final MeterRegistry registry; + + private final WebMvcTagsProvider tagsProvider; + + private final String metricName; + + private final AutoTimer autoTimer; + + /** + * Create a new {@link WebMvcObservabilityFilter} instance. + * @param registry the meter registry + * @param tagsProvider the tags provider + * @param metricName the metric name + * @param autoTimer the auto-timers to apply or {@code null} to disable auto-timing + */ + public WebMvcObservabilityFilter(MeterRegistry registry, WebMvcTagsProvider tagsProvider, String metricName, + AutoTimer autoTimer) { + this.registry = registry; + this.tagsProvider = tagsProvider; + this.metricName = metricName; + this.autoTimer = autoTimer; + } + + @Override + protected boolean shouldNotFilterAsyncDispatch() { + return false; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + TimingContext timingContext = TimingContext.get(request); + if (timingContext == null) { + timingContext = startAndAttachTimingContext(request); + } + try { + filterChain.doFilter(request, response); + if (!request.isAsyncStarted()) { + // Only record when async processing has finished or never been started. + // If async was started by something further down the chain we wait + // until the second filter invocation (but we'll be using the + // TimingContext that was attached to the first) + Throwable exception = fetchException(request); + record(timingContext, request, response, exception); + } + } + catch (Exception ex) { + response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); + record(timingContext, request, response, unwrapNestedServletException(ex)); + throw ex; + } + } + + private Throwable unwrapNestedServletException(Throwable ex) { + return (ex instanceof NestedServletException) ? ex.getCause() : ex; + } + + private TimingContext startAndAttachTimingContext(HttpServletRequest request) { + HttpServerHandlerContext handlerContext = new HttpServerHandlerContext(HttpServletRequestWrapper.wrap(request)); + Timer.Sample sample = Timer.start(this.registry, handlerContext); + Timer.Scope scope = sample.makeCurrent(); + TimingContext timingContext = new TimingContext(sample, scope, handlerContext); + timingContext.attachTo(request); + return timingContext; + } + + private Throwable fetchException(HttpServletRequest request) { + return (Throwable) request.getAttribute(DispatcherServlet.EXCEPTION_ATTRIBUTE); + } + + private void record(TimingContext timingContext, HttpServletRequest request, HttpServletResponse response, + Throwable exception) { + try { + Object handler = getHandler(request); + Set annotations = getTimedAnnotations(handler); + HttpServerHandlerContext handlerContext = timingContext.getHandlerContext(); + handlerContext.setResponse(HttpServletResponseWrapper.wrap(handlerContext.getRequest(), response, exception)); + AutoTimer.apply(this.autoTimer, this.metricName, annotations, + builder -> stop(enhanceBuilder(builder, handler, request, response, exception), timingContext)); + } + catch (Exception ex) { + logger.warn("Failed to record timer metrics", ex); + // Allow request-response exchange to continue, unaffected by metrics problem + } + } + + private Object getHandler(HttpServletRequest request) { + return request.getAttribute(HandlerMapping.BEST_MATCHING_HANDLER_ATTRIBUTE); + } + + private Set getTimedAnnotations(Object handler) { + if (handler instanceof HandlerMethod) { + HandlerMethod handlerMethod = (HandlerMethod) handler; + return TimedAnnotations.get(handlerMethod.getMethod(), handlerMethod.getBeanType()); + } + return Collections.emptySet(); + } + + private void stop(Timer.Builder builder, TimingContext timingContext) { + timingContext.getScope().close(); + timingContext.getSample().stop(builder); + } + + private Timer.Builder enhanceBuilder(Timer.Builder builder, Object handler, HttpServletRequest request, HttpServletResponse response, + Throwable exception) { + return builder.tags(this.tagsProvider.getTags(request, response, handler, exception)); + } + + /** + * Context object attached to a request to retain information across the multiple + * filter calls that happen with async requests. + */ + private static class TimingContext { + + private static final String ATTRIBUTE = TimingContext.class.getName(); + + private final Timer.Sample sample; + private final Timer.Scope scope; + private final HttpServerHandlerContext handlerContext; + + TimingContext(Timer.Sample sample, Timer.Scope scope, HttpServerHandlerContext handlerContext) { + this.sample = sample; + this.scope = scope; + this.handlerContext = handlerContext; + } + + Timer.Sample getSample() { + return this.sample; + } + + Timer.Scope getScope() { + return this.scope; + } + + HttpServerHandlerContext getHandlerContext() { + return this.handlerContext; + } + + void attachTo(HttpServletRequest request) { + request.setAttribute(ATTRIBUTE, this); + } + + static TimingContext get(HttpServletRequest request) { + return (TimingContext) request.getAttribute(ATTRIBUTE); + } + + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/WebMvcTags.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/WebMvcTags.java new file mode 100644 index 000000000000..d1fa55f03281 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/WebMvcTags.java @@ -0,0 +1,189 @@ +/* + * Copyright 2012-2022 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.web.servlet.observability; + +import java.util.regex.Pattern; + +import io.micrometer.api.instrument.Tag; +import io.micrometer.api.instrument.transport.http.tags.Outcome; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpStatus; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.util.pattern.PathPattern; + +/** + * Factory methods for {@link Tag Tags} associated with a request-response exchange that + * is handled by Spring MVC. + * + * @author Jon Schneider + * @author Andy Wilkinson + * @author Brian Clozel + * @author Michael McFadyen + * @since 6.0.0 + */ +public final class WebMvcTags { + + private static final String DATA_REST_PATH_PATTERN_ATTRIBUTE = "org.springframework.data.rest.webmvc.RepositoryRestHandlerMapping.EFFECTIVE_REPOSITORY_RESOURCE_LOOKUP_PATH"; + + private static final Tag URI_NOT_FOUND = Tag.of("uri", "NOT_FOUND"); + + private static final Tag URI_REDIRECTION = Tag.of("uri", "REDIRECTION"); + + private static final Tag URI_ROOT = Tag.of("uri", "root"); + + private static final Tag URI_UNKNOWN = Tag.of("uri", "UNKNOWN"); + + private static final Tag EXCEPTION_NONE = Tag.of("exception", "None"); + + private static final Tag STATUS_UNKNOWN = Tag.of("status", "UNKNOWN"); + + private static final Tag METHOD_UNKNOWN = Tag.of("method", "UNKNOWN"); + + private static final Pattern TRAILING_SLASH_PATTERN = Pattern.compile("/$"); + + private static final Pattern MULTIPLE_SLASH_PATTERN = Pattern.compile("//+"); + + private WebMvcTags() { + } + + /** + * Creates a {@code method} tag based on the {@link HttpServletRequest#getMethod() + * method} of the given {@code request}. + * @param request the request + * @return the method tag whose value is a capitalized method (e.g. GET). + */ + public static Tag method(HttpServletRequest request) { + return (request != null) ? Tag.of("method", request.getMethod()) : METHOD_UNKNOWN; + } + + /** + * Creates a {@code status} tag based on the status of the given {@code response}. + * @param response the HTTP response + * @return the status tag derived from the status of the response + */ + public static Tag status(HttpServletResponse response) { + return (response != null) ? Tag.of("status", Integer.toString(response.getStatus())) : STATUS_UNKNOWN; + } + + /** + * Creates a {@code uri} tag based on the URI of the given {@code request}. Uses the + * {@link HandlerMapping#BEST_MATCHING_PATTERN_ATTRIBUTE} best matching pattern if + * available. Falling back to {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND} + * for 404 responses, {@code root} for requests with no path info, and {@code UNKNOWN} + * for all other requests. + * @param request the request + * @param response the response + * @return the uri tag derived from the request + */ + public static Tag uri(HttpServletRequest request, HttpServletResponse response) { + return uri(request, response, false); + } + + /** + * Creates a {@code uri} tag based on the URI of the given {@code request}. Uses the + * {@link HandlerMapping#BEST_MATCHING_PATTERN_ATTRIBUTE} best matching pattern if + * available. Falling back to {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND} + * for 404 responses, {@code root} for requests with no path info, and {@code UNKNOWN} + * for all other requests. + * @param request the request + * @param response the response + * @param ignoreTrailingSlash whether to ignore the trailing slash + * @return the uri tag derived from the request + */ + public static Tag uri(HttpServletRequest request, HttpServletResponse response, boolean ignoreTrailingSlash) { + if (request != null) { + String pattern = getMatchingPattern(request); + if (pattern != null) { + if (ignoreTrailingSlash && pattern.length() > 1) { + pattern = TRAILING_SLASH_PATTERN.matcher(pattern).replaceAll(""); + } + if (pattern.isEmpty()) { + return URI_ROOT; + } + return Tag.of("uri", pattern); + } + if (response != null) { + HttpStatus status = extractStatus(response); + if (status != null) { + if (status.is3xxRedirection()) { + return URI_REDIRECTION; + } + if (status == HttpStatus.NOT_FOUND) { + return URI_NOT_FOUND; + } + } + } + String pathInfo = getPathInfo(request); + if (pathInfo.isEmpty()) { + return URI_ROOT; + } + } + return URI_UNKNOWN; + } + + private static HttpStatus extractStatus(HttpServletResponse response) { + try { + return HttpStatus.valueOf(response.getStatus()); + } + catch (IllegalArgumentException ex) { + return null; + } + } + + private static String getMatchingPattern(HttpServletRequest request) { + PathPattern dataRestPathPattern = (PathPattern) request.getAttribute(DATA_REST_PATH_PATTERN_ATTRIBUTE); + if (dataRestPathPattern != null) { + return dataRestPathPattern.getPatternString(); + } + return (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + } + + private static String getPathInfo(HttpServletRequest request) { + String pathInfo = request.getPathInfo(); + String uri = StringUtils.hasText(pathInfo) ? pathInfo : "/"; + uri = MULTIPLE_SLASH_PATTERN.matcher(uri).replaceAll("/"); + return TRAILING_SLASH_PATTERN.matcher(uri).replaceAll(""); + } + + /** + * Creates an {@code exception} tag based on the {@link Class#getSimpleName() simple + * name} of the class of the given {@code exception}. + * @param exception the exception, may be {@code null} + * @return the exception tag derived from the exception + */ + public static Tag exception(Throwable exception) { + if (exception != null) { + String simpleName = exception.getClass().getSimpleName(); + return Tag.of("exception", StringUtils.hasText(simpleName) ? simpleName : exception.getClass().getName()); + } + return EXCEPTION_NONE; + } + + /** + * Creates an {@code outcome} tag based on the status of the given {@code response}. + * @param response the HTTP response + * @return the outcome tag derived from the status of the response + */ + public static Tag outcome(HttpServletResponse response) { + Outcome outcome = (response != null) ? Outcome.forStatus(response.getStatus()) : Outcome.UNKNOWN; + return outcome.asTag(); + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/WebMvcTagsContributor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/WebMvcTagsContributor.java new file mode 100644 index 000000000000..d5ae7f97ffef --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/WebMvcTagsContributor.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2022 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.web.servlet.observability; + +import io.micrometer.api.instrument.LongTaskTimer; +import io.micrometer.api.instrument.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * A contributor of {@link Tag Tags} for Spring MVC-based request handling. Typically used + * by a {@link WebMvcTagsProvider} to provide tags in addition to its defaults. + * + * @author Andy Wilkinson + * @since 6.0.0 + */ +public interface WebMvcTagsContributor { + + /** + * Provides tags to be associated with metrics for the given {@code request} and + * {@code response} exchange. + * @param request the request + * @param response the response + * @param handler the handler for the request or {@code null} if the handler is + * unknown + * @param exception the current exception, if any + * @return tags to associate with metrics for the request and response exchange + */ + Iterable getTags(HttpServletRequest request, HttpServletResponse response, Object handler, + Throwable exception); + + /** + * Provides tags to be used by {@link LongTaskTimer long task timers}. + * @param request the HTTP request + * @param handler the handler for the request or {@code null} if the handler is + * unknown + * @return tags to associate with metrics recorded for the request + */ + Iterable getLongRequestTags(HttpServletRequest request, Object handler); + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/WebMvcTagsProvider.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/WebMvcTagsProvider.java new file mode 100644 index 000000000000..06ab81a12a36 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/observability/WebMvcTagsProvider.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2022 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.web.servlet.observability; + +import io.micrometer.api.instrument.LongTaskTimer; +import io.micrometer.api.instrument.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Provides {@link Tag Tags} for Spring MVC-based request handling. + * + * @author Jon Schneider + * @author Andy Wilkinson + * @since 6.0.0 + */ +public interface WebMvcTagsProvider { + + /** + * Provides tags to be associated with metrics for the given {@code request} and + * {@code response} exchange. + * @param request the request + * @param response the response + * @param handler the handler for the request or {@code null} if the handler is + * unknown + * @param exception the current exception, if any + * @return tags to associate with metrics for the request and response exchange + */ + Iterable getTags(HttpServletRequest request, HttpServletResponse response, Object handler, + Throwable exception); + + /** + * Provides tags to be used by {@link LongTaskTimer long task timers}. + * @param request the HTTP request + * @param handler the handler for the request or {@code null} if the handler is + * unknown + * @return tags to associate with metrics recorded for the request + */ + Iterable getLongRequestTags(HttpServletRequest request, Object handler); + +}