From afcfb6a391d4fe998b3447f587f5ac21688c2e93 Mon Sep 17 00:00:00 2001 From: Pedram Safaei Date: Tue, 22 Sep 2026 09:45:57 +0000 Subject: [PATCH 1/6] Tag database client spans with the identity encoded in RDS endpoint hostnames Database client spans against Amazon RDS only carried peer.hostname, for example orders-db.c9akciq32bzq.us-east-1.rds.amazonaws.com. That name deterministically encodes the DB instance (or Aurora cluster, custom endpoint or RDS Proxy) identifier and the Region, but nothing exposed them as queryable tags. Correlating a slow query with the aws.rds.* integration metrics for the same instance meant parsing the hostname by hand. DatabaseClientDecorator now parses the hostname whenever it matches an RDS endpoint and tags the span with: - aws.rds.identifier: the leading label (instance, cluster, custom endpoint or proxy name) - aws.rds.endpoint_type: instance, cluster, cluster-ro, cluster-custom or proxy - aws.region: the Region encoded in the hostname - dbinstanceidentifier when the endpoint is an instance endpoint, and dbclusteridentifier when it is a cluster or cluster reader endpoint. These match the dimension tags on the aws.rds.* metrics so APM spans and the RDS integration (and Database Monitoring) join on the same key. The instance or cluster identifier is only claimed when the endpoint type proves it. A cluster endpoint does not name the writer instance behind it, and a custom endpoint or proxy name is not a cluster identifier, so those types get aws.rds.identifier only. Both the standard (...rds.amazonaws.com) and China (..rds..amazonaws.com.cn) shapes are handled. Parsing is cached per hostname; hostnames not ending in ".amazonaws.com" or ".amazonaws.com.cn" (an O(1) suffix check) are rejected before the cache so they neither evict RDS entries nor pay for the parse, and are otherwise left untouched. So every existing database client integration (JDBC, R2DBC, Vert.x, ...) picks this up with no behaviour change outside RDS. --- .../decorator/AwsRdsEndpoint.java | 229 ++++++++++++++++++ .../decorator/DatabaseClientDecorator.java | 27 +++ .../DatabaseClientDecoratorTest.groovy | 44 ++++ .../decorator/AwsRdsEndpointTest.java | 159 ++++++++++++ .../api/InstrumentationTags.java | 9 + 5 files changed, 468 insertions(+) create mode 100644 dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java create mode 100644 dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java new file mode 100644 index 00000000000..b0d8028945e --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java @@ -0,0 +1,229 @@ +package datadog.trace.bootstrap.instrumentation.decorator; + +import datadog.trace.api.cache.DDCache; +import datadog.trace.api.cache.DDCaches; +import java.util.Locale; + +/** + * Identity encoded in an Amazon RDS endpoint hostname. + * + *

RDS hands out DNS names of the form {@code ...rds.amazonaws.com} (or + * {@code ..rds..amazonaws.com.cn} in the China partition). The leading + * label is the DB instance identifier, the Aurora cluster identifier, a custom endpoint name or an + * RDS Proxy name depending on the {@code } label prefix. The Region is always present. + * + *

Only the endpoint type is recoverable from the name. A cluster endpoint always names a + * cluster, but the writer instance behind it is not part of the hostname, so this class never + * claims an instance identifier for a cluster endpoint. + */ +public final class AwsRdsEndpoint { + + public enum Type { + /** {@code ...rds.amazonaws.com}. */ + INSTANCE("instance"), + /** {@code .cluster-..rds.amazonaws.com}. */ + CLUSTER("cluster"), + /** {@code .cluster-ro-..rds.amazonaws.com}. */ + CLUSTER_READER("cluster-ro"), + /** {@code .cluster-custom-..rds.amazonaws.com}. */ + CLUSTER_CUSTOM("cluster-custom"), + /** {@code .proxy-..rds.amazonaws.com}. */ + PROXY("proxy"); + + private final String tagValue; + + Type(String tagValue) { + this.tagValue = tagValue; + } + + public String tagValue() { + return tagValue; + } + } + + private static final AwsRdsEndpoint NOT_RDS = new AwsRdsEndpoint(null, null, null); + + // Applications talk to a handful of databases; the cache only needs to absorb the per-span + // parse of the same few hostnames. + private static final DDCache CACHE = DDCaches.newFixedSizeCache(16); + + private final String identifier; + private final Type type; + private final String region; + + private AwsRdsEndpoint(String identifier, Type type, String region) { + this.identifier = identifier; + this.type = type; + this.region = region; + } + + /** + * Parses an RDS endpoint hostname. + * + * @return the identity encoded in the hostname, or {@code null} when the hostname is not an RDS + * endpoint. + */ + public static AwsRdsEndpoint parse(final CharSequence hostname) { + if (hostname == null || hostname.length() == 0) { + return null; + } + String hostnameString = hostname.toString(); + // Every RDS endpoint, in any partition, ends in ".amazonaws.com" or ".amazonaws.com.cn". + // Reject everything else before touching the cache so the handful of real RDS hostnames are + // never evicted by the unbounded stream of non-AWS database hosts, and so those hosts never + // pay for the label split. + if (!isPlausibleRdsHostname(hostnameString)) { + return null; + } + AwsRdsEndpoint endpoint = CACHE.computeIfAbsent(hostnameString, AwsRdsEndpoint::doParse); + return endpoint == NOT_RDS ? null : endpoint; + } + + private static final String AMAZONAWS_COM = ".amazonaws.com"; + private static final String AMAZONAWS_COM_CN = ".amazonaws.com.cn"; + + /** + * O(1) check that the hostname ends with {@code .amazonaws.com} or {@code .amazonaws.com.cn}, + * ignoring case and tolerating the same trailing {@code .} and {@code :port} that {@link + * #doParse} accepts, so the gate and the parser never disagree on what is RDS-shaped. Anchoring + * on the suffix (rather than scanning for the substring) also rejects hostnames that merely + * contain {@code .amazonaws.com} followed by other labels. + */ + static boolean isPlausibleRdsHostname(final String hostname) { + int end = hostname.length(); + if (end > 0 && hostname.charAt(end - 1) == '.') { + end--; + } + end = stripPort(hostname, end); + return endsWithIgnoreCase(hostname, end, AMAZONAWS_COM) + || endsWithIgnoreCase(hostname, end, AMAZONAWS_COM_CN); + } + + /** Returns the end index with a trailing {@code :port} removed. Bounded to 5 digits. */ + private static int stripPort(final String hostname, final int end) { + int i = end; + int digits = 0; + while (i > 0 && digits < 5 && isAsciiDigit(hostname.charAt(i - 1))) { + i--; + digits++; + } + return digits > 0 && i > 0 && hostname.charAt(i - 1) == ':' ? i - 1 : end; + } + + private static boolean isAsciiDigit(final char c) { + return c >= '0' && c <= '9'; + } + + private static boolean endsWithIgnoreCase( + final String hostname, final int end, final String suffix) { + final int length = suffix.length(); + return end >= length && hostname.regionMatches(true, end - length, suffix, 0, length); + } + + /** The DB instance identifier, cluster identifier, custom endpoint name or proxy name. */ + public String identifier() { + return identifier; + } + + public Type type() { + return type; + } + + /** The AWS Region the endpoint lives in, for example {@code us-east-1}. */ + public String region() { + return region; + } + + private static AwsRdsEndpoint doParse(final String rawHostname) { + String hostname = rawHostname.toLowerCase(Locale.ROOT); + int end = hostname.length(); + // Strip a trailing dot from a fully qualified name and a :port suffix if one leaked in. + if (end > 0 && hostname.charAt(end - 1) == '.') { + end--; + } + int colon = hostname.indexOf(':'); + if (colon >= 0) { + end = Math.min(end, colon); + } + String[] labels = hostname.substring(0, end).split("\\.", -1); + + // ...rds.amazonaws.com + if (labels.length == 6 + && "rds".equals(labels[3]) + && "amazonaws".equals(labels[4]) + && "com".equals(labels[5])) { + return build(labels[0], labels[1], labels[2]); + } + // ..rds..amazonaws.com.cn + if (labels.length == 7 + && "rds".equals(labels[2]) + && "amazonaws".equals(labels[4]) + && "com".equals(labels[5]) + && "cn".equals(labels[6])) { + return build(labels[0], labels[1], labels[3]); + } + return NOT_RDS; + } + + private static AwsRdsEndpoint build( + final String identifier, final String hash, final String region) { + if (identifier.isEmpty() || !isRegion(region)) { + return NOT_RDS; + } + Type type; + String suffix; + if (hash.startsWith("cluster-ro-")) { + type = Type.CLUSTER_READER; + suffix = hash.substring("cluster-ro-".length()); + } else if (hash.startsWith("cluster-custom-")) { + type = Type.CLUSTER_CUSTOM; + suffix = hash.substring("cluster-custom-".length()); + } else if (hash.startsWith("cluster-")) { + type = Type.CLUSTER; + suffix = hash.substring("cluster-".length()); + } else if (hash.startsWith("proxy-")) { + type = Type.PROXY; + suffix = hash.substring("proxy-".length()); + } else { + type = Type.INSTANCE; + suffix = hash; + } + if (!isHash(suffix)) { + return NOT_RDS; + } + return new AwsRdsEndpoint(identifier, type, region); + } + + /** The account-scoped hash RDS appends to every endpoint: lowercase alphanumerics only. */ + private static boolean isHash(final String value) { + if (value.length() < 8 || value.length() > 16) { + return false; + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'))) { + return false; + } + } + return true; + } + + /** An AWS Region code such as us-east-1, us-gov-west-1, cn-north-1 or eu-isoe-west-1. */ + private static boolean isRegion(final String value) { + int length = value.length(); + if (length < 8 || length > 24) { + return false; + } + int dashes = 0; + for (int i = 0; i < length; i++) { + char c = value.charAt(i); + if (c == '-') { + dashes++; + } else if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'))) { + return false; + } + } + // Every Region ends in a digit and has at least two dashes (e.g. us-east-1). + return dashes >= 2 && value.charAt(0) != '-' && Character.isDigit(value.charAt(length - 1)); + } +} diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/DatabaseClientDecorator.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/DatabaseClientDecorator.java index 79d08f4139a..c32cdaaa8fb 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/DatabaseClientDecorator.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/DatabaseClientDecorator.java @@ -16,6 +16,7 @@ import datadog.trace.api.naming.SpanNaming; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; +import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags; import datadog.trace.bootstrap.instrumentation.api.Tags; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import java.util.function.BiConsumer; @@ -74,6 +75,7 @@ public void onConnection(final AgentSpan span, final CONNECTION connection) { CharSequence hostName = dbHostname(connection); if (hostName != null) { span.setTag(Tags.PEER_HOSTNAME, hostName); + onRdsEndpoint(span, AwsRdsEndpoint.parse(hostName)); if (Config.get().isDbClientSplitByHost()) { span.setServiceName(hostName.toString(), DB_CLIENT_SPLIT_BY_HOST); @@ -82,6 +84,31 @@ public void onConnection(final AgentSpan span, final CONNECTION connection) { } } + /** + * Tags the identity an Amazon RDS endpoint hostname carries: the identifier, what kind of + * endpoint it is and its Region. The DB instance or cluster identifier is only claimed when the + * endpoint type proves it, since a cluster endpoint does not name the instance behind it. + */ + protected void onRdsEndpoint(final AgentSpan span, final AwsRdsEndpoint endpoint) { + if (endpoint == null) { + return; + } + span.setTag(InstrumentationTags.AWS_RDS_IDENTIFIER, endpoint.identifier()); + span.setTag(InstrumentationTags.AWS_RDS_ENDPOINT_TYPE, endpoint.type().tagValue()); + span.setTag(InstrumentationTags.AWS_REGION, endpoint.region()); + switch (endpoint.type()) { + case INSTANCE: + span.setTag(InstrumentationTags.RDS_DB_INSTANCE_IDENTIFIER, endpoint.identifier()); + break; + case CLUSTER: + case CLUSTER_READER: + span.setTag(InstrumentationTags.RDS_DB_CLUSTER_IDENTIFIER, endpoint.identifier()); + break; + default: + break; + } + } + protected void onInstance(final AgentSpan span, final String dbInstance) { if (dbInstance != null) { span.setTag(Tags.DB_INSTANCE, dbInstance); diff --git a/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/decorator/DatabaseClientDecoratorTest.groovy b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/decorator/DatabaseClientDecoratorTest.groovy index 93852ccc88c..782aa2c8346 100644 --- a/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/decorator/DatabaseClientDecoratorTest.groovy +++ b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/decorator/DatabaseClientDecoratorTest.groovy @@ -4,6 +4,7 @@ import datadog.trace.api.DDTags import datadog.trace.api.TagMap import datadog.trace.bootstrap.instrumentation.api.AgentSpan import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext +import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags import datadog.trace.bootstrap.instrumentation.api.Tags import static datadog.trace.api.config.TraceInstrumentationConfig.DB_CLIENT_HOST_SPLIT_BY_HOST @@ -88,6 +89,49 @@ class DatabaseClientDecoratorTest extends ClientDecoratorTest { true | true | true | [user: "test-user", instance: "test-instance"] } + def "test onConnection tags RDS endpoint identity for #hostname"() { + setup: + def decorator = newDecorator() + + when: + decorator.onConnection(span, [user: "test-user", hostname: hostname]) + + then: + 1 * span.setTag(Tags.DB_USER, "test-user") + 1 * span.setTag(Tags.PEER_HOSTNAME, hostname) + 1 * span.setTag(InstrumentationTags.AWS_RDS_IDENTIFIER, identifier) + 1 * span.setTag(InstrumentationTags.AWS_RDS_ENDPOINT_TYPE, endpointType) + 1 * span.setTag(InstrumentationTags.AWS_REGION, "us-east-1") + if (instanceIdentifier) { + 1 * span.setTag(InstrumentationTags.RDS_DB_INSTANCE_IDENTIFIER, instanceIdentifier) + } + if (clusterIdentifier) { + 1 * span.setTag(InstrumentationTags.RDS_DB_CLUSTER_IDENTIFIER, clusterIdentifier) + } + 0 * _ + + where: + hostname | identifier | endpointType | instanceIdentifier | clusterIdentifier + "orders.c9akciq32bzq.us-east-1.rds.amazonaws.com" | "orders" | "instance" | "orders" | null + "orders.cluster-c9akciq32bzq.us-east-1.rds.amazonaws.com" | "orders" | "cluster" | null | "orders" + "orders.cluster-ro-c9akciq32bzq.us-east-1.rds.amazonaws.com" | "orders" | "cluster-ro" | null | "orders" + "reports.cluster-custom-c9akciq32bzq.us-east-1.rds.amazonaws.com" | "reports" | "cluster-custom" | null | null + "orders.proxy-c9akciq32bzq.us-east-1.rds.amazonaws.com" | "orders" | "proxy" | null | null + } + + def "test onConnection leaves non-RDS hostname untagged"() { + setup: + def decorator = newDecorator() + + when: + decorator.onConnection(span, [user: "test-user", hostname: "db.internal.example.com"]) + + then: + 1 * span.setTag(Tags.DB_USER, "test-user") + 1 * span.setTag(Tags.PEER_HOSTNAME, "db.internal.example.com") + 0 * _ + } + def "test onStatement"() { setup: def decorator = newDecorator() diff --git a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java new file mode 100644 index 00000000000..945ec58a4e8 --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java @@ -0,0 +1,159 @@ +package datadog.trace.bootstrap.instrumentation.decorator; + +import static datadog.trace.bootstrap.instrumentation.decorator.AwsRdsEndpoint.Type.CLUSTER; +import static datadog.trace.bootstrap.instrumentation.decorator.AwsRdsEndpoint.Type.CLUSTER_CUSTOM; +import static datadog.trace.bootstrap.instrumentation.decorator.AwsRdsEndpoint.Type.CLUSTER_READER; +import static datadog.trace.bootstrap.instrumentation.decorator.AwsRdsEndpoint.Type.INSTANCE; +import static datadog.trace.bootstrap.instrumentation.decorator.AwsRdsEndpoint.Type.PROXY; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class AwsRdsEndpointTest { + + private static final String RDS = "orders-db.c9akciq32bzq.us-east-1.rds.amazonaws.com"; + + static Stream rdsEndpoints() { + return Stream.of( + Arguments.of(RDS, "orders-db", INSTANCE, "us-east-1"), + Arguments.of( + "Orders-DB.C9AKCIQ32BZQ.US-EAST-1.RDS.AMAZONAWS.COM", + "orders-db", + INSTANCE, + "us-east-1"), + Arguments.of(RDS + ".", "orders-db", INSTANCE, "us-east-1"), + Arguments.of(RDS + ":5432", "orders-db", INSTANCE, "us-east-1"), + Arguments.of( + "orders-db.c9akciq32bzq.us-gov-west-1.rds.amazonaws.com", + "orders-db", + INSTANCE, + "us-gov-west-1"), + Arguments.of( + "orders-db.c9akciq32bzq.eu-isoe-west-1.rds.amazonaws.com", + "orders-db", + INSTANCE, + "eu-isoe-west-1"), + Arguments.of( + "orders-db.c9akciq32bzq.rds.cn-north-1.amazonaws.com.cn", + "orders-db", + INSTANCE, + "cn-north-1"), + Arguments.of( + "orders-aurora.cluster-c9akciq32bzq.us-west-2.rds.amazonaws.com", + "orders-aurora", + CLUSTER, + "us-west-2"), + Arguments.of( + "orders-aurora.cluster-ro-c9akciq32bzq.us-west-2.rds.amazonaws.com", + "orders-aurora", + CLUSTER_READER, + "us-west-2"), + Arguments.of( + "reporting.cluster-custom-c9akciq32bzq.us-west-2.rds.amazonaws.com", + "reporting", + CLUSTER_CUSTOM, + "us-west-2"), + Arguments.of( + "orders-proxy.proxy-c9akciq32bzq.ap-southeast-2.rds.amazonaws.com", + "orders-proxy", + PROXY, + "ap-southeast-2")); + } + + @ParameterizedTest(name = "parses {0}") + @MethodSource("rdsEndpoints") + void parsesRdsEndpoint( + String hostname, String identifier, AwsRdsEndpoint.Type type, String region) { + AwsRdsEndpoint endpoint = AwsRdsEndpoint.parse(hostname); + assertNotNull(endpoint); + assertEquals(identifier, endpoint.identifier()); + assertEquals(type, endpoint.type()); + assertEquals(region, endpoint.region()); + } + + @ParameterizedTest(name = "rejects {0}") + @NullAndEmptySource + @ValueSource( + strings = { + "localhost", + "db.internal.example.com", + "orders-db.c9akciq32bzq.us-east-1.amazonaws.com", + "orders-db.rds.amazonaws.com", + "c9akciq32bzq.us-east-1.rds.amazonaws.com", + "orders-db.c9akciq32bzq.us-east-1.rds.amazonaws.com.evil.example", + "orders-db.not_a_hash!.us-east-1.rds.amazonaws.com", + "orders-db.c9akciq32bzq.useast1.rds.amazonaws.com", + "orders-db.c9akciq32bzq.us-east.rds.amazonaws.com", + ".c9akciq32bzq.us-east-1.rds.amazonaws.com", + "orders-db.cluster-.us-east-1.rds.amazonaws.com", + "s3.us-east-1.amazonaws.com", + "dynamodb.us-east-1.amazonaws.com", + }) + void rejectsNonRdsHostname(String hostname) { + assertNull(AwsRdsEndpoint.parse(hostname)); + } + + @Test + void cachesParsedEndpointsPerHostname() { + assertSame(AwsRdsEndpoint.parse(RDS), AwsRdsEndpoint.parse(RDS)); + } + + @ParameterizedTest(name = "gate accepts {0}") + @ValueSource( + strings = { + RDS, + RDS + ".", + RDS + ":5432", + RDS + ":5432.", + "orders-db.c9akciq32bzq.rds.cn-north-1.amazonaws.com.cn", + "orders-db.c9akciq32bzq.rds.cn-north-1.amazonaws.com.cn.", + "orders-db.c9akciq32bzq.rds.cn-north-1.amazonaws.com.cn:3306", + "ORDERS-DB.C9AKCIQ32BZQ.US-EAST-1.RDS.AMAZONAWS.COM", + "orders-db.c9akciq32bzq.us-east-1.rds.AmazonAWS.com", + // shaped like an AWS host, so it may reach the cache; doParse rejects it structurally + "s3.us-east-1.amazonaws.com", + }) + void preCacheGateAccepts(String hostname) { + assertTrue(AwsRdsEndpoint.isPlausibleRdsHostname(hostname)); + } + + @ParameterizedTest(name = "gate rejects {0}") + @ValueSource( + strings = { + "db-01.internal", + "localhost", + "orders-db.amazonaws.co", + "a.b", + "", + // ".amazonaws.com" present but not the suffix: rejected before the cache + "orders-db.c9akciq32bzq.us-east-1.rds.amazonaws.com.evil.example", + "orders-db.c9akciq32bzq.us-east-1.rds.amazonaws.com.cn.evil.example", + // port must be all digits and at most five of them + RDS + ":54x2", + RDS + ":543210", + }) + void preCacheGateRejects(String hostname) { + assertFalse(AwsRdsEndpoint.isPlausibleRdsHostname(hostname)); + } + + @Test + void nonRdsHostnamesDoNotEvictCachedRdsEndpoints() { + AwsRdsEndpoint first = AwsRdsEndpoint.parse(RDS); + for (int i = 0; i < 100; i++) { + assertNull(AwsRdsEndpoint.parse("db-" + i + ".internal")); + assertNull(AwsRdsEndpoint.parse("db-" + i + ".rds.amazonaws.com.evil.example")); + } + assertSame(first, AwsRdsEndpoint.parse(RDS)); + } +} diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java index 0c1054e7776..23cfe89cc24 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java @@ -37,6 +37,15 @@ public class InstrumentationTags { public static final String AWS_REQUEST_ID = "aws.requestId"; public static final String AWS_STORAGE_CLASS = "aws.storage.class"; + // Identity encoded in an Amazon RDS endpoint hostname. dbinstanceidentifier and + // dbclusteridentifier match the dimension tags on the aws.rds.* metrics so database client + // spans and Database Monitoring join on the same keys. + public static final String AWS_REGION = "aws.region"; + public static final String AWS_RDS_IDENTIFIER = "aws.rds.identifier"; + public static final String AWS_RDS_ENDPOINT_TYPE = "aws.rds.endpoint_type"; + public static final String RDS_DB_INSTANCE_IDENTIFIER = "dbinstanceidentifier"; + public static final String RDS_DB_CLUSTER_IDENTIFIER = "dbclusteridentifier"; + // These are temporary keys used for span pointer hash calculation public static final String S3_ETAG = "s3.eTag"; public static final String DYNAMO_PRIMARY_KEY_1 = "dynamodb.primary_key_1"; From 5c85113bc42b724b3d33d47c846653b2959bfc82 Mon Sep 17 00:00:00 2001 From: Syntax Error <34566793+pedramsafaei@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:11:23 -0700 Subject: [PATCH 2/6] Update dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java Co-authored-by: Stuart McCulloch --- .../bootstrap/instrumentation/decorator/AwsRdsEndpoint.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java index b0d8028945e..e56c3ae9b96 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java @@ -64,7 +64,7 @@ private AwsRdsEndpoint(String identifier, Type type, String region) { * endpoint. */ public static AwsRdsEndpoint parse(final CharSequence hostname) { - if (hostname == null || hostname.length() == 0) { + if (hostname == null || hostname.length() < AMAZONAWS_COM.length()) { return null; } String hostnameString = hostname.toString(); From 7c23b21b1a2614c4083090a574e4f281af392635 Mon Sep 17 00:00:00 2001 From: Syntax Error <34566793+pedramsafaei@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:18:08 +0000 Subject: [PATCH 3/6] Clarify RDS hostname pre-filter Javadoc on malformed ports The pre-cache gate strips only a well-formed :port (at most five ASCII digits), so it rejects a hostname with a malformed or oversized port even though the parser would truncate at the first ':' and could otherwise accept the labels before it. The previous Javadoc claimed the gate and the parser never disagree on what is RDS-shaped, which overstated the relationship. Describe the gate as a conservative pre-filter that only ever keeps a superset of non-RDS hosts out of the cache and never a valid RDS endpoint. --- .../instrumentation/decorator/AwsRdsEndpoint.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java index e56c3ae9b96..51f3a6e6f4c 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java @@ -84,10 +84,16 @@ public static AwsRdsEndpoint parse(final CharSequence hostname) { /** * O(1) check that the hostname ends with {@code .amazonaws.com} or {@code .amazonaws.com.cn}, - * ignoring case and tolerating the same trailing {@code .} and {@code :port} that {@link - * #doParse} accepts, so the gate and the parser never disagree on what is RDS-shaped. Anchoring - * on the suffix (rather than scanning for the substring) also rejects hostnames that merely - * contain {@code .amazonaws.com} followed by other labels. + * ignoring case and tolerating a trailing {@code .} and a well-formed {@code :port} (at most five + * ASCII digits). Anchoring on the suffix (rather than scanning for the substring) also rejects + * hostnames that merely contain {@code .amazonaws.com} followed by other labels. + * + *

This is a conservative pre-filter, not an exact mirror of {@link #doParse}. A hostname with + * a malformed or oversized port (for example {@code ...amazonaws.com:54x2} or {@code :543210}) is + * rejected here even though {@code doParse} would truncate at the first {@code :} and could + * otherwise accept the labels before it. Real RDS endpoints never carry such a suffix, so + * rejecting them early is safe: the gate only ever keeps a superset of non-RDS hosts out of the + * cache, never a valid RDS endpoint. */ static boolean isPlausibleRdsHostname(final String hostname) { int end = hostname.length(); From aff0e5a6867efcfdba238b8add8c5e617185e96d Mon Sep 17 00:00:00 2001 From: Pedram Safaei <34566793+pedramsafaei@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:15:47 +0000 Subject: [PATCH 4/6] Parse user-defined RDS Proxy endpoints A user-defined RDS Proxy endpoint has the shape .endpoint.proxy-..rds.amazonaws.com, with an extra endpoint label the default proxy endpoint does not carry. The parser only recognized the default shape, so read-only and other user-defined proxy endpoints matched neither branch and their database spans received none of the new identity or Region tags. Drop the endpoint label when the following label starts with proxy- so the proxy name and Region are read by the same validated branches as the default proxy endpoint, in both the standard and China layouts. The normalization is gated on the proxy- prefix, so a non-proxy endpoint label is still rejected. --- .../instrumentation/decorator/AwsRdsEndpoint.java | 15 ++++++++++++++- .../decorator/AwsRdsEndpointTest.java | 14 +++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java index 51f3a6e6f4c..38d258d8ce7 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java @@ -27,7 +27,10 @@ public enum Type { CLUSTER_READER("cluster-ro"), /** {@code .cluster-custom-..rds.amazonaws.com}. */ CLUSTER_CUSTOM("cluster-custom"), - /** {@code .proxy-..rds.amazonaws.com}. */ + /** + * {@code .proxy-..rds.amazonaws.com}, or a user-defined endpoint + * {@code .endpoint.proxy-..rds.amazonaws.com}. + */ PROXY("proxy"); private final String tagValue; @@ -153,6 +156,16 @@ private static AwsRdsEndpoint doParse(final String rawHostname) { } String[] labels = hostname.substring(0, end).split("\\.", -1); + // A user-defined RDS Proxy endpoint carries an extra "endpoint" label: + // .endpoint.proxy-..rds.amazonaws.com. Drop that label so the proxy name + // and Region are read by the same validated branches as the default proxy endpoint. + if (labels.length > 3 && "endpoint".equals(labels[1]) && labels[2].startsWith("proxy-")) { + String[] normalized = new String[labels.length - 1]; + normalized[0] = labels[0]; + System.arraycopy(labels, 2, normalized, 1, labels.length - 2); + labels = normalized; + } + // ...rds.amazonaws.com if (labels.length == 6 && "rds".equals(labels[3]) diff --git a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java index 945ec58a4e8..21d70a3d2fa 100644 --- a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java +++ b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java @@ -68,7 +68,17 @@ static Stream rdsEndpoints() { "orders-proxy.proxy-c9akciq32bzq.ap-southeast-2.rds.amazonaws.com", "orders-proxy", PROXY, - "ap-southeast-2")); + "ap-southeast-2"), + Arguments.of( + "orders-ro.endpoint.proxy-c9akciq32bzq.us-east-1.rds.amazonaws.com", + "orders-ro", + PROXY, + "us-east-1"), + Arguments.of( + "orders-ro.endpoint.proxy-c9akciq32bzq.rds.cn-north-1.amazonaws.com.cn", + "orders-ro", + PROXY, + "cn-north-1")); } @ParameterizedTest(name = "parses {0}") @@ -97,6 +107,8 @@ void parsesRdsEndpoint( "orders-db.c9akciq32bzq.us-east.rds.amazonaws.com", ".c9akciq32bzq.us-east-1.rds.amazonaws.com", "orders-db.cluster-.us-east-1.rds.amazonaws.com", + // "endpoint" label only normalizes for proxy endpoints; a non-proxy third label is not RDS + "orders-db.endpoint.c9akciq32bzq.us-east-1.rds.amazonaws.com", "s3.us-east-1.amazonaws.com", "dynamodb.us-east-1.amazonaws.com", }) From 0acd850e0b388fc3c3757f33fcfddb5dc1855eb5 Mon Sep 17 00:00:00 2001 From: pedramsafaei <34566793+pedramsafaei@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:12:41 +0000 Subject: [PATCH 5/6] Parse RDS endpoints in every AWS partition and both label orders Strip the partition DNS suffix (amazonaws.com, amazonaws.com.cn, c2s.ic.gov, sc2s.sgov.gov, from the AWS SDK endpoint partition data) before reading the labels, so the same parse serves commercial, GovCloud, China and the ISO partitions. Accept both the region-before-service layout ...rds and the China service-before-region layout ..rds., so a region-before-service China endpoint such as orders-db..cn-north-1.rds.amazonaws.com.cn is now tagged. The pre-cache suffix gate accepts the same set of suffixes. A user-defined RDS Proxy endpoint keeps the existing endpoint-label normalization, now exercised in the ISO partition too, and the extra 'endpoint' label is only dropped for a real proxy hash so non-proxy hosts are not misclassified. Register the RDS identity tags on the db.client span type in the tag registry. Tests: add region-before-service China, ISO c2s.ic.gov and sc2s.sgov.gov instance/cluster/proxy cases, a user-defined proxy endpoint in the ISO partition, and negative cases for suffix lookalikes with appended labels and for an 'endpoint' label that is not a proxy. --- .../decorator/AwsRdsEndpoint.java | 88 +++++++++++++------ .../decorator/AwsRdsEndpointTest.java | 54 ++++++++++++ tag-conventions.yaml | 8 ++ 3 files changed, 123 insertions(+), 27 deletions(-) diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java index 38d258d8ce7..c52fc7d05fc 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java @@ -67,14 +67,15 @@ private AwsRdsEndpoint(String identifier, Type type, String region) { * endpoint. */ public static AwsRdsEndpoint parse(final CharSequence hostname) { - if (hostname == null || hostname.length() < AMAZONAWS_COM.length()) { + if (hostname == null || hostname.length() < SHORTEST_DNS_SUFFIX.length()) { return null; } String hostnameString = hostname.toString(); - // Every RDS endpoint, in any partition, ends in ".amazonaws.com" or ".amazonaws.com.cn". - // Reject everything else before touching the cache so the handful of real RDS hostnames are - // never evicted by the unbounded stream of non-AWS database hosts, and so those hosts never - // pay for the label split. + // Every RDS endpoint ends in a known AWS partition DNS suffix (commercial and GovCloud + // amazonaws.com, China amazonaws.com.cn, ISO c2s.ic.gov, ISO-B sc2s.sgov.gov). Reject + // everything else before touching the cache so the handful of real RDS hostnames are never + // evicted by the unbounded stream of non-AWS database hosts, and so those hosts never pay for + // the label split. if (!isPlausibleRdsHostname(hostnameString)) { return null; } @@ -82,14 +83,28 @@ public static AwsRdsEndpoint parse(final CharSequence hostname) { return endpoint == NOT_RDS ? null : endpoint; } - private static final String AMAZONAWS_COM = ".amazonaws.com"; - private static final String AMAZONAWS_COM_CN = ".amazonaws.com.cn"; + // AWS partition DNS suffixes that carry RDS endpoints, from the AWS SDK endpoint partition data: + // aws / aws-us-gov -> amazonaws.com + // aws-cn -> amazonaws.com.cn + // aws-iso -> c2s.ic.gov + // aws-iso-b -> sc2s.sgov.gov + private static final String[] DNS_SUFFIXES = { + ".amazonaws.com", ".amazonaws.com.cn", ".c2s.ic.gov", ".sc2s.sgov.gov" + }; + // The shortest suffix (".c2s.ic.gov"), used as a cheap length floor before any work. + private static final String SHORTEST_DNS_SUFFIX = ".c2s.ic.gov"; + + // The same suffixes ordered longest first, so doParse strips ".amazonaws.com.cn" rather than + // shortening it to ".amazonaws.com" and misreading the labels. + private static final String[] LONGEST_FIRST_DNS_SUFFIXES = { + ".amazonaws.com.cn", ".sc2s.sgov.gov", ".amazonaws.com", ".c2s.ic.gov" + }; /** - * O(1) check that the hostname ends with {@code .amazonaws.com} or {@code .amazonaws.com.cn}, - * ignoring case and tolerating a trailing {@code .} and a well-formed {@code :port} (at most five - * ASCII digits). Anchoring on the suffix (rather than scanning for the substring) also rejects - * hostnames that merely contain {@code .amazonaws.com} followed by other labels. + * O(1) check that the hostname ends with one of the known AWS partition DNS suffixes, ignoring + * case and tolerating a trailing {@code .} and a well-formed {@code :port} (at most five ASCII + * digits). Anchoring on the suffix (rather than scanning for a substring) also rejects hostnames + * that merely contain such a suffix followed by other labels. * *

This is a conservative pre-filter, not an exact mirror of {@link #doParse}. A hostname with * a malformed or oversized port (for example {@code ...amazonaws.com:54x2} or {@code :543210}) is @@ -104,8 +119,12 @@ static boolean isPlausibleRdsHostname(final String hostname) { end--; } end = stripPort(hostname, end); - return endsWithIgnoreCase(hostname, end, AMAZONAWS_COM) - || endsWithIgnoreCase(hostname, end, AMAZONAWS_COM_CN); + for (String suffix : DNS_SUFFIXES) { + if (endsWithIgnoreCase(hostname, end, suffix)) { + return true; + } + } + return false; } /** Returns the end index with a trailing {@code :port} removed. Bounded to 5 digits. */ @@ -154,11 +173,28 @@ private static AwsRdsEndpoint doParse(final String rawHostname) { if (colon >= 0) { end = Math.min(end, colon); } - String[] labels = hostname.substring(0, end).split("\\.", -1); + + // Strip the partition DNS suffix (.amazonaws.com, .amazonaws.com.cn, .c2s.ic.gov, + // .sc2s.sgov.gov) so the remaining labels are the same shape in every partition: the leading + // identifier, the account hash, the Region and the literal "rds". The suffix is matched + // longest first so .amazonaws.com.cn is not shortened to .amazonaws.com. + String prefix = null; + for (String suffix : LONGEST_FIRST_DNS_SUFFIXES) { + if (end >= suffix.length() + && hostname.regionMatches(end - suffix.length(), suffix, 0, suffix.length())) { + prefix = hostname.substring(0, end - suffix.length()); + break; + } + } + if (prefix == null) { + return NOT_RDS; + } + + String[] labels = prefix.split("\\.", -1); // A user-defined RDS Proxy endpoint carries an extra "endpoint" label: - // .endpoint.proxy-..rds.amazonaws.com. Drop that label so the proxy name - // and Region are read by the same validated branches as the default proxy endpoint. + // .endpoint.proxy-..rds. Drop that label so the proxy name and + // Region are read by the same validated branches as the default proxy endpoint. if (labels.length > 3 && "endpoint".equals(labels[1]) && labels[2].startsWith("proxy-")) { String[] normalized = new String[labels.length - 1]; normalized[0] = labels[0]; @@ -166,19 +202,17 @@ private static AwsRdsEndpoint doParse(final String rawHostname) { labels = normalized; } - // ...rds.amazonaws.com - if (labels.length == 6 - && "rds".equals(labels[3]) - && "amazonaws".equals(labels[4]) - && "com".equals(labels[5])) { + // After stripping the suffix an RDS endpoint is always four labels: the identifier, the hash, + // and the Region and the literal "rds" in either order. Commercial, GovCloud and ISO endpoints + // put the Region before "rds" (...rds), the China partition also uses + // ..rds., so accept both orders. + if (labels.length != 4) { + return NOT_RDS; + } + if ("rds".equals(labels[3])) { return build(labels[0], labels[1], labels[2]); } - // ..rds..amazonaws.com.cn - if (labels.length == 7 - && "rds".equals(labels[2]) - && "amazonaws".equals(labels[4]) - && "com".equals(labels[5]) - && "cn".equals(labels[6])) { + if ("rds".equals(labels[2])) { return build(labels[0], labels[1], labels[3]); } return NOT_RDS; diff --git a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java index 21d70a3d2fa..d85a65a1caa 100644 --- a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java +++ b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java @@ -49,6 +49,39 @@ static Stream rdsEndpoints() { "orders-db", INSTANCE, "cn-north-1"), + // China also hands out the region-before-service layout, same as the commercial partition. + Arguments.of( + "orders-db.c9akciq32bzq.cn-north-1.rds.amazonaws.com.cn", + "orders-db", + INSTANCE, + "cn-north-1"), + Arguments.of( + "orders-aurora.cluster-c9akciq32bzq.cn-northwest-1.rds.amazonaws.com.cn", + "orders-aurora", + CLUSTER, + "cn-northwest-1"), + // ISO partition (aws-iso) RDS endpoints use the c2s.ic.gov DNS suffix. + Arguments.of( + "orders-db.c9akciq32bzq.us-iso-east-1.rds.c2s.ic.gov", + "orders-db", + INSTANCE, + "us-iso-east-1"), + Arguments.of( + "orders-aurora.cluster-ro-c9akciq32bzq.us-iso-west-1.rds.c2s.ic.gov", + "orders-aurora", + CLUSTER_READER, + "us-iso-west-1"), + // ISO-B partition (aws-iso-b) RDS endpoints use the sc2s.sgov.gov DNS suffix. + Arguments.of( + "orders-db.c9akciq32bzq.us-isob-east-1.rds.sc2s.sgov.gov", + "orders-db", + INSTANCE, + "us-isob-east-1"), + Arguments.of( + "orders-proxy.proxy-c9akciq32bzq.us-isob-east-1.rds.sc2s.sgov.gov", + "orders-proxy", + PROXY, + "us-isob-east-1"), Arguments.of( "orders-aurora.cluster-c9akciq32bzq.us-west-2.rds.amazonaws.com", "orders-aurora", @@ -74,6 +107,13 @@ static Stream rdsEndpoints() { "orders-ro", PROXY, "us-east-1"), + // User-defined RDS Proxy endpoint in the ISO partition: the extra "endpoint" label is + // normalized away and the c2s.ic.gov suffix is still recognised. + Arguments.of( + "orders-ro.endpoint.proxy-c9akciq32bzq.us-iso-east-1.rds.c2s.ic.gov", + "orders-ro", + PROXY, + "us-iso-east-1"), Arguments.of( "orders-ro.endpoint.proxy-c9akciq32bzq.rds.cn-north-1.amazonaws.com.cn", "orders-ro", @@ -109,6 +149,13 @@ void parsesRdsEndpoint( "orders-db.cluster-.us-east-1.rds.amazonaws.com", // "endpoint" label only normalizes for proxy endpoints; a non-proxy third label is not RDS "orders-db.endpoint.c9akciq32bzq.us-east-1.rds.amazonaws.com", + // an "endpoint" label whose next label is not a proxy hash is not normalized, so the ISO + // host is left with an extra label and is not RDS + "orders-db.endpoint.c9akciq32bzq.us-iso-east-1.rds.c2s.ic.gov", + // ISO suffix but missing the "rds" service label: not an RDS endpoint + "orders-db.c9akciq32bzq.us-iso-east-1.c2s.ic.gov", + // ISO suffix but a malformed Region: rejected structurally + "orders-db.c9akciq32bzq.usiso.rds.c2s.ic.gov", "s3.us-east-1.amazonaws.com", "dynamodb.us-east-1.amazonaws.com", }) @@ -131,6 +178,10 @@ void cachesParsedEndpointsPerHostname() { "orders-db.c9akciq32bzq.rds.cn-north-1.amazonaws.com.cn", "orders-db.c9akciq32bzq.rds.cn-north-1.amazonaws.com.cn.", "orders-db.c9akciq32bzq.rds.cn-north-1.amazonaws.com.cn:3306", + "orders-db.c9akciq32bzq.cn-north-1.rds.amazonaws.com.cn", + "orders-db.c9akciq32bzq.us-iso-east-1.rds.c2s.ic.gov", + "orders-db.c9akciq32bzq.us-iso-east-1.rds.C2S.IC.GOV", + "orders-db.c9akciq32bzq.us-isob-east-1.rds.sc2s.sgov.gov", "ORDERS-DB.C9AKCIQ32BZQ.US-EAST-1.RDS.AMAZONAWS.COM", "orders-db.c9akciq32bzq.us-east-1.rds.AmazonAWS.com", // shaped like an AWS host, so it may reach the cache; doParse rejects it structurally @@ -151,6 +202,9 @@ void preCacheGateAccepts(String hostname) { // ".amazonaws.com" present but not the suffix: rejected before the cache "orders-db.c9akciq32bzq.us-east-1.rds.amazonaws.com.evil.example", "orders-db.c9akciq32bzq.us-east-1.rds.amazonaws.com.cn.evil.example", + // ISO suffixes present but followed by further labels: rejected before the cache + "orders-db.c9akciq32bzq.us-iso-east-1.rds.c2s.ic.gov.evil.example", + "orders-db.c9akciq32bzq.us-isob-east-1.rds.sc2s.sgov.gov.evil.example", // port must be all digits and at most five of them RDS + ":54x2", RDS + ":543210", diff --git a/tag-conventions.yaml b/tag-conventions.yaml index 3f13f0fbd49..db12f5c790c 100644 --- a/tag-conventions.yaml +++ b/tag-conventions.yaml @@ -220,6 +220,14 @@ span_types: - { dd-name: db.operation, type: string, required: recommended, otel-name: db.operation.name, span-kind-neutral: true } - { dd-name: db.user, type: string, required: recommended } - { dd-name: db.statement, type: string, required: recommended, otel-name: db.query.text, span-kind-neutral: true } + # Identity encoded in an Amazon RDS endpoint hostname. dbinstanceidentifier and + # dbclusteridentifier match the dimension tags on the aws.rds.* integration metrics so a + # database client span and the RDS integration join on the same keys. + - { dd-name: aws.region, type: string, required: optional } + - { dd-name: aws.rds.identifier, type: string, required: optional } + - { dd-name: aws.rds.endpoint_type, type: string, required: optional } + - { dd-name: dbinstanceidentifier, type: string, required: optional } + - { dd-name: dbclusteridentifier, type: string, required: optional } # A command's outcome (Redis, Memcached). - { dd-name: db.command.cancelled, type: boolean, required: optional } - { dd-name: db.command.results.count, type: int, required: optional } From 242e961db504df978f334e9923f1c729d8ed28ab Mon Sep 17 00:00:00 2001 From: Pedram Safaei <34566793+pedramsafaei@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:52:59 +0000 Subject: [PATCH 6/6] Support all AWS partition RDS DNS suffixes Recognize the published ISO-E, ISO-F, and European Sovereign Cloud DNS suffixes and test both parsing and pre-cache rejection behavior. Assisted-by: Kiro --- .../decorator/AwsRdsEndpoint.java | 34 +++++++++++-------- .../decorator/AwsRdsEndpointTest.java | 23 ++++++++++++- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java index c52fc7d05fc..789cd531698 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java @@ -71,9 +71,8 @@ public static AwsRdsEndpoint parse(final CharSequence hostname) { return null; } String hostnameString = hostname.toString(); - // Every RDS endpoint ends in a known AWS partition DNS suffix (commercial and GovCloud - // amazonaws.com, China amazonaws.com.cn, ISO c2s.ic.gov, ISO-B sc2s.sgov.gov). Reject - // everything else before touching the cache so the handful of real RDS hostnames are never + // Every RDS endpoint ends in a known AWS partition DNS suffix. Reject everything else before + // touching the cache so the handful of real RDS hostnames are never // evicted by the unbounded stream of non-AWS database hosts, and so those hosts never pay for // the label split. if (!isPlausibleRdsHostname(hostnameString)) { @@ -83,13 +82,15 @@ public static AwsRdsEndpoint parse(final CharSequence hostname) { return endpoint == NOT_RDS ? null : endpoint; } - // AWS partition DNS suffixes that carry RDS endpoints, from the AWS SDK endpoint partition data: - // aws / aws-us-gov -> amazonaws.com - // aws-cn -> amazonaws.com.cn - // aws-iso -> c2s.ic.gov - // aws-iso-b -> sc2s.sgov.gov + // AWS partition DNS suffixes that carry RDS endpoints, from the AWS SDK endpoint partition data. private static final String[] DNS_SUFFIXES = { - ".amazonaws.com", ".amazonaws.com.cn", ".c2s.ic.gov", ".sc2s.sgov.gov" + ".amazonaws.com", + ".amazonaws.com.cn", + ".c2s.ic.gov", + ".sc2s.sgov.gov", + ".cloud.adc-e.uk", + ".csp.hci.ic.gov", + ".amazonaws.eu" }; // The shortest suffix (".c2s.ic.gov"), used as a cheap length floor before any work. private static final String SHORTEST_DNS_SUFFIX = ".c2s.ic.gov"; @@ -97,7 +98,13 @@ public static AwsRdsEndpoint parse(final CharSequence hostname) { // The same suffixes ordered longest first, so doParse strips ".amazonaws.com.cn" rather than // shortening it to ".amazonaws.com" and misreading the labels. private static final String[] LONGEST_FIRST_DNS_SUFFIXES = { - ".amazonaws.com.cn", ".sc2s.sgov.gov", ".amazonaws.com", ".c2s.ic.gov" + ".amazonaws.com.cn", + ".cloud.adc-e.uk", + ".csp.hci.ic.gov", + ".sc2s.sgov.gov", + ".amazonaws.com", + ".amazonaws.eu", + ".c2s.ic.gov" }; /** @@ -174,10 +181,9 @@ private static AwsRdsEndpoint doParse(final String rawHostname) { end = Math.min(end, colon); } - // Strip the partition DNS suffix (.amazonaws.com, .amazonaws.com.cn, .c2s.ic.gov, - // .sc2s.sgov.gov) so the remaining labels are the same shape in every partition: the leading - // identifier, the account hash, the Region and the literal "rds". The suffix is matched - // longest first so .amazonaws.com.cn is not shortened to .amazonaws.com. + // Strip the partition DNS suffix so the remaining labels are the same shape in every + // partition. The suffix is matched longest first so one suffix cannot be mistaken for a + // shorter suffix. String prefix = null; for (String suffix : LONGEST_FIRST_DNS_SUFFIXES) { if (end >= suffix.length() diff --git a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java index d85a65a1caa..d429d795c2e 100644 --- a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java +++ b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java @@ -40,10 +40,25 @@ static Stream rdsEndpoints() { INSTANCE, "us-gov-west-1"), Arguments.of( - "orders-db.c9akciq32bzq.eu-isoe-west-1.rds.amazonaws.com", + "orders-db.c9akciq32bzq.eu-isoe-west-1.rds.cloud.adc-e.uk", "orders-db", INSTANCE, "eu-isoe-west-1"), + Arguments.of( + "orders-db.c9akciq32bzq.us-isof-east-1.rds.csp.hci.ic.gov", + "orders-db", + INSTANCE, + "us-isof-east-1"), + Arguments.of( + "orders-proxy.proxy-c9akciq32bzq.us-isof-south-1.rds.csp.hci.ic.gov", + "orders-proxy", + PROXY, + "us-isof-south-1"), + Arguments.of( + "orders-db.c9akciq32bzq.eusc-de-east-1.rds.amazonaws.eu", + "orders-db", + INSTANCE, + "eusc-de-east-1"), Arguments.of( "orders-db.c9akciq32bzq.rds.cn-north-1.amazonaws.com.cn", "orders-db", @@ -182,6 +197,9 @@ void cachesParsedEndpointsPerHostname() { "orders-db.c9akciq32bzq.us-iso-east-1.rds.c2s.ic.gov", "orders-db.c9akciq32bzq.us-iso-east-1.rds.C2S.IC.GOV", "orders-db.c9akciq32bzq.us-isob-east-1.rds.sc2s.sgov.gov", + "orders-db.c9akciq32bzq.eu-isoe-west-1.rds.cloud.adc-e.uk", + "orders-db.c9akciq32bzq.us-isof-east-1.rds.csp.hci.ic.gov", + "orders-db.c9akciq32bzq.eusc-de-east-1.rds.amazonaws.eu", "ORDERS-DB.C9AKCIQ32BZQ.US-EAST-1.RDS.AMAZONAWS.COM", "orders-db.c9akciq32bzq.us-east-1.rds.AmazonAWS.com", // shaped like an AWS host, so it may reach the cache; doParse rejects it structurally @@ -205,6 +223,9 @@ void preCacheGateAccepts(String hostname) { // ISO suffixes present but followed by further labels: rejected before the cache "orders-db.c9akciq32bzq.us-iso-east-1.rds.c2s.ic.gov.evil.example", "orders-db.c9akciq32bzq.us-isob-east-1.rds.sc2s.sgov.gov.evil.example", + "orders-db.c9akciq32bzq.eu-isoe-west-1.rds.cloud.adc-e.uk.evil.example", + "orders-db.c9akciq32bzq.us-isof-east-1.rds.csp.hci.ic.gov.evil.example", + "orders-db.c9akciq32bzq.eusc-de-east-1.rds.amazonaws.eu.evil.example", // port must be all digits and at most five of them RDS + ":54x2", RDS + ":543210",