diff --git a/.gitignore b/.gitignore index 5a4edd14ce..fd2be638be 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ __fuzz__ #==============================================================================# .DS_Store .env +.envrc +.direnv Cargo.lock # this is ignored so you don't update by accident, but is committed. docker-sync.yml libtest.so diff --git a/libdd-data-pipeline-ffi/Cargo.toml b/libdd-data-pipeline-ffi/Cargo.toml index cb61cbea65..ed3d27c9fd 100644 --- a/libdd-data-pipeline-ffi/Cargo.toml +++ b/libdd-data-pipeline-ffi/Cargo.toml @@ -32,4 +32,5 @@ libdd-trace-utils = { path = "../libdd-trace-utils" } libdd-data-pipeline = { path = "../libdd-data-pipeline" } libdd-common-ffi = { path = "../libdd-common-ffi", default-features = false } libdd-tinybytes = { path = "../libdd-tinybytes" } +libdd-trace-utils = { path = "../libdd-trace-utils", default-features = false } tracing = { version = "0.1", default-features = false } diff --git a/libdd-data-pipeline-ffi/cbindgen.toml b/libdd-data-pipeline-ffi/cbindgen.toml index 34ace82fbb..888f1e8a7e 100644 --- a/libdd-data-pipeline-ffi/cbindgen.toml +++ b/libdd-data-pipeline-ffi/cbindgen.toml @@ -23,6 +23,8 @@ renaming_overrides_prefixing = true "ExporterResponse" = "ddog_TraceExporterResponse" "ExporterErrorCode" = "ddog_TraceExporterErrorCode" "ExporterError" = "ddog_TraceExporterError" +"TracerSpan" = "ddog_TracerSpan" +"TracerTraceChunks" = "ddog_TracerTraceChunks" [export.mangle] rename_types = "PascalCase" @@ -36,4 +38,4 @@ must_use = "DDOG_CHECK_RETURN" [parse] parse_deps = true -include = ["libdd-common", "libdd-common-ffi", "libdd-data-pipeline"] +include = ["libdd-common", "libdd-common-ffi", "libdd-data-pipeline", "libdd-trace-utils"] diff --git a/libdd-data-pipeline-ffi/src/lib.rs b/libdd-data-pipeline-ffi/src/lib.rs index d85002ab40..b48c3ba3be 100644 --- a/libdd-data-pipeline-ffi/src/lib.rs +++ b/libdd-data-pipeline-ffi/src/lib.rs @@ -9,3 +9,4 @@ mod error; mod response; mod trace_exporter; +mod tracer; diff --git a/libdd-data-pipeline-ffi/src/tracer.rs b/libdd-data-pipeline-ffi/src/tracer.rs new file mode 100644 index 0000000000..7a1808cb78 --- /dev/null +++ b/libdd-data-pipeline-ffi/src/tracer.rs @@ -0,0 +1,902 @@ +// Copyright 2024-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! FFI functions for creating and manipulating individual tracer spans. +//! +//! Provides an opaque [`TracerSpan`] handle wrapping a `Span`, +//! allowing callers to construct spans field-by-field from C. + +use crate::error::{ExporterError, ExporterErrorCode as ErrorCode}; +use crate::response::ExporterResponse; +use libdd_common_ffi::slice::AsBytes; +use libdd_common_ffi::CharSlice; +use libdd_data_pipeline::trace_exporter::TraceExporter; +use libdd_tinybytes::BytesString; +use libdd_trace_utils::span::v04::SpanBytes; +use std::ptr::NonNull; + +#[cfg(all(feature = "catch_panic", panic = "unwind"))] +use std::panic::{catch_unwind, AssertUnwindSafe}; + +#[cfg(all(feature = "catch_panic", panic = "unwind"))] +use tracing::error; + +// --------------------------------------------------------------------------- +// Macros (local copies, matching trace_exporter.rs pattern) +// --------------------------------------------------------------------------- + +macro_rules! gen_error { + ($l:expr) => { + Some(Box::new(ExporterError::new($l, &$l.to_string()))) + }; +} + +#[cfg(all(feature = "catch_panic", panic = "unwind"))] +macro_rules! catch_panic { + ($f:expr, $err:expr) => { + match catch_unwind(AssertUnwindSafe(|| $f)) { + Ok(ret) => ret, + Err(info) => { + if let Some(s) = info.downcast_ref::() { + error!(error = %ErrorCode::Panic, s); + } else if let Some(s) = info.downcast_ref::<&str>() { + error!(error = %ErrorCode::Panic, s); + } else { + error!(error = %ErrorCode::Panic, "Unable to retrieve panic context"); + } + $err + } + } + }; +} + +#[cfg(any(not(feature = "catch_panic"), panic = "abort"))] +macro_rules! catch_panic { + ($f:expr, $err:expr) => { + $f + }; +} + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +/// Convert a [`CharSlice`] to a [`BytesString`], copying the bytes. +/// +/// Returns an error if the slice is not valid UTF-8. +#[inline] +fn charslice_to_bytesstring(s: CharSlice) -> Result> { + match BytesString::from_slice(s.as_bytes()) { + Ok(bs) => Ok(bs), + Err(_) => Err(Box::new(ExporterError::new( + ErrorCode::InvalidInput, + &ErrorCode::InvalidInput.to_string(), + ))), + } +} + +// --------------------------------------------------------------------------- +// TracerSpan +// --------------------------------------------------------------------------- + +/// Opaque handle wrapping a single `Span`. +pub struct TracerSpan(pub(crate) SpanBytes); + +/// Create a new span with all scalar fields set. +/// +/// String fields are copied from the provided slices. The `meta` and +/// `metrics` maps start empty; use [`ddog_tracer_span_set_meta`] and +/// [`ddog_tracer_span_set_metric`] to populate them. +/// +/// # Arguments +/// +/// * `out_handle` – Receives the new `TracerSpan` handle on success. +/// * `service`, `name`, `resource`, `span_type` – UTF-8 string fields. +/// * `trace_id_low`, `trace_id_high` – 128-bit trace ID split into two +/// 64-bit halves (low = bits 0‥63, high = bits 64‥127). +/// * `span_id` – Span identifier. +/// * `parent_id` – Parent span identifier (0 for root spans). +/// * `start` – Start time in nanoseconds since Unix epoch. +/// * `duration` – Duration in nanoseconds. +/// * `error` – Error status (0 = no error). +/// +/// # Safety +/// +/// `out_handle` must point to valid, writable memory for a `Box`. +/// All `CharSlice` arguments must point to valid memory for their stated +/// length. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_new( + out_handle: NonNull>, + service: CharSlice, + name: CharSlice, + resource: CharSlice, + span_type: CharSlice, + trace_id_low: u64, + trace_id_high: u64, + span_id: u64, + parent_id: u64, + start: i64, + duration: i64, + error: i32, +) -> Option> { + catch_panic!( + { + let service = match charslice_to_bytesstring(service) { + Ok(s) => s, + Err(e) => return Some(e), + }; + let name = match charslice_to_bytesstring(name) { + Ok(s) => s, + Err(e) => return Some(e), + }; + let resource = match charslice_to_bytesstring(resource) { + Ok(s) => s, + Err(e) => return Some(e), + }; + let span_type = match charslice_to_bytesstring(span_type) { + Ok(s) => s, + Err(e) => return Some(e), + }; + + let trace_id: u128 = ((trace_id_high as u128) << 64) | (trace_id_low as u128); + + let span = SpanBytes { + service, + name, + resource, + r#type: span_type, + trace_id, + span_id, + parent_id, + start, + duration, + error, + ..Default::default() + }; + + out_handle.as_ptr().write(Box::new(TracerSpan(span))); + None + }, + gen_error!(ErrorCode::Panic) + ) +} + +/// Free a `TracerSpan` and all its contents. +/// +/// After this call the handle is invalid and must not be reused. +/// +/// # Safety +/// +/// `handle` must have been created by [`ddog_tracer_span_new`] and must not +/// be used after this call. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_free(handle: Box) { + drop(handle); +} + +/// Add or overwrite a string tag (`meta`) on the span. +/// +/// Both `key` and `value` are copied into the span. +/// +/// # Safety +/// +/// `handle` must be a valid pointer to a `TracerSpan`. +/// `key` and `value` must point to valid UTF-8 memory. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_set_meta( + handle: Option<&mut TracerSpan>, + key: CharSlice, + value: CharSlice, +) -> Option> { + catch_panic!( + if let Some(span) = handle { + let key = match charslice_to_bytesstring(key) { + Ok(s) => s, + Err(e) => return Some(e), + }; + let value = match charslice_to_bytesstring(value) { + Ok(s) => s, + Err(e) => return Some(e), + }; + span.0.meta.insert(key, value); + None + } else { + gen_error!(ErrorCode::InvalidArgument) + }, + gen_error!(ErrorCode::Panic) + ) +} + +/// Add or overwrite a numeric tag (`metric`) on the span. +/// +/// The `key` is copied into the span. +/// +/// # Safety +/// +/// `handle` must be a valid pointer to a `TracerSpan`. +/// `key` must point to valid UTF-8 memory. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_set_metric( + handle: Option<&mut TracerSpan>, + key: CharSlice, + value: f64, +) -> Option> { + catch_panic!( + if let Some(span) = handle { + let key = match charslice_to_bytesstring(key) { + Ok(s) => s, + Err(e) => return Some(e), + }; + span.0.metrics.insert(key, value); + None + } else { + gen_error!(ErrorCode::InvalidArgument) + }, + gen_error!(ErrorCode::Panic) + ) +} + +// --------------------------------------------------------------------------- +// Span getters (for reading field values back to C / Ruby) +// --------------------------------------------------------------------------- + +/// Return the span's `name` as a borrowed `CharSlice`. +/// +/// The returned slice borrows from the span and is valid as long as the +/// span handle is not freed. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_get_name<'a>( + handle: Option<&'a TracerSpan>, +) -> CharSlice<'a> { + match handle { + Some(s) => CharSlice::from_bytes(s.0.name.as_ref().as_bytes()), + None => CharSlice::from_bytes(b""), + } +} + +/// Return the span's `service` field. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_get_service<'a>( + handle: Option<&'a TracerSpan>, +) -> CharSlice<'a> { + match handle { + Some(s) => CharSlice::from_bytes(s.0.service.as_ref().as_bytes()), + None => CharSlice::from_bytes(b""), + } +} + +/// Return the span's `resource` field. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_get_resource<'a>( + handle: Option<&'a TracerSpan>, +) -> CharSlice<'a> { + match handle { + Some(s) => CharSlice::from_bytes(s.0.resource.as_ref().as_bytes()), + None => CharSlice::from_bytes(b""), + } +} + +/// Return the span's `type` field. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_get_type<'a>( + handle: Option<&'a TracerSpan>, +) -> CharSlice<'a> { + match handle { + Some(s) => CharSlice::from_bytes(s.0.r#type.as_ref().as_bytes()), + None => CharSlice::from_bytes(b""), + } +} + +/// Return the span's `span_id`. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_get_span_id( + handle: Option<&TracerSpan>, +) -> u64 { + handle.map_or(0, |s| s.0.span_id) +} + +/// Return the span's `parent_id`. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_get_parent_id( + handle: Option<&TracerSpan>, +) -> u64 { + handle.map_or(0, |s| s.0.parent_id) +} + +/// Return the span's `start` time in nanoseconds since epoch. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_get_start( + handle: Option<&TracerSpan>, +) -> i64 { + handle.map_or(0, |s| s.0.start) +} + +/// Return the span's `duration` in nanoseconds. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_get_duration( + handle: Option<&TracerSpan>, +) -> i64 { + handle.map_or(0, |s| s.0.duration) +} + +/// Return the span's `error` status. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_get_error( + handle: Option<&TracerSpan>, +) -> i32 { + handle.map_or(0, |s| s.0.error) +} + +/// Return the span's 128-bit trace ID split into low and high 64-bit halves. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_get_trace_id( + handle: Option<&TracerSpan>, + out_low: &mut u64, + out_high: &mut u64, +) { + match handle { + Some(s) => { + *out_low = s.0.trace_id as u64; + *out_high = (s.0.trace_id >> 64) as u64; + } + None => { + *out_low = 0; + *out_high = 0; + } + } +} + +/// Look up a `meta` tag by key. Returns `true` if found, writing the +/// value's pointer and length into the out parameters. +/// +/// The output pointer borrows from the span and is valid as long as the +/// span handle is not freed. +/// +/// # Safety +/// +/// `out_ptr` and `out_len` must be valid writable pointers. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_get_meta( + handle: Option<&TracerSpan>, + key: CharSlice, + out_ptr: *mut *const u8, + out_len: *mut usize, +) -> bool { + let span = match handle { + Some(s) => s, + None => return false, + }; + if out_ptr.is_null() || out_len.is_null() { + return false; + } + let key_str = match key.try_to_utf8() { + Ok(s) => s, + Err(_) => return false, + }; + match span.0.meta.get(key_str) { + Some(v) => { + let bytes = v.as_ref().as_bytes(); + *out_ptr = bytes.as_ptr(); + *out_len = bytes.len(); + true + } + None => false, + } +} + +/// Look up a `metric` by key. Returns `true` if found, writing the value +/// into `out_value`. +/// +/// # Safety +/// +/// `out_value` must be a valid writable pointer. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_span_get_metric( + handle: Option<&TracerSpan>, + key: CharSlice, + out_value: *mut f64, +) -> bool { + let span = match handle { + Some(s) => s, + None => return false, + }; + if out_value.is_null() { + return false; + } + let key_str = match key.try_to_utf8() { + Ok(s) => s, + Err(_) => return false, + }; + match span.0.metrics.get(key_str) { + Some(&v) => { + *out_value = v; + true + } + None => false, + } +} + +// --------------------------------------------------------------------------- +// TracerTraceChunks +// --------------------------------------------------------------------------- + +/// Opaque handle wrapping `Vec>>` — a list of trace +/// chunks, each containing a list of spans. +pub struct TracerTraceChunks(pub(crate) Vec>); + +/// Create a new empty trace chunks container. +/// +/// # Safety +/// +/// `out_handle` must point to valid writable memory. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_trace_chunks_new( + out_handle: NonNull>, +) { + catch_panic!( + out_handle + .as_ptr() + .write(Box::new(TracerTraceChunks(Vec::new()))), + () + ) +} + +/// Free a trace chunks container and all its contents. +/// +/// # Safety +/// +/// `handle` must have been created by [`ddog_tracer_trace_chunks_new`]. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_trace_chunks_free(handle: Box) { + drop(handle); +} + +/// Start a new chunk (trace) inside the container. Subsequent +/// [`ddog_tracer_trace_chunks_push_span`] calls will append to this chunk. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_trace_chunks_begin_chunk( + handle: Option<&mut TracerTraceChunks>, +) { + if let Some(chunks) = handle { + chunks.0.push(Vec::new()); + } +} + +/// Move a span into the current (last) chunk, consuming the span handle. +/// +/// A chunk must have been started with [`ddog_tracer_trace_chunks_begin_chunk`] +/// before calling this function. +/// +/// # Safety +/// +/// `span` is consumed and must not be used after this call. +#[no_mangle] +pub unsafe extern "C" fn ddog_tracer_trace_chunks_push_span( + handle: Option<&mut TracerTraceChunks>, + span: Box, +) -> Option> { + catch_panic!( + if let Some(chunks) = handle { + if let Some(chunk) = chunks.0.last_mut() { + chunk.push(span.0); + None + } else { + gen_error!(ErrorCode::InvalidArgument) + } + } else { + gen_error!(ErrorCode::InvalidArgument) + }, + gen_error!(ErrorCode::Panic) + ) +} + +// --------------------------------------------------------------------------- +// Send trace chunks +// --------------------------------------------------------------------------- + +/// Send trace chunks through a [`TraceExporter`], consuming the chunks. +/// +/// This calls `TraceExporter::send_trace_chunks` which processes stats, +/// serializes in the configured output format, and sends to the agent +/// with retry logic. +/// +/// # Safety +/// +/// * `exporter` must be a valid `TraceExporter` pointer. +/// * `chunks` is consumed and must not be used after this call. +/// * If `response_out` is non-null it receives a pointer to the response. +#[no_mangle] +pub unsafe extern "C" fn ddog_trace_exporter_send_trace_chunks( + exporter: Option<&TraceExporter>, + chunks: Box, + response_out: Option>>, +) -> Option> { + let exporter = match exporter { + Some(e) => e, + None => return gen_error!(ErrorCode::InvalidArgument), + }; + + catch_panic!( + match exporter.send_trace_chunks(chunks.0) { + Ok(resp) => { + if let Some(out) = response_out { + out.as_ptr().write(Box::new(ExporterResponse::from(resp))); + } + None + } + Err(e) => Some(Box::new(ExporterError::from(e))), + }, + gen_error!(ErrorCode::Panic) + ) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::ddog_trace_exporter_error_free; + use libdd_common_ffi::slice::AsBytes; + use std::mem::MaybeUninit; + + fn cs(s: &str) -> CharSlice<'_> { + CharSlice::from_bytes(s.as_bytes()) + } + + unsafe fn make_minimal_span() -> Box { + let mut handle = MaybeUninit::>::uninit(); + let out = NonNull::new(handle.as_mut_ptr()).unwrap(); + let err = ddog_tracer_span_new( + out, + cs("svc"), + cs("op"), + cs("res"), + cs(""), + 1, 0, 1, 0, 0, 0, 0, + ); + assert!(err.is_none()); + handle.assume_init() + } + + #[test] + fn new_sets_all_scalar_fields() { + unsafe { + let mut handle = MaybeUninit::>::uninit(); + let out = NonNull::new(handle.as_mut_ptr()).unwrap(); + + let err = ddog_tracer_span_new( + out, + cs("my-service"), + cs("web.request"), + cs("GET /users"), + cs("web"), + 0xdeadbeef, // trace_id_low + 0x00000001, // trace_id_high + 12345, // span_id + 67890, // parent_id + 1_700_000_000_000_000_000i64, // start (ns) + 25_000_000, // duration (25 ms) + 0, // error + ); + assert!(err.is_none()); + + let span = handle.assume_init(); + assert_eq!(span.0.service.as_ref(), "my-service"); + assert_eq!(span.0.name.as_ref(), "web.request"); + assert_eq!(span.0.resource.as_ref(), "GET /users"); + assert_eq!(span.0.r#type.as_ref(), "web"); + assert_eq!(span.0.trace_id, (1u128 << 64) | 0xdeadbeef); + assert_eq!(span.0.span_id, 12345); + assert_eq!(span.0.parent_id, 67890); + assert_eq!(span.0.start, 1_700_000_000_000_000_000); + assert_eq!(span.0.duration, 25_000_000); + assert_eq!(span.0.error, 0); + assert!(span.0.meta.is_empty()); + assert!(span.0.metrics.is_empty()); + assert!(span.0.span_links.is_empty()); + assert!(span.0.span_events.is_empty()); + + ddog_tracer_span_free(span); + } + } + + #[test] + fn set_meta_inserts_entries() { + unsafe { + let mut span = make_minimal_span(); + + let err = ddog_tracer_span_set_meta(Some(&mut *span), cs("http.method"), cs("GET")); + assert!(err.is_none()); + + let err = ddog_tracer_span_set_meta(Some(&mut *span), cs("http.url"), cs("/users")); + assert!(err.is_none()); + + assert_eq!(span.0.meta.len(), 2); + assert_eq!(span.0.meta.get("http.method").unwrap().as_ref(), "GET"); + assert_eq!(span.0.meta.get("http.url").unwrap().as_ref(), "/users"); + + ddog_tracer_span_free(span); + } + } + + #[test] + fn set_meta_overwrites_existing_key() { + unsafe { + let mut span = make_minimal_span(); + + ddog_tracer_span_set_meta(Some(&mut *span), cs("k"), cs("v1")); + ddog_tracer_span_set_meta(Some(&mut *span), cs("k"), cs("v2")); + + assert_eq!(span.0.meta.len(), 1); + assert_eq!(span.0.meta.get("k").unwrap().as_ref(), "v2"); + + ddog_tracer_span_free(span); + } + } + + #[test] + fn set_metric_inserts_entries() { + unsafe { + let mut span = make_minimal_span(); + + let err = ddog_tracer_span_set_metric(Some(&mut *span), cs("_dd.measured"), 1.0); + assert!(err.is_none()); + + let err = ddog_tracer_span_set_metric( + Some(&mut *span), + cs("_sampling_priority_v1"), + 2.0, + ); + assert!(err.is_none()); + + assert_eq!(span.0.metrics.len(), 2); + assert_eq!(*span.0.metrics.get("_dd.measured").unwrap(), 1.0); + assert_eq!(*span.0.metrics.get("_sampling_priority_v1").unwrap(), 2.0); + + ddog_tracer_span_free(span); + } + } + + #[test] + fn set_meta_null_handle_returns_error() { + unsafe { + let err = ddog_tracer_span_set_meta(None, cs("k"), cs("v")); + assert!(err.is_some()); + ddog_trace_exporter_error_free(err); + } + } + + #[test] + fn set_metric_null_handle_returns_error() { + unsafe { + let err = ddog_tracer_span_set_metric(None, cs("k"), 1.0); + assert!(err.is_some()); + ddog_trace_exporter_error_free(err); + } + } + + #[test] + fn new_with_empty_strings_succeeds() { + unsafe { + let mut handle = MaybeUninit::>::uninit(); + let out = NonNull::new(handle.as_mut_ptr()).unwrap(); + + let err = ddog_tracer_span_new( + out, + cs(""), cs(""), cs(""), cs(""), + 0, 0, 0, 0, 0, 0, 0, + ); + assert!(err.is_none()); + + let span = handle.assume_init(); + assert_eq!(span.0.name.as_ref(), ""); + assert_eq!(span.0.service.as_ref(), ""); + + ddog_tracer_span_free(span); + } + } + + #[test] + fn trace_id_128bit_roundtrip() { + unsafe { + let mut handle = MaybeUninit::>::uninit(); + let out = NonNull::new(handle.as_mut_ptr()).unwrap(); + + let low: u64 = 0x00000001deadbeef; + let high: u64 = 0x0000000167890abc; + + let err = ddog_tracer_span_new( + out, + cs("s"), cs("n"), cs("r"), cs(""), + low, high, 1, 0, 0, 0, 0, + ); + assert!(err.is_none()); + + let span = handle.assume_init(); + let expected: u128 = ((high as u128) << 64) | (low as u128); + assert_eq!(span.0.trace_id, expected); + + // Verify we can extract the halves back out + assert_eq!(span.0.trace_id as u64, low); + assert_eq!((span.0.trace_id >> 64) as u64, high); + + ddog_tracer_span_free(span); + } + } + + // -- Span getter tests -------------------------------------------------- + + #[test] + fn getter_name() { + unsafe { + let span = make_minimal_span(); + let name = ddog_tracer_span_get_name(Some(&*span)); + assert_eq!(name.try_to_utf8().unwrap(), "op"); + ddog_tracer_span_free(span); + } + } + + #[test] + fn getter_service() { + unsafe { + let span = make_minimal_span(); + let svc = ddog_tracer_span_get_service(Some(&*span)); + assert_eq!(svc.try_to_utf8().unwrap(), "svc"); + ddog_tracer_span_free(span); + } + } + + #[test] + fn getter_resource() { + unsafe { + let span = make_minimal_span(); + let res = ddog_tracer_span_get_resource(Some(&*span)); + assert_eq!(res.try_to_utf8().unwrap(), "res"); + ddog_tracer_span_free(span); + } + } + + #[test] + fn getter_numeric_fields() { + unsafe { + let mut handle = MaybeUninit::>::uninit(); + let out = NonNull::new(handle.as_mut_ptr()).unwrap(); + ddog_tracer_span_new( + out, + cs("s"), cs("n"), cs("r"), cs("t"), + 0xBEEF, 0xCAFE, 42, 99, + 1_000_000, 2_000_000, 1, + ); + let span = handle.assume_init(); + + assert_eq!(ddog_tracer_span_get_span_id(Some(&*span)), 42); + assert_eq!(ddog_tracer_span_get_parent_id(Some(&*span)), 99); + assert_eq!(ddog_tracer_span_get_start(Some(&*span)), 1_000_000); + assert_eq!(ddog_tracer_span_get_duration(Some(&*span)), 2_000_000); + assert_eq!(ddog_tracer_span_get_error(Some(&*span)), 1); + + let mut low: u64 = 0; + let mut high: u64 = 0; + ddog_tracer_span_get_trace_id(Some(&*span), &mut low, &mut high); + assert_eq!(low, 0xBEEF); + assert_eq!(high, 0xCAFE); + + ddog_tracer_span_free(span); + } + } + + #[test] + fn getter_meta_found() { + unsafe { + let mut span = make_minimal_span(); + ddog_tracer_span_set_meta(Some(&mut *span), cs("k"), cs("v")); + + let mut ptr: *const u8 = std::ptr::null(); + let mut len: usize = 0; + let found = ddog_tracer_span_get_meta( + Some(&*span), cs("k"), &mut ptr, &mut len, + ); + assert!(found); + let val = std::str::from_utf8(std::slice::from_raw_parts(ptr, len)).unwrap(); + assert_eq!(val, "v"); + + ddog_tracer_span_free(span); + } + } + + #[test] + fn getter_meta_not_found() { + unsafe { + let span = make_minimal_span(); + let mut ptr: *const u8 = std::ptr::null(); + let mut len: usize = 0; + let found = ddog_tracer_span_get_meta( + Some(&*span), cs("missing"), &mut ptr, &mut len, + ); + assert!(!found); + ddog_tracer_span_free(span); + } + } + + #[test] + fn getter_metric_found() { + unsafe { + let mut span = make_minimal_span(); + ddog_tracer_span_set_metric(Some(&mut *span), cs("m"), 3.14); + + let mut val: f64 = 0.0; + let found = ddog_tracer_span_get_metric(Some(&*span), cs("m"), &mut val); + assert!(found); + assert!((val - 3.14).abs() < f64::EPSILON); + + ddog_tracer_span_free(span); + } + } + + #[test] + fn getter_metric_not_found() { + unsafe { + let span = make_minimal_span(); + let mut val: f64 = 0.0; + let found = ddog_tracer_span_get_metric(Some(&*span), cs("nope"), &mut val); + assert!(!found); + ddog_tracer_span_free(span); + } + } + + // -- TracerTraceChunks tests -------------------------------------------- + + #[test] + fn trace_chunks_build_and_push() { + unsafe { + let mut chunks_handle = MaybeUninit::>::uninit(); + let out = NonNull::new(chunks_handle.as_mut_ptr()).unwrap(); + ddog_tracer_trace_chunks_new(out); + let mut chunks = chunks_handle.assume_init(); + + // Chunk 1: two spans + ddog_tracer_trace_chunks_begin_chunk(Some(&mut *chunks)); + + let s1 = make_minimal_span(); + let err = ddog_tracer_trace_chunks_push_span(Some(&mut *chunks), s1); + assert!(err.is_none()); + + let s2 = make_minimal_span(); + let err = ddog_tracer_trace_chunks_push_span(Some(&mut *chunks), s2); + assert!(err.is_none()); + + // Chunk 2: one span + ddog_tracer_trace_chunks_begin_chunk(Some(&mut *chunks)); + let s3 = make_minimal_span(); + let err = ddog_tracer_trace_chunks_push_span(Some(&mut *chunks), s3); + assert!(err.is_none()); + + assert_eq!(chunks.0.len(), 2); + assert_eq!(chunks.0[0].len(), 2); + assert_eq!(chunks.0[1].len(), 1); + + ddog_tracer_trace_chunks_free(chunks); + } + } + + #[test] + fn push_span_without_begin_chunk_returns_error() { + unsafe { + let mut chunks_handle = MaybeUninit::>::uninit(); + let out = NonNull::new(chunks_handle.as_mut_ptr()).unwrap(); + ddog_tracer_trace_chunks_new(out); + let mut chunks = chunks_handle.assume_init(); + + // No begin_chunk — push should fail + let s = make_minimal_span(); + let err = ddog_tracer_trace_chunks_push_span(Some(&mut *chunks), s); + assert!(err.is_some()); + ddog_trace_exporter_error_free(err); + + ddog_tracer_trace_chunks_free(chunks); + } + } +} diff --git a/ruby/.gitignore b/ruby/.gitignore index 334ce71a5b..1b837cfd95 100644 --- a/ruby/.gitignore +++ b/ruby/.gitignore @@ -10,6 +10,9 @@ # rspec failure tracking .rspec_status -gems.locked +/gems.locked -vendor/ +/vendor/ + +/.envrc +/.direnv diff --git a/ruby/Rakefile b/ruby/Rakefile index 1b739ecf92..d48a297012 100644 --- a/ruby/Rakefile +++ b/ruby/Rakefile @@ -229,6 +229,250 @@ module Helpers end end +# --------------------------------------------------------------------------- +# From-source compilation (for local development) +# +# Usage: +# rake compile +# +# This builds the Rust shared library from source and populates the vendor +# tree so that dd-trace-rb can link against it. After running this task, +# point dd-trace-rb at this gem with: +# +# gem 'libdatadog', path: '/ruby' +# +# Prerequisites: +# - A working Rust toolchain (cargo, rustc) +# - pkg-config (for dd-trace-rb's extconf.rb) +# - install_name_tool (macOS only, ships with Xcode CLI tools) +# --------------------------------------------------------------------------- +module CompileFromSource + # Ruby gem platform → Rust target triple + RUST_TARGETS = { + "arm64-darwin" => "aarch64-apple-darwin", + "x86_64-darwin" => "x86_64-apple-darwin", + "x86_64-linux" => "x86_64-unknown-linux-gnu", + "x86_64-linux-musl" => "x86_64-unknown-linux-musl", + "aarch64-linux" => "aarch64-unknown-linux-gnu", + "aarch64-linux-musl" => "aarch64-unknown-linux-musl" + }.freeze + + # Features to enable on the profiling FFI uber-crate. + # This must match what the official builder (`builder/src/bin/release.rs`) + # enables so that the resulting shared library exports all symbols that + # dd-trace-rb's C extensions expect. + CARGO_FEATURES = %w[ + data-pipeline-ffi + cbindgen + ddtelemetry-ffi + crashtracker-ffi + crashtracker-collector + demangler + ddsketch-ffi + datadog-library-config-ffi + datadog-log-ffi + datadog-ffe-ffi + ].freeze + + PROFILING_FFI_CRATE = "libdd-profiling-ffi" + PKGCONFIG_TEMPLATE_DIR = "libdd-profiling-ffi" + + class << self + def project_root + File.expand_path("..", __dir__) + end + + # Replicates the platform detection from Libdatadog.current_platform + # so that `rake compile` works without requiring the full gem to be loaded. + def ruby_platform + platform = Gem::Platform.local.to_s + platform = platform[0..-5] if platform.end_with?("-gnu") + platform = platform.gsub(/-darwin-?\d*$/, "-darwin") if platform.include?("darwin") + if RbConfig::CONFIG["arch"].include?("-musl") && !platform.include?("-musl") + RbConfig::CONFIG["arch"] + else + platform + end + end + + def rust_target + platform = ruby_platform + RUST_TARGETS.fetch(platform) do + raise "No Rust target mapping for Ruby platform #{platform.inspect}. " \ + "Known platforms: #{RUST_TARGETS.keys.inspect}" + end + end + + def vendor_dir + File.join(__dir__, "vendor", "libdatadog-#{Libdatadog::LIB_VERSION}", ruby_platform) + end + + def target_dir + File.join(project_root, "target", rust_target, "release") + end + + def dylib_extension + ruby_platform.include?("darwin") ? "dylib" : "so" + end + + # -- Step 1: Build the Rust shared library --------------------------------- + + def cargo_build! + features = CARGO_FEATURES.join(",") + target = rust_target + + puts "Building #{PROFILING_FFI_CRATE} for #{target} (release)..." + cmd = %W[ + cargo build + -p #{PROFILING_FFI_CRATE} + --features #{features} + --release + --target #{target} + ] + puts " #{cmd.join(" ")}" + + Dir.chdir(project_root) do + raise "Cargo build failed (exit #{$?.exitstatus})" unless system(*cmd) + end + end + + # -- Step 2: Build the dedup_headers helper -------------------------------- + # + # cbindgen emits each module header as a self-contained file, so types + # defined in common.h are duplicated into every child header. The + # dedup_headers tool strips those duplicates so the C compiler doesn't + # see conflicting typedefs when both common.h and a child header are + # included. + + def build_dedup_headers! + puts "Building dedup_headers tool..." + Dir.chdir(project_root) do + raise "Failed to build dedup_headers" \ + unless system("cargo", "build", "-p", "tools", "--bin", "dedup_headers", "--release") + end + end + + # -- Step 3: Install headers ----------------------------------------------- + + def install_headers! + src_include = File.join(project_root, "target", "include", "datadog") + dst_include = File.join(vendor_dir, "include", "datadog") + FileUtils.mkdir_p(dst_include) + + headers = Dir.glob(File.join(src_include, "*.h")) + raise "No headers found in #{src_include} — did cbindgen run?" if headers.empty? + + headers.each do |h| + FileUtils.cp(h, dst_include) + puts " Copied #{File.basename(h)}" + end + + # Dedup: strip definitions from child headers that already exist in common.h + dedup_bin = File.join(project_root, "target", "release", "dedup_headers") + base_header = File.join(dst_include, "common.h") + child_headers = Dir.glob(File.join(dst_include, "*.h")) + .reject { |h| File.basename(h) == "common.h" } + + unless child_headers.empty? + puts " Running dedup_headers on #{child_headers.length} child headers..." + raise "dedup_headers failed" unless system(dedup_bin, base_header, *child_headers) + end + end + + # -- Step 4: Install shared library ---------------------------------------- + + def install_libs! + dst_lib = File.join(vendor_dir, "lib") + FileUtils.mkdir_p(dst_lib) + + ext = dylib_extension + src_dylib = File.join(target_dir, "libdatadog_profiling_ffi.#{ext}") + src_static = File.join(target_dir, "libdatadog_profiling_ffi.a") + dst_dylib = File.join(dst_lib, "libdatadog_profiling.#{ext}") + dst_static = File.join(dst_lib, "libdatadog_profiling.a") + + raise "Shared library not found at #{src_dylib}" unless File.exist?(src_dylib) + + FileUtils.cp(src_dylib, dst_dylib) + puts " Installed libdatadog_profiling.#{ext}" + + if File.exist?(src_static) + FileUtils.cp(src_static, dst_static) + puts " Installed libdatadog_profiling.a" + end + + # macOS: rewrite the dylib's install name so the linker records an + # @rpath-relative reference instead of an absolute build path. + if ruby_platform.include?("darwin") + puts " Fixing dylib install name with install_name_tool..." + system("install_name_tool", "-id", "@rpath/libdatadog_profiling.dylib", dst_dylib) + end + end + + # -- Step 5: Generate pkg-config files ------------------------------------- + # + # dd-trace-rb's extconf.rb uses pkg-config to discover include paths and + # linker flags. The .pc files use ${pcfiledir}-relative paths so they + # work regardless of where the gem tree lives on disk. + + def install_pkgconfig! + dst_pkgconfig = File.join(vendor_dir, "lib", "pkgconfig") + FileUtils.mkdir_p(dst_pkgconfig) + + template_dir = File.join(project_root, PKGCONFIG_TEMPLATE_DIR) + + %w[datadog_profiling_with_rpath.pc datadog_profiling.pc].each do |pc_name| + template = File.join(template_dir, "#{pc_name}.in") + raise "pkgconfig template not found: #{template}" unless File.exist?(template) + + content = File.read(template).gsub("@Datadog_VERSION@", Libdatadog::LIB_VERSION) + File.write(File.join(dst_pkgconfig, pc_name), content) + puts " Generated #{pc_name}" + end + end + + # -- Orchestrator ---------------------------------------------------------- + + def run! + puts "=" * 72 + puts "Compiling libdatadog from source" + puts " Ruby platform : #{ruby_platform}" + puts " Rust target : #{rust_target}" + puts " Vendor dir : #{vendor_dir}" + puts "=" * 72 + puts + + cargo_build! + puts + build_dedup_headers! + puts + + puts "Installing into #{vendor_dir}..." + FileUtils.rm_rf(vendor_dir) + FileUtils.mkdir_p(vendor_dir) + + install_headers! + install_libs! + install_pkgconfig! + + puts + puts "=" * 72 + puts "Done! Vendor tree ready at:" + puts " #{vendor_dir}" + puts + puts "To use in dd-trace-rb, add to its Gemfile:" + puts " gem 'libdatadog', path: '#{File.expand_path(__dir__)}'" + puts "Then: cd && bundle exec rake compile" + puts "=" * 72 + end + end +end + +desc "Build libdatadog from Rust source and populate the vendor tree for the current platform" +task :compile do + CompileFromSource.run! +end + Rake::Task["build"].clear task(:build) { raise "Build task is disabled, use package instead" } diff --git a/ruby/default.nix b/ruby/default.nix new file mode 100644 index 0000000000..fb3934b804 --- /dev/null +++ b/ruby/default.nix @@ -0,0 +1,11 @@ +# flake-compat shim for usage without flakes +(import + ( + let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in + fetchTarball { + url = lock.nodes.flake-compat.locked.url or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; + sha256 = lock.nodes.flake-compat.locked.narHash; + } + ) + { src = ./.; } +).defaultNix diff --git a/ruby/flake.lock b/ruby/flake.lock new file mode 100644 index 0000000000..07e3069676 --- /dev/null +++ b/ruby/flake.lock @@ -0,0 +1,76 @@ +{ + "nodes": { + "flake-compat": { + "locked": { + "lastModified": 1733328505, + "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "revCount": 69, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz" + }, + "original": { + "type": "tarball", + "url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1770272467, + "narHash": "sha256-D6CEKO7fdmlv/DiZpgP6XHpYgEvDlVpyAAw9htKIw5I=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "f62a8f3634fede22e9a75f1a7774929da7549adc", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "release-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-compat": "flake-compat", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/ruby/flake.nix b/ruby/flake.nix new file mode 100644 index 0000000000..ffa6338645 --- /dev/null +++ b/ruby/flake.nix @@ -0,0 +1,106 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/release-25.11"; + + # cross-platform convenience + flake-utils.url = "github:numtide/flake-utils"; + + # backwards compatibility with nix-build and nix-shell + flake-compat.url = "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"; + }; + + outputs = { self, nixpkgs, flake-utils, flake-compat }: + # resolve for all platforms in turn + flake-utils.lib.eachDefaultSystem (system: + let + # packages for this system platform + pkgs = nixpkgs.legacyPackages.${system}; + + # control versions + ruby = pkgs.ruby_4_0; + llvm = pkgs.llvmPackages_19; + gcc = pkgs.gcc14; + + hook = '' + # get major.minor.0 ruby version + export RUBY_VERSION="$(ruby -e 'puts RUBY_VERSION.gsub(/\d+$/, "0")')" + + # make gem install work in-project, compatibly with bundler + export GEM_HOME="$(pwd)/vendor/bundle/ruby/$RUBY_VERSION" + + # make bundle work in-project + export BUNDLE_PATH="$(pwd)/vendor/bundle" + + # enable calling gem scripts without bundle exec + export PATH="$GEM_HOME/bin:$PATH" + + # enable implicitly resolving gems to bundled version + export RUBYGEMS_GEMDEPS="$(pwd)/gems.rb" + ''; + + deps = [ + pkgs.libyaml.dev # for gem psych + pkgs.libffi.dev # for gem fiddle + pkgs.pkg-config # for dd-trace-rb extconf.rb + + # Rust toolchain for building libdatadog from source + pkgs.rustc + pkgs.cargo + + # TODO: some gems insist on using `gcc` on Linux, satisfy them for now: + # - json + # - protobuf + # - ruby-prof + gcc + ]; + in { + devShells.default = llvm.stdenv.mkDerivation { + name = "libdatadog-ruby-devshell"; + + buildInputs = [ ruby ] ++ deps; + + shellHook = hook; + }; + + devShells.ruby40 = llvm.stdenv.mkDerivation { + name = "libdatadog-ruby-devshell"; + + buildInputs = [ pkgs.ruby_4_0 ] ++ deps; + + shellHook = hook; + }; + + devShells.ruby34 = llvm.stdenv.mkDerivation { + name = "libdatadog-ruby-devshell"; + + buildInputs = [ pkgs.ruby_3_4 ] ++ deps; + + shellHook = hook; + }; + + devShells.ruby33 = llvm.stdenv.mkDerivation { + name = "libdatadog-ruby-devshell"; + + buildInputs = [ pkgs.ruby_3_3 ] ++ deps; + + shellHook = hook; + }; + + devShells.ruby32 = llvm.stdenv.mkDerivation { + name = "libdatadog-ruby-devshell"; + + buildInputs = [ pkgs.ruby_3_2 ] ++ deps; + + shellHook = hook; + }; + + devShells.ruby31 = llvm.stdenv.mkDerivation { + name = "libdatadog-ruby-devshell"; + + buildInputs = [ pkgs.ruby_3_1 ] ++ deps; + + shellHook = hook; + }; + } + ); +} diff --git a/ruby/shell.nix b/ruby/shell.nix new file mode 100644 index 0000000000..950c1c8180 --- /dev/null +++ b/ruby/shell.nix @@ -0,0 +1,11 @@ +# flake-compat shim for usage without flakes +(import + ( + let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in + fetchTarball { + url = lock.nodes.flake-compat.locked.url or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; + sha256 = lock.nodes.flake-compat.locked.narHash; + } + ) + { src = ./.; } +).shellNix