diff --git a/.gitlab/datasources/test-suites.yaml b/.gitlab/datasources/test-suites.yaml index 56ab4026f..5adf681a3 100644 --- a/.gitlab/datasources/test-suites.yaml +++ b/.gitlab/datasources/test-suites.yaml @@ -7,3 +7,4 @@ test_suites: - name: oom - name: lmi-oom - name: payload-size + - name: dsm diff --git a/bottlecap/Cargo.lock b/bottlecap/Cargo.lock index 3eacd5c28..8a3b2f3a0 100644 --- a/bottlecap/Cargo.lock +++ b/bottlecap/Cargo.lock @@ -536,6 +536,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 b3ef5c58d..c9a9160ba 100644 --- a/bottlecap/Cargo.toml +++ b/bottlecap/Cargo.toml @@ -28,6 +28,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 } @@ -95,9 +99,6 @@ tower = { version = "0.5", features = ["util"] } mock_instant = "0.6" serial_test = "3.1" tempfile = "3.20" -# fake-intake test harness: decode msgpack+gzip stats payloads on arrival -rmp-serde = { version = "1.3.1", default-features = false } -flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] } [build-dependencies] # No external dependencies needed for the build script diff --git a/bottlecap/src/bin/bottlecap/main.rs b/bottlecap/src/bin/bottlecap/main.rs index 39f352149..ba24a221d 100644 --- a/bottlecap/src/bin/bottlecap/main.rs +++ b/bottlecap/src/bin/bottlecap/main.rs @@ -330,6 +330,54 @@ 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 dsm_service = config + .service + .clone() + .or_else(|| tags_provider.get_canonical_resource_name()) + .unwrap_or_else(|| "aws.lambda".to_string()) + .to_lowercase(); + 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={}, site={}, tags={:?}", + config.ext.dsm_consume_enabled, + dsm_service, + dsm_env, + dsm_version, + config.site, + dsm_tags + ); + + Some(Arc::new( + bottlecap::traces::data_streams::DsmProcessor::new( + dsm_service, + dsm_env, + EXTENSION_VERSION.to_string(), + dsm_version, + dsm_tags, + &config.site, + Arc::clone(&proxy_aggregator), + ), + )) + } else { + None + }; + // Lifecycle Invocation Processor let (invocation_processor_handle, invocation_processor_service) = InvocationProcessorService::new( @@ -339,6 +387,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; @@ -372,6 +421,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( @@ -430,6 +480,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 @@ -460,6 +511,7 @@ async fn extension_loop_active( proxy_flusher_clone, metrics_flushers_clone, metrics_aggr_handle_clone, + dsm_processor_clone, ); loop { @@ -634,6 +686,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 { @@ -1104,6 +1157,7 @@ fn start_trace_agent( invocation_processor_handle: InvocationProcessorHandle, appsec_processor: Option>>, client: &Client, + proxy_aggregator: Arc>, ) -> ( Sender, Arc, @@ -1168,7 +1222,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), diff --git a/bottlecap/src/config/mod.rs b/bottlecap/src/config/mod.rs index e9ce7f331..c625c7011 100644 --- a/bottlecap/src/config/mod.rs +++ b/bottlecap/src/config/mod.rs @@ -81,6 +81,23 @@ pub struct LambdaConfig { /// without durable execution context enrichment. Defaults to 0 until the tracer-side /// durable execution support is released; set to 50 to re-enable enrichment. pub lambda_durable_function_log_buffer_size: usize, + + // 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 { @@ -106,6 +123,9 @@ impl Default for LambdaConfig { api_security_sample_delay: Duration::from_secs(30), custom_metrics_exclude_tags: Vec::new(), lambda_durable_function_log_buffer_size: 0, + dsm_consume_enabled: false, + dsm_exchange_name: None, + dsm_kafka_group: None, } } } @@ -182,6 +202,18 @@ pub struct LambdaConfigSource { /// 0 (hold mechanism disabled). #[serde(deserialize_with = "deser_opt_lossless")] pub lambda_durable_function_log_buffer_size: 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 { @@ -207,7 +239,16 @@ impl DatadogConfigExtension for LambdaConfig { api_security_sample_delay, lambda_durable_function_log_buffer_size, ], - option: [span_dedup_timeout, api_key_secret_reload_interval, appsec_rules], + option: [span_dedup_timeout, api_key_secret_reload_interval, appsec_rules, 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 ); // OR-merge serverless_logs_enabled with the logs_enabled alias. Either @@ -503,6 +544,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/processor.rs b/bottlecap/src/lifecycle/invocation/processor.rs index 7d6943bdf..516db574a 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}, }; @@ -111,6 +111,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, } impl Processor { @@ -154,9 +164,20 @@ impl Processor { durable_context_tx, restore_time: None, init_duration_metric_emitted: false, + dsm_processor: None, + dsm_processed_request_ids: HashSet::new(), } } + /// 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) { @@ -699,6 +720,24 @@ impl Processor { .await; } + 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(&mut self, context: Context) -> (Vec, usize) { let mut body_size = std::mem::size_of_val(&context.invocation_span); let mut traces = vec![context.invocation_span.clone()]; @@ -862,18 +901,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. @@ -1025,7 +1053,15 @@ impl Processor { "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, @@ -1111,6 +1147,64 @@ impl Processor { if let Some(inferred_span) = &self.inferrer.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) { @@ -1534,6 +1628,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 { @@ -1548,7 +1677,181 @@ 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 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(), + "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") + } + + 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()), @@ -1556,7 +1859,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 { @@ -2825,6 +3128,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/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 eb81944eb..f86323ff6 100644 --- a/bottlecap/src/lifecycle/invocation/triggers/sqs_event.rs +++ b/bottlecap/src/lifecycle/invocation/triggers/sqs_event.rs @@ -2,7 +2,8 @@ use crate::config::aws::get_aws_partition_by_region; use crate::lifecycle::invocation::{ 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}, }, @@ -202,6 +203,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 { @@ -397,6 +420,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"); diff --git a/bottlecap/src/proxy/interceptor.rs b/bottlecap/src/proxy/interceptor.rs index 21a018377..0eb2b673b 100644 --- a/bottlecap/src/proxy/interceptor.rs +++ b/bottlecap/src/proxy/interceptor.rs @@ -112,6 +112,31 @@ 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| { + error!("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; +} + /// Given an optional String representing the LWA proxy lambda runtime API, /// return a `SocketAddr` that can be used to bind the proxy server. /// @@ -196,7 +221,20 @@ async fn invocation_next_proxy( } } - if aws_config.aws_lwa_proxy_lambda_runtime_api.is_some() { + // 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")); + let dsm_consume_enabled = std::env::var("DD_DATA_STREAMS_ENABLED") + .is_ok_and(|v| v.eq_ignore_ascii_case("true")); + if aws_config.aws_lwa_proxy_lambda_runtime_api.is_some() || experimental_proxy_enabled { + debug!( + "PROXY | invocation_next_proxy | driving universal instrumentation from intercepted payload" + ); lwa::process_invocation_next( &invocation_processor, &intercepted_parts_clone, @@ -204,6 +242,16 @@ async fn invocation_next_proxy( Arc::clone(&propagator), ) .await; + } else if dsm_consume_enabled { + debug!( + "PROXY | invocation_next_proxy | recording DSM consume from intercepted payload" + ); + record_dsm_consume_from_invocation_next( + &invocation_processor, + &intercepted_parts_clone, + &body, + ) + .await; } } }); @@ -449,6 +497,7 @@ mod tests { }; #[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"; @@ -508,6 +557,7 @@ mod tests { metrics_aggregator, Arc::clone(&propagator), durable_context_tx, + None, ); tokio::spawn(async move { invocation_processor_service.run().await; 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..f94a9a5d1 --- /dev/null +++ b/bottlecap/src/traces/data_streams/processor.rs @@ -0,0 +1,276 @@ +//! 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; + +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, + site: &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, + target_url: format!("https://trace.agent.{site}/api/v0.1/pipeline_stats"), + } + } + + /// 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())); + let dsm = DsmProcessor::new( + "svc".into(), + "env".into(), + "1.0".into(), + "2.0".into(), + vec!["team:serverless".into()], + "datadoghq.com", + 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!(batch[0].target_url.ends_with("/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()], + "datadoghq.com", + 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..f8d810eb5 --- /dev/null +++ b/bottlecap/src/traces/data_streams/sketch.rs @@ -0,0 +1,357 @@ +//! 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` (see `tools/dsm/gen_sketch_golden.js`). +//! +//! 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; + +/// 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(); + // KeyMapping.minPossible = MIN_NORMAL * gamma (MIN_NORMAL = f64::MIN_POSITIVE). + let min_possible = f64::MIN_POSITIVE * 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}`"); + } + } +} 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/integration-tests/bin/app.ts b/integration-tests/bin/app.ts index 2bc9cf07b..185a732db 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 {AuthRoleStack} from '../lib/auth-role'; import {ACCOUNT, getIdentifier, REGION} from '../config'; import {CapacityProviderStack} from "../lib/capacity-provider"; @@ -56,6 +57,9 @@ const stacks = [ new PayloadSize(app, `integ-${identifier}-payload-size`, { env, }), + new Dsm(app, `integ-${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..10ec5d0f0 --- /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 = `integ-${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 3e69dab78..f1c469afb 100644 --- a/integration-tests/tests/utils/datadog.ts +++ b/integration-tests/tests/utils/datadog.ts @@ -388,3 +388,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; +}