From 8e2b390d5ef59fd1b11fdab278872bd09fb928f0 Mon Sep 17 00:00:00 2001 From: Jordan Gonzalez <30836115+duncanista@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:32:15 -0400 Subject: [PATCH 1/2] refactor(build): feature-gate OTLP and AppSec subsystems (default-on) Put the otlp and appsec subsystems behind Cargo features that are ON by default, so the shipped binary is unchanged, but can be turned off to drop their heavy, exclusive dependencies: - otlp gates: opentelemetry-proto, opentelemetry-semantic-conventions, tonic, tonic-types (all otlp-only; prost stays since it is shared with proxy/interceptor, traces/trace_processor and datadog-protos). - appsec gates: libddwaf (and transitively libddwaf-sys + its C build toolchain and aws-lc crypto). lib.rs cfg-gates 'pub mod otlp' / 'pub mod appsec'. When appsec is off, an uninhabited AppSecProcessorStub stands in for the processor type so the Option>> fields, the proxy state tuple, and all 'appsec: None' call sites keep the same shape; only the code paths that actually call into the WAF are cfg-gated. start_otlp_agent gets a no-op fallback returning None. fips implies appsec (FIPS-mode WAF requires libddwaf compiled in). Both the default build and a build with everything in 'default' except otlp/appsec are clippy-clean (pedantic + unwrap_used denied). --- bottlecap/Cargo.toml | 29 +++++-- bottlecap/src/bin/bottlecap/main.rs | 32 +++++++- bottlecap/src/lib.rs | 12 +++ .../src/lifecycle/invocation/triggers/body.rs | 4 + bottlecap/src/proxy/interceptor.rs | 45 +++++++--- bottlecap/src/traces/trace_agent.rs | 5 +- bottlecap/src/traces/trace_processor.rs | 82 +++++++++++-------- 7 files changed, 154 insertions(+), 55 deletions(-) diff --git a/bottlecap/Cargo.toml b/bottlecap/Cargo.toml index 714afa77f..143164ab9 100644 --- a/bottlecap/Cargo.toml +++ b/bottlecap/Cargo.toml @@ -48,14 +48,17 @@ rustls-pki-types = { version = "1.0", default-features = false } hyper-rustls = { version = "0.27.7", default-features = false } rand = { version = "0.8", default-features = false } prost = { version = "0.14", default-features = false } -tonic = { version = "0.14", features = ["transport", "codegen", "server", "channel", "router"], default-features = false } -tonic-types = { version = "0.14", default-features = false } +# otlp-only (also pulled transitively via datadog-protos, but the OTLP gRPC server +# needs them as direct deps). Gated behind the `otlp` feature. +tonic = { version = "0.14", features = ["transport", "codegen", "server", "channel", "router"], default-features = false, optional = true } +tonic-types = { version = "0.14", default-features = false, optional = true } zstd = { version = "0.13.3", default-features = false } futures = { version = "0.3.31", default-features = false } serde-aux = { version = "4.7", default-features = false } serde_html_form = { version = "0.2", default-features = false } -opentelemetry-proto = { version = "0.31.0", features = ["trace", "with-serde", "gen-tonic"] } -opentelemetry-semantic-conventions = { version = "0.30", features = ["semconv_experimental"] } +# otlp-only. Gated behind the `otlp` feature. +opentelemetry-proto = { version = "0.31.0", features = ["trace", "with-serde", "gen-tonic"], optional = true } +opentelemetry-semantic-conventions = { version = "0.30", features = ["semconv_experimental"], optional = true } # Pinned to <0.8.3: version 0.8.3 upgraded to openssl-probe 0.2.x which scans all cert # directories and parses ~200 individual cert files on Lambda instead of loading a single # bundle file, adding ~45ms to each reqwest::Client::build() call. @@ -85,7 +88,8 @@ datadog-opentelemetry = { git = "https://github.com/DataDog/dd-trace-rs", rev = dogstatsd = { git = "https://github.com/DataDog/serverless-components", rev = "8ce37eb029410b7cf30847376772e3af6baa5f5c", default-features = false } datadog-fips = { git = "https://github.com/DataDog/serverless-components", rev = "8ce37eb029410b7cf30847376772e3af6baa5f5c", default-features = false } datadog-agent-config = { git = "https://github.com/DataDog/serverless-components", rev = "8ce37eb029410b7cf30847376772e3af6baa5f5c", default-features = false } -libddwaf = { version = "1.28.1", git = "https://github.com/DataDog/libddwaf-rust", rev = "d1534a158d976bd4f747bf9fcc58e0712d2d17fc", default-features = false, features = ["serde"] } +# appsec-only. Gated behind the `appsec` feature. +libddwaf = { version = "1.28.1", git = "https://github.com/DataDog/libddwaf-rust", rev = "d1534a158d976bd4f747bf9fcc58e0712d2d17fc", default-features = false, features = ["serde"], optional = true } [dev-dependencies] figment = { version = "0.10", default-features = false, features = ["yaml", "env", "test"] } @@ -129,6 +133,8 @@ tikv-jemallocator = "0.5" [features] default = [ + "otlp", + "appsec", "reqwest/rustls-tls-native-roots", "datadog-fips/default", "datadog-agent-config/https", @@ -137,6 +143,17 @@ default = [ "libdd-trace-obfuscation/https", "libdd-trace-stats/https", ] +# OpenTelemetry Protocol (OTLP) ingest. Pulls in the heavy `tonic` / +# `opentelemetry-proto` stack. On by default so the shipped binary is unchanged. +otlp = [ + "dep:opentelemetry-proto", + "dep:opentelemetry-semantic-conventions", + "dep:tonic", + "dep:tonic-types", +] +# App & API Protection (AAP / AppSec). Pulls in the `libddwaf` WAF engine. On by +# default so the shipped binary is unchanged. +appsec = ["dep:libddwaf"] fips = [ "datadog-agent-config/fips", "libdd-common/fips", @@ -144,6 +161,8 @@ fips = [ "libdd-trace-obfuscation/fips", "libdd-trace-stats/fips", "datadog-fips/fips", + # FIPS-mode WAF only makes sense when AppSec is compiled in. + "appsec", "libddwaf/fips", "reqwest/rustls-tls-native-roots-no-provider", "rustls/fips", diff --git a/bottlecap/src/bin/bottlecap/main.rs b/bottlecap/src/bin/bottlecap/main.rs index 440fa2f7b..8fc03444f 100644 --- a/bottlecap/src/bin/bottlecap/main.rs +++ b/bottlecap/src/bin/bottlecap/main.rs @@ -16,11 +16,16 @@ use tikv_jemallocator::Jemalloc; #[global_allocator] static GLOBAL: Jemalloc = Jemalloc; +#[cfg(not(feature = "appsec"))] +use bottlecap::AppSecProcessorStub as AppSecProcessor; +#[cfg(feature = "appsec")] +use bottlecap::appsec::processor::{ + Error::FeatureDisabled as AppSecFeatureDisabled, Processor as AppSecProcessor, +}; +#[cfg(feature = "otlp")] +use bottlecap::otlp::{agent::Agent as OtlpAgent, should_enable_otlp_agent}; use bottlecap::{ DOGSTATSD_PORT, LAMBDA_RUNTIME_SLUG, - appsec::processor::{ - Error::FeatureDisabled as AppSecFeatureDisabled, Processor as AppSecProcessor, - }, config::{ self, Config, aws::{AwsConfig, build_lambda_function_arn}, @@ -53,7 +58,6 @@ use bottlecap::{ flusher::LogsFlusher, lambda::DurableContextUpdate, }, - otlp::{agent::Agent as OtlpAgent, should_enable_otlp_agent}, proxy::{interceptor, should_start_proxy}, secrets::decrypt, tags::{ @@ -388,6 +392,7 @@ async fn extension_loop_active( }); // AppSec processor (if enabled) + #[cfg(feature = "appsec")] let appsec_processor = match AppSecProcessor::new(config) { Ok(p) => Some(Arc::new(TokioMutex::new(p))), Err(AppSecFeatureDisabled) => None, @@ -398,6 +403,10 @@ async fn extension_loop_active( None } }; + // AppSec compiled out: the processor is always absent, but the value is still + // threaded through trace/proxy wiring (as `None`) to keep call sites uniform. + #[cfg(not(feature = "appsec"))] + let appsec_processor: Option>> = None; let ( trace_agent_channel, @@ -1441,6 +1450,7 @@ async fn setup_telemetry_client( Ok(cancel_token) } +#[cfg(feature = "otlp")] fn start_otlp_agent( config: &Arc, tags_provider: Arc, @@ -1473,6 +1483,20 @@ fn start_otlp_agent( Some(cancel_token) } +/// No-op fallback when the `otlp` feature is disabled: the OTLP agent is not +/// compiled in, so there is never a cancellation token to return. +#[cfg(not(feature = "otlp"))] +#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)] +fn start_otlp_agent( + _config: &Arc, + _tags_provider: Arc, + _trace_processor: Arc, + _trace_tx: Sender, + _stats_concentrator: StatsConcentratorHandle, +) -> Option { + None +} + fn start_api_runtime_proxy( config: &Arc, aws_config: Arc, diff --git a/bottlecap/src/lib.rs b/bottlecap/src/lib.rs index df94fd246..680c10a75 100644 --- a/bottlecap/src/lib.rs +++ b/bottlecap/src/lib.rs @@ -19,6 +19,7 @@ // Allow use of the `coverage_nightly` attribute #![cfg_attr(coverage_nightly, feature(coverage_attribute))] +#[cfg(feature = "appsec")] pub mod appsec; pub mod config; pub mod event_bus; @@ -31,6 +32,7 @@ pub mod logger; pub mod logs; pub mod lwa; pub mod metrics; +#[cfg(feature = "otlp")] pub mod otlp; pub mod proc; pub mod proxy; @@ -38,6 +40,16 @@ pub mod secrets; pub mod tags; pub mod traces; +/// Placeholder standing in for [`appsec::processor::Processor`] when the `appsec` +/// feature is disabled. It keeps `Option>>` fields and the proxy +/// state tuple identically shaped across both builds, so call sites that merely +/// thread the value through (always as `None`) compile unchanged. It is an +/// uninhabited enum and can never be constructed; every code path that would +/// actually invoke the WAF is itself `#[cfg(feature = "appsec")]`-gated. +#[cfg(not(feature = "appsec"))] +#[derive(Debug, Clone, Copy)] +pub enum AppSecProcessorStub {} + pub const LAMBDA_RUNTIME_SLUG: &str = "lambda"; // todo: consider making this configurable diff --git a/bottlecap/src/lifecycle/invocation/triggers/body.rs b/bottlecap/src/lifecycle/invocation/triggers/body.rs index 6d09961fd..f283f5701 100644 --- a/bottlecap/src/lifecycle/invocation/triggers/body.rs +++ b/bottlecap/src/lifecycle/invocation/triggers/body.rs @@ -16,6 +16,10 @@ pub struct Body { impl Body { /// Obtains a reader to the data contained in this [`Body`], decoded from /// Base64 if [`Body::is_base64_encoded`] is `true`. + /// + /// Only consumed by the App and API Protection request/response inspection + /// code, so it is dead code when the `appsec` feature is disabled. + #[cfg_attr(not(feature = "appsec"), allow(dead_code))] pub(crate) fn reader<'a>(&'a self) -> Result>, base64::DecodeError> { let Some(body) = &self.body else { return Ok(None); diff --git a/bottlecap/src/proxy/interceptor.rs b/bottlecap/src/proxy/interceptor.rs index 21a018377..0a077764e 100644 --- a/bottlecap/src/proxy/interceptor.rs +++ b/bottlecap/src/proxy/interceptor.rs @@ -1,9 +1,13 @@ +#[cfg(not(feature = "appsec"))] +use crate::AppSecProcessorStub as AppSecProcessor; +#[cfg(feature = "appsec")] +use crate::appsec::processor::Processor as AppSecProcessor; use crate::lifecycle::invocation::processor_service::InvocationProcessorHandle; +#[cfg(feature = "appsec")] use crate::lifecycle::invocation::triggers::IdentifiedTrigger; use crate::traces::propagation::DatadogCompositePropagator; use crate::{ - appsec::processor::Processor as AppSecProcessor, config::aws::AwsConfig, - extension::EXTENSION_HOST, lwa, proxy::tee_body::TeeBodyWithCompletion, + config::aws::AwsConfig, extension::EXTENSION_HOST, lwa, proxy::tee_body::TeeBodyWithCompletion, }; use axum::{ Router, @@ -179,6 +183,7 @@ async fn invocation_next_proxy( if let Ok(body) = intercepted_completion_receiver.await { debug!("PROXY | invocation_next_proxy | intercepted body completed"); + #[cfg(feature = "appsec")] if let Some(appsec_processor) = appsec_processor && let Some(request_id) = intercepted_parts_clone .headers @@ -195,6 +200,10 @@ async fn invocation_next_proxy( } } } + // AppSec compiled out: the processor is always `None`; drop it to keep + // the State tuple shape uniform without an unused-variable warning. + #[cfg(not(feature = "appsec"))] + let _ = appsec_processor; if aws_config.aws_lwa_proxy_lambda_runtime_api.is_some() { lwa::process_invocation_next( @@ -241,6 +250,7 @@ async fn invocation_response_proxy( join_set.spawn(async move { if let Ok(body) = outgoing_completion_receiver.await { debug!("PROXY | invocation_response_proxy | intercepted outgoing body completed"); + #[cfg(feature = "appsec")] if let Some(appsec_processor) = appsec_processor { appsec_processor .lock() @@ -248,6 +258,8 @@ async fn invocation_response_proxy( .process_invocation_result(&request_id, &body) .await; } + #[cfg(not(feature = "appsec"))] + let _ = appsec_processor; if aws_config_clone.aws_lwa_proxy_lambda_runtime_api.is_some() { lwa::process_invocation_response(&invocation_processor, &body).await; @@ -304,14 +316,17 @@ async fn invocation_error_proxy( request: Request, ) -> Response { debug!("PROXY | invocation_error_proxy | api_version: {api_version}, request_id: {request_id}"); - let State((_, _, _, appsec_processor, _, _)) = &state; - if let Some(appsec_processor) = appsec_processor { - // Marking any outstanding security context as finalized by sending a blank response. - appsec_processor - .lock() - .await - .process_invocation_result(&request_id, &Bytes::from("{}")) - .await; + #[cfg(feature = "appsec")] + { + let State((_, _, _, appsec_processor, _, _)) = &state; + if let Some(appsec_processor) = appsec_processor { + // Marking any outstanding security context as finalized by sending a blank response. + appsec_processor + .lock() + .await + .process_invocation_result(&request_id, &Bytes::from("{}")) + .await; + } } passthrough_proxy(state, request).await @@ -442,13 +457,16 @@ mod tests { use hyper_util::rt::TokioIo; use super::*; + #[cfg(feature = "appsec")] + use crate::appsec::processor::Error::FeatureDisabled as AppSecFeatureDisabled; use crate::lifecycle::invocation::processor_service::InvocationProcessorService; use crate::{ - LAMBDA_RUNTIME_SLUG, appsec::processor::Error::FeatureDisabled as AppSecFeatureDisabled, - config::Config, tags::provider::Provider, traces::propagation::DatadogCompositePropagator, + LAMBDA_RUNTIME_SLUG, config::Config, tags::provider::Provider, + traces::propagation::DatadogCompositePropagator, }; #[tokio::test] + #[allow(clippy::too_many_lines)] async fn test_noop_proxy() { let aws_lwa_lambda_runtime_api = "127.0.0.1:12345"; let aws_lambda_runtime_api = "127.0.0.1:12344"; @@ -513,6 +531,7 @@ mod tests { invocation_processor_service.run().await; }); + #[cfg(feature = "appsec")] let appsec_processor = match AppSecProcessor::new(&config) { Ok(p) => Some(Arc::new(TokioMutex::new(p))), Err(AppSecFeatureDisabled) => None, @@ -523,6 +542,8 @@ mod tests { None } }; + #[cfg(not(feature = "appsec"))] + let appsec_processor: Option>> = None; let proxy_handle = start( aws_config, diff --git a/bottlecap/src/traces/trace_agent.rs b/bottlecap/src/traces/trace_agent.rs index 37b71d29f..1bbfeee26 100644 --- a/bottlecap/src/traces/trace_agent.rs +++ b/bottlecap/src/traces/trace_agent.rs @@ -21,9 +21,12 @@ use tokio_util::sync::CancellationToken; use tower_http::limit::RequestBodyLimitLayer; use tracing::{debug, error, warn}; +#[cfg(not(feature = "appsec"))] +use crate::AppSecProcessorStub as AppSecProcessor; +#[cfg(feature = "appsec")] +use crate::appsec::processor::Processor as AppSecProcessor; use crate::traces::trace_processor::SendingTraceProcessor; use crate::{ - appsec::processor::Processor as AppSecProcessor, config, http::{extract_request_body, handler_not_found}, lifecycle::invocation::{ diff --git a/bottlecap/src/traces/trace_processor.rs b/bottlecap/src/traces/trace_processor.rs index 256d8bf14..ad0fa1591 100644 --- a/bottlecap/src/traces/trace_processor.rs +++ b/bottlecap/src/traces/trace_processor.rs @@ -1,7 +1,11 @@ // Copyright 2023-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +#[cfg(not(feature = "appsec"))] +use crate::AppSecProcessorStub as AppSecProcessor; +#[cfg(feature = "appsec")] use crate::appsec::processor::Processor as AppSecProcessor; +#[cfg(feature = "appsec")] use crate::appsec::processor::context::HoldArguments; use crate::config; use crate::lifecycle::invocation::processor::S_TO_MS; @@ -522,41 +526,53 @@ impl SendingTraceProcessor { body_size: usize, span_pointers: Option>, ) -> Result<(), SendError> { - traces = if let Some(appsec) = &self.appsec { - let mut appsec = appsec.lock().await; - traces.into_iter().filter_map(|mut trace| { - let Some(span) = AppSecProcessor::service_entry_span_mut(&mut trace) else { - return Some(trace); - }; - - let (finalized, ctx) = appsec.process_span(span); - if finalized { - Some(trace) - } else if let Some(ctx) = ctx{ - debug!("TRACE_PROCESSOR | Holding trace for App & API Protection additional data"); - ctx.hold_trace(trace, SendingTraceProcessor{ appsec: None, processor: self.processor.clone(), trace_tx: self.trace_tx.clone(), stats_generator: self.stats_generator.clone() }, HoldArguments{ - config:Arc::clone(&config), - tags_provider:Arc::clone(&tags_provider), - body_size, - span_pointers:span_pointers.clone(), - tracer_header_tags_lang: header_tags.lang.to_string(), - tracer_header_tags_lang_version: header_tags.lang_version.to_string(), - tracer_header_tags_lang_interpreter: header_tags.lang_interpreter.to_string(), - tracer_header_tags_lang_vendor: header_tags.lang_vendor.to_string(), - tracer_header_tags_tracer_version: header_tags.tracer_version.to_string(), - tracer_header_tags_container_id: header_tags.container_id.to_string(), - tracer_header_tags_client_computed_top_level: header_tags.client_computed_top_level, - tracer_header_tags_client_computed_stats: header_tags.client_computed_stats, - tracer_header_tags_dropped_p0_traces: header_tags.dropped_p0_traces, - tracer_header_tags_dropped_p0_spans: header_tags.dropped_p0_spans, - }); - None + // App & API Protection span processing. Only compiled in when the `appsec` + // feature is enabled; otherwise the traces pass through untouched. The + // outer block keeps `traces` reassigned (hence `mut` used) in both builds. + traces = { + #[cfg(feature = "appsec")] + { + if let Some(appsec) = &self.appsec { + let mut appsec = appsec.lock().await; + traces.into_iter().filter_map(|mut trace| { + let Some(span) = AppSecProcessor::service_entry_span_mut(&mut trace) else { + return Some(trace); + }; + + let (finalized, ctx) = appsec.process_span(span); + if finalized { + Some(trace) + } else if let Some(ctx) = ctx{ + debug!("TRACE_PROCESSOR | Holding trace for App & API Protection additional data"); + ctx.hold_trace(trace, SendingTraceProcessor{ appsec: None, processor: self.processor.clone(), trace_tx: self.trace_tx.clone(), stats_generator: self.stats_generator.clone() }, HoldArguments{ + config:Arc::clone(&config), + tags_provider:Arc::clone(&tags_provider), + body_size, + span_pointers:span_pointers.clone(), + tracer_header_tags_lang: header_tags.lang.to_string(), + tracer_header_tags_lang_version: header_tags.lang_version.to_string(), + tracer_header_tags_lang_interpreter: header_tags.lang_interpreter.to_string(), + tracer_header_tags_lang_vendor: header_tags.lang_vendor.to_string(), + tracer_header_tags_tracer_version: header_tags.tracer_version.to_string(), + tracer_header_tags_container_id: header_tags.container_id.to_string(), + tracer_header_tags_client_computed_top_level: header_tags.client_computed_top_level, + tracer_header_tags_client_computed_stats: header_tags.client_computed_stats, + tracer_header_tags_dropped_p0_traces: header_tags.dropped_p0_traces, + tracer_header_tags_dropped_p0_spans: header_tags.dropped_p0_spans, + }); + None + } else { + Some(trace) + } + }).collect() } else { - Some(trace) + traces } - }).collect() - } else { - traces + } + #[cfg(not(feature = "appsec"))] + { + traces + } }; if traces.is_empty() { From 434f80f8ec7ea673afef38f57d2256f2cd08d47f Mon Sep 17 00:00:00 2001 From: Jordan Gonzalez <30836115+duncanista@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:23:53 -0400 Subject: [PATCH 2/2] fix(build): keep OTLP compiled into the FIPS feature set fips builds use --no-default-features --features=fips, which omitted otlp and would drop the OTLP subsystem from shipped FIPS layers. Add otlp to the fips feature list alongside appsec. --- bottlecap/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bottlecap/Cargo.toml b/bottlecap/Cargo.toml index 143164ab9..f491bd49c 100644 --- a/bottlecap/Cargo.toml +++ b/bottlecap/Cargo.toml @@ -161,6 +161,9 @@ fips = [ "libdd-trace-obfuscation/fips", "libdd-trace-stats/fips", "datadog-fips/fips", + # OTLP is unconditional in the shipped binary; keep it in the FIPS feature set + # too since fips builds use --no-default-features. + "otlp", # FIPS-mode WAF only makes sense when AppSec is compiled in. "appsec", "libddwaf/fips",