From 8fedd769dd50564d41e8cb085d2fe3687ed81e36 Mon Sep 17 00:00:00 2001 From: Yiming Luo <10097700+lym953@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:24:16 -0400 Subject: [PATCH 1/6] fix(deps): bump h2 to 0.4.18 to fix RUSTSEC-2026-0258 (#1335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The **Audit** check (`cargo-audit`) is failing on `main`: [RUSTSEC-2026-0258](https://rustsec.org/advisories/RUSTSEC-2026-0258) — `h2` accepts and queues empty DATA frames without limit, which can lead to unbounded memory usage or a panic on length overflow. Low severity, patched in `h2` 0.4.16. This bumps `h2` 0.4.13 → 0.4.18 in `bottlecap/Cargo.lock`. Lockfile-only; no source changes. Notes on the diff: - The diff is restricted to the two `h2` lines. Running `cargo update -p h2` locally also re-resolved six unrelated `windows-sys` entries downward (0.61.2 → 0.52.0/0.60.2); that churn was reverted, and `cargo metadata --locked` confirms the lockfile is still self-consistent. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) --- bottlecap/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bottlecap/Cargo.lock b/bottlecap/Cargo.lock index aa845b039..b3d982108 100644 --- a/bottlecap/Cargo.lock +++ b/bottlecap/Cargo.lock @@ -1419,9 +1419,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.17" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f877e75f39e9827ec50a572dd592684ac28c029578726c85f1b2aa6ab807449" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", From fa5656968a522bca3759b125e738a48f3e3c2351 Mon Sep 17 00:00:00 2001 From: "E. Lewis" Date: Fri, 21 Aug 2026 12:24:37 -0400 Subject: [PATCH 2/6] [SVLS-8070] Remove dormant GHCR publisher (#1330) ## Overview * Removes the dormant GHCR publisher; companion to https://github.com/DataDog/serverless-init-ci/pull/51 * Updates Bottlecap's locked `h2` dependency from 0.4.13 to 0.4.16 to resolve [RUSTSEC-2026-0258](https://rustsec.org/advisories/RUSTSEC-2026-0258), which is blocking `main` and unrelated PRs ## Testing * `cargo check --workspace --locked` --- .github/CODEOWNERS | 3 - .../serverless-init-ci-publish.sts.yaml | 25 ---- .../publish-serverless-init-to-ghcr.yml | 129 ------------------ 3 files changed, 157 deletions(-) delete mode 100644 .github/chainguard/serverless-init-ci-publish.sts.yaml delete mode 100644 .github/workflows/publish-serverless-init-to-ghcr.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e9f59694a..6d5bb5bdb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,4 @@ * @DataDog/serverless-aws -.github/chainguard/serverless-init-ci-publish.sts.yaml @DataDog/serverless -.github/workflows/publish-serverless-init-to-ghcr.yml @DataDog/serverless - bottlecap/src/traces/ @DataDog/serverless-aws @DataDog/apm-serverless bottlecap/tests/apm_integration_test.rs @DataDog/serverless-aws @DataDog/apm-serverless diff --git a/.github/chainguard/serverless-init-ci-publish.sts.yaml b/.github/chainguard/serverless-init-ci-publish.sts.yaml deleted file mode 100644 index 30ce1176a..000000000 --- a/.github/chainguard/serverless-init-ci-publish.sts.yaml +++ /dev/null @@ -1,25 +0,0 @@ -# DD Octo STS Trust Policy for serverless-init-ci GitLab pipeline -# -# This policy allows the serverless-init-ci GitLab pipeline to trigger -# GitHub Actions workflows that publish serverless-init images to GHCR. -# -# Reference: https://datadoghq.atlassian.net/wiki/spaces/SECENG/pages/5138645099 -# Pipeline: https://gitlab.ddbuild.io/DataDog/serverless-init-ci - -issuer: https://gitlab.ddbuild.io - -# Subject pattern matches the serverless-init-ci repo on any protected branch or tag -subject_pattern: "project_path:DataDog/serverless-init-ci:ref_type:(branch|tag):ref:.*" - -# Only allow protected branches and tags (security control) -claim_pattern: - project_path: "DataDog/serverless-init-ci" - ref_type: "^(branch|tag)$" - ref_protected: "true" - pipeline_source: "^(web|pipeline)$" - ci_config_ref_uri: "^gitlab\\.ddbuild\\.io/DataDog/serverless-init-ci//\\.gitlab-ci\\.yml@refs/(heads|tags)/.*$" - -# Minimal permissions: only trigger GitHub Actions workflows -# The workflow itself uses GITHUB_TOKEN for GHCR access -permissions: - actions: write diff --git a/.github/workflows/publish-serverless-init-to-ghcr.yml b/.github/workflows/publish-serverless-init-to-ghcr.yml deleted file mode 100644 index d5bc2e963..000000000 --- a/.github/workflows/publish-serverless-init-to-ghcr.yml +++ /dev/null @@ -1,129 +0,0 @@ -name: Publish serverless-init to GHCR - -on: - workflow_dispatch: - inputs: - source_image: - description: 'Source image from registry.datadoghq.com (e.g., registry.datadoghq.com/serverless-init:1.7.8)' - required: true - type: string - version: - description: 'Version tag (e.g., 1.7.8 or 1.7.8-rc1)' - required: true - type: string - image_suffix: - description: 'Image suffix (empty for standard, -alpine for alpine)' - required: false - type: string - default: '' - pipeline_id: - description: 'GitLab pipeline ID' - required: true - type: string - is_latest: - description: 'Tag as latest (true for prod releases, false for RCs)' - required: true - type: boolean - default: false - -permissions: - packages: write - contents: read - -jobs: - publish-to-ghcr: - runs-on: ubuntu-latest - steps: - - name: Install crane - run: | - cd /tmp - wget https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz - tar -xzf go-containerregistry_Linux_x86_64.tar.gz - sudo mv crane /usr/local/bin/ - crane version - - - name: Login to GHCR - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Wait for image availability - run: | - SOURCE_IMAGE="${{ inputs.source_image }}" - MAX_ATTEMPTS=20 - RETRY_DELAY=30 - # Maximum wait time: 20 attempts × 30s = 600s (10 minutes) - - echo "⏳ Waiting for image to be available: ${SOURCE_IMAGE}" - echo "Will check every ${RETRY_DELAY}s for up to $((MAX_ATTEMPTS * RETRY_DELAY))s" - - for i in $(seq 1 $MAX_ATTEMPTS); do - echo "Attempt $i/$MAX_ATTEMPTS: Checking if image exists..." - - if crane manifest ${SOURCE_IMAGE} >/dev/null 2>&1; then - echo "✅ Image is available!" - exit 0 - fi - - if [ $i -lt $MAX_ATTEMPTS ]; then - echo "⏳ Image not yet available, waiting ${RETRY_DELAY}s..." - sleep $RETRY_DELAY - fi - done - - echo "❌ Image did not become available after $((MAX_ATTEMPTS * RETRY_DELAY))s" - exit 1 - - - name: Copy image to GHCR - run: | - SOURCE_IMAGE="${{ inputs.source_image }}" - VERSION="${{ inputs.version }}" - IMAGE_SUFFIX="${{ inputs.image_suffix }}" - PIPELINE_ID="${{ inputs.pipeline_id }}" - IS_LATEST="${{ inputs.is_latest }}" - - DEST_BASE="ghcr.io/datadog/datadog-lambda-extension/serverless-init" - - echo "📦 Publishing serverless-init image to GHCR" - echo " Source: ${SOURCE_IMAGE}" - echo " Destinations:" - echo " - ${DEST_BASE}:${VERSION}${IMAGE_SUFFIX}" - echo " - ${DEST_BASE}:v${PIPELINE_ID}${IMAGE_SUFFIX}" - - # Copy with version tag (with retry logic) - # Maximum retry duration: 3 attempts with 10s delays between retries - # This workflow is triggered in parallel with the publish attempt to registry.datadoghq.com - # Registry.datadoghq.com should normally need about ~30 seconds to recieve the new image - MAX_COPY_ATTEMPTS=3 - COPY_RETRY_DELAY=10 - - for i in $(seq 1 $MAX_COPY_ATTEMPTS); do - echo "Copying image (attempt $i/$MAX_COPY_ATTEMPTS)..." - - if crane copy ${SOURCE_IMAGE} ${DEST_BASE}:${VERSION}${IMAGE_SUFFIX}; then - echo "✅ Image copied successfully!" - break - fi - - if [ $i -lt $MAX_COPY_ATTEMPTS ]; then - echo "⚠️ Copy failed, retrying in ${COPY_RETRY_DELAY}s..." - sleep $COPY_RETRY_DELAY - else - echo "❌ Failed to copy image after $MAX_COPY_ATTEMPTS attempts" - exit 1 - fi - done - - # Tag for pipeline ID - crane tag ${DEST_BASE}:${VERSION}${IMAGE_SUFFIX} v${PIPELINE_ID}${IMAGE_SUFFIX} - - # Tag as latest if this is a production release - if [ "$IS_LATEST" = "true" ]; then - echo " - ${DEST_BASE}:latest${IMAGE_SUFFIX}" - crane tag ${DEST_BASE}:${VERSION}${IMAGE_SUFFIX} latest${IMAGE_SUFFIX} - fi - - echo "✅ Successfully published image to GHCR!" - echo "📍 View at: https://github.com/DataDog/datadog-lambda-extension/pkgs/container/datadog-lambda-extension%2Fserverless-init" From bfa7b847c6ed00513cbe55b0eaa57d63f7e701e0 Mon Sep 17 00:00:00 2001 From: Yiming Luo <10097700+lym953@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:21:51 -0400 Subject: [PATCH 3/6] chore: add PR size guidance to AGENTS.md (#1331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview Adds a line to `AGENTS.md` asking agents to keep PRs small — usually no more than 300 lines of core code, excluding tests, dependency updates, and other generated changes — so reviews stay manageable. ## Testing Docs-only change; no code affected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index f18a00881..251aae1fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ All Rust code lives under `bottlecap/`. Run these from that directory unless not ## Pull Requests - When creating a PR, follow the PR template at `.github/pull_request_template.md`. +- Try to keep PRs small to make review easier: usually no more than 300 lines of core code, excluding tests, dependency updates, and other generated changes. - When making a change on a PR, update the PR title and summary if necessary. - When reviewing a PR, make sure the PR summary accurately describes the code changes. From 4ff0524f5deddf9ec685b68754d2ea04ddbf633e Mon Sep 17 00:00:00 2001 From: James Eastham Date: Thu, 27 Aug 2026 16:51:01 +0100 Subject: [PATCH 4/6] feat(dsm): Add support for automatic DSM context extraction inside the extension (#1265) **Please include Jira ticket in title.** Update the trace context propagation support in universal instrumentation to include the automatic extraction of Data Streams Monitoring context for SQS, SNS, Kinesis and EventBridge. Added unit tests. Manually tested the functionality for Java, Go and .NET. Java and Go work correctly. [The .NET tracer needs updating to support the 2.x version of `Amazon.Lambda.RuntimeSupport`](https://github.com/DataDog/dd-trace-dotnet/pull/8797) so can't test that manually yet. --- .gitlab/datasources/test-suites.yaml | 1 + bottlecap/Cargo.lock | 1 + bottlecap/Cargo.toml | 4 + bottlecap/src/bin/bottlecap/main.rs | 91 +- bottlecap/src/config/mod.rs | 58 + bottlecap/src/flushing/service.rs | 17 + bottlecap/src/lifecycle/invocation/context.rs | 10 +- .../src/lifecycle/invocation/processor.rs | 522 +++++- .../lifecycle/invocation/processor_service.rs | 30 +- .../src/lifecycle/invocation/span_inferrer.rs | 149 +- .../invocation/triggers/event_bridge_event.rs | 139 ++ .../invocation/triggers/kinesis_event.rs | 51 +- .../src/lifecycle/invocation/triggers/mod.rs | 71 + .../invocation/triggers/msk_event.rs | 158 +- .../invocation/triggers/sns_event.rs | 50 +- .../invocation/triggers/sqs_event.rs | 133 +- bottlecap/src/proxy/interceptor.rs | 167 +- bottlecap/src/proxy/mod.rs | 52 +- .../src/traces/data_streams/aggregator.rs | 293 ++++ .../src/traces/data_streams/checkpoint.rs | 124 ++ bottlecap/src/traces/data_streams/context.rs | 172 ++ .../data_streams/fixtures/sketch_golden.json | 1491 +++++++++++++++++ bottlecap/src/traces/data_streams/mod.rs | 25 + bottlecap/src/traces/data_streams/pathway.rs | 159 ++ .../src/traces/data_streams/processor.rs | 326 ++++ .../traces/data_streams/propagation_hash.rs | 50 + bottlecap/src/traces/data_streams/sketch.rs | 375 +++++ bottlecap/src/traces/mod.rs | 1 + bottlecap/src/traces/span_pointers.rs | 2 +- bottlecap/tests/common/fake_intake.rs | 89 + bottlecap/tests/dsm_integration_test.rs | 132 ++ .../tests/payloads/sqs_binary_event.json | 29 + integration-tests/bin/app.ts | 4 + integration-tests/lib/stacks/dsm.ts | 65 + integration-tests/tests/dsm.test.ts | 111 ++ integration-tests/tests/utils/datadog.ts | 38 + 36 files changed, 5044 insertions(+), 146 deletions(-) create mode 100644 bottlecap/src/traces/data_streams/aggregator.rs create mode 100644 bottlecap/src/traces/data_streams/checkpoint.rs create mode 100644 bottlecap/src/traces/data_streams/context.rs create mode 100644 bottlecap/src/traces/data_streams/fixtures/sketch_golden.json create mode 100644 bottlecap/src/traces/data_streams/mod.rs create mode 100644 bottlecap/src/traces/data_streams/pathway.rs create mode 100644 bottlecap/src/traces/data_streams/processor.rs create mode 100644 bottlecap/src/traces/data_streams/propagation_hash.rs create mode 100644 bottlecap/src/traces/data_streams/sketch.rs create mode 100644 bottlecap/tests/dsm_integration_test.rs create mode 100644 bottlecap/tests/payloads/sqs_binary_event.json create mode 100644 integration-tests/lib/stacks/dsm.ts create mode 100644 integration-tests/tests/dsm.test.ts diff --git a/.gitlab/datasources/test-suites.yaml b/.gitlab/datasources/test-suites.yaml index d4eb86259..5cde833fc 100644 --- a/.gitlab/datasources/test-suites.yaml +++ b/.gitlab/datasources/test-suites.yaml @@ -7,4 +7,5 @@ test_suites: - name: oom - name: lmi-oom - name: payload-size + - name: dsm - name: durable-cold-start diff --git a/bottlecap/Cargo.lock b/bottlecap/Cargo.lock index b3d982108..19dbc278c 100644 --- a/bottlecap/Cargo.lock +++ b/bottlecap/Cargo.lock @@ -606,6 +606,7 @@ dependencies = [ "rustls-webpki", "serde", "serde-aux", + "serde_bytes", "serde_html_form", "serde_json", "serial_test", diff --git a/bottlecap/Cargo.toml b/bottlecap/Cargo.toml index 1356c67a5..f0575d46c 100644 --- a/bottlecap/Cargo.toml +++ b/bottlecap/Cargo.toml @@ -33,6 +33,10 @@ regex = { version = "1.10", default-features = false } reqwest = { version = "0.12.11", features = ["json", "http2"], default-features = false } serde = { version = "1.0", default-features = false, features = ["derive"] } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } +serde_bytes = { version = "0.11", default-features = false, features = ["std"] } +# DSM pipeline-stats serialization (msgpack + gzip) for extension-side checkpoints. +rmp-serde = { version = "1.3.1", default-features = false } +flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] } thiserror = { version = "1.0", default-features = false } # Transitive dependency (pulled in via cookie). Pinned to >=0.3.47 so cargo audit / CI passes (RUSTSEC-2026-0009). time = { version = "0.3.47", default-features = false } diff --git a/bottlecap/src/bin/bottlecap/main.rs b/bottlecap/src/bin/bottlecap/main.rs index d21201ff9..fec4f4b73 100644 --- a/bottlecap/src/bin/bottlecap/main.rs +++ b/bottlecap/src/bin/bottlecap/main.rs @@ -111,6 +111,16 @@ use tracing::{debug, error, info, warn}; use tracing_subscriber::EnvFilter; use ustr::Ustr; +fn resolve_dsm_service( + configured_service: Option<&str>, + canonical_resource_name: Option<&str>, +) -> String { + configured_service + .map(std::string::ToString::to_string) + .or_else(|| canonical_resource_name.map(std::string::ToString::to_string)) + .unwrap_or_else(|| "aws.lambda".to_string()) +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let start_time = Instant::now(); @@ -450,6 +460,53 @@ async fn extension_loop_active( .await; let propagator = Arc::new(DatadogCompositePropagator::new(Arc::clone(config))); + + // Shared proxy aggregator (used by the trace agent's proxy endpoints and, + // when enabled, the extension-side DSM processor). + let proxy_aggregator = Arc::new(TokioMutex::new(proxy_aggregator::Aggregator::default())); + + // Extension-side Data Streams Monitoring (consume checkpoints), gated by + // DD_DATA_STREAMS_ENABLED. + let dsm_processor = if config.ext.dsm_consume_enabled { + let canonical_resource_name = tags_provider.get_canonical_resource_name(); + let dsm_service = resolve_dsm_service( + config.service.as_deref(), + canonical_resource_name.as_deref(), + ); + let dsm_env = config.env.clone().unwrap_or_default(); + let dsm_version = config.version.clone().unwrap_or_default(); + let mut dsm_tags: Vec = config + .tags + .iter() + .map(|(key, value)| format!("{key}:{value}")) + .collect(); + dsm_tags.sort(); + + debug!( + "DSM startup config: enabled={}, service={}, env={}, version={}, apm_dd_url={}, tags={:?}", + config.ext.dsm_consume_enabled, + dsm_service, + dsm_env, + dsm_version, + config.apm_dd_url, + dsm_tags + ); + + Some(Arc::new( + bottlecap::traces::data_streams::DsmProcessor::new( + dsm_service, + dsm_env, + EXTENSION_VERSION.to_string(), + dsm_version, + dsm_tags, + &config.apm_dd_url, + Arc::clone(&proxy_aggregator), + ), + )) + } else { + None + }; + // Lifecycle Invocation Processor let (invocation_processor_handle, invocation_processor_service) = InvocationProcessorService::new( @@ -459,6 +516,7 @@ async fn extension_loop_active( metrics_aggregator_handle.clone(), Arc::clone(&propagator), durable_context_tx, + dsm_processor.clone(), ); tokio::spawn(async move { invocation_processor_service.run().await; @@ -492,6 +550,7 @@ async fn extension_loop_active( invocation_processor_handle.clone(), appsec_processor.clone(), &shared_client, + Arc::clone(&proxy_aggregator), ); let api_runtime_proxy_shutdown_signal = start_api_runtime_proxy( @@ -550,6 +609,7 @@ async fn extension_loop_active( let stats_flusher_clone = Arc::clone(&stats_flusher); let proxy_flusher_clone = proxy_flusher.clone(); let metrics_aggr_handle_clone = metrics_aggregator_handle.clone(); + let dsm_processor_clone = dsm_processor.clone(); // In Managed Instance mode, create a separate interval for the background flusher task. // We don't reuse race_flush_interval because we need to configure the missed tick @@ -580,6 +640,7 @@ async fn extension_loop_active( proxy_flusher_clone, metrics_flushers_clone, metrics_aggr_handle_clone, + dsm_processor_clone, ); loop { @@ -754,6 +815,7 @@ async fn extension_loop_active( proxy_flusher.clone(), Arc::clone(&metrics_flushers), metrics_aggregator_handle.clone(), + dsm_processor.clone(), ); handle_next_invocation(next_lambda_response, &invocation_processor_handle).await; loop { @@ -1226,6 +1288,7 @@ fn start_trace_agent( invocation_processor_handle: InvocationProcessorHandle, appsec_processor: Option>>, client: &Client, + proxy_aggregator: Arc>, ) -> ( Sender, Arc, @@ -1290,7 +1353,6 @@ fn start_trace_agent( tokio::spawn(span_dedup_service.run()); // Proxy - let proxy_aggregator = Arc::new(TokioMutex::new(proxy_aggregator::Aggregator::default())); let proxy_flusher = Arc::new(ProxyFlusher::new( api_key_factory.clone(), Arc::clone(&proxy_aggregator), @@ -1566,6 +1628,7 @@ fn start_api_runtime_proxy( invocation_processor_handle.clone(), appsec_processor, propagator, + config.ext.dsm_consume_enabled, ) .ok() } @@ -1591,3 +1654,29 @@ mod flush_handles_tests { assert!(!handles.has_pending()); } } + +#[cfg(test)] +mod dsm_config_tests { + use super::resolve_dsm_service; + + #[test] + fn preserves_explicit_service_case() { + assert_eq!( + resolve_dsm_service(Some("MyMixedCaseService"), Some("function-name")), + "MyMixedCaseService" + ); + } + + #[test] + fn falls_back_to_canonical_resource_name() { + assert_eq!( + resolve_dsm_service(None, Some("FunctionName")), + "FunctionName" + ); + } + + #[test] + fn falls_back_to_aws_lambda_when_unset() { + assert_eq!(resolve_dsm_service(None, None), "aws.lambda"); + } +} diff --git a/bottlecap/src/config/mod.rs b/bottlecap/src/config/mod.rs index f1e00d333..3585b4b2a 100644 --- a/bottlecap/src/config/mod.rs +++ b/bottlecap/src/config/mod.rs @@ -88,6 +88,23 @@ pub struct LambdaConfig { pub policy_enabled: bool, /// Configured Tero policy providers (file/HTTP). pub policy_providers: Option>, + + // Data Streams Monitoring + /// Enable extension-side DSM consume checkpoints. Gated by the same + /// `DD_DATA_STREAMS_ENABLED` flag the tracer libraries use; the extension + /// and tracer never emit checkpoints for the same runtime, so sharing the + /// flag cannot double-count. + /// Java/.NET/Go - Datadog Lambda supports calls to '/start-invocation', no tracer support for parsing Lambda payloads + /// Python - Wrapper script in datadog-lambda-python extracts context and DSM, but does not call `/start-invocation` + /// JS - Wrapper script in datadog-lambda-js extracts context and DSM, but does not call `/start-invocation` + pub dsm_consume_enabled: bool, + /// Fallback DSM `exchange` (event bus name) used for `EventBridge` consume + /// checkpoints when it cannot be derived from the event payload + /// (`DD_DSM_EXCHANGE_NAME`). + pub dsm_exchange_name: Option, + /// Consumer group used for `MSK`/Kafka DSM consume checkpoints, which is not + /// present in the Lambda event payload (`DD_DSM_KAFKA_GROUP`). + pub dsm_kafka_group: Option, } impl Default for LambdaConfig { @@ -114,6 +131,9 @@ impl Default for LambdaConfig { lambda_durable_function_log_buffer_size: 0, policy_enabled: false, policy_providers: None, + dsm_consume_enabled: false, + dsm_exchange_name: None, + dsm_kafka_group: None, } } } @@ -198,6 +218,18 @@ pub struct LambdaConfigSource { /// provider configs. #[serde(deserialize_with = "deserialize_policy_providers")] pub policy_providers: Option>, + + /// `DD_DATA_STREAMS_ENABLED` — enable extension-side DSM consume + /// checkpoints. Shared with the tracer libraries; merges into the + /// `dsm_consume_enabled` config field. + #[serde(deserialize_with = "deser_opt_bool")] + pub data_streams_enabled: Option, + /// `DD_DSM_EXCHANGE_NAME` — fallback exchange name for `EventBridge` DSM checkpoints. + #[serde(deserialize_with = "deser_opt_str")] + pub dsm_exchange_name: Option, + /// `DD_DSM_KAFKA_GROUP` — consumer group for MSK/Kafka DSM consume checkpoints. + #[serde(deserialize_with = "deser_opt_str")] + pub dsm_kafka_group: Option, } impl DatadogConfigExtension for LambdaConfig { @@ -225,9 +257,20 @@ impl DatadogConfigExtension for LambdaConfig { api_key_secret_reload_interval, appsec_rules, policy_providers, + dsm_exchange_name, + dsm_kafka_group, ], ); + // data_streams_enabled (source / DD_DATA_STREAMS_ENABLED) → + // dsm_consume_enabled (config) + datadog_agent_config::merge_option_to_value!( + self, + dsm_consume_enabled, + source, + data_streams_enabled + ); + // Preserve legacy OR-merge semantics: when either env var is // explicitly set, the resolved value is the OR of the two (unset // counts as false for the OR). When neither is set, the default @@ -556,6 +599,21 @@ mod lambda_config_tests { assert!(!config.ext.lambda_extension_compute_stats); } + #[test] + fn dsm_consume_enabled_from_data_streams_env() { + let config = load(|jail| { + jail.set_env("DD_DATA_STREAMS_ENABLED", "true"); + Ok(()) + }); + assert!(config.ext.dsm_consume_enabled); + } + + #[test] + fn dsm_consume_enabled_defaults_false() { + let config = load(|_| Ok(())); + assert!(!config.ext.dsm_consume_enabled); + } + // ---- Duration fields ---- #[test] diff --git a/bottlecap/src/flushing/service.rs b/bottlecap/src/flushing/service.rs index bd9c66882..a8181d7e9 100644 --- a/bottlecap/src/flushing/service.rs +++ b/bottlecap/src/flushing/service.rs @@ -29,6 +29,11 @@ pub struct FlushingService { proxy_flusher: Arc, metrics_flushers: Arc>, + /// Optional extension-side DSM processor. When present, its aggregated + /// pipeline-stats payload is drained into the proxy aggregator immediately + /// before each proxy flush. `None` unless `DD_DATA_STREAMS_ENABLED` is set. + dsm_processor: Option>, + // Metrics aggregator handle for getting data to flush metrics_aggr_handle: MetricsAggregatorHandle, @@ -46,6 +51,7 @@ impl FlushingService { proxy_flusher: Arc, metrics_flushers: Arc>, metrics_aggr_handle: MetricsAggregatorHandle, + dsm_processor: Option>, ) -> Self { Self { logs_flusher, @@ -53,6 +59,7 @@ impl FlushingService { stats_flusher, proxy_flusher, metrics_flushers, + dsm_processor, metrics_aggr_handle, handles: FlushHandles::new(), } @@ -123,6 +130,11 @@ impl FlushingService { sf.flush(false, None).await.unwrap_or_default() })); + // Drain DSM pipeline stats into the proxy aggregator before flushing. + if let Some(dsm) = &self.dsm_processor { + dsm.drain_into_proxy().await; + } + // Spawn proxy flush let pf = self.proxy_flusher.clone(); self.handles @@ -324,6 +336,11 @@ impl FlushingService { }) .collect(); + // Drain DSM pipeline stats into the proxy aggregator before flushing. + if let Some(dsm) = &self.dsm_processor { + dsm.drain_into_proxy().await; + } + tokio::join!( self.logs_flusher.flush(None), futures::future::join_all(metrics_futures), diff --git a/bottlecap/src/lifecycle/invocation/context.rs b/bottlecap/src/lifecycle/invocation/context.rs index dd25bd69a..58f9eda26 100644 --- a/bottlecap/src/lifecycle/invocation/context.rs +++ b/bottlecap/src/lifecycle/invocation/context.rs @@ -1,5 +1,7 @@ use crate::{ - lifecycle::invocation::processor::MS_TO_NS, metrics::enhanced::lambda::EnhancedMetricData, + lifecycle::invocation::processor::MS_TO_NS, + lifecycle::invocation::span_inferrer::InferredSpanData, + metrics::enhanced::lambda::EnhancedMetricData, }; use datadog_opentelemetry::propagation::context::SpanContext; use std::{ @@ -55,6 +57,11 @@ pub struct Context { /// Used when generating the extension-side `aws.lambda` span (Path B) so the backend /// stats directive (`_dd.compute_stats`) is stamped consistently with Path A. pub client_computed_stats: bool, + /// Per-invocation inferred span / trigger data produced by the span inferrer. + /// + /// Kept on the context (not the shared `Processor`) so concurrent Managed + /// Instance invocations don't overwrite each other's inference output. + pub inferred: InferredSpanData, } /// Struct containing the information needed to reparent a span. @@ -108,6 +115,7 @@ impl Default for Context { extracted_span_context: None, oom_emitted: false, client_computed_stats: false, + inferred: InferredSpanData::default(), } } } diff --git a/bottlecap/src/lifecycle/invocation/processor.rs b/bottlecap/src/lifecycle/invocation/processor.rs index 307484b56..3ddcdf1b1 100644 --- a/bottlecap/src/lifecycle/invocation/processor.rs +++ b/bottlecap/src/lifecycle/invocation/processor.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, VecDeque}, + collections::{HashMap, HashSet, VecDeque}, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; @@ -42,6 +42,7 @@ use crate::{ tags::{lambda::tags::resolve_runtime_from_proc, provider}, traces::{ propagation::{DatadogCompositePropagator, carrier::JsonCarrier}, + span_pointers::SpanPointer, trace_processor::SendingTraceProcessor, }, }; @@ -111,6 +112,16 @@ pub struct Processor { /// on `platform.report`. This flag ensures whichever event arrives first wins and the other is skipped, /// preventing double counting. init_duration_metric_emitted: bool, + /// Optional extension-side DSM consume processor. `Some` only when + /// `DD_DATA_STREAMS_ENABLED` is set; records `direction:in` checkpoints from + /// inbound event payloads. + #[allow(clippy::struct_field_names)] + dsm_processor: Option>, + /// Request IDs whose extension-side DSM consume checkpoints have already + /// been recorded. This prevents double-counting when the same invocation + /// payload is observed through both the runtime API proxy and tracer-driven + /// `/lambda/start-invocation`. + dsm_processed_request_ids: HashSet, /// How the cold-start invocation metric in On-Demand mode has been resolved relative to /// `platform.initStart`, since either the first invocation or `platform.initStart` can /// arrive first (the Extensions API's `next()` call is unbuffered, while Telemetry API @@ -175,10 +186,21 @@ impl Processor { durable_context_tx, restore_time: None, init_duration_metric_emitted: false, + dsm_processor: None, + dsm_processed_request_ids: HashSet::new(), first_invocation_metric_status: FirstInvocationMetricStatus::Pending, } } + /// Attach an extension-side DSM consume processor. Called during startup only + /// when `DD_DATA_STREAMS_ENABLED` is set. + pub fn set_dsm_processor( + &mut self, + dsm_processor: Arc, + ) { + self.dsm_processor = Some(dsm_processor); + } + /// Given a `request_id`, creates the context and adds the enhanced metric offsets to the context buffer. /// pub fn on_invoke_event(&mut self, request_id: String) { @@ -279,9 +301,6 @@ impl Processor { "Found buffered UniversalInstrumentationStart for request_id: {}", request_id ); - // Infer span - self.inferrer - .infer_span(&buffered_event.payload_value, &self.aws_config); self.process_on_universal_instrumentation_start( request_id, buffered_event.headers, @@ -292,7 +311,6 @@ impl Processor { if let Some((headers, payload_value)) = self.context_buffer.pair_invoke_event(&request_id) { - self.inferrer.infer_span(&payload_value, &self.aws_config); self.process_on_universal_instrumentation_start(request_id, headers, payload_value); } } @@ -698,7 +716,7 @@ impl Processor { .meta .extend(self.dynamic_tags.clone()); - if let Some(trigger_tags) = self.inferrer.get_trigger_tags() { + if let Some(trigger_tags) = context.inferred.get_trigger_tags() { context.invocation_span.meta.extend(trigger_tags); } @@ -716,7 +734,7 @@ impl Processor { } self.inferrer - .complete_inferred_spans(&context.invocation_span); + .complete_inferred_spans(&mut context.inferred, &context.invocation_span); // Handle cold start span if present. Timeout handling can synthesize an // invocation trace ID even when no tracer is installed; that must not @@ -748,27 +766,47 @@ impl Processor { ) { // Capture before `get_ctx_spans` consumes `context`. let client_computed_stats = context.client_computed_stats; - let (traces, body_size) = self.get_ctx_spans(context); + let span_pointers = context.inferred.span_pointers.clone(); + let (traces, body_size) = Self::get_ctx_spans(context); self.send_spans( traces, body_size, tags_provider, trace_sender, client_computed_stats, + span_pointers, ) .await; } - fn get_ctx_spans(&mut self, context: Context) -> (Vec, usize) { + fn release_invocation_context(&mut self, request_id: &String) { + // Release the context now that all processing for this invocation is complete. + // This prevents unbounded memory growth across warm invocations. + self.context_buffer.remove(request_id); + // Prune DSM idempotency state at the same lifecycle boundary as the context, + // not on flush. A flush can happen while an invocation is still active. + self.dsm_processed_request_ids.remove(request_id); + // Prune the corresponding reparenting entry so that update_reparenting does not + // warn about a missing context for already-completed invocations. + self.context_buffer + .sorted_reparenting_info + .retain(|info| info.request_id != *request_id); + trace!( + "Context released (buffer size after remove: {})", + self.context_buffer.size() + ); + } + + fn get_ctx_spans(context: Context) -> (Vec, usize) { let mut body_size = std::mem::size_of_val(&context.invocation_span); let mut traces = vec![context.invocation_span.clone()]; - if let Some(inferred_span) = &self.inferrer.inferred_span { + if let Some(inferred_span) = &context.inferred.inferred_span { body_size += std::mem::size_of_val(inferred_span); traces.push(inferred_span.clone()); } - if let Some(ws) = &self.inferrer.wrapped_inferred_span { + if let Some(ws) = &context.inferred.wrapped_inferred_span { body_size += std::mem::size_of_val(ws); traces.push(ws.clone()); } @@ -823,7 +861,7 @@ impl Processor { // The cold start span is extension-generated and not tied to a tracer's stats // signal, so the backend should compute its stats unless the extension does. - self.send_spans(traces, body_size, tags_provider, trace_sender, false) + self.send_spans(traces, body_size, tags_provider, trace_sender, false, None) .await; } } @@ -838,6 +876,7 @@ impl Processor { tags_provider: &Arc, trace_sender: &Arc, client_computed_stats: bool, + span_pointers: Option>, ) { // todo: figure out what to do here // `client_computed_stats` is propagated from the tracer's placeholder span so the @@ -863,7 +902,7 @@ impl Processor { header_tags, vec![traces], body_size, - self.inferrer.span_pointers.clone(), + span_pointers, ) .await { @@ -922,18 +961,7 @@ impl Processor { .set_cpu_time_enhanced_metrics(offsets.cpu_offset.clone()); } - // Release the context now that all processing for this invocation is complete. - // This prevents unbounded memory growth across warm invocations. - self.context_buffer.remove(request_id); - // Prune the corresponding reparenting entry so that update_reparenting does not - // warn about a missing context for already-completed invocations. - self.context_buffer - .sorted_reparenting_info - .retain(|info| info.request_id != *request_id); - trace!( - "Context released (buffer size after remove: {})", - self.context_buffer.size() - ); + self.release_invocation_context(request_id); } /// Handles Managed Instance mode platform report processing. @@ -1080,15 +1108,21 @@ impl Processor { ) { self.tracer_detected = true; - self.inferrer.infer_span(&payload_value, &self.aws_config); - // Both modes: use request_id-based pairing when the tracer sends the header. if let Some(req_id) = request_id { debug!( "Processing UniversalInstrumentationStart for request_id: {}", req_id ); - if self + if self.aws_config.is_managed_instance_mode() + && self.context_buffer.get(&req_id).is_some() + { + // Managed Instance mode creates the invocation context from the + // platform invoke event without enqueueing the request id in the + // FIFO pairing queue. If the request-id-addressed context already + // exists, process immediately instead of buffering forever. + self.process_on_universal_instrumentation_start(req_id, headers, payload_value); + } else if self .context_buffer .pair_universal_instrumentation_start_with_request_id( &req_id, @@ -1132,10 +1166,15 @@ impl Processor { headers: HashMap, payload_value: Value, ) { + // Infer the span for this specific invocation and store it on its own + // Context. Computed before borrowing the context mutably. + let inferred = self.inferrer.infer_span(&payload_value, &self.aws_config); + let Some(context) = self.context_buffer.get_mut(&request_id) else { debug!("Cannot process on invocation start, no context for request_id: {request_id}"); return; }; + context.inferred = inferred; // Tag the invocation span with the request payload if self.config.ext.capture_lambda_payload { @@ -1161,9 +1200,9 @@ impl Processor { // Set the right data to the correct root level span, // If there's an inferred span, then that should be the root. - if self.inferrer.inferred_span.is_some() { - self.inferrer.set_parent_id(sc.span_id); - self.inferrer.extend_meta(sc.tags.clone()); + if context.inferred.inferred_span.is_some() { + context.inferred.set_parent_id(sc.span_id); + context.inferred.extend_meta(sc.tags.clone()); } else { context.invocation_span.meta.extend(sc.tags.clone()); } @@ -1171,9 +1210,67 @@ impl Processor { // If we have an inferred span, set the invocation span parent id // to be the inferred span id, even if we don't have an extracted trace context - if let Some(inferred_span) = &self.inferrer.inferred_span { + if let Some(inferred_span) = &context.inferred.inferred_span { context.invocation_span.parent_id = inferred_span.span_id; } + + self.record_dsm_consume_from_payload(request_id, &payload_value); + } + + pub fn record_dsm_consume_from_payload(&mut self, request_id: String, payload_value: &Value) { + // Extension-side DSM: record a consume (`direction:in`) checkpoint for + // DSM-eligible event sources, continuing any inbound pathway context. + if let Some(dsm) = self.dsm_processor.as_ref() { + if self.dsm_processed_request_ids.insert(request_id.clone()) { + debug!("DSM: extraction hook fired for request {request_id}"); + let identified = + crate::lifecycle::invocation::triggers::IdentifiedTrigger::from_value( + payload_value, + ); + if let Some(trigger) = SpanInferrer::get_trigger_type(identified) { + // Batched sources (SQS/SNS/Kinesis) yield one checkpoint per + // record so every message's pathway context is captured. + let checkpoints = trigger.get_dsm_checkpoints(payload_value); + if checkpoints.is_empty() { + debug!( + "DSM: identified trigger is not DSM-eligible, skipping consume checkpoint" + ); + } else { + debug!( + "DSM: trigger is DSM-eligible, {} record(s)", + checkpoints.len() + ); + for mut checkpoint in checkpoints { + resolve_dsm_eventbridge_exchange( + &mut checkpoint.edge_tags, + self.config.ext.dsm_exchange_name.as_deref(), + ); + apply_dsm_kafka_group_fallback( + &mut checkpoint.edge_tags, + self.config.ext.dsm_kafka_group.as_deref(), + ); + debug!( + "DSM: recording consume checkpoint edge_tags={:?}", + checkpoint.edge_tags + ); + dsm.record_consume( + &checkpoint.edge_tags, + &checkpoint.carrier, + checkpoint.payload_size_bytes, + ); + } + } + } else { + debug!("DSM: no trigger identified for payload, skipping consume checkpoint"); + } + } else { + debug!( + "DSM: consume checkpoint already recorded for request {request_id}, skipping" + ); + } + } else { + debug!("DSM: no DSM processor available, skipping consume checkpoint"); + } } pub fn add_reparenting(&mut self, request_id: String, span_id: u64, parent_id: u64) { @@ -1378,7 +1475,7 @@ impl Processor { } // If we have an inferred span, set the status code to it - self.inferrer.set_status_code(status_code_as_string); + context.inferred.set_status_code(status_code_as_string); } let mut trace_id: u64 = 0; @@ -1432,8 +1529,8 @@ impl Processor { } context.invocation_span.trace_id = trace_id; - if self.inferrer.inferred_span.is_some() { - self.inferrer.extend_meta(tags); + if context.inferred.inferred_span.is_some() { + context.inferred.extend_meta(tags); } else { context.invocation_span.parent_id = parent_id; context.invocation_span.meta.extend(tags); @@ -1597,6 +1694,41 @@ impl Processor { } } +/// Resolve the `exchange` (event bus) tag for `EventBridge` (`type:eventbridge`) +/// DSM consume edge tags, with precedence: configured `DD_DSM_EXCHANGE_NAME` > +/// payload-derived bus (rule ARN) > `default`. The resolved tag always replaces +/// any payload-derived `exchange:` tag; other sources are never affected. +fn resolve_dsm_eventbridge_exchange(edge_tags: &mut Vec, configured: Option<&str>) { + if !edge_tags.iter().any(|t| t == "type:eventbridge") { + return; + } + // Precedence: configured `DD_DSM_EXCHANGE_NAME` > payload-derived bus (rule + // ARN) > `default`. EventBridge consume checkpoints always carry an + // `exchange:` tag so the node hashes consistently across invocations. + let payload_exchange = edge_tags + .iter() + .find_map(|t| t.strip_prefix("exchange:").map(ToString::to_string)); + let exchange = configured + .map(ToString::to_string) + .or(payload_exchange) + .unwrap_or_else(|| "default".to_string()); + edge_tags.retain(|t| !t.starts_with("exchange:")); + edge_tags.push(format!("exchange:{exchange}")); +} + +/// Apply the configured `DD_DSM_KAFKA_GROUP` fallback to DSM consume edge tags. +/// The Kafka/`MSK` consumer group is not present in the Lambda event payload, so +/// it can only be supplied via config. Applies only to `type:kafka` tags that do +/// not already carry a `group:` tag. +fn apply_dsm_kafka_group_fallback(edge_tags: &mut Vec, group: Option<&str>) { + if let Some(group) = group + && edge_tags.iter().any(|t| t == "type:kafka") + && !edge_tags.iter().any(|t| t.starts_with("group:")) + { + edge_tags.push(format!("group:{group}")); + } +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -1611,7 +1743,250 @@ mod tests { use dogstatsd::metric::EMPTY_TAGS; use serde_json::json; + fn sqs_payload() -> Value { + json!({ + "Records": [{ + "messageId": "msg-1", + "receiptHandle": "handle", + "attributes": { + "ApproximateFirstReceiveTimestamp": "1700000000000", + "ApproximateReceiveCount": "1", + "SentTimestamp": "1700000000000", + "SenderId": "sender", + "AWSTraceHeader": null + }, + "messageAttributes": {}, + "md5OfBody": "5d41402abc4b2a76b9719d911017c592", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:test-queue", + "awsRegion": "us-east-1", + "body": "hello" + }] + }) + } + + fn sqs_payload_for_queue(queue_name: &str) -> Value { + json!({ + "Records": [{ + "messageId": "msg-1", + "receiptHandle": "handle", + "attributes": { + "ApproximateFirstReceiveTimestamp": "1700000000000", + "ApproximateReceiveCount": "1", + "SentTimestamp": "1700000000000", + "SenderId": "sender", + "AWSTraceHeader": null + }, + "messageAttributes": {}, + "md5OfBody": "5d41402abc4b2a76b9719d911017c592", + "eventSource": "aws:sqs", + "eventSourceARN": format!("arn:aws:sqs:us-east-1:123456789012:{queue_name}"), + "awsRegion": "us-east-1", + "body": "hello" + }] + }) + } + + fn attach_test_dsm_processor(processor: &mut Processor) { + let proxy = Arc::new(tokio::sync::Mutex::new( + crate::traces::proxy_aggregator::Aggregator::default(), + )); + processor.set_dsm_processor(Arc::new(crate::traces::data_streams::DsmProcessor::new( + "svc".into(), + "env".into(), + "1.0".into(), + "2.0".into(), + Vec::new(), + "https://trace.agent.datadoghq.com", + proxy, + ))); + } + + #[test] + fn dsm_exchange_config_takes_priority_over_payload() { + // Priority 1: configured DD_DSM_EXCHANGE_NAME overrides a payload-derived bus. + let mut tags = vec![ + "direction:in".to_string(), + "type:eventbridge".to_string(), + "exchange:payload-bus".to_string(), + "topic:OrderPlaced".to_string(), + ]; + resolve_dsm_eventbridge_exchange(&mut tags, Some("config-bus")); + assert!(tags.contains(&"exchange:config-bus".to_string())); + assert!(!tags.contains(&"exchange:payload-bus".to_string())); + // Exactly one exchange tag remains. + assert_eq!( + tags.iter().filter(|t| t.starts_with("exchange:")).count(), + 1 + ); + } + + #[test] + fn dsm_exchange_uses_payload_bus_when_unconfigured() { + // Priority 2: payload-derived bus is kept when no config is set. + let mut tags = vec![ + "direction:in".to_string(), + "type:eventbridge".to_string(), + "exchange:payload-bus".to_string(), + "topic:OrderPlaced".to_string(), + ]; + resolve_dsm_eventbridge_exchange(&mut tags, None); + assert!(tags.contains(&"exchange:payload-bus".to_string())); + assert_eq!( + tags.iter().filter(|t| t.starts_with("exchange:")).count(), + 1 + ); + } + + #[test] + fn dsm_exchange_uses_config_when_no_payload_bus() { + let mut tags = vec![ + "direction:in".to_string(), + "type:eventbridge".to_string(), + "topic:OrderPlaced".to_string(), + ]; + resolve_dsm_eventbridge_exchange(&mut tags, Some("config-bus")); + assert!(tags.contains(&"exchange:config-bus".to_string())); + } + + #[test] + fn dsm_exchange_defaults_when_nothing_found() { + // Priority 3: no config and no payload bus => `default` floor. + let mut tags = vec![ + "direction:in".to_string(), + "type:eventbridge".to_string(), + "topic:OrderPlaced".to_string(), + ]; + resolve_dsm_eventbridge_exchange(&mut tags, None); + assert!(tags.contains(&"exchange:default".to_string())); + } + + #[test] + fn dsm_exchange_ignored_for_non_eventbridge_sources() { + // SQS consume tags must never receive an exchange. + let mut tags = vec![ + "direction:in".to_string(), + "topic:my-queue".to_string(), + "type:sqs".to_string(), + ]; + let before = tags.clone(); + resolve_dsm_eventbridge_exchange(&mut tags, Some("config-bus")); + assert_eq!(tags, before); + } + + #[test] + fn dsm_kafka_group_fallback_injects_for_kafka_without_group() { + let mut tags = vec![ + "direction:in".to_string(), + "topic:my-topic".to_string(), + "type:kafka".to_string(), + ]; + apply_dsm_kafka_group_fallback(&mut tags, Some("my-group")); + assert_eq!( + tags, + vec![ + "direction:in".to_string(), + "topic:my-topic".to_string(), + "type:kafka".to_string(), + "group:my-group".to_string(), + ] + ); + } + + #[test] + fn dsm_kafka_group_fallback_does_not_override_existing_group() { + let mut tags = vec![ + "direction:in".to_string(), + "group:payload-group".to_string(), + "topic:my-topic".to_string(), + "type:kafka".to_string(), + ]; + let before = tags.clone(); + apply_dsm_kafka_group_fallback(&mut tags, Some("my-group")); + assert_eq!(tags, before); + } + + #[test] + fn dsm_kafka_group_fallback_ignored_for_non_kafka_sources() { + // SQS consume tags must never receive an injected group. + let mut tags = vec![ + "direction:in".to_string(), + "topic:my-queue".to_string(), + "type:sqs".to_string(), + ]; + let before = tags.clone(); + apply_dsm_kafka_group_fallback(&mut tags, Some("my-group")); + assert_eq!(tags, before); + } + + #[test] + fn dsm_kafka_group_fallback_noop_when_unconfigured() { + let mut tags = vec![ + "direction:in".to_string(), + "topic:my-topic".to_string(), + "type:kafka".to_string(), + ]; + let before = tags.clone(); + apply_dsm_kafka_group_fallback(&mut tags, None); + assert_eq!(tags, before); + } + fn setup() -> Processor { + setup_with_initialization_type("on-demand") + } + + #[tokio::test] + async fn test_concurrent_invocations_do_not_share_inferred_span() { + // Regression: Managed Instance mode runs concurrent invocations. Inferred + // span data must live on each invocation's Context, not on the shared + // Processor, otherwise a later invocation's inference overwrites an earlier + // one before it is emitted, and the earlier span completes with the wrong + // trigger/inference data. + let mut p = setup_managed_instance(); + + let req_a = String::from("req-a"); + let req_b = String::from("req-b"); + + // Two invocations start; each creates its own context. + p.on_invoke_event(req_a.clone()); + p.on_invoke_event(req_b.clone()); + + // Universal instrumentation start arrives for each, with a distinct SQS queue. + // B arrives after A, which under the old shared-state design overwrote A. + p.on_universal_instrumentation_start( + HashMap::new(), + sqs_payload_for_queue("queue-a"), + Some(req_a.clone()), + ); + p.on_universal_instrumentation_start( + HashMap::new(), + sqs_payload_for_queue("queue-b"), + Some(req_b.clone()), + ); + + let ctx_a = p.context_buffer.get(&req_a).expect("context a not found"); + let ctx_b = p.context_buffer.get(&req_b).expect("context b not found"); + + let span_a = ctx_a + .inferred + .inferred_span + .as_ref() + .expect("inferred span a missing"); + let span_b = ctx_b + .inferred + .inferred_span + .as_ref() + .expect("inferred span b missing"); + + assert_eq!(span_a.resource, "queue-a"); + assert_eq!(span_b.resource, "queue-b"); + } + + fn setup_managed_instance() -> Processor { + setup_with_initialization_type(config::aws::LAMBDA_MANAGED_INSTANCES_INIT_TYPE) + } + + fn setup_with_initialization_type(initialization_type: &str) -> Processor { let aws_config = Arc::new(AwsConfig { region: "us-east-1".into(), aws_lwa_proxy_lambda_runtime_api: Some("***".into()), @@ -1619,7 +1994,7 @@ mod tests { sandbox_init_time: Instant::now(), runtime_api: "***".into(), exec_wrapper: None, - initialization_type: "on-demand".into(), + initialization_type: initialization_type.into(), }); let config = Arc::new(config::Config { @@ -2536,7 +2911,6 @@ mod tests { #[tokio::test] async fn test_get_ctx_spans_prioritizes_snapstart_over_cold_start() { - let mut processor = setup(); let request_id = String::from("test-request-id"); // Create invocation span @@ -2570,7 +2944,7 @@ mod tests { context.snapstart_restore_span = Some(snapstart_span.clone()); // Call get_ctx_spans to get the spans that would be sent - let (spans, _body_size) = processor.get_ctx_spans(context); + let (spans, _body_size) = Processor::get_ctx_spans(context); // Verify that exactly 2 spans are returned: // 1. invocation_span @@ -2993,6 +3367,78 @@ mod tests { ); } + #[tokio::test] + async fn managed_instance_start_processes_immediately_when_context_exists() { + let mut p = setup_managed_instance(); + attach_test_dsm_processor(&mut p); + let request_id = String::from("req-lmi-dsm"); + + p.on_invoke_event(request_id.clone()); + p.on_universal_instrumentation_start( + HashMap::new(), + sqs_payload(), + Some(request_id.clone()), + ); + + assert!( + p.dsm_processed_request_ids.contains(&request_id), + "LMI UniversalInstrumentationStart must process immediately when the context already exists" + ); + } + + #[tokio::test] + async fn dsm_proxy_payload_extraction_does_not_mark_tracer_detected_or_reparent() { + let mut p = setup(); + attach_test_dsm_processor(&mut p); + let request_id = String::from("req-dsm-proxy-only"); + + p.record_dsm_consume_from_payload(request_id.clone(), &sqs_payload()); + + assert!(p.dsm_processed_request_ids.contains(&request_id)); + assert!( + !p.tracer_detected, + "DSM-only proxy extraction must not be treated as tracer universal instrumentation" + ); + assert!( + p.context_buffer.sorted_reparenting_info.is_empty(), + "DSM-only proxy extraction must not add LWA reparenting" + ); + } + + #[tokio::test] + async fn dsm_consume_is_idempotent_per_request_id() { + let mut p = setup(); + attach_test_dsm_processor(&mut p); + let request_id = String::from("req-dsm-dedupe"); + p.context_buffer.start_context(&request_id, Span::default()); + + p.process_on_universal_instrumentation_start( + request_id.clone(), + HashMap::new(), + sqs_payload(), + ); + p.process_on_universal_instrumentation_start( + request_id.clone(), + HashMap::new(), + sqs_payload(), + ); + + assert_eq!(p.dsm_processed_request_ids.len(), 1); + assert!(p.dsm_processed_request_ids.contains(&request_id)); + } + + #[tokio::test] + async fn dsm_idempotency_state_is_cleared_with_invocation_context() { + let mut p = setup(); + let request_id = String::from("req-dsm-cleanup"); + p.context_buffer.start_context(&request_id, Span::default()); + p.dsm_processed_request_ids.insert(request_id.clone()); + + p.release_invocation_context(&request_id); + + assert!(!p.dsm_processed_request_ids.contains(&request_id)); + } + /// Two OOM signals for the same `request_id` increment the metric exactly once. /// Exercises the `Context::oom_emitted` dedup flag. #[tokio::test] diff --git a/bottlecap/src/lifecycle/invocation/processor_service.rs b/bottlecap/src/lifecycle/invocation/processor_service.rs index c5703c042..7e068bf8a 100644 --- a/bottlecap/src/lifecycle/invocation/processor_service.rs +++ b/bottlecap/src/lifecycle/invocation/processor_service.rs @@ -83,6 +83,10 @@ pub enum ProcessorCommand { payload_value: Value, request_id: Option, }, + RecordDsmConsumeFromPayload { + request_id: String, + payload_value: Value, + }, UniversalInstrumentationEnd { headers: HashMap, payload_value: Value, @@ -283,6 +287,19 @@ impl InvocationProcessorHandle { .await } + pub async fn record_dsm_consume_from_payload( + &self, + request_id: String, + payload_value: Value, + ) -> Result<(), mpsc::error::SendError> { + self.sender + .send(ProcessorCommand::RecordDsmConsumeFromPayload { + request_id, + payload_value, + }) + .await + } + pub async fn on_universal_instrumentation_end( &self, headers: HashMap, @@ -464,10 +481,11 @@ impl InvocationProcessorService { metrics_aggregator_handle: AggregatorHandle, propagator: Arc, durable_context_tx: mpsc::Sender, + dsm_processor: Option>, ) -> (InvocationProcessorHandle, Self) { let (sender, receiver) = mpsc::channel(1000); - let processor = Processor::new( + let mut processor = Processor::new( tags_provider, config, aws_config, @@ -475,6 +493,9 @@ impl InvocationProcessorService { propagator, durable_context_tx, ); + if let Some(dsm) = dsm_processor { + processor.set_dsm_processor(dsm); + } let handle = InvocationProcessorHandle { sender }; let service = Self { @@ -586,6 +607,13 @@ impl InvocationProcessorService { request_id, ); } + ProcessorCommand::RecordDsmConsumeFromPayload { + request_id, + payload_value, + } => { + self.processor + .record_dsm_consume_from_payload(request_id, &payload_value); + } ProcessorCommand::UniversalInstrumentationEnd { headers, payload_value, diff --git a/bottlecap/src/lifecycle/invocation/span_inferrer.rs b/bottlecap/src/lifecycle/invocation/span_inferrer.rs index 50e80ba5a..6bb10e555 100644 --- a/bottlecap/src/lifecycle/invocation/span_inferrer.rs +++ b/bottlecap/src/lifecycle/invocation/span_inferrer.rs @@ -23,35 +23,62 @@ use crate::{ }; use datadog_opentelemetry::propagation::context::SpanContext; -#[derive(Default)] -pub struct SpanInferrer { - config: Arc, +/// Per-invocation inference output produced by [`SpanInferrer::infer_span`]. +/// +/// This lives on each invocation's `Context` (not on the shared `Processor`) so +/// that concurrent Managed Instance invocations do not overwrite each other's +/// inferred span/trigger data before it is emitted. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct InferredSpanData { // Span inferred from the Lambda incoming request payload pub inferred_span: Option, // Nested span inferred from the Lambda incoming request payload pub wrapped_inferred_span: Option, // If the inferred span is async - is_async_span: bool, + pub is_async_span: bool, // Generated Span Context from Step Functions or context taken from `AWSTraceHeader` when java->sqs->java - generated_span_context: Option, + pub generated_span_context: Option, // Tags generated from the trigger - trigger_tags: Option>, + pub trigger_tags: Option>, // Span pointers from S3 or DynamoDB streams pub span_pointers: Option>, } +impl InferredSpanData { + /// If an `inferred_span` exists, set its `parent_id`. + pub fn set_parent_id(&mut self, parent_id: u64) { + if let Some(s) = &mut self.inferred_span { + s.parent_id = parent_id; + } + } + + pub fn extend_meta(&mut self, iter: HashMap) { + if let Some(s) = &mut self.inferred_span { + s.meta.extend(iter); + } + } + + pub fn set_status_code(&mut self, status_code: String) { + if let Some(s) = &mut self.inferred_span { + s.meta.insert("http.status_code".to_string(), status_code); + } + } + + /// Returns a clone of the tags associated with the inferred span + #[must_use] + pub fn get_trigger_tags(&self) -> Option> { + self.trigger_tags.clone() + } +} + +pub struct SpanInferrer { + config: Arc, +} + impl SpanInferrer { #[must_use] pub fn new(config: Arc) -> Self { - Self { - config, - inferred_span: None, - wrapped_inferred_span: None, - is_async_span: false, - generated_span_context: None, - trigger_tags: None, - span_pointers: None, - } + Self { config } } #[must_use] @@ -217,12 +244,9 @@ impl SpanInferrer { /// an inferred span and set it to `self.inferred_span` /// #[allow(clippy::too_many_lines)] - pub fn infer_span(&mut self, payload_value: &Value, aws_config: &AwsConfig) { - self.inferred_span = None; - self.wrapped_inferred_span = None; - self.is_async_span = false; - self.generated_span_context = None; - self.trigger_tags = None; + #[must_use] + pub fn infer_span(&self, payload_value: &Value, aws_config: &AwsConfig) -> InferredSpanData { + let mut data = InferredSpanData::default(); let mut inferred_span = Span { span_id: generate_span_id(), @@ -254,8 +278,8 @@ impl SpanInferrer { .insert("dd_resource_key".to_string(), dd_resource_key); } - self.wrapped_inferred_span = wrapped_inferred_span; - self.span_pointers = span_pointers; + data.wrapped_inferred_span = wrapped_inferred_span; + data.span_pointers = span_pointers; let mut trigger_tags = t.get_tags(); trigger_tags.insert( @@ -263,12 +287,10 @@ impl SpanInferrer { t.get_arn(&aws_config.region), ); - self.trigger_tags = Some(trigger_tags); - self.is_async_span = t.is_async(); + data.trigger_tags = Some(trigger_tags); + data.is_async_span = t.is_async(); - if should_skip_inferred_span { - self.inferred_span = None; - } else { + if !should_skip_inferred_span { let synchronicity = if t.is_async() { "async" } else { "sync" }; inferred_span .meta @@ -277,35 +299,17 @@ impl SpanInferrer { "_inferred_span.synchronicity".to_string(), synchronicity.to_string(), ); - self.inferred_span = Some(inferred_span); + data.inferred_span = Some(inferred_span); } } - } - - /// If a `self.inferred_span` exist, set the `parent_id` to - /// the span. - /// - pub fn set_parent_id(&mut self, parent_id: u64) { - if let Some(s) = &mut self.inferred_span { - s.parent_id = parent_id; - } - } - - pub fn extend_meta(&mut self, iter: HashMap) { - if let Some(s) = &mut self.inferred_span { - s.meta.extend(iter); - } - } - pub fn set_status_code(&mut self, status_code: String) { - if let Some(s) = &mut self.inferred_span { - s.meta.insert("http.status_code".to_string(), status_code); - } + data } // TODO: add status tag and other info from response - pub fn complete_inferred_spans(&mut self, invocation_span: &Span) { - if let Some(s) = &mut self.inferred_span { + pub fn complete_inferred_spans(&self, data: &mut InferredSpanData, invocation_span: &Span) { + let is_async_span = data.is_async_span; + if let Some(s) = &mut data.inferred_span { s.trace_id = invocation_span.trace_id; s.error = invocation_span.error; s.meta.insert( @@ -316,7 +320,7 @@ impl SpanInferrer { let appsec_enabled = self.config.ext.serverless_appsec_enabled; propagate_appsec(appsec_enabled, invocation_span, s); - if let Some(ws) = &mut self.wrapped_inferred_span { + if let Some(ws) = &mut data.wrapped_inferred_span { ws.trace_id = invocation_span.trace_id; ws.error = invocation_span.error; ws.meta @@ -330,7 +334,7 @@ impl SpanInferrer { s.parent_id = ws.span_id; // TODO: clean this logic - if self.is_async_span { + if is_async_span { // SNS to SQS span duration will be set if ws.duration == 0 { let duration = s.start - ws.start; @@ -342,7 +346,7 @@ impl SpanInferrer { } } - if self.is_async_span { + if is_async_span { // SNS to SQS span duration will be set if s.duration == 0 { let duration = invocation_span.start - s.start; @@ -354,13 +358,6 @@ impl SpanInferrer { } } } - - /// Returns a clone of the tags associated with the inferred span - /// - #[must_use] - pub fn get_trigger_tags(&self) -> Option> { - self.trigger_tags.clone() - } } fn propagate_appsec( @@ -584,7 +581,7 @@ mod tests { #[test] fn test_span_inferrer_infer_span() { let config = Arc::new(Config::default()); - let mut inferrer = SpanInferrer::new(config); + let inferrer = SpanInferrer::new(config); // Create a payload with AWSTraceHeader from Java->SQS->Java let payload = json!({ @@ -617,20 +614,20 @@ mod tests { initialization_type: "on-demand".into(), }); - inferrer.infer_span(&payload, &aws_config); + let data = inferrer.infer_span(&payload, &aws_config); // Test that the inferrer processed the SQS event correctly assert!( - inferrer.inferred_span.is_some(), + data.inferred_span.is_some(), "Should have inferred span from SQS event" ); assert!( - inferrer.trigger_tags.is_some(), + data.trigger_tags.is_some(), "Should have trigger tags from SQS event" ); // Verify the trigger tags contain the expected SQS information - let trigger_tags = inferrer.trigger_tags.expect("Should have trigger tags"); + let trigger_tags = data.trigger_tags.expect("Should have trigger tags"); assert!( trigger_tags.contains_key("function_trigger.event_source"), "Should have event source in trigger tags" @@ -665,9 +662,9 @@ mod tests { fn test_complete_inferred_spans_propagates_appsec_from_invocation() { let payload = api_gateway_rest_payload(); let aws_config = aws_config("us-east-1"); - let mut inferrer = SpanInferrer::new(Arc::new(Config::default())); + let inferrer = SpanInferrer::new(Arc::new(Config::default())); - inferrer.infer_span(&payload, &aws_config); + let mut data = inferrer.infer_span(&payload, &aws_config); let mut invocation_span = Span { trace_id: 42, @@ -675,7 +672,7 @@ mod tests { service: "lambda-service".to_string(), ..Span::default() }; - if let Some(inferred_span) = &inferrer.inferred_span { + if let Some(inferred_span) = &data.inferred_span { invocation_span.start = inferred_span.start; } invocation_span.duration = 1; @@ -687,9 +684,9 @@ mod tests { r#"{"triggers":["rule"]}"#.to_string(), ); - inferrer.complete_inferred_spans(&invocation_span); + inferrer.complete_inferred_spans(&mut data, &invocation_span); - let inferred_span = inferrer + let inferred_span = data .inferred_span .as_ref() .expect("Inferred span should still be present"); @@ -722,25 +719,25 @@ mod tests { }, ..Config::default() }; - let mut inferrer = SpanInferrer::new(Arc::new(config)); + let inferrer = SpanInferrer::new(Arc::new(config)); let payload = api_gateway_rest_payload(); let aws_config = aws_config("us-east-1"); - inferrer.infer_span(&payload, &aws_config); + let mut data = inferrer.infer_span(&payload, &aws_config); let mut invocation_span = Span { trace_id: 7, service: "lambda-service".to_string(), ..Span::default() }; - if let Some(inferred_span) = &inferrer.inferred_span { + if let Some(inferred_span) = &data.inferred_span { invocation_span.start = inferred_span.start; } invocation_span.duration = 1; - inferrer.complete_inferred_spans(&invocation_span); + inferrer.complete_inferred_spans(&mut data, &invocation_span); - let inferred_span = inferrer + let inferred_span = data .inferred_span .as_ref() .expect("Inferred span should still be present"); diff --git a/bottlecap/src/lifecycle/invocation/triggers/event_bridge_event.rs b/bottlecap/src/lifecycle/invocation/triggers/event_bridge_event.rs index 322c82738..bc3580370 100644 --- a/bottlecap/src/lifecycle/invocation/triggers/event_bridge_event.rs +++ b/bottlecap/src/lifecycle/invocation/triggers/event_bridge_event.rs @@ -111,6 +111,47 @@ impl Trigger for EventBridgeEvent { fn is_async(&self) -> bool { true } + + fn get_payload_size_bytes(&self) -> f64 { + // Measure the serialized JSON byte length of the event detail object. + serde_json::to_string(&self.detail).map_or(0.0, |s| s.len() as f64) + } + + fn get_dsm_edge_tags(&self) -> Option> { + // EventBridge consume edge tags. `topic` is the detail-type. `exchange` + // (event bus) is not carried in the event; we only emit a payload-derived + // bus here when a `:rule//` ARN is present. The final exchange + // value (with `DD_DSM_EXCHANGE_NAME` taking priority and a `default` + // floor) is resolved downstream in the extraction hook. + let mut tags = vec!["direction:in".to_string(), "type:eventbridge".to_string()]; + if let Some(bus) = self.event_bus_name() { + tags.push(format!("exchange:{bus}")); + } + tags.push(format!("topic:{}", self.detail_type)); + Some(tags) + } +} + +impl EventBridgeEvent { + /// Payload-derived event bus name from a triggering rule ARN in `resources`. + /// Only non-default buses can be recovered, encoded as `:rule//`; + /// the first segment is the bus. Default-bus rules (`:rule/`, no bus + /// segment) and missing rule ARNs return `None`, leaving the hook to apply + /// the configured override or the `default` floor. + fn event_bus_name(&self) -> Option { + for arn in &self.resources { + if let Some(rest) = arn.split(":rule/").nth(1) { + let mut segments = rest.split('/'); + let first = segments.next().unwrap_or_default(); + // `:rule//` => bus is the first segment. + // `:rule/` (default bus) => no second segment, not derivable here. + if segments.next().is_some() && !first.is_empty() { + return Some(first.to_string()); + } + } + } + None + } } impl ServiceNameResolver for EventBridgeEvent { @@ -236,6 +277,75 @@ mod tests { assert_eq!(event.get_arn("us-east-1"), "my.event"); } + fn make_event(detail_type: &str, resources: Vec) -> EventBridgeEvent { + EventBridgeEvent { + id: "id".to_string(), + version: "0".to_string(), + account: "123456789012".to_string(), + time: Utc::now(), + region: "us-east-1".to_string(), + resources, + source: "my.event".to_string(), + detail_type: detail_type.to_string(), + detail: serde_json::json!({}), + replay_name: None, + } + } + + #[test] + fn test_get_dsm_edge_tags_no_resources_omits_exchange() { + // The standard fixture has no `resources`, so the bus name is unknown + // and the exchange tag must be omitted. + let json = read_json_file("eventbridge_event.json"); + let payload = serde_json::from_str(&json).expect("Failed to deserialize into Value"); + let event = + EventBridgeEvent::new(payload).expect("Failed to deserialize EventBridge Event"); + assert_eq!( + event.get_dsm_edge_tags(), + Some(vec![ + "direction:in".to_string(), + "type:eventbridge".to_string(), + "topic:UserSignUp".to_string(), + ]) + ); + } + + #[test] + fn test_get_dsm_edge_tags_recovers_bus_from_rule_arn() { + let event = make_event( + "OrderPlaced", + vec!["arn:aws:events:us-east-1:123456789012:rule/my-bus/my-rule".to_string()], + ); + assert_eq!( + event.get_dsm_edge_tags(), + Some(vec![ + "direction:in".to_string(), + "type:eventbridge".to_string(), + "exchange:my-bus".to_string(), + "topic:OrderPlaced".to_string(), + ]) + ); + } + + #[test] + fn test_get_dsm_edge_tags_default_bus_rule_arn_omits_exchange_at_trigger() { + // Default-bus rule ARNs (`:rule/`, no bus segment) are not + // derivable at the trigger level; the `default` floor is applied later + // by the extraction hook. + let event = make_event( + "OrderPlaced", + vec!["arn:aws:events:us-east-1:123456789012:rule/my-rule".to_string()], + ); + assert_eq!( + event.get_dsm_edge_tags(), + Some(vec![ + "direction:in".to_string(), + "type:eventbridge".to_string(), + "topic:OrderPlaced".to_string(), + ]) + ); + } + #[test] fn test_get_carrier() { let json = read_json_file("eventbridge_event.json"); @@ -370,4 +480,33 @@ mod tests { "eventbridge" // fallback value ); } + + #[test] + fn test_get_payload_size_bytes() { + // Construct an event with a known detail and verify payload_size_bytes + // equals the byte length of the compact JSON serialization of that detail. + let detail = serde_json::json!({"key": "value"}); + let expected_bytes = serde_json::to_string(&detail) + .expect("serialization must succeed") + .len() as f64; + + let event = EventBridgeEvent { + id: "id".to_string(), + version: "0".to_string(), + account: "123456789012".to_string(), + time: Utc::now(), + region: "us-east-1".to_string(), + resources: vec![], + source: "my.source".to_string(), + detail_type: "MyType".to_string(), + detail, + replay_name: None, + }; + + assert!( + (event.get_payload_size_bytes() - expected_bytes).abs() < f64::EPSILON, + "expected {expected_bytes}, got {}", + event.get_payload_size_bytes() + ); + } } diff --git a/bottlecap/src/lifecycle/invocation/triggers/kinesis_event.rs b/bottlecap/src/lifecycle/invocation/triggers/kinesis_event.rs index 7883dfda4..14a10f25e 100644 --- a/bottlecap/src/lifecycle/invocation/triggers/kinesis_event.rs +++ b/bottlecap/src/lifecycle/invocation/triggers/kinesis_event.rs @@ -10,7 +10,8 @@ use tracing::debug; use crate::lifecycle::invocation::{ processor::S_TO_NS, triggers::{ - DATADOG_CARRIER_KEY, FUNCTION_TRIGGER_EVENT_SOURCE_TAG, ServiceNameResolver, Trigger, + DATADOG_CARRIER_KEY, DsmCheckpointInput, FUNCTION_TRIGGER_EVENT_SOURCE_TAG, + ServiceNameResolver, Trigger, dsm_checkpoints_from_records, }, }; @@ -132,6 +133,35 @@ impl Trigger for KinesisRecord { fn is_async(&self) -> bool { true } + + fn get_dsm_edge_tags(&self) -> Option> { + // stream name = last `/` segment of the event source ARN. + let stream = self + .event_source_arn + .split('/') + .next_back() + .unwrap_or_default(); + if stream.is_empty() { + return Some(vec!["direction:in".to_string(), "type:kinesis".to_string()]); + } + Some(vec![ + "direction:in".to_string(), + format!("topic:{stream}"), + "type:kinesis".to_string(), + ]) + } + + fn get_payload_size_bytes(&self) -> f64 { + // The `data` field is base64-encoded; report the decoded byte length + // so the DSM PayloadSize sketch reflects the actual message size. + general_purpose::STANDARD + .decode(&self.kinesis.data) + .map_or(0.0, |b| b.len() as f64) + } + + fn get_dsm_checkpoints(&self, payload: &Value) -> Vec { + dsm_checkpoints_from_records::(payload) + } } impl ServiceNameResolver for KinesisRecord { @@ -370,4 +400,23 @@ mod tests { "kinesis" // fallback value ); } + + #[test] + fn test_get_dsm_checkpoints_payload_size() { + // The checkpoint payload_size_bytes must equal the decoded byte length + // of the base64-encoded `kinesis.data` field for each record. + let json = read_json_file("kinesis_event.json"); + let payload: Value = serde_json::from_str(&json).expect("Failed to deserialize into Value"); + + let event = KinesisRecord::new(payload.clone()).expect("Failed to deserialize"); + let checkpoints = event.get_dsm_checkpoints(&payload); + + assert_eq!(checkpoints.len(), 1); + // The fixture data field decodes to 155 bytes. + assert!( + (checkpoints[0].payload_size_bytes - 155.0).abs() < f64::EPSILON, + "expected 155.0, got {}", + checkpoints[0].payload_size_bytes + ); + } } diff --git a/bottlecap/src/lifecycle/invocation/triggers/mod.rs b/bottlecap/src/lifecycle/invocation/triggers/mod.rs index 8e89b2a4c..8feb79843 100644 --- a/bottlecap/src/lifecycle/invocation/triggers/mod.rs +++ b/bottlecap/src/lifecycle/invocation/triggers/mod.rs @@ -112,6 +112,43 @@ pub fn get_default_service_name( instance_name.to_string() } +/// DSM consume inputs for a single record: the source-specific edge tags plus +/// the record's carrier (which may contain the inbound pathway context). +#[derive(Debug, Clone, PartialEq)] +pub struct DsmCheckpointInput { + pub edge_tags: Vec, + pub carrier: HashMap, + /// Byte length of the record payload (message body / decoded data). + /// Used to populate the DSM `PayloadSize` sketch; 0.0 when not applicable. + pub payload_size_bytes: f64, +} + +/// Build per-record DSM consume inputs for a batched event by deserializing +/// every entry in the `Records` array into `T` and reading its edge tags and +/// carrier. Records that fail to deserialize or are not DSM-eligible (no edge +/// tags) are skipped. Returns empty when there is no `Records` array. +pub(crate) fn dsm_checkpoints_from_records(payload: &Value) -> Vec +where + T: Trigger + serde::de::DeserializeOwned, +{ + let Some(records) = payload.get("Records").and_then(Value::as_array) else { + return Vec::new(); + }; + records + .iter() + .filter_map(|record| { + let record: T = serde_json::from_value(record.clone()).ok()?; + let edge_tags = record.get_dsm_edge_tags()?; + let payload_size_bytes = record.get_payload_size_bytes(); + Some(DsmCheckpointInput { + edge_tags, + carrier: record.get_carrier(), + payload_size_bytes, + }) + }) + .collect() +} + pub trait Trigger: ServiceNameResolver { fn new(payload: Value) -> Option where @@ -130,6 +167,40 @@ pub trait Trigger: ServiceNameResolver { fn get_carrier(&self) -> HashMap; fn is_async(&self) -> bool; + /// Data Streams Monitoring consume-side edge tags for this trigger, with the + /// `direction:in` tag first. Returns `None` for sources that are not + /// DSM-eligible. Default: `None`. + fn get_dsm_edge_tags(&self) -> Option> { + None + } + + /// Byte length of this record's payload (message body / decoded data). + /// Used to populate the DSM `PayloadSize` sketch. Default: `0.0`. + fn get_payload_size_bytes(&self) -> f64 { + 0.0 + } + + /// Per-record DSM consume inputs for this (possibly batched) event. + /// + /// Each Lambda invocation can deliver multiple records (e.g. an SQS/SNS/ + /// Kinesis batch), and every record can carry its own inbound pathway + /// context. The default implementation yields a single entry derived from + /// the representative record this trigger was parsed from; batched sources + /// override it to yield one entry per record so no message is dropped. + /// + /// `payload` is the full, unparsed event so overrides can re-read every + /// record. Records that are not DSM-eligible are omitted. + fn get_dsm_checkpoints(&self, _payload: &Value) -> Vec { + match self.get_dsm_edge_tags() { + Some(edge_tags) => vec![DsmCheckpointInput { + edge_tags, + carrier: self.get_carrier(), + payload_size_bytes: self.get_payload_size_bytes(), + }], + None => Vec::new(), + } + } + fn get_dd_resource_key(&self, _region: &str) -> Option { None } diff --git a/bottlecap/src/lifecycle/invocation/triggers/msk_event.rs b/bottlecap/src/lifecycle/invocation/triggers/msk_event.rs index c36607d18..6a0cf436e 100644 --- a/bottlecap/src/lifecycle/invocation/triggers/msk_event.rs +++ b/bottlecap/src/lifecycle/invocation/triggers/msk_event.rs @@ -1,7 +1,9 @@ use crate::lifecycle::invocation::processor::MS_TO_NS; use crate::lifecycle::invocation::triggers::{ - FUNCTION_TRIGGER_EVENT_SOURCE_TAG, ServiceNameResolver, Trigger, + DsmCheckpointInput, FUNCTION_TRIGGER_EVENT_SOURCE_TAG, ServiceNameResolver, Trigger, }; +use base64::Engine; +use base64::engine::general_purpose; use libdd_trace_protobuf::pb::Span; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -263,6 +265,51 @@ impl Trigger for MSKEvent { fn is_async(&self) -> bool { true } + + fn get_dsm_checkpoints(&self, payload: &Value) -> Vec { + // `new` prunes the records map to a single record, so iterate the full + // unparsed payload to capture every Kafka record in the batch. Edge tags + // follow the dd-trace Kafka consume convention + // (`[direction:in, topic:, type:kafka]`); the `group:` tag is not present in the event and is injected from config + // (`DD_DSM_KAFKA_GROUP`) by the extraction hook. + let Some(records_map) = payload.get("records").and_then(Value::as_object) else { + return Vec::new(); + }; + let mut checkpoints = Vec::new(); + for group in records_map.values() { + let records: Vec<&Value> = match group { + Value::Array(arr) => arr.iter().collect(), + Value::Object(obj) => obj.values().collect(), + _ => Vec::new(), + }; + for record in records { + let Some(topic) = record.get("topic").and_then(Value::as_str) else { + continue; + }; + let carrier = record + .get("headers") + .map_or_else(HashMap::new, headers_to_string_map); + // The `value` field is base64-encoded; report the decoded + // byte length so the DSM PayloadSize sketch is accurate. + let payload_size_bytes = record + .get("value") + .and_then(Value::as_str) + .and_then(|v| general_purpose::STANDARD.decode(v).ok()) + .map_or(0.0, |b| b.len() as f64); + checkpoints.push(DsmCheckpointInput { + edge_tags: vec![ + "direction:in".to_string(), + format!("topic:{topic}"), + "type:kafka".to_string(), + ], + carrier, + payload_size_bytes, + }); + } + } + checkpoints + } } impl ServiceNameResolver for MSKEvent { @@ -616,4 +663,113 @@ mod tests { assert_eq!(record.partition, 0); assert!(event.get_carrier().is_empty()); } + + #[test] + fn test_get_dsm_checkpoints_one_per_record() { + // Two topic-partitions, each with a record carrying its own pathway + // context header. `dd-pathway-ctx-base64` bytes: "ctxA"=[99,116,120,65], + // "ctxB"=[99,116,120,66]. + let payload = serde_json::json!({ + "eventSource": "aws:kafka", + "eventSourceArn": "arn:aws:kafka:us-east-1:123456789012:cluster/demo-cluster/751d2973-a626-431c-9d4e-d7975eb44dd7-2", + "records": { + "topicA-0": [{ + "topic": "topicA", "partition": 0, "timestamp": 1000.0, + "headers": [{ "dd-pathway-ctx-base64": [99, 116, 120, 65] }] + }], + "topicB-0": [{ + "topic": "topicB", "partition": 0, "timestamp": 2000.0, + "headers": [{ "dd-pathway-ctx-base64": [99, 116, 120, 66] }] + }] + } + }); + + // `new` prunes to one record; the per-record checkpoints must come from + // the full payload, not the pruned trigger. + let trigger = MSKEvent::new(payload.clone()).expect("Failed to deserialize MSKEvent"); + let checkpoints = trigger.get_dsm_checkpoints(&payload); + + assert_eq!(checkpoints.len(), 2, "expected one checkpoint per record"); + + for (topic, ctx) in [("topicA", "ctxA"), ("topicB", "ctxB")] { + let cp = checkpoints + .iter() + .find(|c| c.edge_tags.contains(&format!("topic:{topic}"))) + .unwrap_or_else(|| panic!("missing checkpoint for {topic}")); + assert_eq!( + cp.edge_tags, + vec![ + "direction:in".to_string(), + format!("topic:{topic}"), + "type:kafka".to_string(), + ] + ); + assert_eq!( + cp.carrier.get("dd-pathway-ctx-base64").map(String::as_str), + Some(ctx) + ); + } + } + + #[test] + fn test_get_dsm_checkpoints_payload_size() { + // Each record's payload_size_bytes must equal the decoded byte length of + // its base64-encoded `value` field. A null value must yield 0.0. + // + // topic1 records decode to 34 and 33 bytes respectively (see fixture). + // topic2 record has a null value → 0.0. + let payload = serde_json::json!({ + "eventSource": "aws:kafka", + "eventSourceArn": "arn:aws:kafka:us-east-1:123456789012:cluster/demo-cluster/751d2973-a626-431c-9d4e-d7975eb44dd7-2", + "records": { + "topic1-0": [ + {"topic": "topic1", "partition": 0, "timestamp": 1000.0, + "value": "eyJvcmRlcklkIjoiMTIzNCIsImFtb3VudCI6MTAwLjAxfQ==", + "headers": []}, + {"topic": "topic1", "partition": 0, "timestamp": 2000.0, + "value": "eyJvcmRlcklkIjoiNTY3OCIsImFtb3VudCI6NTAuMDB9", + "headers": []} + ], + "topic2-0": [ + {"topic": "topic2", "partition": 0, "timestamp": 3000.0, + "value": serde_json::Value::Null, + "headers": []} + ] + } + }); + + let trigger = MSKEvent::new(payload.clone()).expect("Failed to deserialize MSKEvent"); + let checkpoints = trigger.get_dsm_checkpoints(&payload); + + assert_eq!(checkpoints.len(), 3); + + let topic1_sizes: Vec = checkpoints + .iter() + .filter(|c| c.edge_tags.iter().any(|t| t == "topic:topic1")) + .map(|c| c.payload_size_bytes) + .collect(); + assert_eq!(topic1_sizes.len(), 2); + assert!( + topic1_sizes + .iter() + .any(|&s| (s - 34.0).abs() < f64::EPSILON), + "expected a 34-byte record, got {topic1_sizes:?}" + ); + assert!( + topic1_sizes + .iter() + .any(|&s| (s - 33.0).abs() < f64::EPSILON), + "expected a 33-byte record, got {topic1_sizes:?}" + ); + + let topic2_cp = checkpoints + .iter() + .find(|c| c.edge_tags.iter().any(|t| t == "topic:topic2")) + .expect("topic2 checkpoint must exist"); + assert!( + topic2_cp.payload_size_bytes.abs() < f64::EPSILON, + "null value must yield 0.0, got {}", + topic2_cp.payload_size_bytes + ); + } } diff --git a/bottlecap/src/lifecycle/invocation/triggers/sns_event.rs b/bottlecap/src/lifecycle/invocation/triggers/sns_event.rs index 96cbc152d..b4488949c 100644 --- a/bottlecap/src/lifecycle/invocation/triggers/sns_event.rs +++ b/bottlecap/src/lifecycle/invocation/triggers/sns_event.rs @@ -10,7 +10,8 @@ use crate::lifecycle::invocation::{ base64_to_string, processor::MS_TO_NS, triggers::{ - DATADOG_CARRIER_KEY, FUNCTION_TRIGGER_EVENT_SOURCE_TAG, ServiceNameResolver, Trigger, + DATADOG_CARRIER_KEY, DsmCheckpointInput, FUNCTION_TRIGGER_EVENT_SOURCE_TAG, + ServiceNameResolver, Trigger, dsm_checkpoints_from_records, event_bridge_event::EventBridgeEvent, }, }; @@ -165,6 +166,23 @@ impl Trigger for SnsRecord { fn is_async(&self) -> bool { true } + + fn get_dsm_edge_tags(&self) -> Option> { + // SNS uses the full topic ARN as the topic tag (matches dd-trace-js). + Some(vec![ + "direction:in".to_string(), + format!("topic:{}", self.sns.topic_arn), + "type:sns".to_string(), + ]) + } + + fn get_payload_size_bytes(&self) -> f64 { + self.sns.message.as_ref().map_or(0.0, |m| m.len() as f64) + } + + fn get_dsm_checkpoints(&self, payload: &Value) -> Vec { + dsm_checkpoints_from_records::(payload) + } } impl ServiceNameResolver for SnsRecord { @@ -463,4 +481,34 @@ mod tests { "sns" // fallback value ); } + + #[test] + fn test_get_dsm_checkpoints_payload_size() { + // The checkpoint payload_size_bytes must equal the byte length of the + // SNS Message field for each record in the batch. + let json = read_json_file("sns_event.json"); + let mut payload: Value = + serde_json::from_str(&json).expect("Failed to deserialize into Value"); + let records = payload["Records"].as_array().expect("Records array"); + let mut first = records[0].clone(); + let mut second = records[0].clone(); + first["Sns"]["Message"] = Value::from("hello"); // 5 bytes + second["Sns"]["Message"] = Value::from("world!"); // 6 bytes + payload["Records"] = Value::from(vec![first, second]); + + let trigger = SnsRecord::new(payload.clone()).expect("Failed to deserialize SnsRecord"); + let checkpoints = trigger.get_dsm_checkpoints(&payload); + + assert_eq!(checkpoints.len(), 2); + assert!( + (checkpoints[0].payload_size_bytes - 5.0).abs() < f64::EPSILON, + "expected 5.0, got {}", + checkpoints[0].payload_size_bytes + ); + assert!( + (checkpoints[1].payload_size_bytes - 6.0).abs() < f64::EPSILON, + "expected 6.0, got {}", + checkpoints[1].payload_size_bytes + ); + } } diff --git a/bottlecap/src/lifecycle/invocation/triggers/sqs_event.rs b/bottlecap/src/lifecycle/invocation/triggers/sqs_event.rs index f65174287..9eb5e34ba 100644 --- a/bottlecap/src/lifecycle/invocation/triggers/sqs_event.rs +++ b/bottlecap/src/lifecycle/invocation/triggers/sqs_event.rs @@ -3,7 +3,8 @@ use crate::lifecycle::invocation::{ base64_to_string, processor::MS_TO_NS, triggers::{ - DATADOG_CARRIER_KEY, FUNCTION_TRIGGER_EVENT_SOURCE_TAG, ServiceNameResolver, Trigger, + DATADOG_CARRIER_KEY, DsmCheckpointInput, FUNCTION_TRIGGER_EVENT_SOURCE_TAG, + ServiceNameResolver, Trigger, dsm_checkpoints_from_records, event_bridge_event::EventBridgeEvent, sns_event::{SnsEntity, SnsRecord}, }, @@ -217,6 +218,28 @@ impl Trigger for SqsRecord { fn is_async(&self) -> bool { true } + + fn get_dsm_edge_tags(&self) -> Option> { + // queue name = last `:` segment of the event source ARN. + let queue = self + .event_source_arn + .split(':') + .next_back() + .unwrap_or_default(); + Some(vec![ + "direction:in".to_string(), + format!("topic:{queue}"), + "type:sqs".to_string(), + ]) + } + + fn get_payload_size_bytes(&self) -> f64 { + self.body.len() as f64 + } + + fn get_dsm_checkpoints(&self, payload: &Value) -> Vec { + dsm_checkpoints_from_records::(payload) + } } impl ServiceNameResolver for SqsRecord { @@ -412,6 +435,89 @@ mod tests { ); } + #[test] + fn test_get_dsm_checkpoints_one_per_record() { + // Build a two-record batch from the single-record fixture, giving each + // record a distinct queue and a distinct pathway carrier. + let json = read_json_file("sqs_event.json"); + let mut payload: Value = + serde_json::from_str(&json).expect("Failed to deserialize into Value"); + let records = payload["Records"].as_array().expect("Records array"); + let mut first = records[0].clone(); + let mut second = records[0].clone(); + + first["eventSourceARN"] = Value::from("arn:aws:sqs:us-east-1:123456789012:QueueA"); + first["messageAttributes"]["_datadog"]["stringValue"] = + Value::from("{\"x-datadog-trace-id\":\"111\",\"dd-pathway-ctx-base64\":\"ctxA\"}"); + + second["eventSourceARN"] = Value::from("arn:aws:sqs:us-east-1:123456789012:QueueB"); + second["messageAttributes"]["_datadog"]["stringValue"] = + Value::from("{\"x-datadog-trace-id\":\"222\",\"dd-pathway-ctx-base64\":\"ctxB\"}"); + + payload["Records"] = Value::from(vec![first, second]); + + let trigger = SqsRecord::new(payload.clone()).expect("Failed to deserialize SqsRecord"); + let checkpoints = trigger.get_dsm_checkpoints(&payload); + + assert_eq!(checkpoints.len(), 2, "expected one checkpoint per record"); + + assert_eq!( + checkpoints[0].edge_tags, + vec![ + "direction:in".to_string(), + "topic:QueueA".to_string(), + "type:sqs".to_string(), + ] + ); + assert_eq!( + checkpoints[0].carrier.get("dd-pathway-ctx-base64"), + Some(&"ctxA".to_string()) + ); + + assert_eq!( + checkpoints[1].edge_tags, + vec![ + "direction:in".to_string(), + "topic:QueueB".to_string(), + "type:sqs".to_string(), + ] + ); + assert_eq!( + checkpoints[1].carrier.get("dd-pathway-ctx-base64"), + Some(&"ctxB".to_string()) + ); + } + + #[test] + fn test_get_dsm_checkpoints_payload_size() { + // Each checkpoint's payload_size_bytes must equal the UTF-8 byte length + // of its record's `body` field. + let json = read_json_file("sqs_event.json"); + let mut payload: Value = + serde_json::from_str(&json).expect("Failed to deserialize into Value"); + let records = payload["Records"].as_array().expect("Records array"); + let mut first = records[0].clone(); + let mut second = records[0].clone(); + first["body"] = Value::from("hello"); // 5 bytes + second["body"] = Value::from("world!"); // 6 bytes + payload["Records"] = Value::from(vec![first, second]); + + let trigger = SqsRecord::new(payload.clone()).expect("Failed to deserialize SqsRecord"); + let checkpoints = trigger.get_dsm_checkpoints(&payload); + + assert_eq!(checkpoints.len(), 2); + assert!( + (checkpoints[0].payload_size_bytes - 5.0).abs() < f64::EPSILON, + "expected 5.0, got {}", + checkpoints[0].payload_size_bytes + ); + assert!( + (checkpoints[1].payload_size_bytes - 6.0).abs() < f64::EPSILON, + "expected 6.0, got {}", + checkpoints[1].payload_size_bytes + ); + } + #[test] fn test_get_carrier() { let json = read_json_file("sqs_event.json"); @@ -534,6 +640,31 @@ mod tests { assert_eq!(carrier, expected); } + #[test] + fn test_get_carrier_from_binary_message_attribute() { + // SNS -> SQS raw message delivery: `_datadog` is a top-level SQS Binary + // attribute whose `binaryValue` is base64-encoded carrier JSON, and the + // body is the raw published message (no SNS envelope). + let json = read_json_file("sqs_binary_event.json"); + let payload = serde_json::from_str(&json).expect("Failed to deserialize into Value"); + let event = SqsRecord::new(payload).expect("Failed to deserialize SqsRecord"); + let carrier = event.get_carrier(); + + assert_eq!( + carrier.get("dd-pathway-ctx-base64").map(String::as_str), + Some("Ev+XMfNJ31T+hcjp+Gf+hcjp+Gc="), + "DSM pathway context must be extracted from the Binary attribute" + ); + assert_eq!( + carrier.get("x-datadog-trace-id").map(String::as_str), + Some("2522563026513800488") + ); + assert_eq!( + carrier.get("x-datadog-parent-id").map(String::as_str), + Some("1052921069172192507") + ); + } + #[test] fn test_get_carrier_from_eventbridge() { let json = read_json_file("eventbridge_sqs_event.json"); diff --git a/bottlecap/src/proxy/interceptor.rs b/bottlecap/src/proxy/interceptor.rs index 21a018377..aedd6b7f6 100644 --- a/bottlecap/src/proxy/interceptor.rs +++ b/bottlecap/src/proxy/interceptor.rs @@ -34,14 +34,23 @@ type InterceptorState = ( InvocationProcessorHandle, Option>>, Arc, + bool, Arc>>, ); +#[derive(Debug, PartialEq, Eq)] +enum InvocationNextProcessingMode { + UniversalInstrumentation, + DsmOnly, + None, +} + pub fn start( aws_config: Arc, invocation_processor_handle: InvocationProcessorHandle, appsec_processor: Option>>, propagator: Arc, + dsm_consume_enabled: bool, ) -> Result> { let socket = get_proxy_socket_address(aws_config.aws_lwa_proxy_lambda_runtime_api.as_ref()); let shutdown_token = CancellationToken::new(); @@ -61,6 +70,7 @@ pub fn start( invocation_processor_handle, appsec_processor, propagator, + dsm_consume_enabled, tasks.clone(), ); @@ -112,6 +122,45 @@ async fn graceful_shutdown(tasks: Arc>>, shutdown_token: Cance } } +async fn record_dsm_consume_from_invocation_next( + invocation_processor: &InvocationProcessorHandle, + parts: &http::response::Parts, + body: &Bytes, +) { + let Some(request_id) = parts + .headers + .get("Lambda-Runtime-Aws-Request-Id") + .and_then(|v| v.to_str().ok()) + .map(std::string::ToString::to_string) + else { + debug!("PROXY | invocation_next_proxy | missing request id for DSM consume extraction"); + return; + }; + + let payload_value = serde_json::from_slice(body).unwrap_or_else(|e| { + debug!("PROXY | invocation_next_proxy | error parsing DSM payload as JSON: {e}"); + serde_json::json!({}) + }); + + let _ = invocation_processor + .record_dsm_consume_from_payload(request_id, payload_value) + .await; +} + +fn get_invocation_next_processing_mode( + aws_config: &AwsConfig, + experimental_proxy_enabled: bool, + dsm_consume_enabled: bool, +) -> InvocationNextProcessingMode { + if aws_config.aws_lwa_proxy_lambda_runtime_api.is_some() || experimental_proxy_enabled { + InvocationNextProcessingMode::UniversalInstrumentation + } else if dsm_consume_enabled { + InvocationNextProcessingMode::DsmOnly + } else { + InvocationNextProcessingMode::None + } +} + /// Given an optional String representing the LWA proxy lambda runtime API, /// return a `SocketAddr` that can be used to bind the proxy server. /// @@ -133,9 +182,15 @@ fn get_proxy_socket_address(aws_lwa_proxy_lambda_runtime_api: Option<&String>) - async fn invocation_next_proxy( Path(api_version): Path, - State((aws_config, client, invocation_processor, appsec_processor, propagator, tasks)): State< - InterceptorState, - >, + State(( + aws_config, + client, + invocation_processor, + appsec_processor, + propagator, + dsm_consume_enabled, + tasks, + )): State, request: Request, ) -> Response { debug!("PROXY | invocation_next_proxy | api_version: {api_version}"); @@ -196,14 +251,43 @@ async fn invocation_next_proxy( } } - if aws_config.aws_lwa_proxy_lambda_runtime_api.is_some() { - lwa::process_invocation_next( - &invocation_processor, - &intercepted_parts_clone, - &body, - Arc::clone(&propagator), - ) - .await; + // Drive full LWA universal instrumentation only for LWA and the + // experimental wrapper proxy. DSM-only proxy support must not reuse + // `lwa::process_invocation_next`, because that path also queues LWA + // reparenting with a synthetic invocation span id. In tracer runtimes + // that still call `/lambda/start-invocation`, that synthetic id can + // conflict with the tracer-provided invocation span id. + let experimental_proxy_enabled = std::env::var("DD_EXPERIMENTAL_ENABLE_PROXY") + .is_ok_and(|v| v.eq_ignore_ascii_case("true")); + match get_invocation_next_processing_mode( + &aws_config, + experimental_proxy_enabled, + dsm_consume_enabled, + ) { + InvocationNextProcessingMode::UniversalInstrumentation => { + debug!( + "PROXY | invocation_next_proxy | driving universal instrumentation from intercepted payload" + ); + lwa::process_invocation_next( + &invocation_processor, + &intercepted_parts_clone, + &body, + Arc::clone(&propagator), + ) + .await; + } + InvocationNextProcessingMode::DsmOnly => { + debug!( + "PROXY | invocation_next_proxy | recording DSM consume from intercepted payload" + ); + record_dsm_consume_from_invocation_next( + &invocation_processor, + &intercepted_parts_clone, + &body, + ) + .await; + } + InvocationNextProcessingMode::None => {} } } }); @@ -223,7 +307,7 @@ async fn invocation_next_proxy( async fn invocation_response_proxy( Path((api_version, request_id)): Path<(String, String)>, - State((aws_config, client, invocation_processor, appsec_processor, _, tasks)): State< + State((aws_config, client, invocation_processor, appsec_processor, _, _, tasks)): State< InterceptorState, >, request: Request, @@ -304,7 +388,7 @@ 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; + 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 @@ -318,7 +402,7 @@ async fn invocation_error_proxy( } async fn passthrough_proxy( - State((aws_config, client, _, _, _, _)): State, + State((aws_config, client, _, _, _, _, _)): State, request: Request, ) -> Response { let (parts, body) = request.into_parts(); @@ -448,7 +532,48 @@ mod tests { config::Config, tags::provider::Provider, traces::propagation::DatadogCompositePropagator, }; + fn test_aws_config(aws_lwa_proxy_lambda_runtime_api: Option) -> AwsConfig { + AwsConfig { + region: "us-east-1".to_string(), + function_name: "arn:some-function".to_string(), + sandbox_init_time: Instant::now(), + runtime_api: "127.0.0.1:12344".to_string(), + aws_lwa_proxy_lambda_runtime_api, + exec_wrapper: None, + initialization_type: "on-demand".into(), + } + } + + #[test] + fn invocation_next_processing_mode_uses_resolved_dsm_flag() { + let aws_config = test_aws_config(None); + + assert_eq!( + get_invocation_next_processing_mode(&aws_config, false, true), + InvocationNextProcessingMode::DsmOnly + ); + assert_eq!( + get_invocation_next_processing_mode(&aws_config, false, false), + InvocationNextProcessingMode::None + ); + } + + #[test] + fn invocation_next_processing_mode_prefers_universal_instrumentation() { + let aws_config = test_aws_config(Some("127.0.0.1:9000".to_string())); + + assert_eq!( + get_invocation_next_processing_mode(&aws_config, false, true), + InvocationNextProcessingMode::UniversalInstrumentation + ); + assert_eq!( + get_invocation_next_processing_mode(&test_aws_config(None), true, true), + InvocationNextProcessingMode::UniversalInstrumentation + ); + } + #[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"; @@ -489,15 +614,9 @@ mod tests { tokio::spawn(service.run()); let metrics_aggregator = handle; - let aws_config = Arc::new(AwsConfig { - region: "us-east-1".to_string(), - function_name: "arn:some-function".to_string(), - sandbox_init_time: Instant::now(), - runtime_api: aws_lambda_runtime_api.to_string(), - aws_lwa_proxy_lambda_runtime_api: Some(aws_lwa_lambda_runtime_api.to_string()), - exec_wrapper: None, - initialization_type: "on-demand".into(), - }); + let mut aws_config_data = test_aws_config(Some(aws_lwa_lambda_runtime_api.to_string())); + aws_config_data.runtime_api = aws_lambda_runtime_api.to_string(); + let aws_config = Arc::new(aws_config_data); let propagator = Arc::new(DatadogCompositePropagator::new(Arc::clone(&config))); let (durable_context_tx, _durable_context_rx) = tokio::sync::mpsc::channel(1); let (invocation_processor_handle, invocation_processor_service) = @@ -508,6 +627,7 @@ mod tests { metrics_aggregator, Arc::clone(&propagator), durable_context_tx, + None, ); tokio::spawn(async move { invocation_processor_service.run().await; @@ -529,6 +649,7 @@ mod tests { invocation_processor_handle, appsec_processor, propagator, + config.ext.dsm_consume_enabled, ) .expect("Failed to start API runtime proxy"); let https = HttpConnector::new(); diff --git a/bottlecap/src/proxy/mod.rs b/bottlecap/src/proxy/mod.rs index 6db2d818d..a602481b4 100644 --- a/bottlecap/src/proxy/mod.rs +++ b/bottlecap/src/proxy/mod.rs @@ -26,7 +26,9 @@ pub fn should_start_proxy(config: &Arc, aws_config: Arc) -> b lwa_proxy_set || (datadog_wrapper_set - && (config.ext.serverless_appsec_enabled || experimental_proxy_enabled)) + && (config.ext.serverless_appsec_enabled + || config.ext.dsm_consume_enabled + || experimental_proxy_enabled)) } #[cfg(test)] @@ -115,6 +117,54 @@ mod tests { assert!(!should_start_proxy(&config, aws_config)); } + #[test] + fn test_should_start_proxy_dsm_enabled_and_datadog_wrapper_set() { + // DSM enabled with the datadog wrapper must start the proxy so the + // interceptor's DsmOnly path can run, even when AppSec is disabled. + let config = Arc::new(Config { + ext: crate::config::LambdaConfig { + serverless_appsec_enabled: false, + dsm_consume_enabled: true, + ..Default::default() + }, + ..Default::default() + }); + let aws_config = Arc::new(AwsConfig { + region: "us-east-1".to_string(), + aws_lwa_proxy_lambda_runtime_api: None, + function_name: String::new(), + runtime_api: String::new(), + sandbox_init_time: Instant::now(), + exec_wrapper: Some("/opt/datadog_wrapper".to_string()), + initialization_type: "on-demand".into(), + }); + assert!(should_start_proxy(&config, aws_config)); + } + + #[test] + fn test_should_start_proxy_dsm_enabled_datadog_wrapper_not_set() { + // DSM enabled without LWA or the datadog wrapper must not start the proxy: + // the runtime API is not intercepted, so there is nothing to extract from. + let config = Arc::new(Config { + ext: crate::config::LambdaConfig { + serverless_appsec_enabled: false, + dsm_consume_enabled: true, + ..Default::default() + }, + ..Default::default() + }); + let aws_config = Arc::new(AwsConfig { + region: "us-east-1".to_string(), + aws_lwa_proxy_lambda_runtime_api: None, + function_name: String::new(), + runtime_api: String::new(), + sandbox_init_time: Instant::now(), + exec_wrapper: Some("/opt/not_datadog".to_string()), + initialization_type: "on-demand".into(), + }); + assert!(!should_start_proxy(&config, aws_config)); + } + #[test] fn test_should_start_proxy_appsec_enabled_datadog_wrapper_not_set() { let config = Arc::new(Config { diff --git a/bottlecap/src/traces/data_streams/aggregator.rs b/bottlecap/src/traces/data_streams/aggregator.rs new file mode 100644 index 000000000..d650715db --- /dev/null +++ b/bottlecap/src/traces/data_streams/aggregator.rs @@ -0,0 +1,293 @@ +//! In-memory aggregation of DSM consume checkpoints into pipeline-stats buckets, +//! and serialization to the msgpack payload the DSM intake expects. +//! +//! Mirrors the `dd-trace-js` processor: 10-second time buckets keyed by checkpoint +//! hash, each holding `EdgeLatency` / `PathwayLatency` / `PayloadSize` sketches. +//! The serialized payload is msgpack (struct-as-map) and is gzipped by the +//! flusher before being sent to `/api/v0.1/pipeline_stats`. + +use std::collections::HashMap; + +use serde::Serialize; + +use crate::traces::data_streams::checkpoint::Checkpoint; +use crate::traces::data_streams::sketch::DdSketch; + +/// Bucket width in nanoseconds (10s), matching the tracer. +const BUCKET_SIZE_NS: u64 = 10_000_000_000; + +/// A single checkpoint's accumulated stats within a bucket. +struct StatsPoint { + hash: u64, + parent_hash: u64, + edge_tags: Vec, + edge_latency: DdSketch, + pathway_latency: DdSketch, + payload_size: DdSketch, +} + +impl StatsPoint { + fn new(hash: u64, parent_hash: u64, edge_tags: Vec) -> Self { + Self { + hash, + parent_hash, + edge_tags, + edge_latency: DdSketch::new(), + pathway_latency: DdSketch::new(), + payload_size: DdSketch::new(), + } + } + + fn add(&mut self, edge_latency_ns: u64, pathway_latency_ns: u64, payload_size: f64) { + #[allow(clippy::cast_precision_loss)] + let edge_s = edge_latency_ns as f64 / 1e9; + #[allow(clippy::cast_precision_loss)] + let pathway_s = pathway_latency_ns as f64 / 1e9; + self.edge_latency.accept(edge_s); + self.pathway_latency.accept(pathway_s); + self.payload_size.accept(payload_size); + } +} + +/// One time bucket: a set of checkpoints keyed by hash. +#[derive(Default)] +struct StatsBucket { + points: HashMap, +} + +/// Aggregates DSM checkpoints across invocations until flushed. +pub struct Aggregator { + service: String, + env: String, + tracer_version: String, + version: String, + tags: Vec, + buckets: HashMap, +} + +impl Aggregator { + #[must_use] + pub fn new( + service: String, + env: String, + tracer_version: String, + version: String, + tags: Vec, + ) -> Self { + Self { + service, + env, + tracer_version, + version, + tags, + buckets: HashMap::new(), + } + } + + /// Fold a computed consume checkpoint into the appropriate time bucket. + pub fn add(&mut self, checkpoint: &Checkpoint, payload_size: f64) { + let bucket_start = checkpoint.current_ts_ns - (checkpoint.current_ts_ns % BUCKET_SIZE_NS); + let hash = u64::from_le_bytes(checkpoint.hash); + let parent_hash = u64::from_le_bytes(checkpoint.parent_hash); + + let bucket = self.buckets.entry(bucket_start).or_default(); + let point = bucket + .points + .entry(hash) + .or_insert_with(|| StatsPoint::new(hash, parent_hash, checkpoint.edge_tags.clone())); + point.add( + checkpoint.edge_latency_ns, + checkpoint.pathway_latency_ns, + payload_size, + ); + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.buckets.is_empty() + } + + /// Drain all buckets and build the msgpack `StatsPayload` (struct-as-map). + /// Returns `None` when there is nothing to flush. + #[must_use] + pub fn take_payload(&mut self) -> Option> { + if self.buckets.is_empty() { + return None; + } + + let stats: Vec = self + .buckets + .drain() + .map(|(start, bucket)| StatsBucketSer { + start, + duration: BUCKET_SIZE_NS, + stats: bucket + .points + .into_values() + .map(|p| StatsPointSer { + hash: p.hash, + parent_hash: p.parent_hash, + edge_tags: p.edge_tags, + edge_latency: p.edge_latency.to_proto_bytes(), + pathway_latency: p.pathway_latency.to_proto_bytes(), + payload_size: p.payload_size.to_proto_bytes(), + }) + .collect(), + backlogs: Vec::new(), + }) + .collect(); + + let payload = StatsPayloadSer { + env: self.env.clone(), + service: self.service.clone(), + stats, + tracer_version: self.tracer_version.clone(), + lang: "rust-extension".to_string(), + version: self.version.clone(), + tags: self.tags.clone(), + // TODO(DSM): Validate resolver-side behavior for extension-produced + // DD_TAGS / unified tags and whether ProcessTags should also be + // emitted in addition to top-level Tags. + }; + + match rmp_serde::to_vec_named(&payload) { + Ok(buf) => Some(buf), + Err(e) => { + tracing::warn!("DSM: failed to serialize pipeline stats payload: {e}"); + None + } + } + } +} + +#[derive(Serialize)] +struct StatsPayloadSer { + #[serde(rename = "Env")] + env: String, + #[serde(rename = "Service")] + service: String, + #[serde(rename = "Stats")] + stats: Vec, + #[serde(rename = "TracerVersion")] + tracer_version: String, + #[serde(rename = "Lang")] + lang: String, + #[serde(rename = "Version")] + version: String, + #[serde(rename = "Tags")] + tags: Vec, +} + +#[derive(Serialize)] +struct StatsBucketSer { + #[serde(rename = "Start")] + start: u64, + #[serde(rename = "Duration")] + duration: u64, + #[serde(rename = "Stats")] + stats: Vec, + #[serde(rename = "Backlogs")] + backlogs: Vec<()>, +} + +#[derive(Serialize)] +struct StatsPointSer { + #[serde(rename = "Hash")] + hash: u64, + #[serde(rename = "ParentHash")] + parent_hash: u64, + #[serde(rename = "EdgeTags")] + edge_tags: Vec, + #[serde(rename = "EdgeLatency", with = "serde_bytes")] + edge_latency: Vec, + #[serde(rename = "PathwayLatency", with = "serde_bytes")] + pathway_latency: Vec, + #[serde(rename = "PayloadSize", with = "serde_bytes")] + payload_size: Vec, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::traces::data_streams::checkpoint::compute_consume_checkpoint; + use serde::Deserialize; + + #[derive(Deserialize)] + struct DecodedPayload { + #[serde(rename = "TracerVersion")] + tracer_version: String, + #[serde(rename = "Version")] + version: String, + #[serde(rename = "Tags")] + tags: Vec, + } + + fn edge_tags() -> Vec { + vec![ + "direction:in".to_string(), + "topic:q".to_string(), + "type:sqs".to_string(), + ] + } + + #[test] + fn empty_aggregator_has_no_payload() { + let mut agg = Aggregator::new( + "svc".into(), + "env".into(), + "1.0".into(), + "2.0".into(), + vec!["team:serverless".into()], + ); + assert!(agg.is_empty()); + assert!(agg.take_payload().is_none()); + } + + #[test] + fn aggregates_and_serializes() { + let mut agg = Aggregator::new( + "svc".into(), + "env".into(), + "1.0".into(), + "2.0".into(), + vec!["team:serverless".into(), "region:us-east-1".into()], + ); + let cp = compute_consume_checkpoint("svc", "env", &edge_tags(), None, 2_000_000_000, None); + agg.add(&cp, 128.0); + assert!(!agg.is_empty()); + + let payload = agg.take_payload().expect("payload"); + assert!(!payload.is_empty()); + assert!(agg.is_empty()); + + assert_eq!( + payload[0], 0x87, + "top-level payload must be a 7-entry msgpack map" + ); + + let decoded: DecodedPayload = rmp_serde::from_slice(&payload).expect("decode payload"); + assert_eq!(decoded.tracer_version, "1.0"); + assert_eq!(decoded.version, "2.0"); + assert_eq!(decoded.tags, vec!["team:serverless", "region:us-east-1"]); + } + + #[test] + fn same_hash_merges_into_one_point() { + let mut agg = Aggregator::new( + "svc".into(), + "env".into(), + "1.0".into(), + "2.0".into(), + vec!["team:serverless".into()], + ); + let cp1 = compute_consume_checkpoint("svc", "env", &edge_tags(), None, 2_000_000_000, None); + let cp2 = compute_consume_checkpoint("svc", "env", &edge_tags(), None, 2_000_000_001, None); + agg.add(&cp1, 1.0); + agg.add(&cp2, 1.0); + + assert_eq!(agg.buckets.len(), 1); + let bucket = agg.buckets.values().next().unwrap(); + assert_eq!(bucket.points.len(), 1); + } +} diff --git a/bottlecap/src/traces/data_streams/checkpoint.rs b/bottlecap/src/traces/data_streams/checkpoint.rs new file mode 100644 index 000000000..fb4ba8347 --- /dev/null +++ b/bottlecap/src/traces/data_streams/checkpoint.rs @@ -0,0 +1,124 @@ +//! Consume-side DSM checkpoint computation. +//! +//! This is the extension-only subset of `dd-trace-js`'s `setCheckpoint`: we only +//! ever produce a single inbound (`direction:in`) checkpoint continuing from an +//! extracted parent context. The tracer's in-process `closestOppositeDirection` +//! loop handling does not apply, because the extension never observes the +//! produce side of a pathway. + +use crate::traces::data_streams::context::DsmContext; +use crate::traces::data_streams::pathway::compute_pathway_hash; + +/// Parent hash used when there is no inbound context (pathway entry point). +pub const ENTRY_PARENT_HASH: [u8; 8] = [0; 8]; + +/// A computed consume checkpoint, ready to be folded into a stats bucket. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Checkpoint { + /// This checkpoint's pathway hash. + pub hash: [u8; 8], + /// The parent pathway hash this checkpoint continues from. + pub parent_hash: [u8; 8], + /// Sorted edge tags (direction tag first, as supplied by the caller). + pub edge_tags: Vec, + /// Time the producer-to-consumer edge took, in nanoseconds. + pub edge_latency_ns: u64, + /// Total pathway latency from origin to here, in nanoseconds. + pub pathway_latency_ns: u64, + /// Wall-clock time of this checkpoint, in nanoseconds (used for bucketing). + pub current_ts_ns: u64, +} + +/// Compute an inbound (`direction:in`) consume checkpoint. +/// +/// * `edge_tags` must contain `direction:in` and the source-specific tags. +/// * `ctx` is the extracted inbound DSM context, if any. +/// * `now_ns` is the current wall-clock time in nanoseconds. +/// * `propagation_hash` is the optional process/container-tag hash. +#[must_use] +pub fn compute_consume_checkpoint( + service: &str, + env: &str, + edge_tags: &[String], + ctx: Option<&DsmContext>, + now_ns: u64, + propagation_hash: Option, +) -> Checkpoint { + let (parent_hash, pathway_start_ns, edge_start_ns) = match ctx { + Some(ctx) => (ctx.hash, ctx.pathway_start_ns, ctx.edge_start_ns), + None => (ENTRY_PARENT_HASH, now_ns, now_ns), + }; + + let hash = compute_pathway_hash(service, env, edge_tags, parent_hash, propagation_hash); + + // Saturating: a clock skew where the stored start is in the future yields 0 + // latency rather than a wildly large wrapped value. + let edge_latency_ns = now_ns.saturating_sub(edge_start_ns); + let pathway_latency_ns = now_ns.saturating_sub(pathway_start_ns); + + Checkpoint { + hash, + parent_hash, + edge_tags: edge_tags.to_vec(), + edge_latency_ns, + pathway_latency_ns, + current_ts_ns: now_ns, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tags() -> Vec { + vec![ + "direction:in".to_string(), + "topic:my-topic".to_string(), + "type:sqs".to_string(), + ] + } + + #[test] + fn continues_from_extracted_context() { + let ctx = DsmContext { + hash: [1, 2, 3, 4, 5, 6, 7, 8], + pathway_start_ns: 1_000_000_000, + edge_start_ns: 1_500_000_000, + }; + let now = 2_000_000_000; + + let cp = compute_consume_checkpoint("svc", "env", &tags(), Some(&ctx), now, None); + + assert_eq!(cp.parent_hash, [1, 2, 3, 4, 5, 6, 7, 8]); + assert_eq!(cp.edge_latency_ns, 500_000_000); // now - edge_start + assert_eq!(cp.pathway_latency_ns, 1_000_000_000); // now - pathway_start + assert_eq!(cp.current_ts_ns, now); + // Hash must match a direct pathway-hash computation with the parent. + assert_eq!( + cp.hash, + compute_pathway_hash("svc", "env", &tags(), ctx.hash, None) + ); + } + + #[test] + fn entry_point_when_no_context() { + let now = 2_000_000_000; + let cp = compute_consume_checkpoint("svc", "env", &tags(), None, now, None); + + assert_eq!(cp.parent_hash, ENTRY_PARENT_HASH); + assert_eq!(cp.edge_latency_ns, 0); + assert_eq!(cp.pathway_latency_ns, 0); + } + + #[test] + fn clock_skew_saturates_to_zero() { + let ctx = DsmContext { + hash: [0; 8], + pathway_start_ns: 5_000_000_000, // in the future relative to now + edge_start_ns: 5_000_000_000, + }; + let cp = compute_consume_checkpoint("svc", "env", &tags(), Some(&ctx), 1_000_000_000, None); + assert_eq!(cp.edge_latency_ns, 0); + assert_eq!(cp.pathway_latency_ns, 0); + } +} diff --git a/bottlecap/src/traces/data_streams/context.rs b/bottlecap/src/traces/data_streams/context.rs new file mode 100644 index 000000000..1c086139c --- /dev/null +++ b/bottlecap/src/traces/data_streams/context.rs @@ -0,0 +1,172 @@ +//! Decoding of inbound Data Streams Monitoring (DSM) pathway context. +//! +//! The wire format (after base64 decoding) is: +//! 1. first 8 bytes: raw pathway hash +//! 2. zigzag-encoded signed varint (protobuf `sint64`): `pathwayStartMs` +//! 3. zigzag-encoded signed varint (protobuf `sint64`): `edgeStartMs` +//! +//! NOTE: an earlier design note described these as plain unsigned varints. The +//! `dd-trace-js` tracer actually zigzag-encodes them (a positive `n` is stored +//! as `2n`), so they must be zigzag-decoded to recover the millisecond value. +//! +//! Both timestamps are stored in milliseconds and converted to nanoseconds by +//! multiplying by `1_000_000`, matching `dd-trace-js`. +//! +//! All decoding fails closed: malformed payloads return `None` and are treated +//! as "no parent DSM context". + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; + +/// Carrier key (preferred) holding the base64-encoded DSM pathway context. +pub const DD_PATHWAY_CTX_BASE64_KEY: &str = "dd-pathway-ctx-base64"; +/// Legacy carrier key holding the base64-encoded DSM pathway context. +pub const DD_PATHWAY_CTX_KEY: &str = "dd-pathway-ctx"; + +const MS_TO_NS: u64 = 1_000_000; + +/// An inbound DSM pathway context extracted from a carrier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DsmContext { + /// Raw 8-byte parent pathway hash (opaque; do not reinterpret for hashing). + pub hash: [u8; 8], + /// Pathway start time in nanoseconds. + pub pathway_start_ns: u64, + /// Edge start time in nanoseconds. + pub edge_start_ns: u64, +} + +impl DsmContext { + /// Decode a DSM context from a base64-encoded `dd-pathway-ctx-base64` value. + #[must_use] + pub fn from_base64(input: &str) -> Option { + let bytes = STANDARD.decode(input).ok()?; + Self::from_bytes(&bytes) + } + + /// Decode a DSM context from its raw binary representation. + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Option { + if bytes.len() < 8 { + return None; + } + + let mut hash = [0u8; 8]; + hash.copy_from_slice(&bytes[..8]); + + let (pathway_start_ms, rest) = decode_zigzag_varint(&bytes[8..])?; + let (edge_start_ms, _) = decode_zigzag_varint(rest)?; + + Some(Self { + hash, + pathway_start_ns: ms_to_ns(pathway_start_ms)?, + edge_start_ns: ms_to_ns(edge_start_ms)?, + }) + } +} + +/// Convert a (signed) millisecond timestamp to nanoseconds. Negative values are +/// rejected — DSM timestamps are always positive wall-clock times. +fn ms_to_ns(ms: i64) -> Option { + u64::try_from(ms).ok()?.checked_mul(MS_TO_NS) +} + +/// Decode a zigzag-encoded signed varint (protobuf `sint64`). +fn decode_zigzag_varint(bytes: &[u8]) -> Option<(i64, &[u8])> { + let (raw, rest) = decode_uvarint(bytes)?; + // Zigzag decode: (raw >> 1) ^ -(raw & 1). + #[allow(clippy::cast_possible_wrap)] + let decoded = ((raw >> 1) as i64) ^ -((raw & 1) as i64); + Some((decoded, rest)) +} + +/// Decode an unsigned LEB128 varint, returning the value and the remaining bytes. +/// +/// Returns `None` if the input is truncated or the varint overflows `u64`. +fn decode_uvarint(bytes: &[u8]) -> Option<(u64, &[u8])> { + let mut result: u64 = 0; + let mut shift: u32 = 0; + + for (idx, &byte) in bytes.iter().enumerate() { + // A u64 holds at most 10 varint groups (last group contributes 1 bit). + if shift >= 64 { + return None; + } + let payload = u64::from(byte & 0x7f); + result |= payload.checked_shl(shift)?; + + if byte & 0x80 == 0 { + return Some((result, &bytes[idx + 1..])); + } + shift += 7; + } + + // Ran out of bytes before the terminating group. + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Pinned `dd-trace-js` fixture. + const FIXTURE_B64: &str = "Z7CzXmXArPrE58Cfj2LI2cOfj2I="; + + #[test] + fn decodes_pinned_base64_fixture() { + let ctx = DsmContext::from_base64(FIXTURE_B64).expect("should decode"); + + assert_eq!(hex::encode(ctx.hash), "67b0b35e65c0acfa"); + assert_eq!(ctx.pathway_start_ns, 1_685_673_482_722_000_000); + assert_eq!(ctx.edge_start_ns, 1_685_673_506_404_000_000); + } + + #[test] + fn rejects_short_context() { + assert!(DsmContext::from_bytes(&[0u8; 7]).is_none()); + } + + #[test] + fn rejects_missing_varints() { + // 8 hash bytes but no varints follows. + assert!(DsmContext::from_bytes(&[0u8; 8]).is_none()); + } + + #[test] + fn rejects_truncated_varint() { + // Hash + a varint with continuation bit set but no following byte. + let mut bytes = vec![0u8; 8]; + bytes.push(0x80); + assert!(DsmContext::from_bytes(&bytes).is_none()); + } + + #[test] + fn rejects_invalid_base64() { + assert!(DsmContext::from_base64("not valid base64!!!").is_none()); + } + + #[test] + fn uvarint_single_byte() { + let (value, rest) = decode_uvarint(&[0x01]).expect("decode"); + assert_eq!(value, 1); + assert!(rest.is_empty()); + } + + #[test] + fn zigzag_decodes_positive() { + // 1685673482722 zigzag-encoded is 2 * 1685673482722 = 3371346965444. + // 3371346965444 in LEB128: encode and decode round-trip via the public API + // is covered by the pinned fixture; here we check the helper directly. + let (value, _) = decode_zigzag_varint(&[0xac, 0x02]).expect("decode"); + // raw uvarint 300 -> zigzag -> 150 + assert_eq!(value, 150); + } + + #[test] + fn uvarint_multi_byte() { + // 300 = 0xAC 0x02 in LEB128. + let (value, rest) = decode_uvarint(&[0xac, 0x02, 0xff]).expect("decode"); + assert_eq!(value, 300); + assert_eq!(rest, &[0xff]); + } +} diff --git a/bottlecap/src/traces/data_streams/fixtures/sketch_golden.json b/bottlecap/src/traces/data_streams/fixtures/sketch_golden.json new file mode 100644 index 000000000..1cf560223 --- /dev/null +++ b/bottlecap/src/traces/data_streams/fixtures/sketch_golden.json @@ -0,0 +1,1491 @@ +{ + "generator": "dd-trace-js vendored @datadog/sketches-js", + "sketch": "LogCollapsingLowestDenseDDSketch (relativeAccuracy=0.01, binLimit=2048)", + "cases": [ + { + "name": "single_value_1s", + "values": [ + 1 + ], + "valueHex": "0a1409fd4a815abf52f03f11000000000000000018001285081280080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000187f1a021800210000000000000000", + "valueBase64": "ChQJ/UqBWr9S8D8RAAAAAAAAAAAYABKFCBKACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8D8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYfxoCGAAhAAAAAAAAAAA=", + "byteLen": 1067, + "decoded": { + "mapping": { + "gamma": 1.02020202020202, + "indexOffset": 0, + "interpolation": "NONE" + }, + "positiveValues": { + "contiguousBinCounts": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "binCounts": {}, + "contiguousBinIndexOffset": -64 + }, + "negativeValues": { + "contiguousBinCounts": [], + "binCounts": {}, + "contiguousBinIndexOffset": 0 + }, + "zeroCount": 0 + } + }, + { + "name": "single_value_tenth", + "values": [ + 0.1 + ], + "valueHex": "0a1409fd4a815abf52f03f11000000000000000018001286081280080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000018e5021a021800210000000000000000", + "valueBase64": "ChQJ/UqBWr9S8D8RAAAAAAAAAAAYABKGCBKACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8D8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY5QIaAhgAIQAAAAAAAAAA", + "byteLen": 1068, + "decoded": { + "mapping": { + "gamma": 1.02020202020202, + "indexOffset": 0, + "interpolation": "NONE" + }, + "positiveValues": { + "contiguousBinCounts": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "binCounts": {}, + "contiguousBinIndexOffset": -179 + }, + "negativeValues": { + "contiguousBinCounts": [], + "binCounts": {}, + "contiguousBinIndexOffset": 0 + }, + "zeroCount": 0 + } + }, + { + "name": "single_value_1ms", + "values": [ + 0.001 + ], + "valueHex": "0a1409fd4a815abf52f03f11000000000000000018001286081280080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000018b1061a021800210000000000000000", + "valueBase64": "ChQJ/UqBWr9S8D8RAAAAAAAAAAAYABKGCBKACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8D8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYsQYaAhgAIQAAAAAAAAAA", + "byteLen": 1068, + "decoded": { + "mapping": { + "gamma": 1.02020202020202, + "indexOffset": 0, + "interpolation": "NONE" + }, + "positiveValues": { + "contiguousBinCounts": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "binCounts": {}, + "contiguousBinIndexOffset": -409 + }, + "negativeValues": { + "contiguousBinCounts": [], + "binCounts": {}, + "contiguousBinIndexOffset": 0 + }, + "zeroCount": 0 + } + }, + { + "name": "multi_spread", + "values": [ + 0.001, + 0.01, + 0.1, + 1, + 10 + ], + "valueHex": "0a1409fd4a815abf52f03f11000000000000000018001286201280200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000018e3051a021800210000000000000000", + "valueBase64": "ChQJ/UqBWr9S8D8RAAAAAAAAAAAYABKGIBKAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8D8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8D8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY4wUaAhgAIQAAAAAAAAAA", + "byteLen": 4140, + "decoded": { + "mapping": { + "gamma": 1.02020202020202, + "indexOffset": 0, + "interpolation": "NONE" + }, + "positiveValues": { + "contiguousBinCounts": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "binCounts": {}, + "contiguousBinIndexOffset": -370 + }, + "negativeValues": { + "contiguousBinCounts": [], + "binCounts": {}, + "contiguousBinIndexOffset": 0 + }, + "zeroCount": 0 + } + }, + { + "name": "repeated_same", + "values": [ + 0.5, + 0.5, + 0.5, + 0.5 + ], + "valueHex": "0a1409fd4a815abf52f03f11000000000000000018001286081280080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000104000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000018c3011a021800210000000000000000", + "valueBase64": "ChQJ/UqBWr9S8D8RAAAAAAAAAAAYABKGCBKACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYwwEaAhgAIQAAAAAAAAAA", + "byteLen": 1068, + "decoded": { + "mapping": { + "gamma": 1.02020202020202, + "indexOffset": 0, + "interpolation": "NONE" + }, + "positiveValues": { + "contiguousBinCounts": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "binCounts": {}, + "contiguousBinIndexOffset": -98 + }, + "negativeValues": { + "contiguousBinCounts": [], + "binCounts": {}, + "contiguousBinIndexOffset": 0 + }, + "zeroCount": 0 + } + }, + { + "name": "payload_sizes", + "values": [ + 100, + 256, + 1024, + 4096 + ], + "valueHex": "0a1409fd4a815abf52f03f110000000000000000180012861012801000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f03f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001888031a021800210000000000000000", + "valueBase64": "ChQJ/UqBWr9S8D8RAAAAAAAAAAAYABKGEBKAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8D8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8D8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8D8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGIgDGgIYACEAAAAAAAAAAA==", + "byteLen": 2092, + "decoded": { + "mapping": { + "gamma": 1.02020202020202, + "indexOffset": 0, + "interpolation": "NONE" + }, + "positiveValues": { + "contiguousBinCounts": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "binCounts": {}, + "contiguousBinIndexOffset": 196 + }, + "negativeValues": { + "contiguousBinCounts": [], + "binCounts": {}, + "contiguousBinIndexOffset": 0 + }, + "zeroCount": 0 + } + }, + { + "name": "zero_value", + "values": [ + 0 + ], + "valueHex": "0a1409fd4a815abf52f03f1100000000000000001800120218001a02180021000000000000f03f", + "valueBase64": "ChQJ/UqBWr9S8D8RAAAAAAAAAAAYABICGAAaAhgAIQAAAAAAAPA/", + "byteLen": 39, + "decoded": { + "mapping": { + "gamma": 1.02020202020202, + "indexOffset": 0, + "interpolation": "NONE" + }, + "positiveValues": { + "contiguousBinCounts": [], + "binCounts": {}, + "contiguousBinIndexOffset": 0 + }, + "negativeValues": { + "contiguousBinCounts": [], + "binCounts": {}, + "contiguousBinIndexOffset": 0 + }, + "zeroCount": 1 + } + } + ] +} diff --git a/bottlecap/src/traces/data_streams/mod.rs b/bottlecap/src/traces/data_streams/mod.rs new file mode 100644 index 000000000..266c51d9f --- /dev/null +++ b/bottlecap/src/traces/data_streams/mod.rs @@ -0,0 +1,25 @@ +//! Data Streams Monitoring (DSM) support. +//! +//! This module provides `dd-trace-js`-compatible primitives for continuing an +//! inbound DSM pathway from request payloads and computing consume-side +//! checkpoint hashes inside the extension. +//! +//! The pieces are split so the compatibility-sensitive steps can be tested in +//! isolation: +//! * [`context`] — decode inbound pathway context (base64 + binary + varint). +//! * [`pathway`] — compute the pathway/checkpoint hash. +//! * [`checkpoint`] — compute a consume-side checkpoint from an extracted context. +//! * [`propagation_hash`] — optional process/container-tag propagation hash. + +pub mod aggregator; +pub mod checkpoint; +pub mod context; +pub mod pathway; +pub mod processor; +pub mod propagation_hash; +pub mod sketch; + +pub use checkpoint::{Checkpoint, compute_consume_checkpoint}; +pub use context::DsmContext; +pub use pathway::compute_pathway_hash; +pub use processor::DsmProcessor; diff --git a/bottlecap/src/traces/data_streams/pathway.rs b/bottlecap/src/traces/data_streams/pathway.rs new file mode 100644 index 000000000..233a3a2da --- /dev/null +++ b/bottlecap/src/traces/data_streams/pathway.rs @@ -0,0 +1,159 @@ +//! DSM pathway hash computation, byte-for-byte compatible with `dd-trace-js`. +//! +//! See `docs`/design notes: the algorithm intentionally preserves a quirk where +//! the 16-byte `current_hash || parent_hash` buffer is converted to a (lossy) +//! UTF-8 string *before* the final SHA-256, rather than hashing the raw bytes. +//! Do not "simplify" this to `sha256(&combined)` — it would break compatibility +//! with pathways produced by the tracers. + +use sha2::{Digest, Sha256}; +use std::fmt::Write as _; + +const MANUAL_CHECKPOINT_TAG: &str = "manual_checkpoint:true"; + +/// First 8 bytes of `SHA-256(bytes)`. +fn sha256_first8(bytes: &[u8]) -> [u8; 8] { + let digest = Sha256::digest(bytes); + let mut out = [0u8; 8]; + out.copy_from_slice(&digest[..8]); + out +} + +/// Compute a DSM pathway hash for a checkpoint. +/// +/// * `service` / `env` — local service identity. +/// * `edge_tags` — checkpoint edge tags (e.g. `direction:in`, `type:sqs`). +/// Sorted and de-`manual_checkpoint`-ed before hashing. +/// * `parent_hash` — raw 8-byte parent pathway hash (zero bytes if no parent). +/// * `propagation_hash` — optional process/container-tag propagation hash. +#[must_use] +pub fn compute_pathway_hash( + service: &str, + env: &str, + edge_tags: &[String], + parent_hash: [u8; 8], + propagation_hash: Option, +) -> [u8; 8] { + let mut tags = edge_tags.to_vec(); + tags.sort_unstable(); + + let joined_tags = tags + .iter() + .filter(|tag| tag.as_str() != MANUAL_CHECKPOINT_TAG) + .map(String::as_str) + .collect::(); + + let mut base = format!("{service}{env}{joined_tags}"); + if let Some(hash) = propagation_hash { + // Appended as ":" + lowercase hex with no "0x" prefix and no leading zeros, + // matching JS `Number.prototype.toString(16)`. + write!(&mut base, ":{hash:x}").expect("writing to String cannot fail"); + } + + let current_hash = sha256_first8(base.as_bytes()); + + let mut combined = [0u8; 16]; + combined[..8].copy_from_slice(¤t_hash); + combined[8..].copy_from_slice(&parent_hash); + + // Compatibility-critical: lossy UTF-8 round-trip before the final hash. + let combined_string = String::from_utf8_lossy(&combined); + sha256_first8(combined_string.as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tags(values: &[&str]) -> Vec { + values.iter().map(|s| (*s).to_string()).collect() + } + + /// Pinned `dd-trace-js` fixture. + #[test] + fn matches_pinned_pathway_hash() { + let hash = compute_pathway_hash( + "test-service", + "test-env", + &tags(&["direction:in", "group:group1", "topic:topic1", "type:kafka"]), + [0u8; 8], + None, + ); + assert_eq!(hex::encode(hash), "67b0b35e65c0acfa"); + } + + #[test] + fn tag_order_does_not_change_hash() { + let sorted = compute_pathway_hash( + "test-service", + "test-env", + &tags(&["direction:in", "group:group1", "topic:topic1", "type:kafka"]), + [0u8; 8], + None, + ); + let shuffled = compute_pathway_hash( + "test-service", + "test-env", + &tags(&["type:kafka", "topic:topic1", "direction:in", "group:group1"]), + [0u8; 8], + None, + ); + assert_eq!(sorted, shuffled); + } + + #[test] + fn manual_checkpoint_tag_is_excluded() { + let without = compute_pathway_hash("svc", "env", &tags(&["direction:in"]), [0u8; 8], None); + let with = compute_pathway_hash( + "svc", + "env", + &tags(&["direction:in", "manual_checkpoint:true"]), + [0u8; 8], + None, + ); + assert_eq!(without, with); + } + + #[test] + fn parent_hash_changes_result() { + let a = compute_pathway_hash("svc", "env", &tags(&["direction:in"]), [0u8; 8], None); + let b = compute_pathway_hash( + "svc", + "env", + &tags(&["direction:in"]), + [1, 2, 3, 4, 5, 6, 7, 8], + None, + ); + assert_ne!(a, b); + } + + #[test] + fn propagation_hash_changes_result() { + let absent = compute_pathway_hash("svc", "env", &tags(&["direction:in"]), [0u8; 8], None); + let present = compute_pathway_hash( + "svc", + "env", + &tags(&["direction:in"]), + [0u8; 8], + Some(0x1234_5678_9abc_def0), + ); + let present_repeat = compute_pathway_hash( + "svc", + "env", + &tags(&["direction:in"]), + [0u8; 8], + Some(0x1234_5678_9abc_def0), + ); + let different = compute_pathway_hash( + "svc", + "env", + &tags(&["direction:in"]), + [0u8; 8], + Some(0x0fed_cba9_8765_4321), + ); + + assert_ne!(absent, present); + assert_eq!(present, present_repeat); + assert_ne!(present, different); + } +} diff --git a/bottlecap/src/traces/data_streams/processor.rs b/bottlecap/src/traces/data_streams/processor.rs new file mode 100644 index 000000000..7d31f6542 --- /dev/null +++ b/bottlecap/src/traces/data_streams/processor.rs @@ -0,0 +1,326 @@ +//! Extension-side DSM consume processor. +//! +//! Owns the checkpoint [`Aggregator`] and bridges it to the existing proxy +//! flush path: consume checkpoints are folded in during invocation start, and +//! on flush the aggregated pipeline-stats payload is gzipped and enqueued as a +//! [`ProxyRequest`] so the shared [`crate::traces::proxy_flusher`] ships it to +//! `/api/v0.1/pipeline_stats` (adding the API key + tags). +//! +//! Gated entirely by `DD_DATA_STREAMS_ENABLED`; when disabled this is never +//! constructed. + +use std::io::Write; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use bytes::Bytes; +use flate2::Compression; +use flate2::write::GzEncoder; +use reqwest::header::{CONTENT_ENCODING, CONTENT_TYPE, HeaderMap, HeaderValue}; +use tokio::sync::Mutex as TokioMutex; +use tracing::{debug, warn}; + +use crate::traces::data_streams::aggregator::Aggregator; +use crate::traces::data_streams::checkpoint::compute_consume_checkpoint; +use crate::traces::data_streams::context::{ + DD_PATHWAY_CTX_BASE64_KEY, DD_PATHWAY_CTX_KEY, DsmContext, +}; +use crate::traces::proxy_aggregator::{Aggregator as ProxyAggregator, ProxyRequest}; + +/// gzip level used by the tracer for pipeline stats. +const GZIP_LEVEL: u32 = 1; +/// The trace intake path appended to `apm_dd_url` by the upstream config crate. +/// Must be stripped before deriving non-trace endpoints from that field. +const TRACE_INTAKE_ROUTE: &str = "/api/v0.2/traces"; + +pub struct DsmProcessor { + service: String, + env: String, + aggregator: Mutex, + proxy_aggregator: Arc>, + target_url: String, +} + +impl DsmProcessor { + #[must_use] + pub fn new( + service: String, + env: String, + tracer_version: String, + version: String, + tags: Vec, + apm_dd_url: &str, + proxy_aggregator: Arc>, + ) -> Self { + let aggregator = + Aggregator::new(service.clone(), env.clone(), tracer_version, version, tags); + Self { + service, + env, + aggregator: Mutex::new(aggregator), + proxy_aggregator, + // config.apm_dd_url is a fully-resolved trace endpoint that already + // ends with TRACE_INTAKE_ROUTE (e.g. ".../api/v0.2/traces"). Strip + // that suffix to recover the base URL before appending the DSM route; + // without this the target becomes .../api/v0.2/traces/api/v0.1/pipeline_stats. + target_url: format!( + "{}/api/v0.1/pipeline_stats", + apm_dd_url + .trim_end_matches('/') + .trim_end_matches(TRACE_INTAKE_ROUTE) + ), + } + } + + /// Record a consume (`direction:in`) checkpoint for an inbound event. + /// + /// `edge_tags` come from the trigger (`Trigger::get_dsm_edge_tags`); `carrier` + /// is the trigger carrier (which may contain the inbound pathway context). + pub fn record_consume( + &self, + edge_tags: &[String], + carrier: &std::collections::HashMap, + payload_size: f64, + ) { + let ctx = extract_pathway_context(carrier); + let now_ns = now_unix_nanos(); + + let checkpoint = compute_consume_checkpoint( + &self.service, + &self.env, + edge_tags, + ctx.as_ref(), + now_ns, + None, + ); + + debug!( + "DSM: recorded consume checkpoint hash={:x} parent={:x} has_inbound_ctx={} edge_tags={:?}", + u64::from_le_bytes(checkpoint.hash), + u64::from_le_bytes(checkpoint.parent_hash), + ctx.is_some(), + edge_tags + ); + + match self.aggregator.lock() { + Ok(mut agg) => agg.add(&checkpoint, payload_size), + Err(e) => warn!("DSM: aggregator lock poisoned; dropping consume checkpoint: {e}"), + } + } + + /// Drain the aggregator into the proxy aggregator for flushing. No-op when + /// there is nothing buffered. + pub async fn drain_into_proxy(&self) { + let payload = match self.aggregator.lock() { + Ok(mut agg) => agg.take_payload(), + Err(e) => { + warn!("DSM: aggregator lock poisoned; skipping pipeline stats flush: {e}"); + return; + } + }; + let Some(payload) = payload else { + return; + }; + + let body = match gzip(&payload) { + Ok(b) => b, + Err(e) => { + warn!("DSM: failed to gzip pipeline stats payload: {e}"); + return; + } + }; + + let mut headers = HeaderMap::new(); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("application/msgpack"), + ); + headers.insert(CONTENT_ENCODING, HeaderValue::from_static("gzip")); + + let request = ProxyRequest { + headers, + body: Bytes::from(body), + target_url: self.target_url.clone(), + }; + + debug!( + "DSM: enqueued pipeline stats payload ({} bytes gzipped)", + request.body.len() + ); + self.proxy_aggregator.lock().await.add(request); + } +} + +/// Extract the inbound DSM pathway context from a carrier, preferring the +/// explicit base64 key. The legacy `dd-pathway-ctx` key carries the raw binary +/// DSM context by tracer convention (not base64), so decode it from bytes. +/// Fails closed (returns `None`) on malformed input. +fn extract_pathway_context( + carrier: &std::collections::HashMap, +) -> Option { + carrier + .get(DD_PATHWAY_CTX_BASE64_KEY) + .and_then(|v| DsmContext::from_base64(v)) + .or_else(|| { + carrier + .get(DD_PATHWAY_CTX_KEY) + .and_then(|v| DsmContext::from_bytes(v.as_bytes())) + }) +} + +fn now_unix_nanos() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX)) + .unwrap_or(0) +} + +fn gzip(data: &[u8]) -> std::io::Result> { + let mut encoder = GzEncoder::new(Vec::new(), Compression::new(GZIP_LEVEL)); + encoder.write_all(data)?; + encoder.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn extracts_base64_context_from_carrier() { + let mut carrier = HashMap::new(); + carrier.insert( + DD_PATHWAY_CTX_BASE64_KEY.to_string(), + "Z7CzXmXArPrE58Cfj2LI2cOfj2I=".to_string(), + ); + let ctx = extract_pathway_context(&carrier).expect("context"); + assert_eq!(hex::encode(ctx.hash), "67b0b35e65c0acfa"); + } + + #[test] + fn extracts_legacy_raw_context_from_carrier() { + let mut carrier = HashMap::new(); + // Raw context bytes: 8-byte hash (`abcdefgh`), pathwayStartMs=1 + // (zigzag varint 2), edgeStartMs=2 (zigzag varint 4). These bytes are + // UTF-8 representable, matching the current string carrier shape while + // still exercising raw-byte decoding for `dd-pathway-ctx`. + carrier.insert( + DD_PATHWAY_CTX_KEY.to_string(), + "abcdefgh\u{0002}\u{0004}".to_string(), + ); + let ctx = extract_pathway_context(&carrier).expect("context"); + assert_eq!(ctx.hash, *b"abcdefgh"); + assert_eq!(ctx.pathway_start_ns, 1_000_000); + assert_eq!(ctx.edge_start_ns, 2_000_000); + } + + #[test] + fn base64_context_takes_precedence_over_legacy_context() { + let mut carrier = HashMap::new(); + carrier.insert( + DD_PATHWAY_CTX_BASE64_KEY.to_string(), + "Z7CzXmXArPrE58Cfj2LI2cOfj2I=".to_string(), + ); + carrier.insert( + DD_PATHWAY_CTX_KEY.to_string(), + "abcdefgh\u{0002}\u{0004}".to_string(), + ); + + let ctx = extract_pathway_context(&carrier).expect("context"); + assert_eq!(hex::encode(ctx.hash), "67b0b35e65c0acfa"); + } + + #[test] + fn missing_context_returns_none() { + let carrier = HashMap::new(); + assert!(extract_pathway_context(&carrier).is_none()); + } + + #[test] + fn malformed_context_returns_none() { + let mut carrier = HashMap::new(); + carrier.insert(DD_PATHWAY_CTX_BASE64_KEY.to_string(), "@@bad@@".to_string()); + assert!(extract_pathway_context(&carrier).is_none()); + } + + #[tokio::test] + async fn drain_enqueues_proxy_request_when_data_present() { + let proxy = Arc::new(TokioMutex::new(ProxyAggregator::default())); + // Pass the full apm_dd_url as resolved by config (includes the trace + // intake path), mirroring what main.rs supplies in production. + let dsm = DsmProcessor::new( + "svc".into(), + "env".into(), + "1.0".into(), + "2.0".into(), + vec!["team:serverless".into()], + "https://trace.agent.datadoghq.com/api/v0.2/traces", + proxy.clone(), + ); + + let edge_tags = vec![ + "direction:in".to_string(), + "topic:q".to_string(), + "type:sqs".to_string(), + ]; + dsm.record_consume(&edge_tags, &HashMap::new(), 128.0); + dsm.drain_into_proxy().await; + + let batch = proxy.lock().await.get_batch(); + assert_eq!(batch.len(), 1); + assert_eq!( + batch[0].target_url, + "https://trace.agent.datadoghq.com/api/v0.1/pipeline_stats" + ); + } + + #[tokio::test] + async fn target_url_honors_custom_apm_dd_url() { + // config.apm_dd_url for a custom DD_APM_DD_URL is resolved by the + // upstream config crate as "{custom_url}/api/v0.2/traces". The + // processor must strip that suffix so the DSM endpoint is rooted at + // the same host/prefix, not nested under the trace route. + let proxy = Arc::new(TokioMutex::new(ProxyAggregator::default())); + let dsm = DsmProcessor::new( + "svc".into(), + "env".into(), + "1.0".into(), + "2.0".into(), + Vec::new(), + "https://my-proxy.example.com/api/v0.2/traces", + proxy.clone(), + ); + + let edge_tags = vec![ + "direction:in".to_string(), + "topic:q".to_string(), + "type:sqs".to_string(), + ]; + dsm.record_consume(&edge_tags, &HashMap::new(), 128.0); + dsm.drain_into_proxy().await; + + let batch = proxy.lock().await.get_batch(); + assert_eq!(batch.len(), 1); + assert_eq!( + batch[0].target_url, + "https://my-proxy.example.com/api/v0.1/pipeline_stats" + ); + } + + #[tokio::test] + async fn drain_is_noop_when_empty() { + let proxy = Arc::new(TokioMutex::new(ProxyAggregator::default())); + let dsm = DsmProcessor::new( + "svc".into(), + "env".into(), + "1.0".into(), + "2.0".into(), + vec!["team:serverless".into()], + "https://trace.agent.datadoghq.com/api/v0.2/traces", + proxy.clone(), + ); + dsm.drain_into_proxy().await; + assert_eq!(proxy.lock().await.get_batch().len(), 0); + } +} diff --git a/bottlecap/src/traces/data_streams/propagation_hash.rs b/bottlecap/src/traces/data_streams/propagation_hash.rs new file mode 100644 index 000000000..1c9b085cd --- /dev/null +++ b/bottlecap/src/traces/data_streams/propagation_hash.rs @@ -0,0 +1,50 @@ +//! Optional DSM propagation hash. +//! +//! Used when process-tag propagation is enabled. The input is the serialized +//! process tags plus the container-tags hash returned by the agent. +//! +//! NOTE: `dd-trace-js` comments describe this as "FNV-1a", but the code performs +//! multiply-then-XOR, i.e. FNV-1 (not FNV-1a). We match the implementation, not +//! the comment, for compatibility. + +const FNV1_64_OFFSET_BASIS: u64 = 0xCBF2_9CE4_8422_2325; +const FNV1_64_PRIME: u64 = 0x0000_0100_0000_01B3; + +/// Compute the FNV-1 (64-bit) hash over `bytes`, matching `dd-trace-js`. +#[must_use] +pub fn fnv1_64(bytes: &[u8]) -> u64 { + let mut hash = FNV1_64_OFFSET_BASIS; + for &byte in bytes { + hash = hash.wrapping_mul(FNV1_64_PRIME); + hash ^= u64::from(byte); + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input_is_offset_basis() { + assert_eq!(fnv1_64(b""), FNV1_64_OFFSET_BASIS); + } + + #[test] + fn is_deterministic() { + assert_eq!(fnv1_64(b"process-tags:foo"), fnv1_64(b"process-tags:foo")); + } + + #[test] + fn differs_by_input() { + assert_ne!(fnv1_64(b"foo"), fnv1_64(b"bar")); + } + + #[test] + fn applies_multiply_before_xor() { + // FNV-1 (multiply-then-XOR) differs from FNV-1a (XOR-then-multiply). + // Verify the first step explicitly for a single byte. + let expected = FNV1_64_OFFSET_BASIS.wrapping_mul(FNV1_64_PRIME) ^ u64::from(b'a'); + assert_eq!(fnv1_64(b"a"), expected); + } +} diff --git a/bottlecap/src/traces/data_streams/sketch.rs b/bottlecap/src/traces/data_streams/sketch.rs new file mode 100644 index 000000000..a5a0b5ceb --- /dev/null +++ b/bottlecap/src/traces/data_streams/sketch.rs @@ -0,0 +1,375 @@ +//! A byte-for-byte port of the tracer's `LogCollapsingLowestDenseDDSketch` +//! (relative accuracy 0.01, bin limit 2048) and its protobuf serialization. +//! +//! DSM's `EdgeLatency` / `PathwayLatency` / `PayloadSize` fields are the raw +//! `DDSketch` protobuf bytes produced by `@datadog/sketches-js`. To stay +//! compatible we must reproduce both the binning *and* the dense-store layout +//! exactly (including the bin-centering that pads each chunk with zeros). +//! +//! Verified against fixtures generated by the real tracer in +//! `fixtures/sketch_golden.json`. +//! +//! Note: this intentionally does not use `libdd-ddsketch::DDSketch` for the +//! sketch itself. The crate does provide a Datadog `DDSketch` implementation and +//! generated protobuf structs, but its public Rust API does not expose a +//! `LogCollapsingLowestDenseDDSketch` type or constructors for the tracer/DSM +//! mapping and store configuration used by `@datadog/sketches-js`. +//! +//! In the pinned `libdd-ddsketch` version, `DDSketch::default()` uses the +//! backend-oriented mapping (relative accuracy 0.007751937984496124, non-zero +//! index offset, `ln`/`floor` indexing) and its dense store does not reproduce +//! the JS tracer's chunk-centering layout. DSM pipeline stats require the JS +//! tracer wire format: relative accuracy 0.01, zero index offset, `log2`/`ceil` +//! indexing, collapse-lowest dense stores, and byte-for-byte protobuf output for +//! `EdgeLatency`, `PathwayLatency`, and `PayloadSize`. Using +//! `libdd-ddsketch::DDSketch` directly changes those serialized bytes, so this +//! local implementation is kept and guarded by tracer-generated golden fixtures. + +/// Default relative accuracy used by DSM sketches. +const RELATIVE_ACCURACY: f64 = 0.01; +const BIN_LIMIT: i64 = 2048; +const CHUNK_SIZE: i64 = 128; +// Match sketches-js KeyMapping.MIN_SAFE_FLOAT (= 2^-1023), which is smaller +// than Rust's f64::MIN_POSITIVE (= 2^-1022). +const JS_MIN_SAFE_FLOAT: f64 = 1.112_536_929_253_600_7e-308; + +/// Logarithmic key mapping, mirroring `sketches-js` `LogarithmicMapping`. +#[derive(Debug, Clone, Copy)] +struct LogarithmicMapping { + gamma: f64, + multiplier: f64, + min_possible: f64, +} + +impl LogarithmicMapping { + fn new(relative_accuracy: f64) -> Self { + let i = 2.0 * relative_accuracy / (1.0 - relative_accuracy); + let gamma = 1.0 + i; + // KeyMapping multiplier is 1/ln1p(i); LogarithmicMapping then * ln(2). + let multiplier = std::f64::consts::LN_2 / i.ln_1p(); + // sketches-js uses KeyMapping.MIN_SAFE_FLOAT (= 2^-1023) here rather + // than the platform's smallest positive normal value. + let min_possible = JS_MIN_SAFE_FLOAT * gamma; + Self { + gamma, + multiplier, + min_possible, + } + } + + /// `key(v) = ceil(log2(v) * multiplier)` (offset is always 0 for DSM). + fn key(&self, v: f64) -> i32 { + // Match the JS `Math.log2(v) * multiplier` op order exactly. + #[allow(clippy::cast_possible_truncation)] + let k = (v.log2() * self.multiplier).ceil() as i32; + k + } +} + +/// Collapse-lowest dense store, mirroring `sketches-js` `CollapsingLowestDenseStore`. +#[derive(Debug, Clone)] +struct CollapsingLowestDenseStore { + bins: Vec, + count: f64, + min_key: i32, + max_key: i32, + offset: i32, + is_collapsed: bool, +} + +impl CollapsingLowestDenseStore { + fn new() -> Self { + Self { + bins: Vec::new(), + count: 0.0, + // Emulate JS +Inf / -Inf sentinels for an empty store. + min_key: i32::MAX, + max_key: i32::MIN, + offset: 0, + is_collapsed: false, + } + } + + fn length(&self) -> i32 { + i32::try_from(self.bins.len()).unwrap_or(i32::MAX) + } + + #[allow(clippy::unused_self)] + fn get_new_length(&self, new_min: i32, new_max: i32) -> usize { + let desired = i64::from(new_max) - i64::from(new_min) + 1; + // ceil(desired / CHUNK_SIZE) without float casts. + let chunks = (desired + CHUNK_SIZE - 1) / CHUNK_SIZE; + let len = (CHUNK_SIZE * chunks).min(BIN_LIMIT); + usize::try_from(len).unwrap_or(0) + } + + fn add(&mut self, key: i32, weight: f64) { + let idx = self.get_index(key); + self.bins[idx] += weight; + self.count += weight; + } + + fn get_index(&mut self, key: i32) -> usize { + if key < self.min_key { + if self.is_collapsed { + return 0; + } + self.extend_range(key, key); + if self.is_collapsed { + return 0; + } + } else if key > self.max_key { + self.extend_range(key, key); + } + #[allow(clippy::cast_sign_loss)] + let idx = (key - self.offset) as usize; + idx + } + + fn extend_range(&mut self, key: i32, key2: i32) { + let new_min = key.min(key2).min(self.min_key); + let new_max = key.max(key2).max(self.max_key); + + if self.bins.is_empty() { + self.bins = vec![0.0; self.get_new_length(new_min, new_max)]; + self.offset = new_min; + self.adjust(new_min, new_max); + } else if new_min >= self.min_key && new_max < self.offset + self.length() { + self.min_key = new_min; + self.max_key = new_max; + } else { + let new_length = self.get_new_length(new_min, new_max); + if new_length > self.bins.len() { + self.bins.resize(new_length, 0.0); + } + self.adjust(new_min, new_max); + } + } + + /// `CollapsingLowestDenseStore._adjust`. + fn adjust(&mut self, new_min: i32, new_max: i32) { + if new_max - new_min + 1 > self.length() { + // Collapse the lowest bins to fit within bin_limit. + let collapse_min = new_max - self.length() + 1; + if collapse_min >= self.max_key { + self.offset = collapse_min; + self.min_key = collapse_min; + self.bins.iter_mut().for_each(|b| *b = 0.0); + self.bins[0] = self.count; + } else { + let shift = self.offset - collapse_min; + if shift < 0 { + #[allow(clippy::cast_sign_loss)] + let n = (self.min_key - self.offset) as usize; + #[allow(clippy::cast_sign_loss)] + let r = (collapse_min - self.offset) as usize; + let s: f64 = self.bins[n..=r].iter().sum(); + self.bins[n..r].iter_mut().for_each(|b| *b = 0.0); + self.bins[r] += s; + self.min_key = collapse_min; + self.shift_bins(shift); + } else { + self.min_key = collapse_min; + self.shift_bins(shift); + } + } + self.max_key = new_max; + self.is_collapsed = true; + } else { + self.center_bins(new_min, new_max); + self.min_key = new_min; + self.max_key = new_max; + } + } + + fn center_bins(&mut self, new_min: i32, new_max: i32) { + let middle_key = new_min + (new_max - new_min + 1) / 2; + let shift = (self.offset + self.length() / 2) - middle_key; + self.shift_bins(shift); + } + + fn shift_bins(&mut self, shift: i32) { + if shift > 0 { + #[allow(clippy::cast_sign_loss)] + let s = shift as usize; + let keep = self.bins.len() - s; + let mut new_bins = vec![0.0; s]; + new_bins.extend_from_slice(&self.bins[..keep]); + self.bins = new_bins; + } else if shift < 0 { + #[allow(clippy::cast_sign_loss)] + let a = (-shift) as usize; + let mut new_bins = self.bins[a..].to_vec(); + new_bins.resize(self.bins.len(), 0.0); + self.bins = new_bins; + } + self.offset -= shift; + } +} + +/// A `DDSketch` matching the tracer's `LogCollapsingLowestDenseDDSketch`. +#[derive(Debug, Clone)] +pub struct DdSketch { + mapping: LogarithmicMapping, + store: CollapsingLowestDenseStore, + negative_store: CollapsingLowestDenseStore, + zero_count: f64, +} + +impl Default for DdSketch { + fn default() -> Self { + Self::new() + } +} + +impl DdSketch { + #[must_use] + pub fn new() -> Self { + Self { + mapping: LogarithmicMapping::new(RELATIVE_ACCURACY), + store: CollapsingLowestDenseStore::new(), + negative_store: CollapsingLowestDenseStore::new(), + zero_count: 0.0, + } + } + + /// Accept a single value (weight 1), mirroring `DDSketch.accept`. + pub fn accept(&mut self, value: f64) { + if value > self.mapping.min_possible { + let key = self.mapping.key(value); + self.store.add(key, 1.0); + } else if value < -self.mapping.min_possible { + let key = self.mapping.key(-value); + self.negative_store.add(key, 1.0); + } else { + self.zero_count += 1.0; + } + } + + /// Serialize to the `DDSketch` protobuf wire format. + #[must_use] + pub fn to_proto_bytes(&self) -> Vec { + let mut out = Vec::new(); + + // Field 1: mapping (IndexMapping), length-delimited. + let mut mapping = Vec::new(); + write_tag(&mut mapping, 1, WIRE_FIXED64); + mapping.extend_from_slice(&self.mapping.gamma.to_le_bytes()); + write_tag(&mut mapping, 2, WIRE_FIXED64); + mapping.extend_from_slice(&0.0f64.to_le_bytes()); // indexOffset + write_tag(&mut mapping, 3, WIRE_VARINT); + mapping.push(0); // interpolation = NONE + write_tag(&mut out, 1, WIRE_LEN); + write_uvarint(&mut out, mapping.len() as u64); + out.extend_from_slice(&mapping); + + // Field 2: positiveValues (Store). + write_tag(&mut out, 2, WIRE_LEN); + let pos = encode_store(&self.store); + write_uvarint(&mut out, pos.len() as u64); + out.extend_from_slice(&pos); + + // Field 3: negativeValues (Store). + write_tag(&mut out, 3, WIRE_LEN); + let neg = encode_store(&self.negative_store); + write_uvarint(&mut out, neg.len() as u64); + out.extend_from_slice(&neg); + + // Field 4: zeroCount (double) — always emitted (matches sketches-js). + write_tag(&mut out, 4, WIRE_FIXED64); + out.extend_from_slice(&self.zero_count.to_le_bytes()); + + out + } +} + +const WIRE_VARINT: u8 = 0; +const WIRE_FIXED64: u8 = 1; +const WIRE_LEN: u8 = 2; + +fn write_tag(buf: &mut Vec, field: u32, wire: u8) { + write_uvarint(buf, u64::from((field << 3) | u32::from(wire))); +} + +fn write_uvarint(buf: &mut Vec, mut value: u64) { + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + buf.push(byte); + if value == 0 { + break; + } + } +} + +fn write_zigzag32(buf: &mut Vec, value: i32) { + #[allow(clippy::cast_sign_loss)] + let zz = ((value << 1) ^ (value >> 31)) as u32; + write_uvarint(buf, u64::from(zz)); +} + +/// Encode a dense store: field 2 = packed `contiguousBinCounts`, +/// field 3 = `contiguousBinIndexOffset` (sint32). Field 2 is omitted when empty, +/// matching `sketches-js`. +fn encode_store(store: &CollapsingLowestDenseStore) -> Vec { + let mut buf = Vec::new(); + if !store.bins.is_empty() { + write_tag(&mut buf, 2, WIRE_LEN); + write_uvarint(&mut buf, (store.bins.len() * 8) as u64); + for &count in &store.bins { + buf.extend_from_slice(&count.to_le_bytes()); + } + } + write_tag(&mut buf, 3, WIRE_VARINT); + write_zigzag32(&mut buf, store.offset); + buf +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use serde_json::Value; + + const GOLDEN: &str = include_str!("fixtures/sketch_golden.json"); + + #[test] + fn gamma_matches_tracer() { + let m = LogarithmicMapping::new(RELATIVE_ACCURACY); + assert_eq!(hex::encode(m.gamma.to_le_bytes()), "fd4a815abf52f03f"); + } + + #[test] + fn matches_all_golden_vectors() { + let golden: Value = serde_json::from_str(GOLDEN).expect("parse fixture"); + let cases = golden["cases"].as_array().expect("cases array"); + + for case in cases { + let name = case["name"].as_str().unwrap(); + let mut sketch = DdSketch::new(); + for v in case["values"].as_array().unwrap() { + sketch.accept(v.as_f64().unwrap()); + } + let got = hex::encode(sketch.to_proto_bytes()); + let want = case["valueHex"].as_str().unwrap(); + assert_eq!(got, want, "sketch mismatch for case `{name}`"); + } + } + + #[test] + fn accepts_values_between_js_and_rust_min_thresholds() { + let mut sketch = DdSketch::new(); + + // 0.75 * MIN_POSITIVE lies between the JS threshold (2^-1023) and the + // Rust normal minimum (2^-1022), so JS accepts it into the positive + // store rather than folding it into zero_count. + sketch.accept(0.75 * f64::MIN_POSITIVE); + + assert!((sketch.store.count - 1.0).abs() < f64::EPSILON); + assert!(sketch.negative_store.count.abs() < f64::EPSILON); + assert!(sketch.zero_count.abs() < f64::EPSILON); + } +} diff --git a/bottlecap/src/traces/mod.rs b/bottlecap/src/traces/mod.rs index 41ee7f064..0d9981805 100644 --- a/bottlecap/src/traces/mod.rs +++ b/bottlecap/src/traces/mod.rs @@ -1,6 +1,7 @@ // Copyright 2023-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +pub mod data_streams; pub mod http_client; pub mod propagation; pub mod proxy_aggregator; diff --git a/bottlecap/src/traces/span_pointers.rs b/bottlecap/src/traces/span_pointers.rs index 1f88a5ee5..ee29ec457 100644 --- a/bottlecap/src/traces/span_pointers.rs +++ b/bottlecap/src/traces/span_pointers.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; const SPAN_POINTER_HASH_LENGTH: usize = 32; -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct SpanPointer { pub hash: String, pub kind: String, diff --git a/bottlecap/tests/common/fake_intake.rs b/bottlecap/tests/common/fake_intake.rs index 8f027676a..1963f8a9a 100644 --- a/bottlecap/tests/common/fake_intake.rs +++ b/bottlecap/tests/common/fake_intake.rs @@ -1,6 +1,9 @@ // Copyright 2023-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +// This helper is `#[path]`-included by several test binaries; not every binary +// exercises every endpoint/accessor, so unused items here are expected. +#![allow(dead_code)] //! In-process fake Datadog intake for APM payload-level integration tests. //! //! Spawns an axum server on a random local port that accepts the same APM @@ -12,6 +15,7 @@ //! //! - `POST /api/v0.2/stats`: msgpack, gzip-compressed, `pb::StatsPayload` //! - `POST /api/v0.2/traces`: protobuf (optionally zstd-compressed), `pb::AgentPayload` +//! - `POST /api/v0.1/pipeline_stats`: msgpack (struct-as-map), gzip-compressed, DSM pipeline stats //! //! Prototype for APMSVLS-494 phase 1. If the API proves out, this file gets //! extracted into the shared `datadog/apm-agent-parity-rs` repo in phase 2. @@ -28,15 +32,52 @@ use axum::{ }; use libdd_trace_protobuf::pb; use prost::Message; +use serde::Deserialize; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio::task::JoinHandle; +/// A DSM pipeline-stats payload as it lands on `/api/v0.1/pipeline_stats`. +/// Only the fields tests assert on are decoded; serde ignores the rest +/// (including the `serde_bytes` latency sketches). +#[derive(Clone, Deserialize)] +pub struct PipelineStatsPayload { + #[serde(rename = "Env")] + pub env: String, + #[serde(rename = "Service")] + pub service: String, + #[serde(rename = "TracerVersion")] + pub tracer_version: String, + #[serde(rename = "Version")] + pub version: String, + #[serde(rename = "Tags")] + pub tags: Vec, + #[serde(rename = "Stats")] + pub stats: Vec, +} + +#[derive(Clone, Deserialize)] +pub struct PipelineStatsBucket { + #[serde(rename = "Stats")] + pub stats: Vec, +} + +#[derive(Clone, Deserialize)] +pub struct PipelineStatsPoint { + #[serde(rename = "Hash")] + pub hash: u64, + #[serde(rename = "ParentHash")] + pub parent_hash: u64, + #[serde(rename = "EdgeTags")] + pub edge_tags: Vec, +} + /// Captured, decoded APM payloads for a single test run. #[derive(Default)] struct Captured { stats: Vec, traces: Vec, + pipeline_stats: Vec, } /// Shared server state. The axum handlers write to the mutexes; tests read @@ -71,6 +112,7 @@ impl FakeIntake { let router = Router::new() .route("/api/v0.2/stats", post(handle_stats)) .route("/api/v0.2/traces", post(handle_traces)) + .route("/api/v0.1/pipeline_stats", post(handle_pipeline_stats)) .with_state(Arc::clone(&state)); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); @@ -103,6 +145,24 @@ impl FakeIntake { format!("{}/api/v0.2/traces", self.base_url) } + /// Base URL (scheme + host + port, no path). Use as the `apm_dd_url` for + /// components that build their own endpoint path (e.g. `DsmProcessor`). + #[must_use] + pub fn base_url(&self) -> String { + self.base_url.clone() + } + + /// All DSM pipeline-stats payloads captured so far, in arrival order. + #[must_use] + pub fn pipeline_stats_payloads(&self) -> Vec { + self.state + .captured + .lock() + .expect("fake_intake: pipeline_stats mutex poisoned") + .pipeline_stats + .clone() + } + /// All `StatsPayload`s captured so far, in arrival order. #[must_use] pub fn stats_payloads(&self) -> Vec { @@ -195,6 +255,35 @@ async fn handle_traces( } } +async fn handle_pipeline_stats( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> StatusCode { + let decoded = match decompress(&headers, &body) { + Ok(d) => d, + Err(e) => { + eprintln!("{e}"); + return StatusCode::BAD_REQUEST; + } + }; + match rmp_serde::from_slice::(&decoded) { + Ok(payload) => { + state + .captured + .lock() + .expect("fake_intake: pipeline_stats mutex poisoned") + .pipeline_stats + .push(payload); + StatusCode::ACCEPTED + } + Err(err) => { + eprintln!("fake_intake: failed to decode pipeline stats msgpack: {err}"); + StatusCode::BAD_REQUEST + } + } +} + /// Decompress a request body based on its `Content-Encoding` header. /// Supports `gzip` and `zstd`. An unknown or absent encoding is treated as /// identity: the body is returned unchanged. diff --git a/bottlecap/tests/dsm_integration_test.rs b/bottlecap/tests/dsm_integration_test.rs new file mode 100644 index 000000000..a086a8ebe --- /dev/null +++ b/bottlecap/tests/dsm_integration_test.rs @@ -0,0 +1,132 @@ +// Copyright 2023-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Payload-level DSM integration test using the in-process fake-intake. +//! +//! Covers the full Data Streams Monitoring egress path: +//! +//! `DsmProcessor::record_consume` → `drain_into_proxy` (aggregate + serialize + +//! gzip) → `ProxyFlusher::flush` → `POST /api/v0.1/pipeline_stats`. +//! +//! The test spins up a `FakeIntake`, points the `DsmProcessor` at it via the +//! `apm_dd_url` argument (mirroring how `DD_APM_DD_URL` flows through in +//! production), triggers a flush, then decodes the captured msgpack payload and +//! asserts on concrete fields. Unit tests stop at the `ProxyRequest` boundary; +//! this is the only coverage of the wire format + transport + endpoint routing +//! together. + +use std::collections::HashMap; +use std::sync::Arc; + +use bottlecap::LAMBDA_RUNTIME_SLUG; +use bottlecap::config::Config; +use bottlecap::tags::provider::Provider; +use bottlecap::traces::data_streams::processor::DsmProcessor; +use bottlecap::traces::proxy_aggregator::Aggregator as ProxyAggregator; +use bottlecap::traces::proxy_flusher::Flusher as ProxyFlusher; +use datadog_fips::reqwest_adapter::create_reqwest_client_builder; +use dogstatsd::api_key::ApiKeyFactory; +use tokio::sync::Mutex; + +#[path = "common/fake_intake.rs"] +mod fake_intake; + +use fake_intake::FakeIntake; + +const DD_API_KEY: &str = "my_test_key"; + +fn test_config() -> Arc { + Arc::new(Config { + api_key: DD_API_KEY.to_string(), + site: "datadoghq.com".to_string(), + ..Config::default() + }) +} + +fn tags_provider(config: &Arc) -> Arc { + Arc::new(Provider::new( + Arc::clone(config), + LAMBDA_RUNTIME_SLUG.to_string(), + &HashMap::from([( + "function_arn".to_string(), + "arn:aws:lambda:us-west-2:123456789012:function:my-function".to_string(), + )]), + )) +} + +#[tokio::test] +async fn dsm_pipeline_stats_roundtrip_through_fake_intake() { + let fake_intake = FakeIntake::start().await; + let config = test_config(); + let http_client = create_reqwest_client_builder() + .expect("failed to create reqwest client builder") + .no_proxy() + .build() + .expect("failed to create reqwest client"); + let proxy_aggregator = Arc::new(Mutex::new(ProxyAggregator::default())); + + // The DsmProcessor derives its target URL from `apm_dd_url`, appending + // `/api/v0.1/pipeline_stats`. Pointing it at the fake intake's base URL + // exercises the same path a custom DD_APM_DD_URL takes in production. + let dsm_processor = DsmProcessor::new( + "fake-intake-dsm-service".to_string(), + "test-env".to_string(), + "1.0".to_string(), + "2.0".to_string(), + vec![ + "team:serverless".to_string(), + "region:us-east-1".to_string(), + ], + &fake_intake.base_url(), + Arc::clone(&proxy_aggregator), + ); + + let edge_tags = vec![ + "direction:in".to_string(), + "topic:my-queue".to_string(), + "type:sqs".to_string(), + ]; + // No inbound pathway context in the carrier: this is a root consume node. + dsm_processor.record_consume(&edge_tags, &HashMap::new(), 128.0); + dsm_processor.drain_into_proxy().await; + + let flusher = ProxyFlusher::new( + Arc::new(ApiKeyFactory::new(DD_API_KEY)), + Arc::clone(&proxy_aggregator), + tags_provider(&config), + Arc::clone(&config), + http_client, + ); + + // flush() awaits the HTTP response, so the intake handler has captured the + // payload by the time this returns; no polling needed. + let failed = flusher.flush(None).await; + assert!( + failed.is_none(), + "flush reported failed requests: {failed:?}" + ); + + let payloads = fake_intake.pipeline_stats_payloads(); + assert_eq!( + payloads.len(), + 1, + "expected exactly one pipeline-stats payload" + ); + + let payload = &payloads[0]; + assert_eq!(payload.service, "fake-intake-dsm-service"); + assert_eq!(payload.env, "test-env"); + assert_eq!(payload.tracer_version, "1.0"); + assert_eq!(payload.version, "2.0"); + assert_eq!(payload.tags, vec!["team:serverless", "region:us-east-1"]); + + assert_eq!(payload.stats.len(), 1, "expected one stats bucket"); + let points = &payload.stats[0].stats; + assert_eq!(points.len(), 1, "expected one stats point"); + + let point = &points[0]; + assert_eq!(point.edge_tags, edge_tags); + // Root consume node: no inbound context, so parent_hash is zero. + assert_eq!(point.parent_hash, 0); + assert_ne!(point.hash, 0, "consume checkpoint hash must be populated"); +} diff --git a/bottlecap/tests/payloads/sqs_binary_event.json b/bottlecap/tests/payloads/sqs_binary_event.json new file mode 100644 index 000000000..04a92dc36 --- /dev/null +++ b/bottlecap/tests/payloads/sqs_binary_event.json @@ -0,0 +1,29 @@ +{ + "Records": [ + { + "attributes": { + "AWSTraceHeader": "Root=1-6a7047f0-000000002301f1b51ee3ad28;Parent=0e9cba22408874fb;Sampled=1", + "ApproximateFirstReceiveTimestamp": "1785743344086", + "ApproximateReceiveCount": "1", + "SenderId": "AROA3TYMO3BGLN4BJFZQH:AWS-CLOUDCAST", + "SentTimestamp": "1785743344084" + }, + "awsRegion": "us-west-1", + "body": "{\"data\":{\"message\":\"Hello from DSM SNS demo\"},\"source\":\"java-dsm-demo-sns-producer\",\"type\":\"dsm.sns.demo.v1\"}", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-west-1:927749345584:java-dsm-demo-sns-demo-dev-dev", + "md5OfBody": "d0f22180e2cfeeed2792094dea97f103", + "md5OfMessageAttributes": "0d7adfa416aed513b06a7c9d6087381e", + "messageAttributes": { + "_datadog": { + "binaryListValues": [], + "binaryValue": "eyJkZC1wYXRod2F5LWN0eC1iYXNlNjQiOiJFditYTWZOSjMxVCtoY2pwK0dmK2hjanArR2M9IiwieC1kYXRhZG9nLXRyYWNlLWlkIjoiMjUyMjU2MzAyNjUxMzgwMDQ4OCIsIngtZGF0YWRvZy1wYXJlbnQtaWQiOiIxMDUyOTIxMDY5MTcyMTkyNTA3IiwieC1kYXRhZG9nLXNhbXBsaW5nLXByaW9yaXR5IjoiMSIsIngtZGF0YWRvZy10YWdzIjoiX2RkLnAuZG09LTEsX2RkLnAudGlkPTZhNzA0N2VmMDAwMDAwMDAiLCJ0cmFjZXBhcmVudCI6IjAwLTZhNzA0N2VmMDAwMDAwMDAyMzAxZjFiNTFlZTNhZDI4LTBlOWNiYTIyNDA4ODc0ZmItMDEiLCJ0cmFjZXN0YXRlIjoiZGQ9czoxO3A6MGU5Y2JhMjI0MDg4NzRmYjt0LmRtOi0xO3QudGlkOjZhNzA0N2VmMDAwMDAwMDAifQ==", + "dataType": "Binary", + "stringListValues": [] + } + }, + "messageId": "8fea9445-ffbb-4720-9399-03ab674f21d7", + "receiptHandle": "AQEBrU3X0N1q9cUzBjTHqcS4qViA7Yq" + } + ] +} diff --git a/integration-tests/bin/app.ts b/integration-tests/bin/app.ts index 9c0ee5e76..c5037476e 100644 --- a/integration-tests/bin/app.ts +++ b/integration-tests/bin/app.ts @@ -10,6 +10,7 @@ import {Oom} from '../lib/stacks/oom'; import {LmiOom} from '../lib/stacks/lmi-oom'; import {CustomMetrics} from '../lib/stacks/custom-metrics'; import {PayloadSize} from '../lib/stacks/payload-size'; +import {Dsm} from '../lib/stacks/dsm'; import {DurableColdStart} from '../lib/stacks/durable-cold-start'; import {AuthRoleStack} from '../lib/auth-role'; import {ACCOUNT, IDENTIFIER, REGION} from '../config'; @@ -58,6 +59,9 @@ const stacks = [ new DurableColdStart(app, `${IDENTIFIER}-durable-cold-start`, { env, }), + new Dsm(app, `${IDENTIFIER}-dsm`, { + env, + }), ] // Tag all stacks so we can easily clean them up diff --git a/integration-tests/lib/stacks/dsm.ts b/integration-tests/lib/stacks/dsm.ts new file mode 100644 index 000000000..00dba7bad --- /dev/null +++ b/integration-tests/lib/stacks/dsm.ts @@ -0,0 +1,65 @@ +import * as cdk from "aws-cdk-lib"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import { Construct } from "constructs"; +import { + createLogGroup, + defaultDatadogEnvVariables, + defaultDatadogSecretPolicy, + getExtensionLayer, + getDefaultJavaLayer, + defaultJavaRuntime, +} from "../util"; + +/** + * Data Streams Monitoring (DSM) extension-side consume checkpoint test stack. + * + * A Java consumer is used deliberately. dd-trace-java's universal + * instrumentation (enabled via /opt/datadog_wrapper) POSTs the event payload to + * the extension's /lambda/start-invocation endpoint, which is the only path + * that drives the extension's DSM extraction hook. The in-process library + * runtimes (Node/Python via datadog-lambda-*) do NOT call start-invocation and + * would never exercise the hook. + * + * DD_DATA_STREAMS_ENABLED turns the feature on. It is the same flag the tracer + * libraries use, but the extension and tracer never emit checkpoints for the + * same runtime: Java's universal instrumentation (the path that drives the + * extension hook) does not propagate DSM, so the extension remains the only + * source of `data_streams.latency` for this service. + * + * Reuses the shared default-java handler, which accepts an arbitrary JSON event + * map; the test invokes it with a synthetic SQS event carrying a known producer + * pathway context. + */ +export class Dsm extends cdk.Stack { + constructor(scope: Construct, id: string, props: cdk.StackProps) { + super(scope, id, props); + + const extensionLayer = getExtensionLayer(this); + const javaLayer = getDefaultJavaLayer(this); + + const functionName = `${id}-sqs-consumer`; + const consumer = new lambda.Function(this, functionName, { + runtime: defaultJavaRuntime, + architecture: lambda.Architecture.ARM_64, + handler: "example.Handler::handleRequest", + code: lambda.Code.fromAsset("./lambda/default-java/target/function.jar"), + functionName, + timeout: cdk.Duration.seconds(30), + memorySize: 512, + environment: { + ...defaultDatadogEnvVariables, + DD_SERVICE: functionName, + AWS_LAMBDA_EXEC_WRAPPER: "/opt/datadog_wrapper", + DD_TRACE_ENABLED: "true", + // Feature under test. Java's universal instrumentation does not emit + // tracer-side DSM, so the extension is the only source of + // data_streams.latency for this service. + DD_DATA_STREAMS_ENABLED: "true", + }, + logGroup: createLogGroup(this, functionName), + }); + consumer.addToRolePolicy(defaultDatadogSecretPolicy); + consumer.addLayers(extensionLayer); + consumer.addLayers(javaLayer); + } +} diff --git a/integration-tests/tests/dsm.test.ts b/integration-tests/tests/dsm.test.ts new file mode 100644 index 000000000..eb2f08e39 --- /dev/null +++ b/integration-tests/tests/dsm.test.ts @@ -0,0 +1,111 @@ +import { hasDataStreamsLatency } from "./utils/datadog"; +import { forceColdStart, invokeLambda } from "./utils/lambda"; +import { getIdentifier, DEFAULT_DATADOG_INDEXING_WAIT_MS } from "../config"; + +const identifier = getIdentifier(); +const stackName = `${identifier}-dsm`; +const functionName = `${stackName}-sqs-consumer`; + +// Queue name the consume edge tag (topic:) is derived from — the last ':' +// segment of the SQS eventSourceARN. +const QUEUE_NAME = "dsm-integ-queue"; + +// A known-good producer DSM pathway context (non-zero parent hash). The +// extension reads this from messageAttributes._datadog and records a consume +// checkpoint parented onto it. +const PRODUCER_PATHWAY_CTX_B64 = "Z7CzXmXArPrE58Cfj2LI2cOfj2I="; + +/** + * Synthetic SQS event. When the Java consumer is invoked with this payload, + * dd-trace-java forwards it to /lambda/start-invocation, the extension infers + * the SQS trigger, reads the _datadog carrier, and records a DSM consume + * checkpoint with edge tags [direction:in, topic:, type:sqs]. + */ +function syntheticSqsEvent() { + // A complete SQS record. The extension deserializes Records[0] strictly, so + // all of messageId/receiptHandle/attributes/md5OfBody/awsRegion/etc. must be + // present or trigger inference is skipped ("missing field `messageId`"). + return { + Records: [ + { + messageId: "059f36b4-87a3-44ab-83d2-661975830a7d", + receiptHandle: "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a", + body: "hello from dsm integration test", + attributes: { + ApproximateReceiveCount: "1", + SentTimestamp: "1545082649183", + SenderId: "AIDAIENQZJOLO23YVJ4VO", + ApproximateFirstReceiveTimestamp: "1545082649185", + }, + messageAttributes: { + _datadog: { + dataType: "String", + stringValue: JSON.stringify({ + "dd-pathway-ctx-base64": PRODUCER_PATHWAY_CTX_B64, + }), + }, + }, + md5OfBody: "e4e68fb7bd0e697a0ae8f1bb342846b3", + eventSource: "aws:sqs", + eventSourceARN: `arn:aws:sqs:us-east-1:123456789012:${QUEUE_NAME}`, + awsRegion: "us-east-1", + }, + ], + }; +} + +describe("DSM extension-side consume checkpoint", () => { + let fromTime: number; + let toTime: number; + + beforeAll(async () => { + await forceColdStart(functionName); + + // Back up the window so the 10s DSM bucket (which the backend aligns to a + // boundary that may precede the invocation) falls inside the query range. + fromTime = Date.now() - 60_000; + + await invokeLambda(functionName, syntheticSqsEvent()); + + // DSM buckets in 10s windows and the backend processes asynchronously, so + // use the long indexing wait like the other metric-based suites. + await new Promise((resolve) => + setTimeout(resolve, DEFAULT_DATADOG_INDEXING_WAIT_MS), + ); + + toTime = Date.now(); + + console.log("DSM consumer invoked and indexing wait complete"); + }, 900000); + + it("creates a DSM node for the consumer service", async () => { + // `service` is always a tag on data_streams.latency (per the DSM team), so + // its presence proves the extension created a consume node for this service. + const exists = await hasDataStreamsLatency(functionName, [], fromTime, toTime); + expect(exists).toBe(true); + }); + + // The following two assertions verify the consume edge tags actually landed, + // not just that *some* DSM node exists. They depend on `type` / `topic` being + // surfaced as tags on data_streams.latency. Verify on first run; if those tags + // are not present, the service-only assertion above remains the gate. + it("tags the consume node with type:sqs", async () => { + const exists = await hasDataStreamsLatency( + functionName, + ["type:sqs"], + fromTime, + toTime, + ); + expect(exists).toBe(true); + }); + + it("tags the consume node with the source queue as topic", async () => { + const exists = await hasDataStreamsLatency( + functionName, + [`topic:${QUEUE_NAME}`], + fromTime, + toTime, + ); + expect(exists).toBe(true); + }); +}); diff --git a/integration-tests/tests/utils/datadog.ts b/integration-tests/tests/utils/datadog.ts index cd72d1fed..6dfde2dd2 100644 --- a/integration-tests/tests/utils/datadog.ts +++ b/integration-tests/tests/utils/datadog.ts @@ -466,3 +466,41 @@ export async function hasMetricWithTag( console.log(`Tag filter query returned ${series.length} series, hasData=${hasData}`); return hasData; } + +/** + * Returns true if the DSM `data_streams.latency` metric reports data points for + * the given service (plus any optional edge tags) in the window. + * + * Data Streams Monitoring is powered by `data_streams.latency`, so the metric + * appearing for a service proves a DSM node was created for it. There is no + * documented public DSM API; per the DSM team this metric is the supported + * signal. `service` is always a tag; edge tags such as `type:sqs` or + * `topic:` are best-effort and should be verified on first run. + * + * Note: DSM metrics are tagged by `service` (not `functionname`), which is why + * this does not reuse `hasMetricWithTag`. + */ +export async function hasDataStreamsLatency( + service: string, + extraTags: string[], + fromTime: number, + toTime: number, +): Promise { + const tags = [`service:${service.toLowerCase()}`, ...extraTags].join(','); + const query = `avg:data_streams.latency{${tags}}`; + + console.log(`Querying DSM latency: ${query}`); + + const response = await datadogClient.get('/api/v1/query', { + params: { + query, + from: Math.floor(fromTime / 1000), + to: Math.floor(toTime / 1000), + }, + }); + + const series = response.data.series || []; + const hasData = series.some((s: any) => Array.isArray(s.pointlist) && s.pointlist.length > 0); + console.log(`DSM latency query returned ${series.length} series, hasData=${hasData}`); + return hasData; +} From e35e062ec39bd67989689262f2271a26bea6a797 Mon Sep 17 00:00:00 2001 From: Zarir Hamza Date: Thu, 27 Aug 2026 15:19:03 -0400 Subject: [PATCH 5/6] feat(traces): use DD_SERVICE for inferred spans when integration service names are removed (#1343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `DD_TRACE_REMOVE_INTEGRATION_SERVICE_NAMES_ENABLED` (default `false`). When it is set **and** `DD_SERVICE` is configured, inferred (synthetic) event-source spans report the function's base service instead of the AWS resource/instance representation. - An explicit `DD_SERVICE_MAPPING` entry still takes precedence. - Applies to the wrapped inferred spans too (SQS→SNS, SQS→EventBridge, SNS→EventBridge). ## Motivation Customers currently need one `DD_SERVICE_MAPPING` entry per trigger type per function to get their event-source spans onto their own service name. This gives them a single setting that consolidates all of them at once. Tracked by [FRSLES-869](https://datadoghq.atlassian.net/browse/FRSLES-869). This repo is the **only** inferred-span implementation for Go, Java, .NET, and Ruby, which delegate inference to the extension entirely. Companion PRs: [datadog-lambda-python#834](https://github.com/DataDog/datadog-lambda-python/pull/834), [datadog-lambda-js#814](https://github.com/DataDog/datadog-lambda-js/pull/814). ## Implementation note Rather than thread two more parameters through `Trigger::enrich_span` and `resolve_service_name` (a ~200-line mechanical change across all 13 trigger modules and their tests), the override is applied in `span_inferrer.rs` immediately after enrichment. To keep `DD_SERVICE_MAPPING` precedence expressed in one place, `Trigger` gains a default `has_service_mapping_entry` method that reuses the same specific/generic identifiers `resolve_service_name` looks up. Happy to switch to the full parameter-threading approach if reviewers prefer a single resolution ladder. The override lowercases `DD_SERVICE` to match the invocation span built in `processor.rs`, so both spans land on the same service. ## Scope Service-name resolution only. `complete_inferred_spans` still sets `peer.service` to the invocation span's service as it does today; revisiting that pairing is deliberately left to a follow-up. ## Test plan - [x] 6 new tests in `span_inferrer.rs`: override applies, disabled by default, yields to `DD_SERVICE_MAPPING`, no-op without `DD_SERVICE`, lowercases `DD_SERVICE`, applies to wrapped spans - [x] 3 new config tests: default off, from env, from YAML - [x] `cargo test --lib` — 550 passed - [x] `cargo clippy --lib --tests -- -D warnings` clean - [x] `cargo fmt --check` clean [FRSLES-869]: https://datadoghq.atlassian.net/browse/FRSLES-869?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- bottlecap/src/config/mod.rs | 41 ++++ .../src/lifecycle/invocation/span_inferrer.rs | 195 ++++++++++++++++++ .../src/lifecycle/invocation/triggers/mod.rs | 9 + bottlecap/src/traces/trace_processor.rs | 39 ++++ 4 files changed, 284 insertions(+) diff --git a/bottlecap/src/config/mod.rs b/bottlecap/src/config/mod.rs index 3585b4b2a..6ea97bf1f 100644 --- a/bottlecap/src/config/mod.rs +++ b/bottlecap/src/config/mod.rs @@ -78,6 +78,12 @@ pub struct LambdaConfig { pub api_security_sample_delay: Duration, pub custom_metrics_exclude_tags: Vec, + /// When true, inferred (synthetic) event-source spans report the function's + /// base service (`DD_SERVICE`) instead of the AWS resource/instance + /// representation. An explicit `DD_SERVICE_MAPPING` entry still wins. + /// Defaults to `false`. + pub trace_remove_integration_service_names_enabled: bool, + /// Maximum number of request IDs whose logs are held in `held_logs` waiting for durable /// execution context. Set to 0 to disable log holding; logs will be flushed immediately /// without durable execution context enrichment. Defaults to 0 until the tracer-side @@ -128,6 +134,7 @@ impl Default for LambdaConfig { api_security_enabled: true, api_security_sample_delay: Duration::from_secs(30), custom_metrics_exclude_tags: Vec::new(), + trace_remove_integration_service_names_enabled: false, lambda_durable_function_log_buffer_size: 0, policy_enabled: false, policy_providers: None, @@ -205,6 +212,12 @@ pub struct LambdaConfigSource { #[serde(deserialize_with = "deser_csv")] pub lambda_customer_metrics_exclude_tags: Vec, + /// `DD_TRACE_REMOVE_INTEGRATION_SERVICE_NAMES_ENABLED` — when true, inferred + /// (synthetic) event-source spans use `DD_SERVICE` rather than the AWS + /// resource/instance name. Defaults to `false`. + #[serde(deserialize_with = "deser_opt_bool")] + pub trace_remove_integration_service_names_enabled: Option, + /// `DD_LAMBDA_DURABLE_FUNCTION_LOG_BUFFER_SIZE` — max number of request IDs /// whose logs are held waiting for durable execution context. Defaults to /// 0 (hold mechanism disabled). @@ -249,6 +262,7 @@ impl DatadogConfigExtension for LambdaConfig { appsec_waf_timeout, api_security_enabled, api_security_sample_delay, + trace_remove_integration_service_names_enabled, lambda_durable_function_log_buffer_size, policy_enabled, ], @@ -599,6 +613,33 @@ mod lambda_config_tests { assert!(!config.ext.lambda_extension_compute_stats); } + #[test] + fn trace_remove_integration_service_names_defaults_false() { + let config = load(|_| Ok(())); + assert!(!config.ext.trace_remove_integration_service_names_enabled); + } + + #[test] + fn trace_remove_integration_service_names_from_env() { + let config = load(|jail| { + jail.set_env("DD_TRACE_REMOVE_INTEGRATION_SERVICE_NAMES_ENABLED", "true"); + Ok(()) + }); + assert!(config.ext.trace_remove_integration_service_names_enabled); + } + + #[test] + fn trace_remove_integration_service_names_from_yaml() { + let config = load(|jail| { + jail.create_file( + "datadog.yaml", + "trace_remove_integration_service_names_enabled: true\n", + )?; + Ok(()) + }); + assert!(config.ext.trace_remove_integration_service_names_enabled); + } + #[test] fn dsm_consume_enabled_from_data_streams_env() { let config = load(|jail| { diff --git a/bottlecap/src/lifecycle/invocation/span_inferrer.rs b/bottlecap/src/lifecycle/invocation/span_inferrer.rs index 6bb10e555..3afa1a789 100644 --- a/bottlecap/src/lifecycle/invocation/span_inferrer.rs +++ b/bottlecap/src/lifecycle/invocation/span_inferrer.rs @@ -23,6 +23,38 @@ use crate::{ }; use datadog_opentelemetry::propagation::context::SpanContext; +/// Point an inferred (synthetic) span at the function's base service when +/// `DD_TRACE_REMOVE_INTEGRATION_SERVICE_NAMES_ENABLED` is set and `DD_SERVICE` +/// is configured, instead of the AWS resource/instance representation the +/// trigger resolved. This gives a single setting that consolidates every +/// event-source span onto the function's service, rather than requiring one +/// `DD_SERVICE_MAPPING` entry per trigger type. +/// +/// An explicit `DD_SERVICE_MAPPING` entry still wins, preserving the precedence +/// in [`Trigger::resolve_service_name`]. +/// +/// The value is lowercased so both spans land on one service. That matches the +/// invocation span directly when AWS service representation is enabled, since +/// `processor.rs` lowercases `DD_SERVICE` there too. When it is disabled the +/// invocation span is initially named `aws.lambda`, and `ChunkProcessor::process` +/// rewrites any `aws.lambda` span to the lowercased `DD_SERVICE` from the tags +/// map, so the two still converge. Both paths are pinned by tests. +fn apply_base_service_override(span: &mut Span, trigger: &dyn Trigger, config: &Config) { + if !config.ext.trace_remove_integration_service_names_enabled { + return; + } + + let Some(service) = config.service.as_deref() else { + return; + }; + + if service.is_empty() || trigger.has_service_mapping_entry(&config.service_mapping) { + return; + } + + span.service = service.to_lowercase(); +} + /// Per-invocation inference output produced by [`SpanInferrer::infer_span`]. /// /// This lives on each invocation's `Context` (not on the shared `Processor`) so @@ -111,6 +143,7 @@ impl SpanInferrer { ) } + #[allow(clippy::too_many_lines)] fn get_wrapped_inferred_span( identified_trigger: &IdentifiedTrigger, inferred_span: &mut Span, @@ -135,6 +168,11 @@ impl SpanInferrer { &config.service_mapping, config.trace_aws_service_representation_enabled, ); + apply_base_service_override( + &mut wrapped_inferred_span, + &wrapped_trigger, + config, + ); inferred_span.meta.extend(wrapped_trigger.get_tags()); wrapped_inferred_span.duration = @@ -161,6 +199,11 @@ impl SpanInferrer { &config.service_mapping, config.trace_aws_service_representation_enabled, ); + apply_base_service_override( + &mut wrapped_inferred_span, + &event_bridge_entity, + config, + ); inferred_span.meta.extend(event_bridge_entity.get_tags()); wrapped_inferred_span.duration = @@ -193,6 +236,11 @@ impl SpanInferrer { &config.service_mapping, config.trace_aws_service_representation_enabled, ); + apply_base_service_override( + &mut wrapped_inferred_span, + &event_bridge_wrapper_message, + config, + ); inferred_span .meta .extend(event_bridge_wrapper_message.get_tags()); @@ -270,6 +318,7 @@ impl SpanInferrer { &self.config.service_mapping, self.config.trace_aws_service_representation_enabled, ); + apply_base_service_override(&mut inferred_span, t.as_ref(), &self.config); } if let Some(dd_resource_key) = t.get_dd_resource_key(&aws_config.region) { @@ -431,6 +480,7 @@ pub fn extract_generated_span_context( #[cfg(test)] mod tests { use super::*; + use crate::config::LambdaConfig; use crate::lifecycle::invocation::triggers::test_utils::read_json_file; use crate::traces::propagation::DatadogCompositePropagator; use datadog_opentelemetry::propagation::TracePropagationStyle; @@ -756,4 +806,149 @@ mod tests { "AppSec JSON should not be added when invocation span has none" ); } + + fn sqs_payload() -> Value { + let json = read_json_file("sqs_event.json"); + serde_json::from_str(&json).expect("Failed to deserialize SQS payload") + } + + /// Infer a span from `payload` and return the resolved inferred-span service. + fn inferred_service(payload: &Value, config: Config) -> String { + let inferrer = SpanInferrer::new(Arc::new(config)); + inferrer + .infer_span(payload, &aws_config("us-east-1")) + .inferred_span + .expect("Should have inferred a span") + .service + } + + #[test] + fn test_base_service_override_uses_dd_service() { + let config = Config { + service: Some("my-lambda-service".to_string()), + ext: LambdaConfig { + trace_remove_integration_service_names_enabled: true, + ..LambdaConfig::default() + }, + ..Config::default() + }; + + assert_eq!( + inferred_service(&sqs_payload(), config), + "my-lambda-service" + ); + } + + #[test] + fn test_base_service_override_disabled_by_default() { + let config = Config { + service: Some("my-lambda-service".to_string()), + ..Config::default() + }; + + // Default behavior is unchanged: the AWS resource name is preserved. + assert_eq!(inferred_service(&sqs_payload(), config), "MyQueue"); + } + + #[test] + fn test_base_service_override_yields_to_service_mapping() { + let config = Config { + service: Some("my-lambda-service".to_string()), + service_mapping: HashMap::from([( + "lambda_sqs".to_string(), + "remapped-queue".to_string(), + )]), + ext: LambdaConfig { + trace_remove_integration_service_names_enabled: true, + ..LambdaConfig::default() + }, + ..Config::default() + }; + + assert_eq!(inferred_service(&sqs_payload(), config), "remapped-queue"); + } + + #[test] + fn test_base_service_override_noop_without_dd_service() { + let config = Config { + service: None, + ext: LambdaConfig { + trace_remove_integration_service_names_enabled: true, + ..LambdaConfig::default() + }, + ..Config::default() + }; + + assert_eq!(inferred_service(&sqs_payload(), config), "MyQueue"); + } + + #[test] + fn test_base_service_override_lowercases_dd_service() { + // The invocation span in processor.rs lowercases DD_SERVICE, so the + // inferred span must too or the two land on different services. + let config = Config { + service: Some("MyLambdaService".to_string()), + ext: LambdaConfig { + trace_remove_integration_service_names_enabled: true, + ..LambdaConfig::default() + }, + ..Config::default() + }; + + assert_eq!(inferred_service(&sqs_payload(), config), "mylambdaservice"); + } + + /// With AWS service representation disabled the trigger would resolve to the + /// generic fallback (`sqs`). The override still applies, and the invocation + /// span converges on the same value via `ChunkProcessor::process` — see + /// `test_invocation_span_normalized_to_dd_service_when_representation_disabled`. + #[test] + fn test_base_service_override_applies_when_representation_disabled() { + let config = Config { + service: Some("my-lambda-service".to_string()), + trace_aws_service_representation_enabled: false, + ext: LambdaConfig { + trace_remove_integration_service_names_enabled: true, + ..LambdaConfig::default() + }, + ..Config::default() + }; + + assert_eq!( + inferred_service(&sqs_payload(), config), + "my-lambda-service" + ); + } + + #[test] + fn test_base_service_override_applies_to_wrapped_span() { + let json = read_json_file("sns_sqs_event.json"); + let payload: Value = + serde_json::from_str(&json).expect("Failed to deserialize SNS-in-SQS payload"); + + let config = Arc::new(Config { + service: Some("my-lambda-service".to_string()), + ext: LambdaConfig { + trace_remove_integration_service_names_enabled: true, + ..LambdaConfig::default() + }, + ..Config::default() + }); + + let inferrer = SpanInferrer::new(config); + let data = inferrer.infer_span(&payload, &aws_config("us-east-1")); + + assert_eq!( + data.inferred_span + .expect("Should have inferred an SQS span") + .service, + "my-lambda-service" + ); + assert_eq!( + data.wrapped_inferred_span + .expect("Should have inferred a wrapped SNS span") + .service, + "my-lambda-service" + ); + } } diff --git a/bottlecap/src/lifecycle/invocation/triggers/mod.rs b/bottlecap/src/lifecycle/invocation/triggers/mod.rs index 8feb79843..37b83dc21 100644 --- a/bottlecap/src/lifecycle/invocation/triggers/mod.rs +++ b/bottlecap/src/lifecycle/invocation/triggers/mod.rs @@ -205,6 +205,15 @@ pub trait Trigger: ServiceNameResolver { None } + /// Whether an explicit `DD_SERVICE_MAPPING` entry targets this trigger, + /// under either its specific or its generic key. Callers that override the + /// resolved service name use this to preserve the precedence established by + /// [`Trigger::resolve_service_name`]: an explicit mapping always wins. + fn has_service_mapping_entry(&self, service_mapping: &HashMap) -> bool { + service_mapping.contains_key(&self.get_specific_identifier()) + || service_mapping.contains_key(self.get_generic_identifier()) + } + /// Default implementation for service name resolution fn resolve_service_name( &self, diff --git a/bottlecap/src/traces/trace_processor.rs b/bottlecap/src/traces/trace_processor.rs index a2db04c0e..cac352a6d 100644 --- a/bottlecap/src/traces/trace_processor.rs +++ b/bottlecap/src/traces/trace_processor.rs @@ -1692,6 +1692,45 @@ mod tests { ); } + /// `processor.rs` builds the invocation span with + /// `get_default_service_name(.., "aws.lambda", representation_enabled)`, so when + /// AWS service representation is disabled the invocation span starts out named + /// `aws.lambda` even though `DD_SERVICE` is set. This normalization step is what + /// puts it back on `DD_SERVICE`, and it is the reason the inferred-span base + /// service override in `span_inferrer.rs` stays consistent with the invocation + /// span in that configuration rather than diverging from it. + #[test] + fn test_invocation_span_normalized_to_dd_service_when_representation_disabled() { + let config = Arc::new(Config { + service: Some("My-Payments-API".to_string()), + trace_aws_service_representation_enabled: false, + ..Config::default() + }); + let mut processor = create_chunk_processor(config); + + let invocation_span = pb::Span { + name: "aws.lambda".to_string(), + service: "aws.lambda".to_string(), + resource: "my-function".to_string(), + ..create_inferred_span() + }; + let mut chunk = pb::TraceChunk { + priority: 1, + origin: "lambda".to_string(), + spans: vec![invocation_span], + tags: HashMap::new(), + dropped_trace: false, + }; + + processor.process(&mut chunk, 0); + + assert_eq!( + chunk.spans[0].service, "my-payments-api", + "invocation span should be normalized to the lowercased DD_SERVICE, \ + matching what apply_base_service_override puts on inferred spans" + ); + } + #[test] fn test_base_service_not_set_on_non_inferred_spans() { let config = Arc::new(Config { From ce439dbed8d68d06abab4d9454951ac3f46353c7 Mon Sep 17 00:00:00 2001 From: Lucas Pimentel Date: Fri, 28 Aug 2026 13:19:18 -0400 Subject: [PATCH 6/6] chore(deps): update `libdatadog` to `72fa8685` and `serverless-components` to `9daae40` (#1332) --- bottlecap/Cargo.lock | 403 ++++++++++++++---- bottlecap/Cargo.toml | 49 +-- bottlecap/src/appsec/processor/context.rs | 29 +- .../src/lifecycle/invocation/processor.rs | 8 +- bottlecap/src/otlp/agent.rs | 2 +- .../src/traces/stats_concentrator_service.rs | 42 +- bottlecap/src/traces/trace_agent.rs | 10 +- bottlecap/src/traces/trace_aggregator.rs | 29 +- .../src/traces/trace_aggregator_service.rs | 12 +- bottlecap/src/traces/trace_processor.rs | 76 ++-- bottlecap/tests/apm_integration_test.rs | 16 +- 11 files changed, 447 insertions(+), 229 deletions(-) diff --git a/bottlecap/Cargo.lock b/bottlecap/Cargo.lock index 19dbc278c..305158ac6 100644 --- a/bottlecap/Cargo.lock +++ b/bottlecap/Cargo.lock @@ -576,13 +576,13 @@ dependencies = [ "indexmap 2.13.0", "itertools 0.14.0", "lazy_static", - "libdd-capabilities", - "libdd-common", - "libdd-trace-normalization", - "libdd-trace-obfuscation", - "libdd-trace-protobuf", - "libdd-trace-stats", - "libdd-trace-utils", + "libdd-capabilities 2.1.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-common 5.1.0", + "libdd-trace-normalization 3.0.0", + "libdd-trace-obfuscation 5.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-trace-protobuf 4.0.0", + "libdd-trace-stats 6.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-trace-utils 9.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", "libddwaf", "log", "mime", @@ -880,13 +880,13 @@ dependencies = [ [[package]] name = "datadog-agent-config" version = "0.1.0" -source = "git+https://github.com/DataDog/serverless-components?rev=d0c7f44191445e20d309734675d5e8b91d2a5d51#d0c7f44191445e20d309734675d5e8b91d2a5d51" +source = "git+https://github.com/DataDog/serverless-components?rev=9daae40afa87f52fad1489f4d7cfd4a579037d2d#9daae40afa87f52fad1489f4d7cfd4a579037d2d" dependencies = [ "datadog-opentelemetry", "dogstatsd", "figment", - "libdd-trace-obfuscation", - "libdd-trace-utils", + "libdd-trace-obfuscation 5.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-trace-utils 9.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", "log", "serde", "serde-aux", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "datadog-fips" version = "0.1.0" -source = "git+https://github.com/DataDog/serverless-components?rev=d0c7f44191445e20d309734675d5e8b91d2a5d51#d0c7f44191445e20d309734675d5e8b91d2a5d51" +source = "git+https://github.com/DataDog/serverless-components?rev=9daae40afa87f52fad1489f4d7cfd4a579037d2d#9daae40afa87f52fad1489f4d7cfd4a579037d2d" dependencies = [ "reqwest", "rustls", @@ -921,15 +921,15 @@ dependencies = [ "hyper 1.8.1", "hyper-util", "libc", - "libdd-capabilities-impl", - "libdd-common", + "libdd-capabilities-impl 3.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-common 5.2.0", "libdd-data-pipeline", "libdd-library-config", "libdd-sampling", - "libdd-shared-runtime", - "libdd-telemetry", - "libdd-tinybytes", - "libdd-trace-utils", + "libdd-shared-runtime 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-telemetry 6.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-tinybytes 1.1.2", + "libdd-trace-utils 9.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "lru", "opentelemetry 0.32.0", "opentelemetry-semantic-conventions 0.32.1", @@ -1047,7 +1047,7 @@ dependencies = [ [[package]] name = "dogstatsd" version = "0.1.0" -source = "git+https://github.com/DataDog/serverless-components?rev=d0c7f44191445e20d309734675d5e8b91d2a5d51#d0c7f44191445e20d309734675d5e8b91d2a5d51" +source = "git+https://github.com/DataDog/serverless-components?rev=9daae40afa87f52fad1489f4d7cfd4a579037d2d#9daae40afa87f52fad1489f4d7cfd4a579037d2d" dependencies = [ "datadog-protos", "ddsketch-agent", @@ -2105,7 +2105,8 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libdd-capabilities" version = "2.1.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a946c29c5fe7cc2adec7a66a35f7cfc138ba75c988d09a7a99c12de7d3b9a7" dependencies = [ "anyhow", "bytes", @@ -2113,23 +2114,51 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "libdd-capabilities" +version = "2.1.0" +source = "git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe#72fa86854c823361b7d234545f1a2f2d21f944fe" +dependencies = [ + "anyhow", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "thiserror 1.0.69", +] + [[package]] name = "libdd-capabilities-impl" version = "3.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e948ad5c65d8598fd3889762ea4767b9a521ebfb2f7b72f6e52162aa4d7f353" dependencies = [ "bytes", "http 1.4.0", "http-body-util", - "libdd-capabilities", - "libdd-common", + "libdd-capabilities 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-common 5.2.0", + "tokio", +] + +[[package]] +name = "libdd-capabilities-impl" +version = "3.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe#72fa86854c823361b7d234545f1a2f2d21f944fe" +dependencies = [ + "anyhow", + "bytes", + "http 1.4.0", + "http-body-util", + "libdd-capabilities 2.1.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-common 5.1.0", "tokio", ] [[package]] name = "libdd-common" version = "5.1.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe#72fa86854c823361b7d234545f1a2f2d21f944fe" dependencies = [ "anyhow", "bytes", @@ -2161,10 +2190,42 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "libdd-common" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350fb68fd76193dceecbadd8add17732f5f2ca0b0c7806d62da0bd110af53410" +dependencies = [ + "anyhow", + "bytes", + "cc", + "const_format", + "futures", + "futures-core", + "futures-util", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "libc", + "nix 0.29.0", + "pin-project", + "regex", + "serde", + "static_assertions", + "thiserror 1.0.69", + "tokio", + "tower-service", + "windows-sys 0.52.0", +] + [[package]] name = "libdd-data-pipeline" version = "7.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433f9bfe60850c0638a273dfb2bd873f5abc12c9983ce74ebffe055a7a3c21c9" dependencies = [ "anyhow", "arc-swap", @@ -2174,19 +2235,19 @@ dependencies = [ "getrandom 0.2.17", "http 1.4.0", "http-body-util", - "libdd-capabilities", - "libdd-capabilities-impl", - "libdd-common", - "libdd-ddsketch", - "libdd-dogstatsd-client", - "libdd-shared-runtime", - "libdd-telemetry", - "libdd-tinybytes", - "libdd-trace-normalization", - "libdd-trace-obfuscation", - "libdd-trace-protobuf", - "libdd-trace-stats", - "libdd-trace-utils", + "libdd-capabilities 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-capabilities-impl 3.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-common 5.2.0", + "libdd-ddsketch 1.1.1", + "libdd-dogstatsd-client 4.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-shared-runtime 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-telemetry 6.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-tinybytes 1.1.2", + "libdd-trace-normalization 3.0.1", + "libdd-trace-obfuscation 5.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-trace-protobuf 4.0.1", + "libdd-trace-stats 6.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-trace-utils 9.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "rmp-serde", "serde", "serde_json", @@ -2200,7 +2261,16 @@ dependencies = [ [[package]] name = "libdd-ddsketch" version = "1.1.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe#72fa86854c823361b7d234545f1a2f2d21f944fe" +dependencies = [ + "prost 0.14.3", +] + +[[package]] +name = "libdd-ddsketch" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "827db9addada0e19a8371241e54fe4f27e9101c5a1dc30a13cf42dea5220e3f0" dependencies = [ "prost 0.14.3", ] @@ -2208,12 +2278,26 @@ dependencies = [ [[package]] name = "libdd-dogstatsd-client" version = "4.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c29c7bd569824c4d8ffe7171bb77e6b69c2161517ed75436ff458b40d41a663d" dependencies = [ "anyhow", "cadence", "http 1.4.0", - "libdd-common", + "libdd-common 5.2.0", + "serde", + "tracing", +] + +[[package]] +name = "libdd-dogstatsd-client" +version = "4.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe#72fa86854c823361b7d234545f1a2f2d21f944fe" +dependencies = [ + "anyhow", + "cadence", + "http 1.4.0", + "libdd-common 5.1.0", "serde", "tracing", ] @@ -2221,11 +2305,12 @@ dependencies = [ [[package]] name = "libdd-library-config" version = "3.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab46ec324c1b916ca06c4a369da171a08eb9f112620e6f6f004860e589892715" dependencies = [ "anyhow", "libc", - "libdd-trace-protobuf", + "libdd-trace-protobuf 4.0.1", "memfd", "prost 0.14.3", "rand 0.8.6", @@ -2238,9 +2323,10 @@ dependencies = [ [[package]] name = "libdd-sampling" version = "5.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6c34dd818feb6a2c1de996017d2f8e3190615c62fa5513f3b9559bce727dfb" dependencies = [ - "libdd-common", + "libdd-common 5.2.0", "lru", "serde", "serde_json", @@ -2249,14 +2335,32 @@ dependencies = [ [[package]] name = "libdd-shared-runtime" version = "2.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab1ad16a0bfcf1ce826e2314c162b04daf4324c71da5a28583ea40e76be8772" dependencies = [ "async-trait", "futures", "futures-util", - "libdd-capabilities", - "libdd-capabilities-impl", - "libdd-common", + "libdd-capabilities 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-capabilities-impl 3.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-common 5.2.0", + "tokio", + "tokio-util", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "libdd-shared-runtime" +version = "2.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe#72fa86854c823361b7d234545f1a2f2d21f944fe" +dependencies = [ + "async-trait", + "futures", + "futures-util", + "libdd-capabilities 2.1.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-capabilities-impl 3.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-common 5.1.0", "tokio", "tokio-util", "tracing", @@ -2266,7 +2370,8 @@ dependencies = [ [[package]] name = "libdd-telemetry" version = "6.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c791d810acbe99b0c7fc4285824f4e27a4b2b9137d4aec8a5df220ca55b34942" dependencies = [ "anyhow", "async-trait", @@ -2277,9 +2382,9 @@ dependencies = [ "http 1.4.0", "http-body-util", "libc", - "libdd-common", - "libdd-ddsketch", - "libdd-shared-runtime", + "libdd-common 5.2.0", + "libdd-ddsketch 1.1.1", + "libdd-shared-runtime 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "serde", "serde_json", "sys-info", @@ -2290,10 +2395,48 @@ dependencies = [ "winver", ] +[[package]] +name = "libdd-telemetry" +version = "6.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe#72fa86854c823361b7d234545f1a2f2d21f944fe" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bytes", + "futures", + "getrandom 0.2.17", + "hashbrown 0.15.5", + "http 1.4.0", + "libc", + "libdd-capabilities 2.1.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-common 5.1.0", + "libdd-ddsketch 1.1.0", + "libdd-shared-runtime 2.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "serde", + "serde_json", + "sys-info", + "tokio", + "tokio-util", + "tracing", + "uuid", + "web-time", + "winver", +] + [[package]] name = "libdd-tinybytes" version = "1.1.1" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe#72fa86854c823361b7d234545f1a2f2d21f944fe" +dependencies = [ + "serde", +] + +[[package]] +name = "libdd-tinybytes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d3e6ade71c21b86ab81fa8d905f9f26733336f1a904bb826b9ac1972b3c8289" dependencies = [ "serde", ] @@ -2301,22 +2444,49 @@ dependencies = [ [[package]] name = "libdd-trace-normalization" version = "3.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe#72fa86854c823361b7d234545f1a2f2d21f944fe" +dependencies = [ + "anyhow", + "libdd-trace-protobuf 4.0.0", +] + +[[package]] +name = "libdd-trace-normalization" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a643220a54228ef837631b5036f34fc13fc8ee2e307a74ac011232c062393fc5" dependencies = [ "anyhow", - "libdd-trace-protobuf", + "libdd-trace-protobuf 4.0.1", ] [[package]] name = "libdd-trace-obfuscation" version = "5.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d313153f515b9dd31a483bc7e4d432ecb76e53dd33f67f4eeaa415319372b68d" dependencies = [ "anyhow", "fluent-uri", - "libdd-common", - "libdd-trace-protobuf", - "libdd-trace-utils", + "libdd-common 5.2.0", + "libdd-trace-protobuf 4.0.1", + "libdd-trace-utils 9.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "log", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "libdd-trace-obfuscation" +version = "5.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe#72fa86854c823361b7d234545f1a2f2d21f944fe" +dependencies = [ + "anyhow", + "fluent-uri", + "libdd-common 5.1.0", + "libdd-trace-protobuf 4.0.0", + "libdd-trace-utils 9.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", "log", "percent-encoding", "serde", @@ -2326,7 +2496,18 @@ dependencies = [ [[package]] name = "libdd-trace-protobuf" version = "4.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe#72fa86854c823361b7d234545f1a2f2d21f944fe" +dependencies = [ + "prost 0.14.3", + "serde", + "serde_bytes", +] + +[[package]] +name = "libdd-trace-protobuf" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1bc3bd67115bec00669d84a29b7b52ef1d61f36dfb49b273b2fee3346bf18f" dependencies = [ "prost 0.14.3", "serde", @@ -2336,7 +2517,35 @@ dependencies = [ [[package]] name = "libdd-trace-stats" version = "6.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f23a575e3ecccfbbbd1612ddb1cb8b08e0c12ae0a62052ef995f05d93976a55b" +dependencies = [ + "anyhow", + "arc-swap", + "async-trait", + "hashbrown 0.15.5", + "http 1.4.0", + "libdd-capabilities 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-capabilities-impl 3.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-common 5.2.0", + "libdd-ddsketch 1.1.1", + "libdd-dogstatsd-client 4.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-shared-runtime 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-telemetry 6.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-trace-obfuscation 5.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-trace-protobuf 4.0.1", + "libdd-trace-utils 9.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rmp-serde", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "libdd-trace-stats" +version = "6.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe#72fa86854c823361b7d234545f1a2f2d21f944fe" dependencies = [ "anyhow", "arc-swap", @@ -2344,27 +2553,64 @@ dependencies = [ "futures", "hashbrown 0.15.5", "http 1.4.0", - "libdd-capabilities", - "libdd-capabilities-impl", - "libdd-common", - "libdd-ddsketch", - "libdd-dogstatsd-client", - "libdd-shared-runtime", - "libdd-telemetry", - "libdd-trace-obfuscation", - "libdd-trace-protobuf", - "libdd-trace-utils", + "libdd-capabilities 2.1.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-capabilities-impl 3.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-common 5.1.0", + "libdd-ddsketch 1.1.0", + "libdd-dogstatsd-client 4.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-shared-runtime 2.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-telemetry 6.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-trace-obfuscation 5.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-trace-protobuf 4.0.0", + "libdd-trace-utils 9.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", "rmp-serde", "serde", "tokio", "tokio-util", "tracing", + "web-time", +] + +[[package]] +name = "libdd-trace-utils" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2529075e0b7494767011c027750951f302b3ee8a5f5d15ab7a6e95e88a755182" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bytes", + "futures", + "getrandom 0.2.17", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "indexmap 2.13.0", + "libdd-capabilities 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-capabilities-impl 3.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libdd-common 5.2.0", + "libdd-tinybytes 1.1.2", + "libdd-trace-normalization 3.0.1", + "libdd-trace-protobuf 4.0.1", + "prost 0.14.3", + "rand 0.8.6", + "rmp", + "rmp-serde", + "rmpv", + "rustc-hash", + "serde", + "serde-transcode", + "serde_json", + "thin-vec", + "tokio", + "tracing", ] [[package]] name = "libdd-trace-utils" version = "9.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=85ce322a1dcb1eda7df9bcc021223b2d1a236783#85ce322a1dcb1eda7df9bcc021223b2d1a236783" +source = "git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe#72fa86854c823361b7d234545f1a2f2d21f944fe" dependencies = [ "anyhow", "base64 0.22.1", @@ -2377,12 +2623,13 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "indexmap 2.13.0", - "libdd-capabilities", - "libdd-capabilities-impl", - "libdd-common", - "libdd-tinybytes", - "libdd-trace-normalization", - "libdd-trace-protobuf", + "itoa", + "libdd-capabilities 2.1.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-capabilities-impl 3.0.0 (git+https://github.com/DataDog/libdatadog?rev=72fa86854c823361b7d234545f1a2f2d21f944fe)", + "libdd-common 5.1.0", + "libdd-tinybytes 1.1.1", + "libdd-trace-normalization 3.0.0", + "libdd-trace-protobuf 4.0.0", "prost 0.14.3", "rand 0.8.6", "rmp", diff --git a/bottlecap/Cargo.toml b/bottlecap/Cargo.toml index f0575d46c..2699f4d88 100644 --- a/bottlecap/Cargo.toml +++ b/bottlecap/Cargo.toml @@ -83,17 +83,23 @@ indexmap = {version = "2.11.0", default-features = false} # be found in the clippy.toml file adjacent to this Cargo.toml. datadog-protos = { version = "0.1.0", default-features = false, git = "https://github.com/DataDog/saluki/", rev = "f863626dbfe3c59bb390985fa6530b0621c2a0a2"} ddsketch-agent = { version = "0.1.0", default-features = false, git = "https://github.com/DataDog/saluki/", rev = "f863626dbfe3c59bb390985fa6530b0621c2a0a2"} -libdd-capabilities = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-common = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783", default-features = false } -libdd-trace-protobuf = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-trace-utils = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783", default-features = false, features = ["mini_agent"] } -libdd-trace-normalization = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-trace-obfuscation = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783", default-features = false } -libdd-trace-stats = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783", default-features = false } +# dd-trace-rs (datadog-opentelemetry) resolves libdd-* from crates.io, so ~11 of these +# crates are compiled twice. A `[patch.crates-io]` section used to collapse them, but it +# cannot be restored while this rev's versions trail the published ones (libdd-common 5.1.0 +# here vs the ^5.2.0 that published libdd-data-pipeline requires) and while the rev carries +# an unreleased breaking libdd-telemetry change under an unchanged version. Re-check with +# `cargo tree --duplicates | grep ^libdd-` when bumping either side. +libdd-capabilities = { git = "https://github.com/DataDog/libdatadog", rev = "72fa86854c823361b7d234545f1a2f2d21f944fe" } +libdd-common = { git = "https://github.com/DataDog/libdatadog", rev = "72fa86854c823361b7d234545f1a2f2d21f944fe", default-features = false } +libdd-trace-protobuf = { git = "https://github.com/DataDog/libdatadog", rev = "72fa86854c823361b7d234545f1a2f2d21f944fe" } +libdd-trace-utils = { git = "https://github.com/DataDog/libdatadog", rev = "72fa86854c823361b7d234545f1a2f2d21f944fe", default-features = false, features = ["mini_agent"] } +libdd-trace-normalization = { git = "https://github.com/DataDog/libdatadog", rev = "72fa86854c823361b7d234545f1a2f2d21f944fe" } +libdd-trace-obfuscation = { git = "https://github.com/DataDog/libdatadog", rev = "72fa86854c823361b7d234545f1a2f2d21f944fe", default-features = false } +libdd-trace-stats = { git = "https://github.com/DataDog/libdatadog", rev = "72fa86854c823361b7d234545f1a2f2d21f944fe", default-features = false } datadog-opentelemetry = { git = "https://github.com/DataDog/dd-trace-rs", rev = "50bfea8755b75e448a80ac04d53fa7edd414eefe", default-features = false, features = ["_unstable_propagation"] } -dogstatsd = { git = "https://github.com/DataDog/serverless-components", rev = "d0c7f44191445e20d309734675d5e8b91d2a5d51", default-features = false } -datadog-fips = { git = "https://github.com/DataDog/serverless-components", rev = "d0c7f44191445e20d309734675d5e8b91d2a5d51", default-features = false } -datadog-agent-config = { git = "https://github.com/DataDog/serverless-components", rev = "d0c7f44191445e20d309734675d5e8b91d2a5d51", default-features = false } +dogstatsd = { git = "https://github.com/DataDog/serverless-components", rev = "9daae40afa87f52fad1489f4d7cfd4a579037d2d", default-features = false } +datadog-fips = { git = "https://github.com/DataDog/serverless-components", rev = "9daae40afa87f52fad1489f4d7cfd4a579037d2d", default-features = false } +datadog-agent-config = { git = "https://github.com/DataDog/serverless-components", rev = "9daae40afa87f52fad1489f4d7cfd4a579037d2d", default-features = false } libddwaf = { version = "1.28.1", git = "https://github.com/DataDog/libddwaf-rust", rev = "d1534a158d976bd4f747bf9fcc58e0712d2d17fc", default-features = false, features = ["serde"] } [dev-dependencies] @@ -178,26 +184,3 @@ fips = [ [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] } - -# datadog-opentelemetry (dd-trace-rs) depends on these libdatadog crates via -# crates.io, while we depend on them directly via a git rev. Without this, -# both copies get compiled. Pointing the crates.io versions at the same git -# rev pinned above collapses them back to one. Verify with -# `cargo tree --duplicates | grep ^libdd-` after bumping either side. -[patch.crates-io] -libdd-capabilities = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-capabilities-impl = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-common = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-data-pipeline = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-ddsketch = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-dogstatsd-client = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-library-config = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-sampling = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-shared-runtime = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-telemetry = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-tinybytes = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-trace-normalization = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-trace-obfuscation = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-trace-protobuf = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-trace-stats = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } -libdd-trace-utils = { git = "https://github.com/DataDog/libdatadog", rev = "85ce322a1dcb1eda7df9bcc021223b2d1a236783" } diff --git a/bottlecap/src/appsec/processor/context.rs b/bottlecap/src/appsec/processor/context.rs index 9576a1582..94f237ae1 100644 --- a/bottlecap/src/appsec/processor/context.rs +++ b/bottlecap/src/appsec/processor/context.rs @@ -5,7 +5,6 @@ use std::time::Duration; use bytes::{Buf, Bytes}; use libdd_trace_protobuf::pb::Span; -use libdd_trace_utils::tracer_header_tags; use libddwaf::object::{Keyed, WafMap, WafObject}; use libddwaf::{Context as WafContext, Handle, RunError, RunOutput, RunResult, waf_map}; use mime::Mime; @@ -17,9 +16,10 @@ use crate::appsec::processor::{InvocationPayload, Processor}; use crate::config::Config; use crate::tags::provider::Provider; use crate::traces::span_pointers::SpanPointer; +use crate::traces::trace_aggregator::OwnedTracerHeaderTags; use crate::traces::trace_processor::SendingTraceProcessor; -/// Holds inforamtion gathered about an invocation. +/// Holds information gathered about an invocation. #[must_use] pub struct Context { /// The request ID of the invocation. @@ -116,19 +116,7 @@ impl Context { .send_processed_traces( args.config, args.tags_provider, - tracer_header_tags::TracerHeaderTags { - lang: &args.tracer_header_tags_lang, - lang_version: &args.tracer_header_tags_lang_version, - lang_interpreter: &args.tracer_header_tags_lang_interpreter, - lang_vendor: &args.tracer_header_tags_lang_vendor, - tracer_version: &args.tracer_header_tags_tracer_version, - container_id: &args.tracer_header_tags_container_id, - client_computed_top_level: args - .tracer_header_tags_client_computed_top_level, - client_computed_stats: args.tracer_header_tags_client_computed_stats, - dropped_p0_traces: args.tracer_header_tags_dropped_p0_traces, - dropped_p0_spans: args.tracer_header_tags_dropped_p0_spans, - }, + args.header_tags.to_tracer_header_tags(), vec![trace], args.body_size, args.span_pointers, @@ -547,16 +535,7 @@ pub struct HoldArguments { pub body_size: usize, pub span_pointers: Option>, - pub tracer_header_tags_lang: String, - pub tracer_header_tags_lang_version: String, - pub tracer_header_tags_lang_interpreter: String, - pub tracer_header_tags_lang_vendor: String, - pub tracer_header_tags_tracer_version: String, - pub tracer_header_tags_container_id: String, - pub tracer_header_tags_client_computed_top_level: bool, - pub tracer_header_tags_client_computed_stats: bool, - pub tracer_header_tags_dropped_p0_traces: usize, - pub tracer_header_tags_dropped_p0_spans: usize, + pub header_tags: OwnedTracerHeaderTags, } /// Names of tags that can be emitted by the WAF. diff --git a/bottlecap/src/lifecycle/invocation/processor.rs b/bottlecap/src/lifecycle/invocation/processor.rs index 3ddcdf1b1..26136c3d0 100644 --- a/bottlecap/src/lifecycle/invocation/processor.rs +++ b/bottlecap/src/lifecycle/invocation/processor.rs @@ -889,10 +889,10 @@ impl Processor { lang_vendor: "", tracer_version: "", container_id: "", - client_computed_top_level: false, - client_computed_stats, - dropped_p0_traces: 0, - dropped_p0_spans: 0, + generic: tracer_header_tags::TracerGenericTags { + client_computed_stats, + ..Default::default() + }, }; if let Err(e) = trace_sender diff --git a/bottlecap/src/otlp/agent.rs b/bottlecap/src/otlp/agent.rs index f302df7e4..20dccc3bc 100644 --- a/bottlecap/src/otlp/agent.rs +++ b/bottlecap/src/otlp/agent.rs @@ -60,7 +60,7 @@ impl TracePipeline { let lambda_extension_compute_stats = self.config.ext.lambda_extension_compute_stats; // Capture before `tracer_header_tags` is moved into process_traces below. - let client_computed_stats = tracer_header_tags.client_computed_stats; + let client_computed_stats = tracer_header_tags.generic.client_computed_stats; let (send_data_builder, processed_traces) = self.trace_processor.process_traces( self.config.clone(), self.tags_provider.clone(), diff --git a/bottlecap/src/traces/stats_concentrator_service.rs b/bottlecap/src/traces/stats_concentrator_service.rs index 1c72944af..e2da17f2e 100644 --- a/bottlecap/src/traces/stats_concentrator_service.rs +++ b/bottlecap/src/traces/stats_concentrator_service.rs @@ -3,7 +3,7 @@ use tokio::sync::{mpsc, oneshot}; use crate::config::Config; use libdd_trace_protobuf::pb; use libdd_trace_protobuf::pb::{ClientStatsPayload, TracerPayload}; -use libdd_trace_stats::span_concentrator::SpanConcentrator; +use libdd_trace_stats::span_concentrator::{CardinalityLimitConfig, SpanConcentrator}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, SystemTime}; @@ -186,10 +186,24 @@ impl StatsConcentratorService { .iter() .map(ToString::to_string) .collect(), - // Disable the cardinality limit to match pre-existing (unbounded) behavior. - Some(usize::MAX), - // Bottlecap does not perform agent-side stats obfuscation. - None, + // Keep stats cardinality unbounded, matching bottlecap's behavior before + // libdatadog gained per-field cardinality limits. Passing `None` would opt + // into CardinalityLimitConfig::default(): 7000 whole-key, 1024 resource, + // 512 http endpoint, 512 peer tags, 100 additional tags, which would + // silently collapse high-cardinality Lambda stats into the + // `tracer_blocked_value` overflow bucket. Every field is pinned instead. + // Per-field limits are `usize::MAX - 1` rather than `usize::MAX` only + // because the constructor warns when whole_key_limit is not strictly + // greater than every per-field limit; both values are unreachable. + Some(CardinalityLimitConfig { + whole_key_limit: usize::MAX, + resource_limit: usize::MAX - 1, + http_endpoint_limit: usize::MAX - 1, + peer_tags_limit: usize::MAX - 1, + additional_tags_limit: usize::MAX - 1, + }), + // No additional stats tag keys: aggregate on the default key fields only. + Vec::new(), ); let service: StatsConcentratorService = Self { concentrator, @@ -221,8 +235,9 @@ impl StatsConcentratorService { response_tx: oneshot::Sender>, ) { let flush_result = self.concentrator.flush(SystemTime::now(), force_flush); - // Obfuscation is disabled (see `SpanConcentrator::new` above), so every bucket ends up - // in `unobfuscated_buckets`; combine both to stay correct if that ever changes. Start + // Obfuscation is excluded at the feature level: bottlecap's `libdd-trace-stats` + // dependency does not enable `stats-obfuscation`, so every bucket ends up in + // `unobfuscated_buckets`; combine both to stay correct if that ever changes. Start // from `unobfuscated_buckets` since it's normally the only non-empty one, avoiding a // reallocation to grow the (usually empty) `obfuscated_buckets` vec. let mut stats_buckets = flush_result.unobfuscated_buckets; @@ -397,15 +412,18 @@ mod tests { ); } - /// The concentrator is configured with no cardinality limit (`Some(usize::MAX)`), so - /// exceeding `libdd_trace_stats`' default limit of 7,000 distinct aggregation keys per - /// bucket must not collapse any of them into the `tracer_blocked_value` overflow key. + /// The concentrator is configured with all five `CardinalityLimitConfig` limits pinned to + /// effectively unbounded values, so exceeding `libdd_trace_stats`' defaults must not collapse + /// any aggregation keys into the `tracer_blocked_value` overflow key. This exercises two + /// limits at once: 7,001 distinct resources exceeds both the default `whole_key_limit` + /// (7,000) and `resource_limit` (1,024), so a regression back to `None` (which would opt + /// into `CardinalityLimitConfig::default()`) fails this test twice over. #[tokio::test] async fn test_no_cardinality_limit_applied() { - use libdd_trace_stats::span_concentrator::DEFAULT_MAX_ENTRIES_PER_BUCKET; + use libdd_trace_stats::span_concentrator::CardinalityLimitConfig; const OVERFLOW_KEY: &str = "tracer_blocked_value"; - let span_count = DEFAULT_MAX_ENTRIES_PER_BUCKET + 1; + let span_count = CardinalityLimitConfig::default().whole_key_limit + 1; let config = Arc::new(Config::default()); let (service, handle) = StatsConcentratorService::new(config); diff --git a/bottlecap/src/traces/trace_agent.rs b/bottlecap/src/traces/trace_agent.rs index 37b71d29f..b34547821 100644 --- a/bottlecap/src/traces/trace_agent.rs +++ b/bottlecap/src/traces/trace_agent.rs @@ -592,7 +592,10 @@ impl TraceAgent { if span.resource == INVOCATION_SPAN_RESOURCE && let Err(e) = invocation_processor_handle - .add_tracer_span(span.clone(), tracer_header_tags.client_computed_stats) + .add_tracer_span( + span.clone(), + tracer_header_tags.generic.client_computed_stats, + ) .await { error!("Failed to add tracer span to processor: {}", e); @@ -807,7 +810,10 @@ mod tests { ); } let tags: TracerHeaderTags<'_> = (&headers).into(); - (tags.client_computed_stats, tags.client_computed_top_level) + ( + tags.generic.client_computed_stats, + tags.generic.client_computed_top_level, + ) } fn parse_stats(value: Option<&str>) -> bool { diff --git a/bottlecap/src/traces/trace_aggregator.rs b/bottlecap/src/traces/trace_aggregator.rs index d24b8349a..96031d0ed 100644 --- a/bottlecap/src/traces/trace_aggregator.rs +++ b/bottlecap/src/traces/trace_aggregator.rs @@ -1,5 +1,5 @@ use libdd_trace_utils::send_data::SendDataBuilder; -use libdd_trace_utils::trace_utils::TracerHeaderTags; +use libdd_trace_utils::trace_utils::{TracerGenericTags, TracerHeaderTags}; use std::collections::VecDeque; use tracing::debug; @@ -16,10 +16,7 @@ pub struct OwnedTracerHeaderTags { pub lang_vendor: String, pub tracer_version: String, pub container_id: String, - pub client_computed_top_level: bool, - pub client_computed_stats: bool, - pub dropped_p0_traces: usize, - pub dropped_p0_spans: usize, + pub generic: TracerGenericTags, } impl From> for OwnedTracerHeaderTags { @@ -31,10 +28,7 @@ impl From> for OwnedTracerHeaderTags { lang_vendor: tags.lang_vendor.to_string(), tracer_version: tags.tracer_version.to_string(), container_id: tags.container_id.to_string(), - client_computed_top_level: tags.client_computed_top_level, - client_computed_stats: tags.client_computed_stats, - dropped_p0_traces: tags.dropped_p0_traces, - dropped_p0_spans: tags.dropped_p0_spans, + generic: tags.generic, } } } @@ -49,10 +43,7 @@ impl OwnedTracerHeaderTags { lang_vendor: &self.lang_vendor, tracer_version: &self.tracer_version, container_id: &self.container_id, - client_computed_top_level: self.client_computed_top_level, - client_computed_stats: self.client_computed_stats, - dropped_p0_traces: self.dropped_p0_traces, - dropped_p0_spans: self.dropped_p0_spans, + generic: self.generic, } } } @@ -153,7 +144,8 @@ impl TraceAggregator { mod tests { use libdd_common::Endpoint; use libdd_trace_utils::{ - trace_utils::TracerHeaderTags, tracer_payload::TracerPayloadCollection, + trace_utils::{TracerGenericTags, TracerHeaderTags}, + tracer_payload::TracerPayloadCollection, }; use super::*; @@ -166,10 +158,11 @@ mod tests { lang_vendor: "lang_vendor", tracer_version: "tracer_version", container_id: "container_id", - client_computed_top_level: true, - client_computed_stats: true, - dropped_p0_traces: 0, - dropped_p0_spans: 0, + generic: TracerGenericTags { + client_computed_top_level: true, + client_computed_stats: true, + ..Default::default() + }, } } diff --git a/bottlecap/src/traces/trace_aggregator_service.rs b/bottlecap/src/traces/trace_aggregator_service.rs index 6c23faed4..a9a085248 100644 --- a/bottlecap/src/traces/trace_aggregator_service.rs +++ b/bottlecap/src/traces/trace_aggregator_service.rs @@ -107,7 +107,8 @@ mod tests { use crate::traces::trace_aggregator::OwnedTracerHeaderTags; use libdd_common::Endpoint; use libdd_trace_utils::{ - send_data::SendDataBuilder, trace_utils::TracerHeaderTags, + send_data::SendDataBuilder, + trace_utils::{TracerGenericTags, TracerHeaderTags}, tracer_payload::TracerPayloadCollection, }; @@ -126,10 +127,11 @@ mod tests { lang_vendor: "lang_vendor", tracer_version: "tracer_version", container_id: "container_id", - client_computed_top_level: true, - client_computed_stats: true, - dropped_p0_traces: 0, - dropped_p0_spans: 0, + generic: TracerGenericTags { + client_computed_top_level: true, + client_computed_stats: true, + ..Default::default() + }, }; let size = 1; let owned_tags = OwnedTracerHeaderTags::from(tracer_header_tags.clone()); diff --git a/bottlecap/src/traces/trace_processor.rs b/bottlecap/src/traces/trace_processor.rs index cac352a6d..cf0e716bb 100644 --- a/bottlecap/src/traces/trace_processor.rs +++ b/bottlecap/src/traces/trace_processor.rs @@ -413,7 +413,7 @@ impl TraceProcessor for ServerlessTraceProcessor { tags_provider: tags_provider.clone(), span_pointers, policy_evaluator: None, - client_computed_stats: header_tags.client_computed_stats, + client_computed_stats: header_tags.generic.client_computed_stats, }, true, // send agentless since we are the agent ) @@ -544,31 +544,32 @@ impl SendingTraceProcessor { }; let (finalized, ctx) = appsec.process_span(span); - if finalized { + 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), + } else if let Some(ctx) = ctx { + debug!( + "TRACE_PROCESSOR | Holding trace for App & API Protection additional data" + ); + // Same sender, minus the App & API Protection processor, so the held + // trace is not re-held when it is flushed. + let sender = SendingTraceProcessor { + appsec: None, + ..self.clone() + }; + let args = 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, - }); + span_pointers: span_pointers.clone(), + header_tags: OwnedTracerHeaderTags::from(header_tags.clone()), + }; + ctx.hold_trace(trace, sender, args); None } else { Some(trace) } - }).collect() + }) + .collect() } else { traces }; @@ -579,7 +580,7 @@ impl SendingTraceProcessor { } // Capture before `header_tags` is moved into process_traces below. - let client_computed_stats = header_tags.client_computed_stats; + let client_computed_stats = header_tags.generic.client_computed_stats; let (payload, processed_traces) = self.processor.process_traces( config.clone(), @@ -727,10 +728,7 @@ mod tests { lang_vendor: "vendor", tracer_version: "4.0.0", container_id: "33", - client_computed_top_level: false, - client_computed_stats: false, - dropped_p0_traces: 0, - dropped_p0_spans: 0, + generic: tracer_header_tags::TracerGenericTags::default(), }; let trace_processor = ServerlessTraceProcessor { @@ -1227,10 +1225,7 @@ mod tests { lang_vendor: "", tracer_version: "1.0", container_id: "", - client_computed_top_level: false, - client_computed_stats: false, - dropped_p0_traces: 0, - dropped_p0_spans: 0, + generic: tracer_header_tags::TracerGenericTags::default(), }; let make_span = |trace_id: u64, priority: Option| -> pb::Span { @@ -1323,10 +1318,7 @@ mod tests { lang_vendor: "", tracer_version: "1.0", container_id: "", - client_computed_top_level: false, - client_computed_stats: false, - dropped_p0_traces: 0, - dropped_p0_spans: 0, + generic: tracer_header_tags::TracerGenericTags::default(), }; let make_dropped_span = |trace_id: u64| -> pb::Span { @@ -1404,10 +1396,7 @@ mod tests { lang_vendor: "", tracer_version: "1.0", container_id: "", - client_computed_top_level: false, - client_computed_stats: false, - dropped_p0_traces: 0, - dropped_p0_spans: 0, + generic: tracer_header_tags::TracerGenericTags::default(), }; let make_span = |trace_id: u64, priority: f64| -> pb::Span { @@ -1512,10 +1501,7 @@ mod tests { lang_vendor: "", tracer_version: "1.0", container_id: "", - client_computed_top_level: false, - client_computed_stats: false, - dropped_p0_traces: 0, - dropped_p0_spans: 0, + generic: tracer_header_tags::TracerGenericTags::default(), }; let span = pb::Span { @@ -1930,10 +1916,10 @@ mod tests { lang_vendor: "vendor", tracer_version: "4.0.0", container_id: "33", - client_computed_top_level: false, - client_computed_stats, - dropped_p0_traces: 0, - dropped_p0_spans: 0, + generic: tracer_header_tags::TracerGenericTags { + client_computed_stats, + ..Default::default() + }, }; sender diff --git a/bottlecap/tests/apm_integration_test.rs b/bottlecap/tests/apm_integration_test.rs index cfb89f244..3fd187ff5 100644 --- a/bottlecap/tests/apm_integration_test.rs +++ b/bottlecap/tests/apm_integration_test.rs @@ -36,7 +36,7 @@ use libdd_common::Endpoint; use libdd_trace_obfuscation::obfuscation_config::ObfuscationConfig; use libdd_trace_protobuf::pb; use libdd_trace_utils::send_data::SendDataBuilder; -use libdd_trace_utils::trace_utils::TracerHeaderTags; +use libdd_trace_utils::trace_utils::{TracerGenericTags, TracerHeaderTags}; use libdd_trace_utils::tracer_payload::TracerPayloadCollection; use tokio::sync::Mutex; @@ -55,10 +55,11 @@ fn header_tags() -> TracerHeaderTags<'static> { lang_vendor: "datadog", tracer_version: "test", container_id: "", - client_computed_top_level: true, - client_computed_stats: true, - dropped_p0_traces: 0, - dropped_p0_spans: 0, + generic: TracerGenericTags { + client_computed_top_level: true, + client_computed_stats: true, + ..Default::default() + }, } } @@ -289,7 +290,10 @@ async fn trace_payload_roundtrip_through_fake_intake() { fn header_tags_with(client_computed_stats: bool) -> TracerHeaderTags<'static> { TracerHeaderTags { - client_computed_stats, + generic: TracerGenericTags { + client_computed_stats, + ..header_tags().generic + }, ..header_tags() } }