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..e56c3ae9b96 --- /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() < AMAZONAWS_COM.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. + 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";