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 }