apollo-http-client 0.3.0

HTTP client for Apollo platform
Documentation
use apollo_http_shared::header::header_attr;
use apollo_http_shared::protocol::version_str;
use apollo_opentelemetry::tower::MakeSpan;
use opentelemetry::KeyValue;
use opentelemetry::trace::{SpanBuilder, SpanKind, Status, TraceContextExt};
use opentelemetry_semantic_conventions::attribute as semconv;

use crate::config::SpansConfig;
use crate::protocol::{HttpBody, Origin, normalize_method};

/// Manages OTel span creation and attribute recording for HTTP client requests.
#[derive(Clone)]
pub(crate) struct HttpSpans {
    spans_config: SpansConfig,
}

impl HttpSpans {
    pub(crate) fn new(spans_config: SpansConfig) -> Self {
        Self { spans_config }
    }

    pub(crate) fn request_body_size(&self) -> bool {
        self.spans_config.request_body_size
    }

    pub(crate) fn response_body_size(&self) -> bool {
        self.spans_config.response_body_size
    }

    /// Records response attributes on the current span after a successful HTTP exchange.
    pub(crate) fn on_response(
        &self,
        protocol_version: http::Version,
        origin: &Origin,
        status: http::StatusCode,
        resp_headers: &http::HeaderMap,
    ) {
        let status_code = i64::from(status.as_u16());

        let cx = opentelemetry::Context::current();
        cx.span()
            .set_attribute(KeyValue::new(semconv::SERVER_ADDRESS, origin.address()));
        if let Some(port) = origin.port() {
            cx.span()
                .set_attribute(KeyValue::new(semconv::SERVER_PORT, port));
        }
        cx.span().set_attribute(KeyValue::new(
            semconv::HTTP_RESPONSE_STATUS_CODE,
            status_code,
        ));
        cx.span().set_attribute(KeyValue::new(
            semconv::NETWORK_PROTOCOL_VERSION,
            version_str(protocol_version),
        ));
        for name in &self.spans_config.response_headers {
            if let Some(kv) = header_attr("http.response.header", resp_headers, name.as_str()) {
                cx.span().set_attribute(kv);
            }
        }
        // Per OTel semconv for client spans: 4xx and 5xx → Error; description omitted
        // because the reason is inferable from http.response.status_code.
        // https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status
        if status.is_client_error() || status.is_server_error() {
            cx.span()
                .set_attribute(KeyValue::new(semconv::ERROR_TYPE, status_code.to_string()));
            cx.span().set_status(Status::error(""));
        }
    }

    /// Records error attributes on the current span after a transport-level failure.
    pub(crate) fn on_error(&self, origin: &Origin, error: &str) {
        let cx = opentelemetry::Context::current();
        cx.span()
            .set_attribute(KeyValue::new(semconv::SERVER_ADDRESS, origin.address()));
        if let Some(port) = origin.port() {
            cx.span()
                .set_attribute(KeyValue::new(semconv::SERVER_PORT, port));
        }
        cx.span()
            .set_attribute(KeyValue::new(semconv::ERROR_TYPE, "_OTHER"));
        cx.span().set_status(Status::error(error.to_owned()));
    }
}

impl MakeSpan<http::Request<HttpBody>> for HttpSpans {
    fn make_span(&self, req: &http::Request<HttpBody>) -> SpanBuilder {
        let method = normalize_method(req.method());
        let mut attrs = vec![
            KeyValue::new(semconv::HTTP_REQUEST_METHOD, method),
            KeyValue::new(semconv::URL_FULL, req.uri().to_string()),
        ];
        if method == "_OTHER" {
            attrs.push(KeyValue::new(
                semconv::HTTP_REQUEST_METHOD_ORIGINAL,
                req.method().to_string(),
            ));
        }
        // https://opentelemetry.io/docs/specs/semconv/http/http-spans/
        if self.spans_config.url_scheme {
            attrs.push(KeyValue::new(
                semconv::URL_SCHEME,
                req.uri().scheme_str().unwrap_or("").to_owned(),
            ));
        }
        if self.spans_config.user_agent
            && let Some(ua) = req
                .headers()
                .get(http::header::USER_AGENT)
                .and_then(|v| v.to_str().ok())
        {
            attrs.push(KeyValue::new(semconv::USER_AGENT_ORIGINAL, ua.to_owned()));
        }
        if self.spans_config.network_transport {
            attrs.push(KeyValue::new(semconv::NETWORK_TRANSPORT, "tcp"));
        }
        if let Some(addr) = &self.spans_config.network_local_address {
            attrs.push(KeyValue::new(semconv::NETWORK_LOCAL_ADDRESS, addr.clone()));
        }
        if let Some(port) = self.spans_config.network_local_port {
            attrs.push(KeyValue::new(semconv::NETWORK_LOCAL_PORT, i64::from(port)));
        }
        for name in &self.spans_config.request_headers {
            if let Some(kv) = header_attr("http.request.header", req.headers(), name.as_str()) {
                attrs.push(kv);
            }
        }
        let span_name = if method == "_OTHER" { "HTTP" } else { method };
        SpanBuilder::from_name(span_name.to_owned())
            .with_kind(SpanKind::Client)
            .with_attributes(attrs)
    }
}

#[cfg(test)]
mod tests {
    use apollo_opentelemetry::tower::MakeSpan as _;
    use bytes::Bytes;
    use http_body_util::{BodyExt as _, Full};
    use opentelemetry::Value;
    use opentelemetry::trace::SpanBuilder;

    use crate::config::SpansConfig;
    use crate::protocol::HttpBody;

    fn empty_body() -> HttpBody {
        Full::new(Bytes::new())
            .map_err(|e: std::convert::Infallible| match e {})
            .boxed()
    }

    fn make_builder(config: SpansConfig, req: &http::Request<HttpBody>) -> SpanBuilder {
        super::HttpSpans::new(config).make_span(req)
    }

    fn attr<'a>(builder: &'a SpanBuilder, key: &str) -> Option<&'a Value> {
        builder
            .attributes
            .as_ref()?
            .iter()
            .find(|kv| kv.key.as_str() == key)
            .map(|kv| &kv.value)
    }

    fn has_attr(builder: &SpanBuilder, key: &str) -> bool {
        attr(builder, key).is_some()
    }

    /// Standard HTTP methods map directly; span name matches the method string.
    #[test]
    fn standard_method_sets_method_attr_and_span_name() {
        let req = http::Request::builder()
            .method(http::Method::GET)
            .uri("http://example.com/path")
            .body(empty_body())
            .unwrap();
        let builder = make_builder(SpansConfig::default(), &req);
        assert_eq!(builder.name.as_ref(), "GET");
        assert_eq!(
            attr(&builder, "http.request.method"),
            Some(&Value::from("GET"))
        );
        assert_eq!(
            attr(&builder, "url.full"),
            Some(&Value::from("http://example.com/path")),
        );
        assert!(!has_attr(&builder, "http.request.method_original"));
    }

    /// Non-standard methods produce `_OTHER`; the original method is preserved and
    /// the span name falls back to the generic `"HTTP"`.
    #[test]
    fn non_standard_method_uses_other_and_preserves_original() {
        let method = http::Method::from_bytes(b"PURGE").unwrap();
        let req = http::Request::builder()
            .method(method)
            .uri("http://example.com/")
            .body(empty_body())
            .unwrap();
        let builder = make_builder(SpansConfig::default(), &req);
        assert_eq!(builder.name.as_ref(), "HTTP");
        assert_eq!(
            attr(&builder, "http.request.method"),
            Some(&Value::from("_OTHER"))
        );
        assert_eq!(
            attr(&builder, "http.request.method_original"),
            Some(&Value::from("PURGE")),
        );
    }

    /// `url.scheme` is absent by default per OTel opt-in rules.
    #[test]
    fn url_scheme_absent_by_default() {
        let req = http::Request::builder()
            .method(http::Method::GET)
            .uri("http://example.com/")
            .body(empty_body())
            .unwrap();
        let builder = make_builder(SpansConfig::default(), &req);
        assert!(!has_attr(&builder, "url.scheme"));
    }

    /// Enabling `url_scheme` adds the scheme from the request URI.
    #[test]
    fn url_scheme_opt_in_adds_attribute() {
        let req = http::Request::builder()
            .method(http::Method::GET)
            .uri("https://example.com/")
            .body(empty_body())
            .unwrap();
        let builder = make_builder(
            SpansConfig {
                url_scheme: true,
                ..Default::default()
            },
            &req,
        );
        assert_eq!(attr(&builder, "url.scheme"), Some(&Value::from("https")));
    }

    /// `user_agent.original` is absent by default even when the header is present.
    #[test]
    fn user_agent_absent_by_default() {
        let req = http::Request::builder()
            .method(http::Method::GET)
            .uri("http://example.com/")
            .header(http::header::USER_AGENT, "my-agent/1.0")
            .body(empty_body())
            .unwrap();
        let builder = make_builder(SpansConfig::default(), &req);
        assert!(!has_attr(&builder, "user_agent.original"));
    }

    /// Enabling `user_agent` captures the `User-Agent` header value.
    #[test]
    fn user_agent_opt_in_captures_header() {
        let req = http::Request::builder()
            .method(http::Method::GET)
            .uri("http://example.com/")
            .header(http::header::USER_AGENT, "my-agent/1.0")
            .body(empty_body())
            .unwrap();
        let builder = make_builder(
            SpansConfig {
                user_agent: true,
                ..Default::default()
            },
            &req,
        );
        assert_eq!(
            attr(&builder, "user_agent.original"),
            Some(&Value::from("my-agent/1.0")),
        );
    }

    /// `network.transport` is opt-in and always `"tcp"` when enabled.
    #[test]
    fn network_transport_opt_in_is_tcp() {
        let req = http::Request::builder()
            .method(http::Method::GET)
            .uri("http://example.com/")
            .body(empty_body())
            .unwrap();
        let builder = make_builder(
            SpansConfig {
                network_transport: true,
                ..Default::default()
            },
            &req,
        );
        assert_eq!(
            attr(&builder, "network.transport"),
            Some(&Value::from("tcp"))
        );
    }

    /// A configured `network_local_address` is recorded as a span attribute.
    #[test]
    fn network_local_address_opt_in() {
        let req = http::Request::builder()
            .method(http::Method::GET)
            .uri("http://example.com/")
            .body(empty_body())
            .unwrap();
        let builder = make_builder(
            SpansConfig {
                network_local_address: Some("10.0.0.1".to_owned()),
                ..Default::default()
            },
            &req,
        );
        assert_eq!(
            attr(&builder, "network.local.address"),
            Some(&Value::from("10.0.0.1")),
        );
    }

    /// A configured `network_local_port` is recorded as an `i64` span attribute.
    #[test]
    fn network_local_port_opt_in() {
        let req = http::Request::builder()
            .method(http::Method::GET)
            .uri("http://example.com/")
            .body(empty_body())
            .unwrap();
        let builder = make_builder(
            SpansConfig {
                network_local_port: Some(8080),
                ..Default::default()
            },
            &req,
        );
        assert_eq!(
            attr(&builder, "network.local.port"),
            Some(&Value::I64(8080))
        );
    }
}