#![deny(missing_docs)]
use std::sync::OnceLock;
use std::time::Duration;
use arn::naive::NaiveArn;
use opentelemetry::KeyValue;
use opentelemetry_sdk::resource::{Resource, ResourceDetector};
use regex::Regex;
use serde::Deserialize;
use crate::attributes as attr;
#[cfg(feature = "anywhere")]
mod anywhere;
pub mod attributes {
pub use opentelemetry_semantic_conventions::resource::{
AWS_ECS_CLUSTER_ARN, AWS_ECS_CONTAINER_ARN, AWS_ECS_LAUNCHTYPE, AWS_ECS_TASK_ARN,
AWS_ECS_TASK_FAMILY, AWS_ECS_TASK_REVISION, AWS_LOG_GROUP_ARNS, AWS_LOG_GROUP_NAMES,
AWS_LOG_STREAM_ARNS, AWS_LOG_STREAM_NAMES, CLOUD_ACCOUNT_ID, CLOUD_AVAILABILITY_ZONE,
CLOUD_PLATFORM, CLOUD_PROVIDER, CLOUD_REGION, CLOUD_RESOURCE_ID, CONTAINER_ID,
CONTAINER_NAME, HOST_ID,
};
pub const AWS_ECS_CONTAINER_INSTANCE_TAG_PREFIX: &str = "aws.ecs.container_instance.tag.";
}
const V4_URI_VAR: &str = "ECS_CONTAINER_METADATA_URI_V4";
const V3_URI_VAR: &str = "ECS_CONTAINER_METADATA_URI";
const METADATA_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Deserialize, Debug)]
struct TaskMetadataV4 {
#[serde(rename = "Cluster")]
cluster: String,
#[serde(rename = "TaskARN")]
task_arn: String,
#[serde(rename = "Family")]
family: String,
#[serde(rename = "Revision")]
revision: String,
#[serde(rename = "AvailabilityZone", default)]
availability_zone: String,
#[serde(rename = "LaunchType", default)]
launch_type: String,
}
#[derive(Deserialize, Debug)]
struct ContainerMetadataV4 {
#[serde(rename = "ContainerARN")]
container_arn: String,
#[serde(rename = "LogDriver", default)]
log_driver: String,
#[serde(rename = "LogOptions", default)]
log_options: Option<LogOptions>,
}
#[derive(Deserialize, Default, Debug)]
struct LogOptions {
#[serde(rename = "awslogs-group", default)]
group: String,
#[serde(rename = "awslogs-stream", default)]
stream: String,
#[serde(rename = "awslogs-region", default)]
region: String,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct EcsResourceDetector;
impl EcsResourceDetector {
pub fn new() -> Self {
Self
}
fn detected_resource(attrs: Vec<KeyValue>) -> Resource {
Resource::builder_empty().with_attributes(attrs).build()
}
fn container_id() -> Option<String> {
container_id_from_cgroup(&std::fs::read_to_string("/proc/self/cgroup").ok()?)
}
}
fn qualify(name: &str, resource_type: &str, template: &NaiveArn) -> String {
if name.starts_with("arn:") {
return name.to_string();
}
format!(
"arn:{}:ecs:{}:{}:{resource_type}/{name}",
template.partition,
template.region.unwrap_or_default(),
template.account_id.unwrap_or_default(),
)
}
impl ResourceDetector for EcsResourceDetector {
fn detect(&self) -> Resource {
let v4 = std::env::var(V4_URI_VAR).ok();
let has_v3 = std::env::var(V3_URI_VAR).is_ok();
if v4.is_none() && !has_v3 {
return Resource::builder_empty().build();
}
let mut attrs = vec![
KeyValue::new(attr::CLOUD_PROVIDER, "aws"),
KeyValue::new(attr::CLOUD_PLATFORM, "aws_ecs"),
];
if let Ok(name) = std::env::var("HOSTNAME").or_else(|_| hostname_fallback()) {
attrs.push(KeyValue::new(attr::CONTAINER_NAME, name));
}
if let Some(id) = Self::container_id() {
attrs.push(KeyValue::new(attr::CONTAINER_ID, id));
}
let Some(uri) = v4 else {
return Self::detected_resource(attrs);
};
let Ok(client) = reqwest::blocking::Client::builder()
.timeout(METADATA_TIMEOUT)
.build()
else {
return Self::detected_resource(attrs);
};
let task: Option<TaskMetadataV4> = client
.get(format!("{uri}/task"))
.send()
.ok()
.and_then(|response| response.json().ok());
let Some(task) = task else {
return Self::detected_resource(attrs);
};
let Ok(task_ref) = NaiveArn::parse(&task.task_arn) else {
return Self::detected_resource(attrs);
};
attrs.extend(task_attributes(&task, &task_ref));
#[cfg(feature = "anywhere")]
if anywhere::is_external(&task.launch_type) {
attrs.extend(anywhere::attributes(
task_ref.region,
&task.cluster,
&task.task_arn,
));
}
let container: Option<ContainerMetadataV4> = client
.get(&uri)
.send()
.ok()
.and_then(|response| response.json().ok());
if let Some(container) = container {
attrs.extend(container_attributes(&container, &task_ref));
}
Self::detected_resource(attrs)
}
}
fn task_attributes(task: &TaskMetadataV4, task_ref: &NaiveArn) -> Vec<KeyValue> {
let mut attrs = Vec::new();
if let Some(region) = task_ref.region {
attrs.push(KeyValue::new(attr::CLOUD_REGION, region.to_string()));
}
if let Some(account) = task_ref.account_id {
attrs.push(KeyValue::new(attr::CLOUD_ACCOUNT_ID, account.to_string()));
}
if !task.availability_zone.is_empty() {
attrs.push(KeyValue::new(
attr::CLOUD_AVAILABILITY_ZONE,
task.availability_zone.clone(),
));
}
attrs.push(KeyValue::new(
attr::AWS_ECS_CLUSTER_ARN,
qualify(&task.cluster, "cluster", task_ref),
));
attrs.push(KeyValue::new(
attr::AWS_ECS_LAUNCHTYPE,
task.launch_type.to_lowercase(),
));
attrs.push(KeyValue::new(attr::AWS_ECS_TASK_ARN, task.task_arn.clone()));
attrs.push(KeyValue::new(
attr::AWS_ECS_TASK_FAMILY,
task.family.clone(),
));
attrs.push(KeyValue::new(
attr::AWS_ECS_TASK_REVISION,
task.revision.clone(),
));
attrs
}
fn container_attributes(container: &ContainerMetadataV4, task_ref: &NaiveArn) -> Vec<KeyValue> {
let mut attrs = Vec::new();
let container_arn = qualify(&container.container_arn, "container", task_ref);
if container.log_driver == "awslogs"
&& let Some(options) = &container.log_options
{
let container_ref = NaiveArn::parse(&container_arn).ok();
attrs.extend(log_attributes(options, container_ref.as_ref(), task_ref));
}
attrs.push(KeyValue::new(
attr::CLOUD_RESOURCE_ID,
container_arn.clone(),
));
attrs.push(KeyValue::new(attr::AWS_ECS_CONTAINER_ARN, container_arn));
attrs
}
fn log_attributes(
options: &LogOptions,
container_ref: Option<&NaiveArn>,
task_ref: &NaiveArn,
) -> Vec<KeyValue> {
if options.group.is_empty() || options.stream.is_empty() {
return Vec::new();
}
let partition = container_ref.map_or(task_ref.partition, |c| c.partition);
let account = container_ref
.and_then(|c| c.account_id)
.or(task_ref.account_id)
.unwrap_or_default();
let region = if options.region.is_empty() {
container_ref
.and_then(|c| c.region)
.or(task_ref.region)
.unwrap_or_default()
} else {
options.region.as_str()
};
let group = &options.group;
let stream = &options.stream;
vec![
KeyValue::new(attr::AWS_LOG_GROUP_NAMES, group.clone()),
KeyValue::new(
attr::AWS_LOG_GROUP_ARNS,
format!("arn:{partition}:logs:{region}:{account}:log-group:{group}:*"),
),
KeyValue::new(attr::AWS_LOG_STREAM_NAMES, stream.clone()),
KeyValue::new(
attr::AWS_LOG_STREAM_ARNS,
format!(
"arn:{partition}:logs:{region}:{account}:log-group:{group}:log-stream:{stream}"
),
),
]
}
fn container_id_from_cgroup(cgroup: &str) -> Option<String> {
static PATTERN: OnceLock<Regex> = OnceLock::new();
let pattern = PATTERN
.get_or_init(|| Regex::new(r"/ecs/[^/]+/([a-f0-9]{64})$").expect("the pattern is valid"));
cgroup
.lines()
.find_map(|line| pattern.captures(line).map(|c| c[1].to_string()))
}
fn hostname_fallback() -> Result<String, std::io::Error> {
Ok(std::fs::read_to_string("/proc/sys/kernel/hostname")?
.trim()
.to_string())
}
#[cfg(test)]
mod tests {
use opentelemetry::{Key, Value};
use super::*;
const TASK_JSON: &str = include_str!("../tests/fixtures/task.json");
const CONTAINER_JSON: &str = include_str!("../tests/fixtures/container.json");
const TASK_ARN: &str =
"arn:aws:ecs:us-west-2:111122223333:task/default/158d1c8083dd49d6b527399fd6414f5c";
fn task() -> TaskMetadataV4 {
serde_json::from_str(TASK_JSON).expect("the task fixture parses")
}
fn container() -> ContainerMetadataV4 {
serde_json::from_str(CONTAINER_JSON).expect("the container fixture parses")
}
fn attribute<'a>(attrs: &'a [KeyValue], key: &str) -> Option<&'a Value> {
attrs
.iter()
.find(|kv| kv.key.as_str() == key)
.map(|kv| &kv.value)
}
fn assert_attribute(attrs: &[KeyValue], key: &str, expected: &str) {
assert_eq!(
attribute(attrs, key).map(ToString::to_string).as_deref(),
Some(expected),
"attribute {key}"
);
}
#[test]
fn detected_resource_does_not_include_default_service_name() {
let resource = EcsResourceDetector::detected_resource(vec![KeyValue::new(
attr::CLOUD_PROVIDER,
"aws",
)]);
assert_eq!(
resource.get(&Key::new(attr::CLOUD_PROVIDER)),
Some("aws".into())
);
assert_eq!(resource.get(&Key::new("service.name")), None);
}
#[test]
fn detects_nothing_off_of_ecs() {
assert_eq!(
EcsResourceDetector.detect(),
Resource::builder_empty().build()
);
}
#[test]
fn task_attributes_describe_the_task() {
let task = task();
let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
let attrs = task_attributes(&task, &task_ref);
assert_attribute(&attrs, attr::CLOUD_REGION, "us-west-2");
assert_attribute(&attrs, attr::CLOUD_ACCOUNT_ID, "111122223333");
assert_attribute(&attrs, attr::CLOUD_AVAILABILITY_ZONE, "us-west-2d");
assert_attribute(&attrs, attr::AWS_ECS_TASK_ARN, TASK_ARN);
assert_attribute(&attrs, attr::AWS_ECS_TASK_FAMILY, "curltest");
assert_attribute(&attrs, attr::AWS_ECS_TASK_REVISION, "26");
}
#[test]
fn task_attributes_qualify_a_bare_cluster_name() {
let task = task();
let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
let attrs = task_attributes(&task, &task_ref);
assert_attribute(
&attrs,
attr::AWS_ECS_CLUSTER_ARN,
"arn:aws:ecs:us-west-2:111122223333:cluster/default",
);
}
#[test]
fn task_attributes_lowercase_the_launch_type() {
let task = task();
let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
let attrs = task_attributes(&task, &task_ref);
assert_attribute(&attrs, attr::AWS_ECS_LAUNCHTYPE, "ec2");
}
#[test]
fn container_attributes_describe_the_container_and_its_logs() {
let task = task();
let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
let attrs = container_attributes(&container(), &task_ref);
let container_arn =
"arn:aws:ecs:us-west-2:111122223333:container/acfcddf8-14b5-4d2a-9c1c-4b5e0ee2b8b4";
assert_attribute(&attrs, attr::CLOUD_RESOURCE_ID, container_arn);
assert_attribute(&attrs, attr::AWS_ECS_CONTAINER_ARN, container_arn);
assert_attribute(&attrs, attr::AWS_LOG_GROUP_NAMES, "/ecs/metadata");
assert_attribute(
&attrs,
attr::AWS_LOG_GROUP_ARNS,
"arn:aws:logs:us-west-2:111122223333:log-group:/ecs/metadata:*",
);
assert_attribute(
&attrs,
attr::AWS_LOG_STREAM_NAMES,
"ecs/curl/8f03e41243824aea923aca126495f665",
);
assert_attribute(
&attrs,
attr::AWS_LOG_STREAM_ARNS,
"arn:aws:logs:us-west-2:111122223333:log-group:/ecs/metadata:log-stream:ecs/curl/8f03e41243824aea923aca126495f665",
);
}
#[test]
fn container_attributes_skip_the_logs_of_another_driver() {
let task = task();
let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
let mut container = container();
container.log_driver = "json-file".to_string();
let attrs = container_attributes(&container, &task_ref);
assert_eq!(attribute(&attrs, attr::AWS_LOG_GROUP_NAMES), None);
assert_eq!(attribute(&attrs, attr::AWS_LOG_STREAM_NAMES), None);
}
#[test]
fn log_attributes_fall_back_to_the_container_region() {
let task = task();
let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
let container_arn = "arn:aws:ecs:eu-central-1:111122223333:container/abc";
let container_ref = NaiveArn::parse(container_arn).expect("the container ARN parses");
let options = LogOptions {
group: "/ecs/metadata".to_string(),
stream: "ecs/curl/abc".to_string(),
region: String::new(),
};
let attrs = log_attributes(&options, Some(&container_ref), &task_ref);
assert_attribute(
&attrs,
attr::AWS_LOG_GROUP_ARNS,
"arn:aws:logs:eu-central-1:111122223333:log-group:/ecs/metadata:*",
);
}
#[test]
fn log_attributes_need_both_a_group_and_a_stream() {
let task = task();
let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
let options = LogOptions {
group: "/ecs/metadata".to_string(),
..Default::default()
};
assert!(log_attributes(&options, None, &task_ref).is_empty());
}
#[test]
fn qualify_leaves_a_full_arn_alone() {
let task_ref = NaiveArn::parse(TASK_ARN).expect("the task ARN parses");
let arn = "arn:aws:ecs:us-east-1:444455556666:cluster/other";
assert_eq!(qualify(arn, "cluster", &task_ref), arn);
}
#[test]
fn container_id_comes_from_the_cgroup() {
let cgroup = "\
11:devices:/ecs/158d1c8083dd49d6b527399fd6414f5c/43481a6ce4842eec8fe72fc28500c6b52edcc0917f105b83379f88cac1ff3946
10:memory:/ecs/158d1c8083dd49d6b527399fd6414f5c/43481a6ce4842eec8fe72fc28500c6b52edcc0917f105b83379f88cac1ff3946
";
assert_eq!(
container_id_from_cgroup(cgroup).as_deref(),
Some("43481a6ce4842eec8fe72fc28500c6b52edcc0917f105b83379f88cac1ff3946")
);
}
#[test]
fn container_id_ignores_a_cgroup_from_elsewhere() {
let cgroup = "\
11:devices:/user.slice
10:memory:/docker/43481a6ce4842eec8fe72fc28500c6b52edcc0917f105b83379f88cac1ff3946
";
assert_eq!(container_id_from_cgroup(cgroup), None);
}
}