Add support for Tapestry web framework
MODIFY
docs/supported-libraries.md
docs/supported-libraries.md
@@ -25,6 +25,7 @@ These are the supported libraries and frameworks:
| [Apache HttpAsyncClient](https://hc.apache.org/index.html) | 4.1+ |
| [Apache HttpClient](https://hc.apache.org/index.html) | 2.0+ |
| [Apache RocketMQ](https://rocketmq.apache.org/) | 4.8+ |
+| [Apache Tapestry](https://tapestry.apache.org/) | 5.4+ |
| [Apache Wicket](https://wicket.apache.org/) | 8.0+ |
| [Armeria](https://armeria.dev) | 1.3+ |
| [AsyncHttpClient](https://github.com/AsyncHttpClient/async-http-client) | 1.9+ |

ADD
instrumentation/tapestry-5.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/tapestry/TapestryInstrumentationModule.java
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.javaagent.instrumentation.tapestry;
+
+import static io.opentelemetry.javaagent.instrumentation.tapestry.TapestryTracer.tracer;
+import static io.opentelemetry.javaagent.tooling.bytebuddy.matcher.ClassLoaderMatcher.hasClassesNamed;
+import static java.util.Arrays.asList;
+import static net.bytebuddy.matcher.ElementMatchers.isMethod;
+import static net.bytebuddy.matcher.ElementMatchers.isPublic;
+import static net.bytebuddy.matcher.ElementMatchers.named;
+import static net.bytebuddy.matcher.ElementMatchers.takesArgument;
+import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
+
+import com.google.auto.service.AutoService;
+import io.opentelemetry.context.Context;
+import io.opentelemetry.context.Scope;
+import io.opentelemetry.javaagent.tooling.InstrumentationModule;
+import io.opentelemetry.javaagent.tooling.TypeInstrumentation;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import net.bytebuddy.asm.Advice;
+import net.bytebuddy.description.method.MethodDescription;
+import net.bytebuddy.description.type.TypeDescription;
+import net.bytebuddy.matcher.ElementMatcher;
+import org.apache.tapestry5.internal.structure.ComponentPageElementImpl;
+import org.apache.tapestry5.services.ComponentEventRequestParameters;
+import org.apache.tapestry5.services.PageRenderRequestParameters;
+
+@AutoService(InstrumentationModule.class)
+public class TapestryInstrumentationModule extends InstrumentationModule {
+
+ public TapestryInstrumentationModule() {
+ super("tapestry", "tapestry-5.4");
+ }
+
+ @Override
+ public ElementMatcher.Junction<ClassLoader> classLoaderMatcher() {
+ // class added in tapestry 5.4.0
+ return hasClassesNamed("org.apache.tapestry5.Binding2");
+ }
+
+ @Override
+ public List<TypeInstrumentation> typeInstrumentations() {
+ return asList(
+ new InitializeActivePageNameInstrumentation(),
+ new ComponentPageElementImplInstrumentation());
+ }
+
+ public static class InitializeActivePageNameInstrumentation implements TypeInstrumentation {
+
+ @Override
+ public ElementMatcher<? super TypeDescription> typeMatcher() {
+ return named("org.apache.tapestry5.services.InitializeActivePageName");
+ }
+
+ @Override
+ public Map<? extends ElementMatcher<? super MethodDescription>, String> transformers() {
+ Map<ElementMatcher<MethodDescription>, String> transformers = new HashMap<>();
+
+ transformers.put(
+ isMethod()
+ .and(isPublic())
+ .and(named("handleComponentEvent"))
+ .and(takesArguments(2))
+ .and(
+ takesArgument(
+ 0, named("org.apache.tapestry5.services.ComponentEventRequestParameters")))
+ .and(
+ takesArgument(1, named("org.apache.tapestry5.services.ComponentRequestHandler"))),
+ InitializeActivePageNameInstrumentation.class.getName() + "$ComponentEventAdvice");
+ transformers.put(
+ isMethod()
+ .and(isPublic())
+ .and(named("handlePageRender"))
+ .and(takesArguments(2))
+ .and(
+ takesArgument(
+ 0, named("org.apache.tapestry5.services.PageRenderRequestParameters")))
+ .and(
+ takesArgument(1, named("org.apache.tapestry5.services.ComponentRequestHandler"))),
+ InitializeActivePageNameInstrumentation.class.getName() + "$PageRenderAdvice");
+
+ return transformers;
+ }
+
+ public static class ComponentEventAdvice {
+ @Advice.OnMethodEnter(suppress = Throwable.class)
+ public static void onEnter(@Advice.Argument(0) ComponentEventRequestParameters parameters) {
+ tracer().updateServerSpanName(parameters.getActivePageName());
+ }
+ }
+
+ public static class PageRenderAdvice {
+ @Advice.OnMethodEnter(suppress = Throwable.class)
+ public static void onEnter(@Advice.Argument(0) PageRenderRequestParameters parameters) {
+ tracer().updateServerSpanName(parameters.getLogicalPageName());
+ }
+ }
+ }
+
+ public static class ComponentPageElementImplInstrumentation implements TypeInstrumentation {
+
+ @Override
+ public ElementMatcher<? super TypeDescription> typeMatcher() {
+ return named("org.apache.tapestry5.internal.structure.ComponentPageElementImpl");
+ }
+
+ @Override
+ public Map<? extends ElementMatcher<? super MethodDescription>, String> transformers() {
+ return Collections.singletonMap(
+ isMethod()
+ .and(named("processEventTriggering"))
+ .and(takesArguments(3))
+ .and(takesArgument(0, String.class))
+ .and(takesArgument(1, named("org.apache.tapestry5.EventContext")))
+ .and(takesArgument(2, named("org.apache.tapestry5.ComponentEventCallback"))),
+ ComponentPageElementImplInstrumentation.class.getName() + "$EventAdvice");
+ }
+
+ public static class EventAdvice {
+ @Advice.OnMethodEnter(suppress = Throwable.class)
+ public static void onEnter(
+ @Advice.This ComponentPageElementImpl componentPageElementImpl,
+ @Advice.Argument(0) String eventType,
+ @Advice.Local("otelContext") Context context,
+ @Advice.Local("otelScope") Scope scope) {
+ context = tracer().startEventSpan(eventType, componentPageElementImpl.getCompleteId());
+ scope = context.makeCurrent();
+ }
+
+ @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class)
+ public static void onExit(
+ @Advice.Thrown Throwable throwable,
+ @Advice.Local("otelContext") Context context,
+ @Advice.Local("otelScope") Scope scope) {
+ scope.close();
+
+ if (throwable != null) {
+ tracer().endExceptionally(context, throwable);
+ } else {
+ tracer().end(context);
+ }
+ }
+ }
+ }
+}

ADD
instrumentation/tapestry-5.4/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/tapestry/TapestryTracer.java
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.javaagent.instrumentation.tapestry;
+
+import io.opentelemetry.api.GlobalOpenTelemetry;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.context.Context;
+import io.opentelemetry.instrumentation.api.servlet.ServletContextPath;
+import io.opentelemetry.instrumentation.api.tracer.BaseTracer;
+import io.opentelemetry.instrumentation.api.tracer.ServerSpan;
+import org.apache.tapestry5.runtime.ComponentEventException;
+
+public class TapestryTracer extends BaseTracer {
+
+ private static final TapestryTracer TRACER = new TapestryTracer();
+
+ public static TapestryTracer tracer() {
+ return TRACER;
+ }
+
+ private TapestryTracer() {
+ super(GlobalOpenTelemetry.get());
+ }
+
+ public void updateServerSpanName(String pageName) {
+ if (pageName == null) {
+ return;
+ }
+ Context context = Context.current();
+ Span span = ServerSpan.fromContextOrNull(context);
+ if (span != null) {
+ if (!pageName.isEmpty()) {
+ pageName = "/" + pageName;
+ }
+ span.updateName(ServletContextPath.prepend(context, pageName));
+ }
+ }
+
+ public Context startEventSpan(String eventType, String componentId) {
+ return super.startSpan(eventType + "/" + componentId);
+ }
+
+ @Override
+ protected Throwable unwrapThrowable(Throwable throwable) {
+ if (throwable instanceof ComponentEventException) {
+ throwable = throwable.getCause();
+ }
+ return super.unwrapThrowable(throwable);
+ }
+
+ @Override
+ protected String getInstrumentationName() {
+ return "io.opentelemetry.javaagent.tapestry-5.4";
+ }
+}

ADD
instrumentation/tapestry-5.4/javaagent/src/test/groovy/TapestryTest.groovy
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import static io.opentelemetry.instrumentation.test.utils.TraceUtils.basicSpan
+
+import io.opentelemetry.api.trace.SpanKind
+import io.opentelemetry.instrumentation.test.AgentInstrumentationSpecification
+import io.opentelemetry.instrumentation.test.asserts.TraceAssert
+import io.opentelemetry.instrumentation.test.base.HttpServerTestTrait
+import io.opentelemetry.instrumentation.test.utils.OkHttpUtils
+import okhttp3.HttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.Response
+import org.eclipse.jetty.server.Server
+import org.eclipse.jetty.util.resource.Resource
+import org.eclipse.jetty.webapp.WebAppContext
+import org.jsoup.Jsoup
+
+class TapestryTest extends AgentInstrumentationSpecification implements HttpServerTestTrait<Server> {
+
+ static OkHttpClient client = OkHttpUtils.client(true)
+
+ @Override
+ Server startServer(int port) {
+ WebAppContext webAppContext = new WebAppContext()
+ webAppContext.setContextPath(getContextPath())
+ // set up test application
+ webAppContext.setBaseResource(Resource.newResource("src/test/webapp"))
+
+ def jettyServer = new Server(port)
+ jettyServer.connectors.each {
+ it.setHost('localhost')
+ }
+
+ jettyServer.setHandler(webAppContext)
+ jettyServer.start()
+
+ return jettyServer
+ }
+
+ @Override
+ void stopServer(Server server) {
+ server.stop()
+ server.destroy()
+ }
+
+ @Override
+ String getContextPath() {
+ return "/jetty-context"
+ }
+
+ static serverSpan(TraceAssert trace, int index, String spanName) {
+ trace.span(index) {
+ hasNoParent()
+
+ name spanName
+ kind SpanKind.SERVER
+ }
+ }
+
+ def "test index page"() {
+ setup:
+ def url = HttpUrl.get(address.resolve("")).newBuilder().build()
+ def request = request(url, "GET", null).build()
+ Response response = client.newCall(request).execute()
+ def doc = Jsoup.parse(response.body().string())
+
+ expect:
+ response.code() == 200
+ doc.selectFirst("title").text() == "Index page"
+
+ assertTraces(1) {
+ trace(0, 2) {
+ serverSpan(it, 0, getContextPath() + "/Index")
+ basicSpan(it, 1, "activate/Index", span(0))
+ }
+ }
+ }
+
+ def "test start action"() {
+ setup:
+ // index.start triggers an action named "start" on index page
+ def url = HttpUrl.get(address.resolve("index.start")).newBuilder().build()
+ def request = request(url, "GET", null).build()
+ Response response = client.newCall(request).execute()
+ def doc = Jsoup.parse(response.body().string())
+
+ expect:
+ response.code() == 200
+ doc.selectFirst("title").text() == "Other page"
+
+ assertTraces(2) {
+ trace(0, 4) {
+ serverSpan(it, 0, getContextPath() + "/Index")
+ basicSpan(it, 1, "activate/Index", span(0))
+ basicSpan(it, 2, "action/Index:start", span(0))
+ basicSpan(it, 3, "HttpServletResponseWrapper.sendRedirect", span(2))
+ }
+ trace(1, 2) {
+ serverSpan(it, 0, getContextPath() + "/Other")
+ basicSpan(it, 1, "activate/Other", span(0))
+ }
+ }
+ }
+
+ def "test exception action"() {
+ setup:
+ // index.exception triggers an action named "exception" on index page
+ def url = HttpUrl.get(address.resolve("index.exception")).newBuilder().build()
+ def request = request(url, "GET", null).build()
+ Response response = client.newCall(request).execute()
+
+ expect:
+ response.code() == 500
+
+ assertTraces(1) {
+ trace(0, 3) {
+ span(0) {
+ hasNoParent()
+ kind SpanKind.SERVER
+ name getContextPath() + "/Index"
+ errored true
+ }
+ basicSpan(it, 1, "activate/Index", span(0))
+ basicSpan(it, 2, "action/Index:exception", span(0), new IllegalStateException("expected"))
+ }
+ }
+ }
+
+ Request.Builder request(HttpUrl url, String method, RequestBody body) {
+ return new Request.Builder()
+ .url(url)
+ .method(method, body)
+ .header("User-Agent", TEST_USER_AGENT)
+ .header("X-Forwarded-For", TEST_CLIENT_IP)
+ }
+}

ADD
instrumentation/tapestry-5.4/javaagent/src/test/java/test/tapestry/components/Layout.java
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package test.tapestry.components;
+
+import org.apache.tapestry5.BindingConstants;
+import org.apache.tapestry5.ComponentResources;
+import org.apache.tapestry5.annotations.Parameter;
+import org.apache.tapestry5.annotations.Property;
+import org.apache.tapestry5.ioc.annotations.Inject;
+
+public class Layout {
+ @Inject private ComponentResources resources;
+
+ @Property
+ @Parameter(required = true, defaultPrefix = BindingConstants.LITERAL)
+ private String title;
+}

ADD
instrumentation/tapestry-5.4/javaagent/src/test/java/test/tapestry/pages/Index.java
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package test.tapestry.pages;
+
+import org.apache.tapestry5.annotations.InjectPage;
+
+public class Index {
+
+ @InjectPage private Other other;
+
+ Object onActionFromStart() {
+ return other;
+ }
+
+ Object onActionFromException() {
+ throw new IllegalStateException("expected");
+ }
+}

ADD
instrumentation/tapestry-5.4/javaagent/src/test/java/test/tapestry/pages/Other.java
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package test.tapestry.pages;
+
+public class Other {}

ADD
instrumentation/tapestry-5.4/javaagent/src/test/resources/test/tapestry/components/Layout.tml
+<!DOCTYPE html>
+<html lang="en" xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd">
+
+ <head>
+ <title>${title}</title>
+ </head>
+
+ <body>
+ <t:body />
+ </body>
+</html>

ADD
instrumentation/tapestry-5.4/javaagent/src/test/resources/test/tapestry/pages/Index.tml
+<html t:type="layout" title="Index page"
+ xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd">
+
+ <t:actionlink t:id="start">start</t:actionlink>
+ <t:actionlink t:id="exception">exception</t:actionlink>
+
+</html>
\ No newline at end of file

ADD
instrumentation/tapestry-5.4/javaagent/src/test/resources/test/tapestry/pages/Other.tml
+<html t:type="layout" title="Other page"
+ xmlns:t="http://tapestry.apache.org/schema/tapestry_5_4.xsd">
+
+</html>
\ No newline at end of file

ADD
instrumentation/tapestry-5.4/javaagent/src/test/webapp/WEB-INF/web.xml
+<?xml version="1.0" encoding="UTF-8"?>
+
+<web-app xmlns="http://java.sun.com/xml/ns/j2ee"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd"
+ version="2.4">
+
+ <context-param>
+ <!--
+ The only significant configuration for Tapestry 5, this informs Tapestry
+ of where to look for pages, components and mixins.
+ -->
+ <param-name>tapestry.app-package</param-name>
+ <param-value>test.tapestry</param-value>
+ </context-param>
+
+ <!-- Filter configuration -->
+ <filter>
+ <filter-name>app</filter-name>
+ <filter-class>org.apache.tapestry5.TapestryFilter</filter-class>
+ </filter>
+
+ <filter-mapping>
+ <filter-name>app</filter-name>
+ <url-pattern>/*</url-pattern>
+ <dispatcher>REQUEST</dispatcher>
+ <dispatcher>ERROR</dispatcher>
+ </filter-mapping>
+
+</web-app>
\ No newline at end of file

ADD
instrumentation/tapestry-5.4/javaagent/tapestry-5.4-javaagent.gradle
+ext {
+ maxJavaVersionForTests = JavaVersion.VERSION_1_8
+}
+
+apply from: "$rootDir/gradle/instrumentation.gradle"
+
+muzzle {
+ pass {
+ group = "org.apache.tapestry"
+ module = "tapestry-core"
+ versions = "[5.4.0,)"
+ assertInverse = true
+ }
+}
+
+dependencies {
+ library 'org.apache.tapestry:tapestry-core:5.4.0'
+
+ testImplementation group: 'org.eclipse.jetty', name: 'jetty-webapp', version: '8.0.0.v20110901'
+ testImplementation group: 'org.jsoup', name: 'jsoup', version: '1.13.1'
+ testImplementation 'javax.annotation:javax.annotation-api:1.3.2'
+
+ testInstrumentation project(':instrumentation:jetty-8.0:javaagent')
+ testInstrumentation project(':instrumentation:servlet:servlet-3.0:javaagent')
+ testInstrumentation project(':instrumentation:servlet:servlet-javax-common:javaagent')
+}
\ No newline at end of file

MODIFY
settings.gradle
settings.gradle
@@ -252,6 +252,7 @@ include ':instrumentation:spring:starters:otlp-exporter-starter'
include ':instrumentation:spring:starters:zipkin-exporter-starter'
include ':instrumentation:spymemcached-2.12:javaagent'
include ':instrumentation:struts-2.3:javaagent'
+include ':instrumentation:tapestry-5.4:javaagent'
include ':instrumentation:tomcat-7.0:javaagent'
include ':instrumentation:twilio-6.6:javaagent'
include ':instrumentation:undertow-1.4:javaagent'

Add support for Tapestry web framework (#2690)