Skip to content

Commit

Permalink
Improve detection of resource attributes on ECS (#4574)
Browse files Browse the repository at this point in the history
* Improve detection of resource attributes on ECS

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.

* Change „24“ to „26“ in fixtures for consistency

`CONTAINER_NAME` and `AWS_ECS_TASK_REVISION` should match

* Implement a few more attributes, fix ARNs

This commit adds implementations for the `aws.ecs.launchtype`
and `aws.logs.stream.arns` attributes, as well as fixing
the generation of log group ARNs.

* EcsResource: Record log group without trailing :*

Both with and without trailing `:*` are valid formats but there is a bug in the OpenTelementry collector which can’t handle the trailing `:*` (for now) (see open-telemetry/opentelemetry-collector-contrib#13702)

So remove addition of the trailing `:*` for now.

Co-authored-by: Michele Mancioppi <michelem@lumigo.io>
  • Loading branch information
felixscheinost and Michele Mancioppi committed Sep 8, 2022
1 parent 66285e2 commit b5ef538
Show file tree
Hide file tree
Showing 6 changed files with 543 additions and 42 deletions.
Expand Up @@ -5,23 +5,30 @@

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.Optional;
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 +43,224 @@ 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();
LogArnBuilder logArnBuilder = new LogArnBuilder();
parseResponse(parser, attrBuilders, logArnBuilder);

logArnBuilder
.getLogGroupArn()
.ifPresent(
logGroupArn -> {
attrBuilders.put(
ResourceAttributes.AWS_LOG_GROUP_ARNS, Collections.singletonList(logGroupArn));
});

logArnBuilder
.getLogStreamArn()
.ifPresent(
logStreamArn -> {
attrBuilders.put(
ResourceAttributes.AWS_LOG_STREAM_ARNS,
Collections.singletonList(logStreamArn));
});
} 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, LogArnBuilder logArnBuilder)
throws IOException {
if (!parser.isExpectedStartObjectToken()) {
logger.log(Level.WARNING, "Couldn't parse ECS metadata, invalid JSON");
return;
}

return Resource.create(attrBuilders.build(), ResourceAttributes.SCHEMA_URL);
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);
logArnBuilder.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, logArnBuilder);
break;
case "awslogs-group":
attrBuilders.put(ResourceAttributes.AWS_LOG_GROUP_NAMES, value);
logArnBuilder.setLogGroupName(value);
break;
case "awslogs-stream":
attrBuilders.put(ResourceAttributes.AWS_LOG_STREAM_NAMES, value);
logArnBuilder.setLogStreamName(value);
break;
case "awslogs-region":
logArnBuilder.setRegion(value);
break;
case "TaskARN":
attrBuilders.put(ResourceAttributes.AWS_ECS_TASK_ARN, value);
break;
case "LaunchType":
attrBuilders.put(ResourceAttributes.AWS_ECS_LAUNCHTYPE, value.toLowerCase());
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;
}
}
}

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 the ARN of a log group or a log stream from region, account,
* group name and stream 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 LogArnBuilder {

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

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

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

void setLogStreamName(@Nullable String logStreamName) {
this.logStreamName = logStreamName;
}

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

Optional<String> getLogGroupArn() {
if (region == null || account == null || logGroupName == null) {
return Optional.empty();
}

return Optional.of("arn:aws:logs:" + region + ":" + account + ":log-group:" + logGroupName);
}

Optional<String> getLogStreamArn() {
if (region == null || account == null || logGroupName == null || logStreamName == null) {
return Optional.empty();
}

return Optional.of(
"arn:aws:logs:"
+ region
+ ":"
+ account
+ ":log-group:"
+ logGroupName
+ ":log-stream:"
+ logStreamName);
}
}

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

0 comments on commit b5ef538

Please sign in to comment.