Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ libdd-http-client @DataDog/apm-common-components-core
libdd-library-config*/ @DataDog/apm-sdk-capabilities-rust
libdd-log*/ @DataDog/apm-common-components-core
libdd-otel-thread-ctx/ @DataDog/apm-common-components-core
libdd-otel-thread-ctx-ffi/ @DataDog/apm-common-components-core
libdd-profiling*/ @DataDog/libdatadog-profiling
libdd-shared-runtime*/ @DataDog/apm-common-components-core
libdd-telemetry*/ @DataDog/apm-common-components-core
Expand Down
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ members = [
"datadog-live-debugger",
"datadog-live-debugger-ffi",
"libdd-otel-thread-ctx",
"libdd-otel-thread-ctx-ffi",
"libdd-profiling",
"libdd-profiling-ffi",
"libdd-profiling-protobuf",
Expand Down
26 changes: 26 additions & 0 deletions libdd-otel-thread-ctx-ffi/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
# SPDX-License-Identifier: Apache-2.0

[package]
name = "libdd-otel-thread-ctx-ffi"
version = "1.0.0"
description = "FFI bindings for the OTel thread-level context publisher"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false

[lib]
crate-type = ["staticlib", "cdylib", "lib"]
bench = false

[dependencies]
libdd-common-ffi = { path = "../libdd-common-ffi", default-features = false }
libdd-otel-thread-ctx = { path = "../libdd-otel-thread-ctx" }

[features]
default = ["cbindgen"]
cbindgen = ["build_common/cbindgen", "libdd-common-ffi/cbindgen"]

[build-dependencies]
build_common = { path = "../build-common" }
44 changes: 44 additions & 0 deletions libdd-otel-thread-ctx-ffi/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
// SPDX-License-Identifier: Apache-2.0
extern crate build_common;

use build_common::generate_and_configure_header;

fn main() {
let header_name = "otel-thread-ctx.h";
generate_and_configure_header(header_name);

let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();

// Export the TLSDESC thread-local variable to the dynamic symbol table so
// external readers (e.g. the eBPF profiler) can locate it. Rust's cdylib
// linker applies a version script with `local: *` that hides all symbols
// not explicitly whitelisted, and also causes lld to relax the TLSDESC
// access to local-exec (LE), eliminating the dynsym entry entirely.
// Passing our own version script with an explicit `global:` entry for the
// symbol beats the `local: *` wildcard and prevents that relaxation.
//
// Merging multiple version scripts is not supported by GNU ld, so we also
// force lld explicitly.
if target_os == "linux" {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
println!("cargo:rustc-cdylib-link-arg=-fuse-ld=lld");
println!(
"cargo:rustc-cdylib-link-arg=-Wl,--version-script={manifest_dir}/tls-dynamic-list.txt"
);

// Expose the profile output directory to integration tests so they can
// locate the cdylib without fragile path-walking.
// OUT_DIR = <target>/[<triple>/]<profile>/build/<pkg>-<hash>/out
// Three levels up lands on <target>/[<triple>/]<profile>.
let out_dir = std::env::var("OUT_DIR").unwrap();
let profile_dir = std::path::PathBuf::from(&out_dir)
.ancestors()
.nth(3)
.unwrap()
.to_str()
.unwrap()
.to_owned();
println!("cargo:rustc-env=CDYLIB_PROFILE_DIR={profile_dir}");
}
}
35 changes: 35 additions & 0 deletions libdd-otel-thread-ctx-ffi/cbindgen.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
# SPDX-License-Identifier: Apache-2.0

language = "C"
cpp_compat = true
tab_width = 2
header = """// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
// SPDX-License-Identifier: Apache-2.0
"""
include_guard = "DDOG_OTEL_THREAD_CTX_H"
style = "both"
pragma_once = true
no_includes = true
sys_includes = ["stdbool.h", "stddef.h", "stdint.h"]

[parse]
parse_deps = true
include = ["libdd-common-ffi", "libdd-otel-thread-ctx"]

[export]
prefix = "ddog_"
renaming_overrides_prefixing = true

[export.rename]
# AtomicU8 doesn't have a proper mapping, and is a Rust implementation detail.
# We map it to plain uint8_t in the C header, since it has the same
# representation.
"AtomicU8" = "uint8_t"

[export.mangle]
rename_types = "PascalCase"

[enum]
prefix_with_name = true
rename_variants = "ScreamingSnakeCase"
106 changes: 106 additions & 0 deletions libdd-otel-thread-ctx-ffi/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
// SPDX-License-Identifier: Apache-2.0

//! FFI bindings for the OTel thread-level context publisher.
//!
//! All symbols are only available on Linux, since the TLSDESC TLS mechanism
//! required by the spec is Linux-specific.

#[cfg(target_os = "linux")]
pub use linux::*;

#[cfg(target_os = "linux")]
mod linux {
use libdd_otel_thread_ctx::linux::{ThreadContext, ThreadContextRecord};
use std::ptr::NonNull;

/// Allocate and initialise a new thread context.
///
/// Returns a non-null owned handle that must eventually be released with
/// `ddog_otel_thread_ctx_free`.
#[no_mangle]
pub extern "C" fn ddog_otel_thread_ctx_new(
trace_id: &[u8; 16],
span_id: &[u8; 8],
local_root_span_id: &[u8; 8],
) -> NonNull<ThreadContextRecord> {
ThreadContext::new(*trace_id, *span_id, *local_root_span_id, &[]).into_ptr()
}

/// Free an owned thread context.
///
/// # Safety
///
/// `ctx` must be a valid non-null pointer obtained from `ddog_otel_thread_ctx_new` or
/// `ddog_otel_thread_ctx_detach`, and must not be used after this call. In particular, `ctx`
/// must not be currently attached to a thread.
#[no_mangle]
pub unsafe extern "C" fn ddog_otel_thread_ctx_free(ctx: *mut ThreadContextRecord) {
if let Some(ctx) = NonNull::new(ctx) {
let _ = ThreadContext::from_ptr(ctx);
}
}

/// Attach `ctx` to the current thread. Returns the previously attached context if any, or null
/// otherwise.
///
/// # Safety
///
/// `ctx` must be a valid non-null pointer obtained from this API. Ownership of `ctx` is
/// transferred to the TLS slot: the caller must not drop `ctx` while it is still actively
/// attached.
///
/// ## In-place update
///
/// The preferred method to update the thread context in place is [ddog_otel_thread_ctx_update].
///
/// If calling into native code is too costly, it is possible to update an attached context
/// directly in-memory without going through libdatadog (contexts are guaranteed to have a
/// stable address through their lifetime). **HOWEVER, IF DOING SO, PLEASE BE VERY CAUTIOUS OF
/// THE FOLLOWING POINTS**:
///
/// 1. The update process requires a [seqlock](https://en.wikipedia.org/wiki/Seqlock)-like
/// pattern: [ThreadContextRecord::valid] must be first set to `0` before the update and set
/// to `1` again at the end. Additionally, depending on your language's memory model, you
/// might need specific synchronization primitives (compiler fences, atomics, etc.), since
/// the context can be read by an asynchronous signal handler at any point in time. See the
/// [Otel thread context
/// specification](https://github.com/open-telemetry/opentelemetry-specification/pull/4947)
/// for more details.
/// 2. Only update the context from the thread it's attached to. Contexts are designed to be
/// attached, written to and read from on the same thread (whether from signal code or
/// program code). Thus, they are NOT thread-safe. Given the current specification, I don't
/// think it's possible to safely update an attached context from a different thread, since
/// the signal handler doesn't assume the context can be written to concurrently from another
/// thread.
#[no_mangle]
pub unsafe extern "C" fn ddog_otel_thread_ctx_attach(
ctx: *mut ThreadContextRecord,
) -> Option<NonNull<ThreadContextRecord>> {
ThreadContext::from_ptr(NonNull::new(ctx)?)
.attach()
.map(ThreadContext::into_ptr)
}

/// Remove the currently attached context from the TLS slot.
///
/// Returns the detached context (caller now owns it and must release it with
/// `ddog_otel_thread_ctx_free`), or null if the slot was empty.
#[no_mangle]
pub extern "C" fn ddog_otel_thread_ctx_detach() -> Option<NonNull<ThreadContextRecord>> {
ThreadContext::detach().map(ThreadContext::into_ptr)
}

/// Update the currently attached context in-place.
///
/// If no context is currently attached, one is created and attached, equivalent to calling
/// `ddog_otel_thread_ctx_new` followed by `ddog_otel_thread_ctx_attach`.
#[no_mangle]
pub extern "C" fn ddog_otel_thread_ctx_update(
trace_id: &[u8; 16],
span_id: &[u8; 8],
local_root_span_id: &[u8; 8],
) {
ThreadContext::update(*trace_id, *span_id, *local_root_span_id, &[]);
}
}
66 changes: 66 additions & 0 deletions libdd-otel-thread-ctx-ffi/tests/elf_properties.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
// SPDX-License-Identifier: Apache-2.0

//! Verify ELF properties of the built cdylib on Linux.
//!
//! These tests check that:
//! - `otel_thread_ctx_v1` is exported in the dynamic symbol table as a TLS GLOBAL symbol.
//! - `otel_thread_ctx_v1` is accessed via TLSDESC relocations (R_X86_64_TLSDESC or
//! R_AARCH64_TLSDESC), as required by the OTel thread-level context sharing spec.
//!
//! The cdylib path is injected at compile time via `build.rs` from `OUT_DIR`.

#![cfg(target_os = "linux")]

use std::path::PathBuf;
use std::process::Command;

const SYMBOL: &str = "otel_thread_ctx_v1";

fn cdylib_path() -> PathBuf {
PathBuf::from(env!("CDYLIB_PROFILE_DIR")).join("liblibdd_otel_thread_ctx_ffi.so")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Point ELF tests at Cargo's deps output directory

During cargo test, Cargo emits cdylib artifacts under target/<profile>/deps, but cdylib_path() currently looks in target/<profile>. That means readelf is invoked on a non-existent file in normal test runs, so these new ELF-property tests fail even when the library is built correctly. Resolve the path from the deps directory (or otherwise discover the real artifact path) so the assertions run against the actual .so.

Useful? React with 👍 / 👎.

}

fn readelf(args: &[&str], path: &PathBuf) -> String {
let out = Command::new("readelf")
.args(args)
.arg(path)
.output()
.expect("failed to run readelf — is binutils installed?");
String::from_utf8_lossy(&out.stdout).into_owned()
}

#[test]
fn otel_thread_ctx_v1_in_dynsym() {
let path = cdylib_path();
let output = readelf(&["-W", "--dyn-syms"], &path);
let line = output
.lines()
.find(|l| l.contains(SYMBOL))
.unwrap_or_else(|| panic!("'{SYMBOL}' not found in dynsym of {}", path.display()));
assert!(
line.contains("TLS") && line.contains("GLOBAL"),
"'{SYMBOL}' is in dynsym but not as TLS GLOBAL — got:\n {line}"
);
}

#[test]
fn otel_thread_ctx_v1_tlsdesc_reloc() {
let path = cdylib_path();
let output = readelf(&["-W", "--relocs"], &path);
let found = output.lines().any(|l| {
l.contains(SYMBOL) && (l.contains("R_X86_64_TLSDESC") || l.contains("R_AARCH64_TLSDESC"))
});
assert!(
found,
"No TLSDESC relocation found for '{SYMBOL}' in {}\n\
All relocations mentioning the symbol:\n{}",
path.display(),
output
.lines()
.filter(|l| l.contains(SYMBOL))
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n")
);
}
3 changes: 3 additions & 0 deletions libdd-otel-thread-ctx-ffi/tls-dynamic-list.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
global: otel_thread_ctx_v1;
};
31 changes: 17 additions & 14 deletions libdd-otel-thread-ctx/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ pub mod linux {
// `valid`. We just use a const assertion in `new()` to avoid surprises and make sure this
// struct has the right total size.
#[repr(C)]
struct ThreadContextRecord {
pub struct ThreadContextRecord {
/// Trace identifier; all-zeroes means "no trace".
trace_id: [u8; 16],
/// Span identifier.
Expand Down Expand Up @@ -302,26 +302,26 @@ pub mod linux {
))
}

/// Turn this thread context into a raw pointer to the underlying [ThreadContextRecord].
/// The pointer must be reconstructed through [`Self::from_raw`] in order to be properly
/// Turn this thread context into a pointer to the underlying [ThreadContextRecord].
/// The pointer must be reconstructed through [`Self::from_ptr`] in order to be properly
/// dropped, or the record will leak.
fn into_raw(self) -> *mut ThreadContextRecord {
pub fn into_ptr(self) -> NonNull<ThreadContextRecord> {
let mdrop = mem::ManuallyDrop::new(self);
mdrop.0.as_ptr()
mdrop.0
}

/// Reconstruct a [ThreadContextRecord] from a raw pointer that is either `null` or comes
/// from [`Self::into_raw`]. Return `None` if `ptr` is null.
/// Reconstruct a [ThreadContextRecord] from a pointer that comes
/// from [`Self::into_ptr`].
///
/// # Safety
///
/// - `ptr` must be `null` or come from a prior call to [`Self::into_raw`].
/// - `ptr` must come from a prior call to [`Self::into_ptr`].
/// - if `ptr` is aliased, accesses through aliases must not be interleaved with method
/// calls on the returned [ThreadContextRecord]. More precisely, mutable references might
/// be reconstructed during those calls, so any constraint from either Stacked Borrows,
/// Tree Borrows or whatever is the current aliasing model implemented in Miri applies.
unsafe fn from_raw(ptr: *mut ThreadContextRecord) -> Option<Self> {
NonNull::new(ptr).map(Self)
pub unsafe fn from_ptr(ptr: NonNull<ThreadContextRecord>) -> Self {
Self(ptr)
}
}

Expand All @@ -345,8 +345,9 @@ pub mod linux {
slot: &AtomicPtr<ThreadContextRecord>,
tgt: *mut ThreadContextRecord,
) -> Option<ThreadContext> {
// Safety: a non-null value in the slot came from a prior `into_raw` call.
unsafe { ThreadContext::from_raw(slot.swap(tgt, Ordering::Relaxed)) }
// Safety: a non-null value in the slot came from a prior `into_ptr` call.
NonNull::new(slot.swap(tgt, Ordering::Relaxed))
.map(|ptr| unsafe { ThreadContext::from_ptr(ptr) })
}

/// Publish a new (or previously detached) thread context record by writing its pointer
Expand All @@ -365,7 +366,7 @@ pub mod linux {
//
// We still need a release fence to avoid exposing uninitialized memory to the handler.
compiler_fence(Ordering::Release);
Self::swap(get_tls_slot(), self.into_raw())
Self::swap(get_tls_slot(), self.into_ptr().as_ptr())
}

/// Update the currently attached record in-place. Sets `valid = 0` before the update and
Expand Down Expand Up @@ -399,7 +400,9 @@ pub mod linux {
// `ThreadContext::new` already initialises `valid = 1`.
let _ = Self::swap(
slot,
ThreadContext::new(trace_id, span_id, local_root_span_id, attrs).into_raw(),
ThreadContext::new(trace_id, span_id, local_root_span_id, attrs)
.into_ptr()
.as_ptr(),
);
}
}
Expand Down
Loading
Loading