Skip to content

Commit

Permalink
Support Spring Web MVC in library instrumentation (#7552)
Browse files Browse the repository at this point in the history
Part of
#7312

This is pretty much a copy of the `spring-webvmc-5.3:library` module
with `s/javax/jakarta/` applied. I'm planning on removing the 5.3
instrumentation after #7312 is done.
  • Loading branch information
Mateusz Rzeszutek authored Jan 12, 2023
1 parent fe6d7eb commit ca310b4
Show file tree
Hide file tree
Showing 13 changed files with 838 additions and 1 deletion.
2 changes: 1 addition & 1 deletion docs/supported-libraries.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ These are the supported libraries and frameworks:
| [Spring RabbitMQ](https://spring.io/projects/spring-amqp) | 1.0+ | N/A | [Messaging Spans] |
| [Spring Scheduling](https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/scheduling/package-summary.html) | 3.1+ | N/A | none |
| [Spring RestTemplate](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/client/package-summary.html) | 3.1+ | [opentelemetry-spring-web-3.1](../instrumentation/spring/spring-web/spring-web-3.1/library) | [HTTP Client Spans], [HTTP Client Metrics] |
| [Spring Web MVC](https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/web/servlet/mvc/package-summary.html) | 3.1+ | [opentelemetry-spring-webmvc-5.3](../instrumentation/spring/spring-webmvc/spring-webmvc-5.3/library) | [HTTP Server Spans], [HTTP Server Metrics] |
| [Spring Web MVC](https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/web/servlet/mvc/package-summary.html) | 3.1+ | [opentelemetry-spring-webmvc-5.3](../instrumentation/spring/spring-webmvc/spring-webmvc-5.3/library),<br>[opentelemetry-spring-webmvc-6.0](../instrumentation/spring/spring-webmvc/spring-webmvc-6.0/library) | [HTTP Server Spans], [HTTP Server Metrics] |
| [Spring Web Services](https://spring.io/projects/spring-ws) | 2.0+ | N/A | none |
| [Spring WebFlux](https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/web/reactive/package-summary.html) | 5.0+ (not including 6.0+ yet) | [opentelemetry-spring-webflux-5.0](../instrumentation/spring/spring-webflux-5.0/library) | [HTTP Client Spans], [HTTP Client Metrics], |
| [Spymemcached](https://github.com/couchbase/spymemcached) | 2.12+ | N/A | [Database Client Spans] |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Library Instrumentation for Spring Web MVC version 6.0.0 and higher

Provides OpenTelemetry instrumentation for Spring WebMVC controllers.

## Quickstart

### Add these dependencies to your project

Replace `SPRING_VERSION` with the version of spring you're using.

- `Minimum version: 6.0.0`

Replace `OPENTELEMETRY_VERSION` with the [latest
release](https://search.maven.org/search?q=g:io.opentelemetry.instrumentation%20AND%20a:opentelemetry-spring-webmvc-6.0).

For Maven add the following to your `pom.xml`:

```xml
<dependencies>
<!-- OpenTelemetry instrumentation -->
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-spring-webmvc-6.0</artifactId>
<version>OPENTELEMETRY_VERSION</version>
</dependency>

<!-- OpenTelemetry exporter -->
<!-- replace this default exporter with your OpenTelemetry exporter (ex. otlp/zipkin/jaeger/..) -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-logging</artifactId>
<version>OPENTELEMETRY_VERSION</version>
</dependency>

<!-- required to instrument Spring WebMVC -->
<!-- this artifact should already be present in your application -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>SPRING_VERSION</version>
</dependency>

</dependencies>
```

For Gradle add the following to your dependencies:

```groovy
// OpenTelemetry instrumentation
implementation("io.opentelemetry.instrumentation:opentelemetry-spring-webmvc-6.0:OPENTELEMETRY_VERSION")
// OpenTelemetry exporter
// replace this default exporter with your OpenTelemetry exporter (ex. otlp/zipkin/jaeger/..)
implementation("io.opentelemetry:opentelemetry-exporter-logging:OPENTELEMETRY_VERSION")
// required to instrument Spring WebMVC
// this artifact should already be present in your application
implementation("org.springframework:spring-webmvc:SPRING_VERSION")
```

### Features

#### `SpringWebMvcTelemetry`

`SpringWebMvcTelemetry` enables creating OpenTelemetry server spans around HTTP requests processed
by the Spring servlet container.

##### Usage in Spring Boot

Spring Boot allows servlet `Filter`s to be registered as beans:

```java
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.instrumentation.spring.webmvc.v6_0.SpringWebMvcTelemetry;
import jakarta.servlet.Filter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SpringWebMvcTelemetryConfiguration {

@Bean
public Filter telemetryFilter(OpenTelemetry openTelemetry) {
return SpringWebMvcTelemetry.create(openTelemetry).createServletFilter();
}
}
```

### Starter Guide

Check
out [OpenTelemetry Manual Instrumentation](https://opentelemetry.io/docs/instrumentation/java/manual/)
to learn more about using the OpenTelemetry API to instrument your code.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
plugins {
id("otel.library-instrumentation")
}

dependencies {
compileOnly("org.springframework:spring-webmvc:6.0.0")
compileOnly("jakarta.servlet:jakarta.servlet-api:5.0.0")

testImplementation(project(":testing-common"))
testImplementation("org.springframework.boot:spring-boot-starter-web:3.0.0")
testImplementation("org.springframework.boot:spring-boot-starter-test:3.0.0")
}

// spring 6 requires java 17
otelJava {
minJavaVersionSupported.set(JavaVersion.VERSION_17)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.instrumentation.spring.webmvc.v6_0;

import static java.util.Objects.requireNonNull;
import static org.springframework.web.util.ServletRequestPathUtils.PATH_ATTRIBUTE;

import io.opentelemetry.context.Context;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.annotation.Nullable;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.web.context.ConfigurableWebApplicationContext;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.HandlerMapping;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.util.ServletRequestPathUtils;

final class HttpRouteSupport {

private final AtomicBoolean contextRefreshTriggered = new AtomicBoolean();
@Nullable private volatile DispatcherServlet dispatcherServlet;
@Nullable private volatile List<HandlerMapping> handlerMappings;
private volatile boolean parseRequestPath;

void onFilterInit(FilterConfig filterConfig) {
WebApplicationContext context =
WebApplicationContextUtils.getWebApplicationContext(filterConfig.getServletContext());
if (!(context instanceof ConfigurableWebApplicationContext)) {
return;
}

DispatcherServlet servlet = context.getBeanProvider(DispatcherServlet.class).getIfAvailable();
if (servlet != null) {
dispatcherServlet = servlet;

((ConfigurableWebApplicationContext) context)
.addApplicationListener(new WebContextRefreshListener());
}
}

// we can't retrieve the handler mappings from the DispatcherServlet in the onRefresh listener,
// because it loads them just after the application context refreshed event is processed
// to work around this, we're setting a boolean flag that'll cause this filter to load the
// mappings the next time it attempts to set the http.route
final class WebContextRefreshListener implements ApplicationListener<ContextRefreshedEvent> {

@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
contextRefreshTriggered.set(true);
}
}

boolean hasMappings() {
if (contextRefreshTriggered.compareAndSet(true, false)) {
// reload the handler mappings only if the web app context was recently refreshed
Optional.ofNullable(dispatcherServlet)
.map(DispatcherServlet::getHandlerMappings)
.ifPresent(this::setHandlerMappings);
}
return handlerMappings != null;
}

private void setHandlerMappings(List<HandlerMapping> mappings) {
List<HandlerMapping> handlerMappings = new ArrayList<>();
for (HandlerMapping mapping : mappings) {
// Originally we ran findMapping at the very beginning of the request. This turned out to have
// application-crashing side-effects with grails. That is why we don't add all HandlerMapping
// classes here. Although now that we run findMapping after the request, and only when server
// span name has not been updated by a controller, the probability of bad side-effects is much
// reduced even if we did add all HandlerMapping classes here.
if (mapping instanceof RequestMappingHandlerMapping) {
handlerMappings.add(mapping);
if (mapping.usesPathPatterns()) {
this.parseRequestPath = true;
}
}
}
if (!handlerMappings.isEmpty()) {
this.handlerMappings = handlerMappings;
}
}

@Nullable
String getHttpRoute(Context context, HttpServletRequest request) {
boolean parsePath = this.parseRequestPath;
Object previousValue = null;
if (parsePath) {
previousValue = request.getAttribute(PATH_ATTRIBUTE);
// sets new value for PATH_ATTRIBUTE of request
ServletRequestPathUtils.parseAndCache(request);
}
try {
if (findMapping(request)) {
// Name the parent span based on the matching pattern
// Let the parent span resource name be set with the attribute set in findMapping.
Object bestMatchingPattern =
request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
if (bestMatchingPattern != null) {
return prependContextPath(request, bestMatchingPattern.toString());
}
}
} finally {
// mimic spring DispatcherServlet and restore the previous value of PATH_ATTRIBUTE
if (parsePath) {
if (previousValue == null) {
request.removeAttribute(PATH_ATTRIBUTE);
} else {
request.setAttribute(PATH_ATTRIBUTE, previousValue);
}
}
}
return null;
}

/**
* When a HandlerMapping matches a request, it sets HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE
* as an attribute on the request. This attribute set as the HTTP route.
*/
private boolean findMapping(HttpServletRequest request) {
try {
// handlerMapping already null-checked above
for (HandlerMapping mapping : requireNonNull(handlerMappings)) {
HandlerExecutionChain handler = mapping.getHandler(request);
if (handler != null) {
return true;
}
}
} catch (Exception ignored) {
// mapping.getHandler() threw exception. Ignore
}
return false;
}

private static String prependContextPath(HttpServletRequest request, String route) {
String contextPath = request.getContextPath();
if (contextPath == null) {
return route;
}
return contextPath + (route.startsWith("/") ? route : ("/" + route));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.instrumentation.spring.webmvc.v6_0;

import io.opentelemetry.context.propagation.TextMapGetter;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Collections;

enum JakartaHttpServletRequestGetter implements TextMapGetter<HttpServletRequest> {
INSTANCE;

@Override
public Iterable<String> keys(HttpServletRequest carrier) {
return Collections.list(carrier.getHeaderNames());
}

@Override
public String get(HttpServletRequest carrier, String key) {
return carrier.getHeader(key);
}
}
Loading

0 comments on commit ca310b4

Please sign in to comment.