opentelemetry_lambda_tower/extractors/
http.rs

1//! HTTP event extractors for API Gateway.
2//!
3//! Provides trace context extraction from API Gateway events:
4//! - [`ApiGatewayV2Extractor`] - HTTP API (v2) events
5//! - [`ApiGatewayV1Extractor`] - REST API (v1) events
6//! - [`HttpEventExtractor`] - Type alias for the most common v2 extractor
7
8use crate::extractor::TraceContextExtractor;
9use aws_lambda_events::apigw::{ApiGatewayProxyRequest, ApiGatewayV2httpRequest};
10use http::HeaderMap;
11use lambda_runtime::Context as LambdaContext;
12use opentelemetry::Context;
13use opentelemetry::propagation::Extractor;
14use opentelemetry::trace::TraceContextExt;
15use opentelemetry_semantic_conventions::attribute::{
16    CLIENT_ADDRESS, HTTP_REQUEST_METHOD, HTTP_ROUTE, NETWORK_PROTOCOL_VERSION, SERVER_ADDRESS,
17    URL_PATH, URL_QUERY, URL_SCHEME, USER_AGENT_ORIGINAL,
18};
19use tracing::Span;
20
21/// Type alias for the most common HTTP extractor (API Gateway HTTP API v2).
22pub type HttpEventExtractor = ApiGatewayV2Extractor;
23
24/// Extractor for API Gateway HTTP API (v2) events.
25///
26/// Extracts trace context from HTTP headers using the globally configured
27/// OpenTelemetry propagator. Falls back to the `_X_AMZN_TRACE_ID` environment
28/// variable if no valid trace context is found in headers.
29///
30/// Configure the propagator via `opentelemetry::global::set_text_map_propagator()`.
31///
32/// # Example
33///
34/// ```ignore
35/// use opentelemetry_lambda_tower::{OtelTracingLayer, ApiGatewayV2Extractor};
36///
37/// let layer = OtelTracingLayer::new(ApiGatewayV2Extractor::new());
38/// ```
39#[derive(Clone, Debug, Default)]
40pub struct ApiGatewayV2Extractor;
41
42impl ApiGatewayV2Extractor {
43    /// Creates a new extractor.
44    ///
45    /// Uses the globally configured OpenTelemetry propagator for trace context extraction.
46    pub fn new() -> Self {
47        Self
48    }
49}
50
51impl TraceContextExtractor<ApiGatewayV2httpRequest> for ApiGatewayV2Extractor {
52    fn extract_context(&self, event: &ApiGatewayV2httpRequest) -> Context {
53        let extractor = HeaderMapExtractor(&event.headers);
54        let ctx = opentelemetry::global::get_text_map_propagator(|propagator| {
55            propagator.extract(&extractor)
56        });
57
58        if ctx.span().span_context().is_valid() {
59            return ctx;
60        }
61
62        if let Ok(xray_header) = std::env::var("_X_AMZN_TRACE_ID") {
63            let env_extractor = XRayEnvExtractor::new(&xray_header);
64            let xray_ctx = opentelemetry::global::get_text_map_propagator(|propagator| {
65                propagator.extract(&env_extractor)
66            });
67            if xray_ctx.span().span_context().is_valid() {
68                return xray_ctx;
69            }
70        }
71
72        Context::current()
73    }
74
75    fn trigger_type(&self) -> &'static str {
76        "http"
77    }
78
79    fn span_name(&self, event: &ApiGatewayV2httpRequest, lambda_ctx: &LambdaContext) -> String {
80        // Use "{method} {route}" format per OTel conventions
81        let method = event.request_context.http.method.as_str();
82
83        // route_key contains the route pattern (e.g., "GET /users/{id}")
84        // We want just the route part, or fall back to the raw path
85        let route = event
86            .route_key
87            .as_deref()
88            .and_then(|rk| rk.split_once(' ').map(|(_, route)| route))
89            .or(event.raw_path.as_deref())
90            .unwrap_or(&lambda_ctx.env_config.function_name);
91
92        format!("{} {}", method, route)
93    }
94
95    fn record_attributes(&self, event: &ApiGatewayV2httpRequest, span: &Span) {
96        span.record(
97            HTTP_REQUEST_METHOD,
98            event.request_context.http.method.as_str(),
99        );
100
101        if let Some(ref path) = event.raw_path {
102            span.record(URL_PATH, path.as_str());
103        }
104
105        if let Some(ref route_key) = event.route_key {
106            if let Some((_, route)) = route_key.split_once(' ') {
107                span.record(HTTP_ROUTE, route);
108            } else {
109                span.record(HTTP_ROUTE, route_key.as_str());
110            }
111        }
112
113        span.record(URL_SCHEME, "https");
114
115        if let Some(ref qs) = event.raw_query_string
116            && !qs.is_empty()
117        {
118            span.record(URL_QUERY, qs.as_str());
119        }
120
121        if let Some(ua) = event.headers.get("user-agent")
122            && let Ok(ua_str) = ua.to_str()
123        {
124            span.record(USER_AGENT_ORIGINAL, ua_str);
125        }
126
127        if let Some(ref ip) = event.request_context.http.source_ip {
128            span.record(CLIENT_ADDRESS, ip.as_str());
129        }
130
131        if let Some(host) = event.headers.get("host")
132            && let Ok(host_str) = host.to_str()
133        {
134            span.record(SERVER_ADDRESS, host_str);
135        }
136
137        if let Some(ref protocol) = event.request_context.http.protocol {
138            let version = extract_http_version(protocol);
139            span.record(NETWORK_PROTOCOL_VERSION, version);
140        }
141    }
142}
143
144/// Extractor for API Gateway REST API (v1) events.
145///
146/// Similar to v2 but handles the different event structure.
147/// Uses the globally configured OpenTelemetry propagator for trace context extraction.
148#[derive(Clone, Debug, Default)]
149pub struct ApiGatewayV1Extractor;
150
151impl ApiGatewayV1Extractor {
152    /// Creates a new extractor.
153    ///
154    /// Uses the globally configured OpenTelemetry propagator for trace context extraction.
155    pub fn new() -> Self {
156        Self
157    }
158}
159
160impl TraceContextExtractor<ApiGatewayProxyRequest> for ApiGatewayV1Extractor {
161    fn extract_context(&self, event: &ApiGatewayProxyRequest) -> Context {
162        let extractor = HeaderMapExtractor(&event.headers);
163        let ctx = opentelemetry::global::get_text_map_propagator(|propagator| {
164            propagator.extract(&extractor)
165        });
166
167        if ctx.span().span_context().is_valid() {
168            return ctx;
169        }
170
171        if let Ok(xray_header) = std::env::var("_X_AMZN_TRACE_ID") {
172            let env_extractor = XRayEnvExtractor::new(&xray_header);
173            let xray_ctx = opentelemetry::global::get_text_map_propagator(|propagator| {
174                propagator.extract(&env_extractor)
175            });
176            if xray_ctx.span().span_context().is_valid() {
177                return xray_ctx;
178            }
179        }
180
181        Context::current()
182    }
183
184    fn trigger_type(&self) -> &'static str {
185        "http"
186    }
187
188    fn span_name(&self, event: &ApiGatewayProxyRequest, lambda_ctx: &LambdaContext) -> String {
189        let method = event.http_method.as_str();
190
191        // Use resource pattern for low-cardinality route
192        let route = event
193            .resource
194            .as_deref()
195            .or(event.path.as_deref())
196            .unwrap_or(&lambda_ctx.env_config.function_name);
197
198        format!("{} {}", method, route)
199    }
200
201    fn record_attributes(&self, event: &ApiGatewayProxyRequest, span: &Span) {
202        span.record(HTTP_REQUEST_METHOD, event.http_method.as_str());
203
204        if let Some(ref path) = event.path {
205            span.record(URL_PATH, path.as_str());
206        }
207
208        if let Some(ref resource) = event.resource {
209            span.record(HTTP_ROUTE, resource.as_str());
210        }
211
212        span.record(URL_SCHEME, "https");
213
214        if let Some(ua) = event.headers.get("user-agent")
215            && let Ok(ua_str) = ua.to_str()
216        {
217            span.record(USER_AGENT_ORIGINAL, ua_str);
218        }
219
220        if let Some(ref ip) = event.request_context.identity.source_ip {
221            span.record(CLIENT_ADDRESS, ip.as_str());
222        }
223
224        if let Some(host) = event.headers.get("host")
225            && let Ok(host_str) = host.to_str()
226        {
227            span.record(SERVER_ADDRESS, host_str);
228        }
229
230        if let Some(ref protocol) = event.request_context.protocol {
231            let version = extract_http_version(protocol);
232            span.record(NETWORK_PROTOCOL_VERSION, version);
233        }
234    }
235}
236
237/// Extracts the HTTP version from a protocol string.
238///
239/// Input formats: "HTTP/1.1", "HTTP/2.0", "HTTP/2", etc.
240/// Returns: "1.1", "2", etc. (just the version part)
241fn extract_http_version(protocol: &str) -> &str {
242    protocol
243        .strip_prefix("HTTP/")
244        .map(|v| v.trim_end_matches(".0"))
245        .unwrap_or(protocol)
246}
247
248/// Adapter to extract from http::HeaderMap using OTel's Extractor trait.
249struct HeaderMapExtractor<'a>(&'a HeaderMap);
250
251impl Extractor for HeaderMapExtractor<'_> {
252    fn get(&self, key: &str) -> Option<&str> {
253        self.0.get(key).and_then(|v| v.to_str().ok())
254    }
255
256    fn keys(&self) -> Vec<&str> {
257        self.0.keys().map(|k| k.as_str()).collect()
258    }
259}
260
261/// Adapter to extract traceparent from X-Ray environment variable format.
262///
263/// X-Ray format: `Root=1-{trace-id};Parent={span-id};Sampled={0|1}`
264/// W3C format: `00-{trace-id}-{parent-id}-{flags}`
265///
266/// The converted traceparent is stored in the struct to satisfy the
267/// `Extractor` trait's lifetime requirements.
268struct XRayEnvExtractor {
269    traceparent: Option<String>,
270}
271
272impl XRayEnvExtractor {
273    fn new(xray: &str) -> Self {
274        Self {
275            traceparent: convert_xray_to_traceparent(xray),
276        }
277    }
278}
279
280impl Extractor for XRayEnvExtractor {
281    fn get(&self, key: &str) -> Option<&str> {
282        if key.eq_ignore_ascii_case("traceparent") {
283            self.traceparent.as_deref()
284        } else {
285            None
286        }
287    }
288
289    fn keys(&self) -> Vec<&str> {
290        if self.traceparent.is_some() {
291            vec!["traceparent"]
292        } else {
293            vec![]
294        }
295    }
296}
297
298/// Converts X-Ray trace header to W3C traceparent format.
299///
300/// X-Ray: `Root=1-{epoch}-{random};Parent={span-id};Sampled=1`
301/// W3C: `00-{trace-id}-{parent-id}-{flags}`
302pub fn convert_xray_to_traceparent(xray: &str) -> Option<String> {
303    let mut trace_id = None;
304    let mut parent_id = None;
305    let mut sampled = false;
306
307    for part in xray.split(';') {
308        if let Some(root) = part.strip_prefix("Root=") {
309            trace_id = parse_xray_trace_id(root);
310        } else if let Some(parent) = part.strip_prefix("Parent=") {
311            parent_id = Some(parent.to_string());
312        } else if part == "Sampled=1" {
313            sampled = true;
314        }
315    }
316
317    let trace = trace_id?;
318    let parent = parent_id?;
319
320    if parent.len() != 16 {
321        return None;
322    }
323
324    let flags = if sampled { "01" } else { "00" };
325    Some(format!("00-{}-{}-{}", trace, parent, flags))
326}
327
328/// Parses X-Ray trace ID format to 32-character hex string.
329///
330/// X-Ray format: `1-{epoch_hex}-{random_hex}`
331/// OTel format: `{epoch_hex}{random_hex}` (32 chars total)
332pub fn parse_xray_trace_id(root: &str) -> Option<String> {
333    let parts: Vec<&str> = root.split('-').collect();
334    if parts.len() == 3 && parts[0] == "1" {
335        let trace_id = format!("{}{}", parts[1], parts[2]);
336        if trace_id.len() == 32 {
337            return Some(trace_id);
338        }
339    }
340    None
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use aws_lambda_events::apigw::{
347        ApiGatewayV2httpRequestContext, ApiGatewayV2httpRequestContextHttpDescription,
348    };
349    use http::HeaderValue;
350    use opentelemetry_sdk::propagation::TraceContextPropagator;
351    use serial_test::serial;
352
353    fn create_test_v2_event() -> ApiGatewayV2httpRequest {
354        let mut headers = HeaderMap::new();
355        headers.insert("content-type", HeaderValue::from_static("application/json"));
356
357        let mut http_desc = ApiGatewayV2httpRequestContextHttpDescription::default();
358        http_desc.method = http::Method::GET;
359        http_desc.source_ip = Some("192.168.1.1".to_string());
360
361        let mut request_context = ApiGatewayV2httpRequestContext::default();
362        request_context.http = http_desc;
363
364        let mut event = ApiGatewayV2httpRequest::default();
365        event.headers = headers;
366        event.raw_path = Some("/users/123".to_string());
367        event.route_key = Some("GET /users/{id}".to_string());
368        event.raw_query_string = Some("foo=bar".to_string());
369        event.request_context = request_context;
370        event
371    }
372
373    fn create_test_lambda_context() -> LambdaContext {
374        LambdaContext::default()
375    }
376
377    #[test]
378    fn test_trigger_type() {
379        let extractor = ApiGatewayV2Extractor::new();
380        assert_eq!(extractor.trigger_type(), "http");
381    }
382
383    #[test]
384    fn test_span_name_from_route_v2() {
385        let extractor = ApiGatewayV2Extractor::new();
386        let event = create_test_v2_event();
387        let ctx = create_test_lambda_context();
388
389        let name = extractor.span_name(&event, &ctx);
390        assert_eq!(name, "GET /users/{id}");
391    }
392
393    #[test]
394    fn test_span_name_fallback_to_path() {
395        let extractor = ApiGatewayV2Extractor::new();
396        let mut event = create_test_v2_event();
397        event.route_key = None;
398        let ctx = create_test_lambda_context();
399
400        let name = extractor.span_name(&event, &ctx);
401        assert_eq!(name, "GET /users/123");
402    }
403
404    #[test]
405    fn test_extract_no_trace_context() {
406        let extractor = ApiGatewayV2Extractor::new();
407        let event = create_test_v2_event();
408
409        let ctx = extractor.extract_context(&event);
410
411        // Should return a context, but span context may not be valid
412        // (no parent trace)
413        assert!(!ctx.span().span_context().is_valid());
414    }
415
416    #[test]
417    #[serial]
418    fn test_extract_traceparent_header() {
419        opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
420
421        let extractor = ApiGatewayV2Extractor::new();
422        let mut event = create_test_v2_event();
423
424        event.headers.insert(
425            "traceparent",
426            HeaderValue::from_static("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
427        );
428
429        let ctx = extractor.extract_context(&event);
430
431        assert!(ctx.span().span_context().is_valid());
432        assert_eq!(
433            ctx.span().span_context().trace_id().to_string(),
434            "4bf92f3577b34da6a3ce929d0e0e4736"
435        );
436    }
437
438    #[test]
439    #[serial]
440    fn test_extract_traceparent_case_insensitive() {
441        opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
442
443        let extractor = ApiGatewayV2Extractor::new();
444        let mut event = create_test_v2_event();
445
446        event.headers.insert(
447            "Traceparent",
448            HeaderValue::from_static("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
449        );
450
451        let ctx = extractor.extract_context(&event);
452        assert!(ctx.span().span_context().is_valid());
453    }
454
455    #[test]
456    fn test_extract_invalid_traceparent() {
457        let extractor = ApiGatewayV2Extractor::new();
458        let mut event = create_test_v2_event();
459
460        event
461            .headers
462            .insert("traceparent", HeaderValue::from_static("invalid"));
463
464        let ctx = extractor.extract_context(&event);
465
466        assert!(!ctx.span().span_context().is_valid());
467    }
468
469    #[test]
470    fn test_parse_xray_trace_id() {
471        // Valid X-Ray trace ID
472        let result = parse_xray_trace_id("1-5759e988-bd862e3fe1be46a994272793");
473        assert!(result.is_some());
474        assert_eq!(result.unwrap(), "5759e988bd862e3fe1be46a994272793");
475    }
476
477    #[test]
478    fn test_parse_xray_trace_id_invalid() {
479        assert!(parse_xray_trace_id("invalid").is_none());
480        assert!(parse_xray_trace_id("1-abc").is_none());
481    }
482
483    #[test]
484    fn test_convert_xray_to_traceparent_sampled() {
485        let xray = "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1";
486        let result = convert_xray_to_traceparent(xray);
487        assert!(result.is_some());
488        assert_eq!(
489            result.unwrap(),
490            "00-5759e988bd862e3fe1be46a994272793-53995c3f42cd8ad8-01"
491        );
492    }
493
494    #[test]
495    fn test_convert_xray_to_traceparent_unsampled() {
496        let xray = "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=0";
497        let result = convert_xray_to_traceparent(xray);
498        assert!(result.is_some());
499        assert_eq!(
500            result.unwrap(),
501            "00-5759e988bd862e3fe1be46a994272793-53995c3f42cd8ad8-00"
502        );
503    }
504
505    #[test]
506    fn test_convert_xray_to_traceparent_missing_parent() {
507        let xray = "Root=1-5759e988-bd862e3fe1be46a994272793;Sampled=1";
508        assert!(convert_xray_to_traceparent(xray).is_none());
509    }
510
511    #[test]
512    fn test_convert_xray_to_traceparent_invalid_parent() {
513        let xray = "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=tooshort;Sampled=1";
514        assert!(convert_xray_to_traceparent(xray).is_none());
515    }
516
517    #[test]
518    fn test_xray_env_extractor_valid() {
519        let xray = "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1";
520        let extractor = XRayEnvExtractor::new(xray);
521        let traceparent = extractor.get("traceparent");
522        assert!(traceparent.is_some());
523        assert_eq!(
524            traceparent.unwrap(),
525            "00-5759e988bd862e3fe1be46a994272793-53995c3f42cd8ad8-01"
526        );
527    }
528
529    #[test]
530    fn test_xray_env_extractor_case_insensitive() {
531        let xray = "Root=1-5759e988-bd862e3fe1be46a994272793;Parent=53995c3f42cd8ad8;Sampled=1";
532        let extractor = XRayEnvExtractor::new(xray);
533        assert!(extractor.get("Traceparent").is_some());
534        assert!(extractor.get("TRACEPARENT").is_some());
535    }
536
537    #[test]
538    fn test_extract_http_version_1_1() {
539        assert_eq!(extract_http_version("HTTP/1.1"), "1.1");
540    }
541
542    #[test]
543    fn test_extract_http_version_2_0() {
544        assert_eq!(extract_http_version("HTTP/2.0"), "2");
545    }
546
547    #[test]
548    fn test_extract_http_version_2() {
549        assert_eq!(extract_http_version("HTTP/2"), "2");
550    }
551
552    #[test]
553    fn test_extract_http_version_fallback() {
554        assert_eq!(extract_http_version("unknown"), "unknown");
555    }
556}