diff --git a/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamBenchmark.java b/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamBenchmark.java new file mode 100644 index 00000000000..2432469949c --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamBenchmark.java @@ -0,0 +1,60 @@ +package datadog.trace.bootstrap.instrumentation.buffer; + +import static java.util.concurrent.TimeUnit.MICROSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.apache.commons.io.IOUtils; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +@State(Scope.Benchmark) +@Warmup(iterations = 1, time = 30, timeUnit = SECONDS) +@Measurement(iterations = 2, time = 30, timeUnit = SECONDS) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(MICROSECONDS) +@Fork(value = 1) +public class InjectingPipeOutputStreamBenchmark { + private static final List htmlContent; + private static final byte[] marker; + private static final byte[] content; + + static { + try (InputStream is = new URL("https://www.google.com").openStream()) { + htmlContent = IOUtils.readLines(is, StandardCharsets.UTF_8); + } catch (IOException ioe) { + throw new RuntimeException(ioe); + } + marker = "".getBytes(StandardCharsets.UTF_8); + content = "" | true | "" + "" | "" | "" | false | "" + "" | "" | "" | false | "" + } +} diff --git a/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/RumHttpServletResponseWrapper.java b/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/RumHttpServletResponseWrapper.java new file mode 100644 index 00000000000..389bf77eddf --- /dev/null +++ b/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/RumHttpServletResponseWrapper.java @@ -0,0 +1,84 @@ +package datadog.trace.instrumentation.servlet3; + +import datadog.trace.api.rum.RumInjector; +import java.io.IOException; +import java.io.PrintWriter; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; + +public class RumHttpServletResponseWrapper extends HttpServletResponseWrapper { + private ServletOutputStream outputStream; + private PrintWriter printWriter; + private boolean shouldInject; + + public RumHttpServletResponseWrapper(HttpServletResponse response) { + super(response); + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + if (!shouldInject) { + return super.getOutputStream(); + } + if (outputStream == null) { + String encoding = getCharacterEncoding(); + if (encoding == null) { + encoding = "UTF-8"; + } + outputStream = + new WrappedServletOutputStream( + super.getOutputStream(), + RumInjector.getMarker(encoding), + RumInjector.getSnippet(encoding), + this::onInjected); + } + return outputStream; + } + + @Override + public PrintWriter getWriter() throws IOException { + if (!shouldInject) { + return super.getWriter(); + } + if (printWriter == null) { + printWriter = new PrintWriter(getOutputStream()); + } + return printWriter; + } + + @Override + public void setContentLength(int len) { + // don't set it since we don't know if we will inject + } + + @Override + public void reset() { + this.outputStream = null; + this.printWriter = null; + this.shouldInject = false; + super.reset(); + } + + @Override + public void resetBuffer() { + this.outputStream = null; + this.printWriter = null; + this.shouldInject = false; + super.resetBuffer(); + } + + public void onInjected(Void ignored) { + try { + setHeader("x-datadog-rum-injected", "1"); + } catch (Throwable ignored2) { + } + } + + @Override + public void setContentType(String type) { + if (type != null && type.contains("html")) { + shouldInject = true; + } + } +} diff --git a/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Advice.java b/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Advice.java index b4f1a5ad164..8ed6322fe4d 100644 --- a/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Advice.java +++ b/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Advice.java @@ -4,6 +4,7 @@ import static datadog.trace.bootstrap.instrumentation.api.Java8BytecodeBridge.spanFromContext; import static datadog.trace.bootstrap.instrumentation.decorator.HttpServerDecorator.DD_DISPATCH_SPAN_ATTRIBUTE; import static datadog.trace.bootstrap.instrumentation.decorator.HttpServerDecorator.DD_FIN_DISP_LIST_SPAN_ATTRIBUTE; +import static datadog.trace.bootstrap.instrumentation.decorator.HttpServerDecorator.DD_RUM_INJECTED; import static datadog.trace.bootstrap.instrumentation.decorator.HttpServerDecorator.DD_SPAN_ATTRIBUTE; import static datadog.trace.instrumentation.servlet3.Servlet3Decorator.DECORATE; @@ -15,6 +16,7 @@ import datadog.trace.api.DDTags; import datadog.trace.api.GlobalTracer; import datadog.trace.api.gateway.Flow; +import datadog.trace.api.rum.RumInjector; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.instrumentation.servlet.ServletBlockingHelper; import java.security.Principal; @@ -30,7 +32,7 @@ public class Servlet3Advice { @Advice.OnMethodEnter(suppress = Throwable.class, skipOn = Advice.OnNonDefaultValue.class) public static boolean onEnter( @Advice.Argument(value = 0, readOnly = false) ServletRequest request, - @Advice.Argument(value = 1) ServletResponse response, + @Advice.Argument(value = 1, readOnly = false) ServletResponse response, @Advice.Local("isDispatch") boolean isDispatch, @Advice.Local("finishSpan") boolean finishSpan, @Advice.Local("contextScope") ContextScope scope) { @@ -41,7 +43,13 @@ public static boolean onEnter( } final HttpServletRequest httpServletRequest = (HttpServletRequest) request; - final HttpServletResponse httpServletResponse = (HttpServletResponse) response; + HttpServletResponse httpServletResponse = (HttpServletResponse) response; + + if (RumInjector.isEnabled() && httpServletRequest.getAttribute(DD_RUM_INJECTED) == null) { + httpServletRequest.setAttribute(DD_RUM_INJECTED, Boolean.TRUE); + httpServletResponse = new RumHttpServletResponseWrapper(httpServletResponse); + response = httpServletResponse; + } Object dispatchSpan = request.getAttribute(DD_DISPATCH_SPAN_ATTRIBUTE); if (dispatchSpan instanceof AgentSpan) { diff --git a/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Instrumentation.java b/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Instrumentation.java index a7dc4f28368..d7732045329 100644 --- a/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Instrumentation.java +++ b/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Instrumentation.java @@ -52,6 +52,8 @@ public String[] helperClassNames() { packageName + ".Servlet3Decorator", packageName + ".ServletRequestURIAdapter", packageName + ".FinishAsyncDispatchListener", + packageName + ".RumHttpServletResponseWrapper", + packageName + ".WrappedServletOutputStream", "datadog.trace.instrumentation.servlet.ServletBlockingHelper", }; } diff --git a/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/WrappedServletOutputStream.java b/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/WrappedServletOutputStream.java new file mode 100644 index 00000000000..32323a1cfff --- /dev/null +++ b/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/WrappedServletOutputStream.java @@ -0,0 +1,85 @@ +package datadog.trace.instrumentation.servlet3; + +import datadog.trace.bootstrap.instrumentation.buffer.InjectingPipeOutputStream; +import datadog.trace.util.MethodHandles; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.invoke.MethodHandle; +import java.util.function.Consumer; +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; + +public class WrappedServletOutputStream extends ServletOutputStream { + private final OutputStream filtered; + private final ServletOutputStream delegate; + + private static final MethodHandle IS_READY_MH = getMh("isReady"); + private static final MethodHandle SET_WRITELISTENER_MH = getMh("setWriteListener"); + + private static final MethodHandle getMh(final String name) { + try { + return new MethodHandles(ServletOutputStream.class.getClassLoader()) + .method(ServletOutputStream.class, name); + } catch (Throwable ignored) { + return null; + } + } + + public WrappedServletOutputStream( + ServletOutputStream delegate, + byte[] marker, + byte[] contentToInject, + Consumer onInjected) { + this.filtered = new InjectingPipeOutputStream(delegate, marker, contentToInject, onInjected); + this.delegate = delegate; + } + + @Override + public void write(int b) throws IOException { + filtered.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + filtered.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + filtered.write(b, off, len); + } + + @Override + public void flush() throws IOException { + filtered.flush(); + } + + @Override + public void close() throws IOException { + filtered.close(); + } + + public boolean isReady() { + if (IS_READY_MH == null) { + return false; + } + try { + return (boolean) IS_READY_MH.invoke(delegate); + } catch (Throwable e) { + // how to sneaky throw? + throw new RuntimeException(e); + } + } + + public void setWriteListener(WriteListener writeListener) { + if (SET_WRITELISTENER_MH == null) { + return; + } + try { + SET_WRITELISTENER_MH.invoke(delegate, writeListener); + } catch (Throwable e) { + // how to sneaky throw? + throw new RuntimeException(e); + } + } +} diff --git a/dd-java-agent/instrumentation/servlet/request-3/src/test/groovy/JettyServlet3Test.groovy b/dd-java-agent/instrumentation/servlet/request-3/src/test/groovy/JettyServlet3Test.groovy index 61a255e1f0d..2ed349c5402 100644 --- a/dd-java-agent/instrumentation/servlet/request-3/src/test/groovy/JettyServlet3Test.groovy +++ b/dd-java-agent/instrumentation/servlet/request-3/src/test/groovy/JettyServlet3Test.groovy @@ -3,6 +3,7 @@ import datadog.trace.agent.test.base.HttpServer import datadog.trace.agent.test.naming.TestingGenericHttpNamingConventions import datadog.trace.api.iast.InstrumentationBridge import datadog.trace.api.iast.sink.ApplicationModule +import datadog.trace.api.rum.RumInjector import datadog.trace.bootstrap.instrumentation.api.AgentSpan import datadog.trace.bootstrap.instrumentation.api.Tags import datadog.trace.instrumentation.servlet3.AsyncDispatcherDecorator @@ -20,6 +21,7 @@ import javax.servlet.AsyncListener import javax.servlet.Servlet import javax.servlet.ServletException import javax.servlet.annotation.WebServlet +import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse @@ -167,8 +169,8 @@ abstract class JettyServlet3Test extends AbstractServlet3Test expectedExtraErrorInformation(ServerEndpoint endpoint) { if (endpoint.throwsException) { ["error.message": "${endpoint.body}", - "error.type": { it == Exception.name || it == InputMismatchException.name }, - "error.stack": String] + "error.type" : { it == Exception.name || it == InputMismatchException.name }, + "error.stack" : String] } else { Collections.emptyMap() } @@ -233,6 +235,77 @@ class JettyServlet3TestSync extends JettyServlet3Test { } } +class JettyServlet3SyncRumInjectionForkedTest extends JettyServlet3TestSync { + + static class RumServlet extends HttpServlet { + private final String mimeType + + RumServlet(String mime) { + this.mimeType = mime + } + + @Override + protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.setContentType(mimeType) + try (def writer = resp.getWriter()) { + writer.println("\n" + + "\n" + + "\n" + + " \n" + + " This is the title of the webpage!\n" + + " \n" + + " \n" + + "

This is an example paragraph. Anything in the body tag will appear on the page, just like this p tag and its contents.

\n" + + " \n" + + "") + } + } + } + + static class HtmlRumServlet extends RumServlet { + HtmlRumServlet() { + super("text/html") + } + } + + static class XmlRumServlet extends RumServlet { + XmlRumServlet() { + super("text/xml") + } + } + + @Override + protected void configurePreAgent() { + super.configurePreAgent() + injectSysConfig("rum.enabled", "true") + injectSysConfig("rum.application.id", "test") + injectSysConfig("rum.client.token", "secret") + } + + @Override + protected void setupServlets(ServletContextHandler servletContextHandler) { + super.setupServlets(servletContextHandler) + addServlet(servletContextHandler, "/gimme-html", HtmlRumServlet) + addServlet(servletContextHandler, "/gimme-xml", XmlRumServlet) + } + + def "test rum injection in head for mime #mime"() { + setup: + def request = new okhttp3.Request.Builder().url(server.address().resolve("gimme-$mime").toURL()) + .get().build() + when: + def response = client.newCall(request).execute() + then: + assert response.code() == 200 + assert response.body().string().contains(new String(RumInjector.getSnippet("UTF-8"), "UTF-8")) == expected + assert response.header("x-datadog-rum-injected") == (expected ? "1" : null) + where: + mime | expected + "html" | true + "xml" | false + } +} + class JettyServlet3SyncV1ForkedTest extends JettyServlet3TestSync implements TestingGenericHttpNamingConventions.ServerV1 { @@ -260,6 +333,7 @@ class JettyServlet3TestAsync extends JettyServlet3Test { class JettyServlet3ASyncV1ForkedTest extends JettyServlet3TestAsync implements TestingGenericHttpNamingConventions.ServerV1 { } + class JettyServlet3TestFakeAsync extends JettyServlet3Test { @Override @@ -586,16 +660,16 @@ class IastJettyServlet3ForkedTest extends JettyServlet3TestSync { client.newCall(request).execute() then: - 1 * appModule.onRealPath(_) - 1 * appModule.checkSessionTrackingModes(_) + 1 * appModule.onRealPath(_) + 1 * appModule.checkSessionTrackingModes(_) 0 * _ when: client.newCall(request).execute() then: //Only call once per application context - 0 * appModule.onRealPath(_) - 0 * appModule.checkSessionTrackingModes(_) + 0 * appModule.onRealPath(_) + 0 * appModule.checkSessionTrackingModes(_) 0 * _ } diff --git a/dd-smoke-tests/rum/build.gradle b/dd-smoke-tests/rum/build.gradle new file mode 100644 index 00000000000..f5a54e7985a --- /dev/null +++ b/dd-smoke-tests/rum/build.gradle @@ -0,0 +1,7 @@ +apply from: "$rootDir/gradle/java.gradle" + +description = 'appsec-smoke-tests' + +dependencies { + api project(':dd-smoke-tests') +} diff --git a/dd-smoke-tests/rum/src/main/groovy/datadog/smoketest/rum/AbstractRumServerSmokeTest.groovy b/dd-smoke-tests/rum/src/main/groovy/datadog/smoketest/rum/AbstractRumServerSmokeTest.groovy new file mode 100644 index 00000000000..dade3311a41 --- /dev/null +++ b/dd-smoke-tests/rum/src/main/groovy/datadog/smoketest/rum/AbstractRumServerSmokeTest.groovy @@ -0,0 +1,21 @@ +package datadog.smoketest.rum + +import datadog.smoketest.AbstractServerSmokeTest +import okhttp3.Response +import spock.lang.Shared + +class AbstractRumServerSmokeTest extends AbstractServerSmokeTest { + @Shared + protected String[] defaultRumProperties = [ + "-Ddd.rum.enabled=true", + "-Ddd.rum.application.id=appid", + "-Ddd.rum.client.token=token" + ] + + + static void assertRumInjected(Response response) { + assert response.header('x-datadog-rum-injected') == '1': 'RUM injected header missing' + def content = response.body().string() + assert content.contains('https://www.datadoghq-browser-agent.com'): 'RUM script not injected' + } +} diff --git a/dd-smoke-tests/rum/tomcat-9/build.gradle b/dd-smoke-tests/rum/tomcat-9/build.gradle new file mode 100644 index 00000000000..2c4d856c541 --- /dev/null +++ b/dd-smoke-tests/rum/tomcat-9/build.gradle @@ -0,0 +1,26 @@ +plugins { + id 'com.gradleup.shadow' +} + +apply from: "$rootDir/gradle/java.gradle" +description = 'RUM Tomcat 9 Smoke Tests' + +dependencies { + implementation 'org.apache.tomcat.embed:tomcat-embed-core:9.0.88' + implementation 'org.apache.tomcat.embed:tomcat-embed-jasper:9.0.88' + implementation 'javax.servlet:javax.servlet-api:4.0.1' + + testImplementation project(':dd-smoke-tests:rum') +} + +jar { + manifest { + attributes('Main-Class': 'com.example.Main') + } +} + +tasks.withType(Test).configureEach { + dependsOn "shadowJar" + + jvmArgs "-Ddatadog.smoketest.rum.tomcat9.shadowJar.path=${tasks.shadowJar.archiveFile.get()}" +} diff --git a/dd-smoke-tests/rum/tomcat-9/src/main/java/com/example/HelloServlet.java b/dd-smoke-tests/rum/tomcat-9/src/main/java/com/example/HelloServlet.java new file mode 100644 index 00000000000..5639cb65677 --- /dev/null +++ b/dd-smoke-tests/rum/tomcat-9/src/main/java/com/example/HelloServlet.java @@ -0,0 +1,29 @@ +package com.example; + +import java.io.IOException; +import java.io.PrintWriter; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public class HelloServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setContentType("text/html;charset=UTF-8"); + try (final PrintWriter writer = resp.getWriter()) { + writer.write( + "" + + "" + + "" + + " " + + " " + + " Hello Servlet" + + "" + + "" + + "

Hello from Tomcat 9 Servlet!

" + + "

This is a demo HTML page served by Java servlet.

" + + "" + + ""); + } + } +} diff --git a/dd-smoke-tests/rum/tomcat-9/src/main/java/com/example/Main.java b/dd-smoke-tests/rum/tomcat-9/src/main/java/com/example/Main.java new file mode 100644 index 00000000000..a69a8701d89 --- /dev/null +++ b/dd-smoke-tests/rum/tomcat-9/src/main/java/com/example/Main.java @@ -0,0 +1,35 @@ +package com.example; + +import java.io.File; +import org.apache.catalina.Context; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.startup.Tomcat; + +public class Main { + public static void main(String[] args) throws LifecycleException { + int port = 8080; + if (args.length == 1) { + port = Integer.parseInt(args[0]); + } + + Tomcat tomcat = new Tomcat(); + tomcat.setPort(port); + tomcat.getConnector(); // This is required to make Tomcat start + tomcat.setBaseDir("."); + + // Add webapp context + String contextPath = ""; + String docBase = new File(".").getAbsolutePath(); + Context context = tomcat.addContext(contextPath, docBase); + + // Add servlet programmatically + context.addServletContainerInitializer( + (c, ctx) -> { + ctx.addServlet("helloServlet", new HelloServlet()).addMapping("/hello"); + }, + null); + + tomcat.start(); + tomcat.getServer().await(); + } +} diff --git a/dd-smoke-tests/rum/tomcat-9/src/test/groovy/datadog/smoketest/rum/tomcat9/Tomcat9RumSmokeTest.groovy b/dd-smoke-tests/rum/tomcat-9/src/test/groovy/datadog/smoketest/rum/tomcat9/Tomcat9RumSmokeTest.groovy new file mode 100644 index 00000000000..25bfe66de87 --- /dev/null +++ b/dd-smoke-tests/rum/tomcat-9/src/test/groovy/datadog/smoketest/rum/tomcat9/Tomcat9RumSmokeTest.groovy @@ -0,0 +1,43 @@ +package datadog.smoketest.rum.tomcat9 + +import datadog.smoketest.rum.AbstractRumServerSmokeTest +import datadog.trace.api.Platform +import okhttp3.Request +import okhttp3.Response + +class Tomcat9RumSmokeTest extends AbstractRumServerSmokeTest { + + + @Override + ProcessBuilder createProcessBuilder() { + String jarPath = System.getProperty('datadog.smoketest.rum.tomcat9.shadowJar.path') + + List command = [] + command.add(javaPath()) + command.addAll(defaultJavaProperties) + command.addAll(defaultRumProperties) + if (Platform.isJavaVersionAtLeast(17)) { + command.addAll((String[]) ['--add-opens', 'java.base/java.lang=ALL-UNNAMED']) + } + command.addAll(['-jar', jarPath, Integer.toString(httpPort)]) + ProcessBuilder processBuilder = new ProcessBuilder(command) + processBuilder.directory(new File(buildDirectory)) + return processBuilder + } + + void 'test RUM SDK injection'() { + given: + def url = "http://localhost:${httpPort}/hello" + def request = new Request.Builder() + .url(url) + .get() + .build() + + when: + Response response = client.newCall(request).execute() + + then: + response.code() == 200 + assertRumInjected(response) + } +} diff --git a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java index a6d5888e76f..c3656cd3251 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java @@ -240,6 +240,11 @@ public final class ConfigDefaults { static final boolean DEFAULT_TELEMETRY_LOG_COLLECTION_ENABLED = true; static final int DEFAULT_TELEMETRY_DEPENDENCY_RESOLUTION_QUEUE_SIZE = 100000; + static final boolean DEFAULT_RUM_ENABLED = false; + static final int DEFAULT_RUM_MAJOR_VERSION = 6; + static final float DEFAULT_RUM_SESSION_SAMPLE_RATE = 100f; + static final float DEFAULT_RUM_SESSION_REPLAY_SAMPLE_RATE = 100f; + static final boolean DEFAULT_SSI_INJECTION_FORCE = false; static final String DEFAULT_INSTRUMENTATION_SOURCE = "manual"; diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/RumConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/RumConfig.java new file mode 100644 index 00000000000..51d90fd31c4 --- /dev/null +++ b/dd-trace-api/src/main/java/datadog/trace/api/config/RumConfig.java @@ -0,0 +1,20 @@ +package datadog.trace.api.config; + +public final class RumConfig { + public static final String RUM_ENABLED = "rum.enabled"; + public static final String RUM_APPLICATION_ID = "rum.application.id"; + public static final String RUM_CLIENT_TOKEN = "rum.client.token"; + public static final String RUM_SITE = "rum.site"; + public static final String RUM_SERVICE = "rum.service"; + public static final String RUM_ENVIRONMENT = "rum.environment"; + public static final String RUM_MAJOR_VERSION = "rum.major.version"; + public static final String RUM_VERSION = "rum.version"; + public static final String RUM_TRACK_USER_INTERACTION = "rum.track.user.interaction"; + public static final String RUM_TRACK_RESOURCES = "rum.track.resources"; + public static final String RUM_TRACK_LONG_TASKS = "rum.track.long.tasks"; + public static final String RUM_DEFAULT_PRIVACY_LEVEL = "rum.default.privacy.level"; + public static final String RUM_SESSION_SAMPLE_RATE = "rum.session.sample.rate"; + public static final String RUM_SESSION_REPLAY_SAMPLE_RATE = "rum.session.replay.sample.rate"; + + private RumConfig() {} +} diff --git a/internal-api/build.gradle b/internal-api/build.gradle index fbf1916a4af..424873f75be 100644 --- a/internal-api/build.gradle +++ b/internal-api/build.gradle @@ -244,6 +244,7 @@ dependencies { api project(':dd-trace-api') api libs.slf4j api project(':components:context') + api project(':components:json') api project(':components:yaml') api project(':components:cli') api project(":utils:time-utils") diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 6534d8f870c..56a55be4204 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -118,6 +118,10 @@ import static datadog.trace.api.ConfigDefaults.DEFAULT_REMOTE_CONFIG_POLL_INTERVAL_SECONDS; import static datadog.trace.api.ConfigDefaults.DEFAULT_REMOTE_CONFIG_TARGETS_KEY; import static datadog.trace.api.ConfigDefaults.DEFAULT_REMOTE_CONFIG_TARGETS_KEY_ID; +import static datadog.trace.api.ConfigDefaults.DEFAULT_RUM_ENABLED; +import static datadog.trace.api.ConfigDefaults.DEFAULT_RUM_MAJOR_VERSION; +import static datadog.trace.api.ConfigDefaults.DEFAULT_RUM_SESSION_REPLAY_SAMPLE_RATE; +import static datadog.trace.api.ConfigDefaults.DEFAULT_RUM_SESSION_SAMPLE_RATE; import static datadog.trace.api.ConfigDefaults.DEFAULT_SCOPE_DEPTH_LIMIT; import static datadog.trace.api.ConfigDefaults.DEFAULT_SCOPE_ITERATION_KEEP_ALIVE; import static datadog.trace.api.ConfigDefaults.DEFAULT_SECURE_RANDOM; @@ -461,6 +465,20 @@ import static datadog.trace.api.config.RemoteConfigConfig.REMOTE_CONFIG_TARGETS_KEY; import static datadog.trace.api.config.RemoteConfigConfig.REMOTE_CONFIG_TARGETS_KEY_ID; import static datadog.trace.api.config.RemoteConfigConfig.REMOTE_CONFIG_URL; +import static datadog.trace.api.config.RumConfig.RUM_APPLICATION_ID; +import static datadog.trace.api.config.RumConfig.RUM_CLIENT_TOKEN; +import static datadog.trace.api.config.RumConfig.RUM_DEFAULT_PRIVACY_LEVEL; +import static datadog.trace.api.config.RumConfig.RUM_ENABLED; +import static datadog.trace.api.config.RumConfig.RUM_ENVIRONMENT; +import static datadog.trace.api.config.RumConfig.RUM_MAJOR_VERSION; +import static datadog.trace.api.config.RumConfig.RUM_SERVICE; +import static datadog.trace.api.config.RumConfig.RUM_SESSION_REPLAY_SAMPLE_RATE; +import static datadog.trace.api.config.RumConfig.RUM_SESSION_SAMPLE_RATE; +import static datadog.trace.api.config.RumConfig.RUM_SITE; +import static datadog.trace.api.config.RumConfig.RUM_TRACK_LONG_TASKS; +import static datadog.trace.api.config.RumConfig.RUM_TRACK_RESOURCES; +import static datadog.trace.api.config.RumConfig.RUM_TRACK_USER_INTERACTION; +import static datadog.trace.api.config.RumConfig.RUM_VERSION; import static datadog.trace.api.config.TraceInstrumentationConfig.ADD_SPAN_POINTERS; import static datadog.trace.api.config.TraceInstrumentationConfig.AXIS_PROMOTE_RESOURCE_NAME; import static datadog.trace.api.config.TraceInstrumentationConfig.CASSANDRA_KEYSPACE_STATEMENT_EXTRACTION_ENABLED; @@ -618,6 +636,8 @@ import datadog.trace.api.iast.telemetry.Verbosity; import datadog.trace.api.naming.SpanNaming; import datadog.trace.api.profiling.ProfilingEnablement; +import datadog.trace.api.rum.RumInjectorConfig; +import datadog.trace.api.rum.RumInjectorConfig.PrivacyLevel; import datadog.trace.bootstrap.config.provider.CapturedEnvironmentConfigSource; import datadog.trace.bootstrap.config.provider.ConfigProvider; import datadog.trace.bootstrap.config.provider.SystemPropertiesConfigSource; @@ -1190,6 +1210,9 @@ public static String getHostName() { private final int stackTraceLengthLimit; + private final boolean rumEnabled; + private final RumInjectorConfig rumInjectorConfig; + // Read order: System Properties -> Env Variables, [-> properties file], [-> default value] private Config() { this(ConfigProvider.createDefault()); @@ -2673,9 +2696,40 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) this.stackTraceLengthLimit = configProvider.getInteger(STACK_TRACE_LENGTH_LIMIT, defaultStackTraceLengthLimit); + this.rumEnabled = configProvider.getBoolean(RUM_ENABLED, DEFAULT_RUM_ENABLED); + this.rumInjectorConfig = parseRumConfig(configProvider); + log.debug("New instance: {}", this); } + private static RumInjectorConfig parseRumConfig(ConfigProvider configProvider) { + String applicationId = configProvider.getString(RUM_APPLICATION_ID); + String clientToken = configProvider.getString(RUM_CLIENT_TOKEN); + if (applicationId == null || clientToken == null) { + return null; + } + try { + return new RumInjectorConfig( + applicationId, + clientToken, + configProvider.getString(RUM_SITE), + configProvider.getString(RUM_SERVICE), + configProvider.getString(RUM_ENVIRONMENT), + configProvider.getInteger(RUM_MAJOR_VERSION, DEFAULT_RUM_MAJOR_VERSION), + configProvider.getString(RUM_VERSION), + configProvider.getBoolean(RUM_TRACK_USER_INTERACTION), + configProvider.getBoolean(RUM_TRACK_RESOURCES), + configProvider.getBoolean(RUM_TRACK_LONG_TASKS), + configProvider.getEnum(RUM_DEFAULT_PRIVACY_LEVEL, PrivacyLevel.class, null), + configProvider.getFloat(RUM_SESSION_SAMPLE_RATE, DEFAULT_RUM_SESSION_SAMPLE_RATE), + configProvider.getFloat( + RUM_SESSION_REPLAY_SAMPLE_RATE, DEFAULT_RUM_SESSION_REPLAY_SAMPLE_RATE)); + } catch (IllegalArgumentException e) { + log.warn("Unable to configure RUM injection", e); + return null; + } + } + /** * Converts a list of packages in Jacoco exclusion format ({@code * my.package.*,my.other.package.*}) to list of package prefixes suitable for use with ASM ({@code @@ -4908,6 +4962,14 @@ public int getCloudPayloadTaggingMaxTags() { return cloudPayloadTaggingMaxTags; } + public boolean isRumEnabled() { + return this.rumEnabled; + } + + public RumInjectorConfig getRumInjectorConfig() { + return this.rumInjectorConfig; + } + private Set getSettingsSetFromEnvironment( String name, Function mapper, boolean splitOnWS) { final String value = configProvider.getString(name, ""); @@ -5588,6 +5650,10 @@ public String toString() { + cloudResponsePayloadTagging + ", experimentalPropagateProcessTagsEnabled=" + experimentalPropagateProcessTagsEnabled + + ", rumEnabled=" + + rumEnabled + + ", rumInjectorConfig=" + + (rumInjectorConfig == null ? "null" : rumInjectorConfig.jsonPayload()) + '}'; } } diff --git a/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java b/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java new file mode 100644 index 00000000000..25e337f67ec --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java @@ -0,0 +1,73 @@ +package datadog.trace.api.rum; + +import datadog.trace.api.Config; +import datadog.trace.api.cache.DDCache; +import datadog.trace.api.cache.DDCaches; +import java.util.function.Function; + +public final class RumInjector { + private static volatile boolean initialized = false; + private static volatile boolean enabled; + private static volatile String snippet; + + private static final String MARKER = ""; + private static final DDCache SNIPPET_CACHE = DDCaches.newFixedSizeCache(16); + private static final DDCache MARKER_CACHE = DDCaches.newFixedSizeCache(16); + private static final Function SNIPPET_ADDER = + charset -> { + try { + return snippet.getBytes(charset); + } catch (Throwable t) { + return null; + } + }; + private static final Function MARKER_ADDER = + charset -> { + try { + return MARKER.getBytes(charset); + } catch (Throwable t) { + return null; + } + }; + + /** + * Check whether RUM injection is enabled and ready to inject. + * + * @return {@code true} if enabled, {@code otherwise}. + */ + public static boolean isEnabled() { + if (!initialized) { + Config config = Config.get(); + boolean rumEnabled = config.isRumEnabled(); + RumInjectorConfig injectorConfig = config.getRumInjectorConfig(); + if (rumEnabled && injectorConfig != null) { + enabled = true; + snippet = injectorConfig.getSnippet(); + } else { + enabled = false; + snippet = null; + } + initialized = true; + } + return enabled; + } + + /** + * Get the HTML snippet to inject RUM SDK + * + * @return The HTML snippet to inject, {@code null} if RUM injection is disabled to inject. + */ + public static byte[] getSnippet(String encoding) { + if (!isEnabled()) { + return null; + } + return SNIPPET_CACHE.computeIfAbsent(encoding, SNIPPET_ADDER); + } + + public static byte[] getMarker(String encoding) { + if (!isEnabled()) { + return null; + } + return MARKER_CACHE.computeIfAbsent(encoding, MARKER_ADDER); + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorConfig.java b/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorConfig.java new file mode 100644 index 00000000000..66b350c2b46 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorConfig.java @@ -0,0 +1,187 @@ +package datadog.trace.api.rum; + +import static java.util.Locale.ROOT; + +import datadog.json.JsonWriter; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; + +public class RumInjectorConfig { + private static final String DEFAULT_SITE = "datadoghq.com"; + private static final String GOV_CLOUD_SITE = "ddog-gov.com"; + private static final Map REGIONS = new HashMap<>(); + + static { + REGIONS.put("datadoghq.com", "us1"); + REGIONS.put("us3.datadoghq.com", "us3"); + REGIONS.put("us5.datadoghq.com", "us5"); + REGIONS.put("datadoghq.eu", "eu1"); + REGIONS.put("ap1.datadoghq.com", "ap1"); + } + + /** RUM application ID */ + public final String applicationId; + /** The client token provided by Datadog to authenticate requests. */ + public final String clientToken; + /** The Datadog site to which data will be sent (e.g., `datadoghq.com`). */ + public final String site; + /** The name of the service being monitored. */ + @Nullable public final String service; + /** The environment of the service (e.g., `prod`, `staging` or `dev). */ + @Nullable public final String env; + /** SDK major version. */ + public int majorVersion; + /** The version of the service (e.g., `0.1.0`, `a8dj92`, `2024-30`). */ + @Nullable public final String version; + /** Enables or disables the automatic collection of users actions (e.g., clicks). */ + @Nullable public final Boolean trackUserInteractions; + /** Enables or disables the collection of resource events (e.g., loading of images or scripts). */ + @Nullable public final Boolean trackResources; + /** Enables or disables the collection of long task events. */ + @Nullable public final Boolean trackLongTask; + /** The privacy level for data collection. */ + @Nullable public final PrivacyLevel defaultPrivacyLevel; + /** The percentage of user sessions to be tracked (between 0.0 and 100.0). */ + public final float sessionSampleRate; + /** + * The percentage of tracked sessions that will include Session Replay data (between 0.0 and + * 100.0). + */ + public final float sessionReplaySampleRate; + + public RumInjectorConfig( + String applicationId, + String clientToken, + @Nullable String site, + @Nullable String service, + @Nullable String env, + int majorVersion, + @Nullable String version, + @Nullable Boolean trackUserInteractions, + @Nullable Boolean trackResources, + @Nullable Boolean trackLongTask, + @Nullable PrivacyLevel defaultPrivacyLevel, + float sessionSampleRate, + float sessionReplaySampleRate) { + if (applicationId == null || applicationId.isEmpty()) { + throw new IllegalArgumentException("Invalid application id: " + applicationId); + } + this.applicationId = applicationId; + if (clientToken == null || clientToken.isEmpty()) { + throw new IllegalArgumentException("Invalid client token: " + clientToken); + } + this.clientToken = clientToken; + if (site == null || site.isEmpty()) { + this.site = DEFAULT_SITE; + } else if (validateSite(site)) { + this.site = site; + } else { + throw new IllegalArgumentException("Invalid site: " + site); + } + this.service = service; + this.env = env; + if (majorVersion != 5 && majorVersion != 6) { + throw new IllegalArgumentException("Invalid major version: " + majorVersion); + } + this.majorVersion = majorVersion; + this.version = version; + this.trackUserInteractions = trackUserInteractions; + this.trackResources = trackResources; + this.trackLongTask = trackLongTask; + if (sessionSampleRate < 0f || sessionSampleRate > 100f) { + throw new IllegalArgumentException("Invalid session sample rate: " + sessionSampleRate); + } + this.sessionSampleRate = sessionSampleRate; + if (sessionReplaySampleRate < 0f || sessionReplaySampleRate > 100f) { + throw new IllegalArgumentException( + "Invalid session replay sample rate: " + sessionReplaySampleRate); + } + this.sessionReplaySampleRate = sessionReplaySampleRate; + this.defaultPrivacyLevel = defaultPrivacyLevel; + } + + private static boolean validateSite(String site) { + for (String key : REGIONS.keySet()) { + if (key.equals(site)) { + return true; + } + } + return false; + } + + public String getSnippet() { + return "\n"; + } + + private String getCdnUrl() { + if (!GOV_CLOUD_SITE.equals(this.site)) { + return "https://www.datadoghq-browser-agent.com/datadog-rum-v" + this.majorVersion + ".js"; + } + return "https://www.datadoghq-browser-agent.com/" + + REGIONS.get(this.site) + + "/v" + + this.majorVersion + + "/datadog-rum.js"; + } + + public String jsonPayload() { + try (JsonWriter writer = new JsonWriter()) { + writer.beginObject(); + writer.name("application_id").value(this.applicationId); + writer.name("client_token").value(this.clientToken); + if (this.site != null) { + writer.name("site").value(this.site); + } + if (this.service != null) { + writer.name("service").value(this.service); + } + if (this.env != null) { + writer.name("env").value(this.env); + } + if (this.version != null) { + writer.name("version").value(this.version); + } + if (this.trackUserInteractions != null) { + writer.name("track_user_interactions").value(this.trackUserInteractions); + } + if (this.trackResources != null) { + writer.name("track_resources").value(this.trackResources); + } + if (this.trackLongTask != null) { + writer.name("track_long_task").value(this.trackLongTask); + } + if (this.defaultPrivacyLevel != null) { + writer.name("default_privacy_level").value(this.defaultPrivacyLevel.toJson()); + } + writer.name("session_sample_rate").value(this.sessionSampleRate); + writer.name("session_replay_sample_rate").value(this.sessionReplaySampleRate); + return writer.toString(); + } catch (Exception e) { + throw new IllegalStateException("Fail to generate config payload", e); + } + } + + public enum PrivacyLevel { + ALLOW, + MASK, + MASK_USER_INPUT; + + public String toJson() { + return toString().toLowerCase(ROOT); + } + } +} diff --git a/settings.gradle b/settings.gradle index cc17a3de79f..c414abaf11e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -153,6 +153,8 @@ include ':dd-smoke-tests:quarkus-native' include ':dd-smoke-tests:sample-trace' include ':dd-smoke-tests:ratpack-1.5' include ':dd-smoke-tests:resteasy' +include ':dd-smoke-tests:rum' +include ':dd-smoke-tests:rum:tomcat-9' include ':dd-smoke-tests:spring-boot-3.0-native' include ':dd-smoke-tests:spring-boot-2.4-webflux' include ':dd-smoke-tests:spring-boot-2.5-webflux'