Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve detection of resource attributes on ECS #4574

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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,183 @@ 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();
fetchMetadata(httpClient, ecsMetadataUrl, attrBuilders);
// For TaskARN, Family, Revision.
// May put the same attribute twice but that shouldn't matter.
fetchMetadata(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 fetchMetadata(
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"),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change "24" to "26" for consistency.

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);
}
}