From 257f167f913dfb364da5b847c0055892bb0c981d Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Thu, 3 Jul 2025 17:45:41 +0200 Subject: [PATCH 01/36] feat(rum): Add initial config and API --- internal-api/build.gradle | 1 + .../main/java/datadog/trace/api/Config.java | 4 + .../trace/api/rum/ContentTypesCache.java | 45 ++++ .../java/datadog/trace/api/rum/RumConfig.java | 198 ++++++++++++++++++ .../datadog/trace/api/rum/RumInjector.java | 18 ++ 5 files changed, 266 insertions(+) create mode 100644 internal-api/src/main/java/datadog/trace/api/rum/ContentTypesCache.java create mode 100644 internal-api/src/main/java/datadog/trace/api/rum/RumConfig.java create mode 100644 internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java 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..3140a95d547 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -1190,6 +1190,10 @@ public static String getHostName() { private final int stackTraceLengthLimit; + // RUM config -- start + // TODO + // RUM config -- end + // Read order: System Properties -> Env Variables, [-> properties file], [-> default value] private Config() { this(ConfigProvider.createDefault()); diff --git a/internal-api/src/main/java/datadog/trace/api/rum/ContentTypesCache.java b/internal-api/src/main/java/datadog/trace/api/rum/ContentTypesCache.java new file mode 100644 index 00000000000..59c439c7173 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/rum/ContentTypesCache.java @@ -0,0 +1,45 @@ +package datadog.trace.api.rum; + +import java.util.HashMap; +import java.util.Map; + +/* + * Cache for ContentTypes answers + */ +public class ContentTypesCache { + static final short MAX_ITEMS = 1_000; + private final Map map; + + /* + * Constructs a new ContentTypes cache for the provided types + * + * @param contentTypes the list of ContentTypes it will store answers for + */ + public ContentTypesCache(String[] contentTypes) { + this.map = new HashMap<>(); + for (String contentType : contentTypes) { + this.map.put(contentType, true); + } + } + + public boolean contains(String headerValue) { + return this.map.computeIfAbsent(headerValue, this::shouldProcess); + } + + private boolean shouldProcess(String headerValue) { + Boolean typeAllowed = this.map.get(getContentType(headerValue)); + return typeAllowed != null && typeAllowed; + } + + private String getContentType(String headerValue) { + // multipart/* are expected to contain boundary unique values + // let's abort early to avoid exploding the cache + // additionally, if the cache is already too big, let's also abort + if (this.map.size() > MAX_ITEMS || headerValue.startsWith("multipart/")) { + return null; + } + // RFC 2045 defines optional parameters always behind a semicolon + int semicolon = headerValue.indexOf(";"); + return semicolon != -1 ? headerValue.substring(0, semicolon) : headerValue; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/rum/RumConfig.java b/internal-api/src/main/java/datadog/trace/api/rum/RumConfig.java new file mode 100644 index 00000000000..f9e241f7bca --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/rum/RumConfig.java @@ -0,0 +1,198 @@ +package datadog.trace.api.rum; + +import static java.util.Locale.ROOT; + +import datadog.json.JsonWriter; +import datadog.trace.api.Config; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; + +public class RumConfig { + 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). */ + @Nullable public final Float sessionSampleRate; + /** + * The percentage of tracked sessions that will include Session Replay data (between 0.0 and + * 100.0). + */ + @Nullable public final Float sessionReplaySampleRate; + + public static RumConfig from(Config config) { + // TODO + return null; + } + + RumConfig( + 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, + @Nullable Float sessionSampleRate, + @Nullable 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 != null && (sessionSampleRate < 0f || sessionSampleRate > 100f)) { + throw new IllegalArgumentException("Invalid session sample rate: " + sessionSampleRate); + } + this.sessionSampleRate = sessionSampleRate; + if (sessionReplaySampleRate != null + && (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"; + } + + private 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()); + } + if (this.sessionSampleRate != null) { + writer.name("session_sample_rate").value(this.sessionSampleRate); + } + if (this.sessionReplaySampleRate != null) { + 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/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..b854c3ea804 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java @@ -0,0 +1,18 @@ +package datadog.trace.api.rum; + +public final class RumInjector { + + private static volatile String snippet; + + private static volatile RumConfig config; + + public static boolean isEnabled() { + // Check lazy config init + // TODO Config.isRumEnabled()? + valid config + return false; + } + + public static String getSnippet() { + return null; + } +} From 5e734e3a3e46387f9cecb536874e836dc0dbc266 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 4 Jul 2025 06:59:17 +0200 Subject: [PATCH 02/36] feat(rum): Iterating on initial config and API --- .../datadog/trace/api/ConfigDefaults.java | 5 ++ .../datadog/trace/api/config/RumConfig.java | 20 ++++++ .../main/java/datadog/trace/api/Config.java | 68 ++++++++++++++++++- .../datadog/trace/api/rum/RumInjector.java | 36 ++++++++-- ...{RumConfig.java => RumInjectorConfig.java} | 33 +++------ 5 files changed, 130 insertions(+), 32 deletions(-) create mode 100644 dd-trace-api/src/main/java/datadog/trace/api/config/RumConfig.java rename internal-api/src/main/java/datadog/trace/api/rum/{RumConfig.java => RumInjectorConfig.java} (87%) 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/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 3140a95d547..b40d229cf87 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,9 +1210,8 @@ public static String getHostName() { private final int stackTraceLengthLimit; - // RUM config -- start - // TODO - // RUM config -- end + private final boolean rumEnabled; + private final RumInjectorConfig rumInjectorConfig; // Read order: System Properties -> Env Variables, [-> properties file], [-> default value] private Config() { @@ -2677,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 @@ -4912,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, ""); @@ -5592,6 +5650,10 @@ public String toString() { + cloudResponsePayloadTagging + ", experimentalPropagateProcessTagsEnabled=" + experimentalPropagateProcessTagsEnabled + + ", rumEnabled=" + + rumEnabled + + ", rumInjectorConfig=" + + 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 index b854c3ea804..4b880f6a252 100644 --- a/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java +++ b/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java @@ -1,18 +1,40 @@ package datadog.trace.api.rum; -public final class RumInjector { +import datadog.trace.api.Config; +public final class RumInjector { + private static volatile boolean initialized = false; + private static volatile boolean enabled; private static volatile String snippet; - private static volatile RumConfig config; - + /** + * Check whether RUM injection is enabled and ready to inject. + * + * @return {@code true} if enabled, {@code otherwise}. + */ public static boolean isEnabled() { - // Check lazy config init - // TODO Config.isRumEnabled()? + valid config - return false; + 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 String getSnippet() { - return null; + return snippet; } } diff --git a/internal-api/src/main/java/datadog/trace/api/rum/RumConfig.java b/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorConfig.java similarity index 87% rename from internal-api/src/main/java/datadog/trace/api/rum/RumConfig.java rename to internal-api/src/main/java/datadog/trace/api/rum/RumInjectorConfig.java index f9e241f7bca..66b350c2b46 100644 --- a/internal-api/src/main/java/datadog/trace/api/rum/RumConfig.java +++ b/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorConfig.java @@ -3,12 +3,11 @@ import static java.util.Locale.ROOT; import datadog.json.JsonWriter; -import datadog.trace.api.Config; import java.util.HashMap; import java.util.Map; import javax.annotation.Nullable; -public class RumConfig { +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<>(); @@ -44,19 +43,14 @@ public class RumConfig { /** 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). */ - @Nullable public final Float sessionSampleRate; + public final float sessionSampleRate; /** * The percentage of tracked sessions that will include Session Replay data (between 0.0 and * 100.0). */ - @Nullable public final Float sessionReplaySampleRate; + public final float sessionReplaySampleRate; - public static RumConfig from(Config config) { - // TODO - return null; - } - - RumConfig( + public RumInjectorConfig( String applicationId, String clientToken, @Nullable String site, @@ -68,8 +62,8 @@ public static RumConfig from(Config config) { @Nullable Boolean trackResources, @Nullable Boolean trackLongTask, @Nullable PrivacyLevel defaultPrivacyLevel, - @Nullable Float sessionSampleRate, - @Nullable Float sessionReplaySampleRate) { + float sessionSampleRate, + float sessionReplaySampleRate) { if (applicationId == null || applicationId.isEmpty()) { throw new IllegalArgumentException("Invalid application id: " + applicationId); } @@ -95,12 +89,11 @@ public static RumConfig from(Config config) { this.trackUserInteractions = trackUserInteractions; this.trackResources = trackResources; this.trackLongTask = trackLongTask; - if (sessionSampleRate != null && (sessionSampleRate < 0f || sessionSampleRate > 100f)) { + if (sessionSampleRate < 0f || sessionSampleRate > 100f) { throw new IllegalArgumentException("Invalid session sample rate: " + sessionSampleRate); } this.sessionSampleRate = sessionSampleRate; - if (sessionReplaySampleRate != null - && (sessionReplaySampleRate < 0f || sessionReplaySampleRate > 100f)) { + if (sessionReplaySampleRate < 0f || sessionReplaySampleRate > 100f) { throw new IllegalArgumentException( "Invalid session replay sample rate: " + sessionReplaySampleRate); } @@ -145,7 +138,7 @@ private String getCdnUrl() { + "/datadog-rum.js"; } - private String jsonPayload() { + public String jsonPayload() { try (JsonWriter writer = new JsonWriter()) { writer.beginObject(); writer.name("application_id").value(this.applicationId); @@ -174,12 +167,8 @@ private String jsonPayload() { if (this.defaultPrivacyLevel != null) { writer.name("default_privacy_level").value(this.defaultPrivacyLevel.toJson()); } - if (this.sessionSampleRate != null) { - writer.name("session_sample_rate").value(this.sessionSampleRate); - } - if (this.sessionReplaySampleRate != null) { - writer.name("session_replay_sample_rate").value(this.sessionReplaySampleRate); - } + 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); From dde7164cb6eb557a08ed832719dfab0697bd2c79 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 4 Jul 2025 07:46:51 +0200 Subject: [PATCH 03/36] fix(rum): Fix config print --- internal-api/src/main/java/datadog/trace/api/Config.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b40d229cf87..56a55be4204 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -5653,7 +5653,7 @@ public String toString() { + ", rumEnabled=" + rumEnabled + ", rumInjectorConfig=" - + rumInjectorConfig.jsonPayload() + + (rumInjectorConfig == null ? "null" : rumInjectorConfig.jsonPayload()) + '}'; } } From bb22e1ac0cfac43044708a429f1a3a0b7ea605b6 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 4 Jul 2025 08:32:45 +0200 Subject: [PATCH 04/36] fix(rum): Remove content type support as only "text/html" is supported --- .../trace/api/rum/ContentTypesCache.java | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 internal-api/src/main/java/datadog/trace/api/rum/ContentTypesCache.java diff --git a/internal-api/src/main/java/datadog/trace/api/rum/ContentTypesCache.java b/internal-api/src/main/java/datadog/trace/api/rum/ContentTypesCache.java deleted file mode 100644 index 59c439c7173..00000000000 --- a/internal-api/src/main/java/datadog/trace/api/rum/ContentTypesCache.java +++ /dev/null @@ -1,45 +0,0 @@ -package datadog.trace.api.rum; - -import java.util.HashMap; -import java.util.Map; - -/* - * Cache for ContentTypes answers - */ -public class ContentTypesCache { - static final short MAX_ITEMS = 1_000; - private final Map map; - - /* - * Constructs a new ContentTypes cache for the provided types - * - * @param contentTypes the list of ContentTypes it will store answers for - */ - public ContentTypesCache(String[] contentTypes) { - this.map = new HashMap<>(); - for (String contentType : contentTypes) { - this.map.put(contentType, true); - } - } - - public boolean contains(String headerValue) { - return this.map.computeIfAbsent(headerValue, this::shouldProcess); - } - - private boolean shouldProcess(String headerValue) { - Boolean typeAllowed = this.map.get(getContentType(headerValue)); - return typeAllowed != null && typeAllowed; - } - - private String getContentType(String headerValue) { - // multipart/* are expected to contain boundary unique values - // let's abort early to avoid exploding the cache - // additionally, if the cache is already too big, let's also abort - if (this.map.size() > MAX_ITEMS || headerValue.startsWith("multipart/")) { - return null; - } - // RFC 2045 defines optional parameters always behind a semicolon - int semicolon = headerValue.indexOf(";"); - return semicolon != -1 ? headerValue.substring(0, semicolon) : headerValue; - } -} From 1bf99a1d1848566c95880828ed140d47766dc840 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 4 Jul 2025 10:17:39 +0200 Subject: [PATCH 05/36] feat(rum): Add smoke tests --- dd-smoke-tests/rum/build.gradle | 7 +++ .../rum/AbstractRumServerSmokeTest.groovy | 38 ++++++++++++++++ dd-smoke-tests/rum/tomcat-9/build.gradle | 26 +++++++++++ .../main/java/com/example/HelloServlet.java | 28 ++++++++++++ .../src/main/java/com/example/Main.java | 34 +++++++++++++++ .../rum/tomcat9/Tomcat9RumSmokeTest.groovy | 43 +++++++++++++++++++ settings.gradle | 2 + 7 files changed, 178 insertions(+) create mode 100644 dd-smoke-tests/rum/build.gradle create mode 100644 dd-smoke-tests/rum/src/main/groovy/datadog/smoketest/rum/AbstractRumServerSmokeTest.groovy create mode 100644 dd-smoke-tests/rum/tomcat-9/build.gradle create mode 100644 dd-smoke-tests/rum/tomcat-9/src/main/java/com/example/HelloServlet.java create mode 100644 dd-smoke-tests/rum/tomcat-9/src/main/java/com/example/Main.java create mode 100644 dd-smoke-tests/rum/tomcat-9/src/test/groovy/datadog/smoketest/rum/tomcat9/Tomcat9RumSmokeTest.groovy 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..6fa39dcf792 --- /dev/null +++ b/dd-smoke-tests/rum/src/main/groovy/datadog/smoketest/rum/AbstractRumServerSmokeTest.groovy @@ -0,0 +1,38 @@ +package datadog.smoketest.rum + +import datadog.smoketest.AbstractServerSmokeTest +import okhttp3.Response +import spock.lang.Shared + +class AbstractRumServerSmokeTest extends AbstractServerSmokeTest { + + + + @Shared + protected String[] defaultRumProperties = [ + "-Ddd.appsec.enabled=${System.getProperty('smoke_test.appsec.enabled') ?: 'true'}", + "-Ddd.profiling.enabled=false", + // TODO: Remove once this is the default value + "-Ddd.api-security.enabled=true", + "-Ddd.appsec.waf.timeout=300000", + "-DPOWERWAF_EXIT_ON_LEAK=true", + // disable AppSec rate limit + "-Ddd.appsec.trace.rate.limit=-1" + ] + (System.getProperty('smoke_test.appsec.enabled') == 'inactive' ? + // enable remote config so that appsec is partially enabled (rc is now enabled by default) + [ + '-Ddd.remote_config.url=https://127.0.0.1:54670/invalid_endpoint', + '-Ddd.remote_config.poll_interval.seconds=3600' + ]: + ['-Ddd.remote_config.enabled=false'] + ) + + + static void assertRumInjected(Response response) { + assert response.header('x-datadog-rum-injected') == '1' : 'RUM injected header missing' + def content = response.body().toString() + 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..e86b58e65c2 --- /dev/null +++ b/dd-smoke-tests/rum/tomcat-9/src/main/java/com/example/HelloServlet.java @@ -0,0 +1,28 @@ +package com.example; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +public class HelloServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.setContentType("text/html;charset=UTF-8"); + resp.getWriter().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..ce614550a82 --- /dev/null +++ b/dd-smoke-tests/rum/tomcat-9/src/main/java/com/example/Main.java @@ -0,0 +1,34 @@ +package com.example; + +import org.apache.catalina.Context; +import org.apache.catalina.startup.Tomcat; + +import java.io.File; + +public class Main { + public static void main(String[] args) throws Exception { + int port = 8080; + if (args.length == 1) { + port = Integer.parseInt(args[0]); + } + + Tomcat tomcat = new Tomcat(); + tomcat.setPort(port); + + // Setup base directory + 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/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' From 8814d867cbe195e37bf6630e52edc6de7c291f6a Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 4 Jul 2025 11:26:39 +0200 Subject: [PATCH 06/36] feat(rum): Add smoke tests --- .../rum/AbstractRumServerSmokeTest.groovy | 41 ++++++------------- .../main/java/com/example/HelloServlet.java | 3 +- .../src/main/java/com/example/Main.java | 11 +++-- 3 files changed, 19 insertions(+), 36 deletions(-) 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 index 6fa39dcf792..9144ccdde39 100644 --- 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 @@ -5,34 +5,19 @@ import okhttp3.Response import spock.lang.Shared class AbstractRumServerSmokeTest extends AbstractServerSmokeTest { - - - - @Shared - protected String[] defaultRumProperties = [ - "-Ddd.appsec.enabled=${System.getProperty('smoke_test.appsec.enabled') ?: 'true'}", - "-Ddd.profiling.enabled=false", - // TODO: Remove once this is the default value - "-Ddd.api-security.enabled=true", - "-Ddd.appsec.waf.timeout=300000", - "-DPOWERWAF_EXIT_ON_LEAK=true", - // disable AppSec rate limit - "-Ddd.appsec.trace.rate.limit=-1" - ] + (System.getProperty('smoke_test.appsec.enabled') == 'inactive' ? - // enable remote config so that appsec is partially enabled (rc is now enabled by default) - [ - '-Ddd.remote_config.url=https://127.0.0.1:54670/invalid_endpoint', - '-Ddd.remote_config.poll_interval.seconds=3600' - ]: - ['-Ddd.remote_config.enabled=false'] - ) - - - static void assertRumInjected(Response response) { - assert response.header('x-datadog-rum-injected') == '1' : 'RUM injected header missing' - def content = response.body().toString() - assert content.contains('https://www.datadoghq-browser-agent.com') : 'RUM script not injected' - } + @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().toString() + assert content.contains('https://www.datadoghq-browser-agent.com'): 'RUM script not injected' + } } 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 index e86b58e65c2..dff7dec2ebc 100644 --- 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 @@ -1,6 +1,5 @@ package com.example; -import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -8,7 +7,7 @@ public class HelloServlet extends HttpServlet { @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("text/html;charset=UTF-8"); resp.getWriter().write( "" + 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 index ce614550a82..434bf83efa5 100644 --- 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 @@ -1,12 +1,12 @@ package com.example; +import java.io.File; import org.apache.catalina.Context; +import org.apache.catalina.LifecycleException; import org.apache.catalina.startup.Tomcat; -import java.io.File; - public class Main { - public static void main(String[] args) throws Exception { + public static void main(String[] args) throws LifecycleException { int port = 8080; if (args.length == 1) { port = Integer.parseInt(args[0]); @@ -14,12 +14,11 @@ public static void main(String[] args) throws Exception { Tomcat tomcat = new Tomcat(); tomcat.setPort(port); - - // Setup base directory + tomcat.getConnector(); // This is required to make Tomcat start tomcat.setBaseDir("."); // Add webapp context - String contextPath = "/"; + String contextPath = ""; String docBase = new File(".").getAbsolutePath(); Context context = tomcat.addContext(contextPath, docBase); From a1f0a77a943a865f41e33e2ab49634ca70caf7d3 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 4 Jul 2025 11:27:49 +0200 Subject: [PATCH 07/36] feat(rum): Add smoke tests --- .../main/java/com/example/HelloServlet.java | 38 +++++++++---------- .../src/main/java/com/example/Main.java | 8 ++-- 2 files changed, 24 insertions(+), 22 deletions(-) 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 index dff7dec2ebc..d88c4774078 100644 --- 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 @@ -1,27 +1,27 @@ package com.example; +import java.io.IOException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import java.io.IOException; public class HelloServlet extends HttpServlet { - @Override - protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { - resp.setContentType("text/html;charset=UTF-8"); - resp.getWriter().write( - "" + - "" + - "" + - " " + - " " + - " Hello Servlet" + - "" + - "" + - "

Hello from Tomcat 9 Servlet!

" + - "

This is a demo HTML page served by Java servlet.

" + - "" + - "" - ); - } + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setContentType("text/html;charset=UTF-8"); + resp.getWriter() + .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 index 434bf83efa5..a69a8701d89 100644 --- 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 @@ -23,9 +23,11 @@ public static void main(String[] args) throws LifecycleException { Context context = tomcat.addContext(contextPath, docBase); // Add servlet programmatically - context.addServletContainerInitializer((c, ctx) -> { - ctx.addServlet("helloServlet", new HelloServlet()).addMapping("/hello"); - }, null); + context.addServletContainerInitializer( + (c, ctx) -> { + ctx.addServlet("helloServlet", new HelloServlet()).addMapping("/hello"); + }, + null); tomcat.start(); tomcat.getServer().await(); From f941b4a69bc354486ac07d11e005d75620f4b2e9 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Fri, 4 Jul 2025 11:29:02 +0200 Subject: [PATCH 08/36] feat(rum): Add smoke tests --- .../rum/AbstractRumServerSmokeTest.groovy | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) 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 index 9144ccdde39..27d2b03ecdf 100644 --- 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 @@ -5,19 +5,17 @@ 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().toString() - assert content.contains('https://www.datadoghq-browser-agent.com'): 'RUM script not injected' - } + @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().toString() + assert content.contains('https://www.datadoghq-browser-agent.com'): 'RUM script not injected' + } } From fe2006cd7c653e293498be8eded1ca9132106483 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Fri, 4 Jul 2025 11:56:28 +0200 Subject: [PATCH 09/36] Add RUM injection for servlet 3 --- .../buffer/InjectingPipeOutputStream.java | 90 +++++++++++++++++++ .../decorator/HttpServerDecorator.java | 1 + .../InjectingPipeOutputStreamTest.groovy | 23 +++++ .../RumHttpServletResponseWrapper.java | 84 +++++++++++++++++ .../servlet3/Servlet3Advice.java | 12 ++- .../servlet3/Servlet3Instrumentation.java | 2 + .../servlet3/WrappedServletOutputStream.java | 57 ++++++++++++ .../src/test/groovy/JettyServlet3Test.groovy | 86 ++++++++++++++++-- .../datadog/trace/api/rum/RumInjector.java | 37 +++++++- 9 files changed, 382 insertions(+), 10 deletions(-) create mode 100644 dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java create mode 100644 dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamTest.groovy create mode 100644 dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/RumHttpServletResponseWrapper.java create mode 100644 dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/WrappedServletOutputStream.java diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java new file mode 100644 index 00000000000..f5d7d60404d --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java @@ -0,0 +1,90 @@ +package datadog.trace.bootstrap.instrumentation.buffer; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.function.Consumer; + +/** + * A circular buffer that holds n+1 bytes and with a lookbehind buffer of n bytes. The first time + * that the latest n bytes matches the marker, a content is injected before. + */ +public class InjectingPipeOutputStream extends FilterOutputStream { + private final byte[] lookbehind; + private int pos; + private boolean bufferFilled; + private final byte[] marker; + private final byte[] contentToInject; + private boolean found = false; + private int matchingPos = 0; + private final Consumer onContentInjected; + + /** + * @param downstream the delegate output stream + * @param marker the marker to find in the stream + * @param contentToInject the content to inject once before the marker if found. + */ + public InjectingPipeOutputStream( + final OutputStream downstream, + final byte[] marker, + final byte[] contentToInject, + final Consumer onContentInjected) { + super(downstream); + this.marker = marker; + this.lookbehind = new byte[marker.length + 1]; + this.pos = 0; + this.contentToInject = contentToInject; + this.onContentInjected = onContentInjected; + } + + @Override + public void write(int b) throws IOException { + if (found) { + out.write(b); + return; + } + lookbehind[pos] = (byte) b; + pos = (pos + 1) % lookbehind.length; + + if (marker[matchingPos++] == b) { + if (matchingPos == marker.length) { + found = true; + out.write(contentToInject); + if (onContentInjected != null) { + onContentInjected.accept(null); + } + drain((pos + 1) % lookbehind.length, marker.length); + return; + } + } else { + matchingPos = 0; + } + + if (!bufferFilled) { + bufferFilled = pos == lookbehind.length - 1; + } + + if (bufferFilled) { + super.write(lookbehind[(pos + 1) % lookbehind.length]); + } + } + + private void drain(int from, int size) throws IOException { + while (size-- > 0) { + super.write(Character.valueOf((char) lookbehind[from])); + from = (from + 1) % lookbehind.length; + } + } + + @Override + public void close() throws IOException { + if (!found) { + if (bufferFilled) { + drain((pos + 2) % lookbehind.length, marker.length - 1); + } else { + drain(0, pos); + } + } + super.close(); + } +} diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java index 33e2ed2ebd8..fa712db6ceb 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java @@ -57,6 +57,7 @@ public abstract class HttpServerDecorator" | "" | "" | 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..fbafb01cdc0 --- /dev/null +++ b/dd-java-agent/instrumentation/servlet/request-3/src/main/java/datadog/trace/instrumentation/servlet3/WrappedServletOutputStream.java @@ -0,0 +1,57 @@ +package datadog.trace.instrumentation.servlet3; + +import datadog.trace.bootstrap.instrumentation.buffer.InjectingPipeOutputStream; +import java.io.IOException; +import java.io.OutputStream; +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; + + 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(); + } + + @Override + public boolean isReady() { + return delegate.isReady(); + } + + @Override + public void setWriteListener(WriteListener writeListener) { + delegate.setWriteListener(writeListener); + } +} 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/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java b/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java index 4b880f6a252..25e337f67ec 100644 --- a/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java +++ b/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java @@ -1,12 +1,35 @@ 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. * @@ -34,7 +57,17 @@ public static boolean isEnabled() { * * @return The HTML snippet to inject, {@code null} if RUM injection is disabled to inject. */ - public static String getSnippet() { - return snippet; + 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); } } From 2ac167a7eac2f54d01df0f91385d28e0b0442f86 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Fri, 4 Jul 2025 12:11:12 +0200 Subject: [PATCH 10/36] fix rum injection smoke test --- .../rum/AbstractRumServerSmokeTest.groovy | 2 +- .../main/java/com/example/HelloServlet.java | 30 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) 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 index 27d2b03ecdf..dade3311a41 100644 --- 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 @@ -15,7 +15,7 @@ class AbstractRumServerSmokeTest extends AbstractServerSmokeTest { static void assertRumInjected(Response response) { assert response.header('x-datadog-rum-injected') == '1': 'RUM injected header missing' - def content = response.body().toString() + 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/src/main/java/com/example/HelloServlet.java b/dd-smoke-tests/rum/tomcat-9/src/main/java/com/example/HelloServlet.java index d88c4774078..5639cb65677 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -9,19 +10,20 @@ public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType("text/html;charset=UTF-8"); - resp.getWriter() - .write( - "" - + "" - + "" - + " " - + " " - + " Hello Servlet" - + "" - + "" - + "

Hello from Tomcat 9 Servlet!

" - + "

This is a demo HTML page served by Java servlet.

" - + "" - + ""); + 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.

" + + "" + + ""); + } } } From c389b7767fdcc597dd17d25246da4babf0b8be3f Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Fri, 4 Jul 2025 12:26:43 +0200 Subject: [PATCH 11/36] fix javadoc --- .../instrumentation/buffer/InjectingPipeOutputStream.java | 1 + 1 file changed, 1 insertion(+) diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java index f5d7d60404d..ea3322103f7 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java @@ -23,6 +23,7 @@ public class InjectingPipeOutputStream extends FilterOutputStream { * @param downstream the delegate output stream * @param marker the marker to find in the stream * @param contentToInject the content to inject once before the marker if found. + * @param onContentInjected callback called when and if the content is injected. */ public InjectingPipeOutputStream( final OutputStream downstream, From 01f6aaeee17956416184ab513e27ad4417a6509f Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Fri, 4 Jul 2025 13:53:30 +0200 Subject: [PATCH 12/36] Add benchmark --- .../InjectingPipeOutputStreamBenchmark.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 dd-java-agent/agent-bootstrap/src/jmh/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamBenchmark.java 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 = "") + injector = new RumInjector(config) + + then: + injector.isEnabled() + injector.getMarker(UTF8) != null + injector.getSnippet(UTF8) != null + } +} diff --git a/internal-api/src/test/java/datadog/trace/api/rum/RumInjectorConfigTest.java b/internal-api/src/test/java/datadog/trace/api/rum/RumInjectorConfigTest.java new file mode 100644 index 00000000000..df4a57bacae --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/rum/RumInjectorConfigTest.java @@ -0,0 +1,224 @@ +package datadog.trace.api.rum; + +import static datadog.trace.api.ConfigDefaults.DEFAULT_RUM_SITE; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.rum.RumInjectorConfig.PrivacyLevel; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class RumInjectorConfigTest { + + @ParameterizedTest + @CsvSource( + value = { + // spotless:off + // Minimal configuration ID + "appId | token | null | null | null | 6 | null | null | null | null | null | null | null | remote-config-id", + // Using site + "appId | token | datadoghq.com | null | null | 6 | null | null | null | null | null | null | null | remote-config-id", + "appId | token | us3.datadoghq.com | null | null | 6 | null | null | null | null | null | null | null | remote-config-id", + "appId | token | us5.datadoghq.com | null | null | 6 | null | null | null | null | null | null | null | remote-config-id", + "appId | token | datadoghq.eu | null | null | 6 | null | null | null | null | null | null | null | remote-config-id", + "appId | token | ap1.datadoghq.com | null | null | 6 | null | null | null | null | null | null | null | remote-config-id", + // Using service + "appId | token | null | svc | null | 6 | null | null | null | null | null | null | null | remote-config-id", + // Using env + "appId | token | null | null | prod | 6 | null | null | null | null | null | null | null | remote-config-id", + // Using major version + "appId | token | null | null | null | 5 | null | null | null | null | null | null | null | remote-config-id", + // Using version + "appId | token | null | null | null | 6 | 1.23 | null | null | null | null | null | null | remote-config-id", + // Using track user interactions + "appId | token | null | null | null | 6 | 1.23 | true | null | null | null | null | null | remote-config-id", + "appId | token | null | null | null | 6 | 1.23 | false | null | null | null | null | null | remote-config-id", + // Using track resources + "appId | token | null | null | null | 6 | 1.23 | null | true | null | null | null | null | remote-config-id", + "appId | token | null | null | null | 6 | 1.23 | null | false | null | null | null | null | remote-config-id", + // Using track long task + "appId | token | null | null | null | 6 | 1.23 | null | null | true | null | null | null | remote-config-id", + "appId | token | null | null | null | 6 | 1.23 | null | null | false | null | null | null | remote-config-id", + // Using default privacy level + "appId | token | null | null | null | 6 | 1.23 | null | null | null | ALLOW | null | null | remote-config-id", + "appId | token | null | null | null | 6 | 1.23 | null | null | null | MASK | null | null | remote-config-id", + "appId | token | null | null | null | 6 | 1.23 | null | null | null | MASK_USER_INPUT | null | null | remote-config-id", + // Using session sample rate + "appId | token | null | null | null | 6 | null | null | null | null | null | 0 | null | remote-config-id", + "appId | token | null | null | null | 6 | null | null | null | null | null | 1 | null | remote-config-id", + "appId | token | null | null | null | 6 | null | null | null | null | null | 25.5 | null | remote-config-id", + "appId | token | null | null | null | 6 | null | null | null | null | null | 100 | null | remote-config-id", + // Using session replay sample rate + "appId | token | null | null | null | 6 | null | null | null | null | null | null | 0 | remote-config-id", + "appId | token | null | null | null | 6 | null | null | null | null | null | null | 1 | remote-config-id", + "appId | token | null | null | null | 6 | null | null | null | null | null | null | 25.5 | remote-config-id", + "appId | token | null | null | null | 6 | null | null | null | null | null | null | 100 | remote-config-id", + // spotless:on + }, + delimiterString = "|", + nullValues = "null") + void testSnippet( + String applicationId, + String clientToken, + String site, + String service, + String env, + int majorVersion, + String version, + Boolean trackUserInteractions, + Boolean trackResources, + Boolean trackLongTask, + PrivacyLevel defaultPrivacyLevel, + Float sessionSampleRate, + Float sessionReplaySampleRate, + String remoteConfigurationId) { + RumInjectorConfig injectorConfig = + new RumInjectorConfig( + applicationId, + clientToken, + site, + service, + env, + majorVersion, + version, + trackUserInteractions, + trackResources, + trackLongTask, + defaultPrivacyLevel, + sessionSampleRate, + sessionReplaySampleRate, + remoteConfigurationId); + + String jsonPayload = injectorConfig.jsonPayload(); + assertTrue(jsonPayload.contains(applicationId)); + assertTrue(jsonPayload.contains("site")); + assertTrue(jsonPayload.contains(clientToken)); + if (site == null) { + assertTrue(jsonPayload.contains(DEFAULT_RUM_SITE)); + } else { + assertTrue(jsonPayload.contains(site)); + } + if (service == null) { + assertFalse(jsonPayload.contains("service")); + } else { + assertTrue(jsonPayload.contains("service")); + assertTrue(jsonPayload.contains(service)); + } + if (env == null) { + assertFalse(jsonPayload.contains("env")); + } else { + assertTrue(jsonPayload.contains("env")); + assertTrue(jsonPayload.contains(env)); + } + if (version == null) { + assertFalse(jsonPayload.contains("version")); + } else { + assertTrue(jsonPayload.contains("version")); + assertTrue(jsonPayload.contains(version)); + } + if (trackUserInteractions == null) { + assertFalse(jsonPayload.contains("trackUserInteractions")); + } else { + assertTrue(jsonPayload.contains("trackUserInteractions")); + } + if (trackResources == null) { + assertFalse(jsonPayload.contains("trackResources")); + } else { + assertTrue(jsonPayload.contains("trackResources")); + } + if (trackLongTask == null) { + assertFalse(jsonPayload.contains("trackLongTask")); + } else { + assertTrue(jsonPayload.contains("trackLongTask")); + } + if (defaultPrivacyLevel == null) { + assertFalse(jsonPayload.contains("defaultPrivacyLevel")); + } else { + assertTrue(jsonPayload.contains("defaultPrivacyLevel")); + assertTrue(jsonPayload.contains(defaultPrivacyLevel.toJson())); + } + if (sessionSampleRate == null) { + assertFalse(jsonPayload.contains("sessionSampleRate")); + } else { + assertTrue(jsonPayload.contains("sessionSampleRate")); + } + if (sessionReplaySampleRate == null) { + assertFalse(jsonPayload.contains("sessionReplaySampleRate")); + } else { + assertTrue(jsonPayload.contains("sessionReplaySampleRate")); + } + if (remoteConfigurationId == null) { + assertFalse(jsonPayload.contains("remoteConfigurationId")); + } else { + assertTrue(jsonPayload.contains("remoteConfigurationId")); + assertTrue(jsonPayload.contains(remoteConfigurationId)); + } + } + + @ParameterizedTest + @CsvSource( + value = { + // spotless:off + // Invalid application ID + "null | token | datadoghq.com | null | null | 6 | null | true | true | true | null | null | null | remote-config-id", + "'' | token | datadoghq.com | null | null | 6 | null | true | true | true | null | null | null | remote-config-id", + // Invalid client token + "appId | null | datadoghq.com | null | null | 6 | null | true | true | true | null | null | null | remote-config-id", + "appId | '' | datadoghq.com | null | null | 6 | null | true | true | true | null | null | null | remote-config-id", + // Invalid site + "appId | token | other.com | null | null | 6 | null | true | true | true | null | null | null | remote-config-id", + // Invalid major version + "appId | token | datadoghq.com | null | null | 4 | null | true | true | true | null | null | null | remote-config-id", + "appId | token | datadoghq.com | null | null | 7 | null | true | true | true | null | null | null | remote-config-id", + // Invalid session sample rate + "appId | token | datadoghq.com | null | null | 6 | null | true | true | true | null | -1 | null | remote-config-id", + "appId | token | datadoghq.com | null | null | 6 | null | true | true | true | null | -0.1 | null | remote-config-id", + "appId | token | datadoghq.com | null | null | 6 | null | true | true | true | null | 101 | null | remote-config-id", + "appId | token | datadoghq.com | null | null | 6 | null | true | true | true | null | 100.1 | null | remote-config-id", + // Invalid session replay sample rate + "appId | token | datadoghq.com | null | null | 6 | null | true | true | true | null | null | -1 | remote-config-id", + "appId | token | datadoghq.com | null | null | 6 | null | true | true | true | null | null | -0.1 | remote-config-id", + "appId | token | datadoghq.com | null | null | 6 | null | true | true | true | null | null | 101 | remote-config-id", + "appId | token | datadoghq.com | null | null | 6 | null | true | true | true | null | null | 100.1 | remote-config-id", + // Invalid rates and remote configuration id + "appId | token | datadoghq.com | null | null | 6 | null | true | true | true | null | null | null | null", + // spotless:on + }, + delimiterString = "|", + nullValues = "null") + void testInvalidConfig( + String applicationId, + String clientToken, + String site, + String service, + String env, + int majorVersion, + String version, + boolean trackUserInteractions, + boolean trackResources, + boolean trackLongTask, + PrivacyLevel defaultPrivacyLevel, + Float sessionSampleRate, + Float sessionReplaySampleRate, + String remoteConfigurationId) { + assertThrows( + IllegalArgumentException.class, + () -> + new RumInjectorConfig( + applicationId, + clientToken, + site, + service, + env, + majorVersion, + version, + trackUserInteractions, + trackResources, + trackLongTask, + defaultPrivacyLevel, + sessionSampleRate, + sessionReplaySampleRate, + remoteConfigurationId)); + } +} From 23f65fb16ed99c52abaee38efade3741d4dc1fc9 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Mon, 7 Jul 2025 11:47:34 +0200 Subject: [PATCH 24/36] Add rum injection for jakarta servlet --- .../src/test/groovy/Jetty11Test.groovy | 17 ++++ .../instrumentation/jetty-12/build.gradle | 2 + .../src/test/ee10/groovy/Jetty12Test.groovy | 19 ++++ .../RumHttpServletResponseWrapper.java | 3 +- .../src/test/groovy/JettyServlet3Test.groovy | 66 +------------- .../servlet3/RumServlet.groovy | 43 ++++++++++ .../JakartaServletInstrumentation.java | 34 +++++++- .../RumHttpServletResponseWrapper.java | 86 +++++++++++++++++++ .../servlet5/WrappedServletOutputStream.java | 53 ++++++++++++ .../servlet5/RumServlet.groovy | 43 ++++++++++ .../groovy/TomcatServletTest.groovy | 22 ++++- .../agent/test/base/HttpServerTest.groovy | 33 +++++++ 12 files changed, 350 insertions(+), 71 deletions(-) create mode 100644 dd-java-agent/instrumentation/servlet/request-3/src/testFixtures/groovy/datadog/trace/instrumentation/servlet3/RumServlet.groovy create mode 100644 dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java create mode 100644 dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/WrappedServletOutputStream.java create mode 100644 dd-java-agent/instrumentation/servlet/request-5/src/testFixtures/groovy/datadog/trace/instrumentation/servlet5/RumServlet.groovy diff --git a/dd-java-agent/instrumentation/jetty-11/src/test/groovy/Jetty11Test.groovy b/dd-java-agent/instrumentation/jetty-11/src/test/groovy/Jetty11Test.groovy index d7fb8e79b50..f4da48aaaf3 100644 --- a/dd-java-agent/instrumentation/jetty-11/src/test/groovy/Jetty11Test.groovy +++ b/dd-java-agent/instrumentation/jetty-11/src/test/groovy/Jetty11Test.groovy @@ -1,7 +1,9 @@ import datadog.trace.agent.test.base.HttpServer import datadog.trace.agent.test.base.HttpServerTest import datadog.trace.agent.test.naming.TestingGenericHttpNamingConventions +import datadog.trace.instrumentation.servlet5.HtmlRumServlet import datadog.trace.instrumentation.servlet5.TestServlet5 +import datadog.trace.instrumentation.servlet5.XmlRumServlet import org.eclipse.jetty.server.Handler import org.eclipse.jetty.server.Server @@ -103,3 +105,18 @@ class Jetty11V1ForkedTest extends Jetty11Test implements TestingGenericHttpNamin true } } + +class JettyRumInjectionForkedTest extends Jetty11V0ForkedTest { + @Override + boolean testRumInjection() { + true + } + + @Override + protected Handler handler() { + def handler = JettyServer.servletHandler(TestServlet5) + handler.addServlet(HtmlRumServlet, "/gimme-html") + handler.addServlet(XmlRumServlet, "/gimme-xml") + handler + } +} diff --git a/dd-java-agent/instrumentation/jetty-12/build.gradle b/dd-java-agent/instrumentation/jetty-12/build.gradle index 374c30a3753..470dfbe1d3a 100644 --- a/dd-java-agent/instrumentation/jetty-12/build.gradle +++ b/dd-java-agent/instrumentation/jetty-12/build.gradle @@ -29,7 +29,9 @@ addTestSuiteForDir('ee9Test', 'test/ee9') addTestSuiteExtendingForDir('ee9LatestDepTest', 'latestDepTest', 'test/ee9') // ee10 addTestSuiteForDir('ee10Test', 'test/ee10') +addTestSuiteExtendingForDir('ee10ForkedTest', 'ee10Test', 'test/ee10') addTestSuiteExtendingForDir('ee10LatestDepTest', 'latestDepTest', 'test/ee10') +addTestSuiteExtendingForDir('ee10LatestDepForkedTest', 'ee10LatestDepTest', 'test/ee10') [compileMain_java17Java, compileTestJava].each { it.configure { diff --git a/dd-java-agent/instrumentation/jetty-12/src/test/ee10/groovy/Jetty12Test.groovy b/dd-java-agent/instrumentation/jetty-12/src/test/ee10/groovy/Jetty12Test.groovy index 8134ef2a8cd..32fd829b8e0 100644 --- a/dd-java-agent/instrumentation/jetty-12/src/test/ee10/groovy/Jetty12Test.groovy +++ b/dd-java-agent/instrumentation/jetty-12/src/test/ee10/groovy/Jetty12Test.groovy @@ -1,7 +1,11 @@ import datadog.trace.agent.test.base.HttpServer import datadog.trace.agent.test.base.HttpServerTest import datadog.trace.agent.test.naming.TestingGenericHttpNamingConventions +import datadog.trace.instrumentation.servlet5.HtmlRumServlet import datadog.trace.instrumentation.servlet5.TestServlet5 +import datadog.trace.instrumentation.servlet5.XmlRumServlet +import org.eclipse.jetty.ee10.servlet.ServletContextHandler +import org.eclipse.jetty.server.Handler import org.eclipse.jetty.server.Server class Jetty12Test extends HttpServerTest implements TestingGenericHttpNamingConventions.ServerV0 { @@ -61,3 +65,18 @@ class Jetty12PojoWebsocketTest extends Jetty12Test { !isLatestDepTest } } + +class Jetty12RumInjectionForkedTest extends Jetty12Test { + @Override + boolean testRumInjection() { + true + } + + @Override + HttpServer server() { + ServletContextHandler handler = JettyServer.servletHandler(TestServlet5) + handler.addServlet(HtmlRumServlet, "/gimme-html") + handler.addServlet(XmlRumServlet, "/gimme-xml") + new JettyServer(handler, useWebsocketPojoEndpoint()) + } +} 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 index 8d8aa1d6971..18081e58dcf 100644 --- 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 @@ -3,6 +3,7 @@ import datadog.trace.api.rum.RumInjector; import java.io.IOException; import java.io.PrintWriter; +import java.nio.charset.Charset; import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponseWrapper; @@ -26,7 +27,7 @@ public ServletOutputStream getOutputStream() throws IOException { if (outputStream == null) { String encoding = getCharacterEncoding(); if (encoding == null) { - encoding = "UTF-8"; + encoding = Charset.defaultCharset().name(); } outputStream = new WrappedServletOutputStream( 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 2ed349c5402..11bf3eb20e7 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,11 +3,12 @@ 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 +import datadog.trace.instrumentation.servlet3.HtmlRumServlet import datadog.trace.instrumentation.servlet3.TestServlet3 +import datadog.trace.instrumentation.servlet3.XmlRumServlet import groovy.servlet.AbstractHttpServlet import org.eclipse.jetty.server.Request import org.eclipse.jetty.server.Server @@ -21,7 +22,6 @@ 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 @@ -236,50 +236,9 @@ 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") + boolean testRumInjection() { + true } @Override @@ -288,25 +247,8 @@ class JettyServlet3SyncRumInjectionForkedTest extends JettyServlet3TestSync { 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 { } diff --git a/dd-java-agent/instrumentation/servlet/request-3/src/testFixtures/groovy/datadog/trace/instrumentation/servlet3/RumServlet.groovy b/dd-java-agent/instrumentation/servlet/request-3/src/testFixtures/groovy/datadog/trace/instrumentation/servlet3/RumServlet.groovy new file mode 100644 index 00000000000..127950fc385 --- /dev/null +++ b/dd-java-agent/instrumentation/servlet/request-3/src/testFixtures/groovy/datadog/trace/instrumentation/servlet3/RumServlet.groovy @@ -0,0 +1,43 @@ +package datadog.trace.instrumentation.servlet3 + +import javax.servlet.ServletException +import javax.servlet.http.HttpServlet +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +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" + + "") + } + } +} + +class HtmlRumServlet extends RumServlet { + HtmlRumServlet() { + super("text/html") + } +} + +class XmlRumServlet extends RumServlet { + XmlRumServlet() { + super("text/xml") + } +} diff --git a/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/JakartaServletInstrumentation.java b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/JakartaServletInstrumentation.java index d60711b404c..6ef4940a41e 100644 --- a/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/JakartaServletInstrumentation.java +++ b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/JakartaServletInstrumentation.java @@ -1,7 +1,9 @@ package datadog.trace.instrumentation.servlet5; import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.hasSuperType; +import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.implementsInterface; import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static datadog.trace.bootstrap.instrumentation.decorator.HttpServerDecorator.DD_RUM_INJECTED; import static datadog.trace.bootstrap.instrumentation.decorator.HttpServerDecorator.DD_SPAN_ATTRIBUTE; import static net.bytebuddy.matcher.ElementMatchers.isMethod; import static net.bytebuddy.matcher.ElementMatchers.isPublic; @@ -14,10 +16,13 @@ import datadog.trace.api.ClassloaderConfigurationOverrides; import datadog.trace.api.Config; import datadog.trace.api.DDTags; +import datadog.trace.api.rum.RumInjector; import datadog.trace.bootstrap.CallDepthThreadLocalMap; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import net.bytebuddy.asm.Advice; import net.bytebuddy.description.type.TypeDescription; import net.bytebuddy.matcher.ElementMatcher; @@ -34,9 +39,17 @@ public String hierarchyMarkerType() { return "jakarta.servlet.http.HttpServlet"; } + @Override + public String[] helperClassNames() { + return new String[] { + packageName + ".RumHttpServletResponseWrapper", packageName + ".WrappedServletOutputStream", + }; + } + @Override public ElementMatcher hierarchyMatcher() { - return hasSuperType(named(hierarchyMarkerType())); + return hasSuperType(named(hierarchyMarkerType())) + .or(implementsInterface(named("jakarta.servlet.FilterChain"))); } @Override @@ -48,15 +61,28 @@ public void methodAdvice(MethodTransformer transformer) { .and(takesArguments(2)) .and(takesArgument(0, named("jakarta.servlet.ServletRequest"))) .and(takesArgument(1, named("jakarta.servlet.ServletResponse"))), - getClass().getName() + "$ExtractPrincipalAdvice"); + getClass().getName() + "$JakartaServletAdvice"); } - public static class ExtractPrincipalAdvice { + public static class JakartaServletAdvice { @Advice.OnMethodEnter(suppress = Throwable.class) - public static AgentSpan before(@Advice.Argument(0) final ServletRequest request) { + public static AgentSpan before( + @Advice.Argument(0) final ServletRequest request, + @Advice.Argument(value = 1, readOnly = false) ServletResponse response) { if (!(request instanceof HttpServletRequest)) { return null; } + + if (response instanceof HttpServletResponse) { + final HttpServletRequest httpServletRequest = (HttpServletRequest) request; + + if (RumInjector.get().isEnabled() + && httpServletRequest.getAttribute(DD_RUM_INJECTED) == null) { + httpServletRequest.setAttribute(DD_RUM_INJECTED, Boolean.TRUE); + response = new RumHttpServletResponseWrapper((HttpServletResponse) response); + } + } + Object span = request.getAttribute(DD_SPAN_ATTRIBUTE); if (span instanceof AgentSpan && CallDepthThreadLocalMap.incrementCallDepth(HttpServletRequest.class) == 0) { diff --git a/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java new file mode 100644 index 00000000000..45caf9750ed --- /dev/null +++ b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java @@ -0,0 +1,86 @@ +package datadog.trace.instrumentation.servlet5; + +import datadog.trace.api.rum.RumInjector; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponseWrapper; +import java.io.IOException; +import java.io.PrintWriter; + +public class RumHttpServletResponseWrapper extends HttpServletResponseWrapper { + private final RumInjector rumInjector; + private ServletOutputStream outputStream; + private PrintWriter printWriter; + private boolean shouldInject; + + public RumHttpServletResponseWrapper(HttpServletResponse response) { + super(response); + this.rumInjector = RumInjector.get(); + } + + @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() { + 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-5/src/main/java/datadog/trace/instrumentation/servlet5/WrappedServletOutputStream.java b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/WrappedServletOutputStream.java new file mode 100644 index 00000000000..2c43af795f8 --- /dev/null +++ b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/WrappedServletOutputStream.java @@ -0,0 +1,53 @@ +package datadog.trace.instrumentation.servlet5; + +import datadog.trace.bootstrap.instrumentation.buffer.InjectingPipeOutputStream; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import java.io.IOException; +import java.io.OutputStream; + +public class WrappedServletOutputStream extends ServletOutputStream { + private final OutputStream filtered; + private final ServletOutputStream delegate; + + public WrappedServletOutputStream( + ServletOutputStream delegate, byte[] marker, byte[] contentToInject, Runnable 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(); + } + + @Override + public boolean isReady() { + return delegate.isReady(); + } + + @Override + public void setWriteListener(WriteListener writeListener) { + delegate.setWriteListener(writeListener); + } +} diff --git a/dd-java-agent/instrumentation/servlet/request-5/src/testFixtures/groovy/datadog/trace/instrumentation/servlet5/RumServlet.groovy b/dd-java-agent/instrumentation/servlet/request-5/src/testFixtures/groovy/datadog/trace/instrumentation/servlet5/RumServlet.groovy new file mode 100644 index 00000000000..1fa57d02da8 --- /dev/null +++ b/dd-java-agent/instrumentation/servlet/request-5/src/testFixtures/groovy/datadog/trace/instrumentation/servlet5/RumServlet.groovy @@ -0,0 +1,43 @@ +package datadog.trace.instrumentation.servlet5 + +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServlet +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +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" + + "") + } + } +} + +class HtmlRumServlet extends RumServlet { + HtmlRumServlet() { + super("text/html") + } +} + +class XmlRumServlet extends RumServlet { + XmlRumServlet() { + super("text/xml") + } +} diff --git a/dd-java-agent/instrumentation/tomcat-5.5/src/latestDepTest/groovy/TomcatServletTest.groovy b/dd-java-agent/instrumentation/tomcat-5.5/src/latestDepTest/groovy/TomcatServletTest.groovy index 541b1b56380..999d9d62c8a 100644 --- a/dd-java-agent/instrumentation/tomcat-5.5/src/latestDepTest/groovy/TomcatServletTest.groovy +++ b/dd-java-agent/instrumentation/tomcat-5.5/src/latestDepTest/groovy/TomcatServletTest.groovy @@ -1,9 +1,8 @@ -import datadog.trace.api.ProcessTags - -import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.WEBSOCKET - import datadog.trace.agent.test.base.HttpServer +import datadog.trace.api.ProcessTags +import datadog.trace.instrumentation.servlet5.HtmlRumServlet import datadog.trace.instrumentation.servlet5.TestServlet5 +import datadog.trace.instrumentation.servlet5.XmlRumServlet import jakarta.servlet.Filter import jakarta.servlet.Servlet import jakarta.servlet.ServletException @@ -23,6 +22,7 @@ import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.CUSTOM import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.EXCEPTION import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.SUCCESS import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.TIMEOUT_ERROR +import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.WEBSOCKET import static datadog.trace.api.config.GeneralConfig.EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED import static org.junit.Assume.assumeTrue @@ -293,6 +293,20 @@ class TomcatServletEnvEntriesTagTest extends TomcatServletTest { } } +class TomcatRumInjectionForkedTest extends TomcatServletTest { + @Override + boolean testRumInjection() { + true + } + + @Override + protected void setupServlets(Context context) { + super.setupServlets(context) + addServlet(context, "/gimme-html", HtmlRumServlet) + addServlet(context, "/gimme-xml", XmlRumServlet) + } +} + diff --git a/dd-java-agent/testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy b/dd-java-agent/testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy index 0bb81fd1102..a06de2014f2 100644 --- a/dd-java-agent/testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy +++ b/dd-java-agent/testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy @@ -27,6 +27,7 @@ import datadog.trace.api.gateway.RequestContextSlot import datadog.trace.api.http.StoredBodySupplier import datadog.trace.api.iast.IastContext import datadog.trace.api.normalize.SimpleHttpPathNormalizer +import datadog.trace.api.rum.RumInjector import datadog.trace.api.telemetry.Endpoint import datadog.trace.api.telemetry.EndpointCollector import datadog.trace.bootstrap.blocking.BlockingActionHelper @@ -167,6 +168,12 @@ abstract class HttpServerTest extends WithHttpServer { injectSysConfig(TRACE_WEBSOCKET_MESSAGES_ENABLED, "true") // allow endpoint discover for the tests injectSysConfig(API_SECURITY_ENDPOINT_COLLECTION_ENABLED, "true") + if (testRumInjection()) { + injectSysConfig("rum.enabled", "true") + injectSysConfig("rum.application.id", "test") + injectSysConfig("rum.client.token", "secret") + injectSysConfig("rum.remote.configuration.id", "12345") + } } // used in blocking tests to check if the handler was skipped @@ -423,6 +430,10 @@ abstract class HttpServerTest extends WithHttpServer { false } + boolean testRumInjection() { + false + } + /** * To be set if the integration name (_dd.integration) differs from the component. * This happen when the controller integration modify the parent component name (i.e. jaxrs) @@ -2191,6 +2202,28 @@ abstract class HttpServerTest extends WithHttpServer { assertEndpointDiscovery(discovered) } + + /** + * This test should be done in a forked test class + * @return + */ + def "test rum injection in head for mime #mime"() { + setup: + assumeTrue(testRumInjection()) + def request = new 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.get().getSnippet("UTF-8"), "UTF-8")) == expected + assert response.header("x-datadog-rum-injected") == (expected ? "1" : null) + where: + mime | expected + "html" | true + "xml" | false + } + // to be overridden for more specific asserts void assertEndpointDiscovery(final List endpoints) { } From dc3beab934ed59bc4aa51baf98f3a08e9870c0ca Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Mon, 7 Jul 2025 12:03:12 +0200 Subject: [PATCH 25/36] codenarc --- .../jetty-12/src/test/ee10/groovy/Jetty12Test.groovy | 1 - 1 file changed, 1 deletion(-) diff --git a/dd-java-agent/instrumentation/jetty-12/src/test/ee10/groovy/Jetty12Test.groovy b/dd-java-agent/instrumentation/jetty-12/src/test/ee10/groovy/Jetty12Test.groovy index 32fd829b8e0..9914b8e7a1f 100644 --- a/dd-java-agent/instrumentation/jetty-12/src/test/ee10/groovy/Jetty12Test.groovy +++ b/dd-java-agent/instrumentation/jetty-12/src/test/ee10/groovy/Jetty12Test.groovy @@ -5,7 +5,6 @@ import datadog.trace.instrumentation.servlet5.HtmlRumServlet import datadog.trace.instrumentation.servlet5.TestServlet5 import datadog.trace.instrumentation.servlet5.XmlRumServlet import org.eclipse.jetty.ee10.servlet.ServletContextHandler -import org.eclipse.jetty.server.Handler import org.eclipse.jetty.server.Server class Jetty12Test extends HttpServerTest implements TestingGenericHttpNamingConventions.ServerV0 { From 58c8ef38b570407bba28cd43a3fe0a10b785ffab Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Mon, 7 Jul 2025 13:27:17 +0200 Subject: [PATCH 26/36] fix(rum): Fix smoke test merge --- .../smoketest/rum/tomcat10/Tomcat10RumSmokeTest.groovy | 4 ++-- .../smoketest/rum/tomcat11/Tomcat11RumSmokeTest.groovy | 4 ++-- .../datadog/smoketest/rum/tomcat9/Tomcat9RumSmokeTest.groovy | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dd-smoke-tests/rum/tomcat-10/src/test/groovy/datadog/smoketest/rum/tomcat10/Tomcat10RumSmokeTest.groovy b/dd-smoke-tests/rum/tomcat-10/src/test/groovy/datadog/smoketest/rum/tomcat10/Tomcat10RumSmokeTest.groovy index 54a07fe3f3e..67b0b17df44 100644 --- a/dd-smoke-tests/rum/tomcat-10/src/test/groovy/datadog/smoketest/rum/tomcat10/Tomcat10RumSmokeTest.groovy +++ b/dd-smoke-tests/rum/tomcat-10/src/test/groovy/datadog/smoketest/rum/tomcat10/Tomcat10RumSmokeTest.groovy @@ -1,7 +1,7 @@ package datadog.smoketest.rum.tomcat10 +import datadog.environment.JavaVirtualMachine import datadog.smoketest.rum.AbstractRumServerSmokeTest -import datadog.trace.api.Platform class Tomcat10RumSmokeTest extends AbstractRumServerSmokeTest { @Override @@ -12,7 +12,7 @@ class Tomcat10RumSmokeTest extends AbstractRumServerSmokeTest { command.add(javaPath()) command.addAll(defaultJavaProperties) command.addAll(defaultRumProperties) - if (Platform.isJavaVersionAtLeast(17)) { + if (JavaVirtualMachine.isJavaVersionAtLeast(17)) { command.addAll((String[]) ['--add-opens', 'java.base/java.lang=ALL-UNNAMED']) } command.addAll(['-jar', jarPath, Integer.toString(httpPort)]) diff --git a/dd-smoke-tests/rum/tomcat-11/src/test/groovy/datadog/smoketest/rum/tomcat11/Tomcat11RumSmokeTest.groovy b/dd-smoke-tests/rum/tomcat-11/src/test/groovy/datadog/smoketest/rum/tomcat11/Tomcat11RumSmokeTest.groovy index 993b69dbe66..5ab1bff993c 100644 --- a/dd-smoke-tests/rum/tomcat-11/src/test/groovy/datadog/smoketest/rum/tomcat11/Tomcat11RumSmokeTest.groovy +++ b/dd-smoke-tests/rum/tomcat-11/src/test/groovy/datadog/smoketest/rum/tomcat11/Tomcat11RumSmokeTest.groovy @@ -1,7 +1,7 @@ package datadog.smoketest.rum.tomcat11 +import datadog.environment.JavaVirtualMachine import datadog.smoketest.rum.AbstractRumServerSmokeTest -import datadog.trace.api.Platform class Tomcat11RumSmokeTest extends AbstractRumServerSmokeTest { @Override @@ -12,7 +12,7 @@ class Tomcat11RumSmokeTest extends AbstractRumServerSmokeTest { command.add(javaPath()) command.addAll(defaultJavaProperties) command.addAll(defaultRumProperties) - if (Platform.isJavaVersionAtLeast(17)) { + if (JavaVirtualMachine.isJavaVersionAtLeast(17)) { command.addAll((String[]) ['--add-opens', 'java.base/java.lang=ALL-UNNAMED']) } command.addAll(['-jar', jarPath, Integer.toString(httpPort)]) 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 index 8555fb773b9..fa26bf2a041 100644 --- 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 @@ -1,7 +1,7 @@ package datadog.smoketest.rum.tomcat9 +import datadog.environment.JavaVirtualMachine import datadog.smoketest.rum.AbstractRumServerSmokeTest -import datadog.trace.api.Platform class Tomcat9RumSmokeTest extends AbstractRumServerSmokeTest { @@ -14,7 +14,7 @@ class Tomcat9RumSmokeTest extends AbstractRumServerSmokeTest { command.add(javaPath()) command.addAll(defaultJavaProperties) command.addAll(defaultRumProperties) - if (Platform.isJavaVersionAtLeast(17)) { + if (JavaVirtualMachine.isJavaVersionAtLeast(17)) { command.addAll((String[]) ['--add-opens', 'java.base/java.lang=ALL-UNNAMED']) } command.addAll(['-jar', jarPath, Integer.toString(httpPort)]) From 71a0923cc08ac8308bf8b611df31df7f8f69f738 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Mon, 7 Jul 2025 13:56:05 +0200 Subject: [PATCH 27/36] exclude spring virtual filter chain --- .../agent/tooling/bytebuddy/matcher/ignored_class_name.trie | 1 + 1 file changed, 1 insertion(+) diff --git a/dd-java-agent/agent-tooling/src/main/resources/datadog/trace/agent/tooling/bytebuddy/matcher/ignored_class_name.trie b/dd-java-agent/agent-tooling/src/main/resources/datadog/trace/agent/tooling/bytebuddy/matcher/ignored_class_name.trie index 18a88747eec..e479329c814 100644 --- a/dd-java-agent/agent-tooling/src/main/resources/datadog/trace/agent/tooling/bytebuddy/matcher/ignored_class_name.trie +++ b/dd-java-agent/agent-tooling/src/main/resources/datadog/trace/agent/tooling/bytebuddy/matcher/ignored_class_name.trie @@ -350,6 +350,7 @@ 0 org.springframework.web.context.support.AbstractRefreshableWebApplicationContext 0 org.springframework.web.context.support.GenericWebApplicationContext 0 org.springframework.web.context.support.XmlWebApplicationContext +1 org.springframework.web.filter.CompositeFilter$VirtualFilterChain 0 org.springframework.web.reactive.* 0 org.springframework.web.servlet.* 0 org.springframework.web.socket.* From af937dc4dbbd3d9f66fe076e7dd634b0f75a5f94 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Mon, 7 Jul 2025 14:33:45 +0200 Subject: [PATCH 28/36] fix(rum): Fix SDK snippet --- .../src/main/java/datadog/trace/api/rum/RumInjectorConfig.java | 1 + 1 file changed, 1 insertion(+) 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 index e0a56d310f5..9f37cb75553 100644 --- a/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorConfig.java +++ b/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorConfig.java @@ -186,6 +186,7 @@ public String jsonPayload() { if (this.remoteConfigurationId != null) { writer.name("remoteConfigurationId").value(this.remoteConfigurationId); } + writer.endObject(); return writer.toString(); } catch (Exception e) { throw new IllegalStateException("Fail to generate config payload", e); From 79db3cef3c8ce39f2403ae4663bae385dc2e8086 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Mon, 7 Jul 2025 14:58:19 +0200 Subject: [PATCH 29/36] final fixes --- .../servlet3/RumHttpServletResponseWrapper.java | 6 ++---- .../servlet5/RumHttpServletResponseWrapper.java | 9 ++++----- .../smoketest/rum/AbstractRumServerSmokeTest.groovy | 3 ++- .../rum/tomcat-9/src/main/java/com/example/Main.java | 2 +- 4 files changed, 9 insertions(+), 11 deletions(-) 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 index 18081e58dcf..7dc9b1fd07d 100644 --- 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 @@ -12,7 +12,7 @@ public class RumHttpServletResponseWrapper extends HttpServletResponseWrapper { private final RumInjector rumInjector; private ServletOutputStream outputStream; private PrintWriter printWriter; - private boolean shouldInject; + private boolean shouldInject = false; public RumHttpServletResponseWrapper(HttpServletResponse response) { super(response); @@ -80,8 +80,6 @@ public void onInjected() { @Override public void setContentType(String type) { - if (type != null && type.contains("html")) { - shouldInject = true; - } + shouldInject = type != null && type.contains("text/html"); } } diff --git a/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java index 45caf9750ed..5a5a6493d20 100644 --- a/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java +++ b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java @@ -6,12 +6,13 @@ import jakarta.servlet.http.HttpServletResponseWrapper; import java.io.IOException; import java.io.PrintWriter; +import java.nio.charset.Charset; public class RumHttpServletResponseWrapper extends HttpServletResponseWrapper { private final RumInjector rumInjector; private ServletOutputStream outputStream; private PrintWriter printWriter; - private boolean shouldInject; + private boolean shouldInject = false; public RumHttpServletResponseWrapper(HttpServletResponse response) { super(response); @@ -26,7 +27,7 @@ public ServletOutputStream getOutputStream() throws IOException { if (outputStream == null) { String encoding = getCharacterEncoding(); if (encoding == null) { - encoding = "UTF-8"; + encoding = Charset.defaultCharset().name(); } outputStream = new WrappedServletOutputStream( @@ -79,8 +80,6 @@ public void onInjected() { @Override public void setContentType(String type) { - if (type != null && type.contains("html")) { - shouldInject = true; - } + shouldInject = type != null && type.contains("text/html"); } } 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 index 1431c541ed4..d9ece96fde9 100644 --- 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 @@ -10,7 +10,8 @@ class AbstractRumServerSmokeTest extends AbstractServerSmokeTest { protected String[] defaultRumProperties = [ "-Ddd.rum.enabled=true", "-Ddd.rum.application.id=appid", - "-Ddd.rum.client.token=token" + "-Ddd.rum.client.token=token", + "-Ddd.rum.remote.configuration.id=12345", ] void 'test RUM SDK injection on html'() { 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 index 96a5f07cd87..d9992f37c92 100644 --- 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 @@ -26,7 +26,7 @@ public static void main(String[] args) throws LifecycleException { context.addServletContainerInitializer( (c, ctx) -> { ctx.addServlet("htmlServlet", new HtmlServlet()).addMapping("/html"); - ctx.addServlet("xmlServlet", new HtmlServlet()).addMapping("/xml"); + ctx.addServlet("xmlServlet", new XmlServlet()).addMapping("/xml"); }, null); From 6672db96735ff42e296b6a3ce6019d929a96321b Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Mon, 7 Jul 2025 16:09:08 +0200 Subject: [PATCH 30/36] fix more tests --- .../rum/tomcat-10/src/main/java/com/example/Main.java | 2 +- .../rum/tomcat-11/src/main/java/com/example/Main.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dd-smoke-tests/rum/tomcat-10/src/main/java/com/example/Main.java b/dd-smoke-tests/rum/tomcat-10/src/main/java/com/example/Main.java index 96a5f07cd87..d9992f37c92 100644 --- a/dd-smoke-tests/rum/tomcat-10/src/main/java/com/example/Main.java +++ b/dd-smoke-tests/rum/tomcat-10/src/main/java/com/example/Main.java @@ -26,7 +26,7 @@ public static void main(String[] args) throws LifecycleException { context.addServletContainerInitializer( (c, ctx) -> { ctx.addServlet("htmlServlet", new HtmlServlet()).addMapping("/html"); - ctx.addServlet("xmlServlet", new HtmlServlet()).addMapping("/xml"); + ctx.addServlet("xmlServlet", new XmlServlet()).addMapping("/xml"); }, null); diff --git a/dd-smoke-tests/rum/tomcat-11/src/main/java/com/example/Main.java b/dd-smoke-tests/rum/tomcat-11/src/main/java/com/example/Main.java index 96a5f07cd87..d9992f37c92 100644 --- a/dd-smoke-tests/rum/tomcat-11/src/main/java/com/example/Main.java +++ b/dd-smoke-tests/rum/tomcat-11/src/main/java/com/example/Main.java @@ -26,7 +26,7 @@ public static void main(String[] args) throws LifecycleException { context.addServletContainerInitializer( (c, ctx) -> { ctx.addServlet("htmlServlet", new HtmlServlet()).addMapping("/html"); - ctx.addServlet("xmlServlet", new HtmlServlet()).addMapping("/xml"); + ctx.addServlet("xmlServlet", new XmlServlet()).addMapping("/xml"); }, null); From fc23ee8c4e400406d3fdad3c7311e362a62c4661 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Mon, 7 Jul 2025 16:26:41 +0200 Subject: [PATCH 31/36] fix(rum): Fix privacy level encoding --- .../datadog/trace/api/rum/RumInjectorConfig.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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 index 9f37cb75553..4bfb228a54b 100644 --- a/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorConfig.java +++ b/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorConfig.java @@ -1,7 +1,6 @@ package datadog.trace.api.rum; import static datadog.trace.api.ConfigDefaults.DEFAULT_RUM_SITE; -import static java.util.Locale.ROOT; import datadog.json.JsonWriter; import java.util.HashMap; @@ -194,12 +193,18 @@ public String jsonPayload() { } public enum PrivacyLevel { - ALLOW, - MASK, - MASK_USER_INPUT; + ALLOW("allow"), + MASK("mask"), + MASK_USER_INPUT("mask-user-input"); + + private final String json; + + PrivacyLevel(String json) { + this.json = json; + } public String toJson() { - return toString().toLowerCase(ROOT); + return this.json; } } } From b1ea903a72161316c45f1d3e748b0361afc5aae8 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Tue, 8 Jul 2025 09:44:49 +0200 Subject: [PATCH 32/36] Apply suggestions --- .../servlet3/RumHttpServletResponseWrapper.java | 3 ++- .../servlet3/WrappedServletOutputStream.java | 15 ++++++++++----- .../servlet5/RumHttpServletResponseWrapper.java | 3 ++- .../java/datadog/trace/api/rum/RumInjector.java | 12 ++++++------ .../datadog/trace/api/rum/RumInjectorTest.groovy | 4 +--- 5 files changed, 21 insertions(+), 16 deletions(-) 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 index 7dc9b1fd07d..d87009351ba 100644 --- 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 @@ -74,7 +74,8 @@ public void resetBuffer() { public void onInjected() { try { setHeader("x-datadog-rum-injected", "1"); - } catch (Throwable ignored2) { + } catch (Throwable ignored) { + // suppress exception if arisen setting this header by us. } } 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 index 31635a886e0..d22d7899836 100644 --- 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 @@ -15,7 +15,7 @@ public class WrappedServletOutputStream extends ServletOutputStream { 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) { + private static MethodHandle getMh(final String name) { try { return new MethodHandles(ServletOutputStream.class.getClassLoader()) .method(ServletOutputStream.class, name); @@ -24,6 +24,11 @@ private static final MethodHandle getMh(final String name) { } } + @SuppressWarnings("unchecked") + private static void sneakyThrow(Throwable e) throws E { + throw (E) e; + } + public WrappedServletOutputStream( ServletOutputStream delegate, byte[] marker, byte[] contentToInject, Runnable onInjected) { this.filtered = new InjectingPipeOutputStream(delegate, marker, contentToInject, onInjected); @@ -62,8 +67,9 @@ public boolean isReady() { try { return (boolean) IS_READY_MH.invoke(delegate); } catch (Throwable e) { - // how to sneaky throw? - throw new RuntimeException(e); + sneakyThrow(e); + // will never return a value but added for the compiler + return false; } } @@ -74,8 +80,7 @@ public void setWriteListener(WriteListener writeListener) { try { SET_WRITELISTENER_MH.invoke(delegate, writeListener); } catch (Throwable e) { - // how to sneaky throw? - throw new RuntimeException(e); + sneakyThrow(e); } } } diff --git a/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java index 5a5a6493d20..ece77c9173e 100644 --- a/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java +++ b/dd-java-agent/instrumentation/servlet/request-5/src/main/java/datadog/trace/instrumentation/servlet5/RumHttpServletResponseWrapper.java @@ -74,7 +74,8 @@ public void resetBuffer() { public void onInjected() { try { setHeader("x-datadog-rum-injected", "1"); - } catch (Throwable ignored2) { + } catch (Throwable ignored) { + // suppress exception if arisen setting this header by us. } } 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 index 36b84d62ac1..356d1309ec4 100644 --- a/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java +++ b/internal-api/src/main/java/datadog/trace/api/rum/RumInjector.java @@ -9,7 +9,7 @@ public final class RumInjector { private static final RumInjector INSTANCE = new RumInjector(Config.get()); private static final String MARKER = ""; - private static final Function MARKER_ADDER = + private static final Function MARKER_BYTES = charset -> { try { return MARKER.getBytes(charset); @@ -23,7 +23,7 @@ public final class RumInjector { private final DDCache snippetCache; private final DDCache markerCache; - private final Function snippetAdder; + private final Function snippetBytes; RumInjector(Config config) { boolean rumEnabled = config.isRumEnabled(); @@ -34,7 +34,7 @@ public final class RumInjector { this.snippet = injectorConfig.getSnippet(); this.snippetCache = DDCaches.newFixedSizeCache(16); this.markerCache = DDCaches.newFixedSizeCache(16); - this.snippetAdder = + this.snippetBytes = charset -> { try { return snippet.getBytes(charset); @@ -47,7 +47,7 @@ public final class RumInjector { this.snippet = null; this.snippetCache = null; this.markerCache = null; - this.snippetAdder = null; + this.snippetBytes = null; } } @@ -74,7 +74,7 @@ public byte[] getSnippet(String encoding) { if (!this.enabled) { return null; } - return this.snippetCache.computeIfAbsent(encoding, this.snippetAdder); + return this.snippetCache.computeIfAbsent(encoding, this.snippetBytes); } /** @@ -88,6 +88,6 @@ public byte[] getMarker(String encoding) { if (!this.enabled) { return null; } - return this.markerCache.computeIfAbsent(encoding, MARKER_ADDER); + return this.markerCache.computeIfAbsent(encoding, MARKER_BYTES); } } diff --git a/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorTest.groovy index 4c2d9f9ad37..4985ca5b7a2 100644 --- a/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorTest.groovy +++ b/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorTest.groovy @@ -3,13 +3,11 @@ package datadog.trace.api.rum import datadog.trace.api.Config import datadog.trace.test.util.DDSpecification -import java.nio.charset.StandardCharsets - import static org.mockito.Mockito.mock import static org.mockito.Mockito.when class RumInjectorTest extends DDSpecification { - public static final String UTF8 = StandardCharsets.UTF_8.name() + public static final String UTF8 = "UTF-8" void 'disabled injector'(){ setup: From 6fb58b4e0547140b5f88a1dd43ff1ba6882a9488 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 8 Jul 2025 10:43:37 +0200 Subject: [PATCH 33/36] feat(rum): Improve config related to PR review feedback --- .../src/main/java/datadog/trace/api/ConfigDefaults.java | 2 +- .../java/datadog/trace/api/rum/RumInjectorConfig.java | 8 ++++++-- .../groovy/datadog/trace/api/rum/RumInjectorTest.groovy | 2 +- .../java/datadog/trace/api/rum/RumInjectorConfigTest.java | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) 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 ac81b2ce326..c03af8bbd82 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 @@ -242,7 +242,7 @@ public final class ConfigDefaults { static final boolean DEFAULT_RUM_ENABLED = false; public static final String DEFAULT_RUM_SITE = DEFAULT_SITE; - static final int DEFAULT_RUM_MAJOR_VERSION = 6; + public static final int DEFAULT_RUM_MAJOR_VERSION = 6; static final boolean DEFAULT_SSI_INJECTION_FORCE = false; static final String DEFAULT_INSTRUMENTATION_SOURCE = "manual"; 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 index 4bfb228a54b..882b4add458 100644 --- a/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorConfig.java +++ b/internal-api/src/main/java/datadog/trace/api/rum/RumInjectorConfig.java @@ -17,6 +17,7 @@ public class RumInjectorConfig { REGIONS.put("us5.datadoghq.com", "us5"); REGIONS.put("datadoghq.eu", "eu1"); REGIONS.put("ap1.datadoghq.com", "ap1"); + REGIONS.put("ap2.datadoghq.com", "ap2"); } /** RUM application ID */ @@ -30,7 +31,7 @@ public class RumInjectorConfig { /** The environment of the service (e.g., `prod`, `staging` or `dev). */ @Nullable public final String env; /** SDK major version. */ - public int majorVersion; + public final 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). */ @@ -50,6 +51,8 @@ public class RumInjectorConfig { @Nullable public final Float sessionReplaySampleRate; /** The remote configuration identifier. */ @Nullable public final String remoteConfigurationId; + /** The JSON representation of injector config to use in the injected SDK snippet. */ + public final String jsonPayload; public RumInjectorConfig( String applicationId, @@ -108,6 +111,7 @@ public RumInjectorConfig( throw new IllegalArgumentException( "Either remote configuration id or both session and session replay sample rates must be set"); } + this.jsonPayload = jsonPayload(); } private static boolean validateSite(String site) { @@ -130,7 +134,7 @@ public String getSnippet() { + "','DD_RUM')\n" + "window.DD_RUM.onReady(function() {\n" + " window.DD_RUM.init(" - + jsonPayload() + + this.jsonPayload + ");\n" + "});\n" + "\n"; diff --git a/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorTest.groovy index 4985ca5b7a2..12541f452fc 100644 --- a/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorTest.groovy +++ b/internal-api/src/test/groovy/datadog/trace/api/rum/RumInjectorTest.groovy @@ -7,7 +7,7 @@ import static org.mockito.Mockito.mock import static org.mockito.Mockito.when class RumInjectorTest extends DDSpecification { - public static final String UTF8 = "UTF-8" + static final String UTF8 = "UTF-8" void 'disabled injector'(){ setup: diff --git a/internal-api/src/test/java/datadog/trace/api/rum/RumInjectorConfigTest.java b/internal-api/src/test/java/datadog/trace/api/rum/RumInjectorConfigTest.java index df4a57bacae..ba561e2e40c 100644 --- a/internal-api/src/test/java/datadog/trace/api/rum/RumInjectorConfigTest.java +++ b/internal-api/src/test/java/datadog/trace/api/rum/RumInjectorConfigTest.java @@ -58,7 +58,7 @@ class RumInjectorConfigTest { }, delimiterString = "|", nullValues = "null") - void testSnippet( + void testValidConfig( String applicationId, String clientToken, String site, From 1e6faf998b4911da285acbe1fa748131d82bfe77 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Tue, 8 Jul 2025 11:15:07 +0200 Subject: [PATCH 34/36] Improve and fix circular buffer --- .../InjectingPipeOutputStreamBenchmark.java | 4 +-- .../buffer/InjectingPipeOutputStream.java | 28 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) 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 index fed7bfe9d77..8b90b4678a7 100644 --- 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 @@ -23,8 +23,8 @@ /* * Benchmark Mode Cnt Score Error Units - * InjectingPipeOutputStreamBenchmark.withPipe avgt 2 16.802 us/op - * InjectingPipeOutputStreamBenchmark.withoutPipe avgt 2 13.001 us/op + * InjectingPipeOutputStreamBenchmark.withPipe avgt 2 15.515 us/op + * InjectingPipeOutputStreamBenchmark.withoutPipe avgt 2 12.861 us/op */ @State(Scope.Benchmark) @Warmup(iterations = 1, time = 30, timeUnit = SECONDS) diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java index 18d135d77ad..47780d5cbb1 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java @@ -31,7 +31,7 @@ public InjectingPipeOutputStream( final Runnable onContentInjected) { super(downstream); this.marker = marker; - this.lookbehind = new byte[marker.length + 1]; + this.lookbehind = new byte[marker.length]; this.pos = 0; this.contentToInject = contentToInject; this.onContentInjected = onContentInjected; @@ -58,13 +58,11 @@ public void write(int b) throws IOException { if (marker[matchingPos++] == b) { if (matchingPos == marker.length) { found = true; - out.write(lookbehind[pos]); - pos = (pos + 1) % lookbehind.length; out.write(contentToInject); if (onContentInjected != null) { onContentInjected.run(); } - drain(lookbehind.length - 1); + drain(); } } else { matchingPos = 0; @@ -77,12 +75,15 @@ public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); return; } - if (len > marker.length * 2) { + if (len > marker.length * 2 - 2) { + // if the content is large enough, we can bulk write everything but the N trail and tail. + // This because the buffer can already contain some byte from a previous single write. + // Also we need to fill the buffer with the tail since we don't know about the next write. int idx = arrayContains(b, marker); if (idx >= 0) { // we have a full match. just write everything found = true; - drain(lookbehind.length); + drain(); out.write(b, off, idx); out.write(contentToInject); if (onContentInjected != null) { @@ -92,15 +93,14 @@ public void write(byte[] b, int off, int len) throws IOException { } else { // we don't have a full match. write everything in a bulk except the lookbehind buffer // sequentially - for (int i = off; i < off + marker.length; i++) { + for (int i = off; i < off + marker.length - 1; i++) { write(b[i]); } - drain(lookbehind.length); - out.write(b, off + marker.length, len - marker.length * 2); - for (int i = len - marker.length; i < len; i++) { + drain(); + out.write(b, off + marker.length - 1, len - marker.length * 2 + 2); + for (int i = len - marker.length + 1; i < len; i++) { write(b[i]); } - drain(lookbehind.length); } } else { // use slow path because the length to write is small and within the lookbehind buffer size @@ -128,9 +128,9 @@ private int arrayContains(byte[] array, byte[] search) { return -1; } - private void drain(int len) throws IOException { + private void drain() throws IOException { if (bufferFilled) { - for (int i = 0; i < len; i++) { + for (int i = 0; i < lookbehind.length; i++) { out.write(lookbehind[(pos + i) % lookbehind.length]); } } else { @@ -144,7 +144,7 @@ private void drain(int len) throws IOException { @Override public void close() throws IOException { if (!found) { - drain(lookbehind.length); + drain(); } super.close(); } From a4d960d9e778a0ff949056dd934134059e57cc37 Mon Sep 17 00:00:00 2001 From: Andrea Marziali Date: Tue, 8 Jul 2025 16:00:01 +0200 Subject: [PATCH 35/36] review --- .../buffer/InjectingPipeOutputStream.java | 6 ++- .../InjectingPipeOutputStreamTest.groovy | 48 ++++++++++++++++++- .../RumHttpServletResponseWrapper.java | 1 + .../RumHttpServletResponseWrapper.java | 1 + 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java index 47780d5cbb1..74d5b1361d8 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStream.java @@ -17,6 +17,7 @@ public class InjectingPipeOutputStream extends FilterOutputStream { private boolean found = false; private int matchingPos = 0; private final Runnable onContentInjected; + private final int bulkWriteThreshold; /** * @param downstream the delegate output stream @@ -35,6 +36,7 @@ public InjectingPipeOutputStream( this.pos = 0; this.contentToInject = contentToInject; this.onContentInjected = onContentInjected; + this.bulkWriteThreshold = marker.length * 2 - 2; } @Override @@ -75,7 +77,7 @@ public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); return; } - if (len > marker.length * 2 - 2) { + if (len > bulkWriteThreshold) { // if the content is large enough, we can bulk write everything but the N trail and tail. // This because the buffer can already contain some byte from a previous single write. // Also we need to fill the buffer with the tail since we don't know about the next write. @@ -97,7 +99,7 @@ public void write(byte[] b, int off, int len) throws IOException { write(b[i]); } drain(); - out.write(b, off + marker.length - 1, len - marker.length * 2 + 2); + out.write(b, off + marker.length - 1, len - bulkWriteThreshold); for (int i = len - marker.length + 1; i < len; i++) { write(b[i]); } diff --git a/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamTest.groovy b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamTest.groovy index d01b38d3a1e..48d552e0a8f 100644 --- a/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamTest.groovy +++ b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/buffer/InjectingPipeOutputStreamTest.groovy @@ -1,8 +1,27 @@ package datadog.trace.bootstrap.instrumentation.buffer +import datadog.trace.test.util.DDSpecification import spock.lang.Specification -class InjectingPipeOutputStreamTest extends Specification { +class InjectingPipeOutputStreamTest extends DDSpecification { + + static class ExceptionControlledOutputStream extends FilterOutputStream { + + boolean failWrite = false + + ExceptionControlledOutputStream(OutputStream out) { + super(out) + } + + @Override + void write(int b) throws IOException { + if (failWrite) { + throw new IOException("Failed") + } + super.write(b) + } + } + def 'should filter a buffer and inject if found #found'() { setup: def downstream = new ByteArrayOutputStream() @@ -20,4 +39,31 @@ class InjectingPipeOutputStreamTest extends Specification { "" | "" | "" | false | "" "" | "" | "" | false | "" } + + def 'should maintain the state in case of IOException for epilogue #epilogue'() { + setup: + def baos = new ByteArrayOutputStream() + def downstream = new ExceptionControlledOutputStream(baos) + def piped = new OutputStreamWriter(new InjectingPipeOutputStream(downstream, "".getBytes("UTF-8"), "".getBytes("UTF-8"), null), + "UTF-8") + when: + piped.write(prologue) + piped.flush() + downstream.failWrite = true + piped.write(epilogue) + piped.flush() + then: + thrown IOException + when: + downstream.failWrite = false + piped.write(epilogue) + piped.close() + then: + def expected = prologue + epilogue + assert expected == new String(baos.toByteArray(), "UTF-8") + where: + prologue | epilogue + "