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