Skip to main content

opentelemetry_detector_ecs/
lib.rs

1//! An OpenTelemetry resource detector for Amazon ECS.
2//!
3//! [`EcsResourceDetector`] reads the ECS task metadata endpoint and reports the
4//! cloud, container, task, and log attributes named by the [semantic
5//! conventions for ECS][conventions]. Anywhere else it reports nothing, so a
6//! program that also runs outside ECS can register it unconditionally:
7//!
8//! ```
9//! use opentelemetry_detector_ecs::EcsResourceDetector;
10//! use opentelemetry_sdk::Resource;
11//!
12//! let resource = Resource::builder()
13//!     .with_detector(Box::new(EcsResourceDetector))
14//!     .build();
15//! ```
16//!
17//! Every key it reports is a public constant in [`attributes`].
18//!
19//! On ECS Anywhere it also reports the Systems Manager managed instance the
20//! task runs on, which costs three API calls and the permissions to make them.
21//! See [`EcsResourceDetector`] for what those are. The `anywhere` cargo
22//! feature, on by default, carries that lookup; turning it off drops the
23//! lookup and the AWS SDK dependencies behind it.
24//!
25//! Detection blocks for up to two seconds while it queries the metadata
26//! endpoint, and five more on ECS Anywhere. It reports whatever it has gathered
27//! so far if the endpoint or the APIs answer slowly, partially, or not at all.
28//!
29//! [conventions]: https://opentelemetry.io/docs/specs/semconv/resource/cloud-provider/aws/ecs/
30//
31// Ported from the Go detector in opentelemetry-go-contrib, which is also
32// licensed under the Apache License, Version 2.0:
33// https://github.com/open-telemetry/opentelemetry-go-contrib/blob/4610324d288f2b56faf237d67b85678f8e6de387/detectors/aws/ecs/ecs.go
34
35#![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
51/// Every resource attribute key the detector reports.
52///
53/// The keys come from [`opentelemetry_semantic_conventions`], which names them
54/// all, so a caller can match on what the detector produces without depending
55/// on that crate directly. The detector itself reads them from here, so the two
56/// lists cannot drift apart.
57pub 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    /// The prefix the detector puts in front of a managed instance tag.
67    ///
68    /// A task on ECS Anywhere runs on a host the ECS agent registered as a
69    /// Systems Manager managed instance. The detector reports every tag on that
70    /// instance, naming a tag `Env` as `aws.ecs.container_instance.tag.Env`.
71    /// The semantic conventions name no such attribute, so the key is this
72    /// crate's own.
73    pub const AWS_ECS_CONTAINER_INSTANCE_TAG_PREFIX: &str = "aws.ecs.container_instance.tag.";
74}
75
76/// The environment variable ECS sets to the task metadata endpoint, version 4.
77const V4_URI_VAR: &str = "ECS_CONTAINER_METADATA_URI_V4";
78
79/// The environment variable ECS sets to the task metadata endpoint, version 3.
80const V3_URI_VAR: &str = "ECS_CONTAINER_METADATA_URI";
81
82/// How long to wait on the metadata endpoint, which answers from the local
83/// host and so should answer quickly.
84const 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/// Describes the Amazon ECS task the current process belongs to.
123///
124/// The detector recognizes ECS by the `ECS_CONTAINER_METADATA_URI_V4` and
125/// `ECS_CONTAINER_METADATA_URI` environment variables. Given the v4 endpoint it
126/// reports the full set of attributes; given only v3 it reports the container
127/// name and ID; given neither it reports an empty [`Resource`].
128///
129/// A task whose launch type is `EXTERNAL` runs on ECS Anywhere, and the
130/// detector goes on to name the Systems Manager managed instance underneath it.
131/// That takes the credentials the environment supplies and three permissions on
132/// the task role:
133///
134/// - `ecs:DescribeTasks`
135/// - `ecs:DescribeContainerInstances`
136/// - `ssm:ListTagsForResource`
137///
138/// Each one the role lacks costs the attributes behind it and leaves a notice
139/// on standard error. Detection succeeds regardless. The lookup exists under
140/// the `anywhere` cargo feature, which is on by default.
141///
142/// See the [crate documentation](crate) for an example.
143#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
144pub struct EcsResourceDetector;
145
146impl EcsResourceDetector {
147    /// Builds a detector.
148    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
161/// Turns a bare `cluster-name` into a full ARN, using the partition, region,
162/// and account of an already-qualified sibling ARN.
163fn 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        // The v3 endpoint carries none of the attributes below, so a v3-only
196        // task gets the container attributes and nothing more.
197        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        // Every remaining attribute is qualified by the task ARN, so an
215        // unparsable one ends the detection.
216        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        // ECS Anywhere runs the task on hardware the metadata endpoint says
226        // nothing about, so the managed instance under it takes three API calls.
227        #[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
250/// Maps task metadata onto resource attributes.
251fn 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
288/// Maps container metadata, including its log configuration, onto resource
289/// attributes.
290fn 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
311/// Maps an `awslogs` log driver configuration onto resource attributes,
312/// falling back to the container and then the task ARN for whatever the driver
313/// leaves unset.
314fn 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
356/// Pulls the 64-character Docker container ID out of a cgroup file, if one of
357/// its lines names an ECS container.
358fn 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    /// The examples AWS publishes for the task metadata endpoint, version 4.
382    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        // Both metadata variables are absent under `cargo test`, so the
428        // detector has nothing to go on.
429        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}