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

Implement aws.ecs.* resource attributes #2626

Merged
merged 11 commits into from Nov 23, 2022
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

### Added

- Implemented retrieving the [`aws.ecs.*` resource attributes](https://opentelemetry.io/docs/reference/specification/resource/semantic_conventions/cloud_provider/aws/ecs/) in `go.opentelemetry.io/detectors/aws/ecs` based on the ECS Metadata v4 endpoint.
mmanciop marked this conversation as resolved.
Show resolved Hide resolved
- The `WithLogger` option to `go.opentelemetry.io/contrib/samplers/jaegerremote` to allow users to pass a `logr.Logger` and have operations logged. (#2566)
- Add the `messaging.url` & `messaging.system` attributes to all appropriate SQS operations in the `go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws` package. (#2879)
- Add example use of the metrics signal to `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp/example`. (#2610)
Expand Down
102 changes: 95 additions & 7 deletions detectors/aws/ecs/ecs.go
Expand Up @@ -17,9 +17,15 @@ package ecs // import "go.opentelemetry.io/contrib/detectors/aws/ecs"
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"regexp"
"runtime"
"strings"

ecsmetadata "github.com/brunoscheufler/aws-ecs-metadata-go"
Copy link
Member

Choose a reason for hiding this comment

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

This repository has 3 stars and 14 commits. I wonder how reliable it can be in the future.
It's also fairly simple. So we can also extract its content and bring it back into this package later on if necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was committing fixed to that repo. The maintainer was nice and responsive. If worse comes to worse, can always be incorporated here.


"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.12.0"
Expand All @@ -35,10 +41,10 @@ const (
)

var (
empty = resource.Empty()
errCannotReadContainerID = errors.New("failed to read container ID from cGroupFile")
errCannotReadContainerName = errors.New("failed to read hostname")
errCannotReadCGroupFile = errors.New("ECS resource detector failed to read cGroupFile")
empty = resource.Empty()
errCannotReadContainerName = errors.New("failed to read hostname")
errCannotRetrieveLogsGroupMetadataV4 = errors.New("the ECS Metadata v4 did not return a AwsLogGroup name")
errCannotRetrieveLogsStreamMetadataV4 = errors.New("the ECS Metadata v4 did not return a AwsLogStream name")
)

// Create interface for methods needing to be mocked.
Expand All @@ -63,7 +69,9 @@ var _ resource.Detector = (*resourceDetector)(nil)

// NewResourceDetector returns a resource detector that will detect AWS ECS resources.
func NewResourceDetector() resource.Detector {
return &resourceDetector{utils: ecsDetectorUtils{}}
return &resourceDetector{
utils: ecsDetectorUtils{},
}
}

// Detect finds associated resources when running on ECS environment.
Expand All @@ -89,22 +97,102 @@ func (detector *resourceDetector) Detect(ctx context.Context) (*resource.Resourc
semconv.ContainerIDKey.String(containerID),
}

if len(metadataURIV4) > 0 {
containerMetadata, err := ecsmetadata.GetContainerV4(ctx, &http.Client{})
if err != nil {
return empty, err
}
attributes = append(
attributes,
semconv.AWSECSContainerARNKey.String(containerMetadata.ContainerARN),
)

taskMetadata, err := ecsmetadata.GetTaskV4(ctx, &http.Client{})
if err != nil {
return empty, err
}

clusterArn := taskMetadata.Cluster
if !strings.HasPrefix(clusterArn, "arn:") {
baseArn := containerMetadata.ContainerARN[:strings.LastIndex(containerMetadata.ContainerARN, ":")]
clusterArn = fmt.Sprintf("%s:cluster/%s", baseArn, clusterArn)
}

logAttributes, err := detector.getLogsAttributes(containerMetadata)
if err != nil {
return empty, err
}

if len(logAttributes) > 0 {
attributes = append(attributes, logAttributes...)
}

attributes = append(
attributes,
semconv.AWSECSClusterARNKey.String(clusterArn),
semconv.AWSECSLaunchtypeKey.String(strings.ToLower(taskMetadata.LaunchType)),
mmanciop marked this conversation as resolved.
Show resolved Hide resolved
semconv.AWSECSTaskARNKey.String(taskMetadata.TaskARN),
semconv.AWSECSTaskFamilyKey.String(taskMetadata.Family),
semconv.AWSECSTaskRevisionKey.String(taskMetadata.Revision),
)
}

return resource.NewWithAttributes(semconv.SchemaURL, attributes...), nil
}

func (detector *resourceDetector) getLogsAttributes(metadata *ecsmetadata.ContainerMetadataV4) ([]attribute.KeyValue, error) {
if metadata.LogDriver != "awslogs" {
return []attribute.KeyValue{}, nil
}

logsOptions := metadata.LogOptions

if len(logsOptions.AwsLogsGroup) < 1 {
return nil, errCannotRetrieveLogsGroupMetadataV4
}

if len(logsOptions.AwsLogsStream) < 1 {
return nil, errCannotRetrieveLogsStreamMetadataV4
}

containerArn := metadata.ContainerARN
logsRegion := logsOptions.AwsRegion
if len(logsRegion) < 1 {
r := regexp.MustCompile(`arn:aws:ecs:([^:]+):.*`)
mmanciop marked this conversation as resolved.
Show resolved Hide resolved
logsRegion = r.FindStringSubmatch(containerArn)[1]
}

r := regexp.MustCompile(`arn:aws:ecs:[^:]+:([^:]+):.*`)
mmanciop marked this conversation as resolved.
Show resolved Hide resolved
awsAccount := r.FindStringSubmatch(containerArn)[1]

return []attribute.KeyValue{
semconv.AWSLogGroupNamesKey.String(logsOptions.AwsLogsGroup),
semconv.AWSLogGroupARNsKey.String(fmt.Sprintf("arn:aws:logs:%s:%s:log-group:%s:*", logsRegion, awsAccount, logsOptions.AwsLogsGroup)),
semconv.AWSLogStreamNamesKey.String(logsOptions.AwsLogsStream),
semconv.AWSLogStreamARNsKey.String(fmt.Sprintf("arn:aws:logs:%s:%s:log-group:%s:log-stream:%s", logsRegion, awsAccount, logsOptions.AwsLogsGroup, logsOptions.AwsLogsStream)),
Comment on lines +169 to +172
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
semconv.AWSLogGroupNamesKey.String(logsOptions.AwsLogsGroup),
semconv.AWSLogGroupARNsKey.String(fmt.Sprintf("arn:aws:logs:%s:%s:log-group:%s:*", logsRegion, awsAccount, logsOptions.AwsLogsGroup)),
semconv.AWSLogStreamNamesKey.String(logsOptions.AwsLogsStream),
semconv.AWSLogStreamARNsKey.String(fmt.Sprintf("arn:aws:logs:%s:%s:log-group:%s:log-stream:%s", logsRegion, awsAccount, logsOptions.AwsLogsGroup, logsOptions.AwsLogsStream)),
semconv.AWSLogGroupARNsKey.StringSlice([]string{fmt.Sprintf("arn:aws:logs:%s:%s:log-group:%s:*", logsRegion, awsAccount, logsOptions.AwsLogsGroup)}),
semconv.AWSLogStreamARNsKey.StringSlice([]string{fmt.Sprintf("arn:aws:logs:%s:%s:log-group:%s:log-stream:%s", logsRegion, awsAccount, logsOptions.AwsLogsGroup, logsOptions.AwsLogsStream)}),

These are duplicative, so I'd suggest only including the ARN as the name can be extracted from it.

The values are expected to be string[].

Copy link
Contributor Author

Choose a reason for hiding this comment

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

String slices introduced in 4e857dd. I am not sold on skipping attributes though, see #2626 (comment).

}, nil
}

// returns docker container ID from default c group path.
func (ecsUtils ecsDetectorUtils) getContainerID() (string, error) {
if runtime.GOOS != "linux" {
// Cgroups are used only under Linux.
return "", nil
}

fileData, err := os.ReadFile(defaultCgroupPath)
if err != nil {
return "", errCannotReadCGroupFile
// Cgroups file not found.
// For example, windows; or when running integration tests outside of a container.
return "", nil
}
splitData := strings.Split(strings.TrimSpace(string(fileData)), "\n")
for _, str := range splitData {
if len(str) > containerIDLength {
return str[len(str)-containerIDLength:], nil
}
}
return "", errCannotReadContainerID
return "", nil
}

// returns host name reported by the kernel.
Expand Down
28 changes: 17 additions & 11 deletions detectors/aws/ecs/ecs_test.go
Expand Up @@ -19,12 +19,12 @@ import (
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"

"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.12.0"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

// Create interface for functions that need to be mocked.
Expand All @@ -42,11 +42,11 @@ func (detectorUtils *MockDetectorUtils) getContainerName() (string, error) {
return args.String(0), args.Error(1)
}

// successfully return resource when process is running on Amazon ECS environment.
func TestDetect(t *testing.T) {
// successfully returns resource when process is running on Amazon ECS environment
// with no Metadata v4.
func TestDetectV3(t *testing.T) {
os.Clearenv()
_ = os.Setenv(metadataV3EnvVar, "3")
_ = os.Setenv(metadataV4EnvVar, "4")

detectorUtils := new(MockDetectorUtils)

Expand All @@ -63,24 +63,30 @@ func TestDetect(t *testing.T) {
detector := &resourceDetector{utils: detectorUtils}
res, _ := detector.Detect(context.Background())

assert.Equal(t, res, expectedResource, "Resource returned is incorrect")
assert.Equal(t, expectedResource, res, "Resource returned is incorrect")
}

// returns empty resource when detector cannot read container ID.
func TestDetectCannotReadContainerID(t *testing.T) {
os.Clearenv()
_ = os.Setenv(metadataV3EnvVar, "3")
_ = os.Setenv(metadataV4EnvVar, "4")
detectorUtils := new(MockDetectorUtils)

detectorUtils.On("getContainerName").Return("container-Name", nil)
detectorUtils.On("getContainerID").Return("", errCannotReadContainerID)
detectorUtils.On("getContainerID").Return("", nil)

attributes := []attribute.KeyValue{
semconv.CloudProviderAWS,
semconv.CloudPlatformAWSECS,
semconv.ContainerNameKey.String("container-Name"),
semconv.ContainerIDKey.String(""),
}
expectedResource := resource.NewWithAttributes(semconv.SchemaURL, attributes...)
detector := &resourceDetector{utils: detectorUtils}
res, err := detector.Detect(context.Background())

assert.Equal(t, errCannotReadContainerID, err)
assert.Equal(t, 0, len(res.Attributes()))
assert.Equal(t, nil, err)
assert.Equal(t, expectedResource, res, "Resource returned is incorrect")
}

// returns empty resource when detector cannot read container Name.
Expand Down
1 change: 1 addition & 0 deletions detectors/aws/ecs/go.mod
Expand Up @@ -3,6 +3,7 @@ module go.opentelemetry.io/contrib/detectors/aws/ecs
go 1.18

require (
github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20220812150832-b6b31c6eeeaf
github.com/stretchr/testify v1.8.1
go.opentelemetry.io/otel v1.11.1
go.opentelemetry.io/otel/sdk v1.11.1
Expand Down
2 changes: 2 additions & 0 deletions detectors/aws/ecs/go.sum
@@ -1,3 +1,5 @@
github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20220812150832-b6b31c6eeeaf h1:WCnJxXZXx9c8gwz598wvdqmu+YTzB9wx2X1OovK3Le8=
github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20220812150832-b6b31c6eeeaf/go.mod h1:CeKhh8xSs3WZAc50xABMxu+FlfAAd5PNumo7NfOv7EE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down
149 changes: 149 additions & 0 deletions detectors/aws/ecs/test/ecs_test.go
@@ -0,0 +1,149 @@
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ecs

import (
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"

ecs "go.opentelemetry.io/contrib/detectors/aws/ecs"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.12.0"

"github.com/stretchr/testify/assert"
)

const (
metadataV4EnvVar = "ECS_CONTAINER_METADATA_URI_V4"
)

// successfully returns resource when process is running on Amazon ECS environment
// with Metadata v4 with the EC2 Launch type.
func TestDetectV4LaunchTypeEc2(t *testing.T) {
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
if strings.HasSuffix(req.URL.String(), "/task") {
content, err := os.ReadFile("metadatav4-response-task-ec2.json")
if err == nil {
_, err = res.Write(content)
if err != nil {
t.Fatal(err)
}
}
} else {
content, err := os.ReadFile("metadatav4-response-container-ec2.json")
if err == nil {
_, err = res.Write(content)
if err != nil {
t.Fatal(err)
}
}
}
}))
defer testServer.Close()

os.Clearenv()
_ = os.Setenv(metadataV4EnvVar, testServer.URL)

hostname, err := os.Hostname()
assert.NoError(t, err, "Error")

attributes := []attribute.KeyValue{
semconv.CloudProviderAWS,
semconv.CloudPlatformAWSECS,
semconv.ContainerNameKey.String(hostname),
// We are not running the test in an actual container,
// the container id is tested with mocks of the cgroup
// file in the unit tests
semconv.ContainerIDKey.String(""),
semconv.AWSECSContainerARNKey.String("arn:aws:ecs:us-west-2:111122223333:container/0206b271-b33f-47ab-86c6-a0ba208a70a9"),
semconv.AWSECSClusterARNKey.String("arn:aws:ecs:us-west-2:111122223333:cluster/default"),
semconv.AWSECSLaunchtypeKey.String("ec2"),
semconv.AWSECSTaskARNKey.String("arn:aws:ecs:us-west-2:111122223333:task/default/158d1c8083dd49d6b527399fd6414f5c"),
semconv.AWSECSTaskFamilyKey.String("curltest"),
semconv.AWSECSTaskRevisionKey.String("26"),
semconv.AWSLogGroupNamesKey.String("/ecs/metadata"),
semconv.AWSLogGroupARNsKey.String("arn:aws:logs:us-west-2:111122223333:log-group:/ecs/metadata:*"),
semconv.AWSLogStreamNamesKey.String("ecs/curl/8f03e41243824aea923aca126495f665"),
semconv.AWSLogStreamARNsKey.String("arn:aws:logs:us-west-2:111122223333:log-group:/ecs/metadata:log-stream:ecs/curl/8f03e41243824aea923aca126495f665"),
}
expectedResource := resource.NewWithAttributes(semconv.SchemaURL, attributes...)
detector := ecs.NewResourceDetector()
res, err := detector.Detect(context.Background())

assert.Equal(t, nil, err, "Detector should not fail")
assert.Equal(t, expectedResource, res, "Resource returned is incorrect")
}

// successfully returns resource when process is running on Amazon ECS environment
// with Metadata v4 with the Fargate Launch type.
func TestDetectV4LaunchTypeFargate(t *testing.T) {
testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
if strings.HasSuffix(req.URL.String(), "/task") {
content, err := os.ReadFile("metadatav4-response-task-fargate.json")
if err == nil {
_, err = res.Write(content)
if err != nil {
panic(err)
}
}
} else {
content, err := os.ReadFile("metadatav4-response-container-fargate.json")
if err == nil {
_, err = res.Write(content)
if err != nil {
panic(err)
}
}
}
}))
defer testServer.Close()

os.Clearenv()
_ = os.Setenv(metadataV4EnvVar, testServer.URL)

hostname, err := os.Hostname()
assert.NoError(t, err, "Error")

attributes := []attribute.KeyValue{
semconv.CloudProviderAWS,
semconv.CloudPlatformAWSECS,
semconv.ContainerNameKey.String(hostname),
// We are not running the test in an actual container,
// the container id is tested with mocks of the cgroup
// file in the unit tests
semconv.ContainerIDKey.String(""),
semconv.AWSECSContainerARNKey.String("arn:aws:ecs:us-west-2:111122223333:container/05966557-f16c-49cb-9352-24b3a0dcd0e1"),
semconv.AWSECSClusterARNKey.String("arn:aws:ecs:us-west-2:111122223333:cluster/default"),
semconv.AWSECSLaunchtypeKey.String("fargate"),
semconv.AWSECSTaskARNKey.String("arn:aws:ecs:us-west-2:111122223333:task/default/e9028f8d5d8e4f258373e7b93ce9a3c3"),
semconv.AWSECSTaskFamilyKey.String("curltest"),
semconv.AWSECSTaskRevisionKey.String("3"),
semconv.AWSLogGroupNamesKey.String("/ecs/containerlogs"),
semconv.AWSLogGroupARNsKey.String("arn:aws:logs:us-west-2:111122223333:log-group:/ecs/containerlogs:*"),
semconv.AWSLogStreamNamesKey.String("ecs/curl/cd189a933e5849daa93386466019ab50"),
semconv.AWSLogStreamARNsKey.String("arn:aws:logs:us-west-2:111122223333:log-group:/ecs/containerlogs:log-stream:ecs/curl/cd189a933e5849daa93386466019ab50"),
}
expectedResource := resource.NewWithAttributes(semconv.SchemaURL, attributes...)
detector := ecs.NewResourceDetector()
res, err := detector.Detect(context.Background())

assert.Equal(t, nil, err, "Detector should not fail")
assert.Equal(t, expectedResource, res, "Resource returned is incorrect")
}