From 57c03d8f792ddff12a0ab91ab1cc977071a808fe Mon Sep 17 00:00:00 2001 From: Daniel Schwartz-Narbone Date: Wed, 28 Jan 2026 16:28:58 -0500 Subject: [PATCH 1/4] fix(profiling): make sure we use the mime type properly --- libdd-profiling-ffi/src/exporter.rs | 62 ++++- .../src/exporter/profile_exporter.rs | 12 +- libdd-profiling/tests/common.rs | 131 +++++++++ libdd-profiling/tests/exporter_e2e.rs | 20 +- libdd-profiling/tests/file_exporter_test.rs | 263 ++++-------------- 5 files changed, 251 insertions(+), 237 deletions(-) create mode 100644 libdd-profiling/tests/common.rs diff --git a/libdd-profiling-ffi/src/exporter.rs b/libdd-profiling-ffi/src/exporter.rs index 2848558441..9dfd40632e 100644 --- a/libdd-profiling-ffi/src/exporter.rs +++ b/libdd-profiling-ffi/src/exporter.rs @@ -11,13 +11,41 @@ use libdd_common_ffi::{ wrap_with_ffi_result, wrap_with_void_ffi_result, Handle, Result, ToInner, VoidResult, }; use libdd_profiling::exporter; -use libdd_profiling::exporter::{ExporterManager, MimeType, ProfileExporter}; +use libdd_profiling::exporter::{ExporterManager, ProfileExporter}; use libdd_profiling::internal::EncodedProfile; use std::borrow::Cow; use std::str::FromStr; type TokioCancellationToken = tokio_util::sync::CancellationToken; +/// MIME type for file attachments +/// Invalid (0) is the default and will cause an error if used +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub enum MimeType { + Invalid = 0, + ApplicationJson, + ApplicationOctetStream, + TextCsv, + TextPlain, + TextXml, +} + +impl TryFrom for exporter::MimeType { + type Error = anyhow::Error; + + fn try_from(mime: MimeType) -> std::result::Result { + match mime { + MimeType::Invalid => anyhow::bail!("Invalid MIME type"), + MimeType::ApplicationJson => Ok(exporter::MimeType::ApplicationJson), + MimeType::ApplicationOctetStream => Ok(exporter::MimeType::ApplicationOctetStream), + MimeType::TextCsv => Ok(exporter::MimeType::TextCsv), + MimeType::TextPlain => Ok(exporter::MimeType::TextPlain), + MimeType::TextXml => Ok(exporter::MimeType::TextXml), + } + } +} + #[allow(dead_code)] #[repr(C)] pub enum ProfilingEndpoint<'a> { @@ -180,15 +208,15 @@ pub unsafe extern "C" fn ddog_prof_Exporter_drop(mut exporter: *mut Handle(slice: Slice<'a, File>) -> Vec> { +unsafe fn into_vec_files<'a>(slice: Slice<'a, File>) -> anyhow::Result>> { slice .into_slice() .iter() .map(|file| { let name = file.name.try_to_utf8().unwrap_or("{invalid utf-8}"); let bytes = file.file.as_slice(); - let mime = file.mime; - exporter::File { name, bytes, mime } + let mime = file.mime.try_into()?; + Ok(exporter::File { name, bytes, mime }) }) .collect() } @@ -244,7 +272,7 @@ pub unsafe extern "C" fn ddog_prof_Exporter_send_blocking( wrap_with_ffi_result!({ let exporter = exporter.to_inner_mut()?; let profile = *profile.take()?; - let files_to_compress_and_export = into_vec_files(files_to_compress_and_export); + let files_to_compress_and_export = into_vec_files(files_to_compress_and_export)?; let tags: Vec = optional_additional_tags .map(|tags| tags.iter().cloned().collect()) .unwrap_or_default(); @@ -398,7 +426,7 @@ pub unsafe extern "C" fn ddog_prof_ExporterManager_queue( wrap_with_void_ffi_result!({ let manager = manager.to_inner_mut()?; let profile = *profile.take()?; - let files_to_compress_and_export = into_vec_files(files_to_compress_and_export); + let files_to_compress_and_export = into_vec_files(files_to_compress_and_export)?; let tags: Vec = optional_additional_tags .map(|tags| tags.iter().cloned().collect()) .unwrap_or_default(); @@ -728,4 +756,26 @@ mod tests { } } } + + #[test] + fn test_invalid_mime_type_returns_error() { + // Test that Invalid MIME type (default value 0) returns an error + let data = b"test data"; + let file = File { + name: CharSlice::from("test.bin"), + file: ByteSlice::from(&data[..]), + mime: MimeType::Invalid, + }; + + let files_slice = unsafe { Slice::from_raw_parts(&file as *const File, 1) }; + let result = unsafe { into_vec_files(files_slice) }; + + assert!(result.is_err(), "Invalid MIME type should return an error"); + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("Invalid MIME type"), + "Error message should mention invalid MIME type, got: {}", + error_msg + ); + } } diff --git a/libdd-profiling/src/exporter/profile_exporter.rs b/libdd-profiling/src/exporter/profile_exporter.rs index efc9a54907..9ee5b7f6cd 100644 --- a/libdd-profiling/src/exporter/profile_exporter.rs +++ b/libdd-profiling/src/exporter/profile_exporter.rs @@ -48,7 +48,6 @@ pub struct ProfileExporter { runtime: Option, } -#[repr(C)] #[derive(Debug, Copy, Clone)] pub enum MimeType { ApplicationJson, @@ -413,7 +412,7 @@ impl ProfileExporter { "event", reqwest::multipart::Part::bytes(event_bytes) .file_name("event.json") - .mime_str("application/json")?, + .mime_str(mime::APPLICATION_JSON.as_ref())?, ); // Add additional files (compressed) @@ -428,14 +427,17 @@ impl ProfileExporter { form = form.part( file.name.to_string(), - reqwest::multipart::Part::bytes(encoder.finish()?).file_name(file.name.to_string()), + reqwest::multipart::Part::bytes(encoder.finish()?) + .file_name(file.name.to_string()) + .mime_str(file.mime.as_str())?, ); } - // Add profile Ok(form.part( "profile.pprof", - reqwest::multipart::Part::bytes(profile.buffer).file_name("profile.pprof"), + reqwest::multipart::Part::bytes(profile.buffer) + .file_name("profile.pprof") + .mime_str(mime::APPLICATION_OCTET_STREAM.as_ref())?, )) } } diff --git a/libdd-profiling/tests/common.rs b/libdd-profiling/tests/common.rs new file mode 100644 index 0000000000..ac4de57c20 --- /dev/null +++ b/libdd-profiling/tests/common.rs @@ -0,0 +1,131 @@ +// Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Common test utilities shared across exporter tests + +#![allow(dead_code)] // Test utilities are used across test modules + +use libdd_profiling::exporter::utils::{parse_http_request, HttpRequest, MultipartPart}; +use libdd_profiling::exporter::{File, MimeType, ProfileExporter}; +use std::path::PathBuf; + +/// Test constants +pub const TEST_LIB_NAME: &str = "dd-trace-foo"; +pub const TEST_LIB_VERSION: &str = "1.2.3"; +pub const FILE_WRITE_DELAY_MS: u64 = 200; + +/// RAII guard to ensure test files are cleaned up even if the test panics +pub struct TempFileGuard(PathBuf); + +impl Drop for TempFileGuard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +impl std::ops::Deref for TempFileGuard { + type Target = PathBuf; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef for TempFileGuard { + fn as_ref(&self) -> &std::path::Path { + self.0.as_ref() + } +} + +/// Create a file-based exporter and return the temp file path with auto-cleanup +pub fn create_file_exporter( + profiling_library_name: &str, + profiling_library_version: &str, + family: &str, + tags: Vec, + api_key: Option<&str>, +) -> anyhow::Result<(ProfileExporter, TempFileGuard)> { + use libdd_profiling::exporter::config; + + // Create a unique temp file path + let file_path = std::env::temp_dir().join(format!( + "libdd_test_{}_{}_{:x}.http", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0), + rand::random::() + )); + + let mut endpoint = config::file(file_path.to_string_lossy().as_ref())?; + if let Some(key) = api_key { + endpoint.api_key = Some(key.to_string().into()); + } + + let exporter = ProfileExporter::new( + profiling_library_name, + profiling_library_version, + family, + tags, + endpoint, + )?; + + Ok((exporter, TempFileGuard(file_path))) +} + +/// Read and parse the dumped HTTP request file +pub fn read_and_parse_request(file_path: &std::path::Path) -> anyhow::Result { + // Wait for file to be written + std::thread::sleep(std::time::Duration::from_millis(FILE_WRITE_DELAY_MS)); + let request_bytes = std::fs::read(file_path)?; + parse_http_request(&request_bytes) +} + +/// Extract and parse the event.json part from multipart request +pub fn extract_event_json(request: &HttpRequest) -> anyhow::Result { + let event_part = request + .multipart_parts + .iter() + .find(|p| p.filename.as_deref() == Some("event.json")) + .ok_or_else(|| anyhow::anyhow!("event.json part not found"))?; + + Ok(serde_json::from_slice(&event_part.content)?) +} + +/// Create standard test additional files with different MIME types +pub fn create_test_additional_files() -> Vec> { + vec![ + File { + name: "jit.pprof", + bytes: b"fake-jit-data", + mime: MimeType::ApplicationOctetStream, + }, + File { + name: "metadata.json", + bytes: b"{\"test\": true}", + mime: MimeType::ApplicationJson, + }, + ] +} + +/// Assert that a multipart part has the expected MIME type +pub fn assert_mime_type(parts: &[MultipartPart], part_name: &str, expected_mime: &str) { + let part = parts + .iter() + .find(|p| p.name == part_name) + .unwrap_or_else(|| panic!("{} part should exist", part_name)); + assert_eq!( + part.content_type.as_deref(), + Some(expected_mime), + "{} should have {} content type", + part_name, + expected_mime + ); +} + +/// Assert all standard MIME types for a complete export +/// (event, profile.pprof, jit.pprof, metadata.json) +pub fn assert_all_standard_mime_types(parts: &[MultipartPart]) { + assert_mime_type(parts, "event", "application/json"); + assert_mime_type(parts, "profile.pprof", "application/octet-stream"); + assert_mime_type(parts, "jit.pprof", "application/octet-stream"); + assert_mime_type(parts, "metadata.json", "application/json"); +} diff --git a/libdd-profiling/tests/exporter_e2e.rs b/libdd-profiling/tests/exporter_e2e.rs index 3bca79c48e..5e46e75aa8 100644 --- a/libdd-profiling/tests/exporter_e2e.rs +++ b/libdd-profiling/tests/exporter_e2e.rs @@ -5,9 +5,11 @@ //! //! These tests validate the full export flow across different endpoint types. +mod common; + use libdd_profiling::exporter::config; use libdd_profiling::exporter::utils::parse_http_request; -use libdd_profiling::exporter::{File, MimeType, ProfileExporter}; +use libdd_profiling::exporter::ProfileExporter; use libdd_profiling::internal::EncodedProfile; use std::collections::HashMap; use std::path::PathBuf; @@ -222,18 +224,7 @@ async fn export_full_profile( ]; // Build additional files - let additional_files = vec![ - File { - name: "jit.pprof", - bytes: b"fake-jit-data", - mime: MimeType::ApplicationOctetStream, - }, - File { - name: "metadata.json", - bytes: b"{\"test\": true}", - mime: MimeType::ApplicationJson, - }, - ]; + let additional_files = common::create_test_additional_files(); // Build metadata let internal_metadata = serde_json::json!({ @@ -384,6 +375,9 @@ fn validate_full_export(req: &ReceivedRequest, expected_path: &str) -> anyhow::R ); } + // Verify all parts have correct MIME types + common::assert_all_standard_mime_types(parts); + Ok(()) } diff --git a/libdd-profiling/tests/file_exporter_test.rs b/libdd-profiling/tests/file_exporter_test.rs index e6912ecda5..469f6391ff 100644 --- a/libdd-profiling/tests/file_exporter_test.rs +++ b/libdd-profiling/tests/file_exporter_test.rs @@ -1,73 +1,16 @@ // Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -use libdd_profiling::exporter::utils::parse_http_request; -use libdd_profiling::exporter::ProfileExporter; -use libdd_profiling::internal::EncodedProfile; -use std::path::PathBuf; - -/// RAII guard to ensure test files are cleaned up even if the test panics -struct TempFileGuard(PathBuf); - -impl Drop for TempFileGuard { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - } -} - -impl std::ops::Deref for TempFileGuard { - type Target = PathBuf; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} +mod common; -impl AsRef for TempFileGuard { - fn as_ref(&self) -> &std::path::Path { - self.0.as_ref() - } -} - -/// Create a file-based exporter and return the temp file path with auto-cleanup -fn create_file_exporter( - profiling_library_name: &str, - profiling_library_version: &str, - family: &str, - tags: Vec, - api_key: Option<&str>, -) -> anyhow::Result<(ProfileExporter, TempFileGuard)> { - use libdd_profiling::exporter::config; - - // Create a unique temp file path - let file_path = std::env::temp_dir().join(format!( - "libdd_test_{}_{}_{:x}.http", - std::process::id(), - chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0), - rand::random::() - )); - - let mut endpoint = config::file(file_path.to_string_lossy().as_ref())?; - if let Some(key) = api_key { - endpoint.api_key = Some(key.to_string().into()); - } - - let exporter = ProfileExporter::new( - profiling_library_name, - profiling_library_version, - family, - tags, - endpoint, - )?; - - Ok((exporter, TempFileGuard(file_path))) -} +use libdd_profiling::internal::EncodedProfile; +use serde_json::json; #[cfg(test)] mod tests { use super::*; + use common::*; use libdd_common::tag; - use serde_json::json; fn default_tags() -> Vec { vec![tag!("service", "php"), tag!("host", "bits")] @@ -76,60 +19,36 @@ mod tests { #[test] #[cfg_attr(miri, ignore)] fn multipart_agent() { - let profiling_library_name = "dd-trace-foo"; - let profiling_library_version = "1.2.3"; - - let (mut exporter, file_path) = create_file_exporter( - profiling_library_name, - profiling_library_version, - "php", - default_tags(), - None, - ) - .expect("exporter to construct"); + let (mut exporter, file_path) = + create_file_exporter(TEST_LIB_NAME, TEST_LIB_VERSION, "php", default_tags(), None) + .expect("exporter to construct"); - // Build and send profile + let additional_files = create_test_additional_files(); let profile = EncodedProfile::test_instance().expect("test profile"); + exporter - .send_blocking(profile, &[], &[], None, None, None, None) + .send_blocking(profile, &additional_files, &[], None, None, None, None) .expect("send to succeed"); - // Read the dump file (wait a moment for it to be written) - // The file is synced before the 200 response, but we still need a small delay - // to ensure the background thread's runtime has fully completed the async operation - std::thread::sleep(std::time::Duration::from_millis(200)); - let request_bytes = std::fs::read(&file_path).expect("read dump file"); + // Parse request and validate + let request = read_and_parse_request(&file_path).expect("parse HTTP request"); + let event_json = extract_event_json(&request).expect("extract event JSON"); - // Parse HTTP request - let request = parse_http_request(&request_bytes).expect("parse HTTP request"); - - // Validate request line + // Validate request line and headers assert_eq!(request.method, "POST"); - assert_eq!(request.path, "/"); // File exporter uses root path - - // Validate headers + assert_eq!(request.path, "/"); assert!(!request.headers.contains_key("dd-api-key")); - assert_eq!( - request.headers.get("dd-evp-origin").unwrap(), - profiling_library_name - ); + assert_eq!(request.headers.get("dd-evp-origin").unwrap(), TEST_LIB_NAME); assert_eq!( request.headers.get("dd-evp-origin-version").unwrap(), - profiling_library_version + TEST_LIB_VERSION ); - // Get parsed multipart body and find event.json part - let event_part = request - .multipart_parts - .iter() - .find(|p| p.filename.as_deref() == Some("event.json")) - .expect("event.json part"); - - let event_json: serde_json::Value = - serde_json::from_slice(&event_part.content).expect("parse event.json"); - // Validate event.json content - assert_eq!(event_json["attachments"], json!(["profile.pprof"])); + assert_eq!( + event_json["attachments"], + json!(["jit.pprof", "metadata.json", "profile.pprof"]) + ); assert_eq!(event_json["endpoint_counts"], json!(null)); assert_eq!(event_json["family"], json!("php")); assert_eq!( @@ -137,16 +56,13 @@ mod tests { json!(env!("CARGO_PKG_VERSION")) ); - let tags_profiler = event_json["tags_profiler"] - .as_str() - .unwrap() - .split(',') - .collect::>(); - assert!(tags_profiler.contains(&"service:php")); - assert!(tags_profiler.contains(&"host:bits")); + // Validate tags + let tags_profiler = event_json["tags_profiler"].as_str().unwrap(); + assert!(tags_profiler.contains("service:php")); + assert!(tags_profiler.contains("host:bits")); let runtime_platform = tags_profiler - .iter() + .split(',') .find(|tag| tag.starts_with("runtime_platform:")) .expect("runtime_platform tag should exist"); assert!( @@ -158,7 +74,7 @@ mod tests { assert_eq!(event_json["version"], json!("4")); - // Verify profile.pprof part exists + // Verify profile.pprof part exists with content let profile_part = request .multipart_parts .iter() @@ -168,22 +84,17 @@ mod tests { !profile_part.content.is_empty(), "profile should have content" ); + + // Verify all MIME types + assert_all_standard_mime_types(&request.multipart_parts); } #[test] #[cfg_attr(miri, ignore)] fn including_internal_metadata() { - let profiling_library_name = "dd-trace-foo"; - let profiling_library_version = "1.2.3"; - - let (mut exporter, file_path) = create_file_exporter( - profiling_library_name, - profiling_library_version, - "php", - default_tags(), - None, - ) - .expect("exporter to construct"); + let (mut exporter, file_path) = + create_file_exporter(TEST_LIB_NAME, TEST_LIB_VERSION, "php", default_tags(), None) + .expect("exporter to construct"); let internal_metadata = json!({ "no_signals_workaround_enabled": "true", @@ -192,7 +103,6 @@ mod tests { "libdatadog_version": env!("CARGO_PKG_VERSION"), }); - // Build and send profile let profile = EncodedProfile::test_instance().expect("test profile"); exporter .send_blocking( @@ -206,22 +116,8 @@ mod tests { ) .expect("send to succeed"); - // Read the dump file (wait a moment for it to be written) - // The file is synced before the 200 response, but we still need a small delay - // to ensure the background thread's runtime has fully completed the async operation - std::thread::sleep(std::time::Duration::from_millis(200)); - let request_bytes = std::fs::read(&file_path).expect("read dump file"); - - // Parse and validate - let request = parse_http_request(&request_bytes).expect("parse HTTP request"); - let event_part = request - .multipart_parts - .iter() - .find(|p| p.filename.as_deref() == Some("event.json")) - .expect("event.json part"); - - let event_json: serde_json::Value = - serde_json::from_slice(&event_part.content).expect("parse event.json"); + let request = read_and_parse_request(&file_path).expect("parse HTTP request"); + let event_json = extract_event_json(&request).expect("extract event JSON"); assert_eq!(event_json["internal"], internal_metadata); } @@ -229,21 +125,12 @@ mod tests { #[test] #[cfg_attr(miri, ignore)] fn including_process_tags() { - let profiling_library_name = "dd-trace-foo"; - let profiling_library_version = "1.2.3"; - - let (mut exporter, file_path) = create_file_exporter( - profiling_library_name, - profiling_library_version, - "php", - default_tags(), - None, - ) - .expect("exporter to construct"); + let (mut exporter, file_path) = + create_file_exporter(TEST_LIB_NAME, TEST_LIB_VERSION, "php", default_tags(), None) + .expect("exporter to construct"); let expected_process_tags = "entrypoint.basedir:net10.0,entrypoint.name:buggybits.program,entrypoint.workdir:this_folder,runtime_platform:x86_64-pc-windows-msvc"; - // Build and send profile let profile = EncodedProfile::test_instance().expect("test profile"); exporter .send_blocking( @@ -257,22 +144,8 @@ mod tests { ) .expect("send to succeed"); - // Read the dump file (wait a moment for it to be written) - // The file is synced before the 200 response, but we still need a small delay - // to ensure the background thread's runtime has fully completed the async operation - std::thread::sleep(std::time::Duration::from_millis(200)); - let request_bytes = std::fs::read(&file_path).expect("read dump file"); - - // Parse and validate - let request = parse_http_request(&request_bytes).expect("parse HTTP request"); - let event_part = request - .multipart_parts - .iter() - .find(|p| p.filename.as_deref() == Some("event.json")) - .expect("event.json part"); - - let event_json: serde_json::Value = - serde_json::from_slice(&event_part.content).expect("parse event.json"); + let request = read_and_parse_request(&file_path).expect("parse HTTP request"); + let event_json = extract_event_json(&request).expect("extract event JSON"); assert_eq!(event_json["process_tags"], expected_process_tags); } @@ -280,17 +153,9 @@ mod tests { #[test] #[cfg_attr(miri, ignore)] fn including_info() { - let profiling_library_name = "dd-trace-foo"; - let profiling_library_version = "1.2.3"; - - let (mut exporter, file_path) = create_file_exporter( - profiling_library_name, - profiling_library_version, - "php", - default_tags(), - None, - ) - .expect("exporter to construct"); + let (mut exporter, file_path) = + create_file_exporter(TEST_LIB_NAME, TEST_LIB_VERSION, "php", default_tags(), None) + .expect("exporter to construct"); let info = json!({ "application": { @@ -309,28 +174,13 @@ mod tests { } }); - // Build and send profile let profile = EncodedProfile::test_instance().expect("test profile"); exporter .send_blocking(profile, &[], &[], None, Some(info.clone()), None, None) .expect("send to succeed"); - // Read the dump file (wait a moment for it to be written) - // The file is synced before the 200 response, but we still need a small delay - // to ensure the background thread's runtime has fully completed the async operation - std::thread::sleep(std::time::Duration::from_millis(200)); - let request_bytes = std::fs::read(&file_path).expect("read dump file"); - - // Parse and validate - let request = parse_http_request(&request_bytes).expect("parse HTTP request"); - let event_part = request - .multipart_parts - .iter() - .find(|p| p.filename.as_deref() == Some("event.json")) - .expect("event.json part"); - - let event_json: serde_json::Value = - serde_json::from_slice(&event_part.content).expect("parse event.json"); + let request = read_and_parse_request(&file_path).expect("parse HTTP request"); + let event_json = extract_event_json(&request).expect("extract event JSON"); assert_eq!(event_json["info"], info); } @@ -338,43 +188,30 @@ mod tests { #[test] #[cfg_attr(miri, ignore)] fn multipart_agentless() { - let profiling_library_name = "dd-trace-foo"; - let profiling_library_version = "1.2.3"; let api_key = "1234567890123456789012"; let (mut exporter, file_path) = create_file_exporter( - profiling_library_name, - profiling_library_version, + TEST_LIB_NAME, + TEST_LIB_VERSION, "php", default_tags(), Some(api_key), ) .expect("exporter to construct"); - // Build and send profile let profile = EncodedProfile::test_instance().expect("test profile"); exporter .send_blocking(profile, &[], &[], None, None, None, None) .expect("send to succeed"); - // Read the dump file (wait a moment for it to be written) - // The file is synced before the 200 response, but we still need a small delay - // to ensure the background thread's runtime has fully completed the async operation - std::thread::sleep(std::time::Duration::from_millis(200)); - let request_bytes = std::fs::read(&file_path).expect("read dump file"); - - // Parse HTTP request - let request = parse_http_request(&request_bytes).expect("parse HTTP request"); + let request = read_and_parse_request(&file_path).expect("parse HTTP request"); // Validate headers - API key should be present assert_eq!(request.headers.get("dd-api-key").unwrap(), api_key); - assert_eq!( - request.headers.get("dd-evp-origin").unwrap(), - profiling_library_name - ); + assert_eq!(request.headers.get("dd-evp-origin").unwrap(), TEST_LIB_NAME); assert_eq!( request.headers.get("dd-evp-origin-version").unwrap(), - profiling_library_version + TEST_LIB_VERSION ); } } From ba0d27e60334c0d2ab82b31169f8fd994a59af1c Mon Sep 17 00:00:00 2001 From: Daniel Schwartz-Narbone Date: Wed, 28 Jan 2026 17:32:24 -0500 Subject: [PATCH 2/4] PR comments --- libdd-profiling-ffi/src/exporter.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libdd-profiling-ffi/src/exporter.rs b/libdd-profiling-ffi/src/exporter.rs index 9dfd40632e..780c32cdd6 100644 --- a/libdd-profiling-ffi/src/exporter.rs +++ b/libdd-profiling-ffi/src/exporter.rs @@ -774,8 +774,7 @@ mod tests { let error_msg = result.unwrap_err().to_string(); assert!( error_msg.contains("Invalid MIME type"), - "Error message should mention invalid MIME type, got: {}", - error_msg + "Error message should mention invalid MIME type, got: {error_msg}" ); } } From d6842110c50f61cfbe0bbc96fe6a1b02408860d6 Mon Sep 17 00:00:00 2001 From: Daniel Schwartz-Narbone Date: Wed, 28 Jan 2026 17:34:19 -0500 Subject: [PATCH 3/4] support missing headers --- .../src/exporter/profile_exporter.rs | 18 ++++++- libdd-profiling/tests/exporter_e2e.rs | 26 ++++++++++ libdd-profiling/tests/file_exporter_test.rs | 50 +++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/libdd-profiling/src/exporter/profile_exporter.rs b/libdd-profiling/src/exporter/profile_exporter.rs index 9ee5b7f6cd..ac8a4cdfd6 100644 --- a/libdd-profiling/src/exporter/profile_exporter.rs +++ b/libdd-profiling/src/exporter/profile_exporter.rs @@ -28,7 +28,7 @@ use super::errors::SendError; use super::file_exporter::spawn_dump_server; use anyhow::Context; use libdd_common::tag::Tag; -use libdd_common::{azure_app_services, tag, Endpoint}; +use libdd_common::{azure_app_services, entity_id, header, tag, Endpoint}; use reqwest::RequestBuilder; use serde_json::json; use std::io::Write; @@ -184,6 +184,22 @@ impl ProfileExporter { ); } + // Add container ID header if available + if let Some(container_id) = entity_id::get_container_id() { + headers.insert( + header::DATADOG_CONTAINER_ID, + reqwest::header::HeaderValue::from_static(container_id), + ); + } + + // Add entity ID header if available + if let Some(entity_id_value) = entity_id::get_entity_id() { + headers.insert( + header::DATADOG_ENTITY_ID, + reqwest::header::HeaderValue::from_static(entity_id_value), + ); + } + // Add Azure App Services tags if available if let Some(aas) = &*azure_app_services::AAS_METADATA { let aas_tags = [ diff --git a/libdd-profiling/tests/exporter_e2e.rs b/libdd-profiling/tests/exporter_e2e.rs index 5e46e75aa8..81e94624c5 100644 --- a/libdd-profiling/tests/exporter_e2e.rs +++ b/libdd-profiling/tests/exporter_e2e.rs @@ -294,6 +294,32 @@ fn validate_full_export(req: &ReceivedRequest, expected_path: &str) -> anyhow::R assert_eq!(req.method, "POST"); assert_eq!(req.path, expected_path); + // Verify container/entity ID headers if available on the system + // These should be consistently present or absent + let has_container_id = req.headers.contains_key("datadog-container-id"); + let has_entity_id = req.headers.contains_key("datadog-entity-id"); + assert_eq!( + has_container_id, has_entity_id, + "Container ID and Entity ID headers should both be present or both absent" + ); + + // If present, verify they match system values + if let Some(container_id) = libdd_common::entity_id::get_container_id() { + assert_eq!( + req.headers.get("datadog-container-id").unwrap(), + container_id, + "Container ID header should match system container ID" + ); + } + + if let Some(entity_id) = libdd_common::entity_id::get_entity_id() { + assert_eq!( + req.headers.get("datadog-entity-id").unwrap(), + entity_id, + "Entity ID header should match system entity ID" + ); + } + // Parse the request to get multipart parts // We need to reconstruct a minimal HTTP request to parse let mut http_request_bytes = Vec::new(); diff --git a/libdd-profiling/tests/file_exporter_test.rs b/libdd-profiling/tests/file_exporter_test.rs index 469f6391ff..014a17cd79 100644 --- a/libdd-profiling/tests/file_exporter_test.rs +++ b/libdd-profiling/tests/file_exporter_test.rs @@ -214,4 +214,54 @@ mod tests { TEST_LIB_VERSION ); } + + #[test] + #[cfg_attr(miri, ignore)] + fn entity_id_headers() { + let (mut exporter, file_path) = create_file_exporter( + TEST_LIB_NAME, + TEST_LIB_VERSION, + "native", + default_tags(), + None, + ) + .expect("exporter to construct"); + + let profile = EncodedProfile::test_instance().expect("test profile"); + exporter + .send_blocking(profile, &[], &[], None, None, None, None) + .expect("send to succeed"); + + let request = read_and_parse_request(&file_path).expect("parse HTTP request"); + + // Check if container-id and entity-id headers are present (if available on system) + // On systems with containers (Docker, K8s, etc.), these should be set + // On systems without containers, they may be None + if let Some(container_id) = libdd_common::entity_id::get_container_id() { + assert_eq!( + request.headers.get("datadog-container-id").unwrap(), + container_id, + "Container ID header should match system container ID" + ); + } + + if let Some(entity_id) = libdd_common::entity_id::get_entity_id() { + assert_eq!( + request.headers.get("datadog-entity-id").unwrap(), + entity_id, + "Entity ID header should match system entity ID" + ); + } + + // If neither is present, that's also valid (non-containerized environment) + // But at least verify the headers are consistently present or absent + let has_container_id = request.headers.contains_key("datadog-container-id"); + let has_entity_id = request.headers.contains_key("datadog-entity-id"); + + // Both should be present together or both absent + assert_eq!( + has_container_id, has_entity_id, + "Container ID and Entity ID headers should both be present or both absent" + ); + } } From b92b05a33c92186ac4be944f31ea81031d194695 Mon Sep 17 00:00:00 2001 From: Daniel Schwartz-Narbone Date: Thu, 29 Jan 2026 10:23:53 -0500 Subject: [PATCH 4/4] use content-encoding header for compressed parts --- .../src/exporter/profile_exporter.rs | 19 ++++++++++++++++--- libdd-profiling/src/exporter/utils.rs | 2 +- libdd-profiling/tests/common.rs | 19 +++++++++++++++++++ libdd-profiling/tests/exporter_e2e.rs | 4 ++++ libdd-profiling/tests/file_exporter_test.rs | 4 ++++ 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/libdd-profiling/src/exporter/profile_exporter.rs b/libdd-profiling/src/exporter/profile_exporter.rs index ac8a4cdfd6..3daba3ce25 100644 --- a/libdd-profiling/src/exporter/profile_exporter.rs +++ b/libdd-profiling/src/exporter/profile_exporter.rs @@ -38,6 +38,16 @@ use tokio_util::sync::CancellationToken; use crate::internal::{EncodedProfile, Profile}; use crate::profiles::{Compressor, DefaultProfileCodec}; +/// Helper to create Content-Encoding: zstd headers for compressed multipart parts +fn create_zstd_headers() -> reqwest::header::HeaderMap { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::CONTENT_ENCODING, + reqwest::header::HeaderValue::from_static("zstd"), + ); + headers +} + #[derive(Debug)] pub struct ProfileExporter { client: reqwest::Client, @@ -431,7 +441,7 @@ impl ProfileExporter { .mime_str(mime::APPLICATION_JSON.as_ref())?, ); - // Add additional files (compressed) + // Add additional files (compressed with zstd) for file in additional_files { let mut encoder = Compressor::::try_new( (file.bytes.len() >> 3).next_power_of_two(), @@ -445,15 +455,18 @@ impl ProfileExporter { file.name.to_string(), reqwest::multipart::Part::bytes(encoder.finish()?) .file_name(file.name.to_string()) - .mime_str(file.mime.as_str())?, + .mime_str(file.mime.as_str())? + .headers(create_zstd_headers()), ); } + // Add profile (already compressed with zstd) Ok(form.part( "profile.pprof", reqwest::multipart::Part::bytes(profile.buffer) .file_name("profile.pprof") - .mime_str(mime::APPLICATION_OCTET_STREAM.as_ref())?, + .mime_str(mime::APPLICATION_OCTET_STREAM.as_ref())? + .headers(create_zstd_headers()), )) } } diff --git a/libdd-profiling/src/exporter/utils.rs b/libdd-profiling/src/exporter/utils.rs index 82e0969232..db07bc4f59 100644 --- a/libdd-profiling/src/exporter/utils.rs +++ b/libdd-profiling/src/exporter/utils.rs @@ -101,7 +101,7 @@ fn parse_multipart(content_type: &str, body: &[u8]) -> anyhow::Result anyhow::R // Verify all parts have correct MIME types common::assert_all_standard_mime_types(parts); + // Verify compressed parts have Content-Encoding: zstd headers (profile.pprof, jit.pprof, + // metadata.json) + common::assert_compressed_parts_have_encoding(&parsed_req, 3); + Ok(()) } diff --git a/libdd-profiling/tests/file_exporter_test.rs b/libdd-profiling/tests/file_exporter_test.rs index 014a17cd79..31233bca70 100644 --- a/libdd-profiling/tests/file_exporter_test.rs +++ b/libdd-profiling/tests/file_exporter_test.rs @@ -87,6 +87,10 @@ mod tests { // Verify all MIME types assert_all_standard_mime_types(&request.multipart_parts); + + // Verify compressed parts have Content-Encoding headers (profile.pprof, jit.pprof, + // metadata.json) + assert_compressed_parts_have_encoding(&request, 3); } #[test]