Skip to content

Commit ec5c117

Browse files
committed
feat: contribute aws ecs resource detector
1 parent c5395f9 commit ec5c117

File tree

5 files changed

+488
-13
lines changed

5 files changed

+488
-13
lines changed

resources/aws/README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ require 'opentelemetry/sdk'
3030
require 'opentelemetry/resource/detector'
3131

3232
OpenTelemetry::SDK.configure do |c|
33-
c.resource = OpenTelemetry::Resource::Detector::AWS.detect
33+
# Specify which AWS resource detectors to use
34+
c.resource = OpenTelemetry::Resource::Detector::AWS.detect([:ec2, :ecs])
35+
36+
# Or use just one detector
37+
c.resource = OpenTelemetry::Resource::Detector::AWS.detect([:ec2])
38+
c.resource = OpenTelemetry::Resource::Detector::AWS.detect([:ecs])
3439
end
3540
```
3641

@@ -52,7 +57,25 @@ Populates `cloud` and `host` for processes running on Amazon EC2, including abst
5257
| `host.name` | Value of hostname from `/latest/meta-data/hostname` request |
5358
| `host.type` | Value of `instanceType` from `/latest/dynamic/instance-identity/document` request |
5459

55-
Additional AWS platforms (ECS, EKS, Lambda) will be supported in future versions.
60+
### AWS ECS Detector
61+
62+
<!-- cspell:ignore launchtype awslogs -->
63+
Populates `cloud`, `container`, and AWS ECS-specific attributes for processes running on Amazon ECS.
64+
| Resource Attribute | Description |
65+
|--------------------|-------------|
66+
| `cloud.platform` | The cloud platform. In this context, it's always "aws_ecs" |
67+
| `cloud.provider` | The cloud provider. In this context, it's always "aws" |
68+
| `container.id` | The container ID from the `/proc/self/cgroup` file |
69+
| `container.name` | The hostname of the container |
70+
| `aws.ecs.container.arn` | The hostname of the container |
71+
| `aws.ecs.cluster.arn` | The ARN of the ECS cluster |
72+
| `aws.ecs.launchtype` | The launch type for the ECS task (e.g., "fargate" or "ec2") |
73+
| `aws.ecs.task.arn` | The ARN of the ECS task |
74+
| `aws.log.group.names` | The CloudWatch log group names (if awslogs driver is used) |
75+
| `aws.log.stream.names` | The CloudWatch log stream names (if awslogs driver is used) |
76+
| `aws.log.stream.arns` | The CloudWatch log stream ARNs (if awslogs driver is used) |
77+
78+
Additional AWS platforms (EKS, Lambda) will be supported in future versions.
5679

5780
## License
5881

resources/aws/lib/opentelemetry/resource/detector/aws.rb

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# SPDX-License-Identifier: Apache-2.0
66

77
require 'opentelemetry/resource/detector/aws/ec2'
8+
require 'opentelemetry/resource/detector/aws/ecs'
89

910
module OpenTelemetry
1011
module Resource
@@ -13,12 +14,31 @@ module Detector
1314
module AWS
1415
extend self
1516

16-
def detect
17-
# This will be a composite of all the AWS platform detectors
18-
EC2.detect
17+
RESOURCE = OpenTelemetry::SDK::Resources::Resource
1918

20-
# For now, return the EC2 resource directly
21-
# In the future, we'll implement detection for EC2, ECS, EKS, etc.
19+
# Get resources from specified AWS resource detectors
20+
#
21+
# @param detectors [Array<Symbol>] List of detectors to use (e.g., :ec2)
22+
# @return [OpenTelemetry::SDK::Resources::Resource] The detected AWS resources
23+
def detect(detectors = [])
24+
return RESOURCE.create({}) if detectors.empty?
25+
26+
resources = detectors.map do |detector|
27+
case detector
28+
when :ec2
29+
EC2.detect
30+
when :ecs
31+
ECS.detect
32+
else
33+
OpenTelemetry.logger.warn("Unknown AWS resource detector: #{detector}")
34+
OpenTelemetry::SDK::Resources::Resource.create({})
35+
end
36+
end
37+
38+
# Merge all resources into a single resource
39+
resources.reduce(OpenTelemetry::SDK::Resources::Resource.create({})) do |merged, resource|
40+
merged.merge(resource)
41+
end
2242
end
2343
end
2444
end
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright The OpenTelemetry Authors
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
require 'net/http'
8+
require 'json'
9+
require 'socket'
10+
require 'opentelemetry/common'
11+
12+
module OpenTelemetry
13+
module Resource
14+
module Detector
15+
module AWS
16+
# ECS contains detect class method for determining the ECS resource attributes
17+
module ECS
18+
extend self
19+
20+
# Container ID length from cgroup file
21+
CONTAINER_ID_LENGTH = 64
22+
23+
# HTTP request timeout in seconds
24+
HTTP_TIMEOUT = 5
25+
26+
# Create a constant for resource semantic conventions
27+
RESOURCE = OpenTelemetry::SemanticConventions::Resource
28+
29+
def detect
30+
# Return empty resource if not running on ECS
31+
metadata_uri = ENV.fetch('ECS_CONTAINER_METADATA_URI', nil)
32+
metadata_uri_v4 = ENV.fetch('ECS_CONTAINER_METADATA_URI_V4', nil)
33+
34+
return OpenTelemetry::SDK::Resources::Resource.create({}) if metadata_uri.nil? && metadata_uri_v4.nil?
35+
36+
resource_attributes = {}
37+
container_id = fetch_container_id
38+
39+
# Base ECS resource attributes
40+
resource_attributes[RESOURCE::CLOUD_PROVIDER] = 'aws'
41+
resource_attributes[RESOURCE::CLOUD_PLATFORM] = 'aws_ecs'
42+
resource_attributes[RESOURCE::CONTAINER_NAME] = Socket.gethostname
43+
resource_attributes[RESOURCE::CONTAINER_ID] = container_id unless container_id.empty?
44+
45+
# If v4 endpoint is not available, return basic resource
46+
return OpenTelemetry::SDK::Resources::Resource.create(resource_attributes) if metadata_uri_v4.nil?
47+
48+
begin
49+
# Fetch container and task metadata
50+
container_metadata = JSON.parse(http_get(metadata_uri_v4.to_s))
51+
task_metadata = JSON.parse(http_get("#{metadata_uri_v4}/task"))
52+
53+
task_arn = task_metadata['TaskARN']
54+
base_arn = task_arn[0..task_arn.rindex(':') - 1]
55+
56+
cluster = task_metadata['Cluster']
57+
cluster_arn = cluster.start_with?('arn:') ? cluster : "#{base_arn}:cluster/#{cluster}"
58+
59+
# Set ECS-specific attributes
60+
resource_attributes[RESOURCE::AWS_ECS_CONTAINER_ARN] = container_metadata['ContainerARN']
61+
resource_attributes[RESOURCE::AWS_ECS_CLUSTER_ARN] = cluster_arn
62+
resource_attributes[RESOURCE::AWS_ECS_LAUNCHTYPE] = task_metadata['LaunchType'].downcase
63+
resource_attributes[RESOURCE::AWS_ECS_TASK_ARN] = task_arn
64+
resource_attributes[RESOURCE::AWS_ECS_TASK_FAMILY] = task_metadata['Family']
65+
resource_attributes[RESOURCE::AWS_ECS_TASK_REVISION] = task_metadata['Revision']
66+
67+
# Add logging attributes if awslogs is used
68+
logs_attributes = get_logs_resource(container_metadata)
69+
resource_attributes.merge!(logs_attributes)
70+
rescue StandardError => e
71+
OpenTelemetry.logger.debug("ECS resource detection failed: #{e.message}")
72+
return OpenTelemetry::SDK::Resources::Resource.create({})
73+
end
74+
75+
# Filter out nil or empty values
76+
resource_attributes.delete_if { |_key, value| value.nil? || value.empty? }
77+
OpenTelemetry::SDK::Resources::Resource.create(resource_attributes)
78+
end
79+
80+
private
81+
82+
# Fetches container ID from /proc/self/cgroup file
83+
#
84+
# @return [String] The container ID or empty string if not found
85+
def fetch_container_id
86+
begin
87+
File.open('/proc/self/cgroup', 'r') do |file|
88+
file.each_line do |line|
89+
line = line.strip
90+
# Look for container ID (64 chars) at the end of the line
91+
return line[-CONTAINER_ID_LENGTH..-1] if line.length > CONTAINER_ID_LENGTH
92+
end
93+
end
94+
rescue Errno::ENOENT => e
95+
OpenTelemetry.logger.debug("Failed to get container ID on ECS: #{e.message}")
96+
end
97+
98+
''
99+
end
100+
101+
# Extracting logging-related resource attributes
102+
#
103+
# @param container_metadata [Hash] Container metadata from ECS metadata endpoint
104+
# @returhn [Hash] Resource attributes for logging configuration
105+
def get_logs_resource(container_metadata)
106+
log_attributes = {}
107+
108+
if container_metadata['LogDriver'] == 'awslogs'
109+
log_options = container_metadata['LogOptions']
110+
111+
if log_options
112+
logs_region = log_options['awslogs-region']
113+
logs_group_name = log_options['awslogs-group']
114+
logs_stream_name = log_options['awslogs-stream']
115+
116+
container_arn = container_metadata['ContainerARN']
117+
118+
# Parse region from ARN if not specified in log options
119+
if logs_region.nil? || logs_region.empty?
120+
region_match = container_arn.match(/arn:aws:ecs:([^:]+):.*/)
121+
logs_region = region_match[1] if region_match
122+
end
123+
124+
# Parse account ID from ARN
125+
account_match = container_arn.match(/arn:aws:ecs:[^:]+:([^:]+):.*/)
126+
aws_account = account_match[1] if account_match
127+
128+
logs_group_arn = nil
129+
logs_stream_arn = nil
130+
131+
if logs_region && aws_account
132+
logs_group_arn = "arn:aws:logs:#{logs_region}:#{aws_account}:log-group:#{logs_group_name}" if logs_group_name
133+
134+
logs_stream_arn = "arn:aws:logs:#{logs_region}:#{aws_account}:log-group:#{logs_group_name}:log-stream:#{logs_stream_name}" if logs_stream_name && logs_group_name
135+
end
136+
137+
log_attributes[RESOURCE::AWS_LOG_GROUP_NAMES] = [logs_group_name].compact
138+
log_attributes[RESOURCE::AWS_LOG_GROUP_ARNS] = [logs_group_arn].compact
139+
log_attributes[RESOURCE::AWS_LOG_STREAM_NAMES] = [logs_stream_name].compact
140+
log_attributes[RESOURCE::AWS_LOG_STREAM_ARNS] = [logs_stream_arn].compact
141+
else
142+
OpenTelemetry.logger.debug("The metadata endpoint v4 has returned 'awslogs' as 'LogDriver', but there is no 'LogOptions' data")
143+
end
144+
end
145+
146+
log_attributes
147+
end
148+
149+
# Makes an HTTP GET request to the specified URL
150+
#
151+
# @param url [String] The URL to request
152+
# @return [String] The response body
153+
def http_get(url)
154+
uri = URI.parse(url)
155+
request = Net::HTTP::Get.new(uri)
156+
157+
http = Net::HTTP.new(uri.host, uri.port)
158+
http.open_timeout = HTTP_TIMEOUT
159+
http.read_timeout = HTTP_TIMEOUT
160+
161+
OpenTelemetry::Common::Utilities.untraced do
162+
response = http.request(request)
163+
raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
164+
165+
response.body
166+
end
167+
end
168+
end
169+
end
170+
end
171+
end
172+
end
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright The OpenTelemetry Authors
4+
#
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
require 'test_helper'
8+
9+
describe OpenTelemetry::Resource::Detector::AWS::ECS do
10+
let(:detector) { OpenTelemetry::Resource::Detector::AWS::ECS }
11+
12+
describe '.detect' do
13+
let(:metadata_uri) { 'http://169.254.170.2/v3' }
14+
let(:metadata_uri_v4) { 'http://169.254.170.2/v4' }
15+
let(:hostname) { 'test-container' }
16+
17+
let(:container_metadata) do
18+
{
19+
'ContainerARN' => 'arn:aws:ecs:us-west-2:123456789012:container/container-id',
20+
'LogDriver' => 'awslogs',
21+
'LogOptions' => {
22+
'awslogs-region' => 'us-west-2',
23+
'awslogs-group' => 'my-log-group',
24+
'awslogs-stream' => 'my-log-stream'
25+
}
26+
}
27+
end
28+
29+
let(:task_metadata) do
30+
{
31+
'Cluster' => 'my-cluster',
32+
'TaskARN' => 'arn:aws:ecs:us-west-2:123456789012:task/task-id',
33+
'Family' => 'my-task-family',
34+
'Revision' => '1',
35+
'LaunchType' => 'FARGATE'
36+
}
37+
end
38+
39+
before do
40+
# Stub environment variables, hostname and File operations
41+
@original_env = ENV.to_hash
42+
ENV.clear
43+
44+
# Initialize WebMock
45+
WebMock.disable_net_connect!
46+
end
47+
48+
after do
49+
# Restore original environment
50+
ENV.replace(@original_env)
51+
WebMock.allow_net_connect!
52+
end
53+
54+
it 'returns empty resource when not running on ECS' do
55+
resource = detector.detect
56+
_(resource).must_be_instance_of(OpenTelemetry::SDK::Resources::Resource)
57+
_(resource.attribute_enumerator.to_h).must_equal({})
58+
end
59+
60+
describe 'when running on ECS with metadata endpoint v4' do
61+
before do
62+
ENV['ECS_CONTAINER_METADATA_URI_V4'] = metadata_uri_v4
63+
64+
# Stub container metadata endpoint
65+
stub_request(:get, metadata_uri_v4)
66+
.to_return(status: 200, body: container_metadata.to_json)
67+
68+
# Stub task metadata endpoint
69+
stub_request(:get, "#{metadata_uri_v4}/task")
70+
.to_return(status: 200, body: task_metadata.to_json)
71+
end
72+
73+
it 'detects ECS resources' do
74+
# Stub the fetch_container_id method directly rather than trying to stub File
75+
detector.stub :fetch_container_id, '0123456789abcdef' * 4 do
76+
Socket.stub :gethostname, hostname do
77+
resource = detector.detect
78+
79+
_(resource).must_be_instance_of(OpenTelemetry::SDK::Resources::Resource)
80+
attributes = resource.attribute_enumerator.to_h
81+
82+
# Check basic attributes
83+
_(attributes[OpenTelemetry::SemanticConventions::Resource::CLOUD_PROVIDER]).must_equal('aws')
84+
_(attributes[OpenTelemetry::SemanticConventions::Resource::CLOUD_PLATFORM]).must_equal('aws_ecs')
85+
_(attributes[OpenTelemetry::SemanticConventions::Resource::CONTAINER_NAME]).must_equal(hostname)
86+
_(attributes[OpenTelemetry::SemanticConventions::Resource::CONTAINER_ID]).must_equal('0123456789abcdef' * 4)
87+
88+
# Check ECS-specific attributes
89+
_(attributes[OpenTelemetry::SemanticConventions::Resource::AWS_ECS_CONTAINER_ARN]).must_equal(container_metadata['ContainerARN'])
90+
_(attributes[OpenTelemetry::SemanticConventions::Resource::AWS_ECS_CLUSTER_ARN]).must_equal('arn:aws:ecs:us-west-2:123456789012:cluster/my-cluster')
91+
_(attributes[OpenTelemetry::SemanticConventions::Resource::AWS_ECS_LAUNCHTYPE]).must_equal('fargate')
92+
_(attributes[OpenTelemetry::SemanticConventions::Resource::AWS_ECS_TASK_ARN]).must_equal(task_metadata['TaskARN'])
93+
_(attributes[OpenTelemetry::SemanticConventions::Resource::AWS_ECS_TASK_FAMILY]).must_equal(task_metadata['Family'])
94+
_(attributes[OpenTelemetry::SemanticConventions::Resource::AWS_ECS_TASK_REVISION]).must_equal(task_metadata['Revision'])
95+
96+
# Check log attributes
97+
_(attributes[OpenTelemetry::SemanticConventions::Resource::AWS_LOG_GROUP_NAMES]).must_equal(['my-log-group'])
98+
_(attributes[OpenTelemetry::SemanticConventions::Resource::AWS_LOG_GROUP_ARNS]).must_equal(['arn:aws:logs:us-west-2:123456789012:log-group:my-log-group'])
99+
_(attributes[OpenTelemetry::SemanticConventions::Resource::AWS_LOG_STREAM_NAMES]).must_equal(['my-log-stream'])
100+
_(attributes[OpenTelemetry::SemanticConventions::Resource::AWS_LOG_STREAM_ARNS]).must_equal(['arn:aws:logs:us-west-2:123456789012:log-group:my-log-group:log-stream:my-log-stream'])
101+
end
102+
end
103+
end
104+
end
105+
106+
describe 'when metadata endpoint fails' do
107+
before do
108+
ENV['ECS_CONTAINER_METADATA_URI_V4'] = metadata_uri_v4
109+
110+
# Stub metadata endpoint to fail
111+
stub_request(:get, metadata_uri_v4)
112+
.to_return(status: 500, body: 'Server Error')
113+
end
114+
115+
it 'returns empty resource on error' do
116+
resource = detector.detect
117+
_(resource).must_be_instance_of(OpenTelemetry::SDK::Resources::Resource)
118+
_(resource.attribute_enumerator.to_h).must_equal({})
119+
end
120+
end
121+
end
122+
end

0 commit comments

Comments
 (0)