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..789cd531698
--- /dev/null
+++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpoint.java
@@ -0,0 +1,288 @@
+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}, or a user-defined endpoint
+ * {@code .endpoint.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() < SHORTEST_DNS_SUFFIX.length()) {
+ return null;
+ }
+ String hostnameString = hostname.toString();
+ // 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)) {
+ return null;
+ }
+ AwsRdsEndpoint endpoint = CACHE.computeIfAbsent(hostnameString, AwsRdsEndpoint::doParse);
+ return endpoint == NOT_RDS ? null : endpoint;
+ }
+
+ // 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",
+ ".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";
+
+ // 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",
+ ".cloud.adc-e.uk",
+ ".csp.hci.ic.gov",
+ ".sc2s.sgov.gov",
+ ".amazonaws.com",
+ ".amazonaws.eu",
+ ".c2s.ic.gov"
+ };
+
+ /**
+ * 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
+ * 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();
+ if (end > 0 && hostname.charAt(end - 1) == '.') {
+ end--;
+ }
+ end = stripPort(hostname, end);
+ 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. */
+ 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);
+ }
+
+ // 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()
+ && 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. 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;
+ }
+
+ // 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]);
+ }
+ if ("rds".equals(labels[2])) {
+ 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 b81b4fb91f9..1264c46e44c 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 b394d605811..212560f48ed 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
@@ -1,5 +1,10 @@
package datadog.trace.bootstrap.instrumentation.decorator
+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.appsec.api.blocking.BlockingContentType
import datadog.appsec.api.blocking.BlockingException
import datadog.trace.api.config.AppSecConfig
@@ -118,6 +123,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..d429d795c2e
--- /dev/null
+++ b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/instrumentation/decorator/AwsRdsEndpointTest.java
@@ -0,0 +1,246 @@
+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.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",
+ 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",
+ 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"),
+ Arguments.of(
+ "orders-ro.endpoint.proxy-c9akciq32bzq.us-east-1.rds.amazonaws.com",
+ "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",
+ PROXY,
+ "cn-north-1"));
+ }
+
+ @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",
+ // "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",
+ })
+ 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.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.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
+ "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",
+ // 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",
+ })
+ 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 1971a551f88..d95676aa28a 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";
+
// Complete resource identity carried by the request.
public static final String AWS_TOPIC_ARN = "aws.topic.arn";
public static final String AWS_STREAM_ARN = "aws.stream.arn";
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 }