opentelemetry_lambda_extension/
resource.rs

1//! Lambda resource attribute detection.
2//!
3//! This module provides an AWS Lambda resource detector that implements the
4//! OpenTelemetry SDK's `ResourceDetector` trait. It detects Lambda runtime
5//! environment attributes and builds properly typed `Resource` objects.
6//!
7//! The detector reads from standard Lambda environment variables and follows
8//! OpenTelemetry semantic conventions for cloud and FaaS attributes.
9
10use opentelemetry::{KeyValue, Value};
11use opentelemetry_proto::tonic::resource::v1::Resource as ProtoResource;
12use opentelemetry_sdk::resource::{Resource, ResourceDetector};
13use opentelemetry_semantic_conventions::SCHEMA_URL;
14use opentelemetry_semantic_conventions::attribute as semconv_attr;
15use opentelemetry_semantic_conventions::resource as semconv_res;
16use std::borrow::Cow;
17use std::env;
18
19/// Re-export semantic conventions for use by other modules.
20pub mod semconv {
21    pub use opentelemetry_semantic_conventions::attribute::{
22        CLOUD_PLATFORM, CLOUD_PROVIDER, CLOUD_REGION, FAAS_COLDSTART, FAAS_INVOCATION_ID,
23        FAAS_MAX_MEMORY, FAAS_NAME, FAAS_TRIGGER, FAAS_VERSION,
24    };
25    pub use opentelemetry_semantic_conventions::resource::{
26        AWS_LOG_GROUP_NAMES, AWS_LOG_STREAM_NAMES, FAAS_INSTANCE, SERVICE_NAME,
27        TELEMETRY_SDK_LANGUAGE, TELEMETRY_SDK_NAME, TELEMETRY_SDK_VERSION,
28    };
29}
30
31/// Environment variable names for Lambda runtime.
32const AWS_EXECUTION_ENV: &str = "AWS_EXECUTION_ENV";
33const AWS_REGION: &str = "AWS_REGION";
34const AWS_LAMBDA_FUNCTION_NAME: &str = "AWS_LAMBDA_FUNCTION_NAME";
35const AWS_LAMBDA_FUNCTION_VERSION: &str = "AWS_LAMBDA_FUNCTION_VERSION";
36const AWS_LAMBDA_FUNCTION_MEMORY_SIZE: &str = "AWS_LAMBDA_FUNCTION_MEMORY_SIZE";
37const AWS_LAMBDA_LOG_GROUP_NAME: &str = "AWS_LAMBDA_LOG_GROUP_NAME";
38const AWS_LAMBDA_LOG_STREAM_NAME: &str = "AWS_LAMBDA_LOG_STREAM_NAME";
39
40/// AWS Lambda resource detector.
41///
42/// Detects Lambda runtime environment attributes from environment variables
43/// and returns an OpenTelemetry `Resource` following semantic conventions.
44///
45/// This detector checks for the `AWS_EXECUTION_ENV` variable to confirm it's
46/// running in a Lambda environment before collecting attributes.
47///
48/// # Environment Variables
49///
50/// The detector reads the following Lambda environment variables:
51/// - `AWS_REGION` - Cloud region
52/// - `AWS_LAMBDA_FUNCTION_NAME` - Function name (faas.name)
53/// - `AWS_LAMBDA_FUNCTION_VERSION` - Function version (faas.version)
54/// - `AWS_LAMBDA_FUNCTION_MEMORY_SIZE` - Memory in MB (converted to bytes for faas.max_memory)
55/// - `AWS_LAMBDA_LOG_GROUP_NAME` - CloudWatch log group (may not be available to extensions)
56/// - `AWS_LAMBDA_LOG_STREAM_NAME` - CloudWatch log stream (faas.instance, may not be available)
57#[derive(Debug, Default)]
58pub struct AwsLambdaDetector;
59
60impl AwsLambdaDetector {
61    /// Creates a new AWS Lambda detector.
62    pub fn new() -> Self {
63        Self
64    }
65}
66
67impl ResourceDetector for AwsLambdaDetector {
68    fn detect(&self) -> Resource {
69        let execution_env = env::var(AWS_EXECUTION_ENV).ok();
70        if !execution_env
71            .as_ref()
72            .map(|e| e.starts_with("AWS_Lambda_"))
73            .unwrap_or(false)
74        {
75            return Resource::builder_empty().build();
76        }
77
78        let mut attributes = vec![
79            KeyValue::new(semconv_attr::CLOUD_PROVIDER, "aws"),
80            KeyValue::new(semconv_attr::CLOUD_PLATFORM, "aws_lambda"),
81        ];
82
83        if let Ok(region) = env::var(AWS_REGION) {
84            attributes.push(KeyValue::new(semconv_attr::CLOUD_REGION, region));
85        }
86
87        if let Ok(name) = env::var(AWS_LAMBDA_FUNCTION_NAME) {
88            attributes.push(KeyValue::new(semconv_attr::FAAS_NAME, name));
89        }
90
91        if let Ok(version) = env::var(AWS_LAMBDA_FUNCTION_VERSION) {
92            attributes.push(KeyValue::new(semconv_attr::FAAS_VERSION, version));
93        }
94
95        if let Ok(memory) = env::var(AWS_LAMBDA_FUNCTION_MEMORY_SIZE)
96            && let Ok(mb) = memory.parse::<i64>()
97        {
98            attributes.push(KeyValue::new(
99                semconv_attr::FAAS_MAX_MEMORY,
100                mb * 1024 * 1024,
101            ));
102        }
103
104        if let Ok(log_group) = env::var(AWS_LAMBDA_LOG_GROUP_NAME) {
105            use opentelemetry::StringValue;
106            attributes.push(KeyValue::new(
107                semconv_res::AWS_LOG_GROUP_NAMES,
108                Value::Array(vec![StringValue::from(log_group)].into()),
109            ));
110        }
111
112        if let Ok(log_stream) = env::var(AWS_LAMBDA_LOG_STREAM_NAME) {
113            attributes.push(KeyValue::new(semconv_res::FAAS_INSTANCE, log_stream));
114        }
115
116        Resource::builder_empty()
117            .with_schema_url(attributes.iter().cloned(), Cow::Borrowed(SCHEMA_URL))
118            .build()
119    }
120}
121
122/// Extension-specific resource detector.
123///
124/// Adds extension-specific attributes that distinguish the extension's
125/// telemetry from the Lambda function's telemetry.
126#[derive(Debug, Default)]
127pub struct ExtensionDetector;
128
129impl ExtensionDetector {
130    /// Creates a new extension detector.
131    pub fn new() -> Self {
132        Self
133    }
134}
135
136impl ResourceDetector for ExtensionDetector {
137    fn detect(&self) -> Resource {
138        let attributes = vec![
139            KeyValue::new(semconv_res::SERVICE_NAME, "opentelemetry-lambda-extension"),
140            KeyValue::new(
141                semconv_res::TELEMETRY_SDK_NAME,
142                "opentelemetry-lambda-extension",
143            ),
144            KeyValue::new(semconv_res::TELEMETRY_SDK_LANGUAGE, "rust"),
145            KeyValue::new(
146                semconv_res::TELEMETRY_SDK_VERSION,
147                env!("CARGO_PKG_VERSION"),
148            ),
149        ];
150
151        Resource::builder_empty()
152            .with_attributes(attributes)
153            .build()
154    }
155}
156
157/// Detects Lambda environment and returns a configured Resource.
158///
159/// This convenience function creates a Resource by running the Lambda
160/// detector and extension detector, merging their results.
161pub fn detect_resource() -> Resource {
162    let lambda_detector = AwsLambdaDetector::new();
163    let extension_detector = ExtensionDetector::new();
164
165    Resource::builder_empty()
166        .with_detector(Box::new(lambda_detector))
167        .with_detector(Box::new(extension_detector))
168        .build()
169}
170
171/// Converts an SDK Resource to the protobuf Resource type for OTLP export.
172pub fn to_proto_resource(resource: &Resource) -> ProtoResource {
173    resource.into()
174}
175
176/// Builder for customising Lambda resource attributes.
177///
178/// This builder wraps the SDK's ResourceBuilder and provides a convenient
179/// API for adding custom attributes alongside detected ones.
180#[derive(Debug)]
181#[must_use = "builders do nothing unless .build() is called"]
182pub struct ResourceBuilder {
183    inner: opentelemetry_sdk::resource::ResourceBuilder,
184}
185
186impl ResourceBuilder {
187    /// Creates a new resource builder with Lambda detection enabled.
188    pub fn new() -> Self {
189        Self {
190            inner: Resource::builder_empty()
191                .with_detector(Box::new(AwsLambdaDetector::new()))
192                .with_detector(Box::new(ExtensionDetector::new())),
193        }
194    }
195
196    /// Detects Lambda environment attributes automatically.
197    ///
198    /// This is included by default in `new()`, but can be called again
199    /// if you've created an empty builder.
200    pub fn detect_lambda_environment(self) -> Self {
201        Self {
202            inner: self
203                .inner
204                .with_detector(Box::new(AwsLambdaDetector::new()))
205                .with_detector(Box::new(ExtensionDetector::new())),
206        }
207    }
208
209    /// Adds a custom string attribute.
210    pub fn add_string(self, key: impl Into<Cow<'static, str>>, value: impl Into<String>) -> Self {
211        Self {
212            inner: self
213                .inner
214                .with_attribute(KeyValue::new(key.into(), value.into())),
215        }
216    }
217
218    /// Adds a custom integer attribute.
219    pub fn add_int(self, key: impl Into<Cow<'static, str>>, value: i64) -> Self {
220        Self {
221            inner: self.inner.with_attribute(KeyValue::new(key.into(), value)),
222        }
223    }
224
225    /// Adds a custom boolean attribute.
226    pub fn add_bool(self, key: impl Into<Cow<'static, str>>, value: bool) -> Self {
227        Self {
228            inner: self.inner.with_attribute(KeyValue::new(key.into(), value)),
229        }
230    }
231
232    /// Builds the SDK Resource.
233    pub fn build(self) -> Resource {
234        self.inner.build()
235    }
236
237    /// Builds and converts to protobuf Resource.
238    pub fn build_proto(self) -> ProtoResource {
239        to_proto_resource(&self.build())
240    }
241}
242
243impl Default for ResourceBuilder {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use opentelemetry_proto::tonic::common::v1::any_value;
253
254    fn get_string_value(resource: &Resource, key: &str) -> Option<String> {
255        use opentelemetry::Key;
256        let owned_key = Key::from(key.to_owned());
257        resource.get(&owned_key).and_then(|v| match v {
258            Value::String(s) => Some(s.to_string()),
259            _ => None,
260        })
261    }
262
263    fn get_int_value(resource: &Resource, key: &str) -> Option<i64> {
264        use opentelemetry::Key;
265        let owned_key = Key::from(key.to_owned());
266        resource.get(&owned_key).and_then(|v| match v {
267            Value::I64(i) => Some(i),
268            _ => None,
269        })
270    }
271
272    #[test]
273    fn test_lambda_detector_outside_lambda() {
274        // Without AWS_EXECUTION_ENV, should return empty resource
275        temp_env::with_vars([(AWS_EXECUTION_ENV, None::<&str>)], || {
276            let detector = AwsLambdaDetector::new();
277            let resource = detector.detect();
278            assert!(resource.is_empty());
279        });
280    }
281
282    #[test]
283    fn test_lambda_detector_in_lambda() {
284        temp_env::with_vars(
285            [
286                (AWS_EXECUTION_ENV, Some("AWS_Lambda_nodejs18.x")),
287                (AWS_REGION, Some("us-east-1")),
288                (AWS_LAMBDA_FUNCTION_NAME, Some("test-function")),
289                (AWS_LAMBDA_FUNCTION_VERSION, Some("$LATEST")),
290                (AWS_LAMBDA_FUNCTION_MEMORY_SIZE, Some("128")),
291            ],
292            || {
293                let detector = AwsLambdaDetector::new();
294                let resource = detector.detect();
295
296                assert_eq!(
297                    get_string_value(&resource, semconv::CLOUD_PROVIDER),
298                    Some("aws".to_string())
299                );
300                assert_eq!(
301                    get_string_value(&resource, semconv::CLOUD_PLATFORM),
302                    Some("aws_lambda".to_string())
303                );
304                assert_eq!(
305                    get_string_value(&resource, semconv::CLOUD_REGION),
306                    Some("us-east-1".to_string())
307                );
308                assert_eq!(
309                    get_string_value(&resource, semconv::FAAS_NAME),
310                    Some("test-function".to_string())
311                );
312                assert_eq!(
313                    get_string_value(&resource, semconv::FAAS_VERSION),
314                    Some("$LATEST".to_string())
315                );
316                assert_eq!(
317                    get_int_value(&resource, semconv::FAAS_MAX_MEMORY),
318                    Some(128 * 1024 * 1024)
319                );
320            },
321        );
322    }
323
324    #[test]
325    fn test_extension_detector() {
326        let detector = ExtensionDetector::new();
327        let resource = detector.detect();
328
329        assert_eq!(
330            get_string_value(&resource, semconv::SERVICE_NAME),
331            Some("opentelemetry-lambda-extension".to_string())
332        );
333        assert_eq!(
334            get_string_value(&resource, semconv::TELEMETRY_SDK_NAME),
335            Some("opentelemetry-lambda-extension".to_string())
336        );
337        assert_eq!(
338            get_string_value(&resource, semconv::TELEMETRY_SDK_LANGUAGE),
339            Some("rust".to_string())
340        );
341        assert!(get_string_value(&resource, semconv::TELEMETRY_SDK_VERSION).is_some());
342    }
343
344    #[test]
345    fn test_resource_builder_custom_attributes() {
346        temp_env::with_vars([(AWS_EXECUTION_ENV, None::<&str>)], || {
347            let resource = ResourceBuilder::new()
348                .add_string("custom.string", "value")
349                .add_int("custom.int", 42)
350                .add_bool("custom.bool", true)
351                .build();
352
353            assert_eq!(
354                get_string_value(&resource, "custom.string"),
355                Some("value".to_string())
356            );
357            assert_eq!(get_int_value(&resource, "custom.int"), Some(42));
358        });
359    }
360
361    #[test]
362    fn test_detect_resource() {
363        temp_env::with_vars([(AWS_EXECUTION_ENV, None::<&str>)], || {
364            let resource = detect_resource();
365            // Should at least have extension detector attributes
366            assert!(get_string_value(&resource, semconv::SERVICE_NAME).is_some());
367        });
368    }
369
370    #[test]
371    fn test_to_proto_resource() {
372        let resource = Resource::builder_empty()
373            .with_attribute(KeyValue::new("test.key", "test.value"))
374            .build();
375
376        let proto = to_proto_resource(&resource);
377
378        assert!(!proto.attributes.is_empty());
379        let attr = &proto.attributes[0];
380        assert_eq!(attr.key, "test.key");
381        match &attr.value {
382            Some(v) => match &v.value {
383                Some(any_value::Value::StringValue(s)) => assert_eq!(s, "test.value"),
384                _ => panic!("Expected string value"),
385            },
386            None => panic!("Expected value"),
387        }
388    }
389
390    #[test]
391    fn test_service_name_is_extension_not_function() {
392        temp_env::with_vars(
393            [
394                (AWS_EXECUTION_ENV, Some("AWS_Lambda_nodejs18.x")),
395                (AWS_LAMBDA_FUNCTION_NAME, Some("my-lambda-function")),
396            ],
397            || {
398                let resource = detect_resource();
399
400                let service_name = get_string_value(&resource, semconv::SERVICE_NAME);
401                let faas_name = get_string_value(&resource, semconv::FAAS_NAME);
402
403                assert_eq!(
404                    service_name,
405                    Some("opentelemetry-lambda-extension".to_string()),
406                    "service.name should be the extension name, not the function name"
407                );
408
409                assert_eq!(
410                    faas_name,
411                    Some("my-lambda-function".to_string()),
412                    "faas.name should be the Lambda function name"
413                );
414            },
415        );
416    }
417
418    #[test]
419    fn test_faas_instance_from_log_stream() {
420        temp_env::with_vars(
421            [
422                (AWS_EXECUTION_ENV, Some("AWS_Lambda_nodejs18.x")),
423                (
424                    AWS_LAMBDA_LOG_STREAM_NAME,
425                    Some("2024/01/01/[$LATEST]abc123"),
426                ),
427            ],
428            || {
429                let detector = AwsLambdaDetector::new();
430                let resource = detector.detect();
431
432                assert_eq!(
433                    get_string_value(&resource, semconv::FAAS_INSTANCE),
434                    Some("2024/01/01/[$LATEST]abc123".to_string())
435                );
436            },
437        );
438    }
439}