1#![deny(missing_docs)]
36
37use std::sync::OnceLock;
38use std::time::Duration;
39
40use arn::naive::NaiveArn;
41use opentelemetry::KeyValue;
42use opentelemetry_sdk::resource::{Resource, ResourceDetector};
43use regex::Regex;
44use serde::Deserialize;
45
46use crate::attributes as attr;
47
48#[cfg(feature = "anywhere")]
49mod anywhere;
50
51pub mod attributes {
58 pub use opentelemetry_semantic_conventions::resource::{
59 AWS_ECS_CLUSTER_ARN, AWS_ECS_CONTAINER_ARN, AWS_ECS_LAUNCHTYPE, AWS_ECS_TASK_ARN,
60 AWS_ECS_TASK_FAMILY, AWS_ECS_TASK_REVISION, AWS_LOG_GROUP_ARNS, AWS_LOG_GROUP_NAMES,
61 AWS_LOG_STREAM_ARNS, AWS_LOG_STREAM_NAMES, CLOUD_ACCOUNT_ID, CLOUD_AVAILABILITY_ZONE,
62 CLOUD_PLATFORM, CLOUD_PROVIDER, CLOUD_REGION, CLOUD_RESOURCE_ID, CONTAINER_ID,
63 CONTAINER_NAME, HOST_ID,
64 };
65
66 pub const AWS_ECS_CONTAINER_INSTANCE_TAG_PREFIX: &str = "aws.ecs.container_instance.tag.";
74}
75
76const V4_URI_VAR: &str = "ECS_CONTAINER_METADATA_URI_V4";
78
79const V3_URI_VAR: &str = "ECS_CONTAINER_METADATA_URI";
81
82const METADATA_TIMEOUT: Duration = Duration::from_secs(2);
85
86#[derive(Deserialize, Debug)]
87struct TaskMetadataV4 {
88 #[serde(rename = "Cluster")]
89 cluster: String,
90 #[serde(rename = "TaskARN")]
91 task_arn: String,
92 #[serde(rename = "Family")]
93 family: String,
94 #[serde(rename = "Revision")]
95 revision: String,
96 #[serde(rename = "AvailabilityZone", default)]
97 availability_zone: String,
98 #[serde(rename = "LaunchType", default)]
99 launch_type: String,
100}
101
102#[derive(Deserialize, Debug)]
103struct ContainerMetadataV4 {
104 #[serde(rename = "ContainerARN")]
105 container_arn: String,
106 #[serde(rename = "LogDriver", default)]
107 log_driver: String,
108 #[serde(rename = "LogOptions", default)]
109 log_options: Option<LogOptions>,
110}
111
112#[derive(Deserialize, Default, Debug)]
113struct LogOptions {
114 #[serde(rename = "awslogs-group", default)]
115 group: String,
116 #[serde(rename = "awslogs-stream", default)]
117 stream: String,
118 #[serde(rename = "awslogs-region", default)]
119 region: String,
120}
121
122#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
144pub struct EcsResourceDetector;
145
146impl EcsResourceDetector {
147 pub fn new() -> Self {
149 Self
150 }
151
152 fn detected_resource(attrs: Vec<KeyValue>) -> Resource {
153 Resource::builder_empty().with_attributes(attrs).build()
154 }
155
156 fn container_id() -> Option<String> {
157 container_id_from_cgroup(&std::fs::read_to_string("/proc/self/cgroup").ok()?)
158 }
159}
160
161fn qualify(name: &str, resource_type: &str, template: &NaiveArn) -> String {
164 if name.starts_with("arn:") {
165 return name.to_string();
166 }
167 format!(
168 "arn:{}:ecs:{}:{}:{resource_type}/{name}",
169 template.partition,
170 template.region.unwrap_or_default(),
171 template.account_id.unwrap_or_default(),
172 )
173}
174
175impl ResourceDetector for EcsResourceDetector {
176 fn detect(&self) -> Resource {
177 let v4 = std::env::var(V4_URI_VAR).ok();
178 let has_v3 = std::env::var(V3_URI_VAR).is_ok();
179 if v4.is_none() && !has_v3 {
180 return Resource::builder_empty().build();
181 }
182
183 let mut attrs = vec![
184 KeyValue::new(attr::CLOUD_PROVIDER, "aws"),
185 KeyValue::new(attr::CLOUD_PLATFORM, "aws_ecs"),
186 ];
187
188 if let Ok(name) = std::env::var("HOSTNAME").or_else(|_| hostname_fallback()) {
189 attrs.push(KeyValue::new(attr::CONTAINER_NAME, name));
190 }
191 if let Some(id) = Self::container_id() {
192 attrs.push(KeyValue::new(attr::CONTAINER_ID, id));
193 }
194
195 let Some(uri) = v4 else {
198 return Self::detected_resource(attrs);
199 };
200
201 let Ok(client) = reqwest::blocking::Client::builder()
202 .timeout(METADATA_TIMEOUT)
203 .build()
204 else {
205 return Self::detected_resource(attrs);
206 };
207
208 let task: Option<TaskMetadataV4> = client
209 .get(format!("{uri}/task"))
210 .send()
211 .ok()
212 .and_then(|response| response.json().ok());
213
214 let Some(task) = task else {
217 return Self::detected_resource(attrs);
218 };
219 let Ok(task_ref) = NaiveArn::parse(&task.task_arn) else {
220 return Self::detected_resource(attrs);
221 };
222
223 attrs.extend(task_attributes(&task, &task_ref));
224
225 #[cfg(feature = "anywhere")]
228 if anywhere::is_external(&task.launch_type) {
229 attrs.extend(anywhere::attributes(
230 task_ref.region,
231 &task.cluster,
232 &task.task_arn,
233 ));
234 }
235
236 let container: Option<ContainerMetadataV4> = client
237 .get(&uri)
238 .send()
239 .ok()
240 .and_then(|response| response.json().ok());
241
242 if let Some(container) = container {
243 attrs.extend(container_attributes(&container, &task_ref));
244 }
245
246 Self::detected_resource(attrs)
247 }
248}
249
250fn task_attributes(task: &TaskMetadataV4, task_ref: &NaiveArn) -> Vec<KeyValue> {
252 let mut attrs = Vec::new();
253
254 if let Some(region) = task_ref.region {
255 attrs.push(KeyValue::new(attr::CLOUD_REGION, region.to_string()));
256 }
257 if let Some(account) = task_ref.account_id {
258 attrs.push(KeyValue::new(attr::CLOUD_ACCOUNT_ID, account.to_string()));
259 }
260 if !task.availability_zone.is_empty() {
261 attrs.push(KeyValue::new(
262 attr::CLOUD_AVAILABILITY_ZONE,
263 task.availability_zone.clone(),
264 ));
265 }
266
267 attrs.push(KeyValue::new(
268 attr::AWS_ECS_CLUSTER_ARN,
269 qualify(&task.cluster, "cluster", task_ref),
270 ));
271 attrs.push(KeyValue::new(
272 attr::AWS_ECS_LAUNCHTYPE,
273 task.launch_type.to_lowercase(),
274 ));
275 attrs.push(KeyValue::new(attr::AWS_ECS_TASK_ARN, task.task_arn.clone()));
276 attrs.push(KeyValue::new(
277 attr::AWS_ECS_TASK_FAMILY,
278 task.family.clone(),
279 ));
280 attrs.push(KeyValue::new(
281 attr::AWS_ECS_TASK_REVISION,
282 task.revision.clone(),
283 ));
284
285 attrs
286}
287
288fn container_attributes(container: &ContainerMetadataV4, task_ref: &NaiveArn) -> Vec<KeyValue> {
291 let mut attrs = Vec::new();
292
293 let container_arn = qualify(&container.container_arn, "container", task_ref);
294
295 if container.log_driver == "awslogs"
296 && let Some(options) = &container.log_options
297 {
298 let container_ref = NaiveArn::parse(&container_arn).ok();
299 attrs.extend(log_attributes(options, container_ref.as_ref(), task_ref));
300 }
301
302 attrs.push(KeyValue::new(
303 attr::CLOUD_RESOURCE_ID,
304 container_arn.clone(),
305 ));
306 attrs.push(KeyValue::new(attr::AWS_ECS_CONTAINER_ARN, container_arn));
307
308 attrs
309}
310
311fn log_attributes(
315 options: &LogOptions,
316 container_ref: Option<&NaiveArn>,
317 task_ref: &NaiveArn,
318) -> Vec<KeyValue> {
319 if options.group.is_empty() || options.stream.is_empty() {
320 return Vec::new();
321 }
322
323 let partition = container_ref.map_or(task_ref.partition, |c| c.partition);
324 let account = container_ref
325 .and_then(|c| c.account_id)
326 .or(task_ref.account_id)
327 .unwrap_or_default();
328 let region = if options.region.is_empty() {
329 container_ref
330 .and_then(|c| c.region)
331 .or(task_ref.region)
332 .unwrap_or_default()
333 } else {
334 options.region.as_str()
335 };
336
337 let group = &options.group;
338 let stream = &options.stream;
339
340 vec![
341 KeyValue::new(attr::AWS_LOG_GROUP_NAMES, group.clone()),
342 KeyValue::new(
343 attr::AWS_LOG_GROUP_ARNS,
344 format!("arn:{partition}:logs:{region}:{account}:log-group:{group}:*"),
345 ),
346 KeyValue::new(attr::AWS_LOG_STREAM_NAMES, stream.clone()),
347 KeyValue::new(
348 attr::AWS_LOG_STREAM_ARNS,
349 format!(
350 "arn:{partition}:logs:{region}:{account}:log-group:{group}:log-stream:{stream}"
351 ),
352 ),
353 ]
354}
355
356fn container_id_from_cgroup(cgroup: &str) -> Option<String> {
359 static PATTERN: OnceLock<Regex> = OnceLock::new();
360
361 let pattern = PATTERN
362 .get_or_init(|| Regex::new(r"/ecs/[^/]+/([a-f0-9]{64})$").expect("the pattern is valid"));
363
364 cgroup
365 .lines()
366 .find_map(|line| pattern.captures(line).map(|c| c[1].to_string()))
367}
368
369fn hostname_fallback() -> Result<String, std::io::Error> {
370 Ok(std::fs::read_to_string("/proc/sys/kernel/hostname")?
371 .trim()
372 .to_string())
373}
374
375#[cfg(test)]
376mod tests {
377 use opentelemetry::{Key, Value};
378
379 use super::*;
380
381 const TASK_JSON: &str = include_str!("../tests/fixtures/task.json");
383 const CONTAINER_JSON: &str = include_str!("../tests/fixtures/container.json");
384
385 const TASK_ARN: &str =
386 "arn:aws:ecs:us-west-2:111122223333:task/default/158d1c8083dd49d6b527399fd6414f5c";
387
388 fn task() -> TaskMetadataV4 {
389 serde_json::from_str(TASK_JSON).expect("the task fixture parses")
390 }
391
392 fn container() -> ContainerMetadataV4 {
393 serde_json::from_str(CONTAINER_JSON).expect("the container fixture parses")
394 }
395
396 fn attribute<'a>(attrs: &'a [KeyValue], key: &str) -> Option<&'a Value> {
397 attrs
398 .iter()
399 .find(|kv| kv.key.as_str() == key)
400 .map(|kv| &kv.value)
401 }
402
403 fn assert_attribute(attrs: &[KeyValue], key: &str, expected: &str) {
404 assert_eq!(
405 attribute(attrs, key).map(ToString::to_string).as_deref(),
406 Some(expected),
407 "attribute {key}"
408 );
409 }
410
411 #[test]
412 fn detected_resource_does_not_include_default_service_name() {
413 let resource = EcsResourceDetector::detected_resource(vec![KeyValue::new(
414 attr::CLOUD_PROVIDER,
415 "aws",
416 )]);
417
418 assert_eq!(
419 resource.get(&Key::new(attr::CLOUD_PROVIDER)),
420 Some("aws".into())
421 );
422 assert_eq!(resource.get(&Key::new("service.name")), None);
423 }
424
425 #[test]
426 fn detects_nothing_off_of_ecs() {
427 assert_eq!(
430 EcsResourceDetector.detect(),
431 Resource::builder_empty().build()
432 );
433 }
434
435 #[test]
436 fn task_attributes_describe_the_task() {
437 let task = task();
438 let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
439 let attrs = task_attributes(&task, &task_ref);
440
441 assert_attribute(&attrs, attr::CLOUD_REGION, "us-west-2");
442 assert_attribute(&attrs, attr::CLOUD_ACCOUNT_ID, "111122223333");
443 assert_attribute(&attrs, attr::CLOUD_AVAILABILITY_ZONE, "us-west-2d");
444 assert_attribute(&attrs, attr::AWS_ECS_TASK_ARN, TASK_ARN);
445 assert_attribute(&attrs, attr::AWS_ECS_TASK_FAMILY, "curltest");
446 assert_attribute(&attrs, attr::AWS_ECS_TASK_REVISION, "26");
447 }
448
449 #[test]
450 fn task_attributes_qualify_a_bare_cluster_name() {
451 let task = task();
452 let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
453 let attrs = task_attributes(&task, &task_ref);
454
455 assert_attribute(
456 &attrs,
457 attr::AWS_ECS_CLUSTER_ARN,
458 "arn:aws:ecs:us-west-2:111122223333:cluster/default",
459 );
460 }
461
462 #[test]
463 fn task_attributes_lowercase_the_launch_type() {
464 let task = task();
465 let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
466 let attrs = task_attributes(&task, &task_ref);
467
468 assert_attribute(&attrs, attr::AWS_ECS_LAUNCHTYPE, "ec2");
469 }
470
471 #[test]
472 fn container_attributes_describe_the_container_and_its_logs() {
473 let task = task();
474 let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
475 let attrs = container_attributes(&container(), &task_ref);
476
477 let container_arn =
478 "arn:aws:ecs:us-west-2:111122223333:container/acfcddf8-14b5-4d2a-9c1c-4b5e0ee2b8b4";
479 assert_attribute(&attrs, attr::CLOUD_RESOURCE_ID, container_arn);
480 assert_attribute(&attrs, attr::AWS_ECS_CONTAINER_ARN, container_arn);
481
482 assert_attribute(&attrs, attr::AWS_LOG_GROUP_NAMES, "/ecs/metadata");
483 assert_attribute(
484 &attrs,
485 attr::AWS_LOG_GROUP_ARNS,
486 "arn:aws:logs:us-west-2:111122223333:log-group:/ecs/metadata:*",
487 );
488 assert_attribute(
489 &attrs,
490 attr::AWS_LOG_STREAM_NAMES,
491 "ecs/curl/8f03e41243824aea923aca126495f665",
492 );
493 assert_attribute(
494 &attrs,
495 attr::AWS_LOG_STREAM_ARNS,
496 "arn:aws:logs:us-west-2:111122223333:log-group:/ecs/metadata:log-stream:ecs/curl/8f03e41243824aea923aca126495f665",
497 );
498 }
499
500 #[test]
501 fn container_attributes_skip_the_logs_of_another_driver() {
502 let task = task();
503 let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
504
505 let mut container = container();
506 container.log_driver = "json-file".to_string();
507 let attrs = container_attributes(&container, &task_ref);
508
509 assert_eq!(attribute(&attrs, attr::AWS_LOG_GROUP_NAMES), None);
510 assert_eq!(attribute(&attrs, attr::AWS_LOG_STREAM_NAMES), None);
511 }
512
513 #[test]
514 fn log_attributes_fall_back_to_the_container_region() {
515 let task = task();
516 let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
517
518 let container_arn = "arn:aws:ecs:eu-central-1:111122223333:container/abc";
519 let container_ref = NaiveArn::parse(container_arn).expect("the container ARN parses");
520
521 let options = LogOptions {
522 group: "/ecs/metadata".to_string(),
523 stream: "ecs/curl/abc".to_string(),
524 region: String::new(),
525 };
526 let attrs = log_attributes(&options, Some(&container_ref), &task_ref);
527
528 assert_attribute(
529 &attrs,
530 attr::AWS_LOG_GROUP_ARNS,
531 "arn:aws:logs:eu-central-1:111122223333:log-group:/ecs/metadata:*",
532 );
533 }
534
535 #[test]
536 fn log_attributes_need_both_a_group_and_a_stream() {
537 let task = task();
538 let task_ref = NaiveArn::parse(&task.task_arn).expect("the task ARN parses");
539
540 let options = LogOptions {
541 group: "/ecs/metadata".to_string(),
542 ..Default::default()
543 };
544
545 assert!(log_attributes(&options, None, &task_ref).is_empty());
546 }
547
548 #[test]
549 fn qualify_leaves_a_full_arn_alone() {
550 let task_ref = NaiveArn::parse(TASK_ARN).expect("the task ARN parses");
551 let arn = "arn:aws:ecs:us-east-1:444455556666:cluster/other";
552
553 assert_eq!(qualify(arn, "cluster", &task_ref), arn);
554 }
555
556 #[test]
557 fn container_id_comes_from_the_cgroup() {
558 let cgroup = "\
55911:devices:/ecs/158d1c8083dd49d6b527399fd6414f5c/43481a6ce4842eec8fe72fc28500c6b52edcc0917f105b83379f88cac1ff3946
56010:memory:/ecs/158d1c8083dd49d6b527399fd6414f5c/43481a6ce4842eec8fe72fc28500c6b52edcc0917f105b83379f88cac1ff3946
561";
562
563 assert_eq!(
564 container_id_from_cgroup(cgroup).as_deref(),
565 Some("43481a6ce4842eec8fe72fc28500c6b52edcc0917f105b83379f88cac1ff3946")
566 );
567 }
568
569 #[test]
570 fn container_id_ignores_a_cgroup_from_elsewhere() {
571 let cgroup = "\
57211:devices:/user.slice
57310:memory:/docker/43481a6ce4842eec8fe72fc28500c6b52edcc0917f105b83379f88cac1ff3946
574";
575
576 assert_eq!(container_id_from_cgroup(cgroup), None);
577 }
578}