Skip to content

Commit

Permalink
Improve detection of resource attributes on ECS
Browse files Browse the repository at this point in the history
This improves the detection of resource attributes on ECS by fetching ECS metadata from `ECS_CONTAINER_METADATA_URI` or `ECS_CONTAINER_METADATA_URI_V4`.

Previously only `CONTAINER_NAME` and `CONTAINER_ID` id were set.

Now we set:

- CONTAINER_ID
- CONTAINER_NAME
- AWS_ECS_CONTAINER_ARN
- CONTAINER_IMAGE_NAME
- CONTAINER_IMAGE_TAG
- aws.ecs.container.image.id
- AWS_LOG_GROUP_ARNS
- AWS_LOG_GROUP_NAMES
- AWS_LOG_STREAM_NAMES
- AWS_ECS_TASK_ARN
- AWS_ECS_TASK_FAMILY
- AWS_ECS_TASK_REVISION

Especially AWS_LOG_GROUP_ARNS is important so that connection of traces to logs works OOTB on X-Ray.
  • Loading branch information
felixscheinost committed Jun 30, 2022
1 parent 0860b38 commit e9ebf1e
Show file tree
Hide file tree
Showing 6 changed files with 498 additions and 43 deletions.
Expand Up @@ -5,23 +5,29 @@

package io.opentelemetry.sdk.extension.aws.resource;

import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.common.AttributesBuilder;
import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.semconv.resource.attributes.ResourceAttributes;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.io.IOException;
import java.util.Collections;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nullable;

/**
* A factory for a {@link Resource} which provides information about the current ECS container if
* running on AWS ECS.
*/
public final class EcsResource {
private static final Logger logger = Logger.getLogger(EcsResource.class.getName());

private static final JsonFactory JSON_FACTORY = new JsonFactory();
private static final String ECS_METADATA_KEY_V4 = "ECS_CONTAINER_METADATA_URI_V4";
private static final String ECS_METADATA_KEY_V3 = "ECS_CONTAINER_METADATA_URI";

Expand All @@ -36,38 +42,184 @@ public static Resource get() {
}

private static Resource buildResource() {
return buildResource(System.getenv(), new DockerHelper());
return buildResource(System.getenv(), new SimpleHttpClient());
}

// Visible for testing
static Resource buildResource(Map<String, String> sysEnv, DockerHelper dockerHelper) {
if (!isOnEcs(sysEnv)) {
return Resource.empty();
static Resource buildResource(Map<String, String> sysEnv, SimpleHttpClient httpClient) {
// Note: If V4 is set V3 is set as well, so check V4 first.
String ecsMetadataUrl =
sysEnv.getOrDefault(ECS_METADATA_KEY_V4, sysEnv.getOrDefault(ECS_METADATA_KEY_V3, ""));
if (!ecsMetadataUrl.isEmpty()) {
AttributesBuilder attrBuilders = Attributes.builder();
parseUrl(httpClient, ecsMetadataUrl, attrBuilders);
// For TaskARN, Family, Revision.
// May put the same attribute twice but that shouldn't matter.
parseUrl(httpClient, ecsMetadataUrl + "/task", attrBuilders);
return Resource.create(attrBuilders.build(), ResourceAttributes.SCHEMA_URL);
}
// Not running on ECS
return Resource.empty();
}

AttributesBuilder attrBuilders = Attributes.builder();
static void parseUrl(SimpleHttpClient httpClient, String url, AttributesBuilder attrBuilders) {
String json = httpClient.fetchString("GET", url, Collections.emptyMap(), null);
if (json.isEmpty()) {
return;
}
attrBuilders.put(ResourceAttributes.CLOUD_PROVIDER, ResourceAttributes.CloudProviderValues.AWS);
attrBuilders.put(
ResourceAttributes.CLOUD_PLATFORM, ResourceAttributes.CloudPlatformValues.AWS_ECS);
try {
String hostName = InetAddress.getLocalHost().getHostName();
attrBuilders.put(ResourceAttributes.CONTAINER_NAME, hostName);
} catch (UnknownHostException e) {
logger.log(Level.WARNING, "Could not get docker container name from hostname.", e);
try (JsonParser parser = JSON_FACTORY.createParser(json)) {
parser.nextToken();
LogGroupArnBuilder logGroupArnBuilder = new LogGroupArnBuilder();
parseResponse(parser, attrBuilders, logGroupArnBuilder);
logGroupArnBuilder.putLogGroupArnInAttributesBuilder(attrBuilders);
} catch (IOException e) {
logger.log(Level.WARNING, "Can't get ECS metadata", e);
}
}

String containerId = dockerHelper.getContainerId();
if (containerId != null && !containerId.isEmpty()) {
attrBuilders.put(ResourceAttributes.CONTAINER_ID, containerId);
static void parseResponse(
JsonParser parser, AttributesBuilder attrBuilders, LogGroupArnBuilder logGroupArnBuilder)
throws IOException {
if (!parser.isExpectedStartObjectToken()) {
logger.log(Level.WARNING, "Couldn't parse ECS metadata, invalid JSON");
return;
}
while (parser.nextToken() != JsonToken.END_OBJECT) {
String value = parser.nextTextValue();
switch (parser.getCurrentName()) {
case "DockerId":
attrBuilders.put(ResourceAttributes.CONTAINER_ID, value);
break;
case "DockerName":
attrBuilders.put(ResourceAttributes.CONTAINER_NAME, value);
break;
case "ContainerARN":
attrBuilders.put(ResourceAttributes.AWS_ECS_CONTAINER_ARN, value);
logGroupArnBuilder.setContainerArn(value);
break;
case "Image":
DockerImage parsedImage = DockerImage.parse(value);
if (parsedImage != null) {
attrBuilders.put(ResourceAttributes.CONTAINER_IMAGE_NAME, parsedImage.getRepository());
attrBuilders.put(ResourceAttributes.CONTAINER_IMAGE_TAG, parsedImage.getTag());
}
break;
case "ImageID":
attrBuilders.put("aws.ecs.container.image.id", value);
break;
case "LogOptions":
// Recursively parse LogOptions
parseResponse(parser, attrBuilders, logGroupArnBuilder);
break;
case "awslogs-group":
attrBuilders.put(ResourceAttributes.AWS_LOG_GROUP_NAMES, value);
logGroupArnBuilder.setLogGroupName(value);
break;
case "awslogs-stream":
attrBuilders.put(ResourceAttributes.AWS_LOG_STREAM_NAMES, value);
break;
case "awslogs-region":
logGroupArnBuilder.setRegion(value);
break;
case "TaskARN":
attrBuilders.put(ResourceAttributes.AWS_ECS_TASK_ARN, value);
break;
case "Family":
attrBuilders.put(ResourceAttributes.AWS_ECS_TASK_FAMILY, value);
break;
case "Revision":
attrBuilders.put(ResourceAttributes.AWS_ECS_TASK_REVISION, value);
break;
default:
parser.skipChildren();
break;
}
}

return Resource.create(attrBuilders.build(), ResourceAttributes.SCHEMA_URL);
}

private static boolean isOnEcs(Map<String, String> sysEnv) {
return !sysEnv.getOrDefault(ECS_METADATA_KEY_V3, "").isEmpty()
|| !sysEnv.getOrDefault(ECS_METADATA_KEY_V4, "").isEmpty();
private EcsResource() {}

/**
* This builder can piece together a log group ARN from region, account and group name as the ARN
* isn't part of the ECS metadata.
*
* <p>If we just set AWS_LOG_GROUP_NAMES then the CloudWatch X-Ray traces view displays "An error
* occurred fetching your data". That's why it's important we set the ARN.
*/
private static class LogGroupArnBuilder {

@Nullable String region;
@Nullable String account;
@Nullable String logGroupName;

void setRegion(@Nullable String region) {
this.region = region;
}

void setLogGroupName(@Nullable String logGroupName) {
this.logGroupName = logGroupName;
}

void setContainerArn(@Nullable String containerArn) {
if (containerArn != null) {
account = containerArn.split(":")[4];
}
}

void putLogGroupArnInAttributesBuilder(AttributesBuilder attributesBuilder) {
if (region == null || account == null || logGroupName == null) {
return;
}
attributesBuilder.put(
ResourceAttributes.AWS_LOG_GROUP_ARNS,
"arn:aws:logs:" + region + ":" + account + ":log-group:" + logGroupName);
}
}

private EcsResource() {}
/**
* This can parse a Docker image name into its parts: repository, tag and sha256.
*/
private static class DockerImage {

private static final Pattern imagePattern =
Pattern.compile(
"^(?<repository>([^/\\s]+/)?([^:\\s]+))(:(?<tag>[^@\\s]+))?(@sha256:(?<sha256>\\d+))?$");

final String repository;
final String tag;

private DockerImage(String repository, String tag) {
this.repository = repository;
this.tag = tag;
}

String getRepository() {
return repository;
}

String getTag() {
return tag;
}

@Nullable
static DockerImage parse(@Nullable String image) {
if (image == null || image.isEmpty()) {
return null;
}
Matcher matcher = imagePattern.matcher(image);
if (!matcher.matches()) {
logger.log(Level.WARNING, "Couldn't parse image '" + image + "'");
return null;
}
String repository = matcher.group("repository");
String tag = matcher.group("tag");
if (tag == null || tag.isEmpty()) {
tag = "latest";
}
return new DockerImage(repository, tag);
}
}
}
Expand Up @@ -9,12 +9,15 @@
import static org.assertj.core.api.Assertions.entry;
import static org.mockito.Mockito.when;

import com.google.common.io.Resources;
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.sdk.autoconfigure.spi.ResourceProvider;
import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.semconv.resource.attributes.ResourceAttributes;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.ServiceLoader;
Expand All @@ -28,45 +31,97 @@ class EcsResourceTest {
private static final String ECS_METADATA_KEY_V4 = "ECS_CONTAINER_METADATA_URI_V4";
private static final String ECS_METADATA_KEY_V3 = "ECS_CONTAINER_METADATA_URI";

@Mock private DockerHelper mockDockerHelper;
@Mock private SimpleHttpClient mockHttpClient;

@Test
void testCreateAttributes() throws UnknownHostException {
when(mockDockerHelper.getContainerId()).thenReturn("0123456789A");
void testCreateAttributesV3() throws IOException {
Map<String, String> mockSysEnv = new HashMap<>();
mockSysEnv.put(ECS_METADATA_KEY_V3, "ecs_metadata_v3_uri");
Resource resource = EcsResource.buildResource(mockSysEnv, mockDockerHelper);
when(mockHttpClient.fetchString("GET", "ecs_metadata_v3_uri", Collections.emptyMap(), null))
.thenReturn(readResourceJson("ecs-container-metadata-v3.json"));
when(mockHttpClient.fetchString(
"GET", "ecs_metadata_v3_uri/task", Collections.emptyMap(), null))
.thenReturn(readResourceJson("ecs-task-metadata-v3.json"));

Resource resource = EcsResource.buildResource(mockSysEnv, mockHttpClient);
Attributes attributes = resource.getAttributes();

assertThat(resource.getSchemaUrl()).isEqualTo(ResourceAttributes.SCHEMA_URL);
assertThat(attributes)
.containsOnly(
entry(ResourceAttributes.CLOUD_PROVIDER, "aws"),
entry(ResourceAttributes.CLOUD_PLATFORM, "aws_ecs"),
entry(ResourceAttributes.CONTAINER_NAME, InetAddress.getLocalHost().getHostName()),
entry(ResourceAttributes.CONTAINER_ID, "0123456789A"));
}

@Test
void testNotOnEcs() {
Map<String, String> mockSysEnv = new HashMap<>();
mockSysEnv.put(ECS_METADATA_KEY_V3, "");
mockSysEnv.put(ECS_METADATA_KEY_V4, "");
Attributes attributes = EcsResource.buildResource(mockSysEnv, mockDockerHelper).getAttributes();
assertThat(attributes).isEmpty();
entry(ResourceAttributes.CONTAINER_NAME, "ecs-nginx-5-nginx-curl-ccccb9f49db0dfe0d901"),
entry(
ResourceAttributes.CONTAINER_ID,
"43481a6ce4842eec8fe72fc28500c6b52edcc0917f105b83379f88cac1ff3946"),
entry(ResourceAttributes.CONTAINER_IMAGE_NAME, "nrdlngr/nginx-curl"),
entry(ResourceAttributes.CONTAINER_IMAGE_TAG, "latest"),
entry(
AttributeKey.stringKey("aws.ecs.container.image.id"),
"sha256:2e00ae64383cfc865ba0a2ba37f61b50a120d2d9378559dcd458dc0de47bc165"),
entry(
ResourceAttributes.AWS_ECS_TASK_ARN,
"arn:aws:ecs:us-east-2:012345678910:task/9781c248-0edd-4cdb-9a93-f63cb662a5d3"),
entry(ResourceAttributes.AWS_ECS_TASK_FAMILY, "nginx"),
entry(ResourceAttributes.AWS_ECS_TASK_REVISION, "5"));
}

@Test
void testContainerIdMissing() throws UnknownHostException {
when(mockDockerHelper.getContainerId()).thenReturn("");
void testCreateAttributesV4() throws IOException {
Map<String, String> mockSysEnv = new HashMap<>();
mockSysEnv.put(ECS_METADATA_KEY_V4, "ecs_metadata_v4_uri");
Attributes attributes = EcsResource.buildResource(mockSysEnv, mockDockerHelper).getAttributes();
when(mockHttpClient.fetchString("GET", "ecs_metadata_v4_uri", Collections.emptyMap(), null))
.thenReturn(readResourceJson("ecs-container-metadata-v4.json"));
when(mockHttpClient.fetchString(
"GET", "ecs_metadata_v4_uri/task", Collections.emptyMap(), null))
.thenReturn(readResourceJson("ecs-task-metadata-v4.json"));

Resource resource = EcsResource.buildResource(mockSysEnv, mockHttpClient);
Attributes attributes = resource.getAttributes();

assertThat(resource.getSchemaUrl()).isEqualTo(ResourceAttributes.SCHEMA_URL);
assertThat(attributes)
.containsOnly(
entry(ResourceAttributes.CLOUD_PROVIDER, "aws"),
entry(ResourceAttributes.CLOUD_PLATFORM, "aws_ecs"),
entry(ResourceAttributes.CONTAINER_NAME, InetAddress.getLocalHost().getHostName()));
entry(ResourceAttributes.CONTAINER_NAME, "ecs-curltest-24-curl-cca48e8dcadd97805600"),
entry(
ResourceAttributes.CONTAINER_ID,
"ea32192c8553fbff06c9340478a2ff089b2bb5646fb718b4ee206641c9086d66"),
entry(
ResourceAttributes.CONTAINER_IMAGE_NAME,
"111122223333.dkr.ecr.us-west-2.amazonaws.com/curltest"),
entry(ResourceAttributes.CONTAINER_IMAGE_TAG, "latest"),
entry(
AttributeKey.stringKey("aws.ecs.container.image.id"),
"sha256:d691691e9652791a60114e67b365688d20d19940dde7c4736ea30e660d8d3553"),
entry(
ResourceAttributes.AWS_ECS_CONTAINER_ARN,
"arn:aws:ecs:us-west-2:111122223333:container/0206b271-b33f-47ab-86c6-a0ba208a70a9"),
entry(
ResourceAttributes.AWS_LOG_GROUP_NAMES, Collections.singletonList("/ecs/metadata")),
entry(
ResourceAttributes.AWS_LOG_GROUP_ARNS,
Collections.singletonList(
"arn:aws:logs:us-west-2:111122223333:log-group:/ecs/metadata")),
entry(
ResourceAttributes.AWS_LOG_STREAM_NAMES,
Collections.singletonList("ecs/curl/8f03e41243824aea923aca126495f665")),
entry(
ResourceAttributes.AWS_ECS_TASK_ARN,
"arn:aws:ecs:us-west-2:111122223333:task/default/158d1c8083dd49d6b527399fd6414f5c"),
entry(ResourceAttributes.AWS_ECS_TASK_FAMILY, "curltest"),
entry(ResourceAttributes.AWS_ECS_TASK_REVISION, "26"));
}

@Test
void testNotOnEcs() {
Map<String, String> mockSysEnv = new HashMap<>();
mockSysEnv.put(ECS_METADATA_KEY_V3, "");
mockSysEnv.put(ECS_METADATA_KEY_V4, "");
Attributes attributes = EcsResource.buildResource(mockSysEnv, mockHttpClient).getAttributes();
assertThat(attributes).isEmpty();
}

@Test
Expand All @@ -76,4 +131,8 @@ void inServiceLoader() {
assertThat(ServiceLoader.load(ResourceProvider.class))
.anyMatch(EcsResourceProvider.class::isInstance);
}

String readResourceJson(String resourceName) throws IOException {
return Resources.toString(Resources.getResource(resourceName), StandardCharsets.UTF_8);
}
}

0 comments on commit e9ebf1e

Please sign in to comment.