diff --git a/.gitlab/build-sidecar.sh b/.gitlab/build-sidecar.sh
index bad3171853d..b3314c4d8ca 100755
--- a/.gitlab/build-sidecar.sh
+++ b/.gitlab/build-sidecar.sh
@@ -21,4 +21,5 @@ fi
SHARED=1 PROFILE=tracer-release host_os="${HOST_OS}" ./compile_rust.sh
cp -v "${CARGO_TARGET_DIR:-target}/tracer-release/libdatadog_php.a" "libdatadog_php_$(uname -m)${suffix}.a"
-objcopy --compress-debug-sections "${CARGO_TARGET_DIR:-target}/tracer-release/libdatadog_php.so" "libdatadog_php_$(uname -m)${suffix}.so"
+output="libdatadog_php_$(uname -m)${suffix}.so"
+objcopy --compress-debug-sections "${CARGO_TARGET_DIR:-target}/tracer-release/libdatadog_php.so" "${output}"
diff --git a/.gitlab/compile_extension.sh b/.gitlab/compile_extension.sh
index 7eae904efee..b43416e47b7 100755
--- a/.gitlab/compile_extension.sh
+++ b/.gitlab/compile_extension.sh
@@ -25,5 +25,10 @@ make -j static &
wait
# Link extension
-sed -i 's/-export-symbols .*\/datadog\.sym/-Wl,--retain-symbols-file=datadog.sym/g' ${EXTENSION_DIR}/ddtrace.ldflags
+if [ "$(uname -s)" = "Linux" ]; then
+ export_symbols_file="datadog-linux.sym"
+else
+ export_symbols_file="datadog.sym"
+fi
+sed -i -E "s#-export-symbols [^ ]+#-Wl,--retain-symbols-file=${export_symbols_file}#g" "${EXTENSION_DIR}/ddtrace.ldflags"
cc -shared -Wl,-whole-archive ${MODULES_DIR}/ddtrace.a -Wl,-no-whole-archive $(cat ${EXTENSION_DIR}/ddtrace.ldflags) ${CARGO_TARGET_DIR}/debug/libdatadog_php.a -Wl,-soname -Wl,ddtrace.so -o ${MODULES_DIR}/ddtrace.so
diff --git a/.gitlab/link-tracing-extension.sh b/.gitlab/link-tracing-extension.sh
index 7378c7879c3..971d6df7be7 100755
--- a/.gitlab/link-tracing-extension.sh
+++ b/.gitlab/link-tracing-extension.sh
@@ -3,7 +3,11 @@ set -e -o pipefail
suffix="${1:-}"
-sed -i 's/-export-symbols .*\/datadog\.sym/-Wl,--retain-symbols-file=datadog.sym/g' "ddtrace_$(uname -m)${suffix}.ldflags"
+export_symbols_file="datadog.sym"
+if [ "$(uname -s)" = "Linux" ]; then
+ export_symbols_file="datadog-linux.sym"
+fi
+sed -i -E "s#-export-symbols [^ ]+#-Wl,--retain-symbols-file=${export_symbols_file}#g" "ddtrace_$(uname -m)${suffix}.ldflags"
pids=()
for archive in extensions_$(uname -m)/*.a; do
(
diff --git a/Cargo.lock b/Cargo.lock
index 7d190dfa02c..516724fe8d1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1339,6 +1339,7 @@ dependencies = [
"libdd-crashtracker",
"libdd-crashtracker-ffi",
"libdd-data-pipeline",
+ "libdd-library-config",
"libdd-library-config-ffi",
"libdd-remote-config",
"libdd-telemetry",
@@ -1384,8 +1385,10 @@ dependencies = [
"libc 0.2.186",
"libdd-alloc",
"libdd-common",
+ "libdd-library-config",
"libdd-library-config-ffi",
"libdd-profiling",
+ "libdd-trace-protobuf",
"log",
"mach2",
"perfcnt",
@@ -2956,6 +2959,7 @@ dependencies = [
"tracing",
"uuid",
"web-time",
+ "zstd",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index ed732446587..61b26ea0ba8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -42,22 +42,21 @@ inherits = "release"
# level, so they are mirrored here too.
[workspace.dependencies]
anyhow = { version = "1.0", default-features = false }
-arc-swap = "1.7.1"
-hyper = { version = "1.6", features = [
- "http1",
- "client",
-], default-features = false }
-hyper-util = { version = "0.1.10", features = [
- "http1",
- "client",
- "client-legacy",
-] }
+arc-swap = { version = "1.7.1", default-features = false }
+futures = { version = "0.3", default-features = false }
+hyper = { version = "1.6", default-features = false }
+hyper-util = { version = "0.1.10", default-features = false }
+io-lifetimes = { version = "1.0", default-features = false }
+libc = { version = "0.2", default-features = true }
prost-build = { version = "0.14.1", default-features = false }
protoc-bin-vendored = { version = "3.0.0", default-features = false }
+rustls = { version = "0.23", default-features = false }
serde = { version = "1.0", default-features = false }
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }
+syn = { version = "^2", default-features = false }
tokio = { version = "1.36", default-features = false }
tracing = { version = "0.1", default-features = false }
+uuid = { version = "1.7.0", default-features = false }
[workspace.lints]
# empty for compat with libdatadog
diff --git a/Makefile b/Makefile
index aa1ba9dad6f..afdb0270181 100644
--- a/Makefile
+++ b/Makefile
@@ -48,7 +48,7 @@ RUN_TESTS_CMD := DD_SERVICE= DD_ENV= REPORT_EXIT_STATUS=1 TEST_PHP_SRCDIR=$(PROJ
C_FILES = $(shell find components components-rs ext src/dogstatsd tracer zend_abstract_interface -name '*.c' -o -name '*.h' | awk '{ printf "$(BUILD_DIR)/%s\n", $$1 }' )
TEST_FILES = $(shell find tests/ext -name '*.php*' -o -name '*.inc' -o -name '*.json' -o -name '*.yaml' -o -name 'CONFLICTS' | awk '{ printf "$(BUILD_DIR)/%s\n", $$1 }' )
-RUST_FILES = $(BUILD_DIR)/Cargo.toml $(BUILD_DIR)/Cargo.lock $(shell find components-rs -name '*.c' -o -name '*.rs' -o -name 'Cargo.toml' | awk '{ printf "$(BUILD_DIR)/%s\n", $$1 }' ) $(shell find libdatadog/{build-common,datadog-ffe,datadog-ipc,datadog-ipc-macros,datadog-live-debugger,datadog-live-debugger-ffi,libdd-remote-config,datadog-sidecar,datadog-sidecar-ffi,datadog-sidecar-macros,libdd-alloc,libdd-capabilities,libdd-capabilities-impl,libdd-common,libdd-common-ffi,libdd-crashtracker,libdd-crashtracker-ffi,libdd-data-pipeline,libdd-ddsketch,libdd-dogstatsd-client,libdd-library-config,libdd-library-config-ffi,libdd-log,libdd-shared-runtime,libdd-telemetry,libdd-telemetry-ffi,libdd-tinybytes,libdd-trace-*,spawn_worker,tools/{cc_utils,sidecar_mockgen},libdd-trace-*,Cargo.toml} \( -type l -o -type f \) \( -path "*/src*" -o -path "*/examples*" -o -path "*Cargo.toml" -o -path "*/build.rs" -o -path "*/tests/dataservice.rs" -o -path "*/tests/service_functional.rs" \) -not -path "*/datadog-ipc/build.rs" -not -path "*/datadog-sidecar-ffi/build.rs")
+RUST_FILES = $(BUILD_DIR)/Cargo.toml $(BUILD_DIR)/Cargo.lock $(shell find components-rs -name '*.c' -o -name '*.rs' -o -name 'Cargo.toml' | awk '{ printf "$(BUILD_DIR)/%s\n", $$1 }' ) $(shell find libdatadog/{build-common,datadog-ffe,datadog-ipc,datadog-ipc-macros,datadog-live-debugger,datadog-live-debugger-ffi,libdd-remote-config,datadog-sidecar,datadog-sidecar-ffi,datadog-sidecar-macros,libdd-alloc,libdd-capabilities,libdd-capabilities-impl,libdd-common,libdd-common-ffi,libdd-crashtracker,libdd-crashtracker-ffi,libdd-data-pipeline,libdd-ddsketch,libdd-dogstatsd-client,libdd-library-config,libdd-library-config-ffi,libdd-log,libdd-otel-thread-ctx,libdd-shared-runtime,libdd-telemetry,libdd-telemetry-ffi,libdd-tinybytes,libdd-trace-*,spawn_worker,tools/{cc_utils,sidecar_mockgen},libdd-trace-*,Cargo.toml} \( -type l -o -type f \) \( -path "*/src*" -o -path "*/examples*" -o -path "*Cargo.toml" -o -path "*/build.rs" -o -path "*/tests/dataservice.rs" -o -path "*/tests/service_functional.rs" \) -not -path "*/datadog-ipc/build.rs" -not -path "*/datadog-sidecar-ffi/build.rs")
ALL_OBJECT_FILES = $(C_FILES) $(RUST_FILES) $(BUILD_DIR)/Makefile
TEST_OPCACHE_FILES = $(shell find tests/opcache -name '*.php*' -o -name '.gitkeep' | awk '{ printf "$(BUILD_DIR)/%s\n", $$1 }' )
TEST_STUB_FILES = $(shell find tests/ext -type d -name 'stubs' -exec find '{}' -type f \; | awk '{ printf "$(BUILD_DIR)/%s\n", $$1 }' )
@@ -106,7 +106,7 @@ JUNIT_RESULTS_DIR := $(shell pwd)
all: $(BUILD_DIR)/configure $(SO_FILE)
-$(BUILD_DIR)/configure: $(M4_FILES) $(BUILD_DIR)/datadog.sym $(BUILD_DIR)/VERSION
+$(BUILD_DIR)/configure: $(M4_FILES) $(BUILD_DIR)/datadog.sym $(BUILD_DIR)/datadog-linux.sym $(BUILD_DIR)/VERSION
$(Q) (cd $(BUILD_DIR); phpize && $(SED_I) 's/\/FAILED/\/\\bFAILED/' $(BUILD_DIR)/run-tests.php) # Fix PHP 5.4 exit code bug when running selected tests (FAILED vs XFAILED)
$(BUILD_DIR)/run-tests.php: $(if $(ASSUME_COMPILED),, $(BUILD_DIR)/configure)
diff --git a/appsec/cmake/ddtrace.cmake b/appsec/cmake/ddtrace.cmake
index 04134a1711e..f7c9472d2fe 100644
--- a/appsec/cmake/ddtrace.cmake
+++ b/appsec/cmake/ddtrace.cmake
@@ -26,9 +26,9 @@ add_custom_target(libdatadog_stamp
if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
set(EXPORTS_FILE "${CMAKE_BINARY_DIR}/ddtrace_exports.version")
add_custom_target(ddtrace_exports
- COMMAND bash -c "{ echo -e '{\\nglobal:'; sed 's/$/;/' '${CMAKE_SOURCE_DIR}'/../datadog.sym; echo -e 'local:\\n*;\\n};'; } > '${EXPORTS_FILE}'"
+ COMMAND bash -c "{ echo -e '{\\nglobal:'; sed 's/$/;/' '${CMAKE_SOURCE_DIR}'/../datadog-linux.sym; echo -e 'local:\\n*;\\n};'; } > '${EXPORTS_FILE}'"
BYPRODUCTS ${EXPORTS_FILE}
- DEPENDS ${CMAKE_SOURCE_DIR}/../datadog.sym
+ DEPENDS ${CMAKE_SOURCE_DIR}/../datadog-linux.sym
VERBATIM
)
elseif(APPLE)
diff --git a/components-rs/Cargo.toml b/components-rs/Cargo.toml
index 7c78ff2cad6..841ae2f8de5 100644
--- a/components-rs/Cargo.toml
+++ b/components-rs/Cargo.toml
@@ -56,6 +56,9 @@ libc = "0.2"
bincode = { version = "1.3.3" }
hashbrown = "0.15"
+[target.'cfg(target_os = "linux")'.dependencies]
+libdd-library-config = { path = "../libdatadog/libdd-library-config", default-features = false, features = ["otel-thread-ctx"] }
+
[build-dependencies]
cbindgen = "0.27"
diff --git a/components-rs/build.rs b/components-rs/build.rs
index fc5779e6ba1..bda6910746e 100644
--- a/components-rs/build.rs
+++ b/components-rs/build.rs
@@ -1,6 +1,6 @@
fn main() {
// On Linux, set ddog_spawn_direct_entry as the ELF entry point for the
- // cdylib build (libdatadog_php.so in SSI deployments). This allows ld.so
+ // cdylib build (libdatadog_php.so in SSI deployments). This allows ld.so
// to exec the library directly without a trampoline binary.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") {
println!("cargo:rustc-cdylib-link-arg=-Wl,-e,ddog_spawn_direct_entry");
diff --git a/components-rs/datadog.h b/components-rs/datadog.h
index 21c7f656fe4..bac89449eb1 100644
--- a/components-rs/datadog.h
+++ b/components-rs/datadog.h
@@ -41,6 +41,11 @@ void datadog_generate_session_id(void);
void datadog_format_runtime_id(uint8_t (*buf)[36]);
+#ifdef __linux__
+bool datadog_publish_otel_process_context(ddog_CharSlice process_tags);
+
+#endif
+
ddog_CharSlice ddtrace_get_container_id(void);
void ddtrace_set_container_cgroup_path(ddog_CharSlice path);
diff --git a/components-rs/lib.rs b/components-rs/lib.rs
index ae44c380288..00c26337eea 100644
--- a/components-rs/lib.rs
+++ b/components-rs/lib.rs
@@ -90,6 +90,72 @@ pub extern "C" fn datadog_format_runtime_id(buf: &mut [u8; 36]) {
unsafe { datadog_runtime_id.as_hyphenated().encode_lower(buf) };
}
+#[cfg(target_os = "linux")]
+fn char_slice_string(value: CharSlice<'_>) -> String {
+ value.to_utf8_lossy().into_owned()
+}
+
+#[cfg(target_os = "linux")]
+fn hostname() -> String {
+ let max_len = unsafe { libc::sysconf(libc::_SC_HOST_NAME_MAX) };
+ let max_len = usize::try_from(max_len).unwrap_or(255);
+ let mut buffer = vec![0; max_len.saturating_add(1)];
+
+ if unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) } != 0 {
+ return String::new();
+ }
+
+ let len = buffer
+ .iter()
+ .position(|&byte| byte == 0)
+ .unwrap_or(buffer.len());
+ String::from_utf8_lossy(&buffer[..len]).into_owned()
+}
+
+/// Publish or update dd-trace-php's standard Linux OTel Process Context.
+#[cfg(target_os = "linux")]
+#[no_mangle]
+pub extern "C" fn datadog_publish_otel_process_context(process_tags: CharSlice<'_>) -> bool {
+ use libdd_library_config::otel_process_ctx;
+ use libdd_library_config::tracer_metadata::{ThreadLocalMetadata, TracerMetadata};
+
+ let metadata = TracerMetadata {
+ // Safety: the runtime ID is only mutated from single-threaded contexts.
+ runtime_id: Some(unsafe { datadog_runtime_id.as_hyphenated().to_string() }),
+ tracer_language: "php".to_owned(),
+ tracer_version: include_str!("../VERSION").trim().to_owned(),
+ hostname: hostname(),
+ process_tags: Some(char_slice_string(process_tags)),
+ container_id: get_container_id().map(str::to_owned),
+ threadlocal_metadata: Some(ThreadLocalMetadata {
+ attribute_keys: vec![
+ "service.name".to_owned(),
+ "deployment.environment.name".to_owned(),
+ "service.version".to_owned(),
+ "thread.id".to_owned(),
+ ],
+ ..Default::default()
+ }),
+ ..Default::default()
+ };
+
+ let mut context = metadata.to_otel_process_ctx();
+ // TracerMetadata emits empty resource attributes for absent optional fields to advertise
+ // support. These values can vary independently on every PHP request, so their values and
+ // resource keys must be omitted from the process-wide context. The keys remain discoverable
+ // through threadlocal.attribute_key_map.
+ if let Some(resource) = context.resource.as_mut() {
+ resource.attributes.retain(|attribute| {
+ !matches!(
+ attribute.key.as_str(),
+ "service.name" | "service.version" | "deployment.environment.name"
+ )
+ });
+ }
+
+ otel_process_ctx::publish(&context).is_ok()
+}
+
#[must_use]
#[no_mangle]
pub extern "C" fn ddtrace_get_container_id() -> CharSlice<'static> {
diff --git a/config.m4 b/config.m4
index aa69d309a43..f89e3461e36 100644
--- a/config.m4
+++ b/config.m4
@@ -100,6 +100,19 @@ if test "$PHP_DDTRACE" != "no"; then
EXTRA_CFLAGS="$EXTRA_CFLAGS -Wno-microsoft-anon-tag"
])
+ case "$host_os:$host_cpu" in
+ linux*:x86_64)
+ AC_LIBTOOL_COMPILER_OPTION([whether -mtls-dialect=gnu2 is a valid compiler argument],
+ lt_cv_ddtrace_tls_dialect_gnu2,
+ [-mtls-dialect=gnu2], [],
+ [
+ CFLAGS="$CFLAGS -mtls-dialect=gnu2"
+ EXTRA_CFLAGS="$EXTRA_CFLAGS -mtls-dialect=gnu2"
+ ],
+ [AC_MSG_ERROR([x86-64 Linux OTel context sharing requires compiler support for -mtls-dialect=gnu2])])
+ ;;
+ esac
+
DD_TRACE_VENDOR_SOURCES="\
tracer/vendor/mpack/mpack.c \
tracer/vendor/mt19937/mt19937-64.c \
@@ -168,6 +181,12 @@ if test "$PHP_DDTRACE" != "no"; then
"
fi
+ case "$host_os" in
+ linux*)
+ EXTRA_TRACER_SOURCES="$EXTRA_TRACER_SOURCES tracer/otel_context.c"
+ ;;
+ esac
+
dnl datadog.c/ddtrace.c comes first, then everything else alphabetically
DATADOG_PHP_SOURCES="$EXTRA_DATADOG_SOURCES \
ext/datadog.c
@@ -307,10 +326,14 @@ if test "$PHP_DDTRACE" != "no"; then
AC_CHECK_HEADER(time.h, [], [AC_MSG_ERROR([Cannot find or include time.h])])
if test "$ext_shared" = "yes"; then
- dnl Only export symbols defined in datadog.sym, which should all be marked as
+ dnl Only export the platform's listed symbols, which should all be marked as
dnl DATADOG_PUBLIC in their source files as well.
EXTRA_CFLAGS="$EXTRA_CFLAGS -fvisibility=hidden"
- EXTRA_LDFLAGS="$EXTRA_LDFLAGS -export-symbols $ext_srcdir/datadog.sym -flto -fuse-linker-plugin"
+ case $host_os in
+ linux*) DDTRACE_EXPORT_SYMBOLS="$ext_srcdir/datadog-linux.sym" ;;
+ *) DDTRACE_EXPORT_SYMBOLS="$ext_srcdir/datadog.sym" ;;
+ esac
+ EXTRA_LDFLAGS="$EXTRA_LDFLAGS -export-symbols $DDTRACE_EXPORT_SYMBOLS -flto -fuse-linker-plugin"
dnl On Linux: set the ELF entry point so ddtrace.so can be exec'd directly by ld.so
dnl for sidecar spawning (no trampoline binary, no memfd, no temp files).
diff --git a/datadog-linux.sym b/datadog-linux.sym
new file mode 100644
index 00000000000..227c07946a1
--- /dev/null
+++ b/datadog-linux.sym
@@ -0,0 +1,46 @@
+ddtrace_close_all_spans_and_flush
+datadog_get_formatted_session_id
+ddtrace_get_profiling_context
+ddtrace_get_root_span
+datadog_process_tags_get_serialized
+datadog_get_sidecar_queue_id
+ddtrace_get_priority_sampling_on_span_zobj
+ddtrace_set_priority_sampling_on_span_zobj
+ddtrace_add_propagated_tag_on_span_zobj
+datadog_runtime_id
+ddtrace_user_req_add_listeners
+ddtrace_ip_extraction_find
+datadog_set_all_thread_vm_interrupt
+datadog_get_telemetry_rc_info
+datadog_metric_register_buffer
+datadog_metric_add_point
+ddtrace_emit_asm_event
+datadog_loaded_by_ssi
+datadog_ssi_forced_injection_enabled
+ddtrace_guess_endpoint_from_url
+ddog_remote_config_reader_for_path
+ddog_remote_config_read
+ddog_remote_config_reader_drop
+get_module
+ddog_crashtracker_entry_point
+ddog_daemon_entry_point
+ddog_set_rc_notify_fn
+ddog_remote_config_path
+ddog_remote_config_path_free
+ddog_library_configurator_new
+ddog_library_configurator_with_local_path
+ddog_library_configurator_with_fleet_path
+ddog_library_configurator_with_detect_process_info
+ddog_library_configurator_get
+ddog_library_config_source_to_string
+ddog_library_config_drop
+ddog_Error_message
+ddog_Error_drop
+ddog_library_configurator_drop
+ddog_sidecar_enqueue_telemetry_log
+ddog_sidecar_enqueue_telemetry_point
+ddog_sidecar_enqueue_telemetry_metric
+ddog_sidecar_connect
+ddog_sidecar_ping
+ddog_sidecar_transport_drop
+otel_thread_ctx_v1
diff --git a/ext/datadog.c b/ext/datadog.c
index fbd031d1ca7..5dae1929034 100644
--- a/ext/datadog.c
+++ b/ext/datadog.c
@@ -11,6 +11,7 @@
#include "excluded_modules.h"
#include "agent_info.h"
#include "logging.h"
+#include "ffi_utils.h"
#include "phpinfo.h"
#include "process_tags.h"
#include "remote_config.h"
@@ -547,6 +548,10 @@ static void dd_rinit_once(void) {
datadog_process_tags_first_rinit();
datadog_sidecar_update_process_tags();
}
+#ifdef __linux__
+ zend_string *process_tags = datadog_process_tags_get_serialized();
+ datadog_publish_otel_process_context(dd_zend_string_to_CharSlice(process_tags));
+#endif
// Uses config, cannot run earlier
#ifndef _WIN32
@@ -704,7 +709,12 @@ static PHP_MINFO_FUNCTION(datadog) {
void datadog_internal_handle_fork(void) {
// CHILD PROCESS
+ datadog_generate_runtime_id();
datadog_sidecar_handle_fork();
+#ifdef __linux__
+ zend_string *process_tags = datadog_process_tags_get_serialized();
+ datadog_publish_otel_process_context(dd_zend_string_to_CharSlice(process_tags));
+#endif
#ifdef DDTRACE
ddtrace_internal_handle_fork();
diff --git a/ext/sidecar.c b/ext/sidecar.c
index 224774d3a55..1c9c8d9b6fd 100644
--- a/ext/sidecar.c
+++ b/ext/sidecar.c
@@ -469,7 +469,11 @@ void datadog_sidecar_handle_fork(void) {
return;
}
- datadog_force_new_instance_id();
+ if (datadog_sidecar_instance_id) {
+ ddog_sidecar_instanceId_drop(datadog_sidecar_instance_id);
+ datadog_sidecar_instance_id = NULL;
+ }
+ dd_set_resettable_sidecar_globals();
// After fork only one thread (the one that called fork) survives, so we only
// need to drop and reconnect the current thread's transport.
diff --git a/libdatadog b/libdatadog
index 95610de06a7..5761c06ff1c 160000
--- a/libdatadog
+++ b/libdatadog
@@ -1 +1 @@
-Subproject commit 95610de06a776b8d645fe77ad8b8e1848ecd53b7
+Subproject commit 5761c06ff1cee9fd0568e0917b4c56f8a16515bd
diff --git a/loader/dd_library_loader.c b/loader/dd_library_loader.c
index 4eaff1efa7f..6d7b40ce08a 100644
--- a/loader/dd_library_loader.c
+++ b/loader/dd_library_loader.c
@@ -322,6 +322,7 @@ injected_ext ddloader_injected_ext_config[EXT_COUNT] = {
ZEND_MOD_OPTIONAL("ddtrace")
ZEND_MOD_OPTIONAL("ddtrace_injected")
ZEND_MOD_OPTIONAL("datadog-profiling")
+ ZEND_MOD_OPTIONAL("opentelemetry")
ZEND_MOD_OPTIONAL("ev")
ZEND_MOD_OPTIONAL("event")
ZEND_MOD_OPTIONAL("libevent")
diff --git a/package.xml b/package.xml
index b69cba5d1dd..013638deb17 100644
--- a/package.xml
+++ b/package.xml
@@ -75,6 +75,7 @@ ${changelog}
${codefiles}
+
diff --git a/profiling/Cargo.toml b/profiling/Cargo.toml
index 37f9dea8dd8..c2ae57147d7 100644
--- a/profiling/Cargo.toml
+++ b/profiling/Cargo.toml
@@ -41,6 +41,10 @@ uuid = { version = "1.0", features = ["v4"] }
[target.'cfg(target_vendor = "apple")'.dependencies]
mach2 = "0.6.0"
+[target.'cfg(target_os = "linux")'.dependencies]
+libdd-library-config = { path = "../libdatadog/libdd-library-config", default-features = false, features = ["process-context-reader"] }
+libdd-trace-protobuf = { path = "../libdatadog/libdd-trace-protobuf" }
+
[dependencies.tracing-subscriber]
version = "0.3"
optional = true
diff --git a/profiling/src/lib.rs b/profiling/src/lib.rs
index f815f1514c4..4f27a8e94cb 100644
--- a/profiling/src/lib.rs
+++ b/profiling/src/lib.rs
@@ -9,6 +9,8 @@ mod pthread;
mod sapi;
mod wall_time;
+mod process_context;
+
#[cfg(php_run_time_cache)]
mod string_set;
@@ -94,13 +96,17 @@ static mut RUNTIME_PHP_VERSION: &str = {
/// The first time this is accessed must be after config is initialized in
/// the first RINIT and before mshutdown!
static GLOBAL_TAGS: LazyLock> = LazyLock::new(|| {
+ #[cfg(target_os = "linux")]
+ let runtime_id = process_context::runtime_id().unwrap_or_else(|| runtime_id().to_string());
+ #[cfg(not(target_os = "linux"))]
+ let runtime_id = runtime_id().to_string();
let mut tags = vec![
tag!("language", "php"),
tag!("profiler_version", env!("PROFILER_VERSION")),
// SAFETY: calling getpid() is safe.
Tag::new("process_id", unsafe { libc::getpid() }.to_string())
.expect("process_id tag to be valid"),
- Tag::new("runtime-id", runtime_id().to_string()).expect("runtime-id tag to be valid"),
+ Tag::new("runtime-id", runtime_id).expect("runtime-id tag to be valid"),
];
// This should probably be "language_version", but this is the
@@ -159,10 +165,13 @@ extern "C" {
}
/// Module dependencies for the profiler extension.
-static MODULE_DEPS: [zend::ModuleDep; 8] = [
+static MODULE_DEPS: [zend::ModuleDep; 9] = [
zend::ModuleDep::required(cstr!("standard")),
zend::ModuleDep::required(cstr!("json")),
+ // Load after optional context publishers so their Process and Thread Context
+ // are available when profiling starts.
zend::ModuleDep::optional(cstr!("ddtrace")),
+ zend::ModuleDep::optional(cstr!("opentelemetry")),
// Optionally, be dependent on these event extensions so that the functions they provide
// are registered in the function table and we can hook into them.
zend::ModuleDep::optional(cstr!("ev")),
@@ -703,6 +712,9 @@ extern "C" fn rinit(_type: c_int, _module_number: c_int) -> ZendResult {
Profiler::init(system_settings);
if system_settings.profiling_enabled {
+ #[cfg(target_os = "linux")]
+ let process_identity = process_context::identity();
+
// Not logging, rinit could be quite spammy.
_ = REQUEST_LOCALS.try_with_borrow(|locals| {
let cpu_time_enabled = system_settings.profiling_experimental_cpu_time_enabled;
@@ -712,18 +724,39 @@ extern "C" fn rinit(_type: c_int, _module_number: c_int) -> ZendResult {
TAGS.set({
// SAFETY: accessing in RINIT after config is initialized.
let globals = GLOBAL_TAGS.deref();
- let extra_tags_len = locals.service.is_some() as usize
- + locals.env.is_some() as usize
- + locals.version.is_some() as usize
+ #[cfg(target_os = "linux")]
+ let service = process_identity
+ .service
+ .as_ref()
+ .or(locals.service.as_ref());
+ #[cfg(not(target_os = "linux"))]
+ let service = locals.service.as_ref();
+ #[cfg(target_os = "linux")]
+ let environment = process_identity
+ .environment
+ .as_ref()
+ .or(locals.env.as_ref());
+ #[cfg(not(target_os = "linux"))]
+ let environment = locals.env.as_ref();
+ #[cfg(target_os = "linux")]
+ let version = process_identity
+ .version
+ .as_ref()
+ .or(locals.version.as_ref());
+ #[cfg(not(target_os = "linux"))]
+ let version = locals.version.as_ref();
+ let extra_tags_len = service.is_some() as usize
+ + environment.is_some() as usize
+ + version.is_some() as usize
+ locals.git_commit_sha.is_some() as usize
+ locals.git_repository_url.is_some() as usize;
let mut tags = Vec::new();
tags.reserve_exact(globals.len() + extra_tags_len + locals.tags.len());
tags.extend_from_slice(globals.as_slice());
- add_optional_tag(&mut tags, "service", &locals.service);
- add_optional_tag(&mut tags, "env", &locals.env);
- add_optional_tag(&mut tags, "version", &locals.version);
+ add_optional_tag(&mut tags, "service", &service);
+ add_optional_tag(&mut tags, "env", &environment);
+ add_optional_tag(&mut tags, "version", &version);
add_optional_tag(&mut tags, "git.commit.sha", &locals.git_commit_sha);
add_optional_tag(&mut tags, "git.repository_url", &locals.git_repository_url);
tags.extend_from_slice(locals.tags.as_slice());
diff --git a/profiling/src/module_globals.rs b/profiling/src/module_globals.rs
index 8c7d061a079..f35210bfa62 100644
--- a/profiling/src/module_globals.rs
+++ b/profiling/src/module_globals.rs
@@ -5,6 +5,11 @@ use core::mem::MaybeUninit;
use core::ptr;
use core::sync::atomic::AtomicU32;
+#[cfg(target_os = "linux")]
+use crate::process_context::ProcessContextCache;
+#[cfg(target_os = "linux")]
+use core::cell::RefCell;
+
#[cfg(php_zend_mm_set_custom_handlers_ex)]
use crate::allocation::allocation_ge84::ZendMMState;
#[cfg(not(php_zend_mm_set_custom_handlers_ex))]
@@ -21,6 +26,8 @@ pub struct ProfilerGlobals {
/// the PHP thread, so the value must remain atomic despite living in
/// thread-local PHP module globals.
pub interrupt_count: AtomicU32,
+ #[cfg(target_os = "linux")]
+ pub(crate) process_context: RefCell,
/// Per-thread allocation sampling state. Kept in PHP globals so allocator
/// hooks can reuse an already-resolved TSRM cache instead of accessing Rust TLS.
pub allocation_profiling_stats: UnsafeCell>,
@@ -41,6 +48,8 @@ pub static mut GLOBALS_ID: i32 = 0;
pub static mut GLOBALS: ProfilerGlobals = ProfilerGlobals {
zend_mm_state: Cell::new(ZendMMState::new()),
interrupt_count: AtomicU32::new(0),
+ #[cfg(target_os = "linux")]
+ process_context: RefCell::new(ProcessContextCache::new()),
allocation_profiling_stats: UnsafeCell::new(MaybeUninit::uninit()),
};
@@ -127,6 +136,9 @@ pub unsafe extern "C" fn ginit(_globals_ptr: *mut c_void) {
let globals = _globals_ptr.cast::();
(*globals).zend_mm_state = Cell::new(ZendMMState::new());
(*globals).interrupt_count = AtomicU32::new(0);
+ #[cfg(target_os = "linux")]
+ ptr::addr_of_mut!((*globals).process_context)
+ .write(RefCell::new(ProcessContextCache::new()));
(*globals).allocation_profiling_stats = UnsafeCell::new(MaybeUninit::uninit());
}
@@ -143,9 +155,15 @@ pub unsafe extern "C" fn gshutdown(_globals_ptr: *mut c_void) {
#[cfg(php_zts)]
crate::timeline::timeline_gshutdown();
- // TODO: Florian, do we need this?
- // let globals = globals_ptr.cast::();
- // (*globals).zend_mm_state = ZendMMState::new();
+ #[cfg(target_os = "linux")]
+ {
+ let globals = _globals_ptr.cast::();
+ if let Ok(mut cache) = (*globals).process_context.try_borrow_mut() {
+ cache.reset();
+ }
+ #[cfg(php_zts)]
+ ptr::drop_in_place(ptr::addr_of_mut!((*globals).process_context));
+ }
// SAFETY: this is called in thread gshutdown as expected, no other places.
allocation::gshutdown();
diff --git a/profiling/src/php_ffi.c b/profiling/src/php_ffi.c
index 9a82c509c6f..74acea9eafa 100644
--- a/profiling/src/php_ffi.c
+++ b/profiling/src/php_ffi.c
@@ -20,6 +20,25 @@ static void locate_datadog_runtime_id(const zend_extension *extension) {
datadog_runtime_id = DL_FETCH_SYMBOL(extension->handle, "datadog_runtime_id");
}
+#ifdef __linux__
+// Standard OTel context providers export this TLS symbol globally. Cache its
+// address per thread because dynamic TLS addresses differ between threads.
+static void *datadog_php_profiling_inactive_otel_thread_context = NULL;
+static __thread void **datadog_php_profiling_otel_thread_context_slot = NULL;
+
+const void *datadog_php_profiling_get_otel_thread_context(void) {
+ if (!datadog_php_profiling_otel_thread_context_slot) {
+ datadog_php_profiling_otel_thread_context_slot =
+ DL_FETCH_SYMBOL(NULL, "otel_thread_ctx_v1");
+ if (!datadog_php_profiling_otel_thread_context_slot) {
+ datadog_php_profiling_otel_thread_context_slot =
+ &datadog_php_profiling_inactive_otel_thread_context;
+ }
+ }
+ return *datadog_php_profiling_otel_thread_context_slot;
+}
+#endif
+
static void locate_ddtrace_get_profiling_context(const zend_extension *extension) {
ddtrace_profiling_context (*get_profiling)(void) =
DL_FETCH_SYMBOL(extension->handle, "ddtrace_get_profiling_context");
diff --git a/profiling/src/php_ffi.h b/profiling/src/php_ffi.h
index 51a36b08590..ec7dcba417b 100644
--- a/profiling/src/php_ffi.h
+++ b/profiling/src/php_ffi.h
@@ -99,6 +99,14 @@ extern ddtrace_profiling_context (*datadog_php_profiling_get_profiling_context)(
*/
extern zend_string *(*datadog_php_profiling_get_process_tags_serialized)(void);
+/**
+ * Returns the calling thread's record published through the standard Linux
+ * `otel_thread_ctx_v1` TLS symbol, or NULL when unavailable.
+ */
+#ifdef __linux__
+const void *datadog_php_profiling_get_otel_thread_context(void);
+#endif
+
/**
* Called by this zend_extension's .startup handler. Does things that are
* burdensome in Rust, like locating the ddtrace extension in the module
diff --git a/profiling/src/process_context.rs b/profiling/src/process_context.rs
new file mode 100644
index 00000000000..0083ec37bf0
--- /dev/null
+++ b/profiling/src/process_context.rs
@@ -0,0 +1,57 @@
+// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+#[cfg(target_os = "linux")]
+#[derive(Debug, Default)]
+pub(crate) struct ThreadContext {
+ pub(crate) local_root_span_id: u64,
+ pub(crate) span_id: u64,
+ pub(crate) thread_id: Option,
+ pub(crate) service: Option,
+ pub(crate) environment: Option,
+ pub(crate) version: Option,
+}
+
+#[derive(Default)]
+pub(crate) struct ProcessIdentity {
+ pub(crate) service: Option,
+ pub(crate) environment: Option,
+ pub(crate) version: Option,
+}
+
+#[cfg(target_os = "linux")]
+#[derive(Clone, Copy, Default)]
+pub(crate) struct ProcessIdentityRef<'a> {
+ pub(crate) service: Option<&'a str>,
+ pub(crate) environment: Option<&'a str>,
+ pub(crate) version: Option<&'a str>,
+}
+
+#[cfg(target_os = "linux")]
+impl From> for ProcessIdentity {
+ fn from(identity: ProcessIdentityRef<'_>) -> Self {
+ Self {
+ service: identity.service.map(str::to_owned),
+ environment: identity.environment.map(str::to_owned),
+ version: identity.version.map(str::to_owned),
+ }
+ }
+}
+
+#[cfg(target_os = "linux")]
+pub(crate) enum ThreadContextRead {
+ /// No valid OTel Thread Context is currently attached.
+ Inactive,
+ Active(ThreadContext),
+}
+
+#[cfg(target_os = "linux")]
+#[path = "process_context/linux.rs"]
+mod platform;
+
+#[cfg(target_os = "linux")]
+pub(crate) use platform::thread_context;
+#[cfg(target_os = "linux")]
+pub(crate) use platform::ProcessContextCache;
+#[cfg(target_os = "linux")]
+pub(crate) use platform::{identity, initialize, invalidate_before_fork, process_tags, runtime_id};
diff --git a/profiling/src/process_context/linux.rs b/profiling/src/process_context/linux.rs
new file mode 100644
index 00000000000..36672dc45de
--- /dev/null
+++ b/profiling/src/process_context/linux.rs
@@ -0,0 +1,611 @@
+// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
+// SPDX-License-Identifier: Apache-2.0
+
+use super::{ProcessIdentity, ProcessIdentityRef, ThreadContext, ThreadContextRead};
+use crate::bindings::datadog_php_profiling_get_otel_thread_context;
+use libdd_library_config::otel_process_ctx::ProcessContextSelfReader;
+use libdd_trace_protobuf::opentelemetry::proto::common::v1::{any_value, KeyValue, ProcessContext};
+
+const THREAD_CONTEXT_HEADER_SIZE: usize = 28;
+const MAX_THREAD_ATTRIBUTES_SIZE: usize = 612;
+const THREADLOCAL_ATTRIBUTE_KEY_MAP: &str = "threadlocal.attribute_key_map";
+
+#[derive(Default)]
+struct ResourceOffsets {
+ service_name: Option,
+ service_version: Option,
+ deployment_environment_name: Option,
+ service_instance_id: Option,
+}
+
+#[derive(Default)]
+struct ExtraAttributeOffsets {
+ process_tags: Option,
+}
+
+#[derive(Default)]
+struct ThreadAttributeOffsets {
+ key_count: usize,
+ local_root_span_id: Option,
+ service_name: Option,
+ service_version: Option,
+ deployment_environment_name: Option,
+ thread_id: Option,
+}
+
+#[derive(Default)]
+struct ProcessContextOffsets {
+ resource: ResourceOffsets,
+ extra: ExtraAttributeOffsets,
+ thread: ThreadAttributeOffsets,
+}
+
+struct CachedProcessContext {
+ context: ProcessContext,
+ offsets: ProcessContextOffsets,
+}
+
+impl Default for CachedProcessContext {
+ fn default() -> Self {
+ Self::new(ProcessContext::default())
+ }
+}
+
+impl CachedProcessContext {
+ fn new(context: ProcessContext) -> Self {
+ let offsets = ProcessContextOffsets::from_context(&context);
+ Self { context, offsets }
+ }
+
+ fn resource_string(&self, offset: Option) -> Option<&str> {
+ offset
+ .and_then(|offset| self.context.resource.as_ref()?.attributes.get(offset))
+ .and_then(string_value)
+ }
+
+ fn extra_string(&self, offset: Option) -> Option<&str> {
+ offset
+ .and_then(|offset| self.context.extra_attributes.get(offset))
+ .and_then(string_value)
+ }
+
+ fn identity(&self) -> ProcessIdentityRef<'_> {
+ ProcessIdentityRef {
+ service: self.resource_string(self.offsets.resource.service_name),
+ environment: self.resource_string(self.offsets.resource.deployment_environment_name),
+ version: self.resource_string(self.offsets.resource.service_version),
+ }
+ }
+}
+
+fn string_value(attribute: &KeyValue) -> Option<&str> {
+ let any_value::Value::StringValue(value) = attribute.value.as_ref()?.value.as_ref()? else {
+ return None;
+ };
+ (!value.is_empty()).then_some(value.as_str())
+}
+
+impl ProcessContextOffsets {
+ fn from_context(context: &ProcessContext) -> Self {
+ let mut offsets = Self::default();
+
+ if let Some(resource) = context.resource.as_ref() {
+ for (index, attribute) in resource.attributes.iter().enumerate() {
+ match attribute.key.as_str() {
+ "service.name" => offsets.resource.service_name = Some(index),
+ "service.version" => offsets.resource.service_version = Some(index),
+ "deployment.environment.name" => {
+ offsets.resource.deployment_environment_name = Some(index);
+ }
+ "service.instance.id" => offsets.resource.service_instance_id = Some(index),
+ _ => {}
+ }
+ }
+ }
+
+ for (index, attribute) in context.extra_attributes.iter().enumerate() {
+ match attribute.key.as_str() {
+ "datadog.process_tags" => offsets.extra.process_tags = Some(index),
+ THREADLOCAL_ATTRIBUTE_KEY_MAP => {
+ offsets.thread = ThreadAttributeOffsets::from_attribute(attribute);
+ }
+ _ => {}
+ }
+ }
+
+ offsets
+ }
+}
+
+impl ThreadAttributeOffsets {
+ fn from_attribute(attribute: &KeyValue) -> Self {
+ let Some(any_value::Value::ArrayValue(key_map)) = attribute
+ .value
+ .as_ref()
+ .and_then(|value| value.value.as_ref())
+ else {
+ return Self::default();
+ };
+
+ let mut offsets = Self {
+ // Thread Context key indices are u8, so entries beyond this cannot
+ // be referenced by the v1 record.
+ key_count: key_map.values.len().min(u8::MAX as usize + 1),
+ ..Self::default()
+ };
+ for (index, key) in key_map.values.iter().enumerate() {
+ let Ok(index) = u8::try_from(index) else {
+ break;
+ };
+ let Some(any_value::Value::StringValue(key)) = key.value.as_ref() else {
+ continue;
+ };
+ match key.as_str() {
+ "datadog.local_root_span_id" => offsets.local_root_span_id = Some(index),
+ "service.name" => offsets.service_name = Some(index),
+ "service.version" => offsets.service_version = Some(index),
+ "deployment.environment.name" => {
+ offsets.deployment_environment_name = Some(index);
+ }
+ "thread.id" => offsets.thread_id = Some(index),
+ _ => {}
+ }
+ }
+ offsets
+ }
+}
+
+/// Per-PHP-thread cache of the decoded OTel Process Context.
+///
+/// The reader is deliberately short-lived. A refresh discovers the current
+/// mapping, decodes it, and immediately closes the reader's copy pipe.
+pub(crate) struct ProcessContextCache {
+ context: Option,
+ /// Consecutive samples that observed an unknown key index. Recovery is
+ /// attempted at powers of two and suppressed once this saturates.
+ unknown_index_observations: u8,
+}
+
+impl ProcessContextCache {
+ pub(crate) const fn new() -> Self {
+ Self {
+ context: None,
+ unknown_index_observations: 0,
+ }
+ }
+
+ pub(crate) fn reset(&mut self) {
+ *self = Self::new();
+ }
+
+ /// Reads Process Context on this PHP thread's first request. A failed read
+ /// installs an empty context so later requests do not repeat discovery.
+ pub(crate) fn initialize(&mut self) {
+ if self.context.is_some() {
+ return;
+ }
+ if self.read_process_context().is_err() {
+ self.context = Some(CachedProcessContext::default());
+ }
+ }
+
+ fn read_process_context(&mut self) -> std::io::Result<()> {
+ let result = ProcessContextSelfReader::new().and_then(|reader| reader.read());
+ match result {
+ Ok(context) => {
+ self.context = Some(CachedProcessContext::new(context));
+ Ok(())
+ }
+ Err(error) => Err(error),
+ }
+ }
+
+ fn unknown_index_refresh_due(&mut self) -> bool {
+ self.unknown_index_observations = self.unknown_index_observations.saturating_add(1);
+ self.unknown_index_observations.is_power_of_two()
+ }
+
+ /// Cold relative to sampling. With the current fixed key map, this is only
+ /// expected while restoring a cache invalidated before fork.
+ #[cold]
+ #[inline(never)]
+ fn refresh(&mut self) -> bool {
+ if !self.unknown_index_refresh_due() {
+ return false;
+ }
+ if self.read_process_context().is_err() {
+ return false;
+ }
+
+ self.unknown_index_observations = 0;
+ true
+ }
+
+ fn decode_thread_attributes(
+ &self,
+ attributes: &[u8],
+ defaults: ProcessIdentityRef<'_>,
+ ) -> (ThreadContext, bool) {
+ let offsets = self.context.as_ref().map(|cached| &cached.offsets.thread);
+ let mut context = ThreadContext::default();
+ let mut unknown_index = false;
+ let mut offset = 0;
+
+ while offset + 2 <= attributes.len() {
+ let key_index = attributes[offset] as usize;
+ let value_size = attributes[offset + 1] as usize;
+ offset += 2;
+
+ let Some(value_end) = offset.checked_add(value_size) else {
+ break;
+ };
+ if value_end > attributes.len() {
+ break;
+ }
+
+ let Some(offsets) = offsets else {
+ unknown_index = true;
+ offset = value_end;
+ continue;
+ };
+ if key_index >= offsets.key_count {
+ unknown_index = true;
+ offset = value_end;
+ continue;
+ }
+
+ let key_index = key_index as u8;
+ let interesting = offsets.local_root_span_id == Some(key_index)
+ || offsets.service_name == Some(key_index)
+ || offsets.service_version == Some(key_index)
+ || offsets.deployment_environment_name == Some(key_index)
+ || offsets.thread_id == Some(key_index);
+ if !interesting {
+ offset = value_end;
+ continue;
+ }
+
+ let Ok(value) = std::str::from_utf8(&attributes[offset..value_end]) else {
+ offset = value_end;
+ continue;
+ };
+ if !value.is_empty() {
+ if offsets.local_root_span_id == Some(key_index) {
+ context.local_root_span_id = u64::from_str_radix(value, 16).unwrap_or_default();
+ } else if offsets.service_name == Some(key_index) {
+ if Some(value) != defaults.service {
+ context.service = Some(value.to_owned());
+ }
+ } else if offsets.deployment_environment_name == Some(key_index) {
+ if Some(value) != defaults.environment {
+ context.environment = Some(value.to_owned());
+ }
+ } else if offsets.service_version == Some(key_index) {
+ if Some(value) != defaults.version {
+ context.version = Some(value.to_owned());
+ }
+ } else if offsets.thread_id == Some(key_index) {
+ context.thread_id = value.parse().ok().filter(|id| *id >= 0);
+ }
+ }
+
+ offset = value_end;
+ }
+
+ (context, unknown_index)
+ }
+}
+
+// Standalone Rust tests do not run inside PHP and therefore have no TSRM
+// module globals. Use Rust TLS to preserve the per-thread cache semantics
+// without entering the PHP module-globals path.
+#[cfg(test)]
+std::thread_local! {
+ static TEST_CACHE: std::cell::RefCell =
+ const { std::cell::RefCell::new(ProcessContextCache::new()) };
+}
+
+#[cfg(test)]
+fn with_cache(f: impl FnOnce(&std::cell::RefCell) -> R) -> R {
+ TEST_CACHE.with(f)
+}
+
+#[cfg(not(test))]
+fn with_cache(f: impl FnOnce(&std::cell::RefCell) -> R) -> R {
+ // SAFETY: PHP module globals are initialized by GINIT and are local to the
+ // current PHP thread in ZTS builds. NTS executes PHP on one thread.
+ let globals = unsafe { &*crate::module_globals::get_profiler_globals() };
+ f(&globals.process_context)
+}
+
+pub(crate) fn initialize() {
+ with_cache(|cache| {
+ if let Ok(mut cache) = cache.try_borrow_mut() {
+ cache.initialize();
+ }
+ });
+}
+
+pub(crate) fn invalidate_before_fork() {
+ with_cache(|cache| {
+ if let Ok(mut cache) = cache.try_borrow_mut() {
+ cache.reset();
+ }
+ });
+}
+
+pub(crate) fn identity() -> ProcessIdentity {
+ with_cache(|cache| {
+ cache
+ .try_borrow()
+ .ok()
+ .and_then(|cache| {
+ cache
+ .context
+ .as_ref()
+ .map(|cached| cached.identity().into())
+ })
+ .unwrap_or_default()
+ })
+}
+
+pub(crate) fn process_tags() -> Option {
+ with_cache(|cache| {
+ let cache = cache.try_borrow().ok()?;
+ let cached = cache.context.as_ref()?;
+ cached
+ .extra_string(cached.offsets.extra.process_tags)
+ .map(str::to_owned)
+ })
+}
+
+pub(crate) fn runtime_id() -> Option {
+ with_cache(|cache| {
+ let cache = cache.try_borrow().ok()?;
+ let cached = cache.context.as_ref()?;
+ cached
+ .resource_string(cached.offsets.resource.service_instance_id)
+ .map(str::to_owned)
+ })
+}
+
+pub(crate) fn thread_context(defaults: ProcessIdentityRef<'_>) -> ThreadContextRead {
+ let record = unsafe { datadog_php_profiling_get_otel_thread_context() }.cast::();
+ if record.is_null() {
+ return ThreadContextRead::Inactive;
+ }
+
+ // The record belongs to the calling PHP thread. The tracer cannot mutate
+ // it while the profiler is executing on that same thread.
+ let header = unsafe { std::slice::from_raw_parts(record, THREAD_CONTEXT_HEADER_SIZE) };
+ if header[24] != 1 {
+ return ThreadContextRead::Inactive;
+ }
+
+ let attributes_size = u16::from_ne_bytes([header[26], header[27]]) as usize;
+ if attributes_size > MAX_THREAD_ATTRIBUTES_SIZE {
+ return ThreadContextRead::Inactive;
+ }
+
+ let attributes = unsafe {
+ std::slice::from_raw_parts(record.add(THREAD_CONTEXT_HEADER_SIZE), attributes_size)
+ };
+ let mut context = with_cache(|cell| {
+ let Ok(cache) = cell.try_borrow() else {
+ return ThreadContext::default();
+ };
+ let (decoded, unknown_index) = cache.decode_thread_attributes(attributes, defaults);
+ if !unknown_index {
+ return decoded;
+ }
+ drop(cache);
+
+ let Ok(mut cache) = cell.try_borrow_mut() else {
+ return decoded;
+ };
+ if !cache.refresh() {
+ return decoded;
+ }
+ cache.decode_thread_attributes(attributes, defaults).0
+ });
+ context.span_id = u64::from_be_bytes(
+ header[16..24]
+ .try_into()
+ .expect("the span-id field has a fixed eight-byte size"),
+ );
+
+ ThreadContextRead::Active(context)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use libdd_trace_protobuf::opentelemetry::proto::common::v1::{AnyValue, ArrayValue};
+ use libdd_trace_protobuf::opentelemetry::proto::resource::v1::Resource;
+
+ fn string_attribute(key: &str, value: &str) -> KeyValue {
+ KeyValue {
+ key: key.to_owned(),
+ value: Some(AnyValue {
+ value: Some(any_value::Value::StringValue(value.to_owned())),
+ }),
+ key_ref: 0,
+ }
+ }
+
+ fn key_map(keys: &[&str]) -> KeyValue {
+ KeyValue {
+ key: THREADLOCAL_ATTRIBUTE_KEY_MAP.to_owned(),
+ value: Some(AnyValue {
+ value: Some(any_value::Value::ArrayValue(ArrayValue {
+ values: keys
+ .iter()
+ .map(|key| AnyValue {
+ value: Some(any_value::Value::StringValue((*key).to_owned())),
+ })
+ .collect(),
+ })),
+ }),
+ key_ref: 0,
+ }
+ }
+
+ fn context(resource: Vec, extra_attributes: Vec) -> ProcessContext {
+ ProcessContext {
+ resource: Some(Resource {
+ attributes: resource,
+ dropped_attributes_count: 0,
+ entity_refs: vec![],
+ }),
+ extra_attributes,
+ }
+ }
+
+ fn cache(context: ProcessContext) -> ProcessContextCache {
+ ProcessContextCache {
+ context: Some(CachedProcessContext::new(context)),
+ unknown_index_observations: 0,
+ }
+ }
+
+ fn encoded_attributes(attributes: &[(u8, &[u8])]) -> Vec {
+ let mut encoded = Vec::new();
+ for (key, value) in attributes {
+ encoded.push(*key);
+ encoded.push(value.len().try_into().expect("test value fits in u8"));
+ encoded.extend_from_slice(value);
+ }
+ encoded
+ }
+
+ #[test]
+ fn backs_off_unknown_index_refreshes() {
+ let mut cache = ProcessContextCache::new();
+ let refresh_observations: Vec<_> = (1..=300)
+ .filter(|_| cache.unknown_index_refresh_due())
+ .collect();
+
+ assert_eq!(refresh_observations, [1, 2, 4, 8, 16, 32, 64, 128]);
+ assert_eq!(cache.unknown_index_observations, u8::MAX);
+
+ cache.unknown_index_observations = 0;
+ assert!(cache.unknown_index_refresh_due());
+ assert_eq!(cache.unknown_index_observations, 1);
+ }
+
+ #[test]
+ fn caches_process_identity_runtime_id_and_tags_by_discovered_offset() {
+ let cached = CachedProcessContext::new(context(
+ vec![
+ string_attribute("unrelated", "ignored"),
+ string_attribute("service.version", "1.2.3"),
+ string_attribute("service.instance.id", "runtime-id-from-publisher"),
+ string_attribute("service.name", "checkout"),
+ string_attribute("deployment.environment.name", "production"),
+ ],
+ vec![
+ key_map(&[
+ "datadog.local_root_span_id",
+ "service.name",
+ "deployment.environment.name",
+ "service.version",
+ "thread.id",
+ ]),
+ string_attribute("datadog.process_tags", "region:us-east-1"),
+ ],
+ ));
+
+ let identity = cached.identity();
+ assert_eq!(identity.service, Some("checkout"));
+ assert_eq!(identity.environment, Some("production"));
+ assert_eq!(identity.version, Some("1.2.3"));
+ assert_eq!(
+ cached.resource_string(cached.offsets.resource.service_instance_id),
+ Some("runtime-id-from-publisher")
+ );
+ assert_eq!(
+ cached.extra_string(cached.offsets.extra.process_tags),
+ Some("region:us-east-1")
+ );
+ }
+
+ #[test]
+ fn decodes_semantic_thread_attributes_from_the_process_key_map() {
+ let cache = cache(context(
+ vec![],
+ vec![key_map(&[
+ "ignored",
+ "thread.id",
+ "service.version",
+ "datadog.local_root_span_id",
+ "service.name",
+ "deployment.environment.name",
+ ])],
+ ));
+ let attributes = encoded_attributes(&[
+ (0, b"not interesting"),
+ (1, b"42"),
+ (2, b"2.0.0"),
+ (3, b"fedcba9876543210"),
+ (4, b"root-service"),
+ (5, b"configured-env"),
+ ]);
+
+ let (decoded, unknown_index) = cache.decode_thread_attributes(
+ &attributes,
+ ProcessIdentityRef {
+ service: Some("configured-service"),
+ environment: Some("configured-env"),
+ version: Some("configured-version"),
+ },
+ );
+
+ assert!(!unknown_index);
+ assert_eq!(decoded.thread_id, Some(42));
+ assert_eq!(decoded.local_root_span_id, 0xfedc_ba98_7654_3210);
+ assert_eq!(decoded.service.as_deref(), Some("root-service"));
+ assert_eq!(decoded.environment, None);
+ assert_eq!(decoded.version.as_deref(), Some("2.0.0"));
+ }
+
+ #[test]
+ fn malformed_empty_and_unknown_attributes_do_not_erase_defaults() {
+ let cache = cache(context(
+ vec![],
+ vec![key_map(&[
+ "datadog.local_root_span_id",
+ "service.name",
+ "deployment.environment.name",
+ "service.version",
+ "thread.id",
+ ])],
+ ));
+
+ let attributes = encoded_attributes(&[
+ (1, b""),
+ (2, &[0xff]),
+ (3, b"configured-version"),
+ (4, b"-1"),
+ (6, b"new-key"),
+ ]);
+ let (decoded, unknown_index) = cache.decode_thread_attributes(
+ &attributes,
+ ProcessIdentityRef {
+ service: Some("configured-service"),
+ environment: Some("configured-env"),
+ version: Some("configured-version"),
+ },
+ );
+
+ assert!(unknown_index);
+ assert_eq!(decoded.service, None);
+ assert_eq!(decoded.environment, None);
+ assert_eq!(decoded.version, None);
+ assert_eq!(decoded.thread_id, None);
+
+ let (truncated, unknown_index) =
+ cache.decode_thread_attributes(&[1, 10, b'a'], ProcessIdentityRef::default());
+ assert!(!unknown_index);
+ assert_eq!(truncated.service, None);
+ }
+}
diff --git a/profiling/src/profiling/mod.rs b/profiling/src/profiling/mod.rs
index 574320c1665..6c7b95c46a3 100644
--- a/profiling/src/profiling/mod.rs
+++ b/profiling/src/profiling/mod.rs
@@ -18,12 +18,16 @@ use crate::bindings::ddog_php_prof_get_active_fiber;
use crate::bindings::ddog_php_prof_get_active_fiber_test as ddog_php_prof_get_active_fiber;
use crate::allocation::ALLOCATION_PROFILING_INTERVAL;
+#[cfg(not(target_os = "linux"))]
+use crate::bindings::datadog_php_profiling_get_profiling_context;
use crate::bindings::{
- datadog_php_profiling_get_process_tags_serialized, datadog_php_profiling_get_profiling_context,
- zai_str_from_zstr, zend_execute_data,
+ datadog_php_profiling_get_process_tags_serialized, zai_str_from_zstr, zend_execute_data,
};
use crate::config::SystemSettings;
use crate::exception::EXCEPTION_PROFILING_INTERVAL;
+use crate::process_context::ProcessIdentity;
+#[cfg(target_os = "linux")]
+use crate::process_context::{ProcessIdentityRef, ThreadContextRead};
use crate::{Clocks, RefCellExt, CLOCKS, REQUEST_LOCALS, TAGS};
use chrono::Utc;
use core::mem::forget;
@@ -43,6 +47,7 @@ use std::borrow::Cow;
use std::collections::HashMap;
use std::hash::Hash;
use std::num::NonZeroI64;
+use std::ops::{Deref, DerefMut};
use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Barrier, OnceLock};
use std::thread::JoinHandle;
@@ -166,6 +171,82 @@ pub struct Label {
pub value: LabelValue,
}
+struct SampleLabels {
+ labels: Vec